feat: 작업실 "조립 설명서" — .scad에서 단계별 조립도·분해도·부품표를 자동 생성
- 명세(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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>(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<string>();
|
||||
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));
|
||||
}
|
||||
@@ -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/<id>/조립설명서/ 에 렌더된다. */
|
||||
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로 교체한다(따옴표가 든 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<string, string>();
|
||||
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('');
|
||||
|
||||
@@ -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<void> {
|
||||
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<unknown> = 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<RenderResult | { ok: false; error: string }> {
|
||||
const run = renderChain.then(() => doRender(workspace, projectId, assembly));
|
||||
renderChain = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function doRender(workspace: string, projectId: string, assembly: Assembly): Promise<RenderResult | { ok: false; error: string }> {
|
||||
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; }
|
||||
}
|
||||
@@ -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` };
|
||||
}
|
||||
|
||||
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' : '') +
|
||||
`})\n})\n` +
|
||||
`그림을 확인해 부품 배치/번호/단계 순서가 이상하면 set_assembly로 고쳐 다시 렌더할 것. 사용자는 대시보드의 "조립" 탭에서 전체 설명서를 보고 인쇄할 수 있다.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: `알 수 없는 action: ${action}` };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<void>(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); });
|
||||
@@ -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<void>(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 });
|
||||
}
|
||||
});
|
||||
@@ -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 </a/b c/m.scad>\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 || '', /!\[분해도\]/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>조립 설명서</title>
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||||
<style>
|
||||
/* 문서 페이지 — 테마와 무관하게 항상 밝은 배경(인쇄/PDF 용). */
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;background:#eceff3;color:#1f2933;font-family:system-ui,-apple-system,'Noto Sans KR','Malgun Gothic',sans-serif;}
|
||||
.toolbar{position:sticky;top:0;z-index:10;display:flex;gap:10px;align-items:center;padding:8px 16px;background:#1f2933;color:#fff;font-size:13px;}
|
||||
.toolbar a,.toolbar button{color:#fff;text-decoration:none;background:none;border:1px solid rgba(255,255,255,.35);border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit;}
|
||||
.toolbar a:hover,.toolbar button:hover{background:rgba(255,255,255,.15);}
|
||||
.toolbar .sp{flex:1;}
|
||||
.toolbar .stale{color:#fbbf24;font-size:12px;}
|
||||
.sheet{max-width:920px;margin:18px auto;background:#fff;padding:36px 44px;box-shadow:0 2px 14px rgba(0,0,0,.12);}
|
||||
h1{font-size:26px;margin:0 0 4px;}
|
||||
.meta{color:#6b7785;font-size:12px;margin-bottom:18px;}
|
||||
.desc{font-size:13px;line-height:1.7;margin:0 0 18px;white-space:pre-wrap;}
|
||||
h2{font-size:15px;margin:26px 0 10px;padding-bottom:5px;border-bottom:2px solid #f59e0b;}
|
||||
.cover-imgs{display:grid;grid-template-columns:1fr 1fr;gap:10px;align-items:start;}
|
||||
.cover-imgs figure{margin:0;border:1px solid #e1e5ea;border-radius:8px;overflow:hidden;}
|
||||
.cover-imgs img{display:block;width:100%;height:auto;}
|
||||
.step-img{display:flex;align-items:center;justify-content:center;}
|
||||
.step-img img{display:block;max-width:100%;max-height:440px;width:auto;height:auto;}
|
||||
figcaption{font-size:11px;color:#6b7785;text-align:center;padding:4px 0;border-top:1px solid #e1e5ea;background:#fafbfc;}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px;}
|
||||
th,td{border:1px solid #d5dae0;padding:5px 9px;text-align:left;vertical-align:top;}
|
||||
th{background:#f3f5f7;font-weight:700;font-size:11.5px;}
|
||||
td.n{width:44px;text-align:center;}
|
||||
td.q{width:60px;text-align:center;}
|
||||
.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:12px;padding:0 5px;}
|
||||
.badge{font-size:11px;padding:1px 8px;border-radius:9px;background:#e5e9ee;color:#4b5866;}
|
||||
.badge.보유{background:#d9f5e3;color:#1e7a45;}
|
||||
.badge.주문완료{background:#dbeafe;color:#1d4ed8;}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
|
||||
.step{border:1px solid #d5dae0;border-radius:10px;margin:14px 0;overflow:hidden;break-inside:avoid;page-break-inside:avoid;}
|
||||
.step-hdr{display:flex;align-items:center;gap:10px;background:#1f2933;color:#fff;padding:8px 14px;}
|
||||
.step-hdr .no{background:#f59e0b;border-radius:6px;padding:2px 10px;font-weight:800;font-size:13px;}
|
||||
.step-hdr .title{font-weight:700;font-size:15px;flex:1;}
|
||||
.step-hdr .chk{width:20px;height:20px;border:2px solid #fff;border-radius:4px;background:transparent;flex-shrink:0;}
|
||||
.step-body{display:grid;grid-template-columns:1.25fr 1fr;gap:0;}
|
||||
.step-img{border-right:1px solid #e1e5ea;background:#fff;}
|
||||
.step-info{padding:12px 14px;font-size:12.5px;line-height:1.65;}
|
||||
.step-info h4{margin:12px 0 4px;font-size:11px;color:#6b7785;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.step-info h4:first-child{margin-top:0;}
|
||||
.step-info .txt{white-space:pre-wrap;}
|
||||
.chips{display:flex;flex-direction:column;gap:4px;}
|
||||
.chip{display:flex;align-items:center;gap:7px;}
|
||||
ul.plain{margin:0;padding-left:18px;}
|
||||
.empty{padding:40px;text-align:center;color:#6b7785;font-size:14px;}
|
||||
.note{font-size:11.5px;color:#8a5a00;background:#fff7e6;border:1px solid #f5d9a3;border-radius:6px;padding:6px 10px;margin:10px 0;}
|
||||
@media (max-width:720px){.sheet{padding:18px 14px}.cover-imgs,.two,.step-body{grid-template-columns:1fr}.step-img{border-right:none;border-bottom:1px solid #e1e5ea}}
|
||||
@page{size:A4;margin:12mm;}
|
||||
@media print{
|
||||
html,body{background:#fff;}
|
||||
.toolbar{display:none;}
|
||||
.sheet{box-shadow:none;margin:0;max-width:none;padding:0;}
|
||||
h2{break-after:avoid;}
|
||||
.step{margin:10px 0;}
|
||||
.step-hdr,.num,.badge,th{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar no-print">
|
||||
<a id="back" href="/html/workshop-app.html">← 작업실</a>
|
||||
<span id="title-mini" style="font-weight:700"></span>
|
||||
<span class="sp"></span>
|
||||
<span class="stale" id="stale" style="display:none">⚠ 설명서 그림이 현재 명세/모델과 다릅니다 — 작업실 "조립" 탭에서 다시 렌더하세요</span>
|
||||
<button onclick="window.print()">🖨 인쇄 / PDF 저장</button>
|
||||
</div>
|
||||
<div class="sheet" id="sheet"><div class="empty">불러오는 중…</div></div>
|
||||
|
||||
<script>
|
||||
const TOKEN_KEY='smallclaw_token';
|
||||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||||
function authH(){const t=getToken();return t?{Authorization:'Bearer '+t}:{};}
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
||||
|
||||
async function main(){
|
||||
const projectId=new URLSearchParams(location.search).get('projectId')||'';
|
||||
document.getElementById('back').href='/html/workshop-app.html';
|
||||
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname+location.search);return;}
|
||||
const sheet=document.getElementById('sheet');
|
||||
let d;
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(projectId),{headers:authH()});
|
||||
if(r.status===401){location.href='/login.html?redirect='+encodeURIComponent(location.pathname+location.search);return;}
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
d=await r.json();
|
||||
}catch(e){sheet.innerHTML='<div class="empty">설명서를 불러오지 못했습니다: '+esc(e.message)+'</div>';return;}
|
||||
|
||||
const proj=d.project,a=proj.assembly;
|
||||
document.title=proj.name+' 조립 설명서';
|
||||
document.getElementById('title-mini').textContent=proj.name+' — 조립 설명서';
|
||||
if(!a||!a.steps.length){sheet.innerHTML='<div class="empty">아직 조립 설명서 명세가 없습니다.<br>작업실의 "조립" 탭에서 부품과 단계를 만들거나, 채팅에 "조립 설명서 만들어줘"라고 해 보세요.</div>';return;}
|
||||
if(d.stale)document.getElementById('stale').style.display='inline';
|
||||
|
||||
const bomStatus=name=>{
|
||||
if(!name)return '';
|
||||
const b=(proj.parts||[]).find(p=>String(p.name).trim().toLowerCase()===String(name).trim().toLowerCase());
|
||||
return b?'<span class="badge '+esc(b.status)+'">'+esc(b.status)+'</span>':'<span class="badge">BOM 미연결</span>';
|
||||
};
|
||||
const numOf=new Map(a.parts.map((p,i)=>[p.id,i+1]));
|
||||
const stepsOfPart=new Map();
|
||||
a.steps.forEach((s,i)=>s.partIds.forEach(id=>{if(!stepsOfPart.has(id))stepsOfPart.set(id,[]);stepsOfPart.get(id).push(i+1);}));
|
||||
|
||||
// 체결 부품 합계 / 공구 합집합
|
||||
const fast=new Map();const tools=new Set();
|
||||
a.steps.forEach(s=>{s.fasteners.forEach(f=>fast.set(f.name,(fast.get(f.name)||0)+f.qty));s.tools.forEach(t=>tools.add(t));});
|
||||
|
||||
let h='<h1>'+esc(proj.name)+' 조립 설명서</h1>';
|
||||
h+='<div class="meta">부품 '+a.parts.length+'개 · 조립 '+a.steps.length+'단계'+(d.renderedAt?' · 그림 생성 '+new Date(d.renderedAt).toLocaleString('ko-KR'):'')+'</div>';
|
||||
if(proj.description)h+='<p class="desc">'+esc(proj.description.length>600?proj.description.slice(0,600)+'…':proj.description)+'</p>';
|
||||
if(!d.images)h+='<div class="note">그림이 아직 렌더되지 않았습니다. 작업실 "조립" 탭에서 🖼 렌더링을 눌러 주세요.</div>';
|
||||
if(d.images){
|
||||
h+='<div class="cover-imgs"><figure><img src="'+esc(d.images.overview)+'" alt="조립 완성"><figcaption>조립 완성</figcaption></figure>'
|
||||
+(d.images.exploded?'<figure><img src="'+esc(d.images.exploded)+'" alt="분해도"><figcaption>분해도 (번호 = 부품 목록)</figcaption></figure>':'')+'</div>';
|
||||
}
|
||||
if(d.stale&&d.images)h+='<div class="note">이 그림은 명세/모델이 바뀌기 전 상태일 수 있습니다.</div>';
|
||||
|
||||
h+='<h2>부품 목록</h2><table><thead><tr><th>번호</th><th>부품</th><th>수량</th><th>구매 상태(BOM)</th><th>조립 단계</th></tr></thead><tbody>';
|
||||
a.parts.forEach((p,i)=>{
|
||||
h+='<tr><td class="n"><span class="num">'+(i+1)+'</span></td><td>'+esc(p.name)+(p.bom&&p.bom!==p.name?'<div style="color:#6b7785;font-size:11px">BOM: '+esc(p.bom)+'</div>':'')+'</td><td class="q">'+p.qty+'</td><td>'+bomStatus(p.bom)+'</td><td>'
|
||||
+((stepsOfPart.get(p.id)||[]).map(n=>'STEP '+n).join(', ')||'<span style="color:#b45309">미배정</span>')+'</td></tr>';
|
||||
});
|
||||
h+='</tbody></table>';
|
||||
|
||||
if(fast.size||tools.size){
|
||||
h+='<h2>준비물</h2><div class="two">';
|
||||
h+='<div><table><thead><tr><th>체결 부품(전체 합계)</th><th style="width:60px">수량</th></tr></thead><tbody>'
|
||||
+(fast.size?[...fast].map(([n,q])=>'<tr><td>'+esc(n)+'</td><td class="q">'+q+'</td></tr>').join(''):'<tr><td colspan="2" style="color:#6b7785">없음</td></tr>')+'</tbody></table></div>';
|
||||
h+='<div><table><thead><tr><th>공구</th></tr></thead><tbody>'
|
||||
+(tools.size?[...tools].map(t=>'<tr><td>'+esc(t)+'</td></tr>').join(''):'<tr><td style="color:#6b7785">없음</td></tr>')+'</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
h+='<h2>조립 순서</h2>';
|
||||
a.steps.forEach((s,i)=>{
|
||||
const img=d.images&&d.images.steps[i];
|
||||
h+='<div class="step"><div class="step-hdr"><span class="no">STEP '+(i+1)+'</span><span class="title">'+esc(s.title)+'</span><span class="chk" title="완료 체크"></span></div><div class="step-body">';
|
||||
h+='<div class="step-img">'+(img?'<img src="'+esc(img)+'" alt="STEP '+(i+1)+'">':'<div class="empty" style="padding:60px 10px">그림 없음</div>')+'</div>';
|
||||
h+='<div class="step-info">';
|
||||
if(s.desc)h+='<h4>작업 내용</h4><div class="txt">'+esc(s.desc)+'</div>';
|
||||
if(s.partIds.length){
|
||||
h+='<h4>이번 단계 부품</h4><div class="chips">'+s.partIds.map(id=>{const p=a.parts.find(x=>x.id===id);return p?'<div class="chip"><span class="num">'+numOf.get(id)+'</span>'+esc(p.name)+(p.qty>1?' × '+p.qty:'')+'</div>':'';}).join('')+'</div>';
|
||||
}
|
||||
if(s.fasteners.length)h+='<h4>체결 부품</h4><ul class="plain">'+s.fasteners.map(f=>'<li>'+esc(f.name)+' × '+f.qty+'</li>').join('')+'</ul>';
|
||||
if(s.tools.length)h+='<h4>공구</h4><ul class="plain">'+s.tools.map(t=>'<li>'+esc(t)+'</li>').join('')+'</ul>';
|
||||
h+='</div></div></div>';
|
||||
});
|
||||
sheet.innerHTML=h;
|
||||
}
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
</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
|
||||
<div class="rb-tabs">
|
||||
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
|
||||
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
|
||||
<button class="rb-tab-btn" data-tab="assembly" onclick="switchTab('assembly')">조립</button>
|
||||
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
|
||||
<button class="rb-tab-btn" data-tab="files" onclick="switchTab('files')">파일</button>
|
||||
<button class="rb-tab-btn" data-tab="overview" onclick="switchTab('overview')">개요</button>
|
||||
@@ -352,6 +375,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="rb-pane" id="pane-assembly"></div>
|
||||
|
||||
<div class="rb-pane" id="pane-equip">
|
||||
<div class="rb-equip-grid">
|
||||
<div class="rb-equip-card">
|
||||
@@ -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/<id>/조립설명서/ 에 저장한다(설명서 페이지는 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)=>`
|
||||
<div class="asm-row asm-part" data-id="${escAttr(p.id)}">
|
||||
<span class="num">${i+1}</span>
|
||||
<input type="text" style="flex:1;min-width:110px" placeholder="부품 이름" value="${escAttr(p.name)}" oninput="asmPart('${p.id}','name',this.value)">
|
||||
<input type="text" style="flex:1.2;min-width:120px;font-family:monospace" placeholder="모듈 호출 예: camera(80)" value="${escAttr(p.call)}" oninput="asmPart('${p.id}','call',this.value)" title=".scad의 조립 좌표 모듈 호출">
|
||||
<span style="font-size:10px;color:var(--muted)">분해</span>
|
||||
${[0,1,2].map(k=>`<input type="number" class="off" title="${'XYZ'[k]} 방향으로 띄울 거리(mm)" value="${escAttr(p.offset[k])}" oninput="asmOffset('${p.id}',${k},this.value)">`).join('')}
|
||||
<select title="대시보드 BOM 부품과 연결(구매 상태 표시)" onchange="asmPart('${p.id}','bom',this.value)">
|
||||
<option value="">BOM 연결 없음</option>
|
||||
${bomNames.map(n=>`<option value="${escAttr(n)}" ${p.bom===n?'selected':''}>${escAttr(n)}</option>`).join('')}
|
||||
${p.bom&&!bomNames.includes(p.bom)?`<option value="${escAttr(p.bom)}" selected>${escAttr(p.bom)}</option>`:''}
|
||||
</select>
|
||||
<input type="number" min="1" style="width:52px" title="수량" value="${escAttr(p.qty)}" oninput="asmPart('${p.id}','qty',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',-1)" title="위로(번호 변경)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',1)" title="아래로(번호 변경)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelPart('${p.id}')">✕</button>
|
||||
</div>`).join('')||'<div class="rb-empty">부품이 없습니다. 위에서 .scad를 스캔해 모듈을 추가하거나 직접 추가하세요.</div>';
|
||||
const stepCards=(a?a.steps:[]).map((st,i)=>`
|
||||
<div class="asm-step" data-id="${escAttr(st.id)}">
|
||||
<div class="asm-row" style="margin-bottom:2px">
|
||||
<b style="color:#f59e0b">STEP ${i+1}</b>
|
||||
<input type="text" style="flex:1;min-width:140px" placeholder="단계 제목" value="${escAttr(st.title)}" oninput="asmStep('${st.id}','title',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',-1)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',1)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelStep('${st.id}')">✕</button>
|
||||
</div>
|
||||
<textarea placeholder="작업 내용(어떻게 끼우고 조이는지)" oninput="asmStep('${st.id}','desc',this.value)">${escAttr(st.desc)}</textarea>
|
||||
<div class="asm-lbl">이번 단계에 붙는 부품</div>
|
||||
<div class="asm-chips">${a.parts.length?a.parts.map((p,k)=>`<label class="asm-chip${st.partIds.includes(p.id)?' on':''}"><input type="checkbox" ${st.partIds.includes(p.id)?'checked':''} onchange="asmToggleStepPart('${st.id}','${p.id}',this.checked)"><span class="num">${k+1}</span>${escAttr(p.name)}</label>`).join(''):'<span class="rb-empty">먼저 부품을 추가하세요</span>'}</div>
|
||||
<div class="asm-lbl">체결 부품 (한 줄에 하나: 이름 | 수량)</div>
|
||||
<textarea style="min-height:38px" placeholder="M4x10 볼트 | 4" oninput="asmFasteners('${st.id}',this.value)">${escAttr((st.fasteners||[]).map(f=>f.name+' | '+f.qty).join('\n'))}</textarea>
|
||||
<div class="asm-lbl">공구 (쉼표로 구분)</div>
|
||||
<div class="asm-row"><input type="text" style="flex:1" placeholder="2.5mm 육각렌치, 니퍼" value="${escAttr((st.tools||[]).join(', '))}" oninput="asmTools('${st.id}',this.value)"></div>
|
||||
</div>`).join('')||'<div class="rb-empty">단계가 없습니다. "+ 단계 추가"를 누르세요.</div>';
|
||||
|
||||
pane.innerHTML=`
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">📐 조립 모델(.scad) <span style="font-weight:400;color:var(--muted)">— 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.</span></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="asm-row">
|
||||
<input type="text" id="asm-scad" list="asm-scad-list" style="flex:1;min-width:220px" placeholder="workshop/<프로젝트id>/CAD/xxx.scad" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
|
||||
<datalist id="asm-scad-list">${asmScadFiles.map(f=>`<option value="${escAttr(f)}">`).join('')}</datalist>
|
||||
<button class="rb-mini-btn" onclick="asmScan()">🔍 모듈 스캔</button>
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<span style="font-size:11px;color:var(--muted)">보는 각도</span>
|
||||
<input type="number" style="width:64px" title="위에서 내려다보는 각도(0~90)" value="${escAttr(a?a.view.rx:60)}" oninput="asmView('rx',this.value)">
|
||||
<input type="number" style="width:64px" title="회전 각도" value="${escAttr(a?a.view.rz:35)}" oninput="asmView('rz',this.value)">
|
||||
<span style="font-size:11px;color:var(--muted);margin-left:8px">분해 간격 ×</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5" style="width:64px" value="${escAttr(a?a.explode:1)}" oninput="asmSet('explode',this.value)">
|
||||
</div>
|
||||
<div id="asm-scan-out">${asmScanHtml()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🔩 부품 <span style="font-weight:400;color:var(--muted)">번호 = 순서 (그림의 풍선 번호)</span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmAddPart()">+ 부품 추가</button></div>
|
||||
<div class="asm-sec-body">${partRows}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🪜 조립 순서<span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmStepsToTasks()" title="STEP들을 '작업' 탭에 조립 단계로 추가">→ 작업 탭으로 가져오기</button>
|
||||
<button class="rb-mini-btn" onclick="asmAddStep()">+ 단계 추가</button></div>
|
||||
<div class="asm-sec-body">${stepCards}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🖼 설명서 <span id="asm-status" style="font-weight:400"></span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-add-btn" style="margin:0" id="asm-render-btn" onclick="asmRender()">🖼 그림 렌더링</button>
|
||||
<a class="rb-mini-btn" style="text-decoration:none" id="asm-open" href="/html/assembly-manual.html?projectId=${encodeURIComponent((currentProject()||{}).id||'')}" target="_blank">📄 설명서 열기 / 인쇄</a></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="rb-empty" style="padding:0 0 6px">💬 채팅에 <b>"조립 설명서 만들어줘"</b>라고 하면 AI가 .scad를 읽고 부품/단계 초안을 잡아 줍니다.</div>
|
||||
<div id="asm-imgs"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
renderAsmStatus();
|
||||
renderAssemblyImages();
|
||||
}
|
||||
|
||||
function renderAsmStatus(){
|
||||
const el=document.getElementById('asm-status');
|
||||
if(!el)return;
|
||||
const a=assembly;
|
||||
let t='';
|
||||
if(asmBusy)t='<span style="color:#f59e0b">렌더링 중… (부품 수에 따라 10~60초)</span>';
|
||||
else if(!a||!a.parts.length||!a.steps.length)t='<span style="color:var(--muted)">부품과 단계를 채운 뒤 렌더링하세요</span>';
|
||||
else if(!asmInfo||!asmInfo.rendered)t='<span style="color:var(--muted)">아직 렌더링 안 됨</span>';
|
||||
else t='<span style="color:var(--muted)">마지막 렌더 '+new Date(asmInfo.renderedAt).toLocaleString('ko-KR')+'</span>'+(asmInfo.stale?' <span style="color:#f59e0b">⚠ 모델이 바뀜</span>':'')+(asmInfo.specChanged?' <span style="color:#f59e0b">⚠ 명세 변경됨 — 다시 렌더링</span>':'');
|
||||
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='<div class="asm-imgs">'
|
||||
+'<figure><img src="'+escAttr(im.overview)+'" loading="lazy"><figcaption>조립 완성</figcaption></figure>'
|
||||
+(im.exploded?'<figure><img src="'+escAttr(im.exploded)+'" loading="lazy"><figcaption>분해도</figcaption></figure>':'')
|
||||
+im.steps.map((u,i)=>'<figure><img src="'+escAttr(u)+'" loading="lazy"><figcaption>STEP '+(i+1)+'</figcaption></figure>').join('')+'</div>'
|
||||
+(asmInfo.unassigned&&asmInfo.unassigned.length?'<div class="asm-warn">⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'</div>':'');
|
||||
}
|
||||
|
||||
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 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='<div class="asm-warn">.scad 경로를 먼저 입력하세요(입력칸을 누르면 프로젝트의 .scad 후보가 뜹니다).</div>';return;}
|
||||
out.innerHTML='<div class="rb-empty">스캔 중…</div>';
|
||||
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='<div class="asm-warn">스캔 실패: '+escHtml(e.message)+'</div>';}
|
||||
}
|
||||
// 스캔 결과는 부품을 추가해 패널이 다시 그려져도 남아 있어야 한다(연속으로 여러 모듈을 담는 흐름).
|
||||
function asmScanHtml(){
|
||||
if(!asmScanMods)return '';
|
||||
const usable=asmScanMods.filter(m=>!m.isPrintPart);
|
||||
return '<div class="asm-lbl">조립 좌표 모듈 — 눌러서 부품으로 추가 (part_* 는 프린트 배치용이라 제외)</div><div class="asm-mods">'
|
||||
+(usable.length?usable.map((m,i)=>'<button class="rb-mini-btn" onclick="asmAddFromScan('+i+')" title="'+escAttr(m.params.join(', '))+'">+ '+escHtml(m.name)+(m.requiredParams.length?'('+escHtml(m.requiredParams.join(', '))+')':'()')+'</button>').join(''):'<span class="rb-empty">module이 없습니다.</span>')+'</div>';
|
||||
}
|
||||
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로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).
|
||||
|
||||
Reference in New Issue
Block a user