From 8677a090d5d69d7daa763ed06427169be65cef5a Mon Sep 17 00:00:00 2001 From: kim Date: Thu, 24 Sep 2026 23:03:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=EC=8B=A4=20"?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD=20=EC=84=A4=EB=AA=85=EC=84=9C"=20=E2=80=94?= =?UTF-8?q?=20.scad=EC=97=90=EC=84=9C=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD=EB=8F=84=C2=B7=EB=B6=84=ED=95=B4=EB=8F=84?= =?UTF-8?q?=C2=B7=EB=B6=80=ED=92=88=ED=91=9C=EB=A5=BC=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세(project.assembly): 부품(call=조립 모듈 호출, 분해 offset, BOM 연결, 수량) + 조립 단계(부품/체결부품/공구/설명). 프로젝트 데이터라 이력·충돌감지·복제가 그대로 적용된다 - 렌더러: 원본 .scad를 수정하지 않고 `use <>` wrapper로 부품을 골라 OpenSCAD ortho 렌더. 안쪽 color가 우선이라 색을 못 덮는 제약을 "전체 렌더 vs 부품 뺀 렌더"의 픽셀 차이로 해결 → 이번 단계 부품만 원색+주황 외곽선, 나머지는 옅게, 부품 번호 풍선은 그 부품이 실제 보이는 영역에 배치(가림 처리 정확). 모든 그림 같은 카메라 (bbox 기반 fit, 투영식 실측 검증), 공통 여백 크롭으로 단계 간 축척 유지 - call 검증: 모듈 1개 호출만(따옴표/세미콜론/중괄호/주석/import·surface 등 금지) + .scad 안에 있는 module만 - 대시보드 "조립" 탭: .scad 스캔→부품 추가, 분해 offset, 단계 편집, 그림 렌더, 작업 탭으로 가져오기 - 설명서 페이지 assembly-manual.html: 완성/분해도, 부품표(BOM 구매상태), 체결부품·공구 합계, STEP 카드, A4 인쇄/PDF - 채팅 도구: scan_scad / get_assembly / set_assembly / render_assembly ("조립 설명서 만들어줘" → AI가 초안) - 테스트: 순수함수·실제 OpenSCAD/PIL 렌더(가장자리 잘림 없음 검증)·라우트 HTTP 통합·실브라우저 하네스 Co-Authored-By: Claude Sonnet 5 --- src/gateway/chat/retry-decisions.ts | 2 +- src/gateway/routes/routes-workshop.ts | 76 ++++++ src/gateway/routes/workshop-assembly.ts | 223 ++++++++++++++++ src/gateway/routes/workshop-storage.ts | 18 ++ src/tools/workshop-assembly-render.ts | 334 ++++++++++++++++++++++++ src/tools/workshop-project.ts | 67 ++++- tests/manual/assembly-ui-check.ts | 118 +++++++++ tests/workshop-assembly-routes.test.ts | 73 ++++++ tests/workshop-assembly.test.ts | 227 ++++++++++++++++ tests/workshop-review-fixes.test.ts | 2 +- web-ui/html/assembly-manual.html | 159 +++++++++++ web-ui/html/workshop-app.html | 308 ++++++++++++++++++++++ 12 files changed, 1604 insertions(+), 3 deletions(-) create mode 100644 src/gateway/routes/workshop-assembly.ts create mode 100644 src/tools/workshop-assembly-render.ts create mode 100644 tests/manual/assembly-ui-check.ts create mode 100644 tests/workshop-assembly-routes.test.ts create mode 100644 tests/workshop-assembly.test.ts create mode 100644 web-ui/html/assembly-manual.html diff --git a/src/gateway/chat/retry-decisions.ts b/src/gateway/chat/retry-decisions.ts index 3c95097..7acc0c9 100644 --- a/src/gateway/chat/retry-decisions.ts +++ b/src/gateway/chat/retry-decisions.ts @@ -190,7 +190,7 @@ export const WORKSHOP_MUTATING_ACTIONS = new Set([ 'add_task', 'update_task', 'delete_task', 'set_task_done', 'add_phase', 'rename_phase', 'delete_phase', 'add_note', 'set_notes', 'add_link', 'delete_link', 'rename_project', 'set_budget', 'archive_project', 'unarchive_project', 'duplicate_project', 'delete_project', - 'restore_history', + 'restore_history', 'set_assembly', 'render_assembly', ]); export function shouldForceWorkshopSaveRetry(input: WorkshopSaveRetryInput): boolean { diff --git a/src/gateway/routes/routes-workshop.ts b/src/gateway/routes/routes-workshop.ts index d87a1e4..c43184f 100644 --- a/src/gateway/routes/routes-workshop.ts +++ b/src/gateway/routes/routes-workshop.ts @@ -21,6 +21,8 @@ import { } from './workshop-storage'; import { searchDanawa } from '../../tools/danawa'; import { renderStlPng } from '../../tools/stl-cad-core'; +import { scanScadModules, unassignedParts } from './workshop-assembly'; +import { renderAssemblyManual, readManifest, resolveScadPath, currentAssemblyHash, assemblyOutDir, ASSEMBLY_DIR_NAME } from '../../tools/workshop-assembly-render'; import { clearHistory } from '../session'; import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage'; @@ -238,6 +240,80 @@ export function registerWorkshopRoutes( res.status(404).json({ error: 'render failed' }); }); + // ── 조립 설명서 ─────────────────────────────────────────────────────────────── + // 명세(부품/단계)는 프로젝트 저장 API로 저장되고(project.assembly), 여기는 조회/스캔/렌더만 한다. + const assemblyUrl = (projectId: string, file: string, v: number) => + `/api/files/${APP_TYPE}/${projectId}/${[ASSEMBLY_DIR_NAME, file].map(encodeURIComponent).join('/')}?v=${v}`; + + const assemblyInfo = (workspace: string, project: any) => { + const a = project.assembly; + const manifest = readManifest(workspace, project.id); + const outDir = assemblyOutDir(workspace, project.id); + const has = (f: string) => fs.existsSync(path.join(outDir, f)); + const v = manifest?.renderedAt || 0; + const rendered = !!manifest && has('overview.png'); + return { + project: { id: project.id, name: project.name, description: project.description || '', parts: project.parts || [], assembly: a || null }, + rendered, + renderedAt: manifest?.renderedAt || null, + stale: !!(a && rendered && manifest && currentAssemblyHash(workspace, a) !== manifest.hash), + images: rendered ? { + overview: assemblyUrl(project.id, 'overview.png', v), + exploded: has('exploded.png') ? assemblyUrl(project.id, 'exploded.png', v) : null, + steps: (manifest!.steps || []).filter(has).map(f => assemblyUrl(project.id, f, v)), + } : null, + unassigned: a ? unassignedParts(a).map(p => p.name) : [], + }; + }; + + app.get('/api/workshop/assembly/:id', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const id = String(req.params.id || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' }); + const workspace = getUserWorkspace(session.username); + const project = loadWorkshop(workspace).projects.find(p => p.id === id); + if (!project) return res.status(404).json({ error: 'not found' }); + res.json(assemblyInfo(workspace, project)); + }); + + // .scad의 module 목록(편집기 "부품으로 추가" 후보). 경로는 워크스페이스 안의 .scad만. + app.get('/api/workshop/assembly-scan', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const workspace = getUserWorkspace(session.username); + const r = resolveScadPath(workspace, String(req.query.path || '').replace(/\\/g, '/')); + if (!r.ok) return res.status(400).json({ error: r.error }); + const modules = scanScadModules(fs.readFileSync(r.abs, 'utf-8')); + res.json({ modules }); + }); + + // 워크스페이스의 프로젝트 하위 .scad 후보(편집기 경로 선택 도우미) + app.get('/api/workshop/assembly-scad-files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const projectId = String(req.query.projectId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) return res.status(400).json({ error: 'invalid projectId' }); + const workspace = getUserWorkspace(session.username); + const base = path.join(workspace, 'workshop', projectId); + const files = walkCaseFiles(base).filter(e => /\.scad$/i.test(e.name)).map(e => `workshop/${projectId}/${e.relPath}`); + res.json({ files }); + }); + + app.post('/api/workshop/assembly/:id/render', async (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const id = String(req.params.id || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' }); + const workspace = getUserWorkspace(session.username); + const project = loadWorkshop(workspace).projects.find(p => p.id === id); + if (!project) return res.status(404).json({ error: 'not found' }); + if (!project.assembly) return res.status(400).json({ error: '조립 설명서 명세가 없습니다.' }); + const r = await renderAssemblyManual(workspace, id, project.assembly); + if (!r.ok) return res.status(422).json({ error: r.error }); + res.json({ ...assemblyInfo(workspace, project), warnings: r.warnings, seconds: r.seconds }); + }); + // 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다. app.put('/api/workshop/active', (req, res) => { const session = getSessionUser(req); diff --git a/src/gateway/routes/workshop-assembly.ts b/src/gateway/routes/workshop-assembly.ts new file mode 100644 index 0000000..fc49305 --- /dev/null +++ b/src/gateway/routes/workshop-assembly.ts @@ -0,0 +1,223 @@ +/** + * workshop-assembly.ts + * "조립 설명서"(2026-09-24) — 작업실 프로젝트의 OpenSCAD 조립 모델(.scad)로부터 단계별 조립 설명서를 + * 만들기 위한 데이터 모델과 순수 함수들(검증/정규화/스캔/카메라 맞춤/해시). 실제 렌더는 + * src/tools/workshop-assembly-render.ts. 데이터는 프로젝트(project.assembly)에 들어 있어서 이력·충돌 + * 감지·복제가 그대로 적용된다. + * + * 개념 + * - parts: 조립체를 이루는 부품. call = .scad의 조립 좌표 모듈 호출("base_plate()", "camera(80)"), + * offset = 분해도에서 띄울 방향/거리(mm), bom = 대시보드 BOM 부품 이름(선택, 구매/보유 상태 표시용). + * 부품 번호 = 배열 순서+1 (그림의 풍선 번호와 부품표 번호가 같다). + * - steps: 조립 순서. partIds = 이 단계에서 "새로 붙는" 부품. 단계 그림은 그때까지 붙은 부품 전체를 + * 그리되 이번 단계 부품만 원색+주황 외곽선, 나머지는 옅게 보여준다. + */ +import crypto from 'crypto'; + +export interface AssemblyPart { + id: string; + name: string; + call: string; + offset: [number, number, number]; + bom?: string; + qty: number; +} +export interface AssemblyFastener { name: string; qty: number } +export interface AssemblyStep { + id: string; + title: string; + desc: string; + partIds: string[]; + fasteners: AssemblyFastener[]; + tools: string[]; +} +export interface Assembly { + /** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/print/rig.scad") */ + scad: string; + view: { rx: number; rz: number }; + /** 분해도 간격 배율(offset × 이 값). 기본 1 */ + explode: number; + parts: AssemblyPart[]; + steps: AssemblyStep[]; +} + +export const ASSEMBLY_LIMITS = { parts: 60, steps: 60, fasteners: 20, tools: 20, text: 2000 }; + +const SAFE_ID = /^[A-Za-z0-9_-]{1,64}$/; +const clampNum = (v: any, lo: number, hi: number, dflt: number): number => { + const n = Number(v); + return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; +}; +const str = (v: any, max: number): string => String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, max).trim(); + +// call 검증: "모듈이름(인자…)" 한 개의 호출만. 문자열/세미콜론/중괄호/백슬래시/주석/일부 예약어를 막아서 +// 프로젝트 데이터가 OpenSCAD 임의 코드(파일 읽기 import/surface 등)로 번지지 않게 한다. +const CALL_RE = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(([^;{}"'`\\]*)\)$/; +const FORBIDDEN_MODULES = new Set(['import', 'surface', 'include', 'use', 'dxf_linear_extrude', 'dxf_rotate_extrude', 'import_stl', 'import_off', 'import_dxf', 'projection', 'text', 'render']); +export function parseCall(call: string): { ok: true; module: string } | { ok: false; error: string } { + const m = CALL_RE.exec(String(call || '').trim()); + if (!m) return { ok: false, error: `call은 "모듈이름(인자)" 한 개의 호출이어야 합니다(따옴표/세미콜론/중괄호 불가): ${String(call).slice(0, 60)}` }; + if (/\/\/|\/\*/.test(m[2])) return { ok: false, error: '주석은 쓸 수 없습니다.' }; + if (FORBIDDEN_MODULES.has(m[1])) return { ok: false, error: `허용되지 않는 모듈: ${m[1]}` }; + // 인자 안에서도 금지 모듈 호출 차단(예: camera(import(...)) 형태) + for (const bad of FORBIDDEN_MODULES) if (new RegExp(`\\b${bad}\\s*\\(`).test(m[2])) return { ok: false, error: `허용되지 않는 호출: ${bad}(…)` }; + return { ok: true, module: m[1] }; +} + +// strict=true(채팅 도구 set_assembly): 잘못된 call은 거부해 모델에게 바로 피드백. +// strict=false(대시보드 저장): 편집 중인 미완성 행(빈 call 등) 때문에 프로젝트 전체 저장이 막히면 안 되므로 +// 그대로 보존한다 — 위험한 call은 저장돼도 렌더 때 parseCall로 다시 걸러진다(저장 자체는 무해한 문자열). +export function normalizeAssembly(input: any, strict = true): { ok: true; assembly: Assembly } | { ok: false; error: string } { + if (!input || typeof input !== 'object') return { ok: false, error: 'assembly 객체가 필요합니다.' }; + const scad = str(input.scad, 300).replace(/\\/g, '/'); + if (scad && (!/\.scad$/i.test(scad) || scad.split('/').some(s => s === '..') || scad.startsWith('/'))) { + return { ok: false, error: 'scad는 워크스페이스 기준 상대경로의 .scad 파일이어야 합니다(.. 불가).' }; + } + if (input.parts != null && !Array.isArray(input.parts)) return { ok: false, error: 'parts는 배열이어야 합니다.' }; + if (input.steps != null && !Array.isArray(input.steps)) return { ok: false, error: 'steps는 배열이어야 합니다.' }; + const rawParts: any[] = input.parts || []; + const rawSteps: any[] = input.steps || []; + if (rawParts.length > ASSEMBLY_LIMITS.parts) return { ok: false, error: `부품은 최대 ${ASSEMBLY_LIMITS.parts}개입니다.` }; + if (rawSteps.length > ASSEMBLY_LIMITS.steps) return { ok: false, error: `단계는 최대 ${ASSEMBLY_LIMITS.steps}개입니다.` }; + + const genId = (prefix: string) => `${prefix}_${crypto.randomBytes(4).toString('hex')}`; + const used = new Set(); + const uniqueId = (v: any, prefix: string) => { + let id = typeof v === 'string' && SAFE_ID.test(v) ? v : genId(prefix); + while (used.has(id)) id = genId(prefix); + used.add(id); + return id; + }; + + const parts: AssemblyPart[] = []; + for (const p of rawParts) { + const call = str(p?.call, 200); + const parsed = parseCall(call); + if (!parsed.ok && strict) return { ok: false, error: `부품 "${str(p?.name, 60) || call}": ${parsed.error}` }; + const off = Array.isArray(p?.offset) ? p.offset : [0, 0, 0]; + const part: AssemblyPart = { + id: uniqueId(p?.id, 'ap'), + name: str(p?.name, 100) || (parsed.ok ? parsed.module : ''), + call, + offset: [clampNum(off[0], -2000, 2000, 0), clampNum(off[1], -2000, 2000, 0), clampNum(off[2], -2000, 2000, 0)], + qty: Math.round(clampNum(p?.qty, 1, 999, 1)), + }; + const bom = str(p?.bom, 100); + if (bom) part.bom = bom; + parts.push(part); + } + const partIds = new Set(parts.map(p => p.id)); + + const steps: AssemblyStep[] = []; + for (const st of rawSteps) { + const ids = (Array.isArray(st?.partIds) ? st.partIds : []).filter((x: any) => typeof x === 'string' && partIds.has(x)); + steps.push({ + id: uniqueId(st?.id, 'as'), + title: str(st?.title, 120) || `단계 ${steps.length + 1}`, + desc: str(st?.desc, ASSEMBLY_LIMITS.text), + partIds: [...new Set(ids)], + fasteners: (Array.isArray(st?.fasteners) ? st.fasteners : []).slice(0, ASSEMBLY_LIMITS.fasteners) + .map((f: any) => ({ name: str(f?.name, 80), qty: Math.round(clampNum(f?.qty, 1, 9999, 1)) })).filter((f: AssemblyFastener) => f.name), + tools: (Array.isArray(st?.tools) ? st.tools : []).slice(0, ASSEMBLY_LIMITS.tools).map((t: any) => str(t, 60)).filter(Boolean), + }); + } + + return { + ok: true, + assembly: { + scad, + view: { rx: clampNum(input.view?.rx, 0, 90, 60), rz: clampNum(input.view?.rz, -360, 360, 35) }, + explode: clampNum(input.explode, 0.1, 5, 1), + parts, + steps, + }, + }; +} + +// 렌더 결과가 입력(.scad 내용+조립 명세)과 같은지 판정하는 해시 — 다르면 "설명서 그림이 오래됨" 표시. +export function assemblyHash(assembly: Assembly, scadSource: string): string { + const spec = { + view: assembly.view, explode: assembly.explode, + parts: assembly.parts.map(p => [p.id, p.name, p.call, p.offset]), + steps: assembly.steps.map(s => [s.id, s.partIds]), + }; + return crypto.createHash('sha1').update(scadSource).update(JSON.stringify(spec)).digest('hex').slice(0, 16); +} + +// .scad에서 module 목록과 매개변수(기본값 유무)를 뽑는다 — 편집기의 "부품으로 추가" 후보용. +export interface ScadModule { name: string; params: string[]; requiredParams: string[]; isPrintPart: boolean } +export function scanScadModules(source: string): ScadModule[] { + const out: ScadModule[] = []; + const re = /(^|\n)\s*module\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source))) { + const params = m[3].split(',').map(s => s.trim()).filter(Boolean); + out.push({ + name: m[2], + params: params.map(p => p.split('=')[0].trim()), + requiredParams: params.filter(p => !p.includes('=')).map(p => p.trim()), + // part_* 는 프린트 배치용(베드 위 평평하게) 모듈인 관례 — 조립 좌표가 아니라서 조립도엔 안 쓴다 + isPrintPart: /^part_/.test(m[2]), + }); + } + return out; +} + +// 조립 모델용 wrapper: 원본 .scad를 use로 가져오면 모듈/함수만 들어오고 최상위 형상(전체 조립체 등)은 +// 실행되지 않는다 — 원본 파일을 수정하지 않고 원하는 부품만 골라 그릴 수 있다. +export function buildWrapperScad(scadAbsPath: string, items: { call: string; offset?: [number, number, number] }[]): string { + const lines = [`use <${scadAbsPath.replace(/>/g, '')}>`]; + for (const it of items) { + const o = it.offset; + lines.push(o && (o[0] || o[1] || o[2]) ? `translate([${o[0]}, ${o[1]}, ${o[2]}]) ${it.call};` : `${it.call};`); + } + return lines.join('\n') + '\n'; +} + +// OpenSCAD `--camera`(ortho) 좌표 맞춤. 실측으로 검증한 투영식: 카메라 좌표 v = Rx(-rx)·Rz(-rz)·(p - target), +// 화면 x = W/2 + v.x·s, y = H/2 - v.y·s, s = H / (2·dist·tan(22.5°/2)). +export interface Bbox { min: [number, number, number]; max: [number, number, number] } +export function fitCamera(bbox: Bbox, view: { rx: number; rz: number }, size: { w: number; h: number }, margin = 0.08): { camera: string; scale: number } { + const rx = (view.rx * Math.PI) / 180, rz = (view.rz * Math.PI) / 180; + const c = bbox.min.map((v, i) => (v + bbox.max[i]) / 2) as [number, number, number]; + const rot = (p: number[]) => { + const x0 = p[0] - c[0], y0 = p[1] - c[1], z0 = p[2] - c[2]; + // Rz(-rz) + const cz = Math.cos(-rz), sz = Math.sin(-rz); + const x1 = cz * x0 - sz * y0, y1 = sz * x0 + cz * y0, z1 = z0; + // Rx(-rx) + const cx = Math.cos(-rx), sx = Math.sin(-rx); + return [x1, cx * y1 - sx * z1]; + }; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const x of [bbox.min[0], bbox.max[0]]) for (const y of [bbox.min[1], bbox.max[1]]) for (const z of [bbox.min[2], bbox.max[2]]) { + const [vx, vy] = rot([x, y, z]); + minX = Math.min(minX, vx); maxX = Math.max(maxX, vx); minY = Math.min(minY, vy); maxY = Math.max(maxY, vy); + } + const w = Math.max(1e-3, maxX - minX), h = Math.max(1e-3, maxY - minY); + const scale = Math.min((size.w * (1 - 2 * margin)) / w, (size.h * (1 - 2 * margin)) / h); // px per mm + const dist = size.h / (2 * scale * Math.tan((22.5 * Math.PI) / 360)); + const f = (n: number) => Math.round(n * 1000) / 1000; + return { camera: `${f(c[0])},${f(c[1])},${f(c[2])},${view.rx},0,${view.rz},${f(dist)}`, scale }; +} + +// 단계별 "그때까지 붙은 부품" 계산. 같은 부품이 여러 단계에 적혀 있으면 처음 등장한 단계에서만 새 부품이다. +export function stepPartSets(assembly: Assembly): { before: AssemblyPart[]; added: AssemblyPart[] }[] { + const byId = new Map(assembly.parts.map(p => [p.id, p])); + const installed: AssemblyPart[] = []; + const seen = new Set(); + return assembly.steps.map(step => { + const before = [...installed]; + const added: AssemblyPart[] = []; + for (const id of step.partIds) { + const p = byId.get(id); + if (p && !seen.has(id)) { seen.add(id); added.push(p); installed.push(p); } + } + return { before, added }; + }); +} + +export function unassignedParts(assembly: Assembly): AssemblyPart[] { + const used = new Set(assembly.steps.flatMap(s => s.partIds)); + return assembly.parts.filter(p => !used.has(p.id)); +} diff --git a/src/gateway/routes/workshop-storage.ts b/src/gateway/routes/workshop-storage.ts index a3062c6..9f65f80 100644 --- a/src/gateway/routes/workshop-storage.ts +++ b/src/gateway/routes/workshop-storage.ts @@ -7,6 +7,7 @@ */ import fs from 'fs'; import path from 'path'; +import { Assembly, normalizeAssembly } from './workshop-assembly'; export interface WorkshopPartLink { id: string; @@ -50,6 +51,8 @@ export interface WorkshopProject { budget?: number; /** 보관됨 — 프로젝트 선택 목록에서 기본 숨김(삭제와 달리 데이터·파일 유지). */ archived?: boolean; + /** 조립 설명서 명세(부품/단계). 그림은 workshop//조립설명서/ 에 렌더된다. */ + assembly?: Assembly; /** 저장 때마다 서버가 갱신하는 버전(ms). 클라이언트가 이 값을 기준으로 충돌을 감지한다. */ updatedAt?: number; } @@ -100,6 +103,12 @@ export function normalizeProjectInput(incoming: any, id: string): { ok: true; pr for (const pt of incoming.parts) { if (!pt || typeof pt !== 'object' || (pt.links != null && !Array.isArray(pt.links))) return { ok: false, error: 'parts[].links must be array' }; } + let assembly: Assembly | undefined; + if (incoming.assembly != null) { + const a = normalizeAssembly(incoming.assembly, false); + if (!a.ok) return { ok: false, error: `assembly: ${a.error}` }; + assembly = a.assembly; + } // 항목 id는 클라이언트가 onclick="fn('')"에 그대로 끼워 넣는다 — 안전한 문자만 허용하고 // 아니면 새 id로 교체한다(따옴표가 든 id로 인라인 핸들러에 코드가 주입되는 것 방지). const safeId = (v: any, prefix: string) => (typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : genWorkshopId(prefix)); @@ -110,6 +119,7 @@ export function normalizeProjectInput(incoming: any, id: string): { ok: true; pr description: incoming.description ?? '', budget: Math.round(Number(incoming.budget) || 0), archived: incoming.archived === true, + ...(assembly ? { assembly } : {}), phases: incoming.phases.map((ph: any) => ({ ...ph, id: safeId(ph.id, 'ph'), @@ -125,6 +135,7 @@ export function normalizeProjectInput(incoming: any, id: string): { ok: true; pr links: (pt.links ?? []).map((l: any) => ({ ...l, id: safeId(l?.id, 'lk') })), })), }; + if (!assembly) delete (project as any).assembly; return { ok: true, project }; } @@ -368,6 +379,12 @@ export function duplicateProject(src: WorkshopProject, newId: string): WorkshopP const { priceCheck: _pc, ...rest } = pt; return { ...rest, id: genWorkshopId('p'), status: '검토중' as const, links: (pt.links || []).map(l => ({ ...l, id: genWorkshopId('lk') })) }; }); + if (copy.assembly) { + // 부품 id를 새로 발급하고 단계의 partIds도 같이 매핑 — 그림(렌더)은 복제본에서 다시 만들어야 한다. + const idMap = new Map(); + copy.assembly.parts = copy.assembly.parts.map(ap => { const id = genWorkshopId('ap'); idMap.set(ap.id, id); return { ...ap, id }; }); + copy.assembly.steps = copy.assembly.steps.map(st => ({ ...st, id: genWorkshopId('as'), partIds: st.partIds.map(x => idMap.get(x)!).filter(Boolean) })); + } copy.phases = (copy.phases || []).map(ph => ({ ...ph, id: genWorkshopId('ph'), tasks: (ph.tasks || []).map(t => ({ ...t, id: genWorkshopId('t'), done: false })), @@ -410,6 +427,7 @@ export function projectToReadme(project: WorkshopProject): string { const total = project.parts.reduce((sum, p) => sum + p.qty * p.unitPrice, 0); lines.push(`목표 예산: ${fmtWon(project.budget)} (총 예상비용 ${Math.round((total / project.budget) * 100)}%${total > project.budget ? ' — 초과' : ''})`); } + if (project.assembly && project.assembly.steps.length) lines.push(`조립 설명서: 부품 ${project.assembly.parts.length}개 · 단계 ${project.assembly.steps.length}개 (${project.assembly.scad})`); const prog = projectProgress(project); if (prog.total) lines.push(`작업 진행: ${prog.done}/${prog.total} (${prog.pct}%)`); lines.push(''); diff --git a/src/tools/workshop-assembly-render.ts b/src/tools/workshop-assembly-render.ts new file mode 100644 index 0000000..9cbf01d --- /dev/null +++ b/src/tools/workshop-assembly-render.ts @@ -0,0 +1,334 @@ +/** + * workshop-assembly-render.ts + * 조립 설명서 그림 렌더러(2026-09-24). 프로젝트의 .scad를 `use`로 가져와 부품을 골라 그리는 wrapper를 + * OpenSCAD(ortho 프리뷰)로 렌더하고, PIL로 합성한다: + * - overview.png : 조립 완성 그림 + 부품 번호 풍선 + * - exploded.png : 부품별 offset만큼 띄운 분해도 + 부품 번호 풍선 + * - step-NN.png : 그 단계까지 붙은 부품 전체를 그리되 "이번 단계 부품"만 원색+주황 외곽선, 나머지는 옅게 + * + * 부품 강조/번호 위치는 OpenSCAD 색을 덮어쓸 수 없다는 제약(안쪽 color가 우선)을 피해 "차이 이미지"로 푼다: + * 전체 렌더 F와 그 부품을 뺀 렌더 A의 픽셀 차이 = 그 부품이 실제로 보이는 영역(가림 처리 정확). + * 모든 그림이 같은 카메라(부품 전체 bbox 기준)를 써서 단계가 바뀌어도 화면이 흔들리지 않는다. + */ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { spawn } from 'child_process'; +import { runOpenscad, readStlBBox } from './stl-cad-core.js'; +import { + Assembly, AssemblyPart, assemblyHash, buildWrapperScad, fitCamera, parseCall, scanScadModules, + stepPartSets, unassignedParts, +} from '../gateway/routes/workshop-assembly.js'; + +export const ASSEMBLY_DIR_NAME = '조립설명서'; +const IMG_W = 1000, IMG_H = 750; + +export interface RenderResult { + ok: true; + hash: string; + renderedAt: number; + overview: string; + exploded: string; + steps: string[]; + warnings: string[]; + seconds: number; +} + +// PIL 합성 스크립트(python3 -c) — image.ts의 OCR_SCRIPT와 같은 임베드 방식. +const COMPOSE_SCRIPT = ` +import sys, json +import numpy as np +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +job = json.load(open(sys.argv[1])) + +def load(p): + return np.asarray(Image.open(p).convert('RGB')).astype(np.int16) + +F = load(job['full']) +H, W = F.shape[:2] +bg = F[2, 2] +isbg = (np.abs(F - bg).sum(axis=2) <= 12) + +def diffmask(path): + if not path: + return ~isbg + O = load(path) + return (np.abs(F - O).sum(axis=2) > 20) + +out = F.copy() +ring = np.zeros(F.shape[:2], bool) +if job.get('highlight'): + M = diffmask(job.get('base')) + muted = F * 0.42 + 255 * 0.58 + out = np.where(M[..., None], F, muted) + # 주황 외곽선(마스크를 부풀린 테두리) + mimg = Image.fromarray((M * 255).astype('uint8')) + dil = np.asarray(mimg.filter(ImageFilter.MaxFilter(7))) > 0 + ring = dil & ~M + out[ring] = (245, 158, 11) +out[isbg & ~ring] = 255 +img = Image.fromarray(np.clip(out, 0, 255).astype('uint8')) +draw = ImageDraw.Draw(img) + +font = None +for fp in ('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/nanum/NanumSquareRoundB.ttf'): + try: + font = ImageFont.truetype(fp, 17); break + except Exception: + pass +if font is None: + font = ImageFont.load_default() + +placed = [] +for c in job.get('callouts', []): + M = diffmask(c.get('without')) + ys, xs = np.nonzero(M) + if len(xs) < 25: + continue + cx, cy = xs.mean(), ys.mean() + k = int(np.argmin((xs - cx) ** 2 + (ys - cy) ** 2)) # 부품 위의 점(비볼록이어도 부품 안) + px, py = int(xs[k]), int(ys[k]) + for _ in range(12): + if all((px - qx) ** 2 + (py - qy) ** 2 > 30 ** 2 for qx, qy in placed): + break + py += 30 + py = min(max(py, 16), H - 16); px = min(max(px, 16), W - 16) + placed.append((px, py)) + r = 15 + draw.ellipse((px - r, py - r, px + r, py + r), fill=(245, 158, 11), outline=(255, 255, 255), width=2) + t = str(c['n']) + bb = draw.textbbox((0, 0), t, font=font) + draw.text((px - (bb[2] - bb[0]) / 2 - bb[0], py - (bb[3] - bb[1]) / 2 - bb[1]), t, fill=(255, 255, 255), font=font) + +img.save(job['out']) +print(json.dumps({'callouts': len(placed)})) +`; + +// 여백 자르기: 주어진 그림들의 "흰색이 아닌 영역" 합집합 bbox(+pad)로 같은 크기로 잘라, 단계 그림끼리 +// 축척/위치는 그대로(단계가 바뀌어도 화면이 안 흔들림) 두고 쓸데없는 흰 여백만 없앤다. +const CROP_SCRIPT = ` +import sys, json +import numpy as np +from PIL import Image +files = json.load(open(sys.argv[1])) +pad = int(sys.argv[2]) +boxes = [] +for f in files: + a = np.asarray(Image.open(f).convert('RGB')).astype(int) + m = (a.sum(axis=2) < 750) + ys, xs = np.nonzero(m) + if len(xs): + boxes.append((xs.min(), ys.min(), xs.max(), ys.max())) +if boxes: + x0 = min(b[0] for b in boxes) - pad; y0 = min(b[1] for b in boxes) - pad + x1 = max(b[2] for b in boxes) + pad; y1 = max(b[3] for b in boxes) + pad + for f in files: + im = Image.open(f).convert('RGB') + W, H = im.size + im.crop((max(0, x0), max(0, y0), min(W, x1), min(H, y1))).save(f) +`; + +function runCrop(files: string[], tmpDir: string, pad = 28): Promise { + return new Promise((resolve) => { + if (!files.length) return resolve(); + const listPath = path.join(tmpDir, `crop${Date.now()}.json`); + fs.writeFileSync(listPath, JSON.stringify(files)); + const proc = spawn('python3', ['-c', CROP_SCRIPT, listPath, String(pad)], { stdio: 'ignore' }); + const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 30_000); + proc.on('error', () => { clearTimeout(timer); resolve(); }); // 자르기 실패는 치명적이지 않다(여백만 남음) + proc.on('close', () => { clearTimeout(timer); resolve(); }); + }); +} + +function runCompose(jobPath: string): Promise<{ ok: true } | { ok: false; detail: string }> { + return new Promise((resolve) => { + const proc = spawn('python3', ['-c', COMPOSE_SCRIPT, jobPath], { stdio: ['ignore', 'pipe', 'pipe'] }); + let err = ''; + proc.stderr?.on('data', (d: Buffer) => { err += d.toString('utf8'); if (err.length > 3000) err = err.slice(-3000); }); + const timer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 60_000); + proc.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, detail: String(e.message || e) }); }); + proc.on('close', (code) => { + clearTimeout(timer); + if (code === 0) resolve({ ok: true }); + else resolve({ ok: false, detail: err.split('\n').filter(Boolean).slice(-4).join(' ') || `python exit ${code}` }); + }); + }); +} + +// 동시에 하나만 렌더(CPU/메모리 보호, 같은 tmp 경로 충돌 방지) +let renderChain: Promise = Promise.resolve(); + +export function assemblyOutDir(workspace: string, projectId: string): string { + return path.join(workspace, 'workshop', projectId, ASSEMBLY_DIR_NAME); +} + +export function resolveScadPath(workspace: string, scadRel: string): { ok: true; abs: string } | { ok: false; error: string } { + if (!scadRel) return { ok: false, error: '조립 모델(.scad) 경로가 지정되지 않았습니다.' }; + const abs = path.resolve(workspace, scadRel); + const root = path.resolve(workspace) + path.sep; + if (!abs.startsWith(root) || !/\.scad$/i.test(abs)) return { ok: false, error: '.scad 파일은 워크스페이스 안에 있어야 합니다.' }; + if (!fs.existsSync(abs)) return { ok: false, error: `.scad 파일이 없습니다: ${scadRel}` }; + return { ok: true, abs }; +} + +export function currentAssemblyHash(workspace: string, assembly: Assembly): string | null { + const r = resolveScadPath(workspace, assembly.scad); + if (!r.ok) return null; + try { return assemblyHash(assembly, fs.readFileSync(r.abs, 'utf-8')); } catch { return null; } +} + +export function renderAssemblyManual(workspace: string, projectId: string, assembly: Assembly): Promise { + const run = renderChain.then(() => doRender(workspace, projectId, assembly)); + renderChain = run.catch(() => undefined); + return run; +} + +async function doRender(workspace: string, projectId: string, assembly: Assembly): Promise { + const t0 = Date.now(); + const warnings: string[] = []; + const sc = resolveScadPath(workspace, assembly.scad); + if (!sc.ok) return sc; + if (!assembly.parts.length) return { ok: false, error: '부품이 하나도 없습니다.' }; + if (!assembly.steps.length) return { ok: false, error: '조립 단계가 하나도 없습니다.' }; + + const source = fs.readFileSync(sc.abs, 'utf-8'); + const modules = new Set(scanScadModules(source).map(m => m.name)); + for (const p of assembly.parts) { + const parsed = parseCall(p.call); + if (!parsed.ok) return { ok: false, error: `부품 "${p.name}": ${parsed.error}` }; + if (!modules.has(parsed.module)) return { ok: false, error: `부품 "${p.name}": .scad에 module ${parsed.module}이(가) 없습니다.` }; + } + const orphan = unassignedParts(assembly); + if (orphan.length) warnings.push(`어느 단계에도 배정되지 않아 단계 그림에 안 나오는 부품: ${orphan.map(p => p.name).join(', ')}`); + const emptySteps = assembly.steps.filter(s => !s.partIds.length).map(s => s.title); + if (emptySteps.length) warnings.push(`부품이 없는 단계(그림이 앞 단계와 같음): ${emptySteps.join(', ')}`); + + const tmp = path.join(workspace, '.smallclaw', 'workshop-assembly-tmp', `${projectId}_${crypto.randomBytes(3).toString('hex')}`); + fs.mkdirSync(tmp, { recursive: true }); + const outDir = assemblyOutDir(workspace, projectId); + fs.mkdirSync(outDir, { recursive: true }); + + let counter = 0; + const renderSet = async (items: { part: AssemblyPart; exploded?: boolean }[], camera: string): Promise<{ ok: true; png: string | null } | { ok: false; error: string }> => { + if (!items.length) return { ok: true, png: null }; // 빈 장면 — 합성기가 "전부 새 부품"으로 처리 + const n = ++counter; + const scadFile = path.join(tmp, `w${n}.scad`); + const png = path.join(tmp, `r${n}.png`); + const ex = assembly.explode; + fs.writeFileSync(scadFile, buildWrapperScad(sc.abs, items.map(i => ({ + call: i.part.call, + offset: i.exploded ? [i.part.offset[0] * ex, i.part.offset[1] * ex, i.part.offset[2] * ex] as [number, number, number] : undefined, + })))); + const r = await runOpenscad(['-o', png, `--imgsize=${IMG_W},${IMG_H}`, '--colorscheme=Tomorrow', '--projection=ortho', `--camera=${camera}`, scadFile], 60_000); + if (!r.ok) return { ok: false, error: `렌더 실패(${items.map(i => i.part.name).join(',')}): ${r.detail}` }; + if (!fs.existsSync(png)) return { ok: false, error: '렌더 결과 이미지가 없습니다.' }; + return { ok: true, png }; + }; + + const bboxOf = async (items: { part: AssemblyPart; exploded?: boolean }[]) => { + const n = ++counter; + const scadFile = path.join(tmp, `b${n}.scad`); + const stl = path.join(tmp, `b${n}.stl`); + const ex = assembly.explode; + fs.writeFileSync(scadFile, buildWrapperScad(sc.abs, items.map(i => ({ + call: i.part.call, + offset: i.exploded ? [i.part.offset[0] * ex, i.part.offset[1] * ex, i.part.offset[2] * ex] as [number, number, number] : undefined, + })))); + // 2021.01은 기본이 ASCII STL이라 binstl로 강제(readStlBBox는 바이너리만 지원) + const r = await runOpenscad(['-o', stl, '--export-format', 'binstl', scadFile], 120_000); + if (!r.ok) return { ok: false as const, error: `형상 범위 계산 실패: ${r.detail}` }; + try { return { ok: true as const, bbox: readStlBBox(stl) }; } catch (e: any) { return { ok: false as const, error: `형상 범위 계산 실패: ${String(e?.message || e)}` }; } + }; + + const compose = async (job: any, outPng: string) => { + const jobPath = path.join(tmp, `job${++counter}.json`); + fs.writeFileSync(jobPath, JSON.stringify({ ...job, out: outPng })); + return runCompose(jobPath); + }; + + try { + const all = assembly.parts.map(part => ({ part })); + const allEx = assembly.parts.map(part => ({ part, exploded: true })); + const b1 = await bboxOf(all); + if (!b1.ok) return b1; + const b2 = await bboxOf(allEx); + if (!b2.ok) return b2; + const camA = fitCamera(b1.bbox, assembly.view, { w: IMG_W, h: IMG_H }).camera; + const camE = fitCamera(b2.bbox, assembly.view, { w: IMG_W, h: IMG_H }).camera; + + const numberOf = new Map(assembly.parts.map((p, i) => [p.id, i + 1])); + + // 1) overview / exploded — 부품 하나씩 뺀 렌더로 풍선 위치를 구한다 + const plain = async (items: { part: AssemblyPart; exploded?: boolean }[], camera: string, outName: string) => { + const full = await renderSet(items, camera); + if (!full.ok) return full; + const callouts: { n: number; without: string | null }[] = []; + for (const it of items) { + const w = await renderSet(items.filter(x => x !== it), camera); + if (!w.ok) return w; + callouts.push({ n: numberOf.get(it.part.id)!, without: w.png }); + } + const c = await compose({ full: full.png, callouts, highlight: false }, path.join(outDir, outName)); + if (!c.ok) return { ok: false as const, error: `합성 실패(${outName}): ${c.detail}` }; + return { ok: true as const }; + }; + const ov = await plain(all, camA, 'overview.png'); + if (!ov.ok) return ov; + const ex = await plain(allEx, camE, 'exploded.png'); + if (!ex.ok) return ex; + + // 2) 단계별 — 그때까지 붙은 부품 전체 + 이번 단계 부품 강조 + const sets = stepPartSets(assembly); + const stepFiles: string[] = []; + for (let i = 0; i < sets.length; i++) { + const { before, added } = sets[i]; + const items = [...before, ...added].map(part => ({ part })); + const name = `step-${String(i + 1).padStart(2, '0')}.png`; + if (!items.length) { // 부품이 아직 하나도 없는 단계 — 준비 단계로 빈 그림 대신 완성 그림을 옅게 + fs.copyFileSync(path.join(outDir, 'overview.png'), path.join(outDir, name)); + stepFiles.push(name); + continue; + } + const full = await renderSet(items, camA); + if (!full.ok) return full; + const base = await renderSet(before.map(part => ({ part })), camA); + if (!base.ok) return base; + const callouts: { n: number; without: string | null }[] = []; + for (const a of added) { + if (added.length === 1) { callouts.push({ n: numberOf.get(a.id)!, without: base.png }); continue; } + const w = await renderSet([...before, ...added.filter(x => x !== a)].map(part => ({ part })), camA); + if (!w.ok) return w; + callouts.push({ n: numberOf.get(a.id)!, without: w.png }); + } + const c = await compose({ full: full.png, base: base.png, callouts, highlight: added.length > 0 }, path.join(outDir, name)); + if (!c.ok) return { ok: false, error: `합성 실패(${name}): ${c.detail}` }; + stepFiles.push(name); + } + + // 2.5) 여백 자르기 — 단계 그림은 한 묶음(같은 크롭)으로, 완성/분해도는 각각 + await runCrop(stepFiles.map(f => path.join(outDir, f)), tmp); + await runCrop([path.join(outDir, 'overview.png')], tmp); + await runCrop([path.join(outDir, 'exploded.png')], tmp); + + // 3) 옛 단계 그림 정리(단계 수가 줄었을 때) + for (const f of fs.readdirSync(outDir)) { + if (/^step-\d+\.png$/.test(f) && !stepFiles.includes(f)) { try { fs.unlinkSync(path.join(outDir, f)); } catch { /* noop */ } } + } + const hash = assemblyHash(assembly, source); + const renderedAt = Date.now(); + const rel = (f: string) => `workshop/${projectId}/${ASSEMBLY_DIR_NAME}/${f}`; + fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify({ hash, renderedAt, steps: stepFiles }, null, 2), 'utf-8'); + return { + ok: true, hash, renderedAt, + overview: rel('overview.png'), exploded: rel('exploded.png'), steps: stepFiles.map(rel), warnings, + seconds: Math.round((Date.now() - t0) / 100) / 10, + }; + } finally { + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* noop */ } + } +} + +export function readManifest(workspace: string, projectId: string): { hash: string; renderedAt: number; steps: string[] } | null { + try { return JSON.parse(fs.readFileSync(path.join(assemblyOutDir(workspace, projectId), 'manifest.json'), 'utf-8')); } catch { return null; } +} diff --git a/src/tools/workshop-project.ts b/src/tools/workshop-project.ts index 55c00b3..8a5c282 100644 --- a/src/tools/workshop-project.ts +++ b/src/tools/workshop-project.ts @@ -9,6 +9,8 @@ import { duplicateProject, projectProgress, listHistory, restoreFromHistory, recordHistory, } from '../gateway/routes/workshop-storage.js'; import { searchDanawa } from './danawa.js'; +import { normalizeAssembly, scanScadModules, parseCall, unassignedParts } from '../gateway/routes/workshop-assembly.js'; +import { renderAssemblyManual, resolveScadPath, ASSEMBLY_DIR_NAME } from './workshop-assembly-render.js'; // "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만 // 다루던 robot_project를 여러 프로젝트를 오갈 수 있게 일반화(2026-09-20). 대시보드와 같은 @@ -95,8 +97,9 @@ export const workshopProjectTool = { 'add_note', 'set_notes', 'add_link', 'delete_link', 'rename_project', 'set_budget', 'archive_project', 'unarchive_project', 'duplicate_project', 'delete_project', 'list_history', 'restore_history', 'view_image', + 'get_assembly', 'set_assembly', 'scan_scad', 'render_assembly', ], - description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(텍스트는 description 파라미터로 전달). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제. add_parts: 부품 여러 개 일괄 추가(parts 배열). check_price: 부품(part_name) 또는 검색어(query)의 다나와 시세 조회(PC부품 위주 — 전자부품/소모품은 결과가 없을 수 있음). update_task: 작업 문구 수정(new_text)/기한 설정(due, 빈 문자열이면 해제). delete_task/delete_phase/rename_phase: 작업·단계 삭제/단계 이름 변경(new_phase_name). set_notes: 메모 전체 교체(notes_text). rename_project(new_project_name)/set_budget(budget, 원 단위, 0이면 해제)/archive_project/unarchive_project/duplicate_project(구조만 복제, 진행상태 초기화)/delete_project(confirm=true 필수, 첨부파일·채팅기록까지 삭제되므로 사용자가 명시적으로 요청했을 때만). list_history: 최근 변경 이력. restore_history: 이력 시각(ts)으로 프로젝트를 되돌림. view_image: 프로젝트 첨부 사진을 모델이 직접 보게 함(file_name=파일명 또는 상대경로 일부, 사용자 메시지의 [첨부 사진: ...]이 이 파일) — 부품 식별, 배선/조립 상태 확인 등에 쓴다.', + description: 'list_projects: 전체 프로젝트 목록. create_project: 새 프로젝트 생성(+활성화). set_active_project: 대시보드에서 볼 프로젝트 전환. get: 프로젝트 조회(개요+예산 요약+부품+작업+메모). set_description: 프로젝트 서술형 개요 설정(텍스트는 description 파라미터로 전달). add_part/update_part/delete_part: 부품 추가/수정/삭제. add_task: 단계에 작업 추가. set_task_done: 작업 완료/미완료. add_phase: 새 단계 추가. add_note: 메모 한 줄 추가. add_link/delete_link: 부품에 참고링크(구매처/대체품/업그레이드/레포 등) 추가/삭제. add_parts: 부품 여러 개 일괄 추가(parts 배열). check_price: 부품(part_name) 또는 검색어(query)의 다나와 시세 조회(PC부품 위주 — 전자부품/소모품은 결과가 없을 수 있음). update_task: 작업 문구 수정(new_text)/기한 설정(due, 빈 문자열이면 해제). delete_task/delete_phase/rename_phase: 작업·단계 삭제/단계 이름 변경(new_phase_name). set_notes: 메모 전체 교체(notes_text). rename_project(new_project_name)/set_budget(budget, 원 단위, 0이면 해제)/archive_project/unarchive_project/duplicate_project(구조만 복제, 진행상태 초기화)/delete_project(confirm=true 필수, 첨부파일·채팅기록까지 삭제되므로 사용자가 명시적으로 요청했을 때만). list_history: 최근 변경 이력. restore_history: 이력 시각(ts)으로 프로젝트를 되돌림. view_image: 프로젝트 첨부 사진을 모델이 직접 보게 함(file_name=파일명 또는 상대경로 일부, 사용자 메시지의 [첨부 사진: ...]이 이 파일) — 부품 식별, 배선/조립 상태 확인 등에 쓴다. [조립 설명서] 사용자가 "조립 설명서/조립도 만들어줘"라고 하면: ①scan_scad(scad_path)로 프로젝트 .scad의 module 목록을 보고(조립 좌표 모듈만 — part_* 는 프린트 배치용이라 제외) ②부품(call="모듈(인자)", 분해도 offset [x,y,z] mm, 대시보드 BOM 이름 bom)과 조립 순서 단계(title/desc/partIds/fasteners/tools)를 정해 set_assembly로 저장 ③render_assembly로 그림을 렌더. 단계 순서는 아래(바닥·기초)에서 위로, 큰 구조 → 작은 부품 순으로. 렌더 결과 이미지를 확인해 이상하면 offset/순서를 고쳐 다시 렌더할 것. 설명서는 대시보드 "조립" 탭에서 보고 인쇄한다.', }, project_name: { type: 'string', description: '(create_project/set_active_project 필수, 그 외 모든 액션 선택) 대상 프로젝트 이름(일부만 일치해도 됨). 생략하면 현재 활성 프로젝트를 대상으로 함.' }, description: { type: 'string', description: '(set_description 액션에서 필수 — 파라미터 이름은 description이다) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 대시보드의 "개요" 탭에 표시된다.' }, @@ -133,6 +136,11 @@ export const workshopProjectTool = { query: { type: 'string', description: '(check_price 선택) part_name 대신 직접 검색어를 줄 때' }, confirm: { type: 'boolean', description: '(delete_project 필수) 반드시 true — 사용자가 삭제를 명시적으로 요청한 경우에만' }, ts: { type: 'number', description: '(restore_history 필수) list_history가 준 이력 시각(ts)' }, + scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/CAD/print/rig.scad"). 생략하면 현재 조립 명세의 scad.' }, + assembly: { + type: 'object', + description: '(set_assembly 필수) 조립 설명서 명세 전체(부분 수정도 전체를 다시 넣을 것 — 먼저 get_assembly로 현재 값 확인). {scad, view:{rx,rz}, explode, parts:[{id,name,call,offset:[x,y,z],bom,qty}], steps:[{title,desc,partIds:[부품 id],fasteners:[{name,qty}],tools:[문자열]}]}. parts[].id는 짧은 영문/숫자(예: "base"), steps[].partIds가 참조한다. call은 .scad에 있는 조립 모듈 한 개의 호출("base_plate()", "camera(80)") — 따옴표/세미콜론 금지.', + }, file_name: { type: 'string', description: '(view_image 필수) 프로젝트 첨부 사진의 파일명 또는 상대경로(일부만 일치해도 됨, 예: "사진/motor.jpg")' }, }, additionalProperties: false, @@ -506,6 +514,63 @@ export const workshopProjectTool = { return { success: true, stdout: `[${project.name}] 사진: ${rel}\n![${path.basename(rel)}](${url})` }; } + if (action === 'get_assembly') { + if (!project.assembly) return { success: true, stdout: `[${project.name}] 조립 설명서가 아직 없습니다. scan_scad → set_assembly → render_assembly 순서로 만들 것.` }; + const a = project.assembly; + return { success: true, stdout: `[${project.name}] 조립 설명서 명세(부품 ${a.parts.length}, 단계 ${a.steps.length}):\n${JSON.stringify(a, null, 1)}` }; + } + + if (action === 'scan_scad') { + const rel = String(args?.scad_path || project.assembly?.scad || '').trim().replace(/\\/g, '/'); + const r = resolveScadPath(workspaceRoot, rel); + if (!r.ok) return { success: false, error: r.error }; + const mods = scanScadModules(fs.readFileSync(r.abs, 'utf-8')); + const assemble = mods.filter(m => !m.isPrintPart); + return { + success: true, + stdout: `${rel} — module ${mods.length}개. 조립 좌표 모듈(조립도 후보):\n` + + assemble.map(m => `- ${m.name}(${m.params.join(', ')})${m.requiredParams.length ? ` ← 필수 인자 ${m.requiredParams.join(', ')}` : ''}`).join('\n') + + `\n프린트 배치용(part_*, 조립도엔 안 씀): ${mods.filter(m => m.isPrintPart).map(m => m.name).join(', ') || '없음'}`, + }; + } + + if (action === 'set_assembly') { + if (!args?.assembly || typeof args.assembly !== 'object') return { success: false, error: 'assembly 객체가 필요합니다(get_assembly 참고).' }; + const n = normalizeAssembly(args.assembly); + if (!n.ok) return { success: false, error: n.error }; + const notes: string[] = []; + if (n.assembly.scad) { + const r = resolveScadPath(workspaceRoot, n.assembly.scad); + if (!r.ok) notes.push(`⚠ ${r.error}`); + else { + const modNames = new Set(scanScadModules(fs.readFileSync(r.abs, 'utf-8')).map(m => m.name)); + for (const p of n.assembly.parts) { + const pc = parseCall(p.call); + if (pc.ok && !modNames.has(pc.module)) notes.push(`⚠ 부품 "${p.name}": .scad에 module ${pc.module}이(가) 없음`); + } + } + } else notes.push('⚠ scad 경로가 비어 있음 — 렌더 전에 지정해야 함'); + const orphan = unassignedParts(n.assembly); + if (orphan.length) notes.push(`⚠ 어느 단계에도 없는 부품: ${orphan.map(p => p.name).join(', ')}`); + project.assembly = n.assembly; + saveWorkshop(workspaceRoot, data); + return { success: true, stdout: `[${project.name}] 조립 설명서 명세 저장됨: 부품 ${n.assembly.parts.length}개, 단계 ${n.assembly.steps.length}개.${notes.length ? '\n' + notes.join('\n') : ''}\n다음: render_assembly로 그림을 만들 것.` }; + } + + if (action === 'render_assembly') { + if (!project.assembly) return { success: false, error: '조립 설명서 명세가 없습니다. set_assembly로 먼저 저장하세요.' }; + const r = await renderAssemblyManual(workspaceRoot, project.id, project.assembly); + if (!r.ok) return { success: false, error: `렌더 실패: ${r.error}` }; + const url = (rel: string) => `/api/files/${rel.split('/').map(encodeURIComponent).join('/')}`; + return { + success: true, + stdout: `[${project.name}] 조립 설명서 그림 렌더 완료(${r.seconds}초, 단계 ${r.steps.length}장). 폴더: workshop/${project.id}/${ASSEMBLY_DIR_NAME}/\n` + + (r.warnings.length ? r.warnings.map(w => `⚠ ${w}`).join('\n') + '\n' : '') + + `![조립 완성](${url(r.overview)})\n![분해도](${url(r.exploded)})\n` + + `그림을 확인해 부품 배치/번호/단계 순서가 이상하면 set_assembly로 고쳐 다시 렌더할 것. 사용자는 대시보드의 "조립" 탭에서 전체 설명서를 보고 인쇄할 수 있다.`, + }; + } + return { success: false, error: `알 수 없는 action: ${action}` }; }, }; diff --git a/tests/manual/assembly-ui-check.ts b/tests/manual/assembly-ui-check.ts new file mode 100644 index 0000000..d0f630c --- /dev/null +++ b/tests/manual/assembly-ui-check.ts @@ -0,0 +1,118 @@ +// 조립 설명서 UI 실브라우저 검증(수동): npx tsx tests/manual/assembly-ui-check.ts +// 임시 데이터 디렉토리에 실제 스캐너 .scad를 복사해 "조립" 탭 편집 → 스캔 → 렌더 → 설명서 페이지까지 확인. +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import http from 'http'; +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'asmui-')); +process.env.SMALLCLAW_DATA_DIR = tmp; +const OUT = process.env.SHOT_DIR || '/tmp'; +const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/workspace/workshop/proj_scan_dental/CAD/print/scanner-rig.scad'; +(async () => { + const { registerWorkshopRoutes } = await import('../../src/gateway/routes/routes-workshop'); + const { chromium } = require('playwright-core'); + const app = express(); + app.use(express.json()); + app.get('/api/auth/status', (_q, r) => r.json({ authenticated: true })); + app.get('/api/chat/sessions/:id', (_q, r) => r.json({ history: [] })); + app.get('/api/settings/model', (_q, r) => r.json({ primary: 'test-model' })); + app.get('/api/k2/status', (_q, r) => r.json({ online: false })); + const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep); + registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside); + // 실제 서버의 /api/files/{*filePath}(워크스페이스 파일 서빙)를 흉내 + app.use('/api/files', (req, res, next) => express.static(path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace'))(req, res, next)); + app.use(express.static('/srv/homeclaw/web-ui')); + const server = http.createServer(app); + await new Promise(r => server.listen(0, r)); + const base = `http://127.0.0.1:${(server.address() as any).port}`; + const ws = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace'); + fs.mkdirSync(path.join(ws, 'workshop/pa/CAD'), { recursive: true }); + fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pa/CAD/rig.scad')); + await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + id: 'pa', name: '치과용 스캐너(시험)', description: '듀얼카메라 DLP 구조광 스캐너 데스크탑 랙', notes: '', phases: [], + parts: [{ id: 'b1', name: '베이스 플레이트', qty: 1, unitPrice: 30000, status: '보유', memo: '', links: [] }] }) }); + + const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] }); + const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }); + const errors: string[] = []; + page.on('pageerror', (e: any) => errors.push(e.message)); + page.on('dialog', (d: any) => d.accept()); + await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} }); + const results: string[] = []; + const check = (n: string, ok: boolean, x = '') => results.push(`${ok ? 'PASS' : 'FAIL'} ${n}${x ? ' — ' + x : ''}`); + + await page.goto(`${base}/html/workshop-app.html`); + await page.waitForSelector('#project-select'); + await page.selectOption('#project-select', 'pa'); + await page.click('.rb-tab-btn[data-tab=assembly]'); + await page.waitForSelector('#asm-scad'); + await page.fill('#asm-scad', 'workshop/pa/CAD/rig.scad'); + await page.click('button:has-text("모듈 스캔")'); + await page.waitForSelector('.asm-mods button'); + const modCount = await page.locator('.asm-mods button').count(); + check('스캔: 조립 모듈 목록(part_* 제외)', modCount > 5 && !(await page.locator('.asm-mods').innerText()).includes('part_'), `${modCount}개`); + + for (const name of ['base_plate', 'turntable', 'support_frame', 'head_plate', 'projector']) { + await page.click(`.asm-mods button:has-text("+ ${name}")`); + } + check('부품 5개 추가됨', (await page.locator('.asm-part').count()) === 5); + // 부품 이름/분해 offset 지정 + const names = ['베이스 플레이트', '턴테이블', '지지 프레임', '헤드 플레이트', '프로젝터']; + const offs = [-60, 40, 20, 120, 190]; + for (let i = 0; i < 5; i++) { + const row = page.locator('.asm-part').nth(i); + await row.locator('input[type=text]').first().fill(names[i]); + await row.locator('input.off').nth(2).fill(String(offs[i])); + } + await page.locator('.asm-part').first().locator('select').selectOption('베이스 플레이트'); // BOM 연결 + // 단계 3개 + const steps = [['베이스와 턴테이블', [0, 1], 'M4x10 볼트 | 4', '2.5mm 육각렌치'], ['기둥 세우기', [2], '', ''], ['헤드와 프로젝터', [3, 4], 'M3x8 볼트 x 8', '드라이버, 니퍼']] as const; + for (const [title, idxs, fast, tools] of steps) { + await page.click('#pane-assembly button:has-text("+ 단계 추가")'); + const st = page.locator('.asm-step').last(); + await st.locator('input[type=text]').first().fill(title); + await st.locator('textarea').first().fill(title + ' 단계 설명입니다.'); + for (const k of idxs) await st.locator('.asm-chip').nth(k).click(); + const st2 = page.locator('.asm-step').last(); + if (fast) await st2.locator('textarea').nth(1).fill(fast); + if (tools) await st2.locator('input[type=text]').nth(1).fill(tools); + } + check('단계 3개 + 부품 배정', (await page.locator('.asm-step').count()) === 3 && (await page.locator('.asm-step').first().locator('.asm-chip.on').count()) === 2); + await page.screenshot({ path: `${OUT}/asm-1-editor.png`, fullPage: false }); + + await page.click('#asm-render-btn'); + await page.waitForSelector('.asm-imgs figure', { timeout: 120000 }); + await page.waitForTimeout(500); + const figs = await page.locator('.asm-imgs figure').count(); + check('렌더: 완성+분해+단계 3 = 5장', figs === 5, `${figs}장`); + check('상태 표시(마지막 렌더)', (await page.locator('#asm-status').innerText()).includes('마지막 렌더')); + // 명세를 바꾸면 "명세 변경" 표시 + await page.locator('.asm-part').first().locator('input.off').nth(2).fill('-80'); + check('명세 변경 시 경고 표시', (await page.locator('#asm-status').innerText()).includes('명세 변경')); + await page.screenshot({ path: `${OUT}/asm-2-rendered.png`, fullPage: true }); + + // 작업 탭으로 가져오기 + await page.click('button:has-text("작업 탭으로 가져오기")'); + await page.click('.rb-tab-btn[data-tab=tasks]'); + check('STEP들이 작업 탭 "조립" 단계로', (await page.locator('.rb-phase-name').first().inputValue()) === '조립' && (await page.locator('.rb-task-row').count()) === 3); + + // 설명서 페이지 + await page.waitForTimeout(1200); // 저장 debounce + const page2 = await browser.newPage({ viewport: { width: 1000, height: 1300 } }); + page2.on('pageerror', (e: any) => errors.push('manual: ' + e.message)); + await page2.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} }); + await page2.goto(`${base}/html/assembly-manual.html?projectId=pa`); + await page2.waitForSelector('.step'); + check('설명서: STEP 3개+부품표 5행', (await page2.locator('.step').count()) === 3 && (await page2.locator('tbody tr').count()) >= 5); + check('설명서: 체결부품 합계(M4x10=4, M3x8=8)', (await page2.locator('body').innerText()).includes('M3x8 볼트') && (await page2.locator('body').innerText()).includes('8')); + check('설명서: BOM 상태(보유) 연결 표시', (await page2.locator('.badge.보유').count()) === 1); + check('설명서: 그림 로드됨', await page2.evaluate(() => [...document.images].every(i => i.complete && i.naturalWidth > 0))); + await page2.screenshot({ path: `${OUT}/asm-3-manual.png`, fullPage: true }); + await page2.pdf({ path: `${OUT}/asm-manual.pdf`, format: 'A4', printBackground: true }); + + console.log(results.join('\n')); + console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음'); + await browser.close(); server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); +})().catch(e => { console.error('HARNESS ERROR', e); process.exit(1); }); diff --git a/tests/workshop-assembly-routes.test.ts b/tests/workshop-assembly-routes.test.ts new file mode 100644 index 0000000..826fe3d --- /dev/null +++ b/tests/workshop-assembly-routes.test.ts @@ -0,0 +1,73 @@ +// 조립 설명서 라우트 통합(HTTP) — 임시 데이터 디렉토리 + 가짜 세션. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import http from 'http'; +import { spawnSync } from 'child_process'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'asmint-')); +process.env.SMALLCLAW_DATA_DIR = tmp; +const hasTools = spawnSync('openscad', ['--version']).status === 0 && spawnSync('python3', ['-c', 'import PIL, numpy']).status === 0; + +test('조립 설명서 라우트: 저장→스캔→렌더→stale', { skip: !hasTools && 'openscad/PIL 없음' }, async () => { + const { registerWorkshopRoutes } = await import('../src/gateway/routes/routes-workshop'); + const app = express(); + app.use(express.json()); + const isInside = (b: string, t: string) => path.resolve(t).startsWith(path.resolve(b) + path.sep); + registerWorkshopRoutes(app, () => ({ username: 'tester' }), isInside); + const server = http.createServer(app); + await new Promise(r => server.listen(0, r)); + const base = `http://127.0.0.1:${(server.address() as any).port}`; + const j = async (m: string, u: string, body?: any) => { + const r = await fetch(base + u, { method: m, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); + return { status: r.status, body: (r.headers.get('content-type') || '').includes('json') ? await r.json() : null }; + }; + const ws = path.join(tmp, '.smallclaw', 'users', 'tester', 'workspace'); + fs.mkdirSync(path.join(ws, 'workshop/pa/CAD'), { recursive: true }); + const scadPath = path.join(ws, 'workshop/pa/CAD/m.scad'); + fs.writeFileSync(scadPath, 'module base(){ color("gray") cube([40,40,5]); }\nmodule top(){ color("red") translate([0,0,10]) cube([40,40,4]); }\nmodule part_x(){ cube(1); }\n'); + try { + const assembly = { scad: 'workshop/pa/CAD/m.scad', view: { rx: 60, rz: 35 }, explode: 1, + parts: [{ id: 'b', name: '베이스', call: 'base()', offset: [0, 0, -20] }, { id: 't', name: '상판', call: 'top()', offset: [0, 0, 30] }], + steps: [{ title: '베이스', partIds: ['b'] }, { title: '상판', partIds: ['t'], fasteners: [{ name: 'M3', qty: 4 }] }] }; + let r = await j('PUT', '/api/workshop/project/pa', { id: 'pa', name: '조립 시험', parts: [], phases: [], notes: '', assembly }); + assert.equal(r.status, 200); + // 조회: 아직 렌더 전 + r = await j('GET', '/api/workshop/assembly/pa'); + assert.equal(r.status, 200); + assert.equal(r.body.rendered, false); + assert.equal(r.body.project.assembly.parts.length, 2); + // 스캔/scad 후보 + r = await j('GET', '/api/workshop/assembly-scan?path=' + encodeURIComponent('workshop/pa/CAD/m.scad')); + assert.deepEqual(r.body.modules.map((m: any) => m.name), ['base', 'top', 'part_x']); + assert.equal((await j('GET', '/api/workshop/assembly-scan?path=' + encodeURIComponent('../../etc/passwd'))).status, 400); + r = await j('GET', '/api/workshop/assembly-scad-files?projectId=pa'); + assert.deepEqual(r.body.files, ['workshop/pa/CAD/m.scad']); + // 렌더 + r = await j('POST', '/api/workshop/assembly/pa/render'); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.rendered, true); + assert.equal(r.body.stale, false); + assert.equal(r.body.images.steps.length, 2); + assert.match(r.body.images.overview, /^\/api\/files\/workshop\/pa\/%EC%A1%B0%EB%A6%BD%EC%84%A4%EB%AA%85%EC%84%9C\/overview\.png\?v=\d+$/); + // 그림 파일이 파일 탭(기존 /api/workshop/files)에도 보인다 + r = await j('GET', '/api/workshop/files?projectId=pa'); + assert.ok(r.body.files.some((f: any) => f.relPath === '조립설명서/step-01.png')); + // 모델 수정 → stale + fs.appendFileSync(scadPath, '// changed\n'); + r = await j('GET', '/api/workshop/assembly/pa'); + assert.equal(r.body.stale, true); + // 잘못된 명세는 렌더 422, 명세 없는 프로젝트는 400 + await j('PUT', '/api/workshop/project/pa?force=1', { id: 'pa', name: '조립 시험', parts: [], phases: [], notes: '', assembly: { ...assembly, parts: [{ id: 'b', name: 'x', call: 'nope()' }] } }); + assert.equal((await j('POST', '/api/workshop/assembly/pa/render')).status, 422); + await j('PUT', '/api/workshop/project/pb', { id: 'pb', name: '명세 없음', parts: [], phases: [], notes: '' }); + assert.equal((await j('POST', '/api/workshop/assembly/pb/render')).status, 400); + assert.equal((await j('GET', '/api/workshop/assembly/nope')).status, 404); + } finally { + await new Promise(r => server.close(r)); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/workshop-assembly.test.ts b/tests/workshop-assembly.test.ts new file mode 100644 index 0000000..a8d92d5 --- /dev/null +++ b/tests/workshop-assembly.test.ts @@ -0,0 +1,227 @@ +// 조립 설명서(2026-09-24): 명세 검증/정규화/스캔/카메라 맞춤 + 실제 OpenSCAD·PIL 렌더 통합 + 채팅 도구 액션. +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { + parseCall, normalizeAssembly, scanScadModules, buildWrapperScad, fitCamera, stepPartSets, unassignedParts, assemblyHash, ASSEMBLY_LIMITS, +} from '../src/gateway/routes/workshop-assembly'; +import { renderAssemblyManual, resolveScadPath, readManifest, currentAssemblyHash } from '../src/tools/workshop-assembly-render'; +import { workshopProjectTool } from '../src/tools/workshop-project'; +import { saveWorkshop, loadWorkshop, duplicateProject, normalizeProjectInput, projectToReadme } from '../src/gateway/routes/workshop-storage'; + +const SCAD = ` +module base(){ color("gray") cube([40,40,5]); } +module post(x){ color("blue") translate([x,0,5]) cube([5,5,30]); } +module top(){ color("red") translate([0,0,35]) cube([40,40,4]); } +module part_base(){ cube([40,40,5]); } +// module commented_out(){} +`; +const hasTools = spawnSync('openscad', ['--version']).status === 0 && spawnSync('python3', ['-c', 'import PIL, numpy']).status === 0; + +function tmpWs(): string { + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'asm-')); + fs.mkdirSync(path.join(ws, 'workshop/p1/CAD'), { recursive: true }); + fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/m.scad'), SCAD); + saveWorkshop(ws, { activeProjectId: 'p1', projects: [{ id: 'p1', name: 'P', parts: [{ id: 'b1', name: '베이스 판', qty: 1, unitPrice: 0, status: '보유', memo: '', links: [] }], phases: [], notes: '' }] }); + return ws; +} +const spec = () => ({ + scad: 'workshop/p1/CAD/m.scad', view: { rx: 60, rz: 35 }, explode: 1, + parts: [ + { id: 'base', name: '베이스', call: 'base()', offset: [0, 0, -30], bom: '베이스 판' }, + { id: 'p1', name: '기둥 A', call: 'post(0)', offset: [-20, 0, 10] }, + { id: 'p2', name: '기둥 B', call: 'post(35)', offset: [20, 0, 10] }, + { id: 'top', name: '상판', call: 'top()', offset: [0, 0, 60] }, + ], + steps: [ + { title: '베이스', desc: '바닥에 놓는다', partIds: ['base'], fasteners: [{ name: 'M3 나사', qty: 4 }], tools: ['드라이버'] }, + { title: '기둥', partIds: ['p1', 'p2'] }, + { title: '상판', partIds: ['top'] }, + ], +}); + +describe('call 검증', () => { + test('정상 호출', () => { + for (const c of ['base()', 'camera(80)', 'camera(-80 / 2)', 'post( x = 3, y = [1,2] )', 'm(1+2*3)']) assert.equal(parseCall(c).ok, true, c); + }); + test('임의 코드/파일 읽기/다중 문장/문자열은 거부', () => { + for (const c of ['import("/etc/passwd")', 'base(); cube(1)', 'base() { cube(1); }', 'x("a")', "x('a')", 'surface(file=1)', 'a(import(1))', 'text(3)', 'use(1)', 'base', '', 'a()//x', 'a(/*x*/)', 'a(\\)']) + assert.equal(parseCall(c).ok, false, c); + }); +}); + +describe('명세 정규화', () => { + test('id 정리, partIds는 존재하는 부품만, 중복 제거, 숫자 범위 제한', () => { + const r = normalizeAssembly({ ...spec(), steps: [{ title: '', partIds: ['base', 'base', 'nope'] }], explode: 99, view: { rx: 500, rz: 'x' } }); + assert.ok(r.ok); + if (r.ok) { + assert.deepEqual(r.assembly.steps[0].partIds, ['base']); + assert.equal(r.assembly.steps[0].title, '단계 1'); + assert.equal(r.assembly.explode, 5); + assert.equal(r.assembly.view.rx, 90); + assert.equal(r.assembly.view.rz, 35); + } + }); + test('strict는 잘못된 call 거부, lenient(대시보드 저장)는 미완성 행을 보존', () => { + const bad = { ...spec(), parts: [{ id: 'x', name: '', call: '' }] }; + assert.equal(normalizeAssembly(bad).ok, false); + const r = normalizeAssembly(bad, false); + assert.ok(r.ok); + }); + test('scad 경로: 절대경로/.. /.scad 아님 거부', () => { + for (const s of ['/etc/x.scad', '../x.scad', 'a/../../x.scad', 'a/b.txt']) assert.equal(normalizeAssembly({ ...spec(), scad: s }).ok, false, s); + }); + test('한도 초과 거부', () => { + assert.equal(normalizeAssembly({ ...spec(), parts: Array.from({ length: ASSEMBLY_LIMITS.parts + 1 }, (_, i) => ({ id: `a${i}`, call: 'base()' })) }).ok, false); + }); + test('프로젝트 PUT 정규화(normalizeProjectInput)에 assembly가 통합되고 없으면 제거', () => { + const base = { id: 'p', name: 'A', parts: [], phases: [] }; + const withA = normalizeProjectInput({ ...base, assembly: spec() }, 'p'); + assert.ok(withA.ok && withA.project.assembly && withA.project.assembly.parts.length === 4); + const without = normalizeProjectInput({ ...base }, 'p'); + assert.ok(without.ok && !('assembly' in without.project)); + // 미완성 행이 있어도 프로젝트 저장이 막히지 않는다 + assert.ok(normalizeProjectInput({ ...base, assembly: { scad: '', parts: [{ call: '' }], steps: [] } }, 'p').ok); + }); +}); + +describe('스캔/wrapper/카메라/단계 계산', () => { + test('scanScadModules: 모듈·매개변수·필수인자·프린트용 구분, 주석 처리된 module 제외', () => { + const mods = scanScadModules(SCAD); + assert.deepEqual(mods.map(m => m.name), ['base', 'post', 'top', 'part_base']); + const post = mods.find(m => m.name === 'post')!; + assert.deepEqual(post.requiredParams, ['x']); + assert.equal(mods.find(m => m.name === 'part_base')!.isPrintPart, true); + }); + test('buildWrapperScad: use + 호출, offset이 있으면 translate', () => { + const w = buildWrapperScad('/a/b c/m.scad', [{ call: 'base()' }, { call: 'post(3)', offset: [1, 2, 3] }]); + assert.equal(w, 'use \nbase();\ntranslate([1, 2, 3]) post(3);\n'); + }); + test('fitCamera: 카메라 문자열 형식과 큰 모델일수록 거리 증가', () => { + const small = fitCamera({ min: [0, 0, 0], max: [10, 10, 10] }, { rx: 60, rz: 35 }, { w: 1000, h: 750 }); + const big = fitCamera({ min: [0, 0, 0], max: [100, 100, 100] }, { rx: 60, rz: 35 }, { w: 1000, h: 750 }); + assert.match(small.camera, /^5,5,5,60,0,35,[\d.]+$/); + assert.ok(parseFloat(big.camera.split(',')[6]) > parseFloat(small.camera.split(',')[6]) * 5); + assert.ok(small.scale > big.scale); + }); + test('stepPartSets: 그때까지 붙은 부품 누적, 중복 배정은 처음 단계에서만 새 부품', () => { + const a = normalizeAssembly({ ...spec(), steps: [{ partIds: ['base'] }, { partIds: ['base', 'p1'] }, { partIds: [] }] }); + assert.ok(a.ok); + if (a.ok) { + const sets = stepPartSets(a.assembly); + assert.deepEqual(sets.map(s => s.added.map(p => p.id)), [['base'], ['p1'], []]); + assert.deepEqual(sets[2].before.map(p => p.id), ['base', 'p1']); + assert.deepEqual(unassignedParts(a.assembly).map(p => p.id), ['p2', 'top']); + } + }); + test('assemblyHash: scad 내용이나 명세가 바뀌면 달라진다', () => { + const a = (normalizeAssembly(spec()) as any).assembly; + const h1 = assemblyHash(a, SCAD); + assert.equal(assemblyHash(a, SCAD), h1); + assert.notEqual(assemblyHash(a, SCAD + ' '), h1); + assert.notEqual(assemblyHash({ ...a, view: { rx: 30, rz: 35 } }, SCAD), h1); + }); + test('duplicateProject: 조립 명세 id 재발급 + 단계의 partIds 매핑', () => { + const a = (normalizeAssembly(spec()) as any).assembly; + const copy = duplicateProject({ id: 'x', name: 'X', parts: [], phases: [], notes: '', assembly: a } as any, 'y'); + const ids = new Set(copy.assembly!.parts.map(p => p.id)); + assert.equal(ids.size, 4); + assert.ok(copy.assembly!.parts.every(p => !a.parts.some((o: any) => o.id === p.id))); + assert.ok(copy.assembly!.steps.every(s => s.partIds.every(id => ids.has(id)))); + assert.deepEqual(copy.assembly!.steps.map(s => s.partIds.length), [1, 2, 1]); + }); + test('resolveScadPath: 워크스페이스 밖/비-scad 거부', () => { + const ws = tmpWs(); + assert.equal(resolveScadPath(ws, 'workshop/p1/CAD/m.scad').ok, true); + assert.equal(resolveScadPath(ws, '../etc/passwd').ok, false); + assert.equal(resolveScadPath(ws, 'workshop/p1/CAD/none.scad').ok, false); + assert.equal(resolveScadPath(ws, 'workshop/p1/CAD').ok, false); + }); +}); + +describe('렌더 통합(OpenSCAD+PIL)', { skip: !hasTools && 'openscad/PIL 없음' }, () => { + const stat = (png: string) => { + const r = spawnSync('python3', ['-c', ` +import sys, numpy as np +from PIL import Image +a = np.asarray(Image.open(sys.argv[1]).convert('RGB')).astype(int) +H, W = a.shape[:2] +orange = int(((abs(a[...,0]-245)<6)&(abs(a[...,1]-158)<6)&(abs(a[...,2]-11)<6)).sum()) +edge = np.concatenate([a[0], a[-1], a[:,0], a[:,-1]]) +print(W, H, orange, int((edge.sum(axis=1) < 740).sum())) +`, png], { encoding: 'utf-8' }); + const [w, h, orange, edgeDark] = r.stdout.trim().split(' ').map(Number); + return { w, h, orange, edgeDark }; + }; + + test('전체 파이프라인: 완성/분해/단계 그림 생성, 풍선(주황) 존재, 가장자리에 모델이 안 잘림, 해시/manifest', async () => { + const ws = tmpWs(); + const a = (normalizeAssembly(spec()) as any).assembly; + const r = await renderAssemblyManual(ws, 'p1', a); + assert.ok(r.ok, JSON.stringify(r)); + if (!r.ok) return; + const dir = path.join(ws, 'workshop/p1/조립설명서'); + assert.deepEqual(fs.readdirSync(dir).sort(), ['exploded.png', 'manifest.json', 'overview.png', 'step-01.png', 'step-02.png', 'step-03.png']); + for (const f of ['overview.png', 'exploded.png', 'step-01.png', 'step-02.png', 'step-03.png']) { + const s = stat(path.join(dir, f)); + assert.ok(s.w <= 1000 && s.h <= 750 && s.w > 100 && s.h > 100, `${f}: ${s.w}x${s.h}`); + assert.ok(s.orange > 100, `${f}: 풍선/외곽선 주황 픽셀 ${s.orange}`); + assert.equal(s.edgeDark, 0, `${f}: 모델이 프레임 가장자리에 닿음(잘림)`); + } + const m = readManifest(ws, 'p1')!; + assert.equal(m.hash, r.hash); + assert.equal(currentAssemblyHash(ws, a), r.hash); + // scad 수정 → 해시 달라짐(stale 판정 근거) + fs.appendFileSync(path.join(ws, 'workshop/p1/CAD/m.scad'), '\n// changed\n'); + assert.notEqual(currentAssemblyHash(ws, a), r.hash); + assert.equal(fs.readdirSync(path.join(ws, '.smallclaw/workshop-assembly-tmp')).length, 0, '임시 파일 정리'); + }); + + test('단계를 줄여 다시 렌더하면 옛 step 그림 삭제, 없는 module/빈 명세는 명확한 오류', async () => { + const ws = tmpWs(); + const a = (normalizeAssembly(spec()) as any).assembly; + await renderAssemblyManual(ws, 'p1', a); + await renderAssemblyManual(ws, 'p1', { ...a, steps: a.steps.slice(0, 1) }); + assert.deepEqual(fs.readdirSync(path.join(ws, 'workshop/p1/조립설명서')).filter(f => f.startsWith('step-')), ['step-01.png']); + const bad = await renderAssemblyManual(ws, 'p1', { ...a, parts: [{ ...a.parts[0], call: 'nope()' }] }); + assert.equal(bad.ok, false); + assert.match((bad as any).error, /module nope/); + assert.equal((await renderAssemblyManual(ws, 'p1', { ...a, parts: [] })).ok, false); + assert.equal((await renderAssemblyManual(ws, 'p1', { ...a, scad: 'workshop/p1/CAD/nope.scad' })).ok, false); + }); +}); + +describe('workshop_project 조립 액션', () => { + const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws }); + test('scan_scad → set_assembly → get_assembly (+ 잘못된 입력 피드백)', async () => { + const ws = tmpWs(); + const scan = await run(ws, { action: 'scan_scad', scad_path: 'workshop/p1/CAD/m.scad' }); + assert.equal(scan.success, true); + assert.match(scan.stdout || '', /- post\(x\) ← 필수 인자 x/); + assert.match(scan.stdout || '', /part_base/); + assert.equal((await run(ws, { action: 'scan_scad', scad_path: '../x.scad' })).success, false); + + assert.equal((await run(ws, { action: 'set_assembly' })).success, false); + assert.equal((await run(ws, { action: 'set_assembly', assembly: { ...spec(), parts: [{ id: 'a', name: 'x', call: 'import("/etc/passwd")' }] } })).success, false); + const ok = await run(ws, { action: 'set_assembly', assembly: { ...spec(), parts: [...spec().parts, { id: 'z', name: '유령', call: 'ghost()' }] } }); + assert.equal(ok.success, true, ok.error); + assert.match(ok.stdout || '', /module ghost이\(가\) 없음/); + assert.match(ok.stdout || '', /어느 단계에도 없는 부품: 유령/); + const got = await run(ws, { action: 'get_assembly' }); + assert.match(got.stdout || '', /부품 5, 단계 3/); + assert.match(projectToReadme(loadWorkshop(ws).projects[0]), /조립 설명서: 부품 5개 · 단계 3개/); + }); + + test('render_assembly: 이미지 마크다운 반환(비전 임베드용) + 명세 없으면 오류', { skip: !hasTools && 'openscad/PIL 없음' }, async () => { + const ws = tmpWs(); + assert.equal((await run(ws, { action: 'render_assembly' })).success, false); + await run(ws, { action: 'set_assembly', assembly: spec() }); + const r = await run(ws, { action: 'render_assembly' }); + assert.equal(r.success, true, r.error); + assert.match(r.stdout || '', /!\[조립 완성\]\(\/api\/files\/workshop\/p1\/%EC%A1%B0%EB%A6%BD%EC%84%A4%EB%AA%85%EC%84%9C\/overview\.png\)/); + assert.match(r.stdout || '', /!\[분해도\]/); + }); +}); diff --git a/tests/workshop-review-fixes.test.ts b/tests/workshop-review-fixes.test.ts index 96e9780..e7cbef5 100644 --- a/tests/workshop-review-fixes.test.ts +++ b/tests/workshop-review-fixes.test.ts @@ -173,7 +173,7 @@ describe('workshop-app.html 저장 로직', () => { }, document: { getElementById: (id: string) => (id === 'conflict-banner' ? banner : el), activeElement: null }, window: { addEventListener() {} }, setTimeout, clearTimeout, JSON, console, escHtml: undefined, - authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, syncBudgetInput() {}, refreshChatForProject() {}, clearStlSelection() {}, + authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, syncBudgetInput() {}, refreshAssemblyPane() {}, refreshChatForProject() {}, clearStlSelection() {}, loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(), alert() {}, }; vm.createContext(ctx); diff --git a/web-ui/html/assembly-manual.html b/web-ui/html/assembly-manual.html new file mode 100644 index 0000000..79bdb07 --- /dev/null +++ b/web-ui/html/assembly-manual.html @@ -0,0 +1,159 @@ + + + + + +조립 설명서 + + + + +
+ ← 작업실 + + + + +
+
불러오는 중…
+ + + + diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index 70359ef..f495634 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -160,6 +160,28 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .rb-attach-grid img:hover{border-color:#f59e0b;} .rb-file-thumb img.stl-thumb{object-fit:contain;background:#2b2b2b;} .rb-hdr-check{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:3px;cursor:pointer;flex-shrink:0;} +/* ── 조립 설명서 탭 ── */ +.asm-sec{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:14px;} +.asm-sec-hdr{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;flex-wrap:wrap;} +.asm-sec-body{padding:10px 12px;} +.asm-row{display:flex;gap:6px;align-items:center;margin-bottom:6px;flex-wrap:wrap;} +.asm-row input[type=text],.asm-row input[type=number],.asm-row select,.asm-step textarea{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;} +.asm-row input:focus,.asm-row select:focus,.asm-step textarea:focus{border-color:#f59e0b;} +.asm-part .num,.asm-chip .num{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;border-radius:11px;background:#f59e0b;color:#fff;font-weight:700;font-size:11px;padding:0 5px;flex-shrink:0;} +.asm-part input.off{width:58px;} +.asm-step{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:var(--panel-2);} +.asm-step textarea{width:100%;box-sizing:border-box;min-height:54px;resize:vertical;margin-top:4px;line-height:1.5;} +.asm-chips{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0;} +.asm-chip{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:14px;padding:2px 10px 2px 4px;font-size:11px;cursor:pointer;background:var(--panel);user-select:none;} +.asm-chip.on{border-color:#f59e0b;background:rgba(245,158,11,.12);} +.asm-chip input{display:none;} +.asm-lbl{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-top:6px;} +.asm-imgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:8px;} +.asm-imgs figure{margin:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#fff;} +.asm-imgs img{display:block;width:100%;height:auto;} +.asm-imgs figcaption{font-size:10px;color:#666;text-align:center;padding:2px;} +.asm-warn{font-size:11px;color:#f59e0b;margin-top:6px;} +.asm-mods{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;} @media (max-width:900px){ .rb-budget-target input{width:90px;} .rb-ptable{font-size:11px;} @@ -318,6 +340,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
+ @@ -352,6 +375,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
+
+
@@ -472,6 +497,7 @@ let phases=[]; let notes=''; let overviewDesc=''; let budget=0; // 목표 예산(원, 0=미설정) +let assembly=null; // 조립 설명서 명세(없으면 null) — project.assembly와 같은 객체 let showArchived=false; try{showArchived=localStorage.getItem('ws_show_archived')==='1';}catch{} let conflict=null; // {id,server} — 다른 곳에서 먼저 수정돼 저장이 보류된 프로젝트 @@ -527,6 +553,7 @@ function loadLocalFromProject(){ notes=proj?(proj.notes||''):''; overviewDesc=proj?(proj.description||''):''; budget=proj?(Number(proj.budget)||0):0; + assembly=proj&&proj.assembly?proj.assembly:null; } function syncLocalToProject(){ @@ -537,6 +564,7 @@ function syncLocalToProject(){ proj.notes=notes; proj.description=overviewDesc; proj.budget=budget; + if(assembly)proj.assembly=assembly;else delete proj.assembly; if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id); } @@ -603,6 +631,7 @@ function refreshAllPanes(){ document.getElementById('overview-textarea').value=overviewDesc; syncBudgetInput(); updateBudget(); + refreshAssemblyPane(); } function switchProject(id){ @@ -751,6 +780,7 @@ async function softRefreshWorkshop(opts){ if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc; syncBudgetInput(); updateBudget(); + refreshAssemblyPane(); if(activeChanged){ // 채팅에서 set_active_project/create_project로 프로젝트가 바뀐 경우 — // switchProject와 같은 정리를 해줘야 한다. 안 그러면 옛 프로젝트에서 @@ -864,6 +894,7 @@ function switchTab(tab){ document.getElementById('pane-'+tab).classList.add('active'); if(tab==='equip')startEquipTimers();else stopEquipTimers(); if(tab==='files')loadFiles(); + if(tab==='assembly')refreshAssemblyPane(true); } // ── 장비 위젯(K2/밀링) ──────────────────────────────────────────────────────── @@ -1991,6 +2022,283 @@ async function clearChat(){ addMsg('system','새 대화가 시작되었습니다.'); } +// ── 조립 설명서 탭 ──────────────────────────────────────────────────────────── +// 명세(부품/단계)는 project.assembly에 들어 있어 저장/이력/충돌감지가 다른 데이터와 같다. 그림은 서버가 +// OpenSCAD로 렌더해 workshop//조립설명서/ 에 저장한다(설명서 페이지는 assembly-manual.html). +let asmInfo=null; // 서버 조회 결과(그림 URL, stale 등) +let asmScanMods=null; // 스캔한 .scad module 목록 +let asmScadFiles=[]; // 프로젝트 하위 .scad 후보 +let asmBusy=false; +let asmLoadSeq=0; +function asmPaneActive(){return document.getElementById('pane-assembly').classList.contains('active');} +let asmProjectId=''; +function refreshAssemblyPane(forceLoad){ + const pid=(currentProject()||{}).id||''; + if(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;} // 프로젝트가 바뀌면 그림 정보 초기화 + if(!asmPaneActive())return; + renderAssembly(); + if(forceLoad||asmInfo===null)loadAssemblyInfo(); +} +function ensureAssembly(){ + if(!assembly)assembly={scad:'',view:{rx:60,rz:35},explode:1,parts:[],steps:[]}; + return assembly; +} +async function loadAssemblyInfo(){ + const proj=currentProject(); + if(!proj)return; + const seq=++asmLoadSeq; + try{ + const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id),{headers:authH()}); + if(!r.ok)return; + const d=await r.json(); + if(seq!==asmLoadSeq)return; + asmInfo=d; + const fr=await fetch('/api/workshop/assembly-scad-files?projectId='+encodeURIComponent(proj.id),{headers:authH()}); + if(fr.ok)asmScadFiles=(await fr.json()).files||[]; + if(seq===asmLoadSeq&&asmPaneActive())renderAssemblyImages(); + }catch{} +} +function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();} + +function renderAssembly(){ + const pane=document.getElementById('pane-assembly'); + const a=assembly; + const bomNames=parts.map(p=>p.name).filter(Boolean); + const partRows=(a?a.parts:[]).map((p,i)=>` +
+ ${i+1} + + + 분해 + ${[0,1,2].map(k=>``).join('')} + + + + + +
`).join('')||'
부품이 없습니다. 위에서 .scad를 스캔해 모듈을 추가하거나 직접 추가하세요.
'; + const stepCards=(a?a.steps:[]).map((st,i)=>` +
+
+ STEP ${i+1} + + + + +
+ +
이번 단계에 붙는 부품
+
${a.parts.length?a.parts.map((p,k)=>``).join(''):'먼저 부품을 추가하세요'}
+
체결 부품 (한 줄에 하나: 이름 | 수량)
+ +
공구 (쉼표로 구분)
+
+
`).join('')||'
단계가 없습니다. "+ 단계 추가"를 누르세요.
'; + + pane.innerHTML=` +
+
📐 조립 모델(.scad) — 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.
+
+
+ + ${asmScadFiles.map(f=>` + +
+
+ 보는 각도 + + + 분해 간격 × + +
+
${asmScanHtml()}
+
+
+
+
🔩 부품 번호 = 순서 (그림의 풍선 번호) +
+
${partRows}
+
+
+
🪜 조립 순서 + +
+
${stepCards}
+
+
+
🖼 설명서 + + 📄 설명서 열기 / 인쇄
+
+
💬 채팅에 "조립 설명서 만들어줘"라고 하면 AI가 .scad를 읽고 부품/단계 초안을 잡아 줍니다.
+
+
+
`; + renderAsmStatus(); + renderAssemblyImages(); +} + +function renderAsmStatus(){ + const el=document.getElementById('asm-status'); + if(!el)return; + const a=assembly; + let t=''; + if(asmBusy)t='렌더링 중… (부품 수에 따라 10~60초)'; + else if(!a||!a.parts.length||!a.steps.length)t='부품과 단계를 채운 뒤 렌더링하세요'; + else if(!asmInfo||!asmInfo.rendered)t='아직 렌더링 안 됨'; + else t='마지막 렌더 '+new Date(asmInfo.renderedAt).toLocaleString('ko-KR')+''+(asmInfo.stale?' ⚠ 모델이 바뀜':'')+(asmInfo.specChanged?' ⚠ 명세 변경됨 — 다시 렌더링':''); + el.innerHTML=t; + const b=document.getElementById('asm-render-btn'); + if(b){b.disabled=asmBusy;b.textContent=asmBusy?'렌더링 중…':'🖼 그림 렌더링';} +} +function renderAssemblyImages(){ + renderAsmStatus(); + const box=document.getElementById('asm-imgs'); + if(!box)return; + if(!asmInfo||!asmInfo.images){box.innerHTML='';return;} + const im=asmInfo.images; + box.innerHTML='
' + +'
조립 완성
' + +(im.exploded?'
분해도
':'') + +im.steps.map((u,i)=>'
STEP '+(i+1)+'
').join('')+'
' + +(asmInfo.unassigned&&asmInfo.unassigned.length?'
⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'
':''); +} + +// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ── +function asmSet(k,v){ + const a=ensureAssembly(); + a[k]=k==='explode'?(Number(v)||1):v; + asmMarkChanged(); +} +function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();} +function asmFind(id){return ensureAssembly().parts.find(p=>p.id===id);} +function asmPart(id,k,v){ + const p=asmFind(id);if(!p)return; + if(k==='qty')p.qty=Math.max(1,Math.round(Number(v)||1)); + else if(k==='bom'){if(v)p.bom=v;else delete p.bom;} + else p[k]=v; + asmMarkChanged(); +} +function asmOffset(id,k,v){const p=asmFind(id);if(!p)return;p.offset[k]=Number(v)||0;asmMarkChanged();} +function asmAddPart(mod){ + const a=ensureAssembly(); + a.parts.push({id:genId('ap'),name:mod?mod.name:'',call:mod?mod.call:'',offset:[0,0,0],qty:1}); + renderAssembly();scheduleSave(); +} +function asmDelPart(id){ + const a=ensureAssembly(); + a.parts=a.parts.filter(p=>p.id!==id); + a.steps.forEach(s=>{s.partIds=s.partIds.filter(x=>x!==id);}); + renderAssembly();scheduleSave(); +} +function asmMovePart(id,dir){ + const a=ensureAssembly(); + const i=a.parts.findIndex(p=>p.id===id),j=i+dir; + if(i<0||j<0||j>=a.parts.length)return; + [a.parts[i],a.parts[j]]=[a.parts[j],a.parts[i]]; + renderAssembly();scheduleSave(); +} +function asmAddStep(){ + const a=ensureAssembly(); + a.steps.push({id:genId('as'),title:'',desc:'',partIds:[],fasteners:[],tools:[]}); + renderAssembly();scheduleSave(); +} +function asmDelStep(id){const a=ensureAssembly();a.steps=a.steps.filter(s=>s.id!==id);renderAssembly();scheduleSave();} +function asmMoveStep(id,dir){ + const a=ensureAssembly(); + const i=a.steps.findIndex(s=>s.id===id),j=i+dir; + if(i<0||j<0||j>=a.steps.length)return; + [a.steps[i],a.steps[j]]=[a.steps[j],a.steps[i]]; + renderAssembly();scheduleSave(); +} +function asmStepFind(id){return ensureAssembly().steps.find(s=>s.id===id);} +function asmStep(id,k,v){const s=asmStepFind(id);if(!s)return;s[k]=v;asmMarkChanged();} +function asmToggleStepPart(stepId,partId,on){ + const s=asmStepFind(stepId);if(!s)return; + s.partIds=on?[...new Set([...s.partIds,partId])]:s.partIds.filter(x=>x!==partId); + renderAssembly(); + scheduleSave(); +} +// "이름 | 수량" / "이름 × 수량" / "이름 x 수량" 한 줄씩 → [{name,qty}] +function asmFasteners(id,text){ + const s=asmStepFind(id);if(!s)return; + s.fasteners=String(text||'').split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{ + const m=/^(.*?)\s*(?:[|×]\s*|\s[xX*]\s+)(\d+)\s*$/.exec(l); + return m?{name:m[1].trim(),qty:Math.max(1,Number(m[2])||1)}:{name:l,qty:1}; + }).filter(f=>f.name); + asmMarkChanged(); +} +function asmTools(id,text){ + const s=asmStepFind(id);if(!s)return; + s.tools=String(text||'').split(',').map(t=>t.trim()).filter(Boolean); + asmMarkChanged(); +} + +async function asmScan(){ + const a=ensureAssembly(); + const out=document.getElementById('asm-scan-out'); + if(!a.scad){out.innerHTML='
.scad 경로를 먼저 입력하세요(입력칸을 누르면 프로젝트의 .scad 후보가 뜹니다).
';return;} + out.innerHTML='
스캔 중…
'; + try{ + const r=await fetch('/api/workshop/assembly-scan?path='+encodeURIComponent(a.scad),{headers:authH()}); + const d=await r.json(); + if(!r.ok)throw new Error(d.error||('HTTP '+r.status)); + asmScanMods=d.modules; + out.innerHTML=asmScanHtml(); + }catch(e){out.innerHTML='
스캔 실패: '+escHtml(e.message)+'
';} +} +// 스캔 결과는 부품을 추가해 패널이 다시 그려져도 남아 있어야 한다(연속으로 여러 모듈을 담는 흐름). +function asmScanHtml(){ + if(!asmScanMods)return ''; + const usable=asmScanMods.filter(m=>!m.isPrintPart); + return '
조립 좌표 모듈 — 눌러서 부품으로 추가 (part_* 는 프린트 배치용이라 제외)
' + +(usable.length?usable.map((m,i)=>'').join(''):'module이 없습니다.')+'
'; +} +function asmAddFromScan(i){ + const m=(asmScanMods||[]).filter(x=>!x.isPrintPart)[i]; + if(!m)return; + // 필수 인자가 있는 모듈은 0으로 채운 호출을 만들어 두고 사용자가 고치게 한다(예: camera(0) → camera(80)). + asmAddPart({name:m.name,call:m.name+'('+m.requiredParams.map(()=>'0').join(', ')+')'}); +} + +async function asmRender(){ + const a=assembly; + if(!a||!a.parts.length||!a.steps.length){alert('부품과 단계를 먼저 채워 주세요.');return;} + if(asmBusy)return; + const proj=currentProject(); + if(!proj)return; + asmBusy=true;renderAsmStatus(); + try{ + await flushNow(); // 명세 저장이 끝난 뒤 렌더(서버는 저장된 명세로 그린다) + if(conflict)throw new Error('저장 충돌을 먼저 해결하세요.'); + const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id)+'/render',{method:'POST',headers:authH()}); + const d=await r.json().catch(()=>({})); + if(!r.ok)throw new Error(d.error||('HTTP '+r.status)); + asmInfo=d; + if(d.warnings&&d.warnings.length)alert(d.warnings.join('\n')); + }catch(e){alert('렌더 실패: '+e.message);} + finally{asmBusy=false;renderAssemblyImages();} +} + +// STEP들을 작업 탭의 "조립" 단계로 추가(이미 같은 문구가 있으면 건너뜀) +function asmStepsToTasks(){ + const a=assembly; + if(!a||!a.steps.length){alert('가져올 단계가 없습니다.');return;} + let ph=phases.find(p=>p.name==='조립'); + if(!ph){ph={id:genId('ph'),name:'조립',tasks:[]};phases.push(ph);} + let added=0; + a.steps.forEach((s,i)=>{ + const text='STEP '+(i+1)+': '+(s.title||'(제목 없음)'); + if(!ph.tasks.some(t=>t.text===text)){ph.tasks.push({id:genId('t'),text,done:false});added++;} + }); + renderPhases();scheduleSave(); + alert(added?('작업 탭의 "조립" 단계에 '+added+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.'); +} + // ── 사진 첨부(📷) ───────────────────────────────────────────────────────────── // 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면 // workshop_project view_image로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).