diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index c5b0d8bd6..c7f365b38 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -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(); - const occupiedPositions = new Set(); - 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 = 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, - prev: Set | 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(); }} />