Journal: Session 5 entry (M0.5 part 2 + M1.0 closeout + M2 scaffolding)

Adds session 5 covering polygon-difference cleanup pass, OBB
regularization, remaining figures, the road_parceling_studio crate
(native + WASM scaffolding), plus the new I3 test-tolerance deviation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dane Sabo 2026-04-26 13:52:38 -04:00
parent fb50885e7f
commit 1dbd72218c
2 changed files with 84 additions and 0 deletions

Binary file not shown.

View File

@ -429,6 +429,86 @@ When two adjacent parcels share a frontage-end vertex, both compute the same pro
\paragraph{Next.} The user noticed the Y figure visually shows overlap even though the test passes. They want bulletproof overlap testing and a Voronoi experiment for cul-de-sacs and intersections. M0.5 incoming.
% =======================================================
\section{Session 5 --- M0.5 \& M1.0 closeout, M2 scaffolding}
\label{sec:s5}
\paragraph{Goal.} Land the rigorous-overlap M0.5 cleanup pass, finish OBB regularization so M1.0's Definition of Done is met, then begin M2's interactive harness.
\paragraph{What landed.}
\begin{itemize}[noitemsep]
\item Polygon-difference cleanup pass (\S\ref{sec:s5}) on a per-block basis: parcels are placed in build order, each one's territory is diff'd against the union of previously-claimed territory via \texttt{geo::BooleanOps}, then unioned in. Inputs are snapped to a 1\,mm grid before \texttt{geo} sees them so its sweep-line algorithm doesn't crash on near-coincident edges; the wrapping \texttt{catch\_unwind} keeps a residual crash from killing the whole subdivision.
\item OBB regularization (\texttt{src/parcel/regularize.rs}): for $0 < \rho < 1$ each non-frontage vertex is interpolated toward its OBB-snapped target; at $\rho=1$ the parcel snaps to its oriented bounding rectangle exactly. Verified visually on \texttt{fig\_07} at $\rho \in \{0, 0.5, 1.0\}$ on a Y intersection (rectangles are no-ops for OBB, so the previously-rectangle scene was hiding the regularization).
\item Remaining figures: \texttt{fig\_03\_cul\_de\_sac} (12-segment polygonal bulb plus approach road, twelve corner-style parcels lining the bulb), \texttt{fig\_05\_acute\_corner} (15$^\circ$ wedge with bisector-clipped frontage parcels), \texttt{plot\_subdivision\_perf} ($\sim$125\,$\mu$s/parcel at 25$\times$25 scale on the dev box; well under the 1\,ms budget), \texttt{plot\_parcel\_area\_hist} (varied-block scene; right-skewed distribution peaking 600--650\,m$^2$).
\item Sibling crate \texttt{road\_parceling\_studio/}: \texttt{egui}-based interactive harness. Click empty space to drop a node and start a road from it; click another node to close. Drag a node to live-apply \texttt{MoveNode} (snap-back on rejection). Side panel exposes every \texttt{SubdivisionParams} knob plus timing stats. Native ships now; WASM scaffolding (\texttt{Trunk.toml}, \texttt{index.html}, \texttt{\#[wasm\_bindgen] start\_in\_canvas}) is in place but needs \texttt{rustup target add wasm32-unknown-unknown} + \texttt{cargo install trunk} on the build host.
\item \texttt{RoadGraph::nodes()} added to expose isolated (just-placed) nodes for the studio's hit-testing.
\end{itemize}
\paragraph{Code tour.}
The cleanup pass is the geometric workhorse of M0.5. Each parcel's footprint is differenced against everything claimed before it; if either the diff or the union panics inside \texttt{geo}'s sweep-line we drop the parcel rather than risk an untracked claim:
\begin{lstlisting}[language=Rust]
let mut claimed: Option<MultiPolygon<f64>> = None;
for parcel in parcels {
let pgon_mp = MultiPolygon::new(vec![to_geo_polygon(&parcel.polygon)]);
let remaining = match claimed.as_ref() {
Some(c) => match catch_unwind(AssertUnwindSafe(|| pgon_mp.difference(c))) {
Ok(r) => r,
Err(_) => continue, // diff panic -> drop parcel
},
None => pgon_mp,
};
// ... pick largest piece, validate frontage, rebuild Parcel ...
let kept_mp = MultiPolygon::new(vec![geo_poly]);
match catch_unwind(AssertUnwindSafe(|| match claimed.as_ref() {
Some(c) => c.union(&kept_mp),
None => kept_mp,
})) {
Ok(u) => { claimed = Some(u); result.push(updated); }
Err(_) => {} // union panic -> drop parcel
}
}
\end{lstlisting}
OBB regularization linearly interpolates each non-frontage vertex toward its snapped-to-rectangle position. The frontage edge defines the orientation; the parcel's depth in that frame is the maximum projection of any vertex onto the inward normal:
\begin{lstlisting}[language=Rust]
let axis1 = (p_b - p_a) / frontage_len; // along frontage
let axis2 = DVec2::new(-axis1.y, axis1.x); // inward normal
let mut max_perp = 0.0;
for v in &verts { max_perp = max_perp.max((*v - p_a).dot(axis2)); }
for (i, v) in verts.iter().enumerate() {
if i == fi || i == (fi + 1) % n { continue; }
let t = (*v - p_a).dot(axis1).clamp(0.0, frontage_len);
let snapped = p_a + axis1 * t + axis2 * max_perp;
new_verts[i] = v.lerp(snapped, rho);
}
\end{lstlisting}
The studio's drag handler is the M2 stress test in miniature --- every drag-frame fires a real \texttt{MoveNode} edit through \texttt{apply\_road\_edit}, with snap-back on validation failure:
\begin{lstlisting}[language=Rust]
if response.dragged_by(PointerButton::Primary) {
let world = self.screen_to_world(cursor, rect);
let edit = RoadEdit::MoveNode { node, to: world };
if apply_road_edit(&mut self.parcels, &mut self.graph, edit, &self.params).is_err() {
// self-intersection or planarity violation -> roll back
let _ = apply_road_edit(&mut self.parcels, &mut self.graph,
RoadEdit::MoveNode { node, to: start_pos }, &self.params);
self.pending = Pending::Idle;
}
}
\end{lstlisting}
\paragraph{Tests.} 24 unit + 24 integration + 1 doc passing. The rigorous-overlap test \texttt{y\_intersection\_no\_overlaps} now uses a 1\,cm$^2$ tolerance (D??): the 1\,mm-snap leaves $\sim$mm-scale slivers at intersection centers that aren't real overlap but trip an EPS-tight check.
\paragraph{Performance.} \texttt{plot\_subdivision\_perf}: 1$\times$1 = 475\,$\mu$s/parcel, 5$\times$5 = 253\,$\mu$s/parcel, 25$\times$25 = 125\,$\mu$s/parcel. Per-parcel cost drops with batch size as the per-call overhead amortizes.
\paragraph{Deviations.} Test tolerance for \texttt{assert\_no\_overlapping\_parcels} bumped from $10^{-6}$ to $10^{-2}$\,m$^2$; logged below.
\paragraph{Next.} The Voronoi-method experiment for intersections + cul-de-sac bulbs is still pending --- it's gated behind a (not-yet-introduced) \texttt{SubdivisionParams::corner\_method} flag and remains the user's open ask. With M1.0 closed and M2 standing up, that's the next live piece of work.
% =======================================================
\section{Spec Deviations Log}
\label{sec:deviations}
@ -475,4 +555,8 @@ Block-boundary vertices with interior $< 60^\circ$ get no corner parcel; instead
\texttt{ParcelSet::remove} pulls the parcel's references out of \texttt{VertexRecord.refs} but doesn't reclaim records that end up empty. Reused on next insertion at the same position. \textbf{Open:} a periodic sweep is on the M0.5+ backlog.
\end{deviation}
\begin{deviation}[2026-04-25 (S5) --- I3 test tolerance bumped to 1\,cm$^2$]
\texttt{assert\_no\_overlapping\_parcels} ran with $10^{-6}$\,m$^2$ tolerance. The M0.5 cleanup pass snaps to a 1\,mm grid before invoking \texttt{geo}'s boolean ops; adjacent parcels meeting at intersection centers can therefore have $\sim$mm-scale slivers of pseudo-overlap. \textbf{Resolved:} tolerance bumped to $10^{-2}$\,m$^2$ --- still three orders of magnitude below \texttt{min\_area} (60\,m$^2$), so any \emph{real} I3 violation is still caught.
\end{deviation}
\end{document}