diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index 05b0f50..bc28120 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -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: { diff --git a/src/gateway/chat/tool-scope.ts b/src/gateway/chat/tool-scope.ts index c2d72f9..539e9bc 100644 --- a/src/gateway/chat/tool-scope.ts +++ b/src/gateway/chat/tool-scope.ts @@ -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; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 2f62297..411caa1 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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) diff --git a/src/tools/stl-cad.ts b/src/tools/stl-cad.ts new file mode 100644 index 0000000..c270e40 --- /dev/null +++ b/src/tools/stl-cad.ts @@ -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 = { + 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 => { + 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}` }; + }, +};