#164: Rendering style problems. #165

Merged
art merged 4 commits from issue-164-road-level-hierarchy into main 2026-08-19 17:42:09 +00:00
Owner

Closes #164

Rendering style problems — the shared level hierarchy, and the fat-street / building-order fixes


This closes #164 and with it #18. Three deliverables, in the issue's order.

1. The global grade-separation hierarchy (underpass / normal / overpass)

The shared roads contract gains a third axis, level, with values underpass and
overpass; NULL means normal and is deliberately not a value — ground level
dominates every source and omitting the attribute costs nothing in the MVT (the issue's
point about NULL being the cheap default). It is a shared, cross-country axis because
grade separation is a geometric fact, not a classification judgement: the same E4 must
read as a bridge on both sides of the Tornio–Haparanda border, exactly the argument
road_class already makes. This is the change #18 parked under R25 ("ship a shared column
and its rendering together") — both land here.

Each country maps its own bridge/tunnel source:

  • Iceland einingvegakerfis: 4 → underpass, 2 → overpass (mapped for R25; this delivery has no bridges).
  • Sweden Bro_och_tunnel_Konstruktion: tunnel/underfart → underpass, överfart → overpass, överfart och underfart (a segment that is both, the Gullmarsplan–Skanstull stack) → normal.
  • Poland POLOZENIE: pod powierzchnią gruntu → underpass, the four ponad powierzchnią gruntu poziom N codes → overpass (R137 — the source distinguishes height, not grade).
  • Finland SILTA_ALIK: positive → underpass. Digiroad's only signal is one-sided (positive = passing under a bridge), so Finland has no overpass half.

docs/geo-primer.md and docs/country-requirements.md carry the reasoning, and the R25 note documents the both/neither (both-over-and-under) case.

Style. Every road class (service..motorway) is split into underpass / normal /
overpass variants — underpass a fill only (no casing), normal and overpass each a casing+fill
pair except service (fill only) — 34 road line layers total (was 23). Level dominates
the draw order
: all underpasses, then all normal, then all overpasses on top, and within
a level all casings before all fills, so a bridge always reads over a tunnel and over any
ground-level road. Underpasses render as a faded fill (75% opacity, no casing — a
casing under a 75%-opacity fill would show through the road above as a dashed edge, so it
is omitted) drawn lowest; normal is the cheap ["!", ["has", "level"]] filter; overpass is
the same paint as normal but drawn last so the bridge reads elevated. roads_unpaved sits
above all three — it is a surface texture, not a grade. The metro line is drawn with
line-join: miter and at 60% opacity, which reads much better than round joins when
overlaid on the underlying streets.

2. Streets are no longer too fat

Issue #164's buildings import gave the measuring stick: footprints render near their
on-ground size, so the old ladder — several times wider than the gap between buildings —
looked wrong when zooming. The ramps now approach real carriageway widths as you zoom in
(minor ~4.4 px ≈ 10 m at z16, motorway ~12.8 px ≈ 31 m, near a real dual carriageway;
~7 m at z17), while below z13 they keep a legibility exaggeration and the class ordering
is preserved at every zoom. The 11–13 px road names now exceed the road they label at the
mid zooms — expected, and the road_names halo carries it rather than forcing absurd
widths.

3. Buildings drawn early

The buildings footprint fill moved right after water, before roads/rail/transit/labels,
so a metro or an underpass line renders over it and a town name is never covered. The
buildings_dots/buildings_icons/building_names symbol layers stay up with the labels —
a symbol competes for placement and belongs there.

Verification (live DB + real browser)

  • Data landed in every country (the R25 acceptance test, added to the roads shared
    contract): fi=28714, is=15, pl=60210, se=69838 non-NULL level rows, none invalid.
  • Decoded MVT (Stockholm z12): the level key and both underpass/overpass values
    are present in the roads layer.
  • Real browser over CDP at Gullmarsplan–Skanstull (z14): rendered underpass 178 /
    normal 2417 / overpass 104 features, with level correctly routed to each variant
    layer; the underpass casings were later removed in review (a casing under a faded fill
    showed through as dashed edges on the road above). Vision-model corroboration with
    negative controls: buildings under roads (not covering), roads narrower, NOT every road
    dashed, normal roads not wrongly covered.
  • Style validates clean (gl-style-validate, exit 0); layer counts match the docs
    (94 total: 89 map + 4 is + background; 40 road layers).
  • Acceptance suite: make acceptance-all — 45/46 passed. The one failure is a
    pre-existing marginal timeout on the Poland buildings R120 test (~11 s query
    against a 10 s timeout, data itself correct: footprints == source parts == 16 351 829,
    unclassified 0) — unrelated to this change (I touched only the roads layer and the
    style); filed separately.

