import * as THREE from 'three'; import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; import { renderProse } from '../math'; 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, minViewportWidth = 0): THREE.Sprite | CSS2DObject { if (text.includes('$')) { const element = document.createElement('div'); element.className = 'scene-math-label'; element.style.color = colour; renderProse(element, text); const annotation = new CSS2DObject(element); annotation.position.copy(vector(position)); annotation.onBeforeRender = () => { element.style.visibility = (element.parentElement?.clientWidth ?? Infinity) < minViewportWidth ? 'hidden' : 'visible'; }; group.add(annotation); return annotation; } 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); sprite.material.opacity = viewportSize.x < minViewportWidth ? 0 : 1; const responsiveHeight = viewportSize.x < 600 ? screenHeight * 0.78 : screenHeight; const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * responsiveHeight / 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(); const materials = new Set(); const textures = new Set(); 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(); }