Files
homeclaw/web-ui/js/app/workshop-assembly.js
T
kimandClaude Sonnet 5 0b7057f975 refactor: 작업실 스크립트에서 조립·장비 탭을 별도 모듈로 분리하고 지연 로딩
- workshop-app.js 114KB → 83KB(핵심), workshop-assembly.js(22KB)·workshop-equip.js(12KB)는 해당 탭을 처음 열 때만 로드
- 코드 내용 변경 없음(구간을 잘라 옮김). 모듈 밖에서 부르던 이름은 refreshAssemblyPane(shim)과
  startEquipTimers/stopEquipTimers 뿐이라 가드된 로더(loadWorkshopModule/withLazyModule)로 감쌈
- 로드 중엔 패널을 흐리게(.rb-loading)+클릭 차단, 로드 중 다른 탭으로 옮기면 실행 안 함,
  실패 시 안내 표시 + 다시 열면 재시도(같은 모듈은 한 번만 받음)
- 브라우저 하네스에 지연 로딩 5개 항목 추가(시작 시 미로드/탭별 로드/재요청 없음/실패 안내/재시도), 전체 43개 항목 통과

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 09:53:49 +09:00

368 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 작업실 앱 — 조립 설명서 탭 모듈(2026-09-25 workshop-app.js에서 분리, 내용 변경 없음).
// 명세 편집(부품/단계), .scad 스캔·자동 초안, 렌더, 자동 연결/재렌더.
// workshop-app.js가 탭을 처음 열 때 지연 로딩한다(classic script라 핵심 코드의 전역 변수/함수를 그대로 쓴다).
// ── 조립 설명서 탭 ────────────────────────────────────────────────────────────
// 명세(부품/단계)는 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;
let asmAutoDone=''; // 자동 연결/초안을 이미 시도한 프로젝트 id(한 번만)
let asmDraftInfo=null; // 마지막 자동 초안의 제외/경고 안내
let asmLastError='';
function asmPaneActive(){return document.getElementById('pane-assembly').classList.contains('active');}
let asmProjectId='';
function refreshAssemblyPaneImpl(forceLoad){
const pid=(currentProject()||{}).id||'';
if(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;asmDraftInfo=null;asmLastError='';} // 프로젝트가 바뀌면 그림 정보 초기화
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())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(a.scad&&!a.parts.length&&!a.steps.length&&asmAutoDone!==asmProjectId+':draft'){
asmAutoDone=asmProjectId+':draft';
if(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(){
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>
<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)">
<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(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>':'');
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):(k==='autoRender'?!!v: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(silent){
const a=assembly;
if(!a||!a.parts.length||!a.steps.length){if(!silent)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;
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;
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+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.');
}