Merge branch 'main' into postgres-core

This commit is contained in:
pinpox
2025-07-29 09:41:50 +00:00
6 changed files with 402 additions and 446 deletions

View File

@@ -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<string, MachineRepr>();
private disposeRoot: () => void;
private machinePositionsSignal: Accessor<SceneData>;
constructor(
scene: THREE.Scene,
registry: ObjectRegistry,
machinePositionsSignal: Accessor<SceneData>,
machinesQueryResult: MachinesQueryResult,
selectedIds: Accessor<Set<string>>,
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<string> = 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];
// };

View File

@@ -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<Set<string>>,
) {
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();
}
}

View File

@@ -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<string, ObjectEntry>();
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();
}
}

View File

@@ -1,28 +1,19 @@
import { import { createSignal, createEffect, onCleanup, onMount, on } from "solid-js";
createSignal,
createEffect,
onCleanup,
onMount,
createMemo,
on,
} from "solid-js";
import "./cubes.css"; import "./cubes.css";
import * as THREE from "three"; import * as THREE from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js"; import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
CSS2DRenderer,
CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Toolbar } from "../components/Toolbar/Toolbar"; import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton"; import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider"; import { Divider } from "../components/Divider/Divider";
import { MachinesQueryResult } from "../queries/queries"; import { MachinesQueryResult } from "../queries/queries";
import { SceneData } from "../stores/clan"; import { SceneData } from "../stores/clan";
import { unwrap } from "solid-js/store";
import { Accessor } from "solid-js"; import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop"; import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager";
function garbageCollectGroup(group: THREE.Group) { function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) { for (const child of group.children) {
@@ -40,35 +31,6 @@ function garbageCollectGroup(group: THREE.Group) {
group.clear(); // Clear the 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: { export function CubeScene(props: {
cubesQuery: MachinesQueryResult; cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>; onCreate: () => Promise<{ id: string }>;
@@ -95,8 +57,6 @@ export function CubeScene(props: {
const groupMap = new Map<string, THREE.Group>(); const groupMap = new Map<string, THREE.Group>();
const occupiedPositions = new Set<string>();
let sharedCubeGeometry: THREE.BoxGeometry; let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry; let sharedBaseGeometry: THREE.BoxGeometry;
@@ -113,12 +73,8 @@ export function CubeScene(props: {
spherical: { radius: 0, theta: 0, phi: 0 }, spherical: { radius: 0, theta: 0, phi: 0 },
}); });
// Animation configuration
const ANIMATION_DURATION = 800; // milliseconds
// Grid configuration // Grid configuration
const GRID_SIZE = 2; const GRID_SIZE = 2;
const CUBE_SPACING = 2;
const BASE_SIZE = 0.9; // Height of the cube above the ground const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; // const CUBE_SIZE = BASE_SIZE / 1.5; //
@@ -128,189 +84,12 @@ export function CubeScene(props: {
const FLOOR_COLOR = 0xcdd8d9; 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_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c; 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_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7; 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( function createCubeBase(
cube_pos: [number, number, number], cube_pos: [number, number, number],
opacity = 1, opacity = 1,
@@ -336,67 +115,6 @@ export function CubeScene(props: {
props.onSelect(next); 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 initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical(); const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3( initialSphericalCameraPosition.setFromVector3(
@@ -498,13 +216,13 @@ export function CubeScene(props: {
controls.minZoom = 1.2; controls.minZoom = 1.2;
controls.maxZoom = 3.5; controls.maxZoom = 3.5;
controls.addEventListener("change", () => { controls.addEventListener("change", () => {
// const aspect = container.clientWidth / container.clientHeight; const aspect = container.clientWidth / container.clientHeight;
// const zoom = camera.zoom; const zoom = camera.zoom;
// camera.left = (-d * aspect) / zoom; camera.left = (-d * aspect) / zoom;
// camera.right = (d * aspect) / zoom; camera.right = (d * aspect) / zoom;
// camera.top = d / zoom; camera.top = d / zoom;
// camera.bottom = -d / zoom; camera.bottom = -d / zoom;
// camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderLoop.requestRender(); renderLoop.requestRender();
}); });
@@ -525,31 +243,31 @@ export function CubeScene(props: {
const directionalLight = new THREE.DirectionalLight(0xffffff, 2); const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
// scene.add(new THREE.DirectionalLightHelper(directionalLight)); // scene.add(new THREE.DirectionalLightHelper(directionalLight));
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// scene.add(new THREE.CameraHelper(camera)); // scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical( const lightPos = new THREE.Spherical(
1000, 15,
initialSphericalCameraPosition.phi - Math.PI / 8, initialSphericalCameraPosition.phi - Math.PI / 8,
initialSphericalCameraPosition.theta - Math.PI / 2, initialSphericalCameraPosition.theta - Math.PI / 2,
); );
directionalLight.position.setFromSpherical(lightPos); directionalLight.position.setFromSpherical(lightPos);
directionalLight.target.position.set(0, 0, 0); // Point light at the center directionalLight.rotation.set(0, 0, 0);
// initialSphericalCameraPosition // initialSphericalCameraPosition
directionalLight.castShadow = true; directionalLight.castShadow = true;
// Configure shadow camera for hard, crisp shadows // Configure shadow camera for hard, crisp shadows
directionalLight.shadow.camera.left = -30; directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 30; directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 30; directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -30; directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.1; 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.width = 4096; // Higher resolution for sharper shadows
directionalLight.shadow.mapSize.height = 4096; directionalLight.shadow.mapSize.height = 4096;
directionalLight.shadow.radius = 1; // Hard shadows (low radius) directionalLight.shadow.radius = 1; // Hard shadows (low radius)
directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges
scene.add(directionalLight); scene.add(directionalLight);
scene.add(directionalLight.target); scene.add(directionalLight.target);
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// Floor/Ground - Make it invisible but keep it for reference // Floor/Ground - Make it invisible but keep it for reference
const floorGeometry = new THREE.PlaneGeometry(1000, 1000); 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: // Click handler:
// - Select/deselects a cube in "view" mode // - Select/deselects a cube in "view" mode
// - Creates a new cube in "create" mode // - Creates a new cube in "create" mode
@@ -664,10 +393,11 @@ export function CubeScene(props: {
raycaster.setFromCamera(mouse, camera); raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects( const intersects = raycaster.intersectObjects(
Array.from(groupMap.values()), Array.from(machineManager.machines.values().map((m) => m.group)),
); );
console.log("Intersects:", intersects); console.log("Intersects:", intersects);
if (intersects.length > 0) { if (intersects.length > 0) {
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id; const id = intersects[0].object.userData.id;
toggleSelection(id); toggleSelection(id);
} else { } else {
@@ -698,7 +428,7 @@ export function CubeScene(props: {
container.clientHeight, container.clientHeight,
); );
renderer.render(bgScene, bgCamera); // renderer.render(bgScene, bgCamera);
renderLoop.requestRender(); renderLoop.requestRender();
}; };
@@ -715,10 +445,27 @@ export function CubeScene(props: {
{ capture: true }, { capture: true },
); );
// Initial render
renderLoop.requestRender();
// Cleanup function // Cleanup function
onCleanup(() => { 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(); renderLoop.dispose();
machineManager.dispose(scene);
renderer.domElement.removeEventListener("click", onClick); renderer.domElement.removeEventListener("click", onClick);
renderer.domElement.removeEventListener("mousemove", onMouseMove); renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
@@ -735,153 +482,13 @@ export function CubeScene(props: {
if (container) { if (container) {
container.innerHTML = ""; 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) => { const onAddClick = (event: MouseEvent) => {
setPositionMode("grid"); setPositionMode("grid");
setWorldMode("create"); setWorldMode("create");
renderLoop.requestRender();
}; };
const onMouseMove = (event: MouseEvent) => { const onMouseMove = (event: MouseEvent) => {
if (worldMode() !== "create") return; if (worldMode() !== "create") return;
@@ -948,6 +555,7 @@ export function CubeScene(props: {
setPositionMode("grid"); setPositionMode("grid");
grid.visible = true; grid.visible = true;
} }
renderLoop.requestRender();
}} }}
/> />
<ToolbarButton name="delete" icon="Trash" /> <ToolbarButton name="delete" icon="Trash" />

View File

@@ -1,7 +1,7 @@
import argparse import argparse
import logging 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_lib.machines.actions import list_machines
from clan_cli.completions import add_dynamic_completer, complete_tags 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: 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}}): for name in list_machines(flake, opts={"filter": {"tags": args.tags}}):
print(name) print(name)

View File

@@ -1,4 +1,5 @@
import pytest import pytest
from clan_lib.errors import ClanError
from clan_cli.tests import fixtures_flakes from clan_cli.tests import fixtures_flakes
from clan_cli.tests.helpers import cli 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-with-tags" not in output.out
assert "machine-without-tags" not in output.out assert "machine-without-tags" not in output.out
assert output.out.strip() == "" 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()