ui/cubes: reactive updates, use orthographic

This commit is contained in:
Johannes Kirschbauer
2025-07-23 16:05:51 +02:00
parent 5f567e2473
commit 7065464227
2 changed files with 155 additions and 101 deletions

View File

@@ -1,6 +1,19 @@
import { RouteSectionProps } from "@solidjs/router"; import { RouteSectionProps } from "@solidjs/router";
import { Component, JSX, Show, createSignal, onMount } from "solid-js"; import {
import { useClanURI } from "@/src/hooks/clan"; Component,
JSX,
Show,
createEffect,
createMemo,
createSignal,
on,
onMount,
} from "solid-js";
import {
buildMachinePath,
maybeUseMachineID,
useClanURI,
} from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes"; import { CubeScene } from "@/src/scene/cubes";
import { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries"; import { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries";
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
@@ -14,6 +27,7 @@ import { Modal } from "@/src/components/Modal/Modal";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { createForm, FieldValues, reset } from "@modular-forms/solid"; import { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { useNavigate } from "@solidjs/router";
export const Clan: Component<RouteSectionProps> = (props) => { export const Clan: Component<RouteSectionProps> = (props) => {
return ( return (
@@ -64,7 +78,7 @@ const MockCreateMachine = (props: MockProps) => {
)} )}
</Field> </Field>
<div class="flex w-full items-center justify-end gap-4"> <div class="mt-4 flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={props.onClose}> <Button size="s" hierarchy="secondary" onClick={props.onClose}>
Cancel Cancel
</Button> </Button>
@@ -86,6 +100,7 @@ const MockCreateMachine = (props: MockProps) => {
const ClanSceneController = (props: RouteSectionProps) => { const ClanSceneController = (props: RouteSectionProps) => {
const clanURI = useClanURI(); const clanURI = useClanURI();
const navigate = useNavigate();
const [dialogHandlers, setDialogHandlers] = createSignal<{ const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void; resolve: ({ id }: { id: string }) => void;
@@ -130,6 +145,32 @@ const ClanSceneController = (props: RouteSectionProps) => {
}, 1500); }, 1500);
}); });
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
const onMachineSelect = (ids: Set<string>) => {
// Get the first selected ID and navigate to its machine details
const selected = ids.values().next().value;
if (selected) {
navigate(buildMachinePath(clanURI, selected));
}
};
const machine = createMemo(() => maybeUseMachineID());
createEffect(
on(machine, (machineId) => {
if (machineId) {
setSelectedIds(() => {
const res = new Set<string>();
res.add(machineId);
return res;
});
} else {
setSelectedIds(new Set<string>());
}
}),
);
return ( return (
<SceneDataProvider clanURI={clanURI}> <SceneDataProvider clanURI={clanURI}>
{({ query }) => { {({ query }) => {
@@ -190,6 +231,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
</div> </div>
<CubeScene <CubeScene
selectedIds={selectedIds}
onSelect={onMachineSelect}
isLoading={query.isLoading} isLoading={query.isLoading}
cubesQuery={query} cubesQuery={query}
onCreate={onCreate} onCreate={onCreate}

View File

@@ -36,7 +36,7 @@ function garbageCollectGroup(group: THREE.Group) {
} }
function getFloorPosition( function getFloorPosition(
camera: THREE.PerspectiveCamera, camera: THREE.Camera,
floor: THREE.Object3D, floor: THREE.Object3D,
): [number, number, number] { ): [number, number, number] {
const cameraPosition = camera.position.clone(); const cameraPosition = camera.position.clone();
@@ -67,13 +67,15 @@ function keyFromPos(pos: [number, number]): string {
export function CubeScene(props: { export function CubeScene(props: {
cubesQuery: MachinesQueryResult; cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>; onCreate: () => Promise<{ id: string }>;
selectedIds: Accessor<Set<string>>;
onSelect: (v: Set<string>) => void;
sceneStore: Accessor<SceneData>; sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number]) => void; setMachinePos: (machineId: string, pos: [number, number]) => void;
isLoading: boolean; isLoading: boolean;
}) { }) {
let container: HTMLDivElement; let container: HTMLDivElement;
let scene: THREE.Scene; let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera; let camera: THREE.OrthographicCamera;
let renderer: THREE.WebGLRenderer; let renderer: THREE.WebGLRenderer;
let floor: THREE.Mesh; let floor: THREE.Mesh;
let controls: MapControls; let controls: MapControls;
@@ -108,7 +110,6 @@ export function CubeScene(props: {
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view"); const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>(); const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
const [cameraInfo, setCameraInfo] = createSignal({ const [cameraInfo, setCameraInfo] = createSignal({
position: { x: 0, y: 0, z: 0 }, position: { x: 0, y: 0, z: 0 },
@@ -117,8 +118,6 @@ export function CubeScene(props: {
// Animation configuration // Animation configuration
const ANIMATION_DURATION = 800; // milliseconds const ANIMATION_DURATION = 800; // milliseconds
const DELETE_ANIMATION_DURATION = 400; // milliseconds
const CREATE_ANIMATION_DURATION = 600; // milliseconds
// Grid configuration // Grid configuration
const GRID_SIZE = 2; const GRID_SIZE = 2;
@@ -146,7 +145,6 @@ export function CubeScene(props: {
const CREATE_BASE_EMISSIVE = 0xc5fad7; const CREATE_BASE_EMISSIVE = 0xc5fad7;
createEffect(() => { createEffect(() => {
console.log("Direct query data hook");
// Update when API updates. // Update when API updates.
if (props.cubesQuery.data) { if (props.cubesQuery.data) {
const actualMachines = Object.keys(props.cubesQuery.data); const actualMachines = Object.keys(props.cubesQuery.data);
@@ -189,7 +187,7 @@ export function CubeScene(props: {
console.warn("Not animating!"); console.warn("Not animating!");
return; return;
} }
console.log("Rendering scene...", initBase?.position); console.log("Rendering scene...", camera.toJSON());
needsRender = false; needsRender = false;
@@ -217,23 +215,53 @@ export function CubeScene(props: {
} }
function nextGridPos(): [number, number] { function nextGridPos(): [number, number] {
// Scales up to 10*10 grid = 100 positions let x = 0,
// TODO: Make this more scalable and nicer z = 0;
const maxRows = 10; // or dynamic limit if needed let layer = 1;
const maxCols = 10;
for (let y = 0; y < maxRows; y++) { while (layer < 100) {
for (let x = 0; x < maxCols; x++) { // right
const pos = [x * CUBE_SPACING, y * CUBE_SPACING] as [number, number]; for (let i = 0; i < layer; i++) {
const pos = [x * CUBE_SPACING, z * CUBE_SPACING] as [number, number];
const key = keyFromPos(pos); const key = keyFromPos(pos);
if (!occupiedPositions.has(key)) { if (!occupiedPositions.has(key)) {
return pos; 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;
throw new Error("No free grid positions available."); }
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: // Circle IDEA:
@@ -331,49 +359,10 @@ export function CubeScene(props: {
return base; return base;
} }
function deleteSelectedCubes(selectedSet: Set<string>) {
if (selectedSet.size === 0) return;
console.log("Deleting cubes:", selectedSet);
// Start delete animations
selectedSet.forEach((id) => {
const group = groupMap.get(id);
if (group) {
groupMap.delete(id); // Remove from group map
const base = group.children.find((child) => child.name === "base");
const cube = group.children.find((child) => child.name === "cube");
if (!base || !cube) {
console.warn(`DELETE: Base mesh not found for id: ${id}`);
return;
}
{
setSelectedIds(new Set<string>()); // Clear selection after deletion
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}`);
}
});
}
function toggleSelection(id: string) { function toggleSelection(id: string) {
setSelectedIds((curr) => { const next = new Set<string>();
const next = new Set(curr);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id); next.add(id);
} props.onSelect(next);
return next;
});
} }
function updateMeshColors( function updateMeshColors(
@@ -450,7 +439,7 @@ export function CubeScene(props: {
} }
} }
const initialCameraPosition = { x: 2.8, y: 4, z: -2 }; const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical(); const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3( initialSphericalCameraPosition.setFromVector3(
new THREE.Vector3( new THREE.Vector3(
@@ -465,7 +454,6 @@ export function CubeScene(props: {
onMount(() => { onMount(() => {
// Scene setup // Scene setup
scene = new THREE.Scene(); scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xffffff, 10, 50); //
// Transparent background // Transparent background
scene.background = null; scene.background = null;
@@ -506,17 +494,30 @@ export function CubeScene(props: {
bgScene.add(bgMesh); bgScene.add(bgMesh);
// Camera setup // Camera setup
camera = new THREE.PerspectiveCamera( // /container!.clientWidth / container!.clientHeight,
75, const aspect = window.innerWidth / window.innerHeight;
container!.clientWidth / container!.clientHeight, const d = 20;
0.1, const zoom = 2.5;
camera = new THREE.OrthographicCamera(
(-d * aspect) / zoom,
(d * aspect) / zoom,
d / zoom,
-d / zoom,
0.001,
1000, 1000,
); );
camera.zoom = zoom;
camera.position.setFromSpherical(initialSphericalCameraPosition); camera.position.setFromSpherical(initialSphericalCameraPosition);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
// Renderer setup // Renderer setup
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
logarithmicDepthBuffer: true,
});
renderer.setSize(container.clientWidth, container.clientHeight); renderer.setSize(container.clientWidth, container.clientHeight);
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -527,25 +528,36 @@ export function CubeScene(props: {
// Enable the context menu, // Enable the context menu,
// TODO: disable in production // TODO: disable in production
controls.mouseButtons.RIGHT = null; controls.mouseButtons.RIGHT = null;
controls.addEventListener("change", requestRenderIfNotRequested); 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();
requestRenderIfNotRequested();
});
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5); const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight); scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 2); const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
// 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(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(
100, 1000,
initialSphericalCameraPosition.phi, 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.target.position.set(0, 0, 0); // Point light at the center
// initialSphericalCameraPosition // initialSphericalCameraPosition
directionalLight.castShadow = true; directionalLight.castShadow = true;
@@ -555,10 +567,10 @@ export function CubeScene(props: {
directionalLight.shadow.camera.top = 30; directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30; directionalLight.shadow.camera.bottom = -30;
directionalLight.shadow.camera.near = 0.1; directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 200; directionalLight.shadow.camera.far = 2000;
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 = 0; // 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);
@@ -631,6 +643,17 @@ export function CubeScene(props: {
// Initial camera info update // Initial camera info update
updateCameraInfo(); updateCameraInfo();
createEffect(
on(worldMode, (mode) => {
if (mode === "create") {
initBase!.visible = true;
} else {
initBase!.visible = false;
}
requestRenderIfNotRequested();
}),
);
// 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
@@ -672,7 +695,7 @@ export function CubeScene(props: {
const id = intersects[0].object.userData.id; const id = intersects[0].object.userData.id;
toggleSelection(id); toggleSelection(id);
} else { } else {
setSelectedIds(new Set<string>()); // Clear selection if clicked outside cubes props.onSelect(new Set<string>()); // Clear selection if clicked outside cubes
} }
}; };
@@ -684,8 +707,14 @@ export function CubeScene(props: {
// Handle window resize // Handle window resize
const handleResize = () => { const handleResize = () => {
camera.aspect = container.clientWidth / container.clientHeight; 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(); camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight); renderer.setSize(container.clientWidth, container.clientHeight);
// Update background shader resolution // Update background shader resolution
@@ -740,18 +769,6 @@ export function CubeScene(props: {
}); });
}); });
// 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( function createCube(
gridPosition: [number, number], gridPosition: [number, number],
userData: { id: string }, userData: { id: string },
@@ -787,7 +804,6 @@ export function CubeScene(props: {
// Effect to manage cube meshes - this runs whenever cubes() changes // Effect to manage cube meshes - this runs whenever cubes() changes
createEffect(() => { createEffect(() => {
const currentCubes = cubes(); const currentCubes = cubes();
console.log("Current cubes:", currentCubes);
const existing = new Set(groupMap.keys()); const existing = new Set(groupMap.keys());
@@ -808,7 +824,6 @@ export function CubeScene(props: {
scene.add(group); scene.add(group);
groupMap.set(cube.id, group); groupMap.set(cube.id, group);
} else { } else {
console.log("Updating existing cube:", cube.id);
// Only animate position if not being deleted // Only animate position if not being deleted
const targetPosition = cube.targetPosition || cube.position; const targetPosition = cube.targetPosition || cube.position;
const currentPosition = existingGroup.position.toArray() as [ const currentPosition = existingGroup.position.toArray() as [
@@ -849,7 +864,7 @@ export function CubeScene(props: {
}); });
createEffect( createEffect(
on(selectedIds, (curr, prev) => { on(props.selectedIds, (curr, prev) => {
console.log("Selected cubes:", curr); console.log("Selected cubes:", curr);
// Update colors of selected cubes // Update colors of selected cubes
updateMeshColors(curr, prev); updateMeshColors(curr, prev);
@@ -933,8 +948,7 @@ export function CubeScene(props: {
<ToolbarButton <ToolbarButton
name="new-machine" name="new-machine"
icon="NewMachine" icon="NewMachine"
onMouseEnter={onHover(true)} disabled={positionMode() === "circle"}
onMouseLeave={onHover(false)}
onClick={onAddClick} onClick={onAddClick}
selected={worldMode() === "create"} selected={worldMode() === "create"}
/> />
@@ -945,6 +959,7 @@ export function CubeScene(props: {
onClick={() => { onClick={() => {
if (positionMode() === "grid") { if (positionMode() === "grid") {
setPositionMode("circle"); setPositionMode("circle");
setWorldMode("view");
grid.visible = false; grid.visible = false;
} else { } else {
setPositionMode("grid"); setPositionMode("grid");
@@ -952,11 +967,7 @@ export function CubeScene(props: {
} }
}} }}
/> />
<ToolbarButton <ToolbarButton name="delete" icon="Trash" />
name="delete"
icon="Trash"
onClick={() => deleteSelectedCubes(selectedIds())}
/>
</Toolbar> </Toolbar>
</div> </div>
</> </>