fix(simulation): PR #178 review round — H1-H4, T1, T2
H1: split_edge_at now splits length_cells geometric-distance proportionally (fixed-order f64 polyline sums, halves sum exactly to parent) — subsumes the 2-point-parent all-or-nothing bug. H2: chained-snap test (minor onto minor spur) + determinism assertion actually exercising attach_minors + direct 2-point split unit test. H3: cascade-level RailHeadFacing end-to-end assertion on a plus-shaped landmass fixture (2 deterministic degree-3 settlement junctions). H4+T2: STANDALONE_HQ_JOIN_SQL shared constant, both readers compose it; believability lockstep unit test. T1: HUB_SPACING_DIAG_PX doc cross-refs D-243 elastic seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,10 @@ pub const JUNCTION_DEGREE: u16 = 3;
|
||||
/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other
|
||||
/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`,
|
||||
/// `LONG_HAUL_FRAC`); physical radius is not available to this layer.
|
||||
/// Fixed-grid premise: revisit when [D-243]'s elastic planetary seam gives
|
||||
/// bodies varying grid sizes (`round(2πR / 204.8 km)` regions) — the diagonal
|
||||
/// then genuinely scales with `body_radius_km` and this constant starts doing
|
||||
/// real per-body work (PR #178 T1).
|
||||
const HUB_SPACING_DIAG_PX: f64 = 64.0;
|
||||
|
||||
/// Floor for the hub cap: small grids keep at least this many trunk hubs so
|
||||
@@ -590,13 +594,34 @@ fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u
|
||||
(d2, (jr.round() as u16, jc.round() as u16))
|
||||
}
|
||||
|
||||
/// Geometric length of a polyline in grid px — Euclidean segment lengths
|
||||
/// summed in path order (fixed order + correctly-rounded f64 ops, so the sum
|
||||
/// is bit-reproducible per the module's determinism doctrine).
|
||||
fn polyline_len(path: &[(u16, u16)]) -> f64 {
|
||||
let mut total = 0.0;
|
||||
for w in path.windows(2) {
|
||||
total += euclid(
|
||||
(w[0].0 as f64, w[0].1 as f64),
|
||||
(w[1].0 as f64, w[1].1 as f64),
|
||||
);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`]
|
||||
/// node replaces the single edge with two halves meeting at the junction.
|
||||
/// Both halves inherit the parent's maintenance/named-route identity;
|
||||
/// `length_cells` splits proportionally by polyline vertex count (a routed-
|
||||
/// length proxy — the halves' true routed lengths are not re-measured).
|
||||
/// Returns the junction's node index. Caller guarantees `jpos` is not an
|
||||
/// endpoint of the edge's path (guarded at the call site).
|
||||
/// `length_cells` splits **geometric-distance proportionally** — each half
|
||||
/// gets the parent's routed length scaled by its polyline's share of the
|
||||
/// total geometry (PR #178 H1: the earlier vertex-count proxy went
|
||||
/// all-or-nothing on 2-point parents — `l1 = full, l2 = 0` wherever the
|
||||
/// junction fell — and 2-point parents are routine: every snap spur is one,
|
||||
/// so chained snaps always hit it). The halves always sum exactly to the
|
||||
/// parent's `length_cells` (l2 is the remainder), and an interior junction
|
||||
/// on a parent with `length_cells > 0` gives both halves a non-zero share
|
||||
/// whenever its geometry does. Returns the junction's node index. Caller
|
||||
/// guarantees `jpos` is not an endpoint of the edge's path (guarded at the
|
||||
/// call site).
|
||||
fn split_edge_at(
|
||||
nodes: &mut Vec<RoadNode>,
|
||||
edges: &mut Vec<RoadEdge>,
|
||||
@@ -626,8 +651,13 @@ fn split_edge_at(
|
||||
path2.extend_from_slice(&old.path[si + 1..]);
|
||||
}
|
||||
|
||||
let total_segs = (old.path.len() - 1).max(1) as u32;
|
||||
let l1 = old.length_cells * (path1.len().saturating_sub(1) as u32) / total_segs;
|
||||
let (len1, len2) = (polyline_len(&path1), polyline_len(&path2));
|
||||
let total = len1 + len2;
|
||||
let l1 = if total > 0.0 {
|
||||
((old.length_cells as f64 * len1 / total).round() as u32).min(old.length_cells)
|
||||
} else {
|
||||
0 // fully degenerate geometry (all points coincide) — nothing to apportion
|
||||
};
|
||||
let l2 = old.length_cells - l1;
|
||||
|
||||
edges[ei] = norm_edge(RoadEdge {
|
||||
@@ -1784,4 +1814,138 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PR #178 H1/H2 — edge-split length arithmetic ────────────────────────
|
||||
|
||||
/// Direct unit test of the H1 bug shape: a 2-POINT parent path with real
|
||||
/// `length_cells`. The old vertex-count proxy computed
|
||||
/// `l1 = length × (path1.len()-1) / total_segs` = all-or-nothing whenever
|
||||
/// `total_segs == 1`; the geometric split apportions by where the junction
|
||||
/// actually falls.
|
||||
#[test]
|
||||
fn split_edge_at_two_point_parent_splits_geometrically() {
|
||||
let mk_node = |pos: (u16, u16)| RoadNode {
|
||||
city_id: Some(99),
|
||||
position: pos,
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 0,
|
||||
parent_edge: None,
|
||||
is_hub: true,
|
||||
};
|
||||
// Midpoint split: 10 cells → 5 + 5.
|
||||
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
|
||||
let mut edges = vec![RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![(10, 10), (10, 30)],
|
||||
length_cells: 10,
|
||||
maintenance: MaintenanceAuthority::Communal,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}];
|
||||
let jn = split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 20));
|
||||
assert_eq!(nodes[jn].kind, RoadNodeKind::Junction);
|
||||
assert_eq!(edges.len(), 2);
|
||||
assert_eq!(edges[0].length_cells, 5, "midpoint → equal halves");
|
||||
assert_eq!(edges[1].length_cells, 5);
|
||||
|
||||
// Quarter-point split: 10 cells at t=0.25 → 2/3 + remainder split, but
|
||||
// NEVER all-or-nothing: both halves non-zero, summing to the parent.
|
||||
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
|
||||
let mut edges = vec![RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![(10, 10), (10, 30)],
|
||||
length_cells: 10,
|
||||
maintenance: MaintenanceAuthority::Communal,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
}];
|
||||
split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 15));
|
||||
let (l1, l2) = (edges[0].length_cells, edges[1].length_cells);
|
||||
assert_eq!(l1 + l2, 10, "halves always sum to the parent");
|
||||
assert!(l1 > 0 && l2 > 0, "interior split is never all-or-nothing");
|
||||
assert!(l1 < l2, "the shorter geometric half gets the smaller share");
|
||||
}
|
||||
|
||||
/// H2(a) — chained snap: minor B snaps onto minor A's already-created spur
|
||||
/// (the accreting behavior), splitting a 2-point spur path. Also the H2(b)
|
||||
/// determinism pin on a scenario that actually exercises attach_minors
|
||||
/// (the pre-existing determinism test's 5 placements all become hubs under
|
||||
/// HUB_CAP_MIN = 6, so it never reaches the attach path).
|
||||
#[test]
|
||||
fn chained_snap_onto_earlier_spur_and_attach_determinism() {
|
||||
let hm = flat_hm(64, 32);
|
||||
let ta = ta_for(&hm);
|
||||
let mut placements: Vec<CityPlacement> = vec![
|
||||
placement(1, (16, 4), PoliticalArchetype::Pioneer),
|
||||
placement(2, (16, 60), PoliticalArchetype::Pioneer),
|
||||
placement(3, (4, 4), PoliticalArchetype::Pioneer),
|
||||
placement(4, (4, 60), PoliticalArchetype::Pioneer),
|
||||
placement(5, (28, 4), PoliticalArchetype::Pioneer),
|
||||
placement(6, (28, 60), PoliticalArchetype::Pioneer),
|
||||
];
|
||||
for p in placements.iter_mut() {
|
||||
p.population = 1_000_000; // the six trunk hubs
|
||||
}
|
||||
// Minor A: 8 px above the row-16 trunk edge → boundary snap; its spur
|
||||
// runs down col 32 from the trunk to (8,32).
|
||||
let mut minor_a = placement(7, (8, 32), PoliticalArchetype::Pioneer);
|
||||
minor_a.population = 10_000;
|
||||
// Minor B: 3 px from A's spur segment, 4 px from the trunk → B's
|
||||
// nearest edge is the spur an earlier attachment created.
|
||||
let mut minor_b = placement(8, (12, 35), PoliticalArchetype::Pioneer);
|
||||
minor_b.population = 5_000;
|
||||
placements.push(minor_a);
|
||||
placements.push(minor_b);
|
||||
|
||||
let run = || {
|
||||
build_road_graph(
|
||||
&placements,
|
||||
&ta,
|
||||
&[],
|
||||
64,
|
||||
32,
|
||||
&TerritorialStatus::FrontierUnclaimed,
|
||||
&[],
|
||||
)
|
||||
};
|
||||
let g = run();
|
||||
|
||||
let junctions: Vec<usize> = g
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, n)| n.kind == RoadNodeKind::Junction)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
junctions.len(),
|
||||
2,
|
||||
"A splits the trunk, B splits A's spur — two junctions"
|
||||
);
|
||||
// B's junction sits ON A's spur (col 32, strictly between the trunk
|
||||
// row and A's row) — proving the chain hit a 2-point spur parent.
|
||||
let chained = junctions
|
||||
.iter()
|
||||
.map(|&ji| g.nodes[ji].position)
|
||||
.find(|p| p.1 == 32 && p.0 > 8 && p.0 < 16)
|
||||
.expect("a junction must sit interior to A's spur on col 32");
|
||||
assert_eq!(chained.1, 32);
|
||||
// Both minors are connected; every edge honours the invariants
|
||||
// (including the split halves of the 2-point spur).
|
||||
for mi in [6usize, 7usize] {
|
||||
assert!(
|
||||
g.edges.iter().any(|e| e.from == mi || e.to == mi),
|
||||
"minor node {mi} must be attached"
|
||||
);
|
||||
}
|
||||
for e in &g.edges {
|
||||
assert!(e.from < e.to);
|
||||
assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap());
|
||||
assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap());
|
||||
}
|
||||
// H2(b): the whole accreting attach sequence is deterministic.
|
||||
assert_eq!(run(), run(), "attach_minors must be deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user