diff --git a/src/app/features/galaxy-system/angular-size.ts b/src/app/features/galaxy-system/angular-size.ts new file mode 100644 index 0000000..c10cfc6 --- /dev/null +++ b/src/app/features/galaxy-system/angular-size.ts @@ -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; diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index e6fc830..bb48552 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -251,6 +251,12 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly leaderRef = viewChild.required>('leader'); private readonly objectCardRef = viewChild>(SystemObjectCardComponent, { read: ElementRef }); private readonly dockRef = viewChild>(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) { diff --git a/src/app/features/galaxy-system/host-star-rings.ts b/src/app/features/galaxy-system/host-star-rings.ts index 68fce33..2b634ae 100644 --- a/src/app/features/galaxy-system/host-star-rings.ts +++ b/src/app/features/galaxy-system/host-star-rings.ts @@ -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; diff --git a/src/app/features/galaxy-system/star-field-renderer.ts b/src/app/features/galaxy-system/star-field-renderer.ts index cc7c98e..8220d43 100644 --- a/src/app/features/galaxy-system/star-field-renderer.ts +++ b/src/app/features/galaxy-system/star-field-renderer.ts @@ -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;