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