Initial commit: mesh-studio POC (OpenCASCADE.js + three.js, NURBS extraction)
30 changed files+3669−0
.github/workflows/deploy.ymladded+40−0View file
@@ -0,0 +1,40 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: false
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - uses: actions/setup-node@v4
23+ with:
24+ node-version: 22
25+ cache: npm
26+ - run: npm ci
27+ - run: npm run build
28+ - uses: actions/upload-pages-artifact@v3
29+ with:
30+ path: dist
31+
32+ deploy:
33+ needs: build
34+ runs-on: ubuntu-latest
35+ environment:
36+ name: github-pages
37+ url: ${{ steps.deployment.outputs.page_url }}
38+ steps:
39+ - id: deployment
40+ uses: actions/deploy-pages@v4
.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
.oxlintrc.jsonadded+8−0View file
@@ -0,0 +1,8 @@
1+{
2+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3+ "plugins": ["react", "typescript", "oxc"],
4+ "rules": {
5+ "react/rules-of-hooks": "error",
6+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
7+ }
8+}
CLAUDE.mdadded+62−0View file
@@ -0,0 +1,62 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo.
4+
5+## Architecture
6+
7+```
8+src/occ/ ALL OpenCASCADE.js access lives here. The rest of the app never
9+ touches OCCT — it only sees the SurfaceModel from src/model.
10+ loader.ts initOpenCascade({ mainWasm: wasmUrl }) once; wasm imported with
11+ `?url` so Vite emits it as a static asset. Lazy on first use.
12+ primitives.ts BRepPrimAPI_* builders returning TopoDS_Shape.
13+ importCad.ts STEP/IGES via FS.createDataFile + STEPControl_Reader/IGESControl_Reader.
14+ exportCad.ts STEPControl_Writer -> bytes.
15+ extract.ts shape -> SurfaceModel. Runs BRepMesh (triangulation, always) and
16+ BRepBuilderAPI_NurbsConvert + Geom_BSplineSurface reads (per-face,
17+ best-effort). retessellate() re-meshes at a new quality reusing NURBS.
18+src/model/ types.ts: SurfaceModel = list of Patch (discriminated union).
19+ v1 emits `nurbs` patches. tessellate.ts: pure geometry helpers
20+ (modelBounds, controlNet, mergeTriMeshes) — no three, no OCCT.
21+src/render/ SurfaceView.tsx: plain three.js, one mesh per patch (for picking),
22+ modes shaded/wire/net/iso. palette.ts: per-face colors.
23+src/export/ meshWriters.ts (OBJ/PLY/STL, dependency-free), nurbsJson.ts.
24+src/App.tsx sidebar + viewport; keeps the OpenCascade instance and the
25+ (NURBS-converted) mesh shape in refs for re-tessellation.
26+```
27+
28+## Key gotchas
29+
30+- **OCCT is typed as `any`** (`src/occ/types.ts`). opencascade.js ships a huge
31+ generated `.d.ts`, and the overload-suffixed member names (`_1`, `_2`, …) are
32+ only verifiable at runtime. Keep all OCCT calls in `src/occ/*`; everything
33+ else is strictly typed against `SurfaceModel`. The **build gate is `tsc -b &&
34+ vite build`** — runtime correctness of OCCT calls must be checked in a real
35+ browser (`npm run dev`).
36+- **opencascade.js is pinned to the beta** (`2.0.0-beta.b5ff984`). `latest`
37+ (1.1.1) is an older, different API. Overload suffixes and the `?url` wasm
38+ recipe follow the beta and its examples (donalffons/opencascade.js-examples).
39+- **NURBS extraction is best-effort.** Rendering only needs the BRepMesh
40+ triangulation, which always runs. `extractNurbs` is wrapped in try/catch per
41+ face and returns null on any failure; the sidebar shows "N/M faces" coverage.
42+ If a binding is missing in the build, coverage drops but the app still works.
43+- **Mesh density uses BRepMesh relative mode** (`isRelative = true`), so the
44+ quality slider is a dimensionless fraction independent of model scale — no
45+ bounding-box computation needed. `retessellate` calls `BRepTools.Clean` first
46+ (in a try/catch) so a finer deflection actually refines.
47+- **The "Sample STEP" button** round-trips a box through `shapeToStep` +
48+ `importCadFile` — it exercises the whole STEP export+import pipeline with no
49+ bundled asset, and is the quickest end-to-end check of the CAD path.
50+
51+## Verification
52+
53+Browser is the real verification surface (WASM). `npm run build` confirms TS +
54+bundling (including the OCCT wasm asset). Then `npm run dev` and:
55+- load each primitive; toggle shaded / wireframe / control-net / isocurves;
56+- drag the resolution slider (faceting should visibly change);
57+- click a face → the inspector shows degree / poles / knots;
58+- "Sample STEP" and a real uploaded STEP/IGES both render;
59+- export OBJ/PLY/STL/NURBS-JSON/STEP.
60+
61+Not yet deployed to the org Pages site (same procedure as mesh-converter /
62+mesh-pde-solver when ready).
README.mdadded+79−0View file
@@ -0,0 +1,79 @@
1+# mesh-studio
2+
3+A browser playground for **producing surface meshes with different tools and
4+inspecting them interactively in 3D** ([three.js](https://threejs.org/)). This
5+is a proof-of-concept; the first mesh-producing tool wired in is
6+[OpenCASCADE.js](https://ocjs.org/) (the OCCT CAD kernel compiled to WebAssembly).
7+
8+Everything runs client-side — no server, no uploads.
9+
10+## What's interesting here
11+
12+CAD B-rep faces are not triangles: each face carries an underlying **NURBS
13+(rational B-spline) surface** — a piecewise polynomial in two parameters. That
14+is exactly the "high-order mesh / polynomials on faces" idea. mesh-studio
15+extracts those polynomial patches (control points, weights, knots, degree)
16+*alongside* the triangulation, so you can:
17+
18+- shade the tessellated surface, or view its **wireframe**;
19+- see each face's **control net** (the polynomial's control polygon) and its
20+ **isocurves** (constant-parameter curves sampled on the true surface);
21+- drag a **resolution** slider to re-tessellate the same NURBS faces from
22+ coarse-and-faceted to smooth;
23+- click a face to read its **degree, pole count, knot structure** and whether it
24+ is rational or purely polynomial.
25+
26+## Sources (v1)
27+
28+- **Built-in primitives** generated in-browser: sphere, torus, cylinder, cone,
29+ box, rounded box (free-form fillet faces), and the classic OCCT "bottle".
30+- **STEP / IGES import**: open a `.step`/`.stp` or `.iges`/`.igs` file — OCCT
31+ reads it natively. "Sample STEP" round-trips a box through OCCT's own writer +
32+ reader to demonstrate the import path with no bundled file.
33+
34+## Export
35+
36+- **OBJ / PLY / STL** — the current triangulation.
37+- **NURBS patches (JSON)** — the polynomial-native representation (degrees,
38+ poles, weights, knots per face).
39+- **STEP** — the B-rep (original bytes for an imported file; re-written from the
40+ kernel for primitives).
41+
42+## Internal model
43+
44+The internal representation is deliberately broader than a triangle mesh so more
45+tools can plug in later. A `SurfaceModel` is a list of **patches**, and a patch
46+is a discriminated union (`src/model/types.ts`). v1 emits `nurbs` patches (each
47+carrying both a triangulation and the B-spline surface); `linear`, `lagrange`
48+and `parametric` kinds are reserved growth points for future tools.
49+
50+## Develop
51+
52+```sh
53+npm install
54+npm run dev # http://localhost:5173
55+npm run build # tsc -b && vite build (bundles the ~30 MB OCCT wasm as an asset)
56+npm run lint
57+```
58+
59+The OpenCASCADE runtime (~30 MB WASM) is loaded lazily on the first source
60+action, not at page load.
61+
62+## Layout
63+
64+```
65+src/
66+ occ/ OpenCASCADE integration (all OCCT calls confined here)
67+ loader.ts lazy singleton initOpenCascade({ mainWasm })
68+ primitives.ts BRepPrimAPI shape builders + the tutorial bottle
69+ importCad.ts STEP/IGES readers via Emscripten FS
70+ exportCad.ts STEPControl_Writer
71+ extract.ts shape -> SurfaceModel: BRepMesh triangulation + NURBS extraction
72+ model/ types.ts (SurfaceModel/Patch) + tessellate.ts (pure geometry helpers)
73+ render/ SurfaceView.tsx (plain three.js) + palette.ts
74+ export/ meshWriters.ts (OBJ/PLY/STL) + nurbsJson.ts
75+ sources.ts registry of built-in primitives
76+ App.tsx sidebar UI + viewport
77+```
78+
79+Part of the [concept-collection](https://github.com/concept-collection) org.
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <title>Mesh Studio</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
package-lock.jsonadded+1516−0View file
@@ -0,0 +1,1516 @@
1+{
2+ "name": "mesh-studio",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "mesh-studio",
9+ "version": "0.0.0",
10+ "dependencies": {
11+ "opencascade.js": "2.0.0-beta.b5ff984",
12+ "react": "^19.2.7",
13+ "react-dom": "^19.2.7",
14+ "three": "^0.185.1"
15+ },
16+ "devDependencies": {
17+ "@types/node": "^24.13.2",
18+ "@types/react": "^19.2.17",
19+ "@types/react-dom": "^19.2.3",
20+ "@types/three": "^0.185.0",
21+ "@vitejs/plugin-react": "^6.0.3",
22+ "oxlint": "^1.71.0",
23+ "typescript": "~6.0.2",
24+ "vite": "^8.1.1"
25+ }
26+ },
27+ "node_modules/@dimforge/rapier3d-compat": {
28+ "version": "0.12.0",
29+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
30+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
31+ "dev": true,
32+ "license": "Apache-2.0"
33+ },
34+ "node_modules/@emnapi/core": {
35+ "version": "1.11.1",
36+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
37+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
38+ "dev": true,
39+ "license": "MIT",
40+ "optional": true,
41+ "dependencies": {
42+ "@emnapi/wasi-threads": "1.2.2",
43+ "tslib": "^2.4.0"
44+ }
45+ },
46+ "node_modules/@emnapi/runtime": {
47+ "version": "1.11.1",
48+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
49+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
50+ "dev": true,
51+ "license": "MIT",
52+ "optional": true,
53+ "dependencies": {
54+ "tslib": "^2.4.0"
55+ }
56+ },
57+ "node_modules/@emnapi/wasi-threads": {
58+ "version": "1.2.2",
59+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
60+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
61+ "dev": true,
62+ "license": "MIT",
63+ "optional": true,
64+ "dependencies": {
65+ "tslib": "^2.4.0"
66+ }
67+ },
68+ "node_modules/@napi-rs/wasm-runtime": {
69+ "version": "1.1.6",
70+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
71+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
72+ "dev": true,
73+ "license": "MIT",
74+ "optional": true,
75+ "dependencies": {
76+ "@tybys/wasm-util": "^0.10.3"
77+ },
78+ "funding": {
79+ "type": "github",
80+ "url": "https://github.com/sponsors/Brooooooklyn"
81+ },
82+ "peerDependencies": {
83+ "@emnapi/core": "^1.7.1",
84+ "@emnapi/runtime": "^1.7.1"
85+ }
86+ },
87+ "node_modules/@oxc-project/types": {
88+ "version": "0.139.0",
89+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
90+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
91+ "dev": true,
92+ "license": "MIT",
93+ "funding": {
94+ "url": "https://github.com/sponsors/Boshen"
95+ }
96+ },
97+ "node_modules/@oxlint/binding-android-arm-eabi": {
98+ "version": "1.73.0",
99+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz",
100+ "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==",
101+ "cpu": [
102+ "arm"
103+ ],
104+ "dev": true,
105+ "license": "MIT",
106+ "optional": true,
107+ "os": [
108+ "android"
109+ ],
110+ "engines": {
111+ "node": "^20.19.0 || >=22.12.0"
112+ }
113+ },
114+ "node_modules/@oxlint/binding-android-arm64": {
115+ "version": "1.73.0",
116+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz",
117+ "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==",
118+ "cpu": [
119+ "arm64"
120+ ],
121+ "dev": true,
122+ "license": "MIT",
123+ "optional": true,
124+ "os": [
125+ "android"
126+ ],
127+ "engines": {
128+ "node": "^20.19.0 || >=22.12.0"
129+ }
130+ },
131+ "node_modules/@oxlint/binding-darwin-arm64": {
132+ "version": "1.73.0",
133+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz",
134+ "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==",
135+ "cpu": [
136+ "arm64"
137+ ],
138+ "dev": true,
139+ "license": "MIT",
140+ "optional": true,
141+ "os": [
142+ "darwin"
143+ ],
144+ "engines": {
145+ "node": "^20.19.0 || >=22.12.0"
146+ }
147+ },
148+ "node_modules/@oxlint/binding-darwin-x64": {
149+ "version": "1.73.0",
150+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz",
151+ "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==",
152+ "cpu": [
153+ "x64"
154+ ],
155+ "dev": true,
156+ "license": "MIT",
157+ "optional": true,
158+ "os": [
159+ "darwin"
160+ ],
161+ "engines": {
162+ "node": "^20.19.0 || >=22.12.0"
163+ }
164+ },
165+ "node_modules/@oxlint/binding-freebsd-x64": {
166+ "version": "1.73.0",
167+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz",
168+ "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==",
169+ "cpu": [
170+ "x64"
171+ ],
172+ "dev": true,
173+ "license": "MIT",
174+ "optional": true,
175+ "os": [
176+ "freebsd"
177+ ],
178+ "engines": {
179+ "node": "^20.19.0 || >=22.12.0"
180+ }
181+ },
182+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
183+ "version": "1.73.0",
184+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz",
185+ "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==",
186+ "cpu": [
187+ "arm"
188+ ],
189+ "dev": true,
190+ "license": "MIT",
191+ "optional": true,
192+ "os": [
193+ "linux"
194+ ],
195+ "engines": {
196+ "node": "^20.19.0 || >=22.12.0"
197+ }
198+ },
199+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
200+ "version": "1.73.0",
201+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz",
202+ "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==",
203+ "cpu": [
204+ "arm"
205+ ],
206+ "dev": true,
207+ "license": "MIT",
208+ "optional": true,
209+ "os": [
210+ "linux"
211+ ],
212+ "engines": {
213+ "node": "^20.19.0 || >=22.12.0"
214+ }
215+ },
216+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
217+ "version": "1.73.0",
218+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz",
219+ "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==",
220+ "cpu": [
221+ "arm64"
222+ ],
223+ "dev": true,
224+ "libc": [
225+ "glibc"
226+ ],
227+ "license": "MIT",
228+ "optional": true,
229+ "os": [
230+ "linux"
231+ ],
232+ "engines": {
233+ "node": "^20.19.0 || >=22.12.0"
234+ }
235+ },
236+ "node_modules/@oxlint/binding-linux-arm64-musl": {
237+ "version": "1.73.0",
238+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz",
239+ "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==",
240+ "cpu": [
241+ "arm64"
242+ ],
243+ "dev": true,
244+ "libc": [
245+ "musl"
246+ ],
247+ "license": "MIT",
248+ "optional": true,
249+ "os": [
250+ "linux"
251+ ],
252+ "engines": {
253+ "node": "^20.19.0 || >=22.12.0"
254+ }
255+ },
256+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
257+ "version": "1.73.0",
258+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz",
259+ "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==",
260+ "cpu": [
261+ "ppc64"
262+ ],
263+ "dev": true,
264+ "libc": [
265+ "glibc"
266+ ],
267+ "license": "MIT",
268+ "optional": true,
269+ "os": [
270+ "linux"
271+ ],
272+ "engines": {
273+ "node": "^20.19.0 || >=22.12.0"
274+ }
275+ },
276+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
277+ "version": "1.73.0",
278+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz",
279+ "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==",
280+ "cpu": [
281+ "riscv64"
282+ ],
283+ "dev": true,
284+ "libc": [
285+ "glibc"
286+ ],
287+ "license": "MIT",
288+ "optional": true,
289+ "os": [
290+ "linux"
291+ ],
292+ "engines": {
293+ "node": "^20.19.0 || >=22.12.0"
294+ }
295+ },
296+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
297+ "version": "1.73.0",
298+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz",
299+ "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==",
300+ "cpu": [
301+ "riscv64"
302+ ],
303+ "dev": true,
304+ "libc": [
305+ "musl"
306+ ],
307+ "license": "MIT",
308+ "optional": true,
309+ "os": [
310+ "linux"
311+ ],
312+ "engines": {
313+ "node": "^20.19.0 || >=22.12.0"
314+ }
315+ },
316+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
317+ "version": "1.73.0",
318+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz",
319+ "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==",
320+ "cpu": [
321+ "s390x"
322+ ],
323+ "dev": true,
324+ "libc": [
325+ "glibc"
326+ ],
327+ "license": "MIT",
328+ "optional": true,
329+ "os": [
330+ "linux"
331+ ],
332+ "engines": {
333+ "node": "^20.19.0 || >=22.12.0"
334+ }
335+ },
336+ "node_modules/@oxlint/binding-linux-x64-gnu": {
337+ "version": "1.73.0",
338+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz",
339+ "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==",
340+ "cpu": [
341+ "x64"
342+ ],
343+ "dev": true,
344+ "libc": [
345+ "glibc"
346+ ],
347+ "license": "MIT",
348+ "optional": true,
349+ "os": [
350+ "linux"
351+ ],
352+ "engines": {
353+ "node": "^20.19.0 || >=22.12.0"
354+ }
355+ },
356+ "node_modules/@oxlint/binding-linux-x64-musl": {
357+ "version": "1.73.0",
358+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz",
359+ "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==",
360+ "cpu": [
361+ "x64"
362+ ],
363+ "dev": true,
364+ "libc": [
365+ "musl"
366+ ],
367+ "license": "MIT",
368+ "optional": true,
369+ "os": [
370+ "linux"
371+ ],
372+ "engines": {
373+ "node": "^20.19.0 || >=22.12.0"
374+ }
375+ },
376+ "node_modules/@oxlint/binding-openharmony-arm64": {
377+ "version": "1.73.0",
378+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz",
379+ "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==",
380+ "cpu": [
381+ "arm64"
382+ ],
383+ "dev": true,
384+ "license": "MIT",
385+ "optional": true,
386+ "os": [
387+ "openharmony"
388+ ],
389+ "engines": {
390+ "node": "^20.19.0 || >=22.12.0"
391+ }
392+ },
393+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
394+ "version": "1.73.0",
395+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz",
396+ "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==",
397+ "cpu": [
398+ "arm64"
399+ ],
400+ "dev": true,
401+ "license": "MIT",
402+ "optional": true,
403+ "os": [
404+ "win32"
405+ ],
406+ "engines": {
407+ "node": "^20.19.0 || >=22.12.0"
408+ }
409+ },
410+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
411+ "version": "1.73.0",
412+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz",
413+ "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==",
414+ "cpu": [
415+ "ia32"
416+ ],
417+ "dev": true,
418+ "license": "MIT",
419+ "optional": true,
420+ "os": [
421+ "win32"
422+ ],
423+ "engines": {
424+ "node": "^20.19.0 || >=22.12.0"
425+ }
426+ },
427+ "node_modules/@oxlint/binding-win32-x64-msvc": {
428+ "version": "1.73.0",
429+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz",
430+ "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==",
431+ "cpu": [
432+ "x64"
433+ ],
434+ "dev": true,
435+ "license": "MIT",
436+ "optional": true,
437+ "os": [
438+ "win32"
439+ ],
440+ "engines": {
441+ "node": "^20.19.0 || >=22.12.0"
442+ }
443+ },
444+ "node_modules/@rolldown/binding-android-arm64": {
445+ "version": "1.1.5",
446+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
447+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
448+ "cpu": [
449+ "arm64"
450+ ],
451+ "dev": true,
452+ "license": "MIT",
453+ "optional": true,
454+ "os": [
455+ "android"
456+ ],
457+ "engines": {
458+ "node": "^20.19.0 || >=22.12.0"
459+ }
460+ },
461+ "node_modules/@rolldown/binding-darwin-arm64": {
462+ "version": "1.1.5",
463+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
464+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
465+ "cpu": [
466+ "arm64"
467+ ],
468+ "dev": true,
469+ "license": "MIT",
470+ "optional": true,
471+ "os": [
472+ "darwin"
473+ ],
474+ "engines": {
475+ "node": "^20.19.0 || >=22.12.0"
476+ }
477+ },
478+ "node_modules/@rolldown/binding-darwin-x64": {
479+ "version": "1.1.5",
480+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
481+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
482+ "cpu": [
483+ "x64"
484+ ],
485+ "dev": true,
486+ "license": "MIT",
487+ "optional": true,
488+ "os": [
489+ "darwin"
490+ ],
491+ "engines": {
492+ "node": "^20.19.0 || >=22.12.0"
493+ }
494+ },
495+ "node_modules/@rolldown/binding-freebsd-x64": {
496+ "version": "1.1.5",
497+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
498+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
499+ "cpu": [
500+ "x64"
501+ ],
502+ "dev": true,
503+ "license": "MIT",
504+ "optional": true,
505+ "os": [
506+ "freebsd"
507+ ],
508+ "engines": {
509+ "node": "^20.19.0 || >=22.12.0"
510+ }
511+ },
512+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
513+ "version": "1.1.5",
514+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
515+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
516+ "cpu": [
517+ "arm"
518+ ],
519+ "dev": true,
520+ "license": "MIT",
521+ "optional": true,
522+ "os": [
523+ "linux"
524+ ],
525+ "engines": {
526+ "node": "^20.19.0 || >=22.12.0"
527+ }
528+ },
529+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
530+ "version": "1.1.5",
531+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
532+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
533+ "cpu": [
534+ "arm64"
535+ ],
536+ "dev": true,
537+ "libc": [
538+ "glibc"
539+ ],
540+ "license": "MIT",
541+ "optional": true,
542+ "os": [
543+ "linux"
544+ ],
545+ "engines": {
546+ "node": "^20.19.0 || >=22.12.0"
547+ }
548+ },
549+ "node_modules/@rolldown/binding-linux-arm64-musl": {
550+ "version": "1.1.5",
551+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
552+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
553+ "cpu": [
554+ "arm64"
555+ ],
556+ "dev": true,
557+ "libc": [
558+ "musl"
559+ ],
560+ "license": "MIT",
561+ "optional": true,
562+ "os": [
563+ "linux"
564+ ],
565+ "engines": {
566+ "node": "^20.19.0 || >=22.12.0"
567+ }
568+ },
569+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
570+ "version": "1.1.5",
571+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
572+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
573+ "cpu": [
574+ "ppc64"
575+ ],
576+ "dev": true,
577+ "libc": [
578+ "glibc"
579+ ],
580+ "license": "MIT",
581+ "optional": true,
582+ "os": [
583+ "linux"
584+ ],
585+ "engines": {
586+ "node": "^20.19.0 || >=22.12.0"
587+ }
588+ },
589+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
590+ "version": "1.1.5",
591+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
592+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
593+ "cpu": [
594+ "s390x"
595+ ],
596+ "dev": true,
597+ "libc": [
598+ "glibc"
599+ ],
600+ "license": "MIT",
601+ "optional": true,
602+ "os": [
603+ "linux"
604+ ],
605+ "engines": {
606+ "node": "^20.19.0 || >=22.12.0"
607+ }
608+ },
609+ "node_modules/@rolldown/binding-linux-x64-gnu": {
610+ "version": "1.1.5",
611+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
612+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
613+ "cpu": [
614+ "x64"
615+ ],
616+ "dev": true,
617+ "libc": [
618+ "glibc"
619+ ],
620+ "license": "MIT",
621+ "optional": true,
622+ "os": [
623+ "linux"
624+ ],
625+ "engines": {
626+ "node": "^20.19.0 || >=22.12.0"
627+ }
628+ },
629+ "node_modules/@rolldown/binding-linux-x64-musl": {
630+ "version": "1.1.5",
631+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
632+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
633+ "cpu": [
634+ "x64"
635+ ],
636+ "dev": true,
637+ "libc": [
638+ "musl"
639+ ],
640+ "license": "MIT",
641+ "optional": true,
642+ "os": [
643+ "linux"
644+ ],
645+ "engines": {
646+ "node": "^20.19.0 || >=22.12.0"
647+ }
648+ },
649+ "node_modules/@rolldown/binding-openharmony-arm64": {
650+ "version": "1.1.5",
651+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
652+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
653+ "cpu": [
654+ "arm64"
655+ ],
656+ "dev": true,
657+ "license": "MIT",
658+ "optional": true,
659+ "os": [
660+ "openharmony"
661+ ],
662+ "engines": {
663+ "node": "^20.19.0 || >=22.12.0"
664+ }
665+ },
666+ "node_modules/@rolldown/binding-wasm32-wasi": {
667+ "version": "1.1.5",
668+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
669+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
670+ "cpu": [
671+ "wasm32"
672+ ],
673+ "dev": true,
674+ "license": "MIT",
675+ "optional": true,
676+ "dependencies": {
677+ "@emnapi/core": "1.11.1",
678+ "@emnapi/runtime": "1.11.1",
679+ "@napi-rs/wasm-runtime": "^1.1.6"
680+ },
681+ "engines": {
682+ "node": "^20.19.0 || >=22.12.0"
683+ }
684+ },
685+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
686+ "version": "1.1.5",
687+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
688+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
689+ "cpu": [
690+ "arm64"
691+ ],
692+ "dev": true,
693+ "license": "MIT",
694+ "optional": true,
695+ "os": [
696+ "win32"
697+ ],
698+ "engines": {
699+ "node": "^20.19.0 || >=22.12.0"
700+ }
701+ },
702+ "node_modules/@rolldown/binding-win32-x64-msvc": {
703+ "version": "1.1.5",
704+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
705+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
706+ "cpu": [
707+ "x64"
708+ ],
709+ "dev": true,
710+ "license": "MIT",
711+ "optional": true,
712+ "os": [
713+ "win32"
714+ ],
715+ "engines": {
716+ "node": "^20.19.0 || >=22.12.0"
717+ }
718+ },
719+ "node_modules/@rolldown/pluginutils": {
720+ "version": "1.0.1",
721+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
722+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
723+ "dev": true,
724+ "license": "MIT"
725+ },
726+ "node_modules/@tweenjs/tween.js": {
727+ "version": "23.1.3",
728+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
729+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
730+ "dev": true,
731+ "license": "MIT"
732+ },
733+ "node_modules/@tybys/wasm-util": {
734+ "version": "0.10.3",
735+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
736+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
737+ "dev": true,
738+ "license": "MIT",
739+ "optional": true,
740+ "dependencies": {
741+ "tslib": "^2.4.0"
742+ }
743+ },
744+ "node_modules/@types/node": {
745+ "version": "24.13.3",
746+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
747+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
748+ "dev": true,
749+ "license": "MIT",
750+ "dependencies": {
751+ "undici-types": "~7.18.0"
752+ }
753+ },
754+ "node_modules/@types/react": {
755+ "version": "19.2.17",
756+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
757+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
758+ "dev": true,
759+ "license": "MIT",
760+ "dependencies": {
761+ "csstype": "^3.2.2"
762+ }
763+ },
764+ "node_modules/@types/react-dom": {
765+ "version": "19.2.3",
766+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
767+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
768+ "dev": true,
769+ "license": "MIT",
770+ "peerDependencies": {
771+ "@types/react": "^19.2.0"
772+ }
773+ },
774+ "node_modules/@types/stats.js": {
775+ "version": "0.17.4",
776+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
777+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
778+ "dev": true,
779+ "license": "MIT"
780+ },
781+ "node_modules/@types/three": {
782+ "version": "0.185.0",
783+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz",
784+ "integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==",
785+ "dev": true,
786+ "license": "MIT",
787+ "dependencies": {
788+ "@dimforge/rapier3d-compat": "~0.12.0",
789+ "@tweenjs/tween.js": "~23.1.3",
790+ "@types/stats.js": "*",
791+ "@types/webxr": ">=0.5.17",
792+ "fflate": "~0.8.2",
793+ "meshoptimizer": "~1.1.1"
794+ }
795+ },
796+ "node_modules/@types/webxr": {
797+ "version": "0.5.24",
798+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
799+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
800+ "dev": true,
801+ "license": "MIT"
802+ },
803+ "node_modules/@vitejs/plugin-react": {
804+ "version": "6.0.3",
805+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
806+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
807+ "dev": true,
808+ "license": "MIT",
809+ "dependencies": {
810+ "@rolldown/pluginutils": "^1.0.1"
811+ },
812+ "engines": {
813+ "node": "^20.19.0 || >=22.12.0"
814+ },
815+ "peerDependencies": {
816+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
817+ "babel-plugin-react-compiler": "^1.0.0",
818+ "vite": "^8.0.0"
819+ },
820+ "peerDependenciesMeta": {
821+ "@rolldown/plugin-babel": {
822+ "optional": true
823+ },
824+ "babel-plugin-react-compiler": {
825+ "optional": true
826+ }
827+ }
828+ },
829+ "node_modules/csstype": {
830+ "version": "3.2.3",
831+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
832+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
833+ "dev": true,
834+ "license": "MIT"
835+ },
836+ "node_modules/detect-libc": {
837+ "version": "2.1.2",
838+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
839+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
840+ "dev": true,
841+ "license": "Apache-2.0",
842+ "engines": {
843+ "node": ">=8"
844+ }
845+ },
846+ "node_modules/fdir": {
847+ "version": "6.5.0",
848+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
849+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
850+ "dev": true,
851+ "license": "MIT",
852+ "engines": {
853+ "node": ">=12.0.0"
854+ },
855+ "peerDependencies": {
856+ "picomatch": "^3 || ^4"
857+ },
858+ "peerDependenciesMeta": {
859+ "picomatch": {
860+ "optional": true
861+ }
862+ }
863+ },
864+ "node_modules/fflate": {
865+ "version": "0.8.3",
866+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
867+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
868+ "dev": true,
869+ "license": "MIT"
870+ },
871+ "node_modules/fsevents": {
872+ "version": "2.3.3",
873+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
874+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
875+ "dev": true,
876+ "hasInstallScript": true,
877+ "license": "MIT",
878+ "optional": true,
879+ "os": [
880+ "darwin"
881+ ],
882+ "engines": {
883+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
884+ }
885+ },
886+ "node_modules/lightningcss": {
887+ "version": "1.32.0",
888+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
889+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
890+ "dev": true,
891+ "license": "MPL-2.0",
892+ "dependencies": {
893+ "detect-libc": "^2.0.3"
894+ },
895+ "engines": {
896+ "node": ">= 12.0.0"
897+ },
898+ "funding": {
899+ "type": "opencollective",
900+ "url": "https://opencollective.com/parcel"
901+ },
902+ "optionalDependencies": {
903+ "lightningcss-android-arm64": "1.32.0",
904+ "lightningcss-darwin-arm64": "1.32.0",
905+ "lightningcss-darwin-x64": "1.32.0",
906+ "lightningcss-freebsd-x64": "1.32.0",
907+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
908+ "lightningcss-linux-arm64-gnu": "1.32.0",
909+ "lightningcss-linux-arm64-musl": "1.32.0",
910+ "lightningcss-linux-x64-gnu": "1.32.0",
911+ "lightningcss-linux-x64-musl": "1.32.0",
912+ "lightningcss-win32-arm64-msvc": "1.32.0",
913+ "lightningcss-win32-x64-msvc": "1.32.0"
914+ }
915+ },
916+ "node_modules/lightningcss-android-arm64": {
917+ "version": "1.32.0",
918+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
919+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
920+ "cpu": [
921+ "arm64"
922+ ],
923+ "dev": true,
924+ "license": "MPL-2.0",
925+ "optional": true,
926+ "os": [
927+ "android"
928+ ],
929+ "engines": {
930+ "node": ">= 12.0.0"
931+ },
932+ "funding": {
933+ "type": "opencollective",
934+ "url": "https://opencollective.com/parcel"
935+ }
936+ },
937+ "node_modules/lightningcss-darwin-arm64": {
938+ "version": "1.32.0",
939+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
940+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
941+ "cpu": [
942+ "arm64"
943+ ],
944+ "dev": true,
945+ "license": "MPL-2.0",
946+ "optional": true,
947+ "os": [
948+ "darwin"
949+ ],
950+ "engines": {
951+ "node": ">= 12.0.0"
952+ },
953+ "funding": {
954+ "type": "opencollective",
955+ "url": "https://opencollective.com/parcel"
956+ }
957+ },
958+ "node_modules/lightningcss-darwin-x64": {
959+ "version": "1.32.0",
960+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
961+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
962+ "cpu": [
963+ "x64"
964+ ],
965+ "dev": true,
966+ "license": "MPL-2.0",
967+ "optional": true,
968+ "os": [
969+ "darwin"
970+ ],
971+ "engines": {
972+ "node": ">= 12.0.0"
973+ },
974+ "funding": {
975+ "type": "opencollective",
976+ "url": "https://opencollective.com/parcel"
977+ }
978+ },
979+ "node_modules/lightningcss-freebsd-x64": {
980+ "version": "1.32.0",
981+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
982+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
983+ "cpu": [
984+ "x64"
985+ ],
986+ "dev": true,
987+ "license": "MPL-2.0",
988+ "optional": true,
989+ "os": [
990+ "freebsd"
991+ ],
992+ "engines": {
993+ "node": ">= 12.0.0"
994+ },
995+ "funding": {
996+ "type": "opencollective",
997+ "url": "https://opencollective.com/parcel"
998+ }
999+ },
1000+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1001+ "version": "1.32.0",
1002+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
1003+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
1004+ "cpu": [
1005+ "arm"
1006+ ],
1007+ "dev": true,
1008+ "license": "MPL-2.0",
1009+ "optional": true,
1010+ "os": [
1011+ "linux"
1012+ ],
1013+ "engines": {
1014+ "node": ">= 12.0.0"
1015+ },
1016+ "funding": {
1017+ "type": "opencollective",
1018+ "url": "https://opencollective.com/parcel"
1019+ }
1020+ },
1021+ "node_modules/lightningcss-linux-arm64-gnu": {
1022+ "version": "1.32.0",
1023+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
1024+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
1025+ "cpu": [
1026+ "arm64"
1027+ ],
1028+ "dev": true,
1029+ "libc": [
1030+ "glibc"
1031+ ],
1032+ "license": "MPL-2.0",
1033+ "optional": true,
1034+ "os": [
1035+ "linux"
1036+ ],
1037+ "engines": {
1038+ "node": ">= 12.0.0"
1039+ },
1040+ "funding": {
1041+ "type": "opencollective",
1042+ "url": "https://opencollective.com/parcel"
1043+ }
1044+ },
1045+ "node_modules/lightningcss-linux-arm64-musl": {
1046+ "version": "1.32.0",
1047+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
1048+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
1049+ "cpu": [
1050+ "arm64"
1051+ ],
1052+ "dev": true,
1053+ "libc": [
1054+ "musl"
1055+ ],
1056+ "license": "MPL-2.0",
1057+ "optional": true,
1058+ "os": [
1059+ "linux"
1060+ ],
1061+ "engines": {
1062+ "node": ">= 12.0.0"
1063+ },
1064+ "funding": {
1065+ "type": "opencollective",
1066+ "url": "https://opencollective.com/parcel"
1067+ }
1068+ },
1069+ "node_modules/lightningcss-linux-x64-gnu": {
1070+ "version": "1.32.0",
1071+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
1072+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
1073+ "cpu": [
1074+ "x64"
1075+ ],
1076+ "dev": true,
1077+ "libc": [
1078+ "glibc"
1079+ ],
1080+ "license": "MPL-2.0",
1081+ "optional": true,
1082+ "os": [
1083+ "linux"
1084+ ],
1085+ "engines": {
1086+ "node": ">= 12.0.0"
1087+ },
1088+ "funding": {
1089+ "type": "opencollective",
1090+ "url": "https://opencollective.com/parcel"
1091+ }
1092+ },
1093+ "node_modules/lightningcss-linux-x64-musl": {
1094+ "version": "1.32.0",
1095+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
1096+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
1097+ "cpu": [
1098+ "x64"
1099+ ],
1100+ "dev": true,
1101+ "libc": [
1102+ "musl"
1103+ ],
1104+ "license": "MPL-2.0",
1105+ "optional": true,
1106+ "os": [
1107+ "linux"
1108+ ],
1109+ "engines": {
1110+ "node": ">= 12.0.0"
1111+ },
1112+ "funding": {
1113+ "type": "opencollective",
1114+ "url": "https://opencollective.com/parcel"
1115+ }
1116+ },
1117+ "node_modules/lightningcss-win32-arm64-msvc": {
1118+ "version": "1.32.0",
1119+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
1120+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
1121+ "cpu": [
1122+ "arm64"
1123+ ],
1124+ "dev": true,
1125+ "license": "MPL-2.0",
1126+ "optional": true,
1127+ "os": [
1128+ "win32"
1129+ ],
1130+ "engines": {
1131+ "node": ">= 12.0.0"
1132+ },
1133+ "funding": {
1134+ "type": "opencollective",
1135+ "url": "https://opencollective.com/parcel"
1136+ }
1137+ },
1138+ "node_modules/lightningcss-win32-x64-msvc": {
1139+ "version": "1.32.0",
1140+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
1141+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
1142+ "cpu": [
1143+ "x64"
1144+ ],
1145+ "dev": true,
1146+ "license": "MPL-2.0",
1147+ "optional": true,
1148+ "os": [
1149+ "win32"
1150+ ],
1151+ "engines": {
1152+ "node": ">= 12.0.0"
1153+ },
1154+ "funding": {
1155+ "type": "opencollective",
1156+ "url": "https://opencollective.com/parcel"
1157+ }
1158+ },
1159+ "node_modules/meshoptimizer": {
1160+ "version": "1.1.1",
1161+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
1162+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
1163+ "dev": true,
1164+ "license": "MIT"
1165+ },
1166+ "node_modules/nanoid": {
1167+ "version": "3.3.15",
1168+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
1169+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
1170+ "dev": true,
1171+ "funding": [
1172+ {
1173+ "type": "github",
1174+ "url": "https://github.com/sponsors/ai"
1175+ }
1176+ ],
1177+ "license": "MIT",
1178+ "bin": {
1179+ "nanoid": "bin/nanoid.cjs"
1180+ },
1181+ "engines": {
1182+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1183+ }
1184+ },
1185+ "node_modules/opencascade.js": {
1186+ "version": "2.0.0-beta.b5ff984",
1187+ "resolved": "https://registry.npmjs.org/opencascade.js/-/opencascade.js-2.0.0-beta.b5ff984.tgz",
1188+ "integrity": "sha512-4ZIrYrfrCV7SXvQFSmbHlfueSYngQNWGVrRVloVaAEZHDSY+R1k2htdY4AU5axbIGkbWVmrj+M8nmyeMLAelwA==",
1189+ "license": "LGPL-2.1-only",
1190+ "peerDependencies": {
1191+ "ws": "^8.5.0"
1192+ }
1193+ },
1194+ "node_modules/oxlint": {
1195+ "version": "1.73.0",
1196+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
1197+ "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==",
1198+ "dev": true,
1199+ "license": "MIT",
1200+ "bin": {
1201+ "oxlint": "bin/oxlint"
1202+ },
1203+ "engines": {
1204+ "node": "^20.19.0 || >=22.12.0"
1205+ },
1206+ "funding": {
1207+ "url": "https://github.com/sponsors/Boshen"
1208+ },
1209+ "optionalDependencies": {
1210+ "@oxlint/binding-android-arm-eabi": "1.73.0",
1211+ "@oxlint/binding-android-arm64": "1.73.0",
1212+ "@oxlint/binding-darwin-arm64": "1.73.0",
1213+ "@oxlint/binding-darwin-x64": "1.73.0",
1214+ "@oxlint/binding-freebsd-x64": "1.73.0",
1215+ "@oxlint/binding-linux-arm-gnueabihf": "1.73.0",
1216+ "@oxlint/binding-linux-arm-musleabihf": "1.73.0",
1217+ "@oxlint/binding-linux-arm64-gnu": "1.73.0",
1218+ "@oxlint/binding-linux-arm64-musl": "1.73.0",
1219+ "@oxlint/binding-linux-ppc64-gnu": "1.73.0",
1220+ "@oxlint/binding-linux-riscv64-gnu": "1.73.0",
1221+ "@oxlint/binding-linux-riscv64-musl": "1.73.0",
1222+ "@oxlint/binding-linux-s390x-gnu": "1.73.0",
1223+ "@oxlint/binding-linux-x64-gnu": "1.73.0",
1224+ "@oxlint/binding-linux-x64-musl": "1.73.0",
1225+ "@oxlint/binding-openharmony-arm64": "1.73.0",
1226+ "@oxlint/binding-win32-arm64-msvc": "1.73.0",
1227+ "@oxlint/binding-win32-ia32-msvc": "1.73.0",
1228+ "@oxlint/binding-win32-x64-msvc": "1.73.0"
1229+ },
1230+ "peerDependencies": {
1231+ "oxlint-tsgolint": ">=0.24.0",
1232+ "vite-plus": "*"
1233+ },
1234+ "peerDependenciesMeta": {
1235+ "oxlint-tsgolint": {
1236+ "optional": true
1237+ },
1238+ "vite-plus": {
1239+ "optional": true
1240+ }
1241+ }
1242+ },
1243+ "node_modules/picocolors": {
1244+ "version": "1.1.1",
1245+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1246+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1247+ "dev": true,
1248+ "license": "ISC"
1249+ },
1250+ "node_modules/picomatch": {
1251+ "version": "4.0.5",
1252+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
1253+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
1254+ "dev": true,
1255+ "license": "MIT",
1256+ "engines": {
1257+ "node": ">=12"
1258+ },
1259+ "funding": {
1260+ "url": "https://github.com/sponsors/jonschlinkert"
1261+ }
1262+ },
1263+ "node_modules/postcss": {
1264+ "version": "8.5.16",
1265+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
1266+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
1267+ "dev": true,
1268+ "funding": [
1269+ {
1270+ "type": "opencollective",
1271+ "url": "https://opencollective.com/postcss/"
1272+ },
1273+ {
1274+ "type": "tidelift",
1275+ "url": "https://tidelift.com/funding/github/npm/postcss"
1276+ },
1277+ {
1278+ "type": "github",
1279+ "url": "https://github.com/sponsors/ai"
1280+ }
1281+ ],
1282+ "license": "MIT",
1283+ "dependencies": {
1284+ "nanoid": "^3.3.12",
1285+ "picocolors": "^1.1.1",
1286+ "source-map-js": "^1.2.1"
1287+ },
1288+ "engines": {
1289+ "node": "^10 || ^12 || >=14"
1290+ }
1291+ },
1292+ "node_modules/react": {
1293+ "version": "19.2.7",
1294+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
1295+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
1296+ "license": "MIT",
1297+ "engines": {
1298+ "node": ">=0.10.0"
1299+ }
1300+ },
1301+ "node_modules/react-dom": {
1302+ "version": "19.2.7",
1303+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
1304+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
1305+ "license": "MIT",
1306+ "dependencies": {
1307+ "scheduler": "^0.27.0"
1308+ },
1309+ "peerDependencies": {
1310+ "react": "^19.2.7"
1311+ }
1312+ },
1313+ "node_modules/rolldown": {
1314+ "version": "1.1.5",
1315+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
1316+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
1317+ "dev": true,
1318+ "license": "MIT",
1319+ "dependencies": {
1320+ "@oxc-project/types": "=0.139.0",
1321+ "@rolldown/pluginutils": "^1.0.0"
1322+ },
1323+ "bin": {
1324+ "rolldown": "bin/cli.mjs"
1325+ },
1326+ "engines": {
1327+ "node": "^20.19.0 || >=22.12.0"
1328+ },
1329+ "optionalDependencies": {
1330+ "@rolldown/binding-android-arm64": "1.1.5",
1331+ "@rolldown/binding-darwin-arm64": "1.1.5",
1332+ "@rolldown/binding-darwin-x64": "1.1.5",
1333+ "@rolldown/binding-freebsd-x64": "1.1.5",
1334+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
1335+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
1336+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
1337+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
1338+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
1339+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
1340+ "@rolldown/binding-linux-x64-musl": "1.1.5",
1341+ "@rolldown/binding-openharmony-arm64": "1.1.5",
1342+ "@rolldown/binding-wasm32-wasi": "1.1.5",
1343+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
1344+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
1345+ }
1346+ },
1347+ "node_modules/scheduler": {
1348+ "version": "0.27.0",
1349+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1350+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1351+ "license": "MIT"
1352+ },
1353+ "node_modules/source-map-js": {
1354+ "version": "1.2.1",
1355+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1356+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1357+ "dev": true,
1358+ "license": "BSD-3-Clause",
1359+ "engines": {
1360+ "node": ">=0.10.0"
1361+ }
1362+ },
1363+ "node_modules/three": {
1364+ "version": "0.185.1",
1365+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
1366+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
1367+ "license": "MIT"
1368+ },
1369+ "node_modules/tinyglobby": {
1370+ "version": "0.2.17",
1371+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1372+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1373+ "dev": true,
1374+ "license": "MIT",
1375+ "dependencies": {
1376+ "fdir": "^6.5.0",
1377+ "picomatch": "^4.0.4"
1378+ },
1379+ "engines": {
1380+ "node": ">=12.0.0"
1381+ },
1382+ "funding": {
1383+ "url": "https://github.com/sponsors/SuperchupuDev"
1384+ }
1385+ },
1386+ "node_modules/tslib": {
1387+ "version": "2.8.1",
1388+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1389+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1390+ "dev": true,
1391+ "license": "0BSD",
1392+ "optional": true
1393+ },
1394+ "node_modules/typescript": {
1395+ "version": "6.0.3",
1396+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
1397+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
1398+ "dev": true,
1399+ "license": "Apache-2.0",
1400+ "bin": {
1401+ "tsc": "bin/tsc",
1402+ "tsserver": "bin/tsserver"
1403+ },
1404+ "engines": {
1405+ "node": ">=14.17"
1406+ }
1407+ },
1408+ "node_modules/undici-types": {
1409+ "version": "7.18.2",
1410+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
1411+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
1412+ "dev": true,
1413+ "license": "MIT"
1414+ },
1415+ "node_modules/vite": {
1416+ "version": "8.1.3",
1417+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
1418+ "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
1419+ "dev": true,
1420+ "license": "MIT",
1421+ "dependencies": {
1422+ "lightningcss": "^1.32.0",
1423+ "picomatch": "^4.0.4",
1424+ "postcss": "^8.5.16",
1425+ "rolldown": "~1.1.3",
1426+ "tinyglobby": "^0.2.17"
1427+ },
1428+ "bin": {
1429+ "vite": "bin/vite.js"
1430+ },
1431+ "engines": {
1432+ "node": "^20.19.0 || >=22.12.0"
1433+ },
1434+ "funding": {
1435+ "url": "https://github.com/vitejs/vite?sponsor=1"
1436+ },
1437+ "optionalDependencies": {
1438+ "fsevents": "~2.3.3"
1439+ },
1440+ "peerDependencies": {
1441+ "@types/node": "^20.19.0 || >=22.12.0",
1442+ "@vitejs/devtools": "^0.3.0",
1443+ "esbuild": "^0.27.0 || ^0.28.0",
1444+ "jiti": ">=1.21.0",
1445+ "less": "^4.0.0",
1446+ "sass": "^1.70.0",
1447+ "sass-embedded": "^1.70.0",
1448+ "stylus": ">=0.54.8",
1449+ "sugarss": "^5.0.0",
1450+ "terser": "^5.16.0",
1451+ "tsx": "^4.8.1",
1452+ "yaml": "^2.4.2"
1453+ },
1454+ "peerDependenciesMeta": {
1455+ "@types/node": {
1456+ "optional": true
1457+ },
1458+ "@vitejs/devtools": {
1459+ "optional": true
1460+ },
1461+ "esbuild": {
1462+ "optional": true
1463+ },
1464+ "jiti": {
1465+ "optional": true
1466+ },
1467+ "less": {
1468+ "optional": true
1469+ },
1470+ "sass": {
1471+ "optional": true
1472+ },
1473+ "sass-embedded": {
1474+ "optional": true
1475+ },
1476+ "stylus": {
1477+ "optional": true
1478+ },
1479+ "sugarss": {
1480+ "optional": true
1481+ },
1482+ "terser": {
1483+ "optional": true
1484+ },
1485+ "tsx": {
1486+ "optional": true
1487+ },
1488+ "yaml": {
1489+ "optional": true
1490+ }
1491+ }
1492+ },
1493+ "node_modules/ws": {
1494+ "version": "8.21.0",
1495+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
1496+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
1497+ "license": "MIT",
1498+ "peer": true,
1499+ "engines": {
1500+ "node": ">=10.0.0"
1501+ },
1502+ "peerDependencies": {
1503+ "bufferutil": "^4.0.1",
1504+ "utf-8-validate": ">=5.0.2"
1505+ },
1506+ "peerDependenciesMeta": {
1507+ "bufferutil": {
1508+ "optional": true
1509+ },
1510+ "utf-8-validate": {
1511+ "optional": true
1512+ }
1513+ }
1514+ }
1515+ }
1516+}
package.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "name": "mesh-studio",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "oxlint",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "opencascade.js": "2.0.0-beta.b5ff984",
14+ "react": "^19.2.7",
15+ "react-dom": "^19.2.7",
16+ "three": "^0.185.1"
17+ },
18+ "devDependencies": {
19+ "@types/node": "^24.13.2",
20+ "@types/react": "^19.2.17",
21+ "@types/react-dom": "^19.2.3",
22+ "@types/three": "^0.185.0",
23+ "@vitejs/plugin-react": "^6.0.3",
24+ "oxlint": "^1.71.0",
25+ "typescript": "~6.0.2",
26+ "vite": "^8.1.1"
27+ }
28+}
public/favicon.svgadded+21−0View file
@@ -0,0 +1,21 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2+ <rect width="48" height="48" rx="9" fill="#141821"/>
3+ <g fill="none" stroke="#863bff" stroke-width="2" stroke-linejoin="round">
4+ <path d="M24 6 42 15 42 33 24 42 6 33 6 15 Z"/>
5+ <path d="M6 15 24 24 42 15 M24 24 24 42"/>
6+ </g>
7+ <g stroke="#47bfff" stroke-width="1.4" opacity="0.85">
8+ <path d="M6 15 24 6 42 15" fill="none"/>
9+ <path d="M6 24 24 15 42 24" fill="none"/>
10+ <path d="M6 33 24 24 42 33" fill="none"/>
11+ </g>
12+ <g fill="#ede6ff">
13+ <circle cx="24" cy="6" r="2"/>
14+ <circle cx="42" cy="15" r="2"/>
15+ <circle cx="6" cy="15" r="2"/>
16+ <circle cx="24" cy="24" r="2"/>
17+ <circle cx="42" cy="33" r="2"/>
18+ <circle cx="6" cy="33" r="2"/>
19+ <circle cx="24" cy="42" r="2"/>
20+ </g>
21+</svg>
src/App.tsxadded+342−0View file
@@ -0,0 +1,342 @@
1+import { useEffect, useRef, useState } from 'react'
2+import { SurfaceView } from './render/SurfaceView'
3+import type { ViewMode } from './render/SurfaceView'
4+import type { NurbsPatch, SurfaceModel } from './model/types'
5+import { nurbsCoverage, triangleCount, vertexCount } from './model/types'
6+import { mergeTriMeshes } from './model/tessellate'
7+import { loadOpenCascade } from './occ/loader'
8+import type { OpenCascade, Shape } from './occ/types'
9+import { buildModel, retessellate } from './occ/extract'
10+import { importCadFile } from './occ/importCad'
11+import { shapeToStep } from './occ/exportCad'
12+import { makeBox } from './occ/primitives'
13+import { primitives } from './sources'
14+import { toOBJ, toPLY, toSTL } from './export/meshWriters'
15+import { toNurbsJson } from './export/nurbsJson'
16+import './index.css'
17+
18+type EngineState = 'idle' | 'loading' | 'ready' | 'error'
19+
20+const VIEW_MODES: { id: ViewMode; label: string }[] = [
21+ { id: 'shaded', label: 'Shaded' },
22+ { id: 'wire', label: 'Wireframe' },
23+ { id: 'net', label: 'Control net' },
24+ { id: 'iso', label: 'Isocurves' },
25+]
26+
27+const EXPORT_FORMATS = [
28+ { id: 'obj', label: 'OBJ (triangles)', ext: '.obj' },
29+ { id: 'ply', label: 'PLY (triangles)', ext: '.ply' },
30+ { id: 'stl', label: 'STL (triangles)', ext: '.stl' },
31+ { id: 'nurbs', label: 'NURBS patches (JSON)', ext: '.nurbs.json' },
32+ { id: 'step', label: 'STEP (B-rep)', ext: '.step' },
33+] as const
34+
35+type ExportId = (typeof EXPORT_FORMATS)[number]['id']
36+
37+function download(bytes: Uint8Array, filename: string) {
38+ // copy into a fresh ArrayBuffer-backed view so it is a valid BlobPart
39+ const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' })
40+ const url = URL.createObjectURL(blob)
41+ const a = document.createElement('a')
42+ a.href = url
43+ a.download = filename
44+ a.click()
45+ URL.revokeObjectURL(url)
46+}
47+
48+function App() {
49+ const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
50+ state: 'idle',
51+ message: 'CAD engine loads on first use (~30 MB).',
52+ })
53+ const [model, setModel] = useState<SurfaceModel | null>(null)
54+ const [baseName, setBaseName] = useState('model')
55+ const [quality, setQuality] = useState(0.5)
56+ const [mode, setMode] = useState<ViewMode>('shaded')
57+ const [selectedFaceId, setSelectedFaceId] = useState<number | null>(null)
58+ const [exportId, setExportId] = useState<ExportId>('obj')
59+ const [busy, setBusy] = useState<string | null>(null)
60+ const [error, setError] = useState<string | null>(null)
61+
62+ const ocRef = useRef<OpenCascade | null>(null)
63+ const meshShapeRef = useRef<Shape | null>(null)
64+ const fileInputRef = useRef<HTMLInputElement>(null)
65+
66+ async function ensureOc(): Promise<OpenCascade> {
67+ if (ocRef.current) return ocRef.current
68+ const oc = await loadOpenCascade((message) => setEngine({ state: 'loading', message }))
69+ ocRef.current = oc
70+ setEngine({ state: 'ready', message: 'CAD engine ready' })
71+ return oc
72+ }
73+
74+ async function load(
75+ build: (oc: OpenCascade) => Shape,
76+ source: SurfaceModel['source'],
77+ name: string,
78+ raw?: SurfaceModel['raw'],
79+ ) {
80+ setError(null)
81+ setBusy('building')
82+ setSelectedFaceId(null)
83+ try {
84+ const oc = await ensureOc()
85+ const shape = build(oc)
86+ const { model: built, meshShape } = buildModel(oc, shape, quality, source, raw)
87+ meshShapeRef.current = meshShape
88+ setModel(built)
89+ setBaseName(name)
90+ } catch (e) {
91+ setError(e instanceof Error ? e.message : String(e))
92+ setEngine((s) => (s.state === 'loading' ? { state: 'error', message: 'CAD engine failed to load.' } : s))
93+ } finally {
94+ setBusy(null)
95+ }
96+ }
97+
98+ const loadPrimitive = (id: string) => {
99+ const src = primitives.find((p) => p.id === id)
100+ if (!src) return
101+ void load(src.build, { kind: 'primitive', label: src.label }, src.id)
102+ }
103+
104+ // Round-trips a box through OCCT's STEP writer + reader to exercise the
105+ // import pipeline without shipping a bundled file.
106+ const loadSampleStep = () => {
107+ void load(
108+ (oc) => {
109+ const bytes = shapeToStep(oc, makeBox(oc))
110+ return importCadFile(oc, 'sample.step', bytes).shape
111+ },
112+ { kind: 'step', label: 'sample.step (generated)' },
113+ 'sample',
114+ )
115+ }
116+
117+ const openFile = async (file: File) => {
118+ const bytes = new Uint8Array(await file.arrayBuffer())
119+ const lower = file.name.toLowerCase()
120+ const format: 'step' | 'iges' = lower.endsWith('.iges') || lower.endsWith('.igs') ? 'iges' : 'step'
121+ void load(
122+ (oc) => importCadFile(oc, file.name, bytes).shape,
123+ { kind: format, label: file.name },
124+ file.name.replace(/\.[^.]+$/, ''),
125+ { format, bytes },
126+ )
127+ }
128+
129+ // Re-tessellate (debounced) when the resolution slider settles.
130+ useEffect(() => {
131+ const oc = ocRef.current
132+ const meshShape = meshShapeRef.current
133+ if (!oc || !meshShape || !model) return
134+ const t = setTimeout(() => {
135+ setBusy('meshing')
136+ try {
137+ const patches = retessellate(oc, meshShape, quality, model.patches)
138+ setModel((m) => (m ? { ...m, patches } : m))
139+ } catch (e) {
140+ setError(e instanceof Error ? e.message : String(e))
141+ } finally {
142+ setBusy(null)
143+ }
144+ }, 150)
145+ return () => clearTimeout(t)
146+ // eslint-disable-next-line react-hooks/exhaustive-deps
147+ }, [quality])
148+
149+ const doExport = () => {
150+ if (!model) return
151+ setError(null)
152+ try {
153+ const fmt = EXPORT_FORMATS.find((f) => f.id === exportId)!
154+ let bytes: Uint8Array
155+ if (exportId === 'nurbs') {
156+ bytes = toNurbsJson(model)
157+ } else if (exportId === 'step') {
158+ if (model.raw) {
159+ bytes = model.raw.bytes
160+ } else if (ocRef.current && meshShapeRef.current) {
161+ bytes = shapeToStep(ocRef.current, meshShapeRef.current)
162+ } else {
163+ throw new Error('STEP export unavailable for this model.')
164+ }
165+ } else {
166+ const merged = mergeTriMeshes(model)
167+ bytes = exportId === 'obj' ? toOBJ(merged) : exportId === 'ply' ? toPLY(merged) : toSTL(merged)
168+ }
169+ const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'model'
170+ download(bytes, base + fmt.ext)
171+ } catch (e) {
172+ setError(e instanceof Error ? e.message : String(e))
173+ }
174+ }
175+
176+ const selectedPatch =
177+ selectedFaceId != null
178+ ? (model?.patches.find((p) => p.id === selectedFaceId) as NurbsPatch | undefined)
179+ : undefined
180+ const coverage = model ? nurbsCoverage(model) : null
181+
182+ return (
183+ <div className="app">
184+ <div className="sidebar">
185+ <h1>Mesh Studio</h1>
186+ <p className="tagline">
187+ Generate surface meshes with different tools and inspect them in 3D. First tool:{' '}
188+ <a href="https://ocjs.org/">OpenCASCADE.js</a> — CAD B-rep faces are true NURBS surfaces
189+ (polynomials on faces), extracted here alongside the triangulation.
190+ </p>
191+ <div className={`engine-status ${engine.state}`}>{engine.message}</div>
192+
193+ <section>
194+ <h2>Sources</h2>
195+ <div className="primitive-grid">
196+ {primitives.map((p) => (
197+ <button
198+ key={p.id}
199+ onClick={() => loadPrimitive(p.id)}
200+ disabled={busy !== null}
201+ title={p.blurb}
202+ >
203+ {p.label}
204+ </button>
205+ ))}
206+ </div>
207+ <div className="button-row">
208+ <button onClick={() => fileInputRef.current?.click()} disabled={busy !== null}>
209+ Open STEP/IGES…
210+ </button>
211+ <button onClick={loadSampleStep} disabled={busy !== null}>
212+ Sample STEP
213+ </button>
214+ </div>
215+ <input
216+ ref={fileInputRef}
217+ type="file"
218+ accept=".step,.stp,.iges,.igs"
219+ hidden
220+ onChange={(e) => {
221+ const file = e.target.files?.[0]
222+ if (file) void openFile(file)
223+ e.target.value = ''
224+ }}
225+ />
226+ {busy && <div className="busy">{busy === 'building' ? 'Building model…' : 'Re-meshing…'}</div>}
227+ {error && <div className="error">{error}</div>}
228+ </section>
229+
230+ {model && (
231+ <section>
232+ <h2>Model</h2>
233+ <div className="mesh-info">
234+ <div className="source">{model.source.label}</div>
235+ <div>
236+ {model.patches.length} faces · {triangleCount(model).toLocaleString()} triangles ·{' '}
237+ {vertexCount(model).toLocaleString()} vertices
238+ </div>
239+ {coverage && (
240+ <div className={`chip ${coverage.withNurbs === coverage.total ? 'on' : ''}`}>
241+ NURBS extracted on {coverage.withNurbs}/{coverage.total} faces
242+ </div>
243+ )}
244+ </div>
245+ </section>
246+ )}
247+
248+ {model && (
249+ <section>
250+ <h2>View</h2>
251+ <div className="view-toolbar">
252+ {VIEW_MODES.map((m) => (
253+ <button
254+ key={m.id}
255+ className={mode === m.id ? 'active' : ''}
256+ onClick={() => setMode(m.id)}
257+ >
258+ {m.label}
259+ </button>
260+ ))}
261+ </div>
262+ <label className="slider">
263+ <span>Mesh resolution</span>
264+ <input
265+ type="range"
266+ min={0}
267+ max={1}
268+ step={0.01}
269+ value={quality}
270+ onChange={(e) => setQuality(Number(e.target.value))}
271+ disabled={busy !== null}
272+ />
273+ </label>
274+ <p className="footnote">
275+ Coarse ↔ fine re-tessellates the same NURBS faces — drag to see the polynomial
276+ surface go from faceted to smooth. Click a face to inspect it.
277+ </p>
278+ </section>
279+ )}
280+
281+ {selectedPatch && (
282+ <section>
283+ <h2>Face #{selectedPatch.id}</h2>
284+ {selectedPatch.nurbs ? (
285+ <div className="mesh-info">
286+ <div>
287+ Degree (u, v): <strong>{selectedPatch.nurbs.uDegree}, {selectedPatch.nurbs.vDegree}</strong>
288+ </div>
289+ <div>
290+ Control net: {selectedPatch.nurbs.nu} × {selectedPatch.nurbs.nv} poles
291+ </div>
292+ <div>{selectedPatch.nurbs.weights ? 'Rational (NURBS)' : 'Polynomial (non-rational)'}</div>
293+ <div>
294+ Knots: {selectedPatch.nurbs.uKnots.length} u, {selectedPatch.nurbs.vKnots.length} v
295+ </div>
296+ <div className="footnote">
297+ {selectedPatch.tri.indices.length / 3} triangles at this resolution
298+ </div>
299+ </div>
300+ ) : (
301+ <div className="mesh-info">No NURBS data extracted for this face.</div>
302+ )}
303+ <button className="subtle" onClick={() => setSelectedFaceId(null)}>
304+ Clear selection
305+ </button>
306+ </section>
307+ )}
308+
309+ {model && (
310+ <section>
311+ <h2>Export</h2>
312+ <select value={exportId} onChange={(e) => setExportId(e.target.value as ExportId)}>
313+ {EXPORT_FORMATS.map((f) => (
314+ <option key={f.id} value={f.id}>
315+ {f.label}
316+ </option>
317+ ))}
318+ </select>
319+ <button className="primary" onClick={doExport} disabled={busy !== null}>
320+ Download {EXPORT_FORMATS.find((f) => f.id === exportId)!.ext}
321+ </button>
322+ <p className="footnote">
323+ Triangle formats export the current tessellation. NURBS JSON stores the exact
324+ polynomial patches. STEP hands back the B-rep (original bytes for imported files).
325+ </p>
326+ </section>
327+ )}
328+ </div>
329+
330+ <div className="viewport">
331+ <SurfaceView
332+ model={model}
333+ mode={mode}
334+ selectedFaceId={selectedFaceId}
335+ onSelectFace={setSelectedFaceId}
336+ />
337+ </div>
338+ </div>
339+ )
340+}
341+
342+export default App
src/export/meshWriters.tsadded+105−0View file
@@ -0,0 +1,105 @@
1+/**
2+ * Dependency-free writers from a merged `TriMesh` to common mesh files.
3+ * Positions and normals only — the tessellated approximation of the model.
4+ */
5+import type { TriMesh } from '../model/types'
6+
7+export function toOBJ(mesh: TriMesh): Uint8Array {
8+ const { positions, normals, indices } = mesh
9+ const lines: string[] = ['# mesh-studio export']
10+ for (let i = 0; i < positions.length; i += 3) {
11+ lines.push(`v ${positions[i]} ${positions[i + 1]} ${positions[i + 2]}`)
12+ }
13+ for (let i = 0; i < normals.length; i += 3) {
14+ lines.push(`vn ${normals[i]} ${normals[i + 1]} ${normals[i + 2]}`)
15+ }
16+ for (let i = 0; i < indices.length; i += 3) {
17+ const a = indices[i] + 1
18+ const b = indices[i + 1] + 1
19+ const c = indices[i + 2] + 1
20+ lines.push(`f ${a}//${a} ${b}//${b} ${c}//${c}`)
21+ }
22+ return new TextEncoder().encode(lines.join('\n') + '\n')
23+}
24+
25+export function toPLY(mesh: TriMesh): Uint8Array {
26+ const { positions, normals, indices } = mesh
27+ const nVerts = positions.length / 3
28+ const nFaces = indices.length / 3
29+ const lines: string[] = [
30+ 'ply',
31+ 'format ascii 1.0',
32+ 'comment mesh-studio export',
33+ `element vertex ${nVerts}`,
34+ 'property float x',
35+ 'property float y',
36+ 'property float z',
37+ 'property float nx',
38+ 'property float ny',
39+ 'property float nz',
40+ `element face ${nFaces}`,
41+ 'property list uchar int vertex_index',
42+ 'end_header',
43+ ]
44+ for (let i = 0; i < nVerts; i++) {
45+ const p = i * 3
46+ lines.push(
47+ `${positions[p]} ${positions[p + 1]} ${positions[p + 2]} ` +
48+ `${normals[p]} ${normals[p + 1]} ${normals[p + 2]}`,
49+ )
50+ }
51+ for (let i = 0; i < nFaces; i++) {
52+ const f = i * 3
53+ lines.push(`3 ${indices[f]} ${indices[f + 1]} ${indices[f + 2]}`)
54+ }
55+ return new TextEncoder().encode(lines.join('\n') + '\n')
56+}
57+
58+export function toSTL(mesh: TriMesh): Uint8Array {
59+ const { positions, normals, indices } = mesh
60+ const nTri = indices.length / 3
61+ const buffer = new ArrayBuffer(84 + nTri * 50)
62+ const view = new DataView(buffer)
63+ // 80-byte header left as zeros, then triangle count
64+ view.setUint32(80, nTri, true)
65+ let off = 84
66+ const faceNormal = (a: number, b: number, c: number) => {
67+ // average the vertex normals, fall back to geometric normal
68+ let nx = normals[a] + normals[b] + normals[c]
69+ let ny = normals[a + 1] + normals[b + 1] + normals[c + 1]
70+ let nz = normals[a + 2] + normals[b + 2] + normals[c + 2]
71+ const len = Math.hypot(nx, ny, nz)
72+ if (len < 1e-9) {
73+ const ux = positions[b] - positions[a]
74+ const uy = positions[b + 1] - positions[a + 1]
75+ const uz = positions[b + 2] - positions[a + 2]
76+ const vx = positions[c] - positions[a]
77+ const vy = positions[c + 1] - positions[a + 1]
78+ const vz = positions[c + 2] - positions[a + 2]
79+ nx = uy * vz - uz * vy
80+ ny = uz * vx - ux * vz
81+ nz = ux * vy - uy * vx
82+ }
83+ const l = Math.hypot(nx, ny, nz) || 1
84+ return [nx / l, ny / l, nz / l] as const
85+ }
86+ for (let t = 0; t < nTri; t++) {
87+ const ia = indices[t * 3] * 3
88+ const ib = indices[t * 3 + 1] * 3
89+ const ic = indices[t * 3 + 2] * 3
90+ const [nx, ny, nz] = faceNormal(ia, ib, ic)
91+ view.setFloat32(off, nx, true)
92+ view.setFloat32(off + 4, ny, true)
93+ view.setFloat32(off + 8, nz, true)
94+ off += 12
95+ for (const iv of [ia, ib, ic]) {
96+ view.setFloat32(off, positions[iv], true)
97+ view.setFloat32(off + 4, positions[iv + 1], true)
98+ view.setFloat32(off + 8, positions[iv + 2], true)
99+ off += 12
100+ }
101+ view.setUint16(off, 0, true)
102+ off += 2
103+ }
104+ return new Uint8Array(buffer)
105+}
src/export/nurbsJson.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+/**
2+ * The polynomial-native export: dump each face's NURBS surface (degrees, poles,
3+ * weights, knots) to JSON. This is the "mesh defined by polynomials on faces"
4+ * representation, distinct from any triangulated file.
5+ */
6+import type { SurfaceModel } from '../model/types'
7+
8+export function toNurbsJson(model: SurfaceModel): Uint8Array {
9+ const patches = model.patches.map((p) => {
10+ if (p.kind === 'nurbs' && p.nurbs) {
11+ const n = p.nurbs
12+ return {
13+ id: p.id,
14+ kind: 'nurbs',
15+ uDegree: n.uDegree,
16+ vDegree: n.vDegree,
17+ nu: n.nu,
18+ nv: n.nv,
19+ rational: n.weights !== null,
20+ uKnots: n.uKnots,
21+ uMults: n.uMults,
22+ vKnots: n.vKnots,
23+ vMults: n.vMults,
24+ poles: Array.from(n.poles),
25+ weights: n.weights ? Array.from(n.weights) : null,
26+ }
27+ }
28+ return { id: p.id, kind: p.kind, nurbs: null }
29+ })
30+ const doc = {
31+ format: 'mesh-studio-nurbs',
32+ version: 1,
33+ source: model.source.label,
34+ patchCount: model.patches.length,
35+ polesLayout: 'row-major, pole(i,j) at (i*nv + j)*3',
36+ patches,
37+ }
38+ return new TextEncoder().encode(JSON.stringify(doc, null, 2))
39+}
src/index.cssadded+255−0View file
@@ -0,0 +1,255 @@
1+* {
2+ box-sizing: border-box;
3+}
4+
5+html,
6+body,
7+#root {
8+ height: 100%;
9+ margin: 0;
10+}
11+
12+body {
13+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
14+ font-size: 14px;
15+ color: #e2e5ea;
16+ background: #161a22;
17+}
18+
19+.app {
20+ display: flex;
21+ height: 100%;
22+}
23+
24+.sidebar {
25+ width: 340px;
26+ flex-shrink: 0;
27+ overflow-y: auto;
28+ padding: 20px;
29+ background: #1e2128;
30+ border-right: 1px solid #2c313a;
31+}
32+
33+.sidebar h1 {
34+ margin: 0 0 4px;
35+ font-size: 20px;
36+ color: #fff;
37+}
38+
39+.tagline {
40+ margin: 0 0 10px;
41+ color: #9aa1ac;
42+ font-size: 13px;
43+ line-height: 1.45;
44+}
45+
46+.tagline a {
47+ color: #9b8bff;
48+}
49+
50+.engine-status {
51+ margin: 0 0 16px;
52+ font-size: 12px;
53+ color: #9aa1ac;
54+}
55+
56+.engine-status.loading {
57+ color: #ecc477;
58+}
59+.engine-status.ready {
60+ color: #8fd7ab;
61+}
62+.engine-status.error {
63+ color: #f2a3a3;
64+}
65+
66+.sidebar section {
67+ margin-bottom: 22px;
68+}
69+
70+.sidebar h2 {
71+ font-size: 12px;
72+ text-transform: uppercase;
73+ letter-spacing: 0.08em;
74+ color: #7f8794;
75+ margin: 0 0 8px;
76+}
77+
78+button {
79+ font: inherit;
80+ padding: 7px 12px;
81+ border-radius: 6px;
82+ border: 1px solid #3a4150;
83+ background: #2a2f39;
84+ color: #e2e5ea;
85+ cursor: pointer;
86+}
87+
88+button:hover:not(:disabled) {
89+ background: #333947;
90+}
91+
92+button:disabled {
93+ opacity: 0.5;
94+ cursor: default;
95+}
96+
97+.primitive-grid {
98+ display: grid;
99+ grid-template-columns: 1fr 1fr;
100+ gap: 8px;
101+ margin-bottom: 10px;
102+}
103+
104+.button-row {
105+ display: flex;
106+ gap: 8px;
107+}
108+
109+.button-row button {
110+ flex: 1;
111+}
112+
113+button.primary {
114+ margin-top: 10px;
115+ width: 100%;
116+ background: #6a44c8;
117+ border-color: #6a44c8;
118+ color: #fff;
119+ font-weight: 600;
120+}
121+
122+button.primary:hover:not(:disabled) {
123+ background: #7a54d8;
124+}
125+
126+button.subtle {
127+ margin-top: 8px;
128+ padding: 3px 8px;
129+ font-size: 12px;
130+ color: #9aa1ac;
131+ background: transparent;
132+ border-color: #2c313a;
133+}
134+
135+select {
136+ font: inherit;
137+ width: 100%;
138+ padding: 7px 8px;
139+ border-radius: 6px;
140+ border: 1px solid #3a4150;
141+ background: #2a2f39;
142+ color: #e2e5ea;
143+}
144+
145+.error {
146+ margin-top: 10px;
147+ padding: 8px 10px;
148+ border-radius: 6px;
149+ background: rgba(220, 70, 70, 0.12);
150+ border: 1px solid rgba(220, 70, 70, 0.45);
151+ color: #f2a3a3;
152+ font-size: 13px;
153+ white-space: pre-wrap;
154+}
155+
156+.busy {
157+ margin-top: 10px;
158+ color: #ecc477;
159+ font-size: 13px;
160+}
161+
162+.mesh-info {
163+ font-size: 13px;
164+ line-height: 1.55;
165+}
166+
167+.mesh-info .source {
168+ color: #7f8794;
169+ font-size: 12px;
170+ margin-bottom: 4px;
171+ overflow-wrap: anywhere;
172+}
173+
174+.chip {
175+ display: inline-block;
176+ margin-top: 8px;
177+ padding: 2px 10px;
178+ border-radius: 999px;
179+ font-size: 12px;
180+ border: 1px solid #3a4150;
181+ color: #b3b9c2;
182+}
183+
184+.chip.on {
185+ color: #a8d5b8;
186+ border-color: rgba(80, 190, 120, 0.5);
187+}
188+
189+.view-toolbar {
190+ display: flex;
191+ flex-wrap: wrap;
192+ gap: 0;
193+ border: 1px solid #3a4150;
194+ border-radius: 6px;
195+ overflow: hidden;
196+}
197+
198+.view-toolbar button {
199+ flex: 1 1 auto;
200+ border: none;
201+ border-radius: 0;
202+ background: transparent;
203+ padding: 6px 8px;
204+ font-size: 12px;
205+ color: #9aa1ac;
206+}
207+
208+.view-toolbar button + button {
209+ border-left: 1px solid #3a4150;
210+}
211+
212+.view-toolbar button.active {
213+ background: rgba(106, 68, 200, 0.4);
214+ color: #e7ddff;
215+}
216+
217+.slider {
218+ display: block;
219+ margin: 12px 0 0;
220+}
221+
222+.slider > span {
223+ display: block;
224+ font-size: 12.5px;
225+ color: #9aa1ac;
226+ margin-bottom: 4px;
227+}
228+
229+.slider input[type='range'] {
230+ width: 100%;
231+}
232+
233+.footnote {
234+ margin: 8px 0 0;
235+ color: #6b7280;
236+ font-size: 12px;
237+ line-height: 1.45;
238+}
239+
240+.viewport {
241+ flex: 1;
242+ min-width: 0;
243+ position: relative;
244+}
245+
246+.view-placeholder {
247+ position: absolute;
248+ inset: 0;
249+ display: flex;
250+ align-items: center;
251+ justify-content: center;
252+ color: #6b7280;
253+ font-size: 15px;
254+ pointer-events: none;
255+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import './index.css'
4+import App from './App.tsx'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/model/tessellate.tsadded+96−0View file
@@ -0,0 +1,96 @@
1+/**
2+ * Pure geometry helpers that turn a SurfaceModel into renderable / exportable
3+ * arrays. No three.js and no OpenCASCADE here — both the renderer and the
4+ * export writers depend on this module.
5+ */
6+import type { NurbsSurface, SurfaceModel, TriMesh } from './types'
7+
8+/** Axis-aligned bounds of the model, plus a center and radius for framing. */
9+export interface Bounds {
10+ min: [number, number, number]
11+ max: [number, number, number]
12+ center: [number, number, number]
13+ radius: number
14+}
15+
16+function extend(min: number[], max: number[], x: number, y: number, z: number) {
17+ if (x < min[0]) min[0] = x
18+ if (y < min[1]) min[1] = y
19+ if (z < min[2]) min[2] = z
20+ if (x > max[0]) max[0] = x
21+ if (y > max[1]) max[1] = y
22+ if (z > max[2]) max[2] = z
23+}
24+
25+export function modelBounds(model: SurfaceModel): Bounds {
26+ const min = [Infinity, Infinity, Infinity]
27+ const max = [-Infinity, -Infinity, -Infinity]
28+ for (const p of model.patches) {
29+ const pos = p.tri.positions
30+ for (let i = 0; i + 2 < pos.length; i += 3) extend(min, max, pos[i], pos[i + 1], pos[i + 2])
31+ // include control poles so the control-net view stays in frame
32+ if (p.kind === 'nurbs' && p.nurbs) {
33+ const poles = p.nurbs.poles
34+ for (let i = 0; i + 2 < poles.length; i += 3) extend(min, max, poles[i], poles[i + 1], poles[i + 2])
35+ }
36+ }
37+ if (!isFinite(min[0])) {
38+ min[0] = min[1] = min[2] = -1
39+ max[0] = max[1] = max[2] = 1
40+ }
41+ const center: [number, number, number] = [
42+ (min[0] + max[0]) / 2,
43+ (min[1] + max[1]) / 2,
44+ (min[2] + max[2]) / 2,
45+ ]
46+ const radius =
47+ 0.5 * Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2], 1e-6)
48+ return { min: min as [number, number, number], max: max as [number, number, number], center, radius }
49+}
50+
51+/**
52+ * Line segments of a NURBS control net: the pole grid connected along u and v.
53+ * Returns flat xyz pairs (each 6 numbers = one segment) plus the poles as points.
54+ */
55+export function controlNet(nurbs: NurbsSurface): { segments: Float32Array; points: Float32Array } {
56+ const { nu, nv, poles } = nurbs
57+ const pole = (i: number, j: number, c: number) => poles[(i * nv + j) * 3 + c]
58+ const segs: number[] = []
59+ for (let i = 0; i < nu; i++) {
60+ for (let j = 0; j < nv; j++) {
61+ if (i + 1 < nu) {
62+ segs.push(pole(i, j, 0), pole(i, j, 1), pole(i, j, 2))
63+ segs.push(pole(i + 1, j, 0), pole(i + 1, j, 1), pole(i + 1, j, 2))
64+ }
65+ if (j + 1 < nv) {
66+ segs.push(pole(i, j, 0), pole(i, j, 1), pole(i, j, 2))
67+ segs.push(pole(i, j + 1, 0), pole(i, j + 1, 1), pole(i, j + 1, 2))
68+ }
69+ }
70+ }
71+ return { segments: new Float32Array(segs), points: poles.slice() }
72+}
73+
74+/** Merge every patch triangulation into one indexed mesh (for file export). */
75+export function mergeTriMeshes(model: SurfaceModel): TriMesh {
76+ let nVerts = 0
77+ let nIndices = 0
78+ for (const p of model.patches) {
79+ nVerts += p.tri.positions.length / 3
80+ nIndices += p.tri.indices.length
81+ }
82+ const positions = new Float32Array(nVerts * 3)
83+ const normals = new Float32Array(nVerts * 3)
84+ const indices = new Uint32Array(nIndices)
85+ let vOff = 0
86+ let iOff = 0
87+ for (const p of model.patches) {
88+ const t = p.tri
89+ positions.set(t.positions, vOff * 3)
90+ normals.set(t.normals, vOff * 3)
91+ for (let k = 0; k < t.indices.length; k++) indices[iOff + k] = t.indices[k] + vOff
92+ vOff += t.positions.length / 3
93+ iOff += t.indices.length
94+ }
95+ return { positions, normals, indices }
96+}
src/model/types.tsadded+101−0View file
@@ -0,0 +1,101 @@
1+/**
2+ * The internal mesh model for mesh-studio.
3+ *
4+ * Deliberately broader than a plain triangle mesh: a `SurfaceModel` is a
5+ * collection of *patches*, and a patch is a discriminated union so that
6+ * different mesh-producing tools can contribute different kinds of faces
7+ * without a redesign. v1 (OpenCASCADE) emits `nurbs` patches — each carrying
8+ * both a triangulation for display and the underlying rational-B-spline
9+ * surface (the "polynomial on the face"). The other kinds are reserved growth
10+ * points documented below.
11+ */
12+
13+/** A triangulated approximation of one patch — what three.js consumes. */
14+export interface TriMesh {
15+ /** Flat xyz triples, 3 numbers per vertex. */
16+ positions: Float32Array
17+ /** Flat xyz triples, 3 numbers per vertex (matches positions). */
18+ normals: Float32Array
19+ /** Flat triangle indices (0-based), 3 per triangle. */
20+ indices: Uint32Array
21+}
22+
23+/**
24+ * A rational tensor-product B-spline (NURBS) surface — a piecewise polynomial
25+ * in two parameters (u, v). This is the exact object an OpenCASCADE B-rep face
26+ * carries. Poles are stored row-major: pole (i, j) with 0 <= i < nu,
27+ * 0 <= j < nv lives at index `(i * nv + j) * 3` in `poles`.
28+ */
29+export interface NurbsSurface {
30+ uDegree: number
31+ vDegree: number
32+ nu: number
33+ nv: number
34+ /** Control points ("poles"), nu*nv*3 numbers, row-major. */
35+ poles: Float32Array
36+ /** Per-pole weights, nu*nv numbers, or null when non-rational (pure polynomial). */
37+ weights: Float32Array | null
38+ /** Distinct knot values in u, with matching multiplicities. */
39+ uKnots: number[]
40+ uMults: number[]
41+ vKnots: number[]
42+ vMults: number[]
43+}
44+
45+/** One face of a model. Discriminated on `kind`. */
46+export type Patch =
47+ | NurbsPatch
48+ // --- reserved growth points (not emitted in v1) ---
49+ // A flat linear cell mesh imported from a triangle/quad/polygon format.
50+ | { kind: 'linear'; id: number; tri: TriMesh }
51+ // A nodal high-order element (e.g. surfacefun): order + a grid of sample nodes.
52+ | { kind: 'lagrange'; id: number; tri: TriMesh; order: number }
53+ // A generic parametric patch carrying its own evaluator.
54+ | { kind: 'parametric'; id: number; tri: TriMesh }
55+
56+export interface NurbsPatch {
57+ kind: 'nurbs'
58+ id: number
59+ /** Triangulation for display (from OpenCASCADE's BRepMesh, respects trimming). */
60+ tri: TriMesh
61+ /** The underlying polynomial surface, or null if extraction was unavailable. */
62+ nurbs: NurbsSurface | null
63+ /** Sampled iso-parameter curves (world xyz polylines) for the isocurve view. */
64+ isoLines?: Float32Array[]
65+}
66+
67+export interface SurfaceModel {
68+ patches: Patch[]
69+ source: {
70+ kind: 'primitive' | 'step' | 'iges'
71+ label: string
72+ }
73+ /** Original uploaded bytes, retained so STEP/IGES sources can be re-exported verbatim. */
74+ raw?: { format: 'step' | 'iges'; bytes: Uint8Array }
75+}
76+
77+/** Every patch has a triangulation; this narrows the union for callers. */
78+export function patchTri(patch: Patch): TriMesh {
79+ return patch.tri
80+}
81+
82+export function triangleCount(model: SurfaceModel): number {
83+ let n = 0
84+ for (const p of model.patches) n += p.tri.indices.length / 3
85+ return n
86+}
87+
88+export function vertexCount(model: SurfaceModel): number {
89+ let n = 0
90+ for (const p of model.patches) n += p.tri.positions.length / 3
91+ return n
92+}
93+
94+/** How many faces carry extracted NURBS data (for the "N/M faces" readout). */
95+export function nurbsCoverage(model: SurfaceModel): { withNurbs: number; total: number } {
96+ let withNurbs = 0
97+ for (const p of model.patches) {
98+ if (p.kind === 'nurbs' && p.nurbs) withNurbs++
99+ }
100+ return { withNurbs, total: model.patches.length }
101+}
src/occ/exportCad.tsadded+25−0View file
@@ -0,0 +1,25 @@
1+/**
2+ * Write an OCCT shape out as a STEP file (used for primitive sources, which
3+ * have no original file to hand back).
4+ */
5+import type { OpenCascade, Shape } from './types'
6+
7+export function shapeToStep(oc: OpenCascade, shape: Shape): Uint8Array {
8+ const writer = new oc.STEPControl_Writer_1()
9+ writer.Transfer(
10+ shape,
11+ oc.STEPControl_StepModelType.STEPControl_AsIs,
12+ true,
13+ new oc.Message_ProgressRange_1(),
14+ )
15+ const fname = 'export.step'
16+ writer.Write(fname)
17+ const bytes: Uint8Array = oc.FS.readFile(`/${fname}`)
18+ try {
19+ oc.FS.unlink(`/${fname}`)
20+ } catch {
21+ /* ignore */
22+ }
23+ // Copy out of the WASM heap view into a standalone buffer.
24+ return new Uint8Array(bytes)
25+}
src/occ/extract.tsadded+309−0View file
@@ -0,0 +1,309 @@
1+/**
2+ * shape -> SurfaceModel.
3+ *
4+ * Two things happen per B-rep face:
5+ * - **Triangulation** (always): OpenCASCADE's BRepMesh produces a triangle
6+ * approximation that respects the face's trimming. This is what three.js
7+ * draws, and it never depends on NURBS extraction succeeding.
8+ * - **NURBS extraction** (best-effort): after converting the shape's faces to
9+ * B-spline form, we read each face's poles / weights / knots / degree — the
10+ * actual polynomial that defines the surface — plus a few sampled isocurves.
11+ *
12+ * The mesh density is controlled by a dimensionless `quality` in [0, 1] fed to
13+ * BRepMesh in *relative* mode, so it is independent of the model's scale.
14+ */
15+import type { NurbsSurface, Patch, SurfaceModel, TriMesh } from '../model/types'
16+import type { OpenCascade, Shape } from './types'
17+
18+export interface BuiltModel {
19+ model: SurfaceModel
20+ /** The (NURBS-converted, if possible) shape kept for re-tessellation. */
21+ meshShape: Shape
22+}
23+
24+const EMPTY_TRI: TriMesh = {
25+ positions: new Float32Array(0),
26+ normals: new Float32Array(0),
27+ indices: new Uint32Array(0),
28+}
29+
30+/** Map quality in [0,1] to BRepMesh relative-linear and angular deflections. */
31+function deflections(quality: number): { lin: number; ang: number } {
32+ const q = Math.min(1, Math.max(0, quality))
33+ return {
34+ lin: 0.02 - (0.02 - 0.0007) * q, // relative to edge size
35+ ang: 0.8 - (0.8 - 0.15) * q, // radians
36+ }
37+}
38+
39+function runMesh(oc: OpenCascade, shape: Shape, quality: number): void {
40+ const { lin, ang } = deflections(quality)
41+ try {
42+ // second arg (forceFaceDeflection) is required in this OCCT build; Clean
43+ // wipes the old triangulation so a coarser deflection actually coarsens.
44+ oc.BRepTools.Clean(shape, false)
45+ } catch {
46+ /* Clean unavailable — IncrementalMesh still recomputes when deflection differs */
47+ }
48+ new oc.BRepMesh_IncrementalMesh_2(shape, lin, true, ang, false)
49+}
50+
51+/** Per-vertex normals from the triangle connectivity (fallback if OCCT's fail). */
52+function computeNormals(positions: Float32Array, indices: Uint32Array): Float32Array {
53+ const normals = new Float32Array(positions.length)
54+ for (let t = 0; t + 2 < indices.length; t += 3) {
55+ const a = indices[t] * 3
56+ const b = indices[t + 1] * 3
57+ const c = indices[t + 2] * 3
58+ const ux = positions[b] - positions[a]
59+ const uy = positions[b + 1] - positions[a + 1]
60+ const uz = positions[b + 2] - positions[a + 2]
61+ const vx = positions[c] - positions[a]
62+ const vy = positions[c + 1] - positions[a + 1]
63+ const vz = positions[c + 2] - positions[a + 2]
64+ const nx = uy * vz - uz * vy
65+ const ny = uz * vx - ux * vz
66+ const nz = ux * vy - uy * vx
67+ for (const i of [a, b, c]) {
68+ normals[i] += nx
69+ normals[i + 1] += ny
70+ normals[i + 2] += nz
71+ }
72+ }
73+ for (let i = 0; i < normals.length; i += 3) {
74+ const len = Math.hypot(normals[i], normals[i + 1], normals[i + 2]) || 1
75+ normals[i] /= len
76+ normals[i + 1] /= len
77+ normals[i + 2] /= len
78+ }
79+ return normals
80+}
81+
82+/** Read the BRepMesh triangulation stored on a face (world coordinates). */
83+function readFaceTriangulation(oc: OpenCascade, face: Shape): TriMesh {
84+ const loc = new oc.TopLoc_Location_1()
85+ const handle = oc.BRep_Tool.Triangulation(face, loc, 0)
86+ if (handle.IsNull()) {
87+ loc.delete()
88+ return EMPTY_TRI
89+ }
90+ const tri = handle.get()
91+ const trsf = loc.Transformation()
92+ const nNodes = tri.NbNodes()
93+
94+ const positions = new Float32Array(nNodes * 3)
95+ for (let i = 1; i <= nNodes; i++) {
96+ const node = tri.Node(i)
97+ const p = node.Transformed(trsf)
98+ positions[(i - 1) * 3] = p.X()
99+ positions[(i - 1) * 3 + 1] = p.Y()
100+ positions[(i - 1) * 3 + 2] = p.Z()
101+ node.delete()
102+ p.delete()
103+ }
104+
105+ const forward = face.Orientation_1() === oc.TopAbs_Orientation.TopAbs_FORWARD
106+ const triangles = tri.Triangles()
107+ const nTri = tri.NbTriangles()
108+ const indices = new Uint32Array(nTri * 3)
109+ for (let nt = 1; nt <= nTri; nt++) {
110+ const t = triangles.Value(nt)
111+ let n1 = t.Value(1)
112+ let n2 = t.Value(2)
113+ const n3 = t.Value(3)
114+ if (!forward) {
115+ const tmp = n1
116+ n1 = n2
117+ n2 = tmp
118+ }
119+ indices[(nt - 1) * 3] = n1 - 1
120+ indices[(nt - 1) * 3 + 1] = n2 - 1
121+ indices[(nt - 1) * 3 + 2] = n3 - 1
122+ t.delete()
123+ }
124+
125+ let normals: Float32Array = new Float32Array(nNodes * 3)
126+ let haveNormals = false
127+ try {
128+ const pc = new oc.Poly_Connect_2(handle)
129+ const nrm = new oc.TColgp_Array1OfDir_2(1, nNodes)
130+ oc.StdPrs_ToolTriangulatedShape.Normal(face, pc, nrm)
131+ for (let i = nrm.Lower(); i <= nrm.Upper(); i++) {
132+ const d0 = nrm.Value(i)
133+ const d = d0.Transformed(trsf)
134+ const s = forward ? 1 : -1
135+ normals[(i - 1) * 3] = s * d.X()
136+ normals[(i - 1) * 3 + 1] = s * d.Y()
137+ normals[(i - 1) * 3 + 2] = s * d.Z()
138+ d0.delete()
139+ d.delete()
140+ }
141+ nrm.delete()
142+ pc.delete()
143+ haveNormals = true
144+ } catch {
145+ /* fall back below */
146+ }
147+ if (!haveNormals) normals = computeNormals(positions, indices)
148+
149+ triangles.delete()
150+ trsf.delete()
151+ handle.delete()
152+ loc.delete()
153+ return { positions, normals, indices }
154+}
155+
156+/** Try to convert every face of the shape to B-spline form. */
157+function nurbsConvert(oc: OpenCascade, shape: Shape): { shape: Shape; ok: boolean } {
158+ try {
159+ const conv = new oc.BRepBuilderAPI_NurbsConvert_2(shape, true)
160+ return { shape: conv.Shape(), ok: true }
161+ } catch {
162+ return { shape, ok: false }
163+ }
164+}
165+
166+/** Extract a face's B-spline surface data plus sampled isocurves, or null. */
167+function extractNurbs(oc: OpenCascade, face: Shape): { nurbs: NurbsSurface; isoLines: Float32Array[] } | null {
168+ let handle: Shape | null = null
169+ let bsHandle: Shape | null = null
170+ try {
171+ handle = oc.BRep_Tool.Surface_2(face)
172+ const raw = handle.get()
173+ if (raw.$$?.ptrType?.name !== 'Geom_BSplineSurface*') return null
174+
175+ bsHandle = new oc.Handle_Geom_BSplineSurface_2(raw)
176+ const bs = bsHandle.get()
177+
178+ const uDegree: number = bs.UDegree()
179+ const vDegree: number = bs.VDegree()
180+ const nu: number = bs.NbUPoles()
181+ const nv: number = bs.NbVPoles()
182+ const rational = bs.IsURational() || bs.IsVRational()
183+
184+ const poles = new Float32Array(nu * nv * 3)
185+ const weights = rational ? new Float32Array(nu * nv) : null
186+ for (let i = 1; i <= nu; i++) {
187+ for (let j = 1; j <= nv; j++) {
188+ const p = bs.Pole(i, j)
189+ const idx = ((i - 1) * nv + (j - 1)) * 3
190+ poles[idx] = p.X()
191+ poles[idx + 1] = p.Y()
192+ poles[idx + 2] = p.Z()
193+ p.delete()
194+ if (weights) weights[(i - 1) * nv + (j - 1)] = bs.Weight(i, j)
195+ }
196+ }
197+
198+ const uKnots: number[] = []
199+ const uMults: number[] = []
200+ for (let i = 1; i <= bs.NbUKnots(); i++) {
201+ uKnots.push(bs.UKnot(i))
202+ uMults.push(bs.UMultiplicity(i))
203+ }
204+ const vKnots: number[] = []
205+ const vMults: number[] = []
206+ for (let i = 1; i <= bs.NbVKnots(); i++) {
207+ vKnots.push(bs.VKnot(i))
208+ vMults.push(bs.VMultiplicity(i))
209+ }
210+
211+ const isoLines = sampleIsoLines(bs, uKnots, vKnots)
212+ const nurbs: NurbsSurface = { uDegree, vDegree, nu, nv, poles, weights, uKnots, uMults, vKnots, vMults }
213+ return { nurbs, isoLines }
214+ } catch {
215+ return null
216+ } finally {
217+ bsHandle?.delete()
218+ handle?.delete()
219+ }
220+}
221+
222+/** Sample constant-u and constant-v curves on the surface for the isocurve view. */
223+function sampleIsoLines(bs: Shape, uKnots: number[], vKnots: number[]): Float32Array[] {
224+ const NLINES = 5
225+ const NS = 40
226+ const uMin = uKnots[0]
227+ const uMax = uKnots[uKnots.length - 1]
228+ const vMin = vKnots[0]
229+ const vMax = vKnots[vKnots.length - 1]
230+ const lines: Float32Array[] = []
231+ const evalTo = (line: Float32Array, s: number, u: number, v: number) => {
232+ const p = bs.Value(u, v)
233+ line[s * 3] = p.X()
234+ line[s * 3 + 1] = p.Y()
235+ line[s * 3 + 2] = p.Z()
236+ p.delete()
237+ }
238+ for (let a = 0; a < NLINES; a++) {
239+ const u = uMin + ((uMax - uMin) * a) / (NLINES - 1)
240+ const line = new Float32Array(NS * 3)
241+ for (let s = 0; s < NS; s++) evalTo(line, s, u, vMin + ((vMax - vMin) * s) / (NS - 1))
242+ lines.push(line)
243+ }
244+ for (let a = 0; a < NLINES; a++) {
245+ const v = vMin + ((vMax - vMin) * a) / (NLINES - 1)
246+ const line = new Float32Array(NS * 3)
247+ for (let s = 0; s < NS; s++) evalTo(line, s, uMin + ((uMax - uMin) * s) / (NS - 1), v)
248+ lines.push(line)
249+ }
250+ return lines
251+}
252+
253+/** Iterate the faces of a shape, calling `fn` with each `TopoDS_Face`. */
254+function forEachFace(oc: OpenCascade, shape: Shape, fn: (face: Shape, index: number) => void): void {
255+ const exp = new oc.TopExp_Explorer_1()
256+ let index = 0
257+ for (
258+ exp.Init(shape, oc.TopAbs_ShapeEnum.TopAbs_FACE, oc.TopAbs_ShapeEnum.TopAbs_SHAPE);
259+ exp.More();
260+ exp.Next()
261+ ) {
262+ const face = oc.TopoDS.Face_1(exp.Current())
263+ fn(face, index++)
264+ face.delete()
265+ }
266+ exp.delete()
267+}
268+
269+/** Build a full SurfaceModel from a freshly created / imported shape. */
270+export function buildModel(
271+ oc: OpenCascade,
272+ rawShape: Shape,
273+ quality: number,
274+ source: SurfaceModel['source'],
275+ raw?: SurfaceModel['raw'],
276+): BuiltModel {
277+ const { shape: meshShape, ok } = nurbsConvert(oc, rawShape)
278+ runMesh(oc, meshShape, quality)
279+
280+ const patches: Patch[] = []
281+ forEachFace(oc, meshShape, (face, index) => {
282+ const tri = readFaceTriangulation(oc, face)
283+ const extracted = ok ? extractNurbs(oc, face) : null
284+ patches.push({
285+ kind: 'nurbs',
286+ id: index,
287+ tri,
288+ nurbs: extracted?.nurbs ?? null,
289+ isoLines: extracted?.isoLines,
290+ })
291+ })
292+
293+ return { model: { patches, source, raw }, meshShape }
294+}
295+
296+/** Re-tessellate an existing model at a new quality, reusing its NURBS data. */
297+export function retessellate(
298+ oc: OpenCascade,
299+ meshShape: Shape,
300+ quality: number,
301+ patches: Patch[],
302+): Patch[] {
303+ runMesh(oc, meshShape, quality)
304+ const tris: TriMesh[] = []
305+ forEachFace(oc, meshShape, (face) => {
306+ tris.push(readFaceTriangulation(oc, face))
307+ })
308+ return patches.map((p, i) => ({ ...p, tri: tris[i] ?? p.tri }))
309+}
src/occ/importCad.tsadded+48−0View file
@@ -0,0 +1,48 @@
1+/**
2+ * Import a STEP or IGES file into an OCCT shape, using Emscripten's virtual
3+ * filesystem (adapted from opencascade.js-examples' `loadSTEPorIGES`).
4+ */
5+import type { OpenCascade, Shape } from './types'
6+
7+export interface ImportResult {
8+ shape: Shape
9+ format: 'step' | 'iges'
10+}
11+
12+function formatFor(name: string): 'step' | 'iges' {
13+ const ext = name.toLowerCase().split('.').pop()
14+ if (ext === 'step' || ext === 'stp') return 'step'
15+ if (ext === 'iges' || ext === 'igs') return 'iges'
16+ throw new Error(`Unsupported file ".${ext}". Use .step/.stp or .iges/.igs.`)
17+}
18+
19+export function importCadFile(oc: OpenCascade, name: string, bytes: Uint8Array): ImportResult {
20+ const format = formatFor(name)
21+ const fname = `import.${format}`
22+ try {
23+ oc.FS.unlink(`/${fname}`)
24+ } catch {
25+ /* no stale file */
26+ }
27+ oc.FS.createDataFile('/', fname, bytes, true, true, true)
28+
29+ const reader = format === 'step' ? new oc.STEPControl_Reader_1() : new oc.IGESControl_Reader_1()
30+ const status = reader.ReadFile(fname)
31+ if (status !== oc.IFSelect_ReturnStatus.IFSelect_RetDone) {
32+ try {
33+ oc.FS.unlink(`/${fname}`)
34+ } catch {
35+ /* ignore */
36+ }
37+ throw new Error(`OpenCASCADE could not read this ${format.toUpperCase()} file.`)
38+ }
39+ reader.TransferRoots(new oc.Message_ProgressRange_1())
40+ const shape = reader.OneShape()
41+ try {
42+ oc.FS.unlink(`/${fname}`)
43+ } catch {
44+ /* ignore */
45+ }
46+ if (shape.IsNull()) throw new Error('The file contained no usable geometry.')
47+ return { shape, format }
48+}
src/occ/loader.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+/**
2+ * Loads the OpenCASCADE WASM runtime exactly once and caches the promise.
3+ *
4+ * We deliberately bypass the package's `index.js` wrapper: it does a bare
5+ * `import ... from "./opencascade.full.wasm"`, which a URL-based bundler (Vite /
6+ * rolldown) cannot resolve. Instead we import the Emscripten factory directly
7+ * and hand it the wasm URL through `locateFile` — the `?url` import makes Vite
8+ * emit the ~30 MB wasm as a static asset, fetched lazily on first use.
9+ */
10+import ocFactory from 'opencascade.js/dist/opencascade.full.js'
11+import wasmUrl from 'opencascade.js/dist/opencascade.full.wasm?url'
12+import type { OpenCascade } from './types'
13+
14+type Factory = (module: { locateFile: (path: string) => string }) => Promise<OpenCascade>
15+
16+let cached: Promise<OpenCascade> | null = null
17+
18+export function loadOpenCascade(onStatus?: (msg: string) => void): Promise<OpenCascade> {
19+ if (cached) return cached
20+ onStatus?.('Loading CAD engine (OpenCASCADE, ~30 MB)…')
21+ const factory = ocFactory as unknown as Factory
22+ cached = factory({
23+ locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : path),
24+ })
25+ .then((oc: OpenCascade) => {
26+ onStatus?.('CAD engine ready')
27+ return oc
28+ })
29+ .catch((e: unknown) => {
30+ cached = null // allow a retry on the next action
31+ throw e
32+ })
33+ return cached
34+}
35+
36+/** True once the runtime has finished loading in this session. */
37+export function isLoaded(): boolean {
38+ return cached !== null
39+}
src/occ/primitives.tsadded+107−0View file
@@ -0,0 +1,107 @@
1+/**
2+ * Built-in CAD primitives, each returning a `TopoDS_Shape`. These exercise the
3+ * range of OpenCASCADE face types: analytic (sphere/torus/cylinder/cone) and,
4+ * once fillets or fusions are involved, genuine free-form NURBS faces.
5+ *
6+ * Overload suffixes (`_1`, `_2`, …) follow OCCT header declaration order and
7+ * mirror the usage in the opencascade.js examples.
8+ */
9+import type { OpenCascade, Shape } from './types'
10+
11+export function makeSphere(oc: OpenCascade, radius = 1): Shape {
12+ return new oc.BRepPrimAPI_MakeSphere_1(radius).Shape()
13+}
14+
15+export function makeBox(oc: OpenCascade, dx = 1.5, dy = 1, dz = 1): Shape {
16+ return new oc.BRepPrimAPI_MakeBox_2(dx, dy, dz).Shape()
17+}
18+
19+export function makeCylinder(oc: OpenCascade, radius = 0.7, height = 1.6): Shape {
20+ return new oc.BRepPrimAPI_MakeCylinder_1(radius, height).Shape()
21+}
22+
23+export function makeCone(oc: OpenCascade, r1 = 0.9, r2 = 0.25, height = 1.5): Shape {
24+ return new oc.BRepPrimAPI_MakeCone_1(r1, r2, height).Shape()
25+}
26+
27+export function makeTorus(oc: OpenCascade, major = 1, minor = 0.35): Shape {
28+ return new oc.BRepPrimAPI_MakeTorus_1(major, minor).Shape()
29+}
30+
31+/** A box with all edges rounded — introduces free-form NURBS fillet faces. */
32+export function makeFilletBox(oc: OpenCascade, dx = 1.4, dy = 1, dz = 1, radius = 0.22): Shape {
33+ const box = new oc.BRepPrimAPI_MakeBox_2(dx, dy, dz).Shape()
34+ const mkFillet = new oc.BRepFilletAPI_MakeFillet(box, oc.ChFi3d_FilletShape.ChFi3d_Rational)
35+ const exp = new oc.TopExp_Explorer_2(
36+ box,
37+ oc.TopAbs_ShapeEnum.TopAbs_EDGE,
38+ oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
39+ )
40+ while (exp.More()) {
41+ mkFillet.Add_2(radius, oc.TopoDS.Edge_1(exp.Current()))
42+ exp.Next()
43+ }
44+ exp.delete()
45+ return mkFillet.Shape()
46+}
47+
48+/**
49+ * The classic OpenCASCADE "bottle" tutorial shape (filleted body + threaded
50+ * neck), adapted from opencascade.js-examples. A rich mix of planar, swept and
51+ * lofted faces — a good stress test for tessellation and NURBS extraction.
52+ */
53+export function makeBottle(oc: OpenCascade, myWidth = 1.0, myHeight = 1.4, myThickness = 0.6): Shape {
54+ const aPnt1 = new oc.gp_Pnt_3(-myWidth / 2, 0, 0)
55+ const aPnt2 = new oc.gp_Pnt_3(-myWidth / 2, -myThickness / 4, 0)
56+ const aPnt3 = new oc.gp_Pnt_3(0, -myThickness / 2, 0)
57+ const aPnt4 = new oc.gp_Pnt_3(myWidth / 2, -myThickness / 4, 0)
58+ const aPnt5 = new oc.gp_Pnt_3(myWidth / 2, 0, 0)
59+
60+ const anArcOfCircle = new oc.GC_MakeArcOfCircle_4(aPnt2, aPnt3, aPnt4)
61+ const aSegment1 = new oc.GC_MakeSegment_1(aPnt1, aPnt2)
62+ const aSegment2 = new oc.GC_MakeSegment_1(aPnt4, aPnt5)
63+
64+ const anEdge1 = new oc.BRepBuilderAPI_MakeEdge_24(new oc.Handle_Geom_Curve_2(aSegment1.Value().get()))
65+ const anEdge2 = new oc.BRepBuilderAPI_MakeEdge_24(new oc.Handle_Geom_Curve_2(anArcOfCircle.Value().get()))
66+ const anEdge3 = new oc.BRepBuilderAPI_MakeEdge_24(new oc.Handle_Geom_Curve_2(aSegment2.Value().get()))
67+ const aWire = new oc.BRepBuilderAPI_MakeWire_4(anEdge1.Edge(), anEdge2.Edge(), anEdge3.Edge())
68+
69+ const xAxis = oc.gp.OX()
70+ const aTrsf = new oc.gp_Trsf_1()
71+ aTrsf.SetMirror_2(xAxis)
72+ const aBRepTrsf = new oc.BRepBuilderAPI_Transform_2(aWire.Wire(), aTrsf, false)
73+ const aMirroredShape = aBRepTrsf.Shape()
74+
75+ const mkWire = new oc.BRepBuilderAPI_MakeWire_1()
76+ mkWire.Add_2(aWire.Wire())
77+ mkWire.Add_2(oc.TopoDS.Wire_1(aMirroredShape))
78+ const myWireProfile = mkWire.Wire()
79+
80+ const myFaceProfile = new oc.BRepBuilderAPI_MakeFace_15(myWireProfile, false)
81+ const aPrismVec = new oc.gp_Vec_4(0, 0, myHeight)
82+ let myBody: Shape = new oc.BRepPrimAPI_MakePrism_1(myFaceProfile.Face(), aPrismVec, false, true)
83+
84+ const mkFillet = new oc.BRepFilletAPI_MakeFillet(myBody.Shape(), oc.ChFi3d_FilletShape.ChFi3d_Rational)
85+ const anEdgeExplorer = new oc.TopExp_Explorer_2(
86+ myBody.Shape(),
87+ oc.TopAbs_ShapeEnum.TopAbs_EDGE,
88+ oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
89+ )
90+ while (anEdgeExplorer.More()) {
91+ const anEdge = oc.TopoDS.Edge_1(anEdgeExplorer.Current())
92+ mkFillet.Add_2(myThickness / 12, anEdge)
93+ anEdgeExplorer.Next()
94+ }
95+ myBody = mkFillet.Shape()
96+
97+ const neckLocation = new oc.gp_Pnt_3(0, 0, myHeight)
98+ const neckAxis = oc.gp.DZ()
99+ const neckAx2 = new oc.gp_Ax2_3(neckLocation, neckAxis)
100+ const myNeckRadius = myThickness / 4
101+ const myNeckHeight = myHeight / 10
102+ const MKCylinder = new oc.BRepPrimAPI_MakeCylinder_3(neckAx2, myNeckRadius, myNeckHeight)
103+ const myNeck = MKCylinder.Shape()
104+ myBody = new oc.BRepAlgoAPI_Fuse_3(myBody, myNeck, new oc.Message_ProgressRange_1())
105+
106+ return myBody.Shape()
107+}
src/occ/types.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+/**
2+ * The OpenCASCADE WASM instance.
3+ *
4+ * opencascade.js ships a generated `.d.ts` for the entire OCCT API, but the
5+ * exact overload-suffixed member names (`_1`, `_2`, …) are only verifiable at
6+ * runtime, and the surface is huge. We therefore type the instance loosely and
7+ * keep *all* OCCT access confined to `src/occ/*`. Everything outside this
8+ * directory is fully typed against the `SurfaceModel` in `src/model`.
9+ */
10+// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+export type OpenCascade = any
12+
13+/** An OCCT `TopoDS_Shape` (opaque to the rest of the app). */
14+// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+export type Shape = any
src/render/SurfaceView.tsxadded+231−0View file
@@ -0,0 +1,231 @@
1+/**
2+ * Plain-three.js interactive view of a SurfaceModel (adapted from
3+ * mesh-pde-solver's SurfaceView): WebGLRenderer + OrbitControls + ResizeObserver
4+ * with an imperative scene rebuilt whenever the model, view mode or selection
5+ * changes. One three.js mesh per patch, so faces can be picked by raycasting.
6+ */
7+import { useEffect, useRef } from 'react'
8+import * as THREE from 'three'
9+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
10+import type { Patch, SurfaceModel } from '../model/types'
11+import { controlNet, modelBounds } from '../model/tessellate'
12+import { faceColor, ISO_COLOR, NET_COLOR, POLE_COLOR, SELECTED_COLOR } from './palette'
13+
14+export type ViewMode = 'shaded' | 'wire' | 'net' | 'iso'
15+
16+interface SceneState {
17+ renderer: THREE.WebGLRenderer
18+ scene: THREE.Scene
19+ camera: THREE.PerspectiveCamera
20+ controls: OrbitControls
21+ content: THREE.Group
22+ raycaster: THREE.Raycaster
23+ animId: number
24+}
25+
26+function triGeometry(patch: Patch): THREE.BufferGeometry {
27+ const g = new THREE.BufferGeometry()
28+ g.setAttribute('position', new THREE.BufferAttribute(patch.tri.positions, 3))
29+ if (patch.tri.normals.length === patch.tri.positions.length) {
30+ g.setAttribute('normal', new THREE.BufferAttribute(patch.tri.normals, 3))
31+ } else {
32+ g.computeVertexNormals()
33+ }
34+ g.setIndex(new THREE.BufferAttribute(patch.tri.indices, 1))
35+ return g
36+}
37+
38+function buildContent(
39+ content: THREE.Group,
40+ model: SurfaceModel,
41+ mode: ViewMode,
42+ selectedFaceId: number | null,
43+) {
44+ // dispose previous
45+ content.traverse((obj) => {
46+ const withGeom = obj as THREE.Mesh
47+ withGeom.geometry?.dispose()
48+ const mat = (obj as THREE.Mesh).material
49+ if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
50+ else mat?.dispose()
51+ })
52+ content.clear()
53+
54+ const { center, radius } = modelBounds(model)
55+ content.scale.setScalar(1 / radius)
56+ content.position.set(-center[0] / radius, -center[1] / radius, -center[2] / radius)
57+
58+ const facesFaint = mode === 'net' || mode === 'iso'
59+
60+ for (const patch of model.patches) {
61+ const selected = patch.id === selectedFaceId
62+ const color = selected ? SELECTED_COLOR : faceColor(patch.id)
63+ const geom = triGeometry(patch)
64+
65+ let material: THREE.Material
66+ if (mode === 'wire') {
67+ material = new THREE.MeshBasicMaterial({ color, wireframe: true })
68+ } else if (facesFaint) {
69+ material = new THREE.MeshStandardMaterial({
70+ color,
71+ roughness: 0.7,
72+ metalness: 0.0,
73+ side: THREE.DoubleSide,
74+ transparent: true,
75+ opacity: selected ? 0.35 : 0.12,
76+ })
77+ } else {
78+ material = new THREE.MeshStandardMaterial({
79+ color,
80+ roughness: 0.55,
81+ metalness: 0.08,
82+ side: THREE.DoubleSide,
83+ emissive: selected ? SELECTED_COLOR : new THREE.Color(0, 0, 0),
84+ emissiveIntensity: selected ? 0.35 : 0,
85+ })
86+ }
87+ const mesh = new THREE.Mesh(geom, material)
88+ mesh.userData.faceId = patch.id
89+ content.add(mesh)
90+
91+ if (mode === 'net' && patch.kind === 'nurbs' && patch.nurbs) {
92+ const { segments, points } = controlNet(patch.nurbs)
93+ const segGeom = new THREE.BufferGeometry()
94+ segGeom.setAttribute('position', new THREE.BufferAttribute(segments, 3))
95+ content.add(
96+ new THREE.LineSegments(
97+ segGeom,
98+ new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : NET_COLOR }),
99+ ),
100+ )
101+ const ptGeom = new THREE.BufferGeometry()
102+ ptGeom.setAttribute('position', new THREE.BufferAttribute(points, 3))
103+ content.add(
104+ new THREE.Points(
105+ ptGeom,
106+ new THREE.PointsMaterial({ color: POLE_COLOR, size: 0.03 * radius, sizeAttenuation: true }),
107+ ),
108+ )
109+ }
110+
111+ if (mode === 'iso' && patch.kind === 'nurbs' && patch.isoLines) {
112+ for (const line of patch.isoLines) {
113+ const lineGeom = new THREE.BufferGeometry()
114+ lineGeom.setAttribute('position', new THREE.BufferAttribute(line, 3))
115+ content.add(
116+ new THREE.Line(
117+ lineGeom,
118+ new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : ISO_COLOR }),
119+ ),
120+ )
121+ }
122+ }
123+ }
124+}
125+
126+export function SurfaceView({
127+ model,
128+ mode,
129+ selectedFaceId,
130+ onSelectFace,
131+}: {
132+ model: SurfaceModel | null
133+ mode: ViewMode
134+ selectedFaceId: number | null
135+ onSelectFace: (id: number | null) => void
136+}) {
137+ const containerRef = useRef<HTMLDivElement>(null)
138+ const stateRef = useRef<SceneState | null>(null)
139+ const onSelectRef = useRef(onSelectFace)
140+ onSelectRef.current = onSelectFace
141+
142+ // set up the scene once
143+ useEffect(() => {
144+ const container = containerRef.current
145+ if (!container) return
146+
147+ const renderer = new THREE.WebGLRenderer({ antialias: true })
148+ renderer.setPixelRatio(window.devicePixelRatio)
149+ renderer.setClearColor(0x161a22)
150+ container.appendChild(renderer.domElement)
151+
152+ const scene = new THREE.Scene()
153+ const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
154+ camera.position.set(2.4, 1.8, 2.6)
155+
156+ const controls = new OrbitControls(camera, renderer.domElement)
157+ controls.enableDamping = true
158+
159+ scene.add(new THREE.AmbientLight(0xffffff, 0.55))
160+ const key = new THREE.DirectionalLight(0xffffff, 1.5)
161+ key.position.set(4, 6, 5)
162+ scene.add(key)
163+ const fill = new THREE.DirectionalLight(0xffffff, 0.4)
164+ fill.position.set(-5, -3, -4)
165+ scene.add(fill)
166+
167+ const content = new THREE.Group()
168+ scene.add(content)
169+
170+ const raycaster = new THREE.Raycaster()
171+
172+ const animId = requestAnimationFrame(function loop() {
173+ controls.update()
174+ renderer.render(scene, camera)
175+ if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
176+ })
177+ stateRef.current = { renderer, scene, camera, controls, content, raycaster, animId }
178+
179+ const onPointerDown = (ev: PointerEvent) => {
180+ const st = stateRef.current
181+ if (!st) return
182+ const rect = renderer.domElement.getBoundingClientRect()
183+ const ndc = new THREE.Vector2(
184+ ((ev.clientX - rect.left) / rect.width) * 2 - 1,
185+ -((ev.clientY - rect.top) / rect.height) * 2 + 1,
186+ )
187+ st.raycaster.setFromCamera(ndc, st.camera)
188+ const meshes = st.content.children.filter((c) => (c as THREE.Mesh).isMesh)
189+ const hits = st.raycaster.intersectObjects(meshes, false)
190+ const id = hits.length ? (hits[0].object.userData.faceId as number) : null
191+ onSelectRef.current(id ?? null)
192+ }
193+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
194+
195+ const observer = new ResizeObserver(() => {
196+ const rect = container.getBoundingClientRect()
197+ if (rect.width === 0 || rect.height === 0) return
198+ renderer.setSize(rect.width, rect.height)
199+ camera.aspect = rect.width / rect.height
200+ camera.updateProjectionMatrix()
201+ })
202+ observer.observe(container)
203+
204+ return () => {
205+ observer.disconnect()
206+ renderer.domElement.removeEventListener('pointerdown', onPointerDown)
207+ cancelAnimationFrame(stateRef.current?.animId ?? animId)
208+ controls.dispose()
209+ renderer.dispose()
210+ container.removeChild(renderer.domElement)
211+ stateRef.current = null
212+ }
213+ }, [])
214+
215+ // rebuild content when inputs change
216+ useEffect(() => {
217+ const st = stateRef.current
218+ if (!st) return
219+ if (model) buildContent(st.content, model, mode, selectedFaceId)
220+ else {
221+ st.content.clear()
222+ }
223+ }, [model, mode, selectedFaceId])
224+
225+ return (
226+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
227+ <div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
228+ {!model && <div className="view-placeholder">Pick a primitive or open a STEP/IGES file</div>}
229+ </div>
230+ )
231+}
src/render/palette.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+import * as THREE from 'three'
2+
3+/**
4+ * A distinct color per face id, spread around the hue circle by the golden
5+ * ratio so neighbouring faces stay easy to tell apart at any face count.
6+ */
7+export function faceColor(id: number): THREE.Color {
8+ const h = (id * 0.618033988749895) % 1
9+ return new THREE.Color().setHSL(h, 0.55, 0.62)
10+}
11+
12+export const SELECTED_COLOR = new THREE.Color('#ffb020')
13+export const NET_COLOR = new THREE.Color('#9b6bff')
14+export const POLE_COLOR = new THREE.Color('#ffd166')
15+export const ISO_COLOR = new THREE.Color('#3fc7ff')
src/sources.tsadded+67−0View file
@@ -0,0 +1,67 @@
1+/**
2+ * Registry of built-in primitive sources. Each entry knows how to build its
3+ * OCCT shape; the app hands the shape to `buildModel` (src/occ/extract.ts).
4+ */
5+import type { OpenCascade, Shape } from './occ/types'
6+import {
7+ makeBottle,
8+ makeBox,
9+ makeCone,
10+ makeCylinder,
11+ makeFilletBox,
12+ makeSphere,
13+ makeTorus,
14+} from './occ/primitives'
15+
16+export interface PrimitiveSource {
17+ id: string
18+ label: string
19+ /** One-line note about the kind of faces this produces. */
20+ blurb: string
21+ build: (oc: OpenCascade) => Shape
22+}
23+
24+export const primitives: PrimitiveSource[] = [
25+ {
26+ id: 'sphere',
27+ label: 'Sphere',
28+ blurb: 'One analytic spherical face — a single rational patch after NURBS conversion.',
29+ build: (oc) => makeSphere(oc),
30+ },
31+ {
32+ id: 'torus',
33+ label: 'Torus',
34+ blurb: 'A toroidal face, periodic in both parameters.',
35+ build: (oc) => makeTorus(oc),
36+ },
37+ {
38+ id: 'cylinder',
39+ label: 'Cylinder',
40+ blurb: 'Cylindrical side face capped by two planar disks.',
41+ build: (oc) => makeCylinder(oc),
42+ },
43+ {
44+ id: 'cone',
45+ label: 'Cone',
46+ blurb: 'A truncated cone — conical face plus caps.',
47+ build: (oc) => makeCone(oc),
48+ },
49+ {
50+ id: 'box',
51+ label: 'Box',
52+ blurb: 'Six planar faces — degree (1,1) NURBS patches.',
53+ build: (oc) => makeBox(oc),
54+ },
55+ {
56+ id: 'fillet-box',
57+ label: 'Rounded box',
58+ blurb: 'Box with rounded edges — introduces free-form NURBS fillet faces.',
59+ build: (oc) => makeFilletBox(oc),
60+ },
61+ {
62+ id: 'bottle',
63+ label: 'Bottle',
64+ blurb: 'The OpenCASCADE tutorial bottle: mixed planar, swept and fused faces.',
65+ build: (oc) => makeBottle(oc),
66+ },
67+]
src/vite-env.d.tsadded+7−0View file
@@ -0,0 +1,7 @@
1+/// <reference types="vite/client" />
2+
3+// Vite emits the OpenCASCADE .wasm as a static asset when imported with `?url`.
4+declare module '*.wasm?url' {
5+ const src: string
6+ export default src
7+}
tsconfig.app.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "noUnusedLocals": true,
21+ "noUnusedParameters": true,
22+ "erasableSyntaxOnly": true,
23+ "noFallthroughCasesInSwitch": true
24+ },
25+ "include": ["src"]
26+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "types": ["node"],
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "module": "nodenext",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ /* Linting */
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "erasableSyntaxOnly": true,
20+ "noFallthroughCasesInSwitch": true
21+ },
22+ "include": ["vite.config.ts"]
23+}
vite.config.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+ base: './',
8+ // opencascade.js ships a ~30 MB .wasm; let Vite treat it as a static asset
9+ // (we import it with `?url`) rather than trying to pre-bundle it.
10+ optimizeDeps: { exclude: ['opencascade.js'] },
11+})