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