ui/scene: remove all unneded complexity to reduce complexity and improve performance

This commit is contained in:
Johannes Kirschbauer
2025-07-18 17:12:09 +02:00
parent e37b61240b
commit 1bd950fa39

View File

@@ -13,9 +13,10 @@ import * as THREE from "three";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { UseQueryResult } from "@tanstack/solid-query";
import { ListMachines } from "../routes/Clan/Clan";
import { callApi } from "../hooks/api";
import { MachinesQueryResult } from "../queries/queries";
import { SceneData } from "../stores/clan";
import { unwrap } from "solid-js/store";
import { Accessor } from "solid-js";
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
@@ -56,7 +57,18 @@ function getFloorPosition(
return intersection.toArray() as [number, number, number];
}
export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
function keyFromPos(pos: [number, number]): string {
return `${pos[0]},${pos[1]}`;
}
// type SceneDataUpdater = (sceneData: SceneData) => void;
export function CubeScene(props: {
cubesQuery: MachinesQueryResult;
onCreate?: (id: string) => Promise<void>;
sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number]) => void;
}) {
// sceneData.cubesQuer
let container: HTMLDivElement;
let scene: THREE.Scene;
@@ -71,7 +83,8 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
let raycaster: THREE.Raycaster;
const groupMap = new Map<string, THREE.Group>();
const positionMap = new Map<string, THREE.Vector3>();
const occupiedPositions = new Set<string>();
let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry;
@@ -83,23 +96,15 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
let isAnimating = false; // Flag to prevent multiple loops
let frameCount = 0;
const [ids, setIds] = createSignal<string[]>([]);
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid",
);
const [nextBasePosition, setNextPosition] =
createSignal<THREE.Vector3 | null>(null);
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
// Backed camera position for restoring after switching mode
const [backedCameraPosition, setBackedCameraPosition] = createSignal<{
pos: THREE.Vector3;
dir: THREE.Vector3;
}>();
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
const [deletingIds, setDeletingIds] = createSignal<Set<string>>(new Set());
const [creatingIds, setCreatingIds] = createSignal<Set<string>>(new Set());
const [cameraInfo, setCameraInfo] = createSignal({
position: { x: 0, y: 0, z: 0 },
spherical: { radius: 0, theta: 0, phi: 0 },
@@ -135,29 +140,69 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
const CREATE_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7;
function getDefaultPosition(): [number, number, number] {
createEffect(() => {
console.log("Direct query data hook");
// 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);
}
}
});
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 getGridPosition(
id: string,
index: number,
total: number,
): [number, number, number] {
// TODO: Detect collision with other cubes
const pos = positionMap.get(id);
if (pos) {
return pos.toArray() as [number, number, number];
}
const nextPos = nextBasePosition();
if (!nextPos) {
// Use next position if available
throw new Error("Next position is not set");
function nextGridPos(): [number, number] {
// Scales up to 10*10 grid = 100 positions
// TODO: Make this more scalable and nicer
const maxRows = 10; // or dynamic limit if needed
const maxCols = 10;
for (let y = 0; y < maxRows; y++) {
for (let x = 0; x < maxCols; x++) {
const pos = [x * CUBE_SPACING, y * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) {
occupiedPositions.add(key);
return pos;
}
}
}
const next = nextPos.toArray() as [number, number, number];
positionMap.set(id, new THREE.Vector3(...next));
return next;
throw new Error("No free grid positions available.");
}
// Circle IDEA:
@@ -172,14 +217,11 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
return [x, CUBE_Y, z];
};
// Reactive cubes memo - this recalculates whenever ids() changes
// Reactive cubes memo - this recalculates whenever data changes
const cubes = createMemo(() => {
const currentIds = ids();
const deleting = deletingIds();
const creating = creatingIds();
// Include both active and deleting cubes for smooth transitions
const allIds = [...new Set([...currentIds, ...Array.from(deleting)])];
console.log("Calculating cubes...");
const currentIds = Object.keys(unwrap(props.sceneStore()));
console.log("Current IDs:", currentIds);
let cameraTarget = [0, 0, 0] as [number, number, number];
if (camera && floor) {
@@ -190,16 +232,10 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
? getGridPosition
: getCirclePosition(cameraTarget);
return allIds.map((id, index) => {
const isDeleting = deleting.has(id);
const isCreating = creating.has(id);
return currentIds.map((id, index) => {
const activeIndex = currentIds.indexOf(id);
const position = getCubePosition(
id,
isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index,
currentIds.length,
);
const position = getCubePosition(id, index, currentIds.length);
const targetPosition =
activeIndex >= 0
@@ -209,8 +245,6 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
return {
id,
position,
isDeleting,
isCreating,
targetPosition,
};
});
@@ -243,99 +277,6 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
animate();
}
// Create animation helper
function animateCreate(
mesh: THREE.Mesh,
baseMesh: THREE.Mesh,
onComplete: () => void,
) {
const startTime = Date.now();
// Start with zero scale and full opacity
mesh.scale.setScalar(0);
baseMesh.scale.setScalar(0);
// Ensure materials are fully opaque
// if (Array.isArray(mesh.material)) {
// mesh.material.forEach((material) => {
// (material as THREE.MeshBasicMaterial).opacity = 1;
// material.transparent = false;
// });
// } else {
// (mesh.material as THREE.MeshBasicMaterial).opacity = 1;
// mesh.material.transparent = false;
// }
// if (Array.isArray(baseMesh.material)) {
// baseMesh.material.forEach((material) => {
// (material as THREE.MeshBasicMaterial).opacity = 1;
// material.transparent = false;
// });
// } else {
// (baseMesh.material as THREE.MeshBasicMaterial).opacity = 1;
// baseMesh.material.transparent = false;
// }
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / CREATE_ANIMATION_DURATION, 1);
// Smooth easing function with slight overshoot effect
let easeProgress;
if (progress < 0.8) {
// First 80% - smooth scale up
easeProgress = 1 - Math.pow(1 - progress / 0.8, 3);
} else {
// Last 20% - slight overshoot and settle
const overshootProgress = (progress - 0.8) / 0.2;
const overshoot = Math.sin(overshootProgress * Math.PI) * 0.1;
easeProgress = 1 + overshoot;
}
const scale = easeProgress;
mesh.scale.setScalar(scale);
baseMesh.scale.setScalar(scale);
if (progress >= 1) {
// Ensure final scale is exactly 1
mesh.scale.setScalar(1);
baseMesh.scale.setScalar(1);
onComplete();
} else {
requestAnimationFrame(animate);
}
}
animate();
}
// Delete animation helper
function animateDelete(group: THREE.Group, onComplete: () => void) {
const startTime = Date.now();
// const startScale = group.scale.clone();
// const startOpacity = 1;
function animate() {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / DELETE_ANIMATION_DURATION, 1);
// Smooth easing function
const easeProgress = 1 - Math.pow(1 - progress, 3);
const scale = 1 - easeProgress;
// const opacity = startOpacity * (1 - easeProgress);
group.scale.setScalar(scale);
if (progress >= 1) {
onComplete();
} else {
requestAnimationFrame(animate);
}
}
animate();
}
function createCubeBase(
cube_pos: [number, number, number],
opacity = 1,
@@ -355,33 +296,9 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
return base;
}
// === Add/Delete Cube API ===
function addCube(id: string | undefined = undefined) {
if (!id) {
id = crypto.randomUUID();
}
// Add to creating set first
setCreatingIds((prev) => new Set([...prev, id]));
// Add to ids
setIds((prev) => [...prev, id]);
// Remove from creating set after animation completes
setTimeout(() => {
setCreatingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, CREATE_ANIMATION_DURATION);
}
function deleteSelectedCubes(selectedSet: Set<string>) {
if (selectedSet.size === 0) return;
// Add to deleting set to start animation
setDeletingIds(selectedSet);
console.log("Deleting cubes:", selectedSet);
// Start delete animations
@@ -399,19 +316,13 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
return;
}
animateDelete(group, () => {
// Remove from deleting set when animation completes
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
{
setSelectedIds(new Set<string>()); // Clear selection after deletion
garbageCollectGroup(group); // Clean up geometries and materials
setIds((prev) => prev.filter((existingId) => existingId !== id));
console.log("Done deleting", id, ids());
});
garbageCollectGroup(group); // Clean up geometries and materials
scene.remove(group); // Remove from scene
groupMap.delete(id); // Remove from group map
}
} else {
console.warn(`DELETE: Group not found for id: ${id}`);
}
@@ -515,16 +426,6 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
const grid = new THREE.GridHelper(1000, 1000 / 1, 0xe1edef, 0xe1edef);
createEffect(() => {
if (props.cubesQuery.data) {
for (const machineId of Object.keys(props.cubesQuery.data)) {
console.log("Received: ", machineId);
setNextPosition(new THREE.Vector3(...getDefaultPosition()));
addCube(machineId);
}
}
});
onMount(() => {
// Scene setup
scene = new THREE.Scene();
@@ -723,7 +624,7 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
}
scene.remove(initBase); // Remove any existing base mesh
scene.add(initBase);
setNextPosition(snapped); // Update next position for cube creation
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
}
// If in create mode, don't allow camera movement
return;
@@ -767,11 +668,6 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
cameraDirection,
deltaY * movementSpeed,
); // vertical drag (forward/back)
setBackedCameraPosition({
pos: camera.position.clone(),
dir: camera.getWorldDirection(new THREE.Vector3()).clone(),
});
}
updateCameraInfo();
@@ -806,28 +702,13 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
if (initBase) {
scene.remove(initBase); // Remove the base mesh after adding cube
setWorldMode("view");
const res = callApi("create_machine", {
opts: {
clan_dir: {
identifier: "/home/johannes/git/tmp/my-clan",
},
machine: {
name: "sara",
},
},
});
res.result.then(() => {
props.cubesQuery.refetch();
const pos = nextBasePosition();
if (!pos) {
console.error("No next position set for new cube");
return;
}
// res.result.then(() => {
// props.cubesQuery.refetch();
positionMap.set("sara", pos);
addCube("sara");
});
// positionMap.set("sara", pos);
// addCube("sara");
// });
}
return;
}
@@ -861,12 +742,12 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
frameCount++;
renderer.autoClear = false;
renderer.render(bgScene, bgCamera); // Render background scene
renderer.render(scene, camera);
// Uncomment for memory debugging:
if (frameCount % 300 === 0) logMemoryUsage(); // Log every 60 frames
};
isAnimating = true;
animate();
@@ -882,6 +763,7 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
onCleanup(() => {
// Stop animation loop
isAnimating = false;
renderer.domElement.removeEventListener("mousedown", onMouseDown);
renderer.domElement.removeEventListener("mouseup", onMouseUp);
renderer.domElement.removeEventListener("mousemove", onMouseMove);
@@ -901,30 +783,26 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
if (container) {
container.innerHTML = "";
}
groupMap.forEach((group) => {
garbageCollectGroup(group);
scene.remove(group);
});
groupMap.clear();
});
});
createEffect(() => {
if (!container) return;
if (worldMode() === "create") {
// Show the plus button when in create mode
container.style.cursor = "crosshair";
} else {
container.style.cursor = "pointer";
}
});
createEffect(
// Fly back and forth between circle and grid positions
// ? Do we want to do this.
// We could shift the center of the circle to the camera look at position
on(positionMode, (mode) => {
if (mode === "circle") {
grid.visible = false; // Hide grid when in circle mode
} else if (mode === "grid") {
grid.visible = true; // Show grid when in grid mode
}
}),
);
// TODO: Move into css
// createEffect(
// on(positionMode, (mode) => {
// console.log("Position mode changed:", mode);
// if (mode === "circle") {
// grid.visible = false; // Hide grid when in circle mode
// } else if (mode === "grid") {
// grid.visible = true; // Show grid when in grid mode
// }
// }),
// );
function createCube(
gridPosition: [number, number],
@@ -961,30 +839,28 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
// Effect to manage cube meshes - this runs whenever cubes() changes
createEffect(() => {
const currentCubes = cubes();
console.log("Current cubes:", currentCubes);
const existing = new Set(groupMap.keys());
const deleting = deletingIds();
const creating = creatingIds();
// 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);
// Start create animation if this cube is being created
if (creating.has(cube.id)) {
const mesh = group.children[0] as THREE.Mesh;
const base = group.children[1] as THREE.Mesh;
animateCreate(mesh, base, () => {
// Animation complete callback - could add additional logic here
});
}
} else if (!deleting.has(cube.id)) {
} else {
console.log("Updating existing cube:", cube.id);
// Only animate position if not being deleted
const targetPosition = cube.targetPosition || cube.position;
const currentPosition = existingGroup.position.toArray() as [
@@ -1008,10 +884,15 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
// Remove cubes that are no longer in the state and not being deleted
existing.forEach((id) => {
if (!deleting.has(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]]));
}
}
});
@@ -1025,39 +906,6 @@ export function CubeScene(props: { cubesQuery: UseQueryResult<ListMachines> }) {
}),
);
// Effect to clean up deleted cubes after animation
createEffect(() => {
const deleting = deletingIds();
const currentIds = ids();
// Clean up cubes that finished their delete animation
deleting.forEach((id) => {
if (!currentIds.includes(id)) {
const group = groupMap.get(id);
if (group) {
scene.remove(group);
group.children.forEach((child) => {
// Child is finished with its destroy animation
if (child instanceof THREE.Mesh && child.scale.x <= 0.01) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
groupMap.delete(id);
}
}
});
});
createEffect(() => {
selectedIds(); // Track the signal
// updateMeshColors();
});
onCleanup(() => {
for (const group of groupMap.values()) {
garbageCollectGroup(group);