Merge remote-tracking branch 'origin/fillchunk-shell-derive'

This commit is contained in:
2026-06-28 10:50:55 +02:00
7 changed files with 685 additions and 13 deletions
+16
View File
@@ -2669,3 +2669,19 @@ Test-protocol consequence (T-1083): the believability screen must measure NON-ST
---
DELIVERABLE / DEFINITION OF DONE (D-245 the holy grail): this epic is "done" when the cascade''s nature layers pass the believability litmus at RANDOMLY-SAMPLED locations across all habitable bodies and world seeds ("anywhere", not curated spots). Multi-scale + never-repeating: macro identity, meso non-stationarity (no two same-class km^2 alike), micro mosaic. Automated necessary-conditions screen coherence, non-stationarity, intra-class variety, relief/landmark, climate-appropriateness under a human narrate-the-natural-history sign-off (necessary != sufficient). Enforced by T-1083; strictness ramps budgeted+advisory -> strict as thresholds calibrate. D-245 (architecture) records this as the acceptance gate for the cascade''s nature half.', NULL, '2026-06-28 05:55:49', '2026-06-28 05:55:49.763', '2026-06-28 05:55:49.763', NULL, '067845dcc8df3a2a4f89ff00d3a40f51', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 19:17:27', '2026-06-27 19:17:27.762', '2026-06-27 19:17:27.762', NULL, 'e7130599a19541fca24b52697172b604', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'description', 'On-demand derive phase: read cached DistrictWorldState; per tag, shell_derive(seed, footprint, extent, z) -> Void|Wall|FloorSlab|Roof per voxel. Pre-condition: only after SkeletonGenerated; re-enqueue High if block_tags absent. Budget ~0.4-0.6ms/chunk.', 'On-demand derive phase: read cached DistrictWorldState; per tag, shell_derive(seed, footprint, extent, z) -> Void|Wall|FloorSlab|Roof per voxel. Pre-condition: only after SkeletonGenerated; re-enqueue High if block_tags absent. Budget ~0.4-0.6ms/chunk.
---
Implemented 2026-06-27 (branch fillchunk-shell-derive) as the DATA-PRODUCTION half of the D-230 on-demand derive phase:
- server/src/atlas/shell.rs (new): ShellVoxel{Void,Wall,FloorSlab,Roof} (repr(u8), append-only D-010) + FilledChunk (sparse BTreeMap, only non-Void stored) + pure fill_chunk(). Rectangle-containment (footprint chunk) × z-range (per-floor voxel bands from FloorExtent). Quarter-ground z origin (D-110: ground floor bottom = 0, basements negative). Walls on footprint perimeter, FloorSlab on interior floor bases, Roof one voxel above top floor.
- gen_queue.rs: GenWorkItem::FillChunk now carries pre-resolved block_tags + block_pos + sub_chunk (run_work_item is cache-free, mirrors GenerateSkeleton); GenCompletion::ChunkFilled carries Box<FilledChunk>; arm calls fill_chunk; added pure build_fill_chunk_item(&QuarterWorldState,..).
- plugin.rs: ChunkFilled handler accepts the shell (trace-only no Phase-5 consumer yet).
SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand streaming DISPATCH TRIGGER ("wire chunk_streaming.rs to enqueue FillChunk") is DEFERRED to Phase 5. chunk_streaming.rs is the legacy Phase-5 rendering path (32-tile WalkabilityMap chunks); CLAUDE.md forbids building on the in-world rendering code before Phase 5, and the trigger is gated by T-962. fill_chunk is re-derivable (D-227) so no storage/consumer is required now.
OUT OF SCOPE (other T-959 children): interstitial street/open-space fill + D-235 surface material vocabulary (WallMaterial/RoofForm/StreetSurface) = T-988; condition overlay = T-999.
Tests: determinism, sparsity-vs-dense-volume (budget proxy no flaky wall-clock asserts), wall/floor/roof correctness, basement z-origin, sub-chunk clipping, full submitdrainChunkFilled round-trip. cargo test (1571 lib) + clippy -D warnings + fmt all green.', NULL, '2026-06-27 21:34:18', '2026-06-27 21:34:18.369', '2026-06-27 21:34:18.369', NULL, '35133a94263116cb59fcaee570ea4630', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'status', 'in_progress', 'review', NULL, '2026-06-28 06:28:50', '2026-06-28 06:28:50.937', '2026-06-28 06:28:50.937', NULL, 'ee80d51608116d1aa23a8b641af13c22', 2) ON CONFLICT(hash) DO NOTHING;
+29
View File
@@ -3576,3 +3576,32 @@ Test-protocol consequence (T-1083): the believability screen must measure NON-ST
---
DELIVERABLE / DEFINITION OF DONE (D-245 the holy grail): this epic is "done" when the cascade''s nature layers pass the believability litmus at RANDOMLY-SAMPLED locations across all habitable bodies and world seeds ("anywhere", not curated spots). Multi-scale + never-repeating: macro identity, meso non-stationarity (no two same-class km^2 alike), micro mosaic. Automated necessary-conditions screen coherence, non-stationarity, intra-class variety, relief/landmark, climate-appropriateness under a human narrate-the-natural-history sign-off (necessary != sufficient). Enforced by T-1083; strictness ramps budgeted+advisory -> strict as thresholds calibrate. D-245 (architecture) records this as the acceptance gate for the cascade''s nature half.', 'backlog', 'high', NULL, 'server', 'D-239', '2026-06-28 05:53:59.367', '2026-06-28 05:55:49.763', NULL, 'f44dd0ce875c225d366064a218753165', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'task', '06FB0TNSRZKXHAYMT7Q6WW3XDC', 'FillChunk shell derivation <5ms (Void/Wall/FloorSlab/Roof from cached tags)', 'On-demand derive phase: read cached DistrictWorldState; per tag, shell_derive(seed, footprint, extent, z) -> Void|Wall|FloorSlab|Roof per voxel. Pre-condition: only after SkeletonGenerated; re-enqueue High if block_tags absent. Budget ~0.4-0.6ms/chunk.', 'in_progress', 'high', NULL, 'server', 'D-230', '2026-05-25 19:11:17', '2026-06-27 19:17:27.756', NULL, 'c3f6dbae1f1cc01e40bfb703411b170f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'task', '06FB0TNSRZKXHAYMT7Q6WW3XDC', 'FillChunk shell derivation <5ms (Void/Wall/FloorSlab/Roof from cached tags)', 'On-demand derive phase: read cached DistrictWorldState; per tag, shell_derive(seed, footprint, extent, z) -> Void|Wall|FloorSlab|Roof per voxel. Pre-condition: only after SkeletonGenerated; re-enqueue High if block_tags absent. Budget ~0.4-0.6ms/chunk.
---
Implemented 2026-06-27 (branch fillchunk-shell-derive) as the DATA-PRODUCTION half of the D-230 on-demand derive phase:
- server/src/atlas/shell.rs (new): ShellVoxel{Void,Wall,FloorSlab,Roof} (repr(u8), append-only D-010) + FilledChunk (sparse BTreeMap, only non-Void stored) + pure fill_chunk(). Rectangle-containment (footprint chunk) × z-range (per-floor voxel bands from FloorExtent). Quarter-ground z origin (D-110: ground floor bottom = 0, basements negative). Walls on footprint perimeter, FloorSlab on interior floor bases, Roof one voxel above top floor.
- gen_queue.rs: GenWorkItem::FillChunk now carries pre-resolved block_tags + block_pos + sub_chunk (run_work_item is cache-free, mirrors GenerateSkeleton); GenCompletion::ChunkFilled carries Box<FilledChunk>; arm calls fill_chunk; added pure build_fill_chunk_item(&QuarterWorldState,..).
- plugin.rs: ChunkFilled handler accepts the shell (trace-only no Phase-5 consumer yet).
SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand streaming DISPATCH TRIGGER ("wire chunk_streaming.rs to enqueue FillChunk") is DEFERRED to Phase 5. chunk_streaming.rs is the legacy Phase-5 rendering path (32-tile WalkabilityMap chunks); CLAUDE.md forbids building on the in-world rendering code before Phase 5, and the trigger is gated by T-962. fill_chunk is re-derivable (D-227) so no storage/consumer is required now.
OUT OF SCOPE (other T-959 children): interstitial street/open-space fill + D-235 surface material vocabulary (WallMaterial/RoofForm/StreetSurface) = T-988; condition overlay = T-999.
Tests: determinism, sparsity-vs-dense-volume (budget proxy no flaky wall-clock asserts), wall/floor/roof correctness, basement z-origin, sub-chunk clipping, full submitdrainChunkFilled round-trip. cargo test (1571 lib) + clippy -D warnings + fmt all green.', 'in_progress', 'high', NULL, 'server', 'D-230', '2026-05-25 19:11:17', '2026-06-27 21:34:18.368', NULL, '558c202a0f38cd43859bd807a5793265', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'task', '06FB0TNSRZKXHAYMT7Q6WW3XDC', 'FillChunk shell derivation <5ms (Void/Wall/FloorSlab/Roof from cached tags)', 'On-demand derive phase: read cached DistrictWorldState; per tag, shell_derive(seed, footprint, extent, z) -> Void|Wall|FloorSlab|Roof per voxel. Pre-condition: only after SkeletonGenerated; re-enqueue High if block_tags absent. Budget ~0.4-0.6ms/chunk.
---
Implemented 2026-06-27 (branch fillchunk-shell-derive) as the DATA-PRODUCTION half of the D-230 on-demand derive phase:
- server/src/atlas/shell.rs (new): ShellVoxel{Void,Wall,FloorSlab,Roof} (repr(u8), append-only D-010) + FilledChunk (sparse BTreeMap, only non-Void stored) + pure fill_chunk(). Rectangle-containment (footprint chunk) × z-range (per-floor voxel bands from FloorExtent). Quarter-ground z origin (D-110: ground floor bottom = 0, basements negative). Walls on footprint perimeter, FloorSlab on interior floor bases, Roof one voxel above top floor.
- gen_queue.rs: GenWorkItem::FillChunk now carries pre-resolved block_tags + block_pos + sub_chunk (run_work_item is cache-free, mirrors GenerateSkeleton); GenCompletion::ChunkFilled carries Box<FilledChunk>; arm calls fill_chunk; added pure build_fill_chunk_item(&QuarterWorldState,..).
- plugin.rs: ChunkFilled handler accepts the shell (trace-only no Phase-5 consumer yet).
SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand streaming DISPATCH TRIGGER ("wire chunk_streaming.rs to enqueue FillChunk") is DEFERRED to Phase 5. chunk_streaming.rs is the legacy Phase-5 rendering path (32-tile WalkabilityMap chunks); CLAUDE.md forbids building on the in-world rendering code before Phase 5, and the trigger is gated by T-962. fill_chunk is re-derivable (D-227) so no storage/consumer is required now.
OUT OF SCOPE (other T-959 children): interstitial street/open-space fill + D-235 surface material vocabulary (WallMaterial/RoofForm/StreetSurface) = T-988; condition overlay = T-999.
Tests: determinism, sparsity-vs-dense-volume (budget proxy no flaky wall-clock asserts), wall/floor/roof correctness, basement z-origin, sub-chunk clipping, full submitdrainChunkFilled round-trip. cargo test (1571 lib) + clippy -D warnings + fmt all green.', 'review', 'high', NULL, 'server', 'D-230', '2026-05-25 19:11:17', '2026-06-28 06:28:50.937', NULL, 'e49a948a5bfda49269a7cc64979d13bb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+161 -12
View File
@@ -32,9 +32,10 @@ use crate::atlas::body_world_state::BodyWorldState;
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::district_profile::BodyParams;
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::shell::{fill_chunk, FilledChunk};
use crate::atlas::skeleton_gen::{assign_all_block_tags, generate_quarter_skeleton};
use crate::seed::SeedChain;
use crate::simulation::generator::{CityGenerationContext, QuarterWorldState};
use crate::simulation::generator::{BuildingPropertyTag, CityGenerationContext, QuarterWorldState};
// ---------------------------------------------------------------------------
// Priority
@@ -113,10 +114,29 @@ pub enum GenWorkItem {
population: i64,
founding_age_years: u32,
},
/// Pre-fill a chunk in an existing quarter.
/// Derive the building shell for one 64 m chunk of an existing quarter
/// (D-230 derive phase, T-987).
///
/// 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
/// [`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.
FillChunk {
/// Stable id of the quarter being filled (D-194/D-230).
quarter_id: u64,
block_pos: (u32, u32),
/// Block grid position within the quarter (0..4, 0..4).
block_pos: (u8, u8),
/// Sub-chunk quadrant within the block (0..2, 0..2) — a block is 2×2 chunks.
sub_chunk: (u8, u8),
/// Covering block's building tags, pre-resolved from the cached
/// `QuarterWorldState`. Empty for an open/un-built block (→ empty shell).
/// A `Vec` is three words on the stack regardless of element size (the tags
/// 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>,
},
}
@@ -152,8 +172,10 @@ pub enum GenCompletion {
state: Box<QuarterWorldState>,
},
ChunkFilled {
quarter_id: u64,
block_pos: (u32, u32),
/// The derived shell for this chunk (D-230, T-987). Sparse — only the
/// non-`Void` shell voxels. Carries its own quarter/block/sub-chunk address.
/// Boxed to keep `GenCompletion` variant sizes balanced.
filled: Box<FilledChunk>,
},
/// Work item failed — body_id or city_id for logging.
Failed { item: GenWorkItem, reason: String },
@@ -352,9 +374,10 @@ impl Default for GenerationQueue {
/// Execute one work item. This is the Rayon task body (off the tick thread).
///
/// `AnalyzeBody` runs the real Layer-1 cascade (#968, D-225). `GenerateSkeleton`
/// and `FillChunk` remain stubs — their layers (#957 / #959) are not built yet —
/// returning immediate success so the queue infrastructure stays testable.
/// `AnalyzeBody` runs the real Layer-1 cascade (#968, D-225); `GenerateSkeleton`
/// runs the real plan phase (#957, D-229) producing the skeleton + block tags;
/// `FillChunk` runs the real derive phase (T-987, D-230) producing the building
/// shell from the pre-resolved tags.
fn run_work_item(item: &GenWorkItem) -> GenCompletion {
match item {
GenWorkItem::AnalyzeBody {
@@ -442,10 +465,44 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
GenWorkItem::FillChunk {
quarter_id,
block_pos,
} => GenCompletion::ChunkFilled {
quarter_id: *quarter_id,
block_pos: *block_pos,
},
sub_chunk,
block_tags,
} => {
// 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);
GenCompletion::ChunkFilled {
filled: Box::new(filled),
}
}
}
}
/// 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).
///
/// 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.
pub fn build_fill_chunk_item(
quarter: &QuarterWorldState,
block_pos: (u8, u8),
sub_chunk: (u8, u8),
) -> GenWorkItem {
let block_tags = quarter
.block_tags
.get(&block_pos)
.cloned()
.unwrap_or_default();
GenWorkItem::FillChunk {
quarter_id: quarter.skeleton.quarter_id,
block_pos,
sub_chunk,
block_tags,
}
}
@@ -621,6 +678,8 @@ mod tests {
GenWorkItem::FillChunk {
quarter_id: 99,
block_pos: (0, 0),
sub_chunk: (0, 0),
block_tags: vec![],
},
GenPriority::High,
);
@@ -628,4 +687,94 @@ mod tests {
let completions = q.drain_completions();
assert!(!completions.is_empty() || q.pending_count() == 0);
}
/// A `QuarterWorldState` with one building in block (0,0), used to exercise the
/// full FillChunk path (build item from cached state → Rayon → completion).
fn quarter_with_one_building() -> QuarterWorldState {
use crate::atlas::tile_condition::TileCondition;
use crate::simulation::generator::{
ArchitectureFlavorRef, BuildingEntryClass, BuildingPropertyTag, ConstructionEra,
EraCause, FloorExtent, FloorHeightProfile, QuarterSkeleton, TileRect, ZoneTypeId,
};
use std::collections::BTreeMap;
let mut block_tags: BTreeMap<(u8, u8), Vec<BuildingPropertyTag>> = BTreeMap::new();
block_tags.insert(
(0, 0),
vec![BuildingPropertyTag {
zone_type_id: ZoneTypeId::new("residential_low"),
footprint: TileRect::new(4, 4, 6, 6),
extent: FloorExtent {
base_floor: 0,
floor_count: 2,
heights: FloorHeightProfile::Uniform(3),
},
entry_class: BuildingEntryClass::Public,
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
era: ConstructionEra::Founding,
era_cause: EraCause::Original,
initial_condition: TileCondition::Intact,
doors: Vec::new(),
}],
);
QuarterWorldState {
skeleton: QuarterSkeleton {
quarter_id: 4242,
..Default::default()
},
block_tags,
}
}
#[test]
fn build_fill_chunk_item_pulls_block_tags() {
let quarter = quarter_with_one_building();
let item = build_fill_chunk_item(&quarter, (0, 0), (0, 0));
let GenWorkItem::FillChunk {
quarter_id,
block_pos,
sub_chunk,
block_tags,
} = item
else {
panic!("expected FillChunk");
};
assert_eq!(quarter_id, 4242);
assert_eq!(block_pos, (0, 0));
assert_eq!(sub_chunk, (0, 0));
assert_eq!(
block_tags.len(),
1,
"covering block's tags must be resolved"
);
// A block with no buildings is a valid empty fill, not a missing-skeleton error.
let empty = build_fill_chunk_item(&quarter, (3, 3), (0, 0));
let GenWorkItem::FillChunk { block_tags, .. } = empty else {
panic!("expected FillChunk");
};
assert!(block_tags.is_empty(), "empty block → empty tags");
}
#[test]
fn fill_chunk_round_trip_produces_populated_shell() {
let q = make_queue();
let quarter = quarter_with_one_building();
q.submit(
build_fill_chunk_item(&quarter, (0, 0), (0, 0)),
GenPriority::High,
);
std::thread::sleep(Duration::from_millis(50));
let completions = q.drain_completions();
assert_eq!(completions.len(), 1);
let GenCompletion::ChunkFilled { filled } = &completions[0] else {
panic!("expected ChunkFilled, got {:?}", completions[0]);
};
assert_eq!(filled.quarter_id, 4242);
assert_eq!(filled.chunk_in_quarter(), (0, 0));
assert!(
filled.voxel_count() > 0,
"a chunk containing a building must derive shell voxels"
);
}
}
+1
View File
@@ -24,6 +24,7 @@ pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod scale;
pub mod shell;
pub mod skeleton_gen;
pub mod source_resolver;
pub mod subbiome;
+16 -1
View File
@@ -175,7 +175,22 @@ fn drain_generation_completions(
}
// body_id empty = stub result from GenerateSkeleton stub; silently ignore.
}
GenCompletion::ChunkFilled { .. } => {}
GenCompletion::ChunkFilled { filled } => {
// The shell is derived (D-230, T-987). There is no consumer on the
// main thread yet: in-world rendering of generated tiles is Phase 5
// (gated by T-962), and the on-demand *dispatch* trigger — enqueueing
// FillChunk as the player's load radius enters a chunk — lives in the
// Phase-5 streaming path, which must not be built on the legacy
// `chunk_streaming.rs` rendering code before then (CLAUDE.md cascade
// rule). FillChunk is re-derivable on demand (D-227), so dropping the
// result here costs nothing structural; we only trace it for now.
tracing::trace!(
quarter_id = filled.quarter_id,
chunk = ?filled.chunk_in_quarter(),
voxels = filled.voxel_count(),
"FillChunk derived (no Phase-5 consumer yet)"
);
}
}
}
}
+5
View File
@@ -45,6 +45,9 @@ pub const REGION_M: i32 = 204_800;
/// Voxels per chunk edge (64 m / 1 m). A chunk is 64×64 voxels.
pub const VOXELS_PER_CHUNK: i32 = CHUNK_M / VOXEL_M;
/// Chunks per block edge (128 m / 64 m = 2). A 128 m block is 2×2 chunks — the
/// sub-chunk grid the on-demand fill (D-230) addresses within a block.
pub const CHUNKS_PER_BLOCK: i32 = BLOCK_M / CHUNK_M;
/// Chunks per district edge (2 048 m / 64 m = 32 = `1 << 5`).
pub const CHUNKS_PER_DISTRICT: i32 = DISTRICT_M / CHUNK_M;
/// `log2(CHUNKS_PER_DISTRICT)` — chunk→district is an arithmetic shift (floors
@@ -55,6 +58,8 @@ pub const CHUNK_DISTRICT_SHIFT: u32 = 5;
pub const DISTRICTS_PER_REGION: i32 = REGION_M / DISTRICT_M;
// Compile-time ladder invariants — the build fails if a rung stops nesting.
const _: () = assert!(CHUNKS_PER_BLOCK == 2);
const _: () = assert!(CHUNKS_PER_BLOCK * CHUNK_M == BLOCK_M);
const _: () = assert!(CHUNKS_PER_DISTRICT == 1 << CHUNK_DISTRICT_SHIFT);
const _: () = assert!(CHUNKS_PER_DISTRICT * CHUNK_M == DISTRICT_M);
const _: () = assert!(DISTRICTS_PER_REGION * DISTRICT_M == REGION_M);
+457
View File
@@ -0,0 +1,457 @@
//! Building-shell derivation — the D-230 on-demand `FillChunk` derive phase (T-987).
//!
//! This is the second half of the two-phase fill model (D-230). The **plan phase**
//! (`GenerateSkeleton`, [`crate::atlas::skeleton_gen`]) produces the per-footprint
//! [`BuildingPropertyTag`]s and caches them on the body's `QuarterWorldState`. This
//! module is the **derive phase**: given those frozen tags, it derives the actual
//! `{Void | Wall | FloorSlab | Roof}` shell voxels for a single 64 m chunk, purely
//! and on demand (no cache read, no side effects — the caller pre-resolves the tags
//! into the work item, mirroring the rest of the generation queue, [`crate::atlas::gen_queue`]).
//!
//! ## What this layer is (and is not)
//!
//! - **Is:** the structural *shell* — the four materials named in D-230. The geometry
//! is pure rectangle-containment (is the tile inside a footprint?) + z-range lookup
//! (which floor / roof does this voxel-z belong to?) over the cached tags. No RNG;
//! the shell is fully determined by `(footprint, extent)`, which are themselves a
//! 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.
//!
//! ## Scale + coordinates (D-243)
//!
//! A quarter is 512 m = 4×4 **blocks** (128 m) = 8×8 **chunks** (64 m). A 64 m chunk is
//! exactly one quadrant of a 128 m block, so every chunk lies wholly inside a single
//! block — and a [`BuildingPropertyTag`]'s footprint is block-confined (`TileRect` is
//! block-local, 0..128). Therefore the *only* tags that can touch a chunk are the
//! covering block's tags. The fill works in block-local tile space and subtracts the
//! sub-chunk origin to land in chunk-local space (0..64).
//!
//! ## Vertical origin (D-110)
//!
//! [`FloorExtent`] addresses floors building-relative (base floor bottom = 0), but a
//! chunk mixes buildings with different basement depths, so the shell is emitted in a
//! single **quarter-ground** frame: the ground floor (index 0) bottom sits at `z = 0`,
//! basements are negative, upper floors positive. This is the D-110 convention
//! ("the quarter's ground level is always 0").
//!
//! ## D-010 compliance
//!
//! All arithmetic is integer. `FilledChunk` stores voxels in a `BTreeMap` so iteration
//! order is deterministic; only non-`Void` voxels are stored — the shell is **sparse**,
//! holding wall / per-floor-slab / roof *surfaces* but never the interior air between
//! floors. The derive is `O(built surface)` integer work (rectangle-containment +
//! z-range), which is what keeps it within the D-230 `<5 ms`/chunk budget. (If a future
//! profile shows the `BTreeMap` inserts hot for pathologically dense towers, a
//! pre-sized dense column buffer is the drop-in optimisation — matching D-230's
//! flat-array time estimate — without changing this layer's contract.)
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, TileRect};
/// One structural shell voxel material (D-230).
///
/// `Void` (interior air / open space) is the implicit default and is **never stored**
/// in [`FilledChunk`]; it exists in the vocabulary so the type system can name the
/// full four-way classification and so callers can match exhaustively.
///
/// Integer-discriminant, append-only (D-010). Surface materials (D-235 `WallMaterial`
/// etc.) are a separate axis layered on top by T-988 — do not fold them in here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum ShellVoxel {
/// Interior air / open space — the implicit default, never stored.
#[default]
Void = 0,
/// Vertical structural wall — a footprint-perimeter column voxel.
Wall = 1,
/// Horizontal floor slab — the base voxel of a floor's interior.
FloorSlab = 2,
/// Roof cap — the voxel layer immediately above the topmost floor.
Roof = 3,
}
/// Chunk-local voxel coordinate: `(x, y)` in `0..64`, `z` quarter-ground-relative.
///
/// `x`/`y` are chunk-local tile indices (D-243: a chunk is 64×64 voxels). `z` is the
/// D-110 quarter-ground frame (ground floor bottom = 0, basements negative), so it is
/// signed.
pub type ShellVoxelPos = (u8, u8, i32);
/// The derived shell of a single 64 m chunk — the D-230 `FillChunk` output (T-987).
///
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FilledChunk {
/// Stable id of the quarter this chunk belongs to (D-194/D-230).
pub quarter_id: u64,
/// Block grid position within the quarter's 4×4 block grid (0..4, 0..4).
pub block_pos: (u8, u8),
/// Sub-chunk quadrant within the block (0..2, 0..2) — a 128 m block is 2×2 chunks.
pub sub_chunk: (u8, u8),
/// Non-`Void` shell voxels, keyed by chunk-local position (D-010 ordered).
pub voxels: BTreeMap<ShellVoxelPos, ShellVoxel>,
}
impl FilledChunk {
/// Quarter-local chunk index `(0..8, 0..8)`: `block * 2 + sub_chunk`.
pub fn chunk_in_quarter(&self) -> (u8, u8) {
(
self.block_pos.0 * 2 + self.sub_chunk.0,
self.block_pos.1 * 2 + self.sub_chunk.1,
)
}
/// Number of non-`Void` voxels in this chunk.
pub fn voxel_count(&self) -> usize {
self.voxels.len()
}
/// Material at a chunk-local position; `Void` if nothing was emitted there.
pub fn get(&self, x: u8, y: u8, z: i32) -> ShellVoxel {
self.voxels.get(&(x, y, z)).copied().unwrap_or_default()
}
}
/// 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).
///
/// 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
/// ([`crate::atlas::gen_queue`]).
///
/// `quarter_id` is threaded through for addressing only.
pub fn fill_chunk(
quarter_id: u64,
block_pos: (u8, u8),
sub_chunk: (u8, u8),
block_tags: &[BuildingPropertyTag],
) -> FilledChunk {
debug_assert!(
(sub_chunk.0 as i32) < CHUNKS_PER_BLOCK && (sub_chunk.1 as i32) < CHUNKS_PER_BLOCK,
"sub_chunk {sub_chunk:?} outside the block's {CHUNKS_PER_BLOCK}×{CHUNKS_PER_BLOCK} chunk grid"
);
let mut voxels: BTreeMap<ShellVoxelPos, ShellVoxel> = 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
for tag in block_tags {
shell_derive_into(
&mut voxels,
tag,
(chunk_lo_x, chunk_lo_y, chunk_hi_x, chunk_hi_y),
);
}
FilledChunk {
quarter_id,
block_pos,
sub_chunk,
voxels,
}
}
/// Emit one building's shell voxels into `voxels`, clipped to the chunk's block-local
/// tile window `(lo_x, lo_y, hi_x, hi_y)` (hi exclusive).
///
/// Rectangle-containment (footprint ∩ chunk) × z-range (per-floor voxel bands from the
/// [`FloorExtent`]), per D-230. Walls on the footprint perimeter for the full height,
/// floor slabs on interior tiles at each floor base, a roof cap above the top floor.
fn shell_derive_into(
voxels: &mut BTreeMap<ShellVoxelPos, ShellVoxel>,
tag: &BuildingPropertyTag,
window: (i32, i32, i32, i32),
) {
let (win_lo_x, win_lo_y, win_hi_x, win_hi_y) = window;
let footprint = &tag.footprint;
let extent = &tag.extent;
// D-110 floor indices must fit i8 so the top-floor comparison and the roof
// derivation below cannot wrap. The generator caps floor counts well under this;
// the assert pins the invariant so a future change can't silently drop the roof.
debug_assert!(
extent.base_floor as i16 + extent.floor_count as i16 - 1 <= i8::MAX as i16,
"building floor range exceeds i8 — roof derivation would wrap"
);
// Footprint block-local tile span (inclusive lo, exclusive hi).
let fp_lo_x = footprint.origin.0 as i32;
let fp_lo_y = footprint.origin.1 as i32;
let fp_hi_x = fp_lo_x + footprint.size.0.max(1) as i32;
let fp_hi_y = fp_lo_y + footprint.size.1.max(1) as i32;
// Intersect footprint with the chunk window — nothing to do if disjoint.
let lo_x = fp_lo_x.max(win_lo_x);
let lo_y = fp_lo_y.max(win_lo_y);
let hi_x = fp_hi_x.min(win_hi_x);
let hi_y = fp_hi_y.min(win_hi_y);
if lo_x >= hi_x || lo_y >= hi_y {
return;
}
// Quarter-ground z origin (D-110): subtract the ground floor's building-relative
// base so floor 0 bottom lands at z = 0. `FloorExtent` addresses floors relative to
// `base_floor` (whose bottom is always its own 0), so when a building has no floor 0
// (all-basement / all-elevated — unreachable from the generator today) the fallback
// of 0 applies no shift: the building-relative z passes through unchanged.
let ground_offset = extent
.voxel_range_for_floor(0)
.map(|(lo, _)| lo)
.unwrap_or(0);
let top_floor = (extent.base_floor as i16 + extent.floor_count as i16 - 1) as i8;
let mut roof_z: Option<i32> = None;
for f_offset in 0..extent.floor_count {
let floor_index = (extent.base_floor as i16 + f_offset as i16) as i8;
let Some((rel_lo, rel_hi)) = extent.voxel_range_for_floor(floor_index) else {
continue;
};
let floor_base_z = rel_lo - ground_offset;
let floor_top_z = rel_hi - ground_offset;
for tx in lo_x..hi_x {
for ty in lo_y..hi_y {
let perimeter = is_perimeter(footprint, tx, ty);
// Chunk-local coordinate (0..64).
let cx = (tx - win_lo_x) as u8;
let cy = (ty - win_lo_y) as u8;
for z in floor_base_z..=floor_top_z {
let material = if perimeter {
ShellVoxel::Wall
} else if z == floor_base_z {
ShellVoxel::FloorSlab
} else {
continue; // interior air → Void, not stored
};
voxels.insert((cx, cy, z), material);
}
}
}
if floor_index == top_floor {
roof_z = Some(floor_top_z + 1);
}
}
// Roof cap: one voxel layer above the topmost floor, over the full footprint.
// `roof_z` is `None` only if the top floor's `voxel_range_for_floor` returned `None`
// (impossible for a well-formed `FloorExtent`) — in that case no roof is emitted.
if let Some(rz) = roof_z {
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;
voxels.insert((cx, cy, rz), ShellVoxel::Roof);
}
}
}
}
/// Whether block-local tile `(tx, ty)` is on the outer ring of `footprint`.
///
/// A 1-wide footprint is all perimeter (no interior); callers rely on that so such
/// buildings become solid wall columns rather than empty shells.
fn is_perimeter(footprint: &TileRect, tx: i32, ty: i32) -> bool {
let lo_x = footprint.origin.0 as i32;
let lo_y = footprint.origin.1 as i32;
let hi_x = lo_x + footprint.size.0.max(1) as i32 - 1;
let hi_y = lo_y + footprint.size.1.max(1) as i32 - 1;
tx == lo_x || tx == hi_x || ty == lo_y || ty == hi_y
}
/// 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);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::tile_condition::TileCondition;
use crate::simulation::generator::{
ArchitectureFlavorRef, BuildingEntryClass, ConstructionEra, EraCause, FloorExtent,
FloorHeightProfile, ZoneTypeId,
};
/// Build a `BuildingPropertyTag` with the given block-local footprint and a
/// uniform 3-voxel-per-floor extent (the D-229 default).
fn tag(
origin: (u8, u8),
size: (u8, u8),
base_floor: i8,
floor_count: u8,
) -> BuildingPropertyTag {
BuildingPropertyTag {
zone_type_id: ZoneTypeId::new("residential_low"),
footprint: TileRect::new(origin.0, origin.1, size.0, size.1),
extent: FloorExtent {
base_floor,
floor_count,
heights: FloorHeightProfile::Uniform(3),
},
entry_class: BuildingEntryClass::Public,
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
era: ConstructionEra::Founding,
era_cause: EraCause::Original,
initial_condition: TileCondition::Intact,
doors: Vec::new(),
}
}
#[test]
fn empty_block_yields_empty_chunk() {
let fc = fill_chunk(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));
}
#[test]
fn chunk_in_quarter_maps_block_and_sub_chunk() {
let fc = fill_chunk(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));
}
#[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)]);
// Ground floor (3 voxels: z 0,1,2). Roof at z = 3.
// Corner (2,2) is perimeter → Wall through z 0..=2.
assert_eq!(fc.get(2, 2, 0), ShellVoxel::Wall);
assert_eq!(fc.get(2, 2, 2), ShellVoxel::Wall);
// Interior tile (3,3) → FloorSlab at the floor base (z 0), Void above.
assert_eq!(fc.get(3, 3, 0), ShellVoxel::FloorSlab);
assert_eq!(fc.get(3, 3, 1), ShellVoxel::Void);
// Roof caps the whole footprint at z = 3 (perimeter and interior alike).
assert_eq!(fc.get(2, 2, 3), ShellVoxel::Roof);
assert_eq!(fc.get(3, 3, 3), ShellVoxel::Roof);
// Outside the footprint → Void.
assert_eq!(fc.get(0, 0, 0), ShellVoxel::Void);
}
#[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)]);
// 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);
assert_eq!(fc.get(2, 2, 6), ShellVoxel::FloorSlab);
// Between slabs is interior air.
assert_eq!(fc.get(2, 2, 1), ShellVoxel::Void);
// Perimeter wall runs the full height to the top floor's top voxel (z 8).
assert_eq!(fc.get(0, 0, 8), ShellVoxel::Wall);
// Roof one voxel above the top floor.
assert_eq!(fc.get(2, 2, 9), ShellVoxel::Roof);
}
#[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)]);
// 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.
assert_eq!(fc.get(1, 1, -3), ShellVoxel::FloorSlab);
// Basement perimeter is wall.
assert_eq!(fc.get(0, 0, -1), ShellVoxel::Wall);
}
#[test]
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)]);
// 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).
assert_eq!(fc.get(1, 1, 3), ShellVoxel::FloorSlab);
// Perimeter wall from the bottom.
assert_eq!(fc.get(0, 0, 0), ShellVoxel::Wall);
// Roof one voxel above the two storeys (z 6).
assert_eq!(fc.get(1, 1, 6), ShellVoxel::Roof);
}
#[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)]);
for ty in 0..4u8 {
assert_eq!(fc.get(0, ty, 0), ShellVoxel::Wall);
}
// No FloorSlab anywhere (no interior tiles).
assert!(!fc.voxels.values().any(|v| *v == ShellVoxel::FloorSlab));
}
#[test]
fn footprint_clipped_to_sub_chunk() {
// 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));
// 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.
assert_ne!(left.voxel_count(), 0);
assert!(left.voxels.keys().all(|(x, _, _)| (60..64).contains(x)));
// Right sub-chunk holds block-local x 64..68 → chunk-local x 0..4.
assert_ne!(right.voxel_count(), 0);
assert!(right.voxels.keys().all(|(x, _, _)| (0..4).contains(x)));
}
#[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);
assert_eq!(a, b);
}
#[test]
fn shell_is_sparse_versus_dense_volume() {
// A realistically dense chunk: a 4×4 grid of 14×14 buildings (2-tile gaps),
// each 5 storeys (15 voxels) + roof. The shell stores only surfaces — walls,
// per-floor slabs, roof — so it must hold strictly fewer voxels than the dense
// building volume (which would also store the interior air between floors).
// This sparsity is what keeps the derive within the D-230 <5 ms budget.
let mut tags = Vec::new();
for gx in 0..4u8 {
for gy in 0..4u8 {
tags.push(tag((gx * 16, gy * 16), (14, 14), 0, 5));
}
}
let fc = fill_chunk(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);
assert!(fc.voxel_count() > 0, "a built chunk must derive voxels");
assert!(
fc.voxel_count() < dense_volume,
"shell ({}) must be sparser than the dense volume ({dense_volume}) — \
interior air must not be stored",
fc.voxel_count()
);
}
}