301 lines
8.4 KiB
TypeScript
301 lines
8.4 KiB
TypeScript
import * as THREE from "three";
|
|
import { ObjectRegistry } from "./ObjectRegistry";
|
|
import { Accessor, createEffect, createRoot, on } from "solid-js";
|
|
import { renderLoop } from "./RenderLoop";
|
|
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
|
|
import { FontLoader } from "three/examples/jsm/Addons";
|
|
import jsonfont from "three/examples/fonts/helvetiker_regular.typeface.json";
|
|
|
|
// Constants
|
|
const BASE_SIZE = 0.9;
|
|
const CUBE_SIZE = BASE_SIZE / 1.5;
|
|
const CUBE_HEIGHT = CUBE_SIZE;
|
|
const BASE_HEIGHT = 0.05;
|
|
const CUBE_COLOR = 0xe2eff0;
|
|
const CUBE_EMISSIVE = 0x303030;
|
|
|
|
const CUBE_SELECTED_COLOR = 0x4b6767;
|
|
const HIGHLIGHT_COLOR = 0x00ee66;
|
|
|
|
const BASE_COLOR = 0xdbeaeb;
|
|
const BASE_EMISSIVE = 0x0c0c0c;
|
|
const BASE_SELECTED_COLOR = 0x69b0e3;
|
|
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
|
|
|
|
export function createMachineMesh() {
|
|
const geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
|
|
const material = new THREE.MeshPhongMaterial({
|
|
color: CUBE_COLOR,
|
|
emissive: CUBE_EMISSIVE,
|
|
shininess: 100,
|
|
transparent: true,
|
|
});
|
|
|
|
const cubeMesh = new THREE.Mesh(geometry, material);
|
|
cubeMesh.castShadow = true;
|
|
cubeMesh.receiveShadow = true;
|
|
cubeMesh.name = "cube";
|
|
cubeMesh.position.set(0, CUBE_HEIGHT / 2 + BASE_HEIGHT, 0);
|
|
|
|
const { baseMesh, baseMaterial } = createCubeBase(
|
|
BASE_COLOR,
|
|
BASE_EMISSIVE,
|
|
new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE),
|
|
);
|
|
|
|
return {
|
|
cubeMesh,
|
|
baseMesh,
|
|
baseMaterial,
|
|
geometry,
|
|
material,
|
|
};
|
|
}
|
|
|
|
export function createCubeBase(
|
|
color: THREE.ColorRepresentation,
|
|
emissive: THREE.ColorRepresentation,
|
|
geometry: THREE.BoxGeometry,
|
|
) {
|
|
const baseMaterial = new THREE.MeshPhongMaterial({
|
|
color,
|
|
emissive,
|
|
transparent: true,
|
|
opacity: 1,
|
|
});
|
|
const baseMesh = new THREE.Mesh(geometry, baseMaterial);
|
|
baseMesh.position.set(0, BASE_HEIGHT / 2, 0);
|
|
baseMesh.receiveShadow = false;
|
|
return { baseMesh, baseMaterial };
|
|
}
|
|
|
|
// Function to build rounded rect shape
|
|
export function roundedRectShape(w: number, h: number, r: number) {
|
|
const shape = new THREE.Shape();
|
|
const x = -w / 2;
|
|
const y = -h / 2;
|
|
|
|
shape.moveTo(x + r, y);
|
|
shape.lineTo(x + w - r, y);
|
|
shape.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
shape.lineTo(x + w, y + h - r);
|
|
shape.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
shape.lineTo(x + r, y + h);
|
|
shape.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
shape.lineTo(x, y + r);
|
|
shape.quadraticCurveTo(x, y, x + r, y);
|
|
return shape;
|
|
}
|
|
|
|
export class MachineRepr {
|
|
public id: string;
|
|
public group: THREE.Group;
|
|
|
|
private cubeMesh: THREE.Mesh;
|
|
private baseMesh: THREE.Mesh;
|
|
private geometry: THREE.BoxGeometry;
|
|
private material: THREE.MeshPhongMaterial;
|
|
private camera: THREE.Camera;
|
|
|
|
private disposeRoot: () => void;
|
|
|
|
constructor(
|
|
scene: THREE.Scene,
|
|
registry: ObjectRegistry,
|
|
position: THREE.Vector2,
|
|
id: string,
|
|
selectedSignal: Accessor<Set<string>>,
|
|
highlightGroups: Record<string, Set<string>>, // Reactive store
|
|
camera: THREE.Camera,
|
|
) {
|
|
this.id = id;
|
|
this.camera = camera;
|
|
|
|
const { baseMesh, cubeMesh, geometry, material } = createMachineMesh();
|
|
this.cubeMesh = cubeMesh;
|
|
this.cubeMesh.userData = { id };
|
|
|
|
this.baseMesh = baseMesh;
|
|
this.baseMesh.name = "base";
|
|
|
|
this.geometry = geometry;
|
|
this.material = material;
|
|
|
|
const label = this.createLabel(id);
|
|
|
|
const shadowPlaneMaterial = new THREE.MeshStandardMaterial({
|
|
color: BASE_COLOR,
|
|
roughness: 1,
|
|
metalness: 0,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
});
|
|
|
|
const shadowPlane = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(BASE_SIZE, BASE_SIZE),
|
|
shadowPlaneMaterial,
|
|
);
|
|
|
|
shadowPlane.receiveShadow = true;
|
|
shadowPlane.rotation.x = -Math.PI / 2;
|
|
shadowPlane.position.set(0, BASE_HEIGHT + 0.0001, 0);
|
|
|
|
this.group = new THREE.Group();
|
|
this.group.add(label);
|
|
this.group.add(this.cubeMesh);
|
|
this.group.add(this.baseMesh);
|
|
this.group.add(shadowPlane);
|
|
|
|
this.group.position.set(position.x, 0, position.y);
|
|
this.group.userData.id = id;
|
|
|
|
this.disposeRoot = createRoot((disposeEffects) => {
|
|
createEffect(
|
|
on(
|
|
[selectedSignal, () => Object.entries(highlightGroups)],
|
|
([selectedIds, groups]) => {
|
|
const isSelected = selectedIds.has(this.id);
|
|
const highlightedGroups = groups
|
|
.filter(([, ids]) => ids.has(this.id))
|
|
.map(([name]) => name);
|
|
// Update cube
|
|
(this.cubeMesh.material as THREE.MeshPhongMaterial).color.set(
|
|
isSelected ? CUBE_SELECTED_COLOR : CUBE_COLOR,
|
|
);
|
|
|
|
// Update base
|
|
(this.baseMesh.material as THREE.MeshPhongMaterial).color.set(
|
|
isSelected ? BASE_SELECTED_COLOR : BASE_COLOR,
|
|
);
|
|
|
|
// TOOD: Find a different way to show both selected & highlighted
|
|
// I.e. via outline or pulsing
|
|
// selected > highlighted > normal
|
|
(this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set(
|
|
highlightedGroups.length > 0 ? HIGHLIGHT_COLOR : 0x000000,
|
|
);
|
|
|
|
renderLoop.requestRender();
|
|
},
|
|
),
|
|
);
|
|
|
|
return disposeEffects;
|
|
});
|
|
|
|
scene.add(this.group);
|
|
|
|
registry.add({
|
|
object: this.group,
|
|
id,
|
|
type: "machine",
|
|
dispose: () => this.dispose(scene),
|
|
});
|
|
}
|
|
|
|
public setPosition(position: THREE.Vector2) {
|
|
this.group.position.set(position.x, 0, position.y);
|
|
renderLoop.requestRender();
|
|
}
|
|
|
|
private createLabel(id: string) {
|
|
const group = new THREE.Group();
|
|
// 0x162324
|
|
// const text = new Text();
|
|
// text.text = id;
|
|
// text.font = ttf;
|
|
// text.fontSize = 0.1;
|
|
// text.color = 0xffffff;
|
|
// text.anchorX = "center";
|
|
// text.anchorY = "middle";
|
|
// text.position.set(0, 0, 0.01);
|
|
// text.outlineWidth = 0.005;
|
|
// text.outlineColor = 0x162324;
|
|
// text.sync(() => {
|
|
// renderLoop.requestRender();
|
|
// });
|
|
|
|
const textMaterial = new THREE.MeshPhongMaterial({
|
|
color: 0xffffff,
|
|
});
|
|
const textGeo = new TextGeometry(id, {
|
|
font: new FontLoader().parse(jsonfont),
|
|
size: 0.09,
|
|
depth: 0.001,
|
|
curveSegments: 12,
|
|
bevelEnabled: false,
|
|
});
|
|
|
|
const text = new THREE.Mesh(textGeo, textMaterial);
|
|
textGeo.computeBoundingBox();
|
|
|
|
const bbox = textGeo.boundingBox;
|
|
if (bbox) {
|
|
const xMid = -0.5 * (bbox.max.x - bbox.min.x);
|
|
// const yMid = -0.5 * (bbox.max.y - bbox.min.y);
|
|
// const zMid = -0.5 * (bbox.max.z - bbox.min.z);
|
|
|
|
// Translate geometry so center is at origin / baseline aligned with y=0
|
|
textGeo.translate(xMid, -0.035, 0);
|
|
}
|
|
|
|
// --- Background (rounded rect) ---
|
|
const padding = 0.04;
|
|
const textWidth = bbox ? bbox.max.x - bbox.min.x : 1;
|
|
const bgWidth = textWidth + 10 * padding;
|
|
// const bgWidth = text.text.length * 0.07 + padding;
|
|
const bgHeight = 0.1 + 2 * padding;
|
|
const radius = 0.02;
|
|
|
|
const bgShape = roundedRectShape(bgWidth, bgHeight, radius);
|
|
const bgGeom = new THREE.ShapeGeometry(bgShape);
|
|
const bgMat = new THREE.MeshBasicMaterial({ color: 0x162324 });
|
|
const bg = new THREE.Mesh(bgGeom, bgMat);
|
|
bg.position.set(0, 0, -0.01);
|
|
|
|
// --- Arrow (triangle pointing down) ---
|
|
const arrowShape = new THREE.Shape();
|
|
arrowShape.moveTo(-0.05, 0);
|
|
arrowShape.lineTo(0.05, 0);
|
|
arrowShape.lineTo(0, -0.05);
|
|
arrowShape.closePath();
|
|
|
|
const arrowGeom = new THREE.ShapeGeometry(arrowShape);
|
|
const arrow = new THREE.Mesh(arrowGeom, bgMat);
|
|
arrow.position.set(0, -bgHeight / 2, -0.001);
|
|
|
|
// --- Group ---
|
|
group.add(bg);
|
|
group.add(arrow);
|
|
group.add(text);
|
|
|
|
// Position above cube
|
|
group.position.set(0, CUBE_SIZE + 0.3, 0);
|
|
|
|
// Billboard
|
|
group.userData.isLabel = true; // Mark as label to receive billboarding update in render loop
|
|
group.quaternion.copy(this.camera.quaternion);
|
|
|
|
return group;
|
|
}
|
|
|
|
dispose(scene: THREE.Scene) {
|
|
this.disposeRoot?.(); // Stop SolidJS effects
|
|
|
|
scene.remove(this.group);
|
|
|
|
this.geometry.dispose();
|
|
this.material.dispose();
|
|
|
|
this.group.clear();
|
|
|
|
for (const child of this.cubeMesh.children) {
|
|
if (child instanceof THREE.Mesh)
|
|
(child.material as THREE.Material).dispose();
|
|
|
|
if (child instanceof THREE.Object3D) child.remove();
|
|
}
|
|
(this.baseMesh.material as THREE.Material).dispose();
|
|
}
|
|
}
|