Files
clan-core/pkgs/clan-app/ui/src/scene/cubes.tsx
2025-09-01 19:14:32 +02:00

787 lines
24 KiB
TypeScript

import {
createSignal,
createEffect,
onCleanup,
onMount,
on,
JSX,
Show,
} from "solid-js";
import "./cubes.css";
import * as THREE from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { Toolbar } from "../components/Toolbar/Toolbar";
import { ToolbarButton } from "../components/Toolbar/ToolbarButton";
import { Divider } from "../components/Divider/Divider";
import { MachinesQueryResult, useMachinesQuery } from "../hooks/queries";
import { SceneData } from "../stores/clan";
import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop";
import { ObjectRegistry } from "./ObjectRegistry";
import { MachineManager } from "./MachineManager";
import cx from "classnames";
import { Portal } from "solid-js/web";
import { Menu } from "../components/ContextMenu/ContextMenu";
import {
clearHighlight,
highlightGroups,
setHighlightGroups,
} from "./highlightStore";
import { createMachineMesh } from "./MachineRepr";
function intersectMachines(
event: MouseEvent,
renderer: THREE.WebGLRenderer,
camera: THREE.Camera,
machineManager: MachineManager,
raycaster: THREE.Raycaster,
): string[] {
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
return intersects.map((i) => i.object.userData.id);
}
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
} else {
console.warn("Unknown child type in group:", child);
}
}
group.clear(); // Clear the group
}
// Can be imported by others via wrappers below
// Global signal for last clicked machine
const [lastClickedMachine, setLastClickedMachine] = createSignal<string | null>(
null,
);
// Exported so others could also emit the signal if needed
// And for testing purposes
export function emitMachineClick(id: string | null) {
setLastClickedMachine(id);
if (id) {
// Clear after a short delay to allow re-clicking the same machine
setTimeout(() => {
setLastClickedMachine(null);
}, 100);
}
}
/** Hook for components to subscribe */
export function useMachineClick() {
return lastClickedMachine;
}
/*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" | "move"
>("select");
export { worldMode, setWorldMode };
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] | null) => void;
isLoading: boolean;
clanURI: string;
toolbarPopup?: JSX.Element;
}) {
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.OrthographicCamera;
let renderer: THREE.WebGLRenderer;
let labelRenderer: CSS2DRenderer;
let floor: THREE.Mesh;
let controls: MapControls;
// Raycaster for clicking
const raycaster = new THREE.Raycaster();
let actionBase: THREE.Mesh | undefined;
let actionMachine: THREE.Group | undefined;
// Create background scene
const bgScene = new THREE.Scene();
const bgCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const groupMap = new Map<string, THREE.Group>();
let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry;
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid",
);
// Managed by controls
const [isDragging, setIsDragging] = createSignal(false);
const [cancelMove, setCancelMove] = createSignal<NodeJS.Timeout>();
const [cursorPosition, setCursorPosition] = createSignal<[number, number]>();
const [cameraInfo, setCameraInfo] = createSignal({
position: { x: 0, y: 0, z: 0 },
spherical: { radius: 0, theta: 0, phi: 0 },
});
// Context menu state
const [contextOpen, setContextOpen] = createSignal(false);
const [menuPos, setMenuPos] = createSignal<{ x: number; y: number }>();
const [menuIntersection, setMenuIntersection] = createSignal<string[]>([]);
// Grid configuration
const GRID_SIZE = 1;
const BASE_SIZE = 0.9; // Height of the cube above the ground
const CUBE_SIZE = BASE_SIZE / 1.5; //
const BASE_HEIGHT = 0.05; // Height of the cube above the ground
const CUBE_Y = 0 + CUBE_SIZE / 2 + BASE_HEIGHT / 2; // Y position of the cube above the ground
const CUBE_SEGMENT_HEIGHT = CUBE_SIZE / 1;
const FLOOR_COLOR = 0xcdd8d9;
const BASE_COLOR = 0xecfdff;
const BASE_EMISSIVE = 0x0c0c0c;
const ACTION_BASE_COLOR = 0x636363;
const CREATE_BASE_EMISSIVE = 0xc5fad7;
const MOVE_BASE_EMISSIVE = 0xb2d7ff;
function createCubeBase(
cube_pos: [number, number, number],
opacity = 1,
color: THREE.ColorRepresentation = BASE_COLOR,
emissive: THREE.ColorRepresentation = BASE_EMISSIVE,
) {
const baseMaterial = new THREE.MeshPhongMaterial({
opacity,
color,
emissive,
// flatShading: true,
transparent: true,
});
const base = new THREE.Mesh(sharedBaseGeometry, baseMaterial);
base.position.set(cube_pos[0], BASE_HEIGHT / 2, cube_pos[2]);
base.receiveShadow = true;
return base;
}
const initialCameraPosition = { x: 20, y: 20, z: 20 };
const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3(
new THREE.Vector3(
initialCameraPosition.x,
initialCameraPosition.y,
initialCameraPosition.z,
),
);
const grid = new THREE.GridHelper(1000, 1000 / 1, 0xe1edef, 0xe1edef);
onMount(() => {
// Scene setup
scene = new THREE.Scene();
// Transparent background
scene.background = null;
// Create a fullscreen quad with a gradient shader
// TODO: Recalculate gradient depending on container size
const uniforms = {
colorTop: { value: new THREE.Color("#edf1f1") }, // Top color
colorBottom: { value: new THREE.Color("#e3e7e7") }, // Bottom color
resolution: {
value: new THREE.Vector2(window.innerWidth, window.innerHeight),
},
};
const bgMaterial = new THREE.ShaderMaterial({
uniforms,
vertexShader: `
void main() {
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 colorTop;
uniform vec3 colorBottom;
uniform vec2 resolution;
void main() {
float y = gl_FragCoord.y / resolution.y;
gl_FragColor = vec4(mix(colorBottom, colorTop, y), 1.0);
}
`,
depthTest: false,
depthWrite: false,
});
// Create fullscreen quad geometry
const bgGeometry = new THREE.PlaneGeometry(2, 2);
const bgMesh = new THREE.Mesh(bgGeometry, bgMaterial);
bgScene.add(bgMesh);
// Camera setup
// /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,
logarithmicDepthBuffer: true,
});
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.autoClear = false;
container.appendChild(renderer.domElement);
// Label renderer
labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(container.clientWidth, container.clientHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none";
labelRenderer.domElement.style.zIndex = "0";
container.appendChild(labelRenderer.domElement);
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
controls.mouseButtons.RIGHT = null;
// controls.rotateSpeed = -0.8;
// controls.enableRotate = false;
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();
renderLoop.requestRender();
});
renderLoop.init(
scene,
camera,
renderer,
labelRenderer,
controls,
bgScene,
bgCamera,
);
// controls.addEventListener("start", (e) => {
// setIsDragging(true);
// });
// controls.addEventListener("end", (e) => {
// setIsDragging(false);
// });
// Lighting
const ambientLight = new THREE.AmbientLight(0xd9f2f7, 0.72);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 3.5);
// scene.add(new THREE.DirectionalLightHelper(directionalLight));
// scene.add(new THREE.CameraHelper(camera));
const lightPos = new THREE.Spherical(
15,
initialSphericalCameraPosition.phi - Math.PI / 8,
initialSphericalCameraPosition.theta - Math.PI / 2,
);
directionalLight.position.setFromSpherical(lightPos);
directionalLight.rotation.set(0, 0, 0);
// initialSphericalCameraPosition
directionalLight.castShadow = true;
// Configure shadow camera for hard, crisp shadows
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.1;
directionalLight.shadow.camera.far = 30;
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.blurSamples = 4; // Fewer samples for harder edges
scene.add(directionalLight);
scene.add(directionalLight.target);
// scene.add(new THREE.CameraHelper(directionalLight.shadow.camera));
// Floor/Ground - Make it invisible but keep it for reference
const floorGeometry = new THREE.PlaneGeometry(1000, 1000);
const floorMaterial = new THREE.MeshBasicMaterial({
color: FLOOR_COLOR,
transparent: true,
opacity: 0.0, // Make completely invisible
});
floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0; // Keep at ground level for reference
scene.add(floor);
// grid.material.opacity = 0.2;
// grid.material.transparent = true;
grid.position.y = 0.001; // Slightly above the floor to avoid z-fighting
// grid.rotation.x = -Math.PI / 2;
grid.position.x = 0.5;
grid.position.z = 0.5;
scene.add(grid);
// Shared geometries for cubes and bases
// This allows us to reuse the same geometry for all cubes and bases
sharedCubeGeometry = new THREE.BoxGeometry(
CUBE_SIZE,
CUBE_SEGMENT_HEIGHT,
CUBE_SIZE,
);
sharedBaseGeometry = new THREE.BoxGeometry(
BASE_SIZE,
BASE_HEIGHT,
BASE_SIZE,
);
// Important create CubeBase depends on sharedBaseGeometry
actionBase = createCubeBase(
[1, BASE_HEIGHT / 2, 1],
1,
ACTION_BASE_COLOR,
CREATE_BASE_EMISSIVE,
);
actionBase.visible = false;
scene.add(actionBase);
function createActionMachine() {
const { baseMesh, cubeMesh, material, baseMaterial } =
createMachineMesh();
const group = new THREE.Group();
group.add(baseMesh);
group.add(cubeMesh);
// group.scale.set(0.75, 0.75, 0.75);
material.opacity = 0.6;
baseMaterial.opacity = 0.3;
baseMaterial.emissive.set(MOVE_BASE_EMISSIVE);
// Hide until needed
group.visible = false;
return group;
}
actionMachine = createActionMachine();
scene.add(actionMachine);
// const spherical = new THREE.Spherical();
// spherical.setFromVector3(camera.position);
// Function to update camera info
const updateCameraInfo = () => {
const spherical = new THREE.Spherical();
spherical.setFromVector3(camera.position);
setCameraInfo({
position: {
x: Math.round(camera.position.x * 100) / 100,
y: Math.round(camera.position.y * 100) / 100,
z: Math.round(camera.position.z * 100) / 100,
},
spherical: {
radius: Math.round(spherical.radius * 100) / 100,
theta: Math.round(spherical.theta * 100) / 100,
phi: Math.round(spherical.phi * 100) / 100,
},
});
};
// Initial camera info update
updateCameraInfo();
createEffect(
on(worldMode, (mode) => {
if (mode === "create") {
actionBase!.visible = true;
} else {
actionBase!.visible = false;
}
renderLoop.requestRender();
}),
);
const registry = new ObjectRegistry();
const machineManager = new MachineManager(
scene,
registry,
props.sceneStore,
props.cubesQuery,
props.selectedIds,
props.setMachinePos,
camera,
);
// Click handler:
// - Select/deselects a cube in mode
// - Creates a new cube in "create" mode
const onClick = (event: MouseEvent) => {
if (worldMode() === "create") {
props
.onCreate()
.then(({ id }) => {
//Successfully created machine
const pos = cursorPosition();
if (!pos) {
console.warn("No position set for new cube");
return;
}
props.setMachinePos(id, pos);
})
.catch((error) => {
console.error("Error creating cube:", error);
})
.finally(() => {
if (actionBase) actionBase.visible = false;
setWorldMode("default");
});
}
if (worldMode() === "move") {
console.log("sanpped");
const currId = menuIntersection().at(0);
const pos = cursorPosition();
if (!currId || !pos) return;
props.setMachinePos(currId, pos);
setWorldMode("select");
clearHighlight("move");
}
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(
Array.from(machineManager.machines.values().map((m) => m.group)),
);
console.log("Intersects:", intersects);
if (intersects.length > 0) {
console.log("Clicked on cube:", intersects);
const id = intersects[0].object.userData.id;
if (worldMode() === "select") props.onSelect(new Set<string>([id]));
emitMachineClick(id); // notify subscribers
} else {
emitMachineClick(null);
if (worldMode() === "select") props.onSelect(new Set<string>());
}
};
renderer.domElement.addEventListener("click", onClick);
renderLoop.requestRender();
// Handle window resize
const handleResize = () => {
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);
labelRenderer.setSize(container.clientWidth, container.clientHeight);
// Update background shader resolution
uniforms.resolution.value.set(
container.clientWidth,
container.clientHeight,
);
// renderer.render(bgScene, bgCamera);
renderLoop.requestRender();
};
const handleMouseDown = (e: MouseEvent) => {
const intersection = intersectMachines(
e,
renderer,
camera,
machineManager,
raycaster,
);
if (e.button === 0) {
// Left button
if (worldMode() === "select" && intersection.length) {
// Disable controls to avoid conflict
controls.enabled = false;
// Change cursor to grabbing
const cancelMove = setTimeout(() => {
setIsDragging(true);
// Set machine as flying
setHighlightGroups({ move: new Set(intersection) });
setWorldMode("move");
renderLoop.requestRender();
}, 500);
setCancelMove(cancelMove);
}
}
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
if (!intersection.length) return;
setMenuIntersection(intersection);
setMenuPos({ x: e.clientX, y: e.clientY });
setContextOpen(true);
}
};
const handleMouseUp = (e: MouseEvent) => {
if (e.button === 0) {
console.log("Left mouse up");
setIsDragging(false);
if (cancelMove()) {
clearTimeout(cancelMove()!);
setCancelMove(undefined);
}
if (worldMode() === "move") {
// Cancel long-press if it wasn't triggered yet
// Re-enable controls
controls.enabled = true;
// Set machine as not flying
props.setMachinePos(
highlightGroups["move"].values().next().value!,
cursorPosition() || null,
);
clearHighlight("move");
setWorldMode("select");
renderLoop.requestRender();
}
}
};
renderer.domElement.addEventListener("mousedown", handleMouseDown);
renderer.domElement.addEventListener("mouseup", handleMouseUp);
renderer.domElement.addEventListener("mousemove", onMouseMove);
window.addEventListener("resize", handleResize);
// Initial render
renderLoop.requestRender();
// Cleanup function
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();
machineManager.dispose(scene);
renderer.domElement.removeEventListener("click", onClick);
renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize);
if (actionBase) {
actionBase.geometry.dispose();
if (Array.isArray(actionBase.material)) {
actionBase.material.forEach((material) => material.dispose());
} else {
actionBase.material.dispose();
}
}
if (container) {
container.innerHTML = "";
}
});
});
const onAddClick = (event: MouseEvent) => {
setPositionMode("grid");
setWorldMode("create");
renderLoop.requestRender();
};
const onMouseMove = (event: MouseEvent) => {
if (!(worldMode() === "create" || worldMode() === "move")) return;
console.log("Mouse move in create/move mode");
const actionRepr = worldMode() === "create" ? actionBase : actionMachine;
if (!actionRepr) return;
actionRepr.visible = true;
// (actionRepr.material as THREE.MeshPhongMaterial).emissive.set(
// worldMode() === "create" ? CREATE_BASE_EMISSIVE : MOVE_BASE_EMISSIVE,
// );
// Calculate mouse position in normalized device coordinates
// (-1 to +1) for both components
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(floor);
if (intersects.length > 0) {
const point = intersects[0].point;
// Snap to grid
const snapped = new THREE.Vector3(
Math.round(point.x / GRID_SIZE) * GRID_SIZE,
0,
Math.round(point.z / GRID_SIZE) * GRID_SIZE,
);
// Skip snapping if there's already a cube at this position
if (props.sceneStore()) {
const positions = Object.values(props.sceneStore());
const intersects = positions.some(
(p) => p.position[0] === snapped.x && p.position[1] === snapped.z,
);
if (intersects) {
return;
}
}
if (
Math.abs(actionRepr.position.x - snapped.x) > 0.01 ||
Math.abs(actionRepr.position.z - snapped.z) > 0.01
) {
// Only request render if the position actually changed
actionRepr.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
renderLoop.requestRender();
}
}
};
const handleMenuSelect = (mode: "move") => {
setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) });
console.log("Menu selected, new World mode", worldMode());
};
const machinesQuery = useMachinesQuery(props.clanURI);
return (
<>
<Show when={contextOpen()}>
<Portal mount={document.body}>
<Menu
onSelect={handleMenuSelect}
intersect={menuIntersection()}
x={menuPos()!.x - 10}
y={menuPos()!.y - 10}
close={() => setContextOpen(false)}
/>
</Portal>
</Show>
<div
class={cx(
"cubes-scene-container",
worldMode() === "default" && "cursor-no-drop",
worldMode() === "select" && "cursor-pointer",
worldMode() === "service" && "cursor-pointer",
worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing",
)}
ref={(el) => (container = el)}
/>
<div class="toolbar-container">
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
{props.toolbarPopup}
</div>
<Toolbar>
<ToolbarButton
description="Select machine"
name="Select"
icon="Cursor"
onClick={() => setWorldMode("select")}
selected={worldMode() === "select"}
/>
<ToolbarButton
description="Create new machine"
name="new-machine"
icon="NewMachine"
onClick={onAddClick}
selected={worldMode() === "create"}
/>
<Divider orientation="vertical" />
<ToolbarButton
description="Add new Service"
name="modules"
icon="Services"
selected={worldMode() === "service"}
onClick={() => {
setWorldMode("service");
}}
/>
<ToolbarButton
icon="Update"
name="Reload"
description="Reload machines"
onClick={() => machinesQuery.refetch()}
/>
</Toolbar>
</div>
</>
);
}