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