Commit Graph
4 Commits
Author SHA1 Message Date
SenrokaiandClaude Fable 5 d9bd913458 Draw it flat: an orthographic plan view
A perspective camera leans everything away from the centre of the frame. In a
system that means the orbits are ellipses whose shape depends on where they
happen to sit on screen, so two planets on the same circular orbit do not look
like they are on the same circle. Plan view, in the Display panel, swaps the
projection for a parallel one and swings to look down the plane the current
scale is read against — the galactic plane out in the field, this system's own
orbital plane inside one. Circles are circles again, wherever they are.

Both halves are the feature and neither alone is it. The projection is what
makes the shape honest; the swing is what makes it worth looking at. Orbiting
still works afterwards, so a plan is where the view starts rather than a cage.

The engine now holds both cameras and keeps them in step, rather than making
one on demand: a camera that exists only while it is being looked through is a
camera whose pose is always one swap out of date. The orthographic frustum is
derived, never stored — it is the perspective camera's own frustum at the
current orbit distance, made parallel — which is why the camera flights work
through it untouched. They move the camera; the frame follows.

Three things had to be taught that a projection had changed.

Sprites. three.js turns an angular size into a world size only when it is
compiling against a perspective camera (SpriteNodeMaterial: `camera
.isPerspectiveCamera && sizeAttenuation === false`). Under a parallel one that
step is silently skipped and every star in the field collapses to a thousandth
of a parsec. The same arithmetic is now done in the node graph behind a
uniform, so one material serves both cameras without being recompiled — and
picking follows it exactly, since a star has to be clickable where it is drawn.

Depth. A parallel camera does not back away as its frame grows, so at galactic
framing the backdrop shell and half the Milky Way sit behind its own plane. Its
depth range is symmetric about it instead, which a linear depth buffer can
afford and a perspective one could not.

And distance. Half the map was keyed on how far back the camera was pulled —
the scale ladder, the crossfade, the label radius, the range readout — which
under a parallel projection says nothing at all, because the frustum sets the
extent. They all read one honest equivalent now: the distance a perspective
camera would need to frame the same thing.

Two defects found while verifying, both mine, both from this change:

The per-frame work was computed against the camera captured at bootstrap while
the renderer drew through the other one, so after a swap every label was
projected by a camera nobody was looking through.

And the zoom limits were derived from the orbit limits, which are in whichever
unit space the view is in. Reading them on the frame the scene swaps parsecs
for astronomical units pinned the zoom at the ratio between the two, and
leaving a system landed the view three kiloparsecs out. Zoom is a plain
multiplier on a frame the distance already sets, so it is bounded by a factor.

Verified: build clean, 595/595 unit including a new spec for the projection
arithmetic, 13/13 end-to-end including two that flatten a system and check the
ladder still knows how far out it is, design detector clean, screenshots of
both scales in both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 20:11:31 +02:00
Claude 29fd92d118 Widen the star catalogue, and separate what is drawn from what is known
The map held 8750 stars within 50 pc and rendered 371 systems. Both were
lower than they needed to be, for different reasons.

The star catalogue was capped by its own encoding as much as by the
cutoff: one JSON object per star, eight key names repeated each time, 157
bytes a star. At the range HYG actually reaches that is 17 MB to download
and parse before the first frame. So the numbers move into two binary
column stores — positions in stars.bin, which the GPU is handed verbatim,
and id/magnitude/colour/spectral index in stars-meta.bin — and the JSON
keeps only the strings, with 2600 distinct spectral classifications
collapsed to a dictionary. The layout is defined once, in star-catalog.ts,
and the ETL and the app both use it, so the writer and the reader cannot
drift.

The cutoff then goes to 250 pc: 68388 stars, 7.8x as many for 1.7x the
bytes. That is where HYG's measurements stop rather than a round number —
98.6% of its rows are Hipparcos, whose parallaxes are good to about a
milliarcsecond, so beyond 250 pc it would be plotting noise.

Drawing all of them is a separate question from knowing them, and it is
answered separately. The field draws a budget: every star inside 25 pc,
because the nearest are faint red dwarfs and Proxima Centauri is magnitude
11, then the brightest of everything beyond. Search, navigation and the
planet cross-reference still see the whole catalogue. A real GPU would
draw all 68388 without noticing; the budget is for the machines that would
not, and it is one constant.

Systems were limited by something else entirely. The archive data already
shipped named 4735 host stars and only 388 resolved, because the rest lay
outside a 50 pc catalogue — and the cross-reference kept only its own
result, so redoing it meant re-downloading an archive that is not
reachable from here. Host coordinates are now stored with each planet, and
the match is re-resolved at build time against whatever catalogue the run
produced. Even name matching alone, which needs no coordinates and so
works on the records already shipped, rescues 335 planets across 238
systems: 371 renderable systems become 609.

