refactor: K2 3MF 지원을 패스스루 대신 서버 변환 방식으로 전환

3MF를 미리보기 없이 원본 그대로 보내는 대신, PrusaSlicer --export-stl로
서버에서 STL로 변환한 뒤 기존 STL 파이프라인(미리보기+다중배치편집)에
그대로 태우도록 개선. 사용자 쪽에서도 '그냥 STL로 변환하면 안 되나' 제안.

- routes-k2.ts: POST /api/k2/convert-3mf-to-stl 신규(임시파일→변환→응답,
  cleanupTemp로 정리)
- slicer-view.js: 파일선택 시 .3mf면 변환API 먼저 태운 뒤 기존
  loadModel()/addInstance()로 합류 — STL과 완전히 동일하게 취급됨.
  이전 커밋의 '미리보기 없는 패스스루'(pending3mf 상태/분기) 전부 제거.

print3d_model 채팅도구의 .3mf 다운로드 확장자 인식은 유지(변경 없음).

E2E 검증: 변환 API 단독 호출로 3MF→STL 정상 확인(삼각형 수 일치).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-21 20:43:58 +09:00
co-authored by Claude Code
parent b2285a8059
commit c54c07338a
2 changed files with 78 additions and 67 deletions
+40
View File
@@ -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 */ }
}
+38 -67
View File
@@ -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 <b>${pending3mf.name}</b>` : `모델 <b>${models.length}</b>종 · 인스턴스 <b>${instances.length}</b>개`;
setInfo(
`✅ 슬라이스 완료: <b>${curGcodeFile}</b><br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · ${modelSummary} · 소재 ${d.material}<br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 모델 <b>${models.length}</b>종 · 인스턴스 <b>${instances.length}</b>개 · 소재 ${d.material}<br>` +
`<button class="sv-btn green" id="sv-upload-btn" onclick="window.__slicerUpload()">📤 K2 업로드</button> ` +
`<button class="sv-btn" id="sv-print-btn" onclick="window.__slicerPrint()" disabled>🖨️ 출력 시작</button>`
);
@@ -1002,9 +980,8 @@
</div>
<div id="sv-prof-sections" class="sv-prof-sections"></div>
<div class="sv-file-bar">
<label>STL 파일(여러 개 동시 선택 가능) 또는 3MF 파일 1개</label>
<label>STL/3MF 파일 (여러 개 동시 선택 가능 — 3MF는 자동으로 STL 변환됨)</label>
<input type="file" id="sv-file" accept=".stl,.3mf" multiple>
<div class="sv-place-hint" id="sv-3mf-hint" style="display:none;"></div>
</div>
</aside>
<div class="sv-bar" id="sv-bar">
@@ -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 선택됨: <b>${pending3mf.name}</b> (미리보기/배치편집 없이 원본 그대로 슬라이스) <button class="sv-mini" onclick="window.__clear3mf()">지우기</button>`;
}
setInfo(`📦 3MF 로드됨: <b>${pending3mf.name}</b> — 바로 "🔪 슬라이싱" 눌러 진행하세요.`);
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;
})();