Merge branch 'feat/drawn-set-follows-view' into perf/routing-worker
The label and star-field review fixes arrive under the routing client: the scene keeps constructing RoutingClient beside the neighbourhood, and builds the brightness index where it built the order. Both sides' new scene tests 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:
@@ -23,7 +23,8 @@ import { StarFieldRenderer } from './star-field-renderer';
|
|||||||
|
|
||||||
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
const SUN: StarRecord = { id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 };
|
||||||
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y: 0, z: 0, magnitude: 4.4, spectralType: 'G2V', colorIndex: 0.7 };
|
||||||
const PROXIMA: StarRecord = { id: 2, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
||||||
|
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||||
|
|
||||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||||
@@ -227,6 +228,31 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
refocus.mockRestore();
|
refocus.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not choose the drawn stars again at load, where the renderer has just chosen them', async () => {
|
||||||
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||||
|
|
||||||
|
await advanceFrames(engine, 0.6);
|
||||||
|
|
||||||
|
expect(refocus).not.toHaveBeenCalled();
|
||||||
|
refocus.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the drawn stars alone at galactic scale, however far the view centre sweeps', async () => {
|
||||||
|
const component = fixture.componentInstance as unknown as { controls: { target: THREE.Vector3 } };
|
||||||
|
const camera = engine.getCamera();
|
||||||
|
camera.position.set(0, 0, 30000);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||||
|
|
||||||
|
component.controls.target.set(500, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
component.controls.target.set(1500, 0, 0);
|
||||||
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
|
expect(refocus).not.toHaveBeenCalled();
|
||||||
|
refocus.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
it('keeps the stars of a plotted route drawn, and the selected star', async () => {
|
||||||
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
const component = fixture.componentInstance as unknown as { routeResult: { set(value: unknown): void } };
|
||||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||||
@@ -235,7 +261,8 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
||||||
await advanceFrames(engine, 0.3);
|
await advanceFrames(engine, 0.3);
|
||||||
|
|
||||||
expect(refocus.mock.calls.at(-1)![0].pinnedIds).toEqual([SUN.id, PROXIMA.id]);
|
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
||||||
|
expect(refocus.mock.calls.at(-1)![0].pinned).toEqual([0, 2]);
|
||||||
refocus.mockRestore();
|
refocus.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,6 +294,30 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
|||||||
expect(component.routePending()).toBe(false);
|
expect(component.routePending()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
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);
|
navigationStore.selectStar(SUN.id);
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { StarmapHudComponent } from './starmap-hud.component';
|
|||||||
import { SystemObjectCardComponent } from './system-object-card.component';
|
import { SystemObjectCardComponent } from './system-object-card.component';
|
||||||
import { RoutingClient } from './routing-client';
|
import { RoutingClient } from './routing-client';
|
||||||
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
import { colorIndexToRgb, FOCUS_RADIUS_PC, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||||
import { brightestWithin, brightnessOrder } from '../../shared/astro/brightest';
|
import { BrightnessIndex, brightestWithin, brightnessIndex } from '../../shared/astro/brightest';
|
||||||
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
import { MAX_JUMP_RANGE_PC } from '../hud/routes-panel.component';
|
||||||
import { HostStarRings } from './host-star-rings';
|
import { HostStarRings } from './host-star-rings';
|
||||||
@@ -374,7 +374,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||||
private starIdsWithBodies = new Set<number>();
|
private starIdsWithBodies = new Set<number>();
|
||||||
/** Catalogue indices, brightest first, for the labels to walk rather than sort. See `brightestWithin`. */
|
/** 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. */
|
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
|
||||||
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
|
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
|
||||||
private milkyWay?: MilkyWayRenderer;
|
private milkyWay?: MilkyWayRenderer;
|
||||||
@@ -539,7 +539,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.neighbourhood = new StarNeighbourhood(stars);
|
this.neighbourhood = new StarNeighbourhood(stars);
|
||||||
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
||||||
this.starsByBrightness = brightnessOrder(stars);
|
this.starsByBrightness = brightnessIndex(stars);
|
||||||
this.starSearchIndex.set(
|
this.starSearchIndex.set(
|
||||||
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
||||||
);
|
);
|
||||||
@@ -553,7 +553,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
(id): id is number => id !== null && id !== undefined
|
(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);
|
||||||
|
// It has just chosen around the Sun, which is where the view opens: the first label pass need not choose again.
|
||||||
|
this.starFieldFocus = GALAXY_OVERVIEW_TARGET.clone();
|
||||||
this.galaxyGroup.add(this.starField.object);
|
this.galaxyGroup.add(this.starField.object);
|
||||||
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
||||||
this.galaxyGroup.add(this.hostRings.object);
|
this.galaxyGroup.add(this.hostRings.object);
|
||||||
@@ -786,7 +788,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* of it is always drawn, however faint.
|
* of it is always drawn, however faint.
|
||||||
*/
|
*/
|
||||||
private refocusStarField(): void {
|
private refocusStarField(): void {
|
||||||
if (!this.starField) {
|
// At galactic scale the whole catalogue is a smudge a few pixels across, and the view's centre
|
||||||
|
// sweeps hundreds of parsecs a pass across empty space: nothing to choose, and nothing to see.
|
||||||
|
if (!this.starField || !this.neighbourhood || this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
const centre = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||||
@@ -796,7 +800,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) {
|
if (this.starFieldFocus && this.starFieldFocus.distanceTo(centre) <= STAR_FIELD_REFOCUS_PC && pins === this.starFieldPins) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.starField.refocus({ centre, pinnedIds });
|
// By catalogue index, through the lookup the neighbourhood already holds: building a second
|
||||||
|
// one of 423 651 entries on the first pin stalled the first flight of a session for 50-140 ms.
|
||||||
|
const neighbourhood = this.neighbourhood;
|
||||||
|
const pinned = pinnedIds.map((id) => neighbourhood.indexOf(id)).filter((index): index is number => index !== undefined);
|
||||||
|
this.starField.refocus({ centre, pinned });
|
||||||
this.starFieldFocus = centre.clone();
|
this.starFieldFocus = centre.clone();
|
||||||
this.starFieldPins = pins;
|
this.starFieldPins = pins;
|
||||||
}
|
}
|
||||||
@@ -826,8 +834,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// for anything with catalogued bodies: it is the one distinction the second line can draw that
|
// 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.
|
// the map cannot otherwise show, since it says which of these points is somewhere you can go.
|
||||||
const starIdsWithBodies = this.starIdsWithBodies;
|
const starIdsWithBodies = this.starIdsWithBodies;
|
||||||
const candidates = function* (stars: readonly StarRecord[], order: Uint32Array): Generator<LabeledPoint> {
|
const candidates = function* (stars: readonly StarRecord[], index: BrightnessIndex): Generator<LabeledPoint> {
|
||||||
for (const star of brightestWithin(stars, order, target, labelRadius, selectedId)) {
|
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 };
|
yield { id: star.id, name: star.name, kind: starIdsWithBodies.has(star.id) ? 'System' : 'Star', x: star.x, y: star.y, z: star.z };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -862,10 +870,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (chosen.length >= LABEL_MAX_COUNT) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
||||||
const isKept = candidate.id === keepId;
|
const isKept = candidate.id === keepId;
|
||||||
// Offscreen or behind the camera.
|
// Offscreen or behind the camera.
|
||||||
@@ -892,6 +896,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
placed.push(point);
|
placed.push(point);
|
||||||
chosen.push({ ...candidate, side });
|
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;
|
return chosen;
|
||||||
|
|||||||
@@ -353,10 +353,10 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws a star pinned by id, and passes over ids the catalogue does not hold', () => {
|
it('draws a pinned star, and passes over an index past the end of the catalogue', () => {
|
||||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
|
||||||
renderer.refocus({ pinnedIds: [123456, 77] });
|
renderer.refocus({ pinned: [123456, 0] });
|
||||||
|
|
||||||
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
|
const drawnIds = Array.from({ length: renderer.drawnCount }, (_, i) => renderer.starIdAt(i));
|
||||||
expect(drawnIds).toContain(77);
|
expect(drawnIds).toContain(77);
|
||||||
@@ -366,7 +366,7 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
|
|
||||||
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
|
it('gives each drawn star its own colour and size, wherever the refocus put it', () => {
|
||||||
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinnedIds: [120] });
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 }, pinned: [21] });
|
||||||
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
|
const { colorAttribute, sizeAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute; sizeAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
|
||||||
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||||
@@ -382,6 +382,19 @@ describe('StarFieldRenderer refocus', () => {
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves the buffers alone when the drawn set has not changed, and rewrites them when it has', () => {
|
||||||
|
const renderer = new StarFieldRenderer(catalogue, positions, 10);
|
||||||
|
const { positionAttribute } = renderer as unknown as { positionAttribute: THREE.InstancedBufferAttribute };
|
||||||
|
const version = positionAttribute.version;
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||||
|
expect(positionAttribute.version).toBe(version);
|
||||||
|
|
||||||
|
renderer.refocus({ centre: { x: 0, y: 0, z: -140 } });
|
||||||
|
expect(positionAttribute.version).toBeGreaterThan(version);
|
||||||
|
renderer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
|
it('drops a star from the drawn set, and from picking, once the view has moved away from it', () => {
|
||||||
// The subtle failure this guards: buffers rewritten for a new selection while picking still
|
// The subtle failure this guards: buffers rewritten for a new selection while picking still
|
||||||
// reads the old one would leave clickable ghosts where nothing is drawn.
|
// reads the old one would leave clickable ghosts where nothing is drawn.
|
||||||
|
|||||||
@@ -228,8 +228,6 @@ export class StarFieldRenderer {
|
|||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
private readonly budget: number;
|
private readonly budget: number;
|
||||||
private readonly order: Uint32Array;
|
private readonly order: Uint32Array;
|
||||||
/** Built the first time a star is pinned by id, since nothing else needs it. */
|
|
||||||
private indexById?: Map<number, number>;
|
|
||||||
/**
|
/**
|
||||||
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
* Colour and angular size of every star in the catalogue, worked out once: a refocus then only
|
||||||
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
|
* copies them into the instances, 0.7 ms for the budget rather than 5.6 ms computing them again.
|
||||||
@@ -312,13 +310,14 @@ export class StarFieldRenderer {
|
|||||||
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
* Chooses the drawn stars again for where the view now is, and rewrites the instance buffers
|
||||||
* with them. See {@link selectDrawnStars}.
|
* with them. See {@link selectDrawnStars}.
|
||||||
*/
|
*/
|
||||||
refocus(focus: { centre?: Positioned; pinnedIds?: readonly number[] }): void {
|
refocus(focus: DrawFocus): void {
|
||||||
let pinned: number[] = [];
|
const drawn = selectDrawnStars(this.catalogue, this.budget, focus, this.order);
|
||||||
if (focus.pinnedIds?.length) {
|
// The same stars in the same instances: the buffers already hold them, and a rewrite would
|
||||||
this.indexById ??= new Map(this.catalogue.map((star, index) => [star.id, index]));
|
// upload 2 MB to the GPU for nothing — which a pan across empty space would do every pass.
|
||||||
pinned = focus.pinnedIds.map((id) => this.indexById!.get(id)).filter((index): index is number => index !== undefined);
|
if (drawn.length === this.drawn.length && drawn.every((index, instance) => index === this.drawn[instance])) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.drawn = selectDrawnStars(this.catalogue, this.budget, { centre: focus.centre, pinned }, this.order);
|
this.drawn = drawn;
|
||||||
|
|
||||||
const positions = this.positionAttribute.array as Float32Array;
|
const positions = this.positionAttribute.array as Float32Array;
|
||||||
const colors = this.colorAttribute.array as Float32Array;
|
const colors = this.colorAttribute.array as Float32Array;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { brightestWithin, brightnessOrder } from './brightest';
|
import { brightestWithin, brightnessIndex, brightnessOrder } from './brightest';
|
||||||
|
|
||||||
interface TestStar {
|
interface TestStar {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -46,11 +46,11 @@ describe('brightestWithin', () => {
|
|||||||
|
|
||||||
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
|
it('yields exactly what filtering and then sorting the catalogue did, in the same order', () => {
|
||||||
const stars = cloud(3000);
|
const stars = cloud(3000);
|
||||||
const order = brightnessOrder(stars);
|
const index = brightnessIndex(stars);
|
||||||
const centre = { x: 12, y: -30, z: 5 };
|
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) {
|
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));
|
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 }
|
{ 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', () => {
|
it('reads no further than the caller takes', () => {
|
||||||
@@ -77,7 +77,7 @@ describe('brightestWithin', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const taken: number[] = [];
|
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);
|
taken.push(star.id);
|
||||||
if (taken.length === 15) {
|
if (taken.length === 15) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -30,6 +30,38 @@ export function brightnessOrder(stars: readonly BrightnessRanked[]): Uint32Array
|
|||||||
return Uint32Array.from(stars.keys()).sort((a, b) => magnitudes[a] - magnitudes[b]);
|
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
|
* 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
|
* 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 }>(
|
export function* brightestWithin<T extends BrightnessRanked & Positioned & { readonly id: number }>(
|
||||||
stars: readonly T[],
|
stars: readonly T[],
|
||||||
order: Uint32Array,
|
index: BrightnessIndex,
|
||||||
centre: Positioned,
|
centre: Positioned,
|
||||||
radiusPc: number,
|
radiusPc: number,
|
||||||
alwaysId: number | null
|
alwaysId: number | null
|
||||||
): Generator<T> {
|
): Generator<T> {
|
||||||
|
const { order, positions, ids } = index;
|
||||||
const radiusSq = radiusPc * radiusPc;
|
const radiusSq = radiusPc * radiusPc;
|
||||||
for (const index of order) {
|
for (let at = 0; at < order.length; at++) {
|
||||||
const star = stars[index];
|
const dx = positions[at * 3] - centre.x;
|
||||||
const dx = star.x - centre.x;
|
const dy = positions[at * 3 + 1] - centre.y;
|
||||||
const dy = star.y - centre.y;
|
const dz = positions[at * 3 + 2] - centre.z;
|
||||||
const dz = star.z - centre.z;
|
if (dx * dx + dy * dy + dz * dz <= radiusSq || ids[at] === alwaysId) {
|
||||||
if (dx * dx + dy * dy + dz * dz <= radiusSq || star.id === alwaysId) {
|
yield stars[order[at]];
|
||||||
yield star;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ export class StarNeighbourhood {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
||||||
|
/** Where the star this id names sits in the list the index was built from, or `undefined`. */
|
||||||
|
indexOf(id: number): number | undefined {
|
||||||
|
return this.indexById.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
point(id: number): StarPoint | undefined {
|
point(id: number): StarPoint | undefined {
|
||||||
const index = this.indexById.get(id);
|
const index = this.indexById.get(id);
|
||||||
return index === undefined ? undefined : this.points[index];
|
return index === undefined ? undefined : this.points[index];
|
||||||
|
|||||||
Reference in New Issue
Block a user