feat: K2 슬라이서에 3MF 파일 지원 추가

PrusaSlicer CLI가 3MF를 네이티브로 지원해서(확장자로 자동판별) 서버는
format 쿼리파라미터(stl 기본값, 3mf)로 저장 확장자만 바꾸면 됨.

- routes-k2.ts: /api/k2/slice에 format 파라미터 추가
- print3d.ts: stl_url 확장자가 .3mf면 3mf로 다운로드/슬라이스
- slicer-view.js: 파일선택 .3mf 허용. Three.js STLLoader로는 3MF를
  못 읽으므로 미리보기/배치편집 없이 원본 그대로 패스스루(arrange=1,
  STL 다중 인스턴스 편집과 상호배타적)

E2E 검증: 3MF 큐브 슬라이스 200 성공, 기존 STL 경로 회귀 없음 확인.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-21 20:39:23 +09:00
co-authored by Claude Code
parent af6627f0ce
commit b2285a8059
3 changed files with 85 additions and 19 deletions
+10 -2
View File
@@ -173,8 +173,16 @@ export function registerK2Routes(app: Express): void {
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 100) {
return res.status(400).json({ error: 'STL 파일이 비어있거나 너무 작습니다.' });
return res.status(400).json({ error: '모델 파일이 비어있거나 너무 작습니다.' });
}
// 2026-09-21: 3MF 지원 추가 — PrusaSlicer CLI가 3MF를 네이티브로 지원해서(확장자로 자동판별)
// 그냥 .3mf로 저장해서 넘기면 됨. 3MF는 클라이언트에서 미리보기/배치 편집 없이 원본 그대로
// 통째로 보내는 방식(Three.js STLLoader로는 3MF를 못 읽어서 바로 편집기에 못 올림).
const format = String(req.query.format || 'stl').toLowerCase();
if (format !== 'stl' && format !== '3mf') {
return res.status(400).json({ error: `지원하지 않는 형식: ${format} (stl 또는 3mf)` });
}
const modelExt = format === '3mf' ? '.3mf' : '.stl';
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
}
@@ -205,7 +213,7 @@ export function registerK2Routes(app: Express): void {
const outDir = path.join(workspacePath, 'print3d');
fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `k2ui_${stamp}.stl`);
const stlPath = path.join(outDir, `k2ui_${stamp}${modelExt}`);
fs.writeFileSync(stlPath, buf);
const gcodeFilename = `k2ui_${stamp}.gcode`;
+8 -4
View File
@@ -52,12 +52,12 @@ async function downloadStl(url: string, outPath: string): Promise<{ ok: true } |
}
if (!res.ok || !res.body) return { ok: false, detail: `다운로드 실패: HTTP ${res.status}` };
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — STL이 아닌 것 같습니다.' };
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — STL/3MF가 아닌 것 같습니다.' };
if (buf.length > 100 * 1024 * 1024) return { ok: false, detail: '파일이 100MB를 넘어 너무 큽니다.' };
// Thingiverse 401 응답처럼 인증벽에 막혀 HTML 에러페이지가 대신 내려오는 경우를 거른다.
const head = buf.subarray(0, 200).toString('utf8').toLowerCase();
if (head.includes('<html') || head.includes('<!doctype')) {
return { ok: false, detail: '내려받은 내용이 STL이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드 링크일 수 있습니다). 다른 출처(Printables, GitHub raw 등)를 시도하세요.' };
return { ok: false, detail: '내려받은 내용이 모델 파일이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드 링크일 수 있습니다). 다른 출처(Printables, GitHub raw 등)를 시도하세요.' };
}
fs.writeFileSync(outPath, buf);
return { ok: true };
@@ -72,7 +72,7 @@ export const print3dModelTool = {
required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
stl_url: { type: 'string', description: '(prepare 필수) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 print3d/<프로젝트명>/ 하위폴더에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
@@ -145,7 +145,11 @@ export const print3dModelTool = {
}
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `model_${stamp}.stl`);
// 2026-09-21: 3MF 지원 — URL 확장자가 .3mf면 그대로 3mf로 저장(PrusaSlicer CLI가 네이티브
// 지원, 확장자로 자동판별). 그 외엔 기존처럼 .stl로 저장(URL에 확장자 없어도 안전한 기본값).
const urlExt = (() => { try { return path.extname(new URL(stlUrl).pathname).toLowerCase(); } catch { return ''; } })();
const modelExt = urlExt === '.3mf' ? '.3mf' : '.stl';
const stlPath = path.join(outDir, `model_${stamp}${modelExt}`);
const dl = await downloadStl(stlUrl, stlPath);
if (!dl.ok) return { success: false, error: dl.detail };
+67 -13
View File
@@ -6,7 +6,9 @@
// 2026-09-20 모델 배치: 복제/이동/회전/배율 + **여러 STL 동시 로드**(모델별 색 구분) +
// **위아래 뒤집기**(X축 180°). 인스턴스들을 단일 STL로 구워(bake) 서버에 보내면 서버는
// --dont-arrange 로 원본 XY 를 보존해 슬라이싱한다.
// 백엔드: /api/k2/profile/* · /api/k2/slice?upload=0&arrange=0 · /api/k2/gcode/:filename · /api/k2/upload.
// 백엔드: /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 다중 인스턴스 편집과 상호배타적.
(function () {
'use strict';
const THREE = window.THREE;
@@ -138,6 +140,10 @@
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;
@@ -320,6 +326,15 @@
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];
@@ -861,22 +876,33 @@
async function runSlice() {
setErr(''); setInfo('');
if (!instances.length) { setErr('STL 파일을 선택하세요.'); return; }
if (!pending3mf && !instances.length) { setErr('STL 또는 3MF 파일을 선택하세요.'); return; }
const btn = barEl.querySelector('#sv-slice-btn');
btn.disabled = true; btn.textContent = '슬라이싱 중...';
try {
// 1) 인스턴스들을 단일 STL로 구움(베드 절대좌표). arrange=0 로 서버가 XY 보존.
const baked = bakeInstancesStl();
if (!baked) { setErr('인스턴스를 구울 수 없습니다.'); return; }
// 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';
}
// 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) 슬라이스(업로드 안 함). 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 });
// 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 });
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || ('HTTP ' + r.status));
curGcodeFile = d.gcodeFilename;
@@ -891,9 +917,10 @@
// 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>층 · 모델 <b>${models.length}</b>종 · 인스턴스 <b>${instances.length}</b>개 · 소재 ${d.material}<br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · ${modelSummary} · 소재 ${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>`
);
@@ -975,8 +1002,9 @@
</div>
<div id="sv-prof-sections" class="sv-prof-sections"></div>
<div class="sv-file-bar">
<label>STL 파일 (여러 개 동시 선택 가능)</label>
<input type="file" id="sv-file" accept=".stl" multiple>
<label>STL 파일(여러 개 동시 선택 가능) 또는 3MF 파일 1개</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">
@@ -1009,15 +1037,40 @@
const files = Array.from(ev.target.files || []);
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 files) {
for (const f of stlFiles) {
const buf = await f.arrayBuffer();
const lr = loadModel(buf, f.name);
if (!lr.ok) { setErr(lr.detail + ' (' + f.name + ')'); continue; }
// 겹침 회피용 간단 오프셋 배치 — 세밀 위치는 드래그/자동배치로.
const px = clamp(BED / 2 + (placed - (files.length - 1) / 2) * 35, 15, BED - 15);
const px = clamp(BED / 2 + (placed - (stlFiles.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++;
@@ -1146,4 +1199,5 @@
window.__instArrange = autoArrange;
window.__instDel = deleteSelected;
window.__instClear = clearAll;
window.__clear3mf = clear3mf;
})();