feat: k2 슬라이서에 STL 복수 로드 + 위아래 뒤집기 추가

- 파일 입력 multiple: 여러 STL을 한 번에 불러와 각각 별도 모델(색으로 구분)로 등록,
  인스턴스 1개씩 배치. 모델/인스턴스 구조 분리(userData.modelIdx)로 서로 다른 기하 공존.
- ↕뒤집기 버튼: X축 180° 회전 후 reLiftInstance로 바닥이 베드에 붙도록 position.y 보정.
  bake 시 mesh.matrix(전체 회전·배율·liftY 포함)를 정점에 적용 → 뒤집기 상태까지 보존.
- 전체삭제 버튼 추가. 복제는 회전·뒤집기·배율 전부 복사.
- 기존 좌표계 규약(three Z = BED - bedY) 유지, 다중 모델에서도 인스턴스·툴패스 정렬 유지.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 21:38:10 +09:00
co-authored by Claude Code
parent fedc03d5f4
commit 25a7fbb71d
+149 -91
View File
@@ -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(
`✅ 슬라이스 완료: <b>${curGcodeFile}</b><br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 인스턴스 <b>${instances.length}</b>개 · 소재 ${d.material}<br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 모델 <b>${models.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>`
);
@@ -919,11 +958,13 @@
<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.__instFlip()">↕뒤집기</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>
<button class="sv-mini danger" onclick="window.__instClear()">전체삭제</button>
</div>
<div class="sv-place-hint">뷰에서 모델을 클릭해 선택·드래그로 이동</div>
<div class="sv-place-hint">뷰에서 클릭 선택·드래그 이동 · STL 여러 개 동시 선택 가능</div>
</div>
<div class="sv-prof-bar">
<select id="sv-prof-select"><option value="">— 기본(베이스) —</option></select>
@@ -934,8 +975,8 @@
</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">
<label>STL 파일 (여러 개 동시 선택 가능)</label>
<input type="file" id="sv-file" accept=".stl" multiple>
</div>
</aside>
<div class="sv-bar" id="sv-bar">
@@ -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(`📂 <b>${f.name}</b> · 크기 ${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}개 모델 로드 · 총 <b>${instances.length}</b>개 인스턴스 · <b>${models.length}</b>종`);
}
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;
})();