diff --git a/journal.pdf b/journal.pdf index 508911d..53d5aa0 100644 Binary files a/journal.pdf and b/journal.pdf differ diff --git a/journal.tex b/journal.tex index 7265f1e..332c8fe 100644 --- a/journal.tex +++ b/journal.tex @@ -1023,6 +1023,161 @@ implementation cost: % Future sessions land below this line as new \subsection entries. +% ----------------------------------------------------------------- +\subsection{2026-04-25 --- Session 3: Milestone 0.3 (I3 fix, +minimum-change deformation, SplitSegment preserve)} +\label{sec:session-3} + +\paragraph{Goal of the session.} +Three things came out of looking at session-2's figures: + +\begin{enumerate}[noitemsep] + \item The Y-intersection figure had visible parcel overlaps. A + programmatic test confirmed a real I3 violation — parcels from + adjacent block edges were converging into the same interior near + the acute outer corners. + \item The road-edit figure was \emph{too} eager to deform parcels. + When a road's bottom-right corner moved outward, both the bottom + road's parcels (whose road just got longer along the same line) + and the right road's parcels (whose road actually rotated) showed + up as deformed. The user's preference: only deform parcels on a + road whose direction \emph{actually changed}. + \item Pushing on milestone 0.3 work — \texttt{SplitSegment} + preserve, and as much of the acute-corner / curve story as we can + fit. +\end{enumerate} + +\paragraph{Decisions locked in this session.} + +\begin{decision}[D14, 2026-04-25 -- Minimum-change deformation +(``no-op preserve'')] +When a road edit doesn't change a road's \emph{line} — only its +endpoints shift along the same line, e.g.\ when one node moves +parallel to the road — a parcel whose frontage is still entirely on +the new segment is reported as ``Untouched''. It stays at its +absolute coordinates and isn't added to any +\texttt{EditOutcome} bucket. Trade-off: strict +\emph{vertex-by-vertex} inverse-restore is no longer guaranteed +(corner parcels that got displaced by an earlier edit aren't pulled +back to their original spot when the edit is reversed); the +\texttt{road\_edit\_inverse\_restores} test now checks centroid +drift bounded by the edit delta instead. +\end{decision} + +\begin{decision}[D15, 2026-04-25 -- Bisector-clip at acute corners, +not obtuse] +Acute corners (interior angle $< 60^\circ$) get no corner parcel — +the rectangle/parallelogram construction would extend past the +wedge boundary. Instead, regular parcels along the two edges meeting +at an acute corner get bisector-clipped at that corner, so their +territories stay separated. Obtuse corners ($\geq 60^\circ$) keep +the milestone-0.2 corner parcel and need no bisector clip. +\end{decision} + +\begin{decision}[D16, 2026-04-25 -- \texttt{SplitSegment} preserve +on 4-vertex parcels] +When a road is split, parcels whose frontage entirely on one side of +the split point have their \texttt{frontage\_road} rebound (no +geometric change, reported as Deformed). Parcels whose frontage +spans the split point are cut into two parcels along a perpendicular +through the split — only for the simple 4-vertex (rectangle) case; +more complex polygon shapes fall back to Condemn. Buildings stay +with the larger of the two halves. +\end{decision} + +\paragraph{What landed.} + +\begin{itemize}[leftmargin=*] + \item \texttt{tests/degenerate.rs::y\_intersection\_no\_overlaps} — a + programmatic centroid-in-other-polygon check. Caught the real + Y-intersection I3 violation; passes after the bisector-clip fix. + \item \texttt{subdivide.rs}: re-introduced + \texttt{corner\_bisector} and \texttt{clip\_with\_bisector}, called + conditionally for parcels at acute corners only. + \item \texttt{deform.rs}: new \texttt{DeformResult::Untouched} + branch and the line-unchanged check that returns it. Parcels in + \texttt{Untouched} state are left alone. + \item \texttt{deform.rs}: \texttt{split\_segment\_path} + + \texttt{rebind\_frontage\_road} helpers. \texttt{road\_split\_preserves} + is now active and passing. + \item Two acute-intersection tests + (\texttt{acute\_intersection\_15deg/5deg}) are now active and pass + the I1–I3 invariant check; they don't yet exercise full + sliver-merge but no longer trigger panics or overlaps. +\end{itemize} + +\paragraph{Deviations from spec.} + +\subsubsection*{2026-04-25 --- Inverse-restore is centroid-bounded, +not vertex-exact} + +What changed: \cref{sec:edit-handling}'s I7 (``Applying an edit and +then its inverse restores the original parcel set within +$\varepsilon_{\text{geom}}$ for all preserved parcels'') is satisfied +in spirit but not literally. With the minimum-change deformation +path of D14, parcels whose frontage line didn't change keep their +absolute coordinates; an earlier edit might have left a corner +parcel at, say, $(0.5, 0)$ instead of its original $(0, 0)$, and the +inverse edit will not pull it back. The +\texttt{road\_edit\_inverse\_restores} test instead asserts that +each surviving parcel's \emph{centroid} drifts no more than the +magnitude of the edit delta itself. + +Why: ``minimal cost'' deformation is what the user explicitly asked +for. Strict vertex-exact inverse-restore is incompatible with that +goal — it forces every parcel touching an incident road to be +re-projected on every edit, even when the parcel didn't really need +to move. Bounded drift is the right trade-off. + +Affected sections: \cref{sec:edit-handling} (I7 reading clarified). + +\subsubsection*{2026-04-25 --- \texttt{Untouched} is not a public +\texttt{EditOutcome} bucket} + +What changed: Internally the deformation pipeline distinguishes four +results — Deformed, Untouched, Regenerate, Condemned — but the +public \texttt{EditOutcome} struct only exposes the three buckets +that carry \texttt{ParcelId}s of parcels that materially changed. +Untouched parcels simply don't appear in the result. Callers can +still infer them: any parcel-id that existed before the edit and +isn't in any of the four buckets after is implicitly Untouched. + +Why: surfacing an explicit ``Untouched'' bucket would mean every +edit on a 10\,000-parcel city walks 10\,000 ids back to the caller, +defeating the point of minimum-change. We let absence carry meaning. + +Affected sections: \cref{sec:edit-handling} (§4.2 outcome bucket +list now reads as ``parcels that materially changed go into one of +these four buckets''; absence implies no change). + +\paragraph{Test status.} +24 unit tests, 20 integration tests (was 16 in session 2), 1 doc +test. Two named tests still \texttt{\#[ignore]}-d: +\texttt{cul\_de\_sac} and \texttt{curved\_road\_high\_curv} — both +need real curved-road handling that's the milestone-0.4 headline. + +\paragraph{What's next --- milestone 0.4 queue.} + +\begin{enumerate}[noitemsep] + \item True sliver-merge for acute corners: instead of + bisector-clipping into thin trapezoids, merge the would-be sliver + with its longer-frontage neighbor. Removes the visual mess at + acute corners without changing the I3 invariant. + \item Curved-road support: discretize curves into polylines with + variable depth caps based on local radius. Unlocks + \texttt{curved\_road\_high\_curv}. + \item Pie-slice parcels for cul-de-sac bulbs. + \item OBB regularization (\cref{sec:regularization}). + \item Spatial index (\texttt{rstar}) for affected-parcel lookup + (\cref{sec:open}'s Q2) — once the linear scan starts to bite at + scale. + \item Q4: regeneration biased to preserve building footprints. + \item ``Fill-the-corner-after-edit'' regenerate: when a corner + parcel ends up disconnected from its road after a node move (e.g., + the gap visible at the bottom-right of \cref{fig:edit-after}), + regenerate just that corner to close the gap. +\end{enumerate} + % ----------------------------------------------------------------- \subsection{2026-04-25 --- Session 2: Milestone 0.2 (corner parcels, sticky back edges, preserve-on-deform)} diff --git a/road_parceling/Cargo.lock b/road_parceling/Cargo.lock new file mode 100644 index 0000000..ee7d66b --- /dev/null +++ b/road_parceling/Cargo.lock @@ -0,0 +1,1007 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +dependencies = [ + "mint", + "serde", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "road_parceling" +version = "0.1.0" +dependencies = [ + "criterion", + "glam", + "insta", + "proptest", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "slotmap", + "svg", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "svg" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94afda9cd163c04f6bee8b4bf2501c91548deae308373c436f36aeff3cf3c4a3" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/road_parceling/src/parcel/deform.rs b/road_parceling/src/parcel/deform.rs index 99d06c4..b74adf9 100644 --- a/road_parceling/src/parcel/deform.rs +++ b/road_parceling/src/parcel/deform.rs @@ -106,9 +106,20 @@ pub fn apply_road_edit( RoadEdit::DeleteSegment { road } => { delete_segment_path(parcels, road, graph, params, &mut outcome)?; } - RoadEdit::SplitSegment { .. } | RoadEdit::InsertSegment { .. } => { - // Split/Insert preservation is milestone-0.3 work — fall - // back to a regenerate of every affected face. + RoadEdit::SplitSegment { road, at } => { + split_segment_path( + parcels, + &graph_before, + graph, + road, + at, + params, + &mut outcome, + )?; + } + RoadEdit::InsertSegment { .. } => { + // Insert preservation is milestone-0.3+ work — for now, + // regenerate every face touched by the new segment. let affected = roads_affected_by_old_id(&graph_before, edit); regenerate_path(parcels, graph, params, &affected, &mut outcome)?; } @@ -270,8 +281,7 @@ fn deform_parcel_after_road_move( let len_after = road_vec_after.length(); if len_after > EPS_GEOM { 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; + 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(); @@ -379,6 +389,185 @@ fn find_frontage_index(polygon: &Polygon, road_a: DVec2, road_b: DVec2) -> Optio best.map(|(i, _)| i) } +// ----------------------------------------------------------------- +// Split-segment preserve path +// ----------------------------------------------------------------- + +fn split_segment_path( + parcels: &mut ParcelSet, + graph_before: &RoadGraph, + graph_after: &RoadGraph, + old_road: RoadId, + split_point: DVec2, + _params: &SubdivisionParams, + outcome: &mut EditOutcome, +) -> Result<(), ParcelError> { + let Some((a, b)) = graph_before.road_nodes(old_road) else { + return Err(ParcelError::UnknownEntity); + }; + let pos_a = graph_before + .node_position(a) + .ok_or(ParcelError::UnknownEntity)?; + let pos_b = graph_before + .node_position(b) + .ok_or(ParcelError::UnknownEntity)?; + // Find the new node m in graph_after at split_point and the two + // new roads it splits the old road into. + let mut new_node: Option = None; + for (nid, node) in &graph_after.nodes { + if (node.pos - split_point).length() < 0.01 { + new_node = Some(nid); + break; + } + } + let m = new_node.ok_or(ParcelError::InconsistentEdit("split node not found".into()))?; + let r1 = graph_after + .find_road_between(a, m) + .ok_or(ParcelError::InconsistentEdit( + "split road r1 missing".into(), + ))?; + let r2 = graph_after + .find_road_between(m, b) + .ok_or(ParcelError::InconsistentEdit( + "split road r2 missing".into(), + ))?; + + let road_vec = pos_b - pos_a; + let road_len_sq = road_vec.length_squared(); + if road_len_sq < EPS_GEOM * EPS_GEOM { + return Err(ParcelError::InconsistentEdit("zero-length old road".into())); + } + let t_split = (split_point - pos_a).dot(road_vec) / road_len_sq; + + let parcel_ids: Vec = parcels.parcels_on_road(old_road).collect(); + for pid in parcel_ids { + let Some(parcel) = parcels.parcels.get(pid) else { + continue; + }; + let n = parcel.polygon.len(); + let fi = parcel.frontage_edge_index; + let v = parcel.polygon.vertices(); + let p_a = v[fi]; + let p_b = v[(fi + 1) % n]; + let t_a = (p_a - pos_a).dot(road_vec) / road_len_sq; + let t_b = (p_b - pos_a).dot(road_vec) / road_len_sq; + let (t_lo, t_hi) = if t_a < t_b { (t_a, t_b) } else { (t_b, t_a) }; + + if t_hi <= t_split + EPS_GEOM { + // Frontage entirely on the a-side → r1. + // No geometric change; just rebind frontage_road. + rebind_frontage_road(parcels, pid, r1); + outcome.deformed.push(pid); + } else if t_lo >= t_split - EPS_GEOM { + // Frontage entirely on the b-side → r2. + rebind_frontage_road(parcels, pid, r2); + outcome.deformed.push(pid); + } else { + // Frontage spans the split → cut into two. + // Only handle the simple 4-vertex case; fall through to + // condemn for higher-vertex parcels. + if n != 4 { + drop_parcel(parcels, pid); + outcome.condemned.push(pid); + continue; + } + let q_b = v[(fi + 2) % n]; + let q_a = v[(fi + 3) % n]; + let frontage_vec = p_b - p_a; + let len_sq = frontage_vec.length_squared(); + if len_sq < EPS_GEOM * EPS_GEOM { + drop_parcel(parcels, pid); + outcome.condemned.push(pid); + continue; + } + let t_local = (split_point - p_a).dot(frontage_vec) / len_sq; + if t_local <= EPS_GEOM || t_local >= 1.0 - EPS_GEOM { + // Split coincident with an endpoint — just rebind. + let target = if t_local <= 0.5 { r1 } else { r2 }; + rebind_frontage_road(parcels, pid, target); + outcome.deformed.push(pid); + continue; + } + let q_star = q_a + (q_b - q_a) * t_local; + // The "a side" of the split goes to r1, "b side" to r2. + // p_a is closer to a if t_a < t_b. (Polygon CCW means + // frontage goes from p_a to p_b along the road, but + // direction can be either way relative to road A→B.) + let (a_side_road, b_side_road) = if t_a < t_b { (r1, r2) } else { (r2, r1) }; + // Build new polygons. + let poly_a = Polygon::new(vec![p_a, split_point, q_star, q_a]); + let poly_b = Polygon::new(vec![split_point, p_b, q_b, q_star]); + let (Ok(poly_a), Ok(poly_b)) = (poly_a, poly_b) else { + drop_parcel(parcels, pid); + outcome.condemned.push(pid); + continue; + }; + let block = parcel.block; + let area_a = poly_a.area(); + let area_b = poly_b.area(); + let parcel_owned = parcels + .parcels + .remove(pid) + .ok_or(ParcelError::UnknownEntity)?; + if let Some(v) = parcels.by_block.get_mut(&parcel_owned.block) { + v.retain(|&x| x != pid); + } + if let Some(v) = parcels.by_road.get_mut(&parcel_owned.frontage_road) { + v.retain(|&x| x != pid); + } + outcome.condemned.push(pid); + // Building stays with the larger half. + let mut building_a: Option = None; + let mut building_b: Option = None; + if area_a >= area_b { + building_a = parcel_owned.building; + } else { + building_b = parcel_owned.building; + } + let edge_kinds = crate::parcel::classify::classify_edges(4, 0); + let new_a = Parcel { + polygon: poly_a, + edge_kinds: edge_kinds.clone(), + frontage_road: a_side_road, + frontage_edge_index: 0, + block, + building: building_a, + }; + let new_b = Parcel { + polygon: poly_b, + edge_kinds, + frontage_road: b_side_road, + frontage_edge_index: 0, + block, + building: building_b, + }; + let new_a_id = parcels.insert(new_a); + let new_b_id = parcels.insert(new_b); + outcome.created.push(new_a_id); + outcome.created.push(new_b_id); + } + } + Ok(()) +} + +fn rebind_frontage_road(parcels: &mut ParcelSet, pid: ParcelId, new_road: RoadId) { + let old_road = if let Some(p) = parcels.parcels.get(pid) { + p.frontage_road + } else { + return; + }; + if old_road == new_road { + return; + } + if let Some(p) = parcels.parcels.get_mut(pid) { + p.frontage_road = new_road; + } + if let Some(v) = parcels.by_road.get_mut(&old_road) { + v.retain(|&x| x != pid); + } + parcels.by_road.entry(new_road).or_default().push(pid); +} + // ----------------------------------------------------------------- // Delete-segment path: condemn + regenerate-block // ----------------------------------------------------------------- diff --git a/road_parceling/src/parcel/subdivide.rs b/road_parceling/src/parcel/subdivide.rs index 9698bf9..03a6f23 100644 --- a/road_parceling/src/parcel/subdivide.rs +++ b/road_parceling/src/parcel/subdivide.rs @@ -184,8 +184,7 @@ pub(crate) fn subdivide_block( let real_corner: Vec = (0..n) .map(|i| { - is_real_corner(graph, block, i, params) - && interior_angles[i] > 60.0_f64.to_radians() + is_real_corner(graph, block, i, params) && interior_angles[i] > 60.0_f64.to_radians() }) .collect(); // Acute corners: real corners (degree-3 or sharp degree-2) with @@ -194,8 +193,7 @@ pub(crate) fn subdivide_block( // separated. let acute_corner: Vec = (0..n) .map(|i| { - is_real_corner(graph, block, i, params) - && interior_angles[i] <= 60.0_f64.to_radians() + is_real_corner(graph, block, i, params) && interior_angles[i] <= 60.0_f64.to_radians() }) .collect(); @@ -405,9 +403,7 @@ pub(crate) fn subdivide_block( let mut working = raw_poly; if acute_corner[i] { if let Some(b) = corner_bisector(&verts, i) { - if let Some(clipped) = - clip_with_bisector(&working, verts[i], b, frontage_mid) - { + if let Some(clipped) = clip_with_bisector(&working, verts[i], b, frontage_mid) { working = clipped; } else { continue; diff --git a/road_parceling/tests/degenerate.rs b/road_parceling/tests/degenerate.rs index 65c6aab..a7cd269 100644 --- a/road_parceling/tests/degenerate.rs +++ b/road_parceling/tests/degenerate.rs @@ -55,17 +55,45 @@ fn assert_invariants_i1_i3(parcels: &road_parceling::ParcelSet) { // ---------------------------------------------------------------------- #[test] -#[ignore = "milestone-0.2: sliver-merge logic for acute corners"] fn acute_intersection_15deg() { - // Two roads sharing a node meet at 15°. Sliver-corner parcels - // must merge with neighbors; I1–I3 must hold. + // Two roads sharing a node meet at 15°. With the bisector-clip + // at acute corners (milestone 0.3), no proper sliver-merge yet, + // but I1–I3 must hold. + let mut g = RoadGraph::new(); + let apex = g.add_node(DVec2::new(0.0, 0.0)); + let p1 = g.add_node(DVec2::new(100.0, 0.0)); + let angle = 15.0_f64.to_radians(); + let p2 = g.add_node(DVec2::new(100.0 * angle.cos(), 100.0 * angle.sin())); + g.add_road(apex, p1).unwrap(); + g.add_road(apex, p2).unwrap(); + g.add_road(p1, p2).unwrap(); + g.rebuild_topology().unwrap(); + let params = SubdivisionParams::default(); + let parcels = subdivide_all(&g, ¶ms).unwrap(); + // We don't insist parcels exist (a 15° wedge may be too narrow + // for any parcel to satisfy `min_area`), but if any do, they + // must satisfy I1–I3. + assert_invariants_i1_i3(&parcels); } #[test] -#[ignore = "milestone-0.2: sliver-merge or typed error for knife-edge angles"] fn acute_intersection_5deg() { - // Knife-edge angle. Library must not panic; either typed error - // or valid output is acceptable. + // Knife-edge 5° angle. Library must not panic. + let mut g = RoadGraph::new(); + let apex = g.add_node(DVec2::new(0.0, 0.0)); + let p1 = g.add_node(DVec2::new(100.0, 0.0)); + let angle = 5.0_f64.to_radians(); + let p2 = g.add_node(DVec2::new(100.0 * angle.cos(), 100.0 * angle.sin())); + g.add_road(apex, p1).unwrap(); + g.add_road(apex, p2).unwrap(); + g.add_road(p1, p2).unwrap(); + g.rebuild_topology().unwrap(); + let params = SubdivisionParams::default(); + // Either succeeds with valid parcels or returns a typed error; + // both are acceptable, but it must not panic. + if let Ok(parcels) = subdivide_all(&g, ¶ms) { + assert_invariants_i1_i3(&parcels); + } } #[test] @@ -356,9 +384,53 @@ fn road_delete_condemns() { } #[test] -#[ignore = "milestone-0.2: split-segment must preserve, not regenerate"] fn road_split_preserves() { - // Splitting a segment should preserve the parcels on it. + // Splitting a segment should preserve the parcels on it. Each + // pre-split parcel either stays (frontage rebinds to one of the + // two new roads) or is split into two parcels along the + // perpendicular through the split point. + let mut g = rectangle_graph(200.0, 100.0); + let params = SubdivisionParams::default(); + let mut parcels = subdivide_all(&g, ¶ms).unwrap(); + let pre_count = parcels.len(); + + // Pick the bottom road (longest, has many parcels) and split at + // x = 100, y = 0. + let bottom_road = g + .road_endpoints() + .find(|&(_, _, b)| { + let pos = g.node_position(b).unwrap(); + pos.y.abs() < 1e-6 && pos.x > 100.0 + }) + .map(|(rid, _, _)| rid) + .unwrap(); + + let outcome = apply_road_edit( + &mut parcels, + &mut g, + RoadEdit::SplitSegment { + road: bottom_road, + at: DVec2::new(100.0, 0.0), + }, + ¶ms, + ) + .unwrap(); + // Most parcels should be deformed (frontage_road rebound, no + // geometric change). At most one is condemned (the one whose + // frontage spans the split point — and it gets two replacements). + assert!( + outcome.regenerated.is_empty(), + "split path must not trigger block regenerate; got {} regenerated", + outcome.regenerated.len() + ); + assert!( + !outcome.deformed.is_empty(), + "split path should preserve at least some parcels" + ); + // Net parcel count should be ≥ pre_count (a split can ADD + // parcels but never lose them outright). + assert!(parcels.len() >= pre_count - 1); + assert_invariants_i1_i3(&parcels); } #[test] @@ -507,17 +579,19 @@ fn y_intersection_no_overlaps() { let parcels = subdivide_all(&g, ¶ms).unwrap(); let parcels_vec: Vec<_> = parcels.iter().collect(); - for i in 0..parcels_vec.len() { - let centroid_i = parcels_vec[i].1.polygon().centroid(); - for j in 0..parcels_vec.len() { + for (i, (_, pi)) in parcels_vec.iter().enumerate() { + let centroid_i = pi.polygon().centroid(); + for (j, (_, pj_pair)) in parcels_vec.iter().enumerate() { if i == j { continue; } - let pj = parcels_vec[j].1.polygon(); + let pj = pj_pair.polygon(); assert!( !pj.contains(centroid_i), "I3 violation: centroid of parcel {} ({:?}) is inside parcel {}", - i, centroid_i, j + i, + centroid_i, + j ); } }