Dane Sabo 4b0eae9caf Milestone 0.3: I3 fix at acute corners, min-change deform, SplitSegment preserve
Y-intersection had a real I3 overlap at acute corners, caught by a
new programmatic centroid-in-other-polygon test
(y_intersection_no_overlaps). Fix: bisector-clip regular parcels
adjacent to acute corners (interior < 60°). Obtuse corners keep
their rectangle/parallelogram corner parcels and need no clip.

Minimum-change deformation: when a road's *line* doesn't change —
only its endpoints shift along the same line, e.g., the bottom road
gets longer when its right endpoint moves outward — parcels whose
frontage is still on the new segment are reported as Untouched and
keep their absolute coordinates. Only parcels on a road that
actually rotated get re-projected. Trade-off: vertex-exact
inverse-restore is no longer guaranteed (centroid drift bounded by
edit delta is the new contract).

SplitSegment preserve: rebind frontage_road for parcels whose
frontage is entirely on one side of the split point, or split into
two parcels along a perpendicular through the split point for
parcels that span. road_split_preserves test is now active and
passing. acute_intersection_15deg/5deg also active.

Test status: 24 unit + 20 integration + 1 doc passing; only
cul_de_sac and curved_road_high_curv still ignored (need real curved
roads). Journal §11 session 3 entry added with D14, D15, D16, two
spec deviations, and the milestone-0.4 queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:51:22 -04:00

599 lines
21 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.

