Finding 2 became a real code fix: interstitial_fill_into now enforces the footprint-wins conflict rule (column_has_voxel range probe) — the FilledChunk absence contract was previously a documented promise the code didn't keep against conflicting inputs; pinned by a fully- overlapping-leaf test asserting per-tile resolution. The tautological overlap test replaced with a real rects_overlap() geometric helper (itself sanity-tested) applied pairwise. The degenerate-setback fix is now a standalone pure fn shrink_lot_or_interstitial with four boundary tests — honestly documented as unreachable from live traffic today (every min_lot exceeds every setback), a robustness guard for future recalibration. Both sub-chunk clip tests now reconstruct the full 32-tile union across the seam (disjoint + complete), including the pre-existing footprint clip test (leave-cleaner). Brief's ChunkLayout claim tightened to the verified no-production-consumer statement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 KiB
title, description, type, status, round, created
| title | description | type | status | round | created |
|---|---|---|---|---|---|
| Interstitial Fill — Geometry Model (T-1098) | Design brief pinning the geometry model for chunk-derive interstitial tile classification, before implementation | design | binding | T-1098 | 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:
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 continued 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:
- Footprint-covered — inside a
BuildingPropertyTag.footprintrect (already consumed byshell_derive_intoinshell.rs; unchanged by this ticket). - Interstitial — inside one of the new interstitial
TileRectleaves 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). - Street — the
BLOCK_MARGINperimeter 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 onBlockSkeletontoday; grep confirms it has no production consumer (its only reads are#[cfg(test)]assertions inskeleton_gen.rs—skeleton_has_streets_and_local_lattice— checking that the plan-time writer set it; the writer itself,generate_quarter_skeleton's layout-mode application, populates it but nothing downstream of plan time consumes it yet). It is reserved for that same future street-network step — not something this ticket needs to or should resolve.
Every block-local tile is covered by exactly one of these three at generation
time, because the BSP leaves partition the inner rect completely (§1's list
covers 100% of the inner rect's area, split only by the coverage roll) and the
inner rect plus the margin band partition the full 128×128 block. No tile is
implicit-Void — a chunk that reads all-interstitial (e.g. a reserved
park/plaza block, block.reservation.is_some()) resolves via §4's block-level
fallback, not by falling through unclassified.
One geometry subtlety: an interstitial leaf, unlike a footprint, is not
shrunk by setback — the setback shrink in the current code only applies to
leaves that pass the coverage roll (building lots pull back from the lot
line to leave frontage). An interstitial leaf is the whole BSP leaf rect,
because "the gap between buildings" includes exactly the frontage a
neighboring building's setback would have consumed — there's no second building
on the other side of a continued 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.
-
QuarterWorldStategainsblock_interstitial: BTreeMap<(u8, u8), Vec<TileRect>>(block-local, block-tile-space rects — the §1 output), populated byassign_all_block_tags(or a sibling function) alongsideblock_tags, at the sameGenerateSkeletonplan-time pass. Reserved blocks (block.reservation.is_some()) get an emptyVechere exactly as they get an emptyVecforblock_tagstoday (assign_block_tagsearly-returnsVec::new()for reservations, line 924-926) — §4 covers what a reserved block's chunk reads as instead. -
FillChunkgains two new fields, both pre-resolved at enqueue time (mirroringblock_tags's existing pre-resolution — the whole point of the D-230/T-987 purity contract is thatrun_work_itemnever reads a cache):interstitial_leaves: Vec<TileRect>— the covering block's interstitial rects, pulled fromblock_interstitialthe same wayblock_tagsis pulled fromblock_tagstoday.block_character: BlockFillContext— a small new struct carrying exactly the two scalars a per-tile resolution needs (§3):interstitial_character: InterstitialCharacterandsetback_tier: SetbackTier. Not the wholeBlockSkeleton—BlockSkeletoncarrieshosted_sites,chunk_layout,landmark, etc. thatFillChunkhas no business touching (D-230's derive phase reads only what shell/interstitial resolution needs, same disciplineBuildingPropertyTagalready applies by carrying a frozen projection rather than aCityGenerationContextreference).
Why
setback_tierat block level rather than reading it off aBuildingPropertyTag.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.exteriorfrom. ButSetbackTieris already deterministically a function ofblock.density_pctalone (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:
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 (seededsplitmix64, 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)andinterstitial_character_for(bulk)are already pure integer functions (existing code) — re-invoking them at block level (§2) forBlockFillContextproduces the identical value a building's.exterior.setback_tierwould show, by construction (same input, same function).- §3's
resolveis a total match over two enums — no RNG, no float, fully deterministic givenBlockFillContext. interstitial: BTreeMap<...>keeps iteration order deterministic (same discipline asvoxels/surface_material), satisfyingFilledChunk's existingPartialEq/hashable-content contract used by thefill_is_deterministic- style tests already inshell.rs.- End-to-end: same seed → same
QuarterSkeleton→ same BSP leaves/coverage rolls → same interstitial rect list → sameFillChunkwork item → sameFilledChunk.interstitialmap. A same-seed-twice test at each new layer (mirroringshell.rs's existingfill_is_deterministictest) 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_padcontext-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/
StreetSurfaceper-tile stamp is D-234's future street-network step, already flagged as out of scope by thechunk_layoutstub'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 |