A system was a handful of ellipses floating in the dark. You could see that one orbit was bigger than another, but not how big, and not that a planet sat above or below the plane the others share. Adds the same plane-and-tether reading aid the outer scales got: a polar grid in the system's own reference plane, with a drop line from each body onto it. Ring radii snap to a 1-2-5 ladder rather than dividing the system evenly, because the point is to put a number on a distance — 5, 10, 15 AU can be read at a glance and 4.34, 8.68, 13.02 cannot. That holds across the four orders of magnitude real systems span: the solar system gets 5 AU rings, TRAPPIST-1 gets 0.01 AU ones. The outermost ring encloses the outermost orbit rather than falling just inside it. The rings are dashed. Solid ones would sit in the same plane as the orbit ellipses, which are themselves rings, and at a glance a reference circle and a circular orbit are the same picture. Dashes are cut by dropping whole segments rather than by a dashed material: the ring is already built from independent segment pairs, so a material's dash pattern would restart at every one. Drawing the grid exposed a framing bug it made unmissable. The camera settled along one fixed direction derived from the ecliptic, which is face-on only for the one system whose elements are ecliptic. Every exoplanet system — measured against the plane of the sky, perpendicular to the line of sight to its own host star — was being presented nearly edge-on, a smear of overlapping ellipses. The settle direction is now taken relative to whichever plane the system was measured in, so all of them read as discs. The solar system is unmoved, which a test pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
import * as THREE from 'three/webgpu';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { galacticCentrePositionPc, SUN_HEIGHT_ABOVE_MIDPLANE_PC } from '../../shared/astro/galaxy';
|
|
import { galacticFrameQuaternion, galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
|
|
|
const SEGMENTS_PER_RING = 180;
|
|
|
|
function vertexAt(geometry: THREE.BufferGeometry, index: number): THREE.Vector3 {
|
|
const position = geometry.getAttribute('position');
|
|
return new THREE.Vector3(position.getX(index), position.getY(index), position.getZ(index));
|
|
}
|
|
|
|
describe('galacticFrameQuaternion', () => {
|
|
it('carries the local +Z onto the galactic normal, so a flat grid lands in the galactic plane', () => {
|
|
const rotated = new THREE.Vector3(0, 0, 1).applyQuaternion(galacticFrameQuaternion());
|
|
const normal = galacticNormal();
|
|
expect(rotated.x).toBeCloseTo(normal.x, 9);
|
|
expect(rotated.y).toBeCloseTo(normal.y, 9);
|
|
expect(rotated.z).toBeCloseTo(normal.z, 9);
|
|
});
|
|
|
|
it('tilts that plane the real angle away from the celestial equator', () => {
|
|
// The galactic and celestial poles are 62.9 degrees apart, so the planes are too.
|
|
const normal = galacticNormal();
|
|
expect((Math.acos(Math.abs(normal.z)) * 180) / Math.PI).toBeCloseTo(62.87, 1);
|
|
});
|
|
});
|
|
|
|
describe('PolarGridPlane', () => {
|
|
const rings = [10, 20, 50];
|
|
const spokes = 8;
|
|
const grid = new PolarGridPlane({ ringRadii: rings, spokeCount: spokes, emphasisRadii: [50] });
|
|
|
|
it('draws every ring segment and every spoke', () => {
|
|
expect(grid.object.geometry.getAttribute('position').count).toBe(rings.length * SEGMENTS_PER_RING * 2 + spokes * 2);
|
|
});
|
|
|
|
it('starts hidden, so a view that never zooms out never draws it', () => {
|
|
expect(grid.object.visible).toBe(false);
|
|
});
|
|
|
|
it('fades in and out with strength, and disappears outright at zero', () => {
|
|
grid.setStrength(1);
|
|
expect(grid.object.visible).toBe(true);
|
|
const full = (grid.object.material as THREE.LineBasicMaterial).opacity;
|
|
|
|
grid.setStrength(0.5);
|
|
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(full / 2, 6);
|
|
|
|
grid.setStrength(0);
|
|
expect(grid.object.visible).toBe(false);
|
|
});
|
|
|
|
it('clamps strength rather than letting opacity run past one', () => {
|
|
grid.setStrength(4);
|
|
expect((grid.object.material as THREE.LineBasicMaterial).opacity).toBeLessThanOrEqual(1);
|
|
grid.setStrength(-1);
|
|
expect(grid.object.visible).toBe(false);
|
|
});
|
|
|
|
it('lies in the galactic plane through its centre once placed in the scene', () => {
|
|
grid.object.updateMatrixWorld(true);
|
|
const normal = galacticNormal();
|
|
|
|
for (const index of [0, 100, 1000, grid.object.geometry.getAttribute('position').count - 1]) {
|
|
const world = vertexAt(grid.object.geometry, index).applyMatrix4(grid.object.matrixWorld);
|
|
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
|
}
|
|
});
|
|
|
|
it('sits on the galactic centre when given it, still in the plane', () => {
|
|
const centre = galacticCentrePositionPc();
|
|
const galacticGrid = new PolarGridPlane({
|
|
ringRadii: [2500, 8178],
|
|
spokeCount: 4,
|
|
centre: new THREE.Vector3(centre.x, centre.y, centre.z)
|
|
});
|
|
galacticGrid.object.updateMatrixWorld(true);
|
|
|
|
const normal = galacticNormal();
|
|
const world = vertexAt(galacticGrid.object.geometry, 0).applyMatrix4(galacticGrid.object.matrixWorld);
|
|
// The centre is one Sun-height below the Sun's own plane, and the grid follows it there.
|
|
expect(world.dot(normal)).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
|
|
|
galacticGrid.dispose();
|
|
});
|
|
|
|
it('lies in whatever plane it is oriented into, for a system read against its own', () => {
|
|
// The system view passes the frame its orbital elements were measured in, which has nothing
|
|
// to do with the Galaxy's plane.
|
|
const orientation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
|
const systemGrid = new PolarGridPlane({ ringRadii: [1, 2, 3], spokeCount: 6, orientation });
|
|
systemGrid.object.updateMatrixWorld(true);
|
|
|
|
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(orientation);
|
|
for (const index of [0, 200, systemGrid.object.geometry.getAttribute('position').count - 1]) {
|
|
const world = vertexAt(systemGrid.object.geometry, index).applyMatrix4(systemGrid.object.matrixWorld);
|
|
expect(world.dot(normal)).toBeCloseTo(0, 6);
|
|
}
|
|
// ...and it is genuinely a different plane from the default.
|
|
expect(Math.abs(normal.dot(galacticNormal()))).toBeLessThan(0.99);
|
|
|
|
systemGrid.dispose();
|
|
});
|
|
|
|
it('honours an explicit peak opacity, for a grid that has to sit under other rings', () => {
|
|
const quiet = new PolarGridPlane({ ringRadii: [1, 2], spokeCount: 4, opacity: 0.2 });
|
|
quiet.setStrength(1);
|
|
expect((quiet.object.material as THREE.LineBasicMaterial).opacity).toBeCloseTo(0.2, 6);
|
|
quiet.dispose();
|
|
});
|
|
|
|
it('keeps the emphasised ring brighter than the rest', () => {
|
|
const colors = grid.object.geometry.getAttribute('color');
|
|
// Vertices are written ring by ring, in the order they were listed: 10 pc first, 50 pc last.
|
|
const innerBrightness = colors.getX(0) + colors.getY(0) + colors.getZ(0);
|
|
const emphasisIndex = 2 * SEGMENTS_PER_RING * 2;
|
|
const emphasisBrightness = colors.getX(emphasisIndex) + colors.getY(emphasisIndex) + colors.getZ(emphasisIndex);
|
|
expect(emphasisBrightness).toBeGreaterThan(innerBrightness);
|
|
});
|
|
});
|
|
|
|
describe('TetherField', () => {
|
|
it('drops each point onto the plane, straight down the galactic normal', () => {
|
|
const field = new TetherField(4);
|
|
const point = new THREE.Vector3(12, -7, 30);
|
|
field.setTargets([point]);
|
|
|
|
const geometry = field.object.geometry;
|
|
const top = vertexAt(geometry, 0);
|
|
const foot = vertexAt(geometry, 1);
|
|
const normal = galacticNormal();
|
|
|
|
expect(top.distanceTo(point)).toBeCloseTo(0, 4);
|
|
// The foot is in the plane...
|
|
expect(foot.dot(normal)).toBeCloseTo(0, 4);
|
|
// ...and directly below the point: the drop has no sideways component.
|
|
const drop = top.clone().sub(foot);
|
|
expect(drop.clone().cross(normal).length()).toBeCloseTo(0, 4);
|
|
|
|
field.dispose();
|
|
});
|
|
|
|
it('drops onto an offset plane when asked, for a grid on the true midplane', () => {
|
|
const field = new TetherField(2);
|
|
field.setTargets([new THREE.Vector3(0, 0, 100)], -SUN_HEIGHT_ABOVE_MIDPLANE_PC);
|
|
|
|
const foot = vertexAt(field.object.geometry, 1);
|
|
expect(foot.dot(galacticNormal())).toBeCloseTo(-SUN_HEIGHT_ABOVE_MIDPLANE_PC, 4);
|
|
|
|
field.dispose();
|
|
});
|
|
|
|
it('draws two vertices per tether and nothing for the ones it was not given', () => {
|
|
const field = new TetherField(8);
|
|
field.setTargets([new THREE.Vector3(1, 2, 3), new THREE.Vector3(4, 5, 6)]);
|
|
expect(field.object.geometry.drawRange.count).toBe(4);
|
|
|
|
field.setTargets([]);
|
|
expect(field.object.geometry.drawRange.count).toBe(0);
|
|
|
|
field.dispose();
|
|
});
|
|
|
|
it('drops points past its capacity rather than overrunning the buffer', () => {
|
|
const field = new TetherField(2);
|
|
const points = [new THREE.Vector3(1, 0, 5), new THREE.Vector3(2, 0, 5), new THREE.Vector3(3, 0, 5), new THREE.Vector3(4, 0, 5)];
|
|
expect(() => field.setTargets(points)).not.toThrow();
|
|
expect(field.object.geometry.drawRange.count).toBe(4);
|
|
expect(field.object.geometry.getAttribute('position').count).toBe(4);
|
|
|
|
field.dispose();
|
|
});
|
|
|
|
it('drops down whatever normal it was built with, not always the galactic one', () => {
|
|
const normal = new THREE.Vector3(0, 1, 0);
|
|
const field = new TetherField(2, { normal });
|
|
field.setTargets([new THREE.Vector3(3, 7, 5)]);
|
|
|
|
const foot = vertexAt(field.object.geometry, 1);
|
|
// The foot keeps the in-plane components and loses only the height along the normal.
|
|
expect(foot.x).toBeCloseTo(3, 6);
|
|
expect(foot.y).toBeCloseTo(0, 6);
|
|
expect(foot.z).toBeCloseTo(5, 6);
|
|
|
|
field.dispose();
|
|
});
|
|
|
|
it('normalises the normal it is given, so an unnormalised frame axis still lands on the plane', () => {
|
|
const field = new TetherField(2, { normal: new THREE.Vector3(0, 0, 4) });
|
|
field.setTargets([new THREE.Vector3(1, 1, 9)]);
|
|
expect(vertexAt(field.object.geometry, 1).z).toBeCloseTo(0, 6);
|
|
field.dispose();
|
|
});
|
|
|
|
it('stays hidden until it is given a strength', () => {
|
|
const field = new TetherField(2);
|
|
expect(field.object.visible).toBe(false);
|
|
|
|
field.setStrength(1);
|
|
expect(field.object.visible).toBe(true);
|
|
field.setStrength(0);
|
|
expect(field.object.visible).toBe(false);
|
|
|
|
field.dispose();
|
|
});
|
|
});
|