feat: k2-app에 STL 확인 웹 UI 추가(드롭다운+미리보기+구멍뚫기)
채팅으로 좌표를 말로 불러주는 게 너무 번거롭다는 피드백으로, stl_cad 채팅도구와 같은 기능을 드롭다운+버튼 UI로도 쓸 수 있게 함. - stl-cad-core.ts: stl-cad.ts에서 OpenSCAD 실행/STL 파싱/렌더링 로직을 추출한 공용 모듈 + 워크스페이스 내 STL 파일 재귀 검색(findStlFiles) 추가. stl-cad.ts(채팅도구)와 routes-cad.ts가 공유 - routes-cad.ts: GET /api/cad/list(STL 목록), /api/cad/info(치수), /api/cad/render(PNG 스트리밍), POST /api/cad/add-hole - k2-app.html: "🔧 STL 확인" 모달 — 파일 드롭다운 선택 → 7방향 뷰 버튼 → 실시간 렌더 미리보기+치수, 접이식 "구멍 뚫기" 폼(x/y/z/지름/길이/축). 브라우저로 실제 렌더링/각도전환 확인함 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* routes-cad.ts
|
||||
* stl_cad 채팅도구의 웹 UI 버전 — k2-app.html의 "🔧 STL 확인" 패널이 씀. 채팅으로 좌표를
|
||||
* 불러주는 게 번거롭다는 피드백으로 드롭다운+버튼 UI를 추가하면서 만듦(2026-09-20).
|
||||
* 핵심 로직(OpenSCAD 실행/STL 파싱)은 stl-cad-core.ts를 채팅도구와 공유한다.
|
||||
*/
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { isPathInsideDir } from '../../tools/image';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, findStlFiles } from '../../tools/stl-cad-core';
|
||||
|
||||
function resolveWorkspaceFile(workspacePath: string, relPath: string): string {
|
||||
const resolved = path.resolve(workspacePath, relPath);
|
||||
if (!isPathInsideDir(workspacePath, resolved)) throw new Error('경로가 워크스페이스 밖입니다.');
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function registerCadRoutes(app: Express): void {
|
||||
app.get('/api/cad/list', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const workspacePath = getUserWorkspace(user.username);
|
||||
try {
|
||||
res.json({ files: findStlFiles(workspacePath) });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/cad/info', (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const workspacePath = getUserWorkspace(user.username);
|
||||
let stlPath: string;
|
||||
try { stlPath = resolveWorkspaceFile(workspacePath, String(req.query.path || '')); }
|
||||
catch (e: any) { return res.status(400).json({ error: String(e?.message || e) }); }
|
||||
if (!fs.existsSync(stlPath)) return res.status(404).json({ error: '파일을 찾을 수 없습니다.' });
|
||||
try {
|
||||
const bbox = readStlBBox(stlPath);
|
||||
res.json({ bbox });
|
||||
} catch (e: any) {
|
||||
res.status(400).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/cad/render', async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
if (!fs.existsSync(OPENSCAD_BIN)) return res.status(500).json({ error: 'OpenSCAD가 설치돼있지 않습니다.' });
|
||||
const workspacePath = getUserWorkspace(user.username);
|
||||
let stlPath: string;
|
||||
try { stlPath = resolveWorkspaceFile(workspacePath, String(req.query.path || '')); }
|
||||
catch (e: any) { return res.status(400).json({ error: String(e?.message || e) }); }
|
||||
if (!fs.existsSync(stlPath)) return res.status(404).json({ error: '파일을 찾을 수 없습니다.' });
|
||||
|
||||
const view = String(req.query.view || 'iso');
|
||||
const pngPath = path.join(path.dirname(stlPath), `.cad_render_${Date.now()}.png`);
|
||||
const result = await renderStlPng(stlPath, view, pngPath);
|
||||
if (!result.ok) return res.status(500).json({ error: result.detail });
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
const buf = fs.readFileSync(pngPath);
|
||||
try { fs.unlinkSync(pngPath); } catch { /* noop */ }
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
app.post('/api/cad/add-hole', express.json(), async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
if (!fs.existsSync(OPENSCAD_BIN)) return res.status(500).json({ error: 'OpenSCAD가 설치돼있지 않습니다.' });
|
||||
const workspacePath = getUserWorkspace(user.username);
|
||||
const body = req.body || {};
|
||||
let stlPath: string, outPath: string;
|
||||
try {
|
||||
stlPath = resolveWorkspaceFile(workspacePath, String(body.path || ''));
|
||||
outPath = resolveWorkspaceFile(workspacePath, String(body.output_path || ''));
|
||||
} catch (e: any) {
|
||||
return res.status(400).json({ error: String(e?.message || e) });
|
||||
}
|
||||
if (!fs.existsSync(stlPath)) return res.status(404).json({ error: '파일을 찾을 수 없습니다.' });
|
||||
const x = Number(body.x), y = Number(body.y), z = Number(body.z), diameter = Number(body.diameter);
|
||||
if (![x, y, z, diameter].every(Number.isFinite)) return res.status(400).json({ error: 'x, y, z, diameter가 모두 필요합니다.' });
|
||||
const depth = Number.isFinite(Number(body.depth)) ? Number(body.depth) : 20;
|
||||
const axis = (['x', 'y', 'z'].includes(body.axis) ? body.axis : 'z') as 'x' | 'y' | 'z';
|
||||
|
||||
const result = await addHoleToStl({ stlPath, outPath, x, y, z, diameter, depth, axis });
|
||||
if (!result.ok) return res.status(500).json({ error: result.detail });
|
||||
res.json({ success: true, output_path: path.relative(workspacePath, outPath).replace(/\\/g, '/') });
|
||||
});
|
||||
}
|
||||
@@ -96,6 +96,7 @@ import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
|
||||
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
|
||||
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
|
||||
import { registerWorkshopRoutes } from './routes/routes-workshop';
|
||||
import { registerCadRoutes } from './routes/routes-cad';
|
||||
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
|
||||
import {
|
||||
getConfig,
|
||||
@@ -4613,6 +4614,7 @@ registerWopiRoutes(app, '/api/lawyer');
|
||||
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerWopiRoutes(app, '/api/doctor');
|
||||
registerWorkshopRoutes(app, getSessionUser);
|
||||
registerCadRoutes(app);
|
||||
|
||||
|
||||
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// STL 확인/렌더링/구멍뚫기 공통 로직 — stl_cad 채팅도구(stl-cad.ts)와 웹 UI 라우트
|
||||
// (routes-cad.ts)가 공유한다. 선택 이유·함정은 stl-cad.ts 헤더 주석 참고.
|
||||
export const OPENSCAD_BIN = '/usr/bin/openscad';
|
||||
|
||||
export interface StlBBox { min: [number, number, number]; max: [number, number, number]; triangleCount: number }
|
||||
|
||||
export 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}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 바이너리 STL(80바이트 헤더+삼각형수(4B)+삼각형마다 50B)만 지원 — 3D프린팅에서 압도적으로
|
||||
// 흔한 포맷. ASCII STL("solid ..."로 시작)은 파일 크기가 이 공식과 안 맞을 테니 감지해서
|
||||
// 에러로 안내한다.
|
||||
export 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 방향을 내려보는 탑뷰) — 오늘 렌더해보며 확인함.
|
||||
export 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 async function renderStlPng(stlPath: string, view: string, outPngPath: string): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
let bbox: StlBBox;
|
||||
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: 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 scadPath = outPngPath.replace(/\.png$/i, '.scad');
|
||||
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
|
||||
|
||||
const scadArgs = ['-o', outPngPath, '--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 { ok: false, detail: `알 수 없는 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 { ok: false, detail: render.detail };
|
||||
if (!fs.existsSync(outPngPath) || fs.statSync(outPngPath).size < 100) {
|
||||
return { ok: false, detail: '렌더링은 끝났는데 이미지 파일이 비정상입니다.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function addHoleToStl(opts: {
|
||||
stlPath: string; outPath: string;
|
||||
x: number; y: number; z: number; diameter: number; depth: number; axis: 'x' | 'y' | 'z';
|
||||
}): Promise<{ ok: true } | { ok: false; detail: string }> {
|
||||
const { stlPath, outPath, x, y, z, diameter, depth, axis } = opts;
|
||||
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 { ok: false, detail: render.detail };
|
||||
if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 100) {
|
||||
return { ok: false, detail: '연산은 끝났는데 결과 STL 파일이 비정상입니다.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// 워크스페이스 아래 STL 파일을 재귀적으로 찾는다(드롭다운 목록용). node_modules류 폴더가
|
||||
// 없는 개인 워크스페이스라 별도 제외 패턴 없이 그냥 다 훑는다.
|
||||
export function findStlFiles(workspacePath: string, maxDepth = 6): string[] {
|
||||
const results: string[] = [];
|
||||
function walk(dir: string, depth: number) {
|
||||
if (depth > maxDepth) return;
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, depth + 1);
|
||||
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.stl')) {
|
||||
results.push(path.relative(workspacePath, full).replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(workspacePath, 0);
|
||||
return results.sort();
|
||||
}
|
||||
+11
-105
@@ -1,17 +1,18 @@
|
||||
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';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl } from './stl-cad-core.js';
|
||||
|
||||
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
|
||||
// 검토하거나, 위치를 지정해 구멍을 뚫을 수 있게 한다. 2026-09-20: 사용자가 "FreeCAD
|
||||
// 플러그인"을 요청했으나, FreeCAD는 GUI/Python API가 무겁고 이 워크플로(AI가 파라메트릭
|
||||
// 스크립트를 생성해 실행)엔 OpenSCAD가 훨씬 적합해서 이걸로 구현함 — OpenSCAD는 순수
|
||||
// 텍스트 스크립트 기반 CSG라 명령줄에서 바로 렌더/불리언 연산이 됨. ESP32 카메라 하우징에
|
||||
// 렌즈 소켓 위치를 실제로 이렇게 찾아본 뒤 이 도구로 정리함.
|
||||
const OPENSCAD_BIN = '/usr/bin/openscad';
|
||||
// 렌즈 소켓 위치를 실제로 이렇게 찾아본 뒤 이 도구로 정리함. 채팅으로 좌표를 불러주는 게
|
||||
// 번거롭다는 피드백으로 웹 UI(k2-app.html "🔧 STL 확인" 패널, routes-cad.ts)도 추가함 —
|
||||
// 핵심 로직은 stl-cad-core.ts에 있고 이 파일과 라우트가 공유한다.
|
||||
|
||||
function resolveWorkspacePath(workspacePath: string, relPath: string): string {
|
||||
const resolved = path.resolve(workspacePath, relPath);
|
||||
@@ -21,65 +22,6 @@ function resolveWorkspacePath(workspacePath: string, relPath: string): string {
|
||||
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 대신 이걸 씀, 텍스트 스크립트라 자동화에 더 적합). 워크스페이스 안의 파일만 다룬다. 물리적 동작이 없는 파일 편집이라 확인 없이 바로 실행해도 된다.',
|
||||
@@ -89,7 +31,7 @@ export const stlCadTool = {
|
||||
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은 지원 안 함.' },
|
||||
stl_path: { type: 'string', description: '워크스페이스 기준 상대경로의 바이너리 STL 파일(예: "print3d/esp32_camera/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좌표' },
|
||||
@@ -97,7 +39,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_top_with_flash_hole.stl")' },
|
||||
output_path: { type: 'string', description: '(add_hole 필수) 결과 STL을 저장할 워크스페이스 기준 상대경로(예: "print3d/esp32_camera/esp32_top_with_flash_hole.stl")' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -117,7 +59,7 @@ export const stlCadTool = {
|
||||
const action = String(args?.action || '');
|
||||
|
||||
if (action === 'info') {
|
||||
let bbox: StlBBox;
|
||||
let bbox;
|
||||
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 {
|
||||
@@ -130,32 +72,10 @@ export const stlCadTool = {
|
||||
}
|
||||
|
||||
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 */ }
|
||||
const render = await renderStlPng(stlPath, view, pngPath);
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -164,28 +84,14 @@ export const stlCadTool = {
|
||||
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 axis = (['x', 'y', 'z'].includes(args?.axis) ? args.axis : 'z') as 'x' | 'y' | '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 파일이 비정상입니다.' };
|
||||
}
|
||||
const result = await addHoleToStl({ stlPath, outPath, x, y, z, diameter, depth, axis });
|
||||
if (!result.ok) return { success: false, error: `구멍 뚫기 실패: ${result.detail}` };
|
||||
return {
|
||||
success: true,
|
||||
stdout: `구멍 추가됨: (${x}, ${y}, ${z})에 지름 ${diameter}mm, ${axis}축 방향, 길이 ${depth}mm.\n결과: ${outPathArg}\n\n다시 슬라이싱하려면 print3d_model이나 k2-app 슬라이싱 UI에서 이 새 파일을 사용하세요.`,
|
||||
|
||||
@@ -51,6 +51,21 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.slice-err.show{display:block;}
|
||||
.start-btn{width:100%;margin-top:8px;background:#16a34a;border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
|
||||
.start-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
|
||||
/* ── CAD(STL 확인) 모달 ── */
|
||||
.cad-modal{max-width:520px;}
|
||||
.cad-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
|
||||
.cad-view-btns{display:flex;gap:4px;flex-wrap:wrap;margin:8px 0;}
|
||||
.cad-view-btn{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
|
||||
.cad-view-btn.active{border-color:var(--brand);color:var(--brand);}
|
||||
.cad-preview{width:100%;aspect-ratio:1/1;background:#1a1a1a;border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;margin-bottom:8px;}
|
||||
.cad-preview img{width:100%;height:100%;object-fit:contain;}
|
||||
.cad-preview .cad-placeholder{color:#666;font-size:11px;}
|
||||
.cad-info{font-size:11px;color:var(--muted);margin-bottom:10px;line-height:1.7;}
|
||||
.cad-hole-toggle{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;width:100%;text-align:left;margin-top:6px;}
|
||||
.cad-hole-toggle:hover{border-color:var(--brand);color:var(--brand);}
|
||||
.cad-hole-form{display:none;margin-top:8px;padding-top:8px;border-top:1px solid var(--line);}
|
||||
.cad-hole-form.show{display:block;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -59,6 +74,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<h1>🖨️ K2 콤보</h1>
|
||||
<span class="hdr-sp"></span>
|
||||
<button class="hdr-btn" onclick="openSliceModal()">🔪 슬라이싱</button>
|
||||
<button class="hdr-btn" onclick="openCadModal()">🔧 STL 확인</button>
|
||||
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
|
||||
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
|
||||
<a class="hdr-btn" href="https://print.applecherry.net/" target="_blank">↗ 새 탭</a>
|
||||
@@ -124,6 +140,47 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slice-overlay" id="cad-overlay">
|
||||
<div class="slice-modal cad-modal">
|
||||
<button class="slice-close" onclick="closeCadModal()">✕</button>
|
||||
<h2>🔧 STL 확인</h2>
|
||||
<div class="cad-field">
|
||||
<label>파일</label>
|
||||
<select id="cad-file-select" onchange="onCadFileChange()"></select>
|
||||
</div>
|
||||
<div class="cad-view-btns" id="cad-view-btns">
|
||||
<button class="cad-view-btn active" data-view="iso" onclick="cadSetView('iso')">비스듬히</button>
|
||||
<button class="cad-view-btn" data-view="top" onclick="cadSetView('top')">위</button>
|
||||
<button class="cad-view-btn" data-view="bottom" onclick="cadSetView('bottom')">아래</button>
|
||||
<button class="cad-view-btn" data-view="front" onclick="cadSetView('front')">앞</button>
|
||||
<button class="cad-view-btn" data-view="back" onclick="cadSetView('back')">뒤</button>
|
||||
<button class="cad-view-btn" data-view="left" onclick="cadSetView('left')">왼쪽</button>
|
||||
<button class="cad-view-btn" data-view="right" onclick="cadSetView('right')">오른쪽</button>
|
||||
</div>
|
||||
<div class="cad-preview" id="cad-preview"><span class="cad-placeholder" id="cad-placeholder">파일을 선택하세요</span><img id="cad-preview-img" style="display:none"></div>
|
||||
<div class="cad-info" id="cad-info"></div>
|
||||
<button class="cad-hole-toggle" onclick="toggleHoleForm()">🕳️ 구멍 뚫기 ▾</button>
|
||||
<div class="cad-hole-form" id="cad-hole-form">
|
||||
<div class="slice-row">
|
||||
<div class="slice-field"><label>X</label><input type="number" id="hole-x" value="0" step="0.1"></div>
|
||||
<div class="slice-field"><label>Y</label><input type="number" id="hole-y" value="0" step="0.1"></div>
|
||||
<div class="slice-field"><label>Z</label><input type="number" id="hole-z" value="0" step="0.1"></div>
|
||||
</div>
|
||||
<div class="slice-row">
|
||||
<div class="slice-field"><label>지름(mm)</label><input type="number" id="hole-diameter" value="2" step="0.1" min="0.1"></div>
|
||||
<div class="slice-field"><label>길이(mm)</label><input type="number" id="hole-depth" value="20" step="0.5"></div>
|
||||
<div class="slice-field"><label>방향축</label>
|
||||
<select id="hole-axis"><option value="z">Z</option><option value="x">X</option><option value="y">Y</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slice-field"><label>결과 파일명</label><input type="text" id="hole-output" placeholder="예: esp32_top_with_hole.stl"></div>
|
||||
<button class="slice-btn" onclick="runAddHole()">구멍 뚫어서 새 파일로 저장</button>
|
||||
</div>
|
||||
<div class="slice-err" id="cad-err"></div>
|
||||
<div class="slice-result" id="cad-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
|
||||
function reloadFrame(){const f=document.getElementById('k2-frame');f.src=f.src;}
|
||||
@@ -261,6 +318,125 @@ async function startSlicedPrint(filename){
|
||||
alert('출력 시작 실패: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CAD(STL 확인) 모달 ──────────────────────────────────────────────────────
|
||||
let cadCurrentFile=null;
|
||||
let cadCurrentView='iso';
|
||||
|
||||
async function openCadModal(){
|
||||
document.getElementById('cad-overlay').classList.add('show');
|
||||
document.getElementById('cad-err').classList.remove('show');
|
||||
document.getElementById('cad-result').classList.remove('show');
|
||||
await cadLoadFileList();
|
||||
}
|
||||
function closeCadModal(){ document.getElementById('cad-overlay').classList.remove('show'); }
|
||||
|
||||
async function cadLoadFileList(){
|
||||
const sel=document.getElementById('cad-file-select');
|
||||
sel.innerHTML='<option value="">불러오는 중...</option>';
|
||||
try{
|
||||
const r=await fetch('/api/cad/list',{headers:authH()});
|
||||
const d=await r.json();
|
||||
const files=d.files||[];
|
||||
if(!files.length){ sel.innerHTML='<option value="">워크스페이스에 STL 파일이 없습니다</option>'; return; }
|
||||
sel.innerHTML=files.map(f=>`<option value="${f.replace(/"/g,'"')}">${f}</option>`).join('');
|
||||
if(cadCurrentFile && files.includes(cadCurrentFile)) sel.value=cadCurrentFile;
|
||||
onCadFileChange();
|
||||
}catch(e){
|
||||
sel.innerHTML='<option value="">목록을 불러오지 못했습니다</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function onCadFileChange(){
|
||||
cadCurrentFile=document.getElementById('cad-file-select').value;
|
||||
document.getElementById('cad-err').classList.remove('show');
|
||||
document.getElementById('cad-result').classList.remove('show');
|
||||
if(!cadCurrentFile){
|
||||
document.getElementById('cad-info').textContent='';
|
||||
document.getElementById('cad-preview-img').style.display='none';
|
||||
document.getElementById('cad-placeholder').style.display='flex';
|
||||
document.getElementById('cad-placeholder').textContent='파일을 선택하세요';
|
||||
return;
|
||||
}
|
||||
cadLoadInfo();
|
||||
cadRenderPreview();
|
||||
}
|
||||
|
||||
async function cadLoadInfo(){
|
||||
const infoEl=document.getElementById('cad-info');
|
||||
infoEl.textContent='치수 확인 중...';
|
||||
try{
|
||||
const r=await fetch('/api/cad/info?path='+encodeURIComponent(cadCurrentFile),{headers:authH()});
|
||||
const d=await r.json();
|
||||
if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
|
||||
const b=d.bbox;
|
||||
const size=[b.max[0]-b.min[0], b.max[1]-b.min[1], b.max[2]-b.min[2]].map(n=>n.toFixed(1));
|
||||
infoEl.innerHTML='크기: <b>'+size.join(' × ')+' mm</b> · 삼각형 '+b.triangleCount+'개<br>'+
|
||||
'X['+b.min[0].toFixed(1)+', '+b.max[0].toFixed(1)+'] Y['+b.min[1].toFixed(1)+', '+b.max[1].toFixed(1)+'] Z['+b.min[2].toFixed(1)+', '+b.max[2].toFixed(1)+']';
|
||||
}catch(e){
|
||||
infoEl.textContent='치수 조회 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function cadSetView(view){
|
||||
cadCurrentView=view;
|
||||
document.querySelectorAll('.cad-view-btn').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
|
||||
cadRenderPreview();
|
||||
}
|
||||
|
||||
async function cadRenderPreview(){
|
||||
if(!cadCurrentFile)return;
|
||||
const img=document.getElementById('cad-preview-img');
|
||||
const placeholder=document.getElementById('cad-placeholder');
|
||||
placeholder.style.display='flex'; placeholder.textContent='렌더링 중...';
|
||||
img.style.display='none';
|
||||
try{
|
||||
const url='/api/cad/render?path='+encodeURIComponent(cadCurrentFile)+'&view='+cadCurrentView;
|
||||
const r=await fetch(url,{headers:authH()});
|
||||
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||('HTTP '+r.status)); }
|
||||
const blob=await r.blob();
|
||||
img.src=URL.createObjectURL(blob);
|
||||
img.style.display='block';
|
||||
placeholder.style.display='none';
|
||||
}catch(e){
|
||||
placeholder.textContent='렌더링 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHoleForm(){
|
||||
document.getElementById('cad-hole-form').classList.toggle('show');
|
||||
}
|
||||
|
||||
async function runAddHole(){
|
||||
const errEl=document.getElementById('cad-err');
|
||||
const resultEl=document.getElementById('cad-result');
|
||||
errEl.classList.remove('show'); resultEl.classList.remove('show');
|
||||
if(!cadCurrentFile){ errEl.textContent='STL 파일을 먼저 선택하세요.'; errEl.classList.add('show'); return; }
|
||||
const outputName=document.getElementById('hole-output').value.trim();
|
||||
if(!outputName){ errEl.textContent='결과 파일명을 입력하세요.'; errEl.classList.add('show'); return; }
|
||||
const dir=cadCurrentFile.includes('/')?cadCurrentFile.slice(0,cadCurrentFile.lastIndexOf('/')+1):'';
|
||||
const body={
|
||||
path:cadCurrentFile,
|
||||
output_path:dir+outputName,
|
||||
x:Number(document.getElementById('hole-x').value),
|
||||
y:Number(document.getElementById('hole-y').value),
|
||||
z:Number(document.getElementById('hole-z').value),
|
||||
diameter:Number(document.getElementById('hole-diameter').value),
|
||||
depth:Number(document.getElementById('hole-depth').value),
|
||||
axis:document.getElementById('hole-axis').value,
|
||||
};
|
||||
try{
|
||||
const r=await fetch('/api/cad/add-hole',{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify(body)});
|
||||
const d=await r.json();
|
||||
if(!r.ok||!d.success) throw new Error(d.error||('HTTP '+r.status));
|
||||
resultEl.innerHTML='✅ 저장됨: <b>'+d.output_path+'</b>';
|
||||
resultEl.classList.add('show');
|
||||
await cadLoadFileList();
|
||||
}catch(e){
|
||||
errEl.textContent='구멍 뚫기 실패: '+e.message;
|
||||
errEl.classList.add('show');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user