diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index 6da53c2..ff9fae1 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -1,6 +1,7 @@ import express, { Express, Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; +import os from 'os'; import { getUserWorkspace } from '../../config/config'; import { isPathInsideDir } from '../../tools/image'; import { @@ -255,6 +256,41 @@ export function registerK2Routes(app: Express): void { }); }); + // 3MF → STL 변환(PrusaSlicer --export-stl, 네이티브 지원이라 파싱 직접 안 함). 슬라이서 웹UI가 + // 3MF 선택 시 이걸 먼저 태워서 기존 STL 미리보기/배치편집 파이프라인을 그대로 쓸 수 있게 한다 + // (Three.js STLLoader가 3MF를 못 읽어서 별도 패스스루가 아니라 변환 경로로 감, 2026-09-21). + app.post('/api/k2/convert-3mf-to-stl', 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(PRUSA_SLICER_BIN)) { + return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` }); + } + const buf: Buffer = req.body; + if (!Buffer.isBuffer(buf) || buf.length < 100) { + return res.status(400).json({ error: '3MF 파일이 비어있거나 너무 작습니다.' }); + } + + const stamp = Date.now(); + const tmpDir = os.tmpdir(); + const inPath = path.join(tmpDir, `k2_3mf_${stamp}.3mf`); + const outPath = path.join(tmpDir, `k2_3mf_${stamp}.stl`); + try { fs.writeFileSync(inPath, buf); } + catch (e: any) { return res.status(500).json({ error: '임시 3MF 쓰기 실패: ' + String(e?.message || e) }); } + + const conv = await runPrusaSlicer(['--export-stl', '-o', outPath, inPath], 2 * 60_000); + cleanupTemp(inPath); + if (!conv.ok) { cleanupTemp(outPath); return res.status(500).json({ error: `3MF 변환 실패: ${conv.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) }); + } + }); + // 생성된 gcode를 Moonraker에 업로드(슬라이스와 분리된 명시적 전송 단계). app.post('/api/k2/upload', express.json(), async (req: Request, res: Response) => { const user = (req as any).user; @@ -309,3 +345,7 @@ export function registerK2Routes(app: Express): void { } }); } + +function cleanupTemp(p: string): void { + try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* noop */ } +} diff --git a/web-ui/js/k2/slicer-view.js b/web-ui/js/k2/slicer-view.js index 0063541..5fa021c 100644 --- a/web-ui/js/k2/slicer-view.js +++ b/web-ui/js/k2/slicer-view.js @@ -6,9 +6,11 @@ // 2026-09-20 모델 배치: 복제/이동/회전/배율 + **여러 STL 동시 로드**(모델별 색 구분) + // **위아래 뒤집기**(X축 180°). 인스턴스들을 단일 STL로 구워(bake) 서버에 보내면 서버는 // --dont-arrange 로 원본 XY 를 보존해 슬라이싱한다. -// 백엔드: /api/k2/profile/* · /api/k2/slice?upload=0&arrange=0|1&format=stl|3mf · /api/k2/gcode/:filename · /api/k2/upload. -// 2026-09-21: 3MF 지원 — Three.js STLLoader로는 못 읽어서 미리보기/배치편집 없이 원본 그대로 -// 패스스루(arrange=1, format=3mf). STL 다중 인스턴스 편집과 상호배타적. +// 백엔드: /api/k2/profile/* · /api/k2/slice?upload=0&arrange=0 · /api/k2/convert-3mf-to-stl · +// /api/k2/gcode/:filename · /api/k2/upload. +// 2026-09-21: 3MF 지원 — Three.js STLLoader로는 3MF를 못 읽어서, 파일 선택 시점에 서버 +// (PrusaSlicer --export-stl)에서 먼저 STL로 변환받아 기존 STL 파이프라인(미리보기+배치편집)에 +// 그대로 태운다. 이후로는 STL과 완전히 동일하게 취급됨. (function () { 'use strict'; const THREE = window.THREE; @@ -140,10 +142,6 @@ let layerObjects = []; // THREE.LineSegments per layer let layerZs = []; // Z(mm) per layer let curGcodeFile = null; - // 2026-09-21: 3MF 패스스루 — Three.js STLLoader로는 3MF를 못 읽어서 미리보기/배치편집 없이 - // 원본 파일 그대로 서버에 보내 슬라이스한다(PrusaSlicer CLI가 3MF 네이티브 지원). STL 다중 - // 인스턴스 편집과는 상호배타적 — 3MF 선택 시 기존 STL 모델/인스턴스는 비운다. - let pending3mf = null; // {file: File, name: string} | null let profBaseValues = {}; let profLoaded = false; let singleLayerMode = false; @@ -326,15 +324,6 @@ refreshTransformPanel(); } - // 3MF 패스스루 모드 해제 — STL 쪽으로 되돌아갈 때(또는 사용자가 "지우기" 눌렀을 때) 호출. - function clear3mf() { - pending3mf = null; - if (panelEl) { - const hintEl = panelEl.querySelector('#sv-3mf-hint'); - if (hintEl) { hintEl.style.display = 'none'; hintEl.innerHTML = ''; } - } - } - function duplicateSelected() { if (selectedIdx < 0) return; const s = instances[selectedIdx]; @@ -876,33 +865,23 @@ async function runSlice() { setErr(''); setInfo(''); - if (!pending3mf && !instances.length) { setErr('STL 또는 3MF 파일을 선택하세요.'); return; } + if (!instances.length) { setErr('STL 또는 3MF 파일을 선택하세요.'); return; } const btn = barEl.querySelector('#sv-slice-btn'); btn.disabled = true; btn.textContent = '슬라이싱 중...'; try { - // 1) 모델 바이트 준비 — STL은 인스턴스들을 단일 STL로 구움(베드 절대좌표, arrange=0으로 - // 서버가 XY 보존). 3MF는 미리보기/배치편집 없이 원본 그대로 통째로 보냄(arrange=1, - // PrusaSlicer가 자체 배치 — 우리가 좌표를 안 구웠으니 베드 밖으로 나갈 위험 회피). - let body, format, arrangeParam; - if (pending3mf) { - body = await pending3mf.file.arrayBuffer(); - format = '3mf'; - arrangeParam = '1'; - } else { - body = bakeInstancesStl(); - if (!body) { setErr('인스턴스를 구울 수 없습니다.'); return; } - format = 'stl'; - arrangeParam = '0'; - } + // 1) 인스턴스들을 단일 STL로 구움(베드 절대좌표). arrange=0 로 서버가 XY 보존. + // (3MF는 파일 선택 시점에 이미 서버에서 STL로 변환돼 일반 인스턴스로 들어와 있음.) + const baked = bakeInstancesStl(); + if (!baked) { setErr('인스턴스를 구울 수 없습니다.'); return; } // 2) 현재 편집값을 __current 프로파일로 저장. const sv = await fetch('/api/k2/profiles/__current', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() }, body: JSON.stringify({ overrides: collectOverrides() }) }); const sd = await sv.json(); if (!sv.ok || !sd.success) throw new Error(sd.error || ('HTTP ' + sv.status)); - // 3) 슬라이스(업로드 안 함). - const qs = new URLSearchParams({ profile: '__current', upload: '0', arrange: arrangeParam, format }); - const r = await fetch('/api/k2/slice?' + qs.toString(), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', ...authH() }, body }); + // 3) 슬라이스(업로드 안 함). arrange=0 → 서버 --dont-arrange. + const qs = new URLSearchParams({ profile: '__current', upload: '0', arrange: '0' }); + const r = await fetch('/api/k2/slice?' + qs.toString(), { 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)); curGcodeFile = d.gcodeFilename; @@ -917,10 +896,9 @@ // 5) 정보 표시 + 업로드/출력 버튼 활성화. const filamentStr = d.filamentMm != null ? `${d.filamentMm.toFixed(0)}mm` : '—'; - const modelSummary = pending3mf ? `3MF ${pending3mf.name}` : `모델 ${models.length}종 · 인스턴스 ${instances.length}개`; setInfo( `✅ 슬라이스 완료: ${curGcodeFile}
` + - `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · ${modelSummary} · 소재 ${d.material}
` + + `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 모델 ${models.length}종 · 인스턴스 ${instances.length}개 · 소재 ${d.material}
` + ` ` + `` ); @@ -1002,9 +980,8 @@
- + -
@@ -1038,39 +1015,34 @@ if (!files.length) return; setErr(''); - const mfFiles = files.filter((f) => /\.3mf$/i.test(f.name)); - const stlFiles = files.filter((f) => !/\.3mf$/i.test(f.name)); - - if (mfFiles.length) { - // 3MF는 미리보기/배치편집 없이 원본 그대로 패스스루 — STL 쪽과 상호배타적이라 - // 기존 인스턴스·모델을 비우고 3MF 모드로 전환. 여러 개 섞여 왔으면 첫 3MF만 사용. - if (mfFiles.length > 1 || stlFiles.length) { - setErr('3MF는 한 번에 하나만 지원합니다(STL과 동시 선택 불가) — 첫 번째 3MF 파일만 사용합니다.'); - } - clearAll(); - pending3mf = { file: mfFiles[0], name: mfFiles[0].name }; - const hintEl = panelEl.querySelector('#sv-3mf-hint'); - if (hintEl) { - hintEl.style.display = ''; - hintEl.innerHTML = `📦 3MF 선택됨: ${pending3mf.name} (미리보기/배치편집 없이 원본 그대로 슬라이스) `; - } - setInfo(`📦 3MF 로드됨: ${pending3mf.name} — 바로 "🔪 슬라이싱" 눌러 진행하세요.`); - ev.target.value = ''; - return; - } - - // STL 파일 선택 — 기존 3MF 모드가 켜져 있었으면 해제. - if (pending3mf) clear3mf(); - // 새 모델이 들어오면 기존 툴패스는 무효 → 제거. if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); toolpathGroup = null; layerObjects = []; layerZs = []; } let placed = 0; - for (const f of stlFiles) { - const buf = await f.arrayBuffer(); - const lr = loadModel(buf, f.name); + for (const f of files) { + const isMf = /\.3mf$/i.test(f.name); + let buf; + if (isMf) { + // 2026-09-21: Three.js STLLoader가 3MF를 못 읽어서, 서버(PrusaSlicer --export-stl)에서 + // 먼저 STL로 변환받아 기존 STL 파이프라인(미리보기+배치편집) 그대로 태운다. + setInfo(`📦 3MF→STL 변환 중: ${f.name} ...`); + try { + const mfBuf = await f.arrayBuffer(); + const cr = await fetch('/api/k2/convert-3mf-to-stl', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', ...authH() }, body: mfBuf }); + if (!cr.ok) { + const cd = await cr.json().catch(() => ({})); + setErr((cd.error || ('HTTP ' + cr.status)) + ' (' + f.name + ')'); + continue; + } + buf = await cr.arrayBuffer(); + } catch (e) { setErr('3MF 변환 실패: ' + e.message + ' (' + f.name + ')'); continue; } + } else { + buf = await f.arrayBuffer(); + } + const displayName = isMf ? f.name.replace(/\.3mf$/i, '.stl') : f.name; + const lr = loadModel(buf, displayName); if (!lr.ok) { setErr(lr.detail + ' (' + f.name + ')'); continue; } // 겹침 회피용 간단 오프셋 배치 — 세밀 위치는 드래그/자동배치로. - const px = clamp(BED / 2 + (placed - (stlFiles.length - 1) / 2) * 35, 15, BED - 15); + const px = clamp(BED / 2 + (placed - (files.length - 1) / 2) * 35, 15, BED - 15); const pz = clamp(BED / 2 + (placed % 2) * 35 - 17, 15, BED - 15); addInstance(lr.idx, px, pz, 0, 1); placed++; @@ -1199,5 +1171,4 @@ window.__instArrange = autoArrange; window.__instDel = deleteSelected; window.__instClear = clearAll; - window.__clear3mf = clear3mf; })(); \ No newline at end of file