feat(simulation): interstitial fill — ground-tile character between building footprints (T-1098)
The BSP leaves that lose the D-233 coverage roll in subdivide_block_footprints were computed and discarded; they are now surfaced as the interstitial rect set (BlockSubdivision), making the ground-plane classification exhaustive by construction: footprint / interstitial / street-margin-or-reserved. FillChunk carries the leaves plus a minimal BlockFillContext (interstitial_character + setback_tier, re-derived at block level via the existing pure fn); FilledChunk gains a sparse interstitial map whose absence contract is stated on the struct (missing key = footprint/street/reserved, never unknown). The pure resolution maps OperationsSurface (D-233) first, else setback_tier onto five of D-235's seven interstitial values — dock_slip/market_pad have no specified trigger in the record and point at T-1209 rather than an invented mapping. Design brief with the geometry model at docs/architecture/interstitial-fill-t1098.md (lead-approved checkpoint). Bonus fix: a degenerate setback shrink previously vanished from BOTH lists silently; it now falls through to interstitial. 14 new tests; full cargo test green incl. all golden harnesses; purity per T-987 (plan-time compute, pre-resolved work items, no cache reads). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
---
|
||||
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, and it is an unconsumed stub
|
||||
(grep confirms no reader) 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` |
|
||||
Reference in New Issue
Block a user