Feat(UI/3d): init 3d prototype

This commit is contained in:
Johannes Kirschbauer
2025-05-13 12:25:18 +02:00
parent c5ff7afd21
commit 54e9c5e0bb
6 changed files with 360 additions and 7 deletions

View File

@@ -27,6 +27,7 @@ const config = tseslint.config(
// TODO: make this more strict by removing later // TODO: make this more strict by removing later
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
}, },
}, },
); );

View File

@@ -20,7 +20,8 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"solid-js": "^1.8.11", "solid-js": "^1.8.11",
"solid-markdown": "^2.0.13", "solid-markdown": "^2.0.13",
"solid-toast": "^0.5.0" "solid-toast": "^0.5.0",
"three": "^0.176.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-import-attributes": "^7.27.1",
@@ -28,6 +29,7 @@
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.10.0", "@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -678,6 +680,13 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -2063,6 +2072,13 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2165,12 +2181,42 @@
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.176.0.tgz",
"integrity": "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "^0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz",
"integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
@@ -2616,6 +2662,13 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@webgpu/types": {
"version": "0.1.60",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.60.tgz",
"integrity": "sha512-8B/tdfRFKdrnejqmvq95ogp8tf52oZ51p3f4QD5m5Paey/qlX4Rhhy5Y8tgFMi7Ms70HzcMMw3EQjH/jdhTwlA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -4000,6 +4053,13 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -5060,6 +5120,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/micromark": { "node_modules/micromark": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -7172,6 +7239,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View File

@@ -14,9 +14,12 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@eslint/js": "^9.3.0", "@eslint/js": "^9.3.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.10.0", "@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -32,10 +35,8 @@
"typescript-eslint": "^7.10.0", "typescript-eslint": "^7.10.0",
"vite": "^5.0.11", "vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2", "vite-plugin-solid": "^2.8.2",
"vitest": "^1.6.0", "vite-plugin-solid-svg": "^0.8.1",
"@types/json-schema": "^7.0.15", "vitest": "^1.6.0"
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"vite-plugin-solid-svg": "^0.8.1"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.8", "@floating-ui/dom": "^1.6.8",
@@ -49,6 +50,7 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"solid-js": "^1.8.11", "solid-js": "^1.8.11",
"solid-markdown": "^2.0.13", "solid-markdown": "^2.0.13",
"solid-toast": "^0.5.0" "solid-toast": "^0.5.0",
"three": "^0.176.0"
} }
} }

View File

