qhull-wasm demo: 2D/3D triangulation, convex hull, and Delaunay benchmarks
22 changed files+4980−0
.github/workflows/deploy.ymladded+51−0View file
@@ -0,0 +1,51 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches:
6+ - main
7+ workflow_dispatch:
8+
9+permissions:
10+ contents: read
11+ pages: write
12+ id-token: write
13+
14+concurrency:
15+ group: "pages"
16+ cancel-in-progress: false
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - name: Checkout
23+ uses: actions/checkout@v4
24+
25+ - name: Setup Node
26+ uses: actions/setup-node@v4
27+ with:
28+ node-version: '20'
29+ cache: 'npm'
30+
31+ - name: Install dependencies
32+ run: npm ci
33+
34+ - name: Build
35+ run: npm run build
36+
37+ - name: Upload artifact
38+ uses: actions/upload-pages-artifact@v3
39+ with:
40+ path: ./dist
41+
42+ deploy:
43+ environment:
44+ name: github-pages
45+ url: ${{ steps.deployment.outputs.page_url }}
46+ runs-on: ubuntu-latest
47+ needs: build
48+ steps:
49+ - name: Deploy to GitHub Pages
50+ id: deployment
51+ 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?
README.mdadded+28−0View file
@@ -0,0 +1,28 @@
1+# qhull-wasm-demo
2+
3+Interactive demos and benchmarks for
4+[qhull-wasm](https://github.com/magland/qhull-wasm) — [Qhull](http://www.qhull.org)
5+compiled to WebAssembly for computing convex hulls and Delaunay triangulations
6+in the browser.
7+
8+**Live:** https://concept-collection.github.io/qhull-wasm-demo/
9+
10+- **2D Triangulation** — Delaunay triangulation + convex hull of a point set;
11+ click to add points, switch distributions, toggle circumcircles.
12+- **3D Convex Hull** — triangulated hull of a 3D point cloud, rendered with
13+ three.js (drag to rotate).
14+- **Benchmarks** — times Delaunay triangulation in the browser, alongside a
15+ `.m` script that runs identically in MATLAB, Octave, and
16+ [numbl](https://numbl.org) (all triangulate via Qhull) for an apples-to-apples
17+ desktop-vs-browser comparison. See [scripts/qhull_benchmark.m](scripts/qhull_benchmark.m).
18+
19+## Develop
20+
21+```bash
22+npm install
23+npm run dev # local dev server
24+npm run build # production build -> dist/
25+```
26+
27+Deployed to GitHub Pages by [.github/workflows/deploy.yml](.github/workflows/deploy.yml)
28+on push to `main`.
eslint.config.jsadded+23−0View file
@@ -0,0 +1,23 @@
1+import js from '@eslint/js'
2+import globals from 'globals'
3+import reactHooks from 'eslint-plugin-react-hooks'
4+import reactRefresh from 'eslint-plugin-react-refresh'
5+import tseslint from 'typescript-eslint'
6+import { defineConfig, globalIgnores } from 'eslint/config'
7+
8+export default defineConfig([
9+ globalIgnores(['dist']),
10+ {
11+ files: ['**/*.{ts,tsx}'],
12+ extends: [
13+ js.configs.recommended,
14+ tseslint.configs.recommended,
15+ reactHooks.configs['recommended-latest'],
16+ reactRefresh.configs.vite,
17+ ],
18+ languageOptions: {
19+ ecmaVersion: 2020,
20+ globals: globals.browser,
21+ },
22+ },
23+])
index.htmladded+17−0View file
@@ -0,0 +1,17 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/qhull-logo.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <meta name="description" content="Interactive demos and benchmarks for qhull-wasm: convex hulls and Delaunay triangulations in the browser" />
8+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
11+ <title>qhull-wasm demo</title>
12+ </head>
13+ <body>
14+ <div id="root"></div>
15+ <script type="module" src="/src/main.tsx"></script>
16+ </body>
17+</html>
package-lock.jsonadded+4092−0View file
This diff is 4,097 lines long and is not shown.
package.jsonadded+37−0View file
@@ -0,0 +1,37 @@
1+{
2+ "name": "qhull-wasm-demo",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "eslint .",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "@emotion/react": "^11.14.0",
14+ "@emotion/styled": "^11.14.1",
15+ "@mui/icons-material": "^7.3.4",
16+ "@mui/material": "^7.3.4",
17+ "qhull-wasm": "^0.0.1",
18+ "react": "^19.1.1",
19+ "react-dom": "^19.1.1",
20+ "three": "^0.184.0"
21+ },
22+ "devDependencies": {
23+ "@eslint/js": "^9.36.0",
24+ "@types/node": "^24.6.0",
25+ "@types/react": "^19.1.16",
26+ "@types/react-dom": "^19.1.9",
27+ "@types/three": "^0.184.0",
28+ "@vitejs/plugin-react": "^5.0.4",
29+ "eslint": "^9.36.0",
30+ "eslint-plugin-react-hooks": "^5.2.0",
31+ "eslint-plugin-react-refresh": "^0.4.22",
32+ "globals": "^16.4.0",
33+ "typescript": "~5.9.3",
34+ "typescript-eslint": "^8.45.0",
35+ "vite": "^7.1.7"
36+ }
37+}
public/qhull-logo.svgadded+15−0View file
@@ -0,0 +1,15 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+ <rect width="64" height="64" rx="10" fill="#0d47a1"/>
3+ <g fill="none" stroke="#90caf9" stroke-width="2" stroke-linejoin="round">
4+ <polygon points="14,46 32,10 50,46"/>
5+ <path d="M14,46 L32,32 L50,46 M32,10 L32,32 M22,28 L42,28"/>
6+ </g>
7+ <g fill="#ffffff">
8+ <circle cx="32" cy="10" r="3"/>
9+ <circle cx="14" cy="46" r="3"/>
10+ <circle cx="50" cy="46" r="3"/>
11+ <circle cx="32" cy="32" r="2.5"/>
12+ <circle cx="22" cy="28" r="2.5"/>
13+ <circle cx="42" cy="28" r="2.5"/>
14+ </g>
15+</svg>
scripts/qhull_benchmark.madded+30−0View file
@@ -0,0 +1,30 @@
1+% qhull_benchmark.m
2+%
3+% Times Delaunay triangulation of uniform random points in 2D and 3D.
4+% MATLAB, Octave, and numbl all compute delaunayn via Qhull, so this is a
5+% direct, apples-to-apples comparison with the qhull-wasm benchmark running
6+% in the browser (https://concept-collection.github.io/qhull-wasm-demo/).
7+%
8+% Run with: matlab -batch qhull_benchmark | octave qhull_benchmark.m
9+% numbl run qhull_benchmark.m
10+%
11+% Note: native and browser runs use different random points, but the timing
12+% is dominated by N and dimension, not the specific sample, so totals compare.
13+
14+sizes = [1000 5000 20000 50000];
15+
16+fprintf('Qhull Delaunay benchmark\n');
17+fprintf('%-8s %12s %12s\n', 'N', '2D (ms)', '3D (ms)');
18+fprintf('%s\n', repmat('-', 1, 34));
19+
20+for k = 1:numel(sizes)
21+ n = sizes(k);
22+
23+ P2 = rand(n, 2);
24+ t = tic; delaunayn(P2); ms2 = toc(t) * 1000;
25+
26+ P3 = rand(n, 3);
27+ t = tic; delaunayn(P3); ms3 = toc(t) * 1000;
28+
29+ fprintf('%-8d %12.1f %12.1f\n', n, ms2, ms3);
30+end
src/App.tsxadded+60−0View file
@@ -0,0 +1,60 @@
1+import { useState } from 'react'
2+import {
3+ AppBar, Box, Container, createTheme, CssBaseline, Link, Paper, Tab, Tabs,
4+ ThemeProvider, Toolbar, Typography,
5+} from '@mui/material'
6+import { Delaunay2D } from './components/Delaunay2D'
7+import { Hull3D } from './components/Hull3D'
8+import { Benchmarks } from './components/Benchmarks'
9+
10+const theme = createTheme({
11+ palette: { mode: 'light', primary: { main: '#1565c0' } },
12+})
13+
14+function App() {
15+ const [tab, setTab] = useState(0)
16+ return (
17+ <ThemeProvider theme={theme}>
18+ <CssBaseline />
19+ <AppBar position="static" elevation={0}>
20+ <Toolbar>
21+ <Box component="img" src="/qhull-wasm-demo/qhull-logo.svg" alt="" sx={{ width: 32, height: 32, mr: 1.5 }} />
22+ <Typography variant="h6" sx={{ flexGrow: 1 }}>qhull-wasm</Typography>
23+ <Link href="https://github.com/magland/qhull-wasm" target="_blank" rel="noreferrer" color="inherit" underline="hover">
24+ GitHub
25+ </Link>
26+ </Toolbar>
27+ </AppBar>
28+
29+ <Container maxWidth="md" sx={{ py: 3 }}>
30+ <Typography variant="body1" sx={{ mb: 2 }}>
31+ <Link href="https://github.com/magland/qhull-wasm" target="_blank" rel="noreferrer">qhull-wasm</Link>{' '}
32+ is <Link href="http://www.qhull.org" target="_blank" rel="noreferrer">Qhull</Link> (the reentrant
33+ {' '}<code>libqhull_r</code>) compiled to WebAssembly, with a small JS API for convex hulls and
34+ Delaunay triangulations — the same engine MATLAB and Octave use, now running in the browser.
35+ </Typography>
36+
37+ <Paper variant="outlined" sx={{ mb: 2 }}>
38+ <Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto">
39+ <Tab label="2D Triangulation" />
40+ <Tab label="3D Convex Hull" />
41+ <Tab label="Benchmarks" />
42+ </Tabs>
43+ </Paper>
44+
45+ <Box sx={{ pb: 4 }}>
46+ {tab === 0 && <Delaunay2D />}
47+ {tab === 1 && <Hull3D />}
48+ {tab === 2 && <Benchmarks />}
49+ </Box>
50+
51+ <Typography variant="caption" color="text.secondary" component="div" sx={{ mt: 4 }}>
52+ qhull-wasm is MIT-licensed; it bundles Qhull (Qhull license). Part of the{' '}
53+ <Link href="https://github.com/concept-collection" target="_blank" rel="noreferrer">concept-collection</Link>.
54+ </Typography>
55+ </Container>
56+ </ThemeProvider>
57+ )
58+}
59+
60+export default App
src/components/Benchmarks.tsxadded+106−0View file
@@ -0,0 +1,106 @@
1+import { useState } from 'react'
2+import {
3+ Box, Button, Paper, Stack, Table, TableBody, TableCell, TableContainer,
4+ TableHead, TableRow, Typography, LinearProgress, Link,
5+} from '@mui/material'
6+import { getQhull } from '../qhull'
7+import { points2D, points3D } from '../points'
8+import benchScript from '../../scripts/qhull_benchmark.m?raw'
9+
10+const SIZES = [1000, 5000, 20000, 50000]
11+
12+interface Row { n: number; ms2: number; ms3: number; simp2: number; simp3: number }
13+
14+const yield_ = () => new Promise((r) => setTimeout(r, 0))
15+
16+export function Benchmarks() {
17+ const [rows, setRows] = useState<Row[]>([])
18+ const [running, setRunning] = useState(false)
19+ const [progress, setProgress] = useState(0)
20+
21+ const run = async () => {
22+ setRunning(true); setRows([]); setProgress(0)
23+ const q = await getQhull()
24+ const out: Row[] = []
25+ for (let i = 0; i < SIZES.length; i++) {
26+ const n = SIZES[i]
27+ await yield_()
28+ const p2 = points2D(n, 'uniform', 12345 + i)
29+ let t = performance.now()
30+ const r2 = q.delaunay(p2, 2)
31+ const ms2 = performance.now() - t
32+
33+ const p3 = points3D(n, 'uniform', 54321 + i)
34+ t = performance.now()
35+ const r3 = q.delaunay(p3, 3)
36+ const ms3 = performance.now() - t
37+
38+ out.push({ n, ms2, ms3, simp2: r2.facets.length, simp3: r3.facets.length })
39+ setRows([...out]); setProgress(((i + 1) / SIZES.length) * 100)
40+ }
41+ setRunning(false)
42+ }
43+
44+ const download = () => {
45+ const blob = new Blob([benchScript], { type: 'text/plain' })
46+ const a = document.createElement('a')
47+ a.href = URL.createObjectURL(blob)
48+ a.download = 'qhull_benchmark.m'
49+ a.click()
50+ URL.revokeObjectURL(a.href)
51+ }
52+
53+ return (
54+ <Stack spacing={2}>
55+ <Typography variant="body2" color="text.secondary">
56+ Delaunay triangulation of uniform random points, timed in your browser via qhull-wasm.
57+ Run the matching <code>.m</code> script below in MATLAB, Octave, or{' '}
58+ <Link href="https://numbl.org" target="_blank" rel="noreferrer">numbl</Link> for an
59+ apples-to-apples comparison (all three triangulate via Qhull).
60+ </Typography>
61+
62+ <Box>
63+ <Button variant="contained" onClick={run} disabled={running}>
64+ {running ? 'Running…' : 'Run benchmark'}
65+ </Button>
66+ </Box>
67+ {running && <LinearProgress variant="determinate" value={progress} />}
68+
69+ {rows.length > 0 && (
70+ <TableContainer component={Paper} variant="outlined" sx={{ maxWidth: 640 }}>
71+ <Table size="small">
72+ <TableHead>
73+ <TableRow>
74+ <TableCell>N</TableCell>
75+ <TableCell align="right">2D (ms)</TableCell>
76+ <TableCell align="right">2D simplices</TableCell>
77+ <TableCell align="right">3D (ms)</TableCell>
78+ <TableCell align="right">3D simplices</TableCell>
79+ </TableRow>
80+ </TableHead>
81+ <TableBody>
82+ {rows.map((r) => (
83+ <TableRow key={r.n}>
84+ <TableCell>{r.n.toLocaleString()}</TableCell>
85+ <TableCell align="right">{r.ms2.toFixed(1)}</TableCell>
86+ <TableCell align="right">{r.simp2.toLocaleString()}</TableCell>
87+ <TableCell align="right">{r.ms3.toFixed(1)}</TableCell>
88+ <TableCell align="right">{r.simp3.toLocaleString()}</TableCell>
89+ </TableRow>
90+ ))}
91+ </TableBody>
92+ </Table>
93+ </TableContainer>
94+ )}
95+
96+ <Stack direction="row" spacing={1} alignItems="center">
97+ <Typography variant="subtitle2">Comparison script (MATLAB / Octave / numbl)</Typography>
98+ <Button size="small" onClick={() => navigator.clipboard?.writeText(benchScript)}>Copy</Button>
99+ <Button size="small" onClick={download}>Download .m</Button>
100+ </Stack>
101+ <Paper variant="outlined" sx={{ p: 2, bgcolor: '#0f1722', overflow: 'auto' }}>
102+ <pre style={{ margin: 0, color: '#e0e0e0', fontSize: 13, lineHeight: 1.45 }}>{benchScript}</pre>
103+ </Paper>
104+ </Stack>
105+ )
106+}
src/components/Delaunay2D.tsxadded+137−0View file
@@ -0,0 +1,137 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+import {
3+ Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel,
4+ MenuItem, Select, Slider, Stack, Typography,
5+} from '@mui/material'
6+import { getQhull } from '../qhull'
7+import { points2D, type Dist2D } from '../points'
8+
9+const SIZE = 520
10+const PAD = 24
11+
12+export function Delaunay2D() {
13+ const [dist, setDist] = useState<Dist2D>('uniform')
14+ const [n, setN] = useState(60)
15+ const [seed, setSeed] = useState(1)
16+ const [extra, setExtra] = useState<number[][]>([])
17+ const [showTri, setShowTri] = useState(true)
18+ const [showHull, setShowHull] = useState(true)
19+ const [showCircles, setShowCircles] = useState(false)
20+
21+ const pts = useMemo(
22+ () => [...points2D(n, dist, seed), ...extra],
23+ [n, dist, seed, extra],
24+ )
25+
26+ const [tris, setTris] = useState<number[][]>([])
27+ const [hull, setHull] = useState<number[][]>([])
28+
29+ useEffect(() => {
30+ let cancelled = false
31+ getQhull().then((q) => {
32+ if (cancelled || pts.length < 3) {
33+ setTris([]); setHull([]); return
34+ }
35+ try {
36+ setTris(q.delaunay(pts, 2).facets)
37+ setHull(q.convexHull(pts, 2).facets)
38+ } catch {
39+ setTris([]); setHull([])
40+ }
41+ })
42+ return () => { cancelled = true }
43+ }, [pts])
44+
45+ // Map data coords (roughly [0,1]) to screen.
46+ const toScreen = useCallback((p: number[]) => [
47+ PAD + p[0] * (SIZE - 2 * PAD),
48+ SIZE - (PAD + p[1] * (SIZE - 2 * PAD)),
49+ ], [])
50+
51+ const svgRef = useRef<SVGSVGElement>(null)
52+ const addPoint = (e: React.MouseEvent) => {
53+ const svg = svgRef.current
54+ if (!svg) return
55+ const r = svg.getBoundingClientRect()
56+ const sx = ((e.clientX - r.left) / r.width) * SIZE
57+ const sy = ((e.clientY - r.top) / r.height) * SIZE
58+ const x = (sx - PAD) / (SIZE - 2 * PAD)
59+ const y = (SIZE - sy - PAD) / (SIZE - 2 * PAD)
60+ setExtra((cur) => [...cur, [x, y]])
61+ }
62+
63+ const circles = useMemo(
64+ () => (showCircles ? tris.map((t) => circumcircle(pts[t[0]], pts[t[1]], pts[t[2]])) : []),
65+ [showCircles, tris, pts],
66+ )
67+
68+ return (
69+ <Stack spacing={2}>
70+ <Typography variant="body2" color="text.secondary">
71+ Delaunay triangulation (blue) and convex hull (orange) computed by qhull-wasm.
72+ Click the canvas to add points.
73+ </Typography>
74+ <Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" useFlexGap>
75+ <FormControl size="small" sx={{ minWidth: 130 }}>
76+ <InputLabel>Distribution</InputLabel>
77+ <Select label="Distribution" value={dist} onChange={(e) => { setDist(e.target.value as Dist2D); setExtra([]) }}>
78+ <MenuItem value="uniform">Uniform</MenuItem>
79+ <MenuItem value="disk">Disk</MenuItem>
80+ <MenuItem value="gaussian">Gaussian</MenuItem>
81+ <MenuItem value="grid">Jittered grid</MenuItem>
82+ </Select>
83+ </FormControl>
84+ <Box sx={{ width: 180 }}>
85+ <Typography variant="caption">Points: {n}</Typography>
86+ <Slider size="small" min={4} max={400} value={n} onChange={(_, v) => setN(v as number)} />
87+ </Box>
88+ <Button size="small" variant="outlined" onClick={() => { setSeed((s) => s + 1); setExtra([]) }}>
89+ Regenerate
90+ </Button>
91+ <FormControlLabel control={<Checkbox size="small" checked={showTri} onChange={(e) => setShowTri(e.target.checked)} />} label="Triangulation" />
92+ <FormControlLabel control={<Checkbox size="small" checked={showHull} onChange={(e) => setShowHull(e.target.checked)} />} label="Hull" />
93+ <FormControlLabel control={<Checkbox size="small" checked={showCircles} onChange={(e) => setShowCircles(e.target.checked)} />} label="Circumcircles" />
94+ </Stack>
95+
96+ <Box sx={{ border: '1px solid #ddd', borderRadius: 1, width: 'fit-content', maxWidth: '100%' }}>
97+ <svg
98+ ref={svgRef}
99+ viewBox={`0 0 ${SIZE} ${SIZE}`}
100+ style={{ width: SIZE, maxWidth: '100%', height: 'auto', display: 'block', cursor: 'crosshair', background: '#fafafa' }}
101+ onClick={addPoint}
102+ >
103+ {showCircles && circles.map((c, i) => c && (
104+ <circle key={`c${i}`} cx={toScreen([c.x, c.y])[0]} cy={toScreen([c.x, c.y])[1]}
105+ r={c.r * (SIZE - 2 * PAD)} fill="none" stroke="#26a69a" strokeWidth={0.5} opacity={0.4} />
106+ ))}
107+ {showTri && tris.map((t, i) => {
108+ const a = toScreen(pts[t[0]]), b = toScreen(pts[t[1]]), c = toScreen(pts[t[2]])
109+ return <polygon key={`t${i}`} points={`${a[0]},${a[1]} ${b[0]},${b[1]} ${c[0]},${c[1]}`}
110+ fill="#1976d2" fillOpacity={0.07} stroke="#1976d2" strokeWidth={0.8} />
111+ })}
112+ {showHull && hull.map((e, i) => {
113+ const a = toScreen(pts[e[0]]), b = toScreen(pts[e[1]])
114+ return <line key={`h${i}`} x1={a[0]} y1={a[1]} x2={b[0]} y2={b[1]} stroke="#f57c00" strokeWidth={2.2} />
115+ })}
116+ {pts.map((p, i) => {
117+ const s = toScreen(p)
118+ return <circle key={`p${i}`} cx={s[0]} cy={s[1]} r={2.4} fill="#222" />
119+ })}
120+ </svg>
121+ </Box>
122+
123+ <Typography variant="body2" color="text.secondary">
124+ {pts.length} points → {tris.length} triangles, {hull.length} hull edges.
125+ </Typography>
126+ </Stack>
127+ )
128+}
129+
130+function circumcircle(a: number[], b: number[], c: number[]) {
131+ const ax = a[0], ay = a[1], bx = b[0], by = b[1], cx = c[0], cy = c[1]
132+ const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
133+ if (Math.abs(d) < 1e-12) return null
134+ const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
135+ const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
136+ return { x: ux, y: uy, r: Math.hypot(ax - ux, ay - uy) }
137+}
src/components/Hull3D.tsxadded+179−0View file
@@ -0,0 +1,179 @@
1+import { useEffect, useMemo, useRef, useState } from 'react'
2+import {
3+ Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel,
4+ MenuItem, Select, Slider, Stack, Typography,
5+} from '@mui/material'
6+import * as THREE from 'three'
7+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
8+import { getQhull } from '../qhull'
9+import { points3D, type Dist3D } from '../points'
10+
11+export function Hull3D() {
12+ const [dist, setDist] = useState<Dist3D>('gaussian')
13+ const [n, setN] = useState(120)
14+ const [seed, setSeed] = useState(1)
15+ const [showPoints, setShowPoints] = useState(true)
16+ const [wireframe, setWireframe] = useState(true)
17+ const [spin, setSpin] = useState(true)
18+
19+ const pts = useMemo(() => points3D(n, dist, seed), [n, dist, seed])
20+ const [hull, setHull] = useState<number[][]>([])
21+
22+ useEffect(() => {
23+ let cancelled = false
24+ getQhull().then((q) => {
25+ if (cancelled) return
26+ try { setHull(q.convexHull(pts, 3).facets) } catch { setHull([]) }
27+ })
28+ return () => { cancelled = true }
29+ }, [pts])
30+
31+ const mountRef = useRef<HTMLDivElement>(null)
32+ const sceneRef = useRef<{
33+ renderer: THREE.WebGLRenderer
34+ scene: THREE.Scene
35+ camera: THREE.PerspectiveCamera
36+ controls: OrbitControls
37+ group: THREE.Group
38+ } | null>(null)
39+ const spinRef = useRef(spin)
40+ spinRef.current = spin
41+
42+ // One-time scene setup.
43+ useEffect(() => {
44+ const mount = mountRef.current!
45+ const w = mount.clientWidth, h = 480
46+ const renderer = new THREE.WebGLRenderer({ antialias: true })
47+ renderer.setPixelRatio(window.devicePixelRatio)
48+ renderer.setSize(w, h)
49+ mount.appendChild(renderer.domElement)
50+
51+ const scene = new THREE.Scene()
52+ scene.background = new THREE.Color('#0f1722')
53+ const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100)
54+ camera.position.set(1.6, 1.2, 1.8)
55+
56+ const controls = new OrbitControls(camera, renderer.domElement)
57+ controls.enableDamping = true
58+
59+ scene.add(new THREE.AmbientLight(0xffffff, 0.6))
60+ const dir = new THREE.DirectionalLight(0xffffff, 0.8)
61+ dir.position.set(2, 3, 4)
62+ scene.add(dir)
63+
64+ const group = new THREE.Group()
65+ scene.add(group)
66+
67+ sceneRef.current = { renderer, scene, camera, controls, group }
68+
69+ let raf = 0
70+ const animate = () => {
71+ raf = requestAnimationFrame(animate)
72+ if (spinRef.current) group.rotation.y += 0.004
73+ controls.update()
74+ renderer.render(scene, camera)
75+ }
76+ animate()
77+
78+ const onResize = () => {
79+ const nw = mount.clientWidth
80+ camera.aspect = nw / h
81+ camera.updateProjectionMatrix()
82+ renderer.setSize(nw, h)
83+ }
84+ window.addEventListener('resize', onResize)
85+
86+ return () => {
87+ cancelAnimationFrame(raf)
88+ window.removeEventListener('resize', onResize)
89+ controls.dispose()
90+ renderer.dispose()
91+ mount.removeChild(renderer.domElement)
92+ sceneRef.current = null
93+ }
94+ }, [])
95+
96+ // Rebuild geometry when points / hull / toggles change.
97+ useEffect(() => {
98+ const s = sceneRef.current
99+ if (!s) return
100+ const { group } = s
101+ group.traverse((o) => {
102+ const any = o as Partial<THREE.Mesh>
103+ any.geometry?.dispose()
104+ const mat = any.material
105+ if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
106+ else mat?.dispose()
107+ })
108+ group.clear()
109+
110+ // center & scale points to fit a unit-ish box
111+ const flat = pts.flat()
112+ const c = [0, 1, 2].map((k) => mean(pts.map((p) => p[k])))
113+ const span = Math.max(1e-6, ...flat.map((v, i) => Math.abs(v - c[i % 3]))) * 2
114+ const scale = 1.4 / span
115+ const xf = (p: number[]) => new THREE.Vector3(
116+ (p[0] - c[0]) * scale, (p[1] - c[1]) * scale, (p[2] - c[2]) * scale,
117+ )
118+
119+ if (hull.length) {
120+ const geo = new THREE.BufferGeometry()
121+ const verts: number[] = []
122+ for (const f of hull) for (const idx of f) {
123+ const v = xf(pts[idx]); verts.push(v.x, v.y, v.z)
124+ }
125+ geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3))
126+ geo.computeVertexNormals()
127+ const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
128+ color: 0x42a5f5, transparent: true, opacity: 0.55,
129+ side: THREE.DoubleSide, flatShading: true,
130+ }))
131+ group.add(mesh)
132+ if (wireframe) {
133+ group.add(new THREE.LineSegments(
134+ new THREE.WireframeGeometry(geo),
135+ new THREE.LineBasicMaterial({ color: 0x90caf9, transparent: true, opacity: 0.5 }),
136+ ))
137+ }
138+ }
139+
140+ if (showPoints) {
141+ const pg = new THREE.BufferGeometry()
142+ pg.setAttribute('position', new THREE.Float32BufferAttribute(
143+ pts.flatMap((p) => { const v = xf(p); return [v.x, v.y, v.z] }), 3))
144+ group.add(new THREE.Points(pg, new THREE.PointsMaterial({ color: 0xffd54f, size: 0.04 })))
145+ }
146+ }, [pts, hull, showPoints, wireframe])
147+
148+ return (
149+ <Stack spacing={2}>
150+ <Typography variant="body2" color="text.secondary">
151+ Convex hull of a 3D point cloud, triangulated by qhull-wasm and rendered with three.js. Drag to rotate.
152+ </Typography>
153+ <Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" useFlexGap>
154+ <FormControl size="small" sx={{ minWidth: 130 }}>
155+ <InputLabel>Distribution</InputLabel>
156+ <Select label="Distribution" value={dist} onChange={(e) => setDist(e.target.value as Dist3D)}>
157+ <MenuItem value="gaussian">Gaussian blob</MenuItem>
158+ <MenuItem value="uniform">Uniform cube</MenuItem>
159+ <MenuItem value="sphere">Sphere surface</MenuItem>
160+ </Select>
161+ </FormControl>
162+ <Box sx={{ width: 180 }}>
163+ <Typography variant="caption">Points: {n}</Typography>
164+ <Slider size="small" min={8} max={2000} value={n} onChange={(_, v) => setN(v as number)} />
165+ </Box>
166+ <Button size="small" variant="outlined" onClick={() => setSeed((x) => x + 1)}>Regenerate</Button>
167+ <FormControlLabel control={<Checkbox size="small" checked={showPoints} onChange={(e) => setShowPoints(e.target.checked)} />} label="Points" />
168+ <FormControlLabel control={<Checkbox size="small" checked={wireframe} onChange={(e) => setWireframe(e.target.checked)} />} label="Wireframe" />
169+ <FormControlLabel control={<Checkbox size="small" checked={spin} onChange={(e) => setSpin(e.target.checked)} />} label="Spin" />
170+ </Stack>
171+ <Box ref={mountRef} sx={{ width: '100%', borderRadius: 1, overflow: 'hidden', lineHeight: 0 }} />
172+ <Typography variant="body2" color="text.secondary">
173+ {pts.length} points → hull with {hull.length} triangular facets.
174+ </Typography>
175+ </Stack>
176+ )
177+}
178+
179+function mean(a: number[]) { return a.reduce((s, x) => s + x, 0) / (a.length || 1) }
src/index.cssadded+13−0View file
@@ -0,0 +1,13 @@
1+:root {
2+ font-family: Roboto, system-ui, Avenir, Helvetica, Arial, sans-serif;
3+ line-height: 1.5;
4+}
5+
6+* {
7+ box-sizing: border-box;
8+}
9+
10+html, body, #root {
11+ margin: 0;
12+ padding: 0;
13+}
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/points.tsadded+70−0View file
@@ -0,0 +1,70 @@
1+// Point-set generators used by the demos and benchmarks.
2+
3+/** Deterministic PRNG so demos are reproducible across reloads. */
4+export function mulberry32(seed: number): () => number {
5+ let a = seed >>> 0
6+ return () => {
7+ a |= 0
8+ a = (a + 0x6d2b79f5) | 0
9+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
10+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
11+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
12+ }
13+}
14+
15+export type Dist2D = 'uniform' | 'disk' | 'gaussian' | 'grid'
16+export type Dist3D = 'uniform' | 'sphere' | 'gaussian'
17+
18+/** N 2D points in roughly the unit square, by distribution. */
19+export function points2D(n: number, dist: Dist2D, seed = 1): number[][] {
20+ const r = mulberry32(seed)
21+ const pts: number[][] = []
22+ if (dist === 'grid') {
23+ const side = Math.ceil(Math.sqrt(n))
24+ for (let i = 0; i < n; i++) {
25+ const gx = (i % side) / (side - 1 || 1)
26+ const gy = Math.floor(i / side) / (side - 1 || 1)
27+ const j = 0.15 / side
28+ pts.push([gx + (r() - 0.5) * j, gy + (r() - 0.5) * j])
29+ }
30+ return pts
31+ }
32+ for (let i = 0; i < n; i++) {
33+ if (dist === 'uniform') {
34+ pts.push([r(), r()])
35+ } else if (dist === 'disk') {
36+ const a = r() * 2 * Math.PI
37+ const rad = Math.sqrt(r()) * 0.5
38+ pts.push([0.5 + rad * Math.cos(a), 0.5 + rad * Math.sin(a)])
39+ } else {
40+ pts.push([0.5 + gaussian(r) * 0.15, 0.5 + gaussian(r) * 0.15])
41+ }
42+ }
43+ return pts
44+}
45+
46+/** N 3D points, by distribution. */
47+export function points3D(n: number, dist: Dist3D, seed = 1): number[][] {
48+ const r = mulberry32(seed)
49+ const pts: number[][] = []
50+ for (let i = 0; i < n; i++) {
51+ if (dist === 'uniform') {
52+ pts.push([r() - 0.5, r() - 0.5, r() - 0.5])
53+ } else if (dist === 'sphere') {
54+ // uniform on the unit sphere surface
55+ const u = r() * 2 - 1
56+ const phi = r() * 2 * Math.PI
57+ const s = Math.sqrt(1 - u * u)
58+ pts.push([s * Math.cos(phi) * 0.5, s * Math.sin(phi) * 0.5, u * 0.5])
59+ } else {
60+ pts.push([gaussian(r) * 0.2, gaussian(r) * 0.2, gaussian(r) * 0.2])
61+ }
62+ }
63+ return pts
64+}
65+
66+function gaussian(r: () => number): number {
67+ // Box-Muller
68+ const u = Math.max(r(), 1e-12)
69+ return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * r())
70+}
src/qhull.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+// Single shared instance of the qhull-wasm module.
2+//
3+// In a Vite bundle the .wasm cannot be located next to the glue at runtime, so
4+// we resolve its URL via Vite's `?url` import and feed it to Emscripten's
5+// `locateFile`.
6+import { loadQhull, type Qhull } from 'qhull-wasm'
7+import wasmUrl from 'qhull-wasm/dist/qhull.wasm?url'
8+
9+let qhullPromise: Promise<Qhull> | null = null
10+
11+export function getQhull(): Promise<Qhull> {
12+ if (!qhullPromise) {
13+ qhullPromise = loadQhull({ locateFile: () => wasmUrl })
14+ }
15+ return qhullPromise
16+}
17+
18+export type { Qhull }
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.app.jsonadded+28−0View file
@@ -0,0 +1,28 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "ES2022",
5+ "useDefineForClassFields": true,
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "module": "ESNext",
8+ "types": ["vite/client"],
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+ "strict": true,
21+ "noUnusedLocals": true,
22+ "noUnusedParameters": true,
23+ "erasableSyntaxOnly": true,
24+ "noFallthroughCasesInSwitch": true,
25+ "noUncheckedSideEffectImports": true
26+ },
27+ "include": ["src"]
28+}
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+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "ES2023",
5+ "lib": ["ES2023"],
6+ "module": "ESNext",
7+ "types": ["node"],
8+ "skipLibCheck": true,
9+
10+ /* Bundler mode */
11+ "moduleResolution": "bundler",
12+ "allowImportingTsExtensions": true,
13+ "verbatimModuleSyntax": true,
14+ "moduleDetection": "force",
15+ "noEmit": true,
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "erasableSyntaxOnly": true,
22+ "noFallthroughCasesInSwitch": true,
23+ "noUncheckedSideEffectImports": true
24+ },
25+ "include": ["vite.config.ts"]
26+}
vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
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: '/qhull-wasm-demo/',
8+})