Merge branch 'feat/hud-2d' into feat/hud-about

This commit is contained in:
2026-08-21 14:56:03 +02:00
4 changed files with 35 additions and 26 deletions
@@ -0,0 +1,19 @@
/**
* How a size in pixels becomes a size the scene can draw.
*
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
* share of the screen however far away it is and whatever the window is doing. Pixels are what
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
* is a statement someone can check by looking — so the two are related through a reference
* viewport and field of view, and the pixel figures are exact only at that height.
*
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
* others.
*/
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
export const REFERENCE_FOV_DEGREES = 55;
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
@@ -251,6 +251,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
private readonly objectCardRef = viewChild<SystemObjectCardComponent, ElementRef<HTMLElement>>(SystemObjectCardComponent, { read: ElementRef });
private readonly dockRef = viewChild<HudDockComponent, ElementRef<HTMLElement>>(HudDockComponent, { read: ElementRef });
/**
* The card's own box, looked up when the card changes rather than in the render loop that
* draws the leader to it. The host element is a stable wrapper; the panel inside it is what
* moves, and it is only replaced when a different body is selected.
*/
private readonly objectCardElement = computed(() => this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]') ?? null);
private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group();
@@ -795,7 +801,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const world = member.marker.getWorldPosition(new THREE.Vector3());
this.labelOverlay?.setSelection(world);
const card = this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]');
const card = this.objectCardElement();
const canvas = this.canvasRef().nativeElement;
const projected = world.clone().project(camera);
if (!card || projected.z > 1 || projected.z < -1) {
@@ -2,14 +2,11 @@ import * as THREE from 'three/webgpu';
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
const RING_SIZE_PX = 12;
const RING_PEAK_OPACITY = 0.35;
/** Same reference as `StarFieldRenderer`, so a ring and its star agree on what a pixel is. */
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
const REFERENCE_FOV_DEGREES = 55;
const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
const RING_RADIUS_UV = 0.42;
const RING_STROKE_UV = 0.06;
@@ -4,21 +4,12 @@ import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, unif
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6;
/**
* Star size is expressed in pixels for readability, but the material works in angular size, so
* the two are related through the scene's vertical field of view and a reference viewport.
* Because the size is angular, a star keeps the same share of the screen at any window size —
* these pixel figures are exact only at this reference height.
*/
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
const REFERENCE_FOV_DEGREES = 55;
const PIXELS_TO_ANGULAR_SIZE =
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
/**
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
@@ -270,13 +261,13 @@ export class StarFieldRenderer {
* sub-pixel at the far end of the camera's range.
*/
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
// What a unit of angular size is worth on screen. Under perspective that is set by the
// field of view; under an orthographic camera the same size was already turned into a world
// size by `setProjection`, so it is the frustum that converts it back.
// What a unit of angular size is worth on screen. Under perspective the field of view sets
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
// by that same half-height leaves the reference field of view and nothing else. Both cases
// are therefore one formula over a different angle.
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
const orthographic = camera as THREE.OrthographicCamera;
const halfHeightWorld = perspective ? 0 : (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
const tanHalfFov = perspective ? Math.tan(((camera as THREE.PerspectiveCamera).fov * Math.PI) / 360) : 0;
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
const projected = new THREE.Vector3();
let bestIndex: number | undefined;
@@ -293,11 +284,7 @@ export class StarFieldRenderer {
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
const ndcRadius =
(perspective
? (0.5 * this.angularSizes[index]) / tanHalfFov
: // The world size the star is drawn at, as a fraction of the frustum's half-height.
(0.5 * this.angularSizes[index] * (halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360))) / halfHeightWorld) + PICK_NDC_SLOP;
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
const dx = (projected.x - pointerNdc.x) * aspect;
const dy = projected.y - pointerNdc.y;
const score = Math.hypot(dx, dy) / ndcRadius;