From b2285a805906949ed41698a290ea3c3ca4903b22 Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 21 Sep 2026 20:39:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20K2=20=EC=8A=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=84=9C=EC=97=90=203MF=20=ED=8C=8C=EC=9D=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/gateway/routes/routes-k2.ts | 12 ++++- src/tools/print3d.ts | 12 +++-- web-ui/js/k2/slicer-view.js | 80 +++++++++++++++++++++++++++------ 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index dfc6b71..6da53c2 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -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`; diff --git a/src/tools/print3d.ts b/src/tools/print3d.ts index bc0e2e5..5ec9de2 100644 --- a/src/tools/print3d.ts +++ b/src/tools/print3d.ts @@ -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('/ 하위폴더에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 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 }; diff --git a/web-ui/js/k2/slicer-view.js b/web-ui/js/k2/slicer-view.js index 0de97b8..0063541 100644 --- a/web-ui/js/k2/slicer-view.js +++ b/web-ui/js/k2/slicer-view.js @@ -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 ${pending3mf.name}` : `모델 ${models.length}종 · 인스턴스 ${instances.length}개`; setInfo( `✅ 슬라이스 완료: ${curGcodeFile}
` + - `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 모델 ${models.length}종 · 인스턴스 ${instances.length}개 · 소재 ${d.material}
` + + `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · ${modelSummary} · 소재 ${d.material}
` + ` ` + `` ); @@ -975,8 +1002,9 @@
- - + + +
@@ -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 선택됨: ${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 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; })(); \ No newline at end of file