feat: stl_cad 채팅도구 추가 — OpenSCAD 기반 STL 확인/렌더링/구멍뚫기

"작업실"의 다음 단계로 남겨뒀던 CAD 연동을 시작. FreeCAD 대신 OpenSCAD를
백엔드로 씀 — 텍스트 스크립트 기반 CSG라 AI가 파라메트릭 스크립트를 짜서
바로 실행하는 이 워크플로에 훨씬 적합함(GUI/Python API가 무거운 FreeCAD
대비). 오늘 ESP32 카메라 하우징 렌즈 소켓 위치를 실제로 이 방식(OpenSCAD
--camera 렌더 + 메시 좌표 분석)으로 조사해본 뒤 정식 도구로 정리했다.

- info: 바운딩박스/치수/삼각형수 조회(바이너리 STL 직접 파싱, 의존성 없음)
- render: iso/top/front 등 각도로 PNG 렌더링, 모델이 직접 보게 함
  (buildImageMarkdown 재사용). QT_QPA_PLATFORM=offscreen으로 헤드리스
  렌더 확인(xvfb 불필요)
- add_hole: 지정 좌표에 원통 구멍을 뚫어 새 STL로 저장(difference()).
  테스트 큐브로는 정상 작동 확인했으나, 오늘 다룬 ESP32 STL 자체가
  CGAL이 못 받아들이는 미세 메시 결함이 있어(admesh로도 복구 안 됨)
  그 파일에서는 실패 — 원인이 도구가 아니라 소스 STL 품질 문제임을
  확인함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 20:10:14 +09:00