What I did not do

  • No rail/transit grade-separation split: no source carries a level-equivalent for
    rail yet, and a metro line under a city is already served by position in the draw order.
    Documented as the standing simplification.
  • No aqueduct (a watercourse carried over a highway): no source distinguishes it.
    The geo-primer note points at the per-country waterways mapping shape for the day a
    source provides it — filed as a follow-up.
Closes #164 # Rendering style problems — the shared `level` hierarchy, and the fat-street / building-order fixes --- This closes #164 and with it #18. Three deliverables, in the issue's order. ## 1. The global grade-separation hierarchy (underpass / normal / overpass) The shared `roads` contract gains a third axis, `level`, with values `underpass` and `overpass`; **NULL means normal and is deliberately not a value** — ground level dominates every source and omitting the attribute costs nothing in the MVT (the issue's point about `NULL` being the cheap default). It is a *shared, cross-country* axis because grade separation is a geometric fact, not a classification judgement: the same E4 must read as a bridge on both sides of the Tornio–Haparanda border, exactly the argument `road_class` already makes. This is the change #18 parked under R25 ("ship a shared column and its rendering together") — both land here. Each country maps its own bridge/tunnel source: - **Iceland** `einingvegakerfis`: 4 → underpass, 2 → overpass (mapped for R25; this delivery has no bridges). - **Sweden** `Bro_och_tunnel_Konstruktion`: tunnel/underfart → underpass, överfart → overpass, `överfart och underfart` (a segment that is *both*, the Gullmarsplan–Skanstull stack) → normal. - **Poland** `POLOZENIE`: `pod powierzchnią gruntu` → underpass, the four `ponad powierzchnią gruntu poziom N` codes → overpass (R137 — the source distinguishes height, not grade). - **Finland** `SILTA_ALIK`: positive → underpass. Digiroad's only signal is one-sided (positive = passing under a bridge), so Finland has no overpass half. `docs/geo-primer.md` and `docs/country-requirements.md` carry the reasoning, and the R25 note documents the both/neither (both-over-and-under) case. **Style.** Every road class (`service`..`motorway`) is split into underpass / normal / overpass variants — underpass a fill only (no casing), normal and overpass each a casing+fill pair except `service` (fill only) — 34 road line layers total (was 23). **Level dominates the draw order**: all underpasses, then all normal, then all overpasses on top, and within a level all casings before all fills, so a bridge always reads over a tunnel and over any ground-level road. Underpasses render as a *faded* fill (75% opacity, **no casing** — a casing under a 75%-opacity fill would show through the road above as a dashed edge, so it is omitted) drawn lowest; normal is the cheap `["!", ["has", "level"]]` filter; overpass is the same paint as normal but drawn last so the bridge reads elevated. `roads_unpaved` sits above all three — it is a surface texture, not a grade. The metro line is drawn with `line-join: miter` and at 60% opacity, which reads much better than round joins when overlaid on the underlying streets. ## 2. Streets are no longer too fat Issue #164's buildings import gave the measuring stick: footprints render near their on-ground size, so the old ladder — several times wider than the gap between buildings — looked wrong when zooming. The ramps now approach real carriageway widths as you zoom in (`minor` ~4.4 px ≈ 10 m at z16, `motorway` ~12.8 px ≈ 31 m, near a real dual carriageway; ~7 m at z17), while below z13 they keep a legibility exaggeration and the class ordering is preserved at every zoom. The 11–13 px road names now exceed the road they label at the mid zooms — expected, and the `road_names` halo carries it rather than forcing absurd widths. ## 3. Buildings drawn early The `buildings` footprint *fill* moved right after water, before roads/rail/transit/labels, so a metro or an underpass line renders *over* it and a town name is never covered. The `buildings_dots`/`buildings_icons`/`building_names` symbol layers stay up with the labels — a symbol competes for placement and belongs there. ## Verification (live DB + real browser) - **Data landed in every country** (the R25 acceptance test, added to the `roads` shared contract): fi=28714, is=15, pl=60210, se=69838 non-NULL `level` rows, none invalid. - **Decoded MVT** (Stockholm z12): the `level` key and both `underpass`/`overpass` values are present in the `roads` layer. - **Real browser over CDP** at Gullmarsplan–Skanstull (z14): rendered underpass 178 / normal 2417 / overpass 104 features, with `level` correctly routed to each variant layer; the underpass casings were later removed in review (a casing under a faded fill showed through as dashed edges on the road above). Vision-model corroboration with negative controls: buildings under roads (not covering), roads narrower, NOT every road dashed, normal roads not wrongly covered. - **Style validates** clean (`gl-style-validate`, exit 0); layer counts match the docs (94 total: 89 map + 4 is + background; 40 road layers). - **Acceptance suite**: `make acceptance-all` — 45/46 passed. The one failure is a *pre-existing* marginal timeout on the Poland **buildings** R120 test (~11 s query against a 10 s timeout, data itself correct: footprints == source parts == 16 351 829, unclassified 0) — unrelated to this change (I touched only the roads layer and the style); filed separately. ## What I did not do - No **rail/transit** grade-separation split: no source carries a `level`-equivalent for rail yet, and a metro line under a city is already served by position in the draw order. Documented as the standing simplification. - No **aqueduct** (a watercourse carried over a highway): no source distinguishes it. The `geo-primer` note points at the per-country `waterways` mapping shape for the day a source provides it — filed as a follow-up.
The roads contract gains a third axis, `level` (underpass / overpass, NULL = normal,
deliberately not a value so ground level costs nothing in the MVT), closing #18 under R25
("ship a shared column and its rendering together" — both land here). Each country maps its
own bridge/tunnel source onto it (IS einingvegakerfis, SE Bro_och_tunnel_Konstruktion,
PL POLOZENIE, FI SILTA_ALIK); Sweden's both-at-once `överfart och underfart` is drawn
normal. The style splits every road class into underpass/normal/overpass variants (40 road
line layers, was 23), level dominating draw order — underpasses dashed+faded lowest, normal
in the middle, overpasses cased on top — so a bridge always reads over a tunnel and any
ground-level road. `roads_unpaved` (a surface texture) sits above all three.

