aboutsummaryrefslogtreecommitdiff
path: root/src/models/electric-flux/scene.ts
diff options
context:
space:
mode:
authorvictor2026-10-24 15:57:01 +0200
committervictor2026-10-24 15:57:01 +0200
commit4533910bce394e7a0ee6debfc05ca1221ee51899 (patch)
tree73aaa5b2c7a48dbf226e6b81d499ba1a8ecc33a1 /src/models/electric-flux/scene.ts
parentTilføj dansk (diff)
download3D-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.ts277
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);
+}