UI: move machine specifics into MachineManager

This commit is contained in:
Johannes Kirschbauer
2025-07-29 10:01:48 +02:00
parent 682d8c786c
commit 782e8b330d

View File

@@ -1,28 +1,19 @@
import {
createSignal,
createEffect,
onCleanup,
onMount,
createMemo,
on,
} from "solid-js";
import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
import "./cubes.css";
import * as THREE from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import {
CSS2DRenderer,
CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { MachinesQueryResult } from "../queries/queries";
import { SceneData } from "../stores/clan";
import { unwrap } from "solid-js/store";
import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager";
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
@@ -40,35 +31,6 @@ function garbageCollectGroup(group: THREE.Group) {
group.clear(); // Clear the group
}
function getFloorPosition(
camera: THREE.Camera,
floor: THREE.Object3D,
): [number, number, number] {
const cameraPosition = camera.position.clone();
// Get camera's direction
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
// Define floor plane (XZ-plane at y=0)
const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // Normal = up, constant = 0
// Create ray from camera
const ray = new THREE.Ray(cameraPosition, direction);
// Get intersection point
const intersection = new THREE.Vector3();
ray.intersectPlane(floorPlane, intersection);
return intersection.toArray() as [number, number, number];
}
function keyFromPos(pos: [number, number]): string {
return `${pos[0]},${pos[1]}`;
}
// type SceneDataUpdater = (sceneData: SceneData) => void;
export function CubeScene(props: {
cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>;
@@ -95,8 +57,6 @@ export function CubeScene(props: {
const groupMap = new Map<string, THREE.Group>();
const occupiedPositions = new Set<string>();
let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry;
@@ -113,12 +73,8 @@ export function CubeScene(props: {
spherical: { radius: 0, theta: 0, phi: 0 },
});
// Animation configuration
const ANIMATION_DURATION = 800; // milliseconds
// Grid configuration
const GRID_SIZE = 2;
const CUBE_SPACING = 2;
const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; //
@@ -128,189 +84,12 @@ export function CubeScene(props: {
const FLOOR_COLOR = 0xcdd8d9;
const CUBE_COLOR = 0xd7e0e1;
const CUBE_EMISSIVE = 0x303030; // Emissive color for cubes
const CUBE_SELECTED_COLOR = 0x4b6767;
const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c;
const BASE_SELECTED_COLOR = 0x69b0e3;
const BASE_SELECTED_EMISSIVE = 0x666666; // Emissive color for selected bases
const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7;
createEffect(() => {
// Update when API updates.
if (props.cubesQuery.data) {
const actualMachines = Object.keys(props.cubesQuery.data);
const rawStored = unwrap(props.sceneStore());
const placed: Set<string> = rawStored
? new Set(Object.keys(rawStored))
: new Set();
const nonPlaced = actualMachines.filter((m) => !placed.has(m));
// Initialize occupied positions from previously placed cubes
for (const id of placed) {
occupiedPositions.add(keyFromPos(rawStored[id].position));
}
// Push not explizitly placed machines to the scene
// TODO: Make the user place them manually
// We just calculate some next free position
for (const id of nonPlaced) {
console.log("adding", id);
const position = nextGridPos();
console.log("Got pos", position);
// Add the machine to the store
// Adding it triggers a reactive update
props.setMachinePos(id, position);
occupiedPositions.add(keyFromPos(position));
}
}
});
function getGridPosition(id: string): [number, number, number] {
// TODO: Detect collision with other cubes
const machine = props.sceneStore()[id];
console.log("getGridPosition", id, machine);
if (machine) {
return [machine.position[0], 0, machine.position[1]];
}
// Some fallback to get the next free position
// If the position wasn't avilable in the store
console.warn(`Position for ${id} not set`);
return [0, 0, 0];
}
function nextGridPos(): [number, number] {
let x = 0,
z = 0;
let layer = 1;
while (layer < 100) {
// right
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x += 1;
}
// down
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z += 1;
}
layer++;
// left
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
x -= 1;
}
// up
for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
return pos;
}
z -= 1;
}
layer++;
}
console.warn("No free grid positions available, returning [0, 0]");
// Fallback if no position was found
return [0, 0] as [number, number];
}
// Circle IDEA:
// Need to talk with timo and W about this
const getCirclePosition =
(center: [number, number, number]) =>
(_id: string, index: number, total: number): [number, number, number] => {
const r = total === 1 ? 0 : Math.sqrt(total) * CUBE_SPACING; // Radius based on total cubes
const x = Math.cos((index / total) * 2 * Math.PI) * r + center[0];
const z = Math.sin((index / total) * 2 * Math.PI) * r + center[2];
// Position cubes at y = 0.5 to float above the ground
return [x, CUBE_Y, z];
};
// Reactive cubes memo - this recalculates whenever data changes
const cubes = createMemo(() => {
console.log("Calculating cubes...");
const sceneData = props.sceneStore(); // keep it reactive
if (!sceneData) return [];
const currentIds = Object.keys(sceneData);
console.log("Current IDs:", currentIds);
let cameraTarget = [0, 0, 0] as [number, number, number];
if (camera && floor) {
cameraTarget = getFloorPosition(camera, floor);
}
const getCubePosition =
positionMode() === "grid"
? getGridPosition
: getCirclePosition(cameraTarget);
return currentIds.map((id, index) => {
const activeIndex = currentIds.indexOf(id);
const position = getCubePosition(id, index, currentIds.length);
const targetPosition =
activeIndex >= 0
? getCubePosition(id, activeIndex, currentIds.length)
: getCubePosition(id, index, currentIds.length);
return {
id,
position,
targetPosition,
};
});
});
// Animation helper function
function animateToPosition(
thing: THREE.Object3D,
targetPosition: [number, number, number],
duration: number = ANIMATION_DURATION,
) {
const startPosition = thing.position.clone();
const endPosition = new THREE.Vector3(...targetPosition);
const startTime = Date.now();
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Smooth easing function
const easeProgress = 1 - Math.pow(1 - progress, 3);
thing.position.lerpVectors(startPosition, endPosition, easeProgress);
if (progress < 1) {
requestAnimationFrame(animate);
renderLoop.requestRender();
}
}
animate();
}
function createCubeBase(
cube_pos: [number, number, number],
opacity = 1,
@@ -336,67 +115,6 @@ export function CubeScene(props: {
props.onSelect(next);
}
function updateMeshColors(
selected: Set<string>,
prev: Set<string> | undefined,
) {
for (const id of selected) {
const group = groupMap.get(id);
if (!group) {
console.warn(`UPDATE COLORS: Group not found for id: ${id}`);
continue;
}
const base = group.children.find((child) => child.name === "base");
if (!base || !(base instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Base mesh not found for id: ${id}`);
continue;
}
const cube = group.children.find((child) => child.name === "cube");
if (!cube || !(cube instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Cube mesh not found for id: ${id}`);
continue;
}
const baseMaterial = base.material as THREE.MeshPhongMaterial;
const cubeMaterial = cube.material as THREE.MeshPhongMaterial;
baseMaterial.color.set(BASE_SELECTED_COLOR);
baseMaterial.emissive.set(BASE_SELECTED_EMISSIVE);
cubeMaterial.color.set(CUBE_SELECTED_COLOR);
}
const deselected = Array.from(prev || []).filter((s) => !selected.has(s));
for (const id of deselected) {
const group = groupMap.get(id);
if (!group) {
console.warn(`UPDATE COLORS: Group not found for id: ${id}`);
continue;
}
const base = group.children.find((child) => child.name === "base");
if (!base || !(base instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Base mesh not found for id: ${id}`);
continue;
}
const cube = group.children.find((child) => child.name === "cube");
if (!cube || !(cube instanceof THREE.Mesh)) {
console.warn(`UPDATE COLORS: Cube mesh not found for id: ${id}`);
continue;
}
const baseMaterial = base.material as THREE.MeshPhongMaterial;
const cubeMaterial = cube.material as THREE.MeshPhongMaterial;
baseMaterial.color.set(BASE_COLOR);
baseMaterial.emissive.set(BASE_EMISSIVE);
cubeMaterial.color.set(CUBE_COLOR);
}
renderLoop.requestRender();
}
const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3(
@@ -498,13 +216,13 @@ export function CubeScene(props: {
controls.minZoom = 1.2;
controls.maxZoom = 3.5;
controls.addEventListener("change", () => {
// const aspect = container.clientWidth / container.clientHeight;
// const zoom = camera.zoom;
// camera.left = (-d * aspect) / zoom;
// camera.right = (d * aspect) / zoom;
// camera.top = d / zoom;
// camera.bottom = -d / zoom;
// camera.updateProjectionMatrix();
const aspect = container.clientWidth / container.clientHeight;
const zoom = camera.zoom;
camera.left = (-d * aspect) / zoom;
camera.right = (d * aspect) / zoom;
camera.top = d / zoom;
camera.bottom = -d / zoom;
camera.updateProjectionMatrix();
renderLoop.requestRender();
});
@@ -525,31 +243,31 @@ export function CubeScene(props: {
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
// scene.add(new THREE.DirectionalLightHelper(directionalLight));
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical(
1000,
15,
initialSphericalCameraPosition.phi - Math.PI / 8,
initialSphericalCameraPosition.theta - Math.PI / 2,
);
directionalLight.position.setFromSpherical(lightPos);
directionalLight.target.position.set(0, 0, 0); // Point light at the center
directionalLight.rotation.set(0, 0, 0);
// initialSphericalCameraPosition
directionalLight.castShadow = true;
// Configure shadow camera for hard, crisp shadows
directionalLight.shadow.camera.left = -30;
directionalLight.shadow.camera.right = 30;
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30;
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 2000;
directionalLight.shadow.camera.far = 30;
directionalLight.shadow.mapSize.width = 4096; // Higher resolution for sharper shadows
directionalLight.shadow.mapSize.height = 4096;
directionalLight.shadow.radius = 1; // Hard shadows (low radius)
directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges
scene.add(directionalLight);
scene.add(directionalLight.target);
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// Floor/Ground - Make it invisible but keep it for reference
const floorGeometry = new THREE.PlaneGeometry(1000, 1000);
@@ -630,6 +348,17 @@ export function CubeScene(props: {
}),
);
const registry = new ObjectRegistry();
const machineManager = new MachineManager(
scene,
registry,
props.sceneStore,
props.cubesQuery,
props.selectedIds,
props.setMachinePos,
);
// Click handler:
// - Select/deselects a cube in "view" mode
// - Creates a new cube in "create" mode
@@ -664,10 +393,11 @@ export function CubeScene(props: {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(groupMap.values()),
Array.from(machineManager.machines.values().map((m) => m.group)),
);
console.log("Intersects:", intersects);
if (intersects.length > 0) {
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id;
toggleSelection(id);
} else {
@@ -698,7 +428,7 @@ export function CubeScene(props: {
container.clientHeight,
);
renderer.render(bgScene, bgCamera);
// renderer.render(bgScene, bgCamera);
renderLoop.requestRender();
};
@@ -715,10 +445,27 @@ export function CubeScene(props: {
{ capture: true },
);
// Initial render
renderLoop.requestRender();
// Cleanup function
onCleanup(() => {
for (const group of groupMap.values()) {
garbageCollectGroup(group);
scene.remove(group);
}
groupMap.clear();
// Dispose shared geometries
sharedCubeGeometry?.dispose();
sharedBaseGeometry?.dispose();
renderer?.dispose();
renderLoop.dispose();
machineManager.dispose(scene);
renderer.domElement.removeEventListener("click", onClick);
renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize);
@@ -735,153 +482,13 @@ export function CubeScene(props: {
if (container) {
container.innerHTML = "";
}
groupMap.forEach((group) => {
garbageCollectGroup(group);
scene.remove(group);
});
groupMap.clear();
});
});
function createCube(
gridPosition: [number, number],
userData: { id: string },
) {
// Creates a cube, base, and other visuals
// Groups them together in the scene
const cubeMaterial = new THREE.MeshPhongMaterial({
color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
// specular: 0xffffff,
shininess: 100,
});
const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterial);
cubeMesh.castShadow = true;
cubeMesh.receiveShadow = true;
cubeMesh.userData = userData;
cubeMesh.name = "cube"; // Name for easy identification
cubeMesh.position.set(0, CUBE_Y, 0);
const baseMesh = createCubeBase([0, BASE_HEIGHT / 2, 0]);
baseMesh.name = "base"; // Name for easy identification
const nameDiv = document.createElement("div");
nameDiv.className = "machine-label";
nameDiv.textContent = `${userData.id}`;
const nameLabel = new CSS2DObject(nameDiv);
nameLabel.position.set(0, CUBE_Y + CUBE_SIZE / 2 - 0.2, 0);
cubeMesh.add(nameLabel);
// TODO: Destroy Group in onCleanup
const group = new THREE.Group();
group.add(cubeMesh);
group.add(baseMesh);
group.position.set(gridPosition[0], 0, gridPosition[1]); // Position on the grid
group.userData.id = userData.id;
return group;
}
// Effect to manage cube meshes - this runs whenever cubes() changes
createEffect(() => {
const currentCubes = cubes();
const existing = new Set(groupMap.keys());
// Update existing cubes and create new ones
currentCubes.forEach((cube) => {
const existingGroup = groupMap.get(cube.id);
console.log(
"Processing cube:",
cube.id,
"Existing group:",
existingGroup,
);
if (!existingGroup) {
const group = createCube([cube.position[0], cube.position[2]], {
id: cube.id,
});
scene.add(group);
groupMap.set(cube.id, group);
} else {
// Only animate position if not being deleted
const targetPosition = cube.targetPosition || cube.position;
const currentPosition = existingGroup.position.toArray() as [
number,
number,
number,
];
const target = targetPosition;
// Check if position actually changed
if (
Math.abs(currentPosition[0] - target[0]) > 0.01 ||
Math.abs(currentPosition[1] - target[1]) > 0.01 ||
Math.abs(currentPosition[2] - target[2]) > 0.01
) {
animateToPosition(existingGroup, target);
}
}
existing.delete(cube.id);
});
// Remove cubes that are no longer in the state and not being deleted
existing.forEach((id) => {
if (!currentCubes.find((d) => d.id == id)) {
const group = groupMap.get(id);
if (group) {
console.log("Cleaning...", id);
garbageCollectGroup(group);
scene.remove(group);
groupMap.delete(id);
const pos = group.position.toArray() as [number, number, number];
occupiedPositions.delete(keyFromPos([pos[0], pos[2]]));
}
}
});
renderLoop.requestRender();
});
createEffect(
on(props.selectedIds, (curr, prev) => {
console.log("Selected cubes:", curr);
// Update colors of selected cubes
updateMeshColors(curr, prev);
}),
);
onCleanup(() => {
for (const group of groupMap.values()) {
garbageCollectGroup(group);
scene.remove(group);
}
groupMap.clear();
// Dispose shared geometries
sharedCubeGeometry?.dispose();
sharedBaseGeometry?.dispose();
renderer?.dispose();
});
const onHover = (inside: boolean) => (event: MouseEvent) => {
const pos = nextGridPos();
if (!initBase) return;
if (initBase.visible === false && inside) {
initBase.position.set(pos[0], BASE_HEIGHT / 2, pos[1]);
initBase.visible = true;
}
renderLoop.requestRender();
};
const onAddClick = (event: MouseEvent) => {
setPositionMode("grid");
setWorldMode("create");
renderLoop.requestRender();
};
const onMouseMove = (event: MouseEvent) => {
if (worldMode() !== "create") return;
@@ -948,6 +555,7 @@ export function CubeScene(props: {
setPositionMode("grid");
grid.visible = true;
}
renderLoop.requestRender();
}}
/>
<ToolbarButton name="delete" icon="Trash" />