aboutsummaryrefslogtreecommitdiff
path: root/src/scene/primitives.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/scene/primitives.ts90
1 files changed, 90 insertions, 0 deletions
diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts
new file mode 100644
index 0000000..bce9113
--- /dev/null
+++ b/src/scene/primitives.ts
@@ -0,0 +1,90 @@
+import * as THREE from 'three';
+import type { Point } from '../physics/electricField';
+
+export const colours = { positive: '#ff967b', negative: '#73d7ff', field: '#91e2cb', gold: '#f5d58b', muted: '#8294a8' };
+export const vector = (p: Point) => new THREE.Vector3(p.x, p.y, p.z);
+export function sphere(group: THREE.Group, position: Point, radius: number, colour: string): THREE.Mesh {
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), new THREE.MeshStandardMaterial({ color: colour, roughness: 0.3, emissive: colour, emissiveIntensity: 0.12 }));
+ mesh.position.copy(vector(position));
+ group.add(mesh);
+ return mesh;
+}
+export function box(group: THREE.Group, position: Point, dimensions: Point, colour: string, opacity = 1): THREE.Mesh {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(dimensions.x, dimensions.y, dimensions.z), new THREE.MeshStandardMaterial({ color: colour, transparent: opacity < 1, opacity, depthWrite: opacity === 1, side: THREE.DoubleSide, roughness: 0.45 }));
+ mesh.position.copy(vector(position));
+ group.add(mesh);
+ return mesh;
+}
+export function line(group: THREE.Group, points: Point[], colour: string, opacity = 1): THREE.Line {
+ const mesh = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points.map(vector)), new THREE.LineBasicMaterial({ color: colour, transparent: opacity < 1, opacity }));
+ group.add(mesh);
+ return mesh;
+}
+export function arrow(group: THREE.Group, origin: Point, direction: Point, length: number, colour = colours.field): void {
+ const dir = vector(direction);
+ if (dir.lengthSq() < 1e-24) return;
+ group.add(new THREE.ArrowHelper(dir.normalize(), vector(origin), length, colour, Math.min(0.16, length * 0.3), Math.min(0.1, length * 0.2)));
+}
+export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2): THREE.Sprite {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d')!;
+ context.font = '500 44px system-ui, sans-serif';
+ canvas.width = Math.ceil(context.measureText(text).width) + 36;
+ canvas.height = 80;
+ context.fillStyle = 'rgba(16, 24, 34, 0.85)';
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ context.font = '500 44px system-ui, sans-serif';
+ context.fillStyle = colour;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(text, canvas.width / 2, 40);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false }));
+ sprite.position.copy(vector(position));
+ const aspect = canvas.width / canvas.height;
+ const screenHeight = 23 + Math.min(2, Math.max(-1, width - 2)) * 2;
+ const viewportSize = new THREE.Vector2();
+ const cameraPosition = new THREE.Vector3();
+ // Keep labels legible when the camera or viewport changes. Width hints
+ // affect text size slightly; the actual texture fits the text content.
+ sprite.onBeforeRender = (renderer, _scene, camera) => {
+ if (!(camera instanceof THREE.PerspectiveCamera)) return;
+ sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse);
+ renderer.getSize(viewportSize);
+ const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * screenHeight / Math.max(1, viewportSize.y);
+ sprite.scale.set(worldHeight * aspect, worldHeight, 1);
+ sprite.updateMatrixWorld();
+ };
+ group.add(sprite);
+ return sprite;
+}
+export function grid(group: THREE.Group, y = -0.25) {
+ const helper = new THREE.GridHelper(12, 12, 0x375066, 0x22313f);
+ helper.position.y = y;
+ group.add(helper);
+}
+
+/** Dispose shared geometries/materials once per scene replacement, including
+ * textures owned by generated labels. No GPU resources survive a lesson. */
+export function disposeGroup(group: THREE.Group) {
+ const geometries = new Set<THREE.BufferGeometry>();
+ const materials = new Set<THREE.Material>();
+ const textures = new Set<THREE.Texture>();
+ group.traverse(object => {
+ const mesh = object as THREE.Mesh;
+ if (mesh.geometry) geometries.add(mesh.geometry);
+ if (mesh.material) {
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ for (const material of list) {
+ materials.add(material);
+ const map = (material as THREE.MeshBasicMaterial).map;
+ if (map) textures.add(map);
+ }
+ }
+ });
+ geometries.forEach(geometry => geometry.dispose());
+ materials.forEach(material => material.dispose());
+ textures.forEach(texture => texture.dispose());
+ group.clear();
+}