/ concept-collection / mesh-studio
Sign in
concept-collection / mesh-studio
mesh-studio / src / render / SurfaceView.tsx
251 lines · 8.4 KBCodeBlameHistory
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'
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 10import { AnaglyphEffect } from 'three/examples/jsm/effects/AnaglyphEffect.js'
8a92adcInitial commit: mesh-studio POC (OpenCASCADE.js + three.js, NURBS extraction)Jeremy Magland 11import type { Patch, SurfaceModel } from '../model/types'
12import { controlNet, modelBounds } from '../model/tessellate'
13import { faceColor, ISO_COLOR, NET_COLOR, POLE_COLOR, SELECTED_COLOR } from './palette'
15export type ViewMode = 'shaded' | 'wire' | 'net' | 'iso'
17interface SceneState {
18 renderer: THREE.WebGLRenderer
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 19 effect: AnaglyphEffect
21 camera: THREE.PerspectiveCamera
22 controls: OrbitControls
23 content: THREE.Group
24 raycaster: THREE.Raycaster
25 animId: number
28function triGeometry(patch: Patch): THREE.BufferGeometry {
29 const g = new THREE.BufferGeometry()
30 g.setAttribute('position', new THREE.BufferAttribute(patch.tri.positions, 3))
31 if (patch.tri.normals.length === patch.tri.positions.length) {
32 g.setAttribute('normal', new THREE.BufferAttribute(patch.tri.normals, 3))
33 } else {
34 g.computeVertexNormals()
35 }
36 g.setIndex(new THREE.BufferAttribute(patch.tri.indices, 1))
37 return g
40function buildContent(
41 content: THREE.Group,
42 model: SurfaceModel,
43 mode: ViewMode,
44 selectedFaceId: number | null,
45) {
46 // dispose previous
47 content.traverse((obj) => {
48 const withGeom = obj as THREE.Mesh
49 withGeom.geometry?.dispose()
50 const mat = (obj as THREE.Mesh).material
51 if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
52 else mat?.dispose()
53 })
54 content.clear()
56 const { center, radius } = modelBounds(model)
57 content.scale.setScalar(1 / radius)
58 content.position.set(-center[0] / radius, -center[1] / radius, -center[2] / radius)
60 const facesFaint = mode === 'net' || mode === 'iso'
62 for (const patch of model.patches) {
63 const selected = patch.id === selectedFaceId
64 const color = selected ? SELECTED_COLOR : faceColor(patch.id)
65 const geom = triGeometry(patch)
67 let material: THREE.Material
68 if (mode === 'wire') {
69 material = new THREE.MeshBasicMaterial({ color, wireframe: true })
70 } else if (facesFaint) {
71 material = new THREE.MeshStandardMaterial({
72 color,
73 roughness: 0.7,
74 metalness: 0.0,
75 side: THREE.DoubleSide,
76 transparent: true,
77 opacity: selected ? 0.35 : 0.12,
78 })
79 } else {
80 material = new THREE.MeshStandardMaterial({
81 color,
82 roughness: 0.55,
83 metalness: 0.08,
84 side: THREE.DoubleSide,
85 emissive: selected ? SELECTED_COLOR : new THREE.Color(0, 0, 0),
86 emissiveIntensity: selected ? 0.35 : 0,
87 })
88 }
89 const mesh = new THREE.Mesh(geom, material)
90 mesh.userData.faceId = patch.id
91 content.add(mesh)
93 if (mode === 'net' && patch.kind === 'nurbs' && patch.nurbs) {
94 const { segments, points } = controlNet(patch.nurbs)
95 const segGeom = new THREE.BufferGeometry()
96 segGeom.setAttribute('position', new THREE.BufferAttribute(segments, 3))
97 content.add(
98 new THREE.LineSegments(
99 segGeom,
100 new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : NET_COLOR }),
101 ),
102 )
103 const ptGeom = new THREE.BufferGeometry()
104 ptGeom.setAttribute('position', new THREE.BufferAttribute(points, 3))
105 content.add(
106 new THREE.Points(
107 ptGeom,
108 new THREE.PointsMaterial({ color: POLE_COLOR, size: 0.03 * radius, sizeAttenuation: true }),
109 ),
110 )
111 }
113 if (mode === 'iso' && patch.kind === 'nurbs' && patch.isoLines) {
114 for (const line of patch.isoLines) {
115 const lineGeom = new THREE.BufferGeometry()
116 lineGeom.setAttribute('position', new THREE.BufferAttribute(line, 3))
117 content.add(
118 new THREE.Line(
119 lineGeom,
120 new THREE.LineBasicMaterial({ color: selected ? SELECTED_COLOR : ISO_COLOR }),
121 ),
122 )
123 }
124 }
125 }
128export function SurfaceView({
129 model,
130 mode,
133 onSelectFace,
134}: {
135 model: SurfaceModel | null
136 mode: ViewMode
139 onSelectFace: (id: number | null) => void
140}) {
141 const containerRef = useRef<HTMLDivElement>(null)
142 const stateRef = useRef<SceneState | null>(null)
143 const onSelectRef = useRef(onSelectFace)
144 onSelectRef.current = onSelectFace
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 145 const anaglyphRef = useRef(anaglyph)
146 anaglyphRef.current = anaglyph
148 // set up the scene once
149 useEffect(() => {
150 const container = containerRef.current
151 if (!container) return
153 const renderer = new THREE.WebGLRenderer({ antialias: true })
154 renderer.setPixelRatio(window.devicePixelRatio)
155 renderer.setClearColor(0x161a22)
156 container.appendChild(renderer.domElement)
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 158 const effect = new AnaglyphEffect(renderer)
8a92adcInitial commit: mesh-studio POC (OpenCASCADE.js + three.js, NURBS extraction)Jeremy Magland 160 const scene = new THREE.Scene()
161 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
162 camera.position.set(2.4, 1.8, 2.6)
164 const controls = new OrbitControls(camera, renderer.domElement)
165 controls.enableDamping = true
167 scene.add(new THREE.AmbientLight(0xffffff, 0.55))
168 const key = new THREE.DirectionalLight(0xffffff, 1.5)
169 key.position.set(4, 6, 5)
170 scene.add(key)
171 const fill = new THREE.DirectionalLight(0xffffff, 0.4)
172 fill.position.set(-5, -3, -4)
173 scene.add(fill)
175 const content = new THREE.Group()
176 scene.add(content)
178 const raycaster = new THREE.Raycaster()
180 const animId = requestAnimationFrame(function loop() {
181 controls.update()
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 182 if (anaglyphRef.current) {
183 // converge the stereo eyes on the orbit target and scale the eye
184 // separation with viewing distance (the defaults — planeDistance 0.5,
185 // eyeSep 0.064 — assume a much closer scene and double the image)
186 const dist = camera.position.distanceTo(controls.target)
187 effect.planeDistance = dist
188 effect.eyeSep = dist / 30
189 effect.render(scene, camera)
190 } else {
191 renderer.render(scene, camera)
192 }
8a92adcInitial commit: mesh-studio POC (OpenCASCADE.js + three.js, NURBS extraction)Jeremy Magland 193 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
194 })
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 195 stateRef.current = { renderer, effect, scene, camera, controls, content, raycaster, animId }
197 const onPointerDown = (ev: PointerEvent) => {
198 const st = stateRef.current
199 if (!st) return
200 const rect = renderer.domElement.getBoundingClientRect()
201 const ndc = new THREE.Vector2(
202 ((ev.clientX - rect.left) / rect.width) * 2 - 1,
203 -((ev.clientY - rect.top) / rect.height) * 2 + 1,
204 )
205 st.raycaster.setFromCamera(ndc, st.camera)
206 const meshes = st.content.children.filter((c) => (c as THREE.Mesh).isMesh)
207 const hits = st.raycaster.intersectObjects(meshes, false)
208 const id = hits.length ? (hits[0].object.userData.faceId as number) : null
209 onSelectRef.current(id ?? null)
210 }
211 renderer.domElement.addEventListener('pointerdown', onPointerDown)
213 const observer = new ResizeObserver(() => {
214 const rect = container.getBoundingClientRect()
215 if (rect.width === 0 || rect.height === 0) return
7f0745dMove view controls into viewport overlay; add anaglyph 3D toggleJeremy Magland 216 // effect.setSize sizes the renderer plus the stereo render targets
217 effect.setSize(rect.width, rect.height)
8a92adcInitial commit: mesh-studio POC (OpenCASCADE.js + three.js, NURBS extraction)Jeremy Magland 218 camera.aspect = rect.width / rect.height
219 camera.updateProjectionMatrix()
220 })
221 observer.observe(container)
223 return () => {
224 observer.disconnect()
225 renderer.domElement.removeEventListener('pointerdown', onPointerDown)
226 cancelAnimationFrame(stateRef.current?.animId ?? animId)
227 controls.dispose()
230 container.removeChild(renderer.domElement)
231 stateRef.current = null
232 }
233 }, [])
235 // rebuild content when inputs change
236 useEffect(() => {
237 const st = stateRef.current
238 if (!st) return
239 if (model) buildContent(st.content, model, mode, selectedFaceId)
240 else {
241 st.content.clear()
242 }
243 }, [model, mode, selectedFaceId])
245 return (
246 <div style={{ position: 'relative', width: '100%', height: '100%' }}>
247 <div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
248 {!model && <div className="view-placeholder">Pick a primitive or open a STEP/IGES file</div>}
249 </div>
250 )
moveopenescclose