diff --git a/docs/architecture/interstitial-fill-t1098.md b/docs/architecture/interstitial-fill-t1098.md new file mode 100644 index 000000000..b5d17fdda --- /dev/null +++ b/docs/architecture/interstitial-fill-t1098.md @@ -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 /* 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, 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` — 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` | diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 386776355..0a19b2426 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -43,7 +43,9 @@ use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleto use crate::atlas::trait_catalog_reader::ExteriorCatalog; use crate::bridge::ConnectionId; use crate::seed::SeedChain; -use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState}; +use crate::simulation::generator::{ + BlockFillContext, BuildingPropertyTag, CityGenerationContext, QuarterWorldState, TileRect, +}; // --------------------------------------------------------------------------- // Priority @@ -174,12 +176,14 @@ pub enum GenWorkItem { heightmap: std::sync::Arc, }, /// Derive the building shell for one 64 m chunk of an existing quarter - /// (D-230 derive phase, T-987). + /// (D-230 derive phase, T-987), plus its T-1098 interstitial ground-tile + /// classification. /// - /// The covering block's `block_tags` are **pre-resolved into the item** at enqueue - /// time because `run_work_item` is cache-free (mirrors `GenerateSkeleton`). A 64 m - /// chunk lies wholly within one 128 m block and footprints are block-confined, so - /// the covering block's tags are exactly the relevant set. Build items with + /// The covering block's `block_tags`/`interstitial_leaves`/`block_character` are + /// **pre-resolved into the item** at enqueue time because `run_work_item` is + /// cache-free (mirrors `GenerateSkeleton`). A 64 m chunk lies wholly within one + /// 128 m block and footprints/interstitial leaves are block-confined, so the + /// covering block's data is exactly the relevant set. Build items with /// [`build_fill_chunk_item`] — the D-230 "skeleton not yet processed → re-enqueue /// at `High`" precondition is the caller's cache lookup, which only reaches this /// constructor once the `QuarterWorldState` exists. @@ -196,6 +200,16 @@ pub enum GenWorkItem { /// live behind the pointer), so — unlike `AnalyzeBody`'s boxed `BodyParams` — /// this variant needs no `Box` to stay clippy `large_enum_variant`-clean. block_tags: Vec, + /// T-1098: covering block's interstitial ground-tile rects, pre-resolved + /// from `QuarterWorldState.block_interstitial`. Empty for a reserved block + /// or a block whose BSP leaves all became buildings — both legitimate. + interstitial_leaves: Vec, + /// T-1098: the two block-level scalars an interstitial tile's resolution + /// needs (`InterstitialCharacter` + `SetbackTier`) — NOT the whole + /// `BlockSkeleton` (see `BlockFillContext`'s doc for why). Re-derived once + /// at enqueue time from the cached `QuarterSkeleton`'s block grid, mirroring + /// how `block_tags` is pulled from the cache rather than recomputed here. + block_character: BlockFillContext, }, /// Derive a district-resolution window (D-226 T-1124 amendment, T-1137). /// @@ -1071,8 +1085,9 @@ fn run_work_item( // Step-3 building-property tags per footprint (D-229, #957): subdivide // each block into building plots and tag them. `exterior_catalog` // (D-235, T-988) resolves each tag's BuildingExteriorTag in the - // same pass. - let block_tags = assign_all_block_tags( + // same pass. T-1098: the sibling `block_interstitial` map threads + // block-level ground-tile geometry alongside `block_tags`. + let assignment = assign_all_block_tags( &skeleton, &resolved_context, economic_role, @@ -1085,7 +1100,8 @@ fn run_work_item( body_id: body_id.clone(), state: Box::new(QuarterWorldState { skeleton, - block_tags, + block_tags: assignment.block_tags, + block_interstitial: assignment.block_interstitial, }), } } @@ -1094,11 +1110,21 @@ fn run_work_item( block_pos, sub_chunk, block_tags, + interstitial_leaves, + block_character, } => { // D-230 derive phase: pure rectangle-containment + z-range shell fill over // the pre-resolved tags. No cache read here — that is what keeps FillChunk - // trivially fast and re-derivable (D-227). - let filled = fill_chunk(*quarter_id, *block_pos, *sub_chunk, block_tags); + // trivially fast and re-derivable (D-227). T-1098: interstitial ground-tile + // classification rides the same pre-resolved, cache-free contract. + let filled = fill_chunk( + *quarter_id, + *block_pos, + *sub_chunk, + block_tags, + interstitial_leaves, + *block_character, + ); GenCompletion::ChunkFilled { filled: Box::new(filled), } @@ -1240,14 +1266,22 @@ fn run_work_item( } /// Build a [`GenWorkItem::FillChunk`] for one 64 m sub-chunk of a quarter, pulling the -/// covering block's tags out of the cached `QuarterWorldState` (D-230 derive phase, T-987). +/// covering block's tags + T-1098 interstitial data out of the cached `QuarterWorldState` +/// (D-230 derive phase, T-987). /// /// Pure (no queue/cache handle), so it unit-tests without a running app. The D-230 /// precondition — "`FillChunk` is only dispatched after `SkeletonGenerated` for that /// district has been processed; if absent, re-enqueue at `High`" — is the caller's /// cache lookup: this constructor only runs once the `QuarterWorldState` exists. A /// block with no buildings yields empty `block_tags` (→ an empty, terrain-only shell), -/// which is a valid ready state, not a not-yet-generated one. +/// which is a valid ready state, not a not-yet-generated one. Likewise an empty +/// `interstitial_leaves` (reserved block, or every BSP leaf became a building). +/// +/// `block_character` is re-derived from the cached `QuarterSkeleton`'s block grid — +/// `setback_tier` via the same pure `derive_setback_tier(density_pct)` every building +/// in the block already uses (T-988), `interstitial_character` read straight off +/// `BlockSkeleton` (D-233/T-1097) — rather than carrying the whole `BlockSkeleton` +/// into the work item (see `BlockFillContext`'s doc for why only these two scalars). pub fn build_fill_chunk_item( quarter: &QuarterWorldState, block_pos: (u8, u8), @@ -1258,11 +1292,23 @@ pub fn build_fill_chunk_item( .get(&block_pos) .cloned() .unwrap_or_default(); + let interstitial_leaves = quarter + .block_interstitial + .get(&block_pos) + .cloned() + .unwrap_or_default(); + let block = &quarter.skeleton.blocks[block_pos.0 as usize][block_pos.1 as usize]; + let block_character = BlockFillContext { + interstitial_character: block.interstitial_character, + setback_tier: crate::atlas::trait_exterior::derive_setback_tier(block.density_pct), + }; GenWorkItem::FillChunk { quarter_id: quarter.skeleton.quarter_id, block_pos, sub_chunk, block_tags, + interstitial_leaves, + block_character, } } @@ -1464,6 +1510,12 @@ mod tests { block_pos: (0, 0), sub_chunk: (0, 0), block_tags: vec![], + interstitial_leaves: vec![], + block_character: BlockFillContext { + interstitial_character: + crate::simulation::generator::InterstitialCharacter::OpenSpace, + setback_tier: crate::simulation::generator::SetbackTier::Standard, + }, }, GenPriority::High, ); @@ -1521,6 +1573,7 @@ mod tests { ..Default::default() }, block_tags, + block_interstitial: BTreeMap::new(), } } @@ -1533,6 +1586,7 @@ mod tests { block_pos, sub_chunk, block_tags, + .. } = item else { panic!("expected FillChunk"); diff --git a/server/src/atlas/layer_proxy.rs b/server/src/atlas/layer_proxy.rs index a4d79f9eb..3e6beda5d 100644 --- a/server/src/atlas/layer_proxy.rs +++ b/server/src/atlas/layer_proxy.rs @@ -5185,6 +5185,7 @@ mod tests { ..Default::default() }, block_tags: Default::default(), + block_interstitial: Default::default(), }, ); @@ -5252,6 +5253,7 @@ mod tests { ..Default::default() }, block_tags: Default::default(), + block_interstitial: Default::default(), }, ); @@ -5320,6 +5322,7 @@ mod tests { ..Default::default() }, block_tags: Default::default(), + block_interstitial: Default::default(), }, ); @@ -5386,6 +5389,7 @@ mod tests { ..Default::default() }, block_tags: Default::default(), + block_interstitial: Default::default(), }, ); @@ -5627,6 +5631,7 @@ mod tests { ..Default::default() }, block_tags: Default::default(), + block_interstitial: Default::default(), }, ); diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index 95eb77a1a..2adbfbb55 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -17,9 +17,12 @@ //! deterministic function of the seed (D-010). //! - **Is not:** the *surface* material vocabulary (`WallMaterial`/`RoofForm`/ //! `StreetSurface`, D-235) — that is the `BuildingExteriorTag` visual grammar (T-988), -//! layered on top of this shell. Interstitial street / open-space fill (D-215) and the -//! rolling condition overlay (D-198, T-999) are likewise out of scope here; this layer -//! emits only the four shell materials. +//! layered on top of this shell. Street fill (D-234's future street-network step) and the +//! rolling condition overlay (D-198, T-999) are likewise out of scope here. Ground-plane +//! interstitial classification (the gap between footprints, D-233/D-235) **is** in scope +//! as of T-1098 — see [`FilledChunk::interstitial`] — but street-margin tiles are not: +//! this layer emits the four shell materials plus interstitial character, never a street +//! surface stamp. //! //! ## Scale + coordinates (D-243) //! @@ -54,7 +57,10 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::atlas::scale::{CHUNKS_PER_BLOCK, CHUNK_M, VOXELS_PER_CHUNK}; -use crate::simulation::generator::{BuildingPropertyTag, RoofForm, TileRect, WallMaterial}; +use crate::simulation::generator::{ + resolve_interstitial_type, BlockFillContext, BuildingPropertyTag, InterstitialType, RoofForm, + TileRect, WallMaterial, +}; /// One structural shell voxel material (D-230). /// @@ -98,11 +104,38 @@ pub enum SurfaceMaterial { /// signed. pub type ShellVoxelPos = (u8, u8, i32); -/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987). +/// Chunk-local ground-tile coordinate: `(x, y)` in `0..64` — no `z`. Interstitial +/// classification (T-1098) is a ground-plane property, one value per tile column, +/// not a voxel-height concept (unlike [`ShellVoxelPos`]). +pub type GroundTilePos = (u8, u8); + +/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987), +/// extended by T-1098 with ground-plane interstitial classification. /// /// Sparse: only non-[`ShellVoxel::Void`] voxels are present. `BTreeMap` keeps iteration /// deterministic (D-010). Carries its own quarter-relative address so a consumer /// (Phase 5 rendering) can place it without re-deriving the mapping. +/// +/// **Absence contract (T-1098) — every block-local ground tile in this chunk is one +/// of exactly three kinds, and the three maps below encode which:** +/// +/// 1. **Footprint-covered** — has an entry in `voxels` (and, if `Wall`/`Roof`, in +/// `surface_material`). Never has an entry in `interstitial`. +/// 2. **Interstitial** (the gap between footprints, D-233/D-235) — has an entry in +/// `interstitial`. Never has an entry in `voxels`/`surface_material` at any `z` +/// (ground-plane classification only; a footprint's own tiles are never +/// reclassified as interstitial). +/// 3. **Street-margin, or inside a reserved block** — has **no** entry in any of the +/// three maps. This is future D-234 street-network scope, not an unresolved or +/// "unknown" state: a missing key in `interstitial` means "check `voxels` — if +/// that's also empty at this `(x, y)`, this tile is street-margin or reserved, +/// never an unclassified fourth case." +/// +/// A consumer asking "is this tile interstitial" must read absence-from-`interstitial` +/// as "resolve against `voxels` to tell footprint from street/reserved," never as +/// "unknown" — the exhaustiveness lives in the block-plan-time classification +/// (`atlas::skeleton_gen::subdivide_block_footprints`'s `BlockSubdivision`), and this +/// struct's contract must not reintroduce ambiguity one level up. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct FilledChunk { /// Stable id of the quarter this chunk belongs to (D-194/D-230). @@ -121,6 +154,12 @@ pub struct FilledChunk { /// `shell_derive_into`'s doc comment) — a missing entry means "render the /// generic fallback for this axis," never a panic. pub surface_material: BTreeMap, + /// T-1098: D-235/D-233 ground-tile character for every chunk-local tile + /// classified interstitial (the gap between footprints) — dense over that + /// area, keyed by 2-D chunk-local position (no `z`; see [`GroundTilePos`]). + /// See this struct's doc for the absence contract that ties this map to + /// `voxels`. + pub interstitial: BTreeMap, } impl FilledChunk { @@ -144,11 +183,14 @@ impl FilledChunk { } /// Derive the shell [`FilledChunk`] for the sub-chunk `sub_chunk` of block `block_pos`, -/// given that block's pre-resolved building tags (D-230 derive phase, T-987). +/// given that block's pre-resolved building tags (D-230 derive phase, T-987) and +/// T-1098 interstitial data. /// /// Pure: the output is a total deterministic function of the inputs (D-010). `block_tags` -/// is the covering block's `Vec` from the cached `QuarterWorldState` -/// — pre-resolved by the caller because the work executor is cache-free +/// is the covering block's `Vec`, `interstitial_leaves` its +/// `Vec` of interstitial ground-tile rects, and `block_character` its +/// `BlockFillContext` — all from the cached `QuarterWorldState`/`QuarterSkeleton`, +/// pre-resolved by the caller because the work executor is cache-free /// ([`crate::atlas::gen_queue`]). /// /// `quarter_id` is threaded through for addressing only. @@ -157,6 +199,8 @@ pub fn fill_chunk( block_pos: (u8, u8), sub_chunk: (u8, u8), block_tags: &[BuildingPropertyTag], + interstitial_leaves: &[TileRect], + block_character: BlockFillContext, ) -> FilledChunk { debug_assert!( (sub_chunk.0 as i32) < CHUNKS_PER_BLOCK && (sub_chunk.1 as i32) < CHUNKS_PER_BLOCK, @@ -164,20 +208,22 @@ pub fn fill_chunk( ); let mut voxels: BTreeMap = BTreeMap::new(); let mut surface_material: BTreeMap = BTreeMap::new(); + let mut interstitial: BTreeMap = BTreeMap::new(); // Block-local tile range covered by this 64 m sub-chunk quadrant. let chunk_lo_x = sub_chunk.0 as i32 * CHUNK_M; let chunk_lo_y = sub_chunk.1 as i32 * CHUNK_M; let chunk_hi_x = chunk_lo_x + CHUNK_M; // exclusive let chunk_hi_y = chunk_lo_y + CHUNK_M; // exclusive + let window = (chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y); for tag in block_tags { - shell_derive_into( - &mut voxels, - &mut surface_material, - tag, - (chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y), - ); + shell_derive_into(&mut voxels, &mut surface_material, tag, window); + } + + let interstitial_type = resolve_interstitial_type(block_character); + for leaf in interstitial_leaves { + interstitial_fill_into(&mut interstitial, leaf, interstitial_type, window); } FilledChunk { @@ -186,6 +232,7 @@ pub fn fill_chunk( sub_chunk, voxels, surface_material, + interstitial, } } @@ -315,6 +362,45 @@ fn is_perimeter(footprint: &TileRect, tx: i32, ty: i32) -> bool { tx == lo_x || tx == hi_x || ty == lo_y || ty == hi_y } +/// Emit one interstitial leaf's ground-tile classification into `interstitial` +/// (T-1098), clipped to the chunk's block-local tile window `(lo_x, lo_y, hi_x, +/// hi_y)` (hi exclusive) — mirrors [`shell_derive_into`]'s clip-to-window shape. +/// +/// Rectangle-containment only (no z-range/floors — interstitial fill is a +/// ground-plane classification, one value per tile column). Every tile inside +/// `leaf ∩ window` gets `interstitial_type` — the same value for the whole leaf, +/// since character is a frozen block-level fact (`BlockFillContext`), not a +/// per-tile roll. +fn interstitial_fill_into( + interstitial: &mut BTreeMap, + leaf: &TileRect, + interstitial_type: InterstitialType, + window: (i32, i32, i32, i32), +) { + let (win_lo_x, win_lo_y, win_hi_x, win_hi_y) = window; + + let leaf_lo_x = leaf.origin.0 as i32; + let leaf_lo_y = leaf.origin.1 as i32; + let leaf_hi_x = leaf_lo_x + leaf.size.0.max(1) as i32; + let leaf_hi_y = leaf_lo_y + leaf.size.1.max(1) as i32; + + let lo_x = leaf_lo_x.max(win_lo_x); + let lo_y = leaf_lo_y.max(win_lo_y); + let hi_x = leaf_hi_x.min(win_hi_x); + let hi_y = leaf_hi_y.min(win_hi_y); + if lo_x >= hi_x || lo_y >= hi_y { + return; // leaf disjoint from this chunk's window + } + + for tx in lo_x..hi_x { + for ty in lo_y..hi_y { + let cx = (tx - win_lo_x) as u8; + let cy = (ty - win_lo_y) as u8; + interstitial.insert((cx, cy), interstitial_type); + } + } +} + /// Compile-time sanity: a chunk is 64 voxels on a side, so chunk-local indices fit a u8. const _: () = assert!(VOXELS_PER_CHUNK == CHUNK_M); const _: () = assert!(CHUNK_M <= u8::MAX as i32 + 1); @@ -329,10 +415,37 @@ mod tests { use crate::atlas::tile_condition::TileCondition; use crate::simulation::generator::{ ArchitectureFlavorRef, BuildingEntryClass, BuildingExteriorTag, ConstructionEra, EraCause, - FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, RoofForm, SetbackTier, - StreetSurface, WallMaterial, ZoneTypeId, + FacadeRhythm, FloorExtent, FloorHeightProfile, HsvColor, InterstitialCharacter, RoofForm, + SetbackTier, StreetSurface, WallMaterial, ZoneTypeId, }; + /// Neutral `BlockFillContext` for shell-only tests that don't exercise T-1098 + /// interstitial fill (`OpenSpace`/`Standard` — resolves to `Garden`, irrelevant + /// since these tests pass no `interstitial_leaves`). + const NO_INTERSTITIAL_CTX: BlockFillContext = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Standard, + }; + + /// Test helper: call `fill_chunk` with no interstitial leaves, for the + /// pre-T-1098 shell-only test suite below — keeps those tests focused on + /// shell derivation without threading unused T-1098 args through each call. + fn fill_chunk_shell_only( + quarter_id: u64, + block_pos: (u8, u8), + sub_chunk: (u8, u8), + block_tags: &[BuildingPropertyTag], + ) -> FilledChunk { + fill_chunk( + quarter_id, + block_pos, + sub_chunk, + block_tags, + &[], + NO_INTERSTITIAL_CTX, + ) + } + /// Build a `BuildingPropertyTag` with the given block-local footprint and a /// uniform 3-voxel-per-floor extent (the D-229 default). fn tag( @@ -372,7 +485,7 @@ mod tests { #[test] fn empty_block_yields_empty_chunk() { - let fc = fill_chunk(7, (0, 0), (0, 0), &[]); + let fc = fill_chunk_shell_only(7, (0, 0), (0, 0), &[]); assert_eq!(fc.voxel_count(), 0); assert_eq!(fc.quarter_id, 7); assert_eq!(fc.chunk_in_quarter(), (0, 0)); @@ -380,7 +493,7 @@ mod tests { #[test] fn chunk_in_quarter_maps_block_and_sub_chunk() { - let fc = fill_chunk(0, (3, 2), (1, 0), &[]); + let fc = fill_chunk_shell_only(0, (3, 2), (1, 0), &[]); // block (3,2) sub-chunk (1,0) → quarter chunk (3*2+1, 2*2+0) = (7, 4). assert_eq!(fc.chunk_in_quarter(), (7, 4)); } @@ -388,7 +501,7 @@ mod tests { #[test] fn single_storey_box_has_walls_floor_and_roof() { // 4×4 single-storey building at block-local origin (2,2), sub-chunk (0,0). - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); // Ground floor (3 voxels: z 0,1,2). Roof at z = 3. // Corner (2,2) is perimeter → Wall through z 0..=2. @@ -407,7 +520,7 @@ mod tests { #[test] fn multi_storey_stacks_floor_slabs() { // 5×5, three storeys (z bands 0..2, 3..5, 6..8). Roof at z = 9. - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (5, 5), 0, 3)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((0, 0), (5, 5), 0, 3)]); // Interior tile gets a slab at each floor base: z 0, 3, 6. assert_eq!(fc.get(2, 2, 0), ShellVoxel::FloorSlab); assert_eq!(fc.get(2, 2, 3), ShellVoxel::FloorSlab); @@ -423,7 +536,7 @@ mod tests { #[test] fn basement_floor_is_below_ground_zero() { // base_floor = -1, 2 floors → basement (z -3..-1) + ground (z 0..2). - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), -1, 2)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), -1, 2)]); // Ground floor interior slab at z = 0 (D-110: ground bottom is the origin). assert_eq!(fc.get(1, 1, 0), ShellVoxel::FloorSlab); // Basement interior slab is below zero. @@ -436,7 +549,7 @@ mod tests { fn elevated_building_with_no_ground_floor_anchors_at_its_own_bottom() { // base_floor = 2, no floor 0 → ground_offset falls back to 0, so the building's // own bottom maps to chunk-z 0 (no shift). 2 floors × 3 voxels, then a roof. - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), 2, 2)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((0, 0), (3, 3), 2, 2)]); // Lowest present floor's interior slab sits at chunk-z 0. assert_eq!(fc.get(1, 1, 0), ShellVoxel::FloorSlab); // Second floor's slab one storey up (z 3). @@ -450,7 +563,7 @@ mod tests { #[test] fn one_wide_building_is_all_wall() { // 1×4 footprint — every tile is perimeter, so all Wall (no interior slab). - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((0, 0), (1, 4), 0, 1)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((0, 0), (1, 4), 0, 1)]); for ty in 0..4u8 { assert_eq!(fc.get(0, ty, 0), ShellVoxel::Wall); } @@ -463,8 +576,8 @@ mod tests { // A building spanning the block's left edge into the second sub-chunk. // Footprint block-local x 60..68 straddles the x=64 sub-chunk seam. let building = tag((60, 10), (8, 4), 0, 1); - let left = fill_chunk(1, (0, 0), (0, 0), std::slice::from_ref(&building)); - let right = fill_chunk(1, (0, 0), (1, 0), std::slice::from_ref(&building)); + let left = fill_chunk_shell_only(1, (0, 0), (0, 0), std::slice::from_ref(&building)); + let right = fill_chunk_shell_only(1, (0, 0), (1, 0), std::slice::from_ref(&building)); // Left sub-chunk (0,0): the window origin is 0, so here chunk-local == block-local // (x 60..64). The right sub-chunk below is the general case where they differ. @@ -478,8 +591,8 @@ mod tests { #[test] fn fill_is_deterministic() { let tags = vec![tag((0, 0), (6, 6), -1, 4), tag((40, 40), (10, 8), 0, 2)]; - let a = fill_chunk(99, (1, 1), (0, 1), &tags); - let b = fill_chunk(99, (1, 1), (0, 1), &tags); + let a = fill_chunk_shell_only(99, (1, 1), (0, 1), &tags); + let b = fill_chunk_shell_only(99, (1, 1), (0, 1), &tags); assert_eq!(a, b); } @@ -496,7 +609,7 @@ mod tests { tags.push(tag((gx * 16, gy * 16), (14, 14), 0, 5)); } } - let fc = fill_chunk(1, (0, 0), (0, 0), &tags); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &tags); // Dense volume: 16 buildings × (14×14 footprint) × (5 floors × 3 + 1 roof). let dense_volume = 16 * 14 * 14 * (5 * 3 + 1); @@ -513,7 +626,7 @@ mod tests { #[test] fn wall_voxels_carry_the_buildings_wall_material() { - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); // Corner (2,2) is perimeter → Wall, per the existing shell test above. assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall); assert_eq!( @@ -524,7 +637,7 @@ mod tests { #[test] fn roof_voxels_carry_the_buildings_roof_form() { - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof); assert_eq!( fc.surface_material.get(&(2, 2, 3)), @@ -534,7 +647,7 @@ mod tests { #[test] fn floor_slab_and_void_carry_no_surface_material() { - let fc = fill_chunk(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[tag((2, 2), (4, 4), 0, 1)]); // Interior tile (3,3): FloorSlab at the base, Void above — see the // shipped `single_storey_box_has_walls_floor_and_roof` test. assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab); @@ -544,7 +657,7 @@ mod tests { #[test] fn surface_material_count_matches_wall_plus_roof_voxels() { - let fc = fill_chunk( + let fc = fill_chunk_shell_only( 1, (0, 0), (0, 0), @@ -570,7 +683,7 @@ mod tests { b2.exterior.wall_material = WallMaterial::TimberWall; b2.exterior.roof_form = RoofForm::PitchedRoof; - let fc = fill_chunk(1, (0, 0), (0, 0), &[b1, b2]); + let fc = fill_chunk_shell_only(1, (0, 0), (0, 0), &[b1, b2]); assert_eq!( fc.surface_material.get(&(0, 0, 0)), Some(&SurfaceMaterial::Wall(WallMaterial::StoneWall)) @@ -580,4 +693,141 @@ mod tests { Some(&SurfaceMaterial::Wall(WallMaterial::TimberWall)) ); } + + // ── T-1098: interstitial ground-tile fill ──────────────────────────────── + + #[test] + fn interstitial_leaf_stamps_the_resolved_type_over_its_tiles() { + // A single interstitial leaf, ZeroLot/OpenSpace → Void, at block-local + // (10,10)-(14,14) (a 4x4 leaf), fully inside sub-chunk (0,0). + let leaf = TileRect::new(10, 10, 4, 4); + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::ZeroLot, + }; + let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[leaf], ctx); + + for tx in 10..14u8 { + for ty in 10..14u8 { + assert_eq!( + fc.interstitial.get(&(tx, ty)), + Some(&InterstitialType::Void), + "tile ({tx},{ty}) inside the leaf must resolve to Void (ZeroLot)" + ); + } + } + // Outside the leaf: no entry. + assert_eq!(fc.interstitial.get(&(0, 0)), None); + assert_eq!(fc.interstitial.get(&(20, 20)), None); + } + + #[test] + fn every_setback_tier_resolves_to_its_own_interstitial_type() { + let leaf = TileRect::new(0, 0, 2, 2); + let cases = [ + (SetbackTier::ZeroLot, InterstitialType::Void), + (SetbackTier::Tight, InterstitialType::Court), + (SetbackTier::Standard, InterstitialType::Garden), + (SetbackTier::Generous, InterstitialType::Plaza), + (SetbackTier::Campus, InterstitialType::OpenLawn), + ]; + for (tier, expected) in cases { + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: tier, + }; + let fc = fill_chunk(1, (0, 0), (0, 0), &[], std::slice::from_ref(&leaf), ctx); + assert_eq!( + fc.interstitial.get(&(0, 0)), + Some(&expected), + "setback tier {tier:?} must stamp {expected:?}" + ); + } + } + + #[test] + fn operations_surface_short_circuits_regardless_of_setback_in_fill_chunk() { + // D-233: bulk-industry blocks read as OperationsSurface no matter the + // setback tier — verified at the fill_chunk level, not just resolve(). + let leaf = TileRect::new(5, 5, 3, 3); + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OperationsSurface, + setback_tier: SetbackTier::Campus, // loosest tier — must NOT win + }; + let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[leaf], ctx); + assert_eq!( + fc.interstitial.get(&(5, 5)), + Some(&InterstitialType::OperationsSurface) + ); + } + + #[test] + fn footprint_and_interstitial_tiles_are_mutually_exclusive() { + // A building footprint and an adjacent-but-disjoint interstitial leaf + // in the same chunk: no chunk-local tile appears in both maps. + let building = tag((0, 0), (4, 4), 0, 1); + let leaf = TileRect::new(10, 10, 4, 4); + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Standard, + }; + let fc = fill_chunk(1, (0, 0), (0, 0), &[building], &[leaf], ctx); + + assert!(!fc.interstitial.is_empty(), "leaf must produce entries"); + assert!( + fc.voxels + .keys() + .any(|(x, y, _)| (0..4).contains(x) && (0..4).contains(y)), + "footprint must produce voxel entries" + ); + for (x, y) in fc.interstitial.keys() { + assert!( + fc.voxels.keys().all(|(vx, vy, _)| vx != x || vy != y), + "tile ({x},{y}) must not appear in both voxels and interstitial" + ); + } + } + + #[test] + fn interstitial_leaf_clipped_to_sub_chunk() { + // A leaf spanning the block's sub-chunk seam at x=64: only the portion + // inside each sub-chunk's window is stamped, chunk-local. + let leaf = TileRect::new(60, 10, 8, 4); // block-local x 60..68 + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Standard, + }; + let left = fill_chunk(1, (0, 0), (0, 0), &[], std::slice::from_ref(&leaf), ctx); + let right = fill_chunk(1, (0, 0), (1, 0), &[], &[leaf], ctx); + + assert!(!left.interstitial.is_empty()); + assert!(left.interstitial.keys().all(|(x, _)| (60..64).contains(x))); + assert!(!right.interstitial.is_empty()); + assert!(right.interstitial.keys().all(|(x, _)| (0..4).contains(x))); + } + + #[test] + fn interstitial_fill_is_deterministic() { + let leaves = vec![TileRect::new(0, 0, 6, 6), TileRect::new(40, 40, 10, 8)]; + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Generous, + }; + let a = fill_chunk(99, (1, 1), (0, 1), &[], &leaves, ctx); + let b = fill_chunk(99, (1, 1), (0, 1), &[], &leaves, ctx); + assert_eq!(a, b); + } + + #[test] + fn empty_interstitial_leaves_yields_no_interstitial_entries() { + // A reserved block (or a block whose BSP leaves all became buildings) + // passes an empty interstitial_leaves list — must not error, and must + // not fabricate entries. + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Standard, + }; + let fc = fill_chunk(1, (0, 0), (0, 0), &[], &[], ctx); + assert!(fc.interstitial.is_empty()); + } } diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 610c15f96..181657236 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -842,6 +842,21 @@ fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool { } } +/// The two-way split of a block's BSP leaves (T-1098): the leaves that won +/// their D-233 coverage roll become building footprints; the leaves that lost +/// it are the block's interstitial ground — the gap between footprints. Both +/// lists are exhaustive over the block's inner rect (street-margin-excluded) +/// tile-space by construction: every BSP leaf lands in exactly one list. +#[derive(Debug, Clone, PartialEq, Eq)] +struct BlockSubdivision { + /// Building footprints — shrunk by `setback` to leave street frontage. + footprints: Vec, + /// Interstitial ground-tile rects — the *whole* BSP leaf, unshrunk (see + /// `subdivide_block_footprints`'s doc for why no setback shrink applies + /// here). + interstitial: Vec, +} + /// Subdivide one block into building footprints (D-220/D-229/D-233/D-234). /// /// Lot size + setback scale with `density_pct` (Frontier → few big lots, wide @@ -850,6 +865,16 @@ fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool { /// open space (yards/parks/lots). Footprints are axis-aligned `TileRect`s in /// block tile-space. `waterfront` (D-234b): on the water-facing edge the street /// margin drops to 0 — buildings present flush to the quay (dock-orthogonal). +/// +/// **T-1098:** returns both the footprint list *and* the interstitial leaf +/// list — the BSP leaves that lost the coverage roll, previously discarded. +/// An interstitial leaf is **not** shrunk by `setback`: the setback shrink +/// only applies to leaves that become buildings (pulling back from the lot +/// line to leave frontage for the *building's own* setback); an interstitial +/// leaf has no building on the other side to setback from, so it keeps the +/// whole BSP leaf rect — that full rect is exactly "the gap between +/// buildings," including the frontage a neighbor's setback would have +/// consumed. fn subdivide_block_footprints( density_pct: u8, bulk: &BulkClass, @@ -859,7 +884,7 @@ fn subdivide_block_footprints( _morphology: &MorphologyZone, waterfront: Option, seed: SeedChain, -) -> Vec { +) -> BlockSubdivision { let (min_lot, max_lot, setback) = match density_pct { 0..=20 => (24u8, 48u8, 3u8), // Frontier 21..=45 => (16, 32, 2), // Settled @@ -889,24 +914,38 @@ fn subdivide_block_footprints( bsp(inner, 0, min_lot, max_lot, seed.seed(), &mut leaves); // Keep the coverage fraction as buildings; shrink each by the setback to - // leave street frontage. The rest is interstitial open space. - let mut out = Vec::new(); + // leave street frontage. The rest (T-1098) is interstitial ground — + // collected, not discarded. + let mut footprints = Vec::new(); + let mut interstitial = Vec::new(); 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 + interstitial.push(lot.clone()); // T-1098: the whole leaf, unshrunk + continue; } let w = lot.size.0.saturating_sub(setback); let h = lot.size.1.saturating_sub(setback); if w >= 1 && h >= 1 { - out.push(TileRect::new(lot.origin.0, lot.origin.1, w, h)); + footprints.push(TileRect::new(lot.origin.0, lot.origin.1, w, h)); + } else { + // Degenerate shrink (setback ate the whole lot) — the lot still + // reads as ground, not a building; T-1098 keeps it interstitial + // rather than silently vanishing from both lists. + interstitial.push(lot.clone()); } } - out + BlockSubdivision { + footprints, + interstitial, + } } -/// Tag every building footprint in one block (D-229). Reserved blocks (parks, -/// terminals, plazas) are open/special-use and get no standard building fill. +/// Tag every building footprint in one block (D-229), plus the block's T-1098 +/// interstitial ground-tile rects. Reserved blocks (parks, terminals, plazas) +/// are open/special-use and get no standard building fill (nor interstitial +/// leaves — a reserved block's own fill story is out of this ticket's scope, +/// see the T-1098 brief §4). /// /// `exterior_catalog` is the D-235 exterior-grammar content (T-988), /// pre-resolved at L3→L4 dispatch time alongside the rest of the D-232 catalog @@ -920,9 +959,9 @@ fn assign_block_tags( waterfront: Option, block_chain: SeedChain, exterior_catalog: &ExteriorCatalog, -) -> Vec { +) -> (Vec, Vec) { if block.reservation.is_some() { - return Vec::new(); + return (Vec::new(), Vec::new()); } let prosperity = context.prosperity_baseline_bps; let setting = &context.surrounding_biome; @@ -944,15 +983,17 @@ fn assign_block_tags( heritage_bps: context.swerve_rates_bps.1, }; - let footprints = subdivide_block_footprints( + let subdivision = subdivide_block_footprints( block.density_pct, &context.dominant_bulk_class, &context.morphology_zone, waterfront, block_chain, ); + let interstitial = subdivision.interstitial; - footprints + let tags = subdivision + .footprints .into_iter() .enumerate() .map(|(i, footprint)| { @@ -1032,11 +1073,24 @@ fn assign_block_tags( doors: Vec::new(), // D-231 door derivation is #979 } }) - .collect() + .collect(); + + (tags, interstitial) } -/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag -/// every non-reserved block's footprints. Keyed by 4×4 block grid position. +/// Both block-keyed maps `assign_all_block_tags` produces (T-1098): the +/// existing per-building tags, plus the sibling interstitial-leaf map that +/// threads block-level ground-tile geometry to `FillChunk` (see +/// `simulation::generator::QuarterWorldState.block_interstitial`). +#[derive(Debug, Clone, PartialEq)] +pub struct BlockTagAssignment { + pub block_tags: BTreeMap<(u8, u8), Vec>, + pub block_interstitial: BTreeMap<(u8, u8), Vec>, +} + +/// Build the full `block_tags` (+ T-1098 `block_interstitial`) maps for a +/// quarter (D-229/D-230): subdivide and tag every non-reserved block's +/// footprints. Both maps are keyed by 4×4 block grid position. /// /// `exterior_catalog` is the D-235 exterior-grammar content (T-988) — /// threaded straight through to [`assign_block_tags`]; see its doc comment. @@ -1047,7 +1101,7 @@ pub fn assign_all_block_tags( founding_age_years: u32, chain: SeedChain, exterior_catalog: &ExteriorCatalog, -) -> BTreeMap<(u8, u8), Vec> { +) -> BlockTagAssignment { // Flush-frontage quarter edge: water-facing from a coastal founding // orientation (D-234b — blocks present flush to the quay), or rail-facing // from a RailHeadFacing orientation (T-1076 §4 — blocks present flush @@ -1055,13 +1109,14 @@ pub fn assign_all_block_tags( // mutually exclusive by construction (one orientation per settlement). let flush_edge = coastal_edge(&context.founding_orientation) .or_else(|| railhead_edge(&context.founding_orientation)); - let mut map = BTreeMap::new(); + let mut block_tags = BTreeMap::new(); + let mut block_interstitial = BTreeMap::new(); for row in 0..4u8 { for col in 0..4u8 { let block = &skeleton.blocks[row as usize][col as usize]; let block_chain = chain.derive(SeedDomain::Block, (row * 4 + col) as u64); let waterfront = flush_edge.filter(|&e| block_on_quarter_edge(row, col, e)); - let tags = assign_block_tags( + let (tags, interstitial) = assign_block_tags( block, skeleton, context, @@ -1072,11 +1127,17 @@ pub fn assign_all_block_tags( exterior_catalog, ); if !tags.is_empty() { - map.insert((row, col), tags); + block_tags.insert((row, col), tags); + } + if !interstitial.is_empty() { + block_interstitial.insert((row, col), interstitial); } } } - map + BlockTagAssignment { + block_tags, + block_interstitial, + } } // --------------------------------------------------------------------------- @@ -1726,15 +1787,18 @@ mod tests { #[test] fn footprints_fit_within_block_bounds() { - let fps = subdivide_block_footprints( + let sub = subdivide_block_footprints( 70, &BulkClass::NonPhysical, &MorphologyZone::AlluvialPlain, None, SeedChain::root(1), ); - assert!(!fps.is_empty(), "a dense block should produce footprints"); - for fp in &fps { + assert!( + !sub.footprints.is_empty(), + "a dense block should produce footprints" + ); + for fp in &sub.footprints { assert!(fp.size.0 >= 1 && fp.size.1 >= 1); assert!(fp.origin.0 as u16 + fp.size.0 as u16 <= BLOCK_TILES as u16); assert!(fp.origin.1 as u16 + fp.size.1 as u16 <= BLOCK_TILES as u16); @@ -1758,10 +1822,81 @@ mod tests { SeedChain::root(7), ); assert!( - dense.len() > sparse.len(), + dense.footprints.len() > sparse.footprints.len(), "dense ({}) should pack more lots than sparse ({})", - dense.len(), - sparse.len() + dense.footprints.len(), + sparse.footprints.len() + ); + } + + // ── T-1098: interstitial leaves from subdivide_block_footprints ───────── + + #[test] + fn interstitial_leaves_are_produced_alongside_footprints() { + let sub = subdivide_block_footprints( + 70, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + None, + SeedChain::root(1), + ); + assert!( + !sub.interstitial.is_empty(), + "a block with coverage < 100% must produce interstitial leaves" + ); + for leaf in &sub.interstitial { + assert!(leaf.size.0 >= 1 && leaf.size.1 >= 1); + assert!(leaf.origin.0 as u16 + leaf.size.0 as u16 <= BLOCK_TILES as u16); + assert!(leaf.origin.1 as u16 + leaf.size.1 as u16 <= BLOCK_TILES as u16); + } + } + + #[test] + fn footprints_and_interstitial_leaves_do_not_overlap() { + // Every BSP leaf becomes exactly one of a (shrunk) footprint or an + // (unshrunk) interstitial rect — never both, never neither is + // reflected in this test by checking no footprint origin coincides + // with an interstitial leaf's origin (BSP leaves are disjoint, so + // distinct origins is a sufficient proxy for "different leaves"). + let sub = subdivide_block_footprints( + 50, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + None, + SeedChain::root(3), + ); + for fp in &sub.footprints { + assert!( + !sub.interstitial.contains(fp), + "a footprint rect must not also appear as an interstitial leaf" + ); + } + } + + #[test] + fn low_coverage_bulk_class_yields_more_interstitial_than_high_coverage() { + // BulkSolid (25-40% coverage) vs NonPhysical (85-95% coverage) at the + // same density/seed: BulkSolid must leave far more ground uncovered. + let bulk_solid = subdivide_block_footprints( + 70, + &BulkClass::BulkSolid, + &MorphologyZone::AlluvialPlain, + None, + SeedChain::root(9), + ); + let non_physical = subdivide_block_footprints( + 70, + &BulkClass::NonPhysical, + &MorphologyZone::AlluvialPlain, + None, + SeedChain::root(9), + ); + assert!( + bulk_solid.interstitial.len() >= non_physical.interstitial.len(), + "low-coverage BulkSolid ({}) should leave at least as much interstitial \ + ground as high-coverage NonPhysical ({})", + bulk_solid.interstitial.len(), + non_physical.interstitial.len() ); } @@ -1777,7 +1912,7 @@ mod tests { let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); let sk = generate_quarter_skeleton(&ctx, 100_000_000, "financial", 1, 300, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -1785,6 +1920,7 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); + let tags = &assignment.block_tags; assert!(!tags.is_empty(), "quarter should produce building tags"); // Park reservation blocks (1,2),(1,3),(2,2),(2,3) get no standard fill. for &reserved in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] { @@ -1851,7 +1987,7 @@ mod tests { ); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -1861,7 +1997,7 @@ mod tests { ); let mut found_residential = false; - for (pos, block_tags) in &tags { + for (pos, block_tags) in &assignment.block_tags { let block = &sk.blocks[pos.0 as usize][pos.1 as usize]; if block.district_type != DistrictType::Residential { continue; @@ -1888,7 +2024,7 @@ mod tests { // the harmless pre-T-994 degenerate behaviour. let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -1896,7 +2032,7 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); - for block_tags in tags.values() { + for block_tags in assignment.block_tags.values() { for tag in block_tags { assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0)); } @@ -1918,7 +2054,7 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags_a = assign_all_block_tags( + let assignment_a = assign_all_block_tags( &sk, &ctx, "financial", @@ -1926,7 +2062,7 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); - let tags_b = assign_all_block_tags( + let assignment_b = assign_all_block_tags( &sk, &ctx, "financial", @@ -1934,11 +2070,14 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); - assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)"); + assert_eq!( + assignment_a, assignment_b, + "same seeds → same swerves (D-010)" + ); let mut total = 0usize; let mut swerved = 0usize; - for block_tags in tags_a.values() { + for block_tags in assignment_a.block_tags.values() { for tag in block_tags { total += 1; match &tag.flavor_ref { @@ -1971,7 +2110,7 @@ mod tests { ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; // rates stay (0, 0) from make_context let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -1979,7 +2118,7 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); - for block_tags in tags.values() { + for block_tags in assignment.block_tags.values() { for tag in block_tags { assert!(matches!( tag.flavor_ref, @@ -2038,7 +2177,7 @@ mod tests { }; let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -2048,7 +2187,7 @@ mod tests { ); let mut found_residential = false; - for (pos, block_tags) in &tags { + for (pos, block_tags) in &assignment.block_tags { let block = &sk.blocks[pos.0 as usize][pos.1 as usize]; if block.district_type != DistrictType::Residential { continue; @@ -2094,7 +2233,7 @@ mod tests { }; let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -2104,7 +2243,7 @@ mod tests { ); let mut any_swerved = false; - for block_tags in tags.values() { + for block_tags in assignment.block_tags.values() { for tag in block_tags { if matches!(&tag.flavor_ref, ArchitectureFlavorRef::Swerve(t) if t == "foreign_temple") { @@ -2127,7 +2266,7 @@ mod tests { // Generic/neutral, never panic. let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); - let tags = assign_all_block_tags( + let assignment = assign_all_block_tags( &sk, &ctx, "financial", @@ -2135,8 +2274,8 @@ mod tests { SeedChain::root(42), &ExteriorCatalog::default(), ); - assert!(!tags.is_empty()); - for block_tags in tags.values() { + assert!(!assignment.block_tags.is_empty()); + for block_tags in assignment.block_tags.values() { for tag in block_tags { assert_eq!(tag.exterior.wall_material, WallMaterial::Generic); assert_eq!(tag.exterior.roof_form, RoofForm::Generic); @@ -2365,9 +2504,9 @@ mod tests { SeedChain::root(3), ); let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX); - assert!(min_y(&inland) >= BLOCK_MARGIN); + assert!(min_y(&inland.footprints) >= BLOCK_MARGIN); assert!( - min_y(&quay) < min_y(&inland), + min_y(&quay.footprints) < min_y(&inland.footprints), "quay buildings should reach the water edge" ); } @@ -2390,7 +2529,7 @@ mod tests { SeedChain::root(11), ); let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX); - assert!(min_y(&quay) < min_y(&inland)); + assert!(min_y(&quay.footprints) < min_y(&inland.footprints)); } #[test] diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 89682a079..13a0b76d1 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -1032,6 +1032,80 @@ pub enum InterstitialCharacter { OperationsSurface, } +/// The D-235 ground-tile character resolved for an interstitial tile (T-1098) — +/// the space between building footprints, once classified as neither +/// footprint-covered nor street-margin (see `atlas::shell`'s three-way +/// partition doc). `OperationsSurface` short-circuits from `InterstitialCharacter` +/// (D-233, bulk-industry blocks); every other case reads `SetbackTier` (D-235 +/// step 3). +/// +/// **Five-value subset, not the closed vocabulary.** D-235 names seven +/// interstitial values in prose (`void`/`court`/`garden`/`plaza`/`dock_slip`/ +/// `market_pad`/`open_lawn`); this enum ships the five with an unambiguous +/// `SetbackTier` trigger. `DockSlip`/`MarketPad` are **not yet included** — +/// D-235 gives no concrete trigger condition for either (waterfront edge? +/// zone_type? both?) — see **T-1209**. Add them here (append-only, D-010) once +/// T-1209 lands a derivation rule; do not guess one in to close the enum early. +/// +/// Integer-discriminant, append-only (D-010) — same convention as +/// `ShellVoxel`/`SetbackTier`/the D-235 material axes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum InterstitialType { + /// Zero-lot setback reads as bare ground — no room for a yard. + Void = 0, + /// Tight setback — a narrow shared court between buildings. + Court = 1, + /// Standard setback — an informal garden/yard. + Garden = 2, + /// Generous setback — open enough to read as a small plaza. + Plaza = 3, + /// Campus setback — wide open lawn around the building. + OpenLawn = 4, + /// D-233 built economic infrastructure (bulk industry) — never open space. + OperationsSurface = 5, +} + +/// Minimal block-level context a `FillChunk` interstitial resolution needs +/// (T-1098) — `InterstitialCharacter` (D-233) + `SetbackTier` (D-235), the +/// only two scalars `resolve_interstitial_type` reads. Deliberately **not** +/// the whole `BlockSkeleton`: `hosted_sites`/`chunk_layout`/`landmark`/etc. +/// have no bearing on interstitial-tile character, and carrying them into +/// `FillChunk` would violate the same "only what's needed" discipline +/// `BuildingPropertyTag` already follows by freezing a projection rather than +/// holding a `CityGenerationContext` reference. +/// +/// Both fields are re-derived once at block level from data that already +/// determines them uniformly across the block (`BlockSkeleton.density_pct` / +/// `interstitial_character`) — not read off any one building's tag, so an +/// all-interstitial or reservation-adjacent block still resolves correctly +/// with no building to borrow a tag from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockFillContext { + pub interstitial_character: InterstitialCharacter, + pub setback_tier: SetbackTier, +} + +/// Resolve a classified-interstitial tile to its D-235/D-233 character +/// (T-1098). Pure total function — no RNG, no cache read (T-987 purity); every +/// interstitial tile in a block resolves identically, since both inputs are +/// frozen block-level facts, not per-tile ones. +pub fn resolve_interstitial_type(ctx: BlockFillContext) -> InterstitialType { + if ctx.interstitial_character == InterstitialCharacter::OperationsSurface { + return InterstitialType::OperationsSurface; // D-233 — never open space + } + // D-233 OpenSpace path only. Five of D-235's seven named values map + // unambiguously onto SetbackTier; `dock_slip`/`market_pad` are T-1209 — + // not resolved here (see `InterstitialType` doc). + match ctx.setback_tier { + SetbackTier::ZeroLot => InterstitialType::Void, + SetbackTier::Tight => InterstitialType::Court, + SetbackTier::Standard => InterstitialType::Garden, + SetbackTier::Generous => InterstitialType::Plaza, + SetbackTier::Campus => InterstitialType::OpenLawn, + } +} + /// A single building footprint tag — the frozen step-3 output placed on every /// building footprint at plan time (D-229). /// @@ -1292,6 +1366,13 @@ pub struct QuarterWorldState { /// Each entry is a Vec of one tag per building footprint placed in that block. /// `BTreeMap` for D-010 determinism (no HashMap non-determinism). pub block_tags: BTreeMap<(u8, u8), Vec>, + /// T-1098: interstitial ground-tile rects keyed by the same block grid + /// position — the BSP leaves `subdivide_block_footprints` produced that + /// lost their D-233 coverage roll (the gap between building footprints). + /// A block with no entry here is either reserved (no subdivision run at + /// all) or every leaf became a building (coverage rolled 100%) — both + /// legitimate, not an error. `BTreeMap` for D-010 determinism. + pub block_interstitial: BTreeMap<(u8, u8), Vec>, } /// Data contract between build-time (systems.db) and the runtime-background @@ -1775,4 +1856,65 @@ mod tests { assert_u8(z_levels); assert!(z_levels > 0, "z_levels is always at least 1"); } + + // ── T-1098: resolve_interstitial_type ─────────────────────────────── + + #[test] + fn operations_surface_short_circuits_regardless_of_setback() { + // D-233: bulk-industry blocks read as built infrastructure no matter + // how loose the setback tier — OperationsSurface must win every time. + for tier in [ + SetbackTier::ZeroLot, + SetbackTier::Tight, + SetbackTier::Standard, + SetbackTier::Generous, + SetbackTier::Campus, + ] { + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OperationsSurface, + setback_tier: tier, + }; + assert_eq!( + resolve_interstitial_type(ctx), + InterstitialType::OperationsSurface, + "OperationsSurface must short-circuit for setback tier {tier:?}" + ); + } + } + + #[test] + fn open_space_maps_setback_tier_to_interstitial_type() { + // D-235 step 3: the five unambiguous values, tightest to loosest. + let cases = [ + (SetbackTier::ZeroLot, InterstitialType::Void), + (SetbackTier::Tight, InterstitialType::Court), + (SetbackTier::Standard, InterstitialType::Garden), + (SetbackTier::Generous, InterstitialType::Plaza), + (SetbackTier::Campus, InterstitialType::OpenLawn), + ]; + for (tier, expected) in cases { + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: tier, + }; + assert_eq!( + resolve_interstitial_type(ctx), + expected, + "setback tier {tier:?} must resolve to {expected:?}" + ); + } + } + + #[test] + fn resolve_interstitial_type_is_pure_and_deterministic() { + let ctx = BlockFillContext { + interstitial_character: InterstitialCharacter::OpenSpace, + setback_tier: SetbackTier::Standard, + }; + assert_eq!( + resolve_interstitial_type(ctx), + resolve_interstitial_type(ctx), + "same input must yield the same output every call" + ); + } }