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=¶ms, ?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:
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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,'"')}">${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>
|
||||
|
||||
@@ -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, '"')}">${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;
|
||||
})();
|
||||
Reference in New Issue
Block a user