Files
settled-reach/server/src/atlas/scale.rs
T
jpmschweitzerandClaude Opus 4.8 9c8ad83071 feat(simulation): thread political_archetype/morphology_zone/road_entry_directions through L3->L4 dispatch (T-1039, T-1043)
Completes the GenerateSkeleton dispatch T-1022 wired (which only carried
founding_orientation). build_skeleton_work_item now also threads:

T-1039:
- political_archetype — real value from CityPlacement, replacing the Commission stub.
- morphology_zone — looked up from the covering DistrictProfile via new
  scale::heightmap_pixel_to_district (exported HEIGHTMAP_CELLS_PER_DISTRICT;
  cascade.rs drops its local CELLS_PER_REGION literal for the shared const).
- arrangement_pattern — RE-DERIVED at L4 (option b) via the single pure fn
  attractor_matching::arrangement_pattern(&archetype, &economic_role); both
  inputs already at L4. No CityGenerationContext field, no stored derived state
  (DB-as-cache). Parity test asserts L4 re-derivation == L3-stored value.

T-1043:
- road_entry_directions — derived from T-1038's BodyWorldState.road_graph:
  octant = bearing of each incident road edge, deduped per octant, ordered by
  MaintenanceAuthority rank (the AdminFacing prestige-edge consumer). Feeds
  derive_access_points -> AccessKind::QuarterEdge; BlockJunction fallback now
  fires only for genuinely isolated settlements.

Acceptance covered: Corporate+Fjord -> Ribbon topology (not mesh+Commission);
settlement with a road -> QuarterEdge on the correct octant. 11 new tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:34:22 +02:00

