feat: "STL 확인" → Three.js 3D STL 편집기로 전환

- 신규 팝업 cad-editor-app.html + cad-editor-view.js: Three.js 3D 뷰포트(원점 그리드+축),
  워크스페이스 STL 로드/측정, 변환(이동·회전·배율·뒤집기·거울·중앙정렬), 구멍(표면 클릭 픽),
  박스 컷, 두 STL 합/차/교집합, 새 파일로 저장.
- 좌표계: 모델 원본 프레임 유지(슬라이서처럼 베드 중앙정렬 안 함). STL Z-up↔three Y-up
  매핑 = rotateX(-90°), 역매핑 STL=(threeX,-threeZ,threeY). 변환은 클라이언트 bake, boolean은
  bake→서버 OpenSCAD→결과 바이트 재로드(변환 리셋).
- stl-cad-core: applyBooleanOp(op∈hole/cut/union/difference/intersection) 추가, addHoleToStl를
  그 위의 얇은 래퍼로 리팩터(채팅도구 하위호환 유지).
- routes-cad: GET /api/cad/file(원본 다운로드, dotfiles:allow), POST /api/cad/save(bake된 STL 저장),
  POST /api/cad/apply(바디=primary 바이트, ?op=&params, ?second=) 추가. 기존 list/info/render/
  add-hole은 건드리지 않음.
- k2-app: "🔧 STL 확인" 버튼 → "🔧 STL 편집" 팝업(openCadEditorPopup). 옛 PNG 모달 마크업·JS·CSS 제거.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 21:53:24 +09:00
co-authored by Claude Code
parent 25a7fbb71d
commit 77c573f4a5
5 changed files with 832 additions and 191 deletions
+106 -1
View File
@@ -6,10 +6,11 @@
*/
import express, { Express, Request, Response } from 'express';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import { isPathInsideDir } from '../../tools/image';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, findStlFiles } from '../../tools/stl-cad-core';
import { OPENSCAD_BIN, readStlBBox, renderStlPng, addHoleToStl, applyBooleanOp, findStlFiles } from '../../tools/stl-cad-core';
function resolveWorkspaceFile(workspacePath: string, relPath: string): string {
const resolved = path.resolve(workspacePath, relPath);
@@ -89,4 +90,108 @@ export function registerCadRoutes(app: Express): void {
if (!result.ok) return res.status(500).json({ error: result.detail });
res.json({ success: true, output_path: path.relative(workspacePath, outPath).replace(/\\/g, '/') });
});
// ── STL 편집기(cad-editor-view.js)용 라우트 — 원본 STL 다운로드/저장/boolean 연산 ──────
// 워크스페이스가 .smallclaw/ 아래 도트파일 디렉토리라 res.sendFile 의 dotfiles 기본값
// ('ignore')이 파일을 404로 씹는 함정이 있어 'allow' 명시(slicer gcode 라우트와 동일).
// 원본 STL 바이트 다운로드 — 편집기가 Three.js STLLoader로 3D 로드.
app.get('/api/cad/file', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const workspacePath = getUserWorkspace(user.username);
let stlPath: string;
try { stlPath = resolveWorkspaceFile(workspacePath, String(req.query.path || '')); }
catch (e: any) { return res.status(400).json({ error: String(e?.message || e) }); }
if (!fs.existsSync(stlPath)) return res.status(404).json({ error: '파일을 찾을 수 없습니다.' });
res.type('application/octet-stream').sendFile(stlPath, { dotfiles: 'allow' });
});
// 편집된 STL 바이트를 워크스페이스에 새 파일로 저장(클라이언트가 변환을 bake해 전송).
app.post('/api/cad/save', express.raw({ type: () => true, limit: '100mb' }), (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 84) return res.status(400).json({ error: 'STL 바디가 비어있거나 너무 작습니다.' });
const workspacePath = getUserWorkspace(user.username);
let outPath: string;
try { outPath = resolveWorkspaceFile(workspacePath, String(req.query.path || '')); }
catch (e: any) { return res.status(400).json({ error: String(e?.message || e) }); }
try {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buf);
res.json({ success: true, path: path.relative(workspacePath, outPath).replace(/\\/g, '/') });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// boolean 연산 — 바디=primary STL 바이트, ?op=hole|cut|union|difference|intersection.
// hole/cut 은 ?x/?y/?z/?diameter/?depth/?axis/?w/?h/?d 파라미터. union/difference/intersection
// 은 ?second=<워크스페이스 상대경로> 로 두 번째 STL. 결과 STL 바이트를 octet-stream으로 응답.
app.post('/api/cad/apply', express.raw({ type: () => true, limit: '100mb' }), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!fs.existsSync(OPENSCAD_BIN)) return res.status(500).json({ error: 'OpenSCAD가 설치돼있지 않습니다.' });
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 84) return res.status(400).json({ error: 'STL 바디가 비어있거나 너무 작습니다.' });
const op = String(req.query.op || '');
const validOps = ['hole', 'cut', 'union', 'difference', 'intersection'];
if (!validOps.includes(op)) return res.status(400).json({ error: `op는 ${validOps.join('/')} 중 하나여야 합니다.` });
const workspacePath = getUserWorkspace(user.username);
const num = (k: string) => { const n = Number(req.query[k]); return Number.isFinite(n) ? n : undefined; };
// primary STL을 임시 파일에 쓴다(클라이언트가 변환을 bake한 바이트).
const stamp = Date.now();
const tmpDir = os.tmpdir();
const primaryPath = path.join(tmpDir, `cad_primary_${stamp}.stl`);
const outPath = path.join(tmpDir, `cad_result_${stamp}.stl`);
try { fs.writeFileSync(primaryPath, buf); }
catch (e: any) { return res.status(500).json({ error: '임시 STL 쓰기 실패: ' + String(e?.message || e) }); }
// 두 번째 STL(union/difference/intersection) — 워크스페이스 경로 검증.
let secondPath: string | undefined;
if (op === 'union' || op === 'difference' || op === 'intersection') {
const sec = String(req.query.second || '');
if (!sec) {
cleanupTemp(primaryPath);
return res.status(400).json({ error: `${op} 연산에는 ?second=<STL 경로>가 필요합니다.` });
}
try { secondPath = resolveWorkspaceFile(workspacePath, sec); }
catch (e: any) {
cleanupTemp(primaryPath);
return res.status(400).json({ error: String(e?.message || e) });
}
if (!fs.existsSync(secondPath)) {
cleanupTemp(primaryPath);
return res.status(404).json({ error: '두 번째 STL을 찾을 수 없습니다.' });
}
}
const result = await applyBooleanOp({
primaryPath, outPath, secondPath,
op: op as 'hole' | 'cut' | 'union' | 'difference' | 'intersection',
x: num('x'), y: num('y'), z: num('z'),
diameter: num('diameter'), depth: num('depth'),
axis: (['x', 'y', 'z'].includes(String(req.query.axis)) ? String(req.query.axis) : 'z') as 'x' | 'y' | 'z',
w: num('w'), h: num('h'), d: num('d'),
});
cleanupTemp(primaryPath);
if (!result.ok) { cleanupTemp(outPath); return res.status(500).json({ error: result.detail }); }
try {
const outBuf = fs.readFileSync(outPath);
cleanupTemp(outPath);
res.type('application/octet-stream').send(outBuf);
} catch (e: any) {
cleanupTemp(outPath);
res.status(500).json({ error: '결과 STL 읽기 실패: ' + String(e?.message || e) });
}
});
}
function cleanupTemp(p: string): void {
try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* noop */ }
}
+51 -13
View File
@@ -93,20 +93,47 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
return { ok: true };
}
export async function addHoleToStl(opts: {
stlPath: string; outPath: string;
x: number; y: number; z: number; diameter: number; depth: number; axis: 'x' | 'y' | 'z';
}): Promise<{ ok: true } | { ok: false; detail: string }> {
const { stlPath, outPath, x, y, z, diameter, depth, axis } = opts;
const rot = axis === 'x' ? '[0,90,0]' : axis === 'y' ? '[90,0,0]' : '[0,0,0]';
const scadPath = outPath.replace(/\.stl$/i, `_${Date.now()}.scad`);
fs.writeFileSync(scadPath, [
`difference() {`,
` import(${JSON.stringify(stlPath)});`,
` translate([${x},${y},${z}]) rotate(${rot}) cylinder(h=${depth}, d=${diameter}, center=true, $fn=48);`,
`}`,
].join('\n'));
// STL 편집기(cad-editor-view.js)의 boolean 연산 코어 — 구멍(원통 차집합)·박스 컷·두 STL
// 합집합/차집합/교집합을 하나의 함수로 처리한다. 채팅도구 add_hole 도 루트를 공유. op별로
// .scad 템플릿을 빌드해 runOpenscad 로 STL을 export. hole/cut 은 primary 하나만, union/
// difference/intersection 은 secondPath(워크스페이스 내 두 번째 STL)가 필요.
export type BooleanOp = 'hole' | 'cut' | 'union' | 'difference' | 'intersection';
export async function applyBooleanOp(opts: {
primaryPath: string; outPath: string;
secondPath?: string;
op: BooleanOp;
// hole params
x?: number; y?: number; z?: number; diameter?: number; depth?: number; axis?: 'x' | 'y' | 'z';
// cut params(박스 한 변 길이)
w?: number; h?: number; d?: number;
}): Promise<{ ok: true } | { ok: false; detail: string }> {
const { primaryPath, outPath, secondPath, op } = opts;
const P = JSON.stringify(primaryPath);
const S = secondPath ? JSON.stringify(secondPath) : null;
let body: string;
if (op === 'hole') {
const { x = 0, y = 0, z = 0, diameter = 2, depth = 20, axis = 'z' } = opts;
const rot = axis === 'x' ? '[0,90,0]' : axis === 'y' ? '[90,0,0]' : '[0,0,0]';
body = `difference() {\n import(${P});\n translate([${x},${y},${z}]) rotate(${rot}) cylinder(h=${depth}, d=${diameter}, center=true, $fn=48);\n}\n`;
} else if (op === 'cut') {
const { x = 0, y = 0, z = 0, w = 10, h = 10, d = 10 } = opts;
body = `difference() {\n import(${P});\n translate([${x},${y},${z}]) cube([${w},${h},${d}], center=true);\n}\n`;
} else if (op === 'union') {
if (!S) return { ok: false, detail: 'union 연산에는 두 번째 STL(second)이 필요합니다.' };
body = `union() {\n import(${P});\n import(${S});\n}\n`;
} else if (op === 'difference') {
if (!S) return { ok: false, detail: 'difference 연산에는 두 번째 STL(second)이 필요합니다.' };
body = `difference() {\n import(${P});\n import(${S});\n}\n`;
} else if (op === 'intersection') {
if (!S) return { ok: false, detail: 'intersection 연산에는 두 번째 STL(second)이 필요합니다.' };
body = `intersection() {\n import(${P});\n import(${S});\n}\n`;
} else {
return { ok: false, detail: `알 수 없는 op: ${op}` };
}
const scadPath = outPath.replace(/\.stl$/i, `_${Date.now()}.scad`);
fs.writeFileSync(scadPath, body);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const render = await runOpenscad(['-o', outPath, scadPath], 120_000);
try { fs.unlinkSync(scadPath); } catch { /* noop */ }
@@ -117,6 +144,17 @@ export async function addHoleToStl(opts: {
return { ok: true };
}
// 기존 구멍뚫기(채팅도구 stl_cad + /api/cad/add-hole) — applyBooleanOp 의 얇은 래퍼로 동일 동작.
export async function addHoleToStl(opts: {
stlPath: string; outPath: string;
x: number; y: number; z: number; diameter: number; depth: number; axis: 'x' | 'y' | 'z';
}): Promise<{ ok: true } | { ok: false; detail: string }> {
return applyBooleanOp({
primaryPath: opts.stlPath, outPath: opts.outPath, op: 'hole',
x: opts.x, y: opts.y, z: opts.z, diameter: opts.diameter, depth: opts.depth, axis: opts.axis,
});
}
// 워크스페이스 아래 STL 파일을 재귀적으로 찾는다(드롭다운 목록용). node_modules류 폴더가
// 없는 개인 워크스페이스라 별도 제외 패턴 없이 그냥 다 훑는다.
export function findStlFiles(workspacePath: string, maxDepth = 6): string[] {
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔧 STL 편집기</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;overflow:hidden;background:var(--bg,#1a1a1a);}
/* #cad-root 가 풀뷰포트 — cad-editor-view.js 가 .ce-shell 을 이 안에 빌드.
position:fixed;inset:0 이라 페이지 로드 직후부터 크기가 잡혀 initScene 시점에
뷰포트 clientWidth/Height 가 0 이 되는 블랙스크린 문제가 안 생김(slicer-app 동일 패턴). */
#cad-root{position:fixed;inset:0;}
</style>
</head>
<body>
<div id="cad-root"></div>
<!-- Three.js r128 UMD + 로더 — /vendor/three/ 정적 서빙(전역 THREE 부착). 슬라이서와 동일 벤더. -->
<script src="/vendor/three/three.min.js"></script>
<script src="/vendor/three/STLLoader.js"></script>
<script src="/vendor/three/OrbitControls.js"></script>
<script type="module" src="/js/k2/cad-editor-view.js"></script>
</body>
</html>
+10 -177
View File
@@ -50,22 +50,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.slice-err{margin-top:10px;color:#ef4444;font-size:12px;display:none;}
.slice-err.show{display:block;}
/* ── 3D 슬라이서(Cura 스타일) — 전용 팝업창으로 분리(slicer-app.html). k2-app엔 오버레이 없음 ── */
/* ── CAD(STL 확인) 모달 ── */
.cad-modal{max-width:520px;}
.cad-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.cad-view-btns{display:flex;gap:4px;flex-wrap:wrap;margin:8px 0;}
.cad-view-btn{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.cad-view-btn.active{border-color:var(--brand);color:var(--brand);}
.cad-preview{width:100%;aspect-ratio:1/1;background:#1a1a1a;border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;margin-bottom:8px;}
.cad-preview img{width:100%;height:100%;object-fit:contain;}
.cad-preview .cad-placeholder{color:#666;font-size:11px;}
.cad-info{font-size:11px;color:var(--muted);margin-bottom:10px;line-height:1.7;}
.cad-hole-toggle{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;width:100%;text-align:left;margin-top:6px;}
.cad-hole-toggle:hover{border-color:var(--brand);color:var(--brand);}
.cad-hole-form{display:none;margin-top:8px;padding-top:8px;border-top:1px solid var(--line);}
.cad-hole-form.show{display:block;}
/* ── 3D 슬라이서·STL 편집기 모두 전용 팝업창으로 분리(slicer-app.html / cad-editor-app.html). k2-app엔 오버레이 없음 ── */
</style>
</head>
<body>
@@ -74,7 +59,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<h1>🖨️ K2 콤보</h1>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="openSlicerPopup()">🔪 슬라이싱</button>
<button class="hdr-btn" onclick="openCadModal()">🔧 STL 확인</button>
<button class="hdr-btn" onclick="openCadEditorPopup()">🔧 STL 편집</button>
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
<a class="hdr-btn" href="https://print.applecherry.net/" target="_blank">↗ 새 탭</a>
@@ -95,47 +80,6 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
</div>
<div class="slice-overlay" id="cad-overlay">
<div class="slice-modal cad-modal">
<button class="slice-close" onclick="closeCadModal()">✕</button>
<h2>🔧 STL 확인</h2>
<div class="cad-field">
<label>파일</label>
<select id="cad-file-select" onchange="onCadFileChange()"></select>
</div>
<div class="cad-view-btns" id="cad-view-btns">
<button class="cad-view-btn active" data-view="iso" onclick="cadSetView('iso')">비스듬히</button>
<button class="cad-view-btn" data-view="top" onclick="cadSetView('top')">위</button>
<button class="cad-view-btn" data-view="bottom" onclick="cadSetView('bottom')">아래</button>
<button class="cad-view-btn" data-view="front" onclick="cadSetView('front')">앞</button>
<button class="cad-view-btn" data-view="back" onclick="cadSetView('back')">뒤</button>
<button class="cad-view-btn" data-view="left" onclick="cadSetView('left')">왼쪽</button>
<button class="cad-view-btn" data-view="right" onclick="cadSetView('right')">오른쪽</button>
</div>
<div class="cad-preview" id="cad-preview"><span class="cad-placeholder" id="cad-placeholder">파일을 선택하세요</span><img id="cad-preview-img" style="display:none"></div>
<div class="cad-info" id="cad-info"></div>
<button class="cad-hole-toggle" onclick="toggleHoleForm()">🕳️ 구멍 뚫기 ▾</button>
<div class="cad-hole-form" id="cad-hole-form">
<div class="slice-row">
<div class="slice-field"><label>X</label><input type="number" id="hole-x" value="0" step="0.1"></div>
<div class="slice-field"><label>Y</label><input type="number" id="hole-y" value="0" step="0.1"></div>
<div class="slice-field"><label>Z</label><input type="number" id="hole-z" value="0" step="0.1"></div>
</div>
<div class="slice-row">
<div class="slice-field"><label>지름(mm)</label><input type="number" id="hole-diameter" value="2" step="0.1" min="0.1"></div>
<div class="slice-field"><label>길이(mm)</label><input type="number" id="hole-depth" value="20" step="0.5"></div>
<div class="slice-field"><label>방향축</label>
<select id="hole-axis"><option value="z">Z</option><option value="x">X</option><option value="y">Y</option></select>
</div>
</div>
<div class="slice-field"><label>결과 파일명</label><input type="text" id="hole-output" placeholder="예: esp32_top_with_hole.stl"></div>
<button class="slice-btn" onclick="runAddHole()">구멍 뚫어서 새 파일로 저장</button>
</div>
<div class="slice-err" id="cad-err"></div>
<div class="slice-result" id="cad-result"></div>
</div>
</div>
<script>
// ── 슬라이서 팝업 — Cura 스타일 3D GUI는 전용창(slicer-app.html)으로 분리 ──
// k2-app(카메라+Fluidd 모니터)과 슬라이서(편집 도구) 역할 분리. 인증은 같은 오리진
@@ -145,6 +89,14 @@ function openSlicerPopup(){
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
window.open('/html/slicer-app.html','k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
}
// ── STL 편집기 팝업 — Three.js 3D 편집기(cad-editor-app.html)로 분리 ──
// 옛 "STL 확인"은 서버 렌더 PNG 정적 미리보기 + 구멍뚫기였으나, 3D 상호작용 편집기로 전환.
// 인증은 same-origin smallclaw_session 쿠키가 팝업에 자동 상속(slicer 팝업과 동일).
function openCadEditorPopup(){
const w=1320,h=860;
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
window.open('/html/cad-editor-app.html','cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function reloadFrame(){const f=document.getElementById('k2-frame');f.src=f.src;}
let loaded=false;
@@ -209,125 +161,6 @@ function connectCam(){
}
function reconnectCam(){ connectCam(); }
connectCam();
// ── CAD(STL 확인) 모달 ──────────────────────────────────────────────────────
let cadCurrentFile=null;
let cadCurrentView='iso';
async function openCadModal(){
document.getElementById('cad-overlay').classList.add('show');
document.getElementById('cad-err').classList.remove('show');
document.getElementById('cad-result').classList.remove('show');
await cadLoadFileList();
}
function closeCadModal(){ document.getElementById('cad-overlay').classList.remove('show'); }
async function cadLoadFileList(){
const sel=document.getElementById('cad-file-select');
sel.innerHTML='<option value="">불러오는 중...</option>';
try{
const r=await fetch('/api/cad/list',{headers:authH()});
const d=await r.json();
const files=d.files||[];
if(!files.length){ sel.innerHTML='<option value="">워크스페이스에 STL 파일이 없습니다</option>'; return; }
sel.innerHTML=files.map(f=>`<option value="${f.replace(/"/g,'&quot;')}">${f}</option>`).join('');
if(cadCurrentFile && files.includes(cadCurrentFile)) sel.value=cadCurrentFile;
onCadFileChange();
}catch(e){
sel.innerHTML='<option value="">목록을 불러오지 못했습니다</option>';
}
}
function onCadFileChange(){
cadCurrentFile=document.getElementById('cad-file-select').value;
document.getElementById('cad-err').classList.remove('show');
document.getElementById('cad-result').classList.remove('show');
if(!cadCurrentFile){
document.getElementById('cad-info').textContent='';
document.getElementById('cad-preview-img').style.display='none';
document.getElementById('cad-placeholder').style.display='flex';
document.getElementById('cad-placeholder').textContent='파일을 선택하세요';
return;
}
cadLoadInfo();
cadRenderPreview();
}
async function cadLoadInfo(){
const infoEl=document.getElementById('cad-info');
infoEl.textContent='치수 확인 중...';
try{
const r=await fetch('/api/cad/info?path='+encodeURIComponent(cadCurrentFile),{headers:authH()});
const d=await r.json();
if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
const b=d.bbox;
const size=[b.max[0]-b.min[0], b.max[1]-b.min[1], b.max[2]-b.min[2]].map(n=>n.toFixed(1));
infoEl.innerHTML='크기: <b>'+size.join(' × ')+' mm</b> · 삼각형 '+b.triangleCount+'개<br>'+
'X['+b.min[0].toFixed(1)+', '+b.max[0].toFixed(1)+'] Y['+b.min[1].toFixed(1)+', '+b.max[1].toFixed(1)+'] Z['+b.min[2].toFixed(1)+', '+b.max[2].toFixed(1)+']';
}catch(e){
infoEl.textContent='치수 조회 실패: '+e.message;
}
}
function cadSetView(view){
cadCurrentView=view;
document.querySelectorAll('.cad-view-btn').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
cadRenderPreview();
}
async function cadRenderPreview(){
if(!cadCurrentFile)return;
const img=document.getElementById('cad-preview-img');
const placeholder=document.getElementById('cad-placeholder');
placeholder.style.display='flex'; placeholder.textContent='렌더링 중...';
img.style.display='none';
try{
const url='/api/cad/render?path='+encodeURIComponent(cadCurrentFile)+'&view='+cadCurrentView;
const r=await fetch(url,{headers:authH()});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||('HTTP '+r.status)); }
const blob=await r.blob();
img.src=URL.createObjectURL(blob);
img.style.display='block';
placeholder.style.display='none';
}catch(e){
placeholder.textContent='렌더링 실패: '+e.message;
}
}
function toggleHoleForm(){
document.getElementById('cad-hole-form').classList.toggle('show');
}
async function runAddHole(){
const errEl=document.getElementById('cad-err');
const resultEl=document.getElementById('cad-result');
errEl.classList.remove('show'); resultEl.classList.remove('show');
if(!cadCurrentFile){ errEl.textContent='STL 파일을 먼저 선택하세요.'; errEl.classList.add('show'); return; }
const outputName=document.getElementById('hole-output').value.trim();
if(!outputName){ errEl.textContent='결과 파일명을 입력하세요.'; errEl.classList.add('show'); return; }
const dir=cadCurrentFile.includes('/')?cadCurrentFile.slice(0,cadCurrentFile.lastIndexOf('/')+1):'';
const body={
path:cadCurrentFile,
output_path:dir+outputName,
x:Number(document.getElementById('hole-x').value),
y:Number(document.getElementById('hole-y').value),
z:Number(document.getElementById('hole-z').value),
diameter:Number(document.getElementById('hole-diameter').value),
depth:Number(document.getElementById('hole-depth').value),
axis:document.getElementById('hole-axis').value,
};
try{
const r=await fetch('/api/cad/add-hole',{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify(body)});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||('HTTP '+r.status));
resultEl.innerHTML='✅ 저장됨: <b>'+d.output_path+'</b>';
resultEl.classList.add('show');
await cadLoadFileList();
}catch(e){
errEl.textContent='구멍 뚫기 실패: '+e.message;
errEl.classList.add('show');
}
}
</script>
</body>
</html>
+639
View File
@@ -0,0 +1,639 @@
// STL 3D 편집기 — 2026-09-20. k2-app "🔧 STL 확인" 버튼이 여는 팝업(cad-editor-app.html).
//
// 슬라이서(slicer-view.js)의 Three.js 씬/STLLoader/OrbitControls/포인터 드래그/bake 기계장치를
// 재사용하되, CAD 편집용으로 개조:
// - 단일 모델. 원본 좌표계 유지(베드 중앙 정렬·리프트 안 함). 그리드는 three 원점.
// - 좌표 매핑: STL(Z-up) → three(Y-up) = rotateX(-90°). 역매핑 STL=(threeX, -threeZ, threeY).
// - 변환(이동/회전/배율/뒤집기/거울)은 클라이언트 bake 로 정점에 구워 넣고, boolean(구멍/박스컷/
// 합집합/차집합/교집합)은 bake 한 STL 바이트를 서버 OpenSCAD(/api/cad/apply)에 보내 결과 바이트를
// 다시 로드(변환 리셋). 저장은 bake → /api/cad/save.
//
// 서버: /api/cad/list · /api/cad/file · /api/cad/save · /api/cad/apply · (기존 info/render/add-hole 유지).
(function () {
'use strict';
const THREE = window.THREE;
if (!THREE || !THREE.STLLoader || !THREE.OrbitControls) {
console.error('[cad-editor] Three.js/vendor 로더가 로드되지 않았습니다.');
return;
}
// ── 상태 ──────────────────────────────────────────────────────────────
let overlayEl, vpEl, panelEl, barEl;
let scene, camera, renderer, controls, raycaster;
let mesh = null; // 단일 편집 대상 메시
let nativeBBox = null; // {min:[3], max:[3], size:[3], tris} — 원본 STL 프레임
let holeMarker = null; // 구멍 위치 표시용 구
let pickMode = false; // 표면 클릭으로 구멍 좌표 지정 모드
let files = []; // 워크스페이스 STL 목록
let dragging = false;
let dragOffX = 0, dragOffZ = 0;
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const tmpVec = new THREE.Vector3();
const tmpBox = new THREE.Box3();
const tmpMouse = new THREE.Vector2();
const MESH_COLOR = 0x4a90d9;
const HOLE_MARKER_COLOR = 0xf97316;
function authH() {
let t = '';
try { t = sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || ''; } catch (e) { }
return t ? { Authorization: 'Bearer ' + t } : {};
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// ── Three.js 씬 ───────────────────────────────────────────────────────
function initScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
camera = new THREE.PerspectiveCamera(45, 1, 1, 10000);
camera.position.set(150, 120, 180);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
vpEl.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const key = new THREE.DirectionalLight(0xffffff, 0.75);
key.position.set(200, 400, 200);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.35);
fill.position.set(-200, 200, -150);
scene.add(fill);
// 원점 기준 그리드(10mm 분할, 500mm 폭) — CAD는 모델 원본 좌표를 봐야 하므로 베드 중앙이
// 아니라 three 원점에 둔다.
const grid = new THREE.GridHelper(500, 50, 0x666666, 0x333333);
scene.add(grid);
// 축 헬퍼(Red=X, Green=Y, Blue=Z) — three 좌표계 시각화.
scene.add(new THREE.AxesHelper(80));
// 구멍 위치 마커(작은 구) — 기본 숨김.
holeMarker = new THREE.Mesh(
new THREE.SphereGeometry(1.5, 16, 12),
new THREE.MeshBasicMaterial({ color: HOLE_MARKER_COLOR })
);
holeMarker.visible = false;
scene.add(holeMarker);
raycaster = new THREE.Raycaster();
const dom = renderer.domElement;
dom.addEventListener('pointerdown', onPointerDown);
dom.addEventListener('pointermove', onPointerMove);
dom.addEventListener('pointerup', onPointerUp);
dom.addEventListener('pointercancel', onPointerUp);
resize();
animate();
}
function resize() {
if (!renderer || !vpEl) return;
const w = vpEl.clientWidth, h = vpEl.clientHeight;
if (w < 2 || h < 2) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
let rafId = null;
function animate() {
rafId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// ── 모델 로드 ─────────────────────────────────────────────────────────
// STL(Z-up) → three(Y-up): rotateX(-90°). translate 안 함(원본 좌표 유지).
function loadModelFromBytes(buf) {
const loader = new THREE.STLLoader();
let geo;
try { geo = loader.parse(buf); }
catch (e) { return { ok: false, detail: 'STL 파싱 실패: ' + String(e?.message || e) }; }
if (!geo || !geo.attributes.position || geo.attributes.position.count < 3) {
return { ok: false, detail: 'STL에 삼각형이 없습니다.' };
}
geo.rotateX(-Math.PI / 2);
geo.computeBoundingBox();
geo.computeVertexNormals();
if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); }
mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
color: MESH_COLOR, roughness: 0.55, metalness: 0.05,
flatShading: false, transparent: true, opacity: 0.78, side: THREE.DoubleSide,
}));
scene.add(mesh);
// 원본 프레임 bbox. three bbox (minX..maxX, minY..maxY, minZ..maxZ) →
// native X = threeX, native Y = -threeZ, native Z = threeY.
const bb = geo.boundingBox;
nativeBBox = {
min: [bb.min.x, -bb.max.z, bb.min.y],
max: [bb.max.x, -bb.min.z, bb.max.y],
size: [bb.max.x - bb.min.x, bb.max.z - bb.min.z, bb.max.y - bb.min.y],
tris: Math.floor(geo.attributes.position.count / 3),
};
fitCameraToModel();
refreshPanel();
return { ok: true };
}
function fitCameraToModel() {
if (!mesh) { controls.target.set(0, 0, 0); camera.position.set(150, 120, 180); return; }
mesh.updateMatrixWorld(true);
tmpBox.setFromObject(mesh);
const c = tmpBox.getCenter(new THREE.Vector3());
const sz = tmpBox.getSize(new THREE.Vector3());
controls.target.copy(c);
const d = Math.max(40, Math.max(sz.x, sz.y, sz.z) * 2.2);
camera.position.set(c.x + d * 0.7, c.y + d * 0.55, c.z + d * 0.85);
camera.updateProjectionMatrix();
}
// ── bake: 현재 변환을 정점에 구워 단일 바이너리 STL 로 ────────────────
// mesh.matrix(이동·회전·배율·거울 전부) 적용 후 STL=(threeX, -threeZ, threeY) 로 기록.
// 결과 STL의 좌표 == 현재 월드 좌표이므로, 구멍 좌표(월드)를 그대로 서버에 넘기면 정합.
function bakeStl() {
if (!mesh) return null;
mesh.updateMatrix();
const m = mesh.matrix;
const pos = mesh.geometry.attributes.position;
const tri = Math.floor(pos.count / 3);
const buf = new ArrayBuffer(84 + tri * 50);
const dv = new DataView(buf);
dv.setUint32(80, tri, true);
let off = 84;
const v = new THREE.Vector3();
for (let t = 0; t < tri; t++) {
dv.setFloat32(off, 0, true); dv.setFloat32(off + 4, 0, true); dv.setFloat32(off + 8, 0, true); off += 12;
for (let j = 0; j < 3; j++) {
v.fromBufferAttribute(pos, t * 3 + j).applyMatrix4(m);
dv.setFloat32(off, v.x, true); // STL X = three X
dv.setFloat32(off + 4, -v.z, true); // STL Y = -three Z
dv.setFloat32(off + 8, v.y, true); // STL Z = three Y
off += 12;
}
dv.setUint16(off, 0, true); off += 2;
}
return buf;
}
// ── 변환 패널 동기화 ──────────────────────────────────────────────────
// native 위치 입력 ↔ mesh.position: nativeX=threeX, nativeY=-threeZ, nativeZ=threeY.
function applyTransformInputs() {
if (!mesh) return;
const nx = parseFloat(panelEl.querySelector('#ce-x').value) || 0;
const ny = parseFloat(panelEl.querySelector('#ce-y').value) || 0;
const nz = parseFloat(panelEl.querySelector('#ce-z').value) || 0;
const rx = (parseFloat(panelEl.querySelector('#ce-rx').value) || 0) * Math.PI / 180;
const ry = (parseFloat(panelEl.querySelector('#ce-ry').value) || 0) * Math.PI / 180;
const rz = (parseFloat(panelEl.querySelector('#ce-rz').value) || 0) * Math.PI / 180;
const sc = Math.max(0.01, parseFloat(panelEl.querySelector('#ce-scale').value) || 1);
mesh.position.set(nx, nz, -ny); // threeX=nativeX, threeY=nativeZ, threeZ=-nativeY
mesh.rotation.set(rx, ry, rz);
// 배율(균일) — 거울 버튼이 scale 일부 축을 -로 만들 수 있으므로, 입력은 절댓값 배율로 취급해
// 각 축 절댓값을 sc 로 맞추되 부호는 보존.
mesh.scale.x = Math.sign(mesh.scale.x || 1) * sc;
mesh.scale.y = Math.sign(mesh.scale.y || 1) * sc;
mesh.scale.z = Math.sign(mesh.scale.z || 1) * sc;
}
function refreshPanel() {
const dims = panelEl.querySelector('#ce-dims');
if (!mesh || !nativeBBox) {
if (dims) dims.innerHTML = '<span class="ce-muted">모델 없음</span>';
['ce-x', 'ce-y', 'ce-z', 'ce-rx', 'ce-ry', 'ce-rz', 'ce-scale'].forEach((id) => {
const el = panelEl.querySelector('#' + id); if (el) el.disabled = true;
});
return;
}
['ce-x', 'ce-y', 'ce-z', 'ce-rx', 'ce-ry', 'ce-rz', 'ce-scale'].forEach((id) => {
const el = panelEl.querySelector('#' + id); if (el) el.disabled = false;
});
// 원본 치수 + 삼각형 수.
const s = nativeBBox.size;
dims.innerHTML = `원본 크기: <b>${s[0].toFixed(1)} × ${s[1].toFixed(1)} × ${s[2].toFixed(1)} mm</b> · ${nativeBBox.tris}삼각형<br>` +
`<span class="ce-muted">원본 bbox X[${nativeBBox.min[0].toFixed(1)}, ${nativeBBox.max[0].toFixed(1)}] ` +
`Y[${nativeBBox.min[1].toFixed(1)}, ${nativeBBox.max[1].toFixed(1)}] ` +
`Z[${nativeBBox.min[2].toFixed(1)}, ${nativeBBox.max[2].toFixed(1)}]</span>`;
// 변환 입력값 갱신(역매핑).
const nx = panelEl.querySelector('#ce-x'); if (nx) nx.value = mesh.position.x.toFixed(2);
const ny = panelEl.querySelector('#ce-y'); if (ny) ny.value = (-mesh.position.z).toFixed(2);
const nz = panelEl.querySelector('#ce-z'); if (nz) nz.value = mesh.position.y.toFixed(2);
const rx = panelEl.querySelector('#ce-rx'); if (rx) rx.value = (mesh.rotation.x * 180 / Math.PI).toFixed(1);
const ry = panelEl.querySelector('#ce-ry'); if (ry) ry.value = (mesh.rotation.y * 180 / Math.PI).toFixed(1);
const rz = panelEl.querySelector('#ce-rz'); if (rz) rz.value = (mesh.rotation.z * 180 / Math.PI).toFixed(1);
const sc = panelEl.querySelector('#ce-scale'); if (sc) sc.value = Math.abs(mesh.scale.x).toFixed(2);
}
function flipAxis(ax) {
if (!mesh) return;
mesh.rotation[ax] += Math.PI;
refreshPanel();
}
function mirrorAxis(ax) {
if (!mesh) return;
mesh.scale[ax] = -mesh.scale[ax];
refreshPanel();
}
function centerModel() {
if (!mesh) return;
// 원본 bbox 중심을 three 원점으로 이동(native 중심 → three (cx, cz, -cy)).
if (!nativeBBox) return;
const cx = (nativeBBox.min[0] + nativeBBox.max[0]) / 2;
const cy = (nativeBBox.min[1] + nativeBBox.max[1]) / 2;
const cz = (nativeBBox.min[2] + nativeBBox.max[2]) / 2;
mesh.position.set(-cx, -cz, cy); // native 중심을 원점으로 → three position = -중심(원본 프레임)
refreshPanel();
}
// ── 구멍 위치 마커 ────────────────────────────────────────────────────
// hole 입력(native x,y,z) → three 마커 위치 (x, z, -y).
function updateHoleMarker() {
if (!holeMarker) return;
const x = parseFloat(panelEl.querySelector('#ce-hole-x').value);
const y = parseFloat(panelEl.querySelector('#ce-hole-y').value);
const z = parseFloat(panelEl.querySelector('#ce-hole-z').value);
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
holeMarker.position.set(x, z, -y);
holeMarker.visible = true;
} else {
holeMarker.visible = false;
}
}
// ── 포인터(드래그 이동 / 표면 픽) ─────────────────────────────────────
function setMouseFromEvent(e) {
const rect = renderer.domElement.getBoundingClientRect();
tmpMouse.set(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
}
function onPointerDown(e) {
if (!mesh) return;
setMouseFromEvent(e);
raycaster.setFromCamera(tmpMouse, camera);
if (pickMode) {
// 표면 클릭 → 해당 월드 교차점을 native 좌표 (wx, -wz, wy) 로 변환해 구멍 입력 채움.
const hits = raycaster.intersectObject(mesh, false);
if (hits.length) {
const p = hits[0].point;
panelEl.querySelector('#ce-hole-x').value = p.x.toFixed(2);
panelEl.querySelector('#ce-hole-y').value = (-p.z).toFixed(2);
panelEl.querySelector('#ce-hole-z').value = p.y.toFixed(2);
updateHoleMarker();
setInfo('📍 구멍 위치 지정: ' + p.x.toFixed(1) + ', ' + (-p.z).toFixed(1) + ', ' + p.y.toFixed(1));
}
pickMode = false;
renderer.domElement.style.cursor = '';
return;
}
const hits = raycaster.intersectObject(mesh, false);
if (!hits.length) return; // 빈 곳 → 회전(controls)
// 드래그 평면: 모델 현재 높이(mesh.position.y)의 수평면.
dragPlane.set(new THREE.Vector3(0, 1, 0), -mesh.position.y);
if (raycaster.ray.intersectPlane(dragPlane, tmpVec)) {
dragOffX = mesh.position.x - tmpVec.x;
dragOffZ = mesh.position.z - tmpVec.z;
} else { dragOffX = 0; dragOffZ = 0; }
dragging = true;
controls.enabled = false;
try { renderer.domElement.setPointerCapture(e.pointerId); } catch (err) { }
}
function onPointerMove(e) {
if (!dragging || !mesh) return;
setMouseFromEvent(e);
raycaster.setFromCamera(tmpMouse, camera);
if (raycaster.ray.intersectPlane(dragPlane, tmpVec)) {
mesh.position.x = tmpVec.x + dragOffX;
mesh.position.z = tmpVec.z + dragOffZ;
refreshPanel();
}
}
function onPointerUp(e) {
if (!dragging) return;
dragging = false;
controls.enabled = true;
try { renderer.domElement.releasePointerCapture(e.pointerId); } catch (err) { }
}
// ── 파일 목록 ─────────────────────────────────────────────────────────
async function loadFileList() {
try {
const r = await fetch('/api/cad/list', { headers: authH() });
const d = await r.json();
files = d.files || [];
} catch (e) { files = []; setErr('파일 목록 조회 실패: ' + e.message); }
const opts = ['<option value="">— STL 선택 —</option>']
.concat(files.map((f) => `<option value="${f.replace(/"/g, '&quot;')}">${f}</option>`))
.join('');
const sel = panelEl.querySelector('#ce-file');
const sec = panelEl.querySelector('#ce-second');
if (sel) sel.innerHTML = opts;
if (sec) sec.innerHTML = opts;
}
async function loadSelected() {
const path = panelEl.querySelector('#ce-file').value;
if (!path) return;
setErr(''); setInfo('로드 중...');
try {
const r = await fetch('/api/cad/file?path=' + encodeURIComponent(path), { headers: authH() });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || ('HTTP ' + r.status)); }
const ab = await r.arrayBuffer();
const lr = loadModelFromBytes(ab);
if (!lr.ok) throw new Error(lr.detail);
holeMarker.visible = false;
setInfo('📂 ' + path + ' 로드됨');
} catch (e) { setErr('로드 실패: ' + e.message); }
}
// ── boolean 연산 적용 ─────────────────────────────────────────────────
async function applyOp(qs) {
if (!mesh) { setErr('모델을 먼저 로드하세요.'); return; }
setErr(''); setInfo('연산 중... (OpenSCAD)');
const baked = bakeStl();
if (!baked) { setErr('bake 실패'); return; }
try {
const r = await fetch('/api/cad/apply?' + qs.toString(), {
method: 'POST', headers: { 'Content-Type': 'application/octet-stream', ...authH() }, body: baked,
});
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.error || ('HTTP ' + r.status)); }
const ab = await r.arrayBuffer();
const lr = loadModelFromBytes(ab);
if (!lr.ok) throw new Error(lr.detail);
holeMarker.visible = false;
setInfo('✅ 연산 적용됨 — 변환이 기하에 구워졌습니다(변환 리셋).');
} catch (e) { setErr('연산 실패: ' + e.message); }
}
function applyHole() {
const x = parseFloat(panelEl.querySelector('#ce-hole-x').value) || 0;
const y = parseFloat(panelEl.querySelector('#ce-hole-y').value) || 0;
const z = parseFloat(panelEl.querySelector('#ce-hole-z').value) || 0;
const diameter = parseFloat(panelEl.querySelector('#ce-hole-d').value) || 2;
const depth = parseFloat(panelEl.querySelector('#ce-hole-depth').value) || 20;
const axis = panelEl.querySelector('#ce-hole-axis').value || 'z';
if (diameter <= 0) { setErr('지름이 0보다 커야 합니다.'); return; }
const qs = new URLSearchParams({ op: 'hole', x, y, z, diameter, depth, axis });
applyOp(qs);
}
function applyCut() {
const x = parseFloat(panelEl.querySelector('#ce-cut-x').value) || 0;
const y = parseFloat(panelEl.querySelector('#ce-cut-y').value) || 0;
const z = parseFloat(panelEl.querySelector('#ce-cut-z').value) || 0;
const w = parseFloat(panelEl.querySelector('#ce-cut-w').value) || 10;
const h = parseFloat(panelEl.querySelector('#ce-cut-h').value) || 10;
const d = parseFloat(panelEl.querySelector('#ce-cut-dd').value) || 10;
const qs = new URLSearchParams({ op: 'cut', x, y, z, w, h, d });
applyOp(qs);
}
function applyBool(op) {
const second = panelEl.querySelector('#ce-second').value;
if (!second) { setErr('두 번째 STL을 선택하세요.'); return; }
const qs = new URLSearchParams({ op, second });
applyOp(qs);
}
// ── 저장 ──────────────────────────────────────────────────────────────
async function saveModel() {
if (!mesh) { setErr('모델이 없습니다.'); return; }
let name = panelEl.querySelector('#ce-save-name').value.trim();
if (!name) { setErr('저장할 파일명을 입력하세요.'); return; }
if (!name.toLowerCase().endsWith('.stl')) name += '.stl';
setErr(''); setInfo('저장 중...');
const baked = bakeStl();
if (!baked) { setErr('bake 실패'); return; }
try {
const r = await fetch('/api/cad/save?path=' + encodeURIComponent(name), {
method: 'POST', headers: { 'Content-Type': 'application/octet-stream', ...authH() }, body: baked,
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || ('HTTP ' + r.status));
setInfo('💾 저장됨: ' + d.path);
await loadFileList();
panelEl.querySelector('#ce-file').value = d.path;
} catch (e) { setErr('저장 실패: ' + e.message); }
}
// ── UI 구축 ───────────────────────────────────────────────────────────
function setErr(msg) {
const el = barEl.querySelector('#ce-err');
el.textContent = msg; el.classList.toggle('show', !!msg);
}
function setInfo(html) {
const el = barEl.querySelector('#ce-info');
el.innerHTML = html; el.classList.toggle('show', !!html);
}
function buildOverlay() {
overlayEl = document.getElementById('cad-root');
if (!overlayEl) { console.error('[cad-editor] #cad-root 컨테이너가 없습니다.'); return; }
overlayEl.innerHTML = `
<div class="ce-shell">
<div class="vp" id="ce-vp"></div>
<aside class="ce-panel" id="ce-panel">
<div class="ce-panel-hdr"><h2>🔧 STL 편집기</h2><button class="ce-x" onclick="window.close()">✕</button></div>
<div class="ce-sec">
<div class="ce-sec-hdr">📂 파일</div>
<div class="ce-row">
<select id="ce-file" class="ce-grow"></select>
<button class="ce-mini" onclick="window.__cadRefresh()">↺</button>
<button class="ce-mini primary" onclick="window.__cadLoad()">로드</button>
</div>
<div id="ce-dims" class="ce-dims"><span class="ce-muted">모델 없음</span></div>
</div>
<div class="ce-sec">
<div class="ce-sec-hdr">🔄 변환 <span class="ce-hint">뷰에서 드래그로 이동</span></div>
<div class="ce-grid3">
<label>X<input type="number" id="ce-x" step="0.5" disabled></label>
<label>Y<input type="number" id="ce-y" step="0.5" disabled></label>
<label>Z<input type="number" id="ce-z" step="0.5" disabled></label>
</div>
<div class="ce-grid3">
<label>회전X°<input type="number" id="ce-rx" step="15" disabled></label>
<label>회전Y°<input type="number" id="ce-ry" step="15" disabled></label>
<label>회전Z°<input type="number" id="ce-rz" step="15" disabled></label>
</div>
<label class="ce-full">배율(균일)<input type="number" id="ce-scale" min="0.01" max="20" step="0.1" disabled></label>
<div class="ce-btnrow">
<button class="ce-mini" onclick="window.__cadFlip('x')">뒤집기X</button>
<button class="ce-mini" onclick="window.__cadFlip('y')">뒤집기Y</button>
<button class="ce-mini" onclick="window.__cadFlip('z')">뒤집기Z</button>
</div>
<div class="ce-btnrow">
<button class="ce-mini" onclick="window.__cadMirror('x')">거울X</button>
<button class="ce-mini" onclick="window.__cadMirror('y')">거울Y</button>
<button class="ce-mini" onclick="window.__cadMirror('z')">거울Z</button>
<button class="ce-mini" onclick="window.__cadCenter()">중앙정렬</button>
</div>
</div>
<div class="ce-sec">
<div class="ce-sec-hdr">🕳️ 구멍 뚫기 <button class="ce-mini" onclick="window.__cadPick()">📍 표면에서 선택</button></div>
<div class="ce-grid3">
<label>X<input type="number" id="ce-hole-x" step="0.5" value="0"></label>
<label>Y<input type="number" id="ce-hole-y" step="0.5" value="0"></label>
<label>Z<input type="number" id="ce-hole-z" step="0.5" value="0"></label>
</div>
<div class="ce-grid3">
<label>지름<input type="number" id="ce-hole-d" step="0.1" min="0.1" value="3"></label>
<label>깊이<input type="number" id="ce-hole-depth" step="0.5" value="20"></label>
<label>축<select id="ce-hole-axis"><option value="z">Z</option><option value="x">X</option><option value="y">Y</option></select></label>
</div>
<button class="ce-btn primary" onclick="window.__cadHole()">구멍 뚫기 적용</button>
</div>
<div class="ce-sec">
<div class="ce-sec-hdr">✂️ 박스 컷(차집합)</div>
<div class="ce-grid3">
<label>X<input type="number" id="ce-cut-x" step="0.5" value="0"></label>
<label>Y<input type="number" id="ce-cut-y" step="0.5" value="0"></label>
<label>Z<input type="number" id="ce-cut-z" step="0.5" value="0"></label>
</div>
<div class="ce-grid3">
<label>폭W<input type="number" id="ce-cut-w" step="0.5" value="10"></label>
<label>높이H<input type="number" id="ce-cut-h" step="0.5" value="10"></label>
<label>깊이D<input type="number" id="ce-cut-dd" step="0.5" value="10"></label>
</div>
<button class="ce-btn" onclick="window.__cadCut()">박스 컷 적용</button>
</div>
<div class="ce-sec">
<div class="ce-sec-hdr">⚙️ 두 STL boolean</div>
<div class="ce-row">
<select id="ce-second" class="ce-grow"></select>
</div>
<div class="ce-btnrow">
<button class="ce-mini" onclick="window.__cadBool('union')">합집합 ∪</button>
<button class="ce-mini" onclick="window.__cadBool('difference')">차집합 A−B</button>
<button class="ce-mini" onclick="window.__cadBool('intersection')">교집합 ∩</button>
</div>
</div>
<div class="ce-sec">
<div class="ce-sec-hdr">💾 저장</div>
<div class="ce-row">
<input type="text" id="ce-save-name" class="ce-grow" placeholder="새 파일명.stl">
<button class="ce-mini primary" onclick="window.__cadSave()">저장</button>
</div>
</div>
</aside>
<div class="ce-bar" id="ce-bar">
<div id="ce-info" class="ce-info"></div>
<div id="ce-err" class="ce-err"></div>
</div>
</div>
`;
vpEl = overlayEl.querySelector('#ce-vp');
panelEl = overlayEl.querySelector('#ce-panel');
barEl = overlayEl.querySelector('#ce-bar');
// 변환 입력 → 실시간 반영.
const wire = (id, ev) => { const el = panelEl.querySelector(id); if (!el) return; el.addEventListener(ev, () => { applyTransformInputs(); if (ev === 'change') refreshPanel(); }); };
['#ce-x', '#ce-y', '#ce-z', '#ce-rx', '#ce-ry', '#ce-rz', '#ce-scale'].forEach((id) => { wire(id, 'input'); wire(id, 'change'); });
// 구멍 좌표 입력 → 마커 갱신.
['#ce-hole-x', '#ce-hole-y', '#ce-hole-z'].forEach((id) => {
const el = panelEl.querySelector(id); if (el) el.addEventListener('input', updateHoleMarker);
});
}
function injectStyles() {
if (document.getElementById('ce-styles')) return;
const st = document.createElement('style');
st.id = 'ce-styles';
st.textContent = `
.ce-shell{position:absolute;inset:0;display:grid;grid-template-columns:1fr 310px;grid-template-rows:1fr auto;background:var(--bg,#1a1a1a);z-index:60;}
.ce-shell .vp{grid-column:1;grid-row:1;min-height:0;position:relative;background:#1a1a1a;overflow:hidden;}
.ce-shell .vp canvas{display:block;width:100%;height:100%;}
.ce-panel{grid-column:2;grid-row:1/3;background:var(--panel,#222);border-left:1px solid var(--line,#333);overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.ce-panel-hdr{display:flex;align-items:center;justify-content:space-between;}
.ce-panel-hdr h2{font-size:13px;margin:0;}
.ce-x{background:none;border:none;font-size:16px;color:var(--muted,#888);cursor:pointer;}
.ce-sec{border:1px solid var(--line,#333);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px;}
.ce-sec-hdr{font-size:12px;font-weight:700;display:flex;justify-content:space-between;align-items:center;gap:6px;}
.ce-sec-hdr .ce-hint{font-size:10px;color:var(--muted,#888);font-weight:400;}
.ce-row{display:flex;gap:4px;align-items:center;}
.ce-grow{flex:1;min-width:0;}
.ce-grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;}
.ce-grid3 label,.ce-full{font-size:10px;color:var(--muted,#888);display:flex;flex-direction:column;gap:2px;}
.ce-full{display:flex;flex-direction:column;gap:2px;}
.ce-grid3 input,.ce-grid3 select,.ce-full input,.ce-row input,.ce-row select{background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;outline:none;width:100%;box-sizing:border-box;}
.ce-grid3 input:focus,.ce-row input:focus,.ce-row select:focus{border-color:#f97316;}
.ce-grid3 input:disabled{opacity:.4;}
.ce-btnrow{display:flex;gap:4px;flex-wrap:wrap;}
.ce-mini{background:none;border:1px solid var(--line,#333);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted,#aaa);cursor:pointer;font-family:inherit;}
.ce-mini:hover{border-color:#f97316;color:#f97316;}
.ce-mini.primary{background:#f97316;color:#fff;border-color:#f97316;}
.ce-btn{background:none;border:1px solid var(--line,#333);border-radius:7px;padding:7px 10px;font-size:12px;color:var(--text,#eee);cursor:pointer;font-family:inherit;font-weight:700;width:100%;}
.ce-btn.primary{background:#f97316;color:#fff;border-color:#f97316;}
.ce-btn:hover{border-color:#f97316;}
.ce-dims{font-size:11px;line-height:1.6;color:var(--text,#eee);}
.ce-dims b{color:#f97316;}
.ce-muted{color:var(--muted,#888);}
.ce-bar{grid-column:1;grid-row:2;background:var(--panel,#222);border-top:1px solid var(--line,#333);padding:8px 12px;min-height:20px;}
.ce-info{font-size:12px;color:var(--text,#eee);display:none;}
.ce-info.show{display:block;}
.ce-err{font-size:12px;color:#ef4444;display:none;}
.ce-err.show{display:block;}
@media(max-width:760px){.ce-shell{grid-template-columns:1fr;grid-template-rows:1fr auto auto;}.ce-panel{grid-column:1;grid-row:3;border-left:none;border-top:1px solid var(--line,#333);max-height:45vh;}}
`;
document.head.appendChild(st);
}
// ── 진입점 ────────────────────────────────────────────────────────────
let inited = false;
async function initEditor() {
if (inited) return;
if (!document.getElementById('cad-root')) return;
injectStyles();
buildOverlay();
if (!vpEl) return;
initScene();
await loadFileList();
inited = true;
const ro = new ResizeObserver(() => resize());
ro.observe(vpEl);
requestAnimationFrame(() => { resize(); requestAnimationFrame(resize); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initEditor);
} else {
initEditor();
}
// 글로벌 노출(인라인 onclick).
window.__cadRefresh = loadFileList;
window.__cadLoad = loadSelected;
window.__cadFlip = flipAxis;
window.__cadMirror = mirrorAxis;
window.__cadCenter = centerModel;
window.__cadPick = () => {
if (!mesh) { setErr('모델을 먼저 로드하세요.'); return; }
pickMode = true;
renderer.domElement.style.cursor = 'crosshair';
setInfo('📍 모델 표면을 클릭하세요 (구멍 위치 지정)');
};
window.__cadHole = applyHole;
window.__cadCut = applyCut;
window.__cadBool = applyBool;
window.__cadSave = saveModel;
})();