From 70411d1a9978a78f5fe8c0ca2424b325c7d79217 Mon Sep 17 00:00:00 2001 From: agentelement Date: Wed, 17 Jun 2026 01:06:41 -0700 Subject: [PATCH 01/68] webapp: init --- .gitignore | 7 +++++++ flake.lock | 6 +++--- flake.nix | 1 + package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 6 ++++++ webapp/app.ts | 26 ++++++++++++++++++++++++++ webapp/index.html | 9 +++++++++ webapp/tsconfig.json | 10 ++++++++++ 8 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 webapp/app.ts create mode 100644 webapp/index.html create mode 100644 webapp/tsconfig.json diff --git a/.gitignore b/.gitignore index 82b4e8f..2c07027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ *.stl !data/*.stl __pycache__ +out/ + +node_modules + +# Added by cargo + +/target diff --git a/flake.lock b/flake.lock index 786f446..d4b7931 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1774106199, - "narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=", + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 9a89640..a7181ee 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,7 @@ pkgs.cairo pkgs.openscad pkgs.python3 + pkgs.nodejs ]; nativeBuildInputs = with pkgs; [ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6258df9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "solids", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "three": "^0.184.0", + "typescript": "^6.0.3" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b81a9ee --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "three": "^0.184.0", + "typescript": "^6.0.3" + } +} diff --git a/webapp/app.ts b/webapp/app.ts new file mode 100644 index 0000000..02f208e --- /dev/null +++ b/webapp/app.ts @@ -0,0 +1,26 @@ +import OpenSCAD from "./openscad/openscad.js"; +const three = require('three') + +const filename = "cube.stl"; + +// Instantiate the application +const instance = await OpenSCAD({noInitialRun: true}); + +// Write a file to the filesystem +instance.FS.writeFile("/input.scad", `cube(10);`); // OpenSCAD script to generate a 10mm cube + +// Run like a command-line program with arguments +instance.callMain(["/input.scad", "--enable=manifold", "-o", filename]); // manifold is faster at rendering + +// Read the output 3D-model into a JS byte-array +const output = instance.FS.readFile("/"+filename); + +// Generate a link to output 3D-model and download the output STL file +const link = document.createElement("a"); +link.href = URL.createObjectURL( +new Blob([output], { type: "application/octet-stream" }), null); +link.download = filename; +document.body.append(link); + + +const loader = new STLLoader(); diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..c5c7099 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..7f9630e --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true + }, + "include": ["**/*"], + "exclude": ["**/*.spec.ts"] +} From 6d3c110b09325c54f5c67d682a09c24d55852951 Mon Sep 17 00:00:00 2001 From: agentelement Date: Wed, 17 Jun 2026 20:57:16 -0700 Subject: [PATCH 02/68] webapp: render cube --- package-lock.json | 32 - package.json | 6 - webapp/app.ts | 82 +- webapp/index.html | 2 +- webapp/package-lock.json | 1582 ++++++++++++++++++++++++++++++++++++++ webapp/package.json | 12 + webapp/style.css | 1 + webapp/tsconfig.json | 13 +- 8 files changed, 1662 insertions(+), 68 deletions(-) delete mode 100644 package-lock.json delete mode 100644 package.json create mode 100644 webapp/package-lock.json create mode 100644 webapp/package.json create mode 100644 webapp/style.css diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6258df9..0000000 --- a/package-lock.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "solids", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "three": "^0.184.0", - "typescript": "^6.0.3" - } - }, - "node_modules/three": { - "version": "0.184.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", - "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index b81a9ee..0000000 --- a/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "three": "^0.184.0", - "typescript": "^6.0.3" - } -} diff --git a/webapp/app.ts b/webapp/app.ts index 02f208e..5e798c5 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -1,26 +1,56 @@ -import OpenSCAD from "./openscad/openscad.js"; -const three = require('three') - -const filename = "cube.stl"; - -// Instantiate the application -const instance = await OpenSCAD({noInitialRun: true}); - -// Write a file to the filesystem -instance.FS.writeFile("/input.scad", `cube(10);`); // OpenSCAD script to generate a 10mm cube - -// Run like a command-line program with arguments -instance.callMain(["/input.scad", "--enable=manifold", "-o", filename]); // manifold is faster at rendering - -// Read the output 3D-model into a JS byte-array -const output = instance.FS.readFile("/"+filename); - -// Generate a link to output 3D-model and download the output STL file -const link = document.createElement("a"); -link.href = URL.createObjectURL( -new Blob([output], { type: "application/octet-stream" }), null); -link.download = filename; -document.body.append(link); - - -const loader = new STLLoader(); +import * as THREE from "three"; +import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +const WHITE = 0xffffff; +const GRAY = 0x404040; +const BLUE = 0x6699cc; + +async function init() { + const output = await fetch("cube.stl").then((res) => res.arrayBuffer()); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(WHITE); + + const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); + camera.position.set(20, 15, 20); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(window.innerWidth, window.innerHeight); + document.body.append(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.update(); + + scene.add(new THREE.AmbientLight(GRAY, 2)); + const dirLight = new THREE.DirectionalLight(WHITE, 2); + dirLight.position.set(10, 20, 15); + scene.add(dirLight); + + const loader = new STLLoader(); + const geometry = loader.parse(output); + geometry.center(); + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: BLUE, + flatShading: false, + }); + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + function animate() { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + } + animate(); + + window.addEventListener("resize", () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + }); +} + +init(); diff --git a/webapp/index.html b/webapp/index.html index c5c7099..7202c89 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -3,7 +3,7 @@ - + diff --git a/webapp/package-lock.json b/webapp/package-lock.json new file mode 100644 index 0000000..94f64d0 --- /dev/null +++ b/webapp/package-lock.json @@ -0,0 +1,1582 @@ +{ + "name": "webapp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@tailwindcss/cli": "^4.3.1", + "esbuild": "^0.28.1", + "tailwindcss": "^4.3.1", + "three": "^0.184.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.1.tgz", + "integrity": "sha512-ZWPy20rF+TBfTImxDMG3Wr75Y3RpaPlo9lc+oJbInlMyjT+XPkTVKVIL5RZ7JirXuIahcfHoLNFRmDorKi+JQQ==", + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "enhanced-resolve": "5.21.6", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.1" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/webapp/package.json b/webapp/package.json new file mode 100644 index 0000000..7e4906f --- /dev/null +++ b/webapp/package.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "@tailwindcss/cli": "^4.3.1", + "esbuild": "^0.28.1", + "tailwindcss": "^4.3.1", + "three": "^0.184.0", + "typescript": "^6.0.3" + }, + "scripts": { + "build": "esbuild app.ts --bundle --outfile=a.js --format=esm --target=es2022 --minify --tree-shaking=true --loader:.stl=file && tailwindcss -i style.css -o a.css --minify" + } +} diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/webapp/style.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index 7f9630e..2a6cb0f 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -1,10 +1,17 @@ { "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", "noImplicitAny": true, "removeComments": true, "preserveConstEnums": true, - "sourceMap": true + // "sourceMap": true, + "skipLibCheck": true, + "strict": false, + "outDir": ".", + "allowImportingTsExtensions": false }, - "include": ["**/*"], - "exclude": ["**/*.spec.ts"] + "include": ["**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.js", "node_modules", "openscad", "three"] } From a2b9d80ce6e4258144034a0cc137ec0603ae4de8 Mon Sep 17 00:00:00 2001 From: agentelement Date: Wed, 17 Jun 2026 21:45:28 -0700 Subject: [PATCH 03/68] webapp: add stylesheet to html --- webapp/index.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webapp/index.html b/webapp/index.html index 7202c89..235d497 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -1,6 +1,11 @@ - + + + + + + From b8a83e546d54e229351850d91a83ddfbbafe0030 Mon Sep 17 00:00:00 2001 From: agentelement Date: Wed, 17 Jun 2026 21:45:53 -0700 Subject: [PATCH 04/68] build: ignore a.* --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2c07027..96b4f52 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__ out/ node_modules +a.* # Added by cargo From 13fb4d5879ccf23beb702b965ea512d0d2da219d Mon Sep 17 00:00:00 2001 From: agentelement Date: Thu, 18 Jun 2026 02:03:52 -0700 Subject: [PATCH 05/68] webapp: basic colorscheme --- webapp/app.ts | 28 ++++++++++++++++++++-------- webapp/index.html | 11 +++++++---- webapp/style.css | 8 ++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 5e798c5..d2c4e9a 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -2,28 +2,40 @@ import * as THREE from "three"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -const WHITE = 0xffffff; -const GRAY = 0x404040; -const BLUE = 0x6699cc; +function cssVar(name: string): number { + const hex = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return parseInt(hex.replace("#", ""), 16); +} + +const V_FG = cssVar("--color-v-fg"); +const V_BG = cssVar("--color-v-bg"); +const V_DARK = cssVar("--color-v-dark"); +const V_BORDER = cssVar("--color-v-border"); +const V_BLUE = cssVar("--color-v-blue"); async function init() { const output = await fetch("cube.stl").then((res) => res.arrayBuffer()); const scene = new THREE.Scene(); - scene.background = new THREE.Color(WHITE); + scene.background = new THREE.Color(V_BG); const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(20, 15, 20); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); - document.body.append(renderer.domElement); + renderer.domElement.style.position = "fixed"; + renderer.domElement.style.top = "0"; + renderer.domElement.style.left = "0"; + document.body.prepend(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.update(); - scene.add(new THREE.AmbientLight(GRAY, 2)); - const dirLight = new THREE.DirectionalLight(WHITE, 2); + scene.add(new THREE.AmbientLight(V_FG, 2)); + const dirLight = new THREE.DirectionalLight(V_FG, 2); dirLight.position.set(10, 20, 15); scene.add(dirLight); @@ -33,7 +45,7 @@ async function init() { geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ - color: BLUE, + color: V_BLUE, flatShading: false, }); const mesh = new THREE.Mesh(geometry, material); diff --git a/webapp/index.html b/webapp/index.html index 235d497..b8b51cc 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -1,5 +1,5 @@ - + @@ -7,8 +7,11 @@ - - - +
+ + +
diff --git a/webapp/style.css b/webapp/style.css index f1d8c73..5b5db30 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -1 +1,9 @@ @import "tailwindcss"; + +@theme { + --color-v-fg: #B1B1B3; + --color-v-bg: #232327; + --color-v-dark: #0C0C0D; + --color-v-border: #38383D; + --color-v-blue: #75BAFF; +} From 8f350889a658539f646229bc93bbb221ec8a5511 Mon Sep 17 00:00:00 2001 From: agentelement Date: Sat, 20 Jun 2026 16:26:18 -0700 Subject: [PATCH 06/68] webapp: render without pixelation --- webapp/app.ts | 5 +++++ webapp/index.html | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index d2c4e9a..34e23b1 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -29,6 +29,7 @@ async function init() { renderer.domElement.style.position = "fixed"; renderer.domElement.style.top = "0"; renderer.domElement.style.left = "0"; + renderer.setPixelRatio(window.devicePixelRatio); document.body.prepend(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); @@ -66,3 +67,7 @@ async function init() { } init(); + +document.getElementById('sidebar-close').addEventListener('click', () => { + document.getElementById('sidebar').style.display = 'none'; +}); diff --git a/webapp/index.html b/webapp/index.html index b8b51cc..e4e5aab 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -8,8 +8,12 @@
-
From 570f6a0f929daff95e09bc3ab6612c734f13c90e Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 22 Jun 2026 22:01:51 -0700 Subject: [PATCH 07/68] webapp: slidery bits --- webapp/app.ts | 293 ++++++++++++++++++++++++++++++++++++++-------- webapp/index.html | 39 +++--- webapp/style.css | 30 +++++ 3 files changed, 292 insertions(+), 70 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 34e23b1..9c9c30f 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -2,11 +2,18 @@ import * as THREE from "three"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +// utils --- + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + function cssVar(name: string): number { - const hex = getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - return parseInt(hex.replace("#", ""), 16); + const hex = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + return parseInt(hex.replace("#", ""), 16); } const V_FG = cssVar("--color-v-fg"); @@ -15,59 +22,241 @@ const V_DARK = cssVar("--color-v-dark"); const V_BORDER = cssVar("--color-v-border"); const V_BLUE = cssVar("--color-v-blue"); + +// Canvas logic. Placeholder. --- + async function init() { - const output = await fetch("cube.stl").then((res) => res.arrayBuffer()); - - const scene = new THREE.Scene(); - scene.background = new THREE.Color(V_BG); - - const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); - camera.position.set(20, 15, 20); - - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.domElement.style.position = "fixed"; - renderer.domElement.style.top = "0"; - renderer.domElement.style.left = "0"; - renderer.setPixelRatio(window.devicePixelRatio); - document.body.prepend(renderer.domElement); - - const controls = new OrbitControls(camera, renderer.domElement); - controls.update(); - - scene.add(new THREE.AmbientLight(V_FG, 2)); - const dirLight = new THREE.DirectionalLight(V_FG, 2); - dirLight.position.set(10, 20, 15); - scene.add(dirLight); - - const loader = new STLLoader(); - const geometry = loader.parse(output); - geometry.center(); - geometry.computeVertexNormals(); - - const material = new THREE.MeshStandardMaterial({ - color: V_BLUE, - flatShading: false, - }); - const mesh = new THREE.Mesh(geometry, material); - scene.add(mesh); - - function animate() { - requestAnimationFrame(animate); - controls.update(); - renderer.render(scene, camera); - } - animate(); + const output = await fetch("cube.stl").then((res) => res.arrayBuffer()); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(V_BG); + + const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); + camera.position.set(20, 15, 20); - window.addEventListener("resize", () => { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); + const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); - }); + renderer.domElement.style.position = "fixed"; + renderer.domElement.style.top = "0"; + renderer.domElement.style.left = "0"; + renderer.setPixelRatio(window.devicePixelRatio); + document.body.prepend(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.update(); + + scene.add(new THREE.AmbientLight(V_FG, 2)); + const dirLight = new THREE.DirectionalLight(V_FG, 2); + dirLight.position.set(10, 20, 15); + scene.add(dirLight); + + const loader = new STLLoader(); + const geometry = loader.parse(output); + geometry.center(); + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: V_BLUE, + flatShading: false, + }); + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + function animate() { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + } + animate(); + + window.addEventListener("resize", () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + }); } init(); -document.getElementById('sidebar-close').addEventListener('click', () => { - document.getElementById('sidebar').style.display = 'none'; + +// Runtime page construction --- + +type SliderParam = { + kind: 'slider'; + name: string; label: string; + min: number; max: number; step: number; value: number; +}; + +type SelectParam = { + kind: 'select'; + name: string; label: string; + value: string; + options: { value: string; label: string }[]; + reveal?: (v: string) => string | null; +}; +type Param = SliderParam | SelectParam; + +const OPTIONS: Param[] = [ + { kind: 'slider', name: 'edge_diameter', label: 'edge_diameter', min: 0, max: 100, step: 0.05, value: 3.0 }, + { kind: 'slider', name: 'diameter_tolerance_fit', label: 'diameter_tolerance_fit (mm)', min: 0, max: 1, step: 0.01, value: 0.35 }, + { kind: 'slider', name: 'diameter_taper_fit', label: 'diameter_taper_fit (mm)', min: 0, max: 1, step: 0.01, value: 0.1 }, + { + kind: 'slider', name: 'wall_thickness', label: 'wall_thickness (mm)', min: 0, max: 40, step: 0.01, value: 1.2, + }, + { kind: 'slider', name: 'scale_factor', label: 'scale_factor', min: 0, max: 1000, step: 1, value: 100 }, + { kind: 'slider', name: 'rod_inset', label: 'rod_inset (mm)', min: 0, max: 100, step: 0.1, value: 10 }, + { kind: 'slider', name: 'max_printer_overhang_angle', label: 'max_printer_overhang_angle (deg)', min: 0, max: 30, step: 0.1, value: 15 }, + { + kind: 'select', name: 'offset_type', label: 'offset_type', value: 'auto_global', + options: [ + { value: 'fixed', label: 'fixed' }, + { value: 'auto_global', label: 'auto (global)' }, + { value: 'auto_per_vertex', label: 'auto (per_vertex)' }, + ], + reveal: v => v === 'fixed' ? 'offset' : null + }, + { + kind: 'slider', name: 'offset', label: 'offset (mm)', min: 0, max: 100, step: 0.01, value: 0, + }, +]; + +const TW_CLASS = { + row: 'flex flex-col gap-0.5 px-1.5 py-1', + top: 'flex items-center justify-between gap-1.5', + label: 'font-mono text-[11px] text-v-fg truncate', + num: 'w-16 min-w-16 font-mono text-[11px] text-v-fg bg-black border border-v-border rounded-sm px-1 py-0.5 text-right focus:outline-none focus:border-v-blue', + slider: 'dh-slider w-full h-3.5 cursor-pointer appearance-none bg-transparent', + select: 'w-full font-mono text-[11px] text-v-fg bg-black border border-v-border rounded-sm px-1 py-0.5 focus:outline-none focus:border-v-blue', +}; + +const sidebar = document.getElementById('sidebar')!; +const values: Record = {}; +const rows = new Map(); + +type SliderRow = { + row: HTMLElement; + slider: HTMLInputElement; + num: HTMLInputElement; + param: SliderParam +}; + +type SelectRow = { + row: HTMLElement; + sel: HTMLSelectElement; + param: SelectParam +}; + +function makeSlider(param: SliderParam): HTMLElement { + const row = document.createElement('div'); + row.className = TW_CLASS.row; + row.dataset.param = param.name; + row.innerHTML = + `
${param.label}` + + `
` + + ``; + return row +} + +function makeSelect(param: SelectParam): HTMLElement { + const row = document.createElement('div'); + row.className = TW_CLASS.row; + row.dataset.param = param.name; + const paramsHTML = param.options.map(o => + ``).join(''); + row.innerHTML = + `
${param.label}
` + + ``; + return row +} + +function enforceSelects() { + for (const param of OPTIONS) { + if (param.kind === 'select' && param.reveal) { + const shown = param.reveal(String(values[param.name])); + if (shown) + rows.get(shown)!.style.display = ''; + for (const o of param.options) { + const r = param.reveal(o.value); + if (r && o.value !== String(values[param.name])) + rows.get(r)!.style.display = 'none'; + } + } + } +} + +// Synchronize slider with corresponding num entry and vv +function syncSlider(r: SliderRow) { + const sync = (src: 'slider' | 'num') => { + const raw = parseFloat(src === 'slider' ? r.slider.value : r.num.value); + const v = clamp(isNaN(raw) ? 0 : raw, r.param.min, r.param.max); + r.slider.value = String(v); + r.num.value = String(v); + values[r.param.name] = v; + }; + r.slider.addEventListener('input', () => sync('slider')); + r.num.addEventListener('input', () => sync('num')); + r.num.addEventListener('change', () => sync('num')); +} + +function syncSelect(r: SelectRow) { + r.sel.addEventListener('change', () => { + values[r.param.name] = r.sel.value; + enforceSelects(); + }); +} + +// Initialize sidebar input fields +const opts = document.getElementById('sidebar-opts')!; +for (const param of OPTIONS) { + values[param.name] = param.value; + let row: HTMLElement; + if (param.kind === 'slider') { + row = makeSlider(param); + syncSlider({ + row, + slider: row.querySelector('.dh-slider')!, + num: row.querySelector('input[type="number"]')!, + param + }); + } else { + row = makeSelect(param); + syncSelect({ + row, + sel: row.querySelector('select')!, + param + }); + } + rows.set(param.name, row); + opts.appendChild(row); +} + +// Open/close sidebar +const reopen = document.getElementById('sidebar-reopen')!; +document.getElementById('sidebar-close')!.addEventListener('click', () => { + sidebar.style.display = 'none'; + reopen.style.display = 'flex'; +}); +reopen.addEventListener('click', () => { + sidebar.style.display = ''; + reopen.style.display = 'none'; +}); + +// Resize sidebar +const resizer = document.getElementById('sidebar-resizer')!; +let dragging = false, startX = 0, startW = 0; +resizer.addEventListener('mousedown', (e) => { + dragging = true; + startX = e.clientX; + startW = sidebar.offsetWidth; + document.body.style.userSelect = 'none'; + e.preventDefault(); +}); +window.addEventListener('mousemove', (e) => { + if (!dragging) return; + const w = clamp(startW + (e.clientX - startX), 160, window.innerWidth * 0.5); + sidebar.style.width = w + 'px'; + sidebar.style.maxWidth = 'none'; +}); +window.addEventListener('mouseup', () => { + dragging = false; document.body.style.userSelect = ''; }); diff --git a/webapp/index.html b/webapp/index.html index e4e5aab..de8f305 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -1,21 +1,24 @@ - + - - - - - + + Vertexprint + + + + - -
- - -
- + +
+ + + +
+ diff --git a/webapp/style.css b/webapp/style.css index 5b5db30..5833ea6 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -4,6 +4,36 @@ --color-v-fg: #B1B1B3; --color-v-bg: #232327; --color-v-dark: #0C0C0D; + --color-v-panel: #18181A; --color-v-border: #38383D; --color-v-blue: #75BAFF; } + +/* Tailwind has no utilities for sliders */ +.dh-slider::-webkit-slider-runnable-track { + height: 4px; + background: var(--color-v-border); + border-radius: 2px; +} +.dh-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + margin-top: -4px; + background: var(--color-v-blue); + border: 1px; +} +.dh-slider::-moz-range-track { + height: 4px; + background: var(--color-v-border); + border-radius: 2px; +} +.dh-slider::-moz-range-thumb { + width: 12px; + height: 12px; + background: var(--color-v-blue); + border: 1px; +} + +input[type="number"] { color-scheme: dark; } From 5907efd0d6e304f54f3a1a66e1ad90f463cbb61c Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 22 Jun 2026 22:52:47 -0700 Subject: [PATCH 08/68] refactor(webapp): make param options readable --- webapp/app.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 9c9c30f..2df57dd 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -83,13 +83,18 @@ init(); type SliderParam = { kind: 'slider'; - name: string; label: string; - min: number; max: number; step: number; value: number; + name: string; + label: string; + min: number; + max: number; + step: number; + value: number; }; type SelectParam = { kind: 'select'; - name: string; label: string; + name: string; + label: string; value: string; options: { value: string; label: string }[]; reveal?: (v: string) => string | null; @@ -97,26 +102,89 @@ type SelectParam = { type Param = SliderParam | SelectParam; const OPTIONS: Param[] = [ - { kind: 'slider', name: 'edge_diameter', label: 'edge_diameter', min: 0, max: 100, step: 0.05, value: 3.0 }, - { kind: 'slider', name: 'diameter_tolerance_fit', label: 'diameter_tolerance_fit (mm)', min: 0, max: 1, step: 0.01, value: 0.35 }, - { kind: 'slider', name: 'diameter_taper_fit', label: 'diameter_taper_fit (mm)', min: 0, max: 1, step: 0.01, value: 0.1 }, { - kind: 'slider', name: 'wall_thickness', label: 'wall_thickness (mm)', min: 0, max: 40, step: 0.01, value: 1.2, + kind: 'slider', + name: 'edge_diameter', + label: 'edge_diameter', + min: 0, + max: 100, + step: 0.05, + value: 3.0 }, - { kind: 'slider', name: 'scale_factor', label: 'scale_factor', min: 0, max: 1000, step: 1, value: 100 }, - { kind: 'slider', name: 'rod_inset', label: 'rod_inset (mm)', min: 0, max: 100, step: 0.1, value: 10 }, - { kind: 'slider', name: 'max_printer_overhang_angle', label: 'max_printer_overhang_angle (deg)', min: 0, max: 30, step: 0.1, value: 15 }, { - kind: 'select', name: 'offset_type', label: 'offset_type', value: 'auto_global', + kind: 'slider', + name: 'diameter_tolerance_fit', + label: 'diameter_tolerance_fit (mm)', + min: 0, + max: 1, + step: 0.01, + value: 0.35 + }, + { + kind: 'slider', + name: 'diameter_taper_fit', + label: 'diameter_taper_fit (mm)', + min: 0, + max: 1, + step: 0.01, + value: 0.1 + }, + { + kind: 'slider', + name: 'wall_thickness', + label: 'wall_thickness (mm)', + min: 0, + max: 40, + step: 0.01, + value: 1.2, + }, + { + kind: 'slider', + name: 'scale_factor', + label: 'scale_factor', + min: 0, + max: 1000, + step: 1, + value: 100, + }, + { + kind: 'slider', + name: 'rod_inset', + label: 'rod_inset (mm)', + min: 0, + max: 100, + step: 0.1, + value: 10, + }, + { + kind: 'slider', + name: 'max_printer_overhang_angle', + label: 'max_printer_overhang_angle (deg)', + min: 0, + max: 30, + step: 0.1, + value: 15, + }, + { + kind: 'select', + name: 'offset_type', + label: 'offset_type', + value: 'auto_global', options: [ { value: 'fixed', label: 'fixed' }, { value: 'auto_global', label: 'auto (global)' }, { value: 'auto_per_vertex', label: 'auto (per_vertex)' }, ], - reveal: v => v === 'fixed' ? 'offset' : null + reveal: (v) => v === 'fixed' ? 'offset' : null, }, { - kind: 'slider', name: 'offset', label: 'offset (mm)', min: 0, max: 100, step: 0.01, value: 0, + kind: 'slider', + name: 'offset', + label: 'offset (mm)', + min: 0, + max: 100, + step: 0.01, + value: 0, }, ]; From 1ae50c9c7b2a4809f3964789073524f730abb70d Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 22 Jun 2026 22:57:44 -0700 Subject: [PATCH 09/68] fix: resizing no longer flickers --- webapp/app.ts | 6 +++++- webapp/index.html | 4 ++-- webapp/style.css | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 2df57dd..fdff8ea 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -297,6 +297,7 @@ for (const param of OPTIONS) { rows.set(param.name, row); opts.appendChild(row); } +enforceSelects(); // Open/close sidebar const reopen = document.getElementById('sidebar-reopen')!; @@ -314,6 +315,7 @@ const resizer = document.getElementById('sidebar-resizer')!; let dragging = false, startX = 0, startW = 0; resizer.addEventListener('mousedown', (e) => { dragging = true; + resizer.classList.add('dragging'); startX = e.clientX; startW = sidebar.offsetWidth; document.body.style.userSelect = 'none'; @@ -326,5 +328,7 @@ window.addEventListener('mousemove', (e) => { sidebar.style.maxWidth = 'none'; }); window.addEventListener('mouseup', () => { - dragging = false; document.body.style.userSelect = ''; + dragging = false; + resizer.classList.remove('dragging'); + document.body.style.userSelect = ''; }); diff --git a/webapp/index.html b/webapp/index.html index de8f305..a244a55 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -12,12 +12,12 @@ - + diff --git a/webapp/style.css b/webapp/style.css index 5833ea6..fd9759d 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -37,3 +37,7 @@ } input[type="number"] { color-scheme: dark; } + +/* Highlight while resizing. The cursor can slip off the 2px wide handle, so + * :hover fails */ +#sidebar-resizer.dragging { background-color: var(--color-v-blue); } From 0ce135b410a1c5d1779c4949f936da6333f6745d Mon Sep 17 00:00:00 2001 From: agentelement Date: Tue, 23 Jun 2026 00:16:52 -0700 Subject: [PATCH 10/68] webapp: rename slider options --- webapp/app.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index fdff8ea..e7639cf 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -105,7 +105,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'edge_diameter', - label: 'edge_diameter', + label: 'Rod diameter', min: 0, max: 100, step: 0.05, @@ -114,7 +114,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'diameter_tolerance_fit', - label: 'diameter_tolerance_fit (mm)', + label: 'Diameter tolerance', min: 0, max: 1, step: 0.01, @@ -123,7 +123,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'diameter_taper_fit', - label: 'diameter_taper_fit (mm)', + label: 'Diameter taper', min: 0, max: 1, step: 0.01, @@ -132,7 +132,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'wall_thickness', - label: 'wall_thickness (mm)', + label: 'Wall thickness', min: 0, max: 40, step: 0.01, @@ -141,7 +141,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'scale_factor', - label: 'scale_factor', + label: 'Scale', min: 0, max: 1000, step: 1, @@ -150,7 +150,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'rod_inset', - label: 'rod_inset (mm)', + label: 'Rod inset depth', min: 0, max: 100, step: 0.1, @@ -159,7 +159,7 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'max_printer_overhang_angle', - label: 'max_printer_overhang_angle (deg)', + label: 'Maximum overhang angle', min: 0, max: 30, step: 0.1, @@ -168,19 +168,19 @@ const OPTIONS: Param[] = [ { kind: 'select', name: 'offset_type', - label: 'offset_type', + label: 'Offset type', value: 'auto_global', options: [ - { value: 'fixed', label: 'fixed' }, - { value: 'auto_global', label: 'auto (global)' }, - { value: 'auto_per_vertex', label: 'auto (per_vertex)' }, + { value: 'fixed', label: 'Manual' }, + { value: 'auto_global', label: 'Auto (global)' }, + { value: 'auto_per_vertex', label: 'Auto (per-vertex)' }, ], reveal: (v) => v === 'fixed' ? 'offset' : null, }, { kind: 'slider', name: 'offset', - label: 'offset (mm)', + label: 'Offset', min: 0, max: 100, step: 0.01, From 62103e85532961d009f8981b29bffc7fac019bcd Mon Sep 17 00:00:00 2001 From: agentelement Date: Tue, 23 Jun 2026 00:59:51 -0700 Subject: [PATCH 11/68] webapp: units for numeric inputs --- webapp/app.ts | 21 ++++++++++++++++----- webapp/style.css | 1 + 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index e7639cf..cde7de2 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -89,6 +89,7 @@ type SliderParam = { max: number; step: number; value: number; + unit: string; }; type SelectParam = { @@ -109,7 +110,8 @@ const OPTIONS: Param[] = [ min: 0, max: 100, step: 0.05, - value: 3.0 + value: 3.0, + unit: 'mm', }, { kind: 'slider', @@ -118,7 +120,8 @@ const OPTIONS: Param[] = [ min: 0, max: 1, step: 0.01, - value: 0.35 + value: 0.35, + unit: 'mm', }, { kind: 'slider', @@ -127,7 +130,8 @@ const OPTIONS: Param[] = [ min: 0, max: 1, step: 0.01, - value: 0.1 + value: 0.1, + unit: 'mm', }, { kind: 'slider', @@ -137,6 +141,7 @@ const OPTIONS: Param[] = [ max: 40, step: 0.01, value: 1.2, + unit: 'mm', }, { kind: 'slider', @@ -146,6 +151,7 @@ const OPTIONS: Param[] = [ max: 1000, step: 1, value: 100, + unit: '%', }, { kind: 'slider', @@ -155,6 +161,7 @@ const OPTIONS: Param[] = [ max: 100, step: 0.1, value: 10, + unit: 'mm', }, { kind: 'slider', @@ -164,6 +171,7 @@ const OPTIONS: Param[] = [ max: 30, step: 0.1, value: 15, + unit: '°', }, { kind: 'select', @@ -185,6 +193,7 @@ const OPTIONS: Param[] = [ max: 100, step: 0.01, value: 0, + unit: 'mm', }, ]; @@ -192,7 +201,8 @@ const TW_CLASS = { row: 'flex flex-col gap-0.5 px-1.5 py-1', top: 'flex items-center justify-between gap-1.5', label: 'font-mono text-[11px] text-v-fg truncate', - num: 'w-16 min-w-16 font-mono text-[11px] text-v-fg bg-black border border-v-border rounded-sm px-1 py-0.5 text-right focus:outline-none focus:border-v-blue', + num: 'w-16 min-w-16 font-mono text-[11px] leading-none text-v-fg bg-black border border-v-border rounded-sm py-0.5 pl-1 pr-5 text-left appearance-none focus:outline-none focus:border-v-blue', + unit: 'pointer-events-none absolute right-1 inset-y-0 flex items-center translate-y-px font-mono text-[11px] leading-none text-v-fg/70', slider: 'dh-slider w-full h-3.5 cursor-pointer appearance-none bg-transparent', select: 'w-full font-mono text-[11px] text-v-fg bg-black border border-v-border rounded-sm px-1 py-0.5 focus:outline-none focus:border-v-blue', }; @@ -220,7 +230,8 @@ function makeSlider(param: SliderParam): HTMLElement { row.dataset.param = param.name; row.innerHTML = `
${param.label}` + - `
` + + `
` + + `${param.unit}
` + ``; return row } diff --git a/webapp/style.css b/webapp/style.css index fd9759d..19a5f6c 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -37,6 +37,7 @@ } input[type="number"] { color-scheme: dark; } +input[type="number"] { -moz-appearance: textfield; } /* Highlight while resizing. The cursor can slip off the 2px wide handle, so * :hover fails */ From 2221d346cc170331a8db18a816dcd14dbd9e902a Mon Sep 17 00:00:00 2001 From: agentelement Date: Tue, 23 Jun 2026 02:32:27 -0700 Subject: [PATCH 12/68] webapp: hover-text descriptions --- webapp/app.ts | 24 ++++++++++++++++++------ webapp/style.css | 2 ++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index cde7de2..439e9c0 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; @@ -85,6 +86,7 @@ type SliderParam = { kind: 'slider'; name: string; label: string; + desc: string; min: number; max: number; step: number; @@ -96,6 +98,7 @@ type SelectParam = { kind: 'select'; name: string; label: string; + desc: string; value: string; options: { value: string; label: string }[]; reveal?: (v: string) => string | null; @@ -107,6 +110,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'edge_diameter', label: 'Rod diameter', + desc: 'Diameter of your dowel rods.', min: 0, max: 100, step: 0.05, @@ -117,6 +121,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'diameter_tolerance_fit', label: 'Diameter tolerance', + desc: 'Additional tolerance added to the diameter.\nI recommend adding about 12% of your diameter for wood dowel rods, and 5% for metal', min: 0, max: 1, step: 0.01, @@ -127,6 +132,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'diameter_taper_fit', label: 'Diameter taper', + desc: 'The rod holder diameter decreases by this amount. A small taper is helpful to account for small amounts of unevenness in the diameters of your dowel rods, particularly for wood dowel rods', min: 0, max: 1, step: 0.01, @@ -137,6 +143,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'wall_thickness', label: 'Wall thickness', + desc: 'Thickness of the tube walls.', min: 0, max: 40, step: 0.01, @@ -147,6 +154,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'scale_factor', label: 'Scale', + desc: 'Scale factor. Vertexprinted objects are typically larger than than the original object, so this starts out large.', min: 0, max: 1000, step: 1, @@ -156,7 +164,8 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'rod_inset', - label: 'Rod inset depth', + label: 'Tube depth', + desc: 'The depth of each tube', min: 0, max: 100, step: 0.1, @@ -167,9 +176,10 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'max_printer_overhang_angle', label: 'Maximum overhang angle', + desc: 'The maximum overhang angle your printer allows', min: 0, - max: 30, - step: 0.1, + max: 90, + step: 5, value: 15, unit: '°', }, @@ -177,6 +187,7 @@ const OPTIONS: Param[] = [ kind: 'select', name: 'offset_type', label: 'Offset type', + desc: 'placeholder', // complicated explanation value: 'auto_global', options: [ { value: 'fixed', label: 'Manual' }, @@ -189,6 +200,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'offset', label: 'Offset', + desc: 'placeholder', // see comment above min: 0, max: 100, step: 0.01, @@ -200,7 +212,7 @@ const OPTIONS: Param[] = [ const TW_CLASS = { row: 'flex flex-col gap-0.5 px-1.5 py-1', top: 'flex items-center justify-between gap-1.5', - label: 'font-mono text-[11px] text-v-fg truncate', + label: 'opt-label font-mono text-[11px] text-v-fg truncate', num: 'w-16 min-w-16 font-mono text-[11px] leading-none text-v-fg bg-black border border-v-border rounded-sm py-0.5 pl-1 pr-5 text-left appearance-none focus:outline-none focus:border-v-blue', unit: 'pointer-events-none absolute right-1 inset-y-0 flex items-center translate-y-px font-mono text-[11px] leading-none text-v-fg/70', slider: 'dh-slider w-full h-3.5 cursor-pointer appearance-none bg-transparent', @@ -229,7 +241,7 @@ function makeSlider(param: SliderParam): HTMLElement { row.className = TW_CLASS.row; row.dataset.param = param.name; row.innerHTML = - `
${param.label}` + + `
${param.label}` + `
` + `${param.unit}
` + ``; @@ -243,7 +255,7 @@ function makeSelect(param: SelectParam): HTMLElement { const paramsHTML = param.options.map(o => ``).join(''); row.innerHTML = - `
${param.label}
` + + `
${param.label}
` + ``; return row } diff --git a/webapp/style.css b/webapp/style.css index 19a5f6c..6d7af82 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -42,3 +42,5 @@ input[type="number"] { -moz-appearance: textfield; } /* Highlight while resizing. The cursor can slip off the 2px wide handle, so * :hover fails */ #sidebar-resizer.dragging { background-color: var(--color-v-blue); } + +.opt-label:hover { text-decoration: underline; } From b7e0e0ddeb3ea8a070a0094376e9e19c10c6fed6 Mon Sep 17 00:00:00 2001 From: agentelement Date: Wed, 24 Jun 2026 02:25:49 -0700 Subject: [PATCH 13/68] webapp: narrow sidebar slightly --- webapp/app.ts | 14 +++++++------- webapp/index.html | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 439e9c0..a4187b3 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -46,7 +46,7 @@ async function init() { const controls = new OrbitControls(camera, renderer.domElement); controls.update(); - scene.add(new THREE.AmbientLight(V_FG, 2)); + scene.add(new THREE.AmbientLight(V_DARK, 2)); const dirLight = new THREE.DirectionalLight(V_FG, 2); dirLight.position.set(10, 20, 15); scene.add(dirLight); @@ -241,10 +241,10 @@ function makeSlider(param: SliderParam): HTMLElement { row.className = TW_CLASS.row; row.dataset.param = param.name; row.innerHTML = - `
${param.label}` + - `
` + - `${param.unit}
` + - ``; + `
${param.label}` + + `
` + + `${param.unit}
` + + ``; return row } @@ -255,8 +255,8 @@ function makeSelect(param: SelectParam): HTMLElement { const paramsHTML = param.options.map(o => ``).join(''); row.innerHTML = - `
${param.label}
` + - ``; + `
${param.label}
` + + ``; return row } diff --git a/webapp/index.html b/webapp/index.html index a244a55..e4895cb 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -9,7 +9,7 @@
-
From ea32e55c6bcf760da4aff02046c6fd777edd60cb Mon Sep 17 00:00:00 2001 From: agentelement Date: Sat, 27 Jun 2026 00:40:36 -0700 Subject: [PATCH 20/68] webapp: orientation cube in the bottom-left --- webapp/app.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 67f3d7d..65170d0 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -5,7 +5,6 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; // utils --- - function clamp(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); } @@ -24,8 +23,65 @@ const V_BORDER = cssVar("--color-v-border"); const V_BLUE = cssVar("--color-v-blue"); -// Canvas logic. Placeholder. --- +function makeOrientationCube(renderer: THREE.WebGLRenderer): + [THREE.Mesh, THREE.Scene, THREE.OrthographicCamera] { + const scene = new THREE.Scene(); + const cam = new THREE.OrthographicCamera(); + cam.position.set(0, 0, 1); + + const FACE_COLOR = '#' + V_BORDER.toString(16).padStart(6, '0'); + const TEXT_COLOR = '#' + V_FG.toString(16).padStart(6, '0'); + const TEXTURE_SIZE = 256; + + const maxAnisotropy = renderer.capabilities.getMaxAnisotropy(); + function makeFaceLabel(text: string): THREE.CanvasTexture { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = TEXTURE_SIZE; + const context = canvas.getContext("2d")!; + context.fillStyle = FACE_COLOR; + context.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE); + context.fillStyle = TEXT_COLOR; + context.font = `48px monospace`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(text, TEXTURE_SIZE / 2, TEXTURE_SIZE / 2); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = maxAnisotropy; + return texture; + } + + const faceLabels: string[] = [ + "Right", // +x + "Left", // -x + "Top", // +y + "Bottom", // -y + "Front", // +z + "Back" // -z + ]; + + const cubeMaterials = faceLabels.map(label => { + const texture = makeFaceLabel(label); + return new THREE.MeshPhongMaterial({ + map: texture, + }); + }); + + const cube = new THREE.Mesh( + new THREE.BoxGeometry(0.75, 0.75, 0.75), + cubeMaterials + ); + scene.add(cube); + scene.add(new THREE.AmbientLight(0xffffff, 1)); + const directionalLight = new THREE.DirectionalLight(V_FG, 2); + directionalLight.position.set(1, 1, 1); + scene.add(directionalLight); + + return [cube, scene, cam] +} + +// Canvas logic. Placeholder. --- async function init() { const output = await fetch("cube.stl").then((res) => res.arrayBuffer()); @@ -47,9 +103,9 @@ async function init() { controls.update(); scene.add(new THREE.AmbientLight(V_DARK, 2)); - const dirLight = new THREE.DirectionalLight(V_FG, 2); - dirLight.position.set(10, 20, 15); - scene.add(dirLight); + const directionalLight = new THREE.DirectionalLight(V_FG, 2); + directionalLight.position.set(10, 20, 15); + scene.add(directionalLight); const loader = new STLLoader(); const geometry = loader.parse(output); @@ -63,10 +119,39 @@ async function init() { const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); + + const orientationCubeTuple = makeOrientationCube(renderer); + const cube = orientationCubeTuple[0]; + const cubeScene = orientationCubeTuple[1]; + const cubeCamera = orientationCubeTuple[2]; + + const ORENT_CUBE_SIZE = 240; + renderer.autoClear = false; + renderer.setScissorTest(true); + function animate() { requestAnimationFrame(animate); controls.update(); + + const W = window.innerWidth; + const H = window.innerHeight; + + // Full-screen main scene + renderer.setViewport(0, 0, W, H); + renderer.setScissor(0, 0, W, H); + renderer.clear(); renderer.render(scene, camera); + + // Counter-rotate the orientation cube so its faces track the camera's + // orientation (ie the cube shows where the camera looks). + cube.quaternion.copy(camera.quaternion).invert(); + + const gx = W - ORENT_CUBE_SIZE; // right edge + const gy = 0; // bottom edge (GL y is measured from bottom) + renderer.setViewport(gx, gy, ORENT_CUBE_SIZE, ORENT_CUBE_SIZE); + renderer.setScissor(gx, gy, ORENT_CUBE_SIZE, ORENT_CUBE_SIZE); + renderer.clearDepth(); + renderer.render(cubeScene, cubeCamera); } animate(); From 67cde363b2dfef902e2611898bab06f9e38b81c0 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 6 Jul 2026 20:33:07 -0700 Subject: [PATCH 21/68] webapp: presets button and menu --- webapp/app.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++ webapp/index.html | 6 ++++ 2 files changed, 87 insertions(+) diff --git a/webapp/app.ts b/webapp/app.ts index 65170d0..d625e35 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -478,3 +478,84 @@ inspector_reopen.addEventListener('click', () => { inspector.style.display = ''; inspector_reopen.style.display = 'none'; }); + +// Presets dropdown entries +const PRESETS: Record> = { + 'Platonic solids': { + 'Tetrahedron': 'Tetrahedron.txt', + 'Cube': 'Cube.txt', + 'Octahedron': 'Octahedron.txt', + 'Dodecahedron': 'Dodecahedron.txt', + 'Icosahedron': 'Icosahedron.txt', + }, + 'Archimedean solids': { + 'Truncated Tetrahedron': 'TruncatedTetrahedron.txt', + 'Cuboctahedron': 'Cuboctahedron.txt', + 'Truncated Cube': 'TruncatedCube.txt', + 'Truncated Octahedron': 'TruncatedOctahedron.txt', + 'Rhombicuboctahedron': 'Rhombicuboctahedron.txt', + 'Truncated Cuboctahedron': 'TruncatedCuboctahedron.txt', + 'Snub Cube (laevo)': 'LsnubCube.txt', + 'Icosidodecahedron': 'Icosidodecahedron.txt', + 'Truncated Dodecahedron': 'TruncatedDodecahedron.txt', + 'Truncated Icosahedron': 'TruncatedIcosahedron.txt', + 'Rhombicosidodecahedron': 'Rhombicosidodecahedron.txt', + 'Truncated Icosidodecahedron': 'TruncatedIcosidodecahedron.txt', + 'Snub Dodecahedron (laevo)': 'LsnubDodecahedron.txt', + }, + 'Catalan solids': { + 'Triakis Tetrahedron': 'TriakisTetrahedron.txt', + 'Rhombic Dodecahedron': 'RhombicDodecahedron.txt', + 'Triakis Octahedron': 'TriakisOctahedron.txt', + 'Tetrakis Hexahedron': 'TetrakisHexahedron.txt', + 'Deltoidal Icositetrahedron': 'DeltoidalIcositetrahedron.txt', + 'Disdyakis Dodecahedron': 'DisdyakisDodecahedron.txt', + 'Pentagonal Icositetrahedron (laevo)': 'LpentagonalIcositetrahedron.txt', + 'Rhombic Triacontahedron': 'RhombicTriacontahedron.txt', + 'Triakis Icosahedron': 'TriakisIcosahedron.txt', + 'Pentakis Dodecahedron': 'PentakisDodecahedron.txt', + 'Deltoidal Hexecontahedron': 'DeltoidalHexecontahedron.txt', + 'Disdyakis Triacontahedron': 'DisdyakisTriacontahedron.txt', + 'Pentagonal Hexecontahedron (laevo)': 'LpentagonalHexecontahedron.txt', + }, + 'Misc': { + 'Stanford Bunny': 'Bunny-LowPoly.stl', + 'Rhombic Dodecahedron Tiling': '13RhombicDodecahedra.obj', + }, +}; + +const presets_btn = document.getElementById('presets-btn')!; +const presets_menu = document.getElementById('presets-menu')!; + +for (const [category, solids] of Object.entries(PRESETS)) { + const header = document.createElement('p'); + header.textContent = category; + header.className = 'font-mono text-xs px-3 pt-2 pb-1 text-v-fg/60 border-b border-v-border min-w-full'; + presets_menu.appendChild(header); + for (const [label, file] of Object.entries(solids)) { + const item = document.createElement('button'); + item.type = 'button'; + item.textContent = label; + item.dataset.file = file; + item.className = 'block w-full cursor-pointer text-left font-mono text-xs px-3 py-1 text-v-fg hover:bg-v-blue hover:text-v-dark'; + presets_menu.appendChild(item); + } +} + +presets_btn.addEventListener('click', (e) => { + e.stopPropagation(); + presets_menu.classList.toggle('hidden'); +}); + +// Close the menu when clicking outside it or selecting an item. +document.addEventListener('click', (e) => { + if (!presets_menu.contains(e.target as Node) && e.target !== presets_btn) { + presets_menu.classList.add('hidden'); + } +}); +presets_menu.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.tagName === 'BUTTON') { + presets_menu.classList.add('hidden'); + } +}); diff --git a/webapp/index.html b/webapp/index.html index baf8e30..a836677 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -60,6 +60,12 @@ +
+ + +
From 84ccea7f686e861c064d78e31df4de3057b79129 Mon Sep 17 00:00:00 2001 From: agentelement Date: Tue, 7 Jul 2026 00:07:36 -0700 Subject: [PATCH 22/68] webapp: class titles for all the buttons --- webapp/app.ts | 6 +----- webapp/index.html | 28 ++++++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index d625e35..f42b440 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -119,11 +119,7 @@ async function init() { const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); - - const orientationCubeTuple = makeOrientationCube(renderer); - const cube = orientationCubeTuple[0]; - const cubeScene = orientationCubeTuple[1]; - const cubeCamera = orientationCubeTuple[2]; + const [cube, cubeScene, cubeCamera] = makeOrientationCube(renderer); const ORENT_CUBE_SIZE = 240; renderer.autoClear = false; diff --git a/webapp/index.html b/webapp/index.html index a836677..3943356 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -5,6 +5,7 @@ + @@ -16,21 +17,21 @@ - -
+

Construct

- - From 2569eb8f6b3eb5b6ad923d0090696a738518956e Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 16:29:05 -0700 Subject: [PATCH 56/68] webapp: place meshes in a root group and rotate by 90 degrees --- webapp/app.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index c8da128..21830e3 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -94,6 +94,7 @@ class Canvas { scene: THREE.Scene; camera: THREE.PerspectiveCamera; controls: OrbitControls; + root: THREE.Group; currentMesh: THREE.Mesh | null; currentName: string; @@ -129,7 +130,9 @@ class Canvas { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(V_BG); - this.camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); + this.root = new THREE.Group(); + this.root.rotation.x = -Math.PI / 2; + this.scene.add(this.root); this.camera.position.set(20, 15, 20); const renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -154,7 +157,7 @@ class Canvas { this.scene.add(fillLight); const mesh = new THREE.Mesh(new THREE.BufferGeometry(), this.meshMaterial); - this.scene.add(mesh); + this.root.add(mesh); this.currentMesh = mesh; await this.loadFile(DEFAULT_MODEL); @@ -200,7 +203,7 @@ class Canvas { // Reposition camera so every mesh currently in the scene is in frame, // preserving camera orientation. fitView() { - const box = new THREE.Box3().setFromObject(this.scene); + const box = new THREE.Box3().setFromObject(this.root); if (box.isEmpty()) return; const sphere = new THREE.Sphere(); box.getBoundingSphere(sphere); @@ -284,7 +287,7 @@ class Canvas { const z = PARAMS.scale * position[2]; mesh.position.set(x, y, z); mesh.rotation.set(rotation[0], rotation[1], rotation[2], "ZYX"); - this.scene.add(mesh); + this.root.add(mesh); this.currentVertices.push(mesh) } @@ -338,7 +341,7 @@ class Canvas { mesh.rotation.set(0, theta, phi, "ZYX"); - this.scene.add(mesh); + this.root.add(mesh); this.currentVertices.push(mesh); } } @@ -347,7 +350,7 @@ class Canvas { clear() { for (const mesh of this.currentVertices) { mesh.geometry.dispose(); - this.scene.remove(mesh); + this.root.remove(mesh); } this.currentVertices = []; if (this.currentMesh) { From 3fbbfce186274a4ebb712a6517cdf90710efe5fa Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:04:34 -0700 Subject: [PATCH 57/68] webapp: render quality option --- webapp/app.ts | 11 +++++++++++ webapp/core.ts | 10 ++++++++++ webapp/vertexprint.scad | 5 ++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/webapp/app.ts b/webapp/app.ts index 21830e3..0b066d3 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -508,6 +508,17 @@ const OPTIONS: Param[] = [ value: 0, unit: 'mm', }, + { + kind: 'select', + name: 'renderQuality', + label: 'Render quality', + desc: 'placeholder', + value: 'preview', + options: [ + { value: 'preview', label: 'Preview' }, + { value: 'final', label: 'Final' }, + ], + }, ]; const TW_CLASS = { diff --git a/webapp/core.ts b/webapp/core.ts index 7553169..e6fe44a 100644 --- a/webapp/core.ts +++ b/webapp/core.ts @@ -22,6 +22,7 @@ export class VertexPrintParams { minPrinterOverhangAngle: number; offsetType: string; manualOffset: number; + renderQuality: "preview" | "final"; }; export class VertexPrintOutputs { @@ -760,6 +761,15 @@ class OpenscadArgs { `-DMIN_PRINTER_OVERHANG_ANGLE=${this.options.minPrinterOverhangAngle}`, ); + const fparams = { + // fa, fs + "preview": [12, 2], + "final": [30, 0.2], + } + + args.push(`-DFA=${fparams[this.options.renderQuality][0]}`) + args.push(`-DFS=${fparams[this.options.renderQuality][1]}`) + args.push(`-Dindex=${vertex}`) // vertexFigures (std) const vertexFigure = this.vertexFigures[vertex] diff --git a/webapp/vertexprint.scad b/webapp/vertexprint.scad index b53b489..e73132a 100644 --- a/webapp/vertexprint.scad +++ b/webapp/vertexprint.scad @@ -16,6 +16,9 @@ OUTER_TUBE_RADIUS = EDGE_DIAMETER/2+WALL_THICKNESS; INDEX = 0; +FA=12; +FS=1; + // Openscad hangs if you don't set these lists with a flag vertex_figure = []; vertex_figure_edge = []; @@ -231,4 +234,4 @@ module vertex_holder(index) { tubular_vertex_holder(vertex_figure, offsets, vertex_figure_edge, index); } -vertex_holder(); +vertex_holder($fa=FA, $fs=FS); From 24269f98c57835bd2a1f2b9bbd34835c8a113e3d Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:22:35 -0700 Subject: [PATCH 58/68] webapp: switch to orthographic camera --- webapp/app.ts | 51 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 0b066d3..0e9007a 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -92,9 +92,10 @@ function makeOrientationCube(renderer: THREE.WebGLRenderer): class Canvas { scene: THREE.Scene; - camera: THREE.PerspectiveCamera; + camera: THREE.OrthographicCamera; controls: OrbitControls; root: THREE.Group; + viewHalfHeight: number; currentMesh: THREE.Mesh | null; currentName: string; @@ -133,7 +134,13 @@ class Canvas { this.root = new THREE.Group(); this.root.rotation.x = -Math.PI / 2; this.scene.add(this.root); + + this.camera = new THREE.OrthographicCamera(); this.camera.position.set(20, 15, 20); + this.camera.near = 0.1; + this.camera.far = 1000; + this.viewHalfHeight = 15; + this.updateFrustum(); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); @@ -149,17 +156,24 @@ class Canvas { this.scene.add(new THREE.HemisphereLight(V_FG, V_BG, 1)); const keyLight = new THREE.DirectionalLight(V_FG, 2); - keyLight.position.set(10, 20, 15); - this.scene.add(keyLight); + this.camera.add(keyLight); + this.camera.add(keyLight.target); + keyLight.position.set(0, 0, 0); + keyLight.target.position.set(0, 0, -1); const fillLight = new THREE.DirectionalLight(V_FG, 0.8); - fillLight.position.set(-10, -5, -15); - this.scene.add(fillLight); + this.camera.add(fillLight); + this.camera.add(fillLight.target); + fillLight.position.set(1, -1, 0); + fillLight.target.position.set(0, 0, -1); + + this.scene.add(this.camera); const mesh = new THREE.Mesh(new THREE.BufferGeometry(), this.meshMaterial); this.root.add(mesh); this.currentMesh = mesh; - await this.loadFile(DEFAULT_MODEL); + const data = await fetch(DATA_DIR + DEFAULT_MODEL).then((r) => r.arrayBuffer()); + this.loadGeometry(DEFAULT_MODEL, data); const [cube, cubeScene, cubeCamera] = makeOrientationCube(renderer); @@ -194,12 +208,24 @@ class Canvas { animate(); window.addEventListener("resize", () => { - this.camera.aspect = window.innerWidth / window.innerHeight; - this.camera.updateProjectionMatrix(); + this.updateFrustum(); renderer.setSize(window.innerWidth, window.innerHeight); }); } + // Recompute the orthographic view frustum from the current half-height + // and window aspect ratio. + updateFrustum() { + const aspect = window.innerWidth / window.innerHeight; + const h = this.viewHalfHeight; + const w = h * aspect; + this.camera.left = -w; + this.camera.right = w; + this.camera.top = h; + this.camera.bottom = -h; + this.camera.updateProjectionMatrix(); + } + // Reposition camera so every mesh currently in the scene is in frame, // preserving camera orientation. fitView() { @@ -210,9 +236,7 @@ class Canvas { const radius = sphere.radius || 1; const center = sphere.center; - const fovV = (this.camera.fov * Math.PI) / 180; - const fovH = 2 * Math.atan(Math.tan(fovV / 2) * this.camera.aspect); - const dist = (radius / Math.sin(Math.min(fovV, fovH) / 2)) * 1.25; + const dist = radius * 3; const dir = new THREE.Vector3() .copy(this.camera.position) @@ -223,9 +247,12 @@ class Canvas { this.controls.target.copy(center); this.controls.update(); + const aspect = window.innerWidth / window.innerHeight; + this.viewHalfHeight = (radius / Math.min(1, aspect)) * 1.25; + this.camera.near = Math.max(0.1, radius / 100); this.camera.far = dist + radius * 10; - this.camera.updateProjectionMatrix(); + this.updateFrustum(); } // Load and display geometry. The file extension determines how `data` is From 4f5c7829f7639291a77bae04a7976ce8c26fa9bd Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:23:43 -0700 Subject: [PATCH 59/68] fix(webapp): computeEdgeLengths() now does not rescale --- webapp/core.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/webapp/core.ts b/webapp/core.ts index e6fe44a..02fe4a4 100644 --- a/webapp/core.ts +++ b/webapp/core.ts @@ -530,15 +530,6 @@ class Polyhedron { // rod length = scale * |v1 - v2| - offset(v1, v2) - offset(v2, v1) // value appended to self.edges computeEdgeLengths(): void { - let maxDist = 0; - for (let i = 0; i < this.vertices.rows; i++) { - const d = this.vertices.getRowVector(i).norm(); - if (d > maxDist) { - maxDist = d; - } - } - const scale = this.options.scale / maxDist; - type KLO = { key: string; length: number; offsetLength: number, offsets: [number, number] }; const klo: KLO[] = []; for (const key of this.edges.keys()) { @@ -547,7 +538,8 @@ class Polyhedron { const v1_arr = this.vertices.getRowVector(v1); const v2_arr = this.vertices.getRowVector(v2); const length = Matrix.sub(v2_arr, v1_arr).norm(); - const offsetLength = scale * length - v1_offset - v2_offset; + const offsetLength = this.options.scale * length - v1_offset - v2_offset; + console.log(this.options.scale * length) klo.push({ key, length, offsetLength, offsets: [v1_offset, v2_offset] }); } From 9ff4c28bea8a168dedd22194590435dbf8042b58 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:25:46 -0700 Subject: [PATCH 60/68] webapp: populate vertex/edge inspector on generation --- webapp/app.ts | 77 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 0e9007a..bb8d243 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -260,6 +260,8 @@ class Canvas { loadGeometry(name: string, data: ArrayBuffer) { if (!this.currentMesh) return; this.clear(); + this.currentName = name; + this.currentData = data; let geometry: THREE.BufferGeometry; try { const isObj = name.toLowerCase().endsWith(".obj"); @@ -281,23 +283,10 @@ class Canvas { geometry.center(); geometry.computeVertexNormals(); this.currentMesh.geometry = geometry; - this.fitView(); - } - - // Load a preset. - async loadFile(filename: string) { - const data = await fetch(DATA_DIR + filename).then((r) => r.arrayBuffer()) - this.currentName = filename; - this.currentData = data; - return this.loadGeometry(filename, data); - } - // Load a file selected through the upload button. - async loadUserFile(file: File) { - const data = await file.arrayBuffer(); - this.currentName = file.name; - this.currentData = data; - return this.loadGeometry(file.name, data); + disableDownloadButton(); + resetInspector(); + this.fitView(); } loadVertexPrintOutputs(outputs: VertexPrintOutputs) { @@ -742,12 +731,51 @@ function initInspector() { inspector.style.display = 'none'; inspector_reopen.style.display = 'flex'; }); + inspector_reopen.addEventListener('click', () => { inspector.style.display = ''; inspector_reopen.style.display = 'none'; }); } +// Reset the inspector panes +function resetInspector() { + const msg = 'This pane will populate after you generate artifacts'; + document.querySelectorAll('.inspector-pane').forEach((p) => { + p.innerHTML = `

${msg}

`; + }); +} + +// Fill the vertex inspector with vertex-edge adjacencies +// Fill the edge inspector with edge-vertex adjacencies, and offset-adjusted edge length +function populateInspector(outputs: VertexPrintOutputs) { + const polyhedron = outputs.polyhedron; + const verticesPane = document.querySelector('.inspector-pane[data-pane="vertices"]')!; + const edgesPane = document.querySelector('.inspector-pane[data-pane="edges"]')!; + + const vertexCount = polyhedron.vertexFigures.length; + let vhtml = `

${vertexCount} vertices

`; + vhtml += `
index, edges
`; + for (const vf of polyhedron.vertexFigures) { + vhtml += `
v${vf.vertexIndex} [${vf.edges.join(", ")}]
`; + } + verticesPane.innerHTML = vhtml; + + const edges = [...polyhedron.edges.entries()] + .map(([key, field]) => { + const [v1, v2] = key.split(",").map(Number); + return { name: field.name, v1, v2, offsetLength: field.offsetLength }; + }) + .sort((a, b) => a.name - b.name); + + let ehtml = `

${edges.length} edges

`; + ehtml += `
index, vertices, length (mm)
`; + for (const e of edges) { + ehtml += `
e${e.name} [${e.v1} ${e.v2}] ${e.offsetLength.toFixed(2)}
`; + } + edgesPane.innerHTML = ehtml; +} + // Presets dropdown entries const PRESETS: Record> = { 'Platonic solids': { @@ -808,9 +836,9 @@ function initPresetsMenu(canvas: Canvas) { item.textContent = label; item.dataset.file = file; item.className = 'block w-full cursor-pointer text-left font-mono text-xs px-3 py-1 text-v-fg hover:bg-v-blue hover:text-v-dark'; - item.addEventListener('click', () => { - disableDownloadButton(); - void canvas.loadFile(file); + item.addEventListener('click', async () => { + const data = await fetch(DATA_DIR + file).then((r) => r.arrayBuffer()) + canvas.loadGeometry(file, data); }); presets_menu.appendChild(item); } @@ -841,12 +869,12 @@ function initUploadButton(canvas: Canvas) { const input = document.createElement('input'); input.type = 'file'; input.accept = '.stl,.obj'; - input.addEventListener('change', () => { + input.addEventListener('change', async () => { const file = input.files?.[0]; - if (file) { - disableDownloadButton(); - void canvas.loadUserFile(file); - } + if (!file) return; + const data = await file.arrayBuffer(); + const name = file.name; + void canvas.loadGeometry(name, data); }); input.click(); }); @@ -879,6 +907,7 @@ function initConstructButton(canvas: Canvas) { label.textContent = IDLE_TEXT; button.classList.remove('opacity-70', 'pointer-events-none'); enableDownloadButton(outputs); + populateInspector(outputs); }); } From 3526ea375ab9deecfbc295b58d294277d27e4fb0 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:50:56 -0700 Subject: [PATCH 61/68] webapp: default scale values for presets, Sidebar.setParam --- webapp/app.ts | 124 +++++++++++++++++++++++++++++++------------------- 1 file changed, 77 insertions(+), 47 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index bb8d243..78313e5 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -172,8 +172,6 @@ class Canvas { const mesh = new THREE.Mesh(new THREE.BufferGeometry(), this.meshMaterial); this.root.add(mesh); this.currentMesh = mesh; - const data = await fetch(DATA_DIR + DEFAULT_MODEL).then((r) => r.arrayBuffer()); - this.loadGeometry(DEFAULT_MODEL, data); const [cube, cubeScene, cubeCamera] = makeOrientationCube(renderer); @@ -472,10 +470,10 @@ const OPTIONS: Param[] = [ label: 'Scale', desc: 'Scale factor. Vertexprinted objects are typically larger than than the original object, so this starts out large.', min: 0, - max: 1000, - step: 1, - value: 100, - unit: '%', + max: 100, + step: 0.01, + value: 1.00, + unit: 'x', }, { kind: 'slider', @@ -485,7 +483,7 @@ const OPTIONS: Param[] = [ min: 0, max: 100, step: 0.1, - value: 10, + value: 6, unit: 'mm', }, { @@ -496,7 +494,7 @@ const OPTIONS: Param[] = [ min: 0, max: 90, step: 5, - value: 15, + value: 45, unit: '°', }, { @@ -609,6 +607,30 @@ class Sidebar { } } + // Programmatically set a param value and update the corresponding UI + // element. + setParam(name: NumberKeys | StringKeys, value: number | string) { + const row = this.rows.get(name as string); + if (!row) return; + const param = OPTIONS.find(p => p.name === name); + if (!param) return; + if (param.kind === 'slider') { + const slider = row.querySelector('.dh-slider') as HTMLInputElement; + const num = row.querySelector('input[type="number"]') as HTMLInputElement; + const v = clamp(value as number, param.min, param.max); + slider.value = String(v); + num.value = String(v); + PARAMS[param.name] = v; + const pct = (v - param.min) / (param.max - param.min) * 100; + slider.style.setProperty('--fill', `${pct}%`); + } else { + const sel = row.querySelector('select') as HTMLSelectElement; + sel.value = String(value); + PARAMS[param.name] = value as any; + this.enforceSelects(); + } + } + // Synchronize slider with corresponding num entry and vv syncSlider(r: SliderRow) { const setFill = () => { @@ -776,52 +798,51 @@ function populateInspector(outputs: VertexPrintOutputs) { edgesPane.innerHTML = ehtml; } -// Presets dropdown entries -const PRESETS: Record> = { +const PRESETS: Record> = { 'Platonic solids': { - 'Tetrahedron': 'Tetrahedron.obj', - 'Cube': 'Cube.obj', - 'Octahedron': 'Octahedron.obj', - 'Dodecahedron': 'Dodecahedron.obj', - 'Icosahedron': 'Icosahedron.obj', + 'Tetrahedron': { file: 'Tetrahedron.obj', scale: 50 }, + 'Cube': { file: 'Cube.obj', scale: 50 }, + 'Octahedron': { file: 'Octahedron.obj', scale: 50 }, + 'Dodecahedron': { file: 'Dodecahedron.obj', scale: 50 }, + 'Icosahedron': { file: 'Icosahedron.obj', scale: 50 }, }, 'Archimedean solids': { - 'Truncated Tetrahedron': 'TruncatedTetrahedron.obj', - 'Cuboctahedron': 'Cuboctahedron.obj', - 'Truncated Cube': 'TruncatedCube.obj', - 'Truncated Octahedron': 'TruncatedOctahedron.obj', - 'Rhombicuboctahedron': 'Rhombicuboctahedron.obj', - 'Truncated Cuboctahedron': 'TruncatedCuboctahedron.obj', - 'Snub Cube (laevo)': 'LsnubCube.obj', - 'Icosidodecahedron': 'Icosidodecahedron.obj', - 'Truncated Dodecahedron': 'TruncatedDodecahedron.obj', - 'Truncated Icosahedron': 'TruncatedIcosahedron.obj', - 'Rhombicosidodecahedron': 'Rhombicosidodecahedron.obj', - 'Truncated Icosidodecahedron': 'TruncatedIcosidodecahedron.obj', - 'Snub Dodecahedron (laevo)': 'LsnubDodecahedron.obj', + 'Truncated Tetrahedron': { file: 'TruncatedTetrahedron.obj', scale: 50 }, + 'Cuboctahedron': { file: 'Cuboctahedron.obj', scale: 50 }, + 'Truncated Cube': { file: 'TruncatedCube.obj', scale: 50 }, + 'Truncated Octahedron': { file: 'TruncatedOctahedron.obj', scale: 50 }, + 'Rhombicuboctahedron': { file: 'Rhombicuboctahedron.obj', scale: 50 }, + 'Truncated Cuboctahedron': { file: 'TruncatedCuboctahedron.obj', scale: 50 }, + 'Snub Cube (laevo)': { file: 'LsnubCube.obj', scale: 50 }, + 'Icosidodecahedron': { file: 'Icosidodecahedron.obj', scale: 50 }, + 'Truncated Dodecahedron': { file: 'TruncatedDodecahedron.obj', scale: 50 }, + 'Truncated Icosahedron': { file: 'TruncatedIcosahedron.obj', scale: 50 }, + 'Rhombicosidodecahedron': { file: 'Rhombicosidodecahedron.obj', scale: 50 }, + 'Truncated Icosidodecahedron': { file: 'TruncatedIcosidodecahedron.obj', scale: 50 }, + 'Snub Dodecahedron (laevo)': { file: 'LsnubDodecahedron.obj', scale: 50 }, }, 'Catalan solids': { - 'Triakis Tetrahedron': 'TriakisTetrahedron.obj', - 'Rhombic Dodecahedron': 'RhombicDodecahedron.obj', - 'Triakis Octahedron': 'TriakisOctahedron.obj', - 'Tetrakis Hexahedron': 'TetrakisHexahedron.obj', - 'Deltoidal Icositetrahedron': 'DeltoidalIcositetrahedron.obj', - 'Disdyakis Dodecahedron': 'DisdyakisDodecahedron.obj', - 'Pentagonal Icositetrahedron (laevo)': 'LpentagonalIcositetrahedron.obj', - 'Rhombic Triacontahedron': 'RhombicTriacontahedron.obj', - 'Triakis Icosahedron': 'TriakisIcosahedron.obj', - 'Pentakis Dodecahedron': 'PentakisDodecahedron.obj', - 'Deltoidal Hexecontahedron': 'DeltoidalHexecontahedron.obj', - 'Disdyakis Triacontahedron': 'DisdyakisTriacontahedron.obj', - 'Pentagonal Hexecontahedron (laevo)': 'LpentagonalHexecontahedron.obj', + 'Triakis Tetrahedron': { file: 'TriakisTetrahedron.obj', scale: 50 }, + 'Rhombic Dodecahedron': { file: 'RhombicDodecahedron.obj', scale: 50 }, + 'Triakis Octahedron': { file: 'TriakisOctahedron.obj', scale: 50 }, + 'Tetrakis Hexahedron': { file: 'TetrakisHexahedron.obj', scale: 50 }, + 'Deltoidal Icositetrahedron': { file: 'DeltoidalIcositetrahedron.obj', scale: 50 }, + 'Disdyakis Dodecahedron': { file: 'DisdyakisDodecahedron.obj', scale: 50 }, + 'Pentagonal Icositetrahedron (laevo)': { file: 'LpentagonalIcositetrahedron.obj', scale: 50 }, + 'Rhombic Triacontahedron': { file: 'RhombicTriacontahedron.obj', scale: 50 }, + 'Triakis Icosahedron': { file: 'TriakisIcosahedron.obj', scale: 50 }, + 'Pentakis Dodecahedron': { file: 'PentakisDodecahedron.obj', scale: 50 }, + 'Deltoidal Hexecontahedron': { file: 'DeltoidalHexecontahedron.obj', scale: 50 }, + 'Disdyakis Triacontahedron': { file: 'DisdyakisTriacontahedron.obj', scale: 50 }, + 'Pentagonal Hexecontahedron (laevo)': { file: 'LpentagonalHexecontahedron.obj', scale: 50 }, }, 'Misc': { - 'Stanford Bunny': 'bunny.stl', - 'Rhombic Dodecahedron Tiling': '13RhombicDodecahedra.obj', + 'Stanford Bunny': { file: 'bunny.stl', scale: 6 }, + 'Rhombic Dodecahedron Tiling': { file: '13RhombicDodecahedra.obj', scale: 50 }, }, }; -function initPresetsMenu(canvas: Canvas) { +function initPresetsMenu(canvas: Canvas, sidebar: Sidebar) { const presets_button = document.getElementById('presets-button')!; const presets_menu = document.getElementById('presets-menu')!; @@ -830,13 +851,15 @@ function initPresetsMenu(canvas: Canvas) { header.textContent = category; header.className = 'font-mono text-xs px-3 pt-2 pb-1 text-v-fg/60 border-b border-v-border min-w-full'; presets_menu.appendChild(header); - for (const [label, file] of Object.entries(solids)) { + for (const [label, preset] of Object.entries(solids)) { + const { file, scale } = preset; const item = document.createElement('button'); item.type = 'button'; item.textContent = label; item.dataset.file = file; item.className = 'block w-full cursor-pointer text-left font-mono text-xs px-3 py-1 text-v-fg hover:bg-v-blue hover:text-v-dark'; item.addEventListener('click', async () => { + sidebar.setParam('scale', scale); const data = await fetch(DATA_DIR + file).then((r) => r.arrayBuffer()) canvas.loadGeometry(file, data); }); @@ -952,14 +975,21 @@ function disableDownloadButton() { button.classList.remove('text-v-fg', 'cursor-pointer', 'hover:bg-v-blue', 'hover:text-v-dark'); } +async function initMesh(canvas: Canvas, sidebar: Sidebar) { + sidebar.setParam('scale', 50); + const data = await fetch(DATA_DIR + DEFAULT_MODEL).then((r) => r.arrayBuffer()); + canvas.loadGeometry(DEFAULT_MODEL, data); +} + async function init() { const canvas = new Canvas(); - new Sidebar(); + const sidebar = new Sidebar(); initInspector(); - initPresetsMenu(canvas); + initPresetsMenu(canvas, sidebar); initUploadButton(canvas); initConstructButton(canvas); disableDownloadButton(); + initMesh(canvas, sidebar); } init(); From 6e25471fc4f22cc08ba86ba13de3e2fd1361cccf Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:52:40 -0700 Subject: [PATCH 62/68] webapp: inspector tells you to run the construct button --- webapp/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/index.html b/webapp/index.html index d998a59..648b8c2 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -45,10 +45,10 @@
-

0 vertices

+

This pane will populate after you generate artifacts

From 1a2086ea3f7bf4b212ec9478fa65cefb6219f7ae Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 18:53:11 -0700 Subject: [PATCH 63/68] build(webapp): jszip dependency --- webapp/package-lock.json | 100 +++++++++++++++++++++++++++++++++++++++ webapp/package.json | 3 +- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index e49df15..586d678 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@tailwindcss/cli": "^4.3.1", "esbuild": "^0.28.1", + "jszip": "^3.10.1", "ml-matrix": "^6.13.0", "tailwindcss": "^4.3.1", "three": "^0.184.0", @@ -1225,6 +1226,12 @@ "node": ">=16" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1468,6 +1475,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -1479,6 +1492,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-any-array": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", @@ -1515,6 +1534,12 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1524,6 +1549,27 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1914,6 +1960,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1939,6 +1991,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -1949,6 +2028,12 @@ "node": ">=11.0.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1958,6 +2043,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/svgo": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", @@ -2043,6 +2137,12 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" } } } diff --git a/webapp/package.json b/webapp/package.json index e3d91a5..9ad91e1 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -2,6 +2,7 @@ "dependencies": { "@tailwindcss/cli": "^4.3.1", "esbuild": "^0.28.1", + "jszip": "^3.10.1", "ml-matrix": "^6.13.0", "tailwindcss": "^4.3.1", "three": "^0.184.0", @@ -9,7 +10,7 @@ }, "scripts": { "build:openscad-wasm": "make -C openscad-wasm", - "build": "npm run build:openscad-wasm && esbuild app.ts --bundle --outfile=a.js --format=esm --target=es2022 --minify --tree-shaking=true --loader:.stl=file && tailwindcss -i style.css -o a.css --minify" + "build": "esbuild app.ts --bundle --outfile=a.js --format=esm --target=es2022 --minify --tree-shaking=true --loader:.stl=file && esbuild worker.ts --bundle --outfile=worker.js --format=esm --target=es2022 --minify --tree-shaking=true && tailwindcss -i style.css -o a.css --minify && cp openscad-wasm/build/openscad.wasm.js openscad-wasm/build/openscad.wasm ." }, "devDependencies": { "@iconify/json": "^2.2.490", From 28e3df4e4231d876924629d735b0a60719efdfe7 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 19:06:01 -0700 Subject: [PATCH 64/68] webapp: better hover text for sidebar options --- webapp/app.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index 78313e5..c8fcfcf 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -435,7 +435,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'diameterTolerance', label: 'Diameter tolerance', - desc: 'Additional tolerance added to the diameter.\nI recommend adding about 12% of your diameter for wood dowel rods, and 5% for metal', + desc: 'Additional tolerance added to the diameter.\nAdd about 12% of your diameter for wood dowel rods, and 5% of your diameter for metal dowel rods', min: 0, max: 1, step: 0.01, @@ -446,7 +446,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'diameterTaper', label: 'Diameter taper', - desc: 'The rod holder diameter decreases by this amount. A small taper is helpful to account for small amounts of unevenness in the diameters of your dowel rods, particularly for wood dowel rods', + desc: 'The rod holder diameter decreases by this amount. A small taper is helpful to account for unevenness in the diameters of your dowel rods, particularly for wood dowel rods', min: 0, max: 1, step: 0.01, @@ -468,7 +468,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'scale', label: 'Scale', - desc: 'Scale factor. Vertexprinted objects are typically larger than than the original object, so this starts out large.', + desc: 'Scale factor. Vertexprinted objects are typically larger than than the original object. Objects that are too small risk having their vertex pieces collide.', min: 0, max: 100, step: 0.01, @@ -479,7 +479,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'rodInset', label: 'Tube depth', - desc: 'The depth of each tube', + desc: 'The depth of each tube.', min: 0, max: 100, step: 0.1, @@ -489,8 +489,8 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'minPrinterOverhangAngle', - label: 'Maximum overhang angle', - desc: 'The maximum overhang angle your printer allows', + label: 'Overhang angle', + desc: 'The angle your vertex pieces can overhang by. 0° = any overhang, 90° = no overhang.', min: 0, max: 90, step: 5, @@ -501,7 +501,11 @@ const OPTIONS: Param[] = [ kind: 'select', name: 'offsetType', label: 'Offset type', - desc: 'placeholder', // complicated explanation + desc: 'Dowel rods are offset from the center of each vertex to avoid them from colliding with each other. \ + "Auto (per-edge)" allows a different offset for each edge at a given vertex. It is the most space-efficient.\ + "Auto (per-vertex)" forces the same offset at each vertex, but allows different vertices to have different offsets. \ + "Auto (global)" forces the entire solid to share an offset value. \ + "Manual" allows you to select a global offset value.', value: 'auto_per_edge', options: [ { value: 'fixed', label: 'Manual' }, @@ -515,7 +519,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'manualOffset', label: 'Offset', - desc: 'placeholder', // see comment above + desc: 'Manually set a global offset value.', // see comment above min: 0, max: 100, step: 0.01, @@ -526,7 +530,7 @@ const OPTIONS: Param[] = [ kind: 'select', name: 'renderQuality', label: 'Render quality', - desc: 'placeholder', + desc: 'Quality of part. "Preview" renderes with fewer triangles. Always render with "Final" before printing.', value: 'preview', options: [ { value: 'preview', label: 'Preview' }, From c0c95d49956a4b338f61b07e27ae4d0f8dccfc2a Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 20:01:22 -0700 Subject: [PATCH 65/68] webapp: better tooltips for all buttons --- webapp/app.ts | 99 +++++++++++++++++++++++++++++++++++++++++------ webapp/index.html | 8 ++-- webapp/style.css | 16 ++++++++ 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index c8fcfcf..dc5c79f 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -478,8 +478,8 @@ const OPTIONS: Param[] = [ { kind: 'slider', name: 'rodInset', - label: 'Tube depth', - desc: 'The depth of each tube.', + label: 'Tube length', + desc: 'Length of tube enveloping dowel rods.', min: 0, max: 100, step: 0.1, @@ -490,7 +490,7 @@ const OPTIONS: Param[] = [ kind: 'slider', name: 'minPrinterOverhangAngle', label: 'Overhang angle', - desc: 'The angle your vertex pieces can overhang by. 0° = any overhang, 90° = no overhang.', + desc: 'The angle by which your vertex pieces can overhang. At 0°, any overhang angle is permitted. At 90°, vertexprint minimizes overhangs.', min: 0, max: 90, step: 5, @@ -501,11 +501,11 @@ const OPTIONS: Param[] = [ kind: 'select', name: 'offsetType', label: 'Offset type', - desc: 'Dowel rods are offset from the center of each vertex to avoid them from colliding with each other. \ - "Auto (per-edge)" allows a different offset for each edge at a given vertex. It is the most space-efficient.\ - "Auto (per-vertex)" forces the same offset at each vertex, but allows different vertices to have different offsets. \ - "Auto (global)" forces the entire solid to share an offset value. \ - "Manual" allows you to select a global offset value.', + desc: 'Dowel rods are offset from the center of each vertex to avoid them from colliding with each other within a vertex piece.\n\ + "Auto (per-edge)" allows a different offset for each edge at a given vertex. It is the most space-efficient.\n\ + "Auto (per-vertex)" forces the same offset at each vertex, but allows different vertices to have different offsets.\n\ + "Auto (global)" forces the entire solid to share an offset value.\n\ + "Manual" allows you to select a global offset value.\n', value: 'auto_per_edge', options: [ { value: 'fixed', label: 'Manual' }, @@ -539,6 +539,66 @@ const OPTIONS: Param[] = [ }, ]; +// Build and position a tooltip element for `host`, returning it. +function makeTooltip(host: HTMLElement, text: string): HTMLDivElement { + const tip = document.createElement('div'); + tip.className = 'dh-tooltip'; + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0); + tip.innerHTML = lines.map(l => `

${l}

`).join(''); + document.body.appendChild(tip); + + const r = host.getBoundingClientRect(); + const tw = tip.offsetWidth; + const th = tip.offsetHeight; + const gap = 6; + + // Place tooltip below the host element. Place it above if it overflows the + // viewport. + let top = r.bottom + gap; + if (top + th > window.innerHeight) top = r.top - th - gap; + if (top < 0) top = gap; + + // Left-align the tooltip with the host. Clamp to viewport if this is not possible. + let left = r.left; + if (left + tw > window.innerWidth - gap) left = window.innerWidth - tw - gap; + if (left < gap) left = gap; + tip.style.top = `${top}px`; + tip.style.left = `${left}px`; + return tip; +} + +// Bind a tooltip to a html element +function bindTooltip(element: HTMLElement, text: string) { + let tip: HTMLDivElement | null = null; + let timer: number | null = null; + const title = element.getAttribute('title'); + const show = () => { + if (tip || timer) return; + // strip title while tooltip shows, keep title otherwise for screen + // readers + if (title !== null) element.removeAttribute('title'); + // 1s delay before tooltip shows + timer = window.setTimeout(() => { + timer = null; + tip = makeTooltip(element, text); + }, 600); + }; + const hide = () => { + if (timer !== null) { + window.clearTimeout(timer); timer = null; + } + if (title !== null) element.setAttribute('title', title); + if (tip) { + tip.remove(); tip = null; + } + }; + element.addEventListener('mouseenter', show); + element.addEventListener('focus', show); + element.addEventListener('mouseleave', hide); + element.addEventListener('blur', hide); + element.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); }); +} + const TW_CLASS = { row: 'flex flex-col gap-0.5 px-1.5 py-1', top: 'flex items-center justify-between gap-1.5', @@ -576,10 +636,11 @@ class Sidebar { row.className = TW_CLASS.row; row.dataset.param = param.name; row.innerHTML = - `
${param.label}` + `
${param.label}` + `
` + `${param.unit}
` + ``; + bindTooltip(row.querySelector('.opt-label')!, param.desc); return row } @@ -590,8 +651,9 @@ class Sidebar { const paramsHTML = param.options.map(o => ``).join(''); row.innerHTML = - `
${param.label}
` + `
${param.label}
` + ``; + bindTooltip(row.querySelector('.opt-label')!, param.desc); return row } @@ -694,7 +756,10 @@ class Sidebar { // Open/close sidebar const sidebar_reopen = document.getElementById('sidebar-reopen')!; - document.getElementById('sidebar-close')!.addEventListener('click', () => { + const sidebar_close = document.getElementById('sidebar-close')!; + bindTooltip(sidebar_close, 'Close sidebar'); + bindTooltip(sidebar_reopen, 'Show sidebar'); + sidebar_close.addEventListener('click', () => { sidebar.style.display = 'none'; sidebar_reopen.style.display = 'flex'; }); @@ -753,7 +818,10 @@ function initInspector() { // Open/close inspector const inspector_reopen = document.getElementById('inspector-reopen')!; - document.getElementById('inspector-close')!.addEventListener('click', () => { + const inspector_close = document.getElementById('inspector-close')!; + bindTooltip(inspector_close, 'Close inspector'); + bindTooltip(inspector_reopen, 'Show inspector'); + inspector_close.addEventListener('click', () => { inspector.style.display = 'none'; inspector_reopen.style.display = 'flex'; }); @@ -849,6 +917,7 @@ const PRESETS: Record> = function initPresetsMenu(canvas: Canvas, sidebar: Sidebar) { const presets_button = document.getElementById('presets-button')!; const presets_menu = document.getElementById('presets-menu')!; + bindTooltip(presets_button, 'Open presets'); for (const [category, solids] of Object.entries(PRESETS)) { const header = document.createElement('p'); @@ -892,6 +961,7 @@ function initPresetsMenu(canvas: Canvas, sidebar: Sidebar) { function initUploadButton(canvas: Canvas) { const button = document.getElementById('upload-button')!; + bindTooltip(button, 'Upload files'); button.addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; @@ -910,6 +980,7 @@ function initUploadButton(canvas: Canvas) { function initConstructButton(canvas: Canvas) { const button = document.getElementById('construct-button')!; + bindTooltip(button, 'Vertexprint your structure'); const label = button.querySelector('p')!; const IDLE_TEXT = 'Construct'; @@ -970,6 +1041,7 @@ function enableDownloadButton(outputs: VertexPrintOutputs) { const button = resetDownloadButton(); button.classList.remove('text-v-fg/70', 'cursor-not-allowed'); button.classList.add('text-v-fg', 'cursor-pointer', 'hover:bg-v-blue', 'hover:text-v-dark'); + bindTooltip(button, 'Download generated artifacts.\nYou must generate artifacts before a download.'); button.addEventListener('click', () => { void saveOutputs(outputs); }); } @@ -977,6 +1049,7 @@ function disableDownloadButton() { const button = resetDownloadButton(); button.classList.add('text-v-fg/70', 'cursor-not-allowed'); button.classList.remove('text-v-fg', 'cursor-pointer', 'hover:bg-v-blue', 'hover:text-v-dark'); + bindTooltip(button, 'Download generated artifacts.\nYou must generate artifacts before a download.'); } async function initMesh(canvas: Canvas, sidebar: Sidebar) { @@ -993,6 +1066,8 @@ async function init() { initUploadButton(canvas); initConstructButton(canvas); disableDownloadButton(); + bindTooltip(document.getElementById('help-button')!, 'Help'); + bindTooltip(document.getElementById('source-link')!, 'Source code'); initMesh(canvas, sidebar); } diff --git a/webapp/index.html b/webapp/index.html index 648b8c2..e6ecf52 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -63,21 +63,21 @@ upload
-
- - - +
diff --git a/webapp/style.css b/webapp/style.css index 79b589c..1fb0f6c 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -75,3 +75,19 @@ input[type="number"] { -moz-appearance: textfield; } /* Text on hover is underlined */ .opt-label:hover { text-decoration: underline; } + +.dh-tooltip { + position: fixed; + z-index: 100; + max-width: 256px; + padding: 8px 8px; + background: var(--color-v-dark); + color: var(--color-v-fg); + border: 1px solid var(--color-v-border); + font-family: monospace; + font-size: 11px; + line-height: 1.4; + pointer-events: none; +} +.dh-tooltip p { margin: 0; } +.dh-tooltip p + p { margin-top: 4px; } From e572e2158a78d9970b81b29647fec0f5973a0736 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 21:12:55 -0700 Subject: [PATCH 66/68] webapp: highlight vertices/edges when clicked in the inspector --- webapp/app.ts | 72 +++++++++++++++++++++++++++++++++++++++++++------- webapp/core.ts | 5 ++-- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/webapp/app.ts b/webapp/app.ts index dc5c79f..48201a0 100644 --- a/webapp/app.ts +++ b/webapp/app.ts @@ -101,14 +101,17 @@ class Canvas { currentName: string; currentData: ArrayBuffer; - currentVertices: THREE.Mesh[]; + currentMeshes: THREE.Mesh[]; + vertexCount: number; vertexMaterial: THREE.MeshStandardMaterial; edgeMaterial: THREE.MeshStandardMaterial; meshMaterial: THREE.MeshStandardMaterial; + highlightMaterial: THREE.MeshStandardMaterial; + highlighted: number[]; constructor() { this.currentMesh = null; - this.currentVertices = []; + this.currentMeshes = []; this.vertexMaterial = new THREE.MeshStandardMaterial({ color: V_BLUE, flatShading: false, @@ -121,8 +124,14 @@ class Canvas { color: V_BLUE, flatShading: false, }); + this.highlightMaterial = new THREE.MeshStandardMaterial({ + color: 0xff0000, + flatShading: false, + }); this.currentName = DEFAULT_MODEL; this.currentData = new ArrayBuffer(0); + this.highlighted = []; + this.vertexCount = 0; this.initCanvas(); } @@ -290,6 +299,7 @@ class Canvas { loadVertexPrintOutputs(outputs: VertexPrintOutputs) { this.clear() const polyhedron = outputs.polyhedron; + this.vertexCount = polyhedron.vertexFigures.length; for (let i = 0; i < polyhedron.vertexFigures.length; ++i) { const geometry = new STLLoader().parse(outputs.stls[i]); geometry.computeVertexNormals(); @@ -302,7 +312,7 @@ class Canvas { mesh.position.set(x, y, z); mesh.rotation.set(rotation[0], rotation[1], rotation[2], "ZYX"); this.root.add(mesh); - this.currentVertices.push(mesh) + this.currentMeshes.push(mesh) } this.loadEdges(outputs) @@ -356,21 +366,42 @@ class Canvas { mesh.rotation.set(0, theta, phi, "ZYX"); this.root.add(mesh); - this.currentVertices.push(mesh); + this.currentMeshes.push(mesh); } } // Clear scene of all meshes clear() { - for (const mesh of this.currentVertices) { + for (const mesh of this.currentMeshes) { mesh.geometry.dispose(); this.root.remove(mesh); } - this.currentVertices = []; + this.currentMeshes = []; if (this.currentMesh) { this.currentMesh.geometry.dispose(); this.currentMesh.geometry = new THREE.BufferGeometry(); } + this.highlighted = []; + this.vertexCount = 0; + } + + highlightVertex(index: number) { + this.currentMeshes[index].material = this.highlightMaterial; + this.highlighted.push(index) + } + + highlightEdge(index: number) { + const meshIndex = this.vertexCount + index; + this.currentMeshes[meshIndex].material = this.highlightMaterial; + this.highlighted.push(meshIndex) + } + + clearHighlights() { + for (const v of this.highlighted) { + const mat = v < this.vertexCount ? this.vertexMaterial : this.edgeMaterial; + this.currentMeshes[v].material = mat; + } + this.highlighted = []; } } @@ -842,7 +873,7 @@ function resetInspector() { // Fill the vertex inspector with vertex-edge adjacencies // Fill the edge inspector with edge-vertex adjacencies, and offset-adjusted edge length -function populateInspector(outputs: VertexPrintOutputs) { +function populateInspector(outputs: VertexPrintOutputs, canvas: Canvas) { const polyhedron = outputs.polyhedron; const verticesPane = document.querySelector('.inspector-pane[data-pane="vertices"]')!; const edgesPane = document.querySelector('.inspector-pane[data-pane="edges"]')!; @@ -851,10 +882,17 @@ function populateInspector(outputs: VertexPrintOutputs) { let vhtml = `

${vertexCount} vertices

`; vhtml += `
index, edges
`; for (const vf of polyhedron.vertexFigures) { - vhtml += `
v${vf.vertexIndex} [${vf.edges.join(", ")}]
`; + vhtml += `
v${vf.vertexIndex} [${vf.edges.join(", ")}]
`; } verticesPane.innerHTML = vhtml; + for (let i = 0; i < polyhedron.vertexFigures.length; ++i) { + const vi = document.getElementById(`v${i}`); + vi.addEventListener('click', () => { + canvas.highlightVertex(i); + }); + } + const edges = [...polyhedron.edges.entries()] .map(([key, field]) => { const [v1, v2] = key.split(",").map(Number); @@ -865,9 +903,23 @@ function populateInspector(outputs: VertexPrintOutputs) { let ehtml = `

${edges.length} edges

`; ehtml += `
index, vertices, length (mm)
`; for (const e of edges) { - ehtml += `
e${e.name} [${e.v1} ${e.v2}] ${e.offsetLength.toFixed(2)}
`; + ehtml += `
e${e.name} [${e.v1} ${e.v2}] ${e.offsetLength.toFixed(2)}
`; } edgesPane.innerHTML = ehtml; + + for (let i = 0; i < edges.length; ++i) { + const vi = document.getElementById(`e${i}`); + vi.addEventListener('click', () => { + canvas.highlightEdge(i); + }); + } + + document.addEventListener('click', (e) => { + const t = e.target as Node; + if (!verticesPane.contains(t) && !edgesPane.contains(t)) { + canvas.clearHighlights(); + } + }); } const PRESETS: Record> = { @@ -1005,7 +1057,7 @@ function initConstructButton(canvas: Canvas) { label.textContent = IDLE_TEXT; button.classList.remove('opacity-70', 'pointer-events-none'); enableDownloadButton(outputs); - populateInspector(outputs); + populateInspector(outputs, canvas); }); } diff --git a/webapp/core.ts b/webapp/core.ts index 02fe4a4..ff7a860 100644 --- a/webapp/core.ts +++ b/webapp/core.ts @@ -539,15 +539,16 @@ class Polyhedron { const v2_arr = this.vertices.getRowVector(v2); const length = Matrix.sub(v2_arr, v1_arr).norm(); const offsetLength = this.options.scale * length - v1_offset - v2_offset; - console.log(this.options.scale * length) klo.push({ key, length, offsetLength, offsets: [v1_offset, v2_offset] }); } klo.sort((x, y) => x.offsetLength - y.offsetLength); + const sorted = new Map(); for (let i = 0; i < klo.length; i++) { const { key, length, offsetLength, offsets } = klo[i]; - this.edges.set(key, { length, offsetLength, name: i, offsets }); + sorted.set(key, { length, offsetLength, name: i, offsets }); } + this.edges = sorted; } // Convert facelist into edgelist From d405cce2f0c16b50c3be6d92c5cbe9f3574bec5d Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 21:19:41 -0700 Subject: [PATCH 67/68] build: remove docker --- flake.lock | 6 +++--- flake.nix | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index d4b7931..dc9c41a 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1781577229, - "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "lastModified": 1783522502, + "narHash": "sha256-iffAls3iaNTyJC2faYcUXSI+Gp02cDjYl+MygxKl2GI=", "owner": "nixos", "repo": "nixpkgs", - "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "rev": "0bb7ec54c8483066ec9d7720e780a5caa71f8612", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 5c8e9e2..10147b9 100644 --- a/flake.nix +++ b/flake.nix @@ -26,7 +26,6 @@ pkgs.git pkgs.wget pkgs.deno - pkgs.docker ]; nativeBuildInputs = with pkgs; [ From b6dad83c653a28a67821baba825b4e8c7b468008 Mon Sep 17 00:00:00 2001 From: agentelement Date: Mon, 13 Jul 2026 21:20:35 -0700 Subject: [PATCH 68/68] build: ignore wasm blobs and worker.js --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 96b4f52..c1eca77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ out/ node_modules a.* +openscad.wasm +openscad.wasm.js +worker.js + # Added by cargo /target