/ concept-collection / mesh-studio
Sign in
concept-collection / mesh-studio
mesh-studio / src / render / SurfaceView.tsx
231 lines · 7.6 KBBlameHistoryRaw
1/**
2 * Plain-three.js interactive view of a SurfaceModel (adapted from
3 * mesh-pde-solver's SurfaceView): WebGLRenderer + OrbitControls + ResizeObserver
4 * with an imperative scene rebuilt whenever the model, view mode or selection
5 * changes. One three.js mesh per patch, so faces can be picked by raycasting.
6 */
7import { useEffect, useRef } from 'react'
8import * as THREE from 'three'
9import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
10import type { Patch, SurfaceModel } from '../model/types'
11import { controlNet, modelBounds } from '../model/tessellate'
12import { faceColor, ISO_COLOR, NET_COLOR, POLE_COLOR, SELECTED_COLOR } from './palette'
14export type ViewMode = 'shaded' | 'wire' | 'net' | 'iso'
16interface SceneState {
17 renderer: THREE.WebGLRenderer
18 scene: THREE.Scene
19 camera: THREE.PerspectiveCamera
20 controls: OrbitControls
21 content: THREE.Group
22 raycaster: THREE.Raycaster
23 animId: number
26function triGeometry(patch: Patch): THREE.BufferGeometry {
27 const g = new THREE.BufferGeometry()
28 g.setAttribute('position', new THREE.BufferAttribute(patch.tri.positions, 3))
29 if (patch.tri.normals.length === patch.tri.positions.length) {
30 g.setAttribute('normal', new THREE.BufferAttribute(patch.tri.normals, 3))
31 } else {
32 g.computeVertexNormals()
33 }
34 g.setIndex(new THREE.BufferAttribute(patch.tri.indices, 1))
35 return g
38function buildContent(
39 content: THREE.Group,
40 model: SurfaceModel,
41 mode: ViewMode,
42 selectedFaceId: number | null,
43) {
44 // dispose previous
45 content.traverse((obj) => {
46 const withGeom = obj as THREE.Mesh
47 withGeom.geometry?.dispose()
48 const mat = (obj as THREE.Mesh).material
49 if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
50 else mat?.dispose()
51 })
52 content.clear()
54 const { center, radius } = modelBounds(model)
55 content.scale.setScalar(1 / radius)
56 content.position.set(-center[0] / radius, -center[1] / radius, -center[2] / radius)
58 const facesFaint = mode === 'net' || mode === 'iso'
60 for (const patch of model.patches) {
61 const selected = patch.id === selectedFaceId
62 const color = selected ? SELECTED_COLOR : faceColor(patch.id)
63 const geom = triGeometry(patch)
65 let material: THREE.Material
66 if (mode === 'wire') {
67 material = new THREE.MeshBasicMaterial({ color, wireframe: true })
68 } else if (facesFaint) {
69 material = new THREE.MeshStandardMaterial({
70 color,
71 roughness: 0.7,
72 metalness: 0.0,
73 side: THREE.DoubleSide,
74 transparent: true,
75 opacity: selected ? 0.35 : 0.12,
76 })
77 } else {
78 material = new THREE.MeshStandardMaterial({
79 color,
80 roughness: 0.55,
81 metalness: 0.08,
82 side: THREE.DoubleSide,
83 emissive: selected ? SELECTED_COLOR : new THREE.Color(0, 0, 0),
84 emissiveIntensity: selected ? 0.35 : 0,
85 })
86 }
87 const mesh = new THREE.Mesh(geom, material)
88 mesh.userData.faceId = patch.id
89 content.add(mesh)
91 if (mode === 'net' && patch.kind === 'nurbs' && patch.nurbs) {
92 const { segments, points } = controlNet(patch.nurbs)
93 const segGeom = new THREE.BufferGeometry()
94 segGeom.setAttribute('position', new THREE.BufferAttribute(segments, 3))
95 content.add(
96 new THREE.LineSegments(
97 segGeom,
98 new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : NET_COLOR }),
99 ),
100 )
101 const ptGeom = new THREE.BufferGeometry()
102 ptGeom.setAttribute('position', new THREE.BufferAttribute(points, 3))
103 content.add(
104 new THREE.Points(
105 ptGeom,
106 new THREE.PointsMaterial({ color: POLE_COLOR, size: 0.03 * radius, sizeAttenuation: true }),
107 ),
108 )
109 }
111 if (mode === 'iso' && patch.kind === 'nurbs' && patch.isoLines) {
112 for (const line of patch.isoLines) {
113 const lineGeom = new THREE.BufferGeometry()
114 lineGeom.setAttribute('position', new THREE.BufferAttribute(line, 3))
115 content.add(
116 new THREE.Line(
117 lineGeom,
118 new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : ISO_COLOR }),
119 ),
120 )
121 }
122 }
123 }
126export function SurfaceView({
127 model,
128 mode,
129 selectedFaceId,
130 onSelectFace,
131}: {
132 model: SurfaceModel | null
133 mode: ViewMode
134 selectedFaceId: number | null
135 onSelectFace: (id: number | null) => void
136}) {
137 const containerRef = useRef<HTMLDivElement>(null)
138 const stateRef = useRef<SceneState | null>(null)
139 const onSelectRef = useRef(onSelectFace)
140 onSelectRef.current = onSelectFace
142 // set up the scene once
143 useEffect(() => {
144 const container = containerRef.current
145 if (!container) return
147 const renderer = new THREE.WebGLRenderer({ antialias: true })
148 renderer.setPixelRatio(window.devicePixelRatio)
149 renderer.setClearColor(0x161a22)
150 container.appendChild(renderer.domElement)
152 const scene = new THREE.Scene()
153 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
154 camera.position.set(2.4, 1.8, 2.6)
156 const controls = new OrbitControls(camera, renderer.domElement)
157 controls.enableDamping = true
159 scene.add(new THREE.AmbientLight(0xffffff, 0.55))
160 const key = new THREE.DirectionalLight(0xffffff, 1.5)
161 key.position.set(4, 6, 5)
162 scene.add(key)
163 const fill = new THREE.DirectionalLight(0xffffff, 0.4)
164 fill.position.set(-5, -3, -4)
165 scene.add(fill)
167 const content = new THREE.Group()
168 scene.add(content)
170 const raycaster = new THREE.Raycaster()
172 const animId = requestAnimationFrame(function loop() {
173 controls.update()
174 renderer.render(scene, camera)
175 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
176 })
177 stateRef.current = { renderer, scene, camera, controls, content, raycaster, animId }
179 const onPointerDown = (ev: PointerEvent) => {
180 const st = stateRef.current
181 if (!st) return
182 const rect = renderer.domElement.getBoundingClientRect()
183 const ndc = new THREE.Vector2(
184 ((ev.clientX - rect.left) / rect.width) * 2 - 1,
185 -((ev.clientY - rect.top) / rect.height) * 2 + 1,
186 )
187 st.raycaster.setFromCamera(ndc, st.camera)
188 const meshes = st.content.children.filter((c) => (c as THREE.Mesh).isMesh)
189 const hits = st.raycaster.intersectObjects(meshes, false)
190 const id = hits.length ? (hits[0].object.userData.faceId as number) : null
191 onSelectRef.current(id ?? null)
192 }
193 renderer.domElement.addEventListener('pointerdown', onPointerDown)
195 const observer = new ResizeObserver(() => {
196 const rect = container.getBoundingClientRect()
197 if (rect.width === 0 || rect.height === 0) return
198 renderer.setSize(rect.width, rect.height)
199 camera.aspect = rect.width / rect.height
200 camera.updateProjectionMatrix()
201 })
202 observer.observe(container)
204 return () => {
205 observer.disconnect()
206 renderer.domElement.removeEventListener('pointerdown', onPointerDown)
207 cancelAnimationFrame(stateRef.current?.animId ?? animId)
208 controls.dispose()
209 renderer.dispose()
210 container.removeChild(renderer.domElement)
211 stateRef.current = null
212 }
213 }, [])
215 // rebuild content when inputs change
216 useEffect(() => {
217 const st = stateRef.current
218 if (!st) return
219 if (model) buildContent(st.content, model, mode, selectedFaceId)
220 else {
221 st.content.clear()
222 }
223 }, [model, mode, selectedFaceId])
225 return (
226 <div style={{ position: 'relative', width: '100%', height: '100%' }}>
227 <div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
228 {!model && <div className="view-placeholder">Pick a primitive or open a STEP/IGES file</div>}
229 </div>
230 )
moveopenescclose