feat(simulation): quarter street network — typed corridors/access/lattice (#957)

Complete the Layer-4 quarter geometry with the street layer (D-234), replacing
the ChunkLayout/CorridorSpine/AccessPoint String stubs with real types:
- AccessPoint{position,kind} + AccessKind (QuarterEdge/ReservationGate/
  BlockJunction): the quarter's road nodes (from road_entry_directions octants +
  reservation gates), the node set the D-097 audit reads.
- CorridorSpine{from,to,path}: arterial trunk edges, ±45°-snapped polylines
  (D-096 cap). Topology gated by morphology (D-234a): Ribbon (fjord/canyon/
  mountain-pass), HubSpoke (delta/island/enclosed water), Mesh/Prim-MST
  (plains/meander/coastal). A spanning tree over the access nodes.
- ChunkLayout{spacing,offset,rotation_steps}: per-block local ±45° lattice,
  spacing from density, offset/rotation modulated by the D-096 Grid/Organic mode.

Wired into generate_quarter_skeleton (access_points + corridors + per-block
chunk_layout). All integer-deterministic (D-010). 7 new tests.

Remaining D-234 piece: the per-edge waterfront pier/quay rule needs Layer-1
terrain water-adjacency threaded to the skeleton (cross-layer plumbing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:29:21 +02:00
co-authored by Claude Opus 4.8
parent 728a27e1b4
commit 4c6fe596d1
2 changed files with 403 additions and 15 deletions
+351 -9
View File
@@ -26,11 +26,12 @@ use crate::seed::{SeedChain, SeedDomain};
use std::collections::BTreeMap;
use crate::simulation::generator::{
ArchitectureFlavorRef, BlockPlacement, BlockSkeleton, BuildingEntryClass, BuildingPropertyTag,
BulkClass, CityGenerationContext, ComplexityTier, ConstructionEra, DistrictLayoutMode,
DistrictType, EraCause, FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation,
PoliticalArchetype, QuarterId, QuarterSkeleton, ReservationFunction, ReservationId,
SettingType, TileRect, WorldTier, ZoneTypeId, ZoningType,
AccessKind, AccessPoint, ArchitectureFlavorRef, BlockPlacement, BlockSkeleton,
BuildingEntryClass, BuildingPropertyTag, BulkClass, ChunkLayout, CityGenerationContext,
ComplexityTier, ConstructionEra, CorridorSpine, DistrictLayoutMode, DistrictType, EraCause,
FloorExtent, FloorHeightProfile, MorphologyZone, MultiBlockReservation, PoliticalArchetype,
QuarterId, QuarterSkeleton, ReservationFunction, ReservationId, SettingType, TileRect,
WorldTier, ZoneTypeId, ZoningType,
};
// ---------------------------------------------------------------------------
@@ -102,7 +103,14 @@ pub fn generate_quarter_skeleton(
.first()
.cloned()
.unwrap_or(DistrictType::MixedUse);
let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
let mut blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type);
// ── Street network (D-234) ────────────────────────────────────────────
// Local lattice modulation per block (Grid vs Organic), then the quarter's
// access nodes + morphology-gated arterial corridors.
apply_layout_to_chunks(&mut blocks, &layout_mode);
let access_points = derive_access_points(&context.road_entry_directions, &reservations);
let corridors = derive_corridors(&access_points, &context.morphology_zone);
// ── Compute z_levels ──────────────────────────────────────────────────
// Phase 1: single-storey above ground for all non-reserved blocks.
@@ -120,10 +128,10 @@ pub fn generate_quarter_skeleton(
layout_mode,
blocks,
reservations,
corridors: Vec::new(),
corridors,
z_levels,
social_sites: Vec::new(),
access_points: Vec::new(),
access_points,
society_profile: String::new(),
zone_palette: Vec::new(),
boundaries: String::new(),
@@ -329,7 +337,13 @@ fn build_block_grid(
position: (row as u8, col as u8),
zoning,
reservation,
chunk_layout: String::new(), // stub
// Density-based spacing; layout_mode offset/rotation applied in
// the street-network step (D-234).
chunk_layout: ChunkLayout {
spacing: local_street_spacing(density),
offset: (0, 0),
rotation_steps: 0,
},
hosted_sites: Vec::new(),
era_modifications: Vec::new(),
era_cause: None,
@@ -880,6 +894,230 @@ pub fn assign_all_block_tags(
map
}
// ---------------------------------------------------------------------------
// Street network — access points, arterial corridors, local lattice (D-234, #957)
//
// Arterials (`corridors`) = a least-cost graph over the quarter's access nodes,
// ±45°-snapped (D-234 refined; the node/edge graph the D-097 audit reads). Local
// streets (`chunk_layout`) = a ±45° grid lattice per block, modulated by the
// D-096 layout mode. Morphology gates the trunk topology (D-234a). The per-edge
// waterfront pier rule (D-234b) needs Layer-1 terrain water-adjacency threaded to
// the skeleton and is the remaining piece.
// ---------------------------------------------------------------------------
/// Quarter edge length in tiles (4 blocks × 128).
const QUARTER_TILES: u16 = 512;
/// Local-street lattice spacing (tiles) from build density — denser → tighter.
fn local_street_spacing(density_pct: u8) -> u8 {
match density_pct {
0..=30 => 32,
31..=60 => 24,
61..=85 => 16,
_ => 12,
}
}
/// Perimeter point for a compass octant (0=N…7=NW) on the 512-tile quarter.
fn octant_perimeter(octant: u8) -> (u16, u16) {
let max = QUARTER_TILES - 1;
let mid = QUARTER_TILES / 2;
match octant % 8 {
0 => (mid, 0),
1 => (max, 0),
2 => (max, mid),
3 => (max, max),
4 => (mid, max),
5 => (0, max),
6 => (0, mid),
_ => (0, 0),
}
}
/// Quarter access nodes (D-234): one per road-entry octant + one gate per
/// reservation. Always non-empty (a central junction if nothing else), so the
/// arterial graph is well-defined.
fn derive_access_points(
road_entry_directions: &[u8],
reservations: &[MultiBlockReservation],
) -> Vec<AccessPoint> {
let mut pts = Vec::new();
for &octant in road_entry_directions {
pts.push(AccessPoint {
position: octant_perimeter(octant),
kind: AccessKind::QuarterEdge { octant },
});
}
for (i, res) in reservations.iter().enumerate() {
if let Some(&(r, c)) = res.blocks.first() {
pts.push(AccessPoint {
position: (c as u16 * 128 + 64, r as u16 * 128 + 64),
kind: AccessKind::ReservationGate {
reservation: i as ReservationId + 1,
},
});
}
}
if pts.is_empty() {
pts.push(AccessPoint {
position: (QUARTER_TILES / 2, QUARTER_TILES / 2),
kind: AccessKind::BlockJunction,
});
}
pts
}
/// Trunk-road topology permitted by the region morphology (D-234a).
enum Topology {
/// Linear spine along the terrain axis (fjord/canyon/mountain-pass).
Ribbon,
/// Star from a central hub (delta/island/enclosed water).
HubSpoke,
/// Least-cost mesh — any pattern (plains/meander/coastal-lowland).
Mesh,
}
fn street_topology(m: &MorphologyZone) -> Topology {
match m {
MorphologyZone::Fjord | MorphologyZone::Canyon | MorphologyZone::MountainPass => {
Topology::Ribbon
}
MorphologyZone::Delta
| MorphologyZone::OpenOcean
| MorphologyZone::Lake
| MorphologyZone::Sea
| MorphologyZone::Island => Topology::HubSpoke,
MorphologyZone::AlluvialPlain
| MorphologyZone::MeanderReach
| MorphologyZone::CoastalLowland
| MorphologyZone::Unknown => Topology::Mesh,
}
}
/// Chebyshev distance (diagonal-friendly, matches the ±45° path metric).
fn node_dist(a: (u16, u16), b: (u16, u16)) -> i32 {
let dx = (a.0 as i32 - b.0 as i32).abs();
let dy = (a.1 as i32 - b.1 as i32).abs();
dx.max(dy)
}
/// A ±45°-snapped polyline from `a` to `b`: a 45° diagonal run, then an
/// axis-aligned run (D-096 ±45° cap — only 0°/45°/90° segments).
fn snap45_path(a: (u16, u16), b: (u16, u16)) -> Vec<(u16, u16)> {
let (ax, ay) = (a.0 as i32, a.1 as i32);
let (bx, by) = (b.0 as i32, b.1 as i32);
let diag = (bx - ax).abs().min((by - ay).abs());
let corner = (
(ax + (bx - ax).signum() * diag) as u16,
(ay + (by - ay).signum() * diag) as u16,
);
if corner == a || corner == b {
vec![a, b]
} else {
vec![a, corner, b]
}
}
fn spine(from: usize, to: usize, aps: &[AccessPoint]) -> CorridorSpine {
CorridorSpine {
from: from as u16,
to: to as u16,
path: snap45_path(aps[from].position, aps[to].position),
}
}
/// Prim's minimum-spanning tree over node positions (O(n²); n is small).
fn prim_mst(aps: &[AccessPoint]) -> Vec<(usize, usize)> {
let n = aps.len();
let mut in_tree = vec![false; n];
let mut edges = Vec::new();
in_tree[0] = true;
for _ in 1..n {
let mut best: Option<(usize, usize, i32)> = None;
for i in 0..n {
if !in_tree[i] {
continue;
}
for j in 0..n {
if in_tree[j] {
continue;
}
let d = node_dist(aps[i].position, aps[j].position);
if best.is_none_or(|(_, _, bd)| d < bd) {
best = Some((i, j, d));
}
}
}
if let Some((i, j, _)) = best {
in_tree[j] = true;
edges.push((i, j));
}
}
edges
}
/// Node closest to the quarter centre — the hub for hub-and-spoke topology.
fn central_node(aps: &[AccessPoint]) -> usize {
let c = (QUARTER_TILES / 2, QUARTER_TILES / 2);
(0..aps.len())
.min_by_key(|&i| node_dist(aps[i].position, c))
.unwrap_or(0)
}
/// Derive the quarter's arterial corridors over its access points, with topology
/// gated by morphology (D-234a).
fn derive_corridors(
access_points: &[AccessPoint],
morphology: &MorphologyZone,
) -> Vec<CorridorSpine> {
let n = access_points.len();
if n < 2 {
return Vec::new();
}
match street_topology(morphology) {
Topology::HubSpoke => {
let hub = central_node(access_points);
(0..n)
.filter(|&i| i != hub)
.map(|i| spine(hub, i, access_points))
.collect()
}
Topology::Ribbon => {
// Connect consecutive nodes ordered along the dominant terrain axis.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| {
access_points[i].position.0 as i32 + access_points[i].position.1 as i32
});
order
.windows(2)
.map(|w| spine(w[0], w[1], access_points))
.collect()
}
Topology::Mesh => prim_mst(access_points)
.into_iter()
.map(|(i, j)| spine(i, j, access_points))
.collect(),
}
}
/// Apply the D-096 layout mode to each block's local lattice (D-234): organic
/// settlements jitter the lattice offset + rotation from their `BlockPlacement`;
/// grid settlements keep the axis-aligned default.
fn apply_layout_to_chunks(blocks: &mut [[BlockSkeleton; 4]; 4], layout: &DistrictLayoutMode) {
if let DistrictLayoutMode::Organic { placements } = layout {
for r in 0..4 {
for c in 0..4 {
let p = &placements[r][c];
blocks[r][c].chunk_layout.offset = (
p.offset.0.unsigned_abs().min(255) as u8,
p.offset.1.unsigned_abs().min(255) as u8,
);
blocks[r][c].chunk_layout.rotation_steps = p.rotation_steps;
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1363,4 +1601,108 @@ mod tests {
let b = assign_all_block_tags(&sk, &ctx, "manufacturing", 250, SeedChain::root(5));
assert_eq!(a, b);
}
// ── Street network (#957, D-234) ─────────────────────────────────────────
use crate::simulation::generator::AccessKind;
#[test]
fn access_points_from_entries_and_reservations() {
let res = vec![MultiBlockReservation {
blocks: vec![(1, 2), (1, 3)],
template_tag: String::new(),
function: ReservationFunction::Park,
z_levels: 1,
base_z: 0,
floor_zones: Vec::new(),
z_band_count: 0,
z_band_zones: Vec::new(),
vertical_corridors: Vec::new(),
hosted_sites: Vec::new(),
}];
let aps = derive_access_points(&[0, 4], &res);
assert_eq!(aps.len(), 3); // 2 edges + 1 reservation gate
assert!(matches!(aps[0].kind, AccessKind::QuarterEdge { octant: 0 }));
assert!(aps
.iter()
.any(|a| matches!(a.kind, AccessKind::ReservationGate { .. })));
}
#[test]
fn access_points_never_empty() {
let aps = derive_access_points(&[], &[]);
assert_eq!(aps.len(), 1);
assert!(matches!(aps[0].kind, AccessKind::BlockJunction));
}
#[test]
fn snap45_segments_are_axis_or_diagonal() {
let path = snap45_path((10, 20), (200, 60));
assert!(path.len() >= 2);
for w in path.windows(2) {
let dx = (w[0].0 as i32 - w[1].0 as i32).abs();
let dy = (w[0].1 as i32 - w[1].1 as i32).abs();
// axis-aligned (one delta 0) or perfect 45° diagonal (dx == dy).
assert!(
dx == 0 || dy == 0 || dx == dy,
"segment {:?}->{:?} not ±45°",
w[0],
w[1]
);
}
}
#[test]
fn corridors_form_a_tree() {
// A spanning tree over n nodes has n-1 edges, for each topology.
let mesh = derive_access_points(&[0, 2, 4, 6], &[]);
assert_eq!(
derive_corridors(&mesh, &MorphologyZone::AlluvialPlain).len(),
3
);
assert_eq!(
derive_corridors(&mesh, &MorphologyZone::MountainPass).len(),
3
); // ribbon
assert_eq!(derive_corridors(&mesh, &MorphologyZone::Delta).len(), 3); // hub-spoke
}
#[test]
fn hub_spoke_shares_a_common_node() {
let aps = derive_access_points(&[0, 2, 4, 6], &[]);
let corridors = derive_corridors(&aps, &MorphologyZone::Island);
// Every spoke touches the hub.
let hub = central_node(&aps) as u16;
assert!(corridors.iter().all(|c| c.from == hub || c.to == hub));
}
#[test]
fn skeleton_has_streets_and_local_lattice() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
assert!(!sk.access_points.is_empty());
assert!(!sk.corridors.is_empty());
// Local lattice spacing is set on every block.
for row in &sk.blocks {
for b in row {
assert!(b.chunk_layout.spacing > 0);
}
}
}
#[test]
fn organic_layout_jitters_local_lattice() {
// An old Pioneer settlement is Organic → some block lattice is rotated/offset.
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional);
let sk =
generate_quarter_skeleton(&ctx, 500_000, "residential", 1, 600, SeedChain::root(7));
if matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. }) {
let jittered = sk
.blocks
.iter()
.flatten()
.any(|b| b.chunk_layout.rotation_steps > 0 || b.chunk_layout.offset != (0, 0));
assert!(jittered, "organic quarter should jitter some local lattice");
}
}
}
+52 -6
View File
@@ -50,10 +50,8 @@ pub type Era = String;
pub type EraModification = String;
/// Landmark slot description within a block. Stub.
pub type LandmarkSlot = String;
/// Chunk layout specification within a block (how the 2×2 chunks are arranged). Stub.
pub type ChunkLayout = String;
/// Corridor spine connecting district access points. Stub.
pub type CorridorSpine = String;
// `ChunkLayout`, `CorridorSpine`, `AccessPoint` are typed street-network structs
// (D-234, #957) — see the "Street-network types" section below.
/// District context — neighboring districts, world position, system. Stub.
pub type QuarterContext = String;
/// Society profile reference (Miri's cultural ingredients). Stub.
@@ -64,8 +62,56 @@ pub type ZoneDefinition = String;
pub type QuarterBoundaries = String;
/// Guarantee audit result — tier-appropriate spatial invariant checks. Stub.
pub type GuaranteeAuditResult = String;
/// District access point (entry/exit to neighboring district). Stub.
pub type AccessPoint = String;
// ---------------------------------------------------------------------------
// Street-network types (D-234, #957)
// ---------------------------------------------------------------------------
/// Where a quarter's street network connects to the outside, to a reservation,
/// or to an interior junction (D-234). These are the nodes the arterial graph
/// spans and the guarantee audit (D-097) reads.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct AccessPoint {
/// Tile-space position within the 512-tile quarter (0511).
pub position: (u16, u16),
pub kind: AccessKind,
}
/// Kind of [`AccessPoint`] (D-234).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum AccessKind {
/// Road enters the quarter from outside at a compass octant (0=N…7=NW),
/// from `CityGenerationContext.road_entry_directions`.
QuarterEdge { octant: u8 },
/// Gate into a multi-block reservation.
ReservationGate { reservation: ReservationId },
/// Internal junction between blocks.
BlockJunction,
}
/// One arterial spine — a ±45°-snapped edge of the quarter's trunk-road graph
/// (D-234 refined: arterials = least-cost graph over the access nodes). `from`/`to`
/// index into the quarter's `access_points`; `path` is the tile-space polyline
/// (segments axis- or 45°-aligned, D-096 ±45° cap). This node/edge graph is what
/// the guarantee audit (D-097) reads as the trunk topology.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct CorridorSpine {
pub from: u16,
pub to: u16,
pub path: Vec<(u16, u16)>,
}
/// Local street lattice descriptor for one block (D-234 refined: local streets =
/// a ±45° grid lattice within each block, modulated by the D-096 layout mode).
/// A compact parametric form the fill layer rasterizes — not the raster itself.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
pub struct ChunkLayout {
/// Street spacing in tiles (the block is divided into spacing-sized cells).
pub spacing: u8,
/// Lattice origin offset in tiles (organic jitter; `(0,0)` for grid).
pub offset: (u8, u8),
/// Rotation in 15° steps (03 ⇒ 045°, the D-096 cap; 0 for grid).
pub rotation_steps: u8,
}
/// Base visual palette for a zone. Stub.
pub type BasePalette = String;
/// Economic modifier on a zone palette. Stub.