- 명세(project.assembly): 부품(call=조립 모듈 호출, 분해 offset, BOM 연결, 수량) + 조립 단계(부품/체결부품/공구/설명).
프로젝트 데이터라 이력·충돌감지·복제가 그대로 적용된다
- 렌더러: 원본 .scad를 수정하지 않고 `use <>` wrapper로 부품을 골라 OpenSCAD ortho 렌더. 안쪽 color가 우선이라
색을 못 덮는 제약을 "전체 렌더 vs 부품 뺀 렌더"의 픽셀 차이로 해결 → 이번 단계 부품만 원색+주황 외곽선,
나머지는 옅게, 부품 번호 풍선은 그 부품이 실제 보이는 영역에 배치(가림 처리 정확). 모든 그림 같은 카메라
(bbox 기반 fit, 투영식 실측 검증), 공통 여백 크롭으로 단계 간 축척 유지
- call 검증: 모듈 1개 호출만(따옴표/세미콜론/중괄호/주석/import·surface 등 금지) + .scad 안에 있는 module만
- 대시보드 "조립" 탭: .scad 스캔→부품 추가, 분해 offset, 단계 편집, 그림 렌더, 작업 탭으로 가져오기
- 설명서 페이지 assembly-manual.html: 완성/분해도, 부품표(BOM 구매상태), 체결부품·공구 합계, STEP 카드, A4 인쇄/PDF
- 채팅 도구: scan_scad / get_assembly / set_assembly / render_assembly ("조립 설명서 만들어줘" → AI가 초안)
- 테스트: 순수함수·실제 OpenSCAD/PIL 렌더(가장자리 잘림 없음 검증)·라우트 HTTP 통합·실브라우저 하네스
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
10 KiB
HTML
160 lines
10 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>조립 설명서</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||
<style>
|
||
/* 문서 페이지 — 테마와 무관하게 항상 밝은 배경(인쇄/PDF 용). */
|
||
*{box-sizing:border-box;}
|
||
html,body{margin:0;padding:0;background:#eceff3;color:#1f2933;font-family:system-ui,-apple-system,'Noto Sans KR','Malgun Gothic',sans-serif;}
|
||
.toolbar{position:sticky;top:0;z-index:10;display:flex;gap:10px;align-items:center;padding:8px 16px;background:#1f2933;color:#fff;font-size:13px;}
|
||
.toolbar a,.toolbar button{color:#fff;text-decoration:none;background:none;border:1px solid rgba(255,255,255,.35);border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit;}
|
||
.toolbar a:hover,.toolbar button:hover{background:rgba(255,255,255,.15);}
|
||
.toolbar .sp{flex:1;}
|
||
.toolbar .stale{color:#fbbf24;font-size:12px;}
|
||
.sheet{max-width:920px;margin:18px auto;background:#fff;padding:36px 44px;box-shadow:0 2px 14px rgba(0,0,0,.12);}
|
||
h1{font-size:26px;margin:0 0 4px;}
|
||
.meta{color:#6b7785;font-size:12px;margin-bottom:18px;}
|
||
.desc{font-size:13px;line-height:1.7;margin:0 0 18px;white-space:pre-wrap;}
|
||
h2{font-size:15px;margin:26px 0 10px;padding-bottom:5px;border-bottom:2px solid #f59e0b;}
|
||
.cover-imgs{display:grid;grid-template-columns:1fr 1fr;gap:10px;align-items:start;}
|
||
.cover-imgs figure{margin:0;border:1px solid #e1e5ea;border-radius:8px;overflow:hidden;}
|
||
.cover-imgs img{display:block;width:100%;height:auto;}
|
||
.step-img{display:flex;align-items:center;justify-content:center;}
|
||
.step-img img{display:block;max-width:100%;max-height:440px;width:auto;height:auto;}
|
||
figcaption{font-size:11px;color:#6b7785;text-align:center;padding:4px 0;border-top:1px solid #e1e5ea;background:#fafbfc;}
|
||
table{width:100%;border-collapse:collapse;font-size:12.5px;}
|
||
th,td{border:1px solid #d5dae0;padding:5px 9px;text-align:left;vertical-align:top;}
|
||
th{background:#f3f5f7;font-weight:700;font-size:11.5px;}
|
||
td.n{width:44px;text-align:center;}
|
||
td.q{width:60px;text-align:center;}
|
||
.num{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;border-radius:11px;background:#f59e0b;color:#fff;font-weight:700;font-size:12px;padding:0 5px;}
|
||
.badge{font-size:11px;padding:1px 8px;border-radius:9px;background:#e5e9ee;color:#4b5866;}
|
||
.badge.보유{background:#d9f5e3;color:#1e7a45;}
|
||
.badge.주문완료{background:#dbeafe;color:#1d4ed8;}
|
||
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
|
||
.step{border:1px solid #d5dae0;border-radius:10px;margin:14px 0;overflow:hidden;break-inside:avoid;page-break-inside:avoid;}
|
||
.step-hdr{display:flex;align-items:center;gap:10px;background:#1f2933;color:#fff;padding:8px 14px;}
|
||
.step-hdr .no{background:#f59e0b;border-radius:6px;padding:2px 10px;font-weight:800;font-size:13px;}
|
||
.step-hdr .title{font-weight:700;font-size:15px;flex:1;}
|
||
.step-hdr .chk{width:20px;height:20px;border:2px solid #fff;border-radius:4px;background:transparent;flex-shrink:0;}
|
||
.step-body{display:grid;grid-template-columns:1.25fr 1fr;gap:0;}
|
||
.step-img{border-right:1px solid #e1e5ea;background:#fff;}
|
||
.step-info{padding:12px 14px;font-size:12.5px;line-height:1.65;}
|
||
.step-info h4{margin:12px 0 4px;font-size:11px;color:#6b7785;text-transform:uppercase;letter-spacing:.05em;}
|
||
.step-info h4:first-child{margin-top:0;}
|
||
.step-info .txt{white-space:pre-wrap;}
|
||
.chips{display:flex;flex-direction:column;gap:4px;}
|
||
.chip{display:flex;align-items:center;gap:7px;}
|
||
ul.plain{margin:0;padding-left:18px;}
|
||
.empty{padding:40px;text-align:center;color:#6b7785;font-size:14px;}
|
||
.note{font-size:11.5px;color:#8a5a00;background:#fff7e6;border:1px solid #f5d9a3;border-radius:6px;padding:6px 10px;margin:10px 0;}
|
||
@media (max-width:720px){.sheet{padding:18px 14px}.cover-imgs,.two,.step-body{grid-template-columns:1fr}.step-img{border-right:none;border-bottom:1px solid #e1e5ea}}
|
||
@page{size:A4;margin:12mm;}
|
||
@media print{
|
||
html,body{background:#fff;}
|
||
.toolbar{display:none;}
|
||
.sheet{box-shadow:none;margin:0;max-width:none;padding:0;}
|
||
h2{break-after:avoid;}
|
||
.step{margin:10px 0;}
|
||
.step-hdr,.num,.badge,th{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toolbar no-print">
|
||
<a id="back" href="/html/workshop-app.html">← 작업실</a>
|
||
<span id="title-mini" style="font-weight:700"></span>
|
||
<span class="sp"></span>
|
||
<span class="stale" id="stale" style="display:none">⚠ 설명서 그림이 현재 명세/모델과 다릅니다 — 작업실 "조립" 탭에서 다시 렌더하세요</span>
|
||
<button onclick="window.print()">🖨 인쇄 / PDF 저장</button>
|
||
</div>
|
||
<div class="sheet" id="sheet"><div class="empty">불러오는 중…</div></div>
|
||
|
||
<script>
|
||
const TOKEN_KEY='smallclaw_token';
|
||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||
function authH(){const t=getToken();return t?{Authorization:'Bearer '+t}:{};}
|
||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
||
|
||
async function main(){
|
||
const projectId=new URLSearchParams(location.search).get('projectId')||'';
|
||
document.getElementById('back').href='/html/workshop-app.html';
|
||
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname+location.search);return;}
|
||
const sheet=document.getElementById('sheet');
|
||
let d;
|
||
try{
|
||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(projectId),{headers:authH()});
|
||
if(r.status===401){location.href='/login.html?redirect='+encodeURIComponent(location.pathname+location.search);return;}
|
||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||
d=await r.json();
|
||
}catch(e){sheet.innerHTML='<div class="empty">설명서를 불러오지 못했습니다: '+esc(e.message)+'</div>';return;}
|
||
|
||
const proj=d.project,a=proj.assembly;
|
||
document.title=proj.name+' 조립 설명서';
|
||
document.getElementById('title-mini').textContent=proj.name+' — 조립 설명서';
|
||
if(!a||!a.steps.length){sheet.innerHTML='<div class="empty">아직 조립 설명서 명세가 없습니다.<br>작업실의 "조립" 탭에서 부품과 단계를 만들거나, 채팅에 "조립 설명서 만들어줘"라고 해 보세요.</div>';return;}
|
||
if(d.stale)document.getElementById('stale').style.display='inline';
|
||
|
||
const bomStatus=name=>{
|
||
if(!name)return '';
|
||
const b=(proj.parts||[]).find(p=>String(p.name).trim().toLowerCase()===String(name).trim().toLowerCase());
|
||
return b?'<span class="badge '+esc(b.status)+'">'+esc(b.status)+'</span>':'<span class="badge">BOM 미연결</span>';
|
||
};
|
||
const numOf=new Map(a.parts.map((p,i)=>[p.id,i+1]));
|
||
const stepsOfPart=new Map();
|
||
a.steps.forEach((s,i)=>s.partIds.forEach(id=>{if(!stepsOfPart.has(id))stepsOfPart.set(id,[]);stepsOfPart.get(id).push(i+1);}));
|
||
|
||
// 체결 부품 합계 / 공구 합집합
|
||
const fast=new Map();const tools=new Set();
|
||
a.steps.forEach(s=>{s.fasteners.forEach(f=>fast.set(f.name,(fast.get(f.name)||0)+f.qty));s.tools.forEach(t=>tools.add(t));});
|
||
|
||
let h='<h1>'+esc(proj.name)+' 조립 설명서</h1>';
|
||
h+='<div class="meta">부품 '+a.parts.length+'개 · 조립 '+a.steps.length+'단계'+(d.renderedAt?' · 그림 생성 '+new Date(d.renderedAt).toLocaleString('ko-KR'):'')+'</div>';
|
||
if(proj.description)h+='<p class="desc">'+esc(proj.description.length>600?proj.description.slice(0,600)+'…':proj.description)+'</p>';
|
||
if(!d.images)h+='<div class="note">그림이 아직 렌더되지 않았습니다. 작업실 "조립" 탭에서 🖼 렌더링을 눌러 주세요.</div>';
|
||
if(d.images){
|
||
h+='<div class="cover-imgs"><figure><img src="'+esc(d.images.overview)+'" alt="조립 완성"><figcaption>조립 완성</figcaption></figure>'
|
||
+(d.images.exploded?'<figure><img src="'+esc(d.images.exploded)+'" alt="분해도"><figcaption>분해도 (번호 = 부품 목록)</figcaption></figure>':'')+'</div>';
|
||
}
|
||
if(d.stale&&d.images)h+='<div class="note">이 그림은 명세/모델이 바뀌기 전 상태일 수 있습니다.</div>';
|
||
|
||
h+='<h2>부품 목록</h2><table><thead><tr><th>번호</th><th>부품</th><th>수량</th><th>구매 상태(BOM)</th><th>조립 단계</th></tr></thead><tbody>';
|
||
a.parts.forEach((p,i)=>{
|
||
h+='<tr><td class="n"><span class="num">'+(i+1)+'</span></td><td>'+esc(p.name)+(p.bom&&p.bom!==p.name?'<div style="color:#6b7785;font-size:11px">BOM: '+esc(p.bom)+'</div>':'')+'</td><td class="q">'+p.qty+'</td><td>'+bomStatus(p.bom)+'</td><td>'
|
||
+((stepsOfPart.get(p.id)||[]).map(n=>'STEP '+n).join(', ')||'<span style="color:#b45309">미배정</span>')+'</td></tr>';
|
||
});
|
||
h+='</tbody></table>';
|
||
|
||
if(fast.size||tools.size){
|
||
h+='<h2>준비물</h2><div class="two">';
|
||
h+='<div><table><thead><tr><th>체결 부품(전체 합계)</th><th style="width:60px">수량</th></tr></thead><tbody>'
|
||
+(fast.size?[...fast].map(([n,q])=>'<tr><td>'+esc(n)+'</td><td class="q">'+q+'</td></tr>').join(''):'<tr><td colspan="2" style="color:#6b7785">없음</td></tr>')+'</tbody></table></div>';
|
||
h+='<div><table><thead><tr><th>공구</th></tr></thead><tbody>'
|
||
+(tools.size?[...tools].map(t=>'<tr><td>'+esc(t)+'</td></tr>').join(''):'<tr><td style="color:#6b7785">없음</td></tr>')+'</tbody></table></div></div>';
|
||
}
|
||
|
||
h+='<h2>조립 순서</h2>';
|
||
a.steps.forEach((s,i)=>{
|
||
const img=d.images&&d.images.steps[i];
|
||
h+='<div class="step"><div class="step-hdr"><span class="no">STEP '+(i+1)+'</span><span class="title">'+esc(s.title)+'</span><span class="chk" title="완료 체크"></span></div><div class="step-body">';
|
||
h+='<div class="step-img">'+(img?'<img src="'+esc(img)+'" alt="STEP '+(i+1)+'">':'<div class="empty" style="padding:60px 10px">그림 없음</div>')+'</div>';
|
||
h+='<div class="step-info">';
|
||
if(s.desc)h+='<h4>작업 내용</h4><div class="txt">'+esc(s.desc)+'</div>';
|
||
if(s.partIds.length){
|
||
h+='<h4>이번 단계 부품</h4><div class="chips">'+s.partIds.map(id=>{const p=a.parts.find(x=>x.id===id);return p?'<div class="chip"><span class="num">'+numOf.get(id)+'</span>'+esc(p.name)+(p.qty>1?' × '+p.qty:'')+'</div>':'';}).join('')+'</div>';
|
||
}
|
||
if(s.fasteners.length)h+='<h4>체결 부품</h4><ul class="plain">'+s.fasteners.map(f=>'<li>'+esc(f.name)+' × '+f.qty+'</li>').join('')+'</ul>';
|
||
if(s.tools.length)h+='<h4>공구</h4><ul class="plain">'+s.tools.map(t=>'<li>'+esc(t)+'</li>').join('')+'</ul>';
|
||
h+='</div></div></div>';
|
||
});
|
||
sheet.innerHTML=h;
|
||
}
|
||
main();
|
||
</script>
|
||
</body>
|
||
</html>
|