fix: .scad 선택 시 "새 경로 + 옛 부품" 어긋난 상태로 렌더가 실패하던 문제

- 원인: 다른 .scad를 고르면 경로만 먼저 바꾸고 부품은 나중에(또는 취소하면 영영) 안 바뀌어, 그 사이 저장 타이머/자동 렌더가
  새 파일에 없는 모듈(옛 파일의 motor())로 렌더 → "부품 motor: .scad에 module motor이(가) 없습니다"가 화면에 남음
- 확인창을 먼저 띄우고, 자동 초안이 성공한 뒤에만 경로+부품을 한 번에 교체. 취소/초안 실패 시 아무것도 바꾸지 않고 선택 상자만 원복
  (경로만 바꾸려면 "경로 직접 입력")
- 편집하면 이전 렌더 오류 문구를 지움. 서버 오류 문구에 파일 이름과 해결 방법(자동 초안으로 부품 재생성/모듈 이름 수정) 안내
- 회귀 하네스: 취소/초안 실패/도중 어긋난 상태 저장 감시 (옛 코드에서 4개 항목 실패 확인)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 10:31:53 +09:00
co-authored by Claude Sonnet 5
parent b611c045b6
commit 86d541d928
4 changed files with 43 additions and 19 deletions
+1 -1
View File
@@ -199,7 +199,7 @@ async function doRender(workspace: string, projectId: string, assembly: Assembly
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}이(가) 없습니다.` };
if (!modules.has(parsed.module)) return { ok: false, error: `부품 "${p.name}": ${path.basename(assembly.scad)}에 module ${parsed.module}이(가) 없습니다. 다른 .scad로 바꿨다면 ✨ 자동 초안으로 부품을 다시 만들거나, 부품의 모듈 호출을 그 파일에 있는 이름으로 고쳐 주세요.` };
}
const orphan = unassignedParts(assembly);
if (orphan.length) warnings.push(`어느 단계에도 배정되지 않아 단계 그림에 안 나오는 부품: ${orphan.map(p => p.name).join(', ')}`);
+21 -6
View File
@@ -22,6 +22,7 @@ const OUT = process.env.SHOT_DIR || '/tmp';
fs.copyFileSync(src + 'proj_scan_dental/CAD/scanner-rig.scad', path.join(ws, 'workshop/pa/CAD/scanner-rig.scad'));
fs.copyFileSync(src + 'proj_scan_dental_diy/CAD/scanner-diy-rig.scad', path.join(ws, 'workshop/pa/CAD/scanner-diy-rig.scad'));
fs.copyFileSync(src + 'proj_h70rk5i5/CAD/robot_arm_demo.scad', path.join(ws, 'workshop/pa/CAD/robot_arm_demo.scad'));
fs.writeFileSync(path.join(ws, 'workshop/pa/CAD/no_modules.scad'), 'cube(5);\n'); // 조립 호출이 없어 자동 초안이 실패하는 파일
// 사용자가 이미 scanner-rig.scad를 연결해 둔 상태(부품 1개)
await fetch(`${base}/api/workshop/project/pa?force=1`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'pa', name: '선택 시험', parts: [], phases: [], notes: '',
assembly: { scad: 'workshop/pa/CAD/scanner-rig.scad', parts: [{ id: 'a1', name: '베이스', call: 'base_plate()', offset: [0, 0, 0] }], steps: [] } }) });
@@ -38,28 +39,42 @@ const OUT = process.env.SHOT_DIR || '/tmp';
const ck = (n: string, ok: boolean, x = '') => res.push(`${ok ? 'PASS' : 'FAIL'} ${n}${x ? ' — ' + x : ''}`);
await page.waitForTimeout(2000);
let o = await opts();
ck('선택 상자에 프로젝트의 .scad 3개가 표시됨(예전엔 0개)', o.length === 3, o.join(' | '));
ck('선택 상자에 프로젝트의 .scad 4개가 표시됨(예전엔 0개)', o.length === 4, o.join(' | '));
ck('현재 연결된 파일이 선택되어 있음', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
ck('개수 안내 표시', (await page.locator('#asm-scad-count').innerText()).includes('3개'));
ck('개수 안내 표시', (await page.locator('#asm-scad-count').innerText()).includes('4개'));
await page.screenshot({ path: `${OUT}/scad-after.png` });
// B) 이미 부품이 있을 때 다른 파일 선택 → 확인창 취소: 경로만 바뀌고 부품 유지
// B) 이미 부품이 있을 때 다른 파일 선택 → 확인창 취소: 아무것도 바뀌지 않고 선택이 원래 파일로 되돌아간다
page.removeAllListeners('dialog'); let dlgMsg = '';
page.on('dialog', (d: any) => { dlgMsg = d.message(); d.dismiss(); });
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/robot_arm_demo.scad');
await page.waitForTimeout(600);
await page.waitForTimeout(700);
ck('확인창이 뜸(기존 부품 교체 여부)', dlgMsg.includes('자동 초안으로 바꿀까요'));
ck('취소 시 경로만 변경 + 부품 유지', (await page.inputValue('#asm-scad')) === 'workshop/pa/CAD/robot_arm_demo.scad' && (await page.locator('.asm-part').count()) === 1);
ck('취소 후 선택 상자가 새 파일을 가리킴', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/robot_arm_demo.scad');
ck('취소 시 경로·부품 그대로(어긋난 상태를 만들지 않음)', (await page.inputValue('#asm-scad')) === 'workshop/pa/CAD/scanner-rig.scad' && (await page.locator('.asm-part').count()) === 1);
ck('취소 후 선택 상자가 원래 파일로 복귀', (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad');
// D) 수락했지만 초안이 실패하는 파일(.scad에 모듈이 없음): 경로·부품이 그대로이고 오류 안내만
page.removeAllListeners('dialog'); const dlgs: string[] = [];
page.on('dialog', (d: any) => { dlgs.push(d.message()); d.accept(); });
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/no_modules.scad');
await page.waitForTimeout(2500);
const afterFail = (await (await fetch(`${base}/api/workshop/assembly/pa`)).json()).project.assembly;
ck('초안 실패 시 저장된 경로·부품 그대로', afterFail.scad === 'workshop/pa/CAD/scanner-rig.scad' && afterFail.parts.length === 1, `${afterFail.scad} / 부품 ${afterFail.parts.length}`);
ck('초안 실패 안내 표시 + 선택 상자 원복', (await page.locator('#asm-status').innerText()).includes('자동 초안 실패') && (await page.inputValue('#asm-scad-select')) === 'workshop/pa/CAD/scanner-rig.scad', dlgs.slice(-1)[0]?.slice(0, 40));
// A) 확인창 수락: 새 파일의 자동 초안으로 교체 + 자동 렌더
page.removeAllListeners('dialog'); page.on('dialog', (d: any) => d.accept());
// 선택~교체 사이에 서버에 "새 경로 + 옛 부품(1개)" 같은 어긋난 상태가 저장되는 순간이 있었는지 감시(09-25 신고의 원인)
const mismatched: string[] = []; let watching = true;
const watcher = (async () => { while (watching) { try { const a = (await (await fetch(`${base}/api/workshop/assembly/pa`)).json()).project.assembly; if (a && a.scad.endsWith('scanner-diy-rig.scad') && a.parts.length === 1) mismatched.push(`${a.scad}/${a.parts.length}`); } catch { /* noop */ } await new Promise(r => setTimeout(r, 80)); } })();
await page.selectOption('#asm-scad-select', 'workshop/pa/CAD/scanner-diy-rig.scad');
await page.waitForFunction(() => document.querySelectorAll('.asm-part').length === 6, null, { timeout: 30000 });
ck('수락 시 새 .scad의 자동 초안으로 교체(부품 6개)', true);
await page.waitForSelector('.asm-imgs figure', { timeout: 90000 });
ck('새 파일 기준으로 그림 자동 렌더', (await page.locator('.asm-imgs figure').count()) >= 3);
const saved = await (await fetch(`${base}/api/workshop/assembly/pa`)).json();
watching = false; await watcher;
ck('선택 도중 어긋난 상태(새 경로+옛 부품)가 서버에 저장된 적 없음', mismatched.length === 0, mismatched.slice(0, 2).join(','));
ck('서버에 저장된 조립 모델 경로가 바뀜', saved.project.assembly.scad === 'workshop/pa/CAD/scanner-diy-rig.scad' && saved.project.assembly.parts.length === 6);
// C) 직접 입력하면 선택 상자도 따라감 / 목록 밖 경로는 "(목록 밖)"으로 표시
+2
View File
@@ -190,6 +190,8 @@ print(W, H, orange, int((edge.sum(axis=1) < 740).sum()))
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.match((bad as any).error, /m\.scad/); // 어느 파일인지
assert.match((bad as any).error, /자동 초안으로 부품을 다시 만들거나/); // 해결 방법 안내
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);
});
+19 -12
View File
@@ -70,7 +70,7 @@ async function asmAutoLink(){
await asmRender(true);
}
}
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();}
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;asmLastError='';scheduleSave();renderAsmStatus();}
function renderAssembly(){
const pane=document.getElementById('pane-assembly');
@@ -211,20 +211,26 @@ function refreshScadSelect(){
const c=document.getElementById('asm-scad-count');
if(c)c.textContent=asmScadCountText();
}
// 다른 .scad를 고르면: 부품이 비어 있으면 그 파일의 자동 초안+렌더, 이미 있으면 바꿀지 묻는다(취소하면 경로만 변경).
// 다른 .scad를 고르면: 부품이 비어 있으면 그 파일로 연결+자동 초안+렌더, 이미 있으면 "교체할지" 먼저 묻는다.
// 핵심 규칙: 경로만 먼저 바꾸고 부품을 나중에 바꾸는 "어긋난 중간 상태"를 만들지 않는다 — 그 사이에 저장 타이머/자동 렌더가
// 돌면 새 파일에 없는 모듈(예: 옛 파일의 motor())로 렌더가 실패했다(09-25 신고). 그래서 초안이 성공한 뒤에야 경로+부품을 한 번에 바꾸고,
// 취소하거나 초안이 실패하면 선택을 원래 파일로 되돌린다(경로만 바꾸고 싶으면 아래 "경로 직접 입력" 사용).
async function asmPickScad(v){
const a=ensureAssembly();
if(!v||v===a.scad)return;
const hadParts=a.parts.length>0;
a.scad=v;asmScanMods=null;asmDraftInfo=null;
const inp=document.getElementById('asm-scad');if(inp)inp.value=v;
asmMarkChanged();
if(hadParts&&!confirm('다른 .scad를 선택했습니다.\n\n기존 부품/단계를 "'+scadLabel(v)+'"의 자동 초안으로 바꿀까요?\n(취소하면 경로만 바꾸고 기존 부품은 그대로 둡니다 — 모듈 이름이 맞지 않으면 렌더가 실패할 수 있어요. 되돌리기는 🕘 이력에서 가능)')){
renderAssembly();
if(hadParts&&!confirm('다른 .scad를 선택했습니다.\n\n기존 부품/단계를 "'+scadLabel(v)+'"의 자동 초안으로 바꿀까요?\n\n취소하면 선택이 원래 파일로 되돌아갑니다.\n(경로만 바꾸려면 "경로 직접 입력"을 쓰세요 — 이때 부품의 모듈 이름이 새 파일에 없으면 렌더가 실패합니다. 되돌리기는 🕘 이력에서 가능)')){
refreshScadSelect();
return;
}
if(await asmDraft(!hadParts,{skipConfirm:true}))await asmRender(true);
else renderAssembly();
if(!hadParts){ // 부품이 없으면 어긋날 게 없다 — 경로는 바로 연결
a.scad=v;asmScanMods=null;asmDraftInfo=null;
const inp=document.getElementById('asm-scad');if(inp)inp.value=v;
asmMarkChanged();
}
const ok=await asmDraft(!hadParts,{skipConfirm:true,scad:v});
if(ok)await asmRender(true);
else renderAssembly(); // 실패/건너뜀: 기존 명세 그대로, 선택 상자도 원래 값으로
}
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
@@ -359,12 +365,13 @@ function asmDraftNoteHtml(){
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
async function asmDraft(auto,opts){
const a=ensureAssembly();
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
const target=(opts&&opts.scad)||a.scad; // 선택한 새 파일(아직 확정 전) 또는 현재 연결된 파일
if(!target){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
if(!auto&&!(opts&&opts.skipConfirm)&&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 r=await fetch('/api/workshop/assembly-draft',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({scad:target})});
const d=await r.json().catch(()=>({}));
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
// 자동 흐름에서는 "조립 블록(if PART=="all")"이 있는 .scad만 초안을 채운다 — 데모/애니메이션 파일로 엉뚱한
@@ -374,7 +381,7 @@ async function asmDraft(auto,opts){
renderAssembly();
return false;
}
const keep={scad:a.scad,autoRender:a.autoRender!==false,view:a.view,explode:a.explode};
const keep={scad:target,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;