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:
+158
-12
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user