- 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>
368 lines
22 KiB
JavaScript
368 lines
22 KiB
JavaScript
// 작업실 앱 — 조립 설명서 탭 모듈(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+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.');
|
||
}
|
||
|