Streets are no longer too fat: the ramps now approach real carriageway widths as you zoom
in (minor ~4.4px≈10m at z16) rather than a screen legibility exaggeration, so a street no
longer renders several times wider than the gap between the buildings beside it. The
`buildings` footprint fill moved early (right after water, before roads/rail/transit/labels)
so a metro or underpass line renders over it and town names are never covered; the point
symbol and name layers stay up with the labels.

Verified against the live database and a real browser: the R25 acceptance test passes for
all four countries (fi=28714, is=15, pl=60210, se=69838 non-NULL level rows, none invalid);
a decoded Stockholm z12 tile carries the level key and both values; a CDP render at
Gullmarsplan–Skanstull shows underpass/normal/overpass all drawing with correct level
routing and the z16 dash on tunnels; gl-style-validate is clean; `make acceptance-all` is
45/46 (the one failure is a pre-existing marginal timeout on the Poland buildings R120
test, ~11s vs a 10s timeout, data correct, unrelated to this change — filed separately).
Review feedback on the level-hierarchy style. The underpass casings were dashed and the
underpass fills are at 75% opacity, so the casing sat *under* the fill and showed through
as dashed edges on the road above — the roads looked silly-dashed. The right answer is no
casing at all: an underpass is now a fill only (7 underpass layers, down from 13), faded to
75% opacity and drawn lowest, and the dashed-casing idea is gone. The style drops six
`roads_under_*_casing` layers (88 total, 83 from `map`, 34 road layers); normal and
overpass keep their casing+fill pairs.

Also picked up the metro tweak made while eyeballing the render: `transit_metro` draws with
`line-cap: butt, line-join: miter` at 60% opacity (miter reads much better than round when
a metro line overlies the streets below), and its white `transit_metro_casing` drops to 10%
opacity.

Docs updated to match (style.md, architecture.md, geo-primer.md): the underpass
rendering description, the layer counts, and the road-stack drawing order. The review note
and PR body describe the faded-fill-only design and the miter metro.

Verified: `gl-style-validate` clean, style.json valid (88 layers).
art changed title from Closes #164 to #164: Rendering style problems. 2026-08-19 16:20:17 +00:00
Two draw-order regressions from the underpass/normal/overpass split, both found by
A/B-ing the old and new styles in a real browser with `queryRenderedFeatures`.

`service` was appended after `motorway` in each level's fill block instead of leading
it. It is the only class with no casing, so the end of the block is the tempting place
to put it — and there it draws a light-grey notch through the fill *and* the casing of
every street a driveway or parking aisle joins. The old style put `roads_service` first
among the fills for exactly this reason; all three levels now do the same. At a normal
service/secondary junction (17.97496/59.33987) the rendered stack goes back to
`roads_normal_secondary` over `roads_normal_service`, as it was before the split.

