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) {