1import * as THREE from 'three';
2import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4/**
5 * Three.js scene wrapper: a single indexed triangle mesh with static
6 * per-vertex positions and dynamic per-vertex colors, orbit controls, and
7 * optional camera synchronization with sibling scenes.
8 *
9 * Rendering is on demand: the animation loop ticks every frame (it has to,
10 * to drive OrbitControls damping), but only re-renders when the colors,
11 * camera, or canvas size actually changed.
12 *
13 * Adapted from figpack's SphereEmbedding view (figpack_experimental).
14 */
15export class SphereScene {
16 #scene: THREE.Scene;
17 #camera: THREE.PerspectiveCamera;
18 #renderer: THREE.WebGLRenderer;
19 #controls: OrbitControls;
20 #geometry: THREE.BufferGeometry;
21 #mesh: THREE.Mesh;
22 #animationId: number | null = null;
23 #defaultCameraState: {
24 position: THREE.Vector3;
25 target: THREE.Vector3;
26 } | null = null;
27 #syncing = false;
28 #needsRender = true;
29 #lastW = -1;
30 #lastH = -1;
32 constructor(
33 container: HTMLElement,
34 numVertices: number,
35 indices: Uint32Array,
36 positions: Float32Array,
37 background = '#14161c',
38 ) {
39 this.#scene = new THREE.Scene();
40 this.#scene.background = new THREE.Color(background);
42 this.#camera = new THREE.PerspectiveCamera(50, 1, 0.01, 1000);
44 this.#renderer = new THREE.WebGLRenderer({ antialias: true });
45 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
46 // The canvas always fills its container via CSS; resize() then only
47 // updates the drawing buffer
48 this.#renderer.domElement.style.width = '100%';
49 this.#renderer.domElement.style.height = '100%';
50 this.#renderer.domElement.style.display = 'block';
51 container.appendChild(this.#renderer.domElement);
53 // Lighting: ambient plus a headlight attached to the camera so the
54 // surface stays lit from the viewing direction as it is rotated
55 this.#scene.add(new THREE.AmbientLight(0xffffff, 0.65));
56 const headlight = new THREE.DirectionalLight(0xffffff, 1.6);
57 headlight.position.set(0.5, 0.8, 1);
58 this.#camera.add(headlight);
59 this.#scene.add(this.#camera);
61 this.#geometry = new THREE.BufferGeometry();
62 const positionAttr = new THREE.BufferAttribute(positions, 3);
63 const colorAttr = new THREE.BufferAttribute(
64 new Float32Array(numVertices * 3),
65 3,
66 );
67 colorAttr.setUsage(THREE.DynamicDrawUsage);
68 this.#geometry.setAttribute('position', positionAttr);
69 this.#geometry.setAttribute('color', colorAttr);
70 this.#geometry.setIndex(new THREE.BufferAttribute(indices, 1));
71 this.#geometry.computeVertexNormals();
72 this.#geometry.computeBoundingSphere();
74 const material = new THREE.MeshPhongMaterial({
75 vertexColors: true,
76 side: THREE.DoubleSide,
77 shininess: 25,
78 specular: new THREE.Color(0x222222),
79 });
80 this.#mesh = new THREE.Mesh(this.#geometry, material);
81 this.#scene.add(this.#mesh);
83 this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement);
84 this.#controls.enableDamping = true;
85 this.#controls.dampingFactor = 0.1;
86 // Fires on user input and on every damping-tail update, so the flag stays
87 // set until the camera has fully settled.
88 this.#controls.addEventListener('change', () => {
89 this.#needsRender = true;
90 });
92 this.#animate();
93 }
95 #animate = () => {
96 this.#animationId = requestAnimationFrame(this.#animate);
97 this.#controls.update();
98 if (!this.#needsRender) return;
99 this.#needsRender = false;
100 this.#renderer.render(this.#scene, this.#camera);
101 };
103 updateColors(colors: Float32Array): void {
104 const attr = this.#geometry.getAttribute('color') as THREE.BufferAttribute;
105 (attr.array as Float32Array).set(colors);
106 attr.needsUpdate = true;
107 this.#needsRender = true;
108 }
110 /** Mirror this scene's camera whenever the other scene's controls move. */
111 syncCamerasWith(other: SphereScene): void {
112 const follow = (src: SphereScene, dst: SphereScene) => {
113 src.#controls.addEventListener('change', () => {
114 if (dst.#syncing) return;
115 src.#syncing = true;
116 dst.#camera.position.copy(src.#camera.position);
117 dst.#camera.zoom = src.#camera.zoom;
118 dst.#camera.updateProjectionMatrix();
119 dst.#controls.target.copy(src.#controls.target);
120 dst.#controls.update();
121 dst.#needsRender = true;
122 src.#syncing = false;
123 });
124 };
125 follow(this, other);
126 follow(other, this);
127 }
129 /** Camera pose, for carrying the view across a scene rebuild. */
130 cameraState(): { position: THREE.Vector3; target: THREE.Vector3; zoom: number } {
131 return {
132 position: this.#camera.position.clone(),
133 target: this.#controls.target.clone(),
134 zoom: this.#camera.zoom,
135 };
136 }
138 setCameraState(s: {
139 position: THREE.Vector3;
140 target: THREE.Vector3;
141 zoom: number;
142 }): void {
143 this.#camera.position.copy(s.position);
144 this.#camera.zoom = s.zoom;
145 this.#camera.updateProjectionMatrix();
146 this.#controls.target.copy(s.target);
147 this.#controls.update();
148 this.#needsRender = true;
149 }
151 /** Position the camera to comfortably frame the geometry. */
152 fitCamera(): void {
153 this.#geometry.computeBoundingSphere();
154 const bs = this.#geometry.boundingSphere;
155 if (!bs) return;
156 const radius = Math.max(bs.radius, 1e-6);
157 const distance = radius * 2.6;
158 this.#controls.target.copy(bs.center);
159 this.#camera.position.set(
160 bs.center.x + distance * 0.55,
161 bs.center.y + distance * 0.35,
162 bs.center.z + distance * 0.75,
163 );
164 this.#camera.near = radius * 0.01;
165 this.#camera.far = radius * 100;
166 this.#camera.updateProjectionMatrix();
167 this.#controls.update();
168 this.#needsRender = true;
169 this.#defaultCameraState = {
170 position: this.#camera.position.clone(),
171 target: this.#controls.target.clone(),
172 };
173 }
175 resetCamera(): void {
176 if (this.#defaultCameraState) {
177 this.#camera.position.copy(this.#defaultCameraState.position);
178 this.#controls.target.copy(this.#defaultCameraState.target);
179 this.#controls.update();
180 this.#needsRender = true;
181 } else {
182 this.fitCamera();
183 }
184 }
186 resize(width: number, height: number): void {
187 // Setting canvas.width clears the canvas even at the same value, which
188 // shows as a blank flash until the next render — skip no-op resizes.
189 if (width === this.#lastW && height === this.#lastH) return;
190 this.#lastW = width;
191 this.#lastH = height;
192 this.#camera.aspect = width / Math.max(1, height);
193 this.#camera.updateProjectionMatrix();
194 // updateStyle=false: the canvas keeps its 100%/100% CSS sizing
195 this.#renderer.setSize(width, height, false);
196 // setSize clears the drawing buffer, so a re-render is required even
197 // though nothing in the scene moved
198 this.#needsRender = true;
199 }
201 dispose(): void {
202 if (this.#animationId !== null) {
203 cancelAnimationFrame(this.#animationId);
204 this.#animationId = null;
205 }
206 this.#controls.dispose();
207 this.#geometry.dispose();
208 (this.#mesh.material as THREE.Material).dispose();
209 if (this.#renderer.domElement.parentNode) {
210 this.#renderer.domElement.parentNode.removeChild(
211 this.#renderer.domElement,
212 );
213 }
214 this.#renderer.dispose();
215 }
216}