Merge remote-tracking branch 'origin/fillchunk-shell-derive'
This commit is contained in:
@@ -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 submit→drain→ChunkFilled 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;
|
||||
|
||||
@@ -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 submit→drain→ChunkFilled 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 submit→drain→ChunkFilled 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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user