feat(simulation): FillChunk shell derivation — Void/Wall/FloorSlab/Roof from cached tags (T-987)

Implement the D-230 on-demand derive phase (data-production half). New
atlas/shell.rs derives a sparse building shell for one 64 m chunk from the
plan-phase BuildingPropertyTags: rectangle-containment (footprint ∩ chunk) ×
z-range (per-floor voxel bands from FloorExtent), in a quarter-ground z frame
(D-110). Walls on the footprint perimeter, FloorSlab on interior floor bases,
Roof above the top floor; interior air is Void and never stored.

- gen_queue.rs: GenWorkItem::FillChunk carries pre-resolved block_tags +
  block_pos + sub_chunk (run_work_item stays cache-free, mirroring
  GenerateSkeleton); GenCompletion::ChunkFilled carries Box<FilledChunk>; the
  arm calls shell::fill_chunk; pure build_fill_chunk_item(&QuarterWorldState,..)
  added.
- plugin.rs: ChunkFilled handler accepts the shell (trace-only — the Phase-5
  rendering consumer and the on-demand streaming dispatch trigger are deferred;
  do not build on legacy chunk_streaming.rs before Phase 5, gated by T-962).

Tests: determinism, sparsity-vs-dense-volume, wall/floor/roof correctness,
basement z-origin, sub-chunk clipping, full submit→drain→ChunkFilled round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 23:34:46 +02:00
co-authored by Claude Opus 4.8
parent 85311f716c
commit 5f85dc55d5
6 changed files with 630 additions and 13 deletions
+158 -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,26 @@ 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).
block_tags: Vec<BuildingPropertyTag>,
},
}
@@ -152,8 +169,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 +371,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 +462,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 +675,8 @@ mod tests {
GenWorkItem::FillChunk {
quarter_id: 99,
block_pos: (0, 0),
sub_chunk: (0, 0),
block_tags: vec![],
},
GenPriority::High,
);
@@ -628,4 +684,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"
);
}
}