feat: k2 슬라이서에 Cura식 모델 배치(복제/이동/회전/배율) 추가

단일 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 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 21:29:05 +09:00
co-authored by Claude Code
parent d93e6bcdf9
commit fedc03d5f4
2 changed files with 345 additions and 37 deletions
+8 -2
View File
@@ -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);
+337 -35
View File
@@ -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(
`✅ 슬라이스 완료: <b>${curGcodeFile}</b><br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 수량 ${d.qty} · 소재 ${d.material}<br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 인스턴스 <b>${instances.length}</b>개 · 소재 ${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>`
);
@@ -652,6 +907,24 @@
<h2>🔧 슬라이싱 프로파일</h2>
<button class="sv-x" onclick="window.close()">✕</button>
</div>
<div class="sv-place">
<div class="sv-place-hdr">📦 모델 배치 <span id="sv-inst-info">—</span></div>
<div class="sv-place-grid">
<label>X(mm)<input type="number" id="sv-tx" min="0" max="260" step="1" disabled></label>
<label>Y(mm)<input type="number" id="sv-ty" min="0" max="260" step="1" disabled></label>
<label>회전(°)<input type="number" id="sv-trot" step="15" disabled></label>
<label>배율<input type="number" id="sv-tscale" min="0.1" max="10" step="0.1" disabled></label>
</div>
<div class="sv-place-btns">
<button class="sv-mini" onclick="window.__instDup()">복제</button>
<button class="sv-mini" onclick="window.__instRot(-90)">↺90</button>
<button class="sv-mini" onclick="window.__instRot(90)">↻90</button>
<button class="sv-mini" onclick="window.__instCenter()">중앙</button>
<button class="sv-mini" onclick="window.__instArrange()">자동배치</button>
<button class="sv-mini danger" onclick="window.__instDel()">삭제</button>
</div>
<div class="sv-place-hint">뷰에서 모델을 클릭해 선택·드래그로 이동</div>
</div>
<div class="sv-prof-bar">
<select id="sv-prof-select"><option value="">— 기본(베이스) —</option></select>
<button class="sv-mini" onclick="window.__profLoad()">불러오기</button>
@@ -663,8 +936,6 @@
<div class="sv-file-bar">
<label>STL 파일</label>
<input type="file" id="sv-file" accept=".stl">
<label>수량</label>
<input type="number" id="sv-qty" value="1" min="1" max="20">
</div>
</aside>
<div class="sv-bar" id="sv-bar">
@@ -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(`📂 <b>${f.name}</b> · 크기 ${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;
})();