From 6b557a46c7cda64574d6a3b63c9080f7a642b341 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sat, 25 Jul 2026 01:12:16 +0200 Subject: [PATCH] fix(bodies): register every body texture on its prime meridian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uvSphereMesh` emitted u = lon/2π, putting u=0 on the local +x axis the IAU rotation aims a body's prime meridian at. Standard equirectangular maps paint longitude 0 at the image CENTRE, so every body riding the shared sphere rendered its surface 180° round about the pole — visibly, on the tidally locked Moon, which showed its far side to Earth. Earth escaped it: `cubeSphereMesh` bakes the registration offset already, and the cloud deck compensated with a hand-rolled +0.5 in its fragment shader. Bake the offset into `uvSphereMesh` instead, from the same constant, and drop the shader patch — one convention, one site per mesh. u runs 0.5..1.5 unwrapped so the samplers' `repeat` addressing closes the turn, keeping the quad derivatives continuous across the antimeridian. The constant is no longer Earth's, so `EARTH_TEXTURE_PRIME_MERIDIAN_U` becomes `TEXTURE_PRIME_MERIDIAN_U` (via `npm run refactor`). Picks up and removes the `body-texture-meridian-registration` backlog item, whose "no observable consequence today" reading missed that tidal locking is itself a rotational-phase ground truth. Co-Authored-By: Claude Opus 5 (1M context) --- docs/BACKLOG.md | 1 - ...7-21-body-texture-meridian-registration.md | 27 ------------- src/data/bodies/earthTexturePrimeMeridianU.ts | 34 ----------------- src/data/bodies/texturePrimeMeridianU.ts | 37 ++++++++++++++++++ .../shaders/bodies/cloudShell/fragment.wesl | 34 ++++++----------- .../gpu/shaders/bodies/earth/fragment.wesl | 11 +++--- src/utils/math/cubeSphereMesh.ts | 12 +++--- src/utils/math/uvSphereMesh.ts | 35 +++++++++++++---- tests/utils/math/uvSphereMesh.test.ts | 38 +++++++++++++------ 9 files changed, 114 insertions(+), 115 deletions(-) delete mode 100644 docs/backlog/2026-07-21-body-texture-meridian-registration.md delete mode 100644 src/data/bodies/earthTexturePrimeMeridianU.ts create mode 100644 src/data/bodies/texturePrimeMeridianU.ts diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f0f4ff72b..6faba2071 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -52,7 +52,6 @@ Items with a **→ details** link have a full write-up in [`backlog/`](backlog/) - [ ] **Saturn ring brightness** `ready` — the ring reads too dim next to the new limb-darkened disc; retune ring albedo/exposure (surfaced in the planet-atmospherics per-body visual pass). - [ ] **Atmosphere limb transparent seam** `needs-investigation` — thin fully-transparent ring between a body's surface and its atmosphere shell (seen on Mars). → [details](backlog/2026-07-24-atmosphere-limb-transparent-seam.md) - [ ] **Body-texture colour calibration** `needs-design` — Mars reads over-saturated; the `sss` sources are enhanced, not colorimetric, and no target appearance is recorded. → [details](backlog/2026-07-24-mars-texture-colour-calibration.md) -- [ ] **Body texture meridian registration (non-Earth)** `deferred` — every non-Earth map renders 180° rotated about its spin axis (invisible without a phase ground truth; Earth fixed in #472). → [details](backlog/2026-07-21-body-texture-meridian-registration.md) - [ ] **Body-texture store consolidation** `needs-design` — four renderers (textured, Earth, ring, cloud-shell) each hand-roll map storage + placeholder fallback separately. → [details](backlog/2026-07-24-body-texture-store-consolidation.md) - [ ] **Photoreal-Earth follow-ups** `deferred` — drift traps + fidelity gaps from plans A–E (equirect-uv mirror, setMap kind table, shared proxy-sphere idiom). → [details](backlog/2026-07-19-photoreal-earth-followups.md) - [ ] **Titan atmosphere** `needs-design` — minimal params-row-over-flat-sphere vs full Venus-style cloud-as-surface + limb treatment (needs a texture through the fetch/build pipeline). → [details](backlog/2026-07-19-titan-atmosphere.md) diff --git a/docs/backlog/2026-07-21-body-texture-meridian-registration.md b/docs/backlog/2026-07-21-body-texture-meridian-registration.md deleted file mode 100644 index 419dab564..000000000 --- a/docs/backlog/2026-07-21-body-texture-meridian-registration.md +++ /dev/null @@ -1,27 +0,0 @@ -# Body texture prime-meridian registration (non-Earth) - -**Surfaced:** 2026-07-21, during the solar-system time-control terminator fix -(PR #472, commit `48a8f263`). - -## Context - -Equirectangular planet maps conventionally paint the prime meridian at the -image centre (u = 0.5), but the shared `uvSphereMesh` maps it to u = 0. For -Earth this produced a 180-degree day/night error the moment the live clock -gave a ground truth, and was fixed Earth-scoped: `cubeSphereMesh` + -`earth/fragment.wesl` + `cloudShell/fragment.wesl` now apply -`EARTH_TEXTURE_PRIME_MERIDIAN_U` (see `src/data/bodies/earthTexturePrimeMeridianU.ts`). - -Every other textured body still renders its map 180 degrees rotated about its -spin axis. There is no observable consequence today: no other body has a -rotational-phase ground truth (no city lights, no terminator the user can -check against a wall clock), so the offset is invisible. - -## The work - -Decide whether to shift the shared `uvSphereMesh` (or the per-body samplers) -to the centre-registered convention, verify each body's map actually follows -the centre convention (most NASA/USGS maps do; confirm per source), and do a -visual pass per body. Becomes user-visible the moment any feature exposes a -body's absolute rotational phase (e.g. Great Red Spot longitude, Mars albedo -features against ephemeris predictions). diff --git a/src/data/bodies/earthTexturePrimeMeridianU.ts b/src/data/bodies/earthTexturePrimeMeridianU.ts deleted file mode 100644 index 2d1430d48..000000000 --- a/src/data/bodies/earthTexturePrimeMeridianU.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * EARTH_TEXTURE_PRIME_MERIDIAN_U — the equirectangular-map registration offset - * that puts the prime meridian (geographic longitude 0) at the texture's CENTRE - * column, where every standard planetary map (Blue Marble and the rest) paints - * it. A raw `u = lon / 2π` would land the ANTIMERIDIAN on the local +x axis the - * IAU rotation aims a body's Greenwich at, rotating the whole surface 180° about - * the pole (continents on the wrong hemisphere, the day/night terminator reading - * inverted against a live clock). Adding this offset re-registers the map. - * - * ### One convention, three sites — greppable, not importable - * - * The same `+0.5` fact is encoded in three places that cannot share this symbol, - * because two of them are WESL shaders and WESL cannot import a TS constant: - * - * 1. `src/utils/math/cubeSphereMesh.ts` — bakes it into the Earth-mesh vertex - * `u` (imports THIS constant, the single TS home). - * 2. `src/services/gpu/shaders/bodies/earth/fragment.wesl` — `dirToEquirectUv` - * re-encodes it to sample the identical texel a mesh vertex would, for the - * ground-shadow crossing point that has no interpolated uv. - * 3. `src/services/gpu/shaders/bodies/cloudShell/fragment.wesl` — re-encodes it - * for the cloud deck, which rides the shared `uvSphereMesh` (no baked offset). - * - * Site 1's use of this constant is pinned end-to-end by - * `tests/services/engine/frame/earthTerminator.test.ts` (the terminator reads - * correct only if the mesh uv is registered right). The two shader halves (sites - * 2 & 3) have no TS-importable link, so they are guarded the honest minimum way: - * that same terminator test exercises the shading path, and each shader comment - * names the literal token `EARTH_TEXTURE_PRIME_MERIDIAN_U` plus the other two - * sites, so a `grep` for the token surfaces every place that must move together - * if the map-authoring convention ever changes. - */ - -/** Equirectangular u offset registering longitude 0 to the map centre (u = 0.5). */ -export const EARTH_TEXTURE_PRIME_MERIDIAN_U = 0.5; diff --git a/src/data/bodies/texturePrimeMeridianU.ts b/src/data/bodies/texturePrimeMeridianU.ts new file mode 100644 index 000000000..e1370df03 --- /dev/null +++ b/src/data/bodies/texturePrimeMeridianU.ts @@ -0,0 +1,37 @@ +/** + * TEXTURE_PRIME_MERIDIAN_U — the equirectangular-map registration offset that + * puts the prime meridian (geographic longitude 0) at the texture's CENTRE + * column, where every standard planetary map (Blue Marble, the Solar System + * Scope set, the USGS Galilean maps) paints it. A raw `u = lon / 2π` would land + * the ANTIMERIDIAN on the local +x axis the IAU rotation aims a body's prime + * meridian at, rotating the whole surface 180° about the pole — continents on + * the wrong hemisphere, the day/night terminator reading inverted against a live + * clock, and a tidally-locked Moon showing its FAR side to Earth. Adding this + * offset re-registers the map. + * + * ### One convention, three sites — greppable, not importable + * + * The same `+0.5` fact is encoded in three places that cannot share this symbol, + * because one of them is a WESL shader and WESL cannot import a TS constant: + * + * 1. `src/utils/math/uvSphereMesh.ts` — bakes it into the shared sphere's + * vertex `u` (the textured planets, the Moon and the Galileans, Earth's + * cloud deck). + * 2. `src/utils/math/cubeSphereMesh.ts` — bakes it into the Earth-only mesh's + * vertex `u`. + * 3. `src/services/gpu/shaders/bodies/earth/fragment.wesl` — `dirToEquirectUv` + * re-encodes it to sample the identical texel a mesh vertex would, for the + * ground-shadow crossing point that has no interpolated uv. + * + * Sites 1 and 2 import this constant and are pinned by `uvSphereMesh.test.ts` + * (lon 0 → u 0.5) and `tests/services/engine/frame/earthTerminator.test.ts` (the + * terminator reads correct only if the mesh uv is registered right). The shader + * half (site 3) has no TS-importable link, so it is guarded the honest minimum + * way: that same terminator test exercises the shading path, and its comment + * names the literal token `TEXTURE_PRIME_MERIDIAN_U` plus the other two sites, so + * a `grep` for the token surfaces every place that must move together if the + * map-authoring convention ever changes. + */ + +/** Equirectangular u offset registering longitude 0 to the map centre (u = 0.5). */ +export const TEXTURE_PRIME_MERIDIAN_U = 0.5; diff --git a/src/services/gpu/shaders/bodies/cloudShell/fragment.wesl b/src/services/gpu/shaders/bodies/cloudShell/fragment.wesl index ee9832102..fe992b98c 100644 --- a/src/services/gpu/shaders/bodies/cloudShell/fragment.wesl +++ b/src/services/gpu/shaders/bodies/cloudShell/fragment.wesl @@ -54,14 +54,12 @@ // The cloud map is equirectangular, same v-orientation as the surface albedo: // the renderer uploads it flipY:true so texture v=0 is the image's bottom (south) // row, matching 'uvSphereMesh's south-first v. HANDEDNESS reads raw (longitude on -// +Y winds east to screen-right for an outside viewer). The u ORIGIN needs a +0.5 -// though: the map paints the prime meridian (geographic longitude 0) at its CENTRE -// (u=0.5), while 'uvSphereMesh' emits u=0 at the local +x prime meridian. The -// Earth SURFACE rides the Earth-only 'cubeSphereMesh', which now bakes this same -// +0.5 into its uv; the deck rides the SHARED 'uvSphereMesh' (also serving the -// distant planets), so the correction stays local to this Earth shader rather than -// shifting every planet's mesh. Without it the deck sits 180deg off the surface -// beneath it. See 'earth/fragment.wesl' for the full derivation. +// +Y winds east to screen-right for an outside viewer). The u ORIGIN needs no +// correction here: 'uvSphereMesh' bakes TEXTURE_PRIME_MERIDIAN_U into its vertex +// u, registering the map's centre column (geographic longitude 0) on the local +x +// prime meridian -- the same offset 'cubeSphereMesh' bakes for the Earth SURFACE +// this deck rides above, so the two agree by construction. See +// 'earth/fragment.wesl' for the full derivation. // // ## Blend target // @@ -80,21 +78,11 @@ import package::lib::sphere::CloudShellUniforms; @fragment fn fs(in: VSOut) -> @location(0) vec4 { - // RGB = cloud colour (sRGB de-gammad on read), .a = coverage. The +0.5 on u is - // the EARTH_TEXTURE_PRIME_MERIDIAN_U convention: it puts the map centre - // (geographic longitude 0) on the local +x prime meridian, matching the surface - // (see the '## uv / orientation' note); the shared uvSphereMesh emits u=0 there, - // so the origin correction lives here. WESL cannot import the TS constant, so - // this offset is re-encoded; the other two sites of the same 0.5 are - // 'cubeSphereMesh.ts' (the TS home exporting EARTH_TEXTURE_PRIME_MERIDIAN_U) and - // 'earth/fragment.wesl'. Grep the token to find all three. - // No explicit fract() wrap: the sampler uses addressModeU 'repeat', so a u of - // 0.5..1.5 wraps correctly in hardware. Wrapping in-shader with fract() instead - // breaks the quad derivatives at the u=0.5 seam (the value jumps 1->0), which - // forces the coarsest mip along a ~1px antimeridian line; leaving u continuous - // keeps ddx/ddy well-behaved and lets repeat addressing do the wrap. - let uv = vec2(in.uv.x + 0.5, in.uv.y); - let c = textureSample(cloudTexture, cloudSampler, uv); + // RGB = cloud colour (sRGB de-gammad on read), .a = coverage. The mesh uv is + // already registered on the prime meridian (see the '## uv / orientation' note), + // so it samples straight through. Its u runs 0.5..1.5 unwrapped and the sampler + // uses addressModeU 'repeat', so the hardware closes the turn. + let c = textureSample(cloudTexture, cloudSampler, in.uv); // Sun-relative day/night term, decomposed so 'sunIrradiance' scales the DIRECT // (sun-lit) portion only -- mirroring the surface, whose direct term is scaled // by the same irradiance while its ambient floor is not. The 'u.ambientLight' diff --git a/src/services/gpu/shaders/bodies/earth/fragment.wesl b/src/services/gpu/shaders/bodies/earth/fragment.wesl index 9063d0f00..a53ecc6df 100644 --- a/src/services/gpu/shaders/bodies/earth/fragment.wesl +++ b/src/services/gpu/shaders/bodies/earth/fragment.wesl @@ -144,14 +144,15 @@ import package::lib::math::TAU; // // u = east longitude / TAU + 0.5. 'cubeSphereMesh' puts longitude on +Y, so // lon = atan2(dir.y, dir.x) (0 at +X, +pi/2 at +Y). The +0.5 is the -// EARTH_TEXTURE_PRIME_MERIDIAN_U convention: the prime meridian (lon 0) +// TEXTURE_PRIME_MERIDIAN_U convention: the prime meridian (lon 0) // samples the map CENTRE u=0.5, where standard equirectangular planetary // maps paint geographic longitude 0 (see the '## uv / u-orientation' note). // WESL cannot import the TS constant, so this offset is re-encoded here; the -// other two sites of the same 0.5 are 'cubeSphereMesh.ts' (the TS home that -// exports EARTH_TEXTURE_PRIME_MERIDIAN_U) and 'cloudShell/fragment.wesl'. -// Grep the token to find all three. 'fract' wraps to [0, 1) -- the same -// wrap the sampler's 'repeat' addressing applies. +// other two sites of the same 0.5 are the meshes that bake it into their +// vertex u -- 'cubeSphereMesh.ts' (Earth's, and the TS home that exports +// TEXTURE_PRIME_MERIDIAN_U) and 'uvSphereMesh.ts' (every other body plus +// this Earth's cloud deck). Grep the token to find all three. 'fract' wraps +// to [0, 1) -- the same wrap the sampler's 'repeat' addressing applies. // v = latitude mapped south (0) to north (1): lat = asin(dir.z) in // [-pi/2, pi/2], so v = lat/PI + 0.5 matches the mesh's south-first v // (v=0 south pole, v=1 north pole) that flipY:true co-registers with. diff --git a/src/utils/math/cubeSphereMesh.ts b/src/utils/math/cubeSphereMesh.ts index c1489e799..7a61d8489 100644 --- a/src/utils/math/cubeSphereMesh.ts +++ b/src/utils/math/cubeSphereMesh.ts @@ -72,7 +72,7 @@ import type { CubeSphereMesh } from '../../@types/math/CubeSphereMesh'; import type { Vec3 } from '../../@types/math/Vec3'; -import { EARTH_TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/earthTexturePrimeMeridianU'; +import { TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/texturePrimeMeridianU'; // Six cube faces (index 0..5 = +x, -x, +y, -y, +z, -z). Each carries its outward // normal and two in-plane axes chosen so sAxis × tAxis = normal — which makes the @@ -149,7 +149,7 @@ export function cubeSphereMesh( const lon = Math.atan2(y, x); // Right Ascension, (-π, π] const lat = Math.asin(Math.max(-1, Math.min(1, z))); // Declination, [-π/2, π/2] // Base u in [0,1); per-triangle shifts restore continuity across the seam - // below. EARTH_TEXTURE_PRIME_MERIDIAN_U puts the PRIME MERIDIAN (lon 0, the + // below. TEXTURE_PRIME_MERIDIAN_U puts the PRIME MERIDIAN (lon 0, the // local +x the IAU rotation orients Earth's Greenwich to) at u=0.5 — the // image CENTRE, where every standard equirectangular planetary map (Blue // Marble and the rest) paints geographic longitude 0. Without it a raw @@ -158,10 +158,10 @@ export function cubeSphereMesh( // day/night terminator reads inverted against a live clock (mid-afternoon // Europe shown in night). The seam (u wrap) moves to lon=±π accordingly; // windowShifts re-continuizes it per triangle wherever it lands, and the - // +u=east tangent below is unchanged. The two shader sites that re-encode - // this same offset (they can't import a TS constant) are named in the - // constant's docblock. - let u = lon / (2 * Math.PI) + EARTH_TEXTURE_PRIME_MERIDIAN_U; + // +u=east tangent below is unchanged. `uvSphereMesh` bakes the same offset + // for every other body; the one shader site that re-encodes it (it can't + // import a TS constant) is named in the constant's docblock. + let u = lon / (2 * Math.PI) + TEXTURE_PRIME_MERIDIAN_U; u = u - Math.floor(u); const v = lat / Math.PI + 0.5; uvs.push(u, v); diff --git a/src/utils/math/uvSphereMesh.ts b/src/utils/math/uvSphereMesh.ts index 90aeb4d16..4945bda0a 100644 --- a/src/utils/math/uvSphereMesh.ts +++ b/src/utils/math/uvSphereMesh.ts @@ -20,19 +20,34 @@ * equatorial frame means. Earth's 23.4° tilt relative to the ecliptic is then * the frame relationship itself, not a rotation this mesh has to apply. * - * A static Earth (the renderer applies no diurnal spin) models no rotation, so - * WHICH meridian faces which RA is an arbitrary construction choice, not a - * physical fact to reproduce: the prime meridian (lon 0) is fixed along +x, the - * vernal equinox direction. Longitude winds x→y (eastward, CCW seen from above - * the north pole) — matching both increasing RA and Earth's real eastward spin. + * The mesh's own longitude origin (lon 0) sits along +x; each body's IAU + * rotation (`rotationFromIau`) then swings that meridian to where the body's + * prime meridian actually points, so lon 0 IS the body's prime meridian. + * Longitude winds x→y (eastward, CCW seen from above the north pole) — matching + * both increasing RA and a prograde body's real eastward spin. * * x = cos(lat)·cos(lon) (lat = Dec, lon = RA) * y = cos(lat)·sin(lon) * z = sin(lat) (north pole = +z) * * UV mapping is equirectangular: - * u = longitude / (2π) → [0, 1] west-to-east - * v = latitude / π + 0.5 → [0, 1] south-to-north + * u = longitude / (2π) + TEXTURE_PRIME_MERIDIAN_U → [0.5, 1.5] west-to-east + * v = latitude / π + 0.5 → [0, 1] south-to-north + * + * ## Registering the map on the prime meridian + * + * `TEXTURE_PRIME_MERIDIAN_U` is what makes lon 0 sample the map's CENTRE column, + * where every standard planetary map paints longitude 0. Without it the map's + * antimeridian lands on the prime meridian and the whole surface reads 180° round + * — the Moon, tidally locked with its prime meridian aimed at Earth, showing its + * FAR side. `cubeSphereMesh` (Earth's) bakes the same offset from the same + * constant, so both meshes register identically. + * + * The offset is NOT wrapped back into [0, 1]: u runs 0.5 → 1.5 monotonically and + * the samplers address u as `repeat`, so the hardware does the wrap. Wrapping + * here instead would jump u by a whole turn mid-sphere, and that discontinuity + * breaks the fragment quad's derivatives at the seam — forcing the coarsest mip + * along a ~1 px antimeridian line. * * ## Winding * @@ -45,6 +60,7 @@ * a→c→b restores the outward normal. See the per-quad comment for the layout. */ +import { TEXTURE_PRIME_MERIDIAN_U } from '../../data/bodies/texturePrimeMeridianU'; import type { UvSphereMesh } from '../../@types/math/UvSphereMesh'; export function uvSphereMesh(segments: number, rings: number): UvSphereMesh { @@ -69,7 +85,10 @@ export function uvSphereMesh(segments: number, rings: number): UvSphereMesh { const lon = (2 * Math.PI * s) / segments; const cosLon = Math.cos(lon); const sinLon = Math.sin(lon); - const u = s / segments; // [0,1] west-to-east + // [0.5, 1.5] west-to-east: the map's centre column registers on lon 0 (the + // body's prime meridian), and the turn is left unwrapped for the sampler's + // `repeat` addressing to close — see the module header. + const u = s / segments + TEXTURE_PRIME_MERIDIAN_U; // Unit-sphere position in the equatorial J2000 frame (see module header): // x = cos(lat)*cos(lon), y = cos(lat)*sin(lon), z = sin(lat) diff --git a/tests/utils/math/uvSphereMesh.test.ts b/tests/utils/math/uvSphereMesh.test.ts index c30521a90..6c33a1fd2 100644 --- a/tests/utils/math/uvSphereMesh.test.ts +++ b/tests/utils/math/uvSphereMesh.test.ts @@ -104,16 +104,32 @@ describe('uvSphereMesh', () => { expect(dot).toBeGreaterThan(0); }); - it('uv ranges are within [0,1]', () => { - const { uvs } = uvSphereMesh(12, 6); - const vertexCount = uvs.length / 2; - for (let i = 0; i < vertexCount; i++) { - const u = uvs[i * 2] as number; - const v = uvs[i * 2 + 1] as number; - expect(u).toBeGreaterThanOrEqual(0); - expect(u).toBeLessThanOrEqual(1); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - } + it('registers the map centre on the prime meridian (lon 0 → u 0.5)', () => { + // Standard planetary maps paint geographic longitude 0 at the image CENTRE, + // and the IAU rotation aims a body's prime meridian at local +x (lon 0). A + // raw u = lon/2π would land the map's ANTIMERIDIAN there — the whole surface + // rotated 180° about the pole (the Moon showing its far side from Earth). + const segments = 12; + const rings = 6; + const { uvs } = uvSphereMesh(segments, rings); + const uAt = (r: number, s: number) => uvs[(r * (segments + 1) + s) * 2] as number; + + expect(uAt(0, 0)).toBeCloseTo(0.5); // lon 0 → map centre + expect(uAt(3, segments / 4)).toBeCloseTo(0.75); // lon 90°E → a quarter turn east + // The seam vertex closes exactly one turn; u stays continuous (never wrapped + // back to 0), so the sampler's `repeat` addressing does the wrap and the + // quad derivatives stay well-behaved across the antimeridian. + expect(uAt(3, segments)).toBeCloseTo(1.5); + }); + + it('v runs south-to-north over [0,1]', () => { + const rings = 6; + const segments = 12; + const { uvs } = uvSphereMesh(segments, rings); + const vAt = (r: number) => uvs[r * (segments + 1) * 2 + 1] as number; + + expect(vAt(0)).toBeCloseTo(0); // south pole + expect(vAt(rings / 2)).toBeCloseTo(0.5); // equator + expect(vAt(rings)).toBeCloseTo(1); // north pole }); });