Finding 2 became a real code fix: interstitial_fill_into now enforces the footprint-wins conflict rule (column_has_voxel range probe) — the FilledChunk absence contract was previously a documented promise the code didn't keep against conflicting inputs; pinned by a fully- overlapping-leaf test asserting per-tile resolution. The tautological overlap test replaced with a real rects_overlap() geometric helper (itself sanity-tested) applied pairwise. The degenerate-setback fix is now a standalone pure fn shrink_lot_or_interstitial with four boundary tests — honestly documented as unreachable from live traffic today (every min_lot exceeds every setback), a robustness guard for future recalibration. Both sub-chunk clip tests now reconstruct the full 32-tile union across the seam (disjoint + complete), including the pre-existing footprint clip test (leave-cleaner). Brief's ChunkLayout claim tightened to the verified no-production-consumer statement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
342 lines
20 KiB
Markdown
342 lines
20 KiB
Markdown
---
|
||
title: "Interstitial Fill — Geometry Model (T-1098)"
|
||
description: Design brief pinning the geometry model for chunk-derive interstitial tile classification, before implementation
|
||
type: design
|
||
status: binding
|
||
round: T-1098
|
||
created: 2026-07-26
|
||
---
|
||
|
||
# Interstitial Fill — Geometry Model (T-1098)
|
||
|
||
**Author:** Dudley. **Status:** approved by the lead 2026-07-26 — implementing
|
||
per this shape. `dock_slip`/`market_pad` context-sensitive resolution split out
|
||
as **T-1209**.
|
||
|
||
This is the design pass T-1098 (and its parent T-959) explicitly required before
|
||
writing code: pin which chunk tiles are interstitial vs footprint-covered vs
|
||
street, how block-level metadata reaches `FillChunk`, the per-tile resolution
|
||
rule to a D-235 interstitial type or D-233 ops-surface tag, purity, and the
|
||
determinism story. Every citation below was re-read from the record body
|
||
(`pql decisions read`) before being written down here — not carried over from
|
||
memory or ticket prose.
|
||
|
||
## 1. The geometry source: BSP leaves that lost the coverage roll
|
||
|
||
`subdivide_block_footprints` (`server/src/atlas/skeleton_gen.rs:853-906`) already
|
||
computes the exact geometry this ticket needs — and already throws it away.
|
||
|
||
Walking the function: it BSP-subdivides the block's inner rect (block tile-space
|
||
minus the `BLOCK_MARGIN` perimeter street band, minus a waterfront edge's margin
|
||
when present) into lot-sized `TileRect` leaves (`bsp`, line 768). It then rolls
|
||
per-leaf coverage:
|
||
|
||
```rust
|
||
for (i, lot) in leaves.iter().enumerate() {
|
||
let roll = (splitmix64(seed.seed() ^ (i as u64 + 1)) % 100) as u8;
|
||
if roll >= coverage {
|
||
continue; // interstitial gap
|
||
}
|
||
// ... shrink by setback, push to `out` as a building footprint
|
||
}
|
||
```
|
||
|
||
The `continue`d leaves are — in the function's own comment — "interstitial
|
||
gap"s. They are never collected; only the surviving (shrunk) footprints are
|
||
returned. **This is the geometry source.** There is no need for a separate
|
||
ground-fill pass, no need to re-run BSP at `FillChunk` time, and no need to
|
||
infer interstitial tiles by negation over the footprint list at chunk-derive
|
||
time (which would require re-doing rectangle-containment against every
|
||
building in the block, an O(footprints × chunk tiles) scan for information the
|
||
plan phase already computed as a distinct set of rects for free).
|
||
|
||
**Decision: `subdivide_block_footprints` returns both lists.** It changes shape
|
||
to return `(Vec<TileRect> /* footprints */, Vec<TileRect> /* interstitial
|
||
leaves */)` (or an equivalent small struct — naming TBD at implementation, e.g.
|
||
`BlockSubdivision { footprints, interstitial }`). The BSP leaves that pass the
|
||
coverage roll go to the existing footprint path unchanged; the ones that fail
|
||
become the new interstitial rect list. `assign_block_tags`
|
||
(`skeleton_gen.rs:914`) already calls this function and is the natural place to
|
||
carry the second list forward into whatever block-level structure gets
|
||
threaded to `FillChunk` (§2).
|
||
|
||
### The three-way tile classification at a chunk
|
||
|
||
Given the above, a chunk's 64×64 tile grid partitions into exactly three kinds,
|
||
by *construction*, with no residual "what's left over" category to guess at:
|
||
|
||
1. **Footprint-covered** — inside a `BuildingPropertyTag.footprint` rect (already
|
||
consumed by `shell_derive_into` in `shell.rs`; unchanged by this ticket).
|
||
2. **Interstitial** — inside one of the new interstitial `TileRect` leaves from
|
||
§1 (a BSP leaf that lost its coverage roll, already shrunk to the *inner*
|
||
rect the same way a footprint would be — see note below on setback).
|
||
3. **Street** — the `BLOCK_MARGIN` perimeter band (2 tiles, or 0 on a flush
|
||
waterfront/rail edge per D-234b/T-1076 §4) plus, if in bounds, the
|
||
complementary strip outside the inner rect but inside the block. This band
|
||
is explicitly commented "street" at its point of origin
|
||
(`skeleton_gen.rs:872-887`, `// Perimeter street margin`) — it is claimed by
|
||
the future street-network layer (D-234), not by this ticket. `ChunkLayout`
|
||
(`spacing`/`offset`/`rotation_steps`, `generator.rs:103`) is the *only* other
|
||
street-adjacent data on `BlockSkeleton` today; grep confirms it has **no
|
||
production consumer** (its only reads are `#[cfg(test)]` assertions in
|
||
`skeleton_gen.rs` — `skeleton_has_streets_and_local_lattice` — checking that
|
||
the plan-time writer set it; the writer itself, `generate_quarter_skeleton`'s
|
||
layout-mode application, populates it but nothing downstream of plan time
|
||
consumes it yet). It is reserved for that same future street-network
|
||
step — not something this ticket needs to or should resolve.
|
||
|
||
Every block-local tile is covered by exactly one of these three at generation
|
||
time, because the BSP leaves partition the inner rect completely (§1's list
|
||
covers 100% of the inner rect's area, split only by the coverage roll) and the
|
||
inner rect plus the margin band partition the full 128×128 block. **No tile is
|
||
implicit-Void** — a chunk that reads all-interstitial (e.g. a reserved
|
||
park/plaza block, `block.reservation.is_some()`) resolves via §4's block-level
|
||
fallback, not by falling through unclassified.
|
||
|
||
One geometry subtlety: an interstitial leaf, unlike a footprint, is **not**
|
||
shrunk by `setback` — the setback shrink in the current code only applies to
|
||
leaves that *pass* the coverage roll (building lots pull back from the lot
|
||
line to leave frontage). An interstitial leaf is the *whole* BSP leaf rect,
|
||
because "the gap between buildings" includes exactly the frontage a
|
||
neighboring building's setback would have consumed — there's no second building
|
||
on the other side of a `continue`d leaf to setback *from*. This matches D-235's
|
||
own framing: setback_tier is what *drives* the interstitial character (via
|
||
`SetbackTier` on the covering block, §3), not a second shrink applied to the
|
||
interstitial rect itself.
|
||
|
||
## 2. Threading block-level metadata into `FillChunk`
|
||
|
||
Today `GenWorkItem::FillChunk` (`gen_queue.rs:186-199`) carries only
|
||
`block_tags: Vec<BuildingPropertyTag>` — no block-level data at all.
|
||
`build_fill_chunk_item` (`gen_queue.rs:1251-1267`) pulls that `Vec` from
|
||
`QuarterWorldState.block_tags: BTreeMap<(u8, u8), Vec<BuildingPropertyTag>>`
|
||
(`generator.rs:1288-1295`), keyed by block grid position — there is no sibling
|
||
map for anything block-level (interstitial leaves, `interstitial_character`,
|
||
`setback_tier`, `density_pct`).
|
||
|
||
**Decision: extend `QuarterWorldState` with a second block-keyed map, and add
|
||
the corresponding field to `FillChunk`.**
|
||
|
||
- `QuarterWorldState` gains `block_interstitial:
|
||
BTreeMap<(u8, u8), Vec<TileRect>>` (block-local, block-tile-space rects — the
|
||
§1 output), populated by `assign_all_block_tags` (or a sibling function)
|
||
alongside `block_tags`, at the same `GenerateSkeleton` plan-time pass. Reserved
|
||
blocks (`block.reservation.is_some()`) get an empty `Vec` here exactly as they
|
||
get an empty `Vec` for `block_tags` today (`assign_block_tags` early-returns
|
||
`Vec::new()` for reservations, line 924-926) — §4 covers what a reserved
|
||
block's chunk reads as instead.
|
||
- `FillChunk` gains two new fields, both pre-resolved at enqueue time (mirroring
|
||
`block_tags`'s existing pre-resolution — the whole point of the D-230/T-987
|
||
purity contract is that `run_work_item` never reads a cache):
|
||
- `interstitial_leaves: Vec<TileRect>` — the covering block's interstitial
|
||
rects, pulled from `block_interstitial` the same way `block_tags` is
|
||
pulled from `block_tags` today.
|
||
- `block_character: BlockFillContext` — a small new struct carrying exactly
|
||
the two scalars a per-tile resolution needs (§3): `interstitial_character:
|
||
InterstitialCharacter` and `setback_tier: SetbackTier`. **Not** the whole
|
||
`BlockSkeleton` — `BlockSkeleton` carries `hosted_sites`,
|
||
`chunk_layout`, `landmark`, etc. that `FillChunk` has no business touching
|
||
(D-230's derive phase reads only what shell/interstitial resolution needs,
|
||
same discipline `BuildingPropertyTag` already applies by carrying a frozen
|
||
projection rather than a `CityGenerationContext` reference).
|
||
|
||
Why `setback_tier` at block level rather than reading it off a
|
||
`BuildingPropertyTag.exterior.setback_tier`: T-1098's own deferral rationale
|
||
(quoted in the ticket) is exactly this point — "setback_tier is per-building
|
||
on exterior, but 'the gap between buildings' is block-shaped." An
|
||
interstitial leaf has no covering building to read `.exterior` from. But
|
||
`SetbackTier` is *already* deterministically a function of `block.density_pct`
|
||
alone (`derive_setback_tier`, `trait_exterior.rs:62-69`) — every building
|
||
in a block gets the *same* setback tier today, so re-deriving it once at
|
||
block level (`derive_setback_tier(block.density_pct)`) instead of reading it
|
||
off any one building's tag is not a new derivation, just relocating an
|
||
existing pure function call to the natural owner. This sidesteps needing a
|
||
"pick a neighboring building's tag" rule that would be arbitrary (which
|
||
neighbor?) and asymmetric (empty blocks have none to pick).
|
||
|
||
`build_fill_chunk_item` changes to also look up `block_interstitial.get(&block_pos)`
|
||
and call `derive_setback_tier(block.density_pct)` /
|
||
read `block.interstitial_character` off the cached `QuarterSkeleton`'s block grid,
|
||
threading both into the new `FillChunk` fields. This keeps the "no cache read
|
||
inside `run_work_item`" contract intact — all of it happens at enqueue time,
|
||
in the same place `block_tags` is already resolved.
|
||
|
||
## 3. Per-tile resolution rule
|
||
|
||
For a tile classified **interstitial** (§1), the resolution to a concrete D-235
|
||
`InterstitialType` or the D-233 `OperationsSurface` tag is a total function of
|
||
the two `BlockFillContext` scalars (§2) — no RNG needed, since D-235 already
|
||
ties character to `setback_tier` and D-233/T-1097 already ties character to
|
||
`interstitial_character`, and both are frozen block-level facts, not
|
||
per-tile ones (every interstitial tile in a block reads identically — there is
|
||
no design requirement for per-tile variation *within* one block's interstitial
|
||
character, only across blocks):
|
||
|
||
```
|
||
resolve(block_character: BlockFillContext) -> InterstitialType {
|
||
if block_character.interstitial_character == OperationsSurface {
|
||
return InterstitialType::OperationsSurface; // D-233 — never open space
|
||
}
|
||
match block_character.setback_tier { // D-233 OpenSpace path only
|
||
ZeroLot => InterstitialType::Void, // no yard at zero-lot
|
||
Tight => InterstitialType::Court,
|
||
Standard => InterstitialType::Garden,
|
||
Generous => InterstitialType::Plaza,
|
||
Campus => InterstitialType::OpenLawn,
|
||
}
|
||
}
|
||
```
|
||
|
||
This is a **new enum**, `InterstitialType` (D-235 names the vocabulary in
|
||
prose — `void / court / garden / plaza / dock_slip / market_pad / open_lawn` —
|
||
but no code enum exists yet; grepping the tree confirms it). Six of the seven
|
||
named values map cleanly onto the five-tier `SetbackTier` ladder plus the
|
||
`OperationsSurface` branch above; the remaining two (`dock_slip`, `market_pad`)
|
||
are D-235's own named examples for **specific contexts** (`dock_slip` at a
|
||
waterfront edge, `market_pad` presumably a commercial/marketplace zone
|
||
variant) that the record does not tie to a concrete derivation rule anywhere I
|
||
can find — they read as illustrative vocabulary entries, not a specified
|
||
mapping. **This brief does not invent that mapping — filed as T-1209.** T-1098's
|
||
scope is the geometry + threading + the setback_tier-driven resolution D-235
|
||
step 3 explicitly specifies; `dock_slip`/`market_pad` context-sensitivity
|
||
(waterfront edge, zone_type) is **T-1209**, not guessed at here, per the
|
||
ticket's own instruction to pin the geometry model, not invent unspecified
|
||
content. The five-value mapping above is a **subset of the vocabulary, not the
|
||
permanent shape of it** — the code comment at the resolution match arm points
|
||
at T-1209 explicitly so this doesn't read as a closed enumeration. Every
|
||
interstitial tile still resolves to a defined, non-Void value from the subset.
|
||
|
||
`InterstitialType` is `#[repr(u8)]`, integer-discriminant, append-only — same
|
||
convention as `ShellVoxel`/`SetbackTier`/the D-235 material axes (D-010).
|
||
|
||
**Output shape.** `FilledChunk` (`shell.rs:107-124`) gains a third sparse map,
|
||
symmetric with `voxels`/`surface_material`:
|
||
|
||
```rust
|
||
pub interstitial: BTreeMap<(u8, u8), InterstitialType>,
|
||
```
|
||
|
||
Keyed by chunk-local `(x, y)` only (no `z` — interstitial fill is a ground-plane
|
||
classification, one value per tile column, not a voxel-height concept; this
|
||
matches D-230's own four-way shell vocabulary being about vertical structure,
|
||
which interstitial fill is explicitly outside of per the shell.rs module doc,
|
||
`shell.rs:18-22`, "Interstitial street / open-space fill (D-215)... [is] out
|
||
of scope here"). Every ground tile in the chunk that is not `Void` (i.e. not
|
||
under a footprint or the street margin) gets an entry — dense over the
|
||
interstitial area, not sparse-by-omission the way `voxels` is sparse over
|
||
mostly-`Void` volume. Street-margin tiles (§1 kind 3) get **no** entry in any
|
||
of the three maps in this ticket's scope — D-234's street layer owns that
|
||
band's fill and is explicitly future work; this ticket's `interstitial` map
|
||
covers kind-2 tiles only.
|
||
|
||
**Absence contract (closes the implicit-Void gap one level up — coordinator
|
||
review note).** A tile with no entry in `interstitial` is not "unclassified" —
|
||
the three-way partition in §1 is exhaustive by construction, so absence from
|
||
this map means the tile is one of the *other two* kinds, never an unresolved
|
||
fourth state: either (a) footprint-covered (present instead in `voxels`/
|
||
`surface_material`), or (b) street-margin or a reserved block's chunk (present
|
||
in none of the three maps, per §1 kind 3 and §4). `FilledChunk`'s doc comment
|
||
states this explicitly — a consumer checking "is this tile interstitial" must
|
||
not read a missing key as "unknown," only as "resolve it against `voxels` to
|
||
tell footprint from street/reserved."
|
||
|
||
## 4. Reserved / all-interstitial / empty blocks
|
||
|
||
A reserved block (`block.reservation.is_some()`) has no `block_tags` and
|
||
(per §2) no `block_interstitial` entries either — `assign_block_tags` returns
|
||
early before subdivision ever runs for it. Its chunk therefore resolves via
|
||
neither §1 nor §3 mechanically; every tile in it is street-margin-or-nothing
|
||
by the current geometry. This is **correct and out of scope** for T-1098: D-230
|
||
already documents multi-block reservations (parks, terminals, plazas) as a
|
||
separate `MultiBlockReservation`/`ReservationFunction` system
|
||
(`generator.rs`) that has its own fill story, not this ticket's setback-driven
|
||
interstitial character. This brief does not touch reservation fill; a reserved
|
||
block's `FillChunk` continues to derive an all-`Void`-shell, no-`interstitial`-
|
||
entries chunk exactly as it does today (verified: no test asserts otherwise).
|
||
|
||
A **non-reserved** block with `coverage_pct` rolling zero buildings placed
|
||
(possible at low density/high setback with an unlucky BSP+roll combination,
|
||
though `roofed_coverage_pct`'s minimum is 20% for `BulkLiquid` frontier blocks
|
||
so this is rare but not impossible) still produces a full BSP leaf set, all of
|
||
which fail their coverage roll — its entire inner rect becomes interstitial,
|
||
which is exactly right: an under-built block reads as one big open lawn/plaza,
|
||
not as an error case. No special-casing needed; §1's construction already
|
||
handles it.
|
||
|
||
## 5. Purity (T-987)
|
||
|
||
Nothing above adds a cache read inside `run_work_item`/`fill_chunk`/
|
||
`shell_derive_into`. The interstitial rects and the two `BlockFillContext`
|
||
scalars are computed once, at `GenerateSkeleton` plan time (exactly where
|
||
`BuildingPropertyTag`/`BuildingExteriorTag` are already computed and frozen),
|
||
cached on `QuarterWorldState`, and pre-resolved into the `FillChunk` work item
|
||
by `build_fill_chunk_item` at enqueue time — identical shape to the existing
|
||
`block_tags` contract. The new `interstitial_fill` derive function (§3's
|
||
`resolve`, plus its caller that walks `interstitial_leaves` and stamps chunk-
|
||
local tiles) takes only its work-item fields as input and returns `FilledChunk`'s
|
||
new map: no `systems.db` touch, no `BodyWorldState`/`QuarterWorldState` read,
|
||
no RNG (the classification is a pure lookup on frozen scalars, per §3).
|
||
|
||
## 6. Determinism (D-010)
|
||
|
||
- `subdivide_block_footprints`'s BSP + coverage roll is already deterministic
|
||
(seeded `splitmix64`, no float, no wall-clock/thread-order dependence) — §1
|
||
only changes what the function *returns*, not how it computes, so this
|
||
ticket introduces no new entropy source.
|
||
- `derive_setback_tier(density_pct)` and `interstitial_character_for(bulk)` are
|
||
already pure integer functions (existing code) — re-invoking them at block
|
||
level (§2) for `BlockFillContext` produces the identical value a building's
|
||
`.exterior.setback_tier` would show, by construction (same input, same
|
||
function).
|
||
- §3's `resolve` is a total match over two enums — no RNG, no float, fully
|
||
deterministic given `BlockFillContext`.
|
||
- `interstitial: BTreeMap<...>` keeps iteration order deterministic (same
|
||
discipline as `voxels`/`surface_material`), satisfying `FilledChunk`'s
|
||
existing `PartialEq`/hashable-content contract used by the `fill_is_deterministic`-
|
||
style tests already in `shell.rs`.
|
||
- End-to-end: same seed → same `QuarterSkeleton` → same BSP leaves/coverage
|
||
rolls → same interstitial rect list → same `FillChunk` work item → same
|
||
`FilledChunk.interstitial` map. A same-seed-twice test at each new layer
|
||
(mirroring `shell.rs`'s existing `fill_is_deterministic` test) is the
|
||
verification vehicle in Phase 2.
|
||
|
||
## 7. What a Phase-5 consumer will see differently
|
||
|
||
Nothing renders yet (Phase 4 stays data-only, D-166). What changes is that
|
||
`FilledChunk` — the same struct a Phase-5 renderer will eventually consume —
|
||
gains ground-plane tile character data for every non-footprint,
|
||
non-street-margin tile in a built-up block: a specific `InterstitialType`
|
||
(or `OperationsSurface`) instead of silence. A harness/Atlas consumer asking
|
||
"what is this empty-looking tile between two buildings" gets an answer today
|
||
that it does not get at all right now (the tile simply has no `ShellVoxel`
|
||
entry and no other signal). This is Layer-5 data-completeness, not a rendering
|
||
change — consistent with T-959's closed scope ("Atlas tile-layer
|
||
visualization is T-960's scope — T-959's deliverable is DATA only").
|
||
|
||
## Open items deliberately left for a follow-up, not this ticket
|
||
|
||
- `dock_slip`/`market_pad` context-sensitive resolution (§3) — **T-1209**,
|
||
needs a D-235 amendment or a Miri/Araminta content pass to specify the
|
||
trigger condition (waterfront edge? zone_type? both?) before it can be
|
||
implemented without guessing.
|
||
- The street-margin band (kind 3, §1) getting its own fill/`StreetSurface`
|
||
per-tile stamp is D-234's future street-network step, already flagged as
|
||
out of scope by the `chunk_layout` stub's own doc comment.
|
||
- Reservation-fill (parks/terminals/plazas, §4) is a distinct system
|
||
(`MultiBlockReservation`) with its own eventual fill story — not
|
||
setback-driven interstitial character, and not this ticket.
|
||
|
||
## Summary of the code shape (for the go-ahead message)
|
||
|
||
| Item | Change |
|
||
|---|---|
|
||
| `subdivide_block_footprints` | returns `(footprints, interstitial_leaves)` instead of just footprints |
|
||
| `assign_block_tags` / `assign_all_block_tags` | also produce+return the block's interstitial leaves |
|
||
| `QuarterWorldState` | + `block_interstitial: BTreeMap<(u8,u8), Vec<TileRect>>` |
|
||
| `GenWorkItem::FillChunk` | + `interstitial_leaves: Vec<TileRect>`, + `block_character: BlockFillContext` |
|
||
| `BlockFillContext` (new) | `{ interstitial_character: InterstitialCharacter, setback_tier: SetbackTier }` |
|
||
| `InterstitialType` (new enum, `generator.rs`) | `Void \| Court \| Garden \| Plaza \| OpenLawn \| OperationsSurface` (repr(u8), append-only) |
|
||
| `FilledChunk` | + `interstitial: BTreeMap<ShellVoxelPos2D, InterstitialType>` (2-D key, ground-plane only) |
|
||
| new fn in `shell.rs` | `interstitial_fill_into(...)` — pure, mirrors `shell_derive_into`'s clip-to-window shape |
|
||
| `build_fill_chunk_item` | pulls the two new fields alongside `block_tags` |
|