From a2439583b3394a07ddb67c9342829606f76c4878 Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 22 Sep 2026 16:19:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20stl=5Fcad=20=EB=8F=84=EA=B5=AC=EC=97=90?= =?UTF-8?q?=20scad=E2=86=92STL=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 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 --- src/tools/stl-cad.ts | 60 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/tools/stl-cad.ts b/src/tools/stl-cad.ts index 71f707d..5407884 100644 --- a/src/tools/stl-cad.ts +++ b/src/tools/stl-cad.ts @@ -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=""\'로 전달된다. 생략하면 파일의 기본 동작(보통 전체 조립)을 그대로 렌더링 — 부품 목록을 모르면 먼저 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) }; }