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:
2026-07-26 14:13:08 +02:00
co-authored by Claude Fable 5
parent 63ec09b089
commit 1d676a91f8
6 changed files with 1020 additions and 93 deletions
@@ -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` |
+67 -13
View File
@@ -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<crate::atlas::heightmap::BodyHeightmap>,
},
/// 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<BuildingPropertyTag>,
/// 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<TileRect>,
/// 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");
+5
View File
@@ -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(),
},
);
+283 -33
View File
@@ -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<ShellVoxelPos, SurfaceMaterial>,
/// 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<GroundTilePos, InterstitialType>,
}
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<BuildingPropertyTag>` from the cached `QuarterWorldState`
/// — pre-resolved by the caller because the work executor is cache-free
/// is the covering block's `Vec<BuildingPropertyTag>`, `interstitial_leaves` its
/// `Vec<TileRect>` 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<ShellVoxelPos, ShellVoxel> = BTreeMap::new();
let mut surface_material: BTreeMap<ShellVoxelPos, SurfaceMaterial> = BTreeMap::new();
let mut interstitial: BTreeMap<GroundTilePos, InterstitialType> = 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<GroundTilePos, InterstitialType>,
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());
}
}
+186 -47
View File
@@ -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<TileRect>,
/// Interstitial ground-tile rects — the *whole* BSP leaf, unshrunk (see
/// `subdivide_block_footprints`'s doc for why no setback shrink applies
/// here).
interstitial: Vec<TileRect>,
}
/// 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<Edge>,
seed: SeedChain,
) -> Vec<TileRect> {
) -> 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<Edge>,
block_chain: SeedChain,
exterior_catalog: &ExteriorCatalog,
) -> Vec<BuildingPropertyTag> {
) -> (Vec<BuildingPropertyTag>, Vec<TileRect>) {
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<BuildingPropertyTag>>,
pub block_interstitial: BTreeMap<(u8, u8), Vec<TileRect>>,
}
/// 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<BuildingPropertyTag>> {
) -> 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]
+142
View File
@@ -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<BuildingPropertyTag>>,
/// 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<TileRect>>,
}
/// 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"
);
}
}