/ concept-collection / qhull-wasm-demo
Sign in
concept-collection / qhull-wasm-demo
qhull-wasm-demo / src / components / Hull3D.tsx
191 lines · 7.1 KBBlameHistoryRaw
1import { useEffect, useMemo, useRef, useState } from 'react'
2import {
3 Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel,
4 MenuItem, Select, Slider, Stack, Typography,
5} from '@mui/material'
6import * as THREE from 'three'
7import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
8import { getQhull } from '../qhull'
9import { points3D, type Dist3D } from '../points'
10import { trianglesToMsh, downloadText } from '../gmsh'
12export function Hull3D() {
13 const [dist, setDist] = useState<Dist3D>('gaussian')
14 const [n, setN] = useState(120)
15 const [seed, setSeed] = useState(1)
16 const [showPoints, setShowPoints] = useState(true)
17 const [wireframe, setWireframe] = useState(true)
18 const [spin, setSpin] = useState(true)
20 const pts = useMemo(() => points3D(n, dist, seed), [n, dist, seed])
21 // Hull facets are kept together with the points they were computed from, so
22 // facet indices never reference a stale point set while qhull recomputes.
23 const [data, setData] = useState<{ pts: number[][]; hull: number[][] }>({ pts: [], hull: [] })
25 useEffect(() => {
26 let cancelled = false
27 getQhull().then((q) => {
28 if (cancelled) return
29 try { setData({ pts, hull: q.convexHull(pts, 3).facets }) } catch { setData({ pts, hull: [] }) }
30 })
31 return () => { cancelled = true }
32 }, [pts])
34 const mountRef = useRef<HTMLDivElement>(null)
35 const sceneRef = useRef<{
36 renderer: THREE.WebGLRenderer
37 scene: THREE.Scene
38 camera: THREE.PerspectiveCamera
39 controls: OrbitControls
40 group: THREE.Group
41 } | null>(null)
42 const spinRef = useRef(spin)
43 spinRef.current = spin
45 // One-time scene setup.
46 useEffect(() => {
47 const mount = mountRef.current!
48 const w = mount.clientWidth, h = 480
49 const renderer = new THREE.WebGLRenderer({ antialias: true })
50 renderer.setPixelRatio(window.devicePixelRatio)
51 renderer.setSize(w, h)
52 mount.appendChild(renderer.domElement)
54 const scene = new THREE.Scene()
55 scene.background = new THREE.Color('#0f1722')
56 const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100)
57 camera.position.set(1.6, 1.2, 1.8)
59 const controls = new OrbitControls(camera, renderer.domElement)
60 controls.enableDamping = true
62 scene.add(new THREE.AmbientLight(0xffffff, 0.6))
63 const dir = new THREE.DirectionalLight(0xffffff, 0.8)
64 dir.position.set(2, 3, 4)
65 scene.add(dir)
67 const group = new THREE.Group()
68 scene.add(group)
70 sceneRef.current = { renderer, scene, camera, controls, group }
72 let raf = 0
73 const animate = () => {
74 raf = requestAnimationFrame(animate)
75 if (spinRef.current) group.rotation.y += 0.004
76 controls.update()
77 renderer.render(scene, camera)
78 }
79 animate()
81 const onResize = () => {
82 const nw = mount.clientWidth
83 camera.aspect = nw / h
84 camera.updateProjectionMatrix()
85 renderer.setSize(nw, h)
86 }
87 window.addEventListener('resize', onResize)
89 return () => {
90 cancelAnimationFrame(raf)
91 window.removeEventListener('resize', onResize)
92 controls.dispose()
93 renderer.dispose()
94 mount.removeChild(renderer.domElement)
95 sceneRef.current = null
96 }
97 }, [])
99 // Rebuild geometry when points / hull / toggles change.
100 useEffect(() => {
101 const s = sceneRef.current
102 if (!s) return
103 const { group } = s
104 group.traverse((o) => {
105 const any = o as Partial<THREE.Mesh>
106 any.geometry?.dispose()
107 const mat = any.material
108 if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
109 else mat?.dispose()
110 })
111 group.clear()
113 const { pts, hull } = data
114 // center & scale points to fit a unit-ish box
115 const flat = pts.flat()
116 const c = [0, 1, 2].map((k) => mean(pts.map((p) => p[k])))
117 const span = Math.max(1e-6, ...flat.map((v, i) => Math.abs(v - c[i % 3]))) * 2
118 const scale = 1.4 / span
119 const xf = (p: number[]) => new THREE.Vector3(
120 (p[0] - c[0]) * scale, (p[1] - c[1]) * scale, (p[2] - c[2]) * scale,
121 )
123 if (hull.length) {
124 const geo = new THREE.BufferGeometry()
125 const verts: number[] = []
126 for (const f of hull) for (const idx of f) {
127 const v = xf(pts[idx]); verts.push(v.x, v.y, v.z)
128 }
129 geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3))
130 geo.computeVertexNormals()
131 const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
132 color: 0x42a5f5, transparent: true, opacity: 0.55,
133 side: THREE.DoubleSide, flatShading: true,
134 }))
135 group.add(mesh)
136 if (wireframe) {
137 group.add(new THREE.LineSegments(
138 new THREE.WireframeGeometry(geo),
139 new THREE.LineBasicMaterial({ color: 0x90caf9, transparent: true, opacity: 0.5 }),
140 ))
141 }
142 }
144 if (showPoints) {
145 const pg = new THREE.BufferGeometry()
146 pg.setAttribute('position', new THREE.Float32BufferAttribute(
147 pts.flatMap((p) => { const v = xf(p); return [v.x, v.y, v.z] }), 3))
148 group.add(new THREE.Points(pg, new THREE.PointsMaterial({ color: 0xffd54f, size: 0.04 })))
149 }
150 }, [data, showPoints, wireframe])
152 return (
153 <Stack spacing={2}>
154 <Typography variant="body2" color="text.secondary">
155 Convex hull of a 3D point cloud, triangulated by qhull-wasm and rendered with three.js. Drag to rotate.
156 </Typography>
157 <Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" useFlexGap>
158 <FormControl size="small" sx={{ minWidth: 130 }}>
159 <InputLabel>Distribution</InputLabel>
160 <Select label="Distribution" value={dist} onChange={(e) => setDist(e.target.value as Dist3D)}>
161 <MenuItem value="gaussian">Gaussian blob</MenuItem>
162 <MenuItem value="uniform">Uniform cube</MenuItem>
163 <MenuItem value="sphere">Sphere surface</MenuItem>
164 </Select>
165 </FormControl>
166 <Box sx={{ width: 180 }}>
167 <Typography variant="caption">Points: {n}</Typography>
168 <Slider size="small" min={8} max={2000} value={n} onChange={(_, v) => setN(v as number)} />
169 </Box>
170 <Button size="small" variant="outlined" onClick={() => setSeed((x) => x + 1)}>Regenerate</Button>
171 <FormControlLabel control={<Checkbox size="small" checked={showPoints} onChange={(e) => setShowPoints(e.target.checked)} />} label="Points" />
172 <FormControlLabel control={<Checkbox size="small" checked={wireframe} onChange={(e) => setWireframe(e.target.checked)} />} label="Wireframe" />
173 <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>
182 </Stack>
183 <Box ref={mountRef} sx={{ width: '100%', borderRadius: 1, overflow: 'hidden', lineHeight: 0 }} />
184 <Typography variant="body2" color="text.secondary">
185 {pts.length} points → hull with {data.hull.length} triangular facets.
186 </Typography>
187 </Stack>
188 )
191function mean(a: number[]) { return a.reduce((s, x) => s + x, 0) / (a.length || 1) }
moveopenescclose