3D hull: download the mesh in Gmsh format
Plain-JS MSH 2.2 ASCII writer (no Pyodide/numbl); unreferenced interior
points are dropped and node ids remapped. Useful as input to
mesh-converter.
3 changed files+48−1
README.mdmodified+3−1View file
@@ -10,7 +10,9 @@ in the browser.
1010 - **2D Triangulation** — Delaunay triangulation + convex hull of a point set;
1111 click to add points, switch distributions, toggle circumcircles.
1212 - **3D Convex Hull** — triangulated hull of a 3D point cloud, rendered with
13- three.js (drag to rotate).
13+ three.js (drag to rotate). The hull can be downloaded as a Gmsh `.msh` mesh
14+ (e.g. to feed into
15+ [mesh-converter](https://github.com/concept-collection/mesh-converter)).
1416 - **Benchmarks** — times Delaunay triangulation in the browser, alongside a
1517 `.m` script that runs identically in MATLAB, Octave, and
1618 [numbl](https://numbl.org) (all triangulate via Qhull) for an apples-to-apples
src/components/Hull3D.tsxmodified+9−0View file
@@ -7,6 +7,7 @@ import * as THREE from 'three'
77 import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
88 import { getQhull } from '../qhull'
99 import { points3D, type Dist3D } from '../points'
10+import { trianglesToMsh, downloadText } from '../gmsh'
1011
1112 export function Hull3D() {
1213 const [dist, setDist] = useState<Dist3D>('gaussian')
@@ -170,6 +171,14 @@ export function Hull3D() {
170171 <FormControlLabel control={<Checkbox size="small" checked={showPoints} onChange={(e) => setShowPoints(e.target.checked)} />} label="Points" />
171172 <FormControlLabel control={<Checkbox size="small" checked={wireframe} onChange={(e) => setWireframe(e.target.checked)} />} label="Wireframe" />
172173 <FormControlLabel control={<Checkbox size="small" checked={spin} onChange={(e) => setSpin(e.target.checked)} />} label="Spin" />
174+ <Button
175+ size="small"
176+ variant="outlined"
177+ disabled={!data.hull.length}
178+ onClick={() => downloadText('convex-hull.msh', trianglesToMsh(data.pts, data.hull))}
179+ >
180+ Download .msh
181+ </Button>
173182 </Stack>
174183 <Box ref={mountRef} sx={{ width: '100%', borderRadius: 1, overflow: 'hidden', lineHeight: 0 }} />
175184 <Typography variant="body2" color="text.secondary">
src/gmsh.tsadded+36−0View file
@@ -0,0 +1,36 @@
1+/**
2+ * Minimal Gmsh MSH 2.2 ASCII writer for a triangle surface mesh — plain
3+ * string building, no dependencies. Vertices not referenced by any facet
4+ * (interior points of the cloud) are dropped, and indices are remapped to
5+ * the dense 1-based node ids the format expects.
6+ */
7+export function trianglesToMsh(points: number[][], facets: number[][]): string {
8+ const used = new Map<number, number>() // original index -> 1-based node id
9+ for (const f of facets) {
10+ for (const idx of f) {
11+ if (!used.has(idx)) used.set(idx, used.size + 1)
12+ }
13+ }
14+
15+ const lines = ['$MeshFormat', '2.2 0 8', '$EndMeshFormat', '$Nodes', String(used.size)]
16+ for (const [idx, id] of used) {
17+ const [x, y, z] = points[idx]
18+ lines.push(`${id} ${x} ${y} ${z}`)
19+ }
20+ lines.push('$EndNodes', '$Elements', String(facets.length))
21+ facets.forEach((f, i) => {
22+ // element type 2 = 3-node triangle, two tags
23+ lines.push(`${i + 1} 2 2 1 1 ${used.get(f[0])} ${used.get(f[1])} ${used.get(f[2])}`)
24+ })
25+ lines.push('$EndElements')
26+ return lines.join('\n') + '\n'
27+}
28+
29+export function downloadText(filename: string, text: string) {
30+ const blob = new Blob([text], { type: 'application/octet-stream' })
31+ const a = document.createElement('a')
32+ a.href = URL.createObjectURL(blob)
33+ a.download = filename
34+ a.click()
35+ URL.revokeObjectURL(a.href)
36+}