Merge branch 'main' into postgres-core
This commit is contained in:
154
pkgs/clan-app/ui/src/scene/MachineManager.ts
Normal file
154
pkgs/clan-app/ui/src/scene/MachineManager.ts
Normal 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];
|
||||||
|
// };
|
||||||
141
pkgs/clan-app/ui/src/scene/MachineRepr.ts
Normal file
141
pkgs/clan-app/ui/src/scene/MachineRepr.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
pkgs/clan-app/ui/src/scene/ObjectRegistry.ts
Normal file
43
pkgs/clan-app/ui/src/scene/ObjectRegistry.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user