From 579885a6e243f6b8cd4772bfab3173bd8bf8f02f Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 15 Jul 2025 17:04:10 +0200 Subject: [PATCH 1/5] cubes: scene extend --- pkgs/clan-app/ui/src/scene/cubes.tsx | 519 ++++++++++++++++++++------- 1 file changed, 395 insertions(+), 124 deletions(-) diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 35af31b7a..04e7ed24b 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -5,25 +5,27 @@ import { onCleanup, onMount, createMemo, + on, } from "solid-js"; +import { render } from "solid-js/web"; import * as THREE from "three"; -// Cube Data Model -interface CubeData { - id: string; - position: [number, number, number]; - color: string; -} export function CubeScene() { let container: HTMLDivElement; let scene: THREE.Scene; let camera: THREE.PerspectiveCamera; let renderer: THREE.WebGLRenderer; + + // Create background scene + const bgScene = new THREE.Scene(); + const bgCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + let raycaster: THREE.Raycaster; const meshMap = new Map(); const baseMap = new Map(); // Map for cube bases + const positionMap = new Map(); let sharedCubeGeometry: THREE.BoxGeometry; let sharedBaseGeometry: THREE.BoxGeometry; @@ -36,6 +38,18 @@ export function CubeScene() { let frameCount = 0; const [ids, setIds] = createSignal([]); + const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( + "circle", + ); + const [nextPosition, setNextPosition] = createSignal(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 [selectedIds, setSelectedIds] = createSignal>(new Set()); const [deletingIds, setDeletingIds] = createSignal>(new Set()); const [creatingIds, setCreatingIds] = createSignal>(new Set()); @@ -53,65 +67,51 @@ export function CubeScene() { const GRID_SIZE = 2; const CUBE_SPACING = 2; + const BASE_SIZE = 1; // Height of the cube above the ground + const CUBE_SIZE = BASE_SIZE / 1.2; // Height of the cube above the ground + 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 FLOOR_COLOR = 0xCDD8D9; + + const BASE_COLOR = 0x9cbcff; + const BASE_EMISSIVE = 0x0c0c0c; + + // Calculate grid position for a cube index with floating effect - // function getGridPosition(index: number): [number, number, number] { - // const x = - // (index % GRID_SIZE) * CUBE_SPACING - (GRID_SIZE * CUBE_SPACING) / 2; - // const z = - // Math.floor(index / GRID_SIZE) * CUBE_SPACING - - // (GRID_SIZE * CUBE_SPACING) / 2; - // return [x, 0.5, z]; - // } - // function getGridPosition(index: number): [number, number, number] { - // if (index === 0) return [0, 0.5, 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 = nextPosition(); + if (!nextPos) { + // Use next position if available + throw new Error("Next position is not set"); + } - // let x = 0, z = 0; - // let layer = 1; - // let value = 1; - - // while (true) { - // // right - // for (let i = 0; i < layer; i++) { - // x += 1; - // if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING]; - // } - // // down - // for (let i = 0; i < layer; i++) { - // z += 1; - // if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING]; - // } - // layer++; - // // left - // for (let i = 0; i < layer; i++) { - // x -= 1; - // if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING]; - // } - // // up - // for (let i = 0; i < layer; i++) { - // z -= 1; - // if (value++ === index) return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING]; - // } - // layer++; - - // if (layer > 100) { - // console.warn("Exceeded grid size, returning last position"); - // // If we exceed the index, return the last position - // return [x * CUBE_SPACING, 0.5, z * CUBE_SPACING]; - // } - // } - // } + const next = nextPos.toArray() as [number, number, number]; + positionMap.set(id, new THREE.Vector3(...next)); + return next; + } // Circle IDEA: // Need to talk with timo and W about this function getCirclePosition( + _id: string, index: number, total: number, ): [number, number, number] { - const r = Math.sqrt(total) * CUBE_SPACING; // Radius based on total cubes + 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; const z = Math.sin((index / total) * 2 * Math.PI) * r; // Position cubes at y = 0.5 to float above the ground - return [x, 0.5, z]; + return [x, CUBE_Y, z]; } // Reactive cubes memo - this recalculates whenever ids() changes @@ -123,25 +123,32 @@ export function CubeScene() { // Include both active and deleting cubes for smooth transitions const allIds = [...new Set([...currentIds, ...Array.from(deleting)])]; + const getPosition = + positionMode() === "grid" ? getGridPosition : getCirclePosition; + + console.log("creating", creating); return allIds.map((id, index) => { const isDeleting = deleting.has(id); const isCreating = creating.has(id); const activeIndex = currentIds.indexOf(id); + + const position = getPosition( + id, + isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index, + currentIds.length); + + const targetPosition = + activeIndex >= 0 + ? getPosition(id, activeIndex, currentIds.length) + : getPosition(id, index, currentIds.length); + return { id, - position: getCirclePosition( - isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index, - currentIds.length, - ), - // position: getGridPosition(isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index), + position, isDeleting, isCreating, - // targetPosition: activeIndex >= 0 ? getGridPosition(activeIndex) : getGridPosition(index), - targetPosition: - activeIndex >= 0 - ? getCirclePosition(activeIndex, currentIds.length) - : getCirclePosition(index, currentIds.length), + targetPosition, }; }); }); @@ -149,26 +156,24 @@ export function CubeScene() { // Create multi-colored cube materials for different faces function createCubeMaterials() { const materials = [ - new THREE.MeshBasicMaterial({ color: 0xb0c0c2 }), // Right face - medium - new THREE.MeshBasicMaterial({ color: 0x4d6a6b }), // Left face - dark shadow + new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Right face - medium + new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Left face - dark shadow new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Top face - light - new THREE.MeshBasicMaterial({ color: 0x4d6a6b }), // Bottom face - dark shadow - new THREE.MeshBasicMaterial({ color: 0xb0c0c2 }), // Front face - medium - new THREE.MeshBasicMaterial({ color: 0x4d6a6b }), // Back face - dark shadow + new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Bottom face - dark shadow + new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Front face - medium + new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Back face - dark shadow ]; return materials; } - function createBaseMaterials() { - const materials = [ - new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Right face - medium - new THREE.MeshBasicMaterial({ color: 0xa4b3b5 }), // Left face - dark shadow - new THREE.MeshLambertMaterial({ color: 0xffffff, emissive: 0x303030 }), // Top face - light - new THREE.MeshBasicMaterial({ color: 0xa4b3b5 }), // Bottom face - dark shadow - new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Front face - medium - new THREE.MeshBasicMaterial({ color: 0xa4b3b5 }), // Back face - dark shadow - ]; - return materials; + function createBaseMaterials(opacity: number) { + return new THREE.MeshLambertMaterial({ + color: BASE_COLOR, + emissive: BASE_EMISSIVE, + flatShading: true, + transparent: true, + opacity, + }); } // Animation helper function @@ -319,11 +324,42 @@ export function CubeScene() { animate(); } - function createCubeBase(cube_pos: [number, number, number]) { - const baseMaterials = createBaseMaterials(); - const base = new THREE.Mesh(sharedBaseGeometry, baseMaterials); + // Helper for camera transition animation + function animateCameraToPosition( + targetPosition: THREE.Vector3, + target: THREE.Vector3, + onComplete: () => void, + ) { + const startPosition = camera.position.clone(); + const startTime = Date.now(); + + function animate() { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / ANIMATION_DURATION, 1); + + // Smooth easing function + const easeProgress = 1 - Math.pow(1 - progress, 3); + camera.position.lerpVectors(startPosition, targetPosition, easeProgress); + camera.lookAt(target); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + onComplete(); + } + } + + animate(); + } + + function createCubeBase( + cube_pos: [number, number, number], + opacity: number = 1, + ) { + const baseMaterial = createBaseMaterials(opacity); + const base = new THREE.Mesh(sharedBaseGeometry, baseMaterial); // tranlate_y = - cube_height / 2 - base_height / 2 - base.position.set(cube_pos[0], cube_pos[1] - 0.5 - 0.025, cube_pos[2]); // Position below cube + base.position.set(cube_pos[0], BASE_HEIGHT / 2, cube_pos[2]); // Position Y=0 on the floor base.receiveShadow = true; return base; } @@ -397,22 +433,17 @@ export function CubeScene() { function updateMeshColors() { for (const [id, base] of baseMap.entries()) { const selected = selectedIds().has(id); - const materials = base.material as THREE.Material[]; + const material = base.material as THREE.MeshLambertMaterial; if (selected) { // When selected, make all faces red-ish but maintain the lighting difference - materials.forEach((material, index) => { - if (index === 2) { - (material as THREE.MeshBasicMaterial).color.set(0xff6666); - } - }); + material.color.set(0xffffff); + material.emissive.set(0xff6666); } else { // Normal colors - restore original face colors - materials.forEach((material, index) => { - if (index === 2) { - (material as THREE.MeshBasicMaterial).color.set(0xffffff); - } - }); + material.color.set(BASE_COLOR); + material.emissive.set(BASE_EMISSIVE); + } } } @@ -429,12 +460,61 @@ export function CubeScene() { } } + const initialCameraPosition = { x: 2.8, y: 3.6, z: -2 }; + const initialSphericalCameraPosition = new THREE.Spherical(); + initialSphericalCameraPosition.setFromVector3( + new THREE.Vector3( + initialCameraPosition.x, + initialCameraPosition.y, + initialCameraPosition.z, + ), + ); + + let initBase: THREE.Mesh; + + const grid = new THREE.GridHelper(1000, 1000 / 1, 0xE1EDEF, 0xE1EDEF); + onMount(() => { // Scene setup scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0xffffff, 10, 50); // // 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('##E6EAEA') }, // Top color + colorBottom: { value: new THREE.Color('#C5D1D2') }, // 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 camera = new THREE.PerspectiveCamera( 75, @@ -442,7 +522,7 @@ export function CubeScene() { 0.1, 1000, ); - camera.position.set(11, 8, -11); + camera.position.setFromSpherical(initialSphericalCameraPosition); camera.lookAt(0, 0, 0); // Renderer setup @@ -453,14 +533,20 @@ export function CubeScene() { container.appendChild(renderer.domElement); // Lighting - const ambientLight = new THREE.AmbientLight(0xffffff, 1.0); // Bright + const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Bright scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); - // Position light at 30 degree angle (30 degrees from vertical) - // For 30 degree angle: tan(30°) = opposite/adjacent = x/y - // If y = 100, then x = 100 * tan(30°) = 100 * 0.577 = 57.7 - directionalLight.position.set(57.7, 100, 57.7); + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); + + const lightPos = new THREE.Spherical( + 100, + initialSphericalCameraPosition.phi, + initialSphericalCameraPosition.theta, + ); + lightPos.theta = initialSphericalCameraPosition.theta - Math.PI / 2; // 90 degrees offset + directionalLight.position.setFromSpherical(lightPos); + + // initialSphericalCameraPosition directionalLight.castShadow = true; // Configure shadow camera for hard, crisp shadows @@ -477,31 +563,41 @@ export function CubeScene() { scene.add(directionalLight); // Floor/Ground - Make it invisible but keep it for reference - const floorGeometry = new THREE.PlaneGeometry(50, 50); + const floorGeometry = new THREE.PlaneGeometry(1000, 1000); const floorMaterial = new THREE.MeshBasicMaterial({ - color: 0xcccccc, + color: FLOOR_COLOR, transparent: true, - opacity: 0, // Make completely invisible - visible: false, // Also hide it completely + opacity: 0.1, // Make completely invisible + // visible: false, // Also hide it completely }); const 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(1, 1, 1); - sharedBaseGeometry = new THREE.BoxGeometry(1.2, 0.05, 1.2); + sharedCubeGeometry = new THREE.BoxGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE); + sharedBaseGeometry = new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE); // Basic OrbitControls implementation (simplified) let isDragging = false; let previousMousePosition = { x: 0, y: 0 }; - const spherical = new THREE.Spherical(); - spherical.setFromVector3(camera.position); + // 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, @@ -529,28 +625,96 @@ export function CubeScene() { }; const onMouseMove = (event: MouseEvent) => { + if (worldMode() === "create") { + if (isDragging) return; + + 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, + CUBE_Y, + Math.round(point.z / GRID_SIZE) * GRID_SIZE, + ); + if(!initBase) { + // Create initial base mesh if it doesn't exist + initBase = createCubeBase([snapped.x, 0.0, snapped.z], 0.5); + } else { + initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z); + } + scene.remove(initBase); // Remove any existing base mesh + scene.add(initBase); + setNextPosition(snapped); // Update next position for cube creation + } + // If in create mode, don't allow camera movement + return; + } + if (!isDragging) return; const deltaX = event.clientX - previousMousePosition.x; const deltaY = event.clientY - previousMousePosition.y; + // const deltaY = event.clientY - previousMousePosition.y; + if(positionMode() === "circle") { + const spherical = new THREE.Spherical(); + spherical.setFromVector3(camera.position); + spherical.theta -= deltaX * 0.01; + // spherical.phi += deltaY * 0.01; + // spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); - spherical.theta -= deltaX * 0.01; - spherical.phi += deltaY * 0.01; - spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); + const lightPos = new THREE.Spherical(); + lightPos.setFromVector3(directionalLight.position); + lightPos.theta = spherical.theta - Math.PI / 2; // 90 degrees offset + directionalLight.position.setFromSpherical(lightPos); - camera.position.setFromSpherical(spherical); - camera.lookAt(0, 0, 0); + directionalLight.lookAt(0, 0, 0); + + camera.position.setFromSpherical(spherical); + camera.lookAt(0, 0, 0); + } + else { + const movementSpeed = 0.015; + + // Get camera direction vectors + const cameraDirection = new THREE.Vector3(); + camera.getWorldDirection(cameraDirection); + cameraDirection.y = 0; // Ignore vertical direction + + const cameraRight = new THREE.Vector3(); + cameraRight.crossVectors(camera.up, cameraDirection).normalize(); // Get right vector + + // Move camera based on mouse deltas + camera.position.addScaledVector(cameraRight, deltaX * movementSpeed); // horizontal drag + camera.position.addScaledVector(cameraDirection, deltaY * movementSpeed); // vertical drag (forward/back) + + setBackedCameraPosition({ + pos: camera.position.clone(), + dir: camera.getWorldDirection(new THREE.Vector3()).clone(), + }); + } updateCameraInfo(); previousMousePosition = { x: event.clientX, y: event.clientY }; }; const onWheel = (event: WheelEvent) => { + const spherical = new THREE.Spherical(); + spherical.setFromVector3(camera.position); event.preventDefault(); spherical.radius += event.deltaY * 0.01; - spherical.radius = Math.max(5, Math.min(50, spherical.radius)); + spherical.radius = Math.max(3, Math.min(10, spherical.radius)); // Clamp radius between 5 and 50 camera.position.setFromSpherical(spherical); - camera.lookAt(0, 0, 0); + // camera.lookAt(0, 0, 0); updateCameraInfo(); }; @@ -563,9 +727,19 @@ export function CubeScene() { // Raycaster for clicking raycaster = new THREE.Raycaster(); - // Click handler for cube selection + // Click handler: + // - Select/deselects a cube in "view" mode + // - Creates a new cube in "create" mode const onClick = (event: MouseEvent) => { - if (isDragging) return; // Don't select if we were dragging + if(worldMode() === "create") { + if (initBase) { + scene.remove(initBase); // Remove the base mesh after adding cube + setWorldMode("view"); + addCube(); + } + return; + } + const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2( @@ -581,9 +755,13 @@ export function CubeScene() { if (intersects.length > 0) { const id = intersects[0].object.userData.id; toggleSelection(id); + } else { + setSelectedIds(new Set()); // Clear selection if clicked outside cubes } }; + const currentCubes = cubes(); + renderer.domElement.addEventListener("click", onClick); const animate = () => { @@ -592,10 +770,13 @@ export function CubeScene() { requestAnimationFrame(animate); frameCount++; + renderer.autoClear = false; + renderer.render(bgScene, bgCamera); // Render background scene + renderer.render(scene, camera); // Uncomment for memory debugging: - if (frameCount % 60 === 0) logMemoryUsage(); // Log every 60 frames + if (frameCount % 300 === 0) logMemoryUsage(); // Log every 60 frames }; isAnimating = true; animate(); @@ -619,12 +800,60 @@ export function CubeScene() { renderer.domElement.removeEventListener("click", onClick); window.removeEventListener("resize", handleResize); + if(initBase){ + initBase.geometry.dispose(); + // @ts-ignore: Not sure why this is needed + initBase.material.dispose(); + } + if (container) { container.innerHTML = ""; } }); }); + 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 + animateCameraToPosition( + new THREE.Vector3( + initialCameraPosition.x, + initialCameraPosition.y, + initialCameraPosition.z + ), + new THREE.Vector3(0, 0, 0), + () => { + console.log("Camera animation to circle position complete"); + } + ); + } else if (mode === "grid") { + grid.visible = true; // Show grid when in grid mode + const backup = backedCameraPosition(); // This won't be tracked reactively now + if (backup) { + const target = backup.pos.clone().add(backup.dir); + animateCameraToPosition(backup.pos, target, () => { + console.log("Camera animation to grid position complete"); + }); + } + } + }) + ); + + // Effect to manage cube meshes - this runs whenever cubes() changes createEffect(() => { const currentCubes = cubes(); @@ -632,6 +861,8 @@ export function CubeScene() { const deleting = deletingIds(); const creating = creatingIds(); + console.log("Current cubes:", currentCubes); + // Update existing cubes and create new ones currentCubes.forEach((cube) => { const existingMesh = meshMap.get(cube.id); @@ -649,7 +880,7 @@ export function CubeScene() { meshMap.set(cube.id, mesh); // Create new base mesh - const base = createCubeBase(cube.position); + const base = createCubeBase(cube.position, 1); base.userData.id = cube.id; scene.add(base); baseMap.set(cube.id, base); @@ -681,7 +912,7 @@ export function CubeScene() { if (existingBase) { animateToPosition(existingBase, [ target[0], - target[1] - 0.5 - 0.025, + target[1] - 0.5 - 0.025, // TODO: Refactor this DRY target[2], ]); } @@ -798,16 +1029,57 @@ export function CubeScene() { renderer?.dispose(); }); + const onHover = (inside: boolean) => (event: MouseEvent) => { + // Hover over the plus button, shows a preview of the base mesh + const currentCubes = cubes(); + if (currentCubes.length > 0) { + return; + } + + if (!initBase) { + // Create initial base mesh if it doesn't exist + initBase = createCubeBase([0, 0.5, 0], 0.5); + } + if (inside) { + scene.add(initBase); + } else { + scene.remove(initBase); + } + }; + + const onAddClick = (event: MouseEvent) => { + setPositionMode("grid"); + setWorldMode("create"); + }; + return (
- + + + Selected: {selectedIds().size} cubes | Total: {ids().length} cubes + + {" | "} + World Mode: {worldMode()} + {" | "} + Position Mode: {positionMode()} +
{/* Camera Information Display */} @@ -839,9 +1111,8 @@ export function CubeScene() { ref={(el) => (container = el)} style={{ width: "100%", - height: "1000px", - border: "1px solid #ccc", - cursor: "grab", + height: "90vh", + cursor: "pointer", }} />
From 8c7e93c92e73d2bdc50abc7f31efda19bb1e3d71 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 16 Jul 2025 11:03:40 +0200 Subject: [PATCH 2/5] UI/cubes: group logic to add more meshed --- pkgs/clan-app/ui/src/scene/cubes.tsx | 524 ++++++++++++--------------- 1 file changed, 239 insertions(+), 285 deletions(-) diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 04e7ed24b..fe14f9a19 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -1,4 +1,3 @@ -// Working SolidJS + Three.js cube scene with reactive positioning import { createSignal, createEffect, @@ -7,15 +6,53 @@ import { createMemo, on, } from "solid-js"; -import { render } from "solid-js/web"; import * as THREE from "three"; +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 +} + +function getFloorPosition( + camera: THREE.PerspectiveCamera, + 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]; +} export function CubeScene() { let container: HTMLDivElement; let scene: THREE.Scene; let camera: THREE.PerspectiveCamera; let renderer: THREE.WebGLRenderer; + let floor: THREE.Mesh; // Create background scene const bgScene = new THREE.Scene(); @@ -23,8 +60,7 @@ export function CubeScene() { let raycaster: THREE.Raycaster; - const meshMap = new Map(); - const baseMap = new Map(); // Map for cube bases + const groupMap = new Map(); const positionMap = new Map(); let sharedCubeGeometry: THREE.BoxGeometry; @@ -41,14 +77,15 @@ export function CubeScene() { const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( "circle", ); - const [nextPosition, setNextPosition] = createSignal(null); + const [nextBasePosition, setNextPosition] = + createSignal(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 [backedCameraPosition, setBackedCameraPosition] = createSignal<{ + pos: THREE.Vector3; + dir: THREE.Vector3; + }>(); const [selectedIds, setSelectedIds] = createSignal>(new Set()); const [deletingIds, setDeletingIds] = createSignal>(new Set()); @@ -72,13 +109,11 @@ export function CubeScene() { 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 FLOOR_COLOR = 0xCDD8D9; + const FLOOR_COLOR = 0xcdd8d9; const BASE_COLOR = 0x9cbcff; const BASE_EMISSIVE = 0x0c0c0c; - - // Calculate grid position for a cube index with floating effect function getGridPosition( id: string, index: number, @@ -89,7 +124,7 @@ export function CubeScene() { if (pos) { return pos.toArray() as [number, number, number]; } - const nextPos = nextPosition(); + const nextPos = nextBasePosition(); if (!nextPos) { // Use next position if available throw new Error("Next position is not set"); @@ -102,17 +137,15 @@ export function CubeScene() { // Circle IDEA: // Need to talk with timo and W about this - function getCirclePosition( - _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; - const z = Math.sin((index / total) * 2 * Math.PI) * r; - // Position cubes at y = 0.5 to float above the ground - return [x, CUBE_Y, z]; - } + 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 ids() changes const cubes = createMemo(() => { @@ -123,25 +156,30 @@ export function CubeScene() { // Include both active and deleting cubes for smooth transitions const allIds = [...new Set([...currentIds, ...Array.from(deleting)])]; - const getPosition = - positionMode() === "grid" ? getGridPosition : getCirclePosition; + let cameraTarget = [0, 0, 0] as [number, number, number]; + if (camera && floor) { + cameraTarget = getFloorPosition(camera, floor); + } + const getCubePosition = + positionMode() === "grid" + ? getGridPosition + : getCirclePosition(cameraTarget); - console.log("creating", creating); return allIds.map((id, index) => { const isDeleting = deleting.has(id); const isCreating = creating.has(id); const activeIndex = currentIds.indexOf(id); + const position = getCubePosition( + id, + isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index, + currentIds.length, + ); - const position = getPosition( - id, - isDeleting ? -1 : activeIndex >= 0 ? activeIndex : index, - currentIds.length); - - const targetPosition = - activeIndex >= 0 - ? getPosition(id, activeIndex, currentIds.length) - : getPosition(id, index, currentIds.length); + const targetPosition = + activeIndex >= 0 + ? getCubePosition(id, activeIndex, currentIds.length) + : getCubePosition(id, index, currentIds.length); return { id, @@ -178,11 +216,11 @@ export function CubeScene() { // Animation helper function function animateToPosition( - mesh: THREE.Mesh, + thing: THREE.Object3D, targetPosition: [number, number, number], duration: number = ANIMATION_DURATION, ) { - const startPosition = mesh.position.clone(); + const startPosition = thing.position.clone(); const endPosition = new THREE.Vector3(...targetPosition); const startTime = Date.now(); @@ -193,7 +231,7 @@ export function CubeScene() { // Smooth easing function const easeProgress = 1 - Math.pow(1 - progress, 3); - mesh.position.lerpVectors(startPosition, endPosition, easeProgress); + thing.position.lerpVectors(startPosition, endPosition, easeProgress); if (progress < 1) { requestAnimationFrame(animate); @@ -270,16 +308,10 @@ export function CubeScene() { } // Delete animation helper - function animateDelete( - mesh: THREE.Mesh, - baseMesh: THREE.Mesh, - onComplete: () => void, - ) { + function animateDelete(group: THREE.Group, onComplete: () => void) { const startTime = Date.now(); - const startScale = mesh.scale.clone(); - const startOpacity = Array.isArray(mesh.material) - ? (mesh.material[0] as THREE.MeshBasicMaterial).opacity - : (mesh.material as THREE.MeshBasicMaterial).opacity; + // const startScale = group.scale.clone(); + // const startOpacity = 1; function animate() { const elapsed = Date.now() - startTime; @@ -288,31 +320,9 @@ export function CubeScene() { // Smooth easing function const easeProgress = 1 - Math.pow(1 - progress, 3); const scale = 1 - easeProgress; - const opacity = startOpacity * (1 - easeProgress); + // const opacity = startOpacity * (1 - easeProgress); - mesh.scale.setScalar(scale); - baseMesh.scale.setScalar(scale); - - // Update opacity for all materials - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => { - (material as THREE.MeshBasicMaterial).opacity = opacity; - material.transparent = true; - }); - } else { - (mesh.material as THREE.MeshBasicMaterial).opacity = opacity; - mesh.material.transparent = true; - } - - if (Array.isArray(baseMesh.material)) { - baseMesh.material.forEach((material) => { - (material as THREE.MeshBasicMaterial).opacity = opacity; - material.transparent = true; - }); - } else { - (baseMesh.material as THREE.MeshBasicMaterial).opacity = opacity; - baseMesh.material.transparent = true; - } + group.scale.setScalar(scale); if (progress >= 1) { onComplete(); @@ -324,34 +334,6 @@ export function CubeScene() { animate(); } - // Helper for camera transition animation - function animateCameraToPosition( - targetPosition: THREE.Vector3, - target: THREE.Vector3, - onComplete: () => void, - ) { - const startPosition = camera.position.clone(); - const startTime = Date.now(); - - function animate() { - const elapsed = Date.now() - startTime; - const progress = Math.min(elapsed / ANIMATION_DURATION, 1); - - // Smooth easing function - const easeProgress = 1 - Math.pow(1 - progress, 3); - camera.position.lerpVectors(startPosition, targetPosition, easeProgress); - camera.lookAt(target); - - if (progress < 1) { - requestAnimationFrame(animate); - } else { - onComplete(); - } - } - - animate(); - } - function createCubeBase( cube_pos: [number, number, number], opacity: number = 1, @@ -389,33 +371,40 @@ export function CubeScene() { // Add to deleting set to start animation setDeletingIds(selectedSet); + console.log("Deleting cubes:", selectedSet); // Start delete animations selectedSet.forEach((id) => { - const mesh = meshMap.get(id); - const base = baseMap.get(id); + const group = groupMap.get(id); - if (mesh && base) { - animateDelete(mesh, base, () => { + 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; + } + + animateDelete(group, () => { // Remove from deleting set when animation completes setDeletingIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); + setSelectedIds(new Set()); // Clear selection after deletion + garbageCollectGroup(group); // Clean up geometries and materials + setIds((prev) => prev.filter((existingId) => existingId !== id)); + + console.log("Done deleting", id, ids()); }); + } else { + console.warn(`DELETE: Group not found for id: ${id}`); } }); - - // Remove from ids after a short delay to allow animation to start - setTimeout(() => { - setIds((prev) => prev.filter((id) => !selectedSet.has(id))); - setSelectedIds(new Set()); // Clear selection after deletion - }, 50); - } - - function deleteCube(id: string) { - deleteSelectedCubes(new Set([id])); } function toggleSelection(id: string) { @@ -431,8 +420,15 @@ export function CubeScene() { } function updateMeshColors() { - for (const [id, base] of baseMap.entries()) { + for (const [id, group] of groupMap.entries()) { const selected = selectedIds().has(id); + 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 material = base.material as THREE.MeshLambertMaterial; if (selected) { @@ -443,7 +439,6 @@ export function CubeScene() { // Normal colors - restore original face colors material.color.set(BASE_COLOR); material.emissive.set(BASE_EMISSIVE); - } } } @@ -472,7 +467,7 @@ export function CubeScene() { let initBase: THREE.Mesh; - const grid = new THREE.GridHelper(1000, 1000 / 1, 0xE1EDEF, 0xE1EDEF); + const grid = new THREE.GridHelper(1000, 1000 / 1, 0xe1edef, 0xe1edef); onMount(() => { // Scene setup @@ -483,10 +478,12 @@ export function CubeScene() { // Create a fullscreen quad with a gradient shader // TODO: Recalculate gradient depending on container size - const uniforms = { - colorTop: { value: new THREE.Color('##E6EAEA') }, // Top color - colorBottom: { value: new THREE.Color('#C5D1D2') }, // Bottom color - resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) } + const uniforms = { + colorTop: { value: new THREE.Color("#E6EAEA") }, // Top color + colorBottom: { value: new THREE.Color("#C5D1D2") }, // Bottom color + resolution: { + value: new THREE.Vector2(window.innerWidth, window.innerHeight), + }, }; const bgMaterial = new THREE.ShaderMaterial({ @@ -570,7 +567,7 @@ export function CubeScene() { opacity: 0.1, // Make completely invisible // visible: false, // Also hide it completely }); - const floor = new THREE.Mesh(floorGeometry, floorMaterial); + 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); @@ -579,14 +576,18 @@ export function CubeScene() { // 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.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_SIZE, CUBE_SIZE); - sharedBaseGeometry = new THREE.BoxGeometry(BASE_SIZE, BASE_HEIGHT, BASE_SIZE); + sharedBaseGeometry = new THREE.BoxGeometry( + BASE_SIZE, + BASE_HEIGHT, + BASE_SIZE, + ); // Basic OrbitControls implementation (simplified) let isDragging = false; @@ -643,12 +644,15 @@ export function CubeScene() { // Snap to grid const snapped = new THREE.Vector3( Math.round(point.x / GRID_SIZE) * GRID_SIZE, - CUBE_Y, + BASE_HEIGHT / 2, Math.round(point.z / GRID_SIZE) * GRID_SIZE, ); - if(!initBase) { + if (!initBase) { // Create initial base mesh if it doesn't exist - initBase = createCubeBase([snapped.x, 0.0, snapped.z], 0.5); + initBase = createCubeBase( + [snapped.x, BASE_HEIGHT / 2, snapped.z], + 0.5, + ); } else { initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z); } @@ -665,7 +669,7 @@ export function CubeScene() { const deltaX = event.clientX - previousMousePosition.x; const deltaY = event.clientY - previousMousePosition.y; // const deltaY = event.clientY - previousMousePosition.y; - if(positionMode() === "circle") { + if (positionMode() === "circle") { const spherical = new THREE.Spherical(); spherical.setFromVector3(camera.position); spherical.theta -= deltaX * 0.01; @@ -681,8 +685,7 @@ export function CubeScene() { camera.position.setFromSpherical(spherical); camera.lookAt(0, 0, 0); - } - else { + } else { const movementSpeed = 0.015; // Get camera direction vectors @@ -694,8 +697,11 @@ export function CubeScene() { cameraRight.crossVectors(camera.up, cameraDirection).normalize(); // Get right vector // Move camera based on mouse deltas - camera.position.addScaledVector(cameraRight, deltaX * movementSpeed); // horizontal drag - camera.position.addScaledVector(cameraDirection, deltaY * movementSpeed); // vertical drag (forward/back) + camera.position.addScaledVector(cameraRight, deltaX * movementSpeed); // horizontal drag + camera.position.addScaledVector( + cameraDirection, + deltaY * movementSpeed, + ); // vertical drag (forward/back) setBackedCameraPosition({ pos: camera.position.clone(), @@ -731,7 +737,7 @@ export function CubeScene() { // - Select/deselects a cube in "view" mode // - Creates a new cube in "create" mode const onClick = (event: MouseEvent) => { - if(worldMode() === "create") { + if (worldMode() === "create") { if (initBase) { scene.remove(initBase); // Remove the base mesh after adding cube setWorldMode("view"); @@ -740,7 +746,6 @@ export function CubeScene() { return; } - const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, @@ -749,7 +754,7 @@ export function CubeScene() { raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects( - Array.from(meshMap.values()), + Array.from(groupMap.values()), ); if (intersects.length > 0) { @@ -760,8 +765,6 @@ export function CubeScene() { } }; - const currentCubes = cubes(); - renderer.domElement.addEventListener("click", onClick); const animate = () => { @@ -800,7 +803,7 @@ export function CubeScene() { renderer.domElement.removeEventListener("click", onClick); window.removeEventListener("resize", handleResize); - if(initBase){ + if (initBase) { initBase.geometry.dispose(); // @ts-ignore: Not sure why this is needed initBase.material.dispose(); @@ -813,7 +816,6 @@ export function CubeScene() { }); createEffect(() => { - if (!container) return; if (worldMode() === "create") { // Show the plus button when in create mode @@ -829,64 +831,61 @@ export function CubeScene() { on(positionMode, (mode) => { if (mode === "circle") { grid.visible = false; // Hide grid when in circle mode - animateCameraToPosition( - new THREE.Vector3( - initialCameraPosition.x, - initialCameraPosition.y, - initialCameraPosition.z - ), - new THREE.Vector3(0, 0, 0), - () => { - console.log("Camera animation to circle position complete"); - } - ); } else if (mode === "grid") { grid.visible = true; // Show grid when in grid mode - const backup = backedCameraPosition(); // This won't be tracked reactively now - if (backup) { - const target = backup.pos.clone().add(backup.dir); - animateCameraToPosition(backup.pos, target, () => { - console.log("Camera animation to grid position complete"); - }); - } } - }) + }), ); + function createCube( + gridPosition: [number, number], + userData: { id: string; [key: string]: any }, + ) { + // Creates a cube, base, and other visuals + // Groups them together in the scene + const cubeMaterials = createCubeMaterials(); + const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials); + 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 + + // 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(meshMap.keys()); + const existing = new Set(groupMap.keys()); const deleting = deletingIds(); const creating = creatingIds(); - console.log("Current cubes:", currentCubes); - // Update existing cubes and create new ones currentCubes.forEach((cube) => { - const existingMesh = meshMap.get(cube.id); - const existingBase = baseMap.get(cube.id); + const existingGroup = groupMap.get(cube.id); - if (!existingMesh) { - // Create new cube mesh - const cubeMaterials = createCubeMaterials(); - const mesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials); - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.position.set(...cube.position); - mesh.userData.id = cube.id; - scene.add(mesh); - meshMap.set(cube.id, mesh); - - // Create new base mesh - const base = createCubeBase(cube.position, 1); - base.userData.id = cube.id; - scene.add(base); - baseMap.set(cube.id, base); + 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 }); @@ -894,28 +893,19 @@ export function CubeScene() { } else if (!deleting.has(cube.id)) { // Only animate position if not being deleted const targetPosition = cube.targetPosition || cube.position; - const currentPosition = existingMesh.position.toArray() as [ + 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(existingMesh, target); - - if (existingBase) { - animateToPosition(existingBase, [ - target[0], - target[1] - 0.5 - 0.025, // TODO: Refactor this DRY - target[2], - ]); - } + animateToPosition(existingGroup, target); } } @@ -925,32 +915,9 @@ export function CubeScene() { // Remove cubes that are no longer in the state and not being deleted existing.forEach((id) => { if (!deleting.has(id)) { - // Remove cube mesh - const mesh = meshMap.get(id); - if (mesh) { - scene.remove(mesh); - mesh.geometry.dispose(); - // Dispose materials properly - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => material.dispose()); - } else { - mesh.material.dispose(); - } - meshMap.delete(id); - } - - // Remove base mesh - const base = baseMap.get(id); - if (base) { - scene.remove(base); - base.geometry.dispose(); - // Dispose base materials properly - if (Array.isArray(base.material)) { - base.material.forEach((material) => material.dispose()); - } else { - base.material.dispose(); - } - baseMap.delete(id); + const group = groupMap.get(id); + if (group) { + garbageCollectGroup(group); } } }); @@ -966,31 +933,21 @@ export function CubeScene() { // Clean up cubes that finished their delete animation deleting.forEach((id) => { if (!currentIds.includes(id)) { - // Check if this cube has finished its animation - const mesh = meshMap.get(id); - if (mesh && mesh.scale.x <= 0.01) { - // Remove cube mesh - scene.remove(mesh); - mesh.geometry.dispose(); - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => material.dispose()); - } else { - mesh.material.dispose(); - } - meshMap.delete(id); - - // Remove base mesh - const base = baseMap.get(id); - if (base) { - scene.remove(base); - base.geometry.dispose(); - if (Array.isArray(base.material)) { - base.material.forEach((material) => material.dispose()); - } else { - base.material.dispose(); + 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(); + } } - baseMap.delete(id); - } + }); + groupMap.delete(id); } } }); @@ -1002,25 +959,11 @@ export function CubeScene() { }); onCleanup(() => { - for (const mesh of meshMap.values()) { - // Handle both single material and material array - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => material.dispose()); - } else { - mesh.material.dispose(); - } + for (const group of groupMap.values()) { + garbageCollectGroup(group); + scene.remove(group); } - meshMap.clear(); - - for (const mesh of baseMap.values()) { - // Handle both single material and material array - if (Array.isArray(mesh.material)) { - mesh.material.forEach((material) => material.dispose()); - } else { - mesh.material.dispose(); - } - } - baseMap.clear(); + groupMap.clear(); // Dispose shared geometries sharedCubeGeometry?.dispose(); @@ -1038,7 +981,7 @@ export function CubeScene() { if (!initBase) { // Create initial base mesh if it doesn't exist - initBase = createCubeBase([0, 0.5, 0], 0.5); + initBase = createCubeBase([0, BASE_HEIGHT / 2, 0], 0.5); } if (inside) { scene.add(initBase); @@ -1053,24 +996,8 @@ export function CubeScene() { }; return ( -
+
- - - - Selected: {selectedIds().size} cubes | Total: {ids().length} cubes @@ -1082,6 +1009,15 @@ export function CubeScene() {
+
(container = el)} + style={{ + width: "100%", + height: "80vh", + cursor: "pointer", + }} + /> + {/* Camera Information Display */}
+
+ + + + +
Camera Info:
@@ -1106,15 +1069,6 @@ export function CubeScene() { {cameraInfo().spherical.theta}, φ={cameraInfo().spherical.phi}
- -
(container = el)} - style={{ - width: "100%", - height: "90vh", - cursor: "pointer", - }} - />
); } From 752f030d0366039228d27de0afdc500231e79a6c Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 16 Jul 2025 17:00:20 +0200 Subject: [PATCH 3/5] ui/storybook: add all stories --- pkgs/clan-app/ui/.storybook/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/clan-app/ui/.storybook/main.ts b/pkgs/clan-app/ui/.storybook/main.ts index 22123300c..0702d9972 100644 --- a/pkgs/clan-app/ui/.storybook/main.ts +++ b/pkgs/clan-app/ui/.storybook/main.ts @@ -3,7 +3,7 @@ import type { StorybookConfig } from "@kachurun/storybook-solid-vite"; const config: StorybookConfig = { framework: "@kachurun/storybook-solid-vite", - stories: ["../src/components/**/*.mdx", "../src/components/**/*.stories.tsx"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.tsx"], addons: [ "@storybook/addon-links", "@storybook/addon-docs", From 66bdbb09591f220791a16a57534c818893790c02 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 16 Jul 2025 17:01:00 +0200 Subject: [PATCH 4/5] ui/cubes: init story --- pkgs/clan-app/ui/src/scene/cubes.stories.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 pkgs/clan-app/ui/src/scene/cubes.stories.tsx diff --git a/pkgs/clan-app/ui/src/scene/cubes.stories.tsx b/pkgs/clan-app/ui/src/scene/cubes.stories.tsx new file mode 100644 index 000000000..79c53375e --- /dev/null +++ b/pkgs/clan-app/ui/src/scene/cubes.stories.tsx @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from "@kachurun/storybook-solid"; +import { CubeScene } from "./cubes"; + +const meta: Meta = { + title: "scene/cubes", + component: CubeScene, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; From c9b1b0fb94c109ccafa08ab8c5f3963ac50907c1 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 16 Jul 2025 17:01:37 +0200 Subject: [PATCH 5/5] ui/cubes: align with design --- pkgs/clan-app/ui/src/scene/cubes.css | 15 ++ pkgs/clan-app/ui/src/scene/cubes.tsx | 342 ++++++++++++++------------- 2 files changed, 197 insertions(+), 160 deletions(-) create mode 100644 pkgs/clan-app/ui/src/scene/cubes.css diff --git a/pkgs/clan-app/ui/src/scene/cubes.css b/pkgs/clan-app/ui/src/scene/cubes.css new file mode 100644 index 000000000..dd5175df0 --- /dev/null +++ b/pkgs/clan-app/ui/src/scene/cubes.css @@ -0,0 +1,15 @@ +.cubes-scene-container { + width: 100%; + height: 100vh; + cursor: pointer; +} + +.toolbar-container { + position: absolute; + bottom: 10%; + width: 100%; + z-index: 10; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index fe14f9a19..5fbab30cd 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -6,8 +6,14 @@ import { createMemo, on, } from "solid-js"; +import "./cubes.css"; + import * as THREE from "three"; +import { Toolbar } from "../components/Toolbar/Toolbar"; +import { ToolbarButton } from "../components/Toolbar/ToolbarButton"; +import { Divider } from "../components/Divider/Divider"; + function garbageCollectGroup(group: THREE.Group) { for (const child of group.children) { if (child instanceof THREE.Mesh) { @@ -75,7 +81,7 @@ export function CubeScene() { const [ids, setIds] = createSignal([]); const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( - "circle", + "grid", ); const [nextBasePosition, setNextPosition] = createSignal(null); @@ -104,15 +110,26 @@ export function CubeScene() { const GRID_SIZE = 2; const CUBE_SPACING = 2; - const BASE_SIZE = 1; // Height of the cube above the ground - const CUBE_SIZE = BASE_SIZE / 1.2; // 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 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 = 0x9cbcff; + const CUBE_COLOR = 0xd7e0e1; + const CUBE_EMISSIVE = 0x303030; // Emissive color for cubes + + 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 + + const CREATE_BASE_COLOR = 0x636363; + const CREATE_BASE_EMISSIVE = 0xc5fad7; function getGridPosition( id: string, @@ -191,29 +208,6 @@ export function CubeScene() { }); }); - // Create multi-colored cube materials for different faces - function createCubeMaterials() { - const materials = [ - new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Right face - medium - new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Left face - dark shadow - new THREE.MeshBasicMaterial({ color: 0xdce4e5 }), // Top face - light - new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Bottom face - dark shadow - new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Front face - medium - new THREE.MeshLambertMaterial({ color: 0xb0c0cf, emissive: 0x303030 }), // Back face - dark shadow - ]; - return materials; - } - - function createBaseMaterials(opacity: number) { - return new THREE.MeshLambertMaterial({ - color: BASE_COLOR, - emissive: BASE_EMISSIVE, - flatShading: true, - transparent: true, - opacity, - }); - } - // Animation helper function function animateToPosition( thing: THREE.Object3D, @@ -254,25 +248,25 @@ export function CubeScene() { 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(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; - } + // 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; @@ -336,12 +330,19 @@ export function CubeScene() { function createCubeBase( cube_pos: [number, number, number], - opacity: number = 1, + opacity = 1, + color: THREE.ColorRepresentation = BASE_COLOR, + emissive: THREE.ColorRepresentation = BASE_EMISSIVE, ) { - const baseMaterial = createBaseMaterials(opacity); + const baseMaterial = new THREE.MeshPhongMaterial({ + opacity, + color, + emissive, + // flatShading: true, + transparent: true, + }); const base = new THREE.Mesh(sharedBaseGeometry, baseMaterial); - // tranlate_y = - cube_height / 2 - base_height / 2 - base.position.set(cube_pos[0], BASE_HEIGHT / 2, cube_pos[2]); // Position Y=0 on the floor + base.position.set(cube_pos[0], BASE_HEIGHT / 2, cube_pos[2]); base.receiveShadow = true; return base; } @@ -419,27 +420,62 @@ export function CubeScene() { }); } - function updateMeshColors() { - for (const [id, group] of groupMap.entries()) { - const selected = selectedIds().has(id); + function updateMeshColors( + selected: Set, + prev: Set | 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}`); + 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 material = base.material as THREE.MeshLambertMaterial; + const baseMaterial = base.material as THREE.MeshPhongMaterial; + const cubeMaterial = cube.material as THREE.MeshPhongMaterial; - if (selected) { - // When selected, make all faces red-ish but maintain the lighting difference - material.color.set(0xffffff); - material.emissive.set(0xff6666); - } else { - // Normal colors - restore original face colors - material.color.set(BASE_COLOR); - material.emissive.set(BASE_EMISSIVE); + 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); } } @@ -455,7 +491,7 @@ export function CubeScene() { } } - const initialCameraPosition = { x: 2.8, y: 3.6, z: -2 }; + const initialCameraPosition = { x: 2.8, y: 4, z: -2 }; const initialSphericalCameraPosition = new THREE.Spherical(); initialSphericalCameraPosition.setFromVector3( new THREE.Vector3( @@ -479,8 +515,8 @@ export function CubeScene() { // Create a fullscreen quad with a gradient shader // TODO: Recalculate gradient depending on container size const uniforms = { - colorTop: { value: new THREE.Color("#E6EAEA") }, // Top color - colorBottom: { value: new THREE.Color("#C5D1D2") }, // Bottom color + 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), }, @@ -530,18 +566,21 @@ export function CubeScene() { container.appendChild(renderer.domElement); // Lighting - const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Bright + const ambientLight = new THREE.AmbientLight(0xffffff, 1.5); scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); + const directionalLight = new THREE.DirectionalLight(0xffffff, 2); + // 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, - initialSphericalCameraPosition.theta, + initialSphericalCameraPosition.theta - Math.PI / 2, ); - lightPos.theta = initialSphericalCameraPosition.theta - Math.PI / 2; // 90 degrees offset directionalLight.position.setFromSpherical(lightPos); + directionalLight.target.position.set(0, 0, 0); // Point light at the center // initialSphericalCameraPosition directionalLight.castShadow = true; @@ -558,14 +597,14 @@ export function CubeScene() { directionalLight.shadow.radius = 1; // Hard shadows (low radius) directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges scene.add(directionalLight); + scene.add(directionalLight.target); // 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.1, // Make completely invisible - // visible: false, // Also hide it completely + opacity: 0.0, // Make completely invisible }); floor = new THREE.Mesh(floorGeometry, floorMaterial); floor.rotation.x = -Math.PI / 2; @@ -582,7 +621,11 @@ export function CubeScene() { // 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_SIZE, CUBE_SIZE); + sharedCubeGeometry = new THREE.BoxGeometry( + CUBE_SIZE, + CUBE_SEGMENT_HEIGHT, + CUBE_SIZE, + ); sharedBaseGeometry = new THREE.BoxGeometry( BASE_SIZE, BASE_HEIGHT, @@ -651,7 +694,9 @@ export function CubeScene() { // Create initial base mesh if it doesn't exist initBase = createCubeBase( [snapped.x, BASE_HEIGHT / 2, snapped.z], - 0.5, + 1, + CREATE_BASE_COLOR, + CREATE_BASE_EMISSIVE, // Emissive color ); } else { initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z); @@ -676,12 +721,12 @@ export function CubeScene() { // spherical.phi += deltaY * 0.01; // spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); - const lightPos = new THREE.Spherical(); - lightPos.setFromVector3(directionalLight.position); - lightPos.theta = spherical.theta - Math.PI / 2; // 90 degrees offset - directionalLight.position.setFromSpherical(lightPos); + // const lightPos = new THREE.Spherical(); + // lightPos.setFromVector3(directionalLight.position); + // lightPos.theta = spherical.theta - Math.PI / 2; // 90 degrees offset + // directionalLight.position.setFromSpherical(lightPos); - directionalLight.lookAt(0, 0, 0); + // directionalLight.lookAt(0, 0, 0); camera.position.setFromSpherical(spherical); camera.lookAt(0, 0, 0); @@ -756,7 +801,7 @@ export function CubeScene() { const intersects = raycaster.intersectObjects( Array.from(groupMap.values()), ); - + console.log("Intersects:", intersects); if (intersects.length > 0) { const id = intersects[0].object.userData.id; toggleSelection(id); @@ -805,8 +850,11 @@ export function CubeScene() { if (initBase) { initBase.geometry.dispose(); - // @ts-ignore: Not sure why this is needed - initBase.material.dispose(); + if (Array.isArray(initBase.material)) { + initBase.material.forEach((material) => material.dispose()); + } else { + initBase.material.dispose(); + } } if (container) { @@ -839,12 +887,17 @@ export function CubeScene() { function createCube( gridPosition: [number, number], - userData: { id: string; [key: string]: any }, + userData: { id: string }, ) { // Creates a cube, base, and other visuals // Groups them together in the scene - const cubeMaterials = createCubeMaterials(); - const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials); + 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; @@ -921,10 +974,16 @@ export function CubeScene() { } } }); - - updateMeshColors(); }); + createEffect( + on(selectedIds, (curr, prev) => { + console.log("Selected cubes:", curr); + // Update colors of selected cubes + updateMeshColors(curr, prev); + }), + ); + // Effect to clean up deleted cubes after animation createEffect(() => { const deleting = deletingIds(); @@ -955,7 +1014,7 @@ export function CubeScene() { createEffect(() => { selectedIds(); // Track the signal - updateMeshColors(); + // updateMeshColors(); }); onCleanup(() => { @@ -981,7 +1040,12 @@ export function CubeScene() { if (!initBase) { // Create initial base mesh if it doesn't exist - initBase = createCubeBase([0, BASE_HEIGHT / 2, 0], 0.5); + initBase = createCubeBase( + [0, BASE_HEIGHT / 2, 0], + 1, + CREATE_BASE_COLOR, + CREATE_BASE_EMISSIVE, + ); // Emissive color } if (inside) { scene.add(initBase); @@ -996,79 +1060,37 @@ export function CubeScene() { }; return ( -
-
- - Selected: {selectedIds().size} cubes | Total: {ids().length} cubes - - - {" | "} - World Mode: {worldMode()} - {" | "} - Position Mode: {positionMode()} - -
- -
(container = el)} - style={{ - width: "100%", - height: "80vh", - cursor: "pointer", - }} - /> - - {/* Camera Information Display */} -
-
- - - - -
-
- Camera Info: -
-
- Position: ({cameraInfo().position.x}, {cameraInfo().position.y},{" "} - {cameraInfo().position.z}) -
-
- Spherical: radius={cameraInfo().spherical.radius}, θ= - {cameraInfo().spherical.theta}, φ={cameraInfo().spherical.phi} -
+ onClick={onAddClick} + selected={worldMode() === "create"} + /> + + { + if (positionMode() === "grid") { + setPositionMode("circle"); + } else { + setPositionMode("grid"); + } + }} + /> + deleteSelectedCubes(selectedIds())} + /> +
-
+ ); }