Merge branch 'perf/label-scan' into feat/drawn-set-follows-view

The label fix turns the brightness order into an index with positions and ids
laid out beside it. The star field only needs the order, so it is handed
`.order`. Both sides added scene tests in the same place; both are kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-09-16 15:11:29 +02:00
co-authored by Claude Opus 5
4 changed files with 80 additions and 23 deletions
@@ -239,6 +239,30 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
refocus.mockRestore();
});
it('asks for no more label candidates once the last label it will show is placed', () => {
// Near the Sun a label candidate past the fifteenth can sit at the far end of the catalogue's
// brightness order, so asking for one more than is used can cost a walk of the whole order.
const component = fixture.componentInstance as unknown as {
spreadLabels(candidates: Iterable<{ id: number; name: string; x: number; y: number; z: number }>, camera: THREE.Camera, keepId: null): unknown[];
};
const camera = engine.getCamera();
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
let pulled = 0;
const grid = function* () {
for (let row = 0; row < 5; row++) {
for (let column = 0; column < 5; column++) {
pulled++;
const point = new THREE.Vector3(-0.8 + column * 0.4, -0.8 + row * 0.4, 0.5).unproject(camera);
yield { id: row * 5 + column, name: `label-${pulled}`, x: point.x, y: point.y, z: point.z };
}
}
};
expect(component.spreadLabels(grid(), camera, null)).toHaveLength(15);
expect(pulled).toBe(15);
});
it('flies the camera into a selected star system: hides the galaxy group, shows the system group, and switches to AU-scale near/far planes', async () => {
navigationStore.selectStar(SUN.id);
await flushAsync();
@@ -38,7 +38,7 @@ import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest';
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
import { HostStarRings } from './host-star-rings';
@@ -367,7 +367,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
private starIdsWithBodies = new Set<number>();
/** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */
private starsByBrightness: Uint32Array = new Uint32Array(0);
private starsByBrightness: BrightnessIndex = brightnessIndex([]);
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
private milkyWay?: MilkyWayRenderer;
@@ -530,7 +530,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.starsByBrightness = brightnessOrder(stars);
this.starsByBrightness = brightnessIndex(stars);
this.starSearchIndex.set(
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
);
@@ -544,7 +544,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
(id): id is number => id !== null && id !== undefined
));
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness);
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search), this.starsByBrightness.order);
this.galaxyGroup.add(this.starField.object);
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
this.galaxyGroup.add(this.hostRings.object);
@@ -817,8 +817,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// for anything with catalogued bodies: it is the one distinction the second line can draw that
// the map cannot otherwise show, since it says which of these points is somewhere you can go.
const starIdsWithBodies = this.starIdsWithBodies;
const candidates = function* (stars: readonly StarRecord[], order: Uint32Array): Generator<LabeledPoint> {
for (const star of brightestWithin(stars, order, target, labelRadius, selectedId)) {
const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator<LabeledPoint> {
for (const star of brightestWithin(stars, index, target, labelRadius, selectedId)) {
yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z };
}
};
@@ -853,10 +853,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const projected = new THREE.Vector3();
for (const candidate of candidates) {
if (chosen.length >= LABEL_MAX_COUNT) {
break;
}
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
const isKept = candidate.id === keepId;
// Offscreen or behind the camera.
@@ -883,6 +879,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
placed.push(point);
chosen.push({ ...candidate, side });
// Here rather than at the top of the loop: there, taking the fifteenth label asked the
// candidates for a sixteenth first, and near the Sun finding one walks most of the catalogue.
if (chosen.length >= LABEL_MAX_COUNT) {
break;
}
}
return chosen;
+5 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { brightestWithin, brightnessOrder } from './brightest';
import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest';
interface TestStar {
id: number;
@@ -46,11 +46,11 @@ describe('brightestWithin', () => {
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
const stars = cloud(3000);
const order = brightnessOrder(stars);
const index = brightnessIndex(stars);
const centre = { x: 12, y: -30, z: 5 };
for (const [radius, alwaysId] of [[40, null], [15, 7 * 2999], [0, 7 * 11], [500, null]] as const) {
const lazy = Array.from(brightestWithin(stars, order, centre, radius, alwaysId), (star) => star.id);
const lazy = Array.from(brightestWithin(stars, index, centre, radius, alwaysId), (star) => star.id);
expect(lazy).toEqual(filterThenSort(stars, centre, radius, alwaysId));
}
});
@@ -61,7 +61,7 @@ describe('brightestWithin', () => {
{ id: 2, x: 3, y: 4.001, z: 0, magnitude: 0 }
];
expect(Array.from(brightestWithin(stars, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]);
expect(Array.from(brightestWithin(stars, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 5, null), (star) => star.id)).toEqual([1]);
});
it('reads no further than the caller takes', () => {
@@ -77,7 +77,7 @@ describe('brightestWithin', () => {
});
const taken: number[] = [];
for (const star of brightestWithin(counted, brightnessOrder(stars), { x: 0, y: 0, z: 0 }, 1000, null)) {
for (const star of brightestWithin(counted, brightnessIndex(stars), { x: 0, y: 0, z: 0 }, 1000, null)) {
taken.push(star.id);
if (taken.length === 15) {
break;
+40 -8
View File
@@ -30,6 +30,38 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
}
/**
* The brightness order, with each star's position and id laid out beside it in that order.
*
* A walk has to test every star it passes, and near the Sun it passes nearly all of them: a 4 pc
* label radius holds a few dozen stars, faint dwarfs deep in the order, so the walk rarely finds
* fifteen to name before the end. Reading the stars themselves in brightness order jumps all over
* the catalogue, and a full walk took 19-23 ms — slower than the scan and sort it replaced. Read
* from these arrays, laid out in the order they are walked, the same walk touches memory in
* sequence and reads a star only when it yields one.
*/
export interface BrightnessIndex {
/** Indices into the catalogue, brightest first. */
readonly order: Uint32Array;
/** Positions in the same order, three to a star, at full precision so a star on a radius stays on it. */
readonly positions: Float64Array;
readonly ids: Float64Array;
}
export function brightnessIndex<T extends BrightnessRanked & Positioned & { readonly id: number }>(stars: readonly T[]): BrightnessIndex {
const order = brightnessOrder(stars);
const positions = new Float64Array(order.length * 3);
const ids = new Float64Array(order.length);
order.forEach((index, at) => {
const star = stars[index];
positions[at * 3] = star.x;
positions[at * 3 + 1] = star.y;
positions[at * 3 + 2] = star.z;
ids[at] = star.id;
});
return { order, positions, ids };
}
/**
* The stars within `radiusPc` of `centre`, brightest first, plus the one star `alwaysId` names
* wherever it is — handed over lazily, so a caller that stops after the first few pays for no
@@ -37,19 +69,19 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array
*/
export function* brightestWithin<T extends BrightnessRanked & Positioned & { readonly id: number }>(
stars: readonly T[],
order: Uint32Array,
index: BrightnessIndex,
centre: Positioned,
radiusPc: number,
alwaysId: number | null
): Generator<T> {
const { order, positions, ids } = index;
const radiusSq = radiusPc * radiusPc;
for (const index of order) {
const star = stars[index];
const dx = star.x - centre.x;
const dy = star.y - centre.y;
const dz = star.z - centre.z;
if (dx * dx + dy * dy + dz * dz <= radiusSq || star.id === alwaysId) {
yield star;
for (let at = 0; at < order.length; at++) {
const dx = positions[at * 3] - centre.x;
const dy = positions[at * 3 + 1] - centre.y;
const dz = positions[at * 3 + 2] - centre.z;
if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) {
yield stars[order[at]];
}
}
}