Corrige les 23 erreurs restantes, sans changer le comportement :
- no-unused-vars (18)
- galaxy-system-scene.component.spec.ts : les 5 faux `links` déclaraient des
paramètres _rangePc, _drawn, _budget seulement pour typer mock.calls ; ils sont
typés par vi.fn<LinkScene['routing']['links']>(), même signature.
- body-detail-scene : `const viewModel = this.viewModel()` jamais lu (lecture de
signal hors contexte réactif, sans effet).
- galaxy-system-scene : `const camera = this.engine.getCamera()` jamais lu dans
swapToSystemSpace et swapToGalaxySpace. getCamera() ne lève que si le moteur
n'est pas initialisé, or ces fonctions ne tournent qu'en rappel de rig.flyTo,
piloté par le tick du moteur qui appelle déjà getCamera() à chaque image.
- grid-plane, star-field-renderer : imports cités seulement dans un {@link} de
JSDoc (SUN_HEIGHT_ABOVE_MIDPLANE_PC, REFERENCE_VIEWPORT_HEIGHT_PX). Les modules
restent importés pour leurs autres exports.
- no-useless-assignment (4) : valeurs initiales jamais lues (u, v, s de gaussian(),
affectés dans le do avant toute lecture ; raw de BookmarksStore.read(), affecté
dans le try dont le catch retourne). Les déclarations gardent leur type.
- no-unused-expressions (1) : `this.display().jumpLinks;` dans l'effect des liens
de saut est une lecture voulue (abonnement au signal) ; écrite
`void this.display().jumpLinks;`, même lecture.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
227 lines
9.7 KiB
TypeScript
227 lines
9.7 KiB
TypeScript
import * as THREE from 'three/webgpu';
|
|
|
|
import { GALACTIC_BASIS_EQUATORIAL } from '../../shared/astro/galaxy';
|
|
|
|
const SEGMENTS_PER_RING = 180;
|
|
|
|
/**
|
|
* The rotation that carries the galactic frame's axes onto the scene's equatorial ones, as a
|
|
* quaternion — so a grid built flat in XY comes out lying in the galactic plane, tilted the
|
|
* real 63 degrees against the celestial equator rather than parked on an arbitrary plane.
|
|
*/
|
|
export function galacticFrameQuaternion(): THREE.Quaternion {
|
|
const { x, y, z } = GALACTIC_BASIS_EQUATORIAL;
|
|
const basis = new THREE.Matrix4().makeBasis(new THREE.Vector3(x.x, x.y, x.z), new THREE.Vector3(y.x, y.y, y.z), new THREE.Vector3(z.x, z.y, z.z));
|
|
return new THREE.Quaternion().setFromRotationMatrix(basis);
|
|
}
|
|
|
|
/** The galactic plane's unit normal, in the equatorial frame. */
|
|
export function galacticNormal(): THREE.Vector3 {
|
|
const { z } = GALACTIC_BASIS_EQUATORIAL;
|
|
return new THREE.Vector3(z.x, z.y, z.z);
|
|
}
|
|
|
|
/**
|
|
* Distances here carry no unit of their own: they are whatever the group the grid is added to
|
|
* works in — parsecs in the galaxy view, AU in the system view.
|
|
*/
|
|
export interface PolarGridOptions {
|
|
/** Ring radii to draw, innermost first. */
|
|
readonly ringRadii: readonly number[];
|
|
/** Radial spokes drawn from the innermost to the outermost ring. */
|
|
readonly spokeCount: number;
|
|
/**
|
|
* Rotation from the grid's own XY plane onto the plane it should lie in. Defaults to the
|
|
* galactic plane; the system view passes the frame its orbital elements were measured in.
|
|
*/
|
|
readonly orientation?: THREE.Quaternion;
|
|
/**
|
|
* Centre of the grid, which also fixes the plane it lies in. Defaults to the origin — note
|
|
* that in the galaxy view the origin is the Sun, whose own plane is
|
|
* {@link SUN_HEIGHT_ABOVE_MIDPLANE_PC} above the Galaxy's midplane; that matters at the local
|
|
* scale and is invisible at the galactic one.
|
|
*/
|
|
readonly centre?: THREE.Vector3;
|
|
readonly color?: THREE.ColorRepresentation;
|
|
/** Rings listed here are drawn at full strength — used to call out a meaningful radius. */
|
|
readonly emphasisRadii?: readonly number[];
|
|
/** Peak opacity, for a grid that should read louder or quieter than the default. */
|
|
readonly opacity?: number;
|
|
/**
|
|
* Breaks the rings into dashes. Worth it where the grid shares a plane with real curves it
|
|
* could be mistaken for — the system view draws orbit ellipses in the same plane, and a solid
|
|
* ring there is indistinguishable at a glance from a circular orbit. Dashed reads as
|
|
* "reference", solid as "something is actually there".
|
|
*/
|
|
readonly dashed?: boolean;
|
|
}
|
|
|
|
/** Ring segments per dash and per gap when {@link PolarGridOptions.dashed} is set. */
|
|
const DASH_SEGMENTS = 2;
|
|
|
|
/**
|
|
* A polar grid lying in a reference plane: concentric rings and radial spokes, fading out with
|
|
* radius.
|
|
*
|
|
* This is the one piece of chrome that makes a 3D map readable. Without a reference plane a
|
|
* cloud of points has no depth at all — two stars a thousand parsecs apart look like neighbours,
|
|
* and a planet above its system's plane looks like one inside it. With a plane under them, and a
|
|
* tether from each down to it, the eye reads height directly. It is also the signature of the
|
|
* map this view is modelled on.
|
|
*/
|
|
export class PolarGridPlane {
|
|
readonly object: THREE.LineSegments;
|
|
|
|
private readonly geometry = new THREE.BufferGeometry();
|
|
private readonly material: THREE.LineBasicMaterial;
|
|
private readonly baseOpacity: number;
|
|
|
|
constructor(options: PolarGridOptions) {
|
|
const color = new THREE.Color(options.color ?? 0x4dd7ff);
|
|
const emphasis = new Set(options.emphasisRadii ?? []);
|
|
const outerRadius = Math.max(...options.ringRadii);
|
|
const innerRadius = Math.min(...options.ringRadii);
|
|
|
|
const vertices: number[] = [];
|
|
const colors: number[] = [];
|
|
|
|
const push = (x: number, y: number, brightness: number): void => {
|
|
vertices.push(x, y, 0);
|
|
colors.push(color.r * brightness, color.g * brightness, color.b * brightness);
|
|
};
|
|
|
|
for (const radius of options.ringRadii) {
|
|
// Rings dim toward the edge of the grid so it dissolves into the void instead of ending.
|
|
const brightness = emphasis.has(radius) ? 1 : 0.55 * (1 - (0.6 * radius) / outerRadius);
|
|
for (let segment = 0; segment < SEGMENTS_PER_RING; segment++) {
|
|
// 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 each one. Skipping segments also keeps the dash angular, so every ring is
|
|
// dashed at the same rate however large it is.
|
|
if (options.dashed && segment % (DASH_SEGMENTS * 2) >= DASH_SEGMENTS) {
|
|
continue;
|
|
}
|
|
const a = (segment / SEGMENTS_PER_RING) * Math.PI * 2;
|
|
const b = ((segment + 1) / SEGMENTS_PER_RING) * Math.PI * 2;
|
|
push(Math.cos(a) * radius, Math.sin(a) * radius, brightness);
|
|
push(Math.cos(b) * radius, Math.sin(b) * radius, brightness);
|
|
}
|
|
}
|
|
|
|
for (let spoke = 0; spoke < options.spokeCount; spoke++) {
|
|
const angle = (spoke / options.spokeCount) * Math.PI * 2;
|
|
const cos = Math.cos(angle);
|
|
const sin = Math.sin(angle);
|
|
push(cos * innerRadius, sin * innerRadius, 0.4);
|
|
push(cos * outerRadius, sin * outerRadius, 0.05);
|
|
}
|
|
|
|
this.geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
|
|
this.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
|
|
// Deliberately restrained: the grid is the reference the map is read against, not the map.
|
|
this.baseOpacity = options.opacity ?? 0.55;
|
|
this.material = new THREE.LineBasicMaterial({
|
|
vertexColors: true,
|
|
transparent: true,
|
|
opacity: 0,
|
|
depthWrite: false,
|
|
blending: THREE.AdditiveBlending
|
|
});
|
|
|
|
this.object = new THREE.LineSegments(this.geometry, this.material);
|
|
// Built flat in its own XY plane, then rotated onto the reference plane and slid to centre.
|
|
this.object.quaternion.copy(options.orientation ?? galacticFrameQuaternion());
|
|
this.object.position.copy(options.centre ?? new THREE.Vector3());
|
|
this.object.visible = false;
|
|
this.object.renderOrder = -1;
|
|
}
|
|
|
|
/** Crossfades the grid. Zero hides it outright rather than drawing a fully transparent pass. */
|
|
setStrength(strength: number): void {
|
|
const clamped = Math.max(0, Math.min(1, strength));
|
|
this.material.opacity = clamped * this.baseOpacity;
|
|
this.object.visible = clamped > 0;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.object.removeFromParent();
|
|
this.geometry.dispose();
|
|
this.material.dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The vertical lines dropped from objects onto the reference plane — the other half of what
|
|
* makes the grid work. A point floating over a grid still has ambiguous height; a point with a
|
|
* line down to a marked spot on the grid does not.
|
|
*
|
|
* Drawn as one `LineSegments` with a fixed-capacity buffer and a draw range, so following a
|
|
* changing set of stars costs a buffer write rather than a rebuild.
|
|
*/
|
|
export class TetherField {
|
|
readonly object: THREE.LineSegments;
|
|
|
|
private readonly geometry = new THREE.BufferGeometry();
|
|
private readonly material: THREE.LineBasicMaterial;
|
|
private readonly positions: Float32Array;
|
|
private readonly maxCount: number;
|
|
private readonly normal: THREE.Vector3;
|
|
private readonly peakOpacity: number;
|
|
|
|
constructor(maxCount: number, options: { color?: THREE.ColorRepresentation; normal?: THREE.Vector3; opacity?: number } = {}) {
|
|
this.maxCount = maxCount;
|
|
this.normal = (options.normal ?? galacticNormal()).clone().normalize();
|
|
this.peakOpacity = options.opacity ?? 0.45;
|
|
this.positions = new Float32Array(maxCount * 6);
|
|
const color = options.color ?? 0x4dd7ff;
|
|
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
|
|
this.geometry.setDrawRange(0, 0);
|
|
|
|
this.material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending });
|
|
this.object = new THREE.LineSegments(this.geometry, this.material);
|
|
// The buffer is rewritten in place as the visible set changes, so its bounds are stale by
|
|
// construction; culling on those bounds would blink the whole field in and out.
|
|
this.object.frustumCulled = false;
|
|
this.object.visible = false;
|
|
}
|
|
|
|
/**
|
|
* Drops a tether from each point onto the plane through the origin with this field's normal,
|
|
* offset along that normal by `planeOffset`.
|
|
*
|
|
* The offset is `0` for a plane through the origin — the Sun in the galaxy view, the host star
|
|
* in the system view — and `-SUN_HEIGHT_ABOVE_MIDPLANE_PC` for a grid on the Galaxy's true
|
|
* midplane. Points past the field's capacity are dropped.
|
|
*/
|
|
setTargets(points: readonly THREE.Vector3[], planeOffset = 0): void {
|
|
const normal = this.normal;
|
|
const count = Math.min(points.length, this.maxCount);
|
|
|
|
for (let index = 0; index < count; index++) {
|
|
const point = points[index];
|
|
const height = point.dot(normal) - planeOffset;
|
|
this.positions.set(
|
|
[point.x, point.y, point.z, point.x - normal.x * height, point.y - normal.y * height, point.z - normal.z * height],
|
|
index * 6
|
|
);
|
|
}
|
|
|
|
this.geometry.setDrawRange(0, count * 2);
|
|
this.geometry.getAttribute('position').needsUpdate = true;
|
|
}
|
|
|
|
/** Crossfades the tethers, matching whichever grid they are dropping onto. */
|
|
setStrength(strength: number): void {
|
|
const clamped = Math.max(0, Math.min(1, strength));
|
|
this.material.opacity = clamped * this.peakOpacity;
|
|
this.object.visible = clamped > 0;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.object.removeFromParent();
|
|
this.geometry.dispose();
|
|
this.material.dispose();
|
|
}
|
|
}
|