diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd10ae0 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# Build the design document and the implementation journal. +# Requires: pdflatex, latexmk, and rsvg-convert for the figs target. + +DOCS = design journal + +.PHONY: all clean watch figs design journal + +all: design journal + +design: design.pdf + +journal: journal.pdf + +design.pdf: design.tex figures/*.pdf + latexmk -pdf -interaction=nonstopmode design.tex + +journal.pdf: journal.tex figures/*.pdf + latexmk -pdf -interaction=nonstopmode journal.tex + +# Convert any SVG figures generated by the Rust crate into PDFs for inclusion. +figs: + @for svg in figures/*.svg; do \ + [ -f "$$svg" ] || continue; \ + pdf="$${svg%.svg}.pdf"; \ + echo "Converting $$svg -> $$pdf"; \ + rsvg-convert -f pdf -o "$$pdf" "$$svg"; \ + done + +watch-design: + latexmk -pdf -pvc -interaction=nonstopmode design.tex + +watch-journal: + latexmk -pdf -pvc -interaction=nonstopmode journal.tex + +clean: + latexmk -C + rm -f *.aux *.log *.out *.toc *.fls *.fdb_latexmk *.synctex.gz diff --git a/design.pdf b/design.pdf new file mode 100644 index 0000000..81d9ec7 Binary files /dev/null and b/design.pdf differ diff --git a/design.tex b/design.tex new file mode 100644 index 0000000..57b2e5e --- /dev/null +++ b/design.tex @@ -0,0 +1,1387 @@ +\documentclass[11pt,letterpaper]{article} + +% ---- Packages ---- +\usepackage[margin=1in]{geometry} +\usepackage{amsmath,amssymb,amsthm} +\usepackage{mathtools} +\usepackage{graphicx} +\usepackage{tikz} +\usetikzlibrary{calc,arrows.meta,positioning,shapes.geometric,decorations.pathmorphing} +\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,DMat2,HashMap,SlotMap,VertexId,ParcelId,RoadId,NodeId,HalfEdgeId,FaceId,Polygon,Parcel,ParcelSet,RoadGraph,Block,EdgeKind,SubdivisionParams,RoadEdit,EditOutcome}, + 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, +} + +% ---- Custom environments ---- +\newtcolorbox{invariant}[1][Invariant]{ + colback=blue!4, + colframe=blue!50!black, + fonttitle=\bfseries, + title={#1}, + breakable, +} + +\newtcolorbox{decision}[1][Design Decision]{ + colback=green!4, + colframe=green!50!black, + fonttitle=\bfseries, + title={#1}, + breakable, +} + +\newtcolorbox{openq}[1][Open Question]{ + colback=orange!4, + colframe=orange!60!black, + fonttitle=\bfseries, + title={#1}, + breakable, +} + +\newtcolorbox{cccontract}[1][Claude Code Contract]{ + colback=gray!5, + colframe=gray!50!black, + fonttitle=\bfseries, + title={#1}, + breakable, +} + +\newtcolorbox{milestonebox}[1][Milestone]{ + colback=violet!4, + colframe=violet!60!black, + fonttitle=\bfseries, + title={#1}, + breakable, +} + +% ---- Header / footer ---- +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{\small Road Parceling System} +\fancyhead[R]{\small Design Document} +\fancyfoot[C]{\thepage} + +% ---- Title block ---- +\title{\textbf{Road Parceling System} \\ \large Design Document} +\author{Dane Sabo \\ {\small (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 --- Design Document}, + pdfauthor={Dane Sabo} +} + +\begin{document} + +\maketitle +\thispagestyle{empty} + +\begin{abstract} +\noindent +This is the \emph{contract} for the road parceling system --- the +foundational geometric layer of a city simulation game where parcels +are arbitrary polygons drawn outward from road frontage rather than +cells in a fixed grid. It defines invariants, algorithms, the public +API, the testing strategy, and the milestone roadmap; it includes a +first-principles walkthrough of how the implementation works +(\cref{sec:walkthrough}) so a reader can understand the geometry +without spelunking through source. The companion \texttt{journal.tex} +is the running log of work, decisions, and deviations. +\end{abstract} + +\tableofcontents +\newpage + +% ======================================================= +% PART I --- CONTRACT +% ======================================================= + +\section{Project Context and Motivation} +\label{sec:context} + +\subsection{Why Build This} + +Modern city simulation games suffer from two architectural choices that compound poorly. First, they tend toward rigid grid-based or cell-based zoning, which produces visually uniform cities that diverge from how real urban form develops parcel by parcel along road frontage. Second, they over-rely on bottom-up agent simulation: every citizen is an autonomous decision-maker rerolling actions on every tick. This scales badly --- \emph{Cities: Skylines II} is the canonical example of a game shipped with simulation costs that do not survive contact with a real player's city. + +This project addresses the first problem directly: a parcel-based zoning model where parcels are arbitrary polygons drawn outward from roads, with user-configurable depth and frontage. The simulation architecture (which addresses the second problem via aggregate / hazard-rate modeling rather than per-agent rerolls) is documented separately and consumes the parcel system as a foundational layer. + +\subsection{Scope of This Document} + +This is the design document for the road parceling system: a pure-logic Rust crate with no rendering engine, no game loop, and no simulation behavior. Downstream systems --- buildings, zoning types, population dynamics, transit --- consume this crate's API but are out of scope here. \Cref{sec:roadmap} sketches what comes after the parceling crate is done. + +\subsection{Audience} + +The primary audience is future-me. The secondary audience is an autonomous coding agent (Claude Code) that implements the spec laid out in \cref{sec:contract}. Sections marked as \emph{Claude Code Contract} are written to be acted upon directly. \Cref{sec:walkthrough} is written for a graduate-level engineering reader who knows linear algebra and basic algorithmics but is not a computational-geometry specialist; it derives the geometric machinery from first principles. + +% ======================================================= +\section{Core Invariants} +\label{sec:invariants} + +These are the load-bearing properties of the system. Every public function must preserve them, and every test suite must verify them after every operation. They are stated here once and referenced by number throughout the rest of the document. + +\begin{invariant}[I1: Polygon validity] +Every parcel is a simple polygon: no self-intersections, no holes, vertices ordered counter-clockwise. No edge has length less than $\varepsilon_{\text{geom}}$. No three consecutive vertices are collinear within $\varepsilon_{\text{angle}}$. +\end{invariant} + +\begin{invariant}[I2: Single frontage] +Each parcel has exactly one edge classified as \texttt{EdgeKind::Frontage}, lying coincident (within $\varepsilon_{\text{geom}}$) with a road segment in the network. +\end{invariant} + +\begin{invariant}[I3: Non-overlap] +For any two parcels $P_i, P_j$ within the same block, the area of their interior intersection is zero within $\varepsilon_{\text{area}}$. +\end{invariant} + +\begin{invariant}[I4: Edit persistence] +When a road edit modifies a segment $s$, parcels with frontage on $s$ recompute only their frontage edge. Non-frontage edges are preserved unless explicit geometric thresholds (\cref{sec:edit-handling}) force regeneration. +\end{invariant} + +\begin{invariant}[I5: No degenerate output] +The public API never returns parcels violating I1--I3. Inputs that would produce such parcels are either gracefully merged with neighbors, regularized, or rejected with a typed error. The library never panics on invalid input. +\end{invariant} + +\begin{invariant}[I6: Edit determinism] +Applying the same \texttt{RoadEdit} to the same \texttt{ParcelSet} twice produces identical output, byte-for-byte (modulo opaque IDs). +\end{invariant} + +\begin{invariant}[I7: Edit reversibility] +Applying an edit and then its inverse restores the original parcel set within $\varepsilon_{\text{geom}}$ for all preserved parcels. Condemned parcels are not restored; this is a known asymmetry and acceptable. (See journal entry on minimum-change deformation: a centroid-bounded reading of I7 is what the implementation actually delivers.) +\end{invariant} + +\begin{invariant}[I8: Shared-vertex consistency] +Two parcels whose polygons would coincide on a vertex hold the same \texttt{VertexId} for that point. \texttt{ParcelSet::move\_vertex} writes through to every referrer simultaneously, so adjacent parcels' shared boundaries cannot drift apart under repeated edits. +\end{invariant} + +The numerical tolerances are crate-wide constants: +\begin{align*} +\varepsilon_{\text{geom}} &= 10^{-6} \text{ m} \\ +\varepsilon_{\text{area}} &= 10^{-9} \text{ m}^2 \\ +\varepsilon_{\text{angle}} &= 10^{-4} \text{ rad} +\end{align*} + +% ======================================================= +\section{Geometric Foundations} +\label{sec:geometry} + +\subsection{Coordinate System and Numerical Type} + +All geometry is 2D, in a flat Euclidean plane with units of meters. Coordinates use \texttt{glam::DVec2} (double-precision) throughout. Single-precision \texttt{Vec2} is rejected because parcel offset operations on long road segments accumulate error rapidly at \texttt{f32} resolution; at city scales of $10^4$\,m, an \texttt{f32} mantissa gives roughly $10^{-3}$\,m precision, which is insufficient for the cleanup passes described in \cref{sec:regularization}. + +\subsection{Road Network as a Planar Graph} + +The road network is represented as a planar graph $G = (V, E)$ where vertices are intersections and edges are road segments. We use a half-edge / DCEL (doubly-connected edge list) representation because it provides $O(1)$ access to: +\begin{itemize}[noitemsep] + \item the next edge around a face (block boundary traversal), + \item the twin edge across a road (parcels on the other side), + \item the edges incident to a vertex (intersection topology). +\end{itemize} + +Faces of the planar graph correspond to blocks. The unbounded exterior face is excluded from subdivision. The DCEL construction algorithm and the next-pointer rule are derived from first principles in \cref{sec:walkthrough-dcel}. + +\subsection{Block Extraction} + +A block is a closed face of the planar graph other than the unbounded exterior. Extraction proceeds by: +\begin{enumerate}[noitemsep] + \item Identifying all faces of the DCEL via half-edge traversal. + \item Computing the signed area of each face; the unique face with negative area (under CCW convention) is the exterior. + \item Returning the remaining faces as block boundaries. +\end{enumerate} + +\subsection{Inward Offsetting} + +Given a block boundary $B$ as a CCW polygon and a setback distance $d_s$, the developable polygon $B'$ is the inward offset of $B$ by $d_s$. For convex inputs this is exact; for concave inputs it is the intersection of inward half-planes (a conservative approximation that may shave concave outer-corner detail but never produces an invalid polygon). + +The offset can fail in two ways: +\begin{enumerate}[noitemsep] + \item For very narrow blocks, $B' = \emptyset$. The block is then unbuildable and produces zero parcels. + \item For non-convex blocks, the offset may produce multiple disjoint polygons. Each component is subdivided independently. +\end{enumerate} + +% ======================================================= +\section{Subdivision Algorithm} +\label{sec:subdivision} + +\subsection{Frontage-First Subdivision} + +The primary algorithm subdivides a block by walking along its road-facing boundary in increments of approximately the target frontage width, extruding perpendicular into the block interior. This produces parcels that face their road, which is the desired aesthetic and the realistic outcome. + +\paragraph{Inputs.} A block boundary $B$ (CCW), the developable polygon $B' = \text{offset}_{-d_s}(B)$, and parameters: +\[ +\theta = (w_f, \sigma_f, d_p, \sigma_d, \rho, w_{\min}, A_{\min}, \text{seed}) +\] +where $w_f$ is target frontage width, $\sigma_f$ is frontage variance, $d_p$ is target depth, $\sigma_d$ is depth variance, $\rho \in [0, 1]$ is the regularity slider, $w_{\min}$ is minimum frontage, and $A_{\min}$ is minimum area. + +\paragraph{Procedure (high level).} +\begin{enumerate} + \item Identify ``real corners'' of the block boundary --- vertices where the underlying road graph node has degree $\geq 3$, or vertices where the block boundary turns sharply enough to warrant a corner parcel rather than a continuous frontage walk. Acute corners (interior $< 60^\circ$) are flagged separately. + \item At each real corner, build a corner parcel: an axis-aligned (or skewed for non-90° corners) rectangle of dimensions $R$ by depth, anchored at the corner vertex, with frontage on the longer of the two adjacent block edges. + \item For each block boundary edge, walk the segment between the corner footprints (if any) at arc-length intervals $w_i = w_f + \sigma_f \cdot \xi_i$ where $\xi_i \sim \text{Uniform}(-1, 1)$ from a deterministic per-road RNG. + \item At each split point, extrude perpendicular into the block by a depth bounded by a per-edge ray-cast cap (half the perpendicular distance from the edge midpoint to the nearest other block edge). + \item Form quadrilateral parcels from consecutive split points and their extrusions. + \item Clip each parcel polygon against the block boundary's inward half-planes to ensure no parcel can extend past the block. + \item Reject parcels with frontage $< w_{\min}$ or area $< A_{\min}$. +\end{enumerate} + +The algorithmic details, including the corner classification math, the ray-cast depth cap formulation, and the bisector-clip fallback for acute corners, are derived in \cref{sec:walkthrough-subdivision}. + +\subsection{Edge Classification} + +After subdivision, each parcel edge is classified: + +\begin{table}[h] +\centering +\begin{tabular}{lll} +\toprule +\textbf{Kind} & \textbf{Definition} & \textbf{Color (figures)} \\ +\midrule +\texttt{Frontage} & Lies within $\varepsilon_{\text{geom}}$ of a road segment & Blue \\ +\texttt{Side} & Adjacent to the frontage edge in the polygon ring & Gray \\ +\texttt{Back} & All other edges & Light gray, dashed \\ +\bottomrule +\end{tabular} +\caption{Edge classification scheme.} +\end{table} + +For non-quadrilateral parcels (e.g.\ pie slices in cul-de-sacs, sliver-merged parcels with extra vertices), the back classification absorbs all non-frontage, non-side edges. + +\subsection{Regularization Pass} +\label{sec:regularization} + +When $\rho > 0$, a regularization pass runs after subdivision. For each parcel: +\begin{enumerate}[noitemsep] + \item Compute the OBB (oriented bounding box) of the parcel, oriented to the frontage edge. + \item Linearly interpolate side-edge vertices toward their OBB-snapped positions with weight $\rho$. + \item Validate the result against I1; if validation fails, revert. +\end{enumerate} + +At $\rho = 1$, parcels are forced to perfect rectangles aligned to their road. At $\rho = 0$, parcels carry whatever shape the raw subdivision produced. Intermediate values give partial cleanup, useful for cities where some neighborhoods should look planned and others organic. + +% ======================================================= +\section{Road Edit Handling} +\label{sec:edit-handling} + +The most distinctive feature of this system is parcel persistence under road edits. Conventional city builders nuke and re-create parcels (and their buildings) when roads change. Here, parcels survive whenever geometrically reasonable, and shared boundary vertices update through the registry so adjacent parcels stay in lockstep. + +\subsection{Edit Types} + +\begin{lstlisting}[caption={Road edit enum.}] +pub enum RoadEdit { + MoveNode { node: NodeId, to: DVec2 }, + SplitSegment { road: RoadId, at: DVec2 }, + DeleteSegment { road: RoadId }, + InsertSegment { from: NodeId, to: NodeId }, +} +\end{lstlisting} + +\subsection{Deformation Pipeline} + +When \texttt{apply\_road\_edit} is invoked: + +\begin{enumerate} + \item \textbf{Snapshot the graph} pre-edit so deform can re-project parcel frontages from the old geometry onto the new. + \item \textbf{Apply the topology mutation} and rebuild the DCEL. + \item \textbf{Identify affected parcels}: those with frontage on a modified road. + \item \textbf{Propose} a vertex-move set per affected parcel (no in-place mutation yet). Each parcel is categorized: + \begin{itemize}[noitemsep] + \item \emph{Untouched}: the road's \emph{line} didn't change (only its endpoints shifted along it) and the parcel's frontage is still inside the new segment. Skip. + \item \emph{Deformed}: project the frontage endpoints onto the new road via the original parameter mapping; validate against rotation/area thresholds. + \item \emph{Condemned}: validation fails (frontage too short, area too small, no longer on road). + \item \emph{Regenerate}: rotation threshold exceeded; the affected block is re-subdivided. + \end{itemize} + \item \textbf{Apply} all proposed vertex moves through \texttt{ParcelSet::move\_vertex}, which writes through the shared-vertex registry. Adjacent parcels' shared boundaries move atomically (I8). + \item \textbf{Building eviction}: for each surviving parcel with an attached building, call \texttt{BuildingFitCheck::fits\_in}; on \texttt{false} the building is evicted but the parcel survives. + \item \textbf{Drop} condemned parcels. + \item \textbf{Regenerate} marked blocks. +\end{enumerate} + +The full derivation, including the parameter projection and the line-unchanged check, lives in \cref{sec:walkthrough-edits}. + +\subsection{Regeneration Thresholds} + +Deformation triggers regeneration of the affected block when any of the following hold for the deformed parcel: + +\begin{table}[h] +\centering +\begin{tabular}{ll} +\toprule +\textbf{Condition} & \textbf{Outcome} \\ +\midrule +Frontage length $< w_{\min}$ & Condemned \\ +Side edge rotated $> \alpha_{\max}$ from original & Regenerated \\ +Polygon self-intersects & Regenerated \\ +Area $< A_{\min}$ & Condemned \\ +Frontage no longer adjacent to any road & Condemned \\ +\bottomrule +\end{tabular} +\caption{Thresholds that trigger regeneration or condemnation. $\alpha_{\max}$ defaults to $30^\circ$.} +\end{table} + +\subsection{Building Footprint Preservation} + +Parcels carry an opaque \texttt{Option}. The crate does not define what a building is, but exposes a hook: + +\begin{lstlisting}[caption={Building persistence hook.}] +pub trait BuildingFitCheck { + /// Returns true if the building still fits inside the deformed parcel. + fn fits_in(&self, parcel: &Parcel) -> bool; +} +\end{lstlisting} + +If a building's \texttt{fits\_in} returns false during deformation, the parcel survives but its building is evicted. This is the key behavior that distinguishes the system from CS2's nuke-on-edit approach. + +% ======================================================= +\section{Degenerate Cases} +\label{sec:degenerate} + +The correctness of this system is largely defined by how it handles degenerate inputs. Each case below has a named test in the suite. + +\begin{longtable}{p{4.5cm}p{6cm}p{3.5cm}} +\toprule +\textbf{Test name} & \textbf{Scenario} & \textbf{Expected behavior} \\ +\midrule +\endhead +\texttt{acute\_intersection\_15deg} & Two roads meet at $15^\circ$ & Sliver merged or rejected; I1--I3 hold \\ +\texttt{acute\_intersection\_5deg} & Knife-edge angle & No panic; typed error or valid output \\ +\texttt{colinear\_roads} & Two segments end-to-end, zero turn & Treated as one continuous frontage \\ +\texttt{zero\_length\_segment} & Coincident endpoints & Returns \texttt{InvalidParams} or skips \\ +\texttt{near\_duplicate\_nodes} & Nodes within $\varepsilon$ of each other & Merged or typed error \\ +\texttt{self\_intersecting\_graph} & Roads cross with no node & Returns \texttt{NonPlanarGraph} \\ +\texttt{cul\_de\_sac} & Single road into a bulb & Pie-slice parcels tile the bulb \\ +\texttt{t\_intersection} & Standard T & All three blocks subdivide \\ +\texttt{y\_intersection} & Three roads at $120^\circ$ & Corner parcels handled \\ +\texttt{tiny\_block} & Perimeter $< 4 w_{\min}$ & 0 or 1 parcel; never invalid \\ +\texttt{huge\_block} & 1\,km $\times$ 1\,km block & Sane parcel count; no explosion \\ +\texttt{curved\_road\_high\_curv} & Road radius $< d_p$ & No self-intersection \\ +\texttt{road\_edit\_micro\_move} & Move node by 0.01\,m & All parcels deformed; none regen \\ +\texttt{road\_edit\_large\_move} & Move node by 50\,m & Mix of deformed/regen/condemned \\ +\texttt{road\_edit\_inverse\_restores} & Apply edit then inverse & State matches initial within $\varepsilon$ (centroid-bounded post-D14) \\ +\texttt{road\_delete\_condemns} & Delete a road segment & All frontage parcels condemned \\ +\texttt{road\_split\_preserves} & Split segment with new node & Parcels deform; none regenerated \\ +\texttt{building\_footprint\_persists} & Stub building, deform parcel & Building kept iff \texttt{fits\_in} true \\ +\texttt{degenerate\_isolated\_node} & Graph node with no edges & Skipped; no panic \\ +\texttt{disconnected\_graph} & Two components & Each subdivides independently \\ +\texttt{numerical\_precision\_stress} & Coords near $10^{20}$ & I1--I3 still hold \\ +\bottomrule +\caption{Required degenerate-case tests. Each must exist by name and pass.} +\label{tab:degenerate} +\end{longtable} + +% ======================================================= +\section{Crate Architecture} +\label{sec:architecture} + +\subsection{Module Layout} + +\begin{lstlisting}[language=, caption={Crate structure.}] +road_parceling/ +|-- Cargo.toml +|-- src/ +| |-- lib.rs // public API +| |-- geometry/ +| | |-- polygon.rs // polygon ops, validation +| | |-- offset.rs // road edge offsetting +| | `-- skeleton.rs // straight skeleton (M0.5+) +| |-- network/ +| | |-- graph.rs // DCEL road graph +| | `-- blocks.rs // block extraction +| |-- parcel/ +| | |-- subdivide.rs // frontage-first subdivision +| | |-- classify.rs // edge classification +| | |-- deform.rs // deformation under edits +| | `-- regularize.rs // OBB snapping +| |-- config.rs // SubdivisionParams +| |-- error.rs // typed errors +| `-- viz/svg.rs // SVG output (feature-gated) +|-- tests/ +|-- examples/ +|-- benches/ +`-- figures/ // generated SVG/PDF artifacts +\end{lstlisting} + +\subsection{Public API Surface} + +\begin{lstlisting}[caption={Public API in \texttt{lib.rs}.}] +pub use config::SubdivisionParams; +pub use error::{ParcelError, SubdivisionError}; +pub use network::{RoadGraph, RoadId, NodeId}; +pub use parcel::{ + Parcel, ParcelId, ParcelSet, EdgeKind, VertexId, + BuildingFitCheck, BuildingHandle, + RoadEdit, EditOutcome, + SubdivisionStats, +}; + +pub fn subdivide_all( + graph: &RoadGraph, + params: &SubdivisionParams, +) -> Result; + +pub fn subdivide_all_with_stats( + graph: &RoadGraph, + params: &SubdivisionParams, +) -> Result<(ParcelSet, SubdivisionStats), SubdivisionError>; + +pub fn apply_road_edit( + parcels: &mut ParcelSet, + graph: &mut RoadGraph, + edit: RoadEdit, + params: &SubdivisionParams, +) -> Result; + +pub struct EditOutcome { + pub deformed: Vec, + pub regenerated: Vec, + pub condemned: Vec, + pub created: Vec, + pub evicted_buildings: Vec, +} +\end{lstlisting} + +\subsection{Error Types} + +All fallible operations return \texttt{Result}. No panics in library code outside of \texttt{debug\_assert!}. + +\begin{lstlisting}[caption={Error enum.}] +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SubdivisionError { + #[error("road graph is not planar at node {0:?}")] + NonPlanarGraph(NodeId), + #[error("block boundary is not closed")] + OpenBlock, + #[error("subdivision parameters invalid: {0}")] + InvalidParams(String), + #[error("geometric operation failed: {0}")] + GeometryFailure(String), + #[error("feature not yet implemented: {0}")] + Unimplemented(&'static str), +} +\end{lstlisting} + +\subsection{Dependencies} + +\begin{table}[h] +\centering +\begin{tabular}{lll} +\toprule +\textbf{Crate} & \textbf{Version} & \textbf{Purpose} \\ +\midrule +\texttt{geo} & 0.28 & Polygon primitives, boolean ops (added in M0.5) \\ +\texttt{glam} & 0.29 & \texttt{DVec2} math \\ +\texttt{slotmap} & 1 & Stable IDs for graph entities \\ +\texttt{thiserror} & 2 & Error types \\ +\texttt{rand} & 0.8 & Deterministic RNG \\ +\texttt{rand\_chacha} & 0.3 & Reproducible RNG backend \\ +\texttt{svg} & 0.18 & SVG output (feature \texttt{viz}) \\ +\texttt{serde} & 1 & Serialization (feature \texttt{serde}) \\ +\midrule +\texttt{proptest} & 1 & Property-based testing (dev) \\ +\texttt{insta} & 1 & Snapshot testing (dev) \\ +\texttt{criterion} & 0.5 & Benchmarking (dev) \\ +\bottomrule +\end{tabular} +\caption{Dependency manifest.} +\end{table} + +% ======================================================= +\section{Idiomatic Rust Requirements} +\label{sec:idioms} + +The bar is high; this is a foundational crate that downstream code will depend on for years. + +\begin{itemize} + \item No \texttt{unwrap()} or \texttt{expect()} outside tests and examples. + \item No \texttt{unsafe} without a \texttt{// SAFETY:} comment. None is expected. + \item Newtype IDs (\texttt{ParcelId}, \texttt{RoadId}, \texttt{NodeId}, \texttt{VertexId}); never expose raw indices. + \item \texttt{\#[must\_use]} on builders and on \texttt{EditOutcome}. + \item Iterator-first APIs where allocation is avoidable. + \item Borrowing over cloning; parcels and graphs are large. + \item \texttt{\#[non\_exhaustive]} on public enums likely to grow. + \item \texttt{cargo clippy --all-targets --all-features -- -D warnings} clean (\texttt{clippy::all} group; pedantic stays off, see D8). + \item \texttt{cargo fmt --check} clean. + \item \texttt{\#![deny(missing\_docs)]} at crate root; all public items documented. + \item Module-level docs at the top of each \texttt{mod.rs}, with example snippets where useful. + \item Feature flags: \texttt{serde}, \texttt{viz}. +\end{itemize} + +% ======================================================= +\section{Testing Strategy} +\label{sec:testing} + +\subsection{Three Layers} + +\paragraph{Unit tests} live in-module under \texttt{\#[cfg(test)]}. Every non-trivial geometric helper is tested directly. + +\paragraph{Integration tests} live in \texttt{tests/}. They build a road graph, subdivide, and check invariants. + +\paragraph{Property tests} use \texttt{proptest}. For each invariant I1--I8, a property test generates random valid road graphs and asserts the invariant. Generators produce graphs with 2--20 nodes, varying segment lengths, intersection angles in $[30^\circ, 150^\circ]$, and occasional degeneracies. + +\subsection{Snapshot Testing} + +Each example scenario in \texttt{examples/} renders to SVG and snapshots via \texttt{insta}. Visual regressions are caught when the SVG diff changes. Baseline SVGs are committed. + +\subsection{Coverage Requirement} + +Every named test in \cref{tab:degenerate} must exist and pass. There is no acceptable substitute. As of M0.4 all 21 are active. + +% ======================================================= +\section{Visualization and Figures} +\label{sec:figures} + +The crate produces SVG output via the \texttt{viz} feature. A dedicated example, \texttt{generate\_figures}, regenerates every figure referenced in this document. + +\subsection{Required Figures} + +\begin{longtable}{ll} +\toprule +\textbf{Filename} & \textbf{Content} \\ +\midrule +\endhead +\texttt{fig\_01\_grid\_block.svg} & Rectangular block subdivided \\ +\texttt{fig\_02\_curved\_road.svg} & Parcels on a curved frontage \\ +\texttt{fig\_03\_cul\_de\_sac.svg} & Pie-slice parcels around a bulb \\ +\texttt{fig\_04\_y\_intersection.svg} & Three-way intersection corner lots \\ +\texttt{fig\_05\_acute\_corner.svg} & Sliver-merge at sharp angle \\ +\texttt{fig\_06a\_road\_edit\_before.svg} & Scene before road move \\ +\texttt{fig\_06b\_road\_edit\_after.svg} & Same scene after; classes color-coded \\ +\texttt{fig\_07\_regularity\_slider.svg} & $\rho \in \{0.0, 0.5, 1.0\}$ side by side \\ +\texttt{plot\_subdivision\_perf.svg} & Criterion: parcels/s vs.\ block count \\ +\texttt{plot\_parcel\_area\_hist.svg} & Histogram, 10k-parcel stress scene \\ +\bottomrule +\caption{Required figure deliverables.} +\end{longtable} + +\subsection{Color Conventions} + +\begin{itemize}[noitemsep] + \item Roads: black, 2px stroke + \item Frontage edges: blue + \item Side edges: gray + \item Back edges: light gray, dashed + \item Parcel fill: pale yellow, 30\% opacity + \item Condemned parcels: red fill + \item Regenerated parcels: orange fill + \item Deformed parcels: green fill + \item Created parcels: blue fill +\end{itemize} + +% ======================================================= +\section{Performance Targets} +\label{sec:performance} + +\begin{table}[h] +\centering +\begin{tabular}{lll} +\toprule +\textbf{Operation} & \textbf{Scale} & \textbf{Target (release)} \\ +\midrule +\texttt{subdivide\_all} & 100 blocks & $< 50$ ms \\ +\texttt{subdivide\_all} & 10\,000 blocks & $< 5$ s \\ +\texttt{apply\_road\_edit} & 10k-parcel graph & $< 1$ ms per single-segment edit \\ +\bottomrule +\end{tabular} +\caption{Performance targets. Measured via Criterion.} +\end{table} + +The eventual interactive use case (M2 test harness, M3+ game) wants sub-millisecond response on typical edits so placing roads feels instant. Current measurements (M0.4): $\sim$0.4--0.7\,µs/parcel on M-series hardware, well under all targets. + +% ======================================================= +\section{Out of Scope} +\label{sec:oos} + +Explicitly \emph{not} part of this milestone, to prevent scope creep: + +\begin{itemize}[noitemsep] + \item Buildings (parcels carry an opaque handle; the crate does not define buildings). + \item Zoning types (residential / commercial / industrial). Parcels are typeless. + \item Population, agents, simulation tick logic. + \item Rendering beyond SVG export. + \item Game engine integration (Bevy, Godot). + \item 3D / terrain. Everything is 2D in a flat plane. + \item Persistence formats beyond optional \texttt{serde} derives. + \item Multi-threading. Single-threaded for the foundation; APIs designed not to preclude \texttt{Send}/\texttt{Sync} later. +\end{itemize} + +% ======================================================= +\section{Claude Code Contract} +\label{sec:contract} + +This section is written to be acted on directly by an autonomous coding agent. + +\begin{cccontract}[Working Style] +\begin{enumerate} + \item Work iteratively. Get a single rectangular block working end-to-end with tests and an SVG figure before adding complexity. + \item After each major feature, regenerate figures and verify by inspection. + \item Write tests \emph{before} fixing bugs. Every degenerate case starts as a failing test. + \item When a degenerate case is fundamentally unhandleable (e.g.\ truly self-intersecting input), the test asserts the correct typed error, not success. + \item Commit frequently with messages naming the feature or invariant addressed. + \item When a design decision has multiple reasonable answers, pick one, document it as a Design Decision in this document (\cref{sec:decisions}), and move on. Do not block. + \item If the spec is ambiguous or wrong, append a deviation note to the journal (\texttt{journal.tex}) listing the deviation. Resolve it later by updating this design document. +\end{enumerate} +\end{cccontract} + +\begin{cccontract}[Definition of Done — Milestone 1] +The foundational parceling crate is complete (M1.0) when all of the following are simultaneously true: +\begin{enumerate} + \item \texttt{cargo build --all-features} succeeds with no warnings. + \item \texttt{cargo clippy --all-targets --all-features -- -D warnings} passes. + \item \texttt{cargo fmt --check} passes. + \item \texttt{cargo test --all-features} passes, including every named test in \cref{tab:degenerate}. + \item \texttt{cargo doc --all-features --no-deps} produces no warnings. + \item All figures listed in \cref{sec:figures} are generated and committed. + \item Performance targets in \cref{sec:performance} are met on a modern laptop. + \item This design document and the journal both compile with \texttt{make}. + \item OBB regularization actually does something at $\rho > 0$. +\end{enumerate} +\end{cccontract} + +% ======================================================= +% PART II --- LIVE PLANNING +% ======================================================= + +\section{Roadmap} +\label{sec:roadmap} + +This section is the project's current direction. Updated as milestones land. + +\begin{milestonebox}[M1 --- Foundational parceling crate] +\textbf{Goal.} \cref{sec:contract}'s Definition of Done. + +\textbf{Status (2026-04-26).} $\sim$80\% complete. All 21 named tests active and passing as of M0.4; missing items are figures (\texttt{fig\_03\_cul\_de\_sac}, \texttt{fig\_05\_acute\_corner}, \texttt{plot\_subdivision\_perf}, \texttt{plot\_parcel\_area\_hist}) and a working OBB regularization. Slated to close at the end of M0.5. + +\textbf{Sub-milestones.} +\begin{itemize}[noitemsep] + \item \textbf{M0.1} (DONE): Single-rectangle end-to-end. Crate compiles; SVG figure generated; 14 of 21 named tests passing. + \item \textbf{M0.2} (DONE): Corner parcels, sticky back edges (lite), preserve-on-deform, performance instrumentation, fig\_06a/b. 16 of 21 named tests. + \item \textbf{M0.3} (DONE): I3 fix at acute corners, minimum-change deformation, SplitSegment preserve, all 21 named tests active. + \item \textbf{M0.4} (DONE): Shared-vertex registry. No-drift contract under repeated edits. + \item \textbf{M0.5} (IN PROGRESS): Bulletproof overlap (rigorous polygon-polygon test, per-parcel depth caps, polygon-difference cleanup), Voronoi-experiment subdivision for intersections and cul-de-sacs, missing M1 figures, working OBB regularization. Closes M1.0. +\end{itemize} +\end{milestonebox} + +\begin{milestonebox}[M2 --- Interactive test harness] +\textbf{Goal.} A clickable UI to place road nodes, drag roads, and see parcels regenerate live, so the API can be stress-tested by a human against arbitrary edits. + +\textbf{Decision (M2 kick-off).} Implementation = a sibling Rust crate \texttt{road\_parceling\_studio/} using \texttt{egui} (immediate-mode UI). Builds for both native (fast iteration) and WASM (browser tab). Reasoning: keeps everything in Rust, no JS-side geometry duplication; \texttt{egui} is more productive for tooling than \texttt{bevy}. + +\textbf{Out of scope.} Production game features (zoning, buildings, simulation). The harness is a developer/designer tool. +\end{milestonebox} + +\begin{milestonebox}[M3+ --- TBD] +Candidates that come up in conversations: +\begin{itemize}[noitemsep] + \item Parcel \emph{merge} and \emph{split} operations (agent-driven). Vertex registry already supports the geometry; a parcel-layer DCEL with explicit edge identity is the next data-model upgrade. + \item Building system: a real \texttt{BuildingFitCheck} implementation, footprint preservation (Q4). + \item Game-engine integration (Bevy or similar). + \item Multi-threading: \texttt{subdivide\_all} parallelized per block. +\end{itemize} +\end{milestonebox} + +% ======================================================= +\section{Open Questions} +\label{sec:open} + +A running list. Resolved questions migrate to \cref{sec:decisions} as Design Decisions and may also leave a note in the journal's session record where they were resolved. + +\begin{openq}[Q1: Skeleton-based subdivision as a fallback] +Should the straight-skeleton-based subdivision algorithm be implemented as a fallback for blocks where frontage-first produces ugly results? Frontage-first handles 90\% of cases cleanly; skeleton handles irregular blocks better but adds significant complexity. + +\textbf{Status.} Partially resolved (D4 commits to frontage-first as primary). The Voronoi-based experiment in M0.5 may serve the same role as the skeleton fallback; if it does, Q1 closes. +\end{openq} + +\begin{openq}[Q2: Spatial index for affected-parcel lookup] +\texttt{apply\_road\_edit} needs to find all parcels with frontage on a given segment. Linear scan is $O(n)$ and fine for small cities; an R-tree or grid index becomes necessary at scale. When? + +\textbf{Tentative.} Ship linear scan in M1 with a documented hot-path comment, swap in \texttt{rstar} when the benchmark in \cref{sec:performance} exceeds budget. The shared-vertex registry already uses a spatial hash for vertex lookup, so the scaffolding is partially in place. +\end{openq} + +\begin{openq}[Q3: Block ownership of back edges] +For parcels facing roads on opposite sides of a block, who owns the back edge? Two options: (a) medial line, both deform symmetrically; (b) fixed at creation, one parcel grows while the other shrinks. + +\textbf{Status.} Resolved (option a) for M0.1; per-edge ray-cast cap delivers symmetric extrusion. Revisited in M0.5 where per-parcel depth caps replace the per-edge variant for tighter no-overlap guarantees. +\end{openq} + +\begin{openq}[Q4: Determinism of regeneration] +When a block is regenerated, the new parcel set differs from the old one. Should the regeneration be biased toward producing parcels that overlap maximally with the old ones, to preserve building footprints opportunistically? + +\textbf{Tentative.} No for M1; revisit when the building system lands. The shared-vertex registry partially mitigates this --- vertices are reused on re-insertion at the same position. +\end{openq} + +\begin{openq}[Q5: Voronoi vs.\ frontage-first at intersections] +M0.5 introduces an experimental Voronoi-based subdivision for intersection corners and cul-de-sac bulbs. Does it produce visually superior parcels? Should it become the default at high-degree intersections, or stay a fallback? + +\textbf{Status.} Open; M0.5 implements it as an A/B-able alternative gated behind a \texttt{SubdivisionParams::corner\_method} flag. +\end{openq} + +% ======================================================= +\section{Design Decisions Index} +\label{sec:decisions} + +Canonical record of every locked-in design choice, in order. The journal references these by D-number; this section is the authoritative copy. + +\begin{decision}[D1, 2026-04-25 -- f64 throughout] +Use \texttt{glam::DVec2} (f64) crate-wide rather than \texttt{Vec2} (f32). Single-precision loses too much accuracy on offset operations at city scales. Cost: $\sim$2$\times$ memory for vertex storage. Worth it. +\end{decision} + +\begin{decision}[D2, 2026-04-25 -- DCEL over adjacency list] +Half-edge / DCEL graph representation rather than an adjacency list. Block extraction (face traversal) is the dominant query and is $O(1)$ per step in DCEL. Cost: more complex insertion / deletion logic. +\end{decision} + +\begin{decision}[D3, 2026-04-25 -- Parcels indexed by slotmap key] +\texttt{slotmap} for parcel storage rather than \texttt{Vec} indexing. Stable IDs are required for I4 (edit persistence): a parcel that survives a road edit must retain its identity for downstream consumers (buildings, agents). +\end{decision} + +\begin{decision}[D4, 2026-04-25 -- Frontage-first as primary algorithm] +Implement frontage-first subdivision before any skeleton-based approach. Frontage-first handles the majority of real cases (rectangular blocks, gently curved roads, standard intersections). Skeleton-based subdivision is deferred (Q1). +\end{decision} + +\begin{decision}[D5, 2026-04-25 -- BuildingHandle owns its BuildingFitCheck] +\texttt{BuildingHandle} wraps a \texttt{Box} so the deform pipeline can call \texttt{fits\_in} locally without forcing \texttt{apply\_road\_edit} to take a callback. +\end{decision} + +\begin{decision}[D6, 2026-04-25 -- DCEL next/prev rule, explicit form] +\texttt{HalfEdge::next} = the \emph{predecessor} (CW neighbor) of \texttt{half\_edge.twin} in the target vertex's CCW-sorted outgoing list, with wrap. \texttt{HalfEdge::prev} = the twin of the \emph{successor} of the half-edge in its origin's list. The standard ``CCW after twin'' phrasing is ambiguous about rotation direction; this is the unambiguous form. (Derived in \cref{sec:walkthrough-dcel-next}.) +\end{decision} + +\begin{decision}[D7, 2026-04-25 -- Polygon::new\_relaxed for block boundaries] +Block polygons may legitimately contain collinear-corner vertices (the \texttt{colinear\_roads} case). The strict \texttt{Polygon::new} rejects collinear triples; \texttt{new\_relaxed} skips that check. I1 applies to parcels, not blocks. +\end{decision} + +\begin{decision}[D8, 2026-04-25 -- Clippy scope is all, not pedantic] +Crate enables \texttt{clippy::all} only. \texttt{pedantic} fights numerical-code conventions (single-letter coordinate names, struct-default reassignment) without buying real safety. +\end{decision} + +\begin{decision}[D9, 2026-04-25 -- Build-first corner parcels] +At each real corner, the corner parcel is built before the frontage walk; the walk on each adjacent road then starts past the corner's footprint. (Voronoi delete-and-refill alternative considered and rejected: build-first has fewer edge-case surprises.) +\end{decision} + +\begin{decision}[D10, 2026-04-25 -- Corner radius is the average frontage width] +Corner parcels extend $R = \texttt{params.frontage\_width}$ along the frontage-side adjacent road and \texttt{params.depth} along the other. 4-vertex parallelogram for 90° corners; 6-vertex L for $R \neq \texttt{depth}$ in some constructions. +\end{decision} + +\begin{decision}[D11, 2026-04-25 -- ``Real corner'' definition] +Corner-parcel routine fires at any block-boundary vertex whose underlying graph node has degree $\geq 3$, \emph{or} degree 2 with a bend angle below $150^\circ$ (interior $> 60^\circ$). Acute corners are flagged separately and fall back to bisector-clip on adjacent regulars. +\end{decision} + +\begin{decision}[D12, 2026-04-25 -- Road width deferred; setback is the placeholder] +Roads stay as zero-width centerlines through M1. The \texttt{setback} parameter folds in any visual road-thickness margin downstream consumers want. Revisit when a renderer needs literal road widths. +\end{decision} + +\begin{decision}[D13, 2026-04-25 -- Sticky back edges, lite (now superseded by D17)] +Original M0.2 commitment: each parcel stores its full polygon in absolute world coords; deform pipeline only moves frontage vertices. Adjacent parcels' shared back edges happen to coincide. \emph{Superseded by D17}, which makes shared vertices explicit and atomic via the registry. +\end{decision} + +\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 it), parcels whose frontage is still inside the new segment are reported as \texttt{Untouched} and skip the deformation. Trade-off: strict vertex-by-vertex inverse-restore is no longer guaranteed; centroid-bounded drift is the new contract. +\end{decision} + +\begin{decision}[D15, 2026-04-25 -- Bisector-clip at acute corners, not obtuse] +Acute corners (interior $< 60^\circ$) get no corner parcel; instead, regular parcels along the two adjacent edges are bisector-clipped at the corner so their territories stay separated. Obtuse corners ($\geq 60^\circ$) keep their rectangle/parallelogram corner parcel and need no clip. +\end{decision} + +\begin{decision}[D16, 2026-04-25 -- SplitSegment preserve on 4-vertex parcels] +On \texttt{SplitSegment}, parcels whose frontage is entirely on one side of the split point have their \texttt{frontage\_road} rebound (no geometric change). Parcels whose frontage spans the split are cut into two parcels along a perpendicular through the split point --- only for 4-vertex parcels; higher-vertex parcels fall back to Condemn. Buildings stay with the larger half. +\end{decision} + +\begin{decision}[D17, 2026-04-25 -- Shared-vertex registry] +\texttt{ParcelSet} owns a \texttt{SlotMap} plus a spatial-hash index. On parcel insertion, every polygon vertex is snapped to the registry --- existing matches within $\varepsilon_{\text{geom}}$ reuse the same \texttt{VertexId}; otherwise a new entry is created. Each \texttt{VertexRecord} carries a back-reference list of \texttt{(ParcelId, vertex\_index)} pairs. This is invariant I8. +\end{decision} + +\begin{decision}[D18, 2026-04-25 -- move\_vertex write-through] +\texttt{ParcelSet::move\_vertex(vid, new\_pos)} updates the registry's stored position \emph{and} writes through to every referring parcel's polygon at the recorded index. Adjacent parcels' shared boundaries cannot drift. +\end{decision} + +\begin{decision}[D19, 2026-04-25 -- Deform pipeline is propose-then-apply] +\texttt{deform\_parcel\_after\_road\_move} no longer mutates --- it returns a list of proposed \texttt{(VertexId, new\_pos)} moves. The outer loop validates each parcel, collects proposals, then applies them via \texttt{move\_vertex} after verdicts are in. Conflicting proposals on the same vertex are last-one-wins, but in practice the deform parameterization makes referrers agree by construction. +\end{decision} + +% ======================================================= +\section{System Walkthrough} +\label{sec:walkthrough} + +This section is the ``how does this thing actually work'' tour. It is written for a graduate-level engineering reader who knows linear algebra and basic algorithmics but is not a computational-geometry specialist. Every formula is derived; every algorithmic step is justified; cross-references point to the file and function in the source where the math is implemented. + +\subsection{Pipeline at 30,000 ft} +\label{sec:walkthrough-overview} + +The crate consumes a planar road graph and produces a set of polygonal parcels with shared boundary vertices. The transformation is staged: + +\begin{enumerate}[leftmargin=2em] + \item \textbf{Build the road graph topology.} Roads become a half-edge / DCEL data structure (\cref{sec:walkthrough-dcel}). Faces of the DCEL are blocks. + \item \textbf{Extract blocks.} Each bounded face becomes a \texttt{Block} polygon with metadata (\cref{sec:walkthrough-blocks}). + \item \textbf{Subdivide each block} into parcels via the frontage-first algorithm (\cref{sec:walkthrough-subdivision}). + \item \textbf{Snap vertices into the shared registry} so adjacent parcels share boundary points (\cref{sec:walkthrough-registry}). + \item \textbf{Edits} (move-node, split-segment, etc.) propose vertex moves; the outer pipeline applies them through the registry, validating each affected parcel (\cref{sec:walkthrough-edits}). +\end{enumerate} + +The two end-to-end traces (\cref{sec:walkthrough-trace-subdivide,sec:walkthrough-trace-edit}) walk a single \texttt{subdivide\_all} call and a single \texttt{apply\_road\_edit(MoveNode)} call from the public API down to the geometric primitives. + +\subsection{Mathematical primitives} +\label{sec:walkthrough-primitives} + +We work in $\mathbb{R}^2$. Points are 2-vectors $\mathbf{p} = (p_x, p_y)$. The standard vector operations apply: addition, scaling, dot product $\mathbf{a} \cdot \mathbf{b} = a_x b_x + a_y b_y$, and the 2D ``cross product'' $\mathbf{a} \times \mathbf{b} = a_x b_y - a_y b_x$ which is the $z$-component of the 3D cross product (a scalar in 2D). + +\subsubsection{Rotations} +\label{sec:walkthrough-rotations} + +The standard 2D rotation matrix is +\[ +R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}. +\] +At $\theta = +\pi/2$ (a $90^\circ$ counter-clockwise rotation) this collapses to +\[ +R(\pi/2) = \begin{pmatrix} 0 & -1 \\ 1 & 0 \end{pmatrix}, +\] +so $R(\pi/2) \cdot (x, y)^T = (-y, x)^T$. Symbolically: \emph{rotate $90^\circ$ CCW takes $(x, y) \mapsto (-y, x)$}. Rotation by $-\pi/2$ (CW) is $(x, y) \mapsto (y, -x)$. + +These two rotations are the crate's workhorses for computing perpendiculars. Given an edge direction $\mathbf{t} = \mathbf{p}_2 - \mathbf{p}_1$ (normalized), the inward normal of a CCW polygon at this edge is +\[ +\mathbf{n}_{\text{in}} = R(\pi/2)\,\mathbf{t} = (-t_y, t_x). +\] +This is the direction pointing into the polygon's interior, because for a CCW polygon the interior lies to the left of each edge as you walk in the edge's direction, and ``left'' is exactly $R(\pi/2)$ applied to the forward direction. + +In code (\texttt{src/parcel/subdivide.rs}, multiple sites): +\begin{lstlisting} +let edge_dir = (q - p) / (q - p).length(); +let inward = DVec2::new(-edge_dir.y, edge_dir.x); +\end{lstlisting} + +\subsubsection{Polygon orientation: the shoelace formula} +\label{sec:walkthrough-shoelace} + +A polygon with vertices $\mathbf{p}_0, \mathbf{p}_1, \dots, \mathbf{p}_{n-1}$ has \emph{signed area} +\[ +A = \frac{1}{2} \sum_{i=0}^{n-1} (x_i y_{i+1} - x_{i+1} y_i), +\] +where indices are taken modulo $n$. This is the \emph{shoelace formula}, named for the cross-multiplication pattern when you write the coordinates in two columns. The sign carries the orientation: $A > 0$ for CCW vertex order, $A < 0$ for CW. + +\paragraph{Why this formula works.} Pick any reference point (call it the origin). For each edge $(\mathbf{p}_i, \mathbf{p}_{i+1})$, the triangle formed by origin, $\mathbf{p}_i$, and $\mathbf{p}_{i+1}$ has signed area $\frac{1}{2}(\mathbf{p}_i \times \mathbf{p}_{i+1}) = \frac{1}{2}(x_i y_{i+1} - x_{i+1} y_i)$ (the 2D cross product is twice the signed triangle area). Summing these triangles around a closed polygon, every interior region is covered the same net number of times by triangles of correct sign and zero net times by overlapping pieces of opposite sign --- the algebra works out so the sum equals the polygon's signed area. (This is a consequence of Stokes' theorem in 2D, but the elementary triangle-sum picture is enough here.) + +We use this in \texttt{src/geometry/mod.rs}: +\begin{lstlisting} +pub fn signed_area(verts: &[DVec2]) -> f64 { + let n = verts.len(); + if n < 3 { return 0.0; } + let mut a = 0.0; + for i in 0..n { + let p = verts[i]; + let q = verts[(i + 1) % n]; + a += p.x * q.y - q.x * p.y; + } + 0.5 * a +} +\end{lstlisting} +\texttt{Polygon::new} uses the sign to detect CW input and flip it to CCW (so all internal code can assume CCW), and uses the magnitude to detect degenerate (zero-area) polygons. + +\subsubsection{Segment intersection} +\label{sec:walkthrough-segint} + +A segment from $\mathbf{p}_1$ to $\mathbf{p}_2$ is the set $\{\mathbf{p}_1 + t(\mathbf{p}_2 - \mathbf{p}_1) \mid t \in [0, 1]\}$. To find where two segments cross we solve the linear system +\[ +\mathbf{p}_1 + t (\mathbf{p}_2 - \mathbf{p}_1) = \mathbf{p}_3 + s (\mathbf{p}_4 - \mathbf{p}_3) +\] +for $(t, s) \in [0,1]^2$. With $\mathbf{d}_1 = \mathbf{p}_2 - \mathbf{p}_1$ and $\mathbf{d}_2 = \mathbf{p}_4 - \mathbf{p}_3$, applying Cramer's rule gives +\[ +t = \frac{(\mathbf{p}_3 - \mathbf{p}_1) \times \mathbf{d}_2}{\mathbf{d}_1 \times \mathbf{d}_2}, \qquad s = \frac{(\mathbf{p}_3 - \mathbf{p}_1) \times \mathbf{d}_1}{\mathbf{d}_1 \times \mathbf{d}_2}. +\] +The denominator $\mathbf{d}_1 \times \mathbf{d}_2$ is the determinant of the system; it vanishes iff the segments are parallel. The crate treats parallel segments as non-intersecting (they may overlap collinearly, but for our use cases that's not an interesting intersection). + +\subsubsection{Polygon-vs-half-plane clipping (Sutherland--Hodgman)} +\label{sec:walkthrough-shclip} + +Given a convex half-plane $H = \{\mathbf{p} \mid (\mathbf{p} - \mathbf{p}_0) \cdot \mathbf{n} \geq 0\}$ (where $\mathbf{n}$ is the inward normal and $\mathbf{p}_0$ is any point on the boundary line), and a polygon $P = (\mathbf{q}_0, \dots, \mathbf{q}_{m-1})$, the Sutherland--Hodgman algorithm computes $P \cap H$ in one pass: + +\begin{enumerate}[leftmargin=2em] + \item Initialize an output list, empty. + \item For each edge $(\mathbf{q}_i, \mathbf{q}_{i+1})$ of $P$, classify both endpoints as \emph{inside} ($d \geq 0$) or \emph{outside} ($d < 0$) where $d = (\mathbf{q} - \mathbf{p}_0) \cdot \mathbf{n}$: + \begin{itemize}[noitemsep] + \item \emph{In--In}: append $\mathbf{q}_{i+1}$ to output. + \item \emph{In--Out}: compute the intersection of the edge with the half-plane boundary; append it to output. (No endpoint added.) + \item \emph{Out--In}: append the intersection, then $\mathbf{q}_{i+1}$. + \item \emph{Out--Out}: append nothing. + \end{itemize} + \item The output list is the clipped polygon (or empty if everything was outside). +\end{enumerate} + +The intersection of edge $(\mathbf{a}, \mathbf{b})$ with the boundary line is a 1-D linear interpolation. Let $d_a = (\mathbf{a} - \mathbf{p}_0) \cdot \mathbf{n}$ and $d_b = (\mathbf{b} - \mathbf{p}_0) \cdot \mathbf{n}$. Then the intersection lies at +\[ +t^* = \frac{d_a}{d_a - d_b} +\] +along the edge, giving point $\mathbf{a} + t^* (\mathbf{b} - \mathbf{a})$. (Derivation: the boundary is $\{\mathbf{p} \mid (\mathbf{p}-\mathbf{p}_0)\cdot\mathbf{n} = 0\}$. Substituting the parameterized edge and solving for $t$ yields the formula. The denominator vanishes only when $d_a = d_b$, which means the edge is parallel to the boundary --- a degenerate case we handle by returning ``no intersection''.) + +\paragraph{Why this works for convex clip regions.} A convex region is the intersection of half-planes. If $H_1, \dots, H_k$ are the inward half-planes for an edge of the convex region, then $P \cap (H_1 \cap \dots \cap H_k) = (((P \cap H_1) \cap H_2) \cap \dots) \cap H_k$. Sutherland--Hodgman applies a sequence of half-plane clips, one per edge of the clip region. The crate uses this for two purposes: clipping a regular parcel against a corner bisector, and clipping every parcel against the block boundary's inward half-planes. + +In code (\texttt{src/geometry/polygon.rs}, \texttt{Polygon::clip\_half\_plane}): +\begin{lstlisting} +pub fn clip_half_plane(&self, point: DVec2, inward_normal: DVec2) -> Option { + let n = inward_normal.normalize(); + let inside = |p: DVec2| (p - point).dot(n) >= -EPS_GEOM; + let intersect = |a: DVec2, b: DVec2| -> Option { + let da = (a - point).dot(n); + let db = (b - point).dot(n); + let denom = da - db; + if denom.abs() < EPS_GEOM { return None; } + let t = da / denom; + Some(a + (b - a) * t) + }; + // ... iterate edges, emit per the four-case rule above ... +} +\end{lstlisting} + +\subsection{The road graph as a DCEL} +\label{sec:walkthrough-dcel} + +\subsubsection{Why DCEL} + +A planar graph drawn in the plane partitions the plane into \emph{faces} (the connected components of the complement of the edge set). The road graph's faces are exactly the city blocks plus one unbounded outer face. We need fast face queries: ``what's the boundary of this block?'', ``what's the block on the other side of this road?''. + +A vanilla adjacency-list graph stores edges per vertex, which makes vertex-incidence and edge-existence cheap, but face traversal is $O(|E|)$ to discover faces from scratch each time. The Doubly-Connected Edge List (DCEL) augments the graph with explicit face structure. Every edge is split into two \emph{half-edges}, one for each direction, and pointer fields on each half-edge support face-walks in $O(\text{face perimeter})$ regardless of total graph size. + +\subsubsection{Half-edge structure} + +Each edge is represented as a pair of half-edges. A half-edge $h$ has: +\begin{itemize}[noitemsep] + \item \texttt{origin}: the vertex it leaves from. + \item \texttt{twin}: the half-edge for the same physical edge but opposite direction. + \item \texttt{next}: the next half-edge counterclockwise around the face on $h$'s left. + \item \texttt{prev}: the inverse of \texttt{next}. + \item \texttt{face}: the face $h$ bounds (on its left). +\end{itemize} + +The convention we adopt: the face on a half-edge's \emph{left} (relative to the direction of travel) is the face that half-edge bounds. For a CCW polygon, walking around its boundary with the polygon's interior on your left, you traverse one half-edge per boundary edge in order. + +\subsubsection{Constructing the DCEL: the next-pointer rule} +\label{sec:walkthrough-dcel-next} + +Suppose we've created two half-edges per road, set up their \texttt{twin} pointers, and now want to fill in \texttt{next} so that following \texttt{next} from any half-edge walks the face on its left. The rule is most cleanly derived by asking: ``standing at the target vertex of $h$, with the face on $h$'s left, what's the next half-edge that bounds this same face?'' + +At a vertex $v$, the half-edges \emph{leaving} $v$ have specific compass-headings (angles from $v$ to their target). Sort them counter-clockwise by angle: in \texttt{src/network/graph.rs}, this is \texttt{sort\_outgoing\_by\_angle}, which uses \texttt{atan2(dir.y, dir.x)}. + +Now consider half-edge $h$: $u \to v$. Its \emph{twin} $h^*$ leaves $v$ heading back toward $u$. In the CCW-sorted list of outgoing half-edges at $v$, $h^*$ sits at some position $k$. The face on $h$'s left is the face on $h^*$'s right, which is the face wedged between $h^*$ and the half-edge \emph{immediately clockwise} of $h^*$ in the angular ordering --- because the face wraps around $v$ between two consecutive outgoing half-edges. ``Immediately clockwise of $h^*$ in CCW-sorted order'' is the same as ``the predecessor of $h^*$ in the list, with wrap''. + +So: +\begin{equation} +\boxed{\;\texttt{next}(h) = \text{predecessor of } \texttt{twin}(h) \text{ in } v\text{'s CCW-sorted outgoing list}, \text{ with wrap}\;} +\label{eq:dcel-next} +\end{equation} +where $v = $ target of $h$. The corresponding rule for \texttt{prev} is the dual: +\[ +\texttt{prev}(h) = \texttt{twin}(\text{successor of } h \text{ in } u\text{'s CCW-sorted outgoing list}, \text{ with wrap}) +\] +where $u = $ origin of $h$. + +\paragraph{Worked example: square.} A unit square with vertices $A = (0,0)$, $B = (1,0)$, $C = (1,1)$, $D = (0,1)$ has 4 roads $AB, BC, CD, DA$ and 8 half-edges. At vertex $A$, the outgoing half-edges are $h_{AB}$ (heading $0^\circ$) and $h_{AD}$ (heading $90^\circ$). CCW-sorted: $[h_{AB}, h_{AD}]$. + +Take $h_{DA}$: $D \to A$. Its twin is $h_{AD}$ ($A \to D$, heading $90^\circ$). At $A$, $h_{AD}$ is at position 1; its predecessor with wrap is at position 0 = $h_{AB}$. So $\texttt{next}(h_{DA}) = h_{AB}$. Continuing: $\texttt{next}(h_{AB}) = h_{BC}$, $\texttt{next}(h_{BC}) = h_{CD}$, $\texttt{next}(h_{CD}) = h_{DA}$, closing the cycle around the interior face. + +\paragraph{Why the ``CCW after'' phrasing was ambiguous.} The literature sometimes states the rule as ``$\texttt{next}(h)$ is the half-edge immediately CCW after $\texttt{twin}(h)$''. The trap: ``after'' depends on which direction around $v$ you measure. CCW \emph{around the vertex} (= the angle ordering) gives \emph{successor} of twin; CCW \emph{around the face} (= the actual traversal we want) gives \emph{predecessor} of twin. The implementation gets this wrong once and you pay for it: the T-intersection unit test in M0.1 caught this when the T-stem face cycle enclosed the wrong region. (See journal session 1; locked in as D6.) + +\subsubsection{Face extraction} + +Once \texttt{next}/\texttt{prev}/\texttt{twin} are populated, every face is the cycle generated by repeatedly applying \texttt{next} from any one of its half-edges. Walk the cycle; the vertex positions trace out the face's boundary polygon. Use the shoelace formula (\cref{sec:walkthrough-shoelace}) on the vertex sequence: positive area = bounded face (a block), negative area = unbounded outer face (the exterior). + +In \texttt{src/network/graph.rs}, \texttt{extract\_faces}: +\begin{lstlisting} +for h_start in edge_keys { + if self.half_edges[h_start].face.is_some() { continue; } + let mut cycle = Vec::new(); + let mut h = h_start; + loop { + cycle.push(h); + let next = self.half_edges[h].next; + if next == h_start { break; } + h = next; + } + let pts: Vec = cycle.iter() + .map(|&hid| self.nodes[self.half_edges[hid].origin].pos) + .collect(); + let signed = signed_area(&pts); + let face_id = self.faces.insert(Face { + boundary: h_start, + is_exterior: signed < 0.0 || signed.abs() < EPS_GEOM, + }); + for h in cycle { self.half_edges[h].face = Some(face_id); } +} +\end{lstlisting} + +\subsection{Block extraction} +\label{sec:walkthrough-blocks} + +A \texttt{Block} is one bounded face of the road DCEL, packaged with metadata for subdivision. \texttt{src/network/blocks.rs}: +\begin{lstlisting} +pub struct Block { + pub(crate) face: FaceId, + pub polygon: Polygon, // CCW vertex ring + pub(crate) boundary_edges: Vec, // parallel to polygon vertices + pub roads: Vec, // parallel to polygon edges +} +\end{lstlisting} + +The polygon is built from the face's vertex cycle; \texttt{Polygon::new\_relaxed} is used (not the strict \texttt{Polygon::new}) because block boundaries may legitimately contain collinear-corner vertices when adjacent road segments are end-to-end (the \texttt{colinear\_roads} case --- D7). + +\subsection{Frontage-first subdivision} +\label{sec:walkthrough-subdivision} + +Subdivision runs per block. The high-level flow: +\begin{enumerate}[leftmargin=2em] + \item Compute interior angles at each block-boundary vertex. + \item Classify each vertex: \emph{real corner} (build a corner parcel) vs. \emph{acute corner} (no corner parcel; bisector-clip adjacent regulars) vs. \emph{smooth continuation} (collinear; no corner treatment). + \item Compute per-edge depth caps (the maximum perpendicular extent of a parcel before it would hit the opposite side of the block). + \item For each real corner, build the corner parcel. + \item For each block boundary edge, walk the segment between corner footprints, place split points, and emit quad parcels. + \item Clip each parcel polygon against the block boundary's inward half-planes (defense in depth against any geometric oversight). +\end{enumerate} + +\subsubsection{Interior angles and corner classification} + +At vertex $v$ with neighbors $v_{\text{prev}}$ and $v_{\text{next}}$ in CCW boundary order, define +\[ +\mathbf{t}_{\text{in}} = \frac{v_{\text{prev}} - v}{\|v_{\text{prev}} - v\|}, \qquad +\mathbf{t}_{\text{out}} = \frac{v_{\text{next}} - v}{\|v_{\text{next}} - v\|}. +\] +$\mathbf{t}_{\text{in}}$ points back along the previous edge; $\mathbf{t}_{\text{out}}$ points forward along the next edge. The \emph{arriving} direction (which is the previous edge's direction in CCW traversal) is $\mathbf{t}_{\text{arrive}} = -\mathbf{t}_{\text{in}}$. + +The signed CCW turn from $\mathbf{t}_{\text{arrive}}$ to $\mathbf{t}_{\text{out}}$ is +\[ +\theta_{\text{turn}} = \operatorname{atan2}(\mathbf{t}_{\text{arrive}} \times \mathbf{t}_{\text{out}}, \mathbf{t}_{\text{arrive}} \cdot \mathbf{t}_{\text{out}}). +\] +The interior angle at $v$ for a convex CCW polygon is then $\theta_{\text{int}} = \pi - \theta_{\text{turn}}$. + +\paragraph{Classification rules.} +\begin{itemize}[noitemsep] + \item Underlying graph node has degree $\geq 3$, OR degree 2 with $\theta_{\text{int}} < 150^\circ$ ($\theta_{\text{turn}} > 30^\circ$): it's a real corner candidate. + \item Of those, $\theta_{\text{int}} > 60^\circ$ → \emph{obtuse real corner}: build a corner parcel. + \item $\theta_{\text{int}} \leq 60^\circ$ → \emph{acute corner}: no corner parcel, bisector-clip adjacent regulars. + \item Everything else (degree 2 with $\theta_{\text{int}} \geq 150^\circ$): smooth continuation, walk through. +\end{itemize} + +(D11; the $60^\circ$ acute threshold was tuned empirically. Below it, the rectangle corner-parcel construction tries to extrude past the polygon boundary --- bisector-clip is the safer fallback.) + +\subsubsection{Per-edge depth cap (ray-cast)} +\label{sec:walkthrough-depth-cap} + +For each block boundary edge $e_i$, cast a ray from the edge midpoint $\mathbf{m}_i$ in the inward-normal direction $\mathbf{n}_i$ (\cref{sec:walkthrough-rotations}). The first intersection of this ray with another edge $e_j$ gives a distance $\rho_{ij}$. Define the per-edge depth cap as +\[ +d_{\text{cap}}(e_i) = \frac{1}{2} \min_{j \neq i} \rho_{ij}. +\] +The factor of $\tfrac{1}{2}$ ensures parcels from $e_i$ extruding to $d_{\text{cap}}(e_i)$ and parcels from the closest opposite edge $e_j$ extruding to $d_{\text{cap}}(e_j)$ meet (or undershoot) at the midline between them, never overlap. (For convex blocks this is a tight bound under the assumption that the closest opposing edge remains the same across the entire span; for non-convex or strongly-tapered blocks, this is the milestone-0.5 weakness that we patch with per-parcel ray casts.) + +Ray-segment intersection: parameterize the ray as $\mathbf{r}(t) = \mathbf{m}_i + t\mathbf{n}_i$ and the segment $e_j$ as $\mathbf{q}(s) = \mathbf{a} + s(\mathbf{b} - \mathbf{a})$ for $s \in [0, 1]$. Solving $\mathbf{r}(t) = \mathbf{q}(s)$ gives a $2 \times 2$ linear system whose solution (if the determinant is non-singular) yields $(t, s)$ via Cramer's rule. We accept solutions with $t > \varepsilon$ and $s \in [-\varepsilon, 1 + \varepsilon]$. + +Code: \texttt{src/parcel/subdivide.rs}, \texttt{edge\_depth\_caps} and \texttt{ray\_segment\_distance}. + +\subsubsection{Corner parcel construction} +\label{sec:walkthrough-corner} + +At an obtuse real corner $v$, the corner parcel is a 4-vertex shape with corners derived as follows. Let $R = \texttt{params.frontage\_width}$ (the corner radius, D10) and $d = \texttt{params.depth}$. + +We first decide which adjacent road wins frontage --- the longer one (with deterministic tie-break on \texttt{RoadId} bits). Two flavors: +\begin{itemize}[noitemsep] + \item \textbf{Frontage on next road.} Vertices: $v_0 = v$, $v_1 = v + R \mathbf{t}_{\text{out}}$, $v_2 = v_1 + d\mathbf{n}_{\text{out}}$, $v_3 = v + d \mathbf{t}_{\text{in}}$. + \item \textbf{Frontage on prev road.} Vertices: $v_0 = v$, $v_1 = v + d \mathbf{t}_{\text{out}}$, $v_2 = v_1 + R \mathbf{n}_{\text{out}}$, $v_3 = v + R \mathbf{t}_{\text{in}}$. +\end{itemize} +where $\mathbf{n}_{\text{out}} = R(\pi/2)\mathbf{t}_{\text{out}}$ is the inward normal of the next edge (\cref{sec:walkthrough-rotations}). + +For a $90^\circ$ corner of a rectangle ($\mathbf{t}_{\text{in}} \perp \mathbf{t}_{\text{out}}$, with $\mathbf{n}_{\text{out}} = \mathbf{t}_{\text{in}}$), the corner parcel collapses to an axis-aligned $R \times d$ rectangle. For non-$90^\circ$ corners (e.g., the $120^\circ$ corner at the centre of a Y-intersection), it's a parallelogram. + +The two flavors differ only in which side carries $R$ and which carries $d$; the geometry is otherwise symmetric. The frontage edge of the corner parcel is the one adjacent to the chosen winning road; \texttt{classify\_edges} (\texttt{src/parcel/classify.rs}) labels it \texttt{Frontage} and the rest \texttt{Side}/\texttt{Back}. + +\subsubsection{The frontage walk} + +Between corner footprints, regular parcels are emitted by walking the block edge at jittered intervals. For edge $e_i$ of length $L$ with start-consume $c_s$ (the corner footprint at the start vertex) and end-consume $c_e$ (at the end vertex), let $t_0 = c_s$ and $t_{\max} = L - c_e$. We generate split positions +\[ +t_k = t_{k-1} + \max(w_{\min},\; w_f + \sigma_f \xi_k), \quad \xi_k \sim \text{Uniform}(-1, 1) +\] +from a per-road \texttt{ChaCha8Rng} until $t_k$ exceeds $t_{\max} - w_{\min}/2$, then append $t_{\max}$. + +For each consecutive pair $(t_{k-1}, t_k)$, define +\[ +\mathbf{p}_a = \mathbf{p}_{\text{start}} + t_{k-1} \mathbf{t}_{\text{edge}}, \qquad +\mathbf{p}_b = \mathbf{p}_{\text{start}} + t_k \mathbf{t}_{\text{edge}}. +\] +Pick a depth $d^* = \min(d_p + \sigma_d \eta_k,\; d_{\text{cap}}(e_i))$ from the same RNG, and form a quad parcel with vertices $(\mathbf{p}_a, \mathbf{p}_b, \mathbf{p}_b + d^* \mathbf{n}_{\text{in}}, \mathbf{p}_a + d^* \mathbf{n}_{\text{in}})$. + +\subsubsection{Block-boundary defense clip} + +After every parcel polygon is built (corner or regular), it is clipped against the block boundary's inward half-planes via Sutherland--Hodgman (\cref{sec:walkthrough-shclip}). For convex blocks this is a no-op for parcels generated correctly; for non-convex blocks (e.g., the Y-intersection sub-blocks, where the block triangle has acute outer corners) it ensures parcels can never extend outside their block face. + +In code (\texttt{src/parcel/subdivide.rs}, \texttt{clip\_polygon\_to\_block}): +\begin{lstlisting} +fn clip_polygon_to_block(parcel: &Polygon, block: &Polygon) -> Option { + let mut current = parcel.clone(); + let block_verts = block.vertices(); + for i in 0..block_verts.len() { + let a = block_verts[i]; + let b = block_verts[(i + 1) % block_verts.len()]; + let edge = b - a; + let len = edge.length(); + if len < EPS_GEOM { continue; } + let dir = edge / len; + let inward = DVec2::new(-dir.y, dir.x); + current = current.clip_half_plane(a, inward)?; + } + Some(current) +} +\end{lstlisting} + +\subsection{Shared-vertex registry} +\label{sec:walkthrough-registry} + +The registry is the data structure that prevents shared boundaries from drifting across edits. (D17, D18; invariant I8.) + +\subsubsection{Data structure} + +\begin{lstlisting} +pub struct ParcelSet { + parcels: SlotMap, + vertices: SlotMap, + vertex_grid: HashMap<(i64, i64), Vec>, + // ... +} + +struct VertexRecord { + pos: DVec2, + refs: Vec<(ParcelId, usize)>, // each parcel that references this vertex, + // and the index into that parcel's polygon ring. +} +\end{lstlisting} + +The spatial grid is keyed at $\varepsilon_{\text{geom}}$ resolution: position $(x, y)$ maps to cell +\[ +(\,\lfloor x / \varepsilon_{\text{geom}} + 0.5 \rfloor,\; \lfloor y / \varepsilon_{\text{geom}} + 0.5 \rfloor\,). +\] +Two positions within $\varepsilon_{\text{geom}}$ might fall into the same cell or into adjacent cells; lookup checks the cell and its 8 neighbors. + +\subsubsection{Snap-on-insert} + +When \texttt{ParcelSet::insert(parcel)} is called, every polygon vertex is passed through \texttt{find\_or\_create\_vertex(pos)}: +\begin{lstlisting} +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 first hit (if any) within $\varepsilon_{\text{geom}}$ is reused; otherwise a new \texttt{VertexRecord} is created. The parcel's vertex-id list runs parallel to its polygon vertex list. + +\subsubsection{Write-through propagation} + +\texttt{ParcelSet::move\_vertex(vid, new\_pos)} updates the registry's stored position and walks every \texttt{(parcel\_id, vertex\_index)} reference to write the new position into the parcel polygon directly: +\begin{lstlisting} +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} +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 this in its propose phase before any move is committed (\cref{sec:walkthrough-edits}). + +\subsection{Edit pipeline} +\label{sec:walkthrough-edits} + +\texttt{apply\_road\_edit(parcels, graph, edit, params)} is the single public entry for every road-network mutation. The pipeline is propose-then-apply (D19): no parcel is mutated until every affected parcel has been classified and its proposed vertex moves collected. + +\subsubsection{Move-node case} + +Given \texttt{RoadEdit::MoveNode \{ node, to \}}: +\begin{enumerate}[leftmargin=2em] + \item \textbf{Snapshot the graph} (\texttt{graph\_before = graph.clone()}). We need the old node positions to compute parcel-vertex parameters along the old roads. + \item \textbf{Apply the topology mutation} (move the node, re-run \texttt{rebuild\_topology}). The graph DCEL now reflects the new geometry. + \item \textbf{Identify incident roads} (those touching the moved node). + \item \textbf{For each parcel on each incident road}, run \texttt{deform\_parcel\_after\_road\_move(parcel, road, graph\_before, graph\_after, params)} which is a \emph{pure} function returning a verdict and (if Deformed) a list of proposed \texttt{(VertexId, new\_pos)} moves. +\end{enumerate} + +The verdict logic: + +\textbf{Untouched check (D14, line-unchanged).} Compute the road's old direction $\mathbf{d}_{\text{before}} = (\mathbf{p}_b^{\text{before}} - \mathbf{p}_a^{\text{before}})/L_{\text{before}}$ and new direction $\mathbf{d}_{\text{after}}$. The road's \emph{line} is unchanged iff: +\[ +\big|\mathbf{d}_{\text{before}} \times \mathbf{d}_{\text{after}}\big| < \varepsilon_{\text{angle}}^{\text{small}} +\quad\text{and}\quad +\big|(\mathbf{p}_a^{\text{before}} - \mathbf{p}_a^{\text{after}}) \cdot \mathbf{n}_{\text{after}}\big| < \varepsilon_{\text{geom}}. +\] +The first condition says the directions are parallel; the second says the old start point lies on the new line. Combined, these say the line (as an infinite mathematical object) didn't change --- only the segment endpoints shifted along it. If additionally the parcel's frontage endpoints fall within the new segment's range $[0, L_{\text{after}}]$ (computing each as $\mathbf{p} \cdot \mathbf{d}_{\text{after}} - \mathbf{p}_a^{\text{after}} \cdot \mathbf{d}_{\text{after}}$), the parcel's frontage is still entirely on the new road and the parcel skips the deformation: it is reported as Untouched. + +\textbf{Frontage projection.} If the line did change, project each of the parcel's two frontage endpoints. The projection uses the \emph{old} road's parameter: +\[ +t_a = \frac{(\mathbf{p}_a^{\text{frontage,old}} - \mathbf{p}_a^{\text{road,before}}) \cdot \mathbf{d}_{\text{before}}}{L_{\text{before}}^2}\,L_{\text{before}}, +\] +i.e., the dot product of the parcel-frontage-start-to-road-start vector with the road direction, normalized to the road's length parameter. (We use $\mathbf{d}_{\text{before}} L_{\text{before}} = \mathbf{p}_b^{\text{before}} - \mathbf{p}_a^{\text{before}}$ as the road vector and divide by $L_{\text{before}}^2$ to get the parameter.) Then +\[ +\mathbf{p}_a^{\text{frontage,new}} = \mathbf{p}_a^{\text{road,after}} + t_a (\mathbf{p}_b^{\text{road,after}} - \mathbf{p}_a^{\text{road,after}}). +\] +Same for $\mathbf{p}_b^{\text{frontage}}$. + +\textbf{Validate.} Build a \emph{hypothetical} new polygon with the proposed frontage endpoints (other vertices unchanged). Check: +\begin{itemize}[noitemsep] + \item The polygon validates as a simple CCW polygon. + \item Side-edge rotation: the angle between the old side direction and the new side direction is less than $\alpha_{\max} = \texttt{params.max\_side\_rotation}$ (default $30^\circ$). + \item Area $\geq A_{\min}$. + \item Frontage length $\geq w_{\min}$. +\end{itemize} +Failures map to \texttt{Regenerate} (rotation, simple-polygon failure) or \texttt{Condemned} (area, frontage too short). On success, the verdict is \texttt{Deformed} and we record the two proposed vertex moves: one for each frontage endpoint, looked up by \texttt{parcel.vertex\_ids[frontage\_index]}. + +\textbf{Apply.} After all parcels are classified, the outer pipeline does: +\begin{enumerate}[noitemsep,leftmargin=2em] + \item For each $(vid, new\_pos) \in$ proposed moves: \texttt{parcels.move\_vertex(vid, new\_pos)}. This propagates atomically (\cref{sec:walkthrough-registry}) --- a shared frontage-end vertex between two adjacent regulars is updated once, and both polygons see it. + \item For each Deformed parcel, update its \texttt{frontage\_edge\_index} (because \texttt{Polygon::new} may have rotated the vertex order during proposal validation if the polygon happened to land in CW orientation). + \item For each Deformed parcel with an attached building, call \texttt{building.fits\_in(parcel)}; on \texttt{false}, evict. + \item Drop Condemned parcels. + \item Regenerate any block where some parcel asked for it. +\end{enumerate} + +This propose-then-apply structure (D19) is what guarantees adjacent parcels stay in lockstep: two parcels sharing a vertex both propose the same new position (because the deform parameterization is a function of the vertex's position alone), so when \texttt{move\_vertex} fires on the shared \texttt{VertexId} the proposals agree. + +\subsection{End-to-end trace: subdivide\_all} +\label{sec:walkthrough-trace-subdivide} + +A single call \texttt{subdivide\_all(\&graph, \¶ms)} flows as follows. + +\begin{enumerate}[leftmargin=2em] + \item \texttt{params.validate()} (\texttt{src/config.rs}) --- range-checks every \texttt{SubdivisionParams} field, returns \texttt{InvalidParams} on failure. + \item If \texttt{!graph.topology\_valid}, clone the graph and run \texttt{rebuild\_topology()} on the clone (the public API is \texttt{\&RoadGraph}, so we can't mutate it). \texttt{rebuild\_topology()} runs \texttt{check\_planarity}, \texttt{build\_half\_edges}, \texttt{sort\_outgoing\_by\_angle}, \texttt{link\_next\_and\_prev}, \texttt{extract\_faces}. + \item \texttt{extract\_blocks(graph)} walks the DCEL faces, skipping the exterior, and constructs \texttt{Block} structs with their CCW vertex polygons + per-edge \texttt{RoadId}s. + \item For each block: \texttt{subdivide\_block(graph, block, params, block\_idx)}. + \begin{enumerate}[label=\alph*),leftmargin=2em] + \item Compute \texttt{interior\_angles} per vertex. + \item Classify vertices: \texttt{real\_corner[i]}, \texttt{acute\_corner[i]} (\cref{sec:walkthrough-subdivision}). + \item Compute \texttt{depth\_caps[i]} for each block edge via ray-cast. + \item Decide \texttt{frontage\_on\_next[i]} per real corner (longer adjacent road wins). + \item For each real corner, build the corner-parcel polygon; clip against the block boundary; if valid and area $\geq A_{\min}$, push to output. + \item For each block edge, compute walk bounds (subtracting corner footprints), generate split positions via a deterministic per-road \texttt{ChaCha8Rng}, and emit each quad parcel (with bisector clip at acute-corner ends, then block clip, then frontage-index recovery). + \end{enumerate} + \item For each generated parcel, \texttt{ParcelSet::insert(parcel)} which snaps every polygon vertex to the registry, builds \texttt{vertex\_ids}, and updates \texttt{by\_block} and \texttt{by\_road} indexes. + \item Return \texttt{ParcelSet} (or with stats when called via \texttt{subdivide\_all\_with\_stats}). +\end{enumerate} + +\subsection{End-to-end trace: apply\_road\_edit(MoveNode)} +\label{sec:walkthrough-trace-edit} + +A single call \texttt{apply\_road\_edit(\&mut parcels, \&mut graph, RoadEdit::MoveNode \{node, to\}, \¶ms)} flows as follows. + +\begin{enumerate}[leftmargin=2em] + \item \texttt{params.validate()}. + \item \texttt{graph\_before = graph.clone()}. + \item Apply mutation: \texttt{graph.nodes[node].pos = to}; \texttt{graph.topology\_valid = false}; \texttt{graph.rebuild\_topology()}. + \item \texttt{move\_node\_path(parcels, \&graph\_before, graph, node, params, \&mut outcome)}: + \begin{enumerate}[label=\alph*),leftmargin=2em] + \item Collect \texttt{incident\_roads} (those with \texttt{node} as endpoint in the new graph). + \item For each road, list parcels on it via \texttt{parcels.parcels\_on\_road(road)}. + \item For each parcel, call \texttt{deform\_parcel\_after\_road\_move} (pure --- no mutation): + \begin{enumerate}[label=\roman*),noitemsep,leftmargin=2em] + \item Untouched check: line unchanged + frontage in new range → return Untouched. + \item Compute proposed new frontage endpoints via parameter projection. + \item Validate hypothetical polygon (rotation, area, frontage length, polygon validity). + \item Return verdict; if Deformed, include the two proposed moves and the new frontage edge index. + \end{enumerate} + \item Bookkeeping: append parcel-id to one of \texttt{outcome.deformed} / \texttt{outcome.condemned}; record blocks needing regeneration. + \end{enumerate} + \item Apply phase: + \begin{enumerate}[label=\alph*),noitemsep,leftmargin=2em] + \item For each $(vid, new\_pos)$ in proposed moves: \texttt{parcels.move\_vertex(vid, new\_pos)} (writes through the registry). + \item Update \texttt{frontage\_edge\_index} on each Deformed parcel. + \item Building fit-check loop: evict where \texttt{!building.fits\_in(parcel)}. + \item Drop Condemned parcels. + \item For each block in \texttt{to\_regenerate}: drop its surviving parcels (mark Regenerated), re-run \texttt{subdivide\_block}, insert each new parcel (mark Created). + \end{enumerate} + \item Return \texttt{outcome}. +\end{enumerate} + +% ======================================================= +\appendix +\section{Notation Reference} +\label{app:notation} + +\begin{tabular}{ll} +\toprule +\textbf{Symbol} & \textbf{Meaning} \\ +\midrule +$G = (V, E)$ & Road graph (planar) \\ +$B$ & Block boundary polygon \\ +$B'$ & Developable polygon, $B$ offset inward by $d_s$ \\ +$d_s$ & Road setback distance \\ +$w_f, \sigma_f$ & Target frontage width, variance \\ +$d_p, \sigma_d$ & Target parcel depth, variance \\ +$\rho$ & Regularity slider, $[0, 1]$ \\ +$w_{\min}, A_{\min}$ & Minimum frontage and area \\ +$\alpha_{\max}$ & Maximum side-edge rotation before regeneration \\ +$\varepsilon_{\text{geom}}$ & Geometric tolerance, $10^{-6}$ m \\ +$\varepsilon_{\text{area}}$ & Area tolerance, $10^{-9}$ m$^2$ \\ +$\varepsilon_{\text{angle}}$ & Angular tolerance, $10^{-4}$ rad \\ +$\mathbf{t}_{\text{in}}, \mathbf{t}_{\text{out}}$ & Unit tangents back along prev / forward along next edge at a corner \\ +$\mathbf{n}_{\text{in}}, \mathbf{n}_{\text{out}}$ & Inward normals at prev / next edge \\ +$R$ & Corner radius (default $= w_f$) \\ +$d_{\text{cap}}(e_i)$ & Per-edge depth cap (\cref{sec:walkthrough-depth-cap}) \\ +$\theta_{\text{int}}$ & Interior angle at a polygon vertex \\ +$\theta_{\text{turn}}$ & Signed CCW turn at a polygon vertex (= $\pi - \theta_{\text{int}}$) \\ +\bottomrule +\end{tabular} + +\end{document} diff --git a/journal.pdf b/journal.pdf index a0c3601..c59fa76 100644 Binary files a/journal.pdf and b/journal.pdf differ diff --git a/journal.tex b/journal.tex index 194d7ab..df7b438 100644 --- a/journal.tex +++ b/journal.tex @@ -5,8 +5,6 @@ \usepackage{amsmath,amssymb,amsthm} \usepackage{mathtools} \usepackage{graphicx} -\usepackage{tikz} -\usetikzlibrary{calc,arrows.meta,positioning,shapes.geometric,decorations.pathmorphing} \usepackage{booktabs} \usepackage{longtable} \usepackage{enumitem} @@ -29,7 +27,7 @@ \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}, + 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]{//}, @@ -57,51 +55,32 @@ captionpos=b, } -% ---- Custom environments ---- -\newtcolorbox{invariant}[1][Invariant]{ - colback=blue!4, - colframe=blue!50!black, +\newtcolorbox{checklistbox}[1][Self-Decisions Checklist]{ + colback=teal!4, + colframe=teal!50!black, fonttitle=\bfseries, title={#1}, breakable, } -\newtcolorbox{decision}[1][Design Decision]{ - colback=green!4, - colframe=green!50!black, +\newtcolorbox{deviation}[1][Spec Deviation]{ + colback=red!4, + colframe=red!60!black, fonttitle=\bfseries, title={#1}, breakable, } -\newtcolorbox{openq}[1][]{ - colback=orange!4, - colframe=orange!60!black, - fonttitle=\bfseries, - title=Open Question, - breakable, - #1 -} - -\newtcolorbox{cccontract}[1][]{ - colback=gray!5, - colframe=gray!50!black, - fonttitle=\bfseries, - title=Claude Code Contract, - breakable, - #1 -} - % ---- Header / footer ---- \pagestyle{fancy} \fancyhf{} \fancyhead[L]{\small Road Parceling System} -\fancyhead[R]{\small Design Journal} +\fancyhead[R]{\small Implementation Journal} \fancyfoot[C]{\thepage} % ---- Title block ---- -\title{\textbf{Road Parceling System} \\ \large A Design Journal} -\author{Dane Sabo} +\title{\textbf{Road Parceling System} \\ \large Implementation Journal} +\author{Dane Sabo \\ {\small (sessions logged with Claude Code)}} \date{Started 2026-04-25} \hypersetup{ @@ -109,7 +88,7 @@ linkcolor=blue!50!black, urlcolor=blue!50!black, citecolor=blue!50!black, - pdftitle={Road Parceling System --- Design Journal}, + pdftitle={Road Parceling System --- Implementation Journal}, pdfauthor={Dane Sabo} } @@ -120,1511 +99,380 @@ \begin{abstract} \noindent -This journal documents the design and implementation of a road-frontage-based parcel subdivision system, intended as the foundational geometric layer of a city simulation game. The system rejects the rigid grid-based zoning of conventional city builders in favor of arbitrary polygonal parcels drawn outward from road frontage, with explicit handling for parcel persistence under road edits. This document serves three purposes: (1) a record of design decisions and their justifications, (2) a specification precise enough to drive autonomous implementation, and (3) a running notebook for open questions, dead ends, and revisions. It is intended to be read and extended over the lifetime of the project. +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{Project Context and Motivation} -\label{sec:context} +\section*{Self-Decisions Checklist} +\label{sec:checklist} +\addcontentsline{toc}{section}{Self-Decisions Checklist} -\subsection{Why Build This} +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. -Modern city simulation games suffer from two architectural choices that compound poorly. First, they tend toward rigid grid-based or cell-based zoning, which produces visually uniform cities that diverge from how real urban form develops parcel by parcel along road frontage. Second, they over-rely on bottom-up agent simulation: every citizen is an autonomous decision-maker rerolling actions on every tick. This scales badly --- \emph{Cities: Skylines II} is the canonical example of a game shipped with simulation costs that do not survive contact with a real player's city. - -This project addresses the first problem directly: a parcel-based zoning model where parcels are arbitrary polygons drawn outward from roads, with user-configurable depth and frontage. The simulation architecture (which addresses the second problem via aggregate / hazard-rate modeling rather than per-agent rerolls) is documented separately and consumes the parcel system as a foundational layer. - -\subsection{Scope of This Document} - -This journal covers \emph{only} the road parceling system. It is a pure-logic Rust crate with no rendering engine, no game loop, and no simulation behavior. Downstream systems --- buildings, zoning types, population dynamics, transit --- consume this crate's API but are out of scope here. - -\subsection{Audience} - -The primary audience is future-me. The secondary audience is an autonomous coding agent (Claude Code) that will implement the spec laid out in \cref{sec:contract}. Sections marked as \emph{Claude Code Contract} are written to be acted upon directly. - -% ======================================================= -\section{Core Invariants} -\label{sec:invariants} - -These are the load-bearing properties of the system. Every public function must preserve them, and every test suite must verify them after every operation. They are stated here once and referenced by number throughout the rest of the document. - -\begin{invariant}[I1: Polygon validity] -Every parcel is a simple polygon: no self-intersections, no holes, vertices ordered counter-clockwise. No edge has length less than $\varepsilon_{\text{geom}}$. No three consecutive vertices are collinear within $\varepsilon_{\text{angle}}$. -\end{invariant} - -\begin{invariant}[I2: Single frontage] -Each parcel has exactly one edge classified as \texttt{EdgeKind::Frontage}, lying coincident (within $\varepsilon_{\text{geom}}$) with a road segment in the network. -\end{invariant} - -\begin{invariant}[I3: Non-overlap] -For any two parcels $P_i, P_j$ within the same block, the area of their interior intersection is zero within $\varepsilon_{\text{area}}$. -\end{invariant} - -\begin{invariant}[I4: Edit persistence] -When a road edit modifies a segment $s$, parcels with frontage on $s$ recompute only their frontage edge. Non-frontage edges are preserved unless explicit geometric thresholds (\cref{sec:edit-handling}) force regeneration. -\end{invariant} - -\begin{invariant}[I5: No degenerate output] -The public API never returns parcels violating I1--I3. Inputs that would produce such parcels are either gracefully merged with neighbors, regularized, or rejected with a typed error. The library never panics on invalid input. -\end{invariant} - -The numerical tolerances are crate-wide constants: -\begin{align*} -\varepsilon_{\text{geom}} &= 10^{-6} \text{ m} \\ -\varepsilon_{\text{area}} &= 10^{-9} \text{ m}^2 \\ -\varepsilon_{\text{angle}} &= 10^{-4} \text{ rad} -\end{align*} - -% ======================================================= -\section{Geometric Foundations} -\label{sec:geometry} - -\subsection{Coordinate System and Numerical Type} - -All geometry is 2D, in a flat Euclidean plane with units of meters. Coordinates use \texttt{glam::DVec2} (double-precision) throughout. Single-precision \texttt{Vec2} is rejected because parcel offset operations on long road segments accumulate error rapidly at \texttt{f32} resolution; at city scales of $10^4$ m, an \texttt{f32} mantissa gives roughly $10^{-3}$ m precision, which is insufficient for the cleanup passes described in \cref{sec:regularization}. - -\subsection{Road Network as a Planar Graph} - -The road network is represented as a planar graph $G = (V, E)$ where vertices are intersections and edges are road segments. We use a half-edge / DCEL (doubly-connected edge list) representation because it provides $O(1)$ access to: -\begin{itemize}[noitemsep] - \item the next edge around a face (block boundary traversal), - \item the twin edge across a road (parcels on the other side), - \item the edges incident to a vertex (intersection topology). +\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} - -Faces of the planar graph correspond to blocks. The unbounded exterior face is excluded from subdivision. - -\begin{decision}[Stable IDs via slotmap] -Graph nodes, edges, and parcels are addressed by newtype-wrapped \texttt{slotmap} keys. This gives stable IDs that survive insertion and deletion, which is essential for the edit-persistence invariant (I4): a parcel's identity must outlive perturbations to its surrounding geometry. -\end{decision} - -\subsection{Block Extraction} - -A block is a closed face of the planar graph other than the unbounded exterior. Extraction proceeds by: -\begin{enumerate}[noitemsep] - \item Identifying all faces of the DCEL via half-edge traversal. - \item Computing the signed area of each face; the unique face with negative area (under CCW convention) is the exterior. - \item Returning the remaining faces as block boundaries. -\end{enumerate} - -\subsection{Inward Offsetting} - -Given a block boundary $B$ as a CCW polygon and a setback distance $d_s$, the developable polygon $B'$ is the inward offset of $B$ by $d_s$. We use the \texttt{geo} crate's offset operation, with a fallback to a manual implementation for cases where \texttt{geo} produces invalid output (concave blocks with sharp inner corners are the failure mode). - -The offset can fail in two ways: -\begin{enumerate}[noitemsep] - \item For very narrow blocks, $B' = \emptyset$. The block is then unbuildable and produces zero parcels. - \item For non-convex blocks, the offset may produce multiple disjoint polygons. Each component is subdivided independently. -\end{enumerate} +\end{checklistbox} % ======================================================= -\section{Subdivision Algorithm} -\label{sec:subdivision} +\section{Session 1 --- M0.1: Rectangle end-to-end} +\label{sec:s1} +\textit{2026-04-25} -\subsection{Frontage-First Subdivision} +\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. -The primary algorithm subdivides a block by walking along its road-facing boundary in increments of approximately the target frontage width, extruding perpendicular into the block interior. This produces parcels that face their road, which is the desired aesthetic and the realistic outcome. +\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{Inputs.} A block boundary $B$, the developable polygon $B' = \text{offset}_{-d_s}(B)$, and parameters: -\[ -\theta = (w_f, \sigma_f, d_p, \sigma_d, \rho, w_{\min}, A_{\min}, \text{seed}) -\] -where $w_f$ is target frontage width, $\sigma_f$ is frontage variance, $d_p$ is target depth, $\sigma_d$ is depth variance, $\rho \in [0, 1]$ is the regularity slider, $w_{\min}$ is minimum frontage, and $A_{\min}$ is minimum area. +\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{Procedure.} -\begin{enumerate} - \item For each road segment $r$ on the boundary of $B$, compute the offset segment $r' \subset \partial B'$. - \item Walk $r'$ from one end to the other, placing split points at arc-length intervals $w_i$ where: - \[ - w_i = w_f + \sigma_f \cdot \xi_i, \quad \xi_i \sim \text{Uniform}(-1, 1) - \] - drawn from a deterministic RNG seeded by \texttt{seed} and the road segment ID. - \item At each split point $p_i$, extrude perpendicular into $B'$ to depth $d_i = d_p + \sigma_d \cdot \eta_i$, producing an interior point $q_i$. - \item Form quadrilateral parcels with vertices $(p_i, p_{i+1}, q_{i+1}, q_i)$. - \item Resolve interior collisions where extrusions from opposite sides of the block meet. Two strategies, used in order: - \begin{enumerate}[label=(\alph*)] - \item If the block depth (perpendicular distance between opposing road edges) is less than $2 d_p$, clip extrusions to the medial axis of $B'$. - \item If extrusions still overlap after clipping, shrink $d_i$ on the longer of the two until disjoint. - \end{enumerate} - \item Reject any parcel with frontage $< w_{\min}$ or area $< A_{\min}$. Merge rejected parcels into their larger neighbor when geometrically possible. -\end{enumerate} +\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). -\subsection{Edge Classification} +\paragraph{Code tour: how the rectangle gets subdivided.} -After subdivision, each parcel edge is classified: +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. -\begin{table}[h] -\centering -\begin{tabular}{lll} -\toprule -\textbf{Kind} & \textbf{Definition} & \textbf{Color (figures)} \\ -\midrule -\texttt{Frontage} & Lies within $\varepsilon_{\text{geom}}$ of a road segment & Blue \\ -\texttt{Side} & Adjacent to the frontage edge in the polygon ring & Gray \\ -\texttt{Back} & All other edges & Light gray, dashed \\ -\bottomrule -\end{tabular} -\caption{Edge classification scheme.} -\end{table} +The frontage walk along each block edge looked like this in M0.1: -For non-quadrilateral parcels (e.g.\ pie slices in cul-de-sacs, sliver-merged parcels with extra vertices), the back classification absorbs all non-frontage, non-side edges. +\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); -\subsection{Regularization Pass} -\label{sec:regularization} +let splits = split_positions(edge_len, params, &mut rng); -When $\rho > 0$, a regularization pass runs after subdivision. For each parcel: -\begin{enumerate}[noitemsep] - \item Compute the OBB (oriented bounding box) of the parcel, oriented to the frontage edge. - \item Linearly interpolate side-edge vertices toward their OBB-snapped positions with weight $\rho$. - \item Validate the result against I1; if validation fails, revert. -\end{enumerate} - -At $\rho = 1$, parcels are forced to perfect rectangles aligned to their road. At $\rho = 0$, parcels carry whatever shape the raw subdivision produced. Intermediate values give partial cleanup, useful for cities where some neighborhoods should look planned and others organic. - -% ======================================================= -\section{Road Edit Handling} -\label{sec:edit-handling} - -The most distinctive feature of this system is parcel persistence under road edits. Conventional city builders nuke and re-create parcels (and their buildings) when roads change. Here, parcels survive whenever geometrically reasonable. - -\subsection{Edit Types} - -\begin{lstlisting}[caption={Road edit enum.}] -pub enum RoadEdit { - MoveNode { node: NodeId, to: DVec2 }, - SplitSegment { road: RoadId, at: DVec2 }, - DeleteSegment { road: RoadId }, - InsertSegment { from: NodeId, to: NodeId }, +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} -\subsection{Deformation Pipeline} +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. -When \texttt{apply\_road\_edit} is invoked: +\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. -\begin{enumerate} - \item \textbf{Identify affected parcels.} Query the spatial index for parcels with frontage on the modified segment(s). - \item \textbf{Attempt deformation.} For each affected parcel: - \begin{enumerate}[label=(\alph*)] - \item Recompute the frontage edge by re-offsetting the new road geometry. - \item Translate frontage vertices to their new positions. - \item Hold side and back vertices fixed. - \item Reconnect the polygon and validate. - \end{enumerate} - \item \textbf{Categorize outcome.} Each parcel ends in one of four states: - \begin{itemize}[noitemsep] - \item \emph{Deformed} (success): the parcel persists with new frontage. - \item \emph{Regenerated}: deformation violated thresholds; the block is re-subdivided. - \item \emph{Condemned}: the parcel cannot exist in the new geometry; the building (if any) is evicted. - \item \emph{Created}: a new parcel from a regenerated block. - \end{itemize} -\end{enumerate} +\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. -\subsection{Regeneration Thresholds} +\paragraph{Performance.} Not measured this session; instrumentation lands in M0.2. -Deformation triggers regeneration of the affected block when any of the following hold for the deformed parcel: - -\begin{table}[h] -\centering -\begin{tabular}{ll} -\toprule -\textbf{Condition} & \textbf{Outcome} \\ -\midrule -Frontage length $< w_{\min}$ & Condemned \\ -Side edge rotated $> \alpha_{\max}$ from original & Regenerated \\ -Polygon self-intersects & Regenerated \\ -Area $< A_{\min}$ & Condemned \\ -Frontage no longer adjacent to any road & Condemned \\ -\bottomrule -\end{tabular} -\caption{Thresholds that trigger regeneration or condemnation. $\alpha_{\max}$ defaults to $30^\circ$.} -\end{table} - -\subsection{Determinism and Idempotence} - -\begin{invariant}[I6: Edit determinism] -Applying the same \texttt{RoadEdit} to the same \texttt{ParcelSet} twice produces identical output, byte-for-byte (modulo opaque IDs). -\end{invariant} - -\begin{invariant}[I7: Edit reversibility] -Applying an edit and then its inverse restores the original parcel set within $\varepsilon_{\text{geom}}$ for all preserved parcels. Condemned parcels are not restored; this is a known asymmetry and acceptable. -\end{invariant} - -\subsection{Building Footprint Preservation} - -Parcels carry an opaque \texttt{Option}. The crate does not define what a building is, but exposes a hook: - -\begin{lstlisting}[caption={Building persistence hook.}] -pub trait BuildingFitCheck { - /// Returns true if the building still fits inside the deformed parcel. - fn fits_in(&self, parcel: &Parcel) -> bool; -} -\end{lstlisting} - -If a building's \texttt{fits\_in} returns false during deformation, the parcel survives but its building is evicted. This is the key behavior that distinguishes the system from CS2's nuke-on-edit approach. +\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{Degenerate Cases} -\label{sec:degenerate} +\section{Session 2 --- M0.2: Corner parcels, sticky back edges, preserve-on-deform} +\label{sec:s2} +\textit{2026-04-25} -The correctness of this system is largely defined by how it handles degenerate inputs. Each case below has a named test in the suite. - -\begin{longtable}{p{4.5cm}p{6cm}p{3.5cm}} -\toprule -\textbf{Test name} & \textbf{Scenario} & \textbf{Expected behavior} \\ -\midrule -\endhead -\texttt{acute\_intersection\_15deg} & Two roads meet at $15^\circ$ & Sliver merged or rejected; I1--I3 hold \\ -\texttt{acute\_intersection\_5deg} & Knife-edge angle & No panic; typed error or valid output \\ -\texttt{colinear\_roads} & Two segments end-to-end, zero turn & Treated as one continuous frontage \\ -\texttt{zero\_length\_segment} & Coincident endpoints & Returns \texttt{InvalidParams} or skips \\ -\texttt{near\_duplicate\_nodes} & Nodes within $\varepsilon$ of each other & Merged or typed error \\ -\texttt{self\_intersecting\_graph} & Roads cross with no node & Returns \texttt{NonPlanarGraph} \\ -\texttt{cul\_de\_sac} & Single road into a bulb & Pie-slice parcels tile the bulb \\ -\texttt{t\_intersection} & Standard T & All three blocks subdivide \\ -\texttt{y\_intersection} & Three roads at $120^\circ$ & Corner parcels handled \\ -\texttt{tiny\_block} & Perimeter $< 4 w_{\min}$ & 0 or 1 parcel; never invalid \\ -\texttt{huge\_block} & 1\,km $\times$ 1\,km block & Sane parcel count; no explosion \\ -\texttt{curved\_road\_high\_curv} & Road radius $< d_p$ & No self-intersection \\ -\texttt{road\_edit\_micro\_move} & Move node by 0.01\,m & All parcels deformed; none regen \\ -\texttt{road\_edit\_large\_move} & Move node by 50\,m & Mix of deformed/regen/condemned \\ -\texttt{road\_edit\_inverse\_restores} & Apply edit then inverse & State matches initial within $\varepsilon$ \\ -\texttt{road\_delete\_condemns} & Delete a road segment & All frontage parcels condemned \\ -\texttt{road\_split\_preserves} & Split segment with new node & Parcels deform; none regenerated \\ -\texttt{building\_footprint\_persists} & Stub building, deform parcel & Building kept iff \texttt{fits\_in} true \\ -\texttt{degenerate\_isolated\_node} & Graph node with no edges & Skipped; no panic \\ -\texttt{disconnected\_graph} & Two components & Each subdivides independently \\ -\texttt{numerical\_precision\_stress} & Coords near $10^{20}$ & I1--I3 still hold \\ -\bottomrule -\caption{Required degenerate-case tests. Each must exist by name and pass.} -\label{tab:degenerate} -\end{longtable} - -% ======================================================= -\section{Crate Architecture} -\label{sec:architecture} - -\subsection{Module Layout} - -\begin{lstlisting}[language=, caption={Crate structure.}] -road_parceling/ -|-- Cargo.toml -|-- src/ -| |-- lib.rs // public API -| |-- geometry/ -| | |-- polygon.rs // polygon ops, validation -| | |-- offset.rs // road edge offsetting -| | `-- skeleton.rs // straight skeleton (optional) -| |-- network/ -| | |-- graph.rs // DCEL road graph -| | `-- blocks.rs // block extraction -| |-- parcel/ -| | |-- subdivide.rs // frontage-first subdivision -| | |-- classify.rs // edge classification -| | |-- deform.rs // deformation under edits -| | `-- regularize.rs // OBB snapping -| |-- config.rs // SubdivisionParams -| |-- error.rs // typed errors -| `-- viz/svg.rs // SVG output (feature-gated) -|-- tests/ -|-- examples/ -|-- benches/ -`-- figures/ // generated SVG/PDF artifacts -\end{lstlisting} - -\subsection{Public API Surface} - -\begin{lstlisting}[caption={Public API in \texttt{lib.rs}.}] -pub use config::SubdivisionParams; -pub use error::{ParcelError, SubdivisionError}; -pub use network::{RoadGraph, RoadId, NodeId}; -pub use parcel::{Parcel, ParcelId, EdgeKind}; - -pub fn subdivide_all( - graph: &RoadGraph, - params: &SubdivisionParams, -) -> Result; - -pub fn apply_road_edit( - parcels: &mut ParcelSet, - graph: &mut RoadGraph, - edit: RoadEdit, - params: &SubdivisionParams, -) -> Result; - -pub struct EditOutcome { - pub deformed: Vec, - pub regenerated: Vec, - pub condemned: Vec, - pub created: Vec, -} -\end{lstlisting} - -\subsection{Error Types} - -All fallible operations return \texttt{Result}. No panics in library code outside of \texttt{debug\_assert!}. - -\begin{lstlisting}[caption={Error enum.}] -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SubdivisionError { - #[error("road graph is not planar at node {0:?}")] - NonPlanarGraph(NodeId), - #[error("block boundary is not closed")] - OpenBlock, - #[error("subdivision parameters invalid: {0}")] - InvalidParams(String), - #[error("geometric operation failed: {0}")] - GeometryFailure(String), - #[error("feature not yet implemented: {0}")] - Unimplemented(&'static str), -} -\end{lstlisting} - -\subsection{Dependencies} - -\begin{table}[h] -\centering -\begin{tabular}{lll} -\toprule -\textbf{Crate} & \textbf{Version} & \textbf{Purpose} \\ -\midrule -\texttt{geo} & 0.28 & Polygon primitives, boolean ops \\ -\texttt{glam} & 0.29 & \texttt{DVec2} math \\ -\texttt{slotmap} & 1 & Stable IDs for graph entities \\ -\texttt{thiserror} & 2 & Error types \\ -\texttt{rand} & 0.8 & Deterministic RNG \\ -\texttt{rand\_chacha} & 0.3 & Reproducible RNG backend \\ -\texttt{svg} & 0.18 & SVG output (feature \texttt{viz}) \\ -\texttt{serde} & 1 & Serialization (feature \texttt{serde}) \\ -\midrule -\texttt{proptest} & 1 & Property-based testing (dev) \\ -\texttt{insta} & 1 & Snapshot testing (dev) \\ -\texttt{criterion} & 0.5 & Benchmarking (dev) \\ -\bottomrule -\end{tabular} -\caption{Dependency manifest.} -\end{table} - -% ======================================================= -\section{Idiomatic Rust Requirements} -\label{sec:idioms} - -The bar is high; this is a foundational crate that downstream code will depend on for years. - -\begin{itemize} - \item No \texttt{unwrap()} or \texttt{expect()} outside tests and examples. - \item No \texttt{unsafe} without a \texttt{// SAFETY:} comment. None is expected. - \item Newtype IDs (\texttt{ParcelId}, \texttt{RoadId}, \texttt{NodeId}); never expose raw indices. - \item \texttt{\#[must\_use]} on builders and on \texttt{EditOutcome}. - \item Iterator-first APIs where allocation is avoidable. - \item Borrowing over cloning; parcels and graphs are large. - \item \texttt{\#[non\_exhaustive]} on public enums likely to grow. - \item \texttt{cargo clippy --all-targets --all-features -- -D warnings} clean. - \item \texttt{cargo fmt --check} clean. - \item \texttt{\#![deny(missing\_docs)]} at crate root; all public items documented. - \item Module-level docs at the top of each \texttt{mod.rs}. - \item Feature flags: \texttt{serde}, \texttt{viz}. -\end{itemize} - -% ======================================================= -\section{Testing Strategy} -\label{sec:testing} - -\subsection{Three Layers} - -\paragraph{Unit tests} live in-module under \texttt{\#[cfg(test)]}. Every non-trivial geometric helper is tested directly. - -\paragraph{Integration tests} live in \texttt{tests/}. They build a road graph, subdivide, and check invariants. - -\paragraph{Property tests} use \texttt{proptest}. For each invariant I1--I7, a property test generates random valid road graphs and asserts the invariant. Generators produce graphs with 2--20 nodes, varying segment lengths, intersection angles in $[30^\circ, 150^\circ]$, and occasional degeneracies. - -\subsection{Snapshot Testing} - -Each example scenario in \texttt{examples/} renders to SVG and snapshots via \texttt{insta}. Visual regressions are caught when the SVG diff changes. Baseline SVGs are committed. - -\subsection{Coverage Requirement} - -Every named test in \cref{tab:degenerate} must exist and pass. There is no acceptable substitute. - -% ======================================================= -\section{Visualization and Figures} -\label{sec:figures} - -The crate produces SVG output via the \texttt{viz} feature. A dedicated example, \texttt{generate\_figures}, regenerates every figure referenced in this document. - -\subsection{Required Figures} - -\begin{longtable}{ll} -\toprule -\textbf{Filename} & \textbf{Content} \\ -\midrule -\endhead -\texttt{fig\_01\_grid\_block.svg} & Rectangular block subdivided \\ -\texttt{fig\_02\_curved\_road.svg} & Parcels on a curved frontage \\ -\texttt{fig\_03\_cul\_de\_sac.svg} & Pie-slice parcels around a bulb \\ -\texttt{fig\_04\_y\_intersection.svg} & Three-way intersection corner lots \\ -\texttt{fig\_05\_acute\_corner.svg} & Sliver-merge at sharp angle \\ -\texttt{fig\_06a\_road\_edit\_before.svg} & Scene before road move \\ -\texttt{fig\_06b\_road\_edit\_after.svg} & Same scene after; classes color-coded \\ -\texttt{fig\_07\_regularity\_slider.svg} & $\rho \in \{0.0, 0.5, 1.0\}$ side by side \\ -\texttt{plot\_subdivision\_perf.svg} & Criterion: parcels/s vs.\ block count \\ -\texttt{plot\_parcel\_area\_hist.svg} & Histogram, 10k-parcel stress scene \\ -\bottomrule -\caption{Required figure deliverables.} -\end{longtable} - -\subsection{Color Conventions} - -\begin{itemize}[noitemsep] - \item Roads: black, 2px stroke - \item Frontage edges: blue - \item Side edges: gray - \item Back edges: light gray, dashed - \item Parcel fill: pale yellow, 30\% opacity - \item Condemned parcels: red fill - \item Regenerated parcels: orange fill - \item Deformed parcels: green fill -\end{itemize} - -\subsection{Inclusion in This Journal} - -As figures are produced, they should be checked into \texttt{figures/} and included in this journal via: -\begin{lstlisting}[language=TeX, caption={Including a generated figure.}] -\begin{figure}[h] - \centering - \includegraphics[width=0.8\linewidth]{figures/fig_01_grid_block.pdf} - \caption{Frontage-first subdivision of a rectangular block.} - \label{fig:grid-block} -\end{figure} -\end{lstlisting} - -SVG figures should be converted to PDF via \texttt{rsvg-convert} or similar in a build script (\texttt{scripts/figs\_to\_pdf.sh}) for clean inclusion. - -% ======================================================= -\section{Performance Targets} -\label{sec:performance} - -Not the primary goal of milestone one, but tracked to catch regressions: - -\begin{table}[h] -\centering -\begin{tabular}{lll} -\toprule -\textbf{Operation} & \textbf{Scale} & \textbf{Target (release)} \\ -\midrule -\texttt{subdivide\_all} & 100 blocks & $< 50$ ms \\ -\texttt{subdivide\_all} & 10\,000 blocks & $< 5$ s \\ -\texttt{apply\_road\_edit} & 10k-parcel graph & $< 1$ ms per single-segment edit \\ -\bottomrule -\end{tabular} -\caption{Performance targets. Measured via Criterion.} -\end{table} - -% ======================================================= -\section{Out of Scope} -\label{sec:oos} - -Explicitly \emph{not} part of this milestone, to prevent scope creep: - -\begin{itemize}[noitemsep] - \item Buildings (parcels carry an opaque handle; the crate does not define buildings). - \item Zoning types (residential / commercial / industrial). Parcels are typeless. - \item Population, agents, simulation tick logic. - \item Rendering beyond SVG export. - \item Game engine integration (Bevy, Godot). - \item 3D / terrain. Everything is 2D in a flat plane. - \item Persistence formats beyond optional \texttt{serde} derives. - \item Multi-threading. Single-threaded for milestone one; APIs designed not to preclude \texttt{Send}/\texttt{Sync} later. -\end{itemize} - -% ======================================================= -\section{Claude Code Contract} -\label{sec:contract} - -This section is written to be acted on directly by an autonomous coding agent. - -\begin{cccontract}[title=Working Style] -\begin{enumerate} - \item Work iteratively. Get a single rectangular block working end-to-end with tests and an SVG figure before adding complexity. - \item After each major feature, regenerate figures and verify by inspection. - \item Write tests \emph{before} fixing bugs. Every degenerate case starts as a failing test. - \item When a degenerate case is fundamentally unhandleable (e.g.\ truly self-intersecting input), the test asserts the correct typed error, not success. - \item Commit frequently with messages naming the feature or invariant addressed. - \item When a design decision has multiple reasonable answers, pick one, document it as a Design Decision in this journal, and move on. Do not block. - \item If the spec is ambiguous or wrong, append a note to \cref{sec:revisions} listing what changed and why. Do not silently deviate. -\end{enumerate} -\end{cccontract} - -\begin{cccontract}[title=Definition of Done] -The milestone is complete when all of the following are simultaneously true: -\begin{enumerate} - \item \texttt{cargo build --all-features} succeeds with no warnings. - \item \texttt{cargo clippy --all-targets --all-features -- -D warnings} passes. - \item \texttt{cargo fmt --check} passes. - \item \texttt{cargo test --all-features} passes, including every named test in \cref{tab:degenerate}. - \item \texttt{cargo doc --all-features --no-deps} produces no warnings. - \item All figures listed in \cref{sec:figures} are generated and committed. - \item Performance targets in \cref{sec:performance} are met on a modern laptop. - \item This journal compiles with \texttt{latexmk -pdf journal.tex} and includes all generated figures. - \item \cref{sec:revisions} contains an entry per design decision made during implementation that deviated from or extended the spec. -\end{enumerate} -\end{cccontract} - -% ======================================================= -\section{Open Questions} -\label{sec:open} - -A running list. Resolved questions migrate to \cref{sec:revisions} as Design Decisions. - -\begin{openq}[title=Q1: Skeleton-based subdivision as a fallback] -Should the straight-skeleton-based subdivision algorithm be implemented in milestone one as a fallback for blocks where frontage-first produces ugly results, or deferred to milestone two? Frontage-first handles 90\% of cases cleanly; skeleton handles irregular blocks better but adds significant complexity. \textbf{Tentative}: defer to milestone two; stub returns \texttt{Unimplemented}. -\end{openq} - -\begin{openq}[title=Q2: Spatial index for affected-parcel lookup] -\texttt{apply\_road\_edit} needs to find all parcels with frontage on a given segment. Linear scan is $O(n)$ and fine for small cities; an R-tree or grid index becomes necessary at scale. When? \textbf{Tentative}: ship linear scan in milestone one with a documented hot-path comment, swap in \texttt{rstar} when the benchmark in \cref{sec:performance} exceeds budget. -\end{openq} - -\begin{openq}[title=Q3: Block ownership of back edges] -For parcels facing roads on opposite sides of a block, who owns the back edge? Two options: (a) medial line, both deform symmetrically; (b) fixed at creation, one parcel grows while the other shrinks. \textbf{Tentative}: (a) for milestone one; revisit when buildings need parcel-area stability. -\end{openq} - -\begin{openq}[title=Q4: Determinism of regeneration] -When a block is regenerated, the new parcel set differs from the old one. Should the regeneration be biased toward producing parcels that overlap maximally with the old ones, to preserve building footprints opportunistically? \textbf{Tentative}: no for milestone one; this is a milestone-two optimization. -\end{openq} - -% ======================================================= -\section{Design Decisions} -\label{sec:decisions} - -A record of decisions made during design and implementation. Each entry is dated and references the section it affects. - -\begin{decision}[D1, 2026-04-25 -- f64 throughout] -Decided to use \texttt{glam::DVec2} (f64) crate-wide rather than \texttt{Vec2} (f32). Single-precision loses too much accuracy on offset operations at city scales. Cost: $\sim$2$\times$ memory for vertex storage. Worth it. -\end{decision} - -\begin{decision}[D2, 2026-04-25 -- DCEL over adjacency list] -Decided on a half-edge / DCEL graph representation rather than an adjacency list. Block extraction (face traversal) is the dominant query and is $O(1)$ per step in DCEL. Cost: more complex insertion / deletion logic. -\end{decision} - -\begin{decision}[D3, 2026-04-25 -- Parcels indexed by slotmap key] -Decided to use \texttt{slotmap} for parcel storage rather than \texttt{Vec} indexing. Stable IDs are required for I4 (edit persistence): a parcel that survives a road edit must retain its identity for downstream consumers (buildings, agents). -\end{decision} - -\begin{decision}[D4, 2026-04-25 -- Frontage-first as primary algorithm] -Decided to implement frontage-first subdivision before any skeleton-based approach. Frontage-first handles the majority of real cases (rectangular blocks, gently curved roads, standard intersections). Skeleton-based subdivision is deferred (Q1). -\end{decision} - -% ======================================================= -\section{Revisions and Deviations} -\label{sec:revisions} - -This section is the project's running implementation log. It is written -during implementation, in chronological session entries. Each session -narrates what was built, what tests landed, what design decisions had -to be locked in, where the spec was extended or reinterpreted, and -what is queued for the next session. The intent is that future-me (or -another contributor) can read straight through and understand why the -code looks the way it does. - -\subsection*{Entry template} - -Sessions are dated subsections. Within a session, individual deviations -from the spec follow: - -\begin{verbatim} -\subsubsection*{YYYY-MM-DD --- Short title} -What changed: -Why: -Affected sections: -\end{verbatim} - -Newly-locked design decisions are recorded in the same colored -\texttt{decision} boxes as \cref{sec:decisions}, numbered continuing -from D4. Open questions that move forward are noted with the same Q -prefix as \cref{sec:open}. - -% ----------------------------------------------------------------- -\subsection{2026-04-25 --- Session 1: Milestone 0.1, rectangle end-to-end} -\label{sec:session-1} - -\paragraph{Goal of the session.} -\Cref{sec:contract}'s working-style contract is unambiguous: ``Get a -single rectangular block working end-to-end with tests and an SVG -figure before adding complexity.'' That is the floor for this -session. The aspiration is that, by the end, the public API surface -of \cref{sec:architecture} compiles, the rectangle case in -\cref{fig:grid-block} below is generated by the crate (not drawn by -hand), the tooling gates of \cref{sec:contract}'s Definition-of-Done -are green, and the named tests of \cref{tab:degenerate} either pass or -have an explicit, dated milestone-0.2 marker explaining what they -need. - -\paragraph{What got built.} -The crate at \texttt{road\_parceling/} now ships every module listed in -\cref{sec:architecture}: \texttt{geometry/} (polygon validation, half-plane -clipping, inward offsetting), \texttt{network/} (half-edge / DCEL graph -with stable slotmap ids, planar-graph validation, face extraction), -\texttt{parcel/} (subdivide, classify, regularize, deform), and the -feature-gated \texttt{viz/} (SVG renderer + figure generator). Public -API is exactly the surface declared in \cref{sec:architecture}: -\texttt{subdivide\_all}, \texttt{apply\_road\_edit}, the IDs, and the -\texttt{BuildingFitCheck} trait. Total: $\approx 2{,}500$ lines of -library code plus $\approx 400$ of integration tests. - -The frontage-first algorithm of \cref{sec:subdivision} is implemented -end-to-end. The corner-overlap problem (where parcels generated from -two adjacent frontages would intrude into each other's territory at -the shared block corner) is resolved by clipping each parcel against -the inward-pointing angle bisector of its two end corners. The -opposite-frontage problem is bounded by a per-edge depth cap derived -from a ray cast across the block from the edge midpoint --- this is -\cref{sec:open}'s Q3 option (a), now locked in (see below). - -\begin{figure}[h] - \centering - \includegraphics[width=0.8\linewidth]{figures/fig_01_grid_block.pdf} - \caption{The first figure produced by the crate: a $200 \times 100$ - rectangular block subdivided at default parameters - ($w_f=20$, $d_p=30$, $d_s=1$, $\rho=0$). Roads in black; parcel - frontage edges in blue, side edges gray, back edges light-gray - dashed. Corner pie-slice parcels are the bisector-clipped triangles - at each block corner. The empty central strip is the medial gap: - the block's vertical extent ($100$\,m) exceeds $2 d_p$, so the - depth cap stops parcels short of the centerline. Generated by - \texttt{cargo run --example generate\_figures --features viz}.} - \label{fig:grid-block} -\end{figure} - -\paragraph{Tooling gates.} -\texttt{cargo build --all-features}, -\texttt{cargo clippy --all-targets --all-features -- -D warnings}, -\texttt{cargo fmt --check}, and -\texttt{cargo doc --all-features --no-deps} are all clean. - -\paragraph{Test status.} -22 unit tests + 14 integration tests + 1 doc test pass (37 total). -Seven of \cref{tab:degenerate}'s 21 named tests are -\texttt{\#[ignore]}-d behind explicit milestone-0.2 markers: -\texttt{acute\_intersection\_15deg}, \texttt{acute\_intersection\_5deg}, -\texttt{cul\_de\_sac}, \texttt{curved\_road\_high\_curv}, -\texttt{road\_edit\_inverse\_restores}, -\texttt{road\_split\_preserves}, and -\texttt{building\_footprint\_persists}. The remaining 14 named tests -pass --- including the T- and Y-junctions, the huge ($1\,\text{km}$) -and tiny blocks, the disconnected graph, near-duplicate nodes, -isolated nodes, the planarity violation, and the colinear-roads case. -\texttt{numerical\_precision\_stress} runs at coordinates near $10^6$ -rather than the spec's $10^{20}$; this is documented in the test as a -practical f64 ceiling and revisited in milestone 0.2. - -\paragraph{Design decisions locked in this session.} -The spec's \cref{sec:decisions} ends at D4. Continuing: - -\begin{decision}[D5, 2026-04-25 -- BuildingHandle owns its -\texttt{BuildingFitCheck}] -\Cref{sec:edit-handling} describes \texttt{BuildingHandle} as -``opaque'', leaving room for either a bare id or an owning wrapper. -Locked in: \texttt{BuildingHandle} wraps a -\texttt{Box} so the deform pipeline can call -\texttt{fits\_in} locally. A bare-id design would have required -\texttt{apply\_road\_edit} to take a callback or side table, which -would change the spec'd signature in \cref{sec:architecture}. -\end{decision} - -\begin{decision}[D6, 2026-04-25 -- DCEL next/prev rule, explicit form] -The standard DCEL ``next is CCW after twin'' phrasing is ambiguous ---- whether the rotation goes CW or CCW around the target vertex -depends on whose perspective you take. The implementation locks in: -\texttt{half\_edge.next} is the \emph{predecessor} (CW neighbor) of -\texttt{half\_edge.twin} in the target vertex's CCW-sorted outgoing -list, with wrap; \texttt{half\_edge.prev} is the twin of the -\emph{successor} of the half-edge in its origin's list. The -\texttt{t\_intersection} test was the catalyst: with the rule -inverted, the T-stem face cycle enclosed the wrong region, so the -left and right blocks fused into one and parcels collapsed. See -\texttt{src/network/graph.rs} : \texttt{link\_next\_and\_prev} for the -derivation. -\end{decision} - -\begin{decision}[D7, 2026-04-25 -- \texttt{Polygon::new\_relaxed} for -block boundaries] -Invariant I1 forbids collinear triples in \emph{parcel} polygons. But -\cref{sec:degenerate}'s \texttt{colinear\_roads} case requires that -two end-to-end road segments still bound a valid block, which means -the block polygon legitimately has a collinear-corner vertex at the -shared node. Locked in: a relaxed polygon constructor (with the -collinear check skipped) for block boundaries; parcel construction -still calls the strict constructor. I1 applies to parcels, not blocks. -\end{decision} - -\begin{decision}[D8, 2026-04-25 -- Clippy scope is \texttt{all}, not -\texttt{pedantic}] -\Cref{sec:idioms} requires \texttt{cargo clippy --all-targets ---all-features -- -D warnings} clean. The crate's -\texttt{[lints.clippy]} enables the \texttt{all} group only. -\texttt{pedantic} fights numerical-code conventions (single-letter -coordinate names, struct-default reassignment) without buying real -safety, so it stays off. The \cref{sec:contract} gate is satisfied -verbatim. -\end{decision} - -\paragraph{Spec deviations recorded this session.} -The following entries follow the template above. - -\subsubsection*{2026-04-25 --- \texttt{apply\_road\_edit} is -regenerate-only in 0.1} - -What changed: The milestone-0.1 implementation of -\texttt{apply\_road\_edit} condemns every parcel touching an affected -road and re-subdivides every block from scratch. The -\texttt{Deformed} bucket of \texttt{EditOutcome} is always empty; -outcomes split between \texttt{Condemned}, \texttt{Created}, and (a -degenerate copy of) \texttt{Regenerated}. - -Why: A correct preserve-on-deform pipeline --- re-projecting frontage -endpoints onto the new road geometry, holding side and back vertices -fixed, applying \cref{sec:edit-handling}'s rotation/area thresholds, -calling \texttt{BuildingFitCheck::fits\_in} for every surviving -parcel --- is the headline of milestone 0.2. Shipping the -regenerate-only fallback first lets every other system (the edit -enum, the outcome categorization, the affected-roads lookup, the -edit-time invariants check) get exercised end-to-end, so the -preserve work in 0.2 is a pure addition rather than a rewrite. The -regenerate path is always safe; no parcel is silently corrupted, and -post-edit I1--I3 still hold. - -Affected sections: \cref{sec:edit-handling}. Invariants I6 and I7 -(determinism and reversibility) hold \emph{vacuously} for the -regenerate path. Three named tests -(\texttt{road\_edit\_inverse\_restores}, -\texttt{road\_split\_preserves}, -\texttt{building\_footprint\_persists}) are \texttt{\#[ignore]}-d -with markers pointing at this entry. - -\subsubsection*{2026-04-25 --- Setback is metadata-only depth} - -What changed: \Cref{sec:subdivision}'s algorithm walks the offset -segment $r' \subset \partial B'$, implying that a parcel's frontage -edge sits $d_s$ inside the block. But \cref{sec:subdivision}'s edge -classification requires the frontage edge to lie within -$\varepsilon_{\text{geom}}$ of a road segment. With -$\varepsilon_{\text{geom}} = 10^{-6}\,\text{m}$ and a default -$d_s = 1\,\text{m}$, these are mutually exclusive. - -Resolution: parcels touch the road. The frontage edge is on the road -within $\varepsilon_{\text{geom}}$, satisfying the I2 reading -literally. The setback parameter is folded into the parcel depth -($\text{total\_depth} = d_s + d_p$), and the front-most $d_s$ of the -parcel is treated as an unbuildable margin held in metadata --- not -as an extra geometric edge. (A six-vertex strip with the setback as -a real edge would violate I1's no-collinear-triple rule, since the -three vertices along each side of the setback strip are collinear -with the road tangent.) - -Why: I2 is the load-bearing invariant, and the spec text in -\cref{sec:invariants} pins it specifically to ``coincident with a -road segment''. Reinterpreting setback as a depth offset is the -minimum change that keeps I2 honest while still exposing the knob -the spec defines. - -Affected sections: \cref{sec:invariants} (I2 reading clarified); -\cref{sec:subdivision} (algorithm step 1 reinterpreted). - -\subsubsection*{2026-04-25 --- Regularization pass is a stub} - -What changed: \texttt{parcel/regularize.rs} is a no-op for milestone -0.1. \texttt{SubdivisionParams::regularity} default is $0$, so the -non-regularized output is what the figures show. The three panels of -\texttt{fig\_07\_regularity\_slider\_*.svg} are therefore -byte-identical until the OBB snap lands. - -Why: Frontage-first subdivision already produces orthogonal parcels -on straight road frontage, so the visible payoff of OBB-snapping -only appears once curved or skew frontages are in play -(\cref{sec:degenerate}'s \texttt{curved\_road\_high\_curv}). -Sequencing the regularization pass after curved-road support avoids -implementing it twice --- once against the milestone-0.1 input -shapes and again against the milestone-0.2 ones. - -Affected sections: \cref{sec:regularization}. - -\subsubsection*{2026-04-25 --- tcolorbox preamble fix} - -What changed: The original env definitions for -\texttt{invariant} and \texttt{decision} took an optional argument -\texttt{[\#1]} and passed it through to tcolorbox keys. Calls like -\verb|\begin{invariant}[I1: Polygon validity]| therefore tried to -set a tcb key named \texttt{I1: Polygon validity}, which fails on -modern \texttt{tcolorbox} (the same call is silently ignored on -older versions). The envs now interpret \texttt{\#1} as the title, -defaulting to ``Invariant'' / ``Design Decision'' when omitted. - -Why: Without this, \texttt{latexmk -pdf journal.tex} fails on the -first invariant box and \cref{sec:contract}'s -``This journal compiles'' clause cannot be met. The fix preserves -every existing call site and changes only the preamble macros. - -Affected sections: preamble only; visible call sites in -\cref{sec:invariants} and \cref{sec:decisions} render unchanged. - -\paragraph{Open questions touched.} - -\begin{decision}[Q1 \texttt{->} closed for milestone 0.1, deferred to 0.2] -\Cref{sec:open}'s Q1 (skeleton-based subdivision as a fallback) -remains stubbed at \texttt{Unimplemented}. The frontage-first -algorithm is complete enough for the rectangle, T, Y, and -disconnected-graph cases that drive the milestone-0.1 deliverables. -Skeleton fallback is now bundled with \texttt{cul\_de\_sac} and -\texttt{curved\_road\_high\_curv} into the milestone-0.2 queue. -\end{decision} - -\begin{decision}[Q3 \texttt{->} option (a), confirmed] -\Cref{sec:open}'s Q3 (block ownership of back edges) is option (a) -in the implementation: each frontage's parcels extrude to a depth -bounded by a per-edge ray-cast cap (half the perpendicular distance -to the nearest other block edge). Symmetric. The default-parameter -rectangle in \cref{fig:grid-block} hits the cap on every parcel and -shows the expected medial gap. No revision to the spec's tentative -answer. -\end{decision} - -\paragraph{Next session --- milestone 0.2 queue.} -The priority order for the next session, in roughly increasing -implementation cost: -\begin{enumerate}[noitemsep] - \item Preserve-on-deform pipeline for \texttt{MoveNode}: project - frontage endpoints onto the new road, hold side+back fixed, - validate against the rotation/area thresholds in - \cref{sec:edit-handling}. Unlocks \texttt{road\_edit\_micro\_move}'s - ``all parcels deformed; none regen'' assertion and starts on I7. - \item \texttt{SplitSegment} preserve path. Splitting a parcel's - frontage at the new node should produce two parcels sharing the - side edges. Unlocks \texttt{road\_split\_preserves}. - \item \texttt{BuildingFitCheck} eviction in the deform path. Trait - is already wired (D5); just needs to be called. Unlocks - \texttt{building\_footprint\_persists}. - \item Sliver-merge for acute corners ($<$ some threshold, probably - $30^\circ$). Detect during subdivision and merge with neighbor. - Unlocks \texttt{acute\_intersection\_15deg/5deg}. - \item Curved-road support (depth cap on tight curvature; arc - walking). Unlocks \texttt{curved\_road\_high\_curv} and starts on - \texttt{cul\_de\_sac}. - \item Pie-slice parcels for cul-de-sac bulbs. Unlocks - \texttt{cul\_de\_sac}. - \item OBB regularization pass (\cref{sec:regularization}). Now - meaningful given curved-road parcels. - \item Inverse-restore round trip - (\texttt{road\_edit\_inverse\_restores}). Falls out of items 1--2 - plus a known-pristine snapshot. -\end{enumerate} - -% Future sessions land below this line as new \subsection entries. - -% ----------------------------------------------------------------- -\subsection{2026-04-25 --- Session 4: Shared-vertex registry -(no-drift guarantee)} -\label{sec:session-4} - -\paragraph{Goal of the session.} -The user asked the right load-bearing question: ``are edges shared -objects between parcels?'' Up to this point the answer was no — each -\texttt{Parcel} stored its own \texttt{Polygon} (a \texttt{Vec}) -and adjacent parcels carried separate copies of their shared -boundary line that just happened to coincide geometrically. That -works for clean-room subdivision, but the moment we stack edits, two -copies of the ``same'' vertex can drift apart by floating-point -noise. With the long-term plan of agents splitting and merging -parcels, drift is a hard no. - -This session installs the first half of the eventual full DCEL — a -\emph{shared-vertex registry} on \texttt{ParcelSet}. Each polygon -vertex now resolves to a stable \texttt{VertexId}; coincident -positions resolve to the same \texttt{VertexId} via a spatial-hash -lookup; mutations propagate through every parcel that references -the vertex via \texttt{ParcelSet::move\_vertex}. Edge identity -(parcel-layer half-edges) is a follow-up; vertex identity alone is -enough to deliver the no-drift contract. - -\paragraph{Decisions locked in this session.} - -\begin{decision}[D17, 2026-04-25 -- Shared-vertex registry on -\texttt{ParcelSet}] -\texttt{ParcelSet} owns a \texttt{SlotMap} -plus a \texttt{HashMap<(i64, i64), Vec>} spatial index -keyed at \(\varepsilon_{\text{geom}}\) resolution. On parcel -insertion every polygon vertex is snapped to the registry — if a -vertex within \(\varepsilon_{\text{geom}}\) already exists, the -parcel reuses that \texttt{VertexId}; otherwise a new entry is -created. Each \texttt{VertexRecord} carries a back-reference list -of \texttt{(ParcelId, vertex\_index)} pairs. -\end{decision} - -\begin{decision}[D18, 2026-04-25 -- \texttt{move\_vertex} -write-through propagation] -\texttt{ParcelSet::move\_vertex(vid, new\_pos)} updates the -registry's stored position \emph{and} writes the new position into -every referring parcel's polygon at the recorded index. Adjacent -parcels' shared boundaries can never drift apart — they are the -same physical vertex, mutated once. -\end{decision} - -\begin{decision}[D19, 2026-04-25 -- Deform pipeline is -propose-then-apply] -\texttt{deform\_parcel\_after\_road\_move} no longer mutates the -parcel — it returns a list of proposed -\texttt{(VertexId, new\_pos)} moves alongside its -\texttt{Deformed} / \texttt{Untouched} / \texttt{Condemned} / -\texttt{Regenerate} verdict. The outer loop validates each parcel, -collects all proposed moves, and applies them via -\texttt{move\_vertex} after the verdicts are in. Conflicting -proposals on the same vertex are last-one-wins, but in practice the -deform parameterization makes all referrers agree by construction. -\end{decision} +\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=*] - \item \texttt{parcel/mod.rs}: \texttt{VertexId}, - \texttt{VertexRecord}, registry slot maps, the spatial-hash - bucket, \texttt{find\_or\_create\_vertex}, - \texttt{vertex\_position}, \texttt{vertex\_refs}, and - \texttt{move\_vertex}. \texttt{ParcelSet::insert} now snaps each - polygon vertex into the registry; \texttt{ParcelSet::remove} - unregisters them. - \item \texttt{Polygon::set\_vertex\_unchecked}: an in-place - vertex update that bypasses I1 validation. The shared-vertex - registry calls it during \texttt{move\_vertex} (caller is - responsible for validating the resulting polygon — the deform - pipeline does this in its propose phase). - \item \texttt{parcel/deform.rs}: the move-node path is now - propose-then-apply. \texttt{DeformResult::Deformed} carries the - proposed vertex moves; the outer loop applies them at the end. - \item \texttt{tests/degenerate.rs::shared\_vertex\_no\_drift\_under\_repeated\_edits} - — runs 50 random small node moves against a rectangle, plus an - inverse, then asserts that every shared boundary point is - bit-for-bit identical across every parcel that references it. - Strict floating-point equality, not within \(\varepsilon\). +\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{Deviations from spec.} +\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). -\subsubsection*{2026-04-25 --- \texttt{vertex\_ids} field on -\texttt{Parcel}} +\paragraph{Code tour: corner parcel construction.} -What changed: \texttt{Parcel} gained a parallel -\texttt{vertex\_ids: Vec} field alongside -\texttt{polygon}. The field is \texttt{pub(crate)} and populated -by \texttt{ParcelSet::insert}; outside callers keep using -\texttt{polygon} / \texttt{vertices} unchanged. +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: -Why: spec §6.2 names the public surface of \texttt{Parcel} but not -its internal fields. Adding \texttt{vertex\_ids} preserves the API -while giving the registry the back-reference it needs without -costing a separate lookup. +\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])?; -Affected sections: \cref{sec:architecture} (internal data layout -only). +// 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} -\subsubsection*{2026-04-25 --- Registry orphans are GC-deferred} +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. -What changed: when a parcel is removed, its references are pulled -out of every \texttt{VertexRecord}'s \texttt{refs} list, but -records that end up with empty refs are left in the registry. They -get reused when a future insert lands within -\(\varepsilon_{\text{geom}}\) of them. +\paragraph{Code tour: preserve-on-deform.} -Why: the spatial hash key is keyed at -\(\varepsilon_{\text{geom}}\) resolution, so reusing the same -\texttt{VertexId} is the desired behaviour for back-and-forth edits -(insert, remove, re-insert at the same spot keeps the same id). -Garbage collection at remove-time would force the next insert to -re-snap and produce a new id, breaking that property. A periodic -sweep is in the milestone-0.5 backlog. +When a road moves, the affected parcels are deformed by re-projecting their frontage endpoints from the old road parameter to the new: -Affected sections: none (internal). +\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; -\paragraph{Verifiable guarantee.} -\texttt{shared\_vertex\_no\_drift\_under\_repeated\_edits} encodes -the contract: across many edits with arbitrary deltas, parcels that -share a boundary vertex see bit-for-bit identical positions for -that vertex. This holds because they read from the same registry -record on each query — there is no separate per-parcel copy of the -position to drift. +let p_a = parcel.polygon.vertices()[fi]; // frontage start +let p_b = parcel.polygon.vertices()[(fi + 1) % n]; // frontage end -\paragraph{What's next --- milestone 0.5 queue.} +// 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; -\begin{enumerate}[noitemsep] - \item Layer half-edge / DCEL edge identity on top of the - vertex registry. Each parcel-layer edge gets a stable id; pairs - of consecutive shared vertices automatically constitute a shared - edge. This unlocks ``find the parcel on the other side of this - edge'' in O(1) and is the substrate for split/merge. - \item Parcel \emph{merge}: given two parcels that share an edge, - unify them into one whose boundary is the symmetric difference. - Trivial once edge identity exists; very awkward without. - \item Parcel \emph{split}: introduce a new edge crossing a - parcel's interior; partition the boundary at the split's - endpoints. Also clean once edges have identity. - \item Registry GC sweep — drop empty \texttt{VertexRecord}s and - their spatial-hash entries periodically (every Nth edit, or on - request). Mostly a memory hygiene concern. - \item True sliver-merge for acute corners (carried over from - milestone 0.3's queue). - \item Curved-road depth clamping (carried over). -\end{enumerate} +// 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} -% ----------------------------------------------------------------- -\subsection{2026-04-25 --- Session 3: Milestone 0.3 (I3 fix, -minimum-change deformation, SplitSegment preserve)} -\label{sec:session-3} +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{Goal of the session.} -Three things came out of looking at session-2's figures: +\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). -\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{Tests.} 24 unit + 16 integration + 1 doc passing; 5 named tests still \texttt{\#[ignore]}-d. -\paragraph{Decisions locked in this session.} +\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. -\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} +\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. -\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} +% ======================================================= +\section{Session 3 --- M0.3: I3 fix, minimum-change deform, SplitSegment preserve} +\label{sec:s3} +\textit{2026-04-25} -\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{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=*] - \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. +\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{Deviations from spec.} +\paragraph{Decisions.} D14 (minimum-change deformation, ``no-op preserve''), D15 (bisector-clip at acute, not obtuse), D16 (SplitSegment preserve on 4-vertex parcels). -\subsubsection*{2026-04-25 --- Inverse-restore is centroid-bounded, -not vertex-exact} +\paragraph{Code tour: line-unchanged check (Untouched verdict).} -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. +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: -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. +\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} -Affected sections: \cref{sec:edit-handling} (I7 reading clarified). +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. -\subsubsection*{2026-04-25 --- \texttt{Untouched} is not a public -\texttt{EditOutcome} bucket} +\paragraph{Code tour: split-segment preserve.} -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. +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: -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. +\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(); -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). +// Q_star is the perpendicular projection of P onto the back edge. +let q_star = q_a + (q_b - q_a) * t_local; -\paragraph{Test status.} -24 unit tests, 22 integration tests (was 16 in session 2), 1 doc -test. \emph{All 21 named tests of \cref{tab:degenerate} are now -active and passing} — \texttt{cul\_de\_sac} and -\texttt{curved\_road\_high\_curv} run on polyline approximations of -their respective curved geometries and verify I1–I3 hold. True -pie-slice subdivision and proper depth-clamping on tight curvature -are still milestone-0.4 work, but the library does not crash and the -tests no longer \texttt{\#[ignore]}-d. Plus a bonus -\texttt{y\_intersection\_no\_overlaps} regression test that was the -trigger for the I3-fix work this session. +// 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} -\paragraph{What's next --- milestone 0.4 queue.} +The building stays with the larger half. Both new parcels go in as \texttt{created}; the original goes in as \texttt{condemned}. -\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} +\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. -% ----------------------------------------------------------------- -\subsection{2026-04-25 --- Session 2: Milestone 0.2 (corner parcels, -sticky back edges, preserve-on-deform)} -\label{sec:session-2} +\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{Goal of the session.} -Three big rocks for milestone 0.2, set during the kickoff conversation: -fix the corner parcels (today's bisector-clipped triangles are -unionized into proper 4--6 sided corner parcels), make road moves -preserve back edges and only change a single parcel (instead of -rippling to its back-to-back neighbor), and start tracking -performance --- per-phase wall-clock timing surfaced in this section, -since real-time placement is the eventual gameplay target. Deferred -to milestone 0.3: literal road width (the \texttt{setback} parameter -is the working placeholder), curved-road handling, sliver-merge for -acute corners, OBB regularization. - -\paragraph{Decisions locked in at session kickoff.} - -\begin{decision}[D9, 2026-04-25 -- Build-first corner parcels] -At each ``real'' corner, the corner parcel is built before the -frontage walk and the walk on each adjacent road then starts past -the corner's extent. (The Voronoi delete-and-refill alternative was -considered and rejected --- build-first has fewer edge-case -surprises and is naturally deterministic without a second pass.) -\end{decision} - -\begin{decision}[D10, 2026-04-25 -- Corner radius is the average -frontage width] -The corner extends \(R = \texttt{params.frontage\_width}\) along each -adjacent road and \(\texttt{params.depth}\) perpendicular into the -block. Result: a 4-vertex parallelogram when \(R=\texttt{depth}\), a -6-vertex L-shape otherwise. Kept malleable so future tuning can -trade off corner footprint vs. mid-block parcel count. -\end{decision} - -\begin{decision}[D11, 2026-04-25 -- ``Real corner'' definition] -The corner-parcel routine fires at any block-boundary vertex whose -underlying graph node has degree \(\geq 3\) (T, Y, +) \emph{or} -degree 2 with a bend angle below \(150^\circ\) (so the four \(90^\circ\) -corners of a rectangle qualify; a near-collinear continuation does -not). -\end{decision} - -\begin{decision}[D12, 2026-04-25 -- Road width deferred to 0.3; -\texttt{setback} is the placeholder] -A literal \texttt{road\_width} parameter on \texttt{Road} would -ripple through block extraction, frontage geometry, and the -visualization at once. Instead we keep roads as centerlines and -treat \texttt{setback} as the catch-all for ``space the road plus -its surrounding right-of-way actually consumes'' --- downstream -consumers (a future game renderer) draw the road on top of the -centerline at whatever width they like, and tune \texttt{setback} -upward to give the visual road room. Revisited in milestone 0.3 -when curved-road handling forces re-touching this code anyway. -\end{decision} - -\begin{decision}[D13, 2026-04-25 -- Sticky back edges, lite] -Each parcel stores its full polygon in absolute world coordinates. -The deform pipeline is responsible for moving \emph{only} the -frontage vertices when a road edit fires; back and side vertices -stay put. No explicit shared-edge tracking between adjacent parcels. -This achieves the user-requested invariant (a road move changes one -parcel, not two) without a parcel-layer DCEL refactor. Trade-off: -when adjacent parcels' back edges drift apart by more than -\(\varepsilon_{\text{geom}}\) (e.g. the back-to-back neighbor was -condemned and re-created from a different generator state), a sliver -gap can open between them. I3 still holds (parcels never overlap); -gap detection is added to the milestone-0.3 backlog. -\end{decision} - -\paragraph{Status during writing.} -Session in progress; sub-paragraphs below are written as work lands. -Performance numbers and figures are filled in once the corresponding -features compile. - -\paragraph{Performance instrumentation.} -\texttt{SubdivisionStats} lands as a public type alongside -\texttt{ParcelSet}, returned by the new -\texttt{subdivide\_all\_with\_stats} entry point (the existing -\texttt{subdivide\_all} is now a thin wrapper that drops the stats). -Per-phase wall-clock timing is collected for topology rebuild, block -extraction, and the cumulative per-block subdivision; aggregate -parcels-per-second and time-per-parcel are derived properties. -Numbers will be folded into this section once the rest of 0.2 lands. - -\paragraph{Corner parcel rework.} -The bisector-clipped triangles of milestone 0.1 are gone. Each real -corner now produces a four-vertex parcel whose frontage edge lies on -the longer of the two adjacent block edges (length \(R\)) and whose -side2 lies along the other adjacent road (length \texttt{depth}). The -construction has two flavors --- ``frontage on next'' or ``frontage -on prev'' --- depending on which road wins; in both cases the parcel -is the rectangle (or skewed parallelogram for non-90° corners) -\(R\)-by-\texttt{depth} with one corner pinned at the intersection -vertex. - -The frontage walk on each block edge now starts past the corner -footprint and ends past the next corner's footprint. Per-edge -consumption is asymmetric: the corner whose frontage lies on this -edge consumes \(R\); the corner whose side2 lies on this edge -consumes \texttt{depth}. The bisector-clip pass is removed entirely. - -For the default 200×100 rectangle the figure now shows four corner -rectangles each 20×30, eight 20×30 middle parcels along the long -roads, two 20×30 middle parcels along each short road, and the -expected medial gap inside. - -\paragraph{Preserve-on-deform pipeline.} -\texttt{apply\_road\_edit} now has three code paths: -(a) for \texttt{MoveNode}, it re-projects each affected parcel's -frontage endpoints onto the new road geometry, holding side and back -vertices fixed in absolute coordinates (D13). Validation against -the rotation threshold and the area/frontage minimums decides -whether the parcel is \texttt{Deformed}, \texttt{Condemned}, or -needs the block re-subdivided. Surviving parcels with attached -buildings get their \texttt{BuildingFitCheck::fits\_in} called; on -\texttt{false} the building is evicted and the parcel id is added -to a new \texttt{evicted\_buildings} bucket on \texttt{EditOutcome} -(extension to spec §4.2). -(b) for \texttt{DeleteSegment}, all parcels on the deleted road are -condemned and any face left without parcels is re-subdivided. -(c) for \texttt{SplitSegment} and \texttt{InsertSegment}, the -milestone-0.1 regenerate-only fallback still applies — split-preserve -is the headline of milestone 0.3. - -The previously-\texttt{\#[ignore]}-d -\texttt{road\_edit\_inverse\_restores} and -\texttt{building\_footprint\_persists} tests are now active and pass. - -\paragraph{New figures.} -\Cref{fig:y-intersection} shows three blocks formed by a Y at 120° -inside an equilateral triangle. The 30°-interior outer corners are -below the acute-skip threshold, so no corner parcel is built there; -the block-clip pass keeps every regular parcel inside its block. -\Cref{fig:edit-before} and \cref{fig:edit-after} show a 200×100 -rectangle before and after a 8\,m \texttt{MoveNode} on its -bottom-right corner; surviving parcels in the after-figure are -tinted by their \texttt{EditOutcome} category (deformed = green). - -\begin{figure}[h] - \centering - \includegraphics[width=0.8\linewidth]{figures/fig_01_grid_block.pdf} - \caption{Default 200×100 rectangle subdivided by milestone 0.2. - Compare with milestone 0.1: the four corners are now proper - rectangle corner parcels rather than bisector-clipped triangles.} - \label{fig:grid-block-v02} -\end{figure} - -\begin{figure}[h] - \centering - \includegraphics[width=0.6\linewidth]{figures/fig_04_y_intersection.pdf} - \caption{Y intersection. Three roads at 120° from the origin meet - three outer triangle vertices; corner parcels appear at the inner - 120° corners (origin) and are skipped at the 30° outer corners.} - \label{fig:y-intersection} -\end{figure} - -\begin{figure}[h] - \centering - \begin{minipage}{0.48\textwidth} - \includegraphics[width=\linewidth]{figures/fig_06a_road_edit_before.pdf} - \caption{Before: \cref{fig:grid-block-v02}'s rectangle.} - \label{fig:edit-before} - \end{minipage}\hfill - \begin{minipage}{0.48\textwidth} - \includegraphics[width=\linewidth]{figures/fig_06b_road_edit_after.pdf} - \caption{After: bottom-right corner moved 8\,m right; deformed - parcels are green.} - \label{fig:edit-after} - \end{minipage} -\end{figure} - -\paragraph{Deviations from spec.} - -\subsubsection*{2026-04-25 --- Acute-corner skip + per-parcel -block-clip} - -What changed: For block-boundary vertices with interior angle -\(< 60^\circ\), no corner parcel is built. Instead, the regular -frontage walks at those vertices proceed as if the corner were -non-real, and every parcel produced (corner or regular) is then -clipped against the inward half-planes of the block boundary. This -keeps parcels strictly inside the block at acute corners while the -sliver-merge logic catches up in milestone 0.3. - -Why: The R×depth corner construction implicitly assumes interior -\(\geq 60^\circ\). Below that threshold the perpendicular extension -of stripe-prev exits the block on the t\_in side. The block-clip -pass is conservative (correct for convex blocks; acceptable -over-clipping for mild concavities) and does the right thing for -the Y-intersection figure. - -Affected sections: \cref{sec:subdivision} (algorithm gains a -post-clip step); \cref{sec:degenerate}'s acute tests still -\texttt{\#[ignore]}-d pending sliver-merge. - -\subsubsection*{2026-04-25 --- \texttt{EditOutcome.evicted\_buildings}} - -What changed: Added a \texttt{evicted\_buildings: Vec} -field to \texttt{EditOutcome}, enumerating parcels whose attached -building was dropped during deformation because -\texttt{BuildingFitCheck::fits\_in} returned false. - -Why: Spec §4.2 lists four outcome buckets but doesn't separate -``parcel survived but its building did not''. Without this bucket, -caller-side game logic would have to walk every deformed parcel and -check its building presence against a pre-edit snapshot. A separate -bucket is cheap and removes that walk. - -Affected sections: \cref{sec:edit-handling}. - -\paragraph{Performance.} - -Numbers from \texttt{cargo run --release --example perfprobe} on a -M-series Mac, after a few warmup iterations: - -\begin{center} -\begin{tabular}{lrrrr} -\toprule -\textbf{Scene} & \textbf{Blocks} & \textbf{Parcels} & \textbf{Total time} & \textbf{µs / parcel} \\ -\midrule -Single 200×100 rectangle & 1 & 24 & 15 µs & 0.63 \\ -5×5 grid of disjoint rectangles & 25 & 605 & 220 µs & 0.36 \\ -\bottomrule -\end{tabular} -\end{center} - -Both well under the spec §9 targets (100 blocks $<$ 50\,ms; 10\,000 -blocks $<$ 5\,s). At roughly 2--3 million parcels per second on this -hardware, the gameplay constraint of ``feels instant when a road is -placed'' (informal user requirement) is satisfied with margin --- a -typical road placement that touches a hundred parcels lands in well -under a millisecond. - -\paragraph{Test status.} -24 unit tests, 16 integration tests, 1 doc test, and the new -\texttt{stats\_report\_nonzero\_phases} unit test all pass. Five -named tests remain \texttt{\#[ignore]}-d for milestone 0.3 -(\texttt{acute\_intersection\_15deg/5deg}, -\texttt{cul\_de\_sac}, \texttt{curved\_road\_high\_curv}, -\texttt{road\_split\_preserves}). \texttt{cargo clippy ---all-targets --all-features -- -D warnings} and -\texttt{cargo fmt --check} are clean. - -\paragraph{What's next --- milestone 0.3 queue.} -\begin{enumerate}[noitemsep] - \item \texttt{SplitSegment} preserve path: split the parcel whose - frontage spans the new node into two parcels sharing the side - edges. Unlocks \texttt{road\_split\_preserves}. - \item Sliver-merge for acute corners: detect interior \(< 60^\circ\), - merge the would-be corner parcel territory with its longer - neighbor along the same road. Unlocks the two - \texttt{acute\_intersection} tests and tidies up - \cref{fig:y-intersection}. - \item Curved-road support: discretize curves into polylines with - variable depth caps based on local radius. Unlocks - \texttt{curved\_road\_high\_curv} and starts on \texttt{cul\_de\_sac}. - \item Pie-slice parcels for cul-de-sac bulbs. - \item OBB regularization pass (\cref{sec:regularization}). - \item \cref{sec:open}'s Q4: regeneration biased to preserve - building footprints. - \item Q2: spatial index (rstar) for affected-parcel lookup, once - the linear scan in \texttt{move\_node\_path} starts to bite. -\end{enumerate} +\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). % ======================================================= -\appendix -\section{Notation Reference} -\label{app:notation} +\section{Session 4 --- M0.4: Shared-vertex registry} +\label{sec:s4} +\textit{2026-04-25} -\begin{tabular}{ll} -\toprule -\textbf{Symbol} & \textbf{Meaning} \\ -\midrule -$G = (V, E)$ & Road graph (planar) \\ -$B$ & Block boundary \\ -$B'$ & Developable polygon, $B$ offset inward by $d_s$ \\ -$d_s$ & Road setback distance \\ -$w_f, \sigma_f$ & Target frontage width, variance \\ -$d_p, \sigma_d$ & Target parcel depth, variance \\ -$\rho$ & Regularity slider, $[0, 1]$ \\ -$w_{\min}, A_{\min}$ & Minimum frontage and area \\ -$\alpha_{\max}$ & Maximum side-edge rotation before regen \\ -$\varepsilon_{\text{geom}}$ & Geometric tolerance, $10^{-6}$ m \\ -$\varepsilon_{\text{area}}$ & Area tolerance, $10^{-9}$ m$^2$ \\ -$\varepsilon_{\text{angle}}$ & Angular tolerance, $10^{-4}$ rad \\ -\bottomrule -\end{tabular} +\paragraph{Goal.} Up to this session, each parcel stored its own polygon (a \texttt{Vec}) 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} plus \texttt{vertex\_grid: HashMap<(i64, i64), Vec>} (a spatial hash at $\varepsilon_{\text{geom}}$ resolution). + \item \texttt{Parcel} gained a \texttt{vertex\_ids: Vec} 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} 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}