Former/frontend/src/engine/former-engine.js

653 lines
22 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
export const Z_SPACING = 3;
export function dbg(...args) {
try {
const fn = window?.go?.main?.App?.JSDebugLog;
if (!fn) return;
const msg = '[engine] ' + args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
fn(msg);
} catch (_) {}
}
export class FormerEngine {
constructor(container) {
this.container = container;
this.layers = [];
this.layerMeshes = [];
this.selectedLayerIndex = -1;
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
this.layerGroup = null;
this.arrowGroup = null;
this.elementGroup = null;
this.selectionOutline = null;
this._onLayerSelect = null;
this._onCutoutSelect = null;
this._onCutoutHover = null;
this._onPlacedCutoutSelect = null;
this._modes = {};
this._clickHandlers = [];
this._mouseMoveHandlers = [];
this._mouseDownHandlers = [];
this._mouseUpHandlers = [];
this._initScene();
this._initControls();
this._initGrid();
this._initRaycasting();
this._animate();
}
// ===== Plugin API =====
use(mode) {
this._modes[mode.name] = mode;
mode.install(this);
}
getMode(name) {
return this._modes[name];
}
registerClickHandler(handler) { this._clickHandlers.push(handler); }
unregisterClickHandler(handler) {
const i = this._clickHandlers.indexOf(handler);
if (i >= 0) this._clickHandlers.splice(i, 1);
}
registerMouseMoveHandler(handler) { this._mouseMoveHandlers.push(handler); }
unregisterMouseMoveHandler(handler) {
const i = this._mouseMoveHandlers.indexOf(handler);
if (i >= 0) this._mouseMoveHandlers.splice(i, 1);
}
registerMouseDownHandler(handler) { this._mouseDownHandlers.push(handler); }
unregisterMouseDownHandler(handler) {
const i = this._mouseDownHandlers.indexOf(handler);
if (i >= 0) this._mouseDownHandlers.splice(i, 1);
}
registerMouseUpHandler(handler) { this._mouseUpHandlers.push(handler); }
unregisterMouseUpHandler(handler) {
const i = this._mouseUpHandlers.indexOf(handler);
if (i >= 0) this._mouseUpHandlers.splice(i, 1);
}
// ===== Callbacks =====
onLayerSelect(cb) { this._onLayerSelect = cb; }
onCutoutSelect(cb) { this._onCutoutSelect = cb; }
onCutoutHover(cb) { this._onCutoutHover = cb; }
onPlacedCutoutSelect(cb) { this._onPlacedCutoutSelect = cb; }
// ===== Scene Setup =====
_initScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x000000);
const w = this.container.clientWidth;
const h = this.container.clientHeight;
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 50000);
this.camera.position.set(0, -60, 80);
this.camera.up.set(0, 0, 1);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.container.appendChild(this.renderer.domElement);
this._ambientLight = new THREE.AmbientLight(0xffffff, 0.9);
this.scene.add(this._ambientLight);
this._dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
this._dirLight.position.set(50, -50, 100);
this.scene.add(this._dirLight);
this.layerGroup = new THREE.Group();
this.scene.add(this.layerGroup);
this.arrowGroup = new THREE.Group();
this.arrowGroup.visible = false;
this.scene.add(this.arrowGroup);
this.elementGroup = new THREE.Group();
this.elementGroup.visible = false;
this.scene.add(this.elementGroup);
this._resizeObserver = new ResizeObserver(() => this._onResize());
this._resizeObserver.observe(this.container);
}
_initControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
this.controls.enableZoom = true;
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY
};
this.controls.target.set(0, 0, 0);
this.controls.update();
this.renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
if (this._traditionalControls) {
if (e.shiftKey && e.deltaX === 0) {
e.stopImmediatePropagation();
const dist = this.camera.position.distanceTo(this.controls.target);
const panSpeed = dist * 0.001;
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
const offset = new THREE.Vector3();
offset.addScaledVector(right, e.deltaY * panSpeed);
this.camera.position.add(offset);
this.controls.target.add(offset);
this.controls.update();
}
return;
}
e.stopImmediatePropagation();
if (e.ctrlKey || e.metaKey) {
const factor = 1 + e.deltaY * 0.01;
const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
dir.multiplyScalar(factor);
this.camera.position.copy(this.controls.target).add(dir);
} else {
let dx = e.deltaX;
let dy = e.deltaY;
if (e.shiftKey && dx === 0) {
dx = dy;
dy = 0;
}
const dist = this.camera.position.distanceTo(this.controls.target);
const panSpeed = dist * 0.001;
const right = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 0);
const up = new THREE.Vector3().setFromMatrixColumn(this.camera.matrixWorld, 1);
const offset = new THREE.Vector3();
offset.addScaledVector(right, dx * panSpeed);
offset.addScaledVector(up, -dy * panSpeed);
this.camera.position.add(offset);
this.controls.target.add(offset);
}
this.controls.update();
}, { passive: false, capture: true });
this._ctrlDragging = false;
this._ctrlDragLastY = 0;
this.renderer.domElement.addEventListener('mousedown', (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
this._ctrlDragging = true;
this._ctrlDragLastY = e.clientY;
e.preventDefault();
e.stopImmediatePropagation();
}
}, { capture: true });
const onCtrlDragMove = (e) => {
if (!this._ctrlDragging) return;
const dy = e.clientY - this._ctrlDragLastY;
this._ctrlDragLastY = e.clientY;
const factor = 1 + dy * 0.005;
const dir = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
dir.multiplyScalar(factor);
this.camera.position.copy(this.controls.target).add(dir);
this.controls.update();
};
const onCtrlDragUp = () => {
this._ctrlDragging = false;
};
window.addEventListener('mousemove', onCtrlDragMove);
window.addEventListener('mouseup', onCtrlDragUp);
this._ctrlDragCleanup = () => {
window.removeEventListener('mousemove', onCtrlDragMove);
window.removeEventListener('mouseup', onCtrlDragUp);
};
}
_initGrid() {
this.gridHelper = new THREE.GridHelper(2000, 100, 0x333333, 0x222222);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.z = -0.5;
this.scene.add(this.gridHelper);
this.gridVisible = true;
}
toggleGrid() {
this.gridVisible = !this.gridVisible;
this.gridHelper.visible = this.gridVisible;
return this.gridVisible;
}
_initRaycasting() {
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this._isDragging = false;
this._mouseDownPos = { x: 0, y: 0 };
const canvas = this.renderer.domElement;
canvas.addEventListener('mousedown', e => {
this._isDragging = false;
this._mouseDownPos = { x: e.clientX, y: e.clientY };
for (const h of this._mouseDownHandlers) h(e);
});
canvas.addEventListener('mousemove', e => {
const dx = e.clientX - this._mouseDownPos.x;
const dy = e.clientY - this._mouseDownPos.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this._isDragging = true;
for (const h of this._mouseMoveHandlers) h(e);
});
canvas.addEventListener('mouseup', e => {
for (const h of this._mouseUpHandlers) h(e);
});
canvas.addEventListener('click', e => {
if (this._isDragging) return;
this._updateMouse(e);
this.raycaster.setFromCamera(this.mouse, this.camera);
for (const h of this._clickHandlers) {
if (h(e)) return;
}
// Default: layer selection
const clickables = this.layerMeshes.filter((m, i) => m && this.layers[i]?.visible);
const hits = this.raycaster.intersectObjects(clickables, true);
if (hits.length > 0) {
let hitObj = hits[0].object;
let idx = this.layerMeshes.indexOf(hitObj);
if (idx < 0) {
hitObj.traverseAncestors(p => {
const ei = this.layerMeshes.indexOf(p);
if (ei >= 0) idx = ei;
});
}
if (idx >= 0) this.selectLayer(idx);
} else {
this.selectLayer(-1);
}
});
}
_updateMouse(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
}
_onResize() {
const w = this.container.clientWidth;
const h = this.container.clientHeight;
if (w === 0 || h === 0) return;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
_animate() {
this._animId = requestAnimationFrame(() => this._animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
// ===== Layer Loading =====
async loadLayers(layers, imageUrls) {
this.layers = layers;
const loader = new THREE.TextureLoader();
while (this.layerGroup.children.length > 0) {
const child = this.layerGroup.children[0];
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (child.material.map) child.material.map.dispose();
child.material.dispose();
}
this.layerGroup.remove(child);
}
this.layerMeshes = [];
this.enclosureLayerIndex = -1;
this.trayLayerIndex = -1;
// Reset enclosure mode state if present
const enc = this.getMode('enclosure');
if (enc) {
enc.enclosureMesh = null;
enc.trayMesh = null;
}
let maxW = 0, maxH = 0;
for (let i = 0; i < layers.length; i++) {
const layer = layers[i];
const url = imageUrls[i];
if (layer.name === 'Enclosure') {
this.enclosureLayerIndex = i;
this.layerMeshes.push(null);
continue;
}
if (layer.name === 'Tray') {
this.trayLayerIndex = i;
this.layerMeshes.push(null);
continue;
}
if (!url) { this.layerMeshes.push(null); continue; }
try {
const tex = await new Promise((resolve, reject) => {
loader.load(url, resolve, undefined, reject);
});
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
const imgW = tex.image.width;
const imgH = tex.image.height;
if (imgW > maxW) maxW = imgW;
if (imgH > maxH) maxH = imgH;
const geo = new THREE.PlaneGeometry(imgW, imgH);
const mat = new THREE.MeshBasicMaterial({
map: tex,
transparent: true,
opacity: layer.visible ? layer.baseAlpha : 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(imgW / 2, -imgH / 2, i * Z_SPACING);
mesh.visible = layer.visible;
mesh.userData = { layerIndex: i };
this.layerGroup.add(mesh);
this.layerMeshes.push(mesh);
} catch (e) {
console.warn(`Failed to load layer ${i}:`, e);
this.layerMeshes.push(null);
}
}
this._maxW = maxW;
this._maxH = maxH;
if (maxW > 0 && maxH > 0) {
const cx = maxW / 2;
const cy = -maxH / 2;
const cz = (layers.length * Z_SPACING) / 2;
this.controls.target.set(cx, cy, cz);
const dist = Math.max(maxW, maxH) * 0.7;
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
this.camera.lookAt(cx, cy, cz);
this.controls.update();
this.gridHelper.position.set(cx, cy, -0.5);
}
}
// ===== Layer Visibility =====
setLayerVisibility(index, visible) {
if (index < 0 || index >= this.layerMeshes.length) return;
const mesh = this.layerMeshes[index];
if (!mesh) return;
this.layers[index].visible = visible;
mesh.visible = visible;
if (mesh.material && !mesh.userData?.isEnclosure) {
if (!visible) mesh.material.opacity = 0;
else mesh.material.opacity = this.layers[index].baseAlpha;
}
}
setGroupOpacity(group, opacity) {
group.traverse(c => {
if (c.material) c.material.opacity = opacity;
});
}
setLayerHighlight(index, highlight) {
const hasHL = highlight && index >= 0;
this.layers.forEach((l, i) => {
l.highlight = (i === index && highlight);
const mesh = this.layerMeshes[i];
if (!mesh || !l.visible) return;
if (mesh.userData?.isEnclosure) {
this.setGroupOpacity(mesh, (hasHL && !l.highlight) ? 0.15 : 0.55);
} else if (mesh.material) {
mesh.material.opacity = (hasHL && !l.highlight) ? l.baseAlpha * 0.3 : l.baseAlpha;
}
});
}
// ===== Selection =====
selectLayer(index) {
this.selectedLayerIndex = index;
if (this.selectionOutline) {
this.scene.remove(this.selectionOutline);
this.selectionOutline.geometry?.dispose();
this.selectionOutline.material?.dispose();
this.selectionOutline = null;
}
this.arrowGroup.visible = false;
while (this.arrowGroup.children.length) {
const c = this.arrowGroup.children[0];
this.arrowGroup.remove(c);
}
if (index < 0 || index >= this.layerMeshes.length) {
if (this._onLayerSelect) this._onLayerSelect(-1);
return;
}
const mesh = this.layerMeshes[index];
if (!mesh) {
if (this._onLayerSelect) this._onLayerSelect(index);
return;
}
if (mesh.geometry && !mesh.userData?.isEnclosure) {
const edges = new THREE.EdgesGeometry(mesh.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
color: 0x89b4fa, linewidth: 2,
}));
line.position.copy(mesh.position);
line.position.z += 0.1;
this.selectionOutline = line;
this.scene.add(line);
}
const pos = mesh.position.clone();
if (mesh.userData?.isEnclosure) {
const box = new THREE.Box3().setFromObject(mesh);
box.getCenter(pos);
}
const arrowLen = 8;
const upArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(pos.x, pos.y, pos.z + 2),
arrowLen, 0x89b4fa, 3, 2
);
const downArrow = new THREE.ArrowHelper(
new THREE.Vector3(0, 0, -1),
new THREE.Vector3(pos.x, pos.y, pos.z - 2),
arrowLen, 0x89b4fa, 3, 2
);
this.arrowGroup.add(upArrow);
this.arrowGroup.add(downArrow);
this.arrowGroup.visible = true;
if (this._onLayerSelect) this._onLayerSelect(index);
}
moveSelectedZ(delta) {
if (this.selectedLayerIndex < 0) return;
const mesh = this.layerMeshes[this.selectedLayerIndex];
if (!mesh) return;
mesh.position.z += delta;
if (this.selectionOutline) this.selectionOutline.position.z = mesh.position.z + 0.1;
if (this.arrowGroup.children.length >= 2) {
const pos = mesh.position;
this.arrowGroup.children[0].position.set(pos.x, pos.y, pos.z + 2);
this.arrowGroup.children[1].position.set(pos.x, pos.y, pos.z - 2);
}
}
// ===== Camera =====
_homeTopDown(layerIndex) {
const mesh = this.layerMeshes[layerIndex];
if (!mesh) return;
const pos = mesh.position.clone();
if (mesh.userData?.isEnclosure) {
const box = new THREE.Box3().setFromObject(mesh);
box.getCenter(pos);
}
let imgW, imgH;
if (mesh.geometry?.parameters) {
imgW = mesh.geometry.parameters.width;
imgH = mesh.geometry.parameters.height;
} else {
imgW = this._maxW || 500;
imgH = this._maxH || 500;
}
const dist = Math.max(imgW, imgH) * 1.1;
this.camera.position.set(pos.x, pos.y - dist * 0.01, pos.z + dist);
this.camera.up.set(0, 0, 1);
this.controls.target.set(pos.x, pos.y, pos.z);
this.controls.update();
}
homeTopDown(layerIndex) {
if (layerIndex !== undefined && layerIndex >= 0) {
this._homeTopDown(layerIndex);
} else if (this.selectedLayerIndex >= 0) {
this._homeTopDown(this.selectedLayerIndex);
} else {
const cx = (this._maxW || 500) / 2;
const cy = -(this._maxH || 500) / 2;
const cz = (this.layers.length * Z_SPACING) / 2;
const dist = Math.max(this._maxW || 500, this._maxH || 500) * 1.1;
this.camera.position.set(cx, cy - dist * 0.01, cz + dist);
this.camera.up.set(0, 0, 1);
this.controls.target.set(cx, cy, cz);
this.controls.update();
}
}
resetView() {
if (this.layers.length === 0) return;
const maxW = this._maxW || 500;
const maxH = this._maxH || 500;
const cx = maxW / 2;
const cy = -maxH / 2;
const cz = (this.layers.length * Z_SPACING) / 2;
const dist = Math.max(maxW, maxH) * 0.7;
this.camera.position.set(cx, cy - dist * 0.5, cz + dist * 0.6);
this.camera.up.set(0, 0, 1);
this.controls.target.set(cx, cy, cz);
this.controls.update();
}
setControlScheme(traditional) {
this._traditionalControls = traditional;
if (traditional) {
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.PAN
};
} else {
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.PAN,
RIGHT: THREE.MOUSE.DOLLY
};
}
}
// ===== Shared Utilities =====
disposeGroup(group) {
if (!group) return;
if (group.parent) group.parent.remove(group);
group.traverse(c => {
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (c.material.map) c.material.map.dispose();
c.material.dispose();
}
});
}
loadSTLCentered(arrayBuffer, materialOpts) {
const loader = new STLLoader();
const geometry = loader.parse(arrayBuffer);
geometry.computeBoundingBox();
if (materialOpts.computeNormals) {
geometry.computeVertexNormals();
delete materialOpts.computeNormals;
}
const mat = new THREE.MeshPhongMaterial(materialOpts);
const mesh = new THREE.Mesh(geometry, mat);
const box = geometry.boundingBox;
const center = new THREE.Vector3(
(box.min.x + box.max.x) / 2,
(box.min.y + box.max.y) / 2,
(box.min.z + box.max.z) / 2
);
mesh.position.set(-center.x, -center.y, -center.z);
const size = box.getSize(new THREE.Vector3());
return { mesh, center, size, box };
}
fitCamera(size, target) {
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.5;
const t = target || new THREE.Vector3(0, 0, 0);
this.controls.target.copy(t);
this.camera.position.set(t.x, t.y - dist * 0.5, t.z + dist * 0.6);
this.camera.up.set(0, 0, 1);
this.controls.update();
}
// ===== Dispose =====
dispose() {
if (this._ctrlDragCleanup) this._ctrlDragCleanup();
if (this._animId) cancelAnimationFrame(this._animId);
if (this._resizeObserver) this._resizeObserver.disconnect();
for (const mode of Object.values(this._modes)) {
if (mode.dispose) mode.dispose();
}
this.controls.dispose();
this.renderer.dispose();
if (this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
}
}