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:
@@ -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
@@ -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;
|
||||
})();
|
||||
Reference in New Issue
Block a user