ui/cubes: align with design

This commit is contained in:
Johannes Kirschbauer
2025-07-16 17:01:37 +02:00
parent 66bdbb0959
commit c9b1b0fb94
2 changed files with 197 additions and 160 deletions

View File

@@ -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;
}

View File

@@ -6,8 +6,14 @@ import {
createMemo, createMemo,
on, on,
} from "solid-js"; } from "solid-js";
import "./cubes.css";
import * as THREE from "three"; 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) { function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) { for (const child of group.children) {
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
@@ -75,7 +81,7 @@ export function CubeScene() {
const [ids, setIds] = createSignal<string[]>([]); const [ids, setIds] = createSignal<string[]>([]);
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">( const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"circle", "grid",
); );
const [nextBasePosition, setNextPosition] = const [nextBasePosition, setNextPosition] =
createSignal<THREE.Vector3 | null>(null); createSignal<THREE.Vector3 | null>(null);
@@ -104,15 +110,26 @@ export function CubeScene() {
const GRID_SIZE = 2; const GRID_SIZE = 2;
const CUBE_SPACING = 2; const CUBE_SPACING = 2;
const BASE_SIZE = 1; // 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.2; // 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 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_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 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_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( function getGridPosition(
id: string, 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 // Animation helper function
function animateToPosition( function animateToPosition(
thing: THREE.Object3D, thing: THREE.Object3D,
@@ -254,25 +248,25 @@ export function CubeScene() {
baseMesh.scale.setScalar(0); baseMesh.scale.setScalar(0);
// Ensure materials are fully opaque // Ensure materials are fully opaque
if (Array.isArray(mesh.material)) { // if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => { // mesh.material.forEach((material) => {
(material as THREE.MeshBasicMaterial).opacity = 1; // (material as THREE.MeshBasicMaterial).opacity = 1;
material.transparent = false; // material.transparent = false;
}); // });
} else { // } else {
(mesh.material as THREE.MeshBasicMaterial).opacity = 1; // (mesh.material as THREE.MeshBasicMaterial).opacity = 1;
mesh.material.transparent = false; // mesh.material.transparent = false;
} // }
if (Array.isArray(baseMesh.material)) { // if (Array.isArray(baseMesh.material)) {
baseMesh.material.forEach((material) => { // baseMesh.material.forEach((material) => {
(material as THREE.MeshBasicMaterial).opacity = 1; // (material as THREE.MeshBasicMaterial).opacity = 1;
material.transparent = false; // material.transparent = false;
}); // });
} else { // } else {
(baseMesh.material as THREE.MeshBasicMaterial).opacity = 1; // (baseMesh.material as THREE.MeshBasicMaterial).opacity = 1;
baseMesh.material.transparent = false; // baseMesh.material.transparent = false;
} // }
function animate() { function animate() {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
@@ -336,12 +330,19 @@ export function CubeScene() {
function createCubeBase( function createCubeBase(
cube_pos: [number, number, number], 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); 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]);
base.position.set(cube_pos[0], BASE_HEIGHT / 2, cube_pos[2]); // Position Y=0 on the floor
base.receiveShadow = true; base.receiveShadow = true;
return base; return base;
} }
@@ -419,27 +420,62 @@ export function CubeScene() {
}); });
} }
function updateMeshColors() { function updateMeshColors(
for (const [id, group] of groupMap.entries()) { selected: Set<string>,
const selected = selectedIds().has(id); prev: Set<string> | 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"); const base = group.children.find((child) => child.name === "base");
if (!base || !(base instanceof THREE.Mesh)) { 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; continue;
} }
const material = base.material as THREE.MeshLambertMaterial; const baseMaterial = base.material as THREE.MeshPhongMaterial;
const cubeMaterial = cube.material as THREE.MeshPhongMaterial;
if (selected) { baseMaterial.color.set(BASE_SELECTED_COLOR);
// When selected, make all faces red-ish but maintain the lighting difference baseMaterial.emissive.set(BASE_SELECTED_EMISSIVE);
material.color.set(0xffffff);
material.emissive.set(0xff6666); cubeMaterial.color.set(CUBE_SELECTED_COLOR);
} else {
// Normal colors - restore original face colors
material.color.set(BASE_COLOR);
material.emissive.set(BASE_EMISSIVE);
} }
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(); const initialSphericalCameraPosition = new THREE.Spherical();
initialSphericalCameraPosition.setFromVector3( initialSphericalCameraPosition.setFromVector3(
new THREE.Vector3( new THREE.Vector3(
@@ -479,8 +515,8 @@ export function CubeScene() {
// Create a fullscreen quad with a gradient shader // Create a fullscreen quad with a gradient shader
// TODO: Recalculate gradient depending on container size // TODO: Recalculate gradient depending on container size
const uniforms = { const uniforms = {
colorTop: { value: new THREE.Color("#E6EAEA") }, // Top color colorTop: { value: new THREE.Color("#edf1f1") }, // Top color
colorBottom: { value: new THREE.Color("#C5D1D2") }, // Bottom color colorBottom: { value: new THREE.Color("#e3e7e7") }, // Bottom color
resolution: { resolution: {
value: new THREE.Vector2(window.innerWidth, window.innerHeight), value: new THREE.Vector2(window.innerWidth, window.innerHeight),
}, },
@@ -530,18 +566,21 @@ export function CubeScene() {
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); // Bright const ambientLight = new THREE.AmbientLight(0xffffff, 1.5);
scene.add(ambientLight); 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( const lightPos = new THREE.Spherical(
100, 100,
initialSphericalCameraPosition.phi, initialSphericalCameraPosition.phi,
initialSphericalCameraPosition.theta, initialSphericalCameraPosition.theta - Math.PI / 2,
); );
lightPos.theta = initialSphericalCameraPosition.theta - Math.PI / 2; // 90 degrees offset
directionalLight.position.setFromSpherical(lightPos); directionalLight.position.setFromSpherical(lightPos);
directionalLight.target.position.set(0, 0, 0); // Point light at the center
// initialSphericalCameraPosition // initialSphericalCameraPosition
directionalLight.castShadow = true; directionalLight.castShadow = true;
@@ -558,14 +597,14 @@ export function CubeScene() {
directionalLight.shadow.radius = 1; // Hard shadows (low radius) directionalLight.shadow.radius = 1; // Hard shadows (low radius)
directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges directionalLight.shadow.blurSamples = 4; // Fewer samples for harder edges
scene.add(directionalLight); scene.add(directionalLight);
scene.add(directionalLight.target);
// Floor/Ground - Make it invisible but keep it for reference // Floor/Ground - Make it invisible but keep it for reference
const floorGeometry = new THREE.PlaneGeometry(1000, 1000); const floorGeometry = new THREE.PlaneGeometry(1000, 1000);
const floorMaterial = new THREE.MeshBasicMaterial({ const floorMaterial = new THREE.MeshBasicMaterial({
color: FLOOR_COLOR, color: FLOOR_COLOR,
transparent: true, transparent: true,
opacity: 0.1, // Make completely invisible opacity: 0.0, // Make completely invisible
// visible: false, // Also hide it completely
}); });
floor = new THREE.Mesh(floorGeometry, floorMaterial); floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2; floor.rotation.x = -Math.PI / 2;
@@ -582,7 +621,11 @@ export function CubeScene() {
// Shared geometries for cubes and bases // Shared geometries for cubes and bases
// This allows us to reuse the same geometry for all 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( sharedBaseGeometry = new THREE.BoxGeometry(
BASE_SIZE, BASE_SIZE,
BASE_HEIGHT, BASE_HEIGHT,
@@ -651,7 +694,9 @@ export function CubeScene() {
// Create initial base mesh if it doesn't exist // Create initial base mesh if it doesn't exist
initBase = createCubeBase( initBase = createCubeBase(
[snapped.x, BASE_HEIGHT / 2, snapped.z], [snapped.x, BASE_HEIGHT / 2, snapped.z],
0.5, 1,
CREATE_BASE_COLOR,
CREATE_BASE_EMISSIVE, // Emissive color
); );
} else { } else {
initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z); initBase.position.set(snapped.x, BASE_HEIGHT / 2, snapped.z);
@@ -676,12 +721,12 @@ export function CubeScene() {
// spherical.phi += deltaY * 0.01; // spherical.phi += deltaY * 0.01;
// spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); // spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
const lightPos = new THREE.Spherical(); // const lightPos = new THREE.Spherical();
lightPos.setFromVector3(directionalLight.position); // lightPos.setFromVector3(directionalLight.position);
lightPos.theta = spherical.theta - Math.PI / 2; // 90 degrees offset // lightPos.theta = spherical.theta - Math.PI / 2; // 90 degrees offset
directionalLight.position.setFromSpherical(lightPos); // directionalLight.position.setFromSpherical(lightPos);
directionalLight.lookAt(0, 0, 0); // directionalLight.lookAt(0, 0, 0);
camera.position.setFromSpherical(spherical); camera.position.setFromSpherical(spherical);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
@@ -756,7 +801,7 @@ export function CubeScene() {
const intersects = raycaster.intersectObjects( const intersects = raycaster.intersectObjects(
Array.from(groupMap.values()), Array.from(groupMap.values()),
); );
console.log("Intersects:", intersects);
if (intersects.length > 0) { if (intersects.length > 0) {
const id = intersects[0].object.userData.id; const id = intersects[0].object.userData.id;
toggleSelection(id); toggleSelection(id);
@@ -805,9 +850,12 @@ export function CubeScene() {
if (initBase) { if (initBase) {
initBase.geometry.dispose(); initBase.geometry.dispose();
// @ts-ignore: Not sure why this is needed if (Array.isArray(initBase.material)) {
initBase.material.forEach((material) => material.dispose());
} else {
initBase.material.dispose(); initBase.material.dispose();
} }
}
if (container) { if (container) {
container.innerHTML = ""; container.innerHTML = "";
@@ -839,12 +887,17 @@ export function CubeScene() {
function createCube( function createCube(
gridPosition: [number, number], gridPosition: [number, number],
userData: { id: string; [key: string]: any }, userData: { id: string },
) { ) {
// Creates a cube, base, and other visuals // Creates a cube, base, and other visuals
// Groups them together in the scene // Groups them together in the scene
const cubeMaterials = createCubeMaterials(); const cubeMaterial = new THREE.MeshPhongMaterial({
const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterials); color: CUBE_COLOR,
emissive: CUBE_EMISSIVE,
// specular: 0xffffff,
shininess: 100,
});
const cubeMesh = new THREE.Mesh(sharedCubeGeometry, cubeMaterial);
cubeMesh.castShadow = true; cubeMesh.castShadow = true;
cubeMesh.receiveShadow = true; cubeMesh.receiveShadow = true;
cubeMesh.userData = userData; 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 // Effect to clean up deleted cubes after animation
createEffect(() => { createEffect(() => {
const deleting = deletingIds(); const deleting = deletingIds();
@@ -955,7 +1014,7 @@ export function CubeScene() {
createEffect(() => { createEffect(() => {
selectedIds(); // Track the signal selectedIds(); // Track the signal
updateMeshColors(); // updateMeshColors();
}); });
onCleanup(() => { onCleanup(() => {
@@ -981,7 +1040,12 @@ export function CubeScene() {
if (!initBase) { if (!initBase) {
// Create initial base mesh if it doesn't exist // 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) { if (inside) {
scene.add(initBase); scene.add(initBase);
@@ -996,79 +1060,37 @@ export function CubeScene() {
}; };
return ( return (
<div style="width: 100%; height: 100%; position: relative;"> <>
<div style={{ "margin-bottom": "10px" }}> <div class="cubes-scene-container" ref={(el) => (container = el)} />
<span style={{ "margin-left": "10px" }}> <div class="toolbar-container">
Selected: {selectedIds().size} cubes | Total: {ids().length} cubes <Toolbar>
</span> <ToolbarButton
<span> name="new-machine"
{" | "} icon="NewMachine"
World Mode: {worldMode()}
{" | "}
Position Mode: {positionMode()}
</span>
</div>
<div
ref={(el) => (container = el)}
style={{
width: "100%",
height: "80vh",
cursor: "pointer",
}}
/>
{/* Camera Information Display */}
<div
style={{
"margin-bottom": "10px",
"font-family": "monospace",
"font-size": "12px",
"background-color": "#f5f5f5",
padding: "8px",
"border-radius": "4px",
border: "1px solid #ddd",
}}
>
<div
style={{
position: "absolute",
bottom: "70px",
left: "0",
display: "flex",
"flex-direction": "row",
width: "100%",
}}
>
<button
onClick={onAddClick}
onMouseEnter={onHover(true)} onMouseEnter={onHover(true)}
onMouseLeave={onHover(false)} onMouseLeave={onHover(false)}
> onClick={onAddClick}
Add Cube selected={worldMode() === "create"}
</button> />
<button onClick={() => deleteSelectedCubes(selectedIds())}> <Divider orientation="vertical" />
Delete Selected <ToolbarButton
</button> name="modules"
<button onClick={() => setPositionMode("grid")}> icon="Modules"
Grid Positioning onClick={() => {
</button> if (positionMode() === "grid") {
<button onClick={() => setPositionMode("circle")}> setPositionMode("circle");
Circle Positioning } else {
</button> setPositionMode("grid");
</div> }
<div> }}
<strong>Camera Info:</strong> />
</div> <ToolbarButton
<div> name="delete"
Position: ({cameraInfo().position.x}, {cameraInfo().position.y},{" "} icon="Trash"
{cameraInfo().position.z}) onClick={() => deleteSelectedCubes(selectedIds())}
</div> />
<div> </Toolbar>
Spherical: radius={cameraInfo().spherical.radius}, θ=
{cameraInfo().spherical.theta}, φ={cameraInfo().spherical.phi}
</div>
</div>
</div> </div>
</>
); );
} }