@@ -23,6 +23,7 @@ import { IconVariant } from "./components/icon";
import { Components } from "./routes/components"; import { Components } from "./routes/components";
import { activeURI } from "./App"; import { activeURI } from "./App";
import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step"; import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step";
import { ThreePlayground } from "./three";
export const client = new QueryClient(); export const client = new QueryClient();
@@ -157,6 +158,11 @@ export const routes: AppRoute[] = [
label: "Local Hosts", label: "Local Hosts",
component: () => <HostList />, component: () => <HostList />,
}, },
{
path: "/3d",
label: "3D-Playground",
component: () => <ThreePlayground />,
},
{ {
path: "/api_testing", path: "/api_testing",
label: "api_testing", label: "api_testing",

View File

@@ -63,7 +63,7 @@ const ModuleItem = (props: {
return ( return (
<div <div
class={cx( class={cx(
"col-span-1 flex flex-col gap-3 border-b border-secondary-200 pb-4 gap-2", "col-span-1 flex flex-col border-b border-secondary-200 pb-4 gap-2",
props.class, props.class,
)} )}
> >

View File

@@ -0,0 +1,271 @@
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
import * as THREE from "three";
import { Button } from "./components/button";
import Icon from "./components/icon";
function addCubesSpiral({
scene,
count,
gap,
selected,
}: {
scene: THREE.Scene;
count: number;
gap: number;
selected?: string;
}) {
const cubeSize = 1;
const baseSize = 1.4;
const cubeGeometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
const baseGeometry = new THREE.BoxGeometry(baseSize, 0.05, baseSize);
const cubeMaterial = new THREE.MeshStandardMaterial({
color: 0xe0e0e0,
roughness: 0.6,
metalness: 0.1,
});
const baseMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.8,
metalness: 0,
});
let placed = 0;
const visited = new Set<string>();
let x = 0;
let z = 0;
let dx = 1;
let dz = 0;
let segmentLength = 1;
let segmentPassed = 0;
let stepsTaken = 0;
let turnCounter = 0;
while (placed < count) {
const key = `${x},${z}`;
if (!visited.has(key)) {
if ((x + z) % 2 === 0) {
// Place base
const base = new THREE.Mesh(baseGeometry, baseMaterial);
base.position.set(x * gap, 0, z * gap);
base.receiveShadow = true;
base.castShadow = true;
scene.add(base);
// Place cube
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
if (selected && +selected === placed) {
console.log("Selected", placed);
cube.material = new THREE.MeshStandardMaterial({
color: 0x99e0ff,
roughness: 0.6,
metalness: 0.1,
});
base.material = new THREE.MeshStandardMaterial({
color: 0x99e0ff,
roughness: 0.6,
metalness: 0.1,
});
}
// Store
cube.userData = { id: placed };
cube.position.set(x * gap, 0.55, z * gap);
cube.castShadow = true;
scene.add(cube);
placed++;
}
visited.add(key);
}
x += dx;
z += dz;
segmentPassed++;
stepsTaken++;
if (segmentPassed === segmentLength) {
segmentPassed = 0;
// Turn right: [1,0] → [0,1] → [-1,0] → [0,-1]
const temp = dx;
dx = -dz;
dz = temp;
turnCounter++;
if (turnCounter % 2 === 0) {
segmentLength++;
}
}
// Fail-safe to prevent infinite loops
if (stepsTaken > count * 20) break;
}
// Clean up geometry
cubeGeometry.dispose();
baseGeometry.dispose();
}
interface ViewProps {
count: number;
onCubeClick: (id: number) => void;
selected?: string;
}
const View = (props: ViewProps) => {
let container: HTMLDivElement | undefined;
onMount(() => {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
container!.clientWidth / container!.clientHeight,
0.1,
1000,
);
// Transparent renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container!.clientWidth, container!.clientHeight);
renderer.setClearColor(0x000000, 0); // Transparent background
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container!.appendChild(renderer.domElement);
// Cube (casts shadow)
const cubeGeometry = new THREE.BoxGeometry();
const cubeMaterial = new THREE.MeshStandardMaterial({
color: 0xb0c0c2,
roughness: 0.4,
metalness: 0.1,
});
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.castShadow = true;
cube.position.y = 1;
// scene.add(cube);
addCubesSpiral({
scene,
count: props.count,
gap: 1.5,
selected: props.selected,
});
const factor = Math.log10(props.count) / 10 + 1;
camera.position.set(5 * factor, 6 * factor, 5 * factor); // from above and to the side
camera.lookAt(0, 0, 0);
// Floor (receives shadow)
const floorGeometry = new THREE.PlaneGeometry(100, 100);
const floorMaterial = new THREE.ShadowMaterial({ opacity: 0.1 });
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.1;
floor.receiveShadow = true;
scene.add(floor);
// Light (casts shadow)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-20, 30, 20); // above & behind the cube
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048; // higher res = smoother shadow
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.radius = 6;
// directionalLight.shadow.radius
scene.add(directionalLight);
// Optional ambient light for slight scene illumination
scene.add(new THREE.AmbientLight(0xffffff, 0.2));
// Animate
// const animate = () => {
// animationId = requestAnimationFrame(animate);
// };
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const handleClick = (event: MouseEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const cube = intersects.find((i) => i.object.userData?.id !== undefined);
if (cube) {
props.onCubeClick(cube.object.userData.id);
}
};
renderer.domElement.addEventListener("click", handleClick);
renderer.render(scene, camera);
// let animationId = requestAnimationFrame(animate);
onCleanup(() => {
// cancelAnimationFrame(animationId);
renderer.dispose();
cubeGeometry.dispose();
cubeMaterial.dispose();
floorGeometry.dispose();
floorMaterial.dispose();
container?.removeChild(renderer.domElement);
renderer.domElement.removeEventListener("click", handleClick);
});
});
return (
<div
ref={container}
style={{ width: "100%", height: "100%", overflow: "hidden" }}
/>
);
};
export const ThreePlayground = () => {
const [count, setCount] = createSignal(1);
const [selected, setSelected] = createSignal<string>("");
const onCubeClick = (id: number) => {
console.log(`Cube ${id} clicked`);
setSelected(`${id}`);
};
return (
<div class="relative size-full">
<Show when={selected() || !selected()} keyed>
<Show when={count()} keyed>
{(c) => (
<View count={c} onCubeClick={onCubeClick} selected={selected()} />
)}
</Show>
</Show>
<div class="absolute bottom-4 right-0 z-10 flex w-full items-center justify-center">
<div class="flex w-fit items-center justify-between gap-4 rounded-xl border px-8 py-2 text-white shadow-2xl bg-inv-1 border-inv-1">
<Button startIcon={<Icon icon="Edit" />}></Button>
<Button startIcon={<Icon icon="Grid" />}></Button>
<Button
startIcon={<Icon icon="Plus" />}
onClick={() => {
setCount((c) => c + 1);
}}
></Button>
<Button
startIcon={<Icon icon="Trash" />}
onClick={() => {
setCount((c) => c - 1);
}}
></Button>
</div>
</div>
</div>
);
};