1import { useEffect, useMemo } from 'react'
2import { Canvas, useFrame, useThree } from '@react-three/fiber'
3import { OrbitControls } from '@react-three/drei'
4import * as THREE from 'three'
5import { AnaglyphEffect } from 'three/addons/effects/AnaglyphEffect.js'
6import type { MeshData } from './mesh/types'
7import { VIEW_MODES } from './viewModes'
8import type { ViewMode } from './viewModes'
10const PLAIN_COLOR = '#8fb4d9'
12function MeshObject({ mesh, mode }: { mesh: MeshData; mode: ViewMode }) {
13 const geometry = useMemo(() => {
14 const g = new THREE.BufferGeometry()
15 g.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3))
16 g.setIndex(new THREE.Uint32BufferAttribute(mesh.indices, 1))
17 if (mesh.normals) {
18 g.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3))
19 } else {
20 g.computeVertexNormals()
21 }
22 if (mesh.colors) {
23 g.setAttribute('color', new THREE.Float32BufferAttribute(mesh.colors, 3))
24 }
25 // Center and scale to a consistent size so the fixed camera always frames it
26 g.center()
27 g.computeBoundingSphere()
28 return g
29 }, [mesh])
31 useEffect(() => () => geometry.dispose(), [geometry])
33 const scale = 1.6 / (geometry.boundingSphere?.radius || 1)
34 const useVertexColors = !!mesh.colors
35 // material settings are baked into the compiled shader; remount materials
36 // when they change so three.js rebuilds the program
37 const matKey = `${mode}-${useVertexColors ? 'vc' : 'plain'}`
39 return (
40 <group scale={scale}>
41 {(mode === 'shaded' || mode === 'both') && (
42 <mesh geometry={geometry}>
43 <meshStandardMaterial
44 key={matKey}
45 vertexColors={useVertexColors}
46 color={useVertexColors ? 'white' : PLAIN_COLOR}
47 roughness={0.55}
48 metalness={0.1}
49 side={THREE.DoubleSide}
50 polygonOffset={mode === 'both'}
51 polygonOffsetFactor={1}
52 polygonOffsetUnits={1}
53 />
54 </mesh>
55 )}
56 {(mode === 'wire' || mode === 'both') && (
57 <mesh geometry={geometry}>
58 <meshBasicMaterial
59 key={matKey}
60 wireframe
61 // over the shaded surface use thin dark lines; standalone
62 // wireframe keeps the mesh's own coloring
63 vertexColors={mode === 'wire' && useVertexColors}
64 color={mode === 'both' ? '#10161f' : useVertexColors ? 'white' : PLAIN_COLOR}
65 transparent={mode === 'both'}
66 opacity={mode === 'both' ? 0.35 : 1}
67 />
68 </mesh>
69 )}
70 {mode === 'points' && (
71 <points geometry={geometry}>
72 <pointsMaterial
73 key={matKey}
74 vertexColors={useVertexColors}
75 color={useVertexColors ? 'white' : PLAIN_COLOR}
76 size={0.02}
77 />
78 </points>
79 )}
80 </group>
81 )
82}
84// While mounted, replaces the normal render with three's AnaglyphEffect
85// (red/cyan stereo); a useFrame subscriber with priority > 0 suspends
86// react-three-fiber's own render loop
87function AnaglyphRenderer() {
88 const gl = useThree((s) => s.gl)
89 const size = useThree((s) => s.size)
90 const effect = useMemo(() => new AnaglyphEffect(gl), [gl])
91 useEffect(() => () => effect.dispose(), [effect])
92 useEffect(() => {
93 effect.setSize(size.width, size.height)
94 }, [effect, size])
95 useFrame(({ scene, camera, controls }) => {
96 // Zero parallax at the orbit target, eye separation proportional to the
97 // viewing distance, so stereo depth stays comfortable at any zoom
98 const target = (controls as unknown as { target?: THREE.Vector3 } | null)?.target
99 effect.planeDistance = target ? camera.position.distanceTo(target) : camera.position.length()
100 effect.eyeSep = effect.planeDistance * 0.02
101 effect.render(scene, camera)
102 }, 1)
103 return null
104}
106export function MeshView({
107 mesh,
108 mode,
109 onModeChange,
110 anaglyph,
111 onAnaglyphChange,
112}: {
113 mesh: MeshData
114 mode: ViewMode
115 onModeChange: (mode: ViewMode) => void
116 anaglyph: boolean
117 onAnaglyphChange: (on: boolean) => void
118}) {
119 return (
120 <>
121 <div className="view-toolbar">
122 {VIEW_MODES.map((m) => (
123 <button
124 key={m.id}
125 className={mode === m.id ? 'active' : ''}
126 onClick={() => onModeChange(m.id)}
127 >
128 {m.label}
129 </button>
130 ))}
131 <button
132 className={`sep ${anaglyph ? 'active' : ''}`}
133 onClick={() => onAnaglyphChange(!anaglyph)}
134 title="Anaglyph stereo — view with red/cyan 3D glasses"
135 >
136 3D
137 </button>
138 </div>
139 <Canvas camera={{ position: [2.6, 1.8, 2.6], fov: 45 }}>
140 <color attach="background" args={['#16181d']} />
141 <ambientLight intensity={0.5} />
142 <directionalLight position={[5, 8, 4]} intensity={1.6} />
143 <directionalLight position={[-4, -3, -6]} intensity={0.4} />
144 <MeshObject mesh={mesh} mode={mode} />
145 <OrbitControls makeDefault />
146 {anaglyph && <AnaglyphRenderer />}
147 </Canvas>
148 </>
149 )
150}