Developer-facing notes. Companion to model.md, which explains the science.
src/
PlateTectonicsColors.ts every ProfileColorProperty (default + projector)
PlateTectonicsConstants.ts layout px, Earth-science quantities, time range
common/
EarthProjection.ts the interface both projections implement
MapProjection.ts equirectangular lon/lat ↔ view, with a camera
GlobeProjection.ts orthographic lon/lat ↔ view, with a camera
attachGlobeRotation.ts drag + arrow keys → the globe's camera
attachMapNavigation.ts drag + arrow keys → the flat map's camera
PlateReconstruction.ts Euler-pole rotation and plate velocities
data/
dataTypes.ts shapes of every dataset (hand-written)
hotspots.ts hand-maintained hotspot list
generated/ npm run build-data writes these — do not edit
earth/
model/
EarthModel.ts all AXON state
EarthquakeDepthFilter.ts depth bands and the filter predicate
view/
EarthScreenView.ts layout, view switching, relief loading, pdomOrder
EarthCanvasNode.ts the layers both global views share (canvas)
MapCanvasNode.ts the flat global map
GlobeCanvasNode.ts the 3-D globe
PlateOverlayNode.ts plate labels and motion arrows (Scenery nodes)
LayerControlPanel.ts layer checkboxes + depth filter
ViewControlPanel.ts the globe / flat-map switch
TimeControlPanel.ts time slider, play/pause, speed
MapLegendNode.ts legend strip + data credit
LegendSwatches.ts the symbols, shared by legend and checkboxes
scripts/
build-data.ts fetches and reshapes every dataset
data/ fetch cache, GeoTIFF + netCDF readers, geodesy,
marching-squares contouring, emitters
The map draws roughly 8 800 earthquakes, 1 600 volcanoes, 1 580 boundary segments and
several thousand outline vertices — and every one of them moves when the
reconstruction clock runs, because each vertex is rotated about its plate's Euler
pole. Rebuilding that many Kite Shapes per frame does not keep up, so MapCanvasNode
is a CanvasNode that paints them directly. It repaints only when something it
depends on changes: a layer toggle, the depth filter, the reconstruction time, or a
colour-profile switch.
Things that are few and need crisp text stay ordinary Scenery nodes: the sixteen plate
labels and motion arrows (PlateOverlayNode) — which also keeps them localized and
reachable.
PlateReconstruction.transform writes its result into public scratch fields rather
than returning an object, because it is called tens of thousands of times per frame
and allocating there would dominate the frame budget. At the present day it is the
identity and returns immediately, which is the common case.
Three problems come out of the projection, all handled in MapCanvasNode.appendPolyline:
- The antimeridian. Longitudes are unwrapped as a polyline is walked — each vertex is nudged by whole turns to stay within half a turn of the previous one — so a feature that straddles ±180° stays in one piece. The first vertex is unwrapped against the camera instead, which puts the feature on the copy of the world the map is looking at; the feature is then repeated a world-width either side whenever that copy would show it too, and the clip keeps whichever copies are on screen.
- Circumpolar rings. The North American plate reaches right around the Arctic and the Antarctic plate around the South Pole, so their rings gain a whole turn of longitude. Filling one has to route over the pole it encloses, or the fill spills across the map. The turns the walk accumulates are counted separately from the turns that carried the feature to the camera, or a plate far from the camera would be mistaken for one that goes round the world.
- Closing. Every ring in the data repeats its first vertex at the end, so outlines
are already closed and
closePathis only used for fills — which is just as well, because on an unwrapped ringclosePathwould draw a chord straight across the map.
Coastline vertices carry a per-vertex plate index, so a coastline that straddles a boundary tears apart correctly under reconstruction — Baja California rides the Pacific plate away from North America. The outline is broken at those tears (the fill still spans them) so the torn edge does not leave a stray line across the ocean.
MapProjection carries a camera of the same shape as the globe's — the longitude and
latitude at the centre of the viewport — plus a zoom level the map is drawn at 2^level.
attachMapNavigation moves it, in the same two senses attachGlobeRotation uses: a
drag takes hold of the map, and the arrow keys move the viewpoint.
The two axes are not symmetric, and the projection is why. Longitude is periodic, so
panning east wraps and never stops. Latitude is bounded, so the camera is clamped to
latitudeLimit = 90 − 90/scale: zero at level 0, where the whole 180° is already on
screen and there is nothing to pan to, and opening up as zooming in shrinks what fits.
That is the whole of "left and right always, up and down once you are zoomed in" — no
interaction code special-cases it.
Three things follow from the camera, and each one is a bug that was visible before it was fixed:
projectnow reports false outside the viewport, which is what stops a plate label being drawn over the legend and saves the canvas from plotting 9 000 epicentres that are four viewport-widths off to the side. The flat overlay is clipped to the viewport as well, so a label at the edge is cut rather than spilling.viewXdeliberately does not wrap: the mapping stays linear so an unwrapped polyline keeps its shape. Wrapping isproject's business, for single points.- The dataset seams — the ±180° slits and polar closures that
PLATESandLAND_RINGSare cut along — used to sit exactly on the edge of the viewport, where they could not be seen. Panning moves that edge, so they are now skipped when stroking (never when filling) by the sameisSeamSegmentrule the globe has always used; it moved toEarthCanvasNodewhen the second caller appeared. Without it the Pacific gets a bright line straight up the middle of it.
Reset All puts both cameras back, through EarthScreenView.reset — a camera
is a way of looking at the Earth rather than a fact about it, so neither belongs in the
model. showGlobeProperty does, because it is a choice about what is shown.
The 3-D globe (GlobeCanvasNode, off by default) is the same layers in the same
order and the same colours; only the projection differs. Both views are written against
EarthProjection, whose project returns whether a point can be seen as well as
where it goes — always true on the flat map, and false for the far hemisphere on the
globe. Everything that draws geography goes through it, including PlateOverlayNode,
which is instantiated once per projection.
GlobeProjection is plain orthographic (Snyder pp. 145–153) with the camera stored as
the longitude and latitude at the centre of the disc, so dragging is just moving that
point. It also inverts, which is how the equirectangular relief raster gets onto a
disc: each pixel is un-projected and sampled, into a texture rebuilt only when the
camera moves.
The rectangle's problems (the antimeridian, circumpolar rings) vanish; three of its own
take their place, all in GlobeCanvasNode:
- The limb. Polylines are cut where they cross it — the crossing is found by
interpolating on
GlobeProjection.depth, which changes sign exactly there. Filled outlines cannot simply be cut, so where one dips behind the limb it detours to a ring outside the disc, skirts round to where it reappears and drops back in; the disc clip trims the detour into a clean round edge. (This is NAAP's trick, by way ofBasicCoordinatesAndSeasons.) - Long segments. The datasets were shaped for a flat map, where a straight line between vertices is right and a long one is harmless — the Pacific plate's eastern edge climbs 66° of latitude in a single step. On a globe that step is a chord straight through the Earth, so segments are sampled along their great circle every 5°, which puts them back on the surface where the limb can hide them.
- Dataset seams. A plate that straddles the antimeridian is stored slit open along
±180°, and one that reaches a pole is closed off along the pole — fourteen such
segments in
PLATES, plus two polar ones. On the flat map they fall exactly on the edge of the viewport and are never seen; on a globe the antimeridian is an ordinary meridian, so they are filled but not stroked.
Motion arrows differ too, and the difference belongs to the projection: bearing on
the flat map draws the compass azimuth as a screen angle ("the Nazca plate moves
east"), while on the globe it steps along the bearing across the sphere and projects
both ends, because north is only "up" at the centre of the disc.
npm run build-data is the only thing that touches the network. It writes
src/common/data/generated/, which is committed, so an ordinary npm run build is
offline. Downloads are cached under .cache/data/ (git-ignored) keyed by a hash of
the URL, so changing a query parameter re-fetches instead of silently reusing the old
response.
Notable pieces:
-
scripts/data/dem.tscontains a ~120-line GeoTIFF reader. NOAA's image service returns an uncompressed, tiled, signed-16-bit TIFF, which is little enough format to parse directly and saves a dependency. -
scripts/data/netcdf.tsreads the classic netCDF-3 format, for the same reason: the EarthByte age grid is published as a header of big-endian counted fields followed by the raw values, which is small enough to parse directly and saves the build a dependency on the netCDF/HDF5 stack. -
scripts/data/contour.tsis marching squares, used once, to turn that age grid into isochron polylines. Cells with a missing corner are skipped whole, so a contour stops at the edge of the ocean instead of being interpolated onto land, and it works in grid coordinates so that two neighbouring cells produce bit-identical crossings on their shared edge — which is what lets the segments be strung together by exact key match rather than by proximity. -
Boundary segments are rebuilt from PB2002's step file rather than its boundary file, because only the steps carry the class (
OSR,SUB,CTF, …) that the divergent / convergent / transform colouring needs. -
The relief PNG is rendered at build time from the DEM with a bathymetric-to-alpine colour ramp plus a north-west illumination, and a light ice mask over high polar ground so Greenland and Antarctica read as ice rather than as mountains.
-
scripts/data/gplates.ts+scripts/data/gplates/resolve.pyresolve the deep-time plate model. This is the one step that shells out to Python, and it earns the exception: a GPlates rotation file is a hierarchy of relative rotations whose shape changes with time, and a plate polygon at 100 Ma is rebuilt from whichever moving boundary features bounded it then. pyGPlates is the reference implementation of both, and reimplementing it in TypeScript to save a build-time dependency would be trading a correct answer for a fashionable one. The step creates its own virtualenv under.cache/gplates/, caches the resolved JSON keyed by span and step, and is not needed bynpm run build,npm test, or the shipped sim.
Generated files are excluded from Biome (see biome.json) and formatted by
scripts/data/emit.ts instead, which keeps the numeric arrays compact.
The sim reconstructs plate positions in two quite different ways, and the rendering path is shared rather than duplicated between them.
PlateReconstruction (Earth screen) spins each plate about a fixed Euler pole at a
constant rate. DeepTimeReconstruction (Deep Time screen) interpolates a published
model's sampled finite rotations with a quaternion slerp. What they have in common is
the shape of the answer: transform(lon, lat, frame) writing to scratch lon/lat
fields, which is the SurfaceTransform interface in GlobeFeaturePainter.ts.
That is what lets both screens share GlobeFeaturePainter — the sphere-on-a-disc work
described above, which is by some distance the trickiest code in the sim and the last
thing that should exist in two copies. The painter takes a GlobeProjection and a
SurfaceTransform and knows nothing else about either screen.
Two consequences worth knowing:
- The Deep Time screen's resolved geometry — plate polygons, boundary lines — is
already at the instant being drawn and must not be rotated again. It still goes
through the painter, because subdividing long segments and cutting at the limb apply
to it just as much, so it is handed
IDENTITY_ROTATION_SLOT: row 0 of the rotation table, reserved at build time and guaranteed to be the identity at every sample. There is a test on that, because if it ever stopped being the identity the plates would slide off the continents they belong to. - The Deep Time plate wash is composited on an offscreen canvas and drawn once at
PLATE_FILL_OPACITY, rather than filled plate by plate. The model's topologies are not a clean tiling — several plate IDs resolve to more than one polygon at the same instant, flat slabs and sub-plates overlapping the plate they belong to — and filling each straight onto the globe stacks the alpha in the overlaps, which came out as near-black slivers.
EarthScreenSummaryContentderives its current details paragraph from the model, so a screen-reader user hears whether the globe or the flat map is showing, which layers are drawn, which depths pass the filter, and where in geological time the plates are.- Every control carries an
accessibleName(and a help text where it earns one) from thea11ystring group. EarthScreenViewsets an explicitpdomOrder: the global view and its zoom buttons → view switch → layer checkboxes → depth filter → time slider → time controls → Reset All. The map and the globe are both in it; whichever is hidden drops out on its own.- The keyboard-help dialog has a section per interaction kind: slider, moving a draggable item (which is how both the map and the globe are moved), and basic actions.
npm test runs Vitest over tests/:
| File | Covers |
|---|---|
PlateReconstruction.test.ts |
Euler-pole rotation, round trips, and plate speeds against published values |
EarthModel.test.ts |
layer state, depth bands, the time clock and reset |
MapProjection.test.ts |
projection round trips, the 2:1 viewport, motion-arrow bearings, the camera |
GlobeProjection.test.ts |
orthographic projection and its inverse, visibility, bearings, the camera |
geophysicalData.test.ts |
integrity of every generated dataset, plus a few facts about the Earth |
memory-leak.test.ts |
WeakRef + forced GC on disposables |
geophysicalData.test.ts is the guard on npm run build-data: it fails if a
regeneration returns something mangled, and it checks that deep earthquakes still
cluster around the Pacific, which no amount of reshaping should change.
src/
common/
model/ ColorMode.ts density / temperature / both
Isostasy.ts Airy elevation, crustal density, crustal geotherm
EarthStructure.ts PREM density, layer boundaries, layer temperatures
CrossSectionScale.ts two-band model-metres → view-pixels mapping
EarthCurvature.ts planar arc lengths → a point on a sphere
SectionViewModel.ts flat or 3-D block, and the vertical stretch
view/ EarthMaterial.ts density and temperature colour ramps
ColorModeControlPanel.ts the shared "View" panel
MaterialLegendNode.ts the ramp legend
EarthProbeNode.ts the draggable temperature/density probe
SectionRulerNode.ts the draggable ruler
SectionPlacement.ts what anything drawn over a section needs
SceneCamera.ts the block's perspective projection
QuadRenderer.ts depth-sorted, flat-shaded faces
EarthBlockNode.ts the 3-D block both screens share
TerrainColors.ts the elevation ramp on the block's surface
CanvasArrows.ts arrow-heads and flow lines
crust/ the Crust screen
plate-motion/ the Plate Motion screen
earth/ the globe and the flat map
The three screens share the material colour ramps, the probe, the ruler, the colour-mode panel and the vertical scale; the two schematic ones also share the 3-D block. The global screen shares none of the section machinery, because it draws no section: it is a map, and everything on it is a published dataset rather than a computed shape.
PlateMotionCanvasNode paints sky, then sea, then mantle, then the plates. The mantle is
clipped to below the ground surface — the merge of both plates' crustTop polylines,
sorted by x. An unclipped mantle band starts at sea level and paints straight over the
water, which is what made showSeawaterProperty a no-op and left every ocean floor with
sky above it rather than sea. The clip is also what lets a mountain belt standing above
the waterline still get mantle painted underneath it.
Two related traps, both handled in that file:
- A
PlateOutline's three polylines must run the same direction along x.fillBandcloses a band by walking the top forward and the base back; two that disagree fold the band into a bowtie.tests/PlateGeometry.test.tsasserts the invariant for every pairing, motion and time, because the failure is silent in the model and spectacular on screen. CrossSectionScale.yclamps. A slab that has descended past the bottom of the view does not vanish — every point below the floor lands on the floor, so it is drawn as a horizontal smear along the bottom edge with its arrow-heads strung out sideways.paintSlabtrims the centreline at the first point pastscale.bottomM.
PhET's Plate Motion tab accumulated geometry frame by frame, mutating arrays of samples
inside five behaviour classes. PlateGeometry computes the whole boundary from elapsed
time and nothing else:
(motionType, leftType, rightType, tMyr) → BoundaryGeometry
Nothing in PlateMotionModel mutates a shape. The consequences are worth stating
because they are the reason for the choice:
- Rewind and step-while-paused are free and exact. Setting the clock to any value gives the picture for that value; there is no accumulated state to unwind.
- The evolution is unit-testable without a clock. Every claim in
tests/PlateGeometry.test.ts— that a rift opens, that an arc appears inland of a trench, that a collision conserves area — is asserted by evaluating the function at a time, not by stepping a simulation and hoping. - The picture cannot drift with frame rate. There is no integration to accumulate error.
The one thing given up is PhET's Poisson process for individual magma blobs. The deterministic version looks the same and reproduces on reload.
The same reasoning does not apply to the Crust screen's isostatic settling, which is
genuinely a relaxation with state, and is integrated in IsostaticRelaxation with a
sub-stepped semi-implicit scheme so it stays frame-rate independent by construction.
PhET's original ran on LWJGL with a 3-D camera, and both schematic screens were block diagrams rather than flat sections. That is back, rendered in software:
common/model/ EarthCurvature.ts planar arc lengths → a point on a sphere
SectionViewModel.ts flat or block, and the vertical stretch
common/view/ SceneCamera.ts perspective projection, and the ray back out
QuadRenderer.ts face collection, depth sort, flat shading
EarthBlockNode.ts the block: terrain, walls, water, front face
TerrainColors.ts the elevation ramp on the top surface
SectionPlacement.ts the one thing labels and tools need from a view
SectionRulerNode.ts the ruler, back from RulerNode3D
crust/view/ CrustBlockNode.ts
plate-motion/view/ PlateMotionBlockNode.ts
SceneryStack ships mobius, a three.js wrapper, and it would have given a depth buffer,
per-vertex normals and textures. This does not use it. The pipeline needed here is one
rotation, one translation and a perspective divide, and writing it out keeps the
projection a pure function that is unit-tested rather than trusted, keeps the picture
reproducible without a GPU, and keeps roughly 180 KB of gzipped three.js out of the
bundle.
What that costs is a depth buffer. QuadRenderer substitutes the painter's algorithm —
faces sorted back to front — which is exact here because nothing interpenetrates: the
terrain is a graph over the ground plane and the cross-section is a stack of bands on one
flat sheet. Where depth genuinely cannot decide the order, an explicit BLOCK_LAYER
group does, which is this renderer's version of the moveToFrontNotifier the Java
version used for the same purpose. The groups are spaced ten apart so a subclass can
subdivide one; Plate Motion needs four sub-orders inside sectionRock alone.
The other cost is flat shading: one Lambert factor per face, no texture. Java modulated everything by a tiled noise bitmap. The grids are sampled finely enough that the ground reads as a surface, but a close look finds facets.
EarthBlockNode owns the block, the terrain, the end walls, the water and the camera. A
subclass says how high the ground is, what rock is at a point, and — if its section has
exact layer boundaries — how to paint the front face.
The default front face samples a colour per grid cell, which is what the Crust screen
needs because its content is a continuous field. PlateMotionBlockNode overrides it with
band polygons, because its layer boundaries carry the meaning and grid sampling would
turn each of them into a staircase. That is also far cheaper, which matters because that
screen repaints every frame while its clock runs.
Java hard-coded the camera distance for a 1008 × 676 stage and flew the camera into the
block, so only part of it was ever in frame. SceneCamera.framing instead solves for the
distance that fits a given block into a given viewport, which is what lets a zoom level
change the block and have the camera follow. The consequence is that the block's depth
now has to be chosen to look right — see BLOCK_DEPTH_PER_HEIGHT, which is set against
the block's height rather than its width because what it controls is how much of the
picture the top face takes.
The labels, the probe and the ruler are Scenery nodes, not painted pixels, so they stay
localizable and reachable. Each therefore needs to be positioned in view coordinates, and
each would otherwise have to know which view is showing. SectionPlacement is the one
interface that keeps that in a single place: a screen picks a placement when the mode
changes and hands the same one to everything.
contour is part of that interface rather than something callers derive from two
modelToView calls, because the two views differ in more than a transform: a line of
constant elevation is a horizontal line when flat and an arc on the block, and drawing
sea level as a chord would put the horizon under the ocean.
Five kinds of thing go through it:
common/view/ RangeLabelNode.ts an extent: two model points, a bar, a name between them
BoundaryLineNode.ts a dotted line along a surface, with an x window
EarthProbeNode.ts the temperature/density probe
SectionRulerNode.ts the ruler
plate-motion/view/ PlateHandleNode.ts the manual-mode drag handle
RangeLabelNode is a measurement rather than a caption, and that distinction is the
reason it exists. A word floating in a band says which rock it is; a bar from the top of
the range to its bottom says where the range starts and stops, which on the Crust screen
is the quantity the thickness slider changes and on Plate Motion is the only thing telling
the crust apart from the lithosphere it rides on. Two things about it are load-bearing:
- The label is centred in the visible part of the range, not between its two ends.
A range whose base is off the bottom of the picture — the whole-Earth zoom's core, any
shell on a stretched block — would otherwise put its name where nobody can read it. This
is PhET's
getLabelPosition, andrangeLabelLayoutis a pure function so it can be tested rather than eyeballed. - A range too short to hold its name collapses to a leader line out to the side rather than being dropped. That is what lets a 6 km oceanic crust still be named on a section 300 km deep — the case the old "skip anything under 18 px" rule silently lost.
The bar is clipped to the viewport and the name is not: the name has already been pulled inside, while an unclipped leg runs across the legend and the navigation bar below.
Overlapping ranges are put at different model x. A plate's crust and its lithosphere share a top edge and differ only in where they end, so two bars at the same x would be drawn one over the other; PhET staggered them at ⅙ and ⅓ of the way out from the boundary and that is kept. The Crust screen staggers its four shells the same way.
The original's thermometer and density meter remain merged into one EarthProbeNode
reporting both quantities at one point — not only tidier, but the screens are about
temperature and density not being independent, and reading both at the same place at
the same time is what makes that legible.
The ruler is back. It was dropped on the grounds that a flat section carries its own
implied scale; the block takes that away, being a perspective picture with a
user-adjustable vertical stretch. It is a rigid RulerNode fitted to the section at its
own position — both ends projected on every move, setting its pixel length and angle —
which is exact at the ends and wrong by well under a tick in between, in exchange for
keeping its numbers as real text.
Before anything can move there has to be a plate on each side, and the two ways of putting
one there end at the same call, PlateMotionModel.activateZone( side ).
A press on a crust piece picks it up — armedPlateTypeProperty — and a press on a drop
zone hands it over. The chooser used to fill the first empty side instead, which meant the
user could not say which side a plate went to; a boundary is a comparison between two
sides, so "old ocean on the left" and "old ocean on the right" are not the same experiment.
The same two presses now say which. A zone pressed with nothing in hand clears whatever is
already there, so one side can be changed without New Crust taking the other with it.
A piece can also be dragged straight into a zone, which is what PhET had and what the piece looks like it wants. It is a shortcut through those two steps rather than a second mechanism: leaving the piece arms it, so the zones light up exactly as a click would, and releasing over a zone activates that zone. Two consequences worth knowing:
- A drag released short of a zone leaves the piece in hand, so a miss degrades into the press path instead of dropping what was picked up.
- The push button and the drag stay out of each other's way by a distance threshold. Below it nothing has happened and the button fires as usual; past it the drag takes over and interrupts the button, so one press cannot both drop the piece and toggle it back out of hand. The drag listener is deliberately unattached to the pointer — the button underneath claims it first, and that button is the whole keyboard path.
dropZoneBounds is a pure function of the placement, so which side a release lands on is
unit-tested in both views rather than eyeballed in one. Everything else about a zone —
where it is, how it highlights, whether it is still a target — follows the section it is
drawn on, which is why the chooser is handed a CrustDropTarget by the screen instead of
working any of it out itself.
PlateHandleNode is a handle standing on each plate; dragging one is what advances the
clock, and the direction of the drag is what chooses the boundary type. That is the
screen's causal story — the ridge appears because the user pulled the plates apart, not
because they picked the word "divergent" off a list — and it is why PhET made manual the
default mode rather than an extra.
It does not weaken time-as-a-pure-parameter. A handle moves
timeMillionsOfYearsProperty and nothing else; every shape is still a pure function of
it, so Rewind and step-while-paused stay exact. PhET's manualHandleDragTimeChange called
clock.stepByWallSecondsForced for the same reason.
Three pieces, and the split is deliberate:
PlateMotionModel.isManualModeProperty— while it is set,stepdoes not advance the reconstruction clock at all. PhET'sallowClockTickOnFrame.PlateMotionModel.selectMotionFromDrag— which motion a deflection means, and whether this pairing can do it. In the model, not the handle, so it is unit-tested with the rest of the state machine. A drag that would select an illegal motion is refused and the already-disabled radio button is what explains why; the handle grows no error surface of its own.manualDragRateMyrPerSecond— PhET'smapDragMagnitude,2.5·θ², with the handle's deflection fraction standing in for the angle its handle was tilted through. Quadratic, so a small pull creeps and a hard pull runs.
One divergence: PhET took the absolute value of the rate, so a handle pushed back still ran the boundary forwards. Here the sign is kept, which makes the handle a scrub control as well as a throttle and gives the arrow keys on a focused handle something a keyboard user would expect. It costs nothing — the clock is a parameter, so running it backwards is exact.
Being held out is a duration, not an event, so it has no Property change to hang itself
on; PlateMotionScreenView.step is what advances the clock while a handle is held.
- Transform boundaries. Not for the reason they used to be — see the spike below.
- A toolbox. The probe and the ruler are always on screen, which is what the probe already did; adding a place for them to hide would be a new affordance rather than a restored one.
The documented reason for dropping transform boundaries was that strike-slip motion is
displacement into the page and a cross-section cannot show it. That was true when both
schematic screens were flat sections. The block has depth, so two halves of it sliding
past each other in z is precisely the picture a cross-section could not draw — better than
PhET's own version, which reduced to a rift valley plus arrows. The argument had to be
re-run, and the risk named was that the front face stops being one flat sheet and
QuadRenderer's painter's algorithm stops holding.
It holds. Measured against the real renderer:
- Two section halves on different z planes are ordered correctly by depth. The sort is by mean projected depth within a layer, and two parallel planes never tie.
BLOCK_LAYERneeds no new groups. Its sub-orders are per material — mantle, slab, lithosphere, crust — not per side, so both halves' crust go in one layer and depth separates them. The trap is the opposite: giving each half its own sub-layer would make layer beat depth and paint the far half over the near one.- Neither half interpenetrates the other, so the algorithm stays exact.
What transform would actually need is therefore not a renderer redesign:
- a z offset per side on
BoundaryGeometry, left at zero by every other behaviour; - terrain emitted as two heightfields rather than one, with two new wall faces along the fault — which is the fault scarp, and the whole point of the picture;
- the mantle backdrop emitted per half rather than as one band across the section;
SceneCamera.framinggiven the offset corners. At ±40 km a pulled corner still lands inside the viewport, but the framing points are the block's extent and would be wrong.
So the gate the plan set is open, and doc/model.md records the decision. The remaining
undecided piece is what the flat section does, which genuinely cannot show the motion.
Node already has a bounds property and a scale() method, so nodes that hold a
viewport or a CrossSectionScale name those fields viewBounds and sectionScale.
Shadowing either silently breaks layout in ways that are hard to trace.
Biome's noUnusedPrivateClassMembers does not see reads through const { x } = this
destructuring, so painters read this.sectionScale directly.