/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / src / render / SurfaceView.tsx
479 lines · 15.1 KBBlameHistoryRaw
1/**
2 * The rotatable 3D view (plain three.js, adapted from
3 * surfacefun-interactive's SurfView): shows the uploaded mesh until a
4 * solution arrives, then the solution colored by u with a colorbar.
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.
8 */
9import { useRef, useEffect, useState, type CSSProperties } from 'react'
10import * as THREE from 'three'
11import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
12import { AnaglyphEffect } from 'three/examples/jsm/effects/AnaglyphEffect.js'
13import type { SurfaceMeshData } from '../mesh/surfacemesh'
14import type { SolutionData } from '../engine/engine'
15import { colormapLookup, colormapGradient } from './colormap'
17export interface ViewContent {
18 mesh: SurfaceMeshData | null
19 solution: SolutionData | null
22type ViewMode = 'shaded' | 'wire' | 'both' | 'points'
24const 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' },
31interface SceneState {
32 renderer: THREE.WebGLRenderer
33 scene: THREE.Scene
34 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
39 controls: OrbitControls
40 animId: number
43// data (x,y,z) -> three (X=x, Y=z, Z=y), so data-z is "up" on screen
45function clearScene(scene: THREE.Scene) {
46 const toRemove: THREE.Object3D[] = []
47 scene.traverse((obj) => {
48 if (
49 obj instanceof THREE.Mesh ||
50 obj instanceof THREE.LineSegments ||
51 obj instanceof THREE.Points
52 )
53 toRemove.push(obj)
54 })
55 for (const obj of toRemove) {
56 scene.remove(obj)
57 ;(obj as THREE.Mesh).geometry?.dispose()
58 }
61/** Bounding box across a set of xyz-triple arrays. */
62function bounds(arrays: ArrayLike<number>[]): { center: [number, number, number]; range: number } {
63 const min = [Infinity, Infinity, Infinity]
64 const max = [-Infinity, -Infinity, -Infinity]
65 for (const a of arrays) {
66 for (let i = 0; i + 2 < a.length; i += 3) {
67 for (let d = 0; d < 3; d++) {
68 const v = a[i + d]
69 if (v < min[d]) min[d] = v
70 if (v > max[d]) max[d] = v
71 }
72 }
73 }
74 const range = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1
75 return {
76 center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
77 range,
78 }
81function normalizedPosition(
82 out: Float32Array,
83 outIdx: number,
84 xyz: [number, number, number],
85 center: [number, number, number],
86 range: number,
87) {
88 out[outIdx] = (xyz[0] - center[0]) / range
89 out[outIdx + 1] = (xyz[2] - center[2]) / range
90 out[outIdx + 2] = (xyz[1] - center[1]) / range
93/** Pixel-sized points that read on the white background. */
94function 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 })
103function buildMeshPreview(scene: THREE.Scene, mesh: SurfaceMeshData, mode: ViewMode) {
104 const { positions, cells, cellSize } = mesh
105 const { center, range } = bounds([positions])
106 const nVerts = positions.length / 3
108 const pos = new Float32Array(nVerts * 3)
109 for (let i = 0; i < nVerts; i++) {
110 normalizedPosition(
111 pos,
112 i * 3,
113 [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]],
114 center,
115 range,
116 )
117 }
118 const posAttr = new THREE.BufferAttribute(pos, 3)
119 const nc = cells.length / cellSize
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 }
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 }
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 }
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 */
180function 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
194/** Triangulation of one quad patch's column-major n-by-n grid. */
195function quadLattice(n: number): number[] {
196 const indices: number[] = []
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 }
205 }
206 return indices
209/** Unique edges of the triLattice(n) triangulation, as index pairs. */
210function 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
228/** Grid lines of an n-by-n patch (no triangulation diagonals), index pairs. */
229function 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)
235 }
236 }
237 return pairs
240function buildSolution(scene: THREE.Scene, sol: SolutionData, mode: ViewMode) {
241 const { n, x, y, z, u, umin, umax } = sol
242 const flat: number[] = []
243 for (let k = 0; k < sol.npatches; k++) {
244 for (let i = 0; i < x[k].length; i++) flat.push(x[k][i], y[k][i], z[k][i])
245 }
246 const { center, range } = bounds([flat])
247 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
253 for (let k = 0; k < sol.npatches; k++) {
254 const px = x[k]
255 const py = y[k]
256 const pz = z[k]
257 const pu = u[k]
258 const nv = px.length // n*n grid or n*(n+1)/2 triangle nodes
259 const pos = new Float32Array(nv * 3)
260 const col = new Float32Array(nv * 3)
261 for (let i = 0; i < nv; i++) {
262 normalizedPosition(pos, i * 3, [px[i], py[i], pz[i]], center, range)
263 const [r, g, b] = colormapLookup((pu[i] - umin) / cRange)
264 col[i * 3] = r
265 col[i * 3 + 1] = g
266 col[i * 3 + 2] = b
267 }
268 const posAttr = new THREE.BufferAttribute(pos, 3)
269 const colAttr = new THREE.BufferAttribute(col, 3)
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 }
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 }
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)))
312 }
313 }
316export function SurfaceView({ mesh, solution }: ViewContent) {
317 const containerRef = useRef<HTMLDivElement>(null)
318 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
324 // Set up the scene once
325 useEffect(() => {
326 const container = containerRef.current
327 if (!container) return
329 const renderer = new THREE.WebGLRenderer({ antialias: true })
330 renderer.setPixelRatio(window.devicePixelRatio)
331 renderer.setClearColor(0xffffff)
332 container.appendChild(renderer.domElement)
334 const scene = new THREE.Scene()
335 const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100)
336 camera.position.set(1.2, 0.8, 1.2)
337 camera.lookAt(0, 0, 0)
338 const persp = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
339 const effect = new AnaglyphEffect(renderer)
341 const controls = new OrbitControls(camera, renderer.domElement)
342 controls.enablePan = false
344 scene.add(new THREE.AmbientLight(0xffffff, 0.65))
345 const dirLight = new THREE.DirectionalLight(0xffffff, 1.6)
346 dirLight.position.set(2, 3, 2)
347 scene.add(dirLight)
349 const animId = requestAnimationFrame(function loop() {
350 controls.update()
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 }
371 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
372 })
373 stateRef.current = { renderer, scene, camera, persp, effect, controls, animId }
375 const observer = new ResizeObserver(() => {
376 const rect = container.getBoundingClientRect()
377 if (rect.width === 0 || rect.height === 0) return
378 renderer.setSize(rect.width, rect.height)
379 effect.setSize(rect.width, rect.height)
380 const aspect = rect.width / rect.height
381 const frustumSize = 0.85
382 camera.left = -frustumSize * aspect
383 camera.right = frustumSize * aspect
384 camera.top = frustumSize
385 camera.bottom = -frustumSize
386 camera.updateProjectionMatrix()
387 })
388 observer.observe(container)
390 return () => {
391 observer.disconnect()
392 cancelAnimationFrame(stateRef.current?.animId ?? animId)
393 controls.dispose()
394 effect.dispose()
395 renderer.dispose()
396 container.removeChild(renderer.domElement)
397 stateRef.current = null
398 }
399 }, [])
401 // Rebuild content when data or view mode changes
402 useEffect(() => {
403 const st = stateRef.current
404 if (!st) return
405 clearScene(st.scene)
406 if (solution) buildSolution(st.scene, solution, mode)
407 else if (mesh) buildMeshPreview(st.scene, mesh, mode)
408 }, [mesh, solution, mode])
410 return (
411 <div style={{ position: 'relative', width: '100%', height: '100%' }}>
412 <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 )}
433 {solution && <Colorbar min={solution.umin} max={solution.umax} />}
434 {!mesh && !solution && (
435 <div className="view-placeholder">Upload a surface mesh or load a sample to begin</div>
436 )}
437 </div>
438 )
441function Colorbar({ min, max }: { min: number; max: number }) {
442 const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toPrecision(3))
443 const style: CSSProperties = {
444 position: 'absolute',
445 top: 12,
446 bottom: 12,
447 right: 8,
448 width: 60,
449 display: 'flex',
450 alignItems: 'stretch',
451 pointerEvents: 'none',
452 fontSize: 11,
453 color: '#333',
454 }
455 return (
456 <div style={style}>
457 <div
458 style={{
459 width: 16,
460 height: '100%',
461 background: colormapGradient('to top'),
462 border: '1px solid #999',
463 boxSizing: 'border-box',
464 }}
465 />
466 <div
467 style={{
468 marginLeft: 4,
469 display: 'flex',
470 flexDirection: 'column',
471 justifyContent: 'space-between',
472 }}
473 >
474 <span>{fmt(max)}</span>
475 <span>{fmt(min)}</span>
476 </div>
477 </div>
478 )
moveopenescclose