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 @@