diff --git a/CHANGELOG.md b/CHANGELOG.md index 289cf24d..ba3b23d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Return fallible facet iterators [#458](https://github.com/acgetchell/delaunay/pull/458) - Hydrate TDS through validated UUID snapshots [#454](https://github.com/acgetchell/delaunay/pull/454) [#460](https://github.com/acgetchell/delaunay/pull/460) - Normalize fallible constructors [#459](https://github.com/acgetchell/delaunay/pull/459) [#464](https://github.com/acgetchell/delaunay/pull/464) +- Borrow topology views from canonical storage [#472](https://github.com/acgetchell/delaunay/pull/472) [#474](https://github.com/acgetchell/delaunay/pull/474) +- Make topology views and boundaries owner-aware [#476](https://github.com/acgetchell/delaunay/pull/476) +- Split Pachner moves from vertex lifecycle edits [#477](https://github.com/acgetchell/delaunay/pull/477) - Reject stale adjacency indexes [#451](https://github.com/acgetchell/delaunay/pull/451) [#463](https://github.com/acgetchell/delaunay/pull/463) - Update tooling to Rust 1.96.0 [#430](https://github.com/acgetchell/delaunay/pull/430) [#431](https://github.com/acgetchell/delaunay/pull/431) ### Merged Pull Requests +- Split Pachner moves from vertex lifecycle edits [#477](https://github.com/acgetchell/delaunay/pull/477) +- Make topology views and boundaries owner-aware [#476](https://github.com/acgetchell/delaunay/pull/476) +- Borrow topology views from canonical storage [#472](https://github.com/acgetchell/delaunay/pull/472) [#474](https://github.com/acgetchell/delaunay/pull/474) +- Refactor!(tds): canonicalize incidence storage [#473](https://github.com/acgetchell/delaunay/pull/473) +- Streamline iterator helpers for diagnostics work [#471](https://github.com/acgetchell/delaunay/pull/471) +- Add common Delaunay result alias [#470](https://github.com/acgetchell/delaunay/pull/470) - Add vertex construction macro [#469](https://github.com/acgetchell/delaunay/pull/469) - Preserve setup failure messages [#468](https://github.com/acgetchell/delaunay/pull/468) - Refactor/443 329 typed validation errors [#465](https://github.com/acgetchell/delaunay/pull/465) @@ -62,7 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Align shared development tooling pins with causal-triangulations and harden benchmark baseline metadata parsing. - - [**breaking**] Add vertex construction macro [#469](https://github.com/acgetchell/delaunay/pull/469) [`63228a0`](https://github.com/acgetchell/delaunay/commit/63228a06ffca2ee8d68806823995d92ebfa84525) @@ -70,6 +78,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Export the macro through the root, construction, and triangulation preludes. - Migrate public docs, examples, and benchmark setup to prefer `vertex!` for incidental vertex construction. - Retire the Semgrep rule that banned `vertex!` and document the new Rust style guidance. +- Add common Delaunay result alias [#470](https://github.com/acgetchell/delaunay/pull/470) + [`db46fa2`](https://github.com/acgetchell/delaunay/commit/db46fa2dd1518c1a47defda676453df8829665a6) + + - Add DelaunayError and DelaunayResult for common construction, insertion, validation, coordinate conversion, and toroidal-domain setup workflows. + - Re-export the aliases from the crate root and construction preludes for downstream examples and applications. + - Update public docs and examples to use DelaunayResult when workflow-specific errors are not required. ### Changed @@ -165,11 +179,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Report generated simplex counts in the large-scale smoke benchmark output. - Refactor/443 329 typed validation errors [#465](https://github.com/acgetchell/delaunay/pull/465) [`f6a85e8`](https://github.com/acgetchell/delaunay/commit/f6a85e84d43a81f1d5fdd8ec4c20e65e303dfe40) +- Streamline iterator helpers for diagnostics work [#471](https://github.com/acgetchell/delaunay/pull/471) + [`1985e6f`](https://github.com/acgetchell/delaunay/commit/1985e6ff95d0a452a09034d27d1bc9b21414befa) + + - Preserve point-generator preallocation while using fallible iterator control flow. + - Extract Poisson spacing checks and stream geometry test distance calculations. + - Gate benchmark setup tracing behind bench-logging and update diagnostics examples to use DelaunayResult. +- Refactor!(tds): canonicalize incidence storage [#473](https://github.com/acgetchell/delaunay/pull/473) + [`9a973df`](https://github.com/acgetchell/delaunay/commit/9a973dfa598692261d9ea475f179862e3e92de4d) +- [**breaking**] Borrow topology views from canonical storage [#472](https://github.com/acgetchell/delaunay/pull/472) + [#474](https://github.com/acgetchell/delaunay/pull/474) [`3b6d2d4`](https://github.com/acgetchell/delaunay/commit/3b6d2d4a71e0f40a2e8b1be3871696e9b8f13866) + + - Return validated borrowed simplex vertex slices instead of owned or optional detached snapshots. + - Split convex hull facet access into detached `facet_handles()` and borrowed `facets(triangulation)` views with freshness checks. + - Make vertex and simplex payload setters checked mutations that report typed stale-key errors. + - Preserve fallback rebuild payload restoration through typed simplex-data restore errors. +- [**breaking**] Make topology views and boundaries owner-aware [#476](https://github.com/acgetchell/delaunay/pull/476) + [`db3fcb3`](https://github.com/acgetchell/delaunay/commit/db3fcb3a899db625f3e969c8d2fa098520826479) + + - Replace raw TDS boundary queries with one-sided facet-incidence APIs and keep true boundary classification topology-aware. + - Add borrowed EdgeView, RidgeQuery, RidgeView, and RidgeLinkView surfaces around detached EdgeKey and RidgeCandidate values. + - Interpret boundary facets through GlobalTopology so periodic quotient self-identifications remain closed topology. + - Align Euler validation, focused preludes, docs, examples, benchmarks, and Semgrep rules with the new view/candidate/incidence model. +- [**breaking**] Split Pachner moves from vertex lifecycle edits [#477](https://github.com/acgetchell/delaunay/pull/477) + [`f6efe10`](https://github.com/acgetchell/delaunay/commit/f6efe10c776e08a52efe1d2a4b4e65cf56271cf2) + + - Add a unified PachnerMove request/result API with attempt_pachner dispatch and a focused prelude::pachner import surface. + - Move vertex deletion into its own Delaunay module with typed DeleteVertexError and keep insertion/deletion terminology explicit through public docs and + examples. + + - Keep primitive bistellar flip APIs available from delaunay::flips while hiding them from focused preludes intended for workflow users. + - Add Pachner stress coverage, delete_vertex benchmarks, and Semgrep rules that require fallible results to be consumed and workflow fixtures to use vertex!. + - Split long agent/development guidance into focused dev and architecture docs, including release citation and performance-tuning checklists. ### Dependencies - Bump support tooling and smallvec [`1799d3c`](https://github.com/acgetchell/delaunay/commit/1799d3cbd4a5c01a10c490fffe15cc28b1f3784d) +### Documentation + +- Refresh README and changelog [`b80d25a`](https://github.com/acgetchell/delaunay/commit/b80d25a40df9a06de8554a9900598bdf2a584dbf) + + - Reorganize the README around a concise quickstart, documentation map, + ecosystem notes, benchmarking guidance, and current limitations. + + - Preserve the citation abstract and detailed feature checklist while updating + quickstart guidance for the current published crate version. + + - Regenerate the active and archived changelog files with current release-note + formatting. + ### Fixed - [**breaking**] Reject stale adjacency indexes [#451](https://github.com/acgetchell/delaunay/pull/451) [#463](https://github.com/acgetchell/delaunay/pull/463) diff --git a/Cargo.toml b/Cargo.toml index e8b58e2d..a80ce64e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,11 @@ name = "delete_vertex" path = "benches/delete_vertex.rs" harness = false +[[bench]] +name = "edge_key_queries" +path = "benches/edge_key_queries.rs" +harness = false + [[bench]] name = "tds_clone" path = "benches/tds_clone.rs" diff --git a/benches/README.md b/benches/README.md index 4ec17d53..2becebea 100644 --- a/benches/README.md +++ b/benches/README.md @@ -17,6 +17,7 @@ predicates fast across 2D-5D. | `pl_manifold_repair.rs` | Over-shared facet repair plus orphan cleanup | 3D synthetic repair fixtures | <1 min | PL-manifold repair tuning | | `profiling_suite.rs` | Large-scale construction, memory, query, validation profiling | 2D/3D 10k, 4D 3k, 5D 1k | ~2-3 hr | Manual/monthly | | `delete_vertex.rs` | Vertex deletion and rollback cost | 2D-5D fixed cases | ~1-5 min | Vertex deletion | +| `edge_key_queries.rs` | Public `EdgeKey` construction microbenchmarks | 2D-5D fixed live-edge cases | <1 min | Query API tuning | | `tds_clone.rs` | `Tds::clone()` snapshot cost | Deterministic 2D-5D triangulations | ~1-3 min | Rollback design baselines | | `topology_guarantee_construction.rs` | Cost of topology guarantee modes | 2D-5D construction cases | ~5-15 min | Manual topology policy work | @@ -45,6 +46,7 @@ predicates fast across 2D-5D. | PL-manifold repair path | `cargo bench --profile perf --features bench --bench pl_manifold_repair -- --noplot` | | Large-scale scaling suite | `cargo bench --profile perf --bench profiling_suite -- --noplot` | | Vertex deletion mutation baseline | `cargo bench --profile perf --bench delete_vertex -- --noplot` | +| Edge-key query construction | `cargo bench --profile perf --bench edge_key_queries -- --noplot` | | One-dimension acceptance/profiling run | `just debug-large-scale-{2,3,4,5}d [n] [repair_every]` | | Deep profiling | `cargo bench --profile perf --bench profiling_suite --features count-allocations` | diff --git a/benches/edge_key_queries.rs b/benches/edge_key_queries.rs new file mode 100644 index 00000000..fc591880 --- /dev/null +++ b/benches/edge_key_queries.rs @@ -0,0 +1,168 @@ +#![forbid(unsafe_code)] + +//! Focused microbenchmarks for public edge-key query construction. +//! +//! Run with: +//! +//! ```bash +//! cargo bench --profile perf --bench edge_key_queries -- --noplot +//! ``` + +#[path = "common/bench_utils.rs"] +mod bench_utils; + +use criterion::{ + BenchmarkGroup, BenchmarkId, Criterion, criterion_group, criterion_main, measurement::WallTime, +}; +use delaunay::prelude::construction::{DelaunayTriangulation, DelaunayTriangulationBuilder}; +use delaunay::prelude::generators::generate_random_points_in_range_seeded; +use delaunay::prelude::geometry::{AdaptiveKernel, CoordinateRange}; +use delaunay::prelude::tds::{EdgeKey, VertexKey}; +use delaunay::try_vertices_from_points; +use std::{collections::BTreeSet, hint::black_box, time::Duration}; + +use bench_utils::{OrAbort, OrAbortWithContext, abort_benchmark}; + +const EDGE_PAIR_COUNT: usize = 128; +const COUNT_2D: usize = 1_000; +const COUNT_3D: usize = 350; +const COUNT_4D: usize = 80; +const COUNT_5D: usize = 35; +const SEED_2D: u64 = 9_321; +const SEED_3D: u64 = 9_322; +const SEED_4D: u64 = 9_323; +const SEED_5D: u64 = 9_324; +const SAMPLE_SIZE: usize = 32; + +type BenchTriangulation = DelaunayTriangulation, (), (), D>; + +/// Holds a prebuilt triangulation and live edge endpoints selected outside the measured path. +struct EdgeKeyFixture { + dt: BenchTriangulation, + endpoint_pairs: Vec<(VertexKey, VertexKey)>, + vertex_count: usize, + simplex_count: usize, +} + +/// Returns a deterministic coordinate range for benchmark point clouds. +fn benchmark_bounds() -> CoordinateRange { + CoordinateRange::try_new(-100.0_f64, 100.0).or_abort() +} + +/// Builds a deterministic triangulation and live edge endpoint pairs for one dimension. +fn prepare_fixture(count: usize, seed: u64) -> EdgeKeyFixture { + let points = + generate_random_points_in_range_seeded::(count, benchmark_bounds(), seed).or_abort(); + let vertices = try_vertices_from_points(&points).or_abort(); + let dt = DelaunayTriangulationBuilder::new(&vertices) + .build::<()>() + .or_abort(); + let endpoint_pairs = late_simplex_edge_pairs(&dt); + if endpoint_pairs.len() < EDGE_PAIR_COUNT { + abort_benchmark(format_args!( + "{D}D EdgeKey benchmark found {} unique edge pairs, expected at least {EDGE_PAIR_COUNT}", + endpoint_pairs.len() + )); + } + let _first_pair = endpoint_pairs.first().or_abort(format_args!( + "{D}D EdgeKey benchmark should contain at least one endpoint pair" + )); + + EdgeKeyFixture { + vertex_count: dt.number_of_vertices(), + simplex_count: dt.number_of_simplices(), + dt, + endpoint_pairs, + } +} + +/// Selects unique live edges from the end of simplex storage to exercise lookup cost. +fn late_simplex_edge_pairs( + dt: &BenchTriangulation, +) -> Vec<(VertexKey, VertexKey)> { + let simplex_vertices: Vec<_> = dt + .simplices() + .map(|(_simplex_key, simplex)| simplex.vertices().to_vec()) + .collect(); + let mut pairs = BTreeSet::new(); + + for vertices in simplex_vertices.iter().rev() { + for i in 0..vertices.len() { + for j in (i + 1)..vertices.len() { + let first = vertices[i]; + let second = vertices[j]; + pairs.insert(canonical_pair(first, second)); + if pairs.len() >= EDGE_PAIR_COUNT { + return pairs.into_iter().collect(); + } + } + } + } + + pairs.into_iter().collect() +} + +/// Canonicalizes an endpoint pair without constructing the `EdgeKey` being benchmarked. +fn canonical_pair(first: VertexKey, second: VertexKey) -> (VertexKey, VertexKey) { + if first <= second { + (first, second) + } else { + (second, first) + } +} + +/// Measures successful public `EdgeKey::try_new` construction for one dimension. +fn bench_edge_key_try_new( + group: &mut BenchmarkGroup<'_, WallTime>, + fixture: &EdgeKeyFixture, +) { + let tds = fixture.dt.tds(); + let endpoint_pairs = &fixture.endpoint_pairs; + + group.bench_function( + BenchmarkId::new( + format!("edge_key_try_new_{D}d"), + format!( + "vertices_{}_simplices_{}_edges_{}", + fixture.vertex_count, + fixture.simplex_count, + endpoint_pairs.len() + ), + ), + |b| { + b.iter(|| { + let mut edge_count = 0_usize; + for &(first, second) in endpoint_pairs { + let edge = + EdgeKey::try_new(tds, black_box(first), black_box(second)).or_abort(); + black_box(edge); + edge_count += 1; + } + assert_eq!(edge_count, endpoint_pairs.len()); + black_box(edge_count); + }); + }, + ); +} + +/// Runs edge-key construction benchmarks across supported practical dimensions. +fn edge_key_queries(c: &mut Criterion) { + let mut group = c.benchmark_group("edge_key_queries"); + group.sample_size(SAMPLE_SIZE); + group.warm_up_time(Duration::from_secs(1)); + group.measurement_time(Duration::from_secs(2)); + + let fixture_2d = prepare_fixture::<2>(COUNT_2D, SEED_2D); + let fixture_3d = prepare_fixture::<3>(COUNT_3D, SEED_3D); + let fixture_4d = prepare_fixture::<4>(COUNT_4D, SEED_4D); + let fixture_5d = prepare_fixture::<5>(COUNT_5D, SEED_5D); + + bench_edge_key_try_new(&mut group, &fixture_2d); + bench_edge_key_try_new(&mut group, &fixture_3d); + bench_edge_key_try_new(&mut group, &fixture_4d); + bench_edge_key_try_new(&mut group, &fixture_5d); + group.finish(); +} + +criterion_group!(benches, edge_key_queries); +criterion_main!(benches); diff --git a/docs/api_design.md b/docs/api_design.md index 5e2b2e78..280d02d3 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -437,6 +437,47 @@ Topology APIs use names to make ownership visible: full-TDS clone rollback with a journaled or localized design remains tracked by #364. +### Simplex-Local Incidence Query Vocabulary + +The public incidence-query surface names topology by simplex dimension, not by +one downstream move type: + +| Concept | Simplex dimension | Current public shape | +|---|---:|---| +| Vertex | 0 | `VertexKey`, `adjacent_simplices(vertex)` | +| Edge | 1 | `EdgeKey`, `EdgeView`, `incident_edges(vertex)` | +| Ridge | `D - 2` | `RidgeCandidate`, `RidgeQuery<'tds>`, `RidgeView<'tds>` | +| Facet | `D - 1` | `FacetHandle`, `FacetView<'tds>`, `FacetToSimplicesIndex<'tds, ...>` | +| Cell | `D` | `SimplexKey`, `Simplex` | + +In 2D, an edge is also a cell facet. The first public edge-to-facet bridge is +therefore 2D-specific: + +```rust +dt.try_incident_facets_to_edge_2d(edge) +dt.try_interior_facet_for_edge_2d(edge) +``` + +`try_incident_facets_to_edge_2d` parses the detached edge key against the +current TDS and returns the current simplex-local facet handles for that edge: +one handle for a boundary edge and two for an interior edge in a valid 2D PL +manifold. `try_interior_facet_for_edge_2d` returns one of those handles only +when the edge has exactly two incident 2D facets, making it suitable for +consumer code that needs a `FacetHandle` for a 2D k=2 local move. On +deliberately invalid low-level topology, non-manifold edge multiplicity is +visible through `try_incident_facets_to_edge_2d`; the narrower +`try_interior_facet_for_edge_2d` still returns `Ok(None)` because the edge is not +a two-sided 2D move support. + +These queries are read-only and do not expose a mutable cache. Implementations +may use neighbor walks, maintained TDS incidence, or lifetime-bound derived +indexes internally, but the public contract is stable: detached `*Key` and +`*Handle` inputs are revalidated against the current live owner. Stale keys and +corrupted incidence metadata return typed parse errors rather than being +silently conflated with empty topology. Higher-dimensional incidence should +generalize through simplex-key and ridge/facet/cell vocabulary instead of +treating edge-to-facet as universal. + Runtime generation or identity checks remain appropriate for detached handles, owned snapshots, serialization boundaries, persistent performance caches, and tests that intentionally construct inconsistent topology. They should not be diff --git a/src/core/edge.rs b/src/core/edge.rs index f21b5ba6..51136149 100644 --- a/src/core/edge.rs +++ b/src/core/edge.rs @@ -140,8 +140,9 @@ impl EdgeKey { /// /// Returns [`EdgeKeyError::DuplicateEndpoint`] if both endpoints are the /// same vertex, [`EdgeKeyError::MissingEndpoint`] if either endpoint is not - /// live in `tds`, or [`EdgeKeyError::EdgeNotFound`] if no simplex contains - /// both endpoints. + /// live in `tds`, [`EdgeKeyError::EdgeNotFound`] if no simplex contains both + /// endpoints, or another [`EdgeKeyError`] if the maintained vertex-incidence + /// metadata needed to prove the edge is inconsistent with simplex storage. /// /// # Examples /// @@ -192,14 +193,89 @@ impl EdgeKey { if tds.vertex(b).is_none() { return Err(EdgeKeyError::MissingEndpoint { endpoint: b }); } - if !tds - .simplices() - .any(|(_simplex_key, simplex)| simplex.contains_vertex(a) && simplex.contains_vertex(b)) - { - return Err(EdgeKeyError::EdgeNotFound { v0: a, v1: b }); + let key = Self::from_validated_endpoints(a, b); + let _validated_edge_star = Self::parse_edge_star(tds, key)?; + + Ok(key) + } + + /// Parses the edge star from mutually complete, internally valid endpoint incidence. + /// + /// The success path inspects endpoint stars instead of scanning every + /// simplex. Both endpoint stars are checked for every simplex containing the + /// opposite endpoint, so partially stale reverse incidence is rejected before + /// returning a key. A full simplex-storage scan is reserved for the cold path + /// where neither endpoint star reports the edge, so callers still get a + /// precise `MissingEdgeIncidence` diagnostic when storage and incidence + /// diverge. + fn parse_edge_star( + tds: &Tds, + key: Self, + ) -> Result { + let (v0, v1) = key.endpoints(); + let v0_degree = tds.vertex_to_simplices_index().number_of_simplices(v0); + let v1_degree = tds.vertex_to_simplices_index().number_of_simplices(v1); + let (primary, secondary) = if v0_degree <= v1_degree { + (v0, v1) + } else { + (v1, v0) + }; + + let primary_edge_simplices = Self::edge_simplices_in_star(tds, primary, secondary)?; + let secondary_edge_simplices = Self::edge_simplices_in_star(tds, secondary, primary)?; + + for &simplex_key in primary_edge_simplices.as_slice() { + if !secondary_edge_simplices.contains(&simplex_key) { + return Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: secondary, + simplex_key, + }); + } } - Ok(Self::from_validated_endpoints(a, b)) + for &simplex_key in secondary_edge_simplices.as_slice() { + if !primary_edge_simplices.contains(&simplex_key) { + return Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: primary, + simplex_key, + }); + } + } + + if !primary_edge_simplices.is_empty() { + return Ok(primary_edge_simplices); + } + if EdgeView::endpoints_share_stored_simplex(tds, v0, v1) { + return Err(EdgeKeyError::MissingEdgeIncidence { v0, v1 }); + } + Err(EdgeKeyError::EdgeNotFound { v0, v1 }) + } + + /// Finds all simplices in `source`'s validated star that also contain `target`. + fn edge_simplices_in_star( + tds: &Tds, + source: VertexKey, + target: VertexKey, + ) -> Result { + let mut edge_simplices = SimplexKeyBuffer::new(); + for simplex_key in tds.simplex_keys_containing_vertex(source) { + let simplex = + tds.simplex(simplex_key) + .ok_or(EdgeKeyError::DanglingVertexIncidence { + vertex_key: source, + simplex_key, + })?; + if !simplex.contains_vertex(source) { + return Err(EdgeKeyError::VertexIncidenceMismatch { + vertex_key: source, + simplex_key, + }); + } + if simplex.contains_vertex(target) { + edge_simplices.push(simplex_key); + } + } + Ok(edge_simplices) } /// Creates a canonical edge key from endpoints already known to be distinct. @@ -468,7 +544,7 @@ impl<'tds, U, V, const D: usize> EdgeView<'tds, U, V, D> { .ok_or(EdgeKeyError::MissingEndpoint { endpoint: v1 })?; let key = EdgeKey::from_validated_endpoints(v0, v1); - let incident_simplices = Self::validated_incident_simplices(tds, key)?; + let incident_simplices = EdgeKey::parse_edge_star(tds, key)?; Ok(Self { tds, @@ -514,103 +590,6 @@ impl<'tds, U, V, const D: usize> EdgeView<'tds, U, V, D> { self.incident_simplices.as_slice() } - /// Builds the edge star while checking that endpoint incidence agrees with simplex storage. - /// - /// This helper protects the public [`Self::try_new`] contract by - /// distinguishing a genuinely absent edge from an edge whose simplex storage - /// exists but whose maintained vertex-incidence index is stale. - fn validated_incident_simplices( - tds: &Tds, - key: EdgeKey, - ) -> Result { - let (v0, v1) = key.endpoints(); - let v0_star = Self::validated_endpoint_incidence(tds, v0)?; - let v1_star = Self::validated_endpoint_incidence(tds, v1)?; - let mut incident_simplices = SimplexKeyBuffer::new(); - - for simplex_key in v0_star { - let simplex = - tds.simplex(simplex_key) - .ok_or(EdgeKeyError::DanglingVertexIncidence { - vertex_key: v0, - simplex_key, - })?; - if !simplex.contains_vertex(v0) { - return Err(EdgeKeyError::VertexIncidenceMismatch { - vertex_key: v0, - simplex_key, - }); - } - if !simplex.contains_vertex(v1) { - continue; - } - if !v1_star.contains(&simplex_key) { - return Err(EdgeKeyError::MissingVertexIncidence { - vertex_key: v1, - simplex_key, - }); - } - incident_simplices.push(simplex_key); - } - - for simplex_key in v1_star { - let simplex = - tds.simplex(simplex_key) - .ok_or(EdgeKeyError::DanglingVertexIncidence { - vertex_key: v1, - simplex_key, - })?; - if !simplex.contains_vertex(v1) { - return Err(EdgeKeyError::VertexIncidenceMismatch { - vertex_key: v1, - simplex_key, - }); - } - if simplex.contains_vertex(v0) && !incident_simplices.contains(&simplex_key) { - return Err(EdgeKeyError::MissingVertexIncidence { - vertex_key: v0, - simplex_key, - }); - } - } - - if incident_simplices.is_empty() { - if Self::endpoints_share_stored_simplex(tds, v0, v1) { - return Err(EdgeKeyError::MissingEdgeIncidence { v0, v1 }); - } - return Err(EdgeKeyError::EdgeNotFound { v0, v1 }); - } - - Ok(incident_simplices) - } - - /// Copies one endpoint's incidence list after proving every entry still contains that endpoint. - /// - /// The returned buffer can be intersected with the opposite endpoint's star - /// without silently accepting dangling or mismatched incidence metadata. - fn validated_endpoint_incidence( - tds: &Tds, - vertex_key: VertexKey, - ) -> Result { - let mut incident_simplices = SimplexKeyBuffer::new(); - for simplex_key in tds.simplex_keys_containing_vertex(vertex_key) { - let simplex = - tds.simplex(simplex_key) - .ok_or(EdgeKeyError::DanglingVertexIncidence { - vertex_key, - simplex_key, - })?; - if !simplex.contains_vertex(vertex_key) { - return Err(EdgeKeyError::VertexIncidenceMismatch { - vertex_key, - simplex_key, - }); - } - incident_simplices.push(simplex_key); - } - Ok(incident_simplices) - } - /// Scans canonical simplex storage to classify a missing edge-incidence entry precisely. /// /// Returning `true` means the edge exists in simplex storage and the @@ -850,6 +829,42 @@ mod tests { assert_eq!(incident, HashSet::from([c0, c1])); } + #[test] + fn edge_key_rejects_partial_missing_reverse_vertex_incidence() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + let v0 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 1.0]).unwrap()) + .unwrap(); + + let retained_simplex = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v1, v2]).unwrap()) + .unwrap(); + let missing_simplex = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v1, v0, v3]).unwrap()) + .unwrap(); + let edge = EdgeKey::from_validated_endpoints(v0, v1); + let (_first, second) = edge.endpoints(); + + tds.clear_vertex_incidence_for_test(second); + tds.add_simplex_to_vertex_incidence_for_test(second, retained_simplex); + + let expected_error = EdgeKeyError::MissingVertexIncidence { + vertex_key: second, + simplex_key: missing_simplex, + }; + assert_eq!(EdgeKey::try_new(&tds, v0, v1), Err(expected_error)); + assert_eq!(edge.view(&tds), Err(expected_error)); + } + #[test] fn edge_view_rejects_stale_vertex_incidence() { let mut tds: Tds<(), (), 3> = Tds::empty(); @@ -871,6 +886,13 @@ mod tests { tds.remove_simplex_storage_only_for_test(stale_simplex); let edge = EdgeKey::from_validated_endpoints(v0, v1); + assert_eq!( + EdgeKey::try_new(&tds, v0, v1), + Err(EdgeKeyError::DanglingVertexIncidence { + vertex_key: edge.v0(), + simplex_key: stale_simplex + }) + ); assert_eq!( edge.view(&tds), Err(EdgeKeyError::DanglingVertexIncidence { @@ -900,6 +922,13 @@ mod tests { tds.clear_vertex_incidence_for_test(second); + assert_eq!( + EdgeKey::try_new(&tds, v0, v1), + Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: second, + simplex_key + }) + ); assert_eq!( edge.view(&tds), Err(EdgeKeyError::MissingVertexIncidence { @@ -929,6 +958,13 @@ mod tests { tds.clear_vertex_incidence_for_test(first); + assert_eq!( + EdgeKey::try_new(&tds, v0, v1), + Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: first, + simplex_key + }) + ); assert_eq!( edge.view(&tds), Err(EdgeKeyError::MissingVertexIncidence { diff --git a/src/core/query.rs b/src/core/query.rs index c4ca89f9..ee9d0c08 100644 --- a/src/core/query.rs +++ b/src/core/query.rs @@ -11,11 +11,12 @@ use crate::core::collections::{ FastHashMap, FastHashSet, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer, fast_hash_map_with_capacity, fast_hash_set_with_capacity, }; -use crate::core::edge::EdgeKey; -use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; +use crate::core::edge::{EdgeKey, EdgeKeyError, EdgeView}; +use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle}; use crate::core::simplex::Simplex; use crate::core::tds::{SimplexKey, TdsError, VertexKey}; use crate::core::triangulation::Triangulation; +use crate::core::util::usize_to_u8; use crate::core::vertex::Vertex; use crate::topology::manifold::{ManifoldError, boundary_facet_handles_from_index}; use std::marker::PhantomData; @@ -771,6 +772,164 @@ impl Triangulation { } } +impl Triangulation { + /// Returns one simplex-local facet handle for an interior 2D edge. + /// + /// In 2D, a cell is a triangle and each cell facet is an edge. This query + /// maps a live interior [`EdgeKey`] to one of the two incident + /// [`FacetHandle`] values that can be passed to 2D local-edit APIs such as + /// Pachner k=2 flips. + /// + /// Edges that do not have exactly two incident 2D facets return `Ok(None)`. + /// In a valid 2D PL-manifold this means a boundary edge, but callers working + /// with deliberately invalid low-level topology can inspect the exact + /// multiplicity with [`Self::try_incident_facets_to_edge_2d`]. Stale edge + /// keys and keys that no longer identify a live edge return the + /// [`EdgeKeyError`] produced while parsing the edge. Use + /// [`Self::try_incident_facets_to_edge_2d`] when callers need to distinguish + /// boundary edges from interior edges by multiplicity. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if `edge` is stale, no longer identifies a live + /// edge, or exposes inconsistent maintained incidence metadata in this + /// triangulation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::tds::EdgeKey; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Edge(#[from] delaunay::prelude::tds::EdgeKeyError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let tri = dt.as_triangulation(); + /// let Some((_simplex_key, simplex)) = tri.simplices().next() else { + /// return Ok(()); + /// }; + /// + /// let boundary_edge = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// assert!(tri.try_interior_facet_for_edge_2d(boundary_edge)?.is_none()); + /// # Ok(()) + /// # } + /// ``` + pub fn try_interior_facet_for_edge_2d( + &self, + edge: EdgeKey, + ) -> Result, EdgeKeyError> { + let edge_view = edge.view(&self.tds)?; + let facets = Self::collect_incident_facets_to_edge_2d(&edge_view); + Ok((facets.len() == 2).then(|| facets[0])) + } + + /// Returns the simplex-local facet handles incident to a 2D edge. + /// + /// In a valid 2D triangulation, this yields one handle for a boundary edge + /// and two handles for an interior edge. Stale edge keys and keys that no + /// longer identify a live edge return the [`EdgeKeyError`] produced while + /// parsing the edge. + /// + /// The query is computed from the current TDS vertex→simplices incidence + /// relation, so it reflects local topology mutations without requiring a + /// public mutable cache. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if `edge` is stale, no longer identifies a live + /// edge, or exposes inconsistent maintained incidence metadata in this + /// triangulation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::tds::EdgeKey; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Edge(#[from] delaunay::prelude::tds::EdgeKeyError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let tri = dt.as_triangulation(); + /// let Some((_simplex_key, simplex)) = tri.simplices().next() else { + /// return Ok(()); + /// }; + /// + /// let boundary_edge = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// assert_eq!(tri.try_incident_facets_to_edge_2d(boundary_edge)?.count(), 1); + /// # Ok(()) + /// # } + /// ``` + pub fn try_incident_facets_to_edge_2d( + &self, + edge: EdgeKey, + ) -> Result, EdgeKeyError> { + let edge_view = edge.view(&self.tds)?; + Ok(Self::collect_incident_facets_to_edge_2d(&edge_view).into_iter()) + } + + /// Builds the edge-to-facet answer from a parsed live edge star without storing a cache. + /// + /// Public callers reach this helper only after [`EdgeKey::view`] has proven + /// that the detached key is a live edge in this TDS, so the collector stays + /// infallible and preserves parse failures at the public query boundary. + fn collect_incident_facets_to_edge_2d( + edge_view: &EdgeView<'_, U, V, 2>, + ) -> SmallBuffer { + let (v0, v1) = edge_view.endpoint_keys(); + let mut facets = SmallBuffer::new(); + + for &simplex_key in edge_view.incident_simplices() { + let simplex = edge_view + .tds() + .simplex(simplex_key) + .expect("validated EdgeView contains only live incident simplices"); + let facet_index = Self::facet_index_for_edge_2d(simplex, v0, v1); + facets.push(FacetHandle::from_validated(simplex_key, facet_index)); + } + + facets + } + + /// Converts a validated triangle edge into the facet slot opposite the third vertex. + /// + /// Callers only pass simplices from a parsed [`EdgeView`], so each simplex + /// is already proven to contain both edge endpoints. + fn facet_index_for_edge_2d(simplex: &Simplex, v0: VertexKey, v1: VertexKey) -> u8 { + let vertices = simplex.vertices(); + let facet_index = vertices + .iter() + .position(|&vertex_key| vertex_key != v0 && vertex_key != v1) + .expect("validated 2D EdgeView incident simplex contains both edge endpoints"); + usize_to_u8(facet_index, vertices.len()).expect("2D simplex facet index fits in u8") + } +} + #[cfg(test)] mod tests { use super::*; @@ -834,6 +993,43 @@ mod tests { Triangulation::new_with_tds(FastKernel::new(), tds) } + /// Builds an invalid 2D complex with three triangles sharing the same edge. + fn overshared_edge_fixture_2d() -> ( + Triangulation, (), (), 2>, + VertexKey, + VertexKey, + ) { + let mut tds = Tds::empty(); + let left_endpoint = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) + .unwrap(); + let right_endpoint = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) + .unwrap(); + let upper_apex = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) + .unwrap(); + let lower_apex = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, -1.0]).unwrap()) + .unwrap(); + let extra_apex = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 0.5]).unwrap()) + .unwrap(); + + for apex in [upper_apex, lower_apex, extra_apex] { + tds.insert_simplex_with_mapping_prechecked_topology( + Simplex::try_new(vec![left_endpoint, right_endpoint, apex]).unwrap(), + ) + .unwrap(); + } + + ( + Triangulation::new_with_tds(FastKernel::new(), tds), + left_endpoint, + right_endpoint, + ) + } + /// Returns the edge count for two D-simplices sharing one facet. const fn expected_split_topology_fixture_edges() -> usize { D * (D + 3) / 2 @@ -842,12 +1038,52 @@ mod tests { /// Finds one shared-facet vertex in the split topology fixture. fn shared_fixture_vertex( tri: &Triangulation, (), (), D>, + ) -> VertexKey { + fixture_vertex_by_coords(tri, [0.0; D]) + } + + /// Finds one fixture vertex by exact coordinates chosen by the deterministic helper. + fn fixture_vertex_by_coords( + tri: &Triangulation, (), (), D>, + coords: [f64; D], ) -> VertexKey { tri.vertices() - .find_map(|(vk, _)| (tri.vertex_coords(vk)? == [0.0; D]).then_some(vk)) + .find_map(|(vk, _)| (tri.vertex_coords(vk)? == coords).then_some(vk)) .unwrap() } + /// Checks that each returned 2D facet handle resolves to the queried edge. + fn assert_facet_handles_match_edge_2d( + tri: &Triangulation, (), (), 2>, + edge: EdgeKey, + facets: impl IntoIterator, + ) { + let (v0, v1) = edge.endpoints(); + + for facet in facets { + let view = facet + .view(&tri.tds) + .expect("edge-to-facet query should return live facet handles"); + let facet_index = usize::from(facet.facet_index()); + let mut facet_vertices = view + .simplex() + .vertices() + .iter() + .enumerate() + .filter_map(|(index, &vertex_key)| (index != facet_index).then_some(vertex_key)); + + assert_matches!( + ( + facet_vertices.next(), + facet_vertices.next(), + facet_vertices.next() + ), + (Some(a), Some(b), None) if (a == v0 && b == v1) || (a == v1 && b == v0), + "facet {facet:?} should describe queried edge {edge:?}" + ); + } + } + /// Basic accessor tests across dimensions. macro_rules! test_basic_accessors { ($dim:expr, [$($simplex_coords:expr),+ $(,)?]) => { @@ -1021,6 +1257,84 @@ mod tests { })); } + #[test] + fn edge_to_facet_queries_classify_2d_boundary_and_interior_edges() { + let tri = split_topology_fixture::<2>(); + let shared_left = fixture_vertex_by_coords(&tri, [0.0, 0.0]); + let shared_right = fixture_vertex_by_coords(&tri, [1.0, 0.0]); + let positive_apex = fixture_vertex_by_coords(&tri, [0.2, 1.0]); + + let interior_edge = EdgeKey::try_new(&tri.tds, shared_left, shared_right).unwrap(); + let incident_facets: HashSet<_> = tri + .try_incident_facets_to_edge_2d(interior_edge) + .unwrap() + .collect(); + let interior_facet = tri + .try_interior_facet_for_edge_2d(interior_edge) + .unwrap() + .expect("shared edge should be interior"); + + assert_eq!(incident_facets.len(), 2); + assert!(incident_facets.contains(&interior_facet)); + assert_facet_handles_match_edge_2d(&tri, interior_edge, incident_facets.iter().copied()); + assert_facet_handles_match_edge_2d(&tri, interior_edge, [interior_facet]); + + let boundary_edge = EdgeKey::try_new(&tri.tds, shared_left, positive_apex).unwrap(); + let boundary_facets: Vec<_> = tri + .try_incident_facets_to_edge_2d(boundary_edge) + .unwrap() + .collect(); + assert_eq!(boundary_facets.len(), 1); + assert_facet_handles_match_edge_2d(&tri, boundary_edge, boundary_facets); + assert!( + tri.try_interior_facet_for_edge_2d(boundary_edge) + .unwrap() + .is_none() + ); + } + + #[test] + fn edge_to_facet_queries_expose_non_manifold_2d_multiplicity() { + let (tri, a, b) = overshared_edge_fixture_2d(); + let overshared_edge = EdgeKey::try_new(&tri.tds, a, b).unwrap(); + + let incident_facets: Vec<_> = tri + .try_incident_facets_to_edge_2d(overshared_edge) + .unwrap() + .collect(); + + assert_eq!(incident_facets.len(), 3); + assert_facet_handles_match_edge_2d(&tri, overshared_edge, incident_facets); + assert!( + tri.try_interior_facet_for_edge_2d(overshared_edge) + .unwrap() + .is_none() + ); + } + + #[test] + fn edge_to_facet_query_preserves_incidence_parse_errors() { + let mut tri = split_topology_fixture::<2>(); + let shared_left = fixture_vertex_by_coords(&tri, [0.0, 0.0]); + let shared_right = fixture_vertex_by_coords(&tri, [1.0, 0.0]); + let edge = EdgeKey::try_new(&tri.tds, shared_left, shared_right).unwrap(); + let (first, _) = edge.endpoints(); + + tri.tds.clear_vertex_incidence_for_test(first); + + match tri.try_incident_facets_to_edge_2d(edge) { + Err(EdgeKeyError::MissingVertexIncidence { vertex_key, .. }) if vertex_key == first => { + } + Err(err) => panic!("expected missing vertex incidence for {first:?}, got {err:?}"), + Ok(_) => panic!("expected missing vertex incidence for {first:?}, got facets"), + } + assert_matches!( + tri.try_interior_facet_for_edge_2d(edge), + Err(EdgeKeyError::MissingVertexIncidence { vertex_key, .. }) + if vertex_key == first + ); + } + #[test] fn topology_edges_and_incident_edges_double_tetrahedron_3d() { let vertices = vec![ diff --git a/src/core/tds/incidence.rs b/src/core/tds/incidence.rs index 092a5cf8..a2688a4d 100644 --- a/src/core/tds/incidence.rs +++ b/src/core/tds/incidence.rs @@ -337,6 +337,8 @@ impl VertexIncidenceIndex { #[cfg(test)] mod tests { + use std::assert_matches; + use super::*; use slotmap::KeyData; @@ -356,7 +358,7 @@ mod tests { let err = index.insert_vertex(vertex).unwrap_err(); - assert!(matches!(err, TdsError::InconsistentDataStructure { .. })); + assert_matches!(err, TdsError::InconsistentDataStructure { .. }); assert!(index.contains_vertex(vertex)); assert_eq!(index.number_of_simplices(vertex), 0); } @@ -367,7 +369,7 @@ mod tests { let err = index .insert_simplex(simplex_key(1), &[vertex_key(1)]) .unwrap_err(); - assert!(matches!(err, TdsError::VertexNotFound { .. })); + assert_matches!(err, TdsError::VertexNotFound { .. }); } #[test] @@ -382,7 +384,7 @@ mod tests { .insert_simplex(simplex, &[existing, missing]) .unwrap_err(); - assert!(matches!(err, TdsError::VertexNotFound { .. })); + assert_matches!(err, TdsError::VertexNotFound { .. }); assert_eq!(index.number_of_simplices(existing), 0); } @@ -397,7 +399,7 @@ mod tests { .insert_simplex(simplex, &[vertex, vertex]) .unwrap_err(); - assert!(matches!(err, TdsError::InconsistentDataStructure { .. })); + assert_matches!(err, TdsError::InconsistentDataStructure { .. }); assert_eq!(index.number_of_simplices(vertex), 0); } @@ -428,7 +430,7 @@ mod tests { index.insert_simplex(simplex, &[vertex]).unwrap(); let err = index.remove_isolated_vertex(vertex).unwrap_err(); - assert!(matches!(err, TdsError::InconsistentDataStructure { .. })); + assert_matches!(err, TdsError::InconsistentDataStructure { .. }); } #[test] @@ -437,7 +439,7 @@ mod tests { let err = index.remove_isolated_vertex(vertex_key(1)).unwrap_err(); - assert!(matches!(err, TdsError::VertexNotFound { .. })); + assert_matches!(err, TdsError::VertexNotFound { .. }); assert!(index.is_empty()); } @@ -453,7 +455,7 @@ mod tests { .remove_simplex(simplex, &[vertex, vertex]) .unwrap_err(); - assert!(matches!(err, TdsError::InconsistentDataStructure { .. })); + assert_matches!(err, TdsError::InconsistentDataStructure { .. }); assert_eq!( index.simplex_keys(vertex).collect::>(), vec![simplex] @@ -475,7 +477,7 @@ mod tests { .remove_simplex(before[1], &[vertex, missing]) .unwrap_err(); - assert!(matches!(err, TdsError::VertexNotFound { .. })); + assert_matches!(err, TdsError::VertexNotFound { .. }); assert_eq!( index.simplex_keys(vertex).collect::>(), before.to_vec() diff --git a/src/delaunay/query.rs b/src/delaunay/query.rs index 4f2d2c21..9f97bfb7 100644 --- a/src/delaunay/query.rs +++ b/src/delaunay/query.rs @@ -10,8 +10,8 @@ use crate::core::adjacency::{ EdgeIndex, IncidenceView, SimplexNeighborIndex, TopologyIndexBuildError, TriangulationAdjacency, }; -use crate::core::edge::EdgeKey; -use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; +use crate::core::edge::{EdgeKey, EdgeKeyError}; +use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle}; use crate::core::query::QueryError; use crate::core::simplex::Simplex; use crate::core::tds::{InvariantError, SimplexKey, Tds, TdsError, TdsMutationError, VertexKey}; @@ -1303,6 +1303,129 @@ impl DelaunayTriangulation { self.as_triangulation().vertex_coords(v) } } + +// ============================================================================= +// 2D SIMPLEX-LOCAL INCIDENCE QUERIES +// ============================================================================= + +impl DelaunayTriangulation { + /// Returns one simplex-local facet handle for an interior 2D edge. + /// + /// In 2D, a cell is a triangle and each cell facet is an edge. This query + /// maps a live interior [`EdgeKey`] to one of the two incident + /// [`FacetHandle`] values that can be passed to local-edit APIs such as + /// Pachner k=2 flips. + /// + /// Edges that do not have exactly two incident 2D facets return `Ok(None)`. + /// In a valid 2D PL-manifold this means a boundary edge, but callers working + /// with deliberately invalid low-level topology can inspect the exact + /// multiplicity with [`Self::try_incident_facets_to_edge_2d`]. Stale edge + /// keys and keys that no longer identify a live edge return the + /// [`EdgeKeyError`] produced while parsing the edge. Use + /// [`Self::try_incident_facets_to_edge_2d`] when callers need to distinguish + /// boundary edges from interior edges by multiplicity. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if `edge` is stale, no longer identifies a live + /// edge, or exposes inconsistent maintained incidence metadata in this + /// triangulation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::DelaunayTriangulationBuilder; + /// use delaunay::prelude::tds::EdgeKey; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Edge(#[from] delaunay::prelude::tds::EdgeKeyError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let Some((_simplex_key, simplex)) = dt.simplices().next() else { + /// return Ok(()); + /// }; + /// + /// let boundary_edge = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// assert!(dt.try_interior_facet_for_edge_2d(boundary_edge)?.is_none()); + /// # Ok(()) + /// # } + /// ``` + #[inline] + pub fn try_interior_facet_for_edge_2d( + &self, + edge: EdgeKey, + ) -> Result, EdgeKeyError> { + self.as_triangulation().try_interior_facet_for_edge_2d(edge) + } + + /// Returns the simplex-local facet handles incident to a 2D edge. + /// + /// In a valid 2D triangulation, this yields one handle for a boundary edge + /// and two handles for an interior edge. Stale edge keys and keys that no + /// longer identify a live edge return the [`EdgeKeyError`] produced while + /// parsing the edge. + /// + /// The query is computed from the current TDS vertex→simplices incidence + /// relation, so it reflects local topology mutations without requiring a + /// public mutable cache. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if `edge` is stale, no longer identifies a live + /// edge, or exposes inconsistent maintained incidence metadata in this + /// triangulation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::DelaunayTriangulationBuilder; + /// use delaunay::prelude::tds::EdgeKey; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Edge(#[from] delaunay::prelude::tds::EdgeKeyError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let Some((_simplex_key, simplex)) = dt.simplices().next() else { + /// return Ok(()); + /// }; + /// + /// let boundary_edge = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// assert_eq!(dt.try_incident_facets_to_edge_2d(boundary_edge)?.count(), 1); + /// # Ok(()) + /// # } + /// ``` + #[inline] + pub fn try_incident_facets_to_edge_2d( + &self, + edge: EdgeKey, + ) -> Result, EdgeKeyError> { + self.as_triangulation().try_incident_facets_to_edge_2d(edge) + } +} #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index ab485d3c..7413f1a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1104,7 +1104,7 @@ pub mod query { pub use crate::geometry::traits::coordinate::Coordinate; pub use crate::geometry::{insphere, insphere_distance, insphere_lifted}; pub use crate::tds::{ - AllFacetsIter, BoundaryFacetsIter, EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, + AllFacetsIter, BoundaryFacetsIter, EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, FacetHandle, FacetIncidenceView, FacetToSimplicesIndex, FacetView, IncidenceView, OneSidedFacetsIter, Simplex, SimplexFacetsIter, SimplexKey, SimplexNeighborIndex, TopologyIndexBuildError, TriangulationAdjacency, Vertex, VertexKey, @@ -1746,9 +1746,9 @@ pub mod prelude { pub mod query { // Core read-only traversal / adjacency pub use crate::tds::{ - EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, FacetIncidenceView, FacetToSimplicesIndex, - IncidenceView, SimplexKey, SimplexNeighborIndex, TopologyIndexBuildError, - TriangulationAdjacency, VertexKey, + EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, FacetHandle, FacetIncidenceView, + FacetToSimplicesIndex, IncidenceView, SimplexKey, SimplexNeighborIndex, + TopologyIndexBuildError, TriangulationAdjacency, VertexKey, }; pub use crate::{DelaunayTriangulation, Triangulation}; diff --git a/tests/pachner_roundtrip.rs b/tests/pachner_roundtrip.rs index b54e4f3a..2fa42b38 100644 --- a/tests/pachner_roundtrip.rs +++ b/tests/pachner_roundtrip.rs @@ -2,18 +2,23 @@ //! Public API roundtrip tests for Pachner/bistellar flips. +use std::assert_matches; + use delaunay::prelude::construction::{ ConstructionOptions, DelaunayError, DelaunayResult, DelaunayTriangulation, - InsertionOrderStrategy, TopologyGuarantee, Vertex, vertex, + DelaunayTriangulationBuilder, InsertionOrderStrategy, TopologyGuarantee, Vertex, vertex, }; use delaunay::prelude::geometry::RobustKernel; use delaunay::prelude::pachner::{ - BistellarFlipKind, EdgeKey, FacetHandle, FlipDirection, FlipError, PachnerMove, + BistellarFlipKind, EdgeKey, EdgeKeyError, FacetHandle, FlipDirection, FlipError, PachnerMove, PachnerMoveResult, PachnerMoves, RidgeHandle, SimplexKey, TriangleHandle, VertexKey, }; use uuid::Uuid; type Dt4 = DelaunayTriangulation, (), (), 4>; +type Dt2 = DelaunayTriangulation, (), (), 2>; + +const FLIPPABLE_POINTS_2D: &[[f64; 2]] = &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; const STABLE_POINTS_4D: &[[f64; 4]] = &[ [0.0, 0.0, 0.0, 0.0], @@ -95,13 +100,11 @@ fn stale_pachner_error_propagates_through_delaunay_result() { let err = try_stale_k1_insert(&mut dt, stale_simplex, vertex_coords) .expect_err("stale Pachner failure should propagate through DelaunayResult"); - assert!( - matches!( - &err, - DelaunayError::Flip { - source: FlipError::MissingSimplex { simplex_key }, - } if *simplex_key == stale_simplex - ), + assert_matches!( + &err, + DelaunayError::Flip { + source: FlipError::MissingSimplex { simplex_key }, + } if *simplex_key == stale_simplex, "unexpected DelaunayResult error for stale Pachner move: {err:?}" ); dt.tds() @@ -110,6 +113,64 @@ fn stale_pachner_error_propagates_through_delaunay_result() { assert_eq!(snapshot_topology(&dt), before_failed_attempt); } +#[test] +fn edge_to_facet_query_tracks_2d_k2_mutation_freshness() { + let mut dt = build_flippable_dt_2d(); + let facet = flippable_k2_facet_2d(&dt); + let old_edge = edge_for_facet_2d(&dt, facet); + + let old_incident_facets: Vec<_> = dt + .try_incident_facets_to_edge_2d(old_edge) + .unwrap() + .collect(); + assert_eq!(old_incident_facets.len(), 2); + assert!(old_incident_facets.contains(&facet)); + assert!( + old_incident_facets + .iter() + .all(|&incident_facet| edge_for_facet_2d(&dt, incident_facet) == old_edge) + ); + assert!( + dt.try_interior_facet_for_edge_2d(old_edge) + .unwrap() + .is_some() + ); + + let info = dt + .attempt_pachner(PachnerMove::K2 { facet }) + .expect("2D k=2 flip should succeed on selected fixture facet"); + assert_eq!(info.inserted_face_vertices.len(), 2); + + match dt.try_incident_facets_to_edge_2d(old_edge) { + Err(EdgeKeyError::EdgeNotFound { .. }) => {} + Err(err) => panic!("expected old edge to be absent after k=2 flip, got {err:?}"), + Ok(_) => panic!("expected old edge to be absent after k=2 flip, got facets"), + } + assert_matches!( + dt.try_interior_facet_for_edge_2d(old_edge), + Err(EdgeKeyError::EdgeNotFound { .. }) + ); + + let new_edge = inserted_edge_2d(&dt, &info.inserted_face_vertices); + let new_incident_facets: Vec<_> = dt + .try_incident_facets_to_edge_2d(new_edge) + .unwrap() + .collect(); + assert_eq!(new_incident_facets.len(), 2); + assert!( + new_incident_facets + .iter() + .all(|&incident_facet| edge_for_facet_2d(&dt, incident_facet) == new_edge) + ); + assert!( + dt.try_interior_facet_for_edge_2d(new_edge) + .unwrap() + .is_some() + ); + dt.validate() + .expect("2D k=2 mutation-freshness fixture should remain valid"); +} + /// Attempts a stale k=1 insert through the public `DelaunayResult` alias. #[expect( clippy::result_large_err, @@ -152,6 +213,80 @@ fn build_stable_dt_4d() -> Dt4 { .expect("stable 4D fixture should build") } +/// Builds a deterministic 2D fixture with at least one public k=2 move. +fn build_flippable_dt_2d() -> Dt2 { + let vertices = FLIPPABLE_POINTS_2D + .iter() + .map(|coords| Vertex::<(), 2>::try_new(*coords).expect("stable 2D fixture coordinates")) + .collect::>(); + let simplices = vec![vec![0, 1, 2], vec![0, 2, 3]]; + + let dt = DelaunayTriangulationBuilder::try_from_vertices_and_simplices(&vertices, &simplices) + .expect("explicit 2D fixture connectivity should parse") + .build_with_kernel::<_, ()>(&RobustKernel::new()) + .expect("stable 2D fixture should build"); + dt.validate() + .expect("stable 2D fixture should validate before local edits"); + dt +} + +/// Searches the 2D fixture for an edge facet whose public k=2 move succeeds. +fn flippable_k2_facet_2d(dt: &Dt2) -> FacetHandle { + for (simplex_key, simplex) in dt.simplices() { + let Some(neighbors) = simplex.neighbors() else { + continue; + }; + for (facet_index, neighbor) in neighbors.enumerate() { + if neighbor.is_none() { + continue; + } + let facet = FacetHandle::try_new( + dt.tds(), + simplex_key, + u8::try_from(facet_index).expect("2D facet index should fit in u8"), + ) + .expect("interior 2D facet index should be valid"); + let mut trial = dt.clone(); + if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() && trial.validate().is_ok() + { + return facet; + } + } + } + panic!("stable 2D fixture should contain a public k=2 candidate"); +} + +/// Converts a 2D facet handle into the edge key represented by that facet. +fn edge_for_facet_2d(dt: &Dt2, facet: FacetHandle) -> EdgeKey { + let view = facet + .view(dt.tds()) + .expect("facet handle should still be live"); + let endpoints = view + .simplex() + .vertices() + .iter() + .enumerate() + .filter_map(|(index, &vertex_key)| { + (index != usize::from(view.facet_index())).then_some(vertex_key) + }) + .collect::>(); + let [a, b] = endpoints.as_slice() else { + panic!("2D facet should contain exactly two edge endpoints"); + }; + EdgeKey::try_new(dt.tds(), *a, *b).expect("facet endpoints should form a live edge") +} + +/// Parses the inserted edge reported by a 2D k=2 move. +fn inserted_edge_2d(dt: &Dt2, vertices: &[VertexKey]) -> EdgeKey { + let [a, b] = vertices else { + panic!( + "2D k=2 move should report exactly two inserted edge vertices, got {}", + vertices.len() + ); + }; + EdgeKey::try_new(dt.tds(), *a, *b).expect("reported inserted vertices should form a live edge") +} + /// Checks that a rejected detached move leaves the live topology byte-for-byte equivalent. fn assert_failed_attempt_preserves_topology( dt: &mut Dt4, @@ -215,11 +350,9 @@ fn assert_stale_k1_insert_preserves_topology(mut dt: Dt4) { vertex: stale_attempt_vertex, }, |err| { - assert!( - matches!( - err, - FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex - ), + assert_matches!( + err, + FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex, "unexpected stale k=1 insert error: {err:?}" ); }, @@ -251,11 +384,9 @@ fn assert_stale_k1_remove_preserves_topology(mut dt: Dt4) { &mut dt, PachnerMove::K1Remove { vertex_key }, |err| { - assert!( - matches!( - err, - FlipError::MissingVertex { vertex_key: missing } if *missing == vertex_key - ), + assert_matches!( + err, + FlipError::MissingVertex { vertex_key: missing } if *missing == vertex_key, "unexpected stale k=1 remove error: {err:?}" ); }, @@ -273,11 +404,9 @@ fn assert_stale_k2_preserves_topology(mut dt: Dt4) { let stale_simplex = facet.simplex_key(); assert_failed_attempt_preserves_topology(&mut dt, PachnerMove::K2 { facet }, |err| { - assert!( - matches!( - err, - FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex - ), + assert_matches!( + err, + FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex, "unexpected stale k=2 error: {err:?}" ); }); @@ -296,14 +425,12 @@ fn assert_stale_k2_inverse_preserves_topology(mut dt: Dt4) { assert!(!inverse.removed_simplices.is_empty()); assert_failed_attempt_preserves_topology(&mut dt, PachnerMove::K2Inverse { edge }, |err| { - assert!( - matches!( - err, - FlipError::InvalidEdgeMultiplicity { - found: 0, - expected: 4 - } - ), + assert_matches!( + err, + FlipError::InvalidEdgeMultiplicity { + found: 0, + expected: 4 + }, "unexpected stale inverse k=2 error: {err:?}" ); }); @@ -320,11 +447,9 @@ fn assert_stale_k3_preserves_topology(mut dt: Dt4) { let stale_simplex = ridge.simplex_key(); assert_failed_attempt_preserves_topology(&mut dt, PachnerMove::K3 { ridge }, |err| { - assert!( - matches!( - err, - FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex - ), + assert_matches!( + err, + FlipError::MissingSimplex { simplex_key } if *simplex_key == stale_simplex, "unexpected stale k=3 error: {err:?}" ); }); @@ -343,14 +468,12 @@ fn assert_stale_k3_inverse_preserves_topology(mut dt: Dt4) { assert!(!inverse.removed_simplices.is_empty()); assert_failed_attempt_preserves_topology(&mut dt, PachnerMove::K3Inverse { triangle }, |err| { - assert!( - matches!( - err, - FlipError::InvalidTriangleMultiplicity { - found: 0, - expected: 3 - } - ), + assert_matches!( + err, + FlipError::InvalidTriangleMultiplicity { + found: 0, + expected: 3 + }, "unexpected stale inverse k=3 error: {err:?}" ); }); diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 3d34f385..a59bc227 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -99,7 +99,8 @@ use delaunay::prelude::pachner::{ use delaunay::prelude::query::{ AllFacetsIter as QueryAllFacetsIter, BoundaryFacetsIter as QueryBoundaryFacetsIter, ConvexHull, ConvexHullConstructionError, EdgeIndex as QueryEdgeIndex, EdgeKey as QueryEdgeKey, - EdgeView as QueryEdgeView, FacetIncidenceAnalysis as QueryFacetIncidenceAnalysis, + EdgeView as QueryEdgeView, FacetHandle as QueryFacetHandle, + FacetIncidenceAnalysis as QueryFacetIncidenceAnalysis, FacetIncidenceView as QueryFacetIncidenceView, IncidenceView as QueryIncidenceView, OneSidedFacetsIter as QueryOneSidedFacetsIter, QueryError, SimplexFacetsIter as QuerySimplexFacetsIter, SimplexNeighborIndex as QuerySimplexNeighborIndex, @@ -167,8 +168,8 @@ use delaunay::prelude::{ }; use delaunay::query::{ AllFacetsIter as QueryFacadeAllFacetsIter, BoundaryFacetsIter as QueryFacadeBoundaryFacetsIter, - EdgeIndex as QueryFacadeEdgeIndex, IncidenceView as QueryFacadeIncidenceView, - OneSidedFacetsIter as QueryFacadeOneSidedFacetsIter, + EdgeIndex as QueryFacadeEdgeIndex, FacetHandle as QueryFacadeFacetHandle, + IncidenceView as QueryFacadeIncidenceView, OneSidedFacetsIter as QueryFacadeOneSidedFacetsIter, SimplexFacetsIter as QueryFacadeSimplexFacetsIter, SimplexNeighborIndex as QueryFacadeSimplexNeighborIndex, TriangulationAdjacency as QueryFacadeTriangulationAdjacency, @@ -768,8 +769,12 @@ fn assert_facet_incidence_exports( simplex_key: SimplexKey, ) -> Result<(), PreludeExportTestError> { let facet_handle = FacetHandle::try_new(tds, simplex_key, 0)?; + let query_facet_handle: QueryFacetHandle = facet_handle; + let query_facade_facet_handle: QueryFacadeFacetHandle = facet_handle; let facet_view: FacetView<'_, (), (), 3> = facet_handle.view(tds)?; assert_eq!(facet_view.handle(), facet_handle); + assert_eq!(query_facet_handle, facet_handle); + assert_eq!(query_facade_facet_handle, facet_handle); let facet_index = tds.build_facet_to_simplices_index()?; let incidence = facet_index @@ -1647,10 +1652,10 @@ fn diagnostic_preludes_cover_repair_apis() -> Result<(), PreludeExportTestError> let orientation_kind = DelaunayRepairOrientationCanonicalizationFailureKind::AfterFlipRepair { source_kind: delaunay::prelude::insertion::InsertionErrorKind::DuplicateCoordinates, }; - assert!(matches!( + assert_matches!( orientation_kind, DelaunayRepairOrientationCanonicalizationFailureKind::AfterFlipRepair { .. } - )); + ); let heuristic_vertex: Option = None; assert!(heuristic_vertex.is_none()); let heuristic_reason = diff --git a/tests/trait_bound_ergonomics.rs b/tests/trait_bound_ergonomics.rs index b4194445..b84a722a 100644 --- a/tests/trait_bound_ergonomics.rs +++ b/tests/trait_bound_ergonomics.rs @@ -1,6 +1,6 @@ //! Compile coverage for read-only APIs with non-`DataType` payloads. -use std::hash::Hasher; +use std::{assert_matches, hash::Hasher}; use delaunay::DelaunayTriangulation; use delaunay::prelude::Triangulation; @@ -191,10 +191,10 @@ fn delaunay_empty_query_wrappers_accept_non_datatype_payloads() assert_eq!(dt.edges().count(), 0); assert_eq!(dt.incident_edges(VertexKey::default()).count(), 0); assert_eq!(dt.simplex_neighbors(SimplexKey::default()).count(), 0); - assert!(matches!( + assert_matches!( dt.simplex_vertices(SimplexKey::default()), Err(TdsError::SimplexNotFound { .. }) - )); + ); assert_eq!(dt.vertex_coords(VertexKey::default()), None); let incidence = dt.incidence()?;