feat: 조립 설명서 .scad 자동 연동 — 자동 연결·자동 초안·변경 시 자동 재렌더
- 자동 연결: 조립 탭을 열면 명세가 비어 있을 때 프로젝트에서 가장 최근 수정한 .scad를 자동 연결
- 자동 초안: .scad의 조립 블록(if (PART == "all") { … } 또는 최상위)이 호출하는 모듈을 부품으로 삼고,
변수 인자(camera(+CAM_OFFSET))는 원본을 include한 probe로 값(80)으로 풀고, 부품별 bbox로 분해 offset
(수평 중심 바깥 0.5배·수직 윗쪽 높이 기준 0.9배+층 간 여유)와 조립 순서(.scad에 나열된 호출 순서, 같은
모듈은 첫 등장 위치에 한 단계)를 정한다. %/* 수식자·변환 붙은 호출·이름에 ghost/fov/axis/plane…·isGhost=true
인자는 고스트로 제외하고 안내. 자동 흐름은 조립 블록이 있는 파일만 채워 데모/애니메이션 파일로 엉뚱한
명세가 만들어지지 않게 한다(수동 ✨ 버튼은 허용)
- 자동 재렌더: write_scad가 .scad를 바꾸면 그 파일을 쓰는(렌더한 적 있는) 프로젝트 그림을 백그라운드로
갱신(프로젝트별 합치기), 조립 탭을 열었을 때 stale이면 자동 재렌더. autoRender 체크박스로 끌 수 있음
- 채팅 도구 draft_assembly(apply=true면 저장), 라우트 POST /api/workshop/assembly-draft
- 테스트: 파싱/이름/실행(OpenSCAD)/자동 재렌더/도구 + 실브라우저 자동 연동 시나리오
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', 'set_assembly', 'render_assembly',
|
||||
'restore_history', 'set_assembly', 'render_assembly', 'draft_assembly',
|
||||
]);
|
||||
|
||||
export function shouldForceWorkshopSaveRetry(input: WorkshopSaveRetryInput): boolean {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
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 { renderAssemblyManual, readManifest, resolveScadPath, currentAssemblyHash, assemblyOutDir, ASSEMBLY_DIR_NAME, draftAssembly } from '../../tools/workshop-assembly-render';
|
||||
import { clearHistory } from '../session';
|
||||
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
|
||||
|
||||
@@ -296,8 +296,19 @@ export function registerWorkshopRoutes(
|
||||
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 });
|
||||
// 최근에 수정한 순 — 첫 번째가 "지금 작업 중인 조립 모델"일 가능성이 높아 화면이 자동 연결에 쓴다
|
||||
const found = walkCaseFiles(base).filter(e => /\.scad$/i.test(e.name)).sort((a, b) => Number(b.mtime) - Number(a.mtime));
|
||||
res.json({ files: found.map(e => `workshop/${projectId}/${e.relPath}`) });
|
||||
});
|
||||
|
||||
// .scad 자동 초안 — 조립 블록의 모듈 호출을 부품으로, bbox로 분해 offset/순서를 정해 돌려준다(저장은 안 함).
|
||||
app.post('/api/workshop/assembly-draft', async (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const workspace = getUserWorkspace(session.username);
|
||||
const r = await draftAssembly(workspace, String(req.body?.scad || '').replace(/\\/g, '/'));
|
||||
if (!r.ok) return res.status(422).json({ error: r.error });
|
||||
res.json({ assembly: r.assembly, excluded: r.excluded, warnings: r.warnings, region: r.region });
|
||||
});
|
||||
|
||||
app.post('/api/workshop/assembly/:id/render', async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* workshop-assembly-draft.ts
|
||||
* .scad 자동 연동(2026-09-25) — .scad 소스에서 "조립 초안"(부품 목록)을 뽑는 순수 파싱 함수들.
|
||||
* 핵심 아이디어: 조립체를 그리는 .scad는 보통 최상위(또는 `if (PART == "all") { … }`)에 조립 모듈을
|
||||
* 호출하는 블록이 있다 — 그 호출 목록이 곧 "이 조립체는 이 부품들로 이루어졌다"는 명세다.
|
||||
* 실제 좌표/분해 offset/단계 구성은 실행 결과(bbox)가 필요해서 workshop-assembly-render.ts의 draftAssembly가 맡는다.
|
||||
*/
|
||||
import { scanScadModules } from './workshop-assembly';
|
||||
|
||||
// 주석 제거(문자열 안의 // 는 보존). 줄바꿈은 유지해 위치 계산을 흐트러뜨리지 않는다.
|
||||
export function stripComments(src: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
while (i < n) {
|
||||
const c = src[i], d = src[i + 1];
|
||||
if (c === '"') { // 문자열 리터럴
|
||||
let j = i + 1;
|
||||
while (j < n && src[j] !== '"') { if (src[j] === '\\') j++; j++; }
|
||||
out += src.slice(i, j + 1); i = j + 1; continue;
|
||||
}
|
||||
if (c === '/' && d === '/') { while (i < n && src[i] !== '\n') i++; continue; }
|
||||
if (c === '/' && d === '*') {
|
||||
i += 2;
|
||||
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] === '\n') out += '\n'; i++; }
|
||||
i += 2; continue;
|
||||
}
|
||||
out += c; i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 여는 괄호 위치에서 짝이 맞는 닫는 괄호 위치를 찾는다(문자열 무시). 없으면 -1.
|
||||
export function matchBracket(s: string, open: number): number {
|
||||
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' };
|
||||
const o = s[open], c = pairs[o];
|
||||
if (!c) return -1;
|
||||
let depth = 0;
|
||||
for (let i = open; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') { i++; while (i < s.length && s[i] !== '"') { if (s[i] === '\\') i++; i++; } continue; }
|
||||
if (ch === o) depth++;
|
||||
else if (ch === c) { depth--; if (depth === 0) return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 최상위 쉼표로 인자 분리("a, [1,2], f(3,4)" → 3개).
|
||||
export function splitTopLevelArgs(argText: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0, cur = '';
|
||||
for (let i = 0; i < argText.length; i++) {
|
||||
const ch = argText[i];
|
||||
if (ch === '"') { const j = argText.indexOf('"', i + 1); cur += argText.slice(i, j < 0 ? undefined : j + 1); i = j < 0 ? argText.length : j; continue; }
|
||||
if ('([{'.includes(ch)) depth++;
|
||||
else if (')]}'.includes(ch)) depth--;
|
||||
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.trim()) out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface AssemblyCall { module: string; args: string[]; raw: string; ghost: boolean }
|
||||
|
||||
// 참고용/투명 형상으로 추정되는 이름(조립도에 그릴 실체가 아님) — 자동 초안에선 제외하고 사용자에게 알린다.
|
||||
const GHOST_NAME = /(fov|_cone|cone$|axis|plane|ghost|guide|reference|enclosure|^model$|^model_|_model$|envelope|sweep)/i;
|
||||
|
||||
// 조립 호출 목록 추출. 조립 블록 = `if (… "all" …) { … }` 의 첫 중괄호 안, 없으면 최상위 전체.
|
||||
// 모듈 정의 본문/주석은 제외. `%foo();`(고스트) `*foo();`(비활성) 같은 수식자 붙은 호출은 건너뛴다.
|
||||
export function extractAssemblyCalls(source: string): { calls: AssemblyCall[]; region: 'all-block' | 'top-level' } {
|
||||
const modNames = new Set(scanScadModules(source).map(m => m.name));
|
||||
let text = stripComments(source);
|
||||
// 모듈 정의 본문 제거(같은 길이의 공백으로 대체)
|
||||
const defRe = /(^|[\s;}])module\s+([A-Za-z_]\w*)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const blank = (a: number, b: number) => { text = text.slice(0, a) + text.slice(a, b + 1).replace(/[^\n]/g, ' ') + text.slice(b + 1); };
|
||||
while ((m = defRe.exec(text))) {
|
||||
const parenOpen = text.indexOf('(', m.index + m[1].length);
|
||||
const parenClose = matchBracket(text, parenOpen);
|
||||
if (parenClose < 0) continue;
|
||||
const braceOpen = text.indexOf('{', parenClose);
|
||||
const semi = text.indexOf(';', parenClose);
|
||||
if (braceOpen < 0 || (semi >= 0 && semi < braceOpen)) continue;
|
||||
const braceClose = matchBracket(text, braceOpen);
|
||||
if (braceClose < 0) continue;
|
||||
blank(m.index + m[1].length, braceClose);
|
||||
defRe.lastIndex = braceClose;
|
||||
}
|
||||
|
||||
let region = text;
|
||||
let kind: 'all-block' | 'top-level' = 'top-level';
|
||||
const ifRe = /\bif\s*\(([^)]*)\)\s*\{/g;
|
||||
while ((m = ifRe.exec(text))) {
|
||||
if (/["']all["']/i.test(m[1])) {
|
||||
const open = text.indexOf('{', m.index + m[0].length - 1);
|
||||
const close = matchBracket(text, open);
|
||||
if (close > open) { region = text.slice(open + 1, close); kind = 'all-block'; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const calls: AssemblyCall[] = [];
|
||||
// 문장 시작(구분자 뒤) + 식별자 + ( — 앞에 % * # ! 수식자나 translate() 같은 변환이 붙은 호출은 매치되지 않는다.
|
||||
const callRe = /(^|[;{}])\s*([A-Za-z_]\w*)\s*\(/g;
|
||||
while ((m = callRe.exec(region))) {
|
||||
const name = m[2];
|
||||
if (!modNames.has(name)) continue;
|
||||
const parenOpen = m.index + m[0].length - 1; // 매치가 '(' 로 끝난다
|
||||
const close = matchBracket(region, parenOpen);
|
||||
if (close < 0) continue;
|
||||
if (!/^\s*;/.test(region.slice(close + 1))) continue; // 뒤에 ; 가 없으면 자식이 있는 변환/제어문
|
||||
const argText = region.slice(parenOpen + 1, close);
|
||||
calls.push({
|
||||
module: name, args: splitTopLevelArgs(argText), raw: `${name}(${argText.trim()})`,
|
||||
ghost: GHOST_NAME.test(name) || /ghost/i.test(argText),
|
||||
});
|
||||
callRe.lastIndex = close;
|
||||
}
|
||||
return { calls, region: kind };
|
||||
}
|
||||
|
||||
// 모듈 바로 위 주석의 첫 문장을 부품 이름 후보로 쓴다(없으면 모듈 이름을 사람이 읽기 좋게).
|
||||
export function moduleLabel(source: string, moduleName: string): string {
|
||||
const lines = source.split('\n');
|
||||
const idx = lines.findIndex(l => new RegExp(`^\\s*module\\s+${moduleName}\\s*\\(`).test(l));
|
||||
if (idx > 0) {
|
||||
const cmt: string[] = [];
|
||||
for (let i = idx - 1; i >= 0 && /^\s*\/\//.test(lines[i]); i--) cmt.unshift(lines[i].replace(/^\s*\/\/+\s*/, '').trim());
|
||||
// 구분선(=== 등)뿐인 주석은 무시
|
||||
const first = cmt.find(t => t && !/^[=\-_*#\s]+.*[=\-_*#]{3,}/.test(t) && !/^[=\-_*#\s]+$/.test(t));
|
||||
if (first) {
|
||||
const cut = first.split(/\s[—–-]\s|[.。]\s|\s\(/)[0].trim();
|
||||
if (cut.length >= 2 && cut.length <= 22) return cut;
|
||||
}
|
||||
}
|
||||
return moduleName.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// 같은 모듈이 인자만 달리 여러 번 호출되면(좌/우 카메라 등) 이름이 겹치므로 인자로 구분.
|
||||
export function disambiguateNames(items: { name: string; call: string; module: string }[]): void {
|
||||
const count = new Map<string, number>();
|
||||
for (const it of items) count.set(it.name, (count.get(it.name) || 0) + 1);
|
||||
for (const it of items) {
|
||||
if ((count.get(it.name) || 0) > 1) {
|
||||
const args = /\((.*)\)$/.exec(it.call)?.[1]?.trim();
|
||||
it.name = args ? `${it.name} (${args})` : it.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export interface Assembly {
|
||||
view: { rx: number; rz: number };
|
||||
/** 분해도 간격 배율(offset × 이 값). 기본 1 */
|
||||
explode: number;
|
||||
/** .scad가 바뀌면(채팅 write_scad/scad_to_stl 또는 조립 탭을 열 때 stale 감지) 그림을 자동으로 다시 렌더. 기본 true */
|
||||
autoRender: boolean;
|
||||
parts: AssemblyPart[];
|
||||
steps: AssemblyStep[];
|
||||
}
|
||||
@@ -128,6 +130,7 @@ export function normalizeAssembly(input: any, strict = true): { ok: true; assemb
|
||||
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),
|
||||
autoRender: input.autoRender !== false,
|
||||
parts,
|
||||
steps,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { spawn } from 'child_process';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown, isPathInsideDir } from './image.js';
|
||||
import { autoRenderForScad } from './workshop-assembly-render.js';
|
||||
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, runOpenscad, applyBooleanOp, analyzeStl, StlAnalysis } from './stl-cad-core.js';
|
||||
|
||||
// STL 확인/미리보기/간단 수정(구멍 추가) 도구 — "작업실"에서 만든 파츠를 재출력 없이
|
||||
@@ -375,6 +376,8 @@ export const writeScadTool = {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(scadPath), { recursive: true });
|
||||
fs.writeFileSync(scadPath, content, 'utf8');
|
||||
// 이 .scad를 조립 모델로 쓰는(이미 렌더한 적 있는) 작업실 프로젝트의 조립 설명서 그림을 백그라운드로 갱신
|
||||
autoRenderForScad(workspacePath, scadPath);
|
||||
} catch (e: any) { return { success: false, error: `파일 쓰기 실패: ${String(e?.message || e)}` }; }
|
||||
|
||||
if (!fs.existsSync(OPENSCAD_BIN)) {
|
||||
|
||||
@@ -17,8 +17,10 @@ import { spawn } from 'child_process';
|
||||
import { runOpenscad, readStlBBox } from './stl-cad-core.js';
|
||||
import {
|
||||
Assembly, AssemblyPart, assemblyHash, buildWrapperScad, fitCamera, parseCall, scanScadModules,
|
||||
stepPartSets, unassignedParts,
|
||||
stepPartSets, unassignedParts, normalizeAssembly,
|
||||
} from '../gateway/routes/workshop-assembly.js';
|
||||
import { extractAssemblyCalls, moduleLabel, disambiguateNames } from '../gateway/routes/workshop-assembly-draft.js';
|
||||
import { loadWorkshop } from '../gateway/routes/workshop-storage.js';
|
||||
|
||||
export const ASSEMBLY_DIR_NAME = '조립설명서';
|
||||
const IMG_W = 1000, IMG_H = 750;
|
||||
@@ -332,3 +334,155 @@ async function doRender(workspace: string, projectId: string, assembly: Assembly
|
||||
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; }
|
||||
}
|
||||
|
||||
// ── .scad 자동 초안(2026-09-25) ────────────────────────────────────────────────
|
||||
// .scad의 조립 블록(if (PART=="all") { … } 또는 최상위)이 호출하는 모듈들을 부품으로 삼는다.
|
||||
// ① 인자의 변수(CAM_OFFSET 등)는 원본을 include한 probe로 실제 값으로 풀고 ② 부품별 bbox로 분해 offset(중심에서
|
||||
// 바깥으로 벌림)과 조립 순서(아래→위)를 정한다. 결과는 "초안" — 이름/설명/체결부품은 사용자가 다듬는다.
|
||||
export interface DraftResult { ok: true; assembly: Assembly; excluded: string[]; warnings: string[]; region: 'all-block' | 'top-level' }
|
||||
|
||||
const NUM_LIT = /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i;
|
||||
const VEC_LIT = /^\[\s*[-+\d.e\s,]*\]$/i;
|
||||
|
||||
async function exportBbox(scadAbs: string, call: string, tmp: string, tag: string) {
|
||||
const scadFile = path.join(tmp, `d${tag}.scad`);
|
||||
const stl = path.join(tmp, `d${tag}.stl`);
|
||||
fs.writeFileSync(scadFile, buildWrapperScad(scadAbs, [{ call }]));
|
||||
const r = await runOpenscad(['-o', stl, '--export-format', 'binstl', scadFile], 60_000);
|
||||
if (!r.ok) return null;
|
||||
try { return readStlBBox(stl); } catch { return null; }
|
||||
}
|
||||
|
||||
export async function draftAssembly(workspace: string, scadRel: string): Promise<DraftResult | { ok: false; error: string }> {
|
||||
const sc = resolveScadPath(workspace, scadRel);
|
||||
if (!sc.ok) return sc;
|
||||
const source = fs.readFileSync(sc.abs, 'utf-8');
|
||||
const { calls, region } = extractAssemblyCalls(source);
|
||||
const real = calls.filter(c => !c.ghost);
|
||||
const excluded = [...new Set(calls.filter(c => c.ghost).map(c => c.raw))];
|
||||
if (!real.length) return { ok: false, error: '조립 호출을 찾지 못했습니다 — .scad의 최상위(또는 if (PART == "all") 블록)에 조립 모듈을 호출하는 줄이 있어야 합니다.' };
|
||||
if (real.length > 40) return { ok: false, error: `조립 호출이 ${real.length}개로 너무 많습니다(최대 40). 부품을 직접 골라 추가하세요.` };
|
||||
|
||||
const warnings: string[] = [];
|
||||
const tmp = path.join(workspace, '.smallclaw', 'workshop-assembly-tmp', `draft_${crypto.randomBytes(3).toString('hex')}`);
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
try {
|
||||
// ① 인자 값 풀기 — 리터럴이 아닌 인자만 probe(원본 include, 조립 블록 미실행)로 평가
|
||||
const exprs: { ci: number; ai: number; expr: string }[] = [];
|
||||
real.forEach((c, ci) => c.args.forEach((a, ai) => {
|
||||
const m = /^([A-Za-z_]\w*)\s*=(?!=)\s*(.+)$/s.exec(a); // 이름 붙은 인자 name=값
|
||||
const value = (m ? m[2] : a).trim();
|
||||
if (!NUM_LIT.test(value) && !VEC_LIT.test(value)) exprs.push({ ci, ai, expr: value });
|
||||
}));
|
||||
const resolved = new Map<string, string>();
|
||||
if (exprs.length) {
|
||||
const probe = path.join(tmp, 'probe.scad');
|
||||
const out = path.join(tmp, 'probe.echo');
|
||||
fs.writeFileSync(probe, `include <${sc.abs.replace(/>/g, '')}>\n` + exprs.map(e => `echo("__ARG", ${e.ci}, ${e.ai}, (${e.expr}));`).join('\n') + '\n');
|
||||
const r = await runOpenscad(['-o', out, '-D', 'PART="__none__"', probe], 60_000);
|
||||
if (r.ok && fs.existsSync(out)) {
|
||||
for (const line of fs.readFileSync(out, 'utf-8').split('\n')) {
|
||||
const m = /"__ARG",\s*(\d+),\s*(\d+),\s*(.+?)\s*$/.exec(line.replace(/^ECHO:\s*/, ''));
|
||||
if (m) resolved.set(`${m[1]}:${m[2]}`, m[3].trim());
|
||||
}
|
||||
} else warnings.push(`인자 값을 계산하지 못했습니다(${!r.ok ? r.detail : 'echo 없음'}) — 변수가 든 인자는 원문 그대로 둡니다.`);
|
||||
}
|
||||
const items = real.map((c, ci) => {
|
||||
const args = c.args.map((a, ai) => {
|
||||
const m = /^([A-Za-z_]\w*)\s*=(?!=)\s*(.+)$/s.exec(a);
|
||||
const v = resolved.get(`${ci}:${ai}`);
|
||||
const value = v !== undefined ? v : (m ? m[2] : a).trim().replace(/^\+/, '');
|
||||
if (v === undefined && !NUM_LIT.test(value.replace(/^\+/, '')) && !VEC_LIT.test(value)) warnings.push(`${c.module}: 인자 "${a}"를 숫자로 풀지 못함(그대로 사용 — 렌더가 안 되면 값으로 바꿔 주세요)`);
|
||||
return m ? `${m[1]}=${value}` : value;
|
||||
});
|
||||
const call = `${c.module}(${args.join(', ')})`;
|
||||
return { c, call, name: moduleLabel(source, c.module), module: c.module };
|
||||
});
|
||||
disambiguateNames(items);
|
||||
|
||||
// ② 부품별 bbox
|
||||
const boxes: ({ min: [number, number, number]; max: [number, number, number] } | null)[] = [];
|
||||
for (let i = 0; i < items.length; i++) boxes.push(await exportBbox(sc.abs, items[i].call, tmp, String(i)));
|
||||
const ok = items.map((it, i) => ({ it, box: boxes[i] })).filter(x => {
|
||||
if (!x.box) { warnings.push(`${x.it.call}: 형상이 비어 있거나 계산 실패 — 제외`); return false; }
|
||||
return true;
|
||||
}) as { it: typeof items[number]; box: { min: [number, number, number]; max: [number, number, number] } }[];
|
||||
if (!ok.length) return { ok: false, error: '유효한 형상을 가진 부품이 없습니다.' };
|
||||
|
||||
const center = (b: { min: number[]; max: number[] }) => b.min.map((v, k) => (v + b.max[k]) / 2);
|
||||
const all = { min: [0, 1, 2].map(k => Math.min(...ok.map(x => x.box.min[k]))), max: [0, 1, 2].map(k => Math.max(...ok.map(x => x.box.max[k]))) };
|
||||
const ca = center(all);
|
||||
const size = Math.max(all.max[0] - all.min[0], all.max[1] - all.min[1], all.max[2] - all.min[2]);
|
||||
// ③ 분해 offset: 수평은 중심에서 바깥으로 0.5배. 수직은 "윗쪽 높이"(최저 25%+최고 75%) 기준으로 0.9배 —
|
||||
// 카메라처럼 렌즈가 아래로 삐져나온 부품이 최저 높이 때문에 마운트보다 아래로 오는 역전을 막는다.
|
||||
// 같은 높이 층은 같은 값을 받아 좌우 대칭이 유지된다.
|
||||
const hKey = (x: { box: { min: number[]; max: number[] } }) => 0.25 * x.box.min[2] + 0.75 * x.box.max[2];
|
||||
const meanKey = ok.reduce((sum, x) => sum + hKey(x), 0) / ok.length;
|
||||
const levels = [...new Set(ok.map(x => Math.round(hKey(x) / 5)))].sort((a, b) => a - b);
|
||||
const round5 = (v: number) => Math.round(v / 5) * 5;
|
||||
const parts = ok.map((x, i) => {
|
||||
const c = center(x.box);
|
||||
const layer = (levels.indexOf(Math.round(hKey(x) / 5)) - (levels.length - 1) / 2) * (0.05 * size);
|
||||
return {
|
||||
id: `p${i + 1}`, name: x.it.name, call: x.it.call, qty: 1,
|
||||
offset: [round5((c[0] - ca[0]) * 0.5), round5((c[1] - ca[1]) * 0.5), round5((hKey(x) - meanKey) * 0.9 + layer)] as [number, number, number],
|
||||
};
|
||||
});
|
||||
// ④ 단계: .scad에 나열된 호출 순서를 따른다(작성자가 적은 순서가 보통 조립 순서). 같은 모듈의 호출(좌/우 카메라 등)은
|
||||
// 첫 등장 위치에 한 단계로 묶는다. 높이순 정렬은 렌즈처럼 삐져나온 부품 때문에 순서가 틀어져 쓰지 않는다.
|
||||
const modOrder: string[] = [];
|
||||
for (const x of ok) if (!modOrder.includes(x.it.module)) modOrder.push(x.it.module);
|
||||
const steps: { title: string; desc: string; partIds: string[] }[] = modOrder.map(mod => ({
|
||||
title: '', desc: '',
|
||||
partIds: ok.map((x, i) => ({ x, i })).filter(o => o.x.it.module === mod).map(o => `p${o.i + 1}`),
|
||||
}));
|
||||
for (const st of steps) {
|
||||
const first = ok[Number(st.partIds[0].slice(1)) - 1];
|
||||
const base = moduleLabel(source, first.it.module);
|
||||
st.title = st.partIds.length > 1 ? `${base} ${st.partIds.length}개` : base;
|
||||
st.desc = st.partIds.map(id => {
|
||||
const x = ok[Number(id.slice(1)) - 1];
|
||||
const c = center(x.box).map(v => Math.round(v));
|
||||
return `「${x.it.name}」을(를) 조립체에 장착합니다. (조립 좌표 중심 약 X ${c[0]}, Y ${c[1]}, Z ${c[2]} mm)`;
|
||||
}).join('\n');
|
||||
}
|
||||
if (region === 'top-level') warnings.push('조립 블록(if (PART == "all"))이 없어 최상위 모듈 호출 전체를 부품으로 삼았습니다 — 애니메이션/데모 파일이면 부품 구성이 의도와 다를 수 있습니다.');
|
||||
|
||||
const n = normalizeAssembly({ scad: scadRel, view: { rx: 60, rz: 35 }, explode: 1, parts, steps }, true);
|
||||
if (!n.ok) return { ok: false, error: n.error };
|
||||
return { ok: true, assembly: n.assembly, excluded, warnings: [...new Set(warnings)], region };
|
||||
} finally {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── .scad 변경 시 자동 재렌더 ─────────────────────────────────────────────────
|
||||
// write_scad/scad_to_stl 같은 채팅 도구가 .scad를 바꾸면 그 파일을 쓰는(이미 렌더한 적 있는) 프로젝트의 설명서
|
||||
// 그림을 백그라운드로 다시 만든다. 프로젝트별로 실행 중이면 한 번만 더(합치기).
|
||||
const autoRunning = new Set<string>();
|
||||
const autoAgain = new Set<string>();
|
||||
export function autoRenderForScad(workspace: string, scadAbsPath: string): void {
|
||||
try {
|
||||
const data = loadWorkshop(workspace);
|
||||
for (const project of data.projects) {
|
||||
const a = project.assembly;
|
||||
if (!a || a.autoRender === false || !a.parts.length || !a.steps.length) continue;
|
||||
const r = resolveScadPath(workspace, a.scad);
|
||||
if (!r.ok || path.resolve(r.abs) !== path.resolve(scadAbsPath)) continue;
|
||||
if (!readManifest(workspace, project.id)) continue; // 한 번도 렌더 안 한 프로젝트는 건드리지 않는다
|
||||
const key = `${workspace}::${project.id}`;
|
||||
if (autoRunning.has(key)) { autoAgain.add(key); continue; }
|
||||
autoRunning.add(key);
|
||||
const run = async () => {
|
||||
do {
|
||||
autoAgain.delete(key);
|
||||
const fresh = loadWorkshop(workspace).projects.find(p => p.id === project.id);
|
||||
if (!fresh?.assembly) break;
|
||||
const res = await renderAssemblyManual(workspace, project.id, fresh.assembly);
|
||||
console.log(`[assembly-auto] ${project.name}: ${res.ok ? `그림 자동 갱신(${res.seconds}s)` : `실패 — ${res.error}`}`);
|
||||
} while (autoAgain.has(key));
|
||||
};
|
||||
run().catch(() => undefined).finally(() => autoRunning.delete(key));
|
||||
}
|
||||
} catch { /* 자동 갱신 실패가 원래 도구 결과에 영향을 주면 안 된다 */ }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} 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';
|
||||
import { renderAssemblyManual, resolveScadPath, ASSEMBLY_DIR_NAME, draftAssembly } from './workshop-assembly-render.js';
|
||||
|
||||
// "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만
|
||||
// 다루던 robot_project를 여러 프로젝트를 오갈 수 있게 일반화(2026-09-20). 대시보드와 같은
|
||||
@@ -97,9 +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',
|
||||
'get_assembly', 'set_assembly', 'scan_scad', 'draft_assembly', '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=파일명 또는 상대경로 일부, 사용자 메시지의 [첨부 사진: ...]이 이 파일) — 부품 식별, 배선/조립 상태 확인 등에 쓴다. [조립 설명서] 사용자가 "조립 설명서/조립도 만들어줘"라고 하면: ①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/순서를 고쳐 다시 렌더할 것. 설명서는 대시보드 "조립" 탭에서 보고 인쇄한다.',
|
||||
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=파일명 또는 상대경로 일부, 사용자 메시지의 [첨부 사진: ...]이 이 파일) — 부품 식별, 배선/조립 상태 확인 등에 쓴다. [조립 설명서] 사용자가 "조립 설명서/조립도 만들어줘"라고 하면: ①draft_assembly(scad_path)로 .scad의 조립 블록에서 부품/분해 offset/단계 초안을 자동으로 뽑고(apply=true면 바로 저장; 결과를 보고 이름·설명·체결부품·공구를 다듬어 set_assembly로 다시 저장) — 초안이 마음에 안 들면 scan_scad로 프로젝트 .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이다) 프로젝트 개요 문단 — 무엇을 만드는지/목표/구성 등을 자유 서술. 대시보드의 "개요" 탭에 표시된다.' },
|
||||
@@ -141,6 +141,7 @@ export const workshopProjectTool = {
|
||||
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)") — 따옴표/세미콜론 금지.',
|
||||
},
|
||||
apply: { type: 'boolean', description: '(draft_assembly 선택) true면 초안을 이 프로젝트의 조립 명세로 바로 저장(기존 부품/단계는 덮어씀). 기본 false — 초안만 보여줌.' },
|
||||
file_name: { type: 'string', description: '(view_image 필수) 프로젝트 첨부 사진의 파일명 또는 상대경로(일부만 일치해도 됨, 예: "사진/motor.jpg")' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -534,6 +535,25 @@ export const workshopProjectTool = {
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'draft_assembly') {
|
||||
const rel = String(args?.scad_path || project.assembly?.scad || '').trim().replace(/\\/g, '/');
|
||||
const d = await draftAssembly(workspaceRoot, rel);
|
||||
if (!d.ok) return { success: false, error: d.error };
|
||||
const lines = [
|
||||
`${rel} — 자동 초안: 부품 ${d.assembly.parts.length}개, 단계 ${d.assembly.steps.length}개`,
|
||||
...d.assembly.parts.map((p, i) => ` ${i + 1}. ${p.name} | ${p.call} | 분해 offset [${p.offset.join(', ')}]`),
|
||||
...d.assembly.steps.map((s, i) => ` STEP ${i + 1}: ${s.title} ← ${s.partIds.map(id => d.assembly.parts.findIndex(p => p.id === id) + 1).join(', ')}`),
|
||||
];
|
||||
if (d.excluded.length) lines.push(`제외(투명/참고용 추정): ${d.excluded.join(', ')}`);
|
||||
for (const w of d.warnings) lines.push(`⚠ ${w}`);
|
||||
if (args?.apply === true) {
|
||||
project.assembly = { ...d.assembly, autoRender: project.assembly?.autoRender !== false };
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
lines.push('→ 이 프로젝트의 조립 명세로 저장함. 이름/설명/체결부품/공구를 set_assembly로 다듬고 render_assembly로 그림을 만들 것.');
|
||||
} else lines.push('→ 저장되지 않음. 마음에 들면 draft_assembly(apply=true) 또는 다듬은 값으로 set_assembly.');
|
||||
return { success: true, stdout: lines.join('\n') };
|
||||
}
|
||||
|
||||
if (action === 'set_assembly') {
|
||||
if (!args?.assembly || typeof args.assembly !== 'object') return { success: false, error: 'assembly 객체가 필요합니다(get_assembly 참고).' };
|
||||
const n = normalizeAssembly(args.assembly);
|
||||
|
||||
@@ -31,6 +31,7 @@ const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/wo
|
||||
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: [],
|
||||
assembly: { scad: 'workshop/pa/CAD/rig.scad', parts: [], steps: [] }, // 사용자가 이미 연결해 둔 프로젝트(자동 연결/초안이 끼어들지 않음)
|
||||
parts: [{ id: 'b1', name: '베이스 플레이트', qty: 1, unitPrice: 30000, status: '보유', memo: '', links: [] }] }) });
|
||||
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
||||
@@ -111,6 +112,39 @@ const SRC_SCAD = process.env.SRC_SCAD || '/srv/homeclaw/.smallclaw/users/papa/wo
|
||||
await page2.screenshot({ path: `${OUT}/asm-3-manual.png`, fullPage: true });
|
||||
await page2.pdf({ path: `${OUT}/asm-manual.pdf`, format: 'A4', printBackground: true });
|
||||
|
||||
|
||||
// ── .scad 자동 연동: 빈 프로젝트의 조립 탭을 열기만 하면 자동 연결 → 초안 → 렌더 ──
|
||||
fs.mkdirSync(path.join(ws, 'workshop/pz/CAD'), { recursive: true });
|
||||
fs.copyFileSync(SRC_SCAD, path.join(ws, 'workshop/pz/CAD/rig.scad'));
|
||||
await fetch(`${base}/api/workshop/project/pz?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pz', name: '자동 연동 시험', parts: [], phases: [], notes: '' }) });
|
||||
await page.goto(`${base}/html/workshop-app.html`);
|
||||
await page.waitForSelector('#project-select');
|
||||
await page.selectOption('#project-select', 'pz');
|
||||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||||
await page.waitForSelector('.asm-part', { timeout: 60000 });
|
||||
check('자동 연결: .scad 경로가 채워짐', (await page.inputValue('#asm-scad')) === 'workshop/pz/CAD/rig.scad');
|
||||
const nParts = await page.locator('.asm-part').count();
|
||||
check('자동 초안: 부품이 만들어짐(고스트 제외)', nParts === 9, `${nParts}개`);
|
||||
check('자동 초안: 제외 안내 표시', (await page.locator('#asm-draft-note').innerText()).includes('제외'));
|
||||
await page.waitForSelector('.asm-imgs figure', { timeout: 120000 });
|
||||
check('자동 렌더: 그림 생성(완성+분해+단계)', (await page.locator('.asm-imgs figure').count()) >= 5, `${await page.locator('.asm-imgs figure').count()}장`);
|
||||
await page.screenshot({ path: `${OUT}/asm-4-auto.png`, fullPage: true });
|
||||
|
||||
// .scad가 바뀐 뒤 탭을 다시 열면 자동 재렌더
|
||||
await page.waitForTimeout(1500);
|
||||
const before = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||||
fs.appendFileSync(path.join(ws, 'workshop/pz/CAD/rig.scad'), '\n// changed\n');
|
||||
const stale = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||||
check('.scad 변경 → 서버가 stale 판정', stale.stale === true);
|
||||
await page.click('.rb-tab-btn[data-tab=parts]');
|
||||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||||
for (let i = 0; i < 400; i++) { // 서버 상태를 폴링(최대 ~2분)
|
||||
if ((await (await fetch(`${base}/api/workshop/assembly/pz`)).json()).stale === false) break;
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
const after = await (await fetch(`${base}/api/workshop/assembly/pz`)).json();
|
||||
check('탭을 열면 오래된 그림 자동 재렌더', after.renderedAt > before.renderedAt && after.stale === false);
|
||||
|
||||
console.log(results.join('\n'));
|
||||
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
|
||||
await browser.close(); server.close();
|
||||
|
||||
@@ -8,7 +8,8 @@ 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 { renderAssemblyManual, resolveScadPath, readManifest, currentAssemblyHash, draftAssembly, autoRenderForScad } from '../src/tools/workshop-assembly-render';
|
||||
import { extractAssemblyCalls, stripComments, splitTopLevelArgs, moduleLabel, disambiguateNames } from '../src/gateway/routes/workshop-assembly-draft';
|
||||
import { workshopProjectTool } from '../src/tools/workshop-project';
|
||||
import { saveWorkshop, loadWorkshop, duplicateProject, normalizeProjectInput, projectToReadme } from '../src/gateway/routes/workshop-storage';
|
||||
|
||||
@@ -225,3 +226,141 @@ describe('workshop_project 조립 액션', () => {
|
||||
assert.match(r.stdout || '', /!\[분해도\]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ══ .scad 자동 연동(2026-09-25) ══════════════════════════════════════════════════
|
||||
const RIG = `
|
||||
OFF = 30; // 카메라 오프셋
|
||||
H = 20 + 5;
|
||||
// 베이스 판 — 설명 주석
|
||||
module base(){ color("gray") cube([60,60,5]); }
|
||||
module post(x, y){ color("blue") translate([x,y,5]) cube([5,5,H]); }
|
||||
// 상판
|
||||
module top(){ color("red") translate([0,0,H+5]) cube([60,60,4]); }
|
||||
module cam(sx){ color("green") translate([sx+30,30,H+9]) cube([6,6,6]); }
|
||||
module ghost_cone(){ %cylinder(h=10,d=5); }
|
||||
module part_base(){ cube([60,60,5]); }
|
||||
PART = "all";
|
||||
if (PART == "all") {
|
||||
base();
|
||||
post(0, 0);
|
||||
post(55, 55);
|
||||
top();
|
||||
cam(+OFF); // 변수 인자
|
||||
cam(-OFF);
|
||||
ghost_cone();
|
||||
%base(); // 고스트 수식자 — 건너뜀
|
||||
*top(); // 비활성 — 건너뜀
|
||||
translate([1,1,1]) post(1, 1); // 변환이 붙은 호출 — 건너뜀
|
||||
} else if (PART == "base") part_base();
|
||||
`;
|
||||
|
||||
describe('자동 초안: 파싱', () => {
|
||||
test('stripComments: 주석 제거, 문자열 안 // 는 보존, 줄 수 유지', () => {
|
||||
const t = stripComments('a(); // x\nb("http://y"); /* c\nd */ e();');
|
||||
assert.ok(t.includes('a();') && !t.includes('// x') && t.includes('"http://y"') && !t.includes('c'));
|
||||
assert.equal(t.split('\n').length, 'a(); // x\nb("http://y"); /* c\nd */ e();'.split('\n').length);
|
||||
});
|
||||
test('splitTopLevelArgs: 괄호/대괄호 안 쉼표는 나누지 않음', () => {
|
||||
assert.deepEqual(splitTopLevelArgs('a, [1,2], f(3,4), x=[5,6]'), ['a', '[1,2]', 'f(3,4)', 'x=[5,6]']);
|
||||
assert.deepEqual(splitTopLevelArgs(''), []);
|
||||
});
|
||||
test('extractAssemblyCalls: all-블록의 호출만, 수식자/변환/모듈 정의 본문/else 분기 제외, 고스트 표시', () => {
|
||||
const r = extractAssemblyCalls(RIG);
|
||||
assert.equal(r.region, 'all-block');
|
||||
assert.deepEqual(r.calls.map(c => c.raw), ['base()', 'post(0, 0)', 'post(55, 55)', 'top()', 'cam(+OFF)', 'cam(-OFF)', 'ghost_cone()']);
|
||||
assert.deepEqual(r.calls.filter(c => c.ghost).map(c => c.module), ['ghost_cone']);
|
||||
assert.deepEqual(r.calls[4].args, ['+OFF']);
|
||||
});
|
||||
test('조립 블록이 없으면 최상위 호출 전체(region=top-level), 이름 붙은 ghost 인자도 고스트', () => {
|
||||
const r = extractAssemblyCalls('module a(x){cube(x);}\nmodule arm(ang, isGhost=false){cube(1);}\na(3);\narm(10);\narm(20, isGhost=true);');
|
||||
assert.equal(r.region, 'top-level');
|
||||
assert.deepEqual(r.calls.map(c => [c.raw, c.ghost]), [['a(3)', false], ['arm(10)', false], ['arm(20, isGhost=true)', true]]);
|
||||
});
|
||||
test('moduleLabel: 바로 위 주석의 첫 문장, 없거나 긴 문장이면 모듈 이름', () => {
|
||||
assert.equal(moduleLabel(RIG, 'base'), '베이스 판');
|
||||
assert.equal(moduleLabel(RIG, 'top'), '상판');
|
||||
assert.equal(moduleLabel(RIG, 'post'), 'post');
|
||||
assert.equal(moduleLabel(RIG, 'ghost_cone'), 'ghost cone');
|
||||
});
|
||||
test('disambiguateNames: 같은 이름은 인자로 구분', () => {
|
||||
const items = [{ name: 'cam', call: 'cam(30)', module: 'cam' }, { name: 'cam', call: 'cam(-30)', module: 'cam' }, { name: '상판', call: 'top()', module: 'top' }];
|
||||
disambiguateNames(items);
|
||||
assert.deepEqual(items.map(i => i.name), ['cam (30)', 'cam (-30)', '상판']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('자동 초안: 실행(OpenSCAD)', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||||
test('변수 인자를 값으로 풀고, 고스트 제외, 호출 순서대로 단계 구성, 같은 모듈은 한 단계', async () => {
|
||||
const ws = tmpWs();
|
||||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/rig.scad'), RIG);
|
||||
const r = await draftAssembly(ws, 'workshop/p1/CAD/rig.scad');
|
||||
assert.ok(r.ok, JSON.stringify(r));
|
||||
if (!r.ok) return;
|
||||
assert.equal(r.region, 'all-block');
|
||||
assert.deepEqual(r.assembly.parts.map(p => p.call), ['base()', 'post(0, 0)', 'post(55, 55)', 'top()', 'cam(30)', 'cam(-30)']); // +OFF → 30
|
||||
assert.deepEqual(r.excluded, ['ghost_cone()']);
|
||||
assert.deepEqual(r.assembly.steps.map(s => s.partIds.length), [1, 2, 1, 2]); // base / post×2 / top / cam×2 (같은 모듈은 첫 등장 위치에 한 단계)
|
||||
});
|
||||
});
|
||||
|
||||
describe('자동 초안 → 렌더 → .scad 변경 시 자동 재렌더', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||||
const waitFor = async (cond: () => boolean, ms = 60000) => { const t0 = Date.now(); while (!cond()) { if (Date.now() - t0 > ms) return false; await new Promise(r => setTimeout(r, 300)); } return true; };
|
||||
|
||||
test('초안이 곧바로 렌더 가능하고, write_scad 같은 변경 → autoRenderForScad가 그림을 갱신(autoRender=false면 안 함)', async () => {
|
||||
const ws = tmpWs();
|
||||
const scad = path.join(ws, 'workshop/p1/CAD/rig.scad');
|
||||
fs.writeFileSync(scad, RIG);
|
||||
const d = await draftAssembly(ws, 'workshop/p1/CAD/rig.scad');
|
||||
assert.ok(d.ok);
|
||||
if (!d.ok) return;
|
||||
const data = loadWorkshop(ws);
|
||||
data.projects[0].assembly = d.assembly;
|
||||
saveWorkshop(ws, data);
|
||||
const r1 = await renderAssemblyManual(ws, 'p1', d.assembly);
|
||||
assert.ok(r1.ok, JSON.stringify(r1));
|
||||
if (!r1.ok) return;
|
||||
assert.equal(readManifest(ws, 'p1')!.hash, r1.hash);
|
||||
|
||||
// 1) autoRender=false → 갱신 안 함
|
||||
const off = loadWorkshop(ws); off.projects[0].assembly!.autoRender = false; saveWorkshop(ws, off);
|
||||
fs.appendFileSync(scad, '\n// edit 1\n');
|
||||
autoRenderForScad(ws, scad);
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
assert.equal(readManifest(ws, 'p1')!.hash, r1.hash, 'autoRender=false인데 갱신됨');
|
||||
|
||||
// 2) autoRender=true → manifest 해시가 바뀔 때까지 갱신
|
||||
const on = loadWorkshop(ws); on.projects[0].assembly!.autoRender = true; saveWorkshop(ws, on);
|
||||
autoRenderForScad(ws, scad);
|
||||
autoRenderForScad(ws, scad); // 실행 중 재호출은 합쳐진다(동시 렌더 없음)
|
||||
assert.ok(await waitFor(() => readManifest(ws, 'p1')!.hash !== r1.hash), '자동 재렌더가 안 됨');
|
||||
const m = readManifest(ws, 'p1')!;
|
||||
assert.equal(m.hash, currentAssemblyHash(ws, loadWorkshop(ws).projects[0].assembly!));
|
||||
|
||||
// 3) 다른 파일이 바뀌면 무관, 렌더한 적 없는 프로젝트는 건드리지 않는다
|
||||
const other = path.join(ws, 'workshop/p1/CAD/other.scad');
|
||||
fs.writeFileSync(other, 'cube(1);');
|
||||
const before = readManifest(ws, 'p1')!.renderedAt;
|
||||
autoRenderForScad(ws, other);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
assert.equal(readManifest(ws, 'p1')!.renderedAt, before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workshop_project draft_assembly', { skip: !hasTools && 'openscad/PIL 없음' }, () => {
|
||||
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
|
||||
test('초안만 보여주기(저장 안 함) → apply=true면 저장', async () => {
|
||||
const ws = tmpWs();
|
||||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/rig.scad'), RIG);
|
||||
const a = await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/rig.scad' });
|
||||
assert.equal(a.success, true, a.error);
|
||||
assert.match(a.stdout || '', /자동 초안: 부품 6개, 단계 4개/);
|
||||
assert.match(a.stdout || '', /제외\(투명\/참고용 추정\): ghost_cone\(\)/);
|
||||
assert.match(a.stdout || '', /저장되지 않음/);
|
||||
assert.equal(loadWorkshop(ws).projects[0].assembly, undefined);
|
||||
const b = await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/rig.scad', apply: true });
|
||||
assert.equal(b.success, true);
|
||||
assert.equal(loadWorkshop(ws).projects[0].assembly!.parts.length, 6);
|
||||
assert.equal(loadWorkshop(ws).projects[0].assembly!.autoRender, true);
|
||||
assert.equal((await run(ws, { action: 'draft_assembly', scad_path: 'workshop/p1/CAD/none.scad' })).success, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2030,11 +2030,14 @@ let asmScanMods=null; // 스캔한 .scad module 목록
|
||||
let asmScadFiles=[]; // 프로젝트 하위 .scad 후보
|
||||
let asmBusy=false;
|
||||
let asmLoadSeq=0;
|
||||
let asmAutoDone=''; // 자동 연결/초안을 이미 시도한 프로젝트 id(한 번만)
|
||||
let asmDraftInfo=null; // 마지막 자동 초안의 제외/경고 안내
|
||||
let asmLastError='';
|
||||
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(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;asmDraftInfo=null;asmLastError='';} // 프로젝트가 바뀌면 그림 정보 초기화
|
||||
if(!asmPaneActive())return;
|
||||
renderAssembly();
|
||||
if(forceLoad||asmInfo===null)loadAssemblyInfo();
|
||||
@@ -2055,9 +2058,32 @@ async function loadAssemblyInfo(){
|
||||
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();
|
||||
if(seq!==asmLoadSeq||!asmPaneActive())return;
|
||||
renderAssemblyImages();
|
||||
await asmAutoLink();
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// .scad 자동 연동: ① 조립 모델이 비어 있으면 프로젝트의 가장 최근 .scad를 자동 연결하고, 부품이 없으면 초안까지
|
||||
// 만들어 그림을 렌더한다 ② 이미 연결돼 있고 .scad가 바뀌어 그림이 오래됐으면(autoRender 켜짐) 자동 재렌더.
|
||||
// 사용자가 만든 내용은 덮어쓰지 않는다(빈 명세일 때만 채움).
|
||||
async function asmAutoLink(){
|
||||
if(asmBusy)return;
|
||||
const a=assembly;
|
||||
if(!a||!a.scad){
|
||||
if(asmAutoDone===asmProjectId||!asmScadFiles.length)return;
|
||||
asmAutoDone=asmProjectId;
|
||||
ensureAssembly().scad=asmScadFiles[0];
|
||||
asmMarkChanged();
|
||||
renderAssembly();
|
||||
if(!assembly.parts.length&&await asmDraft(true))await asmRender(true);
|
||||
return;
|
||||
}
|
||||
if(asmInfo&&asmInfo.stale&&a.autoRender!==false&&a.parts.length&&a.steps.length&&asmAutoDone!==asmProjectId+':stale'){
|
||||
asmAutoDone=asmProjectId+':stale'; // 실패해도 이 화면에서 무한 재시도하지 않는다
|
||||
await asmRender(true);
|
||||
}
|
||||
}
|
||||
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();}
|
||||
|
||||
function renderAssembly(){
|
||||
@@ -2107,7 +2133,14 @@ function renderAssembly(){
|
||||
<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>
|
||||
<button class="rb-mini-btn" onclick="asmDraft(false)" title=".scad의 조립 블록에서 부품·분해 위치·조립 순서를 자동으로 뽑습니다">✨ 자동 초안</button>
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
|
||||
<input type="checkbox" ${!a||a.autoRender!==false?'checked':''} onchange="asmSet('autoRender',this.checked)">.scad가 바뀌면 그림 자동 갱신</label>
|
||||
<span style="font-size:10px;color:var(--muted)">${asmScadFiles.length>1?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개 (입력칸에서 선택)':''}</span>
|
||||
</div>
|
||||
<div id="asm-draft-note">${asmDraftNoteHtml()}</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)">
|
||||
@@ -2148,6 +2181,7 @@ function renderAsmStatus(){
|
||||
const a=assembly;
|
||||
let t='';
|
||||
if(asmBusy)t='<span style="color:#f59e0b">렌더링 중… (부품 수에 따라 10~60초)</span>';
|
||||
else if(asmLastError)t='<span style="color:#ef4444">⚠ '+escHtml(asmLastError)+'</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>':'');
|
||||
@@ -2171,7 +2205,7 @@ function renderAssemblyImages(){
|
||||
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
|
||||
function asmSet(k,v){
|
||||
const a=ensureAssembly();
|
||||
a[k]=k==='explode'?(Number(v)||1):v;
|
||||
a[k]=k==='explode'?(Number(v)||1):(k==='autoRender'?!!v:v);
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();}
|
||||
@@ -2265,9 +2299,9 @@ function asmAddFromScan(i){
|
||||
asmAddPart({name:m.name,call:m.name+'('+m.requiredParams.map(()=>'0').join(', ')+')'});
|
||||
}
|
||||
|
||||
async function asmRender(){
|
||||
async function asmRender(silent){
|
||||
const a=assembly;
|
||||
if(!a||!a.parts.length||!a.steps.length){alert('부품과 단계를 먼저 채워 주세요.');return;}
|
||||
if(!a||!a.parts.length||!a.steps.length){if(!silent)alert('부품과 단계를 먼저 채워 주세요.');return;}
|
||||
if(asmBusy)return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
@@ -2279,11 +2313,58 @@ async function asmRender(){
|
||||
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);}
|
||||
asmLastError='';
|
||||
if(!silent&&d.warnings&&d.warnings.length)alert(d.warnings.join('\n'));
|
||||
}catch(e){
|
||||
asmLastError='렌더 실패: '+e.message;
|
||||
if(!silent)alert(asmLastError);
|
||||
}
|
||||
finally{asmBusy=false;renderAssemblyImages();}
|
||||
}
|
||||
|
||||
// 제외/경고 안내(자동 초안 결과) — 패널이 다시 그려져도 남는다
|
||||
function asmDraftNoteHtml(){
|
||||
if(!asmDraftInfo)return '';
|
||||
const i=asmDraftInfo;
|
||||
return (i.excluded.length?'<div class="asm-warn" style="color:var(--muted)">제외(투명/참고용으로 추정): '+i.excluded.map(escHtml).join(', ')+' — 필요하면 위 스캔 목록에서 직접 추가</div>':'')
|
||||
+i.warnings.map(w=>'<div class="asm-warn">⚠ '+escHtml(w)+'</div>').join('');
|
||||
}
|
||||
|
||||
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
|
||||
async function asmDraft(auto){
|
||||
const a=ensureAssembly();
|
||||
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
|
||||
if(!auto&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
|
||||
const out=document.getElementById('asm-draft-note');
|
||||
if(out)out.innerHTML='<div class="rb-empty">.scad 분석 중…</div>';
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly-draft',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({scad:a.scad})});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
// 자동 흐름에서는 "조립 블록(if PART=="all")"이 있는 .scad만 초안을 채운다 — 데모/애니메이션 파일로 엉뚱한
|
||||
// 명세가 조용히 만들어지는 걸 막는다(수동 ✨ 버튼은 그래도 허용하고 경고를 보여준다).
|
||||
if(auto&&d.region!=='all-block'){
|
||||
asmDraftInfo={excluded:[],warnings:['자동 연결만 했습니다 — 이 .scad엔 조립 블록(if (PART == "all"))이 없어 자동 초안은 만들지 않았습니다. 필요하면 ✨ 자동 초안을 직접 눌러 보세요.']};
|
||||
renderAssembly();
|
||||
return false;
|
||||
}
|
||||
const keep={scad:a.scad,autoRender:a.autoRender!==false,view:a.view,explode:a.explode};
|
||||
assembly=Object.assign({},d.assembly,keep);
|
||||
asmDraftInfo={excluded:d.excluded||[],warnings:d.warnings||[]};
|
||||
if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;
|
||||
asmLastError='';
|
||||
renderAssembly();
|
||||
scheduleSave();
|
||||
return true;
|
||||
}catch(e){
|
||||
asmDraftInfo=null;
|
||||
asmLastError='자동 초안 실패: '+e.message;
|
||||
renderAssembly();
|
||||
if(!auto)alert(asmLastError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP들을 작업 탭의 "조립" 단계로 추가(이미 같은 문구가 있으면 건너뜀)
|
||||
function asmStepsToTasks(){
|
||||
const a=assembly;
|
||||
|
||||
Reference in New Issue
Block a user