co-authored by Claude Sonnet 5
parent 7ec72e776a
commit c6e11302d5
4 changed files with 225 additions and 0 deletions
+22
View File
@@ -1149,6 +1149,28 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
},
},
},
{
type: 'function',
function: {
name: 'stl_cad',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
parameters: {
type: 'object', required: ['action', 'stl_path'],
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좌표' },
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 저장 경로(워크스페이스 기준 상대경로)' },
},
},
},
},
{
type: 'function',
function: {
+4
View File
@@ -178,6 +178,9 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
// print3d_model — STL 슬라이싱+K2 프린터 업로드. topicText로 검사(prepare→start 확인
// 왕복이 여러 턴 걸치므로 nvr_snapshot과 같은 이유로 최근 대화 전체를 봄).
const hasPrint3dKeyword = /3d\s*프린트|3d\s*print|stl|슬라이싱|슬라이서|g-?code|출력해|프린터로|k2\s*(?:콤보|프린터)?/i.test(topicText);
// stl_cad — STL 확인/렌더링/구멍뚫기(OpenSCAD). print3d와 겹치는 "stl" 외에 CAD 편집
// 관련 어휘도 추가로 매칭.
const hasStlCadKeyword = /stl|cad|캐드|구멍\s*뚫|렌더링|바운딩박스|openscad|치수\s*확인/i.test(topicText);
// workshop_project — "작업실"(여러 메이커 프로젝트) 대시보드 부품/작업 편집. "로봇" 단독은
// 로봇청소기(Valetudo) 대화와 겹치므로 프로젝트 고유 어휘로만 매칭한다. 새 프로젝트 종류가
// 늘어날 때마다 이 목록도 넓혀줘야 함(앱 세션 안에서는 아래 isWorkshopAppSession이 대신
@@ -378,6 +381,7 @@ export function buildSkillToolFilter(input: ToolScopeInput): (toolName: string)
if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false;
if (name === 'nvr_snapshot' && !hasCctvKeyword) return false;
if (name === 'print3d_model' && !hasPrint3dKeyword) return false;
if (name === 'stl_cad' && !hasStlCadKeyword) return false;
if (name === 'workshop_project' && !isWorkshopAppSession && !hasWorkshopKeyword) return false;
if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true;
+2
View File
@@ -33,6 +33,7 @@ import { drugDurCheckTool } from './mfds.js';
import { nvrSnapshotTool } from './nvr.js';
import { print3dModelTool } from './print3d.js';
import { workshopProjectTool } from './workshop-project.js';
import { stlCadTool } from './stl-cad.js';
export interface Tool {
name: string;
@@ -240,6 +241,7 @@ class ToolRegistry {
this.registerSafe(nvrSnapshotTool);
this.registerSafe(print3dModelTool);
this.registerSafe(workshopProjectTool);
this.registerSafe(stlCadTool);
// News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP)
+197
View File
@@ -0,0 +1,197 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import { buildImageMarkdown, isPathInsideDir } from './image.js';
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
// 검토하거나, 위치를 지정해 구멍을 뚫을 수 있게 한다. 2026-09-20: 사용자가 "FreeCAD
// 플러그인"을 요청했으나, FreeCAD는 GUI/Python API가 무겁고 이 워크플로(AI가 파라메트릭
// 스크립트를 생성해 실행)엔 OpenSCAD가 훨씬 적합해서 이걸로 구현함 — OpenSCAD는 순수
// 텍스트 스크립트 기반 CSG라 명령줄에서 바로 렌더/불리언 연산이 됨. ESP32 카메라 하우징에
// 렌즈 소켓 위치를 실제로 이렇게 찾아본 뒤 이 도구로 정리함.
const OPENSCAD_BIN = '/usr/bin/openscad';
function resolveWorkspacePath(workspacePath: string, relPath: string): string {
const resolved = path.resolve(workspacePath, relPath);
if (!isPathInsideDir(workspacePath, resolved)) {
throw new Error('경로가 워크스페이스 밖입니다.');
}
return resolved;
}
function runOpenscad(args: string[], timeoutMs = 60_000): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
// 헤드리스 서버라 DISPLAY가 없음 — Qt를 offscreen 플랫폼으로 강제해야 렌더가 된다
// (xvfb-run은 설치돼있지 않고, 이 환경변수만으로 충분한 걸 확인함, 2026-09-20).
const env = { ...process.env, QT_QPA_PLATFORM: 'offscreen' };
const proc = spawn(OPENSCAD_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString('utf8'); if (stderr.length > 4000) stderr = stderr.slice(-4000); });
const killTimer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, timeoutMs);
proc.on('error', (e) => { clearTimeout(killTimer); resolve({ ok: false, detail: String(e.message || e) }); });
proc.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0) resolve({ ok: true });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `openscad exit ${code}` });
});
});
}
interface StlBBox { min: [number, number, number]; max: [number, number, number]; triangleCount: number }
// 바이너리 STL(80바이트 헤더+삼각형수(4B)+삼각형마다 50B)만 지원 — 3D프린팅에서 압도적으로
// 흔한 포맷이고, 오늘 다룬 파일들도 전부 이 형식이었다. ASCII STL("solid ..."로 시작)은
// 파일 크기가 이 공식과 안 맞을 테니 감지해서 에러로 안내한다.
function readStlBBox(filePath: string): StlBBox {
const buf = fs.readFileSync(filePath);
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];
let offset = 84;
for (let i = 0; i < triangleCount; i++) {
offset += 12; // 법선벡터(3 floats) 스킵
for (let v = 0; v < 3; v++) {
const x = buf.readFloatLE(offset);
const y = buf.readFloatLE(offset + 4);
const z = buf.readFloatLE(offset + 8);
offset += 12;
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
}
return { min, max, triangleCount };
}
// 카메라 회전값(OpenSCAD 컨벤션: rot=(0,0,0)이 -Z 방향을 내려보는 탑뷰) — 오늘 렌더해보며 확인함.
const VIEW_ROTATIONS: Record<string, [number, number, number]> = {
top: [0, 0, 0],
bottom: [180, 0, 0],
front: [90, 0, 0],
back: [90, 0, 180],
left: [90, 0, 90],
right: [90, 0, 270],
};
export const stlCadTool = {
name: 'stl_cad',
description: 'STL 3D모델 파일을 확인(치수/삼각형수)하거나 여러 각도로 미리보기 렌더링하거나, 지정한 위치에 원통형 구멍을 뚫는다(OpenSCAD 기반 — FreeCAD 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
schema: { action: 'string', stl_path: 'string' },
jsonSchema: {
type: 'object',
required: ['action', 'stl_path'],
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(비스듬한 기본 시야, 형태 전체 파악에 좋음). 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좌표' },
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_top_with_flash_hole.stl")' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
if (!fs.existsSync(OPENSCAD_BIN)) {
return { success: false, error: `OpenSCAD가 설치돼있지 않습니다(${OPENSCAD_BIN}). "sudo apt-get install -y openscad" 필요.` };
}
const workspacePath = getWorkspacePath(args);
let stlPath: string;
try {
stlPath = resolveWorkspacePath(workspacePath, String(args?.stl_path || ''));
} catch (e: any) {
return { success: false, error: String(e?.message || e) };
}
if (!fs.existsSync(stlPath)) return { success: false, error: `파일을 찾을 수 없습니다: ${args?.stl_path}` };
const action = String(args?.action || '');
if (action === 'info') {
let bbox: StlBBox;
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}`,
};
}
if (action === 'render') {
let bbox: StlBBox;
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { success: false, error: String(e?.message || e) }; }
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
const dist = Math.max(50, maxExtent * 3);
const view = String(args?.view || 'iso');
const scadPath = stlPath.replace(/\.stl$/i, `_render_${Date.now()}.scad`);
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
const pngPath = stlPath.replace(/\.stl$/i, `_${view}_${Date.now()}.png`);
const scadArgs = ['-o', pngPath, '--imgsize=900,900', '--colorscheme=Tomorrow'];
if (view === 'iso') {
scadArgs.push('--autocenter', '--viewall');
} else {
const rot = VIEW_ROTATIONS[view];
if (!rot) { try { fs.unlinkSync(scadPath); } catch { /* noop */ } return { success: false, error: `알 수 없는 view: ${view}` }; }
scadArgs.push(`--camera=0,0,0,${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho');
}
scadArgs.push(scadPath);
const render = await runOpenscad(scadArgs, 60_000);
try { fs.unlinkSync(scadPath); } catch { /* noop */ }
if (!render.ok) return { success: false, error: `렌더링 실패: ${render.detail}` };
if (!fs.existsSync(pngPath) || fs.statSync(pngPath).size < 100) {
return { success: false, error: '렌더링은 끝났는데 이미지 파일이 비정상입니다.' };
}
return { success: true, stdout: buildImageMarkdown(pngPath, workspacePath) };
}
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';
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 rot = axis === 'x' ? '[0,90,0]' : axis === 'y' ? '[90,0,0]' : '[0,0,0]';
const scadPath = outPath.replace(/\.stl$/i, `_${Date.now()}.scad`);
fs.writeFileSync(scadPath, [
`difference() {`,
` import(${JSON.stringify(stlPath)});`,
` translate([${x},${y},${z}]) rotate(${rot}) cylinder(h=${depth}, d=${diameter}, center=true, $fn=48);`,
`}`,
].join('\n'));
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const render = await runOpenscad(['-o', outPath, scadPath], 120_000);
try { fs.unlinkSync(scadPath); } catch { /* noop */ }
if (!render.ok) return { success: false, error: `구멍 뚫기 실패: ${render.detail}` };
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 100) {
return { success: false, error: '연산은 끝났는데 결과 STL 파일이 비정상입니다.' };
}
return {
success: true,
stdout: `구멍 추가됨: (${x}, ${y}, ${z})에 지름 ${diameter}mm, ${axis}축 방향, 길이 ${depth}mm.\n결과: ${outPathArg}\n\n다시 슬라이싱하려면 print3d_model이나 k2-app 슬라이싱 UI에서 이 새 파일을 사용하세요.`,
};
}
return { success: false, error: `알 수 없는 action: ${action}` };
},
};