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'
11export 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)
19 const pts = useMemo(() => points3D(n, dist, seed), [n, dist, seed])
20 const [hull, setHull] = useState<number[][]>([])
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])
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
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)
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)
56 const controls = new OrbitControls(camera, renderer.domElement)
57 controls.enableDamping = true
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)
64 const group = new THREE.Group()
65 scene.add(group)
67 sceneRef.current = { renderer, scene, camera, controls, group }
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()
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)
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 }, [])
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()
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 )
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 }
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])
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}
179function mean(a: number[]) { return a.reduce((s, x) => s + x, 0) / (a.length || 1) }