feat: stl_cad 도구에 scad→STL 컴파일 액션 추가

기존엔 info/render/add_hole만 있어서 기존 STL만 다뤘고, .scad 소스를 STL로
직접 컴파일하는 기능이 없었음 — 워크숍 채팅이 ".scad를 STL로 만들어달라"는
요청에 "제가 직접 하는 기능은 없다"고 정확히 답했던 이유.

compile 액션 추가: scad_path(.scad 소스) + part(선택, PART 변수 있는 scad용
-D 'PART="..."' 전달) + output_path로 openscad CLI 호출, 바이너리 STL 생성.
기존 runOpenscad() 헬퍼 재사용.

DIY 스캐너 프로젝트(scanner-diy-rig.scad)에도 PART 분기 추가하고 이 기능으로
프린트 부품 4종(base_plate/turntable/post/tilt_arm) 실제 생성 — 전부 230mm
베드 이내 확인.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-22 16:19:27 +09:00
co-authored by Claude Code
parent 1f87651812
commit a2439583b3
+52 -8
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 } from './stl-cad-core.js';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad } from './stl-cad-core.js';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
// 검토하거나, 위치를 지정해 구멍을 뚫을 수 있게 한다. 2026-09-20: 사용자가 "FreeCAD
@@ -24,14 +24,16 @@ function resolveWorkspacePath(workspacePath: string, relPath: string): string {
export const stlCadTool = {
name: 'stl_cad',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫거나, .scad 소스를 STL로 컴파일한다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string', stl_path: 'string' },
jsonSchema: {
type: 'object',
required: ['action', 'stl_path'],
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_camera/esp32_top.stl"). ASCII STL은 지원 안 함.' },
action: { type: 'string', enum: ['info', 'render', 'add_hole', 'compile'], description: 'info: 치수/바운딩박스/삼각형수 조회(stl_path). render: 지정한 각도로 PNG 미리보기 생성(stl_path, 모델이 직접 봄). add_hole: 지정 좌표에 원통형 구멍 뚫어서 새 STL로 저장(stl_path). compile: .scad 소스를 STL로 렌더/익스포트(scad_path) — 사용자가 ".scad를 STL로 만들어달라"고 하면 이걸 쓴다.' },
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좌표' },
@@ -39,7 +41,7 @@ export const stlCadTool = {
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을 저장할 워크스페이스 기준 상대경로(예: "print3d/esp32_camera/esp32_top_with_flash_hole.stl")' },
output_path: { type: 'string', description: '(add_hole/compile 필수) 결과 STL을 저장할 워크스페이스 기준 상대경로(예: "print3d/esp32_camera/esp32_top_with_flash_hole.stl")' },
},
additionalProperties: false,
},
@@ -48,6 +50,50 @@ export const stlCadTool = {
return { success: false, error: `OpenSCAD가 설치돼있지 않습니다(${OPENSCAD_BIN}). "sudo apt-get install -y openscad" 필요.` };
}
const workspacePath = getWorkspacePath(args);
const action = String(args?.action || '');
// compile은 .scad 소스가 입력이라 stl_path가 아니라 scad_path를 쓴다 — 아래에서 따로 처리.
if (action === 'compile') {
let scadPath: string;
try {
scadPath = resolveWorkspacePath(workspacePath, String(args?.scad_path || ''));
} catch (e: any) {
return { success: false, error: String(e?.message || e) };
}
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로 끝나야 합니다.' };
const part = args?.part !== undefined && args?.part !== null ? String(args.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}`,
};
}
let stlPath: string;
try {
stlPath = resolveWorkspacePath(workspacePath, String(args?.stl_path || ''));
@@ -56,8 +102,6 @@ export const stlCadTool = {
}
if (!fs.existsSync(stlPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.stl_path}` };
const action = String(args?.action || '');
if (action === 'info') {
let bbox;
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }