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:
+52
-8
@@ -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) }; }
|
||||
|
||||
Reference in New Issue
Block a user