From 5f85dc55d58d0754fd4dc8d2c7e3c29364cdcf02 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 23:34:46 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(simulation):=20FillChunk=20shell=20der?= =?UTF-8?q?ivation=20=E2=80=94=20Void/Wall/FloorSlab/Roof=20from=20cached?= =?UTF-8?q?=20tags=20(T-987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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) --- .pql/changelog/ticket_history/2026-06.sql | 15 + .pql/changelog/tickets/2026-06.sql | 15 + server/src/atlas/gen_queue.rs | 170 ++++++++- server/src/atlas/mod.rs | 1 + server/src/atlas/plugin.rs | 17 +- server/src/atlas/shell.rs | 425 ++++++++++++++++++++++ 6 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 server/src/atlas/shell.rs diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index b3dc6409b..ad9a1310a 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2616,3 +2616,18 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang 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 ('06FBPTXP5N42SSGW300FEDTS3M', 'status', 'in_progress', 'review', NULL, '2026-06-17 07:48:14', '2026-06-17 07:48:14.920', '2026-06-17 07:48:14.920', NULL, 'e7115e66b6bd755538db65e42fbc162d', 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 ('06FBPQ0381QWDAQABZAY7MRD8M', 'status', 'review', 'done', NULL, '2026-06-17 08:14:43', '2026-06-17 08:14:43.302', '2026-06-17 08:14:43.302', NULL, 'f90f10ea8bfed4aceb71c55b391bc153', 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 ('06FBPTXP5N42SSGW300FEDTS3M', 'status', 'review', 'done', NULL, '2026-06-17 08:14:43', '2026-06-17 08:14:43.306', '2026-06-17 08:14:43.306', NULL, '1f6b136fdd32b361532caee73a6df1c7', 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; 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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 3f92e236c..b45477a8e 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3533,3 +3533,18 @@ CURRENT LOCATIONS: derive_basin_direction = chunk_context.rs:453-483 (false comm RESOLUTION (D-239 §8 BINDS the D8 thalweg → flow_accumulation alone is insufficient; use the true fdir): in run_layer1 (where fdir is still alive, before it''s discarded), aggregate the dominant D8 direction PER DISTRICT and carry that small per-district direction map on Layer1Output — which composes directly with T-1044''s Layer1Output caching. Thread it through derive_all_districts → derive_district_profile → DistrictProfile.basin_direction; derive_chunk_context reads district.basin_direction. Fix the false doc comment + module contract either way. Do NOT expose the full 131KB fdir grid on DrainageResult — aggregate to per-district direction in the pass. COMPOSE WITH T-1044 (same batch, same layer1.rs/cascade.rs flow). Acceptance: chunk-scale channels respect the covering district''s D8 thalweg (flow downhill, connect across the chunk/region-seed boundary); false comment corrected; golden-seed determinism preserved.', 'done', 'medium', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-17 08:14:43.306', NULL, 'e221e440c3be0db6d0de73a2544dfb9d', 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; 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); diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 0af3c458d..67dc1a6ed 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -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, }, } @@ -152,8 +169,10 @@ pub enum GenCompletion { state: Box, }, 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, }, /// 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> = 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" + ); + } } diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 59b462328..bd9569dd0 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -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; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 6034b317b..bc7a39c4f 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -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)" + ); + } } } } diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs new file mode 100644 index 000000000..910100e16 --- /dev/null +++ b/server/src/atlas/shell.rs @@ -0,0 +1,425 @@ +//! 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::{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, +} + +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` 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 { + let mut voxels: BTreeMap = 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, + 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; + + // 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. If the building has no floor 0 (all + // basement / all elevated — unusual), fall back to the building bottom (= 0). + 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 = 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. + 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 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 holds block-local x 60..64 → chunk-local x 60..64. + 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() + ); + } +} From e3dcf570541131b4466e0241486a5d47194bc648 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 08:29:10 +0200 Subject: [PATCH 2/3] chore(meta): T-987 -> review (PR #171) Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ad9a1310a..6c5ff49f9 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2631,3 +2631,4 @@ SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand st 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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index b45477a8e..709159787 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3548,3 +3548,17 @@ SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand st 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; 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); From 7597e3165edb18ab54d33a73cc0421c6ac0f72d4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 10:24:30 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(simulation):=20address=20PR=20#171=20re?= =?UTF-8?q?view=20=E2=80=94=20all=20findings=20processed=20(T-987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every review finding fixed (no non-blocking parking lot): - shell.rs: debug_assert the D-110 floor-range i8 invariant (H1); clarify the ground_offset fallback comment (T1) and the roof_z None-case (T3); clarify the sub-chunk clip test comment (T4); add a test for the elevated/no-ground-floor fallback (H2). - scale.rs: add CHUNKS_PER_BLOCK (= BLOCK_M/CHUNK_M) + compile-time asserts; used by shell.rs fill_chunk's sub_chunk bounds assert (T5). - gen_queue.rs: document why FillChunk's Vec needs no Box (large_enum_variant non-issue) (T2). clippy --all-targets -D warnings clean; 1572 lib tests pass (+1). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/atlas/gen_queue.rs | 3 +++ server/src/atlas/scale.rs | 5 +++++ server/src/atlas/shell.rs | 40 +++++++++++++++++++++++++++++++---- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 67dc1a6ed..ef58c3382 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -133,6 +133,9 @@ pub enum GenWorkItem { 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, }, } diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs index 21dd624e8..9b0b4d08c 100644 --- a/server/src/atlas/scale.rs +++ b/server/src/atlas/scale.rs @@ -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); diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index 910100e16..bd61bbfd9 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -53,7 +53,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::atlas::scale::{CHUNK_M, VOXELS_PER_CHUNK}; +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). @@ -137,6 +137,10 @@ pub fn fill_chunk( 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 = BTreeMap::new(); // Block-local tile range covered by this 64 m sub-chunk quadrant. @@ -176,6 +180,14 @@ fn shell_derive_into( 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; @@ -192,8 +204,10 @@ fn shell_derive_into( } // Quarter-ground z origin (D-110): subtract the ground floor's building-relative - // base so floor 0 bottom lands at z = 0. If the building has no floor 0 (all - // basement / all elevated — unusual), fall back to the building bottom (= 0). + // 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) @@ -235,6 +249,8 @@ fn shell_derive_into( } // 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 { @@ -362,6 +378,21 @@ mod tests { 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). @@ -381,7 +412,8 @@ mod tests { 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 holds block-local x 60..64 → chunk-local x 60..64. + // 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.