From 1bbbfbe04a54ee1321f857403f271ee6ae045048 Mon Sep 17 00:00:00 2001 From: Jonathan Chen Date: Thu, 18 Jun 2026 14:33:53 -0700 Subject: [PATCH 1/2] refactor(atlas): migrate to public warm_solve + golden the placement decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MakieTextRepel shipped the public warm-start primitive we filed #24 for (PR #27, exported `warm_solve`), so the Atlas no longer needs to reach into unexported internals. Migration (placement-preserving): - Atlas.jl: `using MakieTextRepel: warm_solve` (was ProjectionSolver/RepelParams/solve_cluster). - place.jl: drop the _PARAMS/_SOLVER objects; call `warm_solve(anchors, sizes, bounds; …, _SOLVE_KW...)` with the same params. warm_solve is a thin face over the same seam, so output is identical — verified by a BYTE-IDENTICAL hero re-render (same sha256) and the unchanged geometric golden. - Project.toml: pin rev → 90f87ed3 (past #27/#28). Comments across src updated solve_cluster→warm_solve. Placement golden (now possible — #27 guarantees+documents solver determinism): - golden.jl: `placement_rows`/`atlas_placement_digest` hash the solver's DISCRETE decisions (which side each label leans = offset quadrant, + dropped) from the real COLD solve. Hashing the side, not the pixel offset, keeps it machine-stable (a quadrant only flips on a genuine re-placement) — same machine-independence as the geometric golden. Magnitude/continuity stays covered by test_loop's warm-start delta bound. - New baseline test/golden/atlas-placement.sha256 (+ .rows.txt). Full suite 295/295. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/atlas/Project.toml | 5 ++- examples/atlas/src/Atlas.jl | 2 +- examples/atlas/src/golden.jl | 42 +++++++++++++++++-- examples/atlas/src/loop.jl | 2 +- examples/atlas/src/place.jl | 13 +++--- examples/atlas/src/pois.jl | 2 +- examples/atlas/src/render.jl | 4 +- .../test/golden/atlas-placement.rows.txt | 29 +++++++++++++ .../atlas/test/golden/atlas-placement.sha256 | 1 + examples/atlas/test/runtests.jl | 2 +- examples/atlas/test/test_golden.jl | 29 ++++++++++++- 11 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 examples/atlas/test/golden/atlas-placement.rows.txt create mode 100644 examples/atlas/test/golden/atlas-placement.sha256 diff --git a/examples/atlas/Project.toml b/examples/atlas/Project.toml index b515e3b..eb521c6 100644 --- a/examples/atlas/Project.toml +++ b/examples/atlas/Project.toml @@ -20,8 +20,9 @@ TextMeasure = "06791c1d-2336-41e1-bd6f-a74c63395da6" HouseStyle = {path = "../_housestyle"} # MakieTextRepel has no registered release yet, so it is pinned to a GitHub rev. This atlas # leg therefore needs network access at `Pkg.instantiate` time; a GH/network outage can turn -# its CI red independently of any code change here. -MakieTextRepel = {rev = "dc7178205ce4b05e8bd86c4ae10419f0932e14e6", url = "https://github.com/jowch/MakieTextRepel.jl"} +# its CI red independently of any code change here. Pinned past PRs #27 (public `warm_solve`) +# and #28, so the demo uses only the public API — no internal-solver reach-through. +MakieTextRepel = {rev = "90f87ed3e9be28f1bfcc4754749fe0c3942fb2c0", url = "https://github.com/jowch/MakieTextRepel.jl"} TextMeasure = {path = "../.."} [compat] diff --git a/examples/atlas/src/Atlas.jl b/examples/atlas/src/Atlas.jl index 908fbd2..48ac0a5 100644 --- a/examples/atlas/src/Atlas.jl +++ b/examples/atlas/src/Atlas.jl @@ -2,7 +2,7 @@ module Atlas using TextMeasure -using MakieTextRepel: ProjectionSolver, RepelParams, solve_cluster +using MakieTextRepel: warm_solve import HouseStyle # includes added task-by-task: diff --git a/examples/atlas/src/golden.jl b/examples/atlas/src/golden.jl index 90bf640..0f00f83 100644 --- a/examples/atlas/src/golden.jl +++ b/examples/atlas/src/golden.jl @@ -6,9 +6,15 @@ # and no solver: `font_px` comes from each feature's ground-em (degrees), `band` is the # legibility/size fade (`band_alpha`) times the viewport edge fade (`_edge_alpha`) over a pure # AFFINE projection that reproduces the Makie camera to ≪0.1px. So the digest is reproducible -# across machines / fonts / Makie versions. (The solver's pixel offsets — Makie/solver-dependent -# — are deliberately NOT golden'd; this pins the SCALING + FADE behaviour we tune, e.g. town -# growth, the areal cloud hand-off, and the Range dissolving as the inland towns arrive.) +# across machines / fonts / Makie versions. This pins the SCALING + FADE behaviour we tune, e.g. +# town growth, the areal cloud hand-off, and the Range dissolving as the inland towns arrive. +# +# RAW solver pixel offsets are still NOT hashed — they flow through Makie's projection, which +# isn't guaranteed bit-stable across Makie versions. Instead `placement_rows`/`atlas_placement_digest` +# golden the solver's DISCRETE decisions — which side each label leans (offset quadrant) + whether +# it's dropped — taken from the real COLD solve. Those decisions only flip on a genuine placement +# change, so the digest stays machine-stable like the geometric one. (Offset magnitude / frame-to- +# frame continuity is covered separately by the warm-start delta bound in test_loop.jl.) # # Included after lod.jl / pois.jl (uses their constants + atlas_pois/atlas_areals). @@ -89,3 +95,33 @@ end "SHA-256 hex of the canonical (geometric, deterministic) Atlas LoD/opacity table." atlas_digest(; pagepx = (1620, 1080)) = digest_rows(golden_rows(; pagepx)) + +""" + placement_rows(; pagepx=(1620,1080)) -> Vector{String} + +The solver's DISCRETE placement decisions across `GOLDEN_FRAMES`. For each point label at each +frame (taken from the real COLD solve — `assemble_frame` with no warm-start, so it's deterministic +and frame-independent), emit `frame|id|dropped|sx|sy` where `sx`/`sy` are the SIGN of the label's +offset from its anchor (which quadrant it leans into). Hashing the discrete side, not the pixel +offset, keeps the digest stable under sub-pixel projection noise (it only changes on a genuine +re-placement) — machine-independent like the geometric table above. +""" +function placement_rows(; pagepx = (1620, 1080)) + d = load_atlas_data() + rows = String[] + sgn(x) = x > 0 ? "+" : x < 0 ? "-" : "0" + for frame in GOLDEN_FRAMES + p = frame / _GOLDEN_N + _, _, af = assemble_frame(d, p; pagepx, prev = Dict{Int,Vec2f}()) # cold = deterministic + fp = af.fp + for i in sortperm(fp.ids) # stable order by id + off = fp.offsets[i] + push!(rows, string(frame, "|", fp.ids[i], "|", Int(fp.dropped[i]), + "|", sgn(off[1]), "|", sgn(off[2]))) + end + end + return rows +end + +"SHA-256 hex of the Atlas discrete-placement table (machine-stable; solver decisions, not pixels)." +atlas_placement_digest(; pagepx = (1620, 1080)) = digest_rows(placement_rows(; pagepx)) diff --git a/examples/atlas/src/loop.jl b/examples/atlas/src/loop.jl index f325b82..3833bea 100644 --- a/examples/atlas/src/loop.jl +++ b/examples/atlas/src/loop.jl @@ -3,7 +3,7 @@ # # HONESTY INVARIANT (preserved every frame): # Every label is measured by TextMeasure (via measure_boxes / _unit_box) and -# placed by MakieTextRepel (solve_cluster, via solve_frame). No position is +# placed by MakieTextRepel (warm_solve, via solve_frame). No position is # hand-picked. The warm-start threads solved offsets from frame N into frame N+1 # so the solver RELAXES rather than re-seeds — this is what makes the video smooth. # diff --git a/examples/atlas/src/place.jl b/examples/atlas/src/place.jl index c5b06f8..8bde63e 100644 --- a/examples/atlas/src/place.jl +++ b/examples/atlas/src/place.jl @@ -1,5 +1,6 @@ # SPDX-License-Identifier: MIT -# DECISION (Task 1 spike): raw `solve_cluster` + our own overlap recompute. +# DECISION (Task 1 spike): the raw warm-start solve + our own overlap recompute. Originally the +# unexported `solve_cluster`; migrated to the public `warm_solve` (same seam) once it shipped. # Per-frame data→pixel projection (used by the loop task, NOT here): # px = Point2f(Makie.project(ax.scene, :data, :pixel, data_pt)[Vec(1,2)]) # no frame lag after update_state_before_display! using Makie @@ -23,9 +24,8 @@ function measure_boxes(strings; fontsize = Float64(HouseStyle.RAMP.body), font = [Vec2f(layout(prepare(backend, s)).size[1], boxh) for s in strings] end -const _PARAMS = RepelParams(; only_move=:both, box_padding=5.0, - point_padding=5.5, min_segment_length=2.0) -const _SOLVER = ProjectionSolver(_PARAMS) +# Solver config, passed to the public `warm_solve` per call (kwargs, no solver object). +const _SOLVE_KW = (; only_move=:both, box_padding=5.0, point_padding=5.5, min_segment_length=2.0) # Cartographic default: seed an uncontested label to the UPPER-RIGHT of its dot (Imhof's # preferred position) instead of the solver's bare default (straight below). The seed offset @@ -51,8 +51,9 @@ function solve_frame(ids, anchors, sizes, bounds; prev, settled, obstacles::Vect # pinned_offsets must be length n when pin_mask is provided (solver contract) pinned = pin !== nothing ? Vec2f[get(prev, id, Vec2f(0,0)) for id in ids] : Vec2f[] - r = solve_cluster(_SOLVER, collect(Point2f, anchors), collect(Vec2f, sizes), bounds; - init_state=init, pin_mask=pin, pinned_offsets=pinned, obstacles=obstacles) + r = warm_solve(collect(Point2f, anchors), collect(Vec2f, sizes), bounds; + init_state=init, pin_mask=pin, pinned_offsets=pinned, obstacles=obstacles, + _SOLVE_KW...) FramePlacement(collect(Int, ids), collect(Point2f, anchors), collect(Vec2f, sizes), r.offsets, r.dropped) end diff --git a/examples/atlas/src/pois.jl b/examples/atlas/src/pois.jl index 564e673..2e4c61d 100644 --- a/examples/atlas/src/pois.jl +++ b/examples/atlas/src/pois.jl @@ -2,7 +2,7 @@ # pois.jl — hand-placed feature anchors (the ONLY hand-positioned data in the # piece: each POI / areal carries a real geographic location. Everything that # follows — every label box — is MEASURED by TextMeasure and PLACED by -# MakieTextRepel's solve_cluster. These structs supply anchors only, never the +# MakieTextRepel's warm_solve. These structs supply anchors only, never the # label's final screen position. # # Included after data.jl (uses project_point / Point2f). diff --git a/examples/atlas/src/render.jl b/examples/atlas/src/render.jl index d6a3ba8..2394b68 100644 --- a/examples/atlas/src/render.jl +++ b/examples/atlas/src/render.jl @@ -5,7 +5,7 @@ # `using CairoMakie` lives here deliberately — kept out of place.jl. # # HONESTY INVARIANT: every point label (town + POI) is MEASURED by TextMeasure -# (measure_boxes) and PLACED by MakieTextRepel (solve_cluster, via solve_frame). +# (measure_boxes) and PLACED by MakieTextRepel (warm_solve, via solve_frame). # Rotated region "areals" are likewise MEASURED, and their footprints + sampled # coastline vertices are fed back in as solver OBSTACLES so the label field stays # clear of them. The only hand-positioned values anywhere are feature anchors @@ -423,7 +423,7 @@ end Build a fresh figure for loop phase `p` and place EVERY label honestly: - point labels (active towns + on-screen POIs) measured via TextMeasure, placed by - one `solve_cluster` call against each other, the sampled coastline, and the areals; + one `warm_solve` call against each other, the sampled coastline, and the areals; - areals laid out glyph-by-glyph along an arc (each glyph MEASURED), their per-glyph boxes added as obstacles. diff --git a/examples/atlas/test/golden/atlas-placement.rows.txt b/examples/atlas/test/golden/atlas-placement.rows.txt new file mode 100644 index 0000000..017cf82 --- /dev/null +++ b/examples/atlas/test/golden/atlas-placement.rows.txt @@ -0,0 +1,29 @@ +0|5|0|+|- +64|5|0|+|- +104|5|0|+|+ +104|8|0|+|- +104|10|0|+|+ +104|11|0|+|+ +104|20|0|+|+ +128|5|0|+|+ +128|8|0|+|- +128|10|0|+|+ +128|14|0|+|+ +128|15|0|+|+ +128|16|0|+|+ +128|21|0|+|+ +180|5|0|+|+ +180|10|0|+|+ +180|14|0|+|+ +180|15|0|+|+ +180|16|0|+|- +180|21|0|+|+ +180|1001|0|-|- +180|1003|0|+|- +180|1004|0|-|+ +180|1006|0|+|+ +180|1007|0|+|- +180|1008|0|-|+ +180|1009|0|-|+ +180|1011|0|-|+ +280|5|0|+|- \ No newline at end of file diff --git a/examples/atlas/test/golden/atlas-placement.sha256 b/examples/atlas/test/golden/atlas-placement.sha256 new file mode 100644 index 0000000..54a4172 --- /dev/null +++ b/examples/atlas/test/golden/atlas-placement.sha256 @@ -0,0 +1 @@ +475dfc5295a8e717375e92ee18cacb737c16b73cb09896af72a7c0342fe6627e \ No newline at end of file diff --git a/examples/atlas/test/runtests.jl b/examples/atlas/test/runtests.jl index e4377aa..a5f3a38 100644 --- a/examples/atlas/test/runtests.jl +++ b/examples/atlas/test/runtests.jl @@ -3,7 +3,7 @@ using Atlas, Test @testset "Atlas" begin @testset "loads" begin - @test isdefined(Atlas, :solve_cluster) # MakieTextRepel internal API resolved + @test isdefined(Atlas, :warm_solve) # MakieTextRepel public API resolved end include("test_data.jl") include("test_pois.jl") diff --git a/examples/atlas/test/test_golden.jl b/examples/atlas/test/test_golden.jl index 0573a78..e45c71c 100644 --- a/examples/atlas/test/test_golden.jl +++ b/examples/atlas/test/test_golden.jl @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT using Atlas -using Atlas: golden_rows, atlas_digest, GOLDEN_FRAMES +using Atlas: golden_rows, atlas_digest, GOLDEN_FRAMES, placement_rows, atlas_placement_digest using Test # THE DETERMINISTIC GOLDEN. The gallery invariant: hash the COMPUTED layout table, NEVER pixels. @@ -38,3 +38,30 @@ const GOLDEN_DIR = joinpath(@__DIR__, "golden") @test isfile(path) # fails closed without the sha file @test cs == strip(read(path, String)) # regression anchor end + +# Placement golden: the solver's DISCRETE decisions (which side each label leans + dropped), +# now that MakieTextRepel guarantees solver determinism (public warm_solve, PR #27). Hashes the +# side quadrant — NOT pixel offsets — so it stays machine-stable (a quadrant only flips on a real +# re-placement; offset magnitude/continuity is covered by test_loop's warm-start delta bound). +# Update with `UPDATE_GOLDEN=1`. +@testset "golden: deterministic Atlas placement decisions (side + dropped, machine-stable)" begin + prows = placement_rows() + @test !isempty(prows) + + ps = atlas_placement_digest() + @test length(ps) == 64 # sha256 hex + + # non-vacuous: the solver actually distributes labels — not every label leans the same way. + @test any(r -> endswith(r, "|+|+"), prows) # some lean upper-right + @test any(r -> occursin("|-|", r), prows) # some lean left + @test length(prows) >= 20 # labels actually get placed across the dive + + ppath = joinpath(GOLDEN_DIR, "atlas-placement.sha256") + if get(ENV, "UPDATE_GOLDEN", "") == "1" + mkpath(GOLDEN_DIR) + write(ppath, ps) + write(joinpath(GOLDEN_DIR, "atlas-placement.rows.txt"), join(prows, "\n")) + end + @test isfile(ppath) # fails closed without the sha file + @test ps == strip(read(ppath, String)) # regression anchor +end From 0f330770b9b8af2a7639bdc10012e36d3bb3e5f2 Mon Sep 17 00:00:00 2001 From: Jonathan Chen Date: Thu, 18 Jun 2026 14:43:44 -0700 Subject: [PATCH 2/2] address review nits (round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golden.jl: harden placement_rows against the mutable _PIN_SLO[] global it depends on — explicitly pin it to the production value (true) so a future test mutating the Ref can't perturb the golden. No-op on the digest (it was already true; baseline unchanged). - golden.jl + test_golden.jl: fix the "machine-stable / machine-independent like the geometric golden" overclaim. placement_rows DOES route anchors through Makie's projection (via assemble_frame), unlike the fully-affine geometric table — so the accurate claim is "robust to sub-pixel projection drift" (a quadrant only flips on a real re-placement), not Makie- independent. Reworded the header, docstrings, and the testset name. - README.md: solve_cluster → warm_solve at the three remaining mentions (now matches place.jl). Atlas suite 295/295; placement digest unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/atlas/README.md | 6 +++--- examples/atlas/src/golden.jl | 17 +++++++++++------ examples/atlas/test/test_golden.jl | 9 +++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/examples/atlas/README.md b/examples/atlas/README.md index 29f2a39..538b0dd 100644 --- a/examples/atlas/README.md +++ b/examples/atlas/README.md @@ -25,7 +25,7 @@ Nothing on this map is hand-positioned. The piece holds to three claims, each en a guessed width. Each label is measured **once** at a reference size; its per-frame box is that unit box scaled by `font_px / _REF_PX` (glyph advances scale linearly with size), so the font engine is touched once per label and every frame after is arithmetic. *(single-measure-then-scale)* -2. **Placed.** Every point label's screen position is the output of MakieTextRepel's `solve_cluster` +2. **Placed.** Every point label's screen position is the output of MakieTextRepel's public `warm_solve` — one solve per frame over all labels against each other, the sampled coastline, and the areals. As the camera dives, the previous frame's offsets warm-start the next, so labels glide rather than jump. Placement is then made deterministic by our own two-pass cull (hard coast clearance + @@ -52,7 +52,7 @@ Nothing on this map is hand-positioned. The piece holds to three claims, each en | `pois.jl` | Hand-authored feature anchors: landmark POIs + the curved region areals (the ONLY hand-placed data). | | `camera.jl` | The seamless geometric zoom loop (van Wijk & Nuij easing) → `camera_rect(p)`. | | `lod.jl` | Geographic level-of-detail: `font_px` (ground-em × pixels-per-unit), the `band_alpha` fades, hydrography LoD. | -| `place.jl` | `measure_boxes` (TextMeasure) + `solve_frame` (warm-started `solve_cluster`). | +| `place.jl` | `measure_boxes` (TextMeasure) + `solve_frame` (warm-started `warm_solve`). | | `render.jl` | The honest per-frame pipeline (`assemble_frame`) + the Makie render layer (basemap, areals, labels, chrome). | | `loop.jl` | `render_loop` (the MP4) + `render_hero`. | | `golden.jl` | The deterministic LoD/opacity golden table (geometric, no pixels). | @@ -65,7 +65,7 @@ For each loop phase `p`: camera_rect(p) # camera.jl — this frame's view window (geometric zoom) → project anchors to px # the geography lands on screen → font_px + band # lod.jl — geographic size + legibility/edge/size fades - → measure → seed → solve # place.jl — measure boxes, geography-aware seed, solve_cluster + → measure → seed → solve # place.jl — measure boxes, geography-aware seed, warm_solve → cull + leader fade # deterministic visibility (coast clearance + occlusion) → draw # render.jl — basemap → hydrography → areals → labels → chrome ``` diff --git a/examples/atlas/src/golden.jl b/examples/atlas/src/golden.jl index 0f00f83..33ca9e9 100644 --- a/examples/atlas/src/golden.jl +++ b/examples/atlas/src/golden.jl @@ -12,9 +12,11 @@ # RAW solver pixel offsets are still NOT hashed — they flow through Makie's projection, which # isn't guaranteed bit-stable across Makie versions. Instead `placement_rows`/`atlas_placement_digest` # golden the solver's DISCRETE decisions — which side each label leans (offset quadrant) + whether -# it's dropped — taken from the real COLD solve. Those decisions only flip on a genuine placement -# change, so the digest stays machine-stable like the geometric one. (Offset magnitude / frame-to- -# frame continuity is covered separately by the warm-start delta bound in test_loop.jl.) +# it's dropped — taken from the real COLD solve. These rows DO still route anchors through Makie's +# projection (via `assemble_frame`), unlike the fully-affine geometric table above, so the digest +# is ROBUST TO sub-pixel projection drift — not Makie-independent — because a quadrant only flips on +# a genuine re-placement. (Offset magnitude / frame-to-frame continuity is covered separately by the +# warm-start delta bound in test_loop.jl.) # # Included after lod.jl / pois.jl (uses their constants + atlas_pois/atlas_areals). @@ -103,10 +105,13 @@ The solver's DISCRETE placement decisions across `GOLDEN_FRAMES`. For each point frame (taken from the real COLD solve — `assemble_frame` with no warm-start, so it's deterministic and frame-independent), emit `frame|id|dropped|sx|sy` where `sx`/`sy` are the SIGN of the label's offset from its anchor (which quadrant it leans into). Hashing the discrete side, not the pixel -offset, keeps the digest stable under sub-pixel projection noise (it only changes on a genuine -re-placement) — machine-independent like the geometric table above. +offset, keeps the digest robust to sub-pixel projection noise (it only changes on a genuine +re-placement). It still routes anchors through Makie (via `assemble_frame`), so it is +robust-to-drift, not fully Makie-independent like the affine geometric table above. """ function placement_rows(; pagepx = (1620, 1080)) + _PIN_SLO[] = true # golden reflects production placement (SLO pinned); harden against + # a future test leaving the global Ref `assemble_frame` reads flipped. d = load_atlas_data() rows = String[] sgn(x) = x > 0 ? "+" : x < 0 ? "-" : "0" @@ -123,5 +128,5 @@ function placement_rows(; pagepx = (1620, 1080)) return rows end -"SHA-256 hex of the Atlas discrete-placement table (machine-stable; solver decisions, not pixels)." +"SHA-256 hex of the Atlas discrete-placement table (robust to sub-pixel projection drift; decisions, not pixels)." atlas_placement_digest(; pagepx = (1620, 1080)) = digest_rows(placement_rows(; pagepx)) diff --git a/examples/atlas/test/test_golden.jl b/examples/atlas/test/test_golden.jl index e45c71c..e475e8f 100644 --- a/examples/atlas/test/test_golden.jl +++ b/examples/atlas/test/test_golden.jl @@ -41,10 +41,11 @@ end # Placement golden: the solver's DISCRETE decisions (which side each label leans + dropped), # now that MakieTextRepel guarantees solver determinism (public warm_solve, PR #27). Hashes the -# side quadrant — NOT pixel offsets — so it stays machine-stable (a quadrant only flips on a real -# re-placement; offset magnitude/continuity is covered by test_loop's warm-start delta bound). -# Update with `UPDATE_GOLDEN=1`. -@testset "golden: deterministic Atlas placement decisions (side + dropped, machine-stable)" begin +# side quadrant — NOT pixel offsets — so it's robust to sub-pixel projection drift: a quadrant only +# flips on a real re-placement. (It still routes through Makie, so it's robust-to-drift, not fully +# Makie-independent like the geometric table; offset magnitude/continuity is covered by test_loop's +# warm-start delta bound.) Update with `UPDATE_GOLDEN=1`. +@testset "golden: deterministic Atlas placement decisions (side + dropped, robust to projection drift)" begin prows = placement_rows() @test !isempty(prows)