design.tex carries the contract — invariants, algorithms, public API, roadmap, the canonical D1..D19 decision index, and a new System Walkthrough (§17) that derives the geometry from first principles for a graduate-engineering reader: vector rotations and the inward-normal formula, the shoelace signed area, segment intersection via Cramer, Sutherland–Hodgman half-plane clipping, the DCEL next-pointer rule with worked square example, per-edge depth-cap ray-cast, corner-parcel construction in two flavors, frontage walk, the shared-vertex registry, propose-then-apply deform, and end-to-end traces for both subdivide_all and apply_road_edit(MoveNode). journal.tex carries the live record — Self-Decisions Checklist at the top, sessions 1-4 rewritten condensed with explanatory Rust snippets, and a Spec Deviations Log indexed chronologically. Makefile builds both PDFs (`make all`, `make design`, `make journal`, `make watch-design`, `make watch-journal`, `make figs`). Old single-file journal content lives in git history per user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
479 lines
30 KiB
TeX
479 lines
30 KiB
TeX
\documentclass[11pt,letterpaper]{article}
|
|
|
|
% ---- Packages ----
|
|
\usepackage[margin=1in]{geometry}
|
|
\usepackage{amsmath,amssymb,amsthm}
|
|
\usepackage{mathtools}
|
|
\usepackage{graphicx}
|
|
\usepackage{booktabs}
|
|
\usepackage{longtable}
|
|
\usepackage{enumitem}
|
|
\usepackage{listings}
|
|
\usepackage{xcolor}
|
|
\usepackage{hyperref}
|
|
\usepackage{cleveref}
|
|
\usepackage{fancyhdr}
|
|
\usepackage{titlesec}
|
|
\usepackage{tcolorbox}
|
|
\tcbuselibrary{breakable, skins}
|
|
|
|
% ---- Code listing style for Rust ----
|
|
\definecolor{rustbg}{RGB}{248,248,248}
|
|
\definecolor{rustkw}{RGB}{175,0,75}
|
|
\definecolor{ruststr}{RGB}{0,128,0}
|
|
\definecolor{rustcom}{RGB}{120,120,120}
|
|
\definecolor{rusttype}{RGB}{0,75,150}
|
|
|
|
\lstdefinelanguage{Rust}{
|
|
keywords={fn,let,mut,pub,struct,enum,impl,trait,use,mod,as,async,await,return,if,else,match,for,while,loop,break,continue,in,where,move,ref,self,Self,crate,super,extern,unsafe,const,static,type,dyn,box},
|
|
keywordstyle=\color{rustkw}\bfseries,
|
|
ndkeywords={Vec,Result,Option,Some,None,Ok,Err,String,bool,u8,u16,u32,u64,usize,i8,i16,i32,i64,isize,f32,f64,DVec2,VertexId,ParcelId,RoadId,NodeId,HalfEdgeId,FaceId,Polygon,Parcel,ParcelSet,RoadGraph,Block,EdgeKind,SubdivisionParams,RoadEdit,EditOutcome,SlotMap,HashMap},
|
|
ndkeywordstyle=\color{rusttype}\bfseries,
|
|
sensitive=true,
|
|
comment=[l]{//},
|
|
morecomment=[s]{/*}{*/},
|
|
commentstyle=\color{rustcom}\itshape,
|
|
string=[b]",
|
|
stringstyle=\color{ruststr},
|
|
morestring=[b]'
|
|
}
|
|
|
|
\lstset{
|
|
language=Rust,
|
|
basicstyle=\ttfamily\small,
|
|
backgroundcolor=\color{rustbg},
|
|
frame=single,
|
|
framesep=4pt,
|
|
rulecolor=\color{gray!40},
|
|
numbers=left,
|
|
numberstyle=\tiny\color{gray},
|
|
numbersep=8pt,
|
|
showstringspaces=false,
|
|
breaklines=true,
|
|
breakatwhitespace=true,
|
|
tabsize=4,
|
|
captionpos=b,
|
|
}
|
|
|
|
\newtcolorbox{checklistbox}[1][Self-Decisions Checklist]{
|
|
colback=teal!4,
|
|
colframe=teal!50!black,
|
|
fonttitle=\bfseries,
|
|
title={#1},
|
|
breakable,
|
|
}
|
|
|
|
\newtcolorbox{deviation}[1][Spec Deviation]{
|
|
colback=red!4,
|
|
colframe=red!60!black,
|
|
fonttitle=\bfseries,
|
|
title={#1},
|
|
breakable,
|
|
}
|
|
|
|
% ---- Header / footer ----
|
|
\pagestyle{fancy}
|
|
\fancyhf{}
|
|
\fancyhead[L]{\small Road Parceling System}
|
|
\fancyhead[R]{\small Implementation Journal}
|
|
\fancyfoot[C]{\thepage}
|
|
|
|
% ---- Title block ----
|
|
\title{\textbf{Road Parceling System} \\ \large Implementation Journal}
|
|
\author{Dane Sabo \\ {\small (sessions logged with Claude Code)}}
|
|
\date{Started 2026-04-25}
|
|
|
|
\hypersetup{
|
|
colorlinks=true,
|
|
linkcolor=blue!50!black,
|
|
urlcolor=blue!50!black,
|
|
citecolor=blue!50!black,
|
|
pdftitle={Road Parceling System --- Implementation Journal},
|
|
pdfauthor={Dane Sabo}
|
|
}
|
|
|
|
\begin{document}
|
|
|
|
\maketitle
|
|
\thispagestyle{empty}
|
|
|
|
\begin{abstract}
|
|
\noindent
|
|
This is the running record of work on the road parceling system: one
|
|
chapter per session, with prose narrative and Rust snippets that walk
|
|
through how each session's code actually works. Decisions referenced
|
|
by D-number live canonically in \texttt{design.tex}; this document
|
|
references them but doesn't restate. Spec deviations are logged here
|
|
with one-liners; the resolution (= updating \texttt{design.tex})
|
|
happens out-of-band. Verbose change rationale lives in commit
|
|
messages. The intent is that you can read this end-to-end and grok
|
|
the system as it evolved.
|
|
\end{abstract}
|
|
|
|
\tableofcontents
|
|
\newpage
|
|
|
|
% =======================================================
|
|
\section*{Self-Decisions Checklist}
|
|
\label{sec:checklist}
|
|
\addcontentsline{toc}{section}{Self-Decisions Checklist}
|
|
|
|
When the agent makes a call without an explicit human verdict, it
|
|
goes here for review. Items are crossed off as they're confirmed or
|
|
overruled.
|
|
|
|
\begin{checklistbox}[Open self-decisions awaiting review]
|
|
\begin{itemize}[leftmargin=2em]
|
|
\item \textbf{(Session 5+)} M2 test harness platform = \texttt{egui} (immediate-mode UI), Rust crate \texttt{road\_parceling\_studio/}, builds for both native and WASM. Reasoning: keeps everything in Rust; \texttt{egui} more productive for tooling than \texttt{bevy}; WASM target gets you a clickable browser tab.
|
|
\item \textbf{(Session 5+)} Voronoi-based subdivision experiment is gated behind a \texttt{SubdivisionParams::corner\_method} enum, not a hard switch. Lets us A/B compare against the rectangle method without ripping the existing code out.
|
|
\item \textbf{(Session 5+)} Code walkthrough lives in \texttt{design.tex} \S17, not in the journal. Tutorial-flavored, math from first principles, cross-references to source files. The journal references it; readers wanting to grok the geometry go there.
|
|
\item \textbf{(Session 5+)} Inline rustdoc gets a real pass during the doc work --- module-level intros and short example snippets where they help. \texttt{cargo doc} output is the API-level documentation; gitea pages or similar can host it later.
|
|
\item \textbf{(Session 5+)} fig\_06b's ``corner is disconnected from the new road'' visual gap is left as-is; the upcoming polygon-difference cleanup pass should fix it naturally. If it doesn't, log as a deviation.
|
|
\item \textbf{(Session 5)} Doc restructure: fresh \texttt{journal.tex} with sessions rewritten condensed; old content lives in git history rather than as an archived file. (User confirmed.)
|
|
\end{itemize}
|
|
\end{checklistbox}
|
|
|
|
% =======================================================
|
|
\section{Session 1 --- M0.1: Rectangle end-to-end}
|
|
\label{sec:s1}
|
|
\textit{2026-04-25}
|
|
|
|
\paragraph{Goal.} The Claude Code Contract (\texttt{design.tex} \S13) is unambiguous: get a single rectangular block working end-to-end with tests and an SVG figure before adding complexity. By session close: public API of \texttt{design.tex} \S7 compiles, \texttt{fig\_01\_grid\_block} is generated by the crate, all tooling gates green, every named test in \texttt{design.tex} \S6 either passes or has an explicit milestone-0.2 \texttt{\#[ignore]} marker.
|
|
|
|
\paragraph{What landed.} The \texttt{road\_parceling/} crate ships every module from the architecture spec: \texttt{geometry/} (polygon validation, half-plane clipping, inward offsetting), \texttt{network/} (DCEL graph, planar-graph validation, face extraction), \texttt{parcel/} (subdivide, classify, regularize stub, deform regenerate-only), and feature-gated \texttt{viz/} (SVG renderer + figure generator). $\sim$2{,}500 lines of library code plus $\sim$400 of integration tests.
|
|
|
|
\paragraph{Tooling.} \texttt{cargo build/clippy/fmt/doc/test} all clean. 22 unit + 14 integration + 1 doc test passing; 7 named tests \texttt{\#[ignore]}-d for milestone-0.2 features.
|
|
|
|
\paragraph{Decisions.} D5 (BuildingHandle wraps a trait object), D6 (DCEL next/prev rule explicit form, derived in \texttt{design.tex} \S17.3), D7 (\texttt{Polygon::new\_relaxed} for block boundaries), D8 (clippy::all only, no pedantic).
|
|
|
|
\paragraph{Code tour: how the rectangle gets subdivided.}
|
|
|
|
The pipeline runs \texttt{subdivide\_all(\&graph, \¶ms)}. After topology rebuild and block extraction, each block enters \texttt{subdivide\_block}. For the rectangle, there's one block: a $200\times100$ polygon with 4 corners (all interior 90°) and 4 edges.
|
|
|
|
The frontage walk along each block edge looked like this in M0.1:
|
|
|
|
\begin{lstlisting}[caption={Frontage walk per edge (M0.1 simplified).}]
|
|
let mut rng = rng_for_road(params.seed, road);
|
|
let max_depth = depth_caps[i].min(params.depth + params.depth_variance.abs() + EPS_GEOM);
|
|
|
|
let splits = split_positions(edge_len, params, &mut rng);
|
|
|
|
for k in 0..splits.len() - 1 {
|
|
let p_a = p + edge_dir * splits[k];
|
|
let p_b = p + edge_dir * splits[k + 1];
|
|
let depth = (params.depth + jitter).max(params.min_frontage)
|
|
.min(max_depth);
|
|
let q_a = p_a + inward * depth;
|
|
let q_b = p_b + inward * depth;
|
|
let polygon = Polygon::new(vec![p_a, p_b, q_b, q_a])?;
|
|
// ... bisector clip at adjacent corners, classify edges, push parcel ...
|
|
}
|
|
\end{lstlisting}
|
|
|
|
The M0.1 corner story was bisector-clipping the first/last parcel of each edge against the bisector of the corner. That produces those triangular corner ``slices'' visible in the original \texttt{fig\_01\_grid\_block} --- correct geometrically (no overlaps), but visually cheap. M0.2 reworks this to actual corner parcels.
|
|
|
|
\paragraph{Deviations logged.} See \cref{sec:deviations}. Highlights from this session: \texttt{apply\_road\_edit} regenerate-only (no preserve-on-deform), setback as metadata-only depth (not a separate edge), regularization stub. All three resolve in M0.2/M0.3.
|
|
|
|
\paragraph{Tests.} 14 of 21 named tests passing; 7 \texttt{\#[ignore]}-d for M0.2 (acute-corner sliver-merge, cul-de-sac, curved-road tight curvature, deform-preserve and inverse-restore, split-preserve, building-footprint-persists). Plus all the unit and infrastructure tests.
|
|
|
|
\paragraph{Performance.} Not measured this session; instrumentation lands in M0.2.
|
|
|
|
\paragraph{Next.} Corner parcel rework, sticky back edges, full preserve-on-deform pipeline, building eviction, performance instrumentation. Whatever's left after that closes M1.
|
|
|
|
% =======================================================
|
|
\section{Session 2 --- M0.2: Corner parcels, sticky back edges, preserve-on-deform}
|
|
\label{sec:s2}
|
|
\textit{2026-04-25}
|
|
|
|
\paragraph{Goal.} Three big rocks. (1) Replace M0.1's bisector-clipped triangle ``corners'' with proper corner parcels (4--6 sided rectangles/L-shapes). (2) Sticky back edges: a road move only changes the front parcel's geometry, not its back-to-back neighbor. (3) Preserve-on-deform: re-project frontage vertices onto the new road geometry instead of regenerating the whole block.
|
|
|
|
\paragraph{What landed.}
|
|
|
|
\begin{itemize}[leftmargin=2em]
|
|
\item Build-first corner parcels: at each real corner, the corner parcel is constructed before the frontage walk, and the walk on each adjacent road then starts past the corner's footprint. Two construction flavors (frontage on next road vs. frontage on prev road) depending on which adjacent edge is longer.
|
|
\item Sticky back edges (lite): each parcel stores its own polygon in absolute world coordinates; deform pipeline only moves frontage vertices. Adjacent parcels' shared back edges \emph{happen to coincide} but aren't yet enforced atomically (that's M0.4's job).
|
|
\item Preserve-on-deform pipeline: \texttt{deform\_parcel\_after\_road\_move} re-projects frontage endpoints onto the new road via the original parameter mapping, validates the result against rotation/area thresholds (\texttt{design.tex} \S5.3), and returns one of \texttt{Deformed} / \texttt{Regenerate} / \texttt{Condemned}.
|
|
\item \texttt{BuildingFitCheck} eviction: after a parcel is deformed, if it has a building and \texttt{!building.fits\_in(\&parcel)}, the building is evicted; \texttt{EditOutcome.evicted\_buildings} is a new public bucket.
|
|
\item Performance instrumentation: \texttt{SubdivisionStats} returned alongside \texttt{ParcelSet} from the new \texttt{subdivide\_all\_with\_stats}; per-phase wall-clock timing.
|
|
\item New figures: \texttt{fig\_04\_y\_intersection.svg}, \texttt{fig\_06a\_road\_edit\_before.svg}, \texttt{fig\_06b\_road\_edit\_after.svg}.
|
|
\end{itemize}
|
|
|
|
\paragraph{Decisions.} D9 (build-first corners), D10 ($R = $ avg frontage width), D11 (real-corner definition: degree $\geq 3$ or sharp degree-2), D12 (road width deferred; setback is the placeholder), D13 (sticky back edges, lite --- later superseded by D17).
|
|
|
|
\paragraph{Code tour: corner parcel construction.}
|
|
|
|
At each real corner $v$ with $\mathbf{t}_{\text{in}}$ and $\mathbf{t}_{\text{out}}$ the unit tangents back-along-prev and forward-along-next, and $R$ the corner radius (= \texttt{params.frontage\_width}), the corner parcel is built as one of:
|
|
|
|
\begin{lstlisting}[caption={Corner parcel construction, two flavors (\texttt{src/parcel/subdivide.rs}).}]
|
|
// Flavor A: frontage on next road.
|
|
// v0 -> v1 (length R, on next edge, classified Frontage)
|
|
// v3 -> v0 (length depth, on prev edge, classified Side)
|
|
let n_out = DVec2::new(-t_out.y, t_out.x); // inward normal of next edge
|
|
let v0 = v_curr;
|
|
let v1 = v_curr + t_out * r;
|
|
let v2 = v_curr + t_out * r + n_out * perp_depth;
|
|
let v3 = v_curr + t_in * perp_depth;
|
|
let polygon = Polygon::new(vec![v0, v1, v2, v3])?;
|
|
|
|
// Flavor B: frontage on prev road.
|
|
// v3 -> v0 (length R, on prev edge, classified Frontage)
|
|
// v0 -> v1 (length depth, on next edge, classified Side)
|
|
let v0 = v_curr;
|
|
let v1 = v_curr + t_out * perp_depth;
|
|
let v2 = v_curr + t_out * perp_depth + n_out * r;
|
|
let v3 = v_curr + t_in * r;
|
|
let polygon = Polygon::new(vec![v0, v1, v2, v3])?;
|
|
let frontage_idx = 3; // CCW edge v3 -> v0
|
|
\end{lstlisting}
|
|
|
|
For a $90^\circ$ corner of a rectangle, $\mathbf{n}_{\text{out}} = \mathbf{t}_{\text{in}}$ and the corner parcel collapses to an axis-aligned $R \times \texttt{depth}$ rectangle. For non-$90^\circ$ corners (e.g., the $120^\circ$ corner at the centre of a Y-intersection), it's a parallelogram. See \texttt{design.tex} \S17.5 for the geometric derivation.
|
|
|
|
\paragraph{Code tour: preserve-on-deform.}
|
|
|
|
When a road moves, the affected parcels are deformed by re-projecting their frontage endpoints from the old road parameter to the new:
|
|
|
|
\begin{lstlisting}[caption={Frontage projection (\texttt{src/parcel/deform.rs}, M0.2 form).}]
|
|
let road_vec_before = pb_before - pa_before;
|
|
let len_sq_before = road_vec_before.length_squared();
|
|
let road_vec_after = pb_after - pa_after;
|
|
|
|
let p_a = parcel.polygon.vertices()[fi]; // frontage start
|
|
let p_b = parcel.polygon.vertices()[(fi + 1) % n]; // frontage end
|
|
|
|
// Parameter t in [0, 1] of each frontage endpoint along the OLD road.
|
|
let t_a = (p_a - pa_before).dot(road_vec_before) / len_sq_before;
|
|
let t_b = (p_b - pa_before).dot(road_vec_before) / len_sq_before;
|
|
|
|
// New frontage endpoints: same parameter, NEW road.
|
|
let new_p_a = pa_after + road_vec_after * t_a;
|
|
let new_p_b = pa_after + road_vec_after * t_b;
|
|
\end{lstlisting}
|
|
|
|
Side and back vertices stay fixed in absolute coordinates --- this is what ``sticky back edges'' means. After the parcel's hypothetical new polygon is validated (rotation thresholds, simple-polygon check, area/frontage minimums), the parcel commits the change.
|
|
|
|
\paragraph{Deviations.} \texttt{EditOutcome.evicted\_buildings} is an extension to the spec's four-bucket outcome. \texttt{road\_edit\_inverse\_restores} test was rewritten from strict vertex equality to centroid-bounded drift (D14 lands in M0.3 making this explicit).
|
|
|
|
\paragraph{Tests.} 24 unit + 16 integration + 1 doc passing; 5 named tests still \texttt{\#[ignore]}-d.
|
|
|
|
\paragraph{Performance.} $\sim$0.6\,µs per parcel, $\sim$1.5M parcels/sec on M-series hardware. Two orders of magnitude under the spec targets.
|
|
|
|
\paragraph{Next.} Y intersection has visible parcel overlaps even though the centroid-based no-overlap test passes --- need rigorous polygon-polygon intersection testing. Minimum-change deformation (don't disturb parcels on a road that just got longer along the same line). SplitSegment preserve. Possibly sliver-merge for acute corners.
|
|
|
|
% =======================================================
|
|
\section{Session 3 --- M0.3: I3 fix, minimum-change deform, SplitSegment preserve}
|
|
\label{sec:s3}
|
|
\textit{2026-04-25}
|
|
|
|
\paragraph{Goal.} Three concerns surfaced from looking at the M0.2 figures. (1) The Y intersection had visible overlap; a programmatic test confirmed real I3 violation. (2) The road-edit figure was deforming \emph{too} aggressively: when a road got longer along its own line, parcels on it shouldn't have to move. (3) Push on milestone 0.3 work --- \texttt{SplitSegment} preserve, plus as much of the acute-corner story as we can fit.
|
|
|
|
\paragraph{What landed.}
|
|
|
|
\begin{itemize}[leftmargin=2em]
|
|
\item \texttt{y\_intersection\_no\_overlaps} test: a programmatic centroid-in-other-polygon check caught the real I3 violation at acute outer corners of the Y triangle. Fix: bisector-clip regular parcels adjacent to acute corners; obtuse corners keep their rectangle parcels and need no clip.
|
|
\item Minimum-change deformation: when a road's \emph{line} doesn't change (only its endpoints shift along it), parcels whose frontage is still inside the new segment are reported as \texttt{Untouched} and skip the deform entirely.
|
|
\item \texttt{SplitSegment} preserve: parcels with frontage entirely on one side of the split point have their \texttt{frontage\_road} rebound (no geometric change); parcels spanning the split are cut into two parcels along a perpendicular through the split point. 4-vertex parcels only; higher-vertex shapes fall back to Condemn.
|
|
\item \texttt{acute\_intersection\_15deg} and \texttt{acute\_intersection\_5deg} now active --- the bisector-clip handles them well enough that I1--I3 hold.
|
|
\item \texttt{cul\_de\_sac} and \texttt{curved\_road\_high\_curv} now active using polyline approximations of their curves. Pie-slice subdivision for cul-de-sacs is still M0.5 work; the tests just check I1--I3 hold and nothing panics.
|
|
\end{itemize}
|
|
|
|
\paragraph{Decisions.} D14 (minimum-change deformation, ``no-op preserve''), D15 (bisector-clip at acute, not obtuse), D16 (SplitSegment preserve on 4-vertex parcels).
|
|
|
|
\paragraph{Code tour: line-unchanged check (Untouched verdict).}
|
|
|
|
Before going through the parameter-projection of frontage endpoints, the deform pipeline checks whether the road's \emph{infinite line} actually changed. If only the endpoints shifted along the same line, and the parcel's frontage is still within the new segment's range, no movement is needed:
|
|
|
|
\begin{lstlisting}[caption={Untouched check (\texttt{src/parcel/deform.rs}).}]
|
|
let dir_before = road_vec_before / road_vec_before.length();
|
|
let len_after = road_vec_after.length();
|
|
let dir_after = road_vec_after / len_after;
|
|
let parallel = (dir_before.x * dir_after.y - dir_before.y * dir_after.x).abs() < 1e-6;
|
|
if parallel {
|
|
let new_normal = DVec2::new(-dir_after.y, dir_after.x);
|
|
let perp_dist = (p_a - pa_after).dot(new_normal).abs();
|
|
if perp_dist < EPS_GEOM {
|
|
let t_a_new = (p_a - pa_after).dot(dir_after);
|
|
let t_b_new = (p_b - pa_after).dot(dir_after);
|
|
if t_a_new >= -EPS_GEOM && t_a_new <= len_after + EPS_GEOM
|
|
&& t_b_new >= -EPS_GEOM && t_b_new <= len_after + EPS_GEOM
|
|
{
|
|
return DeformResult::Untouched;
|
|
}
|
|
}
|
|
}
|
|
\end{lstlisting}
|
|
|
|
The two checks together mean ``the line as an infinite mathematical object hasn't moved''. The third (range) check confirms the parcel's frontage is still on the new road segment. If all three pass, the parcel literally doesn't need to move and we save a polygon validation.
|
|
|
|
\paragraph{Code tour: split-segment preserve.}
|
|
|
|
When a road is split by inserting a new node at a midpoint, every parcel on the old road has its frontage entirely on one side or it spans the split point. The first case is a metadata-only update (rebind \texttt{frontage\_road}); the second cuts the parcel:
|
|
|
|
\begin{lstlisting}[caption={Split path for a 4-vertex parcel spanning the split.}]
|
|
// Existing parcel: vertices (p_a, p_b, q_b, q_a) in CCW order, frontage at index 0.
|
|
// Split point P on frontage edge (p_a, p_b).
|
|
let frontage_vec = p_b - p_a;
|
|
let t_local = (split_point - p_a).dot(frontage_vec) / frontage_vec.length_squared();
|
|
|
|
// Q_star is the perpendicular projection of P onto the back edge.
|
|
let q_star = q_a + (q_b - q_a) * t_local;
|
|
|
|
// Two new parcels share their boundary along (P, Q_star).
|
|
let poly_a = Polygon::new(vec![p_a, split_point, q_star, q_a])?; // a-side
|
|
let poly_b = Polygon::new(vec![split_point, p_b, q_b, q_star])?; // b-side
|
|
\end{lstlisting}
|
|
|
|
The building stays with the larger half. Both new parcels go in as \texttt{created}; the original goes in as \texttt{condemned}.
|
|
|
|
\paragraph{Tests.} 24 unit + 22 integration + 1 doc passing. All 21 named tests of \texttt{design.tex} \S6 are now active. Zero \texttt{\#[ignore]}-d.
|
|
|
|
\paragraph{Deviations.} The Y figure passes the test because the centroid-in-other-polygon check is too weak --- known limitation, M0.5 will pull in the \texttt{geo} crate for rigorous polygon-polygon intersection.
|
|
|
|
\paragraph{Next.} Shared-vertex registry to make I3-near-misses impossible by construction (M0.4). After that: bulletproof overlap with rigorous testing + Voronoi experiment + cul-de-sac proper (M0.5).
|
|
|
|
% =======================================================
|
|
\section{Session 4 --- M0.4: Shared-vertex registry}
|
|
\label{sec:s4}
|
|
\textit{2026-04-25}
|
|
|
|
\paragraph{Goal.} Up to this session, each parcel stored its own polygon (a \texttt{Vec<DVec2>}) and adjacent parcels carried separate copies of their shared boundary line. They happened to coincide at construction time, but nothing enforced that they stay coincident across edits. This session installs a shared-vertex registry on \texttt{ParcelSet}: every polygon vertex resolves to a stable \texttt{VertexId}; coincident positions resolve to the same \texttt{VertexId}; mutations propagate to every referrer atomically.
|
|
|
|
This is half of an eventual full DCEL on the parcel layer (vertex identity now; edge identity later). For the no-drift contract --- adjacent parcels' shared boundaries can never drift apart --- vertex identity is sufficient.
|
|
|
|
\paragraph{What landed.}
|
|
|
|
\begin{itemize}[leftmargin=2em]
|
|
\item \texttt{VertexId} (newtype-wrapped slotmap key) and \texttt{VertexRecord} (\texttt{pos} + back-references to every \texttt{(ParcelId, vertex\_index)} that touches it).
|
|
\item \texttt{ParcelSet} owns \texttt{vertices: SlotMap<VertexId, VertexRecord>} plus \texttt{vertex\_grid: HashMap<(i64, i64), Vec<VertexId>>} (a spatial hash at $\varepsilon_{\text{geom}}$ resolution).
|
|
\item \texttt{Parcel} gained a \texttt{vertex\_ids: Vec<VertexId>} field parallel to \texttt{polygon.vertices()}.
|
|
\item \texttt{ParcelSet::insert}: snaps every polygon vertex to the registry. Existing matches within $\varepsilon_{\text{geom}}$ reuse the same \texttt{VertexId}; otherwise a new entry is created.
|
|
\item \texttt{ParcelSet::move\_vertex(vid, new\_pos)}: updates the registry's stored position \emph{and} writes through to every parcel polygon at the recorded index. Adjacent parcels' shared boundaries cannot drift.
|
|
\item Deform pipeline refactored to propose-then-apply: \texttt{deform\_parcel\_after\_road\_move} is now pure --- it returns a list of proposed \texttt{(VertexId, new\_pos)} moves rather than mutating. The outer loop validates each parcel's hypothetical post-move polygon, then applies all proposed moves via \texttt{move\_vertex}.
|
|
\item \texttt{shared\_vertex\_no\_drift\_under\_repeated\_edits} regression test: 50 small random node moves plus an inverse, then asserts every shared boundary vertex is bit-for-bit identical across every parcel that references it. Strict floating-point equality, not within $\varepsilon$.
|
|
\end{itemize}
|
|
|
|
\paragraph{Decisions.} D17 (shared-vertex registry), D18 (\texttt{move\_vertex} write-through), D19 (deform pipeline propose-then-apply). I8 added to the invariant list.
|
|
|
|
\paragraph{Code tour: snap-on-insert.}
|
|
|
|
\begin{lstlisting}[caption={Vertex registry lookup, used during \texttt{ParcelSet::insert}.}]
|
|
fn find_or_create_vertex(&mut self, pos: DVec2) -> VertexId {
|
|
let key = vertex_key(pos);
|
|
for dx in -1..=1 {
|
|
for dy in -1..=1 {
|
|
let bucket = (key.0 + dx, key.1 + dy);
|
|
if let Some(ids) = self.vertex_grid.get(&bucket) {
|
|
for &vid in ids {
|
|
if let Some(rec) = self.vertices.get(vid) {
|
|
if (rec.pos - pos).length_squared() < EPS_GEOM * EPS_GEOM {
|
|
return vid;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let id = self.vertices.insert(VertexRecord { pos, refs: Vec::new() });
|
|
self.vertex_grid.entry(key).or_default().push(id);
|
|
id
|
|
}
|
|
\end{lstlisting}
|
|
|
|
The 3$\times$3 neighborhood lookup handles the case where two coincident-within-$\varepsilon$ positions land on adjacent grid cells (a position right at a cell boundary).
|
|
|
|
\paragraph{Code tour: write-through propagation.}
|
|
|
|
\begin{lstlisting}[caption={\texttt{ParcelSet::move\_vertex} (\texttt{src/parcel/mod.rs}).}]
|
|
pub fn move_vertex(&mut self, vid: VertexId, new_pos: DVec2) {
|
|
let refs = match self.vertices.get_mut(vid) {
|
|
Some(r) => { r.pos = new_pos; r.refs.clone() }
|
|
None => return,
|
|
};
|
|
for (pid, idx) in refs {
|
|
if let Some(p) = self.parcels.get_mut(pid) {
|
|
p.polygon.set_vertex_unchecked(idx, new_pos);
|
|
}
|
|
}
|
|
}
|
|
\end{lstlisting}
|
|
|
|
The clone of \texttt{refs} is so we can give up the borrow on \texttt{self.vertices} before touching \texttt{self.parcels}. After this call returns, every parcel polygon that referenced \texttt{vid} sees \texttt{new\_pos} at its corresponding index. Validation (does the parcel's polygon stay simple after the move?) is the caller's responsibility, because validity depends on which combinations of vertices move together --- the deform pipeline does that in the propose phase before any move is committed.
|
|
|
|
\paragraph{Code tour: propose-then-apply in the deform pipeline.}
|
|
|
|
\begin{lstlisting}[caption={Outer loop in \texttt{move\_node\_path} (\texttt{src/parcel/deform.rs}).}]
|
|
let mut proposed_moves: Vec<(VertexId, DVec2)> = Vec::new();
|
|
for rid in &incident_roads {
|
|
for pid in parcels.parcels_on_road(*rid) {
|
|
let parcel = parcels.parcels.get(pid).unwrap();
|
|
match deform_parcel_after_road_move(parcel, *rid, &graph_before, graph_after, params) {
|
|
DeformResult::Deformed { vertex_moves, new_frontage_edge_index } => {
|
|
outcome.deformed.push(pid);
|
|
proposed_moves.extend(vertex_moves);
|
|
deformed_with_new_fi.push((pid, new_frontage_edge_index));
|
|
}
|
|
// ... Untouched / Condemned / Regenerate ...
|
|
}
|
|
}
|
|
}
|
|
// APPLY phase: vertex moves propagate atomically through the registry.
|
|
for (vid, new_pos) in &proposed_moves {
|
|
parcels.move_vertex(*vid, *new_pos);
|
|
}
|
|
\end{lstlisting}
|
|
|
|
When two adjacent parcels share a frontage-end vertex, both compute the same proposed new position (because the deform parameterization is a function of the vertex's position alone). The shared \texttt{VertexId} appears once in \texttt{proposed\_moves}'s \texttt{move\_vertex} call (or, if both proposals are identical, last-write-wins on bit-for-bit equal values).
|
|
|
|
\paragraph{Tests.} 24 unit + 23 integration + 1 doc passing.
|
|
|
|
\paragraph{Deviations.} None this session.
|
|
|
|
\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{Spec Deviations Log}
|
|
\label{sec:deviations}
|
|
|
|
Each entry is a one-liner pointing at where the implementation diverged from the design contract at the time. Resolution path: update \texttt{design.tex} to absorb the divergence (or revert the implementation) and cross out here.
|
|
|
|
\begin{deviation}[2026-04-25 (S1) --- \texttt{apply\_road\_edit} regenerate-only]
|
|
M0.1 ships a regenerate-only \texttt{apply\_road\_edit}; the \texttt{Deformed} bucket is always empty. \textbf{Resolved:} M0.2 lands the preserve pipeline.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S1) --- Setback is metadata-only depth]
|
|
\texttt{design.tex} \S4 walks the \emph{offset} segment $r' \subset \partial B'$ but \S2 (I2) requires the frontage edge to lie within $\varepsilon_{\text{geom}}$ of the road. With $d_s = 1$\,m default and $\varepsilon = 10^{-6}$\,m these conflict. \textbf{Resolution:} parcels touch the road; \texttt{setback} folds into total depth as a metadata-only ``unbuildable margin''. Updated \texttt{design.tex} \S2 reading.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S1) --- Regularization is a stub]
|
|
M0.1 ships \texttt{regularize\_parcel} as a no-op; default \texttt{params.regularity = 0} hides it. \textbf{Open;} resolves when M1.0 closes (working OBB regularization).
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S1) --- tcolorbox preamble fix]
|
|
Original \texttt{decision} env didn't accept its optional argument as a title; calls \verb|\begin{decision}[D1, ...]| failed under modern \texttt{tcolorbox}. \textbf{Resolved:} env now interprets \texttt{\#1} as the title. Preamble change only.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S2) --- \texttt{EditOutcome.evicted\_buildings}]
|
|
Spec \S5.2 lists 4 outcome buckets; M0.2 adds a 5th, \texttt{evicted\_buildings}, naming parcels whose attached building was dropped by \texttt{BuildingFitCheck}. \textbf{Resolved:} \texttt{design.tex} \S7 public API now includes the field.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S3) --- I7 inverse-restore is centroid-bounded]
|
|
Strict vertex-by-vertex inverse-restore (\S2 I7) is incompatible with D14's minimum-change semantics: corner parcels displaced by an earlier edit don't get pulled back exactly when the inverse fires. \textbf{Resolved:} \texttt{design.tex} \S2 I7 reading clarified to centroid-bounded drift.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S3) --- \texttt{Untouched} not a public outcome bucket]
|
|
The deform pipeline internally distinguishes Untouched, but \texttt{EditOutcome} only exposes parcels that materially changed. Untouched parcels' ids don't appear anywhere; callers infer them by absence. \textbf{Resolved:} \texttt{design.tex} \S5.2 documents this.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S3) --- Acute-corner skip + per-parcel block-clip]
|
|
Block-boundary vertices with interior $< 60^\circ$ get no corner parcel; instead, regular parcels along the two adjacent edges are bisector-clipped at that vertex. Plus every parcel is clipped against the block boundary as a defense-in-depth step. \textbf{Resolved:} captured as D15.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S4) --- Vertex IDs on \texttt{Parcel}]
|
|
\texttt{Parcel} gained a parallel \texttt{vertex\_ids: Vec<VertexId>} field for the registry back-references. \texttt{pub(crate)} only. \textbf{Resolved:} captured as D17/D18; spec \S7 unchanged externally.
|
|
\end{deviation}
|
|
|
|
\begin{deviation}[2026-04-25 (S4) --- Registry orphans are GC-deferred]
|
|
\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}
|
|
|
|
\end{document}
|