From 22707d31bc3c60005b89950f0e1fd97476093e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:51:55 +0000 Subject: [PATCH] Fix the rendering faults on the Crust and Plate Motion screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plate Motion was drawing four things wrong, all visible on first load: - **The sea was never painted.** `paintMantle` filled the section from sea level down, over the seawater rectangle drawn just before it, so `showSeawaterProperty` was a no-op and every ocean floor had sky above it instead of water. The mantle is now clipped to below the ground — the merge of both plates' `crustTop` polylines — which also lets a collision belt standing above the waterline keep mantle under it. - **The subducting plate drew as a bowtie.** `subductionGeometry` reversed the down-going plate's `crustTop` but not its two base lines. `fillBand` closes a band by walking the top forward and the base back, so the mismatched direction folded the plate into a self-crossing X spanning half the section. All three polylines now run the same way, and `PlateGeometry.test.ts` asserts that invariant for every pairing, motion and time — it fails on the old code. - **Density mode, the default, showed no plates and no slab.** The lithospheric mantle and the descending slab were both painted at the asthenosphere's `MANTLE_DENSITY_KG_M3`, so each plate appeared to be crust floating on nothing. They are the same rock at different temperatures, and the density difference is the whole reason a cooled plate can sink, so they now get their own values. - **A spent slab smeared along the floor.** `CrossSectionScale.y` clamps, so a slab past the bottom of the view was drawn as a horizontal streak along the bottom edge with its arrow-heads strung out sideways. The centreline is trimmed at the first point below the section. Also on that screen, the crust chooser overlapped the cross-section: the section's top was inset by a hardcoded 62 px against a panel that is taller than that. It is measured now, since the height depends on the font and on the length of the localized crust names. On Crust, a block whose surface is below sea level had its label placed in the few pixels of water above it, landing on the sea-level line and its caption — which is exactly where the user's own block sits at the default thickness. Submarine blocks now carry the label just inside the rock; blocks standing above the water keep theirs in the sky. `columnAt` also treats the outermost edge of the last block as inside it, so the right-hand edge of the viewport no longer reads as open mantle to the painter and the probe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JZ1JDB8VPtJPHoiu7C33kQ --- doc/implementation-notes.md | 21 +++++ doc/model.md | 15 +++ src/PlateTectonicsConstants.ts | 26 ++++++ src/crust/model/CrustModel.ts | 17 +++- src/crust/view/CrustLabelsNode.ts | 31 +++++-- src/plate-motion/model/PlateGeometry.ts | 14 ++- .../view/PlateMotionCanvasNode.ts | 91 ++++++++++++++++--- .../view/PlateMotionScreenView.ts | 17 ++-- tests/PlateGeometry.test.ts | 26 ++++++ 9 files changed, 225 insertions(+), 33 deletions(-) diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index 5f7ffdc..ac51911 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -271,6 +271,27 @@ models, for the benefit of one painter. a compressed deep one; only the new one carries a zoom level, and only the old one knows about DEM profiles. +### Painting order on Plate Motion + +`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.** `fillBand` + closes a band by walking the top forward and the base back; two that disagree fold the + band into a bowtie. `tests/PlateGeometry.test.ts` asserts the invariant for every + pairing, motion and time, because the failure is silent in the model and spectacular on + screen. +- **`CrossSectionScale.y` clamps.** 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. + `paintSlab` trims the centreline at the first point past `scale.bottomM`. + ## Time as a pure parameter PhET's Plate Motion tab accumulated geometry frame by frame, mutating arrays of samples diff --git a/doc/model.md b/doc/model.md index 2234bc4..49d031a 100644 --- a/doc/model.md +++ b/doc/model.md @@ -330,6 +330,21 @@ Old ocean floor is denser and its lithosphere thicker than young ocean floor bec has had longer to cool. That single difference is what lets the screen answer "which one subducts?" with age rather than composition. +The mantle on this screen is drawn at three densities, not one, because they are three +different temperatures of the same rock: + +| Material | Density kg/m³ | Why | +|---|---|---| +| Asthenosphere | 3300 | The hot, weak mantle the plates ride on | +| Lithospheric mantle | 3400 | ~1000 K colder; α ≈ 3 × 10⁻⁵ /K puts it ~3% denser | +| Descending slab | 3450 | Colder still, and thick enough to stay that way as it sinks | + +The excess density of the cold lithosphere over the asthenosphere beneath it *is* slab +pull — it is why a cooled plate can sink at all. Painting all three at one value, which +is what the screen used to do, erased that from density mode: a plate appeared to be +crust alone, floating on nothing, and the slab was invisible against the mantle it was +descending through. + ### What is allowed, and why | Motion | Plates | Result | Runs for | diff --git a/src/PlateTectonicsConstants.ts b/src/PlateTectonicsConstants.ts index 068dd34..dc49495 100644 --- a/src/PlateTectonicsConstants.ts +++ b/src/PlateTectonicsConstants.ts @@ -304,6 +304,30 @@ export const OLD_OCEANIC_PLATE_TOP_M = -4000; export const OLD_OCEANIC_PLATE_BASE_M = -10000; export const OLD_OCEANIC_PLATE_MANTLE_LITHOSPHERE_M = 55000; +/** + * Density of the rigid mantle inside a plate, below its crust, kg/m³. + * + * Deliberately higher than {@link MANTLE_DENSITY_KG_M3}, which stands for the hotter + * asthenosphere the plates ride on. The two are the same rock; the difference is + * thermal, and it is the whole reason plate tectonics happens. Lithospheric mantle + * averages something like 1000 K colder than the asthenosphere beneath it, and with a + * coefficient of thermal expansion around 3 × 10⁻⁵ /K that makes it roughly 3% denser. + * That excess is what makes a cooled plate negatively buoyant, and therefore what makes + * old ocean floor able to sink. Painting the plate and the asthenosphere at one density + * would erase the quantity in density mode. + */ +export const LITHOSPHERIC_MANTLE_DENSITY_KG_M3 = 3400; + +/** + * Density of a descending slab, kg/m³. + * + * Denser still than the lithospheric mantle it is made of: a slab is thick enough to keep + * its cold interior on the way down, so it warms far more slowly than it descends and its + * contrast against the surrounding mantle grows with depth rather than fading. That + * persistent excess density is slab pull. + */ +export const SLAB_DENSITY_KG_M3 = 3450; + /** Speed a plate moves at the boundary, m per million years — i.e. 15 mm/year. */ export const PLATE_SPEED_M_PER_MYR = 15000; @@ -456,6 +480,8 @@ PlateTectonicsNamespace.register("PlateTectonicsConstants", { OLD_OCEANIC_PLATE_TOP_M, OLD_OCEANIC_PLATE_BASE_M, OLD_OCEANIC_PLATE_MANTLE_LITHOSPHERE_M, + LITHOSPHERIC_MANTLE_DENSITY_KG_M3, + SLAB_DENSITY_KG_M3, PLATE_SPEED_M_PER_MYR, SUBDUCTION_ARC_RADII_M, SUBDUCTION_ARC_ANGLE_FRACTIONS, diff --git a/src/crust/model/CrustModel.ts b/src/crust/model/CrustModel.ts index a8e0124..0888777 100644 --- a/src/crust/model/CrustModel.ts +++ b/src/crust/model/CrustModel.ts @@ -191,9 +191,22 @@ export class CrustModel implements TModel { ]; } - /** The block containing a given model x, or null between and beyond them. */ + /** + * The block containing a given model x, or null between and beyond them. + * + * Half-open on the right so neighbouring blocks do not both claim a shared edge, except + * at the outermost edge of the last block, which has no neighbour to hand the point on + * to. Without that exception the right-hand edge of the viewport — which is exactly that + * outermost edge — reads as being outside every block, so the painter and the probe both + * treat the last sliver of the picture as open mantle. + */ public columnAt(xM: number): CrustColumn | null { - return this.columns.find((column) => xM >= column.leftM && xM < column.rightM) ?? null; + const columns = this.columns; + const last = columns[columns.length - 1]; + if (last && xM >= last.leftM && xM <= last.rightM) { + return last; + } + return columns.find((column) => xM >= column.leftM && xM < column.rightM) ?? null; } /** Which named shell a point is in, for the probe's readout and the labels. */ diff --git a/src/crust/view/CrustLabelsNode.ts b/src/crust/view/CrustLabelsNode.ts index 30a44be..25445be 100644 --- a/src/crust/view/CrustLabelsNode.ts +++ b/src/crust/view/CrustLabelsNode.ts @@ -28,6 +28,9 @@ const BLOCK_LABEL_FONT = new PhetFont({ size: 12, weight: "bold" }); const LAYER_LABEL_FONT = new PhetFont(12); const SEA_LEVEL_FONT = new PhetFont(10); +/** Gap between a block label and the surface it names, view pixels. */ +const BLOCK_LABEL_MARGIN = 6; + export type CrustLabelsNodeOptions = NodeOptions; export class CrustLabelsNode extends Node { @@ -113,15 +116,25 @@ export class CrustLabelsNode extends Node { return; } const centreX = scale.x((column.leftM + column.rightM) / 2); - this.addChild( - new Text(name, { - font: BLOCK_LABEL_FONT, - fill: PlateTectonicsColors.textColorProperty, - centerX: centreX, - bottom: scale.y(column.elevationM) - 6, - maxWidth: (bounds.width / model.columns.length) * 0.9, - }), - ); + const surfaceY = scale.y(column.elevationM); + const label = new Text(name, { + font: BLOCK_LABEL_FONT, + fill: PlateTectonicsColors.textColorProperty, + centerX: centreX, + maxWidth: (bounds.width / model.columns.length) * 0.9, + }); + + // A block standing above the water gets its name in the sky above it. A block + // whose surface is under water gets it just *inside* the rock instead: the space + // above such a block is only a few pixels of sea, and a label placed there lands + // on the sea-level line and its caption — which is exactly where the user's own + // block sits at its default thickness. + if (surfaceY <= seaLevelY - label.height - BLOCK_LABEL_MARGIN) { + label.bottom = surfaceY - BLOCK_LABEL_MARGIN; + } else { + label.top = surfaceY + BLOCK_LABEL_MARGIN; + } + this.addChild(label); }); } diff --git a/src/plate-motion/model/PlateGeometry.ts b/src/plate-motion/model/PlateGeometry.ts index ce29058..f8a9de1 100644 --- a/src/plate-motion/model/PlateGeometry.ts +++ b/src/plate-motion/model/PlateGeometry.ts @@ -48,7 +48,14 @@ import { behaviorFor, type MotionType, subductingSide } from "./BoundaryRules.js import { crustThickness, lithosphereBaseM, type PlateType, plateProperties } from "./PlateType.js"; import { SlabCurve, slabHinge } from "./SlabCurve.js"; -/** One plate, as three polylines from its outer edge in towards the boundary. */ +/** + * One plate, as three polylines across it. + * + * The invariant every producer here has to keep is that all three run in the *same* + * direction along x. The painter closes a band by walking one polyline forward and the + * other back, so two that disagree produce a self-crossing bowtie rather than a plate. + * Which direction that is does not matter, and it differs between behaviours. + */ export type PlateOutline = { readonly crustTop: readonly Vector2[]; readonly crustBase: readonly Vector2[]; @@ -213,8 +220,11 @@ function subductionGeometry(left: PlateType, right: PlateType, down: "left" | "r const fromHinge = Math.abs(xM) / 120000; top.push(new Vector2(xM, crustTopM - trenchDepthM * Math.exp(-fromHinge * fromHinge))); } + // All three polylines run boundary → outer edge. The painter closes a band by walking + // the top forward and the base back, so a polyline that ran the other way would fold + // the band into a bowtie rather than reversing it harmlessly. return { - crustTop: top.reverse(), + crustTop: top, crustBase: [new Vector2(0, crustBaseM), new Vector2(outerM, crustBaseM)], lithosphereBase: [new Vector2(0, lithosphereBaseM(downType)), new Vector2(outerM, lithosphereBaseM(downType))], }; diff --git a/src/plate-motion/view/PlateMotionCanvasNode.ts b/src/plate-motion/view/PlateMotionCanvasNode.ts index f473be3..d9bcbef 100644 --- a/src/plate-motion/view/PlateMotionCanvasNode.ts +++ b/src/plate-motion/view/PlateMotionCanvasNode.ts @@ -18,7 +18,12 @@ import type { CrossSectionScale } from "../../common/model/CrossSectionScale.js" import { paintArrowHead } from "../../common/view/CanvasArrows.js"; import { materialFill } from "../../common/view/EarthMaterial.js"; import PlateTectonicsColors from "../../PlateTectonicsColors.js"; -import { MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K } from "../../PlateTectonicsConstants.js"; +import { + LITHOSPHERIC_MANTLE_DENSITY_KG_M3, + MANTLE_DENSITY_KG_M3, + SLAB_DENSITY_KG_M3, + SURFACE_TEMPERATURE_K, +} from "../../PlateTectonicsConstants.js"; import { boundaryGeometry, type PlateOutline } from "../model/PlateGeometry.js"; import type { PlateMotionModel } from "../model/PlateMotionModel.js"; import { simpleMantleTemperatureK } from "../model/PlateThermal.js"; @@ -29,6 +34,19 @@ const MOTION_ARROW_LENGTH = 46; export type PlateMotionCanvasNodeOptions = CanvasNodeOptions; +/** + * The ground profile across the whole section, left edge to right edge. + * + * The two plates tile the section between them, but neither the order of their samples + * nor which of them covers which side is fixed — a rift walks outwards from the axis, a + * collision walks in from the far field. Sorting the combined samples by x is what makes + * this independent of that: each plate's surface is a function of x, so the merge of the + * two is the ground. + */ +function groundSurface(geometry: ReturnType): Vector2[] { + return [...geometry.left.crustTop, ...geometry.right.crustTop].sort((a, b) => a.x - b.x); +} + export class PlateMotionCanvasNode extends CanvasNode { private readonly model: PlateMotionModel; @@ -89,14 +107,12 @@ export class PlateMotionCanvasNode extends CanvasNode { context.fillRect(bounds.minX, scale.seaLevelY, bounds.width, bounds.maxY - scale.seaLevelY); } - // The mantle is painted as a band rather than sampled per column: on this screen it - // is a backdrop for the plates, not the subject, and a uniform fill reads as one - // continuous medium the plates are moving *through*. - this.paintMantle(context, mode); - const left = model.leftPlateTypeProperty.value; const right = model.rightPlateTypeProperty.value; if (!(left && right)) { + // Nothing has been placed yet, so there is no ground for the sea to lie on and the + // mantle simply fills the section below sea level. + this.paintMantle(context, mode, null); return; } @@ -105,6 +121,14 @@ export class PlateMotionCanvasNode extends CanvasNode { ? boundaryGeometry(motion, left, right, model.timeMillionsOfYearsProperty.value) : boundaryGeometry("convergent", left, right, 0); + // The mantle is painted as a band rather than sampled per column: on this screen it + // is a backdrop for the plates, not the subject, and a uniform fill reads as one + // continuous medium the plates are moving *through*. It is clipped to below the + // ground, though — an unclipped band starts at sea level and paints over the ocean, + // which is why the sea used to be invisible everywhere the sea floor lies (that is, + // everywhere it exists). + this.paintMantle(context, mode, groundSurface(geometry)); + // ── The slab, under everything it passes beneath ────────────────────────── if (geometry.slab.length > 1) { this.paintSlab(context, geometry.slab, geometry.slabHalfThicknessM, mode); @@ -145,11 +169,37 @@ export class PlateMotionCanvasNode extends CanvasNode { } } - /** A uniform mantle band from the deepest plate down to the bottom of the viewport. */ - private paintMantle(context: CanvasRenderingContext2D, mode: Parameters[0]): void { + /** + * The mantle, from the ground down to the bottom of the viewport. + * + * `surface` is the ground profile across the whole section; the fill is clipped to + * below it so the mantle never intrudes into the air or the sea. Pass null before any + * plate has been placed, when there is no ground and the band starts at sea level. + */ + private paintMantle( + context: CanvasRenderingContext2D, + mode: Parameters[0], + surface: readonly Vector2[] | null, + ): void { const scale = this.sectionScale; const bounds = this.canvasBounds; - const topY = scale.y(0); + + context.save(); + let topY = scale.y(0); + if (surface && surface.length > 1) { + context.beginPath(); + context.moveTo(bounds.minX, bounds.maxY); + for (const point of surface) { + context.lineTo(scale.x(point.x), scale.y(point.y)); + } + context.lineTo(bounds.maxX, bounds.maxY); + context.closePath(); + context.clip(); + + // Start at the highest point of the ground rather than at sea level, so a mountain + // belt standing above the water still gets mantle painted under it. + topY = Math.min(...surface.map((point) => scale.y(point.y))); + } // Sampled in horizontal bands so the geotherm shows as a gradient rather than a // single flat colour — the whole point of the temperature mode. @@ -160,6 +210,7 @@ export class PlateMotionCanvasNode extends CanvasNode { context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3, temperatureK).toCSS(); context.fillRect(bounds.minX, y, bounds.width, bandHeight + 0.75); } + context.restore(); } /** One plate: crust over lithospheric mantle, each as a closed band. */ @@ -171,8 +222,11 @@ export class PlateMotionCanvasNode extends CanvasNode { ): void { const properties = plateProperties(type); - // The lithospheric mantle first, so the crust sits on top of it. - context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 900).toCSS(); + // The lithospheric mantle first, so the crust sits on top of it. Drawn at the + // lithospheric density rather than the asthenosphere's: they are the same rock at + // different temperatures, and painting both at MANTLE_DENSITY_KG_M3 made the rigid + // part of every plate vanish into its surroundings in density mode. + context.fillStyle = materialFill(mode, LITHOSPHERIC_MANTLE_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 900).toCSS(); this.fillBand(context, outline.crustBase, outline.lithosphereBase); context.fillStyle = materialFill(mode, properties.densityKgM3, SURFACE_TEMPERATURE_K + 450).toCSS(); @@ -182,12 +236,23 @@ export class PlateMotionCanvasNode extends CanvasNode { /** The descending slab, as a ribbon of constant thickness about its centreline. */ private paintSlab( context: CanvasRenderingContext2D, - centreline: readonly Vector2[], + fullCentreline: readonly Vector2[], halfThicknessM: number, mode: Parameters[0], ): void { const scale = this.sectionScale; + // Cut the slab off at the bottom of the section. CrossSectionScale.y clamps, so every + // point below the floor lands *on* the floor: an untrimmed slab that has descended + // past the view is drawn as a horizontal smear along the bottom edge, with its + // arrow-heads strung out sideways along it. Keeping the first point past the floor and + // dropping the rest lets the ribbon run off the edge and stop there. + const past = fullCentreline.findIndex((point) => point.y < scale.bottomM); + const centreline = past < 0 ? fullCentreline : fullCentreline.slice(0, past + 1); + if (centreline.length < 2) { + return; + } + // Offset perpendicular to the local heading, so the ribbon keeps its thickness round // the bend instead of pinching where the curve is tightest. const upper: Vector2[] = []; @@ -210,7 +275,7 @@ export class PlateMotionCanvasNode extends CanvasNode { // A slab is cold — that is why it is dense enough to sink, and why it stays rigid // far below the depth where the surrounding mantle does not. - context.fillStyle = materialFill(mode, MANTLE_DENSITY_KG_M3 + 100, SURFACE_TEMPERATURE_K + 500).toCSS(); + context.fillStyle = materialFill(mode, SLAB_DENSITY_KG_M3, SURFACE_TEMPERATURE_K + 500).toCSS(); this.fillBand(context, upper, lower); // Arrow-heads down the centreline, showing which way it is going. diff --git a/src/plate-motion/view/PlateMotionScreenView.ts b/src/plate-motion/view/PlateMotionScreenView.ts index e04ece1..b0a7a0d 100644 --- a/src/plate-motion/view/PlateMotionScreenView.ts +++ b/src/plate-motion/view/PlateMotionScreenView.ts @@ -68,11 +68,18 @@ export class PlateMotionScreenView extends ScreenView { const strings = StringManager.getInstance(); const a11y = strings.getPlateMotionA11yStrings().controls; - // The chooser sits above the section, so the section starts below it. - const chooserHeight = 62; + // The chooser sits above the section, so the section starts below it. Built first and + // measured rather than allowed for by a constant: the panel's height depends on the + // font and on the length of the three localized crust names, and a guess that is too + // small puts the panel on top of the cross-section. + const chooser = new CrustChooserPanel(model, { + left: SECTION_VIEW_BOUNDS.minX, + top: SECTION_VIEW_BOUNDS.minY, + }); + const bounds = new Bounds2( SECTION_VIEW_BOUNDS.minX, - SECTION_VIEW_BOUNDS.minY + chooserHeight, + SECTION_VIEW_BOUNDS.minY + chooser.height + PANEL_SPACING, SECTION_VIEW_BOUNDS.maxX, SECTION_VIEW_BOUNDS.maxY, ); @@ -118,10 +125,6 @@ export class PlateMotionScreenView extends ScreenView { this.addChild(probe); // ── Crust chooser, above the section ────────────────────────────────────── - const chooser = new CrustChooserPanel(model, { - left: SECTION_VIEW_BOUNDS.minX, - top: SECTION_VIEW_BOUNDS.minY, - }); this.addChild(chooser); // ── Legend ──────────────────────────────────────────────────────────────── diff --git a/tests/PlateGeometry.test.ts b/tests/PlateGeometry.test.ts index dfe0872..b0e0f03 100644 --- a/tests/PlateGeometry.test.ts +++ b/tests/PlateGeometry.test.ts @@ -58,6 +58,32 @@ describe("boundaryGeometry at rest", () => { expect(geometry).toEqual(restingGeometry("oldOceanic", "oldOceanic")); }); + it("runs every polyline of an outline in the same direction along x", () => { + // The painter closes a band by walking one polyline forward and the other back, so + // two that disagree in direction produce a self-crossing bowtie instead of a plate. + // The subducting plate used to reverse its crustTop and not its base lines, which + // drew the down-going plate as an X across the whole half of the section. + const direction = (points: readonly { x: number }[]): number => + Math.sign((points[points.length - 1]?.x ?? 0) - (points[0]?.x ?? 0)); + + for (const left of PLATE_TYPES) { + for (const right of PLATE_TYPES) { + for (const motion of ["convergent", "divergent"] as const) { + for (const tMyr of [0, 1, 17, 35, 50]) { + const geometry = boundaryGeometry(motion, left, right, tMyr); + for (const outline of [geometry.left, geometry.right]) { + const expected = direction(outline.crustTop); + expect(direction(outline.crustBase), `${left}/${right} ${motion} t=${tMyr} crustBase`).toBe(expected); + expect(direction(outline.lithosphereBase), `${left}/${right} ${motion} t=${tMyr} lithosphere`).toBe( + expected, + ); + } + } + } + } + } + }); + it("never throws for any pairing, motion or time", () => { for (const left of PLATE_TYPES) { for (const right of PLATE_TYPES) {