diff --git a/pkgs/clan-app/ui/src/scene/MachineManager.ts b/pkgs/clan-app/ui/src/scene/MachineManager.ts new file mode 100644 index 000000000..0e2636ccb --- /dev/null +++ b/pkgs/clan-app/ui/src/scene/MachineManager.ts @@ -0,0 +1,154 @@ +import { Accessor, createEffect, createRoot } from "solid-js"; +import { MachineRepr } from "./MachineRepr"; +import * as THREE from "three"; +import { SceneData } from "../stores/clan"; +import { MachinesQueryResult } from "../queries/queries"; +import { ObjectRegistry } from "./ObjectRegistry"; +import { renderLoop } from "./RenderLoop"; + +function keyFromPos(pos: [number, number]): string { + return `${pos[0]},${pos[1]}`; +} + +const CUBE_SPACING = 2; + +export class MachineManager { + public machines = new Map(); + + private disposeRoot: () => void; + + private machinePositionsSignal: Accessor; + + constructor( + scene: THREE.Scene, + registry: ObjectRegistry, + machinePositionsSignal: Accessor, + machinesQueryResult: MachinesQueryResult, + selectedIds: Accessor>, + setMachinePos: (id: string, position: [number, number]) => void, + ) { + this.machinePositionsSignal = machinePositionsSignal; + + this.disposeRoot = createRoot((disposeEffects) => { + createEffect(() => { + const machines = machinePositionsSignal(); + + Object.entries(machines).forEach(([id, data]) => { + const machineRepr = new MachineRepr( + scene, + registry, + new THREE.Vector2(data.position[0], data.position[1]), + id, + selectedIds, + ); + this.machines.set(id, machineRepr); + scene.add(machineRepr.group); + }); + renderLoop.requestRender(); + }); + + // Push positions of previously existing machines to the scene + // TODO: Maybe we should do this in some post query hook? + createEffect(() => { + if (!machinesQueryResult.data) return; + + const actualMachines = Object.keys(machinesQueryResult.data); + const machinePositions = machinePositionsSignal(); + const placed: Set = machinePositions + ? new Set(Object.keys(machinePositions)) + : new Set(); + + const nonPlaced = actualMachines.filter((m) => !placed.has(m)); + + // 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 = this.nextGridPos(); + + setMachinePos(id, position); + } + }); + + return disposeEffects; + }); + } + + nextGridPos(): [number, number] { + const occupiedPositions = new Set( + Object.values(this.machinePositionsSignal()).map((data) => + keyFromPos(data.position), + ), + ); + + 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]; + } + + dispose(scene: THREE.Scene) { + for (const machine of this.machines.values()) { + machine.dispose(scene); + } + // Stop SolidJS effects + this.disposeRoot?.(); + // Clear references + this.machines?.clear(); + } +} + +// TODO: For service focus +// 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]; +// }; diff --git a/pkgs/clan-app/ui/src/scene/MachineRepr.ts b/pkgs/clan-app/ui/src/scene/MachineRepr.ts new file mode 100644 index 000000000..cd29239be --- /dev/null +++ b/pkgs/clan-app/ui/src/scene/MachineRepr.ts @@ -0,0 +1,141 @@ +import * as THREE from "three"; +import { ObjectRegistry } from "./ObjectRegistry"; +import { CSS2DObject } from "three/examples/jsm/renderers/CSS2DRenderer.js"; +import { Accessor, createEffect, createRoot, on } from "solid-js"; +import { renderLoop } from "./RenderLoop"; + +// 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 = 0xd7e0e1; +const CUBE_EMISSIVE = 0x303030; + +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 + +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 disposeRoot: () => void; + + constructor( + scene: THREE.Scene, + registry: ObjectRegistry, + position: THREE.Vector2, + id: string, + selectedSignal: Accessor>, + ) { + this.id = id; + this.geometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE); + this.material = new THREE.MeshPhongMaterial({ + color: CUBE_COLOR, + emissive: CUBE_EMISSIVE, + shininess: 100, + }); + + this.cubeMesh = new THREE.Mesh(this.geometry, this.material); + this.cubeMesh.castShadow = true; + this.cubeMesh.receiveShadow = true; + this.cubeMesh.userData = { id }; + this.cubeMesh.name = "cube"; + this.cubeMesh.position.set(0, CUBE_HEIGHT / 2, 0); + + this.baseMesh = this.createCubeBase( + BASE_COLOR, + BASE_EMISSIVE, + new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE), + ); + this.baseMesh.name = "base"; + + const label = this.createLabel(id); + this.cubeMesh.add(label); + + this.group = new THREE.Group(); + this.group.add(this.cubeMesh); + this.group.add(this.baseMesh); + + this.group.position.set(position.x, 0, position.y); + this.group.userData.id = id; + + this.disposeRoot = createRoot((disposeEffects) => { + createEffect( + on(selectedSignal, (selectedIds) => { + const isSelected = selectedIds.has(this.id); + // 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, + ); + (this.baseMesh.material as THREE.MeshPhongMaterial).emissive.set( + isSelected ? BASE_SELECTED_EMISSIVE : BASE_EMISSIVE, + ); + + renderLoop.requestRender(); + }), + ); + + return disposeEffects; + }); + + scene.add(this.group); + + registry.add({ + object: this.group, + id, + type: "machine", + dispose: () => this.dispose(scene), + }); + } + + private createCubeBase( + color: THREE.ColorRepresentation, + emissive: THREE.ColorRepresentation, + geometry: THREE.BoxGeometry, + ) { + const baseMaterial = new THREE.MeshPhongMaterial({ + color, + emissive, + transparent: true, + opacity: 1, + }); + const base = new THREE.Mesh(geometry, baseMaterial); + base.position.set(0, BASE_HEIGHT / 2, 0); + base.receiveShadow = true; + return base; + } + + private createLabel(id: string) { + const div = document.createElement("div"); + div.className = "machine-label"; + div.textContent = id; + const label = new CSS2DObject(div); + label.position.set(0, CUBE_SIZE + 0.1, 0); + return label; + } + + dispose(scene: THREE.Scene) { + this.disposeRoot?.(); // Stop SolidJS effects + + scene.remove(this.group); + + this.geometry.dispose(); + this.material.dispose(); + (this.baseMesh.material as THREE.Material).dispose(); + } +} diff --git a/pkgs/clan-app/ui/src/scene/ObjectRegistry.ts b/pkgs/clan-app/ui/src/scene/ObjectRegistry.ts new file mode 100644 index 000000000..b6d1699ea --- /dev/null +++ b/pkgs/clan-app/ui/src/scene/ObjectRegistry.ts @@ -0,0 +1,43 @@ +import * as THREE from "three"; + +interface ObjectEntry { + object: THREE.Object3D; + type: string; + id: string; + dispose?: () => void; +} + +export class ObjectRegistry { + #objects = new Map(); + + add(entry: ObjectEntry) { + const key = `${entry.type}:${entry.id}`; + this.#objects.set(key, entry); + } + + getById(type: string, id: string) { + return this.#objects.get(`${type}:${id}`); + } + + getAllByType(type: string) { + return [...this.#objects.values()].filter((obj) => obj.type === type); + } + + removeById(type: string, id: string, scene: THREE.Scene) { + const key = `${type}:${id}`; + const entry = this.#objects.get(key); + if (entry) { + scene.remove(entry.object); + entry.dispose?.(); + this.#objects.delete(key); + } + } + + disposeAll(scene: THREE.Scene) { + for (const entry of this.#objects.values()) { + scene.remove(entry.object); + entry.dispose?.(); + } + this.#objects.clear(); + } +} 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(); }} /> diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index 0af560814..e1153fb19 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -1,7 +1,7 @@ import argparse import logging -from clan_lib.flake import Flake +from clan_lib.flake import require_flake from clan_lib.machines.actions import list_machines from clan_cli.completions import add_dynamic_completer, complete_tags @@ -10,7 +10,7 @@ log = logging.getLogger(__name__) def list_command(args: argparse.Namespace) -> None: - flake: Flake = args.flake + flake = require_flake(args.flake) for name in list_machines(flake, opts={"filter": {"tags": args.tags}}): print(name) diff --git a/pkgs/clan-cli/clan_cli/machines/list_test.py b/pkgs/clan-cli/clan_cli/machines/list_test.py index e19110cf7..f203f940a 100644 --- a/pkgs/clan-cli/clan_cli/machines/list_test.py +++ b/pkgs/clan-cli/clan_cli/machines/list_test.py @@ -1,4 +1,5 @@ import pytest +from clan_lib.errors import ClanError from clan_cli.tests import fixtures_flakes from clan_cli.tests.helpers import cli @@ -359,3 +360,12 @@ def list_mixed_tagged_untagged( assert "machine-with-tags" not in output.out assert "machine-without-tags" not in output.out assert output.out.strip() == "" + + +def test_machines_list_require_flake_error() -> None: + """Test that machines list command fails when flake is required but not provided.""" + with pytest.raises(ClanError) as exc_info: + cli.run(["machines", "list"]) + + error_message = str(exc_info.value) + assert "flake" in error_message.lower()