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:
parent
95b69eddac
commit
4b0eae9caf
BIN
journal.pdf
BIN
journal.pdf
Binary file not shown.
155
journal.tex
155
journal.tex
@ -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 I1–I3 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
1007
road_parceling/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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; I1–I3 must hold.
|
// at acute corners (milestone 0.3), no proper sliver-merge yet,
|
||||||
|
// but I1–I3 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, ¶ms).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 I1–I3.
|
||||||
|
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, ¶ms) {
|
||||||
|
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, ¶ms).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),
|
||||||
|
},
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.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, ¶ms).unwrap();
|
let parcels = subdivide_all(&g, ¶ms).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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user