Support triangle meshes; view-mode toolbar; drop upload size cap
surfacefun now computes on triangle patches, so the pipeline no longer
restricts uploads to quads: bridge.py keeps triangle and quad cells
(splitting quads into triangles when a file mixes the two, since
surfacemesh.import cannot), solve_pde.m reports the patch type, and the
renderer triangulates tri patches with a port of surfacefun's
trilattice. Adds an icosahedral sphere sample plus triangle
eigenfunction and Helmholtz checks to the engine test, and requires
numbl 0.4.12 for its 1x1-tensor gather-orientation fix.
Also:
- tolerate OBJ files with more vt/vn entries than vertices (ported
from mesh-converter), so e.g. the spot example loads
- drop the 4000-cell upload cap; keep the slow-mesh warning
- view-mode toolbar (shaded/wire/both/points) and red/cyan anaglyph
stereo in the 3D view, mirroring mesh-converter
17 changed files+1245−213
CLAUDE.mdmodified+21−11View file
@@ -17,33 +17,43 @@ src/engine/ run-per-solve wrapper over numbl/browser's
1717 worker, VFS, mip bootstrap, and IndexedDB package
1818 persistence; prewarm() at page load triggers the one-time
1919 package download.
20-src/pde/presets.ts PDE definitions, presets, size limits
20+src/pde/presets.ts PDE definitions, presets, slow-mesh warning threshold
2121 src/render/ three.js SurfaceView (mesh preview / solution) + parula
2222 scripts/engine-test.mjs headless Node check of the whole MATLAB pipeline
2323 ```
2424
2525 ## Key gotchas
2626
27-- **numbl >= 0.4.11 from npm.** Needs `NumblSession.readFile` (0.4.10) plus
27+- **numbl >= 0.4.12 from npm.** Needs `NumblSession.readFile` (0.4.10),
2828 enumeration-class support and the 1×1-tensor broadcast-assignment fix
2929 (0.4.11 — surfacefun's `surfacemesh.patchtype` / `dealm` idiom depend on
30- both). To develop against a local numbl checkout, point package.json at
30+ both), and the 1×1-tensor gather-orientation fix (0.4.12 — surfacefun's
31+ `trianglepts` recursion breaks without it; see numbl's
32+ `test_scalar_tensor_vector_index_shape.m`). To develop against a local
33+ numbl checkout, point package.json at
3134 `file:../../numbl` and run `npm run build:lib && npm run build:browser`
3235 there after source changes; when switching back to a `^` range,
3336 `rm -rf node_modules package-lock.json && npm install` (else `npm ci` fails
3437 on the stale `file:` link).
35-- **surfacemesh.import needs MSH 4.1.** surfacefun's gmsh reader
36- (`+surfacemesh/+import/gmsh.m`) parses MSH 4.1 node-entity blocks, not
37- 2.2. `src/mesh/bridge.py` and `scripts/make_samples.py` both write the
38- canonical 4.1 form (one surface entity block, sequential 1-based ids,
39- type-3 quads). Uploaded .msh files in other layouts pass through meshio
40- and get rewritten to 4.1.
38+- **surfacemesh.import needs MSH 4.1, one cell type.** surfacefun's gmsh
39+ reader (`+surfacemesh/+import/gmsh.m`) parses MSH 4.1 node-entity blocks,
40+ not 2.2, and accepts triangle (type 2) or quad (type 3) elements — but
41+ errors on meshes containing both. `src/mesh/bridge.py` and
42+ `scripts/make_samples.py` write the canonical 4.1 form (one surface entity
43+ block, sequential 1-based ids); the bridge splits quads into triangles
44+ when an upload mixes the two kinds. Uploaded .msh files in other layouts
45+ pass through meshio and get rewritten to 4.1.
46+- **Triangle patches are flat vectors, not grids.** An order-p tri patch
47+ holds n(n+1)/2 points (n = p+1) in surfacefun's `trianglepts(n)` ordering;
48+ quad patches are column-major n-by-n grids. `solve_pde.m` reports `ptype`
49+ plus points-per-edge `n`, and SurfaceView triangulates tri patches with a
50+ JS port of surfacefun's `trilattice.m`.
4151 - **Solve errors reject the solve() promise** with the MATLAB error message
4252 (a failed script run is a numbl bootError). Each solve is a fresh session,
4353 so nothing needs to stay alive across failures.
4454 - **jsonencode collapses 1-element vectors to scalars.** Patch arrays are
45- (p+1)^2 >= 9 long so it never bites here, but remember it when adding
46- payload fields.
55+ at least (p+1)(p+2)/2 >= 6 long so it never bites here, but remember it
56+ when adding payload fields.
4757 - Package caching: numbl/browser persists /system (mip + installed
4858 packages) in IndexedDB, wiped after 30 min of inactivity (numbl's default;
4959 lowered from 24 h so a rebuilt surfacefun package refreshes without a manual
README.mdmodified+15−12View file
@@ -1,6 +1,6 @@
11 # mesh-pde-solver
22
3-Upload a quadrilateral surface mesh, pick a PDE, tweak its right-hand side
3+Upload a triangle or quad surface mesh, pick a PDE, tweak its right-hand side
44 and coefficients, and solve it **on the surface** — entirely in your browser.
55 The mesh is converted to Gmsh format with [meshio](https://github.com/nschloe/meshio)
66 (via [Pyodide](https://pyodide.org)), and the PDE is solved by
@@ -23,15 +23,17 @@ start from. The polynomial order per patch is adjustable (accuracy vs. time).
2323 ## Meshes
2424
2525 Uploads go through meshio, so any of `.msh .vtk .vtu .obj .off .ply .inp
26-.mesh .bdf .avs` works — but the mesh **must contain quadrilateral cells**
27-(surfacefun computes on quad patches; triangle-only meshes are rejected).
28-Two sample meshes are bundled. The converted Gmsh file can be downloaded.
29-Whether the surface is closed or open is detected from the edge connectivity.
26+.mesh .bdf .avs` works — the mesh must contain triangle or quadrilateral
27+cells (surfacefun computes on either patch type, but not both at once, so a
28+mixed mesh has its quads split into triangles). Three sample meshes are
29+bundled. The converted Gmsh file can be downloaded. Whether the surface is
30+closed or open is detected from the edge connectivity.
3031
3132 ## How it works
3233
33-1. `src/mesh/` — meshio in Pyodide parses the upload, keeps the quad cells,
34- and writes a canonical Gmsh MSH 4.1 ASCII file plus preview arrays.
34+1. `src/mesh/` — meshio in Pyodide parses the upload, keeps the triangle and
35+ quad cells, and writes a canonical Gmsh MSH 4.1 ASCII file plus preview
36+ arrays.
3537 2. `src/engine/` — each solve boots a fresh managed numbl session
3638 (`createNumblSession` from `numbl/browser`): numbl owns the worker and
3739 VFS and bootstraps the [mip](https://github.com/mip-org) package manager.
@@ -41,8 +43,8 @@ Whether the surface is closed or open is detected from the edge connectivity.
4143 the host reads back before disposing the worker.
4244 3. `matlab/solve_pde.m` — loads the mesh with `surfacemesh.import`,
4345 `resample`s it to the requested order, and solves with `surfaceop`.
44-4. `src/render/SurfaceView.tsx` — three.js view of the quad mesh or the
45- per-patch solution grids with a parula colormap.
46+4. `src/render/SurfaceView.tsx` — three.js view of the mesh or the
47+ per-patch solution data with a parula colormap.
4648
4749 The first visit downloads the Python runtime (~15 MB, browser-cached) and
4850 the surfacefun/chebfun packages (~28 MB). Installed MATLAB packages persist
@@ -64,6 +66,7 @@ numbl's synchronous-XHR `websave`/`webread` with curl (responses cached in
6466 `.cache/`), and checks a Poisson solve against an exact spherical-harmonic
6567 solution.
6668
67-Requires numbl >= 0.4.11 — `NumblSession.readFile` (0.4.10) plus
68-enumeration-class support and the 1×1-tensor broadcast-assignment fix (0.4.11),
69-which surfacefun's `surfacemesh.import` / `patchtype` depend on.
69+Requires numbl >= 0.4.12 — `NumblSession.readFile` (0.4.10), enumeration-class
70+support and the 1×1-tensor broadcast-assignment fix (0.4.11), which
71+surfacefun's `surfacemesh.import` / `patchtype` depend on, and the 1×1-tensor
72+gather-orientation fix (0.4.12), which surfacefun's `trianglepts` depends on.
matlab/solve_pde.mmodified+14−5View file
@@ -1,11 +1,11 @@
11 function result = solve_pde(mshfile, params)
2-%SOLVE_PDE Load the quad mesh and solve the selected PDE on it.
2+%SOLVE_PDE Load the surface mesh and solve the selected PDE on it.
33 % params fields (from the host UI):
44 % pde - 'poisson' (lap u = f) or 'helmholtz' ((lap + c) u = f)
55 % f - right-hand side, a MATLAB expression in x, y, z
66 % c - zeroth-order coefficient expression (helmholtz only)
77 % p - polynomial order per patch
8-% closed - true if every mesh edge is shared by exactly two quads
8+% closed - true if every mesh edge is shared by exactly two cells
99 % (determined host-side from the connectivity)
1010
1111 dom = surfacemesh.import(mshfile, 'gmsh');
@@ -44,8 +44,10 @@ result.pde = params.pde;
4444 end
4545
4646 function data = pack_solution(dom, u)
47-% One flat (column-major) x/y/z/u array per patch, each an n-by-n grid —
48-% the same layout surfacefun-interactive's figure app uses.
47+% One flat x/y/z/u array per patch: a column-major n-by-n grid for quad
48+% patches (the layout surfacefun-interactive's figure app uses), or the
49+% n*(n+1)/2-point trianglepts(n) set for triangle patches. data.n is the
50+% number of points per patch edge in both cases.
4951 np = length(dom);
5052 px = cell(1, np);
5153 py = cell(1, np);
@@ -64,7 +66,14 @@ for k = 1:np
6466 end
6567 data = struct();
6668 data.type = 'solution';
67-data.n = size(dom.x{1}, 1);
69+if ( dom.ptype(1) == surfacemesh.patchtype.tri )
70+ npts = length(dom.x{1});
71+ data.n = round((sqrt(8*npts + 1) - 1) / 2);
72+ data.ptype = 'tri';
73+else
74+ data.n = size(dom.x{1}, 1);
75+ data.ptype = 'quad';
76+end
6877 data.npatches = np;
6978 data.x = px;
7079 data.y = py;
package-lock.jsonmodified+7−7View file
@@ -9,7 +9,7 @@
99 "version": "0.0.0",
1010 "dependencies": {
1111 "fflate": "^0.8.2",
12- "numbl": "^0.4.11",
12+ "numbl": "^0.4.12",
1313 "react": "^19.2.7",
1414 "react-dom": "^19.2.7",
1515 "three": "^0.185.1"
@@ -776,9 +776,9 @@
776776 }
777777 },
778778 "node_modules/@types/hast": {
779- "version": "3.0.4",
780- "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
781- "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
779+ "version": "3.0.5",
780+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
781+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
782782 "license": "MIT",
783783 "dependencies": {
784784 "@types/unist": "*"
@@ -2483,9 +2483,9 @@
24832483 }
24842484 },
24852485 "node_modules/numbl": {
2486- "version": "0.4.11",
2487- "resolved": "https://registry.npmjs.org/numbl/-/numbl-0.4.11.tgz",
2488- "integrity": "sha512-3ire6HYNNQNODaUbDItIL2Hx8HJC521d/alzELIDvGn3MClwGmSCqPVBeliTP+qNK3h4tcnzZnC2hUm4BhidAA==",
2486+ "version": "0.4.12",
2487+ "resolved": "https://registry.npmjs.org/numbl/-/numbl-0.4.12.tgz",
2488+ "integrity": "sha512-1tBOoXACWSfsovtQ+6QrDCDfPJwNTY48xEzIa0EAKY8TRvfosXrNwPl63kjjoL3V89zkmypo17wQayVl3I2ZkQ==",
24892489 "hasInstallScript": true,
24902490 "license": "Apache-2.0",
24912491 "dependencies": {
package.jsonmodified+1−1View file
@@ -12,7 +12,7 @@
1212 },
1313 "dependencies": {
1414 "fflate": "^0.8.2",
15- "numbl": "^0.4.11",
15+ "numbl": "^0.4.12",
1616 "react": "^19.2.7",
1717 "react-dom": "^19.2.7",
1818 "three": "^0.185.1"
public/samples/sphere-tri.mshadded+655−0View file
@@ -0,0 +1,655 @@
1+$MeshFormat
2+4.1 0 8
3+$EndMeshFormat
4+$Nodes
5+1 162 1 162
6+2 1 0 162
7+1
8+2
9+3
10+4
11+5
12+6
13+7
14+8
15+9
16+10
17+11
18+12
19+13
20+14
21+15
22+16
23+17
24+18
25+19
26+20
27+21
28+22
29+23
30+24
31+25
32+26
33+27
34+28
35+29
36+30
37+31
38+32
39+33
40+34
41+35
42+36
43+37
44+38
45+39
46+40
47+41
48+42
49+43
50+44
51+45
52+46
53+47
54+48
55+49
56+50
57+51
58+52
59+53
60+54
61+55
62+56
63+57
64+58
65+59
66+60
67+61
68+62
69+63
70+64
71+65
72+66
73+67
74+68
75+69
76+70
77+71
78+72
79+73
80+74
81+75
82+76
83+77
84+78
85+79
86+80
87+81
88+82
89+83
90+84
91+85
92+86
93+87
94+88
95+89
96+90
97+91
98+92
99+93
100+94
101+95
102+96
103+97
104+98
105+99
106+100
107+101
108+102
109+103
110+104
111+105
112+106
113+107
114+108
115+109
116+110
117+111
118+112
119+113
120+114
121+115
122+116
123+117
124+118
125+119
126+120
127+121
128+122
129+123
130+124
131+125
132+126
133+127
134+128
135+129
136+130
137+131
138+132
139+133
140+134
141+135
142+136
143+137
144+138
145+139
146+140
147+141
148+142
149+143
150+144
151+145
152+146
153+147
154+148
155+149
156+150
157+151
158+152
159+153
160+154
161+155
162+156
163+157
164+158
165+159
166+160
167+161
168+162
169+-0.5257311121191336 0.85065080835204 0
170+0.5257311121191336 0.85065080835204 0
171+-0.5257311121191336 -0.85065080835204 0
172+0.5257311121191336 -0.85065080835204 0
173+0 -0.5257311121191336 0.85065080835204
174+0 0.5257311121191336 0.85065080835204
175+0 -0.5257311121191336 -0.85065080835204
176+0 0.5257311121191336 -0.85065080835204
177+0.85065080835204 0 -0.5257311121191336
178+0.85065080835204 0 0.5257311121191336
179+-0.85065080835204 0 -0.5257311121191336
180+-0.85065080835204 0 0.5257311121191336
181+-0.8090169943749475 0.5000000000000001 0.3090169943749475
182+-0.5000000000000001 0.3090169943749475 0.8090169943749475
183+-0.3090169943749474 0.8090169943749473 0.5
184+0.3090169943749474 0.8090169943749473 0.5
185+0 1 0
186+0.3090169943749474 0.8090169943749473 -0.5
187+-0.3090169943749474 0.8090169943749473 -0.5
188+-0.5000000000000001 0.3090169943749475 -0.8090169943749475
189+-0.8090169943749475 0.5000000000000001 -0.3090169943749475
190+-1 0 0
191+0.5000000000000001 0.3090169943749475 0.8090169943749475
192+0.8090169943749475 0.5000000000000001 0.3090169943749475
193+-0.5000000000000001 -0.3090169943749475 0.8090169943749475
194+0 0 1
195+-0.8090169943749475 -0.5000000000000001 -0.3090169943749475
196+-0.8090169943749475 -0.5000000000000001 0.3090169943749475
197+0 0 -1
198+-0.5000000000000001 -0.3090169943749475 -0.8090169943749475
199+0.8090169943749475 0.5000000000000001 -0.3090169943749475
200+0.5000000000000001 0.3090169943749475 -0.8090169943749475
201+0.8090169943749475 -0.5000000000000001 0.3090169943749475
202+0.5000000000000001 -0.3090169943749475 0.8090169943749475
203+0.3090169943749474 -0.8090169943749473 0.5
204+-0.3090169943749474 -0.8090169943749473 0.5
205+0 -1 0
206+-0.3090169943749474 -0.8090169943749473 -0.5
207+0.3090169943749474 -0.8090169943749473 -0.5
208+0.5000000000000001 -0.3090169943749475 -0.8090169943749475
209+0.8090169943749475 -0.5000000000000001 -0.3090169943749475
210+1 0 0
211+-0.6937804775604491 0.7020464447761631 0.1606220356400231
212+-0.5877852522924731 0.6881909602355867 0.4253254041760199
213+-0.4338885645526948 0.8626684804161862 0.2598919130077544
214+-0.8626684804161862 0.2598919130077544 0.4338885645526948
215+-0.7020464447761631 0.1606220356400231 0.6937804775604491
216+-0.6881909602355868 0.42532540417602 0.5877852522924731
217+-0.42532540417602 0.5877852522924731 0.6881909602355868
218+-0.2598919130077544 0.4338885645526948 0.8626684804161862
219+-0.1606220356400231 0.6937804775604491 0.702046444776163
220+-0.1624598481164531 0.9510565162951536 0.2628655560595668
221+-0.2732665289126717 0.9619383577839176 0
222+0.1606220356400231 0.6937804775604491 0.702046444776163
223+0 0.8506508083520399 0.5257311121191336
224+0.1624598481164531 0.9510565162951536 0.2628655560595668
225+0.4338885645526948 0.8626684804161862 0.2598919130077544
226+0.2732665289126717 0.9619383577839176 0
227+-0.1624598481164531 0.9510565162951536 -0.2628655560595668
228+-0.4338885645526948 0.8626684804161862 -0.2598919130077544
229+0.4338885645526948 0.8626684804161862 -0.2598919130077544
230+0.1624598481164531 0.9510565162951536 -0.2628655560595668
231+0 0.8506508083520399 -0.5257311121191336
232+0.1606220356400231 0.6937804775604491 -0.702046444776163
233+-0.1606220356400231 0.6937804775604491 -0.702046444776163
234+-0.5877852522924731 0.6881909602355867 -0.4253254041760199
235+-0.6937804775604491 0.7020464447761631 -0.1606220356400231
236+-0.2598919130077544 0.4338885645526948 -0.8626684804161862
237+-0.42532540417602 0.5877852522924731 -0.6881909602355868
238+-0.6881909602355868 0.42532540417602 -0.5877852522924731
239+-0.7020464447761631 0.1606220356400231 -0.6937804775604491
240+-0.8626684804161862 0.2598919130077544 -0.4338885645526948
241+-0.8506508083520399 0.5257311121191337 0
242+-0.9619383577839176 0 -0.2732665289126717
243+-0.9510565162951536 0.2628655560595669 -0.1624598481164532
244+-0.9510565162951536 0.2628655560595669 0.1624598481164532
245+-0.9619383577839176 0 0.2732665289126717
246+0.5877852522924731 0.6881909602355867 0.4253254041760199
247+0.6937804775604491 0.7020464447761631 0.1606220356400231
248+0.2598919130077544 0.4338885645526948 0.8626684804161862
249+0.42532540417602 0.5877852522924731 0.6881909602355868
250+0.6881909602355868 0.42532540417602 0.5877852522924731
251+0.7020464447761631 0.1606220356400231 0.6937804775604491
252+0.8626684804161862 0.2598919130077544 0.4338885645526948
253+-0.2628655560595669 0.1624598481164532 0.9510565162951536
254+0 0.2732665289126717 0.9619383577839176
255+-0.7020464447761631 -0.1606220356400231 0.6937804775604491
256+-0.5257311121191337 0 0.8506508083520399
257+-0.2628655560595669 -0.1624598481164532 0.9510565162951536
258+-0.2598919130077544 -0.4338885645526948 0.8626684804161862
259+0 -0.2732665289126717 0.9619383577839176
260+-0.9510565162951536 -0.2628655560595669 0.1624598481164532
261+-0.8626684804161862 -0.2598919130077544 0.4338885645526948
262+-0.8626684804161862 -0.2598919130077544 -0.4338885645526948
263+-0.9510565162951536 -0.2628655560595669 -0.1624598481164532
264+-0.8506508083520399 -0.5257311121191337 0
265+-0.6937804775604491 -0.7020464447761631 -0.1606220356400231
266+-0.6937804775604491 -0.7020464447761631 0.1606220356400231
267+-0.5257311121191337 0 -0.8506508083520399
268+-0.7020464447761631 -0.1606220356400231 -0.6937804775604491
269+0 0.2732665289126717 -0.9619383577839176
270+-0.2628655560595669 0.1624598481164532 -0.9510565162951536
271+-0.2628655560595669 -0.1624598481164532 -0.9510565162951536
272+0 -0.2732665289126717 -0.9619383577839176
273+-0.2598919130077544 -0.4338885645526948 -0.8626684804161862
274+0.42532540417602 0.5877852522924731 -0.6881909602355868
275+0.2598919130077544 0.4338885645526948 -0.8626684804161862
276+0.6937804775604491 0.7020464447761631 -0.1606220356400231
277+0.5877852522924731 0.6881909602355867 -0.4253254041760199
278+0.6881909602355868 0.42532540417602 -0.5877852522924731
279+0.8626684804161862 0.2598919130077544 -0.4338885645526948
280+0.7020464447761631 0.1606220356400231 -0.6937804775604491
281+0.6937804775604491 -0.7020464447761631 0.1606220356400231
282+0.5877852522924731 -0.6881909602355867 0.4253254041760199
283+0.4338885645526948 -0.8626684804161862 0.2598919130077544
284+0.8626684804161862 -0.2598919130077544 0.4338885645526948
285+0.7020464447761631 -0.1606220356400231 0.6937804775604491
286+0.6881909602355868 -0.42532540417602 0.5877852522924731
287+0.42532540417602 -0.5877852522924731 0.6881909602355868
288+0.2598919130077544 -0.4338885645526948 0.8626684804161862
289+0.1606220356400231 -0.6937804775604491 0.702046444776163
290+0.1624598481164531 -0.9510565162951536 0.2628655560595668
291+0.2732665289126717 -0.9619383577839176 0
292+-0.1606220356400231 -0.6937804775604491 0.702046444776163
293+0 -0.8506508083520399 0.5257311121191336
294+-0.1624598481164531 -0.9510565162951536 0.2628655560595668
295+-0.4338885645526948 -0.8626684804161862 0.2598919130077544
296+-0.2732665289126717 -0.9619383577839176 0
297+0.1624598481164531 -0.9510565162951536 -0.2628655560595668
298+0.4338885645526948 -0.8626684804161862 -0.2598919130077544
299+-0.4338885645526948 -0.8626684804161862 -0.2598919130077544
300+-0.1624598481164531 -0.9510565162951536 -0.2628655560595668
301+0 -0.8506508083520399 -0.5257311121191336
302+-0.1606220356400231 -0.6937804775604491 -0.702046444776163
303+0.1606220356400231 -0.6937804775604491 -0.702046444776163
304+0.5877852522924731 -0.6881909602355867 -0.4253254041760199
305+0.6937804775604491 -0.7020464447761631 -0.1606220356400231
306+0.2598919130077544 -0.4338885645526948 -0.8626684804161862
307+0.42532540417602 -0.5877852522924731 -0.6881909602355868
308+0.6881909602355868 -0.42532540417602 -0.5877852522924731
309+0.7020464447761631 -0.1606220356400231 -0.6937804775604491
310+0.8626684804161862 -0.2598919130077544 -0.4338885645526948
311+0.8506508083520399 -0.5257311121191337 0
312+0.9619383577839176 0 -0.2732665289126717
313+0.9510565162951536 -0.2628655560595669 -0.1624598481164532
314+0.9510565162951536 -0.2628655560595669 0.1624598481164532
315+0.9619383577839176 0 0.2732665289126717
316+0.2628655560595669 -0.1624598481164532 0.9510565162951536
317+0.5257311121191337 0 0.8506508083520399
318+0.2628655560595669 0.1624598481164532 0.9510565162951536
319+-0.5877852522924731 -0.6881909602355867 0.4253254041760199
320+-0.42532540417602 -0.5877852522924731 0.6881909602355868
321+-0.6881909602355868 -0.42532540417602 0.5877852522924731
322+-0.42532540417602 -0.5877852522924731 -0.6881909602355868
323+-0.5877852522924731 -0.6881909602355867 -0.4253254041760199
324+-0.6881909602355868 -0.42532540417602 -0.5877852522924731
325+0.5257311121191337 0 -0.8506508083520399
326+0.2628655560595669 -0.1624598481164532 -0.9510565162951536
327+0.2628655560595669 0.1624598481164532 -0.9510565162951536
328+0.9510565162951536 0.2628655560595669 0.1624598481164532
329+0.9510565162951536 0.2628655560595669 -0.1624598481164532
330+0.8506508083520399 0.5257311121191337 0
331+$EndNodes
332+$Elements
333+1 320 1 320
334+2 1 2 320
335+1 1 43 45
336+2 43 13 44
337+3 45 44 15
338+4 43 44 45
339+5 13 46 48
340+6 46 12 47
341+7 48 47 14
342+8 46 47 48
343+9 15 49 51
344+10 49 14 50
345+11 51 50 6
346+12 49 50 51
347+13 13 48 44
348+14 48 14 49
349+15 44 49 15
350+16 48 49 44
351+17 1 45 53
352+18 45 15 52
353+19 53 52 17
354+20 45 52 53
355+21 15 51 55
356+22 51 6 54
357+23 55 54 16
358+24 51 54 55
359+25 17 56 58
360+26 56 16 57
361+27 58 57 2
362+28 56 57 58
363+29 15 55 52
364+30 55 16 56
365+31 52 56 17
366+32 55 56 52
367+33 1 53 60
368+34 53 17 59
369+35 60 59 19
370+36 53 59 60
371+37 17 58 62
372+38 58 2 61
373+39 62 61 18
374+40 58 61 62
375+41 19 63 65
376+42 63 18 64
377+43 65 64 8
378+44 63 64 65
379+45 17 62 59
380+46 62 18 63
381+47 59 63 19
382+48 62 63 59
383+49 1 60 67
384+50 60 19 66
385+51 67 66 21
386+52 60 66 67
387+53 19 65 69
388+54 65 8 68
389+55 69 68 20
390+56 65 68 69
391+57 21 70 72
392+58 70 20 71
393+59 72 71 11
394+60 70 71 72
395+61 19 69 66
396+62 69 20 70
397+63 66 70 21
398+64 69 70 66
399+65 1 67 43
400+66 67 21 73
401+67 43 73 13
402+68 67 73 43
403+69 21 72 75
404+70 72 11 74
405+71 75 74 22
406+72 72 74 75
407+73 13 76 46
408+74 76 22 77
409+75 46 77 12
410+76 76 77 46
411+77 21 75 73
412+78 75 22 76
413+79 73 76 13
414+80 75 76 73
415+81 2 57 79
416+82 57 16 78
417+83 79 78 24
418+84 57 78 79
419+85 16 54 81
420+86 54 6 80
421+87 81 80 23
422+88 54 80 81
423+89 24 82 84
424+90 82 23 83
425+91 84 83 10
426+92 82 83 84
427+93 16 81 78
428+94 81 23 82
429+95 78 82 24
430+96 81 82 78
431+97 6 50 86
432+98 50 14 85
433+99 86 85 26
434+100 50 85 86
435+101 14 47 88
436+102 47 12 87
437+103 88 87 25
438+104 47 87 88
439+105 26 89 91
440+106 89 25 90
441+107 91 90 5
442+108 89 90 91
443+109 14 88 85
444+110 88 25 89
445+111 85 89 26
446+112 88 89 85
447+113 12 77 93
448+114 77 22 92
449+115 93 92 28
450+116 77 92 93
451+117 22 74 95
452+118 74 11 94
453+119 95 94 27
454+120 74 94 95
455+121 28 96 98
456+122 96 27 97
457+123 98 97 3
458+124 96 97 98
459+125 22 95 92
460+126 95 27 96
461+127 92 96 28
462+128 95 96 92
463+129 11 71 100
464+130 71 20 99
465+131 100 99 30
466+132 71 99 100
467+133 20 68 102
468+134 68 8 101
469+135 102 101 29
470+136 68 101 102
471+137 30 103 105
472+138 103 29 104
473+139 105 104 7
474+140 103 104 105
475+141 20 102 99
476+142 102 29 103
477+143 99 103 30
478+144 102 103 99
479+145 8 64 107
480+146 64 18 106
481+147 107 106 32
482+148 64 106 107
483+149 18 61 109
484+150 61 2 108
485+151 109 108 31
486+152 61 108 109
487+153 32 110 112
488+154 110 31 111
489+155 112 111 9
490+156 110 111 112
491+157 18 109 106
492+158 109 31 110
493+159 106 110 32
494+160 109 110 106
495+161 4 113 115
496+162 113 33 114
497+163 115 114 35
498+164 113 114 115
499+165 33 116 118
500+166 116 10 117
501+167 118 117 34
502+168 116 117 118
503+169 35 119 121
504+170 119 34 120
505+171 121 120 5
506+172 119 120 121
507+173 33 118 114
508+174 118 34 119
509+175 114 119 35
510+176 118 119 114
511+177 4 115 123
512+178 115 35 122
513+179 123 122 37
514+180 115 122 123
515+181 35 121 125
516+182 121 5 124
517+183 125 124 36
518+184 121 124 125
519+185 37 126 128
520+186 126 36 127
521+187 128 127 3
522+188 126 127 128
523+189 35 125 122
524+190 125 36 126
525+191 122 126 37
526+192 125 126 122
527+193 4 123 130
528+194 123 37 129
529+195 130 129 39
530+196 123 129 130
531+197 37 128 132
532+198 128 3 131
533+199 132 131 38
534+200 128 131 132
535+201 39 133 135
536+202 133 38 134
537+203 135 134 7
538+204 133 134 135
539+205 37 132 129
540+206 132 38 133
541+207 129 133 39
542+208 132 133 129
543+209 4 130 137
544+210 130 39 136
545+211 137 136 41
546+212 130 136 137
547+213 39 135 139
548+214 135 7 138
549+215 139 138 40
550+216 135 138 139
551+217 41 140 142
552+218 140 40 141
553+219 142 141 9
554+220 140 141 142
555+221 39 139 136
556+222 139 40 140
557+223 136 140 41
558+224 139 140 136
559+225 4 137 113
560+226 137 41 143
561+227 113 143 33
562+228 137 143 113
563+229 41 142 145
564+230 142 9 144
565+231 145 144 42
566+232 142 144 145
567+233 33 146 116
568+234 146 42 147
569+235 116 147 10
570+236 146 147 116
571+237 41 145 143
572+238 145 42 146
573+239 143 146 33
574+240 145 146 143
575+241 5 120 91
576+242 120 34 148
577+243 91 148 26
578+244 120 148 91
579+245 34 117 149
580+246 117 10 83
581+247 149 83 23
582+248 117 83 149
583+249 26 150 86
584+250 150 23 80
585+251 86 80 6
586+252 150 80 86
587+253 34 149 148
588+254 149 23 150
589+255 148 150 26
590+256 149 150 148
591+257 3 127 98
592+258 127 36 151
593+259 98 151 28
594+260 127 151 98
595+261 36 124 152
596+262 124 5 90
597+263 152 90 25
598+264 124 90 152
599+265 28 153 93
600+266 153 25 87
601+267 93 87 12
602+268 153 87 93
603+269 36 152 151
604+270 152 25 153
605+271 151 153 28
606+272 152 153 151
607+273 7 134 105
608+274 134 38 154
609+275 105 154 30
610+276 134 154 105
611+277 38 131 155
612+278 131 3 97
613+279 155 97 27
614+280 131 97 155
615+281 30 156 100
616+282 156 27 94
617+283 100 94 11
618+284 156 94 100
619+285 38 155 154
620+286 155 27 156
621+287 154 156 30
622+288 155 156 154
623+289 9 141 112
624+290 141 40 157
625+291 112 157 32
626+292 141 157 112
627+293 40 138 158
628+294 138 7 104
629+295 158 104 29
630+296 138 104 158
631+297 32 159 107
632+298 159 29 101
633+299 107 101 8
634+300 159 101 107
635+301 40 158 157
636+302 158 29 159
637+303 157 159 32
638+304 158 159 157
639+305 10 147 84
640+306 147 42 160
641+307 84 160 24
642+308 147 160 84
643+309 42 144 161
644+310 144 9 111
645+311 161 111 31
646+312 144 111 161
647+313 24 162 79
648+314 162 31 108
649+315 79 108 2
650+316 162 108 79
651+317 42 161 160
652+318 161 31 162
653+319 160 162 24
654+320 161 162 160
655+$EndElements
scripts/engine-test.mjsmodified+24−1View file
@@ -115,7 +115,8 @@ async function main() {
115115 // 1. Poisson on the closed sphere
116116 let d = solve({pde: 'poisson', f: 'x.*y.*z', c: '', p: 6, closed: true})
117117 console.log(` npatches=${d.npatches} n=${d.n} u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
118- if (d.npatches !== 216 || d.n !== 7) throw new Error('unexpected solution shape')
118+ if (d.npatches !== 216 || d.n !== 7 || d.ptype !== 'quad')
119+ throw new Error('unexpected solution shape')
119120 if (!isFinite(d.umin) || !isFinite(d.umax) || d.umin === d.umax)
120121 throw new Error('degenerate solution values')
121122
@@ -145,6 +146,28 @@ async function main() {
145146 d = solve({pde: 'poisson', f: 'x', c: '', p: 4, closed: true})
146147 if (d.type !== 'solution') throw new Error('solve after error failed')
147148
149+ // 5. Triangle mesh: the same eigenfunction check on an icosahedral sphere.
150+ // Flat triangles hug the sphere worse than the cubed sphere's bilinear
151+ // quads, so geometry error dominates: umax is ~0.0107 low at this
152+ // subdivision level and shrinks 4x per level (O(h^2)) — hence the wider
153+ // tolerance.
154+ vfs.writeFile(
155+ '/project/mesh.msh',
156+ fs.readFileSync(path.join(root, 'public', 'samples', 'sphere-tri.msh'))
157+ )
158+ d = solve({pde: 'poisson', f: '-12*(x.*y.*z)', c: '', p: 6, closed: true})
159+ console.log(` tri eigencheck: umax=${d.umax.toFixed(6)} expected~${expected.toFixed(6)}`)
160+ if (d.ptype !== 'tri' || d.npatches !== 320 || d.n !== 7)
161+ throw new Error('unexpected triangle solution shape')
162+ if (Math.abs(d.umax - expected) > 0.015)
163+ throw new Error('triangle eigenfunction check failed')
164+
165+ // Helmholtz with a variable coefficient also works on triangle patches
166+ d = solve({pde: 'helmholtz', f: '1 + 0*x', c: '100*(1 - z)', p: 6, closed: true})
167+ console.log(` tri helmholtz: u in [${d.umin.toFixed(6)}, ${d.umax.toFixed(6)}]`)
168+ if (d.ptype !== 'tri' || !isFinite(d.umin) || d.umin === d.umax)
169+ throw new Error('triangle helmholtz solve failed')
170+
148171 console.log('engine-test: all checks passed')
149172 }
150173
scripts/make_samples.pymodified+52−9View file
@@ -1,9 +1,9 @@
1-"""Generate the bundled sample quad meshes (public/samples/*.msh).
1+"""Generate the bundled sample meshes (public/samples/*.msh).
22
33 Writes Gmsh MSH 4.1 ASCII files in the same canonical form the in-app
44 converter produces: one surface entity block, sequential 1-based node ids,
5-and 4-node quadrangle elements (type 3) — the layout surfacemesh.import
6-reads.
5+and 3-node triangle (type 2) or 4-node quadrangle (type 3) elements — the
6+layout surfacemesh.import reads.
77
88 Run from the repo root: python3 scripts/make_samples.py
99 """
@@ -14,8 +14,9 @@ import os
1414 OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "public", "samples")
1515
1616
17-def write_msh(path, points, quads):
18- n, m = len(points), len(quads)
17+def write_msh(path, points, cells):
18+ n, m = len(points), len(cells)
19+ etype = 2 if len(cells[0]) == 3 else 3
1920 lines = ["$MeshFormat", "4.1 0 8", "$EndMeshFormat"]
2021 lines.append("$Nodes")
2122 lines.append(f"1 {n} 1 {n}") # numEntityBlocks numNodes minTag maxTag
@@ -25,10 +26,9 @@ def write_msh(path, points, quads):
2526 lines.append("$EndNodes")
2627 lines.append("$Elements")
2728 lines.append(f"1 {m} 1 {m}") # numEntityBlocks numElements minTag maxTag
28- lines.append(f"2 1 3 {m}") # entityDim entityTag elementType(3=quad) numElements
29- for i, q in enumerate(quads, start=1):
30- a, b, c, d = (v + 1 for v in q) # 0-based -> 1-based
31- lines.append(f"{i} {a} {b} {c} {d}")
29+ lines.append(f"2 1 {etype} {m}") # entityDim entityTag elementType numElements
30+ for i, cell in enumerate(cells, start=1):
31+ lines.append(f"{i} " + " ".join(str(v + 1) for v in cell)) # 0- -> 1-based
3232 lines.append("$EndElements")
3333 with open(path, "w") as f:
3434 f.write("\n".join(lines) + "\n")
@@ -77,6 +77,46 @@ def cubed_sphere(m):
7777 return points, quads
7878
7979
80+def icosphere(subdiv):
81+ """Icosahedron subdivided `subdiv` times, projected onto the unit sphere."""
82+ phi = (1 + math.sqrt(5)) / 2
83+ norm = math.sqrt(1 + phi * phi)
84+ points = [
85+ (x / norm, y / norm, z / norm)
86+ for x, y, z in (
87+ (-1, phi, 0), (1, phi, 0), (-1, -phi, 0), (1, -phi, 0),
88+ (0, -1, phi), (0, 1, phi), (0, -1, -phi), (0, 1, -phi),
89+ (phi, 0, -1), (phi, 0, 1), (-phi, 0, -1), (-phi, 0, 1),
90+ )
91+ ]
92+ tris = [
93+ (0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11),
94+ (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8),
95+ (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9),
96+ (4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1),
97+ ]
98+ midpoints = {}
99+
100+ def midpoint(a, b):
101+ key = (a, b) if a < b else (b, a)
102+ if key not in midpoints:
103+ x = points[a][0] + points[b][0]
104+ y = points[a][1] + points[b][1]
105+ z = points[a][2] + points[b][2]
106+ r = math.sqrt(x * x + y * y + z * z)
107+ midpoints[key] = len(points)
108+ points.append((x / r, y / r, z / r))
109+ return midpoints[key]
110+
111+ for _ in range(subdiv):
112+ split = []
113+ for a, b, c in tris:
114+ ab, bc, ca = midpoint(a, b), midpoint(b, c), midpoint(c, a)
115+ split += [(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)]
116+ tris = split
117+ return points, tris
118+
119+
80120 def torus(nu, nv, R=1.0, r=0.4):
81121 points = []
82122 for i in range(nu):
@@ -104,6 +144,9 @@ def main():
104144 pts, quads = cubed_sphere(6)
105145 write_msh(os.path.join(OUT_DIR, "sphere.msh"), pts, quads)
106146 print(f"sphere.msh: {len(pts)} nodes, {len(quads)} quads")
147+ pts, tris = icosphere(2)
148+ write_msh(os.path.join(OUT_DIR, "sphere-tri.msh"), pts, tris)
149+ print(f"sphere-tri.msh: {len(pts)} nodes, {len(tris)} triangles")
107150 pts, quads = torus(24, 12)
108151 write_msh(os.path.join(OUT_DIR, "torus.msh"), pts, quads)
109152 print(f"torus.msh: {len(pts)} nodes, {len(quads)} quads")
src/App.tsxmodified+21−21View file
@@ -1,12 +1,11 @@
11 import { useCallback, useEffect, useState } from 'react'
22 import { ACCEPT, formatForFilename } from './mesh/formats'
33 import { initMeshio, parseMeshFile } from './mesh/meshio'
4-import { edgeClassification, type QuadMeshData } from './mesh/quadmesh'
4+import { edgeClassification, type SurfaceMeshData } from './mesh/surfacemesh'
55 import { prewarm, solve, type SolutionData } from './engine/engine'
66 import {
77 PDES,
8- MAX_QUADS,
9- SLOW_QUADS,
8+ SLOW_CELLS,
109 MIN_ORDER,
1110 MAX_ORDER,
1211 DEFAULT_ORDER,
@@ -20,16 +19,17 @@ let prewarmPromise: Promise<void> | null = null
2019
2120 interface LoadedMesh {
2221 name: string
23- data: QuadMeshData
22+ data: SurfaceMeshData
2423 numVertices: number
25- numQuads: number
24+ numCells: number
2625 closed: boolean
2726 nonManifold: boolean
2827 warnings: string[]
2928 }
3029
3130 const SAMPLES = [
32- { label: 'Sphere (cubed)', file: 'sphere.msh' },
31+ { label: 'Sphere (quads)', file: 'sphere.msh' },
32+ { label: 'Sphere (triangles)', file: 'sphere-tri.msh' },
3333 { label: 'Torus', file: 'torus.msh' },
3434 ]
3535
@@ -89,18 +89,12 @@ export default function App() {
8989 const format = formatForFilename(name)
9090 if (!format) throw new Error(`Unsupported file extension on "${name}"`)
9191 const result = await parseMeshFile(bytes, format)
92- if (result.numQuads > MAX_QUADS) {
93- throw new Error(
94- `${result.numQuads} quads is too many for an in-browser solve ` +
95- `(limit ${MAX_QUADS}); please upload a coarser mesh.`,
96- )
97- }
98- const cls = edgeClassification(result.mesh.quads)
92+ const cls = edgeClassification(result.mesh.cells, result.mesh.cellSize)
9993 setMesh({
10094 name,
10195 data: result.mesh,
10296 numVertices: result.numVertices,
103- numQuads: result.numQuads,
97+ numCells: result.numCells,
10498 closed: cls.closed,
10599 nonManifold: cls.nonManifold,
106100 warnings: result.warnings,
@@ -182,7 +176,12 @@ export default function App() {
182176 }
183177
184178 const booting = !meshioReady || !engineReady
185- const dof = mesh ? mesh.numQuads * (order + 1) * (order + 1) : 0
179+ // points per patch: (p+1)^2 on quads, (p+1)(p+2)/2 on triangles
180+ const dof = mesh
181+ ? mesh.data.cellSize === 3
182+ ? (mesh.numCells * (order + 1) * (order + 2)) / 2
183+ : mesh.numCells * (order + 1) * (order + 1)
184+ : 0
186185 const canSolve = !!mesh && engineReady && !solving && fExpr.trim() !== ''
187186
188187 return (
@@ -190,7 +189,7 @@ export default function App() {
190189 <header>
191190 <h1>Mesh PDE Solver</h1>
192191 <p>
193- Upload a quadrilateral surface mesh, pick a PDE, and solve it on the surface with{' '}
192+ Upload a triangle or quad surface mesh, pick a PDE, and solve it on the surface with{' '}
194193 <a href="https://github.com/danfortunato/surfacefun" target="_blank" rel="noreferrer">
195194 surfacefun
196195 </a>{' '}
@@ -214,8 +213,8 @@ export default function App() {
214213 ))}
215214 </div>
216215 <p className="hint">
217- Quad meshes in any format meshio reads ({ACCEPT.replaceAll(',', ' ')}); converted to
218- Gmsh format for surfacefun.
216+ Triangle or quad meshes in any format meshio reads ({ACCEPT.replaceAll(',', ' ')});
217+ converted to Gmsh format for surfacefun.
219218 </p>
220219 <div className="meshinfo">
221220 {parsing ? (
@@ -225,13 +224,14 @@ export default function App() {
225224 ) : mesh ? (
226225 <>
227226 <div>
228- <strong>{mesh.name}</strong> — {mesh.numVertices} vertices, {mesh.numQuads}{' '}
229- quads, {mesh.closed ? 'closed surface' : 'open surface (boundary present)'}
227+ <strong>{mesh.name}</strong> — {mesh.numVertices} vertices, {mesh.numCells}{' '}
228+ {mesh.data.cellSize === 3 ? 'triangles' : 'quads'},{' '}
229+ {mesh.closed ? 'closed surface' : 'open surface (boundary present)'}
230230 </div>
231231 {mesh.nonManifold && (
232232 <div className="warn">Non-manifold edges detected; the solve may fail.</div>
233233 )}
234- {mesh.numQuads > SLOW_QUADS && (
234+ {mesh.numCells > SLOW_CELLS && (
235235 <div className="warn">Large mesh — the solve may take a while.</div>
236236 )}
237237 {mesh.warnings.map((w) => (
src/engine/engine.tsmodified+6−3View file
@@ -18,15 +18,18 @@ export interface SolveParams {
1818 c: string
1919 /** polynomial order per patch */
2020 p: number
21- /** every mesh edge shared by exactly two quads (from edgeClassification) */
21+ /** every mesh edge shared by exactly two cells (from edgeClassification) */
2222 closed: boolean
2323 }
2424
25-/** Per-patch solution grids, as packed by matlab/solve_pde.m. */
25+/** Per-patch solution data, as packed by matlab/solve_pde.m. */
2626 export interface SolutionData {
2727 type: 'solution'
28- /** points per patch edge (p + 1) */
28+ /** points per patch edge (p + 1); quad patches carry n*n points
29+ * (column-major grid), triangle patches n*(n+1)/2 (trianglepts order) */
2930 n: number
31+ /** patch type of the mesh — never mixed */
32+ ptype: 'quad' | 'tri'
3033 npatches: number
3134 x: number[][]
3235 y: number[][]
src/index.cssmodified+40−0View file
@@ -262,3 +262,43 @@ details summary {
262262 color: #9aa3af;
263263 font-size: 15px;
264264 }
265+
266+.view-toolbar {
267+ position: absolute;
268+ top: 12px;
269+ left: 12px;
270+ z-index: 1;
271+ display: flex;
272+ gap: 0;
273+ border: 1px solid #b9c0ca;
274+ border-radius: 6px;
275+ overflow: hidden;
276+ background: rgba(255, 255, 255, 0.9);
277+}
278+
279+.view-toolbar button {
280+ border: none;
281+ border-radius: 0;
282+ background: transparent;
283+ padding: 4px 10px;
284+ font-size: 12px;
285+ color: #5a6472;
286+}
287+
288+.view-toolbar button + button {
289+ border-left: 1px solid #dde1e7;
290+}
291+
292+/* separates the anaglyph toggle from the mutually-exclusive mode group */
293+.view-toolbar button.sep {
294+ border-left: 3px double #dde1e7;
295+}
296+
297+.view-toolbar button:hover {
298+ background: #eef1f5;
299+}
300+
301+.view-toolbar button.active {
302+ background: rgba(22, 103, 194, 0.12);
303+ color: #1257a6;
304+}
src/mesh/bridge.pymodified+93−33View file
@@ -1,11 +1,13 @@
11 # Runs inside Pyodide. Bridges meshio to the JS app.
22 #
3-# parse_quad_mesh reads an uploaded mesh file with meshio, keeps only its
3+# parse_mesh reads an uploaded mesh file with meshio, keeps its triangle and
44 # quadrilateral cells, and produces three outputs in Pyodide's in-memory
55 # filesystem: the canonical Gmsh MSH 4.1 ASCII file the numbl solver reads
66 # with surfacemesh.import (full float64 precision), plus float32 positions
7-# and uint32 quad indices for the JS-side preview and connectivity checks.
8-# See meshio.ts.
7+# and uint32 cell indices for the JS-side preview and connectivity checks.
8+# surfacefun cannot mix patch types, so a mesh containing both kinds has its
9+# quads split into triangles; the output is always homogeneous (all cells
10+# 3 nodes or all 4). See meshio.ts.
911
1012 import json
1113 import os
@@ -17,44 +19,95 @@ import meshio
1719 WORK = "/work"
1820 OUT_MSH = WORK + "/out.msh"
1921 POSITIONS_F32 = WORK + "/positions.f32"
20-QUADS_U32 = WORK + "/quads.u32"
22+CELLS_U32 = WORK + "/cells.u32"
2123
2224 os.makedirs(WORK, exist_ok=True)
2325
2426
25-def _collect_quads(mesh, warnings):
26- """All quad cells, corner-nodes only; reject meshes without any."""
27- blocks = []
27+class _ObjTolerantMesh(meshio.Mesh):
28+ """OBJ faces index texture coordinates and normals independently of
29+ vertex positions, so a file with UV seams legally has more vt (or vn)
30+ entries than v entries. meshio shoehorns those into point_data, whose
31+ per-vertex length check then rejects the whole file; drop the unmappable
32+ arrays instead and remember what was dropped so callers can warn."""
33+
34+ def __init__(self, points, cells, point_data=None, **kwargs):
35+ point_data = point_data or {}
36+ self.dropped_point_data = {
37+ key: len(value)
38+ for key, value in point_data.items()
39+ if len(value) != len(points)
40+ }
41+ point_data = {
42+ key: value
43+ for key, value in point_data.items()
44+ if key not in self.dropped_point_data
45+ }
46+ super().__init__(points, cells, point_data=point_data, **kwargs)
47+
48+
49+# the reader binds Mesh at module level, so this rebinding scopes the
50+# tolerance to OBJ reads only (elsewhere a mismatch means real corruption)
51+meshio.obj._obj.Mesh = _ObjTolerantMesh
52+
53+_OBJ_POINT_DATA_NAMES = {"obj:vt": "texture coordinates", "obj:vn": "vertex normals"}
54+
55+
56+def _collect_cells(mesh, warnings):
57+ """Triangle and quad cells, corner-nodes only; mixed meshes are reduced
58+ to all-triangle; reject meshes with neither kind."""
59+ tris = []
60+ quads = []
2861 found = set()
2962 for block in mesh.cells:
3063 data = block.data
3164 if not isinstance(data, np.ndarray) or data.ndim != 2:
3265 continue
3366 found.add(block.type)
34- if block.type == "quad":
35- blocks.append(data)
67+ if block.type == "triangle":
68+ tris.append(data)
69+ elif block.type in ("triangle6", "triangle7"):
70+ tris.append(data[:, :3])
71+ warnings.append(
72+ f"{len(data)} higher-order {block.type} cells reduced to corner nodes"
73+ )
74+ elif block.type == "quad":
75+ quads.append(data)
3676 elif block.type in ("quad8", "quad9"):
37- blocks.append(data[:, :4])
77+ quads.append(data[:, :4])
3878 warnings.append(
3979 f"{len(data)} higher-order {block.type} cells reduced to corner nodes"
4080 )
81+ elif block.type == "polygon" and data.shape[1] == 3:
82+ tris.append(data)
4183 elif block.type == "polygon" and data.shape[1] == 4:
42- blocks.append(data)
43- if not blocks:
84+ quads.append(data)
85+ if not tris and not quads:
4486 kinds = ", ".join(sorted(found)) or "none"
4587 raise ValueError(
46- "No quadrilateral cells found (cell types in file: "
88+ "No triangle or quadrilateral cells found (cell types in file: "
4789 + kinds
48- + "). surfacefun solves on quad meshes; "
49- "convert your mesh to quads before uploading."
90+ + "). surfacefun solves on triangle or quad meshes."
5091 )
92+ if tris and quads:
93+ nq = sum(len(q) for q in quads)
94+ for q in quads:
95+ tris.append(q[:, [0, 1, 2]])
96+ tris.append(q[:, [0, 2, 3]])
97+ quads = []
98+ warnings.append(
99+ f"{nq} quads split into triangles (surfacefun cannot mix cell types)"
100+ )
101+ blocks = tris or quads
51102 return np.ascontiguousarray(np.vstack(blocks).astype(np.int64))
52103
53104
54-def _write_msh(path, points, quads):
105+def _write_msh(path, points, cells):
55106 """Canonical Gmsh MSH 4.1 ASCII: one surface entity block, sequential
56- 1-based node ids, 4-node quads (type 3) — what surfacemesh.import reads."""
57- n, m = len(points), len(quads)
107+ 1-based node ids, 3-node triangles (type 2) or 4-node quads (type 3) —
108+ what surfacemesh.import reads."""
109+ n, m = len(points), len(cells)
110+ etype = 2 if cells.shape[1] == 3 else 3
58111 lines = ["$MeshFormat", "4.1 0 8", "$EndMeshFormat"]
59112 lines.append("$Nodes")
60113 lines.append("1 %d 1 %d" % (n, n)) # numEntityBlocks numNodes minTag maxTag
@@ -64,15 +117,15 @@ def _write_msh(path, points, quads):
64117 lines.append("$EndNodes")
65118 lines.append("$Elements")
66119 lines.append("1 %d 1 %d" % (m, m)) # numEntityBlocks numElements minTag maxTag
67- lines.append("2 1 3 %d" % m) # entityDim entityTag elementType(3=quad) numElements
68- for i, q in enumerate(quads, start=1):
69- lines.append("%d %d %d %d %d" % (i, q[0] + 1, q[1] + 1, q[2] + 1, q[3] + 1))
120+ lines.append("2 1 %d %d" % (etype, m)) # entityDim entityTag elementType numElements
121+ for i, c in enumerate(cells, start=1):
122+ lines.append(str(i) + " " + " ".join(str(v + 1) for v in c))
70123 lines.append("$EndElements")
71124 with open(path, "w") as f:
72125 f.write("\n".join(lines) + "\n")
73126
74127
75-def parse_quad_mesh(path, file_format=None):
128+def parse_mesh(path, file_format=None):
76129 warnings = []
77130 try:
78131 # meshio's read helper exits the interpreter when every candidate
@@ -81,6 +134,12 @@ def parse_quad_mesh(path, file_format=None):
81134 except SystemExit:
82135 raise ValueError(f"Could not read file as {file_format or 'any known format'}")
83136
137+ for key, count in getattr(mesh, "dropped_point_data", {}).items():
138+ name = _OBJ_POINT_DATA_NAMES.get(key, key)
139+ warnings.append(
140+ f"dropped {name}: {count} entries for {len(mesh.points)} vertices"
141+ )
142+
84143 points = np.asarray(mesh.points, dtype=np.float64)
85144 if points.ndim != 2:
86145 raise ValueError(f"Unexpected points array shape {points.shape}")
@@ -89,30 +148,31 @@ def parse_quad_mesh(path, file_format=None):
89148 warnings.append("2D points: added z=0")
90149 points = np.ascontiguousarray(points[:, :3])
91150
92- quads = _collect_quads(mesh, warnings)
93- if quads.size and (int(quads.min()) < 0 or int(quads.max()) >= len(points)):
94- raise ValueError("Quad node index out of range")
151+ cells = _collect_cells(mesh, warnings)
152+ if cells.size and (int(cells.min()) < 0 or int(cells.max()) >= len(points)):
153+ raise ValueError("Cell node index out of range")
95154
96- # Drop vertices not referenced by any quad (e.g. triangle-only regions of
97- # a mixed mesh) so the .msh stays minimal and ids stay dense.
98- used = np.unique(quads)
155+ # Drop vertices not referenced by any cell (e.g. stray line elements)
156+ # so the .msh stays minimal and ids stay dense.
157+ used = np.unique(cells)
99158 if len(used) < len(points):
100159 remap = np.full(len(points), -1, dtype=np.int64)
101160 remap[used] = np.arange(len(used))
102161 points = points[used]
103- quads = remap[quads]
162+ cells = remap[cells]
104163 warnings.append(f"dropped {len(remap) - len(used)} unused vertices")
105164
106- _write_msh(OUT_MSH, points, quads)
165+ _write_msh(OUT_MSH, points, cells)
107166 with open(POSITIONS_F32, "wb") as f:
108167 f.write(points.astype(np.float32).tobytes())
109- with open(QUADS_U32, "wb") as f:
110- f.write(np.ascontiguousarray(quads.astype(np.uint32)).tobytes())
168+ with open(CELLS_U32, "wb") as f:
169+ f.write(np.ascontiguousarray(cells.astype(np.uint32)).tobytes())
111170
112171 return json.dumps(
113172 {
114173 "numVertices": len(points),
115- "numQuads": len(quads),
174+ "numCells": len(cells),
175+ "cellSize": int(cells.shape[1]),
116176 "warnings": warnings,
117177 }
118178 )
src/mesh/formats.tsmodified+3−2View file
@@ -1,7 +1,8 @@
11 /**
22 * Upload formats the app accepts. All go through meshio (in Pyodide); the
3- * mesh must contain quadrilateral cells — surfacefun's patches are quads,
4- * and pure-triangle meshes are rejected with a pointer to why.
3+ * mesh must contain triangle or quadrilateral cells — surfacefun computes
4+ * on either patch type (but not both at once, so mixed meshes are split
5+ * into all-triangle in bridge.py).
56 */
67 export interface MeshFormat {
78 id: string // meshio file_format id
src/mesh/meshio.tsmodified+14−11View file
@@ -1,18 +1,18 @@
11 /**
22 * JS side of the meshio bridge (adapted from mesh-converter). Loads Pyodide
33 * from the script tag in index.html, installs meshio via micropip, and runs
4- * bridge.py to turn an uploaded mesh file into the app's quad-mesh
4+ * bridge.py to turn an uploaded mesh file into the app's surface-mesh
55 * representation — all client-side.
66 */
77 import bridgeCode from './bridge.py?raw'
88 import type { MeshFormat } from './formats'
9-import type { QuadMeshData } from './quadmesh'
9+import type { SurfaceMeshData } from './surfacemesh'
1010
1111 const MESHIO_SPEC = 'meshio==5.3.5'
1212
1313 const OUT_MSH = '/work/out.msh'
1414 const POSITIONS_F32 = '/work/positions.f32'
15-const QUADS_U32 = '/work/quads.u32'
15+const CELLS_U32 = '/work/cells.u32'
1616
1717 interface Pyodide {
1818 runPython(code: string): unknown
@@ -32,9 +32,9 @@ declare global {
3232 }
3333
3434 export interface ParseResult {
35- mesh: QuadMeshData
35+ mesh: SurfaceMeshData
3636 numVertices: number
37- numQuads: number
37+ numCells: number
3838 warnings: string[]
3939 }
4040
@@ -79,7 +79,7 @@ export async function parseMeshFile(bytes: Uint8Array, format: MeshFormat): Prom
7979 try {
8080 infoJson = String(
8181 pyodide.runPython(
82- `parse_quad_mesh(${JSON.stringify(inputPath)}, ${JSON.stringify(format.id)})`,
82+ `parse_mesh(${JSON.stringify(inputPath)}, ${JSON.stringify(format.id)})`,
8383 ),
8484 )
8585 } catch (err) {
@@ -93,15 +93,18 @@ export async function parseMeshFile(bytes: Uint8Array, format: MeshFormat): Prom
9393 }
9494 const info = JSON.parse(infoJson) as {
9595 numVertices: number
96- numQuads: number
96+ numCells: number
97+ cellSize: 3 | 4
9798 warnings: string[]
9899 }
99100 const posBytes = pyodide.FS.readFile(POSITIONS_F32)
100- const quadBytes = pyodide.FS.readFile(QUADS_U32)
101- const mesh: QuadMeshData = {
101+ const cellBytes = pyodide.FS.readFile(CELLS_U32)
102+ const mesh: SurfaceMeshData = {
102103 positions: new Float32Array(posBytes.buffer, posBytes.byteOffset, posBytes.byteLength / 4),
103- quads: new Uint32Array(quadBytes.buffer, quadBytes.byteOffset, quadBytes.byteLength / 4),
104+ cells: new Uint32Array(cellBytes.buffer, cellBytes.byteOffset, cellBytes.byteLength / 4),
105+ cellSize: info.cellSize,
104106 mshBytes: pyodide.FS.readFile(OUT_MSH),
105107 }
106- return { mesh, ...info }
108+ const { numVertices, numCells, warnings } = info
109+ return { mesh, numVertices, numCells, warnings }
107110 }
src/mesh/quadmesh.ts →src/mesh/surfacemesh.tsrenamed+21−16View file
@@ -1,38 +1,43 @@
1-/** The app's internal quad-mesh representation and connectivity helpers. */
1+/** The app's internal surface-mesh representation and connectivity helpers. */
22
3-export interface QuadMeshData {
3+export interface SurfaceMeshData {
44 /** xyz triples, one per vertex */
55 positions: Float32Array
6- /** 4 vertex indices per quad, Gmsh corner order (counterclockwise) */
7- quads: Uint32Array
8- /** the canonical Gmsh MSH 2.2 file the solver reads */
6+ /** cellSize vertex indices per cell, Gmsh corner order (counterclockwise) */
7+ cells: Uint32Array
8+ /** nodes per cell: 3 (triangles) or 4 (quads) — never mixed */
9+ cellSize: 3 | 4
10+ /** the canonical Gmsh MSH 4.1 file the solver reads */
911 mshBytes: Uint8Array
1012 }
1113
12-export interface QuadMeshInfo {
14+export interface SurfaceMeshInfo {
1315 numVertices: number
14- numQuads: number
15- /** every edge shared by exactly two quads */
16+ numCells: number
17+ /** every edge shared by exactly two cells */
1618 closed: boolean
17- /** some edge shared by more than two quads */
19+ /** some edge shared by more than two cells */
1820 nonManifold: boolean
1921 warnings: string[]
2022 }
2123
2224 /**
2325 * Classify the mesh from its edge incidence: closed (all edges shared by 2
24- * quads), open (some boundary edges), or non-manifold (an edge on >2 quads).
26+ * cells), open (some boundary edges), or non-manifold (an edge on >2 cells).
2527 */
26-export function edgeClassification(quads: Uint32Array): {
28+export function edgeClassification(
29+ cells: Uint32Array,
30+ cellSize: number,
31+): {
2732 closed: boolean
2833 nonManifold: boolean
2934 } {
3035 const counts = new Map<number, number>()
31- const nq = quads.length / 4
32- for (let k = 0; k < nq; k++) {
33- for (let e = 0; e < 4; e++) {
34- const a = quads[k * 4 + e]
35- const b = quads[k * 4 + ((e + 1) % 4)]
36+ const nc = cells.length / cellSize
37+ for (let k = 0; k < nc; k++) {
38+ for (let e = 0; e < cellSize; e++) {
39+ const a = cells[k * cellSize + e]
40+ const b = cells[k * cellSize + ((e + 1) % cellSize)]
3641 // 2^26 > any vertex count we accept; safe integer key for the pair
3742 const key = a < b ? a * 67108864 + b : b * 67108864 + a
3843 counts.set(key, (counts.get(key) ?? 0) + 1)
src/pde/presets.tsmodified+2−4View file
@@ -55,10 +55,8 @@ export const PDES: PdeDef[] = [
5555 },
5656 ]
5757
58-/** Above this many quads the in-browser solve becomes unreasonably slow. */
59-export const MAX_QUADS = 4000
60-/** Above this, warn that the solve may take a while. */
61-export const SLOW_QUADS = 1500
58+/** Above this many cells, warn that the solve may take a while. */
59+export const SLOW_CELLS = 1500
6260
6361 export const MIN_ORDER = 2
6462 export const MAX_ORDER = 10
src/render/SurfaceView.tsxmodified+256−77View file
@@ -1,25 +1,41 @@
11 /**
22 * The rotatable 3D view (plain three.js, adapted from
3- * surfacefun-interactive's SurfView): shows the uploaded quad mesh until a
3+ * surfacefun-interactive's SurfView): shows the uploaded mesh until a
44 * solution arrives, then the solution colored by u with a colorbar.
5- * Drag to rotate, scroll to zoom.
5+ * Drag to rotate, scroll to zoom. A toolbar (mirroring mesh-converter's)
6+ * picks shaded / wireframe / both / points rendering and toggles red/cyan
7+ * anaglyph stereo.
68 */
7-import { useRef, useEffect, type CSSProperties } from 'react'
9+import { useRef, useEffect, useState, type CSSProperties } from 'react'
810 import * as THREE from 'three'
911 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
10-import type { QuadMeshData } from '../mesh/quadmesh'
12+import { AnaglyphEffect } from 'three/examples/jsm/effects/AnaglyphEffect.js'
13+import type { SurfaceMeshData } from '../mesh/surfacemesh'
1114 import type { SolutionData } from '../engine/engine'
1215 import { colormapLookup, colormapGradient } from './colormap'
1316
1417 export interface ViewContent {
15- mesh: QuadMeshData | null
18+ mesh: SurfaceMeshData | null
1619 solution: SolutionData | null
1720 }
1821
22+type ViewMode = 'shaded' | 'wire' | 'both' | 'points'
23+
24+const VIEW_MODES: { id: ViewMode; label: string }[] = [
25+ { id: 'shaded', label: 'Shaded' },
26+ { id: 'wire', label: 'Wire' },
27+ { id: 'both', label: 'Both' },
28+ { id: 'points', label: 'Points' },
29+]
30+
1931 interface SceneState {
2032 renderer: THREE.WebGLRenderer
2133 scene: THREE.Scene
2234 camera: THREE.OrthographicCamera
35+ /** stand-in for the ortho camera while the anaglyph effect renders
36+ * (the effect derives its stereo pair from a perspective projection) */
37+ persp: THREE.PerspectiveCamera
38+ effect: AnaglyphEffect
2339 controls: OrbitControls
2440 animId: number
2541 }
@@ -29,7 +45,12 @@ interface SceneState {
2945 function clearScene(scene: THREE.Scene) {
3046 const toRemove: THREE.Object3D[] = []
3147 scene.traverse((obj) => {
32- if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments) toRemove.push(obj)
48+ if (
49+ obj instanceof THREE.Mesh ||
50+ obj instanceof THREE.LineSegments ||
51+ obj instanceof THREE.Points
52+ )
53+ toRemove.push(obj)
3354 })
3455 for (const obj of toRemove) {
3556 scene.remove(obj)
@@ -69,8 +90,18 @@ function normalizedPosition(
6990 out[outIdx + 2] = (xyz[1] - center[1]) / range
7091 }
7192
72-function buildMeshPreview(scene: THREE.Scene, mesh: QuadMeshData) {
73- const { positions, quads } = mesh
93+/** Pixel-sized points that read on the white background. */
94+function pointsMaterial(vertexColors: boolean) {
95+ return new THREE.PointsMaterial({
96+ vertexColors,
97+ color: vertexColors ? 0xffffff : 0x51606f,
98+ size: 3.5 * (window.devicePixelRatio || 1),
99+ sizeAttenuation: false,
100+ })
101+}
102+
103+function buildMeshPreview(scene: THREE.Scene, mesh: SurfaceMeshData, mode: ViewMode) {
104+ const { positions, cells, cellSize } = mesh
74105 const { center, range } = bounds([positions])
75106 const nVerts = positions.length / 3
76107
@@ -84,52 +115,129 @@ function buildMeshPreview(scene: THREE.Scene, mesh: QuadMeshData) {
84115 range,
85116 )
86117 }
118+ const posAttr = new THREE.BufferAttribute(pos, 3)
119+ const nc = cells.length / cellSize
120+
121+ if (mode === 'shaded' || mode === 'both') {
122+ const indices: number[] = []
123+ for (let k = 0; k < nc; k++) {
124+ const [a, b, c] = [cells[k * cellSize], cells[k * cellSize + 1], cells[k * cellSize + 2]]
125+ indices.push(a, b, c)
126+ if (cellSize === 4) indices.push(a, c, cells[k * cellSize + 3])
127+ }
128+ const geometry = new THREE.BufferGeometry()
129+ geometry.setAttribute('position', posAttr)
130+ geometry.setIndex(indices)
131+ geometry.computeVertexNormals()
132+ scene.add(
133+ new THREE.Mesh(
134+ geometry,
135+ new THREE.MeshPhongMaterial({
136+ color: 0xb8bec9,
137+ flatShading: true,
138+ side: THREE.DoubleSide,
139+ polygonOffset: mode === 'both',
140+ polygonOffsetFactor: 1,
141+ polygonOffsetUnits: 1,
142+ }),
143+ ),
144+ )
145+ }
146+
147+ if (mode === 'wire' || mode === 'both') {
148+ // cell edges (not the render triangulation, so quads show no diagonals)
149+ const edgeIndices: number[] = []
150+ for (let k = 0; k < nc; k++) {
151+ for (let e = 0; e < cellSize; e++) {
152+ edgeIndices.push(cells[k * cellSize + e], cells[k * cellSize + ((e + 1) % cellSize)])
153+ }
154+ }
155+ const edgeGeometry = new THREE.BufferGeometry()
156+ edgeGeometry.setAttribute('position', posAttr)
157+ edgeGeometry.setIndex(edgeIndices)
158+ scene.add(
159+ new THREE.LineSegments(
160+ edgeGeometry,
161+ mode === 'both'
162+ ? new THREE.LineBasicMaterial({ color: 0x000000, opacity: 0.35, transparent: true })
163+ : new THREE.LineBasicMaterial({ color: 0x33404e }),
164+ ),
165+ )
166+ }
167+
168+ if (mode === 'points') {
169+ const geometry = new THREE.BufferGeometry()
170+ geometry.setAttribute('position', posAttr)
171+ scene.add(new THREE.Points(geometry, pointsMaterial(false)))
172+ }
173+}
174+
175+/**
176+ * Triangulation of the n*(n+1)/2 trianglepts(n) nodes of one triangle patch
177+ * into (n-1)^2 sub-triangles — a 0-based port of surfacefun's trilattice.m.
178+ * The nodes come in columns of decreasing height n, n-1, ..., 1.
179+ */
180+function triLattice(n: number): number[] {
181+ const indices: number[] = []
182+ let colstart = 0
183+ for (let i = 0; i < n - 1; i++) {
184+ const h = n - i - 1
185+ indices.push(colstart, colstart + 1, colstart + 1 + h)
186+ for (let s = colstart + 1; s < colstart + h; s++) {
187+ indices.push(s, s + h, s + h + 1, s, s + 1, s + h + 1)
188+ }
189+ colstart += h + 1
190+ }
191+ return indices
192+}
87193
194+/** Triangulation of one quad patch's column-major n-by-n grid. */
195+function quadLattice(n: number): number[] {
88196 const indices: number[] = []
89- const nq = quads.length / 4
90- for (let k = 0; k < nq; k++) {
91- const [a, b, c, d] = [quads[k * 4], quads[k * 4 + 1], quads[k * 4 + 2], quads[k * 4 + 3]]
92- indices.push(a, b, c, a, c, d)
197+ for (let j = 0; j < n - 1; j++) {
198+ for (let i = 0; i < n - 1; i++) {
199+ const a = j * n + i
200+ const b = j * n + i + 1
201+ const c = (j + 1) * n + i
202+ const d = (j + 1) * n + i + 1
203+ indices.push(a, b, c, b, d, c)
204+ }
93205 }
206+ return indices
207+}
94208
95- const geometry = new THREE.BufferGeometry()
96- geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3))
97- geometry.setIndex(indices)
98- geometry.computeVertexNormals()
99- scene.add(
100- new THREE.Mesh(
101- geometry,
102- new THREE.MeshPhongMaterial({
103- color: 0xb8bec9,
104- flatShading: true,
105- side: THREE.DoubleSide,
106- }),
107- ),
108- )
209+/** Unique edges of the triLattice(n) triangulation, as index pairs. */
210+function triLatticeEdges(n: number): number[] {
211+ const tris = triLattice(n)
212+ const seen = new Set<number>()
213+ const pairs: number[] = []
214+ for (let t = 0; t < tris.length; t += 3) {
215+ for (let e = 0; e < 3; e++) {
216+ const a = tris[t + e]
217+ const b = tris[t + ((e + 1) % 3)]
218+ const key = a < b ? a * 65536 + b : b * 65536 + a
219+ if (!seen.has(key)) {
220+ seen.add(key)
221+ pairs.push(a, b)
222+ }
223+ }
224+ }
225+ return pairs
226+}
109227
110- // quad edges
111- const edgePositions: number[] = []
112- for (let k = 0; k < nq; k++) {
113- for (let e = 0; e < 4; e++) {
114- const a = quads[k * 4 + e]
115- const b = quads[k * 4 + ((e + 1) % 4)]
116- edgePositions.push(
117- pos[a * 3], pos[a * 3 + 1], pos[a * 3 + 2],
118- pos[b * 3], pos[b * 3 + 1], pos[b * 3 + 2],
119- )
228+/** Grid lines of an n-by-n patch (no triangulation diagonals), index pairs. */
229+function quadGridEdges(n: number): number[] {
230+ const pairs: number[] = []
231+ for (let j = 0; j < n; j++) {
232+ for (let i = 0; i < n; i++) {
233+ if (i + 1 < n) pairs.push(j * n + i, j * n + i + 1)
234+ if (j + 1 < n) pairs.push(j * n + i, (j + 1) * n + i)
120235 }
121236 }
122- const edgeGeometry = new THREE.BufferGeometry()
123- edgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3))
124- scene.add(
125- new THREE.LineSegments(
126- edgeGeometry,
127- new THREE.LineBasicMaterial({ color: 0x000000, opacity: 0.35, transparent: true }),
128- ),
129- )
237+ return pairs
130238 }
131239
132-function buildSolution(scene: THREE.Scene, sol: SolutionData) {
240+function buildSolution(scene: THREE.Scene, sol: SolutionData, mode: ViewMode) {
133241 const { n, x, y, z, u, umin, umax } = sol
134242 const flat: number[] = []
135243 for (let k = 0; k < sol.npatches; k++) {
@@ -137,13 +245,17 @@ function buildSolution(scene: THREE.Scene, sol: SolutionData) {
137245 }
138246 const { center, range } = bounds([flat])
139247 const cRange = umax - umin || 1
248+ const isTri = sol.ptype === 'tri'
249+ const faceIndices = isTri ? triLattice(n) : quadLattice(n)
250+ const edgeIndices =
251+ mode === 'wire' || mode === 'both' ? (isTri ? triLatticeEdges(n) : quadGridEdges(n)) : null
140252
141253 for (let k = 0; k < sol.npatches; k++) {
142254 const px = x[k]
143255 const py = y[k]
144256 const pz = z[k]
145257 const pu = u[k]
146- const nv = px.length // n*n grid, column-major
258+ const nv = px.length // n*n grid or n*(n+1)/2 triangle nodes
147259 const pos = new Float32Array(nv * 3)
148260 const col = new Float32Array(nv * 3)
149261 for (let i = 0; i < nv; i++) {
@@ -153,37 +265,61 @@ function buildSolution(scene: THREE.Scene, sol: SolutionData) {
153265 col[i * 3 + 1] = g
154266 col[i * 3 + 2] = b
155267 }
156- const indices: number[] = []
157- for (let j = 0; j < n - 1; j++) {
158- for (let i = 0; i < n - 1; i++) {
159- const a = j * n + i
160- const b = j * n + i + 1
161- const c = (j + 1) * n + i
162- const d = (j + 1) * n + i + 1
163- indices.push(a, b, c, b, d, c)
164- }
268+ const posAttr = new THREE.BufferAttribute(pos, 3)
269+ const colAttr = new THREE.BufferAttribute(col, 3)
270+
271+ if (mode === 'shaded' || mode === 'both') {
272+ const geometry = new THREE.BufferGeometry()
273+ geometry.setAttribute('position', posAttr)
274+ geometry.setAttribute('color', colAttr)
275+ geometry.setIndex(faceIndices)
276+ geometry.computeVertexNormals()
277+ scene.add(
278+ new THREE.Mesh(
279+ geometry,
280+ new THREE.MeshPhongMaterial({
281+ vertexColors: true,
282+ side: THREE.DoubleSide,
283+ shininess: 10,
284+ polygonOffset: mode === 'both',
285+ polygonOffsetFactor: 1,
286+ polygonOffsetUnits: 1,
287+ }),
288+ ),
289+ )
290+ }
291+
292+ if (edgeIndices) {
293+ const edgeGeometry = new THREE.BufferGeometry()
294+ edgeGeometry.setAttribute('position', posAttr)
295+ edgeGeometry.setAttribute('color', colAttr)
296+ edgeGeometry.setIndex(edgeIndices)
297+ scene.add(
298+ new THREE.LineSegments(
299+ edgeGeometry,
300+ mode === 'both'
301+ ? new THREE.LineBasicMaterial({ color: 0x000000, opacity: 0.35, transparent: true })
302+ : new THREE.LineBasicMaterial({ vertexColors: true }),
303+ ),
304+ )
305+ }
306+
307+ if (mode === 'points') {
308+ const geometry = new THREE.BufferGeometry()
309+ geometry.setAttribute('position', posAttr)
310+ geometry.setAttribute('color', colAttr)
311+ scene.add(new THREE.Points(geometry, pointsMaterial(true)))
165312 }
166- const geometry = new THREE.BufferGeometry()
167- geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3))
168- geometry.setAttribute('color', new THREE.BufferAttribute(col, 3))
169- geometry.setIndex(indices)
170- geometry.computeVertexNormals()
171- scene.add(
172- new THREE.Mesh(
173- geometry,
174- new THREE.MeshPhongMaterial({
175- vertexColors: true,
176- side: THREE.DoubleSide,
177- shininess: 10,
178- }),
179- ),
180- )
181313 }
182314 }
183315
184316 export function SurfaceView({ mesh, solution }: ViewContent) {
185317 const containerRef = useRef<HTMLDivElement>(null)
186318 const stateRef = useRef<SceneState | null>(null)
319+ const [mode, setMode] = useState<ViewMode>('both')
320+ const [anaglyph, setAnaglyph] = useState(false)
321+ const anaglyphRef = useRef(anaglyph)
322+ anaglyphRef.current = anaglyph
187323
188324 // Set up the scene once
189325 useEffect(() => {
@@ -199,6 +335,8 @@ export function SurfaceView({ mesh, solution }: ViewContent) {
199335 const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100)
200336 camera.position.set(1.2, 0.8, 1.2)
201337 camera.lookAt(0, 0, 0)
338+ const persp = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
339+ const effect = new AnaglyphEffect(renderer)
202340
203341 const controls = new OrbitControls(camera, renderer.domElement)
204342 controls.enablePan = false
@@ -210,15 +348,35 @@ export function SurfaceView({ mesh, solution }: ViewContent) {
210348
211349 const animId = requestAnimationFrame(function loop() {
212350 controls.update()
213- renderer.render(scene, camera)
351+ if (anaglyphRef.current) {
352+ // The effect needs a perspective projection; mirror the ortho view:
353+ // same pose, fov chosen so the visible height at the orbit target
354+ // matches the ortho frustum at the current zoom.
355+ const d = camera.position.distanceTo(controls.target)
356+ persp.position.copy(camera.position)
357+ persp.quaternion.copy(camera.quaternion)
358+ persp.fov = THREE.MathUtils.radToDeg(
359+ 2 * Math.atan((camera.top - camera.bottom) / 2 / camera.zoom / d),
360+ )
361+ persp.aspect = (camera.right - camera.left) / (camera.top - camera.bottom)
362+ persp.updateProjectionMatrix()
363+ // Zero parallax at the orbit target, eye separation proportional to
364+ // the viewing distance, so stereo depth stays comfortable at any zoom
365+ effect.planeDistance = d
366+ effect.eyeSep = d * 0.02
367+ effect.render(scene, persp)
368+ } else {
369+ renderer.render(scene, camera)
370+ }
214371 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
215372 })
216- stateRef.current = { renderer, scene, camera, controls, animId }
373+ stateRef.current = { renderer, scene, camera, persp, effect, controls, animId }
217374
218375 const observer = new ResizeObserver(() => {
219376 const rect = container.getBoundingClientRect()
220377 if (rect.width === 0 || rect.height === 0) return
221378 renderer.setSize(rect.width, rect.height)
379+ effect.setSize(rect.width, rect.height)
222380 const aspect = rect.width / rect.height
223381 const frustumSize = 0.85
224382 camera.left = -frustumSize * aspect
@@ -233,27 +391,48 @@ export function SurfaceView({ mesh, solution }: ViewContent) {
233391 observer.disconnect()
234392 cancelAnimationFrame(stateRef.current?.animId ?? animId)
235393 controls.dispose()
394+ effect.dispose()
236395 renderer.dispose()
237396 container.removeChild(renderer.domElement)
238397 stateRef.current = null
239398 }
240399 }, [])
241400
242- // Rebuild content when data changes
401+ // Rebuild content when data or view mode changes
243402 useEffect(() => {
244403 const st = stateRef.current
245404 if (!st) return
246405 clearScene(st.scene)
247- if (solution) buildSolution(st.scene, solution)
248- else if (mesh) buildMeshPreview(st.scene, mesh)
249- }, [mesh, solution])
406+ if (solution) buildSolution(st.scene, solution, mode)
407+ else if (mesh) buildMeshPreview(st.scene, mesh, mode)
408+ }, [mesh, solution, mode])
250409
251410 return (
252411 <div style={{ position: 'relative', width: '100%', height: '100%' }}>
253412 <div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
413+ {(mesh || solution) && (
414+ <div className="view-toolbar">
415+ {VIEW_MODES.map((m) => (
416+ <button
417+ key={m.id}
418+ className={mode === m.id ? 'active' : ''}
419+ onClick={() => setMode(m.id)}
420+ >
421+ {m.label}
422+ </button>
423+ ))}
424+ <button
425+ className={`sep ${anaglyph ? 'active' : ''}`}
426+ onClick={() => setAnaglyph((a) => !a)}
427+ title="Anaglyph stereo — view with red/cyan 3D glasses"
428+ >
429+ 3D
430+ </button>
431+ </div>
432+ )}
254433 {solution && <Colorbar min={solution.umin} max={solution.umax} />}
255434 {!mesh && !solution && (
256- <div className="view-placeholder">Upload a quad mesh or load a sample to begin</div>
435+ <div className="view-placeholder">Upload a surface mesh or load a sample to begin</div>
257436 )}
258437 </div>
259438 )