--- 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 /* footprints */, Vec /* 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` — 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>` (`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>` (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` — 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>` | | `GenWorkItem::FillChunk` | + `interstitial_leaves: Vec`, + `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` (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` |