diff --git a/pkgs/webview-ui/app/eslint.config.mjs b/pkgs/webview-ui/app/eslint.config.mjs index 6b35c3e35..c837e0825 100644 --- a/pkgs/webview-ui/app/eslint.config.mjs +++ b/pkgs/webview-ui/app/eslint.config.mjs @@ -27,6 +27,7 @@ const config = tseslint.config( // TODO: make this more strict by removing later "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-non-null-assertion": "off", }, }, ); diff --git a/pkgs/webview-ui/app/package-lock.json b/pkgs/webview-ui/app/package-lock.json index c1b6ca50d..0b19c0ea4 100644 --- a/pkgs/webview-ui/app/package-lock.json +++ b/pkgs/webview-ui/app/package-lock.json @@ -20,7 +20,8 @@ "nanoid": "^5.0.7", "solid-js": "^1.8.11", "solid-markdown": "^2.0.13", - "solid-toast": "^0.5.0" + "solid-toast": "^0.5.0", + "three": "^0.176.0" }, "devDependencies": { "@babel/plugin-syntax-import-attributes": "^7.27.1", @@ -28,6 +29,7 @@ "@tailwindcss/typography": "^0.5.13", "@types/json-schema": "^7.0.15", "@types/node": "^20.12.12", + "@types/three": "^0.176.0", "@typescript-eslint/parser": "^7.10.0", "autoprefixer": "^10.4.19", "classnames": "^2.5.1", @@ -678,6 +680,13 @@ "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2063,6 +2072,13 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2165,12 +2181,42 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "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": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -2616,6 +2662,13 @@ "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": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -4000,6 +4053,13 @@ "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": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5060,6 +5120,13 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7172,6 +7239,12 @@ "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/pkgs/webview-ui/app/package.json b/pkgs/webview-ui/app/package.json index 32298f48c..f7f561603 100644 --- a/pkgs/webview-ui/app/package.json +++ b/pkgs/webview-ui/app/package.json @@ -14,9 +14,12 @@ }, "license": "MIT", "devDependencies": { + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@eslint/js": "^9.3.0", "@tailwindcss/typography": "^0.5.13", + "@types/json-schema": "^7.0.15", "@types/node": "^20.12.12", + "@types/three": "^0.176.0", "@typescript-eslint/parser": "^7.10.0", "autoprefixer": "^10.4.19", "classnames": "^2.5.1", @@ -32,10 +35,8 @@ "typescript-eslint": "^7.10.0", "vite": "^5.0.11", "vite-plugin-solid": "^2.8.2", - "vitest": "^1.6.0", - "@types/json-schema": "^7.0.15", - "@babel/plugin-syntax-import-attributes": "^7.27.1", - "vite-plugin-solid-svg": "^0.8.1" + "vite-plugin-solid-svg": "^0.8.1", + "vitest": "^1.6.0" }, "dependencies": { "@floating-ui/dom": "^1.6.8", @@ -49,6 +50,7 @@ "nanoid": "^5.0.7", "solid-js": "^1.8.11", "solid-markdown": "^2.0.13", - "solid-toast": "^0.5.0" + "solid-toast": "^0.5.0", + "three": "^0.176.0" } } diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index 94b6ba375..d9a863abb 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -23,6 +23,7 @@ import { IconVariant } from "./components/icon"; import { Components } from "./routes/components"; import { activeURI } from "./App"; import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step"; +import { ThreePlayground } from "./three"; export const client = new QueryClient(); @@ -157,6 +158,11 @@ export const routes: AppRoute[] = [ label: "Local Hosts", component: () => , }, + { + path: "/3d", + label: "3D-Playground", + component: () => , + }, { path: "/api_testing", label: "api_testing", diff --git a/pkgs/webview-ui/app/src/routes/modules/list.tsx b/pkgs/webview-ui/app/src/routes/modules/list.tsx index a1fbe773b..2b601a772 100644 --- a/pkgs/webview-ui/app/src/routes/modules/list.tsx +++ b/pkgs/webview-ui/app/src/routes/modules/list.tsx @@ -63,7 +63,7 @@ const ModuleItem = (props: { return (
diff --git a/pkgs/webview-ui/app/src/three.tsx b/pkgs/webview-ui/app/src/three.tsx new file mode 100644 index 000000000..0f9e9b0ed --- /dev/null +++ b/pkgs/webview-ui/app/src/three.tsx @@ -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(); + + 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 ( +
+ ); +}; + +export const ThreePlayground = () => { + const [count, setCount] = createSignal(1); + const [selected, setSelected] = createSignal(""); + + const onCubeClick = (id: number) => { + console.log(`Cube ${id} clicked`); + setSelected(`${id}`); + }; + + return ( +
+ + + {(c) => ( + + )} + + +
+
+ + + + +
+
+
+ ); +};