Merge pull request 'ui/cubes: reactive wiring, use orthographic camera' (#4468) from scene-progress into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4468
This commit is contained in:
hsjobeki
2025-07-23 14:09:42 +00:00
5 changed files with 164 additions and 141 deletions

View File

@@ -18,7 +18,7 @@
}
.md-nav__title,
.md-nav__item.md-nav__item--section>label>span {
.md-nav__item.md-nav__item--section > label > span {
color: var(--md-typeset-a-color);
}

View File

@@ -1,39 +0,0 @@
version: "0.5"
processes:
# App Dev
clan-app-ui:
namespace: "app"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d
npm install
vite
ready_log_line: "VITE"
clan-app:
namespace: "app"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app
./bin/clan-app --debug --content-uri http://localhost:3000
depends_on:
clan-app-ui:
condition: "process_log_ready"
is_foreground: true
ready_log_line: "Debug mode enabled"
# Storybook Dev
storybook:
namespace: "storybook"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d
npm run storybook-dev -- --ci
ready_log_line: "started"
luakit:
namespace: "storybook"
command: "luakit http://localhost:6006"
depends_on:
storybook:
condition: "process_log_ready"

View File

@@ -63,3 +63,11 @@ export const useMachineID = (): string => {
const params = useParams();
return machineIDParam(params);
};
export const maybeUseMachineID = (): string | null => {
const params = useParams();
if (params.machineID === undefined) {
return null;
}
return machineIDParam(params);
};

View File

@@ -1,6 +1,19 @@
import { RouteSectionProps } from "@solidjs/router";
import { Component, JSX, Show, createSignal, onMount } from "solid-js";
import { useClanURI } from "@/src/hooks/clan";
import {
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 { MachinesQueryResult, useMachinesQuery } from "@/src/queries/queries";
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 { createForm, FieldValues, reset } from "@modular-forms/solid";
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { useNavigate } from "@solidjs/router";
export const Clan: Component<RouteSectionProps> = (props) => {
return (
@@ -64,7 +78,7 @@ const MockCreateMachine = (props: MockProps) => {
)}
</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}>
Cancel
</Button>
@@ -86,6 +100,7 @@ const MockCreateMachine = (props: MockProps) => {
const ClanSceneController = (props: RouteSectionProps) => {
const clanURI = useClanURI();
const navigate = useNavigate();
const [dialogHandlers, setDialogHandlers] = createSignal<{
resolve: ({ id }: { id: string }) => void;
@@ -130,6 +145,32 @@ const ClanSceneController = (props: RouteSectionProps) => {
}, 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 (
<SceneDataProvider clanURI={clanURI}>
{({ query }) => {
@@ -190,6 +231,8 @@ const ClanSceneController = (props: RouteSectionProps) => {
</div>
<CubeScene
selectedIds={selectedIds}
onSelect={onMachineSelect}
isLoading={query.isLoading}
cubesQuery={query}
onCreate={onCreate}

View File

@@ -36,7 +36,7 @@ function garbageCollectGroup(group: THREE.Group) {
}
function getFloorPosition(
camera: THREE.PerspectiveCamera,
camera: THREE.Camera,
floor: THREE.Object3D,
): [number, number, number] {
const cameraPosition = camera.position.clone();
@@ -67,13 +67,15 @@ function keyFromPos(pos: [number, number]): string {
export function CubeScene(props: {
cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>;
selectedIds: Accessor<Set<string>>;
onSelect: (v: Set<string>) => void;
sceneStore: Accessor<SceneData>;
setMachinePos: (machineId: string, pos: [number, number]) => void;
isLoading: boolean;
}) {
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let camera: THREE.OrthographicCamera;
let renderer: THREE.WebGLRenderer;
let floor: THREE.Mesh;
let controls: MapControls;
@@ -108,7 +110,6 @@ export function CubeScene(props: {
const [worldMode, setWorldMode] = createSignal<"view" | "create">("view");
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());
const [cameraInfo, setCameraInfo] = createSignal({
position: { x: 0, y: 0, z: 0 },
@@ -117,8 +118,6 @@ export function CubeScene(props: {
// Animation configuration
const ANIMATION_DURATION = 800; // milliseconds
const DELETE_ANIMATION_DURATION = 400; // milliseconds
const CREATE_ANIMATION_DURATION = 600; // milliseconds
// Grid configuration
const GRID_SIZE = 2;
@@ -146,7 +145,6 @@ export function CubeScene(props: {
const CREATE_BASE_EMISSIVE = 0xc5fad7;
createEffect(() => {
console.log("Direct query data hook");
// Update when API updates.
if (props.cubesQuery.data) {
const actualMachines = Object.keys(props.cubesQuery.data);
@@ -189,7 +187,7 @@ export function CubeScene(props: {
console.warn("Not animating!");
return;
}
console.log("Rendering scene...", initBase?.position);
console.log("Rendering scene...", camera.toJSON());
needsRender = false;
@@ -217,23 +215,53 @@ export function CubeScene(props: {
}
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;
let x = 0,
z = 0;
let layer = 1;
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];
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;
}
throw new Error("No free grid positions available.");
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:
@@ -331,49 +359,10 @@ export function CubeScene(props: {
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) {
setSelectedIds((curr) => {
const next = new Set(curr);
if (next.has(id)) {
next.delete(id);
} else {
const next = new Set<string>();
next.add(id);
}
return next;
});
props.onSelect(next);
}
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();
initialSphericalCameraPosition.setFromVector3(
new THREE.Vector3(
@@ -465,7 +454,6 @@ export function CubeScene(props: {
onMount(() => {
// Scene setup
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xffffff, 10, 50); //
// Transparent background
scene.background = null;
@@ -506,17 +494,30 @@ export function CubeScene(props: {
bgScene.add(bgMesh);
// Camera setup
camera = new THREE.PerspectiveCamera(
75,
container!.clientWidth / container!.clientHeight,
0.1,
// /container!.clientWidth / container!.clientHeight,
const aspect = window.innerWidth / window.innerHeight;
const d = 20;
const zoom = 2.5;
camera = new THREE.OrthographicCamera(
(-d * aspect) / zoom,
(d * aspect) / zoom,
d / zoom,
-d / zoom,
0.001,
1000,
);
camera.zoom = zoom;
camera.position.setFromSpherical(initialSphericalCameraPosition);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
// 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.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
@@ -527,25 +528,36 @@ export function CubeScene(props: {
// Enable the context menu,
// TODO: disable in production
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
const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
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.CameraHelper(directionalLight.shadow.camera));
// scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical(
100,
initialSphericalCameraPosition.phi,
1000,
initialSphericalCameraPosition.phi - Math.PI / 8,
initialSphericalCameraPosition.theta - Math.PI / 2,
);
directionalLight.position.setFromSpherical(lightPos);
directionalLight.target.position.set(0, 0, 0); // Point light at the center
// initialSphericalCameraPosition
directionalLight.castShadow = true;
@@ -555,10 +567,10 @@ export function CubeScene(props: {
directionalLight.shadow.camera.top = 30;
directionalLight.shadow.camera.bottom = -30;
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.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
scene.add(directionalLight);
scene.add(directionalLight.target);
@@ -631,6 +643,17 @@ export function CubeScene(props: {
// Initial camera info update
updateCameraInfo();
createEffect(
on(worldMode, (mode) => {
if (mode === "create") {
initBase!.visible = true;
} else {
initBase!.visible = false;
}
requestRenderIfNotRequested();
}),
);
// Click handler:
// - Select/deselects a cube in "view" mode
// - Creates a new cube in "create" mode
@@ -672,7 +695,7 @@ export function CubeScene(props: {
const id = intersects[0].object.userData.id;
toggleSelection(id);
} 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
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();
renderer.setSize(container.clientWidth, container.clientHeight);
// 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(
gridPosition: [number, number],
userData: { id: string },
@@ -787,7 +804,6 @@ export function CubeScene(props: {
// 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());
@@ -808,7 +824,6 @@ export function CubeScene(props: {
scene.add(group);
groupMap.set(cube.id, group);
} 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 [
@@ -849,7 +864,7 @@ export function CubeScene(props: {
});
createEffect(
on(selectedIds, (curr, prev) => {
on(props.selectedIds, (curr, prev) => {
console.log("Selected cubes:", curr);
// Update colors of selected cubes
updateMeshColors(curr, prev);
@@ -933,8 +948,7 @@ export function CubeScene(props: {
<ToolbarButton
name="new-machine"
icon="NewMachine"
onMouseEnter={onHover(true)}
onMouseLeave={onHover(false)}
disabled={positionMode() === "circle"}
onClick={onAddClick}
selected={worldMode() === "create"}
/>
@@ -945,6 +959,7 @@ export function CubeScene(props: {
onClick={() => {
if (positionMode() === "grid") {
setPositionMode("circle");
setWorldMode("view");
grid.visible = false;
} else {
setPositionMode("grid");
@@ -952,11 +967,7 @@ export function CubeScene(props: {
}
}}
/>
<ToolbarButton
name="delete"
icon="Trash"
onClick={() => deleteSelectedCubes(selectedIds())}
/>
<ToolbarButton name="delete" icon="Trash" />
</Toolbar>
</div>
</>