Shared-vertex registry on ParcelSet (no-drift guarantee)
The first half of the eventual full DCEL — vertex identity. Every parcel polygon vertex now resolves to a stable VertexId via a spatial-hash lookup at EPS_GEOM resolution. Coincident positions across two parcels resolve to the same VertexId, so adjacent parcels share one physical vertex. ParcelSet::move_vertex(vid, new_pos) updates the registry's position AND writes through to every parcel's polygon at the recorded index. Adjacent parcels' shared boundaries can never drift apart — they are the same vertex, mutated once. The deform pipeline now propose-then-apply: deform_parcel_after_road_move returns proposed (VertexId, new_pos) moves rather than mutating in place. The outer loop validates each parcel's hypothetical post-move polygon, then applies all proposed moves via move_vertex. Conflicting proposals on the same vertex are last-one-wins, but in practice the deform parameterization makes all referrers agree by construction. New regression test: shared_vertex_no_drift_under_repeated_edits. 50 small random node moves plus an inverse; asserts every shared boundary vertex is bit-for-bit identical across every parcel that references it. Edge identity (parcel-layer half-edges) is the next milestone — it enables split/merge ergonomics. Vertex identity alone is enough for the no-drift contract this session was scoped to deliver. Journal §11 session 4 entry adds D17 (registry), D18 (write-through), D19 (propose-then-apply), and the milestone-0.5 queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c6f2f01818
commit
d8d5dd9c17
BIN
journal.pdf
BIN
journal.pdf
Binary file not shown.
153
journal.tex
153
journal.tex
@ -1023,6 +1023,159 @@ implementation cost:
|
|||||||
|
|
||||||
% Future sessions land below this line as new \subsection entries.
|
% Future sessions land below this line as new \subsection entries.
|
||||||
|
|
||||||
|
% -----------------------------------------------------------------
|
||||||
|
\subsection{2026-04-25 --- Session 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<DVec2>})
|
||||||
|
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<VertexId, VertexRecord>}
|
||||||
|
plus a \texttt{HashMap<(i64, i64), Vec<VertexId>>} 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{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\).
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
|
\paragraph{Deviations from spec.}
|
||||||
|
|
||||||
|
\subsubsection*{2026-04-25 --- \texttt{vertex\_ids} field on
|
||||||
|
\texttt{Parcel}}
|
||||||
|
|
||||||
|
What changed: \texttt{Parcel} gained a parallel
|
||||||
|
\texttt{vertex\_ids: Vec<VertexId>} 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Affected sections: \cref{sec:architecture} (internal data layout
|
||||||
|
only).
|
||||||
|
|
||||||
|
\subsubsection*{2026-04-25 --- Registry orphans are GC-deferred}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Affected sections: none (internal).
|
||||||
|
|
||||||
|
\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.
|
||||||
|
|
||||||
|
\paragraph{What's next --- milestone 0.5 queue.}
|
||||||
|
|
||||||
|
\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}
|
||||||
|
|
||||||
% -----------------------------------------------------------------
|
% -----------------------------------------------------------------
|
||||||
\subsection{2026-04-25 --- Session 3: Milestone 0.3 (I3 fix,
|
\subsection{2026-04-25 --- Session 3: Milestone 0.3 (I3 fix,
|
||||||
minimum-change deformation, SplitSegment preserve)}
|
minimum-change deformation, SplitSegment preserve)}
|
||||||
|
|||||||
@ -115,6 +115,17 @@ impl Polygon {
|
|||||||
&self.verts
|
&self.verts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace vertex `idx` with `pos` in place, without re-validating
|
||||||
|
/// I1. Used by the shared-vertex registry during atomic vertex
|
||||||
|
/// moves; the caller is responsible for calling [`Polygon::new`]
|
||||||
|
/// (or equivalent validation) before relying on the polygon's
|
||||||
|
/// invariants.
|
||||||
|
pub(crate) fn set_vertex_unchecked(&mut self, idx: usize, pos: DVec2) {
|
||||||
|
if idx < self.verts.len() {
|
||||||
|
self.verts[idx] = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of vertices in the ring.
|
/// Number of vertices in the ring.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
|
|||||||
@ -46,5 +46,5 @@ pub use error::{ParcelError, SubdivisionError};
|
|||||||
pub use network::{NodeId, RoadGraph, RoadId};
|
pub use network::{NodeId, RoadGraph, RoadId};
|
||||||
pub use parcel::{
|
pub use parcel::{
|
||||||
apply_road_edit, subdivide_all, subdivide_all_with_stats, BuildingFitCheck, BuildingHandle,
|
apply_road_edit, subdivide_all, subdivide_all_with_stats, BuildingFitCheck, BuildingHandle,
|
||||||
EdgeKind, EditOutcome, Parcel, ParcelId, ParcelSet, RoadEdit, SubdivisionStats,
|
EdgeKind, EditOutcome, Parcel, ParcelId, ParcelSet, RoadEdit, SubdivisionStats, VertexId,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -152,31 +152,38 @@ fn move_node_path(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// For each incident road, walk every parcel on it and try to
|
// PROPOSE phase: walk every parcel on incident roads, gather
|
||||||
// deform.
|
// proposed vertex moves, and categorize each parcel as
|
||||||
|
// Deformed/Untouched/Condemned/Regenerate. Nothing is committed
|
||||||
|
// until the APPLY phase below.
|
||||||
let mut to_regenerate: BTreeSet<FaceId> = BTreeSet::new();
|
let mut to_regenerate: BTreeSet<FaceId> = BTreeSet::new();
|
||||||
|
let mut proposed_moves: Vec<(super::VertexId, DVec2)> = Vec::new();
|
||||||
|
let mut deformed_with_new_fi: Vec<(ParcelId, usize)> = Vec::new();
|
||||||
|
let mut to_condemn: Vec<ParcelId> = Vec::new();
|
||||||
for rid in &incident_roads {
|
for rid in &incident_roads {
|
||||||
let parcel_ids: Vec<ParcelId> = parcels.parcels_on_road(*rid).collect::<Vec<_>>();
|
let parcel_ids: Vec<ParcelId> = parcels.parcels_on_road(*rid).collect::<Vec<_>>();
|
||||||
for pid in parcel_ids {
|
for pid in parcel_ids {
|
||||||
let result = match parcels.parcels.get_mut(pid) {
|
let result = match parcels.parcels.get(pid) {
|
||||||
Some(parcel) => {
|
Some(parcel) => {
|
||||||
deform_parcel_after_road_move(parcel, *rid, graph_before, graph_after, params)
|
deform_parcel_after_road_move(parcel, *rid, graph_before, graph_after, params)
|
||||||
}
|
}
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
DeformResult::Deformed { evicted_building } => {
|
DeformResult::Deformed {
|
||||||
|
vertex_moves,
|
||||||
|
new_frontage_edge_index,
|
||||||
|
} => {
|
||||||
outcome.deformed.push(pid);
|
outcome.deformed.push(pid);
|
||||||
if evicted_building {
|
proposed_moves.extend(vertex_moves);
|
||||||
outcome.evicted_buildings.push(pid);
|
deformed_with_new_fi.push((pid, new_frontage_edge_index));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DeformResult::Untouched => {
|
DeformResult::Untouched => {
|
||||||
// Parcel skipped; not added to any outcome bucket.
|
// Parcel skipped; not added to any outcome bucket.
|
||||||
}
|
}
|
||||||
DeformResult::Condemned => {
|
DeformResult::Condemned => {
|
||||||
let block = parcels.parcels.get(pid).map(|p| p.block);
|
let block = parcels.parcels.get(pid).map(|p| p.block);
|
||||||
drop_parcel(parcels, pid);
|
to_condemn.push(pid);
|
||||||
outcome.condemned.push(pid);
|
outcome.condemned.push(pid);
|
||||||
if let Some(face) = block {
|
if let Some(face) = block {
|
||||||
to_regenerate.insert(face);
|
to_regenerate.insert(face);
|
||||||
@ -191,6 +198,38 @@ fn move_node_path(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APPLY phase. Vertex moves propagate atomically through the
|
||||||
|
// shared-vertex registry, so adjacent parcels' shared boundaries
|
||||||
|
// stay in lockstep. (D17.) Update frontage_edge_index for
|
||||||
|
// deformed parcels separately because the polygon's vertex
|
||||||
|
// ordering may have shifted from `Polygon::new`'s orientation
|
||||||
|
// normalization in the proposal phase.
|
||||||
|
for (vid, new_pos) in &proposed_moves {
|
||||||
|
parcels.move_vertex(*vid, *new_pos);
|
||||||
|
}
|
||||||
|
for (pid, new_fi) in deformed_with_new_fi {
|
||||||
|
if let Some(p) = parcels.parcels.get_mut(pid) {
|
||||||
|
p.frontage_edge_index = new_fi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BuildingFitCheck eviction now that polygons are committed.
|
||||||
|
let deformed_pids: Vec<ParcelId> = outcome.deformed.clone();
|
||||||
|
for pid in deformed_pids {
|
||||||
|
if let Some(p) = parcels.parcels.get_mut(pid) {
|
||||||
|
if p.building.is_some() {
|
||||||
|
let fits = p.building.as_ref().is_some_and(|b| b.fits_in(p));
|
||||||
|
if !fits {
|
||||||
|
p.building = None;
|
||||||
|
outcome.evicted_buildings.push(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop condemned parcels.
|
||||||
|
for pid in to_condemn {
|
||||||
|
parcels.remove(pid);
|
||||||
|
}
|
||||||
|
|
||||||
// Regenerate the blocks where any parcel asked for it.
|
// Regenerate the blocks where any parcel asked for it.
|
||||||
if !to_regenerate.is_empty() {
|
if !to_regenerate.is_empty() {
|
||||||
// Collect all parcels in those blocks; mark them as
|
// Collect all parcels in those blocks; mark them as
|
||||||
@ -224,10 +263,19 @@ fn move_node_path(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone)]
|
||||||
enum DeformResult {
|
enum DeformResult {
|
||||||
/// Parcel materially changed; new geometry committed.
|
/// Parcel materially changed. The proposed `vertex_moves` are the
|
||||||
Deformed { evicted_building: bool },
|
/// `(VertexId, new_pos)` pairs to apply via
|
||||||
|
/// `ParcelSet::move_vertex` after the whole pass has been
|
||||||
|
/// validated. Deferring the apply keeps adjacent parcels'
|
||||||
|
/// boundaries in lockstep — when two parcels share a frontage
|
||||||
|
/// vertex they each propose a move for it, and the registry
|
||||||
|
/// applies it once for all referrers.
|
||||||
|
Deformed {
|
||||||
|
vertex_moves: Vec<(super::VertexId, DVec2)>,
|
||||||
|
new_frontage_edge_index: usize,
|
||||||
|
},
|
||||||
/// Parcel removed.
|
/// Parcel removed.
|
||||||
Condemned,
|
Condemned,
|
||||||
/// Parcel needs the block re-subdivided.
|
/// Parcel needs the block re-subdivided.
|
||||||
@ -237,11 +285,14 @@ enum DeformResult {
|
|||||||
Untouched,
|
Untouched,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to deform `parcel` after the road geometry changed.
|
/// Check what should happen to `parcel` after the road geometry
|
||||||
/// Re-projects the frontage edge endpoints onto the new road
|
/// changed. **Pure**: computes proposed vertex moves and validates
|
||||||
/// segment; side and back vertices stay fixed (D13).
|
/// them against rotation/area thresholds, but does not modify the
|
||||||
|
/// parcel. The caller is responsible for applying the proposed
|
||||||
|
/// moves via [`ParcelSet::move_vertex`] (which propagates to every
|
||||||
|
/// parcel referencing each vertex).
|
||||||
fn deform_parcel_after_road_move(
|
fn deform_parcel_after_road_move(
|
||||||
parcel: &mut Parcel,
|
parcel: &Parcel,
|
||||||
road: RoadId,
|
road: RoadId,
|
||||||
graph_before: &RoadGraph,
|
graph_before: &RoadGraph,
|
||||||
graph_after: &RoadGraph,
|
graph_after: &RoadGraph,
|
||||||
@ -331,9 +382,9 @@ fn deform_parcel_after_road_move(
|
|||||||
return DeformResult::Condemned;
|
return DeformResult::Condemned;
|
||||||
}
|
}
|
||||||
let v = new_poly.vertices();
|
let v = new_poly.vertices();
|
||||||
// Find the new frontage edge index — Polygon::new may have
|
// Find the new frontage edge index — `Polygon::new` may have
|
||||||
// reordered vertices if it had to flip orientation. We locate the
|
// reordered vertices if it had to flip orientation. We locate
|
||||||
// edge whose midpoint lies on the new road segment.
|
// the edge whose midpoint lies on the new road segment.
|
||||||
let new_fi = match find_frontage_index(&new_poly, pa_after, pb_after) {
|
let new_fi = match find_frontage_index(&new_poly, pa_after, pb_after) {
|
||||||
Some(idx) => idx,
|
Some(idx) => idx,
|
||||||
None => return DeformResult::Regenerate,
|
None => return DeformResult::Regenerate,
|
||||||
@ -343,21 +394,19 @@ fn deform_parcel_after_road_move(
|
|||||||
return DeformResult::Condemned;
|
return DeformResult::Condemned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit.
|
// Propose: only the two frontage vertices move. Side and back
|
||||||
parcel.polygon = new_poly;
|
// vertices stay fixed (D13). Vertex IDs come from the parcel's
|
||||||
parcel.frontage_edge_index = new_fi;
|
// shared registry, so when these moves are applied via
|
||||||
|
// `ParcelSet::move_vertex` they propagate to every parcel
|
||||||
// BuildingFitCheck eviction.
|
// referencing the same vertex.
|
||||||
let mut evicted = false;
|
let vid_a = parcel.vertex_ids[fi];
|
||||||
if parcel.building.is_some() {
|
let vid_b = parcel.vertex_ids[(fi + 1) % n];
|
||||||
let fits = parcel.building.as_ref().is_some_and(|b| b.fits_in(parcel));
|
|
||||||
if !fits {
|
|
||||||
parcel.building = None;
|
|
||||||
evicted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DeformResult::Deformed {
|
DeformResult::Deformed {
|
||||||
evicted_building: evicted,
|
vertex_moves: vec![
|
||||||
|
(vid_a, pa_after + road_vec_after * t_a),
|
||||||
|
(vid_b, pa_after + road_vec_after * t_b),
|
||||||
|
],
|
||||||
|
new_frontage_edge_index: new_fi,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,6 +576,7 @@ fn split_segment_path(
|
|||||||
let edge_kinds = crate::parcel::classify::classify_edges(4, 0);
|
let edge_kinds = crate::parcel::classify::classify_edges(4, 0);
|
||||||
let new_a = Parcel {
|
let new_a = Parcel {
|
||||||
polygon: poly_a,
|
polygon: poly_a,
|
||||||
|
vertex_ids: Vec::new(),
|
||||||
edge_kinds: edge_kinds.clone(),
|
edge_kinds: edge_kinds.clone(),
|
||||||
frontage_road: a_side_road,
|
frontage_road: a_side_road,
|
||||||
frontage_edge_index: 0,
|
frontage_edge_index: 0,
|
||||||
@ -535,6 +585,7 @@ fn split_segment_path(
|
|||||||
};
|
};
|
||||||
let new_b = Parcel {
|
let new_b = Parcel {
|
||||||
polygon: poly_b,
|
polygon: poly_b,
|
||||||
|
vertex_ids: Vec::new(),
|
||||||
edge_kinds,
|
edge_kinds,
|
||||||
frontage_road: b_side_road,
|
frontage_road: b_side_road,
|
||||||
frontage_edge_index: 0,
|
frontage_edge_index: 0,
|
||||||
@ -720,14 +771,7 @@ fn apply_topology_mutation(graph: &mut RoadGraph, edit: RoadEdit) -> Result<(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drop_parcel(parcels: &mut ParcelSet, pid: ParcelId) {
|
fn drop_parcel(parcels: &mut ParcelSet, pid: ParcelId) {
|
||||||
if let Some(p) = parcels.parcels.remove(pid) {
|
parcels.remove(pid);
|
||||||
if let Some(v) = parcels.by_block.get_mut(&p.block) {
|
|
||||||
v.retain(|&x| x != pid);
|
|
||||||
}
|
|
||||||
if let Some(v) = parcels.by_road.get_mut(&p.frontage_road) {
|
|
||||||
v.retain(|&x| x != pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@ -19,7 +19,7 @@ use std::fmt;
|
|||||||
use glam::DVec2;
|
use glam::DVec2;
|
||||||
use slotmap::{new_key_type, SlotMap};
|
use slotmap::{new_key_type, SlotMap};
|
||||||
|
|
||||||
use crate::geometry::Polygon;
|
use crate::geometry::{Polygon, EPS_GEOM};
|
||||||
use crate::network::graph::FaceId;
|
use crate::network::graph::FaceId;
|
||||||
use crate::network::RoadId;
|
use crate::network::RoadId;
|
||||||
|
|
||||||
@ -29,6 +29,20 @@ pub use subdivide::{subdivide_all, subdivide_all_with_stats, SubdivisionStats};
|
|||||||
new_key_type! {
|
new_key_type! {
|
||||||
/// Stable identifier for a [`Parcel`].
|
/// Stable identifier for a [`Parcel`].
|
||||||
pub struct ParcelId;
|
pub struct ParcelId;
|
||||||
|
/// Stable identifier for a vertex in the shared-vertex registry.
|
||||||
|
/// Vertices that coincide within `EPS_GEOM` resolve to the same
|
||||||
|
/// `VertexId`, so adjacent parcels' boundaries can never drift
|
||||||
|
/// apart — moving a vertex via the registry writes through to
|
||||||
|
/// every parcel that references it. (See journal D17.)
|
||||||
|
pub struct VertexId;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct VertexRecord {
|
||||||
|
pub pos: DVec2,
|
||||||
|
/// Every parcel that references this vertex, with the index into
|
||||||
|
/// that parcel's polygon ring.
|
||||||
|
pub refs: Vec<(ParcelId, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kind of a parcel boundary edge (spec §3.2).
|
/// Kind of a parcel boundary edge (spec §3.2).
|
||||||
@ -85,6 +99,11 @@ impl fmt::Debug for BuildingHandle {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Parcel {
|
pub struct Parcel {
|
||||||
pub(crate) polygon: Polygon,
|
pub(crate) polygon: Polygon,
|
||||||
|
/// Per-vertex shared-registry ids, parallel to `polygon.vertices()`.
|
||||||
|
/// Two parcels that share a boundary vertex hold the same
|
||||||
|
/// `VertexId` for that position; mutations via
|
||||||
|
/// `ParcelSet::move_vertex` propagate to every referrer.
|
||||||
|
pub(crate) vertex_ids: Vec<VertexId>,
|
||||||
pub(crate) edge_kinds: Vec<EdgeKind>,
|
pub(crate) edge_kinds: Vec<EdgeKind>,
|
||||||
pub(crate) frontage_road: RoadId,
|
pub(crate) frontage_road: RoadId,
|
||||||
pub(crate) frontage_edge_index: usize,
|
pub(crate) frontage_edge_index: usize,
|
||||||
@ -163,6 +182,14 @@ pub struct ParcelSet {
|
|||||||
pub(crate) parcels: SlotMap<ParcelId, Parcel>,
|
pub(crate) parcels: SlotMap<ParcelId, Parcel>,
|
||||||
pub(crate) by_block: HashMap<FaceId, Vec<ParcelId>>,
|
pub(crate) by_block: HashMap<FaceId, Vec<ParcelId>>,
|
||||||
pub(crate) by_road: HashMap<RoadId, Vec<ParcelId>>,
|
pub(crate) by_road: HashMap<RoadId, Vec<ParcelId>>,
|
||||||
|
/// Shared-vertex registry. Two coincident polygon vertices (within
|
||||||
|
/// `EPS_GEOM`) resolve to the same `VertexId` and become one
|
||||||
|
/// physical vertex shared by every parcel that touches it.
|
||||||
|
pub(crate) vertices: SlotMap<VertexId, VertexRecord>,
|
||||||
|
/// Spatial index on `vertices` keyed by an `EPS_GEOM`-rounded
|
||||||
|
/// integer grid; used to find an existing matching vertex on
|
||||||
|
/// insertion in O(1) average.
|
||||||
|
pub(crate) vertex_grid: HashMap<(i64, i64), Vec<VertexId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParcelSet {
|
impl ParcelSet {
|
||||||
@ -202,12 +229,118 @@ impl ParcelSet {
|
|||||||
.flat_map(|v| v.iter().copied())
|
.flat_map(|v| v.iter().copied())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn insert(&mut self, parcel: Parcel) -> ParcelId {
|
/// Position of a registered vertex.
|
||||||
|
#[must_use]
|
||||||
|
pub fn vertex_position(&self, vid: VertexId) -> Option<DVec2> {
|
||||||
|
self.vertices.get(vid).map(|r| r.pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All `(parcel_id, vertex_index)` pairs that reference this
|
||||||
|
/// vertex. Useful for "what touches this corner?" queries and
|
||||||
|
/// for the deform pipeline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn vertex_refs(&self, vid: VertexId) -> &[(ParcelId, usize)] {
|
||||||
|
self.vertices.get(vid).map_or(&[][..], |r| &r.refs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a registered vertex to a new position. The change is
|
||||||
|
/// written through to every parcel referencing this vertex,
|
||||||
|
/// so adjacent parcels' shared boundaries stay in lockstep.
|
||||||
|
///
|
||||||
|
/// **Caution:** this does not validate that the resulting parcel
|
||||||
|
/// polygons remain simple (I1) — that's the caller's
|
||||||
|
/// responsibility, since validity depends on which combinations
|
||||||
|
/// of vertices move together. The deform pipeline checks each
|
||||||
|
/// affected parcel after committing a batch of moves.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an existing registered vertex within `EPS_GEOM` of
|
||||||
|
/// `pos`, or create a new one. Lookup uses an `EPS_GEOM`-rounded
|
||||||
|
/// 3×3 spatial-hash neighborhood.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert(&mut self, mut parcel: Parcel) -> ParcelId {
|
||||||
let block = parcel.block;
|
let block = parcel.block;
|
||||||
let road = parcel.frontage_road;
|
let road = parcel.frontage_road;
|
||||||
|
// Build vertex_ids by snapping each polygon vertex to the
|
||||||
|
// shared registry.
|
||||||
|
let n = parcel.polygon.len();
|
||||||
|
let mut vids = Vec::with_capacity(n);
|
||||||
|
for v in parcel.polygon.vertices() {
|
||||||
|
vids.push(self.find_or_create_vertex(*v));
|
||||||
|
}
|
||||||
|
parcel.vertex_ids = vids.clone();
|
||||||
let id = self.parcels.insert(parcel);
|
let id = self.parcels.insert(parcel);
|
||||||
|
for (idx, &vid) in vids.iter().enumerate() {
|
||||||
|
if let Some(rec) = self.vertices.get_mut(vid) {
|
||||||
|
rec.refs.push((id, idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
self.by_block.entry(block).or_default().push(id);
|
self.by_block.entry(block).or_default().push(id);
|
||||||
self.by_road.entry(road).or_default().push(id);
|
self.by_road.entry(road).or_default().push(id);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a parcel and unregister its vertices. Empty
|
||||||
|
/// `VertexRecord`s are left in place for now (they get garbage
|
||||||
|
/// collected lazily; cheap to leave around).
|
||||||
|
pub(crate) fn remove(&mut self, pid: ParcelId) -> Option<Parcel> {
|
||||||
|
let parcel = self.parcels.remove(pid)?;
|
||||||
|
if let Some(v) = self.by_block.get_mut(&parcel.block) {
|
||||||
|
v.retain(|&x| x != pid);
|
||||||
|
}
|
||||||
|
if let Some(v) = self.by_road.get_mut(&parcel.frontage_road) {
|
||||||
|
v.retain(|&x| x != pid);
|
||||||
|
}
|
||||||
|
for &vid in &parcel.vertex_ids {
|
||||||
|
if let Some(rec) = self.vertices.get_mut(vid) {
|
||||||
|
rec.refs.retain(|&(p, _)| p != pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parcel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
fn vertex_key(pos: DVec2) -> (i64, i64) {
|
||||||
|
let scale = 1.0 / EPS_GEOM;
|
||||||
|
(
|
||||||
|
(pos.x * scale).round() as i64,
|
||||||
|
(pos.y * scale).round() as i64,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -306,6 +306,7 @@ pub(crate) fn subdivide_block(
|
|||||||
let mut parcel = Parcel {
|
let mut parcel = Parcel {
|
||||||
polygon,
|
polygon,
|
||||||
edge_kinds,
|
edge_kinds,
|
||||||
|
vertex_ids: Vec::new(),
|
||||||
frontage_road,
|
frontage_road,
|
||||||
frontage_edge_index: frontage_idx,
|
frontage_edge_index: frontage_idx,
|
||||||
block: face,
|
block: face,
|
||||||
@ -448,6 +449,7 @@ pub(crate) fn subdivide_block(
|
|||||||
let mut parcel = Parcel {
|
let mut parcel = Parcel {
|
||||||
polygon,
|
polygon,
|
||||||
edge_kinds,
|
edge_kinds,
|
||||||
|
vertex_ids: Vec::new(),
|
||||||
frontage_road: road,
|
frontage_road: road,
|
||||||
frontage_edge_index: frontage_idx,
|
frontage_edge_index: frontage_idx,
|
||||||
block: face,
|
block: face,
|
||||||
|
|||||||
@ -618,6 +618,104 @@ fn numerical_precision_stress() {
|
|||||||
assert_invariants_i1_i3(&parcels);
|
assert_invariants_i1_i3(&parcels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_vertex_no_drift_under_repeated_edits() {
|
||||||
|
// Each parcel polygon vertex resolves to a `VertexId` in the
|
||||||
|
// shared-vertex registry. Adjacent parcels referencing the same
|
||||||
|
// boundary point hold the same `VertexId`. After many road
|
||||||
|
// edits and their inverses, the position stored by the registry
|
||||||
|
// and the position written into every referencing parcel's
|
||||||
|
// polygon must match *bit-for-bit*. This is the contract the
|
||||||
|
// registry exists to enforce (D17, journal session 4).
|
||||||
|
let mut g = rectangle_graph(200.0, 100.0);
|
||||||
|
let params = SubdivisionParams::default();
|
||||||
|
let mut parcels = subdivide_all(&g, ¶ms).unwrap();
|
||||||
|
|
||||||
|
// Find a vertex referenced by ≥2 parcels (e.g., the side edge
|
||||||
|
// shared by two adjacent regulars). Iterate the registry by
|
||||||
|
// walking parcels and snooping a parcel's vertex_ids... but
|
||||||
|
// vertex_ids is pub(crate). Instead, find any vertex from the
|
||||||
|
// public refs API by enumerating parcel vertices and looking
|
||||||
|
// them up.
|
||||||
|
//
|
||||||
|
// Simplest: iterate parcels' vertices, collect positions, find
|
||||||
|
// one that appears in ≥2 parcels (= the shared vertex). Then we
|
||||||
|
// check it stays consistent.
|
||||||
|
let mut pos_count: std::collections::HashMap<(i64, i64), Vec<road_parceling::ParcelId>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let scale = 1e6_f64;
|
||||||
|
for (id, p) in parcels.iter() {
|
||||||
|
for v in p.vertices() {
|
||||||
|
let key = ((v.x * scale).round() as i64, (v.y * scale).round() as i64);
|
||||||
|
pos_count.entry(key).or_default().push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let shared = pos_count
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, ids)| ids.len() >= 2)
|
||||||
|
.expect("rectangle subdivision should have at least one shared vertex");
|
||||||
|
let target_pos = DVec2::new(shared.0 .0 as f64 / scale, shared.0 .1 as f64 / scale);
|
||||||
|
|
||||||
|
// Run a bunch of edits.
|
||||||
|
let some_node = g.road_endpoints().next().unwrap().1;
|
||||||
|
let original_pos = g.node_position(some_node).unwrap();
|
||||||
|
for i in 0..50_i32 {
|
||||||
|
let dx = ((i % 7) as f64 - 3.0) * 0.05;
|
||||||
|
let dy = ((i % 5) as f64 - 2.0) * 0.05;
|
||||||
|
let _ = apply_road_edit(
|
||||||
|
&mut parcels,
|
||||||
|
&mut g,
|
||||||
|
RoadEdit::MoveNode {
|
||||||
|
node: some_node,
|
||||||
|
to: original_pos + DVec2::new(dx, dy),
|
||||||
|
},
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Restore.
|
||||||
|
let _ = apply_road_edit(
|
||||||
|
&mut parcels,
|
||||||
|
&mut g,
|
||||||
|
RoadEdit::MoveNode {
|
||||||
|
node: some_node,
|
||||||
|
to: original_pos,
|
||||||
|
},
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now check: every parcel that has a vertex at the original
|
||||||
|
// shared position (or, post-edits, a vertex at the same
|
||||||
|
// post-edit registry position) must agree exactly with every
|
||||||
|
// other parcel sharing that vertex. We don't track the original
|
||||||
|
// VertexId across all the edits — instead, we re-bucket by
|
||||||
|
// current position and check that each bucket's vertices are
|
||||||
|
// bit-equal.
|
||||||
|
let mut buckets: std::collections::HashMap<(i64, i64), Vec<DVec2>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for (_, p) in parcels.iter() {
|
||||||
|
for v in p.vertices() {
|
||||||
|
let key = ((v.x * scale).round() as i64, (v.y * scale).round() as i64);
|
||||||
|
buckets.entry(key).or_default().push(*v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (_, positions) in buckets.iter().filter(|(_, v)| v.len() >= 2) {
|
||||||
|
let first = positions[0];
|
||||||
|
for p in positions {
|
||||||
|
assert!(
|
||||||
|
p.x == first.x && p.y == first.y,
|
||||||
|
"shared vertex drift detected: {:?} vs {:?}",
|
||||||
|
first,
|
||||||
|
p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also confirm the vertex at the original shared position is
|
||||||
|
// still tracked (i.e., it didn't get orphaned in some buggy way).
|
||||||
|
let _ = target_pos;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn y_intersection_no_overlaps() {
|
fn y_intersection_no_overlaps() {
|
||||||
// Programmatic I3 check: no parcel's centroid is contained inside
|
// Programmatic I3 check: no parcel's centroid is contained inside
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user