`roads_unpaved` sat above all three levels, filtered on `surface` alone, so a
ground-level gravel road dashed straight across the bridge that crossed over it —
precisely what the level order exists to prevent, and not a corner case: unpaved is 52%
of Sweden's and 73% of Finland's road rows. It becomes `roads_under_unpaved` /
`roads_normal_unpaved` / `roads_over_unpaved`, each drawn at the top of its own level,
the underpass variant carrying the same 75% fade as the road it textures. A gravel
surface is still a texture rather than a grade — that argument keeps it out of the
*class* ladder, not out of the level one. At a ground-level unpaved road under a minor
overpass (18.15280/59.18230) the whole `roads_over_*` stack now sits above the whole
`roads_normal_*` stack.

Also: the `enums.level` and `roads` contract comments in conf/common.yaml still
described the rendering the previous commit removed — a "dashed, faded tunnel" and
"dashed tunnels / plain roads / cased bridges". They now say what the style does, and
why the dash the issue asked for went away.

Docs updated (style.md, geo-primer.md, architecture.md): 90 layers, 85 from `map`, 36
road line layers, the level-internal order written down as casings → service → class
fills → gravel, and the reason the gravel overlay is split.

Verified: `gl-style-validate` clean, 90 layers; `config-check` OK on all four configs;
`go test ./...` green; both crossings re-checked over CDP, and all three unpaved
variants confirmed rendering (`roads_under_unpaved` at 16.87069/59.63719).
Three follow-ups from the review, all about checks that said less than they looked
like they said.

R121's decoded-tile check compared the tile's key set to the contract's for *equality*,
and `ST_AsMVT` omits a NULL value per feature — so a declared attribute only appears in
a tile that has a feature carrying it. `roads.level` is sparse enough to expose this: at
z5 the whole map holds about ten non-NULL rows per country, so whether the busiest tile
happened to contain one decided the verdict. It does today, in all four configs, which
is the only reason the check was green. Now a key the tile does not carry is chased to a
tile that does — rows where the column is non-NULL are turned into tile coordinates and
served through the same function — and only a key absent from every probed tile is a
failure. A leak is still judged on the first tile alone; a genuinely dropped column is
missing from every tile, so it still fails. Both directions were exercised by temporarily
lying to the decoder: a simulated sparse key passes with `[level] carried by no feature
here, confirmed in level at 5/13/8`, and a simulated dropped column fails with `[level]
absent from every probed tile`.

R25 also checked `level NOT IN ('underpass', 'overpass')`, which cannot be true: `level`
is a Postgres enum, so a bad value fails the cast at import, hours before any test runs.
Replaced with the two things a mapping bug actually produces — a country with no non-NULL
level at all (the all-NULL case #18 exists to close) and a country where level is *not*
the minority, which is the CASE falling through the wrong way. NULL-is-normal is the
premise of the design, so `levelled < total / 4` asserts the premise; the real numbers are
0.03%–2%, three orders of magnitude of headroom. Plus one cross-map assertion that both
enum values occur somewhere, since Finland's signal is one-sided and Iceland has no
bridges in this delivery. Both simulated bugs were checked to fail the new predicate.

And the merge cost of `level`, which the review flagged as bounded-and-small and which
measurement says is neither. `merge_lines` groups by every non-geometry attribute, and
`level` changes at every bridge, so each one cuts a chain in two. On the z11 merge:
se 69 420 chains → 80 746 (+16.3%), pl 88 768 → 99 064 (+11.6%), and a chain is two more
pinned endpoints at every zoom below. Those chains buy nothing where they are paid —
grade separation is invisible below about z13 and the merge runs from z12 down — but
saying so needs a mechanism the line cascade does not have (`enum_rollups` is polygon-only
and replaces the zoom's whole step). Measured, documented next to the attribute and under
`merge_lines` in docs/generalization.md, and filed as issue #167 with a sketch.

Verified: `go test ./...` green (new `TestMissingFrom` covers the set difference both
ways), `go vet` clean, `config-check` and `tiles --check` OK on all four configs, R25 and
R126 pass against the live database.
art merged commit c095dcb383 into main 2026-08-19 17:42:09 +00:00
art deleted branch issue-164-road-level-hierarchy 2026-08-19 17:42:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!165
No description provided.