import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import type { FluxState } from './state'; import { physicsInputs } from './state'; import { electricFieldAt } from './physics'; const colors = { surface: 0x6fc9bb, positive: 0xf4b58a, negative: 0x85bafa, normal: 0xd9e8d2 }; function disposeGroup(group: THREE.Group) { const geometries = new Set(); const materials = new Set(); group.traverse((object) => { if (object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points) { geometries.add(object.geometry); for (const material of Array.isArray(object.material) ? object.material : [object.material]) materials.add(material); } }); geometries.forEach((geometry) => geometry.dispose()); materials.forEach((material) => material.dispose()); group.clear(); } /** A static, demand-rendered scene: no perpetual animation loop or hidden physics state. */ export function createFluxScene( host: HTMLElement, marker: HTMLElement, onContextLost: () => void, ) { const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setClearColor(0x10282d, 0); renderer.outputColorSpace = THREE.SRGBColorSpace; const canvas = renderer.domElement; canvas.tabIndex = 0; host.prepend(canvas); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 80); const controls = new OrbitControls(camera, canvas); controls.enablePan = false; controls.enableDamping = false; controls.minDistance = 7; controls.maxDistance = 23; controls.maxPolarAngle = Math.PI - 0.08; controls.minPolarAngle = 0.08; controls.zoomSpeed = 0.75; controls.rotateSpeed = 0.65; scene.add(new THREE.AmbientLight(0xb5e6e3, 2)); const light = new THREE.DirectionalLight(0xffe5c5, 4); light.position.set(3, 6, 5); scene.add(light); const world = new THREE.Group(); const surfaceGroup = new THREE.Group(); const fieldGroup = new THREE.Group(); const normalGroup = new THREE.Group(); const chargeGroup = new THREE.Group(); scene.add(world, surfaceGroup, fieldGroup, normalGroup, chargeGroup); const grid = new THREE.GridHelper(24, 48, 0x548080, 0x34565a); grid.position.y = -3.2; grid.material.transparent = true; grid.material.opacity = 0.22; world.add(grid); const axis = new THREE.Line( new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(-5, 0, 0), new THREE.Vector3(5, 0, 0)]), new THREE.LineDashedMaterial({ color: 0x729797, transparent: true, opacity: 0.3, dashSize: 0.08, gapSize: 0.12 }), ); axis.computeLineDistances(); world.add(axis); let width = 1; let height = 1; let disposed = false; let contextLost = false; let current: FluxState | undefined; function render() { if (disposed || contextLost) return; renderer.render(scene, camera); if (!current) return; const projected = new THREE.Vector3(current.chargeX, 0, 0).project(camera); const visible = projected.z > -1 && projected.z < 1 && Math.abs(projected.x) < 0.92 && Math.abs(projected.y) < 0.9; marker.hidden = !visible; marker.style.left = `${(projected.x * 0.5 + 0.5) * width}px`; marker.style.top = `${(-projected.y * 0.5 + 0.5) * height + 22}px`; } function setView(view: 'default' | 'front' | 'top') { controls.target.set(0, 0, 0); if (view === 'front') camera.position.set(0, 0, 13); else if (view === 'top') camera.position.set(0, 13, 0.01); else camera.position.set(6.5, 4.2, 9.5); controls.update(); render(); } function addArrow(position: THREE.Vector3, direction: THREE.Vector3, color: number, parent: THREE.Group, length = 0.45) { const arrow = new THREE.ArrowHelper(direction, position, length, color, 0.13, 0.07); parent.add(arrow); } function buildSurface(state: FluxState) { disposeGroup(surfaceGroup); disposeGroup(normalGroup); const radius = state.size; const geometry = state.shape === 'sphere' ? new THREE.SphereGeometry(radius, 48, 32) : new THREE.BoxGeometry(radius * 2, radius * 2, radius * 2); const shell = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color: colors.surface, transparent: true, opacity: 0.055, side: THREE.DoubleSide, depthWrite: false, })); surfaceGroup.add(shell); const lineMaterial = new THREE.LineBasicMaterial({ color: colors.surface, transparent: true, opacity: 0.36, depthWrite: false }); if (state.shape === 'sphere') { for (let latitude = 1; latitude < 10; latitude++) { const phi = Math.PI * latitude / 10; const points = Array.from({ length: 96 }, (_, i) => { const theta = i * Math.PI * 2 / 96; return new THREE.Vector3(radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta)); }); surfaceGroup.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), lineMaterial)); } for (let longitude = 0; longitude < 12; longitude++) { const theta = longitude * Math.PI / 12; const points = Array.from({ length: 96 }, (_, i) => { const phi = i * Math.PI * 2 / 96; return new THREE.Vector3(radius * Math.sin(phi) * Math.cos(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta)); }); surfaceGroup.add(new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(points), lineMaterial)); } for (let i = 0; i < 18; i++) { const normal = fibonacciDirection(i, 18); addArrow(normal.clone().multiplyScalar(radius), normal, colors.normal, normalGroup); } } else { surfaceGroup.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry), lineMaterial)); const gridMaterial = new THREE.LineBasicMaterial({ color: colors.surface, transparent: true, opacity: 0.2 }); const points: THREE.Vector3[] = []; for (const sign of [-1, 1]) { for (let axis = 0; axis < 3; axis++) { const normal = new THREE.Vector3().setComponent(axis, sign); addArrow(normal.clone().multiplyScalar(radius), normal, colors.normal, normalGroup); const u = (axis + 1) % 3; const v = (axis + 2) % 3; for (let step = -2; step <= 2; step++) { const a = new THREE.Vector3().setComponent(axis, sign * radius).setComponent(u, step * radius / 3).setComponent(v, -radius); const b = a.clone().setComponent(v, radius); const c = new THREE.Vector3().setComponent(axis, sign * radius).setComponent(v, step * radius / 3).setComponent(u, -radius); points.push(a, b, c, c.clone().setComponent(u, radius)); } } } surfaceGroup.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), gridMaterial)); } } function buildField(state: FluxState) { disposeGroup(fieldGroup); disposeGroup(chargeGroup); const { charge } = physicsInputs(state); const origin = new THREE.Vector3(state.chargeX, 0, 0); const color = state.chargeNC > 0 ? colors.positive : state.chargeNC < 0 ? colors.negative : 0xa1b3b4; const chargeMesh = new THREE.Mesh(new THREE.SphereGeometry(0.16, 32, 24), new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5, metalness: 0.2, roughness: 0.3, })); chargeMesh.position.copy(origin); chargeGroup.add(chargeMesh); const halo = new THREE.Mesh(new THREE.SphereGeometry(0.23, 24, 16), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.08, depthWrite: false, })); halo.position.copy(origin); chargeGroup.add(halo); if (state.chargeNC === 0) return; const count = Math.round(18 + Math.abs(state.chargeNC) * 9); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.47, depthWrite: false }); const arrowGeometry = new THREE.ConeGeometry(0.045, 0.16, 6); const arrowMaterial = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85 }); const up = new THREE.Vector3(0, 1, 0); for (let i = 0; i < count; i++) { const direction = fibonacciDirection(i, count); // Clip rays to a common world sphere so an external source remains legible. const dot = origin.dot(direction); const end = -dot + Math.sqrt(dot * dot + 5.5 ** 2 - origin.lengthSq()); const points = [origin.clone().addScaledVector(direction, 0.24), origin.clone().addScaledVector(direction, end)]; fieldGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material)); for (let distance = 0.9; distance < end - 0.2; distance += 1.25) { const point = origin.clone().addScaledVector(direction, distance); const field = electricFieldAt(point, charge)!; const fieldDirection = new THREE.Vector3(field.x, field.y, field.z).normalize(); const arrow = new THREE.Mesh(arrowGeometry, arrowMaterial); arrow.position.copy(point); arrow.quaternion.setFromUnitVectors(up, fieldDirection); fieldGroup.add(arrow); } } } function update(state: FluxState) { const shapeChanged = !current || current.shape !== state.shape || current.size !== state.size; const chargeChanged = !current || current.chargeNC !== state.chargeNC || current.chargeX !== state.chargeX; if (shapeChanged) buildSurface(state); if (chargeChanged) buildField(state); surfaceGroup.visible = state.surface; normalGroup.visible = state.normals; fieldGroup.visible = state.fieldLines; current = { ...state }; render(); } function onKey(event: KeyboardEvent) { const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', '+', '=', '-', '_', 'Home']; if (!keys.includes(event.key)) return; event.preventDefault(); if (event.key === 'Home') return setView('default'); const offset = camera.position.clone().sub(controls.target); const spherical = new THREE.Spherical().setFromVector3(offset); if (event.key === 'ArrowLeft') spherical.theta -= 0.12; if (event.key === 'ArrowRight') spherical.theta += 0.12; if (event.key === 'ArrowUp') spherical.phi -= 0.12; if (event.key === 'ArrowDown') spherical.phi += 0.12; if (event.key === '+' || event.key === '=') spherical.radius *= 0.9; if (event.key === '-' || event.key === '_') spherical.radius *= 1.1; spherical.phi = THREE.MathUtils.clamp(spherical.phi, controls.minPolarAngle, controls.maxPolarAngle); spherical.radius = THREE.MathUtils.clamp(spherical.radius, controls.minDistance, controls.maxDistance); camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(spherical)); controls.update(); } function onLost(event: Event) { event.preventDefault(); contextLost = true; marker.hidden = true; onContextLost(); } const observer = new ResizeObserver(() => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); renderer.setSize(width, height); camera.aspect = width / height; camera.updateProjectionMatrix(); render(); }); observer.observe(host); controls.addEventListener('change', render); canvas.addEventListener('keydown', onKey); canvas.addEventListener('webglcontextlost', onLost); setView('default'); return { canvas, update, setView, dispose() { disposed = true; observer.disconnect(); controls.removeEventListener('change', render); controls.dispose(); canvas.removeEventListener('keydown', onKey); canvas.removeEventListener('webglcontextlost', onLost); [world, surfaceGroup, fieldGroup, normalGroup, chargeGroup].forEach(disposeGroup); renderer.dispose(); canvas.remove(); }, }; } function fibonacciDirection(index: number, count: number) { const y = 1 - 2 * (index + 0.5) / count; const radius = Math.sqrt(1 - y * y); const angle = index * Math.PI * (3 - Math.sqrt(5)); return new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius); }