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>
This commit is contained in:
Dane Sabo 2026-04-25 14:51:22 -04:00
parent 95b69eddac
commit 4b0eae9caf
6 changed files with 1446 additions and 25 deletions

Binary file not shown.

View File

@ -1023,6 +1023,161 @@ implementation cost:
% Future sessions land below this line as new \subsection entries. % Future sessions land below this line as new \subsection entries.
% -----------------------------------------------------------------
\subsection{2026-04-25 --- Session 3: Milestone 0.3 (I3 fix,
minimum-change deformation, SplitSegment preserve)}
\label{sec:session-3}
\paragraph{Goal of the session.}
Three things came out of looking at session-2's figures:
\begin{enumerate}[noitemsep]
\item The Y-intersection figure had visible parcel overlaps. A
programmatic test confirmed a real I3 violation — parcels from
adjacent block edges were converging into the same interior near
the acute outer corners.
\item The road-edit figure was \emph{too} eager to deform parcels.
When a road's bottom-right corner moved outward, both the bottom
road's parcels (whose road just got longer along the same line)
and the right road's parcels (whose road actually rotated) showed
up as deformed. The user's preference: only deform parcels on a
road whose direction \emph{actually changed}.
\item Pushing on milestone 0.3 work — \texttt{SplitSegment}
preserve, and as much of the acute-corner / curve story as we can
fit.
\end{enumerate}
\paragraph{Decisions locked in this session.}
\begin{decision}[D14, 2026-04-25 -- Minimum-change deformation
(``no-op preserve'')]
When a road edit doesn't change a road's \emph{line} — only its
endpoints shift along the same line, e.g.\ when one node moves
parallel to the road — a parcel whose frontage is still entirely on
the new segment is reported as ``Untouched''. It stays at its
absolute coordinates and isn't added to any
\texttt{EditOutcome} bucket. Trade-off: strict
\emph{vertex-by-vertex} inverse-restore is no longer guaranteed
(corner parcels that got displaced by an earlier edit aren't pulled
back to their original spot when the edit is reversed); the
\texttt{road\_edit\_inverse\_restores} test now checks centroid
drift bounded by the edit delta instead.
\end{decision}
\begin{decision}[D15, 2026-04-25 -- Bisector-clip at acute corners,
not obtuse]
Acute corners (interior angle $< 60^\circ$) get no corner parcel —
the rectangle/parallelogram construction would extend past the
wedge boundary. Instead, regular parcels along the two edges meeting
at an acute corner get bisector-clipped at that corner, so their
territories stay separated. Obtuse corners ($\geq 60^\circ$) keep
the milestone-0.2 corner parcel and need no bisector clip.
\end{decision}
\begin{decision}[D16, 2026-04-25 -- \texttt{SplitSegment} preserve
on 4-vertex parcels]
When a road is split, parcels whose frontage entirely on one side of
the split point have their \texttt{frontage\_road} rebound (no
geometric change, reported as Deformed). Parcels whose frontage
spans the split point are cut into two parcels along a perpendicular
through the split — only for the simple 4-vertex (rectangle) case;
more complex polygon shapes fall back to Condemn. Buildings stay
with the larger of the two halves.
\end{decision}
\paragraph{What landed.}
\begin{itemize}[leftmargin=*]
\item \texttt{tests/degenerate.rs::y\_intersection\_no\_overlaps} — a
programmatic centroid-in-other-polygon check. Caught the real
Y-intersection I3 violation; passes after the bisector-clip fix.
\item \texttt{subdivide.rs}: re-introduced
\texttt{corner\_bisector} and \texttt{clip\_with\_bisector}, called
conditionally for parcels at acute corners only.
\item \texttt{deform.rs}: new \texttt{DeformResult::Untouched}
branch and the line-unchanged check that returns it. Parcels in
\texttt{Untouched} state are left alone.
\item \texttt{deform.rs}: \texttt{split\_segment\_path} +
\texttt{rebind\_frontage\_road} helpers. \texttt{road\_split\_preserves}
is now active and passing.
\item Two acute-intersection tests
(\texttt{acute\_intersection\_15deg/5deg}) are now active and pass
the I1I3 invariant check; they don't yet exercise full
sliver-merge but no longer trigger panics or overlaps.
\end{itemize}
\paragraph{Deviations from spec.}
\subsubsection*{2026-04-25 --- Inverse-restore is centroid-bounded,
not vertex-exact}
What changed: \cref{sec:edit-handling}'s I7 (``Applying an edit and
then its inverse restores the original parcel set within
$\varepsilon_{\text{geom}}$ for all preserved parcels'') is satisfied
in spirit but not literally. With the minimum-change deformation
path of D14, parcels whose frontage line didn't change keep their
absolute coordinates; an earlier edit might have left a corner
parcel at, say, $(0.5, 0)$ instead of its original $(0, 0)$, and the
inverse edit will not pull it back. The
\texttt{road\_edit\_inverse\_restores} test instead asserts that
each surviving parcel's \emph{centroid} drifts no more than the
magnitude of the edit delta itself.
Why: ``minimal cost'' deformation is what the user explicitly asked
for. Strict vertex-exact inverse-restore is incompatible with that
goal — it forces every parcel touching an incident road to be
re-projected on every edit, even when the parcel didn't really need
to move. Bounded drift is the right trade-off.
Affected sections: \cref{sec:edit-handling} (I7 reading clarified).
\subsubsection*{2026-04-25 --- \texttt{Untouched} is not a public
\texttt{EditOutcome} bucket}
What changed: Internally the deformation pipeline distinguishes four
results — Deformed, Untouched, Regenerate, Condemned — but the
public \texttt{EditOutcome} struct only exposes the three buckets
that carry \texttt{ParcelId}s of parcels that materially changed.
Untouched parcels simply don't appear in the result. Callers can
still infer them: any parcel-id that existed before the edit and
isn't in any of the four buckets after is implicitly Untouched.
Why: surfacing an explicit ``Untouched'' bucket would mean every
edit on a 10\,000-parcel city walks 10\,000 ids back to the caller,
defeating the point of minimum-change. We let absence carry meaning.
Affected sections: \cref{sec:edit-handling} (§4.2 outcome bucket
list now reads as ``parcels that materially changed go into one of
these four buckets''; absence implies no change).
\paragraph{Test status.}
24 unit tests, 20 integration tests (was 16 in session 2), 1 doc
test. Two named tests still \texttt{\#[ignore]}-d:
\texttt{cul\_de\_sac} and \texttt{curved\_road\_high\_curv} — both
need real curved-road handling that's the milestone-0.4 headline.
\paragraph{What's next --- milestone 0.4 queue.}
\begin{enumerate}[noitemsep]
\item True sliver-merge for acute corners: instead of
bisector-clipping into thin trapezoids, merge the would-be sliver
with its longer-frontage neighbor. Removes the visual mess at
acute corners without changing the I3 invariant.
\item Curved-road support: discretize curves into polylines with
variable depth caps based on local radius. Unlocks
\texttt{curved\_road\_high\_curv}.
\item Pie-slice parcels for cul-de-sac bulbs.
\item OBB regularization (\cref{sec:regularization}).
\item Spatial index (\texttt{rstar}) for affected-parcel lookup
(\cref{sec:open}'s Q2) — once the linear scan starts to bite at
scale.
\item Q4: regeneration biased to preserve building footprints.
\item ``Fill-the-corner-after-edit'' regenerate: when a corner
parcel ends up disconnected from its road after a node move (e.g.,
the gap visible at the bottom-right of \cref{fig:edit-after}),
regenerate just that corner to close the gap.
\end{enumerate}
% ----------------------------------------------------------------- % -----------------------------------------------------------------
\subsection{2026-04-25 --- Session 2: Milestone 0.2 (corner parcels, \subsection{2026-04-25 --- Session 2: Milestone 0.2 (corner parcels,
sticky back edges, preserve-on-deform)} sticky back edges, preserve-on-deform)}

1007
road_parceling/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -106,9 +106,20 @@ pub fn apply_road_edit(
RoadEdit::DeleteSegment { road } => { RoadEdit::DeleteSegment { road } => {
delete_segment_path(parcels, road, graph, params, &mut outcome)?; delete_segment_path(parcels, road, graph, params, &mut outcome)?;
} }
RoadEdit::SplitSegment { .. } | RoadEdit::InsertSegment { .. } => { RoadEdit::SplitSegment { road, at } => {
// Split/Insert preservation is milestone-0.3 work — fall split_segment_path(
// back to a regenerate of every affected face. parcels,
&graph_before,
graph,
road,
at,
params,
&mut outcome,
)?;
}
RoadEdit::InsertSegment { .. } => {
// Insert preservation is milestone-0.3+ work — for now,
// regenerate every face touched by the new segment.
let affected = roads_affected_by_old_id(&graph_before, edit); let affected = roads_affected_by_old_id(&graph_before, edit);
regenerate_path(parcels, graph, params, &affected, &mut outcome)?; regenerate_path(parcels, graph, params, &affected, &mut outcome)?;
} }
@ -270,8 +281,7 @@ fn deform_parcel_after_road_move(
let len_after = road_vec_after.length(); let len_after = road_vec_after.length();
if len_after > EPS_GEOM { if len_after > EPS_GEOM {
let dir_after = road_vec_after / len_after; let dir_after = road_vec_after / len_after;
let parallel = let parallel = (dir_before.x * dir_after.y - dir_before.y * dir_after.x).abs() < 1e-6;
(dir_before.x * dir_after.y - dir_before.y * dir_after.x).abs() < 1e-6;
if parallel { if parallel {
let new_normal = DVec2::new(-dir_after.y, dir_after.x); let new_normal = DVec2::new(-dir_after.y, dir_after.x);
let perp_dist = (p_a - pa_after).dot(new_normal).abs(); let perp_dist = (p_a - pa_after).dot(new_normal).abs();
@ -379,6 +389,185 @@ fn find_frontage_index(polygon: &Polygon, road_a: DVec2, road_b: DVec2) -> Optio
best.map(|(i, _)| i) best.map(|(i, _)| i)
} }
// -----------------------------------------------------------------
// Split-segment preserve path
// -----------------------------------------------------------------
fn split_segment_path(
parcels: &mut ParcelSet,
graph_before: &RoadGraph,
graph_after: &RoadGraph,
old_road: RoadId,
split_point: DVec2,
_params: &SubdivisionParams,
outcome: &mut EditOutcome,
) -> Result<(), ParcelError> {
let Some((a, b)) = graph_before.road_nodes(old_road) else {
return Err(ParcelError::UnknownEntity);
};
let pos_a = graph_before
.node_position(a)
.ok_or(ParcelError::UnknownEntity)?;
let pos_b = graph_before
.node_position(b)
.ok_or(ParcelError::UnknownEntity)?;
// Find the new node m in graph_after at split_point and the two
// new roads it splits the old road into.
let mut new_node: Option<NodeId> = None;
for (nid, node) in &graph_after.nodes {
if (node.pos - split_point).length() < 0.01 {
new_node = Some(nid);
break;
}
}
let m = new_node.ok_or(ParcelError::InconsistentEdit("split node not found".into()))?;
let r1 = graph_after
.find_road_between(a, m)
.ok_or(ParcelError::InconsistentEdit(
"split road r1 missing".into(),
))?;
let r2 = graph_after
.find_road_between(m, b)
.ok_or(ParcelError::InconsistentEdit(
"split road r2 missing".into(),
))?;
let road_vec = pos_b - pos_a;
let road_len_sq = road_vec.length_squared();
if road_len_sq < EPS_GEOM * EPS_GEOM {
return Err(ParcelError::InconsistentEdit("zero-length old road".into()));
}
let t_split = (split_point - pos_a).dot(road_vec) / road_len_sq;
let parcel_ids: Vec<ParcelId> = parcels.parcels_on_road(old_road).collect();
for pid in parcel_ids {
let Some(parcel) = parcels.parcels.get(pid) else {
continue;
};
let n = parcel.polygon.len();
let fi = parcel.frontage_edge_index;
let v = parcel.polygon.vertices();
let p_a = v[fi];
let p_b = v[(fi + 1) % n];
let t_a = (p_a - pos_a).dot(road_vec) / road_len_sq;
let t_b = (p_b - pos_a).dot(road_vec) / road_len_sq;
let (t_lo, t_hi) = if t_a < t_b { (t_a, t_b) } else { (t_b, t_a) };
if t_hi <= t_split + EPS_GEOM {
// Frontage entirely on the a-side → r1.
// No geometric change; just rebind frontage_road.
rebind_frontage_road(parcels, pid, r1);
outcome.deformed.push(pid);
} else if t_lo >= t_split - EPS_GEOM {
// Frontage entirely on the b-side → r2.
rebind_frontage_road(parcels, pid, r2);
outcome.deformed.push(pid);
} else {
// Frontage spans the split → cut into two.
// Only handle the simple 4-vertex case; fall through to
// condemn for higher-vertex parcels.
if n != 4 {
drop_parcel(parcels, pid);
outcome.condemned.push(pid);
continue;
}
let q_b = v[(fi + 2) % n];
let q_a = v[(fi + 3) % n];
let frontage_vec = p_b - p_a;
let len_sq = frontage_vec.length_squared();
if len_sq < EPS_GEOM * EPS_GEOM {
drop_parcel(parcels, pid);
outcome.condemned.push(pid);
continue;
}
let t_local = (split_point - p_a).dot(frontage_vec) / len_sq;
if t_local <= EPS_GEOM || t_local >= 1.0 - EPS_GEOM {
// Split coincident with an endpoint — just rebind.
let target = if t_local <= 0.5 { r1 } else { r2 };
rebind_frontage_road(parcels, pid, target);
outcome.deformed.push(pid);
continue;
}
let q_star = q_a + (q_b - q_a) * t_local;
// The "a side" of the split goes to r1, "b side" to r2.
// p_a is closer to a if t_a < t_b. (Polygon CCW means
// frontage goes from p_a to p_b along the road, but
// direction can be either way relative to road A→B.)
let (a_side_road, b_side_road) = if t_a < t_b { (r1, r2) } else { (r2, r1) };
// Build new polygons.
let poly_a = Polygon::new(vec![p_a, split_point, q_star, q_a]);
let poly_b = Polygon::new(vec![split_point, p_b, q_b, q_star]);
let (Ok(poly_a), Ok(poly_b)) = (poly_a, poly_b) else {
drop_parcel(parcels, pid);
outcome.condemned.push(pid);
continue;
};
let block = parcel.block;
let area_a = poly_a.area();
let area_b = poly_b.area();
let parcel_owned = parcels
.parcels
.remove(pid)
.ok_or(ParcelError::UnknownEntity)?;
if let Some(v) = parcels.by_block.get_mut(&parcel_owned.block) {
v.retain(|&x| x != pid);
}
if let Some(v) = parcels.by_road.get_mut(&parcel_owned.frontage_road) {
v.retain(|&x| x != pid);
}
outcome.condemned.push(pid);
// Building stays with the larger half.
let mut building_a: Option<super::BuildingHandle> = None;
let mut building_b: Option<super::BuildingHandle> = None;
if area_a >= area_b {
building_a = parcel_owned.building;
} else {
building_b = parcel_owned.building;
}
let edge_kinds = crate::parcel::classify::classify_edges(4, 0);
let new_a = Parcel {
polygon: poly_a,
edge_kinds: edge_kinds.clone(),
frontage_road: a_side_road,
frontage_edge_index: 0,
block,
building: building_a,
};
let new_b = Parcel {
polygon: poly_b,
edge_kinds,
frontage_road: b_side_road,
frontage_edge_index: 0,
block,
building: building_b,
};
let new_a_id = parcels.insert(new_a);
let new_b_id = parcels.insert(new_b);
outcome.created.push(new_a_id);
outcome.created.push(new_b_id);
}
}
Ok(())
}
fn rebind_frontage_road(parcels: &mut ParcelSet, pid: ParcelId, new_road: RoadId) {
let old_road = if let Some(p) = parcels.parcels.get(pid) {
p.frontage_road
} else {
return;
};
if old_road == new_road {
return;
}
if let Some(p) = parcels.parcels.get_mut(pid) {
p.frontage_road = new_road;
}
if let Some(v) = parcels.by_road.get_mut(&old_road) {
v.retain(|&x| x != pid);
}
parcels.by_road.entry(new_road).or_default().push(pid);
}
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Delete-segment path: condemn + regenerate-block // Delete-segment path: condemn + regenerate-block
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View File

@ -184,8 +184,7 @@ pub(crate) fn subdivide_block(
let real_corner: Vec<bool> = (0..n) let real_corner: Vec<bool> = (0..n)
.map(|i| { .map(|i| {
is_real_corner(graph, block, i, params) is_real_corner(graph, block, i, params) && interior_angles[i] > 60.0_f64.to_radians()
&& interior_angles[i] > 60.0_f64.to_radians()
}) })
.collect(); .collect();
// Acute corners: real corners (degree-3 or sharp degree-2) with // Acute corners: real corners (degree-3 or sharp degree-2) with
@ -194,8 +193,7 @@ pub(crate) fn subdivide_block(
// separated. // separated.
let acute_corner: Vec<bool> = (0..n) let acute_corner: Vec<bool> = (0..n)
.map(|i| { .map(|i| {
is_real_corner(graph, block, i, params) is_real_corner(graph, block, i, params) && interior_angles[i] <= 60.0_f64.to_radians()
&& interior_angles[i] <= 60.0_f64.to_radians()
}) })
.collect(); .collect();
@ -405,9 +403,7 @@ pub(crate) fn subdivide_block(
let mut working = raw_poly; let mut working = raw_poly;
if acute_corner[i] { if acute_corner[i] {
if let Some(b) = corner_bisector(&verts, i) { if let Some(b) = corner_bisector(&verts, i) {
if let Some(clipped) = if let Some(clipped) = clip_with_bisector(&working, verts[i], b, frontage_mid) {
clip_with_bisector(&working, verts[i], b, frontage_mid)
{
working = clipped; working = clipped;
} else { } else {
continue; continue;

View File

@ -55,17 +55,45 @@ fn assert_invariants_i1_i3(parcels: &road_parceling::ParcelSet) {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
#[test] #[test]
#[ignore = "milestone-0.2: sliver-merge logic for acute corners"]
fn acute_intersection_15deg() { fn acute_intersection_15deg() {
// Two roads sharing a node meet at 15°. Sliver-corner parcels // Two roads sharing a node meet at 15°. With the bisector-clip
// must merge with neighbors; I1I3 must hold. // 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] #[test]
#[ignore = "milestone-0.2: sliver-merge or typed error for knife-edge angles"]
fn acute_intersection_5deg() { fn acute_intersection_5deg() {
// Knife-edge angle. Library must not panic; either typed error // Knife-edge 5° angle. Library must not panic.
// or valid output is acceptable. 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] #[test]
@ -356,9 +384,53 @@ fn road_delete_condemns() {
} }
#[test] #[test]
#[ignore = "milestone-0.2: split-segment must preserve, not regenerate"]
fn road_split_preserves() { fn road_split_preserves() {
// Splitting a segment should preserve the parcels on it. // 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] #[test]
@ -507,17 +579,19 @@ fn y_intersection_no_overlaps() {
let parcels = subdivide_all(&g, &params).unwrap(); let parcels = subdivide_all(&g, &params).unwrap();
let parcels_vec: Vec<_> = parcels.iter().collect(); let parcels_vec: Vec<_> = parcels.iter().collect();
for i in 0..parcels_vec.len() { for (i, (_, pi)) in parcels_vec.iter().enumerate() {
let centroid_i = parcels_vec[i].1.polygon().centroid(); let centroid_i = pi.polygon().centroid();
for j in 0..parcels_vec.len() { for (j, (_, pj_pair)) in parcels_vec.iter().enumerate() {
if i == j { if i == j {
continue; continue;
} }
let pj = parcels_vec[j].1.polygon(); let pj = pj_pair.polygon();
assert!( assert!(
!pj.contains(centroid_i), !pj.contains(centroid_i),
"I3 violation: centroid of parcel {} ({:?}) is inside parcel {}", "I3 violation: centroid of parcel {} ({:?}) is inside parcel {}",
i, centroid_i, j i,
centroid_i,
j
); );
} }
} }