From 25a7fbb71d3adbf9e52726a4191d9e34eb85fb33 Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 20 Sep 2026 21:38:10 +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=20STL=20=EB=B3=B5=EC=88=98=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20+=20=EC=9C=84=EC=95=84=EB=9E=98=20=EB=92=A4?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 입력 multiple: 여러 STL을 한 번에 불러와 각각 별도 모델(색으로 구분)로 등록, 인스턴스 1개씩 배치. 모델/인스턴스 구조 분리(userData.modelIdx)로 서로 다른 기하 공존. - ↕뒤집기 버튼: X축 180° 회전 후 reLiftInstance로 바닥이 베드에 붙도록 position.y 보정. bake 시 mesh.matrix(전체 회전·배율·liftY 포함)를 정점에 적용 → 뒤집기 상태까지 보존. - 전체삭제 버튼 추가. 복제는 회전·뒤집기·배율 전부 복사. - 기존 좌표계 규약(three Z = BED - bedY) 유지, 다중 모델에서도 인스턴스·툴패스 정렬 유지. Co-Authored-By: Claude Code --- web-ui/js/k2/slicer-view.js | 240 ++++++++++++++++++++++-------------- 1 file changed, 149 insertions(+), 91 deletions(-) diff --git a/web-ui/js/k2/slicer-view.js b/web-ui/js/k2/slicer-view.js index 2d7b5e0..0de97b8 100644 --- a/web-ui/js/k2/slicer-view.js +++ b/web-ui/js/k2/slicer-view.js @@ -2,8 +2,10 @@ // Three.js(r128 UMD, /vendor/three/)로 260³ 빌드 플레이트 위 STL을 3D로 띄우고, 슬라이스 후 // gcode를 파싱해 툴패스를 레이어별로 시각화한다. 프로파일 설정(스키마·오버라이드·저장)은 // 이전 폼 에디터에서 이 모듈로 이전. 슬라이스↔업로드 흐름 분리(Cura식 slice≠전송). -// 2026-09-20 추가: Cura식 모델 배치 — 복제/이동/회전/배율. 인스턴스들을 단일 STL로 구워 -//(bake) 서버에 보내면 서버는 --dont-arrange 로 원본 XY 를 보존해 슬라이싱한다. +// +// 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. (function () { 'use strict'; @@ -34,8 +36,9 @@ }; const TRAVEL_COLOR = 0x555555; const DEFAULT_COLOR = 0xffffff; - const INST_COLOR = 0x4a90d9; const INST_COLOR_SEL = 0xf97316; + // 모델(파일)별 색 순환 — 선택 시엔 주황(SEL)으로 덮어씀. + const MODEL_COLORS = [0x4a90d9, 0x9b59d6, 0x1abc9c, 0x2ecc71, 0xe74c3c, 0xf1c40f, 0x3aa9c9, 0xe91e8a]; // 소재 온도 프리셋(서버 MATERIAL_PRESETS와 동일). const MATERIAL_TEMP_PRESETS = { @@ -126,12 +129,10 @@ // ── 상태 ────────────────────────────────────────────────────────────── let overlayEl, vpEl, panelEl, barEl; let scene, camera, renderer, controls, raycaster; - // 인스턴스 배치: baseGeo(공유 기하) 위에 instances[](THREE.Mesh)를 올리고, 각각 - // position/rotation/scale 로 변환. 단일 stlMesh 대신 다중 인스턴스. - let baseGeo = null; // 중앙정렬·rotateX(-90) 적용된 공유 BufferGeometry - let baseStlSize = null; // {x,y,z} 기본 모델 크기(클램프/자동배치용) - let baseStlName = ''; - let instances = []; // THREE.Mesh[] (baseGeo 공유, material 개별) + // 모델(파일)과 인스턴스(배치된 복제본)를 분리 — 여러 STL을 각각 모델로 등록하고, + // 인스턴스는 어떤 모델을 참조할지 userData.modelIdx 로 가리킴. + let models = []; // [{name, geo, size, color}] + let instances = []; // THREE.Mesh[] (userData.modelIdx 보유, material 개별) let selectedIdx = -1; let toolpathGroup = null; let layerObjects = []; // THREE.LineSegments per layer @@ -145,6 +146,7 @@ let dragOffX = 0, dragOffZ = 0; const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const tmpVec = new THREE.Vector3(); + const tmpBox = new THREE.Box3(); const tmpMouse = new THREE.Vector2(); function authH() { @@ -154,6 +156,7 @@ } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + function modelColor(i) { return MODEL_COLORS[i % MODEL_COLORS.length]; } // ── Three.js 씬 초기화 ──────────────────────────────────────────────── function initScene() { @@ -235,42 +238,61 @@ renderer.render(scene, camera); } - // ── 인스턴스 배치 ───────────────────────────────────────────────────── + // ── 모델(파일) + 인스턴스 배치 ─────────────────────────────────────── // 좌표계 핵심(2026-09-20 실측으로 정립): - // STL 은 Z-up. loadStlArrayBuffer 에서 rotateX(-90°) → STL +Y 가 three -Z 로 간다. + // STL 은 Z-up. loadModel 에서 rotateX(-90°) → STL +Y 가 three -Z 로 간다. // 그래서 베드 Y 와 three Z 의 관계는 bedY = BED - threeZ (또는 threeZ = BED - bedY). // gcode 툴패스도 같은 규약(renderToolpath 의 BED - sg.z1)을 쓰므로 인스턴스와 정렬됨. - // 인스턴스의 three position (px, 0, pz) 가 의미하는 베드 좌표는 (px, BED - pz). + // 인스턴스의 three position (px, liftY, pz) 가 의미하는 베드 좌표는 (px, BED - pz). + // liftY = reLiftInstance 가 계산하는 "바닥이 베드에 붙도록" 하는 Y 오프셋(뒤집기 후 필수). // bake 시 STL 정점은 (threeX, BED - threeZ, threeY) = (bedX, bedY, 높이) 로 변환. - function makeInstMaterial() { + function makeInstMaterial(modelIdx) { return new THREE.MeshStandardMaterial({ - color: INST_COLOR, roughness: 0.6, metalness: 0.05, + color: modelColor(modelIdx), roughness: 0.6, metalness: 0.05, flatShading: false, transparent: true, opacity: 0.7, }); } function applyInstanceLook() { for (let i = 0; i < instances.length; i++) { - const m = instances[i].material; - m.color.setHex(i === selectedIdx ? INST_COLOR_SEL : INST_COLOR); - if (toolpathGroup) m.opacity = (i === selectedIdx) ? 0.32 : 0.16; - else m.opacity = (i === selectedIdx) ? 0.9 : 0.65; + const inst = instances[i]; + const isSel = i === selectedIdx; + inst.material.color.setHex(isSel ? INST_COLOR_SEL : modelColor(inst.userData.modelIdx)); + inst.material.opacity = toolpathGroup ? (isSel ? 0.32 : 0.16) : (isSel ? 0.9 : 0.65); } } - function clearInstances() { - for (const m of instances) { scene.remove(m); m.material.dispose(); } - instances = []; - selectedIdx = -1; + // 회전/배율/뒤집기 후 모델 바닥이 플레이트(y=0)에 붙도록 position.y 보정. + // X축 180° 뒤집기 등을 하면 기하가 y<0 으로 내려가므로 이 lift 가 없으면 베드 밑으로 간다. + function reLiftInstance(mesh) { + mesh.position.y = 0; + mesh.updateMatrixWorld(true); + tmpBox.setFromObject(mesh); + mesh.position.y = -tmpBox.min.y; } - function addInstance(px, pz, rotY, scale) { - if (!baseGeo) return null; - const mesh = new THREE.Mesh(baseGeo, makeInstMaterial()); + function instModelSize(inst) { + return models[inst.userData.modelIdx]?.size || { x: 20, y: 20, z: 20 }; + } + + // 인스턴스가 플레이트 안에 머무르도록 중심점 클램프(회전에 의한 bbox 확장은 무시, 근사). + function clampInstance(mesh) { + const size = instModelSize(mesh); + const half = Math.max(size.x, size.z) / 2 * mesh.scale.x; + mesh.position.x = clamp(mesh.position.x, half, BED - half); + mesh.position.z = clamp(mesh.position.z, half, BED - half); + } + + function addInstance(modelIdx, px, pz, rotY, scale) { + const m = models[modelIdx]; + if (!m) return null; + const mesh = new THREE.Mesh(m.geo, makeInstMaterial(modelIdx)); + mesh.userData.modelIdx = modelIdx; mesh.position.set(px, 0, pz); - mesh.rotation.y = rotY; + mesh.rotation.set(0, rotY, 0); mesh.scale.setScalar(scale); + reLiftInstance(mesh); scene.add(mesh); instances.push(mesh); return mesh; @@ -282,32 +304,57 @@ refreshTransformPanel(); } - // 인스턴스가 플레이트 안에 머무르도록 중심점 클램프(회전에 의한 bbox 확장은 무시, 근사). - function clampInstance(mesh) { - if (!baseStlSize) return; - const half = Math.max(baseStlSize.x, baseStlSize.z) / 2 * mesh.scale.x; - mesh.position.x = clamp(mesh.position.x, half, BED - half); - mesh.position.z = clamp(mesh.position.z, half, BED - half); + function clearInstances() { + for (const m of instances) { scene.remove(m); m.material.dispose(); } + instances = []; + selectedIdx = -1; + } + + // 판 전체 초기화 — 모델(파일)·인스턴스·툴패스 전부 비움. + function clearAll() { + clearInstances(); + for (const m of models) m.geo.dispose(); + models = []; + if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); toolpathGroup = null; layerObjects = []; layerZs = []; } + setInfo(''); setErr(''); + refreshTransformPanel(); } function duplicateSelected() { - if (selectedIdx < 0 || !instances.length) return; + if (selectedIdx < 0) return; const s = instances[selectedIdx]; - const off = baseStlSize ? Math.max(baseStlSize.x, baseStlSize.z) * s.scale.x + 10 : 20; - const mesh = addInstance(s.position.x + off, s.position.z, s.rotation.y, s.scale.x); - if (mesh) { clampInstance(mesh); selectInstance(instances.length - 1); } + const size = instModelSize(s); + const off = Math.max(size.x, size.z) * s.scale.x + 10; + const mesh = addInstance(s.userData.modelIdx, s.position.x + off, s.position.z, 0, s.scale.x); + if (!mesh) return; + // 회전·뒤집기·배율 전부 복사(뒤집기 상태까지 물려줌). + mesh.rotation.copy(s.rotation); + mesh.scale.copy(s.scale); + reLiftInstance(mesh); + clampInstance(mesh); + selectInstance(instances.length - 1); } function rotateSelected(deg) { + if (selectedIdx < 0) return; + instances[selectedIdx].rotation.y += deg * Math.PI / 180; + refreshTransformPanel(); + } + + // 위아래 뒤집기 — X축 180° 회전 후 리프트(바닥이 베드에 붙도록). + function flipSelected() { if (selectedIdx < 0) return; const m = instances[selectedIdx]; - m.rotation.y += deg * Math.PI / 180; + m.rotation.x += Math.PI; + reLiftInstance(m); refreshTransformPanel(); } function centerSelected() { if (selectedIdx < 0) return; - instances[selectedIdx].position.set(BED / 2, 0, BED / 2); + const m = instances[selectedIdx]; + m.position.x = BED / 2; + m.position.z = BED / 2; refreshTransformPanel(); } @@ -321,7 +368,7 @@ // 모든 인스턴스를 근사 그리드로 재배치(Cura "arrange all"). 모델 bbox 기반 셀 크기. function autoArrange() { - if (!instances.length || !baseStlSize) return; + if (!instances.length) return; const n = instances.length; const cols = Math.ceil(Math.sqrt(n)); const rows = Math.ceil(n / cols); @@ -329,10 +376,10 @@ let i = 0; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols && i < n; c++, i++) { - // 셀 중앙. three Z = BED - bedY 이므로 bedY 중앙 = (r+0.5)*cellD → pz = BED - that. const bedX = (c + 0.5) * cellW; const bedY = (r + 0.5) * cellD; - instances[i].position.set(bedX, 0, BED - bedY); + instances[i].position.x = bedX; + instances[i].position.z = BED - bedY; clampInstance(instances[i]); } } @@ -344,17 +391,19 @@ function applyTransformInputs() { if (selectedIdx < 0) return; const m = instances[selectedIdx]; + const size = instModelSize(m); const tx = panelEl.querySelector('#sv-tx'); const ty = panelEl.querySelector('#sv-ty'); const tr = panelEl.querySelector('#sv-trot'); const ts = panelEl.querySelector('#sv-tscale'); - let half = baseStlSize ? Math.max(baseStlSize.x, baseStlSize.z) / 2 * m.scale.x : 0; + const half = Math.max(size.x, size.z) / 2 * m.scale.x; const bedX = clamp(parseFloat(tx.value) || 0, half, BED - half); const bedY = clamp(parseFloat(ty.value) || 0, half, BED - half); m.position.x = bedX; m.position.z = BED - bedY; m.rotation.y = (parseFloat(tr.value) || 0) * Math.PI / 180; m.scale.setScalar(Math.max(0.1, parseFloat(ts.value) || 1)); + reLiftInstance(m); } function refreshTransformPanel() { @@ -375,11 +424,12 @@ ty.value = (BED - m.position.z).toFixed(1); tr.value = (m.rotation.y * 180 / Math.PI).toFixed(0); ts.value = m.scale.x.toFixed(2); - if (info) info.textContent = `#${selectedIdx + 1}/${instances.length}개`; + const mdl = models[m.userData.modelIdx]; + if (info) info.textContent = `#${selectedIdx + 1}/${instances.length}개` + (mdl ? ' · ' + mdl.name : ''); } - // ── STL 로드 ────────────────────────────────────────────────────────── - function loadStlArrayBuffer(buf, name) { + // ── STL 모델 로드(파일 → models[]) ─────────────────────────────────── + function loadModel(buf, name) { const loader = new THREE.STLLoader(); let geo; try { geo = loader.parse(buf); } @@ -395,21 +445,10 @@ const cz = (bb.min.z + bb.max.z) / 2; geo.translate(-cx, -bb.min.y, -cz); // 플레이트 위(바닥=y0), XZ 중앙 정렬 geo.computeVertexNormals(); - - if (baseGeo) baseGeo.dispose(); - baseGeo = geo; - baseStlName = name || ''; const size = { x: bb.max.x - bb.min.x, y: bb.max.y - bb.min.y, z: bb.max.z - bb.min.z }; - baseStlSize = size; - - // 툴패스가 있으면 제거(새 모델엔 맞지 않음). - if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); toolpathGroup = null; layerObjects = []; layerZs = []; } - clearInstances(); - addInstance(BED / 2, BED / 2, 0, 1); // 플레이트 중앙에 1개 배치 - selectInstance(0); - - fitCameraToPlate(); - return { ok: true, size, name }; + const idx = models.length; + models.push({ name: name || ('model_' + (idx + 1)), geo, size, color: modelColor(idx) }); + return { ok: true, idx, size, name }; } function fitCameraToPlate() { @@ -421,31 +460,31 @@ } // ── 인스턴스들을 단일 바이너리 STL 로 구워 서버 전송용으로 만든다 ─────── - // 각 인스턴스의 변환(scale→rotateY→translate)을 baseGeo 정점에 적용해 three 세계좌표를 - // 구하고, 베드 좌표 (bedX=threeX, bedY=BED-threeZ, 높이=threeY) 로 바꿔 STL 정점으로 기록. - // PrusaSlicer 가 --dont-arrange 로 이 XY 를 그대로 보존(2026-09-20 실측 검증). + // 각 인스턴스의 변환(전체 회전·배율·position 포함 liftY)을 mesh.matrix 그대로 정점에 적용해 + // three 세계좌표를 구하고, 베드 좌표 (bedX=threeX, bedY=BED-threeZ, 높이=threeY) 로 바꿔 + // STL 정점으로 기록. PrusaSlicer 가 --dont-arrange 로 이 XY 를 그대로 보존(2026-09-20 실측). function bakeInstancesStl() { - if (!baseGeo || !instances.length) return null; - const pos = baseGeo.attributes.position; - const triPerInst = Math.floor(pos.count / 3); - const totalTris = triPerInst * instances.length; + if (!instances.length) return null; + let totalTris = 0; + const perInstPos = []; + for (const inst of instances) { + const pos = models[inst.userData.modelIdx].geo.attributes.position; + const tri = Math.floor(pos.count / 3); + perInstPos.push(pos); + totalTris += tri; + } const buf = new ArrayBuffer(84 + totalTris * 50); const dv = new DataView(buf); dv.setUint32(80, totalTris, true); // 80바이트 헤더 뒤 삼각형 수 let off = 84; const v = new THREE.Vector3(); - const m = new THREE.Matrix4(); - const q = new THREE.Quaternion(); - const sc = new THREE.Vector3(1, 1, 1); - const p = new THREE.Vector3(); - const eul = new THREE.Euler(0, 0, 0); - for (const inst of instances) { - p.set(inst.position.x, 0, inst.position.z); - eul.y = inst.rotation.y; - q.setFromEuler(eul); - sc.setScalar(inst.scale.x); - m.compose(p, q, sc); - for (let t = 0; t < triPerInst; t++) { + for (let i = 0; i < instances.length; i++) { + const inst = instances[i]; + inst.updateMatrix(); + const m = inst.matrix; // position(liftY 포함)·회전(X축 뒤집기 포함)·배율 반영 + const pos = perInstPos[i]; + const tri = Math.floor(pos.count / 3); + for (let t = 0; t < tri; t++) { // 법선 0 — PrusaSlicer 가 자체 재계산하므로 0 이어도 안전. dv.setFloat32(off, 0, true); dv.setFloat32(off + 4, 0, true); dv.setFloat32(off + 8, 0, true); off += 12; @@ -822,7 +861,7 @@ async function runSlice() { setErr(''); setInfo(''); - if (!baseGeo || !instances.length) { setErr('STL 파일을 선택하세요.'); return; } + if (!instances.length) { setErr('STL 파일을 선택하세요.'); return; } const btn = barEl.querySelector('#sv-slice-btn'); btn.disabled = true; btn.textContent = '슬라이싱 중...'; @@ -854,7 +893,7 @@ const filamentStr = d.filamentMm != null ? `${d.filamentMm.toFixed(0)}mm` : '—'; setInfo( `✅ 슬라이스 완료: ${curGcodeFile}
` + - `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 인스턴스 ${instances.length}개 · 소재 ${d.material}
` + + `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 모델 ${models.length}종 · 인스턴스 ${instances.length}개 · 소재 ${d.material}
` + ` ` + `` ); @@ -919,11 +958,13 @@ + + -
뷰에서 모델을 클릭해 선택·드래그로 이동
+
뷰에서 클릭 선택·드래그 이동 · STL 여러 개 동시 선택 가능
@@ -934,8 +975,8 @@
- - + +
@@ -960,18 +1001,33 @@ // 배치 패널 숫자 입력 → 선택 인스턴스에 실시간 반영. change 시엔 클램프 결과 표시 갱신. const wireInput = (id, ev) => { const el = panelEl.querySelector(id); if (el) el.addEventListener(ev, () => { applyTransformInputs(); if (ev === 'change') refreshTransformPanel(); }); }; - wireInput('#sv-tx', 'input'); wireInput('#sv-ty', 'input'); wireInput('#sv-trot', 'input'); wireInput('#sv-tscale', 'input'); - ['#sv-tx', '#sv-ty', '#sv-trot', '#sv-tscale'].forEach((id) => wireInput(id, 'change')); + ['#sv-tx', '#sv-ty', '#sv-trot', '#sv-tscale'].forEach((id) => { wireInput(id, 'input'); wireInput(id, 'change'); }); - // STL 파일 선택 → 즉시 3D 로드(슬라이스 전 미리보기 + 배치). + // STL 파일 선택(여러 개) → 각각 모델로 등록 + 인스턴스 1개씩 배치. const fileInput = panelEl.querySelector('#sv-file'); if (fileInput) fileInput.addEventListener('change', async (ev) => { - const f = ev.target.files[0]; if (!f) return; + const files = Array.from(ev.target.files || []); + if (!files.length) return; setErr(''); - const buf = await f.arrayBuffer(); - const lr = loadStlArrayBuffer(buf, f.name); - if (!lr.ok) { setErr(lr.detail); return; } - setInfo(`📂 ${f.name} · 크기 ${lr.size.x.toFixed(1)}×${lr.size.y.toFixed(1)}×${lr.size.z.toFixed(1)}mm`); + // 새 모델이 들어오면 기존 툴패스는 무효 → 제거. + if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); toolpathGroup = null; layerObjects = []; layerZs = []; } + let placed = 0; + for (const f of files) { + 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 pz = clamp(BED / 2 + (placed % 2) * 35 - 17, 15, BED - 15); + addInstance(lr.idx, px, pz, 0, 1); + placed++; + } + if (placed) { + selectInstance(instances.length - 1); + fitCameraToPlate(); + setInfo(`📂 ${placed}개 모델 로드 · 총 ${instances.length}개 인스턴스 · ${models.length}종`); + } + ev.target.value = ''; // 같은 파일 다시 선택 가능하게 초기화 }); } @@ -1085,7 +1141,9 @@ // 인스턴스 배치 조작 window.__instDup = duplicateSelected; window.__instRot = rotateSelected; + window.__instFlip = flipSelected; window.__instCenter = centerSelected; window.__instArrange = autoArrange; window.__instDel = deleteSelected; + window.__instClear = clearAll; })(); \ No newline at end of file