//! Degenerate-case suite from spec Table `tab:degenerate` (§5).
//!
//! Every named test exists. Tests that require milestone-0.2
//! features (full deformation pipeline, curved-road support, sliver
//! merging) are gated with `#[ignore]` and a clear TODO so they
//! show up red in `cargo test -- --ignored` and green in the default
//! run.
use glam::DVec2;
use road_parceling::{
apply_road_edit, subdivide_all, EdgeKind, RoadEdit, RoadGraph, SubdivisionError,
SubdivisionParams,
};
// ---------- helpers ----------
fn rectangle_graph(w: f64, h: f64) -> RoadGraph {
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(w, 0.0));
let c = g.add_node(DVec2::new(w, h));
let d = g.add_node(DVec2::new(0.0, h));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap();
g.add_road(c, d).unwrap();
g.add_road(d, a).unwrap();
g.rebuild_topology().unwrap();
g
}
fn assert_invariants_i1_i3(parcels: &road_parceling::ParcelSet) {
// I1 — every parcel polygon is simple, CCW, non-degenerate. The
// `Polygon` constructor enforces this on insertion, so iterating
// and re-constructing each polygon is sufficient.
for (_, p) in parcels.iter() {
let v: Vec<DVec2> = p.vertices().to_vec();
let _ = road_parceling::geometry::Polygon::new(v).expect("I1: polygon must validate");
}
// I2 — each parcel has exactly one Frontage edge.
for (_, p) in parcels.iter() {
let n_frontage = p
.edge_kinds()
.iter()
.filter(|k| matches!(k, EdgeKind::Frontage))
.count();
assert_eq!(n_frontage, 1, "I2 violation: {n_frontage} frontages");
}
// I3 — non-overlap. Coarse cross-parcel check is deferred to
// milestone 0.2 (requires polygon-polygon intersection); the
// per-block area sum check lives in subdivide.rs unit tests.
}
// ----------------------------------------------------------------------
// Tests #1 #3 (acute / colinear)
// ----------------------------------------------------------------------
#[test]
fn acute_intersection_15deg() {
// Two roads sharing a node meet at 15°. With the bisector-clip
// at acute corners (milestone 0.3), no proper sliver-merge yet,
// but I1I3 must hold.
let mut g = RoadGraph::new();
let apex = g.add_node(DVec2::new(0.0, 0.0));
let p1 = g.add_node(DVec2::new(100.0, 0.0));
let angle = 15.0_f64.to_radians();
let p2 = g.add_node(DVec2::new(100.0 * angle.cos(), 100.0 * angle.sin()));
g.add_road(apex, p1).unwrap();
g.add_road(apex, p2).unwrap();
g.add_road(p1, p2).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
// We don't insist parcels exist (a 15° wedge may be too narrow
// for any parcel to satisfy `min_area`), but if any do, they
// must satisfy I1I3.
assert_invariants_i1_i3(&parcels);
}
#[test]
fn acute_intersection_5deg() {
// Knife-edge 5° angle. Library must not panic.
let mut g = RoadGraph::new();
let apex = g.add_node(DVec2::new(0.0, 0.0));
let p1 = g.add_node(DVec2::new(100.0, 0.0));
let angle = 5.0_f64.to_radians();
let p2 = g.add_node(DVec2::new(100.0 * angle.cos(), 100.0 * angle.sin()));
g.add_road(apex, p1).unwrap();
g.add_road(apex, p2).unwrap();
g.add_road(p1, p2).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
// Either succeeds with valid parcels or returns a typed error;
// both are acceptable, but it must not panic.
if let Ok(parcels) = subdivide_all(&g, &params) {
assert_invariants_i1_i3(&parcels);
}
}
#[test]
fn colinear_roads() {
// Two end-to-end segments with zero turn at the shared node.
// Currently we treat them as two short frontages; the spec
// ultimately wants them merged into one continuous frontage,
// but invariants must hold either way (milestone 0.1 baseline).
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(100.0, 0.0));
let c = g.add_node(DVec2::new(200.0, 0.0));
let d = g.add_node(DVec2::new(200.0, 100.0));
let e = g.add_node(DVec2::new(0.0, 100.0));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap(); // colinear with a-b
g.add_road(c, d).unwrap();
g.add_road(d, e).unwrap();
g.add_road(e, a).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(
!parcels.is_empty(),
"should subdivide despite colinear edges"
);
assert_invariants_i1_i3(&parcels);
}
// ----------------------------------------------------------------------
// Tests #4 #6 (graph degeneracies)
// ----------------------------------------------------------------------
#[test]
fn zero_length_segment() {
// Two coincident-position nodes, road between them.
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(0.0, 0.0));
let _ = g.add_road(a, b); // currently accepted (same id check only)
let params = SubdivisionParams::default();
// Either typed error or skip — both acceptable.
let _ = subdivide_all(&g, &params);
}
#[test]
fn near_duplicate_nodes() {
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(1e-9, 0.0)); // within EPS_GEOM
let c = g.add_node(DVec2::new(1.0, 0.0));
let _ = g.add_road(a, b);
let _ = g.add_road(b, c);
let params = SubdivisionParams::default();
let _ = subdivide_all(&g, &params); // must not panic
}
#[test]
fn self_intersecting_graph() {
// Two roads cross with no node at the crossing.
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(2.0, 2.0));
let c = g.add_node(DVec2::new(0.0, 2.0));
let d = g.add_node(DVec2::new(2.0, 0.0));
g.add_road(a, b).unwrap();
g.add_road(c, d).unwrap();
let err = g.rebuild_topology().unwrap_err();
assert!(matches!(err, SubdivisionError::NonPlanarGraph(_)));
}
// ----------------------------------------------------------------------
// Tests #7 #9 (intersection topologies)
// ----------------------------------------------------------------------
#[test]
#[ignore = "milestone-0.2: pie-slice parcels around a cul-de-sac bulb"]
fn cul_de_sac() {
// A single road into a circular bulb. Pie-slice parcels tile
// the bulb. Requires curve / arc support beyond milestone 0.1.
}
#[test]
fn t_intersection() {
// D ---- F y=h ---->
// | |
// A | vertical road b--c bisecting top edge
// | |
// C ---- E y=0
// Three blocks: left, right, top.
// But actually a real T has two cells. Build: bottom edge plus
// a vertical road meeting it.
// Layout:
// H───G───F
// │ │ │
// │ M │
// │ │ │
// A───B───C
// with a vertical road B-M-G (M is intermediate). Two blocks.
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(100.0, 0.0));
let c = g.add_node(DVec2::new(200.0, 0.0));
let d = g.add_node(DVec2::new(200.0, 100.0));
let e = g.add_node(DVec2::new(100.0, 100.0));
let f = g.add_node(DVec2::new(0.0, 100.0));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap();
g.add_road(c, d).unwrap();
g.add_road(d, e).unwrap();
g.add_road(e, f).unwrap();
g.add_road(f, a).unwrap();
g.add_road(b, e).unwrap(); // T stem
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(parcels.len() > 4);
assert_invariants_i1_i3(&parcels);
}
#[test]
fn y_intersection() {
// Three roads at 120°, meeting at the origin, with a triangular
// outer boundary for the blocks to be bounded.
let mut g = RoadGraph::new();
let center = g.add_node(DVec2::new(0.0, 0.0));
let r = 100.0;
let third = std::f64::consts::TAU / 3.0;
let p1 = g.add_node(DVec2::new(r * 0.0_f64.cos(), r * 0.0_f64.sin()));
let p2 = g.add_node(DVec2::new(r * third.cos(), r * third.sin()));
let p3 = g.add_node(DVec2::new(r * (2.0 * third).cos(), r * (2.0 * third).sin()));
g.add_road(center, p1).unwrap();
g.add_road(center, p2).unwrap();
g.add_road(center, p3).unwrap();
g.add_road(p1, p2).unwrap();
g.add_road(p2, p3).unwrap();
g.add_road(p3, p1).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(!parcels.is_empty());
assert_invariants_i1_i3(&parcels);
}
// ----------------------------------------------------------------------
// Tests #10 #12 (size extremes / curvature)
// ----------------------------------------------------------------------
#[test]
fn tiny_block() {
// Perimeter < 4 * w_min. Should produce 0 parcels without
// panicking.
let g = rectangle_graph(5.0, 4.0);
let params = SubdivisionParams::default(); // min_frontage = 6
let parcels = subdivide_all(&g, &params).unwrap();
assert!(parcels.len() <= 1);
assert_invariants_i1_i3(&parcels);
}
#[test]
fn huge_block() {
let g = rectangle_graph(1000.0, 1000.0);
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
// Sanity: with frontage_width=20 and perimeter=4000, expect
// hundreds of parcels — but bounded.
assert!(parcels.len() > 50);
assert!(parcels.len() < 4000);
assert_invariants_i1_i3(&parcels);
}
#[test]
#[ignore = "milestone-0.2: depth-cap on tight road curvature"]
fn curved_road_high_curv() {
// Road radius < d_p — need to clamp depth to keep parcels from
// self-intersecting through the centerline.
}
// ----------------------------------------------------------------------
// Tests #13 #18 (edits)
// ----------------------------------------------------------------------
#[test]
fn road_edit_micro_move() {
// A 0.01 m move; with a true deform pipeline every parcel
// would survive as `Deformed`. Milestone 0.1 regenerates, but
// the post-edit set must still be valid.
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let some_node = g.road_endpoints().next().unwrap().1;
let pos = g.node_position(some_node).unwrap();
let edit = RoadEdit::MoveNode {
node: some_node,
to: pos + DVec2::new(0.01, 0.0),
};
let _outcome = apply_road_edit(&mut parcels, &mut g, edit, &params).unwrap();
assert!(!parcels.is_empty(), "post-edit set is non-empty");
assert_invariants_i1_i3(&parcels);
}
#[test]
fn road_edit_large_move() {
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let some_node = g.road_endpoints().next().unwrap().1;
let pos = g.node_position(some_node).unwrap();
let edit = RoadEdit::MoveNode {
node: some_node,
to: pos + DVec2::new(50.0, 0.0),
};
let _outcome = apply_road_edit(&mut parcels, &mut g, edit, &params).unwrap();
assert_invariants_i1_i3(&parcels);
}
#[test]
fn road_edit_inverse_restores() {
// Apply edit then its inverse — surviving parcels stay valid and
// drift by no more than the magnitude of the edit delta. The
// minimum-change deformation path (D14) skips parcels whose
// frontage was unaffected by the line change, so strict
// vertex-by-vertex restoration is not the contract; centroid-
// bounded drift is.
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let initial: std::collections::HashMap<_, DVec2> = parcels
.iter()
.map(|(id, p)| (id, p.polygon().centroid()))
.collect();
let some_node = g.road_endpoints().next().unwrap().1;
let pos = g.node_position(some_node).unwrap();
let delta = DVec2::new(0.5, 0.0);
let _ = apply_road_edit(
&mut parcels,
&mut g,
RoadEdit::MoveNode {
node: some_node,
to: pos + delta,
},
&params,
)
.unwrap();
let _ = apply_road_edit(
&mut parcels,
&mut g,
RoadEdit::MoveNode {
node: some_node,
to: pos,
},
&params,
)
.unwrap();
for (id, p) in parcels.iter() {
let Some(orig) = initial.get(&id) else {
continue;
};
let drift = (p.polygon().centroid() - *orig).length();
assert!(
drift <= delta.length() + 1e-6,
"centroid drift {drift} > edit delta {} for parcel {id:?}",
delta.length()
);
}
assert_invariants_i1_i3(&parcels);
}
#[test]
fn road_delete_condemns() {
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let road = g.road_endpoints().next().unwrap().0;
let n_on_road = parcels.parcels_on_road(road).count();
let outcome = apply_road_edit(
&mut parcels,
&mut g,
RoadEdit::DeleteSegment { road },
&params,
)
.unwrap();
assert!(!outcome.condemned.is_empty());
assert!(outcome.condemned.len() >= n_on_road);
}
#[test]
fn road_split_preserves() {
// Splitting a segment should preserve the parcels on it. Each
// pre-split parcel either stays (frontage rebinds to one of the
// two new roads) or is split into two parcels along the
// perpendicular through the split point.
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let pre_count = parcels.len();
// Pick the bottom road (longest, has many parcels) and split at
// x = 100, y = 0.
let bottom_road = g
.road_endpoints()
.find(|&(_, _, b)| {
let pos = g.node_position(b).unwrap();
pos.y.abs() < 1e-6 && pos.x > 100.0
})
.map(|(rid, _, _)| rid)
.unwrap();
let outcome = apply_road_edit(
&mut parcels,
&mut g,
RoadEdit::SplitSegment {
road: bottom_road,
at: DVec2::new(100.0, 0.0),
},
&params,
)
.unwrap();
// Most parcels should be deformed (frontage_road rebound, no
// geometric change). At most one is condemned (the one whose
// frontage spans the split point — and it gets two replacements).
assert!(
outcome.regenerated.is_empty(),
"split path must not trigger block regenerate; got {} regenerated",
outcome.regenerated.len()
);
assert!(
!outcome.deformed.is_empty(),
"split path should preserve at least some parcels"
);
// Net parcel count should be ≥ pre_count (a split can ADD
// parcels but never lose them outright).
assert!(parcels.len() >= pre_count - 1);
assert_invariants_i1_i3(&parcels);
}
#[test]
fn building_footprint_persists() {
// A stub building's `fits_in` returns true iff the parcel area
// stays above a threshold. After a tiny edit it stays above, so
// the building survives.
use road_parceling::{BuildingFitCheck, BuildingHandle, Parcel};
struct StubBuilding {
min_area: f64,
}
impl BuildingFitCheck for StubBuilding {
fn fits_in(&self, parcel: &Parcel) -> bool {
parcel.area() >= self.min_area
}
}
let mut g = rectangle_graph(200.0, 100.0);
let params = SubdivisionParams::default();
let mut parcels = subdivide_all(&g, &params).unwrap();
let pid = parcels.iter().map(|(id, _)| id).next().unwrap();
let attach_area = parcels.get(pid).unwrap().area();
parcels
.get_mut(pid)
.unwrap()
.attach_building(BuildingHandle::new(StubBuilding {
min_area: attach_area * 0.5,
}));
assert!(parcels.get(pid).unwrap().has_building());
let some_node = g.road_endpoints().next().unwrap().1;
let pos = g.node_position(some_node).unwrap();
let _ = apply_road_edit(
&mut parcels,
&mut g,
RoadEdit::MoveNode {
node: some_node,
to: pos + DVec2::new(0.1, 0.0),
},
&params,
)
.unwrap();
if let Some(p) = parcels.get(pid) {
assert!(p.has_building(), "building must persist on tiny edit");
}
}
// ----------------------------------------------------------------------
// Tests #19 #21 (graph oddities)
// ----------------------------------------------------------------------
#[test]
fn degenerate_isolated_node() {
let mut g = RoadGraph::new();
let _isolated = g.add_node(DVec2::new(50.0, 50.0));
// Plus a normal block for there to be something to subdivide.
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(100.0, 0.0));
let c = g.add_node(DVec2::new(100.0, 100.0));
let d = g.add_node(DVec2::new(0.0, 100.0));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap();
g.add_road(c, d).unwrap();
g.add_road(d, a).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(!parcels.is_empty());
assert_invariants_i1_i3(&parcels);
}
#[test]
fn disconnected_graph() {
// Two disjoint rectangles. Each must subdivide independently.
let mut g = RoadGraph::new();
// Component 1
let a = g.add_node(DVec2::new(0.0, 0.0));
let b = g.add_node(DVec2::new(100.0, 0.0));
let c = g.add_node(DVec2::new(100.0, 100.0));
let d = g.add_node(DVec2::new(0.0, 100.0));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap();
g.add_road(c, d).unwrap();
g.add_road(d, a).unwrap();
// Component 2
let e = g.add_node(DVec2::new(500.0, 500.0));
let f = g.add_node(DVec2::new(600.0, 500.0));
let h = g.add_node(DVec2::new(600.0, 600.0));
let i = g.add_node(DVec2::new(500.0, 600.0));
g.add_road(e, f).unwrap();
g.add_road(f, h).unwrap();
g.add_road(h, i).unwrap();
g.add_road(i, e).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(!parcels.is_empty());
// Roughly twice as many as a single block.
let single = subdivide_all(&rectangle_graph(100.0, 100.0), &params).unwrap();
assert!(parcels.len() >= 2 * single.len() - 4);
assert_invariants_i1_i3(&parcels);
}
#[test]
fn numerical_precision_stress() {
// Coords near 1e6 (well inside f64 mantissa). Spec says 1e20 but
// anything past ~1e15 loses meter-level precision; we test a
// city-scale-plus offset that f64 can still handle within
// tolerances.
let offset = 1.0e6;
let mut g = RoadGraph::new();
let a = g.add_node(DVec2::new(offset, offset));
let b = g.add_node(DVec2::new(offset + 200.0, offset));
let c = g.add_node(DVec2::new(offset + 200.0, offset + 100.0));
let d = g.add_node(DVec2::new(offset, offset + 100.0));
g.add_road(a, b).unwrap();
g.add_road(b, c).unwrap();
g.add_road(c, d).unwrap();
g.add_road(d, a).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
assert!(!parcels.is_empty());
assert_invariants_i1_i3(&parcels);
}
#[test]
fn y_intersection_no_overlaps() {
// Programmatic I3 check: no parcel's centroid is contained inside
// any *other* parcel's polygon.
let mut g = RoadGraph::new();
let center = g.add_node(DVec2::new(0.0, 0.0));
let r = 100.0;
let third = std::f64::consts::TAU / 3.0;
let p1 = g.add_node(DVec2::new(r * 0.0_f64.cos(), r * 0.0_f64.sin()));
let p2 = g.add_node(DVec2::new(r * third.cos(), r * third.sin()));
let p3 = g.add_node(DVec2::new(r * (2.0 * third).cos(), r * (2.0 * third).sin()));
g.add_road(center, p1).unwrap();
g.add_road(center, p2).unwrap();
g.add_road(center, p3).unwrap();
g.add_road(p1, p2).unwrap();
g.add_road(p2, p3).unwrap();
g.add_road(p3, p1).unwrap();
g.rebuild_topology().unwrap();
let params = SubdivisionParams::default();
let parcels = subdivide_all(&g, &params).unwrap();
let parcels_vec: Vec<_> = parcels.iter().collect();
for (i, (_, pi)) in parcels_vec.iter().enumerate() {
let centroid_i = pi.polygon().centroid();
for (j, (_, pj_pair)) in parcels_vec.iter().enumerate() {
if i == j {
continue;
}
let pj = pj_pair.polygon();
assert!(
!pj.contains(centroid_i),
"I3 violation: centroid of parcel {} ({:?}) is inside parcel {}",
i,
centroid_i,
j
);
}
}
}