From fedc03d5f4e4d40538417d70802a473751c8a53f Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 20 Sep 2026 21:29:05 +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=20Cura=EC=8B=9D=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98(=EB=B3=B5=EC=A0=9C/=EC=9D=B4=EB=8F=99/?= =?UTF-8?q?=ED=9A=8C=EC=A0=84/=EB=B0=B0=EC=9C=A8)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 stlMesh 대신 인스턴스 모델 도입 — baseGeo 공유 위에 다중 THREE.Mesh를 position/rotation/scale로 배치. 뷰에서 클릭 선택·드래그 이동, 사이드 패널에서 숫자 입력(X/Y/회전/배율) + 복제/↺90/↻90/중앙/자동배치/삭제 버튼. 슬라이스 시 모든 인스턴스를 단일 바이너리 STL로 구워(bake) 전송 — 각 정점을 베드 좌표(bedX=threeX, bedY=BED-threeZ, 높이=threeY)로 변환. 서버 /api/k2/slice 에 arrange=0 추가 → PrusaSlicer --dont-arrange 로 원본 XY 보존(기존 --duplicate 대체, --merge 세그폴트 회피). 실측 검증: 큐브 STL 0..20 을 arrange=0 슬라이스 → 베드 0..20 유지(재중앙화 ❌). 2-큐브 합본 STL → 두 클러스터가 각각 지정 베드 좌표에 배치됨 확인. Co-Authored-By: Claude Code --- src/gateway/routes/routes-k2.ts | 10 +- web-ui/js/k2/slicer-view.js | 372 +++++++++++++++++++++++++++++--- 2 files changed, 345 insertions(+), 37 deletions(-) diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts index a610ec5..dfc6b71 100644 --- a/src/gateway/routes/routes-k2.ts +++ b/src/gateway/routes/routes-k2.ts @@ -186,8 +186,13 @@ export function registerK2Routes(app: Express): void { // 소재는 프로파일 ini의 filament_type/temperature 값에서 읽는다(에디터에서 설정). // 응답 표시용 라벨만 여기서 뽑는다. // 여러 파츠를 한 판에 합치는 --merge는 이 헤드리스 환경에서 세그폴트가 나서(2026-09-20 - // 확인) 지원 안 함 — 같은 STL 반복 출력만 --duplicate로 지원(크래시 없음, 검증됨). - const qty = Math.max(1, Math.min(20, Number.isFinite(Number(req.query.qty)) ? Math.round(Number(req.query.qty)) : 1)); + // 확인) 지원 안 함. 대신 웹 UI의 Cura식 배치 도구가 인스턴스들을 단일 STL로 구워(bake) + // 보내면, arrange=0 일 때 --dont-arrange 로 원본 XY 좌표를 그대로 보존해 슬라이싱한다 + // (2026-09-20 실측: 큐브 STL 0..20 → --dont-arrange 시 베드 0..20 유지, 재중앙화 안 됨). + // arrange=0 이면 인스턴스가 이미 복제까지 구워져 들어오므로 --duplicate는 끈다. + const arrange = String(req.query.arrange || '1'); + const dontArrange = arrange === '0' || arrange === 'false'; + const qty = dontArrange ? 1 : Math.max(1, Math.min(20, Number.isFinite(Number(req.query.qty)) ? Math.round(Number(req.query.qty)) : 1)); const workspacePath = getUserWorkspace(user.username); const profPath = profilePath(workspacePath, profileName); @@ -209,6 +214,7 @@ export function registerK2Routes(app: Express): void { const psArgs = [ ...profileSliceArgs(profPath, gcodePath, stlPath), ...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []), + ...(dontArrange ? ['--dont-arrange'] : []), ]; const slice = await runPrusaSlicer(psArgs, 5 * 60_000); diff --git a/web-ui/js/k2/slicer-view.js b/web-ui/js/k2/slicer-view.js index 4e179df..2d7b5e0 100644 --- a/web-ui/js/k2/slicer-view.js +++ b/web-ui/js/k2/slicer-view.js @@ -2,7 +2,9 @@ // Three.js(r128 UMD, /vendor/three/)로 260³ 빌드 플레이트 위 STL을 3D로 띄우고, 슬라이스 후 // gcode를 파싱해 툴패스를 레이어별로 시각화한다. 프로파일 설정(스키마·오버라이드·저장)은 // 이전 폼 에디터에서 이 모듈로 이전. 슬라이스↔업로드 흐름 분리(Cura식 slice≠전송). -// 백엔드: /api/k2/profile/* · /api/k2/slice?upload=0 · /api/k2/gcode/:filename · /api/k2/upload. +// 2026-09-20 추가: Cura식 모델 배치 — 복제/이동/회전/배율. 인스턴스들을 단일 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'; const THREE = window.THREE; @@ -32,6 +34,8 @@ }; const TRAVEL_COLOR = 0x555555; const DEFAULT_COLOR = 0xffffff; + const INST_COLOR = 0x4a90d9; + const INST_COLOR_SEL = 0xf97316; // 소재 온도 프리셋(서버 MATERIAL_PRESETS와 동일). const MATERIAL_TEMP_PRESETS = { @@ -122,7 +126,13 @@ // ── 상태 ────────────────────────────────────────────────────────────── let overlayEl, vpEl, panelEl, barEl; let scene, camera, renderer, controls, raycaster; - let stlMesh = null; + // 인스턴스 배치: 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 개별) + let selectedIdx = -1; let toolpathGroup = null; let layerObjects = []; // THREE.LineSegments per layer let layerZs = []; // Z(mm) per layer @@ -130,6 +140,12 @@ let profBaseValues = {}; let profLoaded = false; let singleLayerMode = false; + // 드래그 이동 상태 — OrbitControls 와 충돌 안 나게 드래그 중엔 controls 끔. + let dragging = false; + let dragOffX = 0, dragOffZ = 0; + const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const tmpVec = new THREE.Vector3(); + const tmpMouse = new THREE.Vector2(); function authH() { let t = ''; @@ -137,6 +153,8 @@ return t ? { Authorization: 'Bearer ' + t } : {}; } + function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + // ── Three.js 씬 초기화 ──────────────────────────────────────────────── function initScene() { scene = new THREE.Scene(); @@ -187,6 +205,15 @@ raycaster = new THREE.Raycaster(); + // 인스턴스 선택/드래그 — 포인터 이벤트로 직접 처리. OrbitControls 회전과 분리: + // 인스턴스 메시를 클릭하면 선택+드래그 시작(controls 비활성), 빈 곳 클릭은 그대로 + // 회전(controls). pointerup 으로 항상 controls 복구. + const dom = renderer.domElement; + dom.addEventListener('pointerdown', onPointerDown); + dom.addEventListener('pointermove', onPointerMove); + dom.addEventListener('pointerup', onPointerUp); + dom.addEventListener('pointercancel', onPointerUp); + resize(); animate(); } @@ -208,6 +235,149 @@ renderer.render(scene, camera); } + // ── 인스턴스 배치 ───────────────────────────────────────────────────── + // 좌표계 핵심(2026-09-20 실측으로 정립): + // STL 은 Z-up. loadStlArrayBuffer 에서 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). + // bake 시 STL 정점은 (threeX, BED - threeZ, threeY) = (bedX, bedY, 높이) 로 변환. + + function makeInstMaterial() { + return new THREE.MeshStandardMaterial({ + color: INST_COLOR, 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; + } + } + + function clearInstances() { + for (const m of instances) { scene.remove(m); m.material.dispose(); } + instances = []; + selectedIdx = -1; + } + + function addInstance(px, pz, rotY, scale) { + if (!baseGeo) return null; + const mesh = new THREE.Mesh(baseGeo, makeInstMaterial()); + mesh.position.set(px, 0, pz); + mesh.rotation.y = rotY; + mesh.scale.setScalar(scale); + scene.add(mesh); + instances.push(mesh); + return mesh; + } + + function selectInstance(i) { + selectedIdx = (i >= 0 && i < instances.length) ? i : -1; + applyInstanceLook(); + 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 duplicateSelected() { + if (selectedIdx < 0 || !instances.length) 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); } + } + + function rotateSelected(deg) { + if (selectedIdx < 0) return; + const m = instances[selectedIdx]; + m.rotation.y += deg * Math.PI / 180; + refreshTransformPanel(); + } + + function centerSelected() { + if (selectedIdx < 0) return; + instances[selectedIdx].position.set(BED / 2, 0, BED / 2); + refreshTransformPanel(); + } + + function deleteSelected() { + if (selectedIdx < 0) return; + const m = instances[selectedIdx]; + scene.remove(m); m.material.dispose(); + instances.splice(selectedIdx, 1); + selectInstance(Math.min(selectedIdx, instances.length - 1)); + } + + // 모든 인스턴스를 근사 그리드로 재배치(Cura "arrange all"). 모델 bbox 기반 셀 크기. + function autoArrange() { + if (!instances.length || !baseStlSize) return; + const n = instances.length; + const cols = Math.ceil(Math.sqrt(n)); + const rows = Math.ceil(n / cols); + const cellW = BED / cols, cellD = BED / rows; + 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); + clampInstance(instances[i]); + } + } + if (selectedIdx >= 0) applyInstanceLook(); + refreshTransformPanel(); + } + + // 배치 패널의 숫자 입력을 선택 인스턴스에 반영. + function applyTransformInputs() { + if (selectedIdx < 0) return; + const m = instances[selectedIdx]; + 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 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)); + } + + function refreshTransformPanel() { + const tx = panelEl.querySelector('#sv-tx'); + const ty = panelEl.querySelector('#sv-ty'); + const tr = panelEl.querySelector('#sv-trot'); + const ts = panelEl.querySelector('#sv-tscale'); + const info = panelEl.querySelector('#sv-inst-info'); + if (!tx) return; + if (selectedIdx < 0 || !instances.length) { + [tx, ty, tr, ts].forEach((el) => { el.disabled = true; }); + if (info) info.textContent = instances.length ? '선택 없음' : '—'; + return; + } + const m = instances[selectedIdx]; + [tx, ty, tr, ts].forEach((el) => { el.disabled = false; }); + tx.value = m.position.x.toFixed(1); + 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}개`; + } + // ── STL 로드 ────────────────────────────────────────────────────────── function loadStlArrayBuffer(buf, name) { const loader = new THREE.STLLoader(); @@ -226,18 +396,19 @@ geo.translate(-cx, -bb.min.y, -cz); // 플레이트 위(바닥=y0), XZ 중앙 정렬 geo.computeVertexNormals(); - if (stlMesh) { scene.remove(stlMesh); stlMesh.geometry.dispose(); stlMesh.material.dispose(); } - const mat = new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.6, metalness: 0.05, flatShading: false, transparent: true, opacity: 0.55 }); - stlMesh = new THREE.Mesh(geo, mat); - // 플레이트 중앙(BED/2)에 배치. - stlMesh.position.set(BED / 2, 0, BED / 2); - scene.add(stlMesh); + 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; - // 툴패스가 있으면 STL은 반투명(겹침 강조). 없으면 불투명. - stlMesh.material.opacity = toolpathGroup ? 0.18 : 0.9; + // 툴패스가 있으면 제거(새 모델엔 맞지 않음). + if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); toolpathGroup = null; layerObjects = []; layerZs = []; } + clearInstances(); + addInstance(BED / 2, BED / 2, 0, 1); // 플레이트 중앙에 1개 배치 + selectInstance(0); fitCameraToPlate(); - const size = { x: bb.max.x - bb.min.x, y: bb.max.y - bb.min.y, z: bb.max.z - bb.min.z }; return { ok: true, size, name }; } @@ -249,6 +420,96 @@ camera.updateProjectionMatrix(); } + // ── 인스턴스들을 단일 바이너리 STL 로 구워 서버 전송용으로 만든다 ─────── + // 각 인스턴스의 변환(scale→rotateY→translate)을 baseGeo 정점에 적용해 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; + 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++) { + // 법선 0 — PrusaSlicer 가 자체 재계산하므로 0 이어도 안전. + dv.setFloat32(off, 0, true); dv.setFloat32(off + 4, 0, true); dv.setFloat32(off + 8, 0, true); + off += 12; + for (let j = 0; j < 3; j++) { + v.fromBufferAttribute(pos, t * 3 + j).applyMatrix4(m); + // three (X, Y높이, Z) → STL (bedX, bedY, 높이) + dv.setFloat32(off, v.x, true); // STL X = bed X + dv.setFloat32(off + 4, BED - v.z, true); // STL Y = bed Y (three Z 를 뒤집음) + dv.setFloat32(off + 8, v.y, true); // STL Z = 높이 + off += 12; + } + dv.setUint16(off, 0, true); off += 2; // 속성 바이트 + } + } + return buf; + } + + // ── 포인터(선택/드래그 이동) ────────────────────────────────────────── + function setMouseFromEvent(e) { + const rect = renderer.domElement.getBoundingClientRect(); + tmpMouse.set( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1 + ); + } + + function onPointerDown(e) { + if (!instances.length) return; + setMouseFromEvent(e); + raycaster.setFromCamera(tmpMouse, camera); + const hits = raycaster.intersectObjects(instances, false); + if (!hits.length) return; // 빈 곳 클릭 → 회전(controls) 그대로 + const idx = instances.indexOf(hits[0].object); + selectInstance(idx); + // 평면 y=0 과의 교차로 잡기 오프셋 계산(잡은 점이 커서 밑에 머무르도록). + if (raycaster.ray.intersectPlane(dragPlane, tmpVec)) { + dragOffX = instances[idx].position.x - tmpVec.x; + dragOffZ = instances[idx].position.z - tmpVec.z; + } else { dragOffX = 0; dragOffZ = 0; } + dragging = true; + controls.enabled = false; + try { renderer.domElement.setPointerCapture(e.pointerId); } catch (err) { } + } + + function onPointerMove(e) { + if (!dragging || selectedIdx < 0) return; + setMouseFromEvent(e); + raycaster.setFromCamera(tmpMouse, camera); + if (raycaster.ray.intersectPlane(dragPlane, tmpVec)) { + const mesh = instances[selectedIdx]; + mesh.position.x = tmpVec.x + dragOffX; + mesh.position.z = tmpVec.z + dragOffZ; + clampInstance(mesh); + refreshTransformPanel(); + } + } + + function onPointerUp(e) { + if (!dragging) return; + dragging = false; + controls.enabled = true; + try { renderer.domElement.releasePointerCapture(e.pointerId); } catch (err) { } + } + // ── gcode 툴패스 파싱 + 렌더 ────────────────────────────────────────── // G0/G1 이동을 레이어별 세그먼트로 변환. ;TYPE:별 색. 절대/상대 E(M82/M83) 모두 대응. // 레이어 경계는 ;LAYER_CHANGE 마커(PrusaSlicer가 항상 내보냄)로 잡는다 — Z 변화 감지를 @@ -315,7 +576,7 @@ if (px !== nx || py !== ny || pz !== nz) { const color = extruding ? (TYPE_COLORS[curType] ?? DEFAULT_COLOR) : TRAVEL_COLOR; // gcode 좌표계: X/Y가 베드 평면(0~260), Z가 높이. 세그먼트엔 {x=gcodeX, y=gcodeZ(높이), z=gcodeY}. - // STL(rotateX -90)과 정렬하기 위해 렌더단에서 Z를 BED-gcodeY 로 뒤집음(아래 renderToolpath). + // 렌더단에서 three Z = BED - gcodeY 로 뒤집음 → 인스턴스(threeZ = BED - bedY)와 정렬. layers[curLayer].segs.push({ x1: px, y1: pz, z1: py, x2: nx, y2: nz, z2: ny, color }); } x = nx; y = ny; z = nz; e = ne; @@ -339,11 +600,9 @@ for (let s = 0; s < segs.length; s++) { const sg = segs[s]; const o = s * 6; - // gcode (X,Y,Z) → three (X, 높이Y, Z) 매핑. PrusaSlicer가 모델을 베드 중심(130,130)에 - // 두고 gcode 좌표를 베드 좌표계(0~260) 그대로 내보내므로 X는 오프셋 없이 그대로. - // Z는 BED-gcodeY 로 뒤집어 STL 미리보기(rotateX -90 → STL +Y가 three -Z)와 정렬 — - // 옛 코드의 +BED/2 오프셋은 gcode가 이미 베드좌표인 걸 몰라 두 번 더해서 (260,260) 모서리로 - // 밀어버렸음(2026-09-20 "중심에서 벗어남" 수정). + // gcode (X,Y,Z) → three (X, 높이Y, Z) 매핑. PrusaSlicer가 --dont-arrange 로 베드 + // 좌표계(0~260) 그대로 내보내므로 X는 오프셋 없이 그대로. Z는 BED-gcodeY 로 뒤집어 + // 인스턴스 미리보기(rotateX -90 → STL +Y가 three -Z)와 정렬. pos[o] = sg.x1; pos[o + 1] = sg.y1; pos[o + 2] = BED - sg.z1; pos[o + 3] = sg.x2; pos[o + 4] = sg.y2; pos[o + 5] = BED - sg.z2; const c = new THREE.Color(sg.color); @@ -360,8 +619,8 @@ layerObjects.push(ls); } scene.add(toolpathGroup); - // STL 반투명화(툴패스 겹침) - if (stlMesh) stlMesh.material.opacity = 0.18; + // 인스턴스 반투명화(툴패스 겹침) + applyInstanceLook(); } function disposeGroup(g) { @@ -563,26 +822,22 @@ async function runSlice() { setErr(''); setInfo(''); - const fileInput = panelEl.querySelector('#sv-file'); - const file = fileInput.files[0]; - if (!file) { setErr('STL 파일을 선택하세요.'); return; } + if (!baseGeo || !instances.length) { setErr('STL 파일을 선택하세요.'); return; } const btn = barEl.querySelector('#sv-slice-btn'); btn.disabled = true; btn.textContent = '슬라이싱 중...'; try { - // 1) STL을 3D 뷰에 로드(즉시 미리보기). - const buf = await file.arrayBuffer(); - const lr = loadStlArrayBuffer(buf, file.name); - if (!lr.ok) { setErr(lr.detail); return; } + // 1) 인스턴스들을 단일 STL로 구움(베드 절대좌표). arrange=0 로 서버가 XY 보존. + 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 qty = panelEl.querySelector('#sv-qty').value || 1; - const qs = new URLSearchParams({ profile: '__current', qty, upload: '0' }); - const r = await fetch('/api/k2/slice?' + qs.toString(), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', ...authH() }, body: buf }); + // 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; @@ -599,7 +854,7 @@ const filamentStr = d.filamentMm != null ? `${d.filamentMm.toFixed(0)}mm` : '—'; setInfo( `✅ 슬라이스 완료: ${curGcodeFile}
` + - `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 수량 ${d.qty} · 소재 ${d.material}
` + + `예상 시간 ${fmtDuration(d.printTimeSec)} · 필라멘트 ${filamentStr} · ${layers.length}층 · 인스턴스 ${instances.length}개 · 소재 ${d.material}
` + ` ` + `` ); @@ -652,6 +907,24 @@

🔧 슬라이싱 프로파일

+
+
📦 모델 배치 —
+
+ + + + +
+
+ + + + + + +
+
뷰에서 모델을 클릭해 선택·드래그로 이동
+
@@ -663,8 +936,6 @@
- -
@@ -686,6 +957,22 @@ vpEl = overlayEl.querySelector('#sv-vp'); panelEl = overlayEl.querySelector('#sv-panel'); barEl = overlayEl.querySelector('#sv-bar'); + + // 배치 패널 숫자 입력 → 선택 인스턴스에 실시간 반영. 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')); + + // STL 파일 선택 → 즉시 3D 로드(슬라이스 전 미리보기 + 배치). + const fileInput = panelEl.querySelector('#sv-file'); + if (fileInput) fileInput.addEventListener('change', async (ev) => { + const f = ev.target.files[0]; if (!f) 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`); + }); } function injectStyles() { @@ -700,6 +987,16 @@ .sv-panel-hdr{display:flex;align-items:center;justify-content:space-between;} .sv-panel-hdr h2{font-size:13px;margin:0;} .sv-x{background:none;border:none;font-size:16px;color:var(--muted,#888);cursor:pointer;} +.sv-place{border:1px solid var(--line,#333);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px;} +.sv-place-hdr{font-size:12px;font-weight:700;display:flex;justify-content:space-between;align-items:center;} +.sv-place-hdr span{font-size:11px;color:var(--muted,#aaa);font-weight:400;} +.sv-place-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;} +.sv-place-grid label{font-size:10px;color:var(--muted,#888);display:flex;flex-direction:column;gap:2px;} +.sv-place-grid input{background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;outline:none;} +.sv-place-grid input:focus{border-color:#f97316;} +.sv-place-grid input:disabled{opacity:.4;} +.sv-place-btns{display:flex;flex-wrap:wrap;gap:4px;} +.sv-place-hint{font-size:10px;color:var(--muted,#777);} .sv-prof-bar{display:flex;gap:4px;flex-wrap:wrap;align-items:center;} .sv-prof-bar select{flex:1;min-width:90px;background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:6px;padding:5px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;} .sv-mini{background:none;border:1px solid var(--line,#333);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted,#aaa);cursor:pointer;font-family:inherit;} @@ -720,7 +1017,6 @@ .sv-file-bar{border-top:1px solid var(--line,#333);padding-top:8px;display:flex;flex-direction:column;gap:4px;} .sv-file-bar label{font-size:10px;color:var(--muted,#888);} .sv-file-bar input[type=file]{font-size:11px;color:var(--text,#eee);} -.sv-file-bar input[type=number]{width:70px;background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;} .sv-bar{grid-column:1;grid-row:2;background:var(--panel,#222);border-top:1px solid var(--line,#333);padding:8px 12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;} .sv-bar-left{display:flex;align-items:center;gap:8px;} .sv-bar-center{flex:1;display:flex;align-items:center;gap:8px;min-width:200px;} @@ -771,7 +1067,7 @@ initSlicer(); } - // 슬라이더/버튼 콜백을 글로벌로 노출(인라인 onclick 호환). + // 슬라이더/버튼/배치 콜백을 글로벌로 노출(인라인 onclick 호환). window.__slicerSlice = runSlice; window.__slicerUpload = uploadGcode; window.__slicerPrint = startPrint; @@ -786,4 +1082,10 @@ window.__profSave = profSaveAs; window.__profDel = profDeleteSelected; window.__profReset = profResetBase; + // 인스턴스 배치 조작 + window.__instDup = duplicateSelected; + window.__instRot = rotateSelected; + window.__instCenter = centerSelected; + window.__instArrange = autoArrange; + window.__instDel = deleteSelected; })(); \ No newline at end of file