fix: .scad 선택 시 "새 경로 + 옛 부품" 어긋난 상태로 렌더가 실패하던 문제
- 원인: 다른 .scad를 고르면 경로만 먼저 바꾸고 부품은 나중에(또는 취소하면 영영) 안 바뀌어, 그 사이 저장 타이머/자동 렌더가 새 파일에 없는 모듈(옛 파일의 motor())로 렌더 → "부품 motor: .scad에 module motor이(가) 없습니다"가 화면에 남음 - 확인창을 먼저 띄우고, 자동 초안이 성공한 뒤에만 경로+부품을 한 번에 교체. 취소/초안 실패 시 아무것도 바꾸지 않고 선택 상자만 원복 (경로만 바꾸려면 "경로 직접 입력") - 편집하면 이전 렌더 오류 문구를 지움. 서버 오류 문구에 파일 이름과 해결 방법(자동 초안으로 부품 재생성/모듈 이름 수정) 안내 - 회귀 하네스: 취소/초안 실패/도중 어긋난 상태 저장 감시 (옛 코드에서 4개 항목 실패 확인) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(', ')}`);
|
||||
|
||||
@@ -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) 직접 입력하면 선택 상자도 따라감 / 목록 밖 경로는 "(목록 밖)"으로 표시
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user