Two selection rules were tuned for a 50 pc bubble and no longer fit.
Tethers followed the Sun's nearest neighbours, which are a speck at this
range, and now follow the brightest; labels were ranked by proximity,
which named whatever sat nearest the middle of the screen, and are now
ranked by brightness — so the view names Canopus, Achernar and Spica
rather than a clump of catalogue designations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-05 08:35:41 +00:00
Claude c1feb4b74e Rewrite the star field as instanced billboards
Plan step 3 promises glow and size driven by magnitude and spectral type, but
the star field was a THREE.Points cloud and the WebGPU backend — the renderer
this app targets — caps point primitives at a single pixel. Every one of the
8750 stars drew as an identical 1 px dot with a hard edge, discarding the
magnitude sizing entirely; the class comment already admitted sizeNode only did
anything on the WebGL2 fallback.

Each star is now an instanced camera-facing quad on a SpriteNodeMaterial, which
behaves the same on both backends. That material takes each instance's centre
from positionNode rather than from an instance matrix, so position, colour and
size ride on instanced buffer attributes and the mesh itself never moves. A
radial falloff in opacityNode gives each star a bright core inside a soft halo.

Sizes are angular rather than world-space. That keeps a star the same apparent
size at any camera distance, which is both what the old screen-space points did
and what is physically right: real stars are unresolvable point sources, so
apparent size follows brightness, not distance. World-space quads would instead
have made the whole field vanish at the camera's 2000 pc limit.

Picking had to be rebuilt. Billboarding happens in the vertex shader, so the
CPU-side geometry is one quad at the origin and Raycaster cannot see the star
field at all. Selection is now done in screen space against the size each star
is actually drawn at, which is strictly better than the fixed 1.2 pc world
radius it replaces — that radius was over-permissive up close and sub-pixel at
the far end of a 4000x camera range. Stars behind the camera need an explicit
depth guard, because project() mirrors them back onto the screen.

Two things only caught by running it. The colour attribute was declared with
node type 'color', which is not a GLSL type, so the generated shader failed to
compile — it has to be vec3. And the click tolerance was first written as a
floor on the drawn radius, which flattened every star to one hit size, since a
floor generous enough for the faintest star exceeds the brightest star's radius;
adding the slop instead keeps a brighter star the easier target.

Tests: 151 passing, up from 145. Verified in a real browser — shaders compile
clean and the Playwright click-to-select flight passes against the new picking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-04 11:11:23 +00:00
Claude 4aca223027 Regenerate the star catalogue, fixing 2331 names and 875 colours
Two ETL bugs, both fixed at the source and then re-run against HYG. Star ids,
ordering and positions are all unchanged, so stars.bin is byte-identical and
every exoplanet cross-reference still resolves.

Names. HYG's `gl` column already carries its own catalogue prefix ("Gl 581",
"GJ 3512"), unlike the bare numbers in `hd` and `hip`, so prefixing it again
produced 2331 of 8750 stars named "Gl GJ 1076". That corrupted three surfaces at
once: search, the on-screen labels, and exoplanet host-star name matching, which
compares normalised names and could never match "glgj1076" to "gj1076".

Colours. `Number(row['ci']) || 0` cannot tell a blank cell from a real zero, and
0 is a real B-V colour index meaning a hot blue-white A-type star. All 875
affected stars turned out to be blanks — the catalogue contains no genuine zero
inside the distance cutoff — so several hundred red dwarfs were rendering
blue-white. colorIndex is now `number | null` rather than defaulted, because any
numeric default is indistinguishable from a measurement.

Consumers resolve the gap from the spectral type instead. That needs real
parsing: HYG's `spect` column runs to 134 distinct spellings among the affected
stars alone, including a bare lowercase "m" for 354 of them, plus "k-m" ranges,
"dM4" luminosity prefixes and "K:" uncertainty flags. 622 of the 875 recover a
class this way — 497 of them M-class — and the remaining 253, which carry no
classification at all, fall back to neutral white.

The parse is anchored at the start of the string rather than scanning it. A scan
is the obvious implementation and is quietly wrong: the ETL writes the literal
"Unknown" for unclassified stars, that contains a K, and every one of those 253
would have been classified as an orange K-type. A test covers it.

Also lifts parseOptionalNumber out of fetchExoplanets into lib/csv, where both
fetchers now use it, and gives magnitude a faint default instead of 0 — no
current star is affected, but 0 would mean "as bright as Vega" and render an
unphotometered star as one of the largest points on the map.

Tests: 145 passing, up from 116, including the first coverage of
StarFieldRenderer. Build, both typechecks and the Playwright suite are green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-04 10:56:30 +00:00