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