feat: 도면 버튼 — 작업실 STL 카드·STL 편집기에서 3면도 즉시 생성

- 작업실 파일 카드(STL)에 📐 버튼: /api/cad/render?view=three 를 fetch→blob으로
  생성해 새 탭에 표시(Authorization 헤더 필요라 단순 링크 불가). 생성 중 표시 후
  결과로 교체. 카드 하단 좌측 오버레이 버튼.
- STL 편집기 파일 행에 📐 버튼(로드 없이 선택만으로 도면 가능).
- 3면도 실루엣 판정 수정: 옛 '모서리 색과의 차이' 방식은 원통처럼 뷰가 사각형을
  못 채우는 부품에서 bbox 모서리에 옛 배경(cream)이 사각형으로 남았다 — 흰 시트
  기준 임계로 판정하고 크롭 내부 잔여 배경도 흰색으로 통일(2026-09-25 turntable
  실측). 참고: PIL floodfill은 fill 값과 배경이 thresh 이내면 실행 자체를 안 함.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-25 19:45:12 +09:00
co-authored by Claude Code
parent 3761390d8e
commit a61086d93c
4 changed files with 64 additions and 3 deletions
+11 -3
View File
@@ -44,12 +44,20 @@ W, D, H = float(dims['w']), float(dims['d']), float(dims['h'])
def silhouette(p):
im = Image.open(p).convert('RGB')
a = np.asarray(im).astype(int)
bg = a[2, 2]
m = (np.abs(a - bg).sum(axis=2) > 20)
# Cornfield 배경(cream 255,255,229)과 물체(gold)의 명도 차이가 크므로 "흰 시트에서 충분히
# 다른 픽셀 = 실루엣"으로 잡는다. 옛 방식(모서리(2,2) 색과의 차이)은 원통 같은 부품에서
# bbox 사각형 모서리에 옛 배경색이 그대로 남았다(2026-09-25 turntable 실측).
m = (np.abs(a - 255).sum(axis=2) > 60)
ys, xs = np.nonzero(m)
if len(xs) < 5:
return None
return im.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
c = im.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
# 크롭 안에 남은 옛 배경(물체에 둘러싸인 영역 포함)은 흰색으로 통일 — 시트 위에 사각형이 그대로 보이는 것을 막는다.
ca = np.asarray(c)
cm = (np.abs(ca.astype(int) - 255).sum(axis=2) > 60)
out = np.full(ca.shape, 255, np.uint8)
out[cm] = ca[cm]
return Image.fromarray(out)
crops = {}
for key in ('front', 'top', 'right'):
+3
View File
@@ -284,6 +284,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
숨어있다 호버 시 보이되, 체크된 상태면 계속 보임(선택 중임을 표시). */
.rb-file-stl-sel{position:absolute;top:4px;left:4px;z-index:2;width:16px;height:16px;cursor:pointer;opacity:0;transition:.15s;accent-color:#f59e0b;}
.rb-file-card:hover .rb-file-stl-sel,.rb-file-stl-sel:checked{opacity:1;}
.rb-file-draw{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);border:none;border-radius:5px;color:#fff;font-size:11px;padding:2px 6px;cursor:pointer;opacity:0;transition:.15s;}
.rb-file-card:hover .rb-file-draw{opacity:1;}
.rb-file-draw:hover{background:#f97316;}
.rb-file-card.stl-selected{outline:2px solid #f59e0b;outline-offset:-1px;}
/* 지연 로딩 모듈을 받는 동안 탭 패널을 흐리게 하고 클릭을 막는다 */
+32
View File
@@ -106,9 +106,12 @@ function renderFileCard(file){
// STL만 슬라이서로 보낼 다중선택 체크박스(09-22) — 여러 부품 STL을 한 판에 얹어 같이 슬라이싱.
const checked=stlSelection.has(file.relPath);
const selBox=isStl?`<input type="checkbox" class="rb-file-stl-sel" title="슬라이서로 보내기 선택" ${checked?'checked':''} onclick="event.stopPropagation()" onchange="toggleStlSelect('${escJs(file.relPath)}',this.checked)">`:'';
// STL 전용 도면 버튼(09-25) — OpenSCAD 3면도(외곽 치수선) 새 탭 생성.
const drawBtn=isStl?`<button class="rb-file-draw" onclick="openDrawingForFile('${escJs(file.relPath)}')" title="3면도 도면 생성">📐</button>`:'';
return `<div class="rb-file-card${checked?' stl-selected':''}" data-rel="${escAttr(file.relPath)}">
${selBox}
<button class="rb-file-del" onclick="deleteFile('${escJs(file.relPath)}')" title="삭제">✕</button>
${drawBtn}
<a ${openAttrs}>
<div class="rb-file-thumb">${thumb}</div>
<div class="rb-file-info">
@@ -137,6 +140,35 @@ function openCadEditorForFile(relPath){
window.open('/html/cad-editor-app.html?path='+encodeURIComponent(workspacePath),'cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
}
// STL 카드의 "📐" — OpenSCAD 3면도(외곽 치수선 도면)를 생성해 새 탭으로 보여준다(09-25).
// /api/cad/render는 Authorization 헤더가 필요하므로 fetch→blob→objectURL로 연다(단순 링크 안 됨).
// 팝업은 동기으로 먼저 열어 팝업차단을 피하고, 생성 중 표시 후 결과로 교체.
function openDrawingForFile(relPath){
const workspacePath=workshopFilePath(relPath);
if(!workspacePath)return;
const w=1100,h=940;
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
const win=window.open('','cad-drawing',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
if(!win){alert('팝업이 차단됐습니다. 팝업 허용 후 다시 시도하세요.');return;}
win.document.write('<!doctype html><html><head><meta charset="utf-8"><title>도면 생성 중…</title></head><body style="margin:0;font-family:sans-serif;background:#1a1a1a;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh">📐 도면 생성 중… (OpenSCAD 3면도, 몇 초 걸림)</body></html>');
(async()=>{
try{
const r=await fetch('/api/cad/render?path='+encodeURIComponent(workspacePath)+'&view=three',{headers:authH()});
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.error||('HTTP '+r.status));}
const url=URL.createObjectURL(await r.blob());
win.document.title='3면도';
win.document.body.style.cssText='margin:0;background:#2a2a2a;display:flex;align-items:center;justify-content:center;height:100vh';
win.document.body.innerHTML='';
const img=win.document.createElement('img');
img.src=url;img.style.cssText='max-width:100%;max-height:100%;object-fit:contain;';
win.document.body.appendChild(img);
}catch(e){
win.document.body.style.cssText='margin:0;font-family:sans-serif;background:#1a1a1a;color:#ef4444;display:flex;align-items:center;justify-content:center;height:100vh';
win.document.body.textContent='도면 생성 실패: '+e.message;
}
})();
}
// ── 슬라이서 연동(09-22) ────────────────────────────────────────────────────
// STL 카드 체크박스로 여러 부품(예: 스캐너 rig의 base_plate+head_plate+turntable 등)을
// 골라 K2 슬라이서 팝업(slicer-app.html)에 한 번에 올린다. 실제 로드는 slicer-view.js의
+18
View File
@@ -441,6 +441,22 @@
} catch (e) { setErr('저장 실패: ' + e.message); }
}
// ── 도면(3면도) ───────────────────────────────────────────────────────
// 선택된 STL의 제작용 3면도(외곽 치수선)를 서버(OpenSCAD)에서 생성해 새 탭으로 연다.
// /api/cad/render는 Authorization 헤더가 필요하므로 fetch→blob→objectURL.
async function openDrawing() {
const p = panelEl.querySelector('#ce-file').value;
if (!p) { setErr('파일을 먼저 선택하세요.'); return; }
setErr(''); setInfo('📐 도면 생성 중… (OpenSCAD 3면도, 몇 초 걸림)');
try {
const r = await fetch('/api/cad/render?path=' + encodeURIComponent(p) + '&view=three', { headers: authH() });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || ('HTTP ' + r.status)); }
const url = URL.createObjectURL(await r.blob());
window.open(url, '_blank');
setInfo('📐 3면도를 새 탭으로 열었습니다.');
} catch (e) { setErr('도면 생성 실패: ' + e.message); }
}
// ── UI 구축 ───────────────────────────────────────────────────────────
function setErr(msg) {
const el = barEl.querySelector('#ce-err');
@@ -465,6 +481,7 @@
<div class="ce-row">
<select id="ce-file" class="ce-grow"></select>
<button class="ce-mini" onclick="window.__cadRefresh()">↺</button>
<button class="ce-mini" onclick="window.__cadDrawing()">📐</button>
<button class="ce-mini primary" onclick="window.__cadLoad()">로드</button>
</div>
<div id="ce-dims" class="ce-dims"><span class="ce-muted">모델 없음</span></div>
@@ -639,6 +656,7 @@
// 글로벌 노출(인라인 onclick).
window.__cadRefresh = loadFileList;
window.__cadDrawing = openDrawing;
window.__cadLoad = loadSelected;
window.__cadFlip = flipAxis;
window.__cadMirror = mirrorAxis;