181 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Canonical spatial scale ladder (D-243) — **the single source of truth** for
//! the metric containment hierarchy. Every cascade consumer references these
//! constants and conversions; no module re-derives its own scale.
//!
//! This is the *structural* fix for the Q-110 failure mode — three files had
//! three different "region" sizes (1 024 m, 624 km, "16 chunks") because the
//! metres-per-scale lived as scattered magic numbers. With one canonical source
//! a file physically cannot invent a scale that disagrees, and the `const`
//! asserts below fail the build if the rungs ever stop nesting cleanly.
//!
//! ```text
//! voxel 1 m → chunk 64 m → block 128 m → quarter 512 m → district 2 048 m
//! → region ~205 km → (elastic seam) → planet
//! ```
//!
//! Below the region everything is fixed integer math; only [`regions_per_equator`]
//! (the planetary count) varies per body. See [D-243] for the full model.
//!
//! [D-243]: ../../../governance/decisions/architecture.md
// ---------------------------------------------------------------------------
// Edge lengths in metres (absolute, fixed — D-243)
// ---------------------------------------------------------------------------
/// Voxel edge — the tile (D-228 / D-220).
pub const VOXEL_M: i32 = 1;
/// Chunk edge — the stream / derive unit (D-222, D-239).
pub const CHUNK_M: i32 = 64;
/// Block edge — generator planning unit (D-222).
pub const BLOCK_M: i32 = 128;
/// Quarter edge — settlement footprint cell (D-222).
pub const QUARTER_M: i32 = 512;
/// District edge — urban division **and** the local terrain/climate carrier cell
/// (D-222, D-239 §2, D-243). The fine carrier `ChunkContext`/voxels consume.
pub const DISTRICT_M: i32 = 2_048;
/// Region edge — the top hard block: planetary grid + climate/weather/season
/// lockdown (D-243). 100 districts on a side.
pub const REGION_M: i32 = 204_800;
// ---------------------------------------------------------------------------
// Linear nesting ratios (children per parent edge)
// ---------------------------------------------------------------------------
/// 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 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
/// toward −∞ for negative coords, the correct tiling behaviour).
pub const CHUNK_DISTRICT_SHIFT: u32 = 5;
/// Districts per region edge (204 800 m / 2 048 m = 100). Not a power of two,
/// so region mapping uses division, not a shift.
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_DISTRICT == 1 << CHUNK_DISTRICT_SHIFT);
const _: () = assert!(CHUNKS_PER_DISTRICT * CHUNK_M == DISTRICT_M);
const _: () = assert!(DISTRICTS_PER_REGION * DISTRICT_M == REGION_M);
const _: () = assert!(DISTRICTS_PER_REGION == 100);
// ---------------------------------------------------------------------------
// Grid positions + fixed integer addressing
// ---------------------------------------------------------------------------
/// Position on the 64 m chunk grid (chunk-units, not metres). `BTreeMap` key (D-010).
pub type ChunkPos = (i32, i32);
/// Position on the 2 km district grid (district-units) — the terrain/climate carrier cell.
pub type DistrictPos = (i32, i32);
/// Position on the ~205 km region grid (region-units) — the top hard block.
pub type RegionPos = (i32, i32);
/// The district covering a chunk. Arithmetic shift floors toward −∞ so the tiling
/// is consistent across the origin (a chunk at −1 belongs to district −1, not 0).
#[inline]
pub fn chunk_to_district(c: ChunkPos) -> DistrictPos {
(c.0 >> CHUNK_DISTRICT_SHIFT, c.1 >> CHUNK_DISTRICT_SHIFT)
}
/// The region covering a district. Euclidean floor division matches the shift's
/// flooring, so chunk→district→region tiling is seamless across the origin.
#[inline]
pub fn district_to_region(d: DistrictPos) -> RegionPos {
(
d.0.div_euclid(DISTRICTS_PER_REGION),
d.1.div_euclid(DISTRICTS_PER_REGION),
)
}
/// The region covering a chunk (chunk → district → region).
#[inline]
pub fn chunk_to_region(c: ChunkPos) -> RegionPos {
district_to_region(chunk_to_district(c))
}
// ---------------------------------------------------------------------------
// Working-heightmap pixel ↔ district (cascade.rs working grid)
// ---------------------------------------------------------------------------
/// Number of working-heightmap-grid pixels per district side on the standard
/// cascade working resolution (~128×64 working grid; D-203, D-239 §1, T-1023).
///
/// This is NOT a metre-scale constant — it is the `grid_cells_per_district`
/// parameter passed to [`crate::atlas::district_profile::derive_all_districts`].
/// Centralised here so plugin.rs and cascade.rs share one definition and neither
/// hard-codes `8` independently.
///
/// `DistrictPos = (col / HEIGHTMAP_CELLS_PER_DISTRICT, row / HEIGHTMAP_CELLS_PER_DISTRICT)`
/// for a working-grid pixel `(row, col)` — see [`heightmap_pixel_to_district`].
pub const HEIGHTMAP_CELLS_PER_DISTRICT: usize = 8;
/// Convert a working-heightmap-grid pixel coordinate `(row, col)` to the
/// [`DistrictPos`] that covers it.
///
/// `CityPlacement.position` and `RoadNode.position` are both stored in
/// working-heightmap-grid coordinates (row-major, `(row, col)` order), and the
/// district grid is built with the same pixel grid by
/// [`crate::atlas::district_profile::derive_all_districts`]. Integer division
/// floors toward zero, which matches the `BTreeMap` keys inserted by `derive_all_districts`.
#[inline]
pub fn heightmap_pixel_to_district(pixel: (u16, u16)) -> DistrictPos {
let cpd = HEIGHTMAP_CELLS_PER_DISTRICT as i32;
// pixel = (row, col); DistrictPos convention is (dx=col_district, dy=row_district).
let dx = pixel.1 as i32 / cpd;
let dy = pixel.0 as i32 / cpd;
(dx, dy)
}
// ---------------------------------------------------------------------------
// The elastic seam — region ↔ planet (the only per-body-floating quantity)
// ---------------------------------------------------------------------------
/// Number of ~205 km regions around the body's equator: `round(2πR / REGION_M)`,
/// minimum 1 (D-243). `R` is `bodies.body_radius_km` (D-204) — the single
/// body-specific input in the whole scale chain. Pole-to-pole is half this.
pub fn regions_per_equator(body_radius_km: f64) -> u32 {
let circumference_m = 2.0 * std::f64::consts::PI * body_radius_km * 1000.0;
((circumference_m / REGION_M as f64).round() as i64).max(1) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ladder_nests_cleanly() {
assert_eq!(VOXELS_PER_CHUNK, 64);
assert_eq!(CHUNKS_PER_DISTRICT, 32);
assert_eq!(DISTRICTS_PER_REGION, 100);
assert_eq!(CHUNK_M * CHUNKS_PER_DISTRICT, DISTRICT_M);
assert_eq!(DISTRICT_M * DISTRICTS_PER_REGION, REGION_M);
}
#[test]
fn addressing_floors_across_origin() {
// chunk → district (32 chunks/district)
assert_eq!(chunk_to_district((0, 0)), (0, 0));
assert_eq!(chunk_to_district((31, 31)), (0, 0));
assert_eq!(chunk_to_district((32, 32)), (1, 1));
assert_eq!(chunk_to_district((-1, -1)), (-1, -1)); // floors, not truncates
assert_eq!(chunk_to_district((-32, -32)), (-1, -1));
assert_eq!(chunk_to_district((-33, -33)), (-2, -2));
// district → region (100 districts/region)
assert_eq!(district_to_region((99, 0)), (0, 0));
assert_eq!(district_to_region((100, 0)), (1, 0));
assert_eq!(district_to_region((-1, 0)), (-1, 0));
// chunk → region (3200 chunks/region)
assert_eq!(chunk_to_region((3199, 0)), (0, 0));
assert_eq!(chunk_to_region((3200, 0)), (1, 0));
}
#[test]
fn regions_per_equator_earth_and_moon() {
// Earth (R≈6371 km): 2πR ≈ 40 030 km / 204.8 km ≈ 195.46 → 195.
assert_eq!(regions_per_equator(6371.0), 195);
// A small moon (R≈200 km): ≈ 1257 km / 204.8 ≈ 6.1 → 6.
assert_eq!(regions_per_equator(200.0), 6);
// Degenerate radius still yields at least one region.
assert_eq!(regions_per_equator(0.0), 1);
}
}