Files
homeclaw/web-ui/html/assembly-manual.html
kimandClaude Sonnet 5 8677a090d5 feat: 작업실 "조립 설명서" — .scad에서 단계별 조립도·분해도·부품표를 자동 생성
- 명세(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>
2026-09-24 23:03:43 +09:00

160 lines
10 KiB
HTML
Raw Permalink 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.
<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>