diff options
| author | victor | 2026-10-24 15:57:01 +0200 |
|---|---|---|
| committer | victor | 2026-10-24 15:57:01 +0200 |
| commit | 4533910bce394e7a0ee6debfc05ca1221ee51899 (patch) | |
| tree | 73aaa5b2c7a48dbf226e6b81d499ba1a8ecc33a1 /src/models/electric-flux/scene.ts | |
| parent | Tilføj dansk (diff) | |
| download | 3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.tar.gz 3D-Electrophysics-4533910bce394e7a0ee6debfc05ca1221ee51899.zip | |
Astra!ny-start
Diffstat (limited to 'src/models/electric-flux/scene.ts')
| -rw-r--r-- | src/models/electric-flux/scene.ts | 277 |
1 files changed, 277 insertions, 0 deletions
diff --git a/src/models/electric-flux/scene.ts b/src/models/electric-flux/scene.ts new file mode 100644 index 0000000..9409fd4 --- /dev/null +++ b/src/models/electric-flux/scene.ts @@ -0,0 +1,277 @@ +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<THREE.BufferGeometry>(); + const materials = new Set<THREE.Material>(); + 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); +} |