From 77c573f4a58c84ec2f5b53326201a978e9e49168 Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 20 Sep 2026 21:53:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20"STL=20=ED=99=95=EC=9D=B8"=20=E2=86=92?= =?UTF-8?q?=20Three.js=203D=20STL=20=ED=8E=B8=EC=A7=91=EA=B8=B0=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 팝업 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=¶ms, ?second=) 추가. 기존 list/info/render/ add-hole은 건드리지 않음. - k2-app: "🔧 STL 확인" 버튼 → "🔧 STL 편집" 팝업(openCadEditorPopup). 옛 PNG 모달 마크업·JS·CSS 제거. Co-Authored-By: Claude Code --- src/gateway/routes/routes-cad.ts | 107 +++++- src/tools/stl-cad-core.ts | 64 +++- web-ui/html/cad-editor-app.html | 26 ++ web-ui/html/k2-app.html | 187 +-------- web-ui/js/k2/cad-editor-view.js | 639 +++++++++++++++++++++++++++++++ 5 files changed, 832 insertions(+), 191 deletions(-) create mode 100644 web-ui/html/cad-editor-app.html create mode 100644 web-ui/js/k2/cad-editor-view.js diff --git a/src/gateway/routes/routes-cad.ts b/src/gateway/routes/routes-cad.ts index 296bb0e..7b44c18 100644 --- a/src/gateway/routes/routes-cad.ts +++ b/src/gateway/routes/routes-cad.ts @@ -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=가 필요합니다.` }); + } + 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 */ } } diff --git a/src/tools/stl-cad-core.ts b/src/tools/stl-cad-core.ts index a80791d..ca6f190 100644 --- a/src/tools/stl-cad-core.ts +++ b/src/tools/stl-cad-core.ts @@ -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[] { diff --git a/web-ui/html/cad-editor-app.html b/web-ui/html/cad-editor-app.html new file mode 100644 index 0000000..9ca52a2 --- /dev/null +++ b/web-ui/html/cad-editor-app.html @@ -0,0 +1,26 @@ + + + + + +🔧 STL 편집기 + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/web-ui/html/k2-app.html b/web-ui/html/k2-app.html index 0ace155..5c848a0 100644 --- a/web-ui/html/k2-app.html +++ b/web-ui/html/k2-app.html @@ -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엔 오버레이 없음 ── */ @@ -74,7 +59,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis

🖨️ K2 콤보

- + ↗ 새 탭 @@ -95,47 +80,6 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis -
-
- -

🔧 STL 확인

-
- - -
-
- - - - - - - -
-
파일을 선택하세요
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-
-
-
-
- diff --git a/web-ui/js/k2/cad-editor-view.js b/web-ui/js/k2/cad-editor-view.js new file mode 100644 index 0000000..82cc393 --- /dev/null +++ b/web-ui/js/k2/cad-editor-view.js @@ -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 = '모델 없음'; + ['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 = `원본 크기: ${s[0].toFixed(1)} × ${s[1].toFixed(1)} × ${s[2].toFixed(1)} mm · ${nativeBBox.tris}삼각형
` + + `원본 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)}]`; + // 변환 입력값 갱신(역매핑). + 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 = [''] + .concat(files.map((f) => ``)) + .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 = ` +
+
+ +
+
+
+
+
+ `; + 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; +})(); \ No newline at end of file