feat: 작업실 CAD 도구 3종 업그레이드 — 불리언연산·체적/밀폐분석·파라메트릭 컴파일

stl_cad: cut/boolean 액션 추가, info에 체적·밀폐(manifold)·퇴화삼각형·단위힌트,
render 멀티뷰(views), add_hole 관통모드(through). scad_to_stl: defines(임의 변수
오버라이드)·preview·PART 자동목록. write_scad: 검증 STL 분석해 치수/체적/단위힌트
보고, 빈 형상을 문법오류로 오분류하던 것 수정. core에 analyzeStl 추가(기존
readStlBBox는 라우트 호환상 유지). build-tools.ts 프롬프트 스키마 사본 동기화.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 19:28:18 +09:00
co-authored by Claude Code
parent 703ea0f671
commit 7587adb624
3 changed files with 345 additions and 111 deletions
+23 -14
View File
@@ -1153,20 +1153,27 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
type: 'function',
function: {
name: 'stl_cad',
description: '이미 있는 STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구를 쓸 것. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
description: '이미 있는 STL 3D모델 파일을 확인(치수/체적/밀폐여부/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 구멍·박스 컷·불리언 연산(합/차/교집합)으로 편집한다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구로 컴파일할 것. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
parameters: {
type: 'object', required: ['action', 'stl_path'],
type: 'object', required: ['action'],
properties: {
action: { type: 'string', enum: ['info', 'render', 'add_hole'], description: 'info: 치수/바운딩박스/삼각형수 조회. render: 지정한 각도로 PNG 미리보기 생성(모델이 직접 봄). add_hole: 지정 좌표에 원통형 구멍 뚫어서 새 STL로 저장.' },
stl_path: { type: 'string', description: '워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_top.stl"). ASCII STL은 지원 안 함.' },
view: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'], description: '(render, 선택) 보는 각도. 기본 iso.' },
x: { type: 'number', description: '(add_hole 필수) 구멍 중심 X좌표' },
y: { type: 'number', description: '(add_hole 필수) 구멍 중심 Y좌표' },
z: { type: 'number', description: '(add_hole 필수) 구멍 중심 Z좌표' },
action: { type: 'string', enum: ['info', 'render', 'add_hole', 'cut', 'boolean'], description: 'info: 치수/바운딩박스/체적/밀폐(manifold)여부/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path). add_hole: 지정 좌표에 원통형 구멍 뚫어 새 STL로 저장(stl_path). cut: 박스 모양으로 깎아냄(stl_path). boolean: 다른 STL과 합/차/교집합(stl_path + second_path).' },
stl_path: { type: 'string', description: '(info/render/add_hole/cut/boolean 필수) 워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_top.stl"). ASCII STL은 지원 안 함.' },
view: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'], description: '(render, 선택) 단일 각도. 기본 iso. views를 주면 무시됨.' },
views: { type: 'array', items: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'] }, description: '(render, 선택) 여러 각도를 한 번에 렌더 — ["iso","top","front"] 식. view 단일보다 권장.' },
x: { type: 'number', description: '(add_hole/cut 필수) 중심 X좌표(info로 바운딩박스 확인 후 지정)' },
y: { type: 'number', description: '(add_hole/cut 필수) 중심 Y좌표' },
z: { type: 'number', description: '(add_hole/cut 필수) 중심 Z좌표' },
diameter: { type: 'number', description: '(add_hole 필수) 구멍 지름(mm)' },
depth: { type: 'number', description: '(add_hole, 선택) 구멍 길이(mm). 기본 20' },
axis: { type: 'string', enum: ['x', 'y', 'z'], description: '(add_hole, 선택) 구멍 관통 방향 축. 기본 z' },
output_path: { type: 'string', description: '(add_hole 필수) 결과 STL 저장 경로(워크스페이스 기준 상대경로)' },
depth: { type: 'number', description: '(add_hole, 선택) 구멍 길이(mm). through=true면 무시됨. 기본 20' },
axis: { type: 'string', enum: ['x', 'y', 'z'], description: '(add_hole, 선택) 구멍이 뚫리는 방향 축. 기본 z' },
through: { type: 'boolean', description: '(add_hole, 선택) true면 depth 무시하고 해당 축의 모델 전체 폭×2로 자동 설정 — 벽 두께에 상관없이 항상 완전 관통.' },
w: { type: 'number', description: '(cut 필수) 박스 폭 X(mm). x,y,z는 박스 중심' },
h: { type: 'number', description: '(cut 필수) 박스 높이 Y(mm)' },
d: { type: 'number', description: '(cut 필수) 박스 깊이 Z(mm)' },
op: { type: 'string', enum: ['union', 'difference', 'intersection'], description: '(boolean 필수) 합/차(stl_path에서 second_path를 깎아냄)/교집합. 두 STL은 원래 좌표계에서 겹쳐짐.' },
second_path: { type: 'string', description: '(boolean 필수) 두 번째 STL의 워크스페이스 기준 상대경로.' },
output_path: { type: 'string', description: '(add_hole/cut/boolean 필수) 결과 STL 저장 경로(워크스페이스 기준 상대경로, .stl로 끝나야 함)' },
},
},
},
@@ -1180,13 +1187,15 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
// scadToStlTool과 실행 로직 공유 — 이 파일의 스키마는 registry.ts의 tool.jsonSchema와
// 별개로 유지되는 사본이라 둘 다 갱신해야 함, 이번에 그거 놓쳐서 한참 헤맴).
name: 'scad_to_stl',
description: '.scad(OpenSCAD 소스) 파일을 .stl로 컴파일한다. ".scad를 STL로 만들어줘/변환해줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad 커맨드를 직접 실행하려 하지 말 것(subprocess가 막혀있음). 이미 있는 STL 파일을 확인/렌더/수정하려면 이 도구가 아니라 stl_cad를 쓴다.',
description: '.scad(OpenSCAD 소스) 파일을 .stl로 컴파일한다. ".scad를 STL로 만들어줘/변환해줘" 같은 요청엔 이 도구를 바로 쓴다 — python_eval로 openscad 커맨드를 직접 실행하려 하지 말 것(subprocess가 막혀있음). 이미 있는 STL 파일을 확인/렌더/수정하려면 이 도구가 아니라 stl_cad를 쓴다. 결과에 치수/체적/삼각형수/단위힌트가 같이 표시되고, PART 분기가 있는 파일은 부품 목록도 알려준다.',
parameters: {
type: 'object', required: ['scad_path', 'output_path'],
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일.' },
output_path: { type: 'string', description: '결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함).' },
part: { type: 'string', description: '(선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
part: { type: 'string', description: '(선택) PART 변수로 부품을 나누는 scad면 부품 이름을 넣으면 -D \'PART="<part>"\'로 전달. 대소문자/공백/하이픈 차이 자동 흡수, 안 맞으면 가능한 값 목록 제시. 생략하면 기본 동작(보통 전체 조립) — 부품 목록은 part 안 줘도 결과에 표시됨.' },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — { BASE_L: 220, POST_H: 40 } 식으로 주면 각각 -D \'BASE_L=220\' -D \'POST_H=40\' 로 전달돼 기본값을 덮어쓴다. PART와 별개. 숫자/문자열/불린 지원.' },
preview: { type: 'boolean', description: '(선택) true면 컴파일 후 iso PNG 미리보기도 같이 렌더.' },
},
},
},
@@ -1199,7 +1208,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
// 폴더 없으면 실패하는 등 신뢰도가 낮았음). 저장 직후 실제 OpenSCAD 컴파일까지 해서
// 문법오류/빈 형상을 그 자리에서 알려준다.
name: 'write_scad',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것.',
description: '.scad(OpenSCAD 소스) 파일을 새로 만들거나 덮어쓴다. 저장 직후 자동으로 OpenSCAD로 문법/렌더 검증까지 하고 문제가 있으면 바로 알려준다 — 에러가 나오면 그 내용을 보고 content를 고쳐서 같은 scad_path로 다시 호출할 것. 검증 통과하면 치수(X×Y×Z)/체적/삼각형수/단위힌트도 같이 보고해서 스케일 오류(예: 200mm를 0.2로 씀)를 그 자리에서 잡는다. 복잡한 파라메트릭 구조(변수/여러 모듈/조건 분기 등)는 이 도구를 쓰는 모델 등급에서 품질이 떨어질 수 있으니 최대한 단순하게 작성할 것.',
parameters: {
type: 'object', required: ['scad_path', 'content'],
properties: {
+97
View File
@@ -8,6 +8,103 @@ export const OPENSCAD_BIN = '/usr/bin/openscad';
export interface StlBBox { min: [number, number, number]; max: [number, number, number]; triangleCount: number }
// readStlBBox 보다 풍부한 분석 — 3D프린팅 품질 지표(체적/밀폐 manifold/퇴화삼각형/단위힌트)까지.
// 채팅도구 stl_cad info 와 scad_to_stl/write_scad 의 검증 결과가 쓴다. readStlBBox 를 대체하지
// 않고 별도 함수로 둬서 기존 라우트(/api/cad/list 등) 호환을 유지.
export interface StlAnalysis {
min: [number, number, number];
max: [number, number, number];
triangleCount: number;
fileSize: number;
volumeMm3: number; // 부호화 사면체 합의 절댓값
watertight: boolean | null; // null = 삼각형 수 너무 많아(>200k) 메모리 보호상 스킵
nonManifoldEdges: number; // 공유 횟수≠2인 모서리 수
degenerateTriangles: number; // 외적 크기≈0(면적 없음)
unitHint: string; // maxExtent 기준 mm 정상/단위 의심
}
// 꼭지점 양자화 키 — STL은 같은 꼭지점을 삼각형마다 중복 저장(32bit float)하므로, 비트가
// 미세하게 다를 수 있다. 1e-4mm(0.1µm) 단위로 반올림하면 mm 스케일 부품에서 일치하는 꼭지점이
// 안전하게 합쳐진다(0.1µm는 3D프린팅 정밀도보다 훨씬 작음).
function vertexKey(x: number, y: number, z: number): string {
return `${Math.round(x * 1e4) / 1e4},${Math.round(y * 1e4) / 1e4},${Math.round(z * 1e4) / 1e4}`;
}
export function analyzeStl(filePath: string): StlAnalysis {
const buf = fs.readFileSync(filePath);
const fileSize = buf.length;
if (buf.length < 84) throw new Error('STL 파일이 너무 작거나 손상되었습니다.');
const triangleCount = buf.readUInt32LE(80);
const expectedSize = 84 + triangleCount * 50;
if (buf.length !== expectedSize) {
throw new Error('ASCII STL이거나 손상된 바이너리 STL로 보입니다 — 바이너리 STL만 지원합니다.');
}
const min: [number, number, number] = [Infinity, Infinity, Infinity];
const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
// 체적: 부호화 사면체 합 V = |Σ v0·(v1×v2)| / 6. 단위는 mm³.
let vol6 = 0;
let degenerate = 0;
// 밀폐 검사는 모서리→공유수 맵. 200k 삼각형(600k 모서리) 넘으면 메모리 과다 방지차 스킵.
const doManifold = triangleCount <= 200_000;
const edgeCount = doManifold ? new Map<string, number>() : null;
let offset = 84;
for (let i = 0; i < triangleCount; i++) {
offset += 12; // 법선벡터 스킵
const v: [number, number, number][] = [];
for (let vi = 0; vi < 3; vi++) {
const x = buf.readFloatLE(offset);
const y = buf.readFloatLE(offset + 4);
const z = buf.readFloatLE(offset + 8);
offset += 12;
v.push([x, y, z]);
if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z;
if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z;
}
offset += 2; // attribute byte count
// 체적 기여
const [a, b, c] = v;
vol6 += a[0] * (b[1] * c[2] - b[2] * c[1])
- a[1] * (b[0] * c[2] - b[2] * c[0])
+ a[2] * (b[0] * c[1] - b[1] * c[0]);
// 퇴화 삼각형(외적 크기≈0 = 면적 없음) — b-a × c-a 벡터로 판정
const ex = b[0] - a[0], ey = b[1] - a[1], ez = b[2] - a[2];
const fx = c[0] - a[0], fy = c[1] - a[1], fz = c[2] - a[2];
const crossMag = Math.sqrt((ey * fz - ez * fy) ** 2 + (ez * fx - ex * fz) ** 2 + (ex * fy - ey * fx) ** 2);
if (crossMag < 1e-9) degenerate++;
if (edgeCount) {
const keys = [vertexKey(a[0], a[1], a[2]), vertexKey(b[0], b[1], b[2]), vertexKey(c[0], c[1], c[2])];
for (let e = 0; e < 3; e++) {
const k1 = keys[e], k2 = keys[(e + 1) % 3];
const ek = k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`;
edgeCount.set(ek, (edgeCount.get(ek) || 0) + 1);
}
}
}
let watertight: boolean | null = null;
let nonManifoldEdges = 0;
if (edgeCount) {
nonManifoldEdges = 0;
for (const cnt of edgeCount.values()) if (cnt !== 2) nonManifoldEdges++;
watertight = nonManifoldEdges === 0;
}
const maxExtent = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
let unitHint: string;
if (maxExtent > 1000) unitHint = '단위가 m일 수 있음(mm 기대 — 1000mm 초과)';
else if (maxExtent < 0.5) unitHint = '단위가 m이거나 너무 작음(0.5mm 미만)';
else unitHint = 'mm 정상 범위';
return {
min, max, triangleCount, fileSize,
volumeMm3: Math.abs(vol6) / 6,
watertight, nonManifoldEdges, degenerateTriangles: degenerate, unitHint,
};
}
export function runOpenscad(args: string[], timeoutMs = 60_000): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
// 헤드리스 서버라 DISPLAY가 없음 — Qt를 offscreen 플랫폼으로 강제해야 렌더가 된다
+225 -97
View File
@@ -3,7 +3,7 @@ import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown, isPathInsideDir } from './image.js';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad } from './stl-cad-core.js';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis } from './stl-cad-core.js';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
// 검토하거나, 위치를 지정해 구멍을 뚫을 수 있게 한다. 2026-09-20: 사용자가 "FreeCAD
@@ -47,26 +47,133 @@ function resolveWorkspacePath(workspacePath: string, relPath: string): string {
return resolved;
}
// OpenSCAD -D 리터럴로 값 직렬화 — 숫자/불린은 그대로, 문자열은 "..." 로.
function serializeDefineValue(v: unknown): string | null {
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : null;
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'string') return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return null;
}
function buildDefineArgs(defines: any): string[] {
if (!defines || typeof defines !== 'object') return [];
const out: string[] = [];
for (const [k, v] of Object.entries(defines)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue; // OpenSCAD 식별자만
const lit = serializeDefineValue(v);
if (lit !== null) out.push('-D', `${k}=${lit}`);
}
return out;
}
// analyzeStl 결과를 짧은 치수 요약로 — scad_to_stl/write_scad 결과 끝에 붙임.
function formatAnalysisBrief(a: StlAnalysis): string {
const size = [a.max[0] - a.min[0], a.max[1] - a.min[1], a.max[2] - a.min[2]];
return `- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm\n` +
`- 삼각형 수: ${a.triangleCount} · 체적: ${(a.volumeMm3 / 1000).toFixed(2)} cm³ · ${a.unitHint}`;
}
// 전체 분석 블록 — stl_cad info 액션용.
function formatAnalysisFull(a: StlAnalysis): string {
const size = [a.max[0] - a.min[0], a.max[1] - a.min[1], a.max[2] - a.min[2]];
const lines: string[] = [];
lines.push(`- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm`);
lines.push(`- 바운딩박스: X[${a.min[0].toFixed(2)}, ${a.max[0].toFixed(2)}] Y[${a.min[1].toFixed(2)}, ${a.max[1].toFixed(2)}] Z[${a.min[2].toFixed(2)}, ${a.max[2].toFixed(2)}]`);
lines.push(`- 삼각형 수: ${a.triangleCount} · 체적: ${(a.volumeMm3 / 1000).toFixed(2)} cm³ (${a.volumeMm3.toFixed(0)} mm³)`);
lines.push(`- 파일 크기: ${(a.fileSize / 1024 / 1024).toFixed(2)} MB`);
if (a.watertight === null) {
lines.push(`- 밀폐(manifold): 검사 생략(삼각형 수가 너무 많음)`);
} else if (a.watertight) {
lines.push(`- 밀폐(manifold): ✅ 닫힌 다면체(슬라이싱 안전)`);
} else {
lines.push(`- 밀폐(manifold): ⚠️ 비매니폴드 모서리 ${a.nonManifoldEdges}개 — 슬라이서가 자동 수리하거나 출력에 문제가 생길 수 있음`);
}
if (a.degenerateTriangles > 0) lines.push(`- 퇴화 삼각형(면적 0): ${a.degenerateTriangles}개`);
lines.push(`- 단위 힌트: ${a.unitHint}`);
return lines.join('\n');
}
// scad_to_stl 와 stl_cad compile 이 공유하는 컴파일 로직 — part 퍼지매칭·defines(임의 변수
// 오버라이드)·빈 출력 감지·analyzeStl 보고까지 한 번에. preview=true 면 iso PNG도 렌더.
async function compileScadToStl(opts: {
workspacePath: string;
scadPath: string;
scadPathArg: string;
outPath: string;
outPathArg: string;
part?: string;
defines?: any;
preview?: boolean;
}): Promise<{ ok: true; stdout: string } | { ok: false; error: string }> {
const { workspacePath, scadPath, scadPathArg, outPath, outPathArg } = opts;
// PART 퍼지매칭 — 빈 STL 조용히 실패 방지(stl-cad.ts 헤더 참고).
let part = opts.part !== undefined && opts.part !== null ? String(opts.part) : '';
const availableParts = extractScadPartNames(fs.readFileSync(scadPath, 'utf8'));
if (part) {
const resolved = resolvePartName(scadPath, part);
if (resolved.error) return { ok: false, error: resolved.error };
part = resolved.part;
}
const defineArgs = buildDefineArgs(opts.defines);
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
...defineArgs,
'-o', outPath,
'--export-format=binstl',
scadPath,
];
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const run = await runOpenscad(openscadArgs, 120_000);
if (!run.ok) return { ok: false, error: `OpenSCAD 컴파일 실패: ${run.detail}` };
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 84) {
// 빈 출력 — PART 분기가 있는데 part를 안 줘서 "all" 조립체가 빈 경우도 드물지만, 분기
// 매칭에 실패한 경우가 대부분. 후보가 있으면 알려준다.
if (availableParts.length) {
return { ok: false, error: `컴파일은 끝났는데 STL이 비어있습니다 — part 값이 이 scad 파일의 분기와 안 맞을 수 있습니다. 이 파일의 PART 값: ${availableParts.join(', ')}` };
}
return { ok: false, error: '컴파일은 끝났는데 STL 파일이 비어있거나 없습니다 — 형상이 하나도 없는지 scad 소스를 확인하세요.' };
}
let analysis: StlAnalysis;
try { analysis = analyzeStl(outPath); } catch (e: any) { return { ok: false, error: `STL은 생성됐지만 읽기 실패: ${String(e?.message || e)}` }; }
const partLine = part ? ` (PART="${part}")` : '';
const partsLine = (availableParts.length && !part) ? `\n- 이 파일의 PART 부품: ${availableParts.join(', ')}` : '';
let stdout = `**${path.basename(outPath)}** 생성됨${partLine}\n${formatAnalysisBrief(analysis)}\n- 저장 위치: ${outPathArg}${partsLine}`;
if (opts.preview) {
const pngPath = outPath.replace(/\.stl$/i, `_iso_${Date.now()}.png`);
const render = await renderStlPng(outPath, 'iso', pngPath);
if (render.ok) stdout += `\n\n${buildImageMarkdown(pngPath, workspacePath)}`;
}
return { ok: true, stdout };
}
export const stlCadTool = {
name: 'stl_cad',
description: '이미 있는 STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구를 쓸 것 — 그게 그 용도의 전용 도구다. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
description: '이미 있는 STL 3D모델 파일을 확인(치수/체적/밀폐여부/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 구멍·박스 컷·불리언 연산(합/차/교집합)으로 편집한다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀). ⚠️ 입력 파일이 .scad(아직 STL이 아닌 소스)면 이 도구 말고 scad_to_stl 도구로 컴파일할 것. 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string', stl_path: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['compile', 'info', 'render', 'add_hole'], description: 'compile: .scad 소스를 STL로 렌더/익스포트(scad_path 필요, stl_path 아님) — 입력이 .scad 파일이면 항상 이거. info: 이미 있는 STL의 치수/바운딩박스/삼각형수 조회(stl_path). render: 이미 있는 STL을 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 이미 있는 STL의 지정 좌표에 원통형 구멍 뚫어서 새 STL로 저장(stl_path).' },
stl_path: { type: 'string', description: '(info/render/add_hole 필수) 워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_camera/esp32_top.stl"). ASCII STL은 지원 안 함.' },
scad_path: { type: 'string', description: '(compile 필수) 워크스페이스 기준 상대경로의 .scad 소스 파일.' },
part: { type: 'string', description: '(compile, 선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면(예: 이 워크스페이스의 scanner-rig.scad류) 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
view: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'], description: '(render, 선택) 보는 각도. 기본 iso(비스듬한 기본 시야, 형태 전체 파악에 좋음). top/bottom/front/back/left/right는 정면 투영.' },
x: { type: 'number', description: '(add_hole 필수) 구멍 중심 X좌표(모델 원본 좌표계, info로 바운딩박스 확인 후 지정)' },
y: { type: 'number', description: '(add_hole 필수) 구멍 중심 Y좌표' },
z: { type: 'number', description: '(add_hole 필수) 구멍 중심 Z좌표' },
action: { type: 'string', enum: ['info', 'render', 'add_hole', 'cut', 'boolean'], description: 'info: 치수/바운딩박스/체적/밀폐(manifold)여부/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 지정 좌표에 원통형 구멍 뚫어 새 STL로 저장(stl_path). cut: 박스 모양으로 깎아냄(stl_path). boolean: 다른 STL과 합/차/교집합(stl_path + second_path).' },
stl_path: { type: 'string', description: '(info/render/add_hole/cut/boolean 필수) 워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_camera/esp32_top.stl"). ASCII STL은 지원 안 함.' },
view: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'], description: '(render, 선택) 단일 보는 각도. 기본 iso. views를 주면 view는 무시됨.' },
views: { type: 'array', items: { type: 'string', enum: ['iso', 'top', 'bottom', 'front', 'back', 'left', 'right'] }, description: '(render, 선택) 여러 각도를 한 번에 렌더 — ["iso","top","front"] 식으로 주면 각각 PNG를 만들어 모두 돌려줌(모델이 형상 전체를 파악하기 좋음). view 단일보다 이쪽이 권장.' },
x: { type: 'number', description: '(add_hole/cut 필수) 중심 X좌표(모델 원본 좌표계, info로 바운딩박스 확인 후 지정)' },
y: { type: 'number', description: '(add_hole/cut 필수) 중심 Y좌표' },
z: { type: 'number', description: '(add_hole/cut 필수) 중심 Z좌표' },
diameter: { type: 'number', description: '(add_hole 필수) 구멍 지름(mm)' },
depth: { type: 'number', description: '(add_hole, 선택) 구멍 길이(mm). 기본 20(완전 관통되도록 넉넉하게) — 얇은 벽이면 기본값 그대로 둬도 안전함' },
axis: { type: 'string', enum: ['x', 'y', 'z'], description: '(add_hole, 선택) 구멍이 뚫리는 방향(어느 축을 따라 관통하는지). 기본 z' },
output_path: { type: 'string', description: '(add_hole/compile 필수) 결과 STL을 저장할 워크스페이스 기준 상대경로(예: "print3d/esp32_camera/esp32_top_with_flash_hole.stl")' },
depth: { type: 'number', description: '(add_hole, 선택) 구멍 길이(mm). through=true면 무시됨. 기본 20' },
axis: { type: 'string', enum: ['x', 'y', 'z'], description: '(add_hole, 선택) 구멍이 뚫리는 방향 축. 기본 z' },
through: { type: 'boolean', description: '(add_hole, 선택) true면 depth를 무시하고 해당 축의 모델 전체 폭×2로 자동 설정 — 벽 두께에 상관없이 항상 완전 관통됨. 얇은 벽이나 두꺼운 부품 모두 정확히 뚫고 싶을 때 쓸 것.' },
w: { type: 'number', description: '(cut 필수) 박스 폭 X(mm). x,y,z는 박스 중심' },
h: { type: 'number', description: '(cut 필수) 박스 높이 Y(mm)' },
d: { type: 'number', description: '(cut 필수) 박스 깊이 Z(mm)' },
op: { type: 'string', enum: ['union', 'difference', 'intersection'], description: '(boolean 필수) 합집합/차집합(stl_path에서 second_path를 깎아냄)/교집합. 두 STL은 원래 좌표계에서 겹쳐짐(같은 기준으로 설계된 부품에 맞음).' },
second_path: { type: 'string', description: '(boolean 필수) 두 번째 STL의 워크스페이스 기준 상대경로.' },
output_path: { type: 'string', description: '(add_hole/cut/boolean 필수) 결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함)' },
},
additionalProperties: false,
},
@@ -77,7 +184,7 @@ export const stlCadTool = {
const workspacePath = getWorkspacePath(args);
const action = String(args?.action || '');
// compile은 .scad 소스가 입력이라 stl_path가 아니라 scad_path를 쓴다 — 아래에서 따로 처리.
// compile(.scad→STL)은 scad_to_stl 전용 도구로 분리됐지만 execute는 호환상 유지.
if (action === 'compile') {
let scadPath: string;
try {
@@ -87,41 +194,13 @@ export const stlCadTool = {
}
if (!fs.existsSync(scadPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.scad_path}` };
if (!/\.scad$/i.test(scadPath)) return { success: false, error: 'scad_path는 .scad 파일이어야 합니다.' };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다. (compile은 scad_to_stl 도구 사용을 권장)' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
if (!/\.stl$/i.test(outPath)) return { success: false, error: 'output_path는 .stl로 끝나야 합니다.' };
let part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
if (part) {
const resolved = resolvePartName(scadPath, part);
if (resolved.error) return { success: false, error: resolved.error };
part = resolved.part;
}
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
'-o', outPath,
'--export-format=binstl',
scadPath,
];
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const run = await runOpenscad(openscadArgs, 120_000);
if (!run.ok) return { success: false, error: `OpenSCAD 컴파일 실패: ${run.detail}` };
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 84) {
return { success: false, error: '컴파일은 끝났는데 STL 파일이 비어있거나 없습니다 — PART 값이 이 scad 파일의 분기와 안 맞을 수 있습니다.' };
}
let bbox;
try { bbox = readStlBBox(outPath); } catch (e: any) { return { success: false, error: `STL은 생성됐지만 읽기 실패: ${String(e?.message || e)}` }; }
const size = [bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]];
return {
success: true,
stdout: `**${path.basename(outPath)}** 생성됨${part ? ` (PART="${part}")` : ''}\n` +
`- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm\n` +
`- 삼각형 수: ${bbox.triangleCount}\n` +
`- 저장 위치: ${outPathArg}`,
};
const r = await compileScadToStl({ workspacePath, scadPath, scadPathArg: String(args?.scad_path || ''), outPath, outPathArg, part: args?.part, defines: args?.defines, preview: args?.preview });
return r.ok ? { success: true, stdout: r.stdout } : { success: false, error: r.error };
}
let stlPath: string;
@@ -133,45 +212,86 @@ export const stlCadTool = {
if (!fs.existsSync(stlPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.stl_path}` };
if (action === 'info') {
let bbox;
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const size = [bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]];
return {
success: true,
stdout: `**${path.basename(stlPath)}**\n` +
`- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm\n` +
`- 바운딩박스: X[${bbox.min[0].toFixed(2)}, ${bbox.max[0].toFixed(2)}] Y[${bbox.min[1].toFixed(2)}, ${bbox.max[1].toFixed(2)}] Z[${bbox.min[2].toFixed(2)}, ${bbox.max[2].toFixed(2)}]\n` +
`- 삼각형 수: ${bbox.triangleCount}`,
};
let analysis: StlAnalysis;
try { analysis = analyzeStl(stlPath); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
return { success: true, stdout: `**${path.basename(stlPath)}**\n${formatAnalysisFull(analysis)}` };
}
if (action === 'render') {
const view = String(args?.view || 'iso');
const pngPath = stlPath.replace(/\.stl$/i, `_${view}_${Date.now()}.png`);
const render = await renderStlPng(stlPath, view, pngPath);
if (!render.ok) return { success: false, error: `렌더링 실패: ${render.detail}` };
return { success: true, stdout: buildImageMarkdown(pngPath, workspacePath) };
// views(배열/콤마목록)가 있으면 여러 각도를 한 번에, 없으면 view 단일.
let views: string[];
const rawViews = args?.views;
if (Array.isArray(rawViews)) views = rawViews.map(String);
else if (typeof rawViews === 'string' && rawViews.trim()) views = rawViews.split(',').map(s => s.trim()).filter(Boolean);
else views = [String(args?.view || 'iso')];
if (!views.length) views = ['iso'];
const mdParts: string[] = [];
for (const view of views) {
const v = String(view || 'iso');
const pngPath = stlPath.replace(/\.stl$/i, `_${v}_${Date.now()}.png`);
const render = await renderStlPng(stlPath, v, pngPath);
if (!render.ok) return { success: false, error: `렌더링 실패(${v}): ${render.detail}` };
mdParts.push(buildImageMarkdown(pngPath, workspacePath));
}
return { success: true, stdout: mdParts.join('\n\n') };
}
if (action === 'add_hole') {
const x = Number(args?.x), y = Number(args?.y), z = Number(args?.z);
const diameter = Number(args?.diameter);
if (![x, y, z, diameter].every(Number.isFinite)) return { success: false, error: 'x, y, z, diameter가 모두 필요합니다.' };
const depth = Number.isFinite(Number(args?.depth)) ? Number(args.depth) : 20;
const axis = (['x', 'y', 'z'].includes(args?.axis) ? args.axis : 'z') as 'x' | 'y' | 'z';
let depth: number;
if (args?.through === true) {
// 관통 모드 — 해당 축의 모델 폭×2로 자동 설정(항상 뚫림).
let bbox: { min: [number, number, number]; max: [number, number, number] };
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const extent = axis === 'x' ? bbox.max[0] - bbox.min[0] : axis === 'y' ? bbox.max[1] - bbox.min[1] : bbox.max[2] - bbox.min[2];
depth = Math.max(20, extent * 2);
} else {
depth = Number.isFinite(Number(args?.depth)) ? Number(args.depth) : 20;
}
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const result = await addHoleToStl({ stlPath, outPath, x, y, z, diameter, depth, axis });
if (!result.ok) return { success: false, error: `구멍 뚫기 실패: ${result.detail}` };
return {
success: true,
stdout: `구멍 추가됨: (${x}, ${y}, ${z})에 지름 ${diameter}mm, ${axis}축 방향, 길이 ${depth}mm.\n결과: ${outPathArg}\n\n다시 슬라이싱하려면 print3d_model이나 k2-app 슬라이싱 UI에서 이 새 파일을 사용하세요.`,
stdout: `구멍 추가됨: (${x}, ${y}, ${z})에 지름 ${diameter}mm, ${axis}축${args?.through === true ? ` 관통(자동 길이 ${depth.toFixed(1)}mm)` : ` 길이 ${depth}mm`}.\n결과: ${outPathArg}\n\n다시 슬라이싱하려면 print3d_model이나 k2-app 슬라이싱 UI에서 이 새 파일을 사용하세요.`,
};
}
if (action === 'cut') {
const x = Number(args?.x), y = Number(args?.y), z = Number(args?.z);
const w = Number(args?.w), h = Number(args?.h), d = Number(args?.d);
if (![x, y, z, w, h, d].every(Number.isFinite)) return { success: false, error: 'x, y, z, w, h, d가 모두 필요합니다.' };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const result = await applyBooleanOp({ primaryPath: stlPath, outPath, op: 'cut', x, y, z, w, h, d });
if (!result.ok) return { success: false, error: `박스 컷 실패: ${result.detail}` };
return { success: true, stdout: `박스 컷 완료: 중심 (${x}, ${y}, ${z}), 크기 ${w}×${h}×${d}mm(각각 X/Y/Z) 차집합.\n결과: ${outPathArg}` };
}
if (action === 'boolean') {
const op = String(args?.op || '');
if (!['union', 'difference', 'intersection'].includes(op)) return { success: false, error: 'op는 union/difference/intersection 중 하나여야 합니다.' };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let secondPath: string;
try { secondPath = resolveWorkspacePath(workspacePath, String(args?.second_path || '')); } catch (e: any) { return { success: false, error: `second_path: ${String(e?.message || e)}` }; }
if (!fs.existsSync(secondPath)) return { success: false, error: `두 번째 파일을 찾을 수 없습니다: ${args?.second_path}` };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const result = await applyBooleanOp({ primaryPath: stlPath, secondPath, outPath, op: op as 'union' | 'difference' | 'intersection' });
if (!result.ok) return { success: false, error: `${op} 연산 실패: ${result.detail}` };
const opKo = op === 'union' ? '합집합' : op === 'difference' ? '차집합' : '교집합';
return { success: true, stdout: `${opKo} 완료: ${args?.stl_path} ∘ ${args?.second_path}.\n결과: ${outPathArg}` };
}
return { success: false, error: `알 수 없는 action: ${action}` };
},
};
@@ -192,7 +312,9 @@ export const scadToStlTool = {
properties: {
scad_path: { type: 'string', description: '워크스페이스 기준 상대경로의 .scad 소스 파일.' },
output_path: { type: 'string', description: '결과 STL을 저장할 워크스페이스 기준 상대경로(.stl로 끝나야 함).' },
part: { type: 'string', description: '(선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면 여기에 부품 이름을 넣으면 openscad에 -D \'PART="<part>"\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 scad 파일을 read로 읽어 PART 분기(if (PART == "...")) 목록을 확인할 것.' },
part: { type: 'string', description: '(선택) scad 파일이 PART 변수로 부품을 나눠 내보내는 구조면 부품 이름을 넣으면 -D \'PART="<part>"\'로 전달된다. 대소문자/공백/하이픈 차이는 자동으로 흡수되고, 안 맞으면 가능한 값 목록을 바로 알려준다. 생략하면 파일의 기본 동작(보통 전체 조립) — 이 파일의 부품 목록은 part를 안 줘도 결과에 같이 표시된다.' },
defines: { type: 'object', description: '(선택) 파라메트릭 변수 오버라이드 — { BASE_L: 220, POST_H: 40 } 식으로 주면 각각 -D \'BASE_L=220\' -D \'POST_H=40\' 로 전달돼 scad의 기본값을 덮어쓴다. PART와 별개. 숫자/문자열/불린 값 지원(문자열은 자동으로 따옴표 처리).' },
preview: { type: 'boolean', description: '(선택) true면 컴파일 후 iso 각도 PNG 미리보기도 렌더해 같이 돌려줌 — 결과를 눈으로 바로 확인.' },
},
additionalProperties: false,
},
@@ -209,41 +331,13 @@ export const scadToStlTool = {
}
if (!fs.existsSync(scadPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.scad_path}` };
if (!/\.scad$/i.test(scadPath)) return { success: false, error: 'scad_path는 .scad 파일이어야 합니다.' };
const outPathArg = String(args?.output_path || '').trim();
if (!outPathArg) return { success: false, error: 'output_path가 필요합니다.' };
let outPath: string;
try { outPath = resolveWorkspacePath(workspacePath, outPathArg); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
if (!/\.stl$/i.test(outPath)) return { success: false, error: 'output_path는 .stl로 끝나야 합니다.' };
let part = args?.part !== undefined && args?.part !== null ? String(args.part) : '';
if (part) {
const resolved = resolvePartName(scadPath, part);
if (resolved.error) return { success: false, error: resolved.error };
part = resolved.part;
}
const openscadArgs = [
...(part ? ['-D', `PART="${part.replace(/"/g, '\\"')}"`] : []),
'-o', outPath,
'--export-format=binstl',
scadPath,
];
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const run = await runOpenscad(openscadArgs, 120_000);
if (!run.ok) return { success: false, error: `OpenSCAD 컴파일 실패: ${run.detail}` };
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 84) {
return { success: false, error: '컴파일은 끝났는데 STL 파일이 비어있거나 없습니다 — PART 값이 이 scad 파일의 분기와 안 맞을 수 있습니다.' };
}
let bbox;
try { bbox = readStlBBox(outPath); } catch (e: any) { return { success: false, error: `STL은 생성됐지만 읽기 실패: ${String(e?.message || e)}` }; }
const size = [bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]];
return {
success: true,
stdout: `**${path.basename(outPath)}** 생성됨${part ? ` (PART="${part}")` : ''}\n` +
`- 크기(X×Y×Z): ${size.map(n => n.toFixed(1)).join(' × ')} mm\n` +
`- 삼각형 수: ${bbox.triangleCount}\n` +
`- 저장 위치: ${outPathArg}`,
};
const r = await compileScadToStl({ workspacePath, scadPath, scadPathArg: String(args?.scad_path || ''), outPath, outPathArg, part: args?.part, defines: args?.defines, preview: args?.preview });
return r.ok ? { success: true, stdout: r.stdout } : { success: false, error: r.error };
},
};
@@ -287,21 +381,55 @@ export const writeScadTool = {
const tmpOut = scadPath.replace(/\.scad$/i, `.__validate_${Date.now()}.stl`);
const run = await runOpenscad(['-o', tmpOut, '--export-format=binstl', scadPath], 60_000);
const validOutput = fs.existsSync(tmpOut) && fs.statSync(tmpOut).size >= 84;
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
if (!run.ok) {
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
// OpenSCAD는 빈 형상일 때도 exit code≠0 로 끝나며 stderr에 "top level object is
// empty" 를 남긴다 — 이건 문법 오류가 아니라 렌더가 텅 빈 경우다. PART 분기가 있는
// scad에서 part 미지정으로 빈 경우가 흔하므로 part 후보를 같이 알려준다.
if (run.detail && run.detail.includes('top level object is empty')) {
let partsLine = '';
try {
const parts = extractScadPartNames(content);
if (parts.length) partsLine = `\n이 파일의 PART 부품: ${parts.join(', ')} — scad_to_stl로 특정 부품을 컴파일하려면 part 값을 주세요.`;
} catch { /* noop */ }
return {
success: true,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나, PART 분기가 있는데 part를 안 줘서 매칭된 게 없을 수 있음).${partsLine}`,
};
}
return {
success: false,
error: `저장은 됐지만(${scadPathArg}) OpenSCAD 문법 오류가 있습니다: ${run.detail}\n\n` +
`위 오류를 보고 content를 고쳐서 같은 scad_path로 write_scad를 다시 호출할 것.`,
};
}
if (!validOutput) {
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
// PART 분기가 있는 scad면 part 후보 알려줌 — 빈 형상의 흔한 원인이 part 미지정.
let partsLine = '';
try {
const parts = extractScadPartNames(content);
if (parts.length) partsLine = `\n이 파일의 PART 부품: ${parts.join(', ')} — scad_to_stl로 특정 부품을 컴파일하려면 part 값을 주세요.`;
} catch { /* noop */ }
return {
success: true,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나 좌표가 잘못됐을 수 있음) — 확인 필요.`,
stdout: `저장됨: ${scadPathArg}\n⚠️ 문법은 통과했지만 렌더 결과가 비어있습니다(형상이 하나도 없거나 좌표가 잘못됐을 수 있음).${partsLine}`,
};
}
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과(정상 컴파일 확인됨).` };
// 검증용 STL을 삭제 전 분석 — 치수/체적/단위힌트로 스케일 오류를 그 자리에서 잡음.
let analysisLines = '';
try {
const a = analyzeStl(tmpOut);
analysisLines = `\n${formatAnalysisBrief(a)}`;
if (a.unitHint !== 'mm 정상 범위') {
analysisLines += `\n⚠️ ${a.unitHint} — scad에서 숫자 단위가 mm이 맞는지 확인(예: 200을 써야 할 걸 0.2로 썼거나, 반대로 m 단위를 mm로 안 변환했을 수 있음).`;
}
} catch { /* noop */ }
try { if (fs.existsSync(tmpOut)) fs.unlinkSync(tmpOut); } catch { /* noop */ }
return { success: true, stdout: `저장됨: ${scadPathArg} — OpenSCAD 문법/렌더 검증 통과.${analysisLines}` };
},
};