/ concept-collection / mesh-pde-solver
Sign in
concept-collection / mesh-pde-solver
mesh-pde-solver / src / render / SurfaceView.tsx
300 lines · 8.8 KBBlameHistoryRaw
1/**
2 * The rotatable 3D view (plain three.js, adapted from
3 * surfacefun-interactive's SurfView): shows the uploaded quad mesh until a
4 * solution arrives, then the solution colored by u with a colorbar.
5 * Drag to rotate, scroll to zoom.
6 */
7import { useRef, useEffect, type CSSProperties } from 'react'
8import * as THREE from 'three'
9import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
10import type { QuadMeshData } from '../mesh/quadmesh'
11import type { SolutionData } from '../engine/engine'
12import { colormapLookup, colormapGradient } from './colormap'
14export interface ViewContent {
15 mesh: QuadMeshData | null
16 solution: SolutionData | null
19interface SceneState {
20 renderer: THREE.WebGLRenderer
21 scene: THREE.Scene
22 camera: THREE.OrthographicCamera
23 controls: OrbitControls
24 animId: number
27// data (x,y,z) -> three (X=x, Y=z, Z=y), so data-z is "up" on screen
29function clearScene(scene: THREE.Scene) {
30 const toRemove: THREE.Object3D[] = []
31 scene.traverse((obj) => {
32 if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments) toRemove.push(obj)
33 })
34 for (const obj of toRemove) {
35 scene.remove(obj)
36 ;(obj as THREE.Mesh).geometry?.dispose()
37 }
40/** Bounding box across a set of xyz-triple arrays. */
41function bounds(arrays: ArrayLike<number>[]): { center: [number, number, number]; range: number } {
42 const min = [Infinity, Infinity, Infinity]
43 const max = [-Infinity, -Infinity, -Infinity]
44 for (const a of arrays) {
45 for (let i = 0; i + 2 < a.length; i += 3) {
46 for (let d = 0; d < 3; d++) {
47 const v = a[i + d]
48 if (v < min[d]) min[d] = v
49 if (v > max[d]) max[d] = v
50 }
51 }
52 }
53 const range = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1
54 return {
55 center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
56 range,
57 }
60function normalizedPosition(
61 out: Float32Array,
62 outIdx: number,
63 xyz: [number, number, number],
64 center: [number, number, number],
65 range: number,
66) {
67 out[outIdx] = (xyz[0] - center[0]) / range
68 out[outIdx + 1] = (xyz[2] - center[2]) / range
69 out[outIdx + 2] = (xyz[1] - center[1]) / range
72function buildMeshPreview(scene: THREE.Scene, mesh: QuadMeshData) {
73 const { positions, quads } = mesh
74 const { center, range } = bounds([positions])
75 const nVerts = positions.length / 3
77 const pos = new Float32Array(nVerts * 3)
78 for (let i = 0; i < nVerts; i++) {
79 normalizedPosition(
80 pos,
81 i * 3,
82 [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]],
83 center,
84 range,
85 )
86 }
88 const indices: number[] = []
89 const nq = quads.length / 4
90 for (let k = 0; k < nq; k++) {
91 const [a, b, c, d] = [quads[k * 4], quads[k * 4 + 1], quads[k * 4 + 2], quads[k * 4 + 3]]
92 indices.push(a, b, c, a, c, d)
93 }
95 const geometry = new THREE.BufferGeometry()
96 geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3))
97 geometry.setIndex(indices)
98 geometry.computeVertexNormals()
99 scene.add(
100 new THREE.Mesh(
101 geometry,
102 new THREE.MeshPhongMaterial({
103 color: 0xb8bec9,
104 flatShading: true,
105 side: THREE.DoubleSide,
106 }),
107 ),
108 )
110 // quad edges
111 const edgePositions: number[] = []
112 for (let k = 0; k < nq; k++) {
113 for (let e = 0; e < 4; e++) {
114 const a = quads[k * 4 + e]
115 const b = quads[k * 4 + ((e + 1) % 4)]
116 edgePositions.push(
117 pos[a * 3], pos[a * 3 + 1], pos[a * 3 + 2],
118 pos[b * 3], pos[b * 3 + 1], pos[b * 3 + 2],
119 )
120 }
121 }
122 const edgeGeometry = new THREE.BufferGeometry()
123 edgeGeometry.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3))
124 scene.add(
125 new THREE.LineSegments(
126 edgeGeometry,
127 new THREE.LineBasicMaterial({ color: 0x000000, opacity: 0.35, transparent: true }),
128 ),
129 )
132function buildSolution(scene: THREE.Scene, sol: SolutionData) {
133 const { n, x, y, z, u, umin, umax } = sol
134 const flat: number[] = []
135 for (let k = 0; k < sol.npatches; k++) {
136 for (let i = 0; i < x[k].length; i++) flat.push(x[k][i], y[k][i], z[k][i])
137 }
138 const { center, range } = bounds([flat])
139 const cRange = umax - umin || 1
141 for (let k = 0; k < sol.npatches; k++) {
142 const px = x[k]
143 const py = y[k]
144 const pz = z[k]
145 const pu = u[k]
146 const nv = px.length // n*n grid, column-major
147 const pos = new Float32Array(nv * 3)
148 const col = new Float32Array(nv * 3)
149 for (let i = 0; i < nv; i++) {
150 normalizedPosition(pos, i * 3, [px[i], py[i], pz[i]], center, range)
151 const [r, g, b] = colormapLookup((pu[i] - umin) / cRange)
152 col[i * 3] = r
153 col[i * 3 + 1] = g
154 col[i * 3 + 2] = b
155 }
156 const indices: number[] = []
157 for (let j = 0; j < n - 1; j++) {
158 for (let i = 0; i < n - 1; i++) {
159 const a = j * n + i
160 const b = j * n + i + 1
161 const c = (j + 1) * n + i
162 const d = (j + 1) * n + i + 1
163 indices.push(a, b, c, b, d, c)
164 }
165 }
166 const geometry = new THREE.BufferGeometry()
167 geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3))
168 geometry.setAttribute('color', new THREE.BufferAttribute(col, 3))
169 geometry.setIndex(indices)
170 geometry.computeVertexNormals()
171 scene.add(
172 new THREE.Mesh(
173 geometry,
174 new THREE.MeshPhongMaterial({
175 vertexColors: true,
176 side: THREE.DoubleSide,
177 shininess: 10,
178 }),
179 ),
180 )
181 }
184export function SurfaceView({ mesh, solution }: ViewContent) {
185 const containerRef = useRef<HTMLDivElement>(null)
186 const stateRef = useRef<SceneState | null>(null)
188 // Set up the scene once
189 useEffect(() => {
190 const container = containerRef.current
191 if (!container) return
193 const renderer = new THREE.WebGLRenderer({ antialias: true })
194 renderer.setPixelRatio(window.devicePixelRatio)
195 renderer.setClearColor(0xffffff)
196 container.appendChild(renderer.domElement)
198 const scene = new THREE.Scene()
199 const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100)
200 camera.position.set(1.2, 0.8, 1.2)
201 camera.lookAt(0, 0, 0)
203 const controls = new OrbitControls(camera, renderer.domElement)
204 controls.enablePan = false
206 scene.add(new THREE.AmbientLight(0xffffff, 0.65))
207 const dirLight = new THREE.DirectionalLight(0xffffff, 1.6)
208 dirLight.position.set(2, 3, 2)
209 scene.add(dirLight)
211 const animId = requestAnimationFrame(function loop() {
212 controls.update()
213 renderer.render(scene, camera)
214 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
215 })
216 stateRef.current = { renderer, scene, camera, controls, animId }
218 const observer = new ResizeObserver(() => {
219 const rect = container.getBoundingClientRect()
220 if (rect.width === 0 || rect.height === 0) return
221 renderer.setSize(rect.width, rect.height)
222 const aspect = rect.width / rect.height
223 const frustumSize = 0.85
224 camera.left = -frustumSize * aspect
225 camera.right = frustumSize * aspect
226 camera.top = frustumSize
227 camera.bottom = -frustumSize
228 camera.updateProjectionMatrix()
229 })
230 observer.observe(container)
232 return () => {
233 observer.disconnect()
234 cancelAnimationFrame(stateRef.current?.animId ?? animId)
235 controls.dispose()
236 renderer.dispose()
237 container.removeChild(renderer.domElement)
238 stateRef.current = null
239 }
240 }, [])
242 // Rebuild content when data changes
243 useEffect(() => {
244 const st = stateRef.current
245 if (!st) return
246 clearScene(st.scene)
247 if (solution) buildSolution(st.scene, solution)
248 else if (mesh) buildMeshPreview(st.scene, mesh)
249 }, [mesh, solution])
251 return (
252 <div style={{ position: 'relative', width: '100%', height: '100%' }}>
253 <div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
254 {solution && <Colorbar min={solution.umin} max={solution.umax} />}
255 {!mesh && !solution && (
256 <div className="view-placeholder">Upload a quad mesh or load a sample to begin</div>
257 )}
258 </div>
259 )
262function Colorbar({ min, max }: { min: number; max: number }) {
263 const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toPrecision(3))
264 const style: CSSProperties = {
265 position: 'absolute',
266 top: 12,
267 bottom: 12,
268 right: 8,
269 width: 60,
270 display: 'flex',
271 alignItems: 'stretch',
272 pointerEvents: 'none',
273 fontSize: 11,
274 color: '#333',
275 }
276 return (
277 <div style={style}>
278 <div
279 style={{
280 width: 16,
281 height: '100%',
282 background: colormapGradient('to top'),
283 border: '1px solid #999',
284 boxSizing: 'border-box',
285 }}
286 />
287 <div
288 style={{
289 marginLeft: 4,
290 display: 'flex',
291 flexDirection: 'column',
292 justifyContent: 'space-between',
293 }}
294 >
295 <span>{fmt(max)}</span>
296 <span>{fmt(min)}</span>
297 </div>
298 </div>
299 )
moveopenescclose