From 98280f861e835e57a0253fa96e76ed14b9999552 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 5 Oct 2026 15:51:21 -0300 Subject: [PATCH 1/2] fix(universe): beyond the Local Group scale a galaxy is named, not a star or a galaxy inside it on the same pixels --- .../world-context-planner.test.ts | 26 +++++++++++++++++++ .../world-context/world-context-planner.ts | 20 +++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/renderer/src/universe/world-context/world-context-planner.test.ts b/packages/renderer/src/universe/world-context/world-context-planner.test.ts index f5a0dd96b3e..6c15923f4ea 100644 --- a/packages/renderer/src/universe/world-context/world-context-planner.test.ts +++ b/packages/renderer/src/universe/world-context/world-context-planner.test.ts @@ -769,6 +769,30 @@ test('past the Local Group scale the stars give their names to the galaxies', as { const values = labelled(1e6).map(body => body.id); assert.ok(['lmc', 'smc'].every(item => values.includes(item)), 'from 1 Mpc the galaxies are'); } }); +test('beyond the Local Group scale a galaxy is named, not a star or a galaxy inside it on the same pixels', () => { + const points = [plan.focus, ...plan.bodies]; + // The app's tiers: a featured star (M31-V1 in M31, VHK 45 in M33) is admitted before every galaxy. + const calculate = createWorldContextPlanner(plan, Object.fromEntries(plan.bodies.map(body => + [body.id, labelImportance(body.classification ?? 'star', false, 0, body.discovery?.featured === true, body.discovery?.imagery === true)]))); + const input = view(); + const named = (id: string, parsecs: number) => { + // Looking down -z at the body, from `parsecs` above it. + const at = plan.bodies.find(body => body.id === id)!; + input.world.pose.positionM = [at.positionM[0], at.positionM[1], at.positionM[2] + parsecs * 3.085677581491367e16]; + return new Set(calculate(input).projectedBodies.filter(body => body.labelShown).map(body => points[body.index]!.id)); + }; + const andromeda = named('m31', 4e6); + for (const galaxy of ['m31', 'm33']) assert.equal(andromeda.has(galaxy), true, `from 4 Mpc ${galaxy} is named`); + for (const inside of ['m31-v1', 'vhk-45', 'm110', 'm32']) assert.equal(andromeda.has(inside), false, `${inside}, within its galaxy's circle, is not`); + const home = named('milky-way', 4e6); + assert.equal(home.has('milky-way'), true, 'from 4 Mpc the Milky Way is named'); + assert.equal(home.has('lmc'), false, 'the Large Cloud, within its circle, is not'); + const near = named('m31-v1', 100e3); + for (const body of ['m31-v1', 'm31', 'm110', 'm32']) assert.equal(near.has(body), true, `from 100 kpc, inside the Local Group scale and clear of each other, ${body} is named`); + input.bodies[points.findIndex(body => body.id === 'm31-v1')]!.highlighted = true; + assert.equal(named('m31', 4e6).has('m31-v1'), true, 'a star the reader highlights keeps its name'); +}); + test('past the Solar System only the featured stars and the references keep a dot; past the Local Group no body does', () => { const featured = 'betelgeuse'; assert.equal(plan.bodies.some(body => body.id === featured && !body.orbit), true); @@ -788,6 +812,8 @@ test('past the Solar System only the featured stars and the references keep a do test('a body beyond the Local Group keeps its dot at the scale of its cluster, and loses it from the Milky Way', () => { const far = plan.bodies.find(body => body.id === 'm87-star')!, calculate = createWorldContextPlanner(plan), input = view(); + // M87 is left unnamed here: named, it stands for the black hole at its centre (the test above), and its dot covers the black hole's. + input.bodies[[plan.focus, ...plan.bodies].findIndex(body => body.id === 'm87')]!.labelHidden = true; const dotted = (parsecs: number) => { // Looking down -z at M87*, from `parsecs` above it. input.world.pose.positionM = [far.positionM[0], far.positionM[1], far.positionM[2] + parsecs * 3.085677581491367e16]; diff --git a/packages/renderer/src/universe/world-context/world-context-planner.ts b/packages/renderer/src/universe/world-context/world-context-planner.ts index 9be8e4d8818..548f5d71b03 100644 --- a/packages/renderer/src/universe/world-context/world-context-planner.ts +++ b/packages/renderer/src/universe/world-context/world-context-planner.ts @@ -11,7 +11,7 @@ import { createWorldFrameProjection } from '../world-frame-projection.js'; import { admitStableLabels, type StableLabelCandidate } from '../../labels/stable-label-layout.js'; import { createMarkerDeclutter } from './marker-declutter.js'; import type { LabelScreenRect } from '../../labels/screen-label-layout.js'; -import { coveredTopRects, createLabelBudget, FEATURED_STAR_TIER, labelExtentOpacity, labelLimit, inGalaxyField, markerScale, UNIVERSE_LABEL_POLICY } from '../../labels/universe-label-policy.js'; +import { coveredTopRects, createLabelBudget, FEATURED_STAR_TIER, labelExtentOpacity, labelLimit, inGalaxyField, LOCAL_GROUP_SCALE, markerScale, UNIVERSE_LABEL_POLICY } from '../../labels/universe-label-policy.js'; const ORBIT_LOD_PIXELS = 0.1; // Keep the existing exit thresholds. A hidden annotation must clear a small // entry margin before returning, so a boundary cannot reverse its fade each @@ -116,7 +116,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW const satellite = parent !== null && !systemFade.isSystemStar(parent.id), planet = orbit !== null && systemFade.isSystemStar(orbit.centerBodyId); const prominent = planet && (tier ?? 0) >= 3; return { body, orbit, levels, parent, satellite, planet, prominent, hosted: prominent && orbit!.centerBodyId !== plan.focus.id, minor: (tier ?? 2) < 2, scale: markerScale(distanceFromSunM, placedStar ? plan.volume : undefined), - galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), kind: 'classification' in body ? body.classification : undefined, extended: 'classification' in body && isExtendedClassification(body.classification), + galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), kind: 'classification' in body ? body.classification : undefined, inside: 'inside' in body ? body.inside : undefined, extended: 'classification' in body && isExtendedClassification(body.classification), closedOrbit: orbit?.fullTrail === true, drawnRadiusM: body.radiusM * ('billboard' in body && body.billboard ? Math.max(1, billboardImageScale(body.billboard, body.radiusM)) : 1), orbitProjection: createRetainedRingProjection(orbit ? orbit.vertexCount * 2 : 0), // A hidden body is the same retired stub every frame: no projection, no allocation, no packet. @@ -371,9 +371,23 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW // inside the viewport below, while partially clipped circles are left to // normal browser clipping. The viewport width still owns density only. const candidates: (StableLabelCandidate & { projected: ProjectedBody })[] = []; + // Beyond the Local Group scale a galaxy stands for what is inside it: a star, or a galaxy it holds (M110 in M31, the Large + // Cloud in the Milky Way), whose marker falls within the galaxy's circle is not named, so the galaxy is. By tier a + // featured star is admitted before a galaxy and held M31's name across the Local Group; by id M110 took it. The holder + // is found each frame (its row can arrive after the body's, `extend`); one named elsewhere or hidden stands for nothing. + const withinHolder = (entry: Entry, x: number, y: number) => { + for (let holder = bodies[indexById.get(entry.inside ?? '') ?? -1]; holder; holder = bodies[indexById.get(holder.inside ?? '') ?? -1]) { + if (holder.kind !== 'galaxy' || holder.labelSuppressed || holder.labelHidden || holder.bodyHidden) continue; + const [holderX, holderY] = project(frame.eye(holder.body)); + if (Math.hypot(holderX! - x, holderY! - y) < BODY_INDICATOR_DIAMETER + UNIVERSE_LABEL_POLICY.spacingPixels) return true; + } + return false; + }; for (const projected of projectedBodies) { if (projected.entry.retired) continue; // retired with its faded system: never named const { entry, x, y, diameter, annotationVisible, hovered, priority, emphasised, targeted } = projected; + const fromCamera = Math.hypot(...frame.eye(entry.body)); + if (!targeted && fromCamera > LOCAL_GROUP_SCALE.enterDistanceM && withinHolder(entry, x, y)) { projected.nameable = false; continue; } let { circle } = projected; const { body, labelSize: size } = entry; const prominentOrbiter = entry.prominent; @@ -420,7 +434,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW // read as neighbourhood: an inner planet is not less part of its system because the camera frames the outer one. // Past the Local Group scale the galaxies are named, not the stars inside them: a name fades with the body's distance // from the camera over the band where the overview becomes the Local Group (the cluster scale beyond it). - const galactic = 1 - (extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(Math.hypot(...frame.eye(body)), entry.scale.returnDistanceM, entry.scale.enterDistanceM)); + const galactic = 1 - (extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(fromCamera, entry.scale.returnDistanceM, entry.scale.enterDistanceM)); const alpha = flightDestination ? 1 : referenceAnnotationOnly ? galactic : targeted ? markerOpacity : Math.min(markerOpacity, galactic, resolvedDisc || hostedPlanet ? 1 : labelExtentOpacity(localExtent)); // Naming policy, decided before any slot is contested: suppressed, unresolved, too faint // or out of context here, and the body is not one this camera names at all. From e5bfc879bd122f90e489f9b6adeb092285162be5 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 5 Oct 2026 15:57:24 -0300 Subject: [PATCH 2/2] docs(universe): state that a galaxy stands for what is inside it beyond the Local Group scale --- docs/universe-labels.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/universe-labels.md b/docs/universe-labels.md index 8f50fad34a0..85ef582df5f 100644 --- a/docs/universe-labels.md +++ b/docs/universe-labels.md @@ -45,6 +45,14 @@ their existing importance. Available scene-body labels are admitted before the additional disabled moon captions; those captions cannot displace a clickable label. The full sidebar list remains available regardless of scene crowding. +Beyond the Local Group scale, with the camera more than 300 kpc from a body, +a galaxy stands for what is inside it. A star, a black hole or a galaxy it +holds in the object tree is not a caption candidate while its marker falls +within the galaxy's circle, so the galaxy is named: M31 rather than M31-V1 or +M110, the Milky Way rather than the Large Magellanic Cloud. A hovered, +highlighted or selected body keeps its caption. A selected galaxy is named by +its own caption and stands for nothing. + Clickable deep-space labels try below, above, right and left when crowded. Prepared cloud bounds keep those alternatives outside a nebula's image. Disabled moon captions have no circle or navigation target.