diff --git a/benches/boundary_uuid_iter.rs b/benches/boundary_uuid_iter.rs index 47e41bec..5aa27847 100644 --- a/benches/boundary_uuid_iter.rs +++ b/benches/boundary_uuid_iter.rs @@ -10,7 +10,7 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_m use delaunay::prelude::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::generators::generate_random_points_in_range_seeded; use delaunay::prelude::geometry::CoordinateRange; -use delaunay::prelude::query::BoundaryAnalysis; +use delaunay::prelude::query::FacetIncidenceAnalysis; use delaunay::try_vertices_from_points; use uuid::Uuid; @@ -76,13 +76,13 @@ fn bench_boundary_facets_micro(c: &mut Criterion) { .collect::, _>>() .or_abort(); group.bench_with_input( - BenchmarkId::new("is_boundary_facet_3d", requested_vertices), + BenchmarkId::new("is_one_sided_facet_3d", requested_vertices), &(&dt, boundary_facets), |b, (dt, facets)| { b.iter(|| { let confirmed = facets .iter() - .filter(|facet| dt.tds().is_boundary_facet(facet).or_abort()) + .filter(|facet| dt.tds().is_one_sided_facet(facet).or_abort()) .count(); black_box(confirmed); }); diff --git a/benches/common/flip_workflows.rs b/benches/common/flip_workflows.rs index 790dd82a..46d576af 100644 --- a/benches/common/flip_workflows.rs +++ b/benches/common/flip_workflows.rs @@ -19,7 +19,7 @@ use delaunay::prelude::geometry::{CoordinateConversionError, Point, RobustKernel use delaunay::prelude::query::{JaccardComputationError, format_jaccard_report}; use delaunay::prelude::tds::{FacetError, InvariantError, TdsError, VertexKey}; use delaunay::prelude::topology::validation::{ - ManifoldError, RidgeVertices, RidgeVerticesError, ridge_star_simplices, + ManifoldError, RidgeCandidate, RidgeCandidateError, ridge_star_simplices, }; use delaunay::prelude::validation::DelaunayTriangulationValidationError; use thiserror::Error; @@ -93,14 +93,14 @@ pub enum FlipWorkflowError { source: Box, }, - /// Ridge vertices could not be parsed into a valid ridge vertex set. - #[error("invalid ridge vertices for {ridge:?}: {source}")] - InvalidRidgeVertices { + /// Ridge vertices could not be parsed into a valid ridge candidate. + #[error("invalid ridge candidate for {ridge:?}: {source}")] + InvalidRidgeCandidate { /// Ridge handle being inspected. ridge: RidgeHandle, - /// Underlying ridge vertex parsing failure. + /// Underlying ridge candidate parsing failure. #[source] - source: RidgeVerticesError, + source: RidgeCandidateError, }, /// Ridge-star support collection failed. @@ -1289,7 +1289,7 @@ fn ridge_support_points( }); } - let ridge_vertices = RidgeVertices::::try_from_vertices( + let ridge_candidate = RidgeCandidate::::try_from_vertices( simplex .vertices() .iter() @@ -1297,8 +1297,8 @@ fn ridge_support_points( .filter(|(index, _)| *index != omit_a && *index != omit_b) .map(|(_, vertex_key)| *vertex_key), ) - .map_err(|source| FlipWorkflowError::InvalidRidgeVertices { ridge, source })?; - let star_simplices = ridge_star_simplices(dt.tds(), &ridge_vertices) + .map_err(|source| FlipWorkflowError::InvalidRidgeCandidate { ridge, source })?; + let star_simplices = ridge_star_simplices(dt.tds(), &ridge_candidate) .map_err(|source| ridge_star_error(ridge, source))?; let mut keys = Vec::new(); diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index 99f661eb..4f011e7e 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -1138,7 +1138,7 @@ fn bench_bottlenecks(c: &mut Criterion) { }, |dt| { if let Some(dt) = dt { - let boundary_facets = match dt.tds().boundary_facets() { + let boundary_facets = match dt.tds().one_sided_facets() { Ok(value) => value, Err(error) => { abort_benchmark(format_args!( @@ -1146,7 +1146,7 @@ fn bench_bottlenecks(c: &mut Criterion) { )); } }; - black_box(boundary_facets); + black_box(boundary_facets.len()); } }, BatchSize::LargeInput, diff --git a/docs/api_design.md b/docs/api_design.md index 1d21cb15..e6a84810 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -93,7 +93,7 @@ fn main() -> DelaunayResult<()> { ### Advanced Construction: `DelaunayTriangulationBuilder` -For advanced configuration (toroidal topology, custom validation policies, etc.), +For advanced configuration (domain wrapping, toroidal topology, custom validation policies, etc.), use `DelaunayTriangulationBuilder`: ```rust @@ -103,7 +103,7 @@ use delaunay::prelude::construction::{ use delaunay::prelude::validation::ValidationPolicy; fn main() -> DelaunayResult<()> { - // Canonicalized toroidal triangulation in 2D + // Euclidean triangulation of points canonicalized into a toroidal domain. let vertices = vec![ vertex![0.1, 0.1]?, vertex![0.9, 0.9]?, @@ -112,7 +112,7 @@ fn main() -> DelaunayResult<()> { let mut dt = DelaunayTriangulationBuilder::new(&vertices) .try_canonicalized_toroidal([1.0, 1.0]) - ? // Canonicalized toroidal construction + ? // Wrap input coordinates before Euclidean construction. .topology_guarantee(TopologyGuarantee::PLManifoldStrict) .build::<()>()?; @@ -127,7 +127,8 @@ fn main() -> DelaunayResult<()> { **When to use the Builder:** - **Toroidal construction**: Use `.try_toroidal()` for periodic image-point construction or - `.try_canonicalized_toroidal()` for canonicalized construction with explicit domain periods. + `.try_canonicalized_toroidal()` for Euclidean construction after wrapping input coordinates + into explicit domain periods. The periodic image-point path is release-validated in 2D and compact 3D; 4D/5D fail fast pending scalable quotient construction in issue #416. - **Custom topology guarantees**: Set stricter or more relaxed manifold checks @@ -390,8 +391,9 @@ Topology APIs use names to make ownership visible: - `*View` values borrow the canonical owner or are lifetime-bound to it, so they cannot outlive the storage they observe. Examples include `FacetView<'tds>`, - `IncidenceView<'tds>`, `EdgeIndex<'tds>`, `SimplexNeighborIndex<'tds>`, and - `TriangulationAdjacency<'tds>`. + `EdgeView<'tds>`, `RidgeView<'tds>`, `RidgeLinkView<'tds>`, + `IncidenceView<'tds>`, `EdgeIndex<'tds>`, `SimplexNeighborIndex<'tds>`, + and `TriangulationAdjacency<'tds>`. - Borrowed slices over canonical storage follow the same rule. For example, `Tds::simplex_vertices(simplex_key)` validates the key relation, then returns the simplex's stored `&[VertexKey]` instead of copying detached keys into a @@ -401,10 +403,22 @@ Topology APIs use names to make ownership visible: them against a live owner before reading through them. Examples include `VertexKey`, `SimplexKey`, `FacetHandle`, `RidgeHandle`, `EdgeKey`, and `TriangleHandle`. +- Proof-bearing runtime candidates such as `RidgeCandidate` may validate + local arity, uniqueness, and canonical ordering without borrowing an owner, + but they are still detached storage-local values. Convert them to + `RidgeQuery<'tds>` before asking live-TDS questions that may have an empty + answer, or to `RidgeView<'tds>` when the API requires an existing ridge. + `RidgeView` construction proves the candidate vertices are live and have a + non-empty incident simplex star. +- Toroidal covering-space identities such as `LiftedVertexId` and + `LiftedLinkEdge` live under `topology::spaces::toroidal`. They are runtime + graph identities, not TDS storage entries or durable IDs. They preserve + periodic image identity for link traversal and validation; collapsing them to + bare `VertexKey`s is an explicit quotient-space operation. - Owned snapshots are allowed only when the data must cross a persistence, detached-analysis, or cache boundary. `TdsSnapshot`/`RawTdsSnapshot` are the durable UUID persistence boundary. `ConvexHull` is a logically immutable hull - snapshot that stores `FacetHandle`s, while `ConvexHull::facets(triangulation)` + snapshot that stores `FacetHandle`s, while `ConvexHull::try_facets(triangulation)` returns borrowed `FacetView` values and `ConvexHull::facet_handles()` exposes the detached handles explicitly. - Transactional rollback state may own cloned topology or exact mutation diff --git a/docs/code_organization.md b/docs/code_organization.md index bab0f9fe..fa80c52e 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -183,8 +183,8 @@ delaunay/ │ │ │ ├── spatial_hash_grid.rs │ │ │ └── triangulation_maps.rs │ │ ├── traits/ -│ │ │ ├── boundary_analysis.rs │ │ │ ├── data_type.rs +│ │ │ ├── facet_incidence_analysis.rs │ │ │ └── facet_cache.rs │ │ ├── util/ │ │ │ ├── canonical_points.rs @@ -198,10 +198,10 @@ delaunay/ │ │ │ ├── measurement.rs │ │ │ └── uuid.rs │ │ ├── adjacency.rs -│ │ ├── boundary.rs │ │ ├── construction.rs │ │ ├── edge.rs │ │ ├── facet.rs +│ │ ├── facet_incidence.rs │ │ ├── insertion.rs │ │ ├── operations.rs │ │ ├── orientation.rs @@ -478,9 +478,10 @@ paths instead. `TriangulationAdjacency` view (opt-in) - `collections/` - Optimized collection types and spatial acceleration structures - `spatial_hash_grid.rs` - Hash-grid spatial index for duplicate detection and locate-hint selection -- `boundary.rs` - Boundary detection and analysis +- `facet_incidence.rs` - TDS-level one-sided/two-sided facet incidence analysis; true boundary + classification lives in the topology-aware `Triangulation`/manifold layer - `algorithms/` - Core algorithms (incremental insertion, flips, point location, PL-manifold repair) -- `traits/` - Core trait definitions including FacetCacheProvider for performance optimization +- `traits/` - Core boundary/data trait definitions plus internal facet-cache plumbing for performance-sensitive algorithms - `util/` - General utility functions organized by functionality (replaced single `util.rs` file) - `uuid.rs` - UUID generation and validation - `hashing.rs` - Stable, deterministic hash primitives @@ -494,6 +495,18 @@ paths instead. - `canonical_points.rs` - Canonical vertex-ordering helpers for geometric predicate call sites (SoS consistency) - `operations.rs` - Semantic classification and telemetry for topological operations +`edge.rs` and `facet.rs` stay in `src/core/` because they are direct TDS +traversal primitives: `EdgeKey`/`EdgeView` validate endpoint incidence against +canonical storage, and `FacetHandle`/`FacetView` describe a codimension-1 face +of one stored simplex via `simplex_key + facet_index`. They are useful for +adjacency, boundary, hull, and query operations without invoking Level 3 +manifold reasoning. `src/topology/ridge.rs` is intentionally different: a ridge +is a codimension-2 topology construct whose concrete shape depends on `D` +(vertex in 2D, edge in 3D, triangle in 4D, and so on). Its candidate/query/view +types support ridge stars, lifted toroidal links, and PL-manifold validation, so +they belong to the topology layer. Dependency direction should remain +`topology` -> `core`, not the reverse. + Public namespace policy: `crate::core` is the internal implementation namespace for the low-level TDS and algorithm layer. The public low-level surface is exposed through curated modules and focused preludes (`delaunay::tds`, @@ -521,8 +534,8 @@ benchmarks, and tests clear about which part of the API they exercise. | Points, coordinate ranges, kernels, predicates, and geometric measures | `use delaunay::prelude::geometry::*` | | Random points or triangulations for examples, tests, and benchmarks | `use delaunay::prelude::generators::*` | | Read-only traversal, adjacency, convex hulls, and comparison helpers | `use delaunay::prelude::query::*` | -| Topological spaces and topology traits | `use delaunay::prelude::topology::spaces::*` | -| Topology validation and Euler characteristic helpers | `use delaunay::prelude::topology::validation::*` | +| Topological spaces, topology traits, and lifted toroidal IDs | `use delaunay::prelude::topology::spaces::*` | +| Topology validation, Euler characteristic helpers, and ridge queries | `use delaunay::prelude::topology::validation::*` | `use delaunay::prelude::*` remains available for quick experiments and broad interactive use, but repository examples and benchmarks prefer focused preludes. @@ -582,8 +595,10 @@ than through a `delaunay::delaunay` or `delaunay::triangulation` facade. - `characteristics/euler.rs` - Euler characteristic computation for full complexes and boundaries - `characteristics/validation.rs` - Topological validation functions -- `manifold.rs` - Topology-only manifold invariants (e.g., closed boundary checks; see - [`invariants.md`](invariants.md)) +- `manifold.rs` - Topology-only manifold invariants and boundary classification + over declared global topology (e.g., closed boundary checks; see [`invariants.md`](invariants.md)) +- `ridge.rs` - Ridge candidates, borrowed ridge queries/views, lifted ridge-link + views, and ridge-star map builders used by topology validation and localized repair - `spaces/euclidean.rs` - Euclidean space topology helper implementation (f64-oriented) - `spaces/spherical.rs` - Spherical space topology helper implementation (f64-oriented) - `spaces/toroidal.rs` - Toroidal space topology helper implementation (f64-oriented) @@ -601,7 +616,7 @@ than through a `delaunay::delaunay` or `delaunay::triangulation` facade. - **`benches/`** - Performance benchmarks with automated baseline management (2D-5D coverage) and memory allocation tracking (see: [benches/profiling_suite.rs](../benches/README.md#profiling-suite) and [benches/allocation_hot_paths.rs](../benches/README.md)) -- **`tests/`** - Integration tests including basic TDS validation (creation, neighbor assignment, boundary analysis), +- **`tests/`** - Integration tests including basic TDS validation (creation, neighbor assignment, facet-incidence analysis), debugging utilities, regression testing, allocation-measurement smoke coverage (see: [tests/allocation_api.rs](../tests/README.md#allocation_apirs)), and robust predicates validation - **`docs/`** - User and contributor documentation, including architecture/reference guides, @@ -785,7 +800,7 @@ This `justfile`-based workflow provides consistent, cross-platform development c ## Module Organization Patterns The canonical organizational patterns found across key modules in the codebase: -`simplex.rs`, `vertex.rs`, `facet.rs`, `boundary.rs`, and the `util/` submodules +`simplex.rs`, `vertex.rs`, `facet.rs`, `facet_incidence.rs`, and the `util/` submodules under `src/core/util/`. ### Canonical Section Sequence @@ -1150,10 +1165,10 @@ mod tests { - Adjacency testing - Error handling for geometric constraints -#### `boundary.rs` (small module) +#### `facet_incidence.rs` (small module) - Trait implementation focused -- Algorithm-specific testing +- TDS one-sided/two-sided incidence testing - Performance benchmarking - Integration with TDS diff --git a/docs/dev/rust.md b/docs/dev/rust.md index f4aa660b..f7bcb291 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -217,7 +217,7 @@ views must borrow the canonical owner, or return values lifetime-bound to that owner, so the view cannot outlive the data it observes. Detached, copyable runtime references should be named `*Handle` or `*Key` instead, and APIs that turn handles back into views must revalidate the handle against a live owner at -the conversion boundary. For example, `ConvexHull::facets(triangulation)` +the conversion boundary. For example, `ConvexHull::try_facets(triangulation)` returns borrowed `FacetView<'_>` values, while `ConvexHull::facet_handles()` exposes the stored `FacetHandle`s explicitly. diff --git a/docs/dev/tooling-alignment.md b/docs/dev/tooling-alignment.md index f3914908..62f796d8 100644 --- a/docs/dev/tooling-alignment.md +++ b/docs/dev/tooling-alignment.md @@ -171,6 +171,22 @@ The useful updates ported in this pass are: are blocked alongside unqualified impls. These rules complement the existing constructor and direct-storage serde guards without making the validated snapshot representation public API. +- Repository-owned Semgrep now encodes the #461 borrowed-view naming + convention: types named `*View` and `RidgeQuery` must carry a leading + lifetime parameter, while detached ridge values use `RidgeCandidate` and + fallible `try_from_vertices`/`try_new` constructors. This keeps Views, + Handles, Keys, Candidates, Snapshots, and Reports orthogonal in both naming + and lifetime semantics. Semgrep also guards the topology-boundary convention: + raw one-sided facet incidence from `facet_to_simplices.values()` must not be + used as semantic boundary classification; callers should use topology-aware + manifold helpers so periodic self-identifications remain closed topology and + open one-sided facets in closed spaces stay errors. +- The borrowed-view rule's generic-parameter detector is now written as a YAML + block scalar with a literal Rust lifetime lookahead (`(?!')`) instead of the + ambiguous single-quoted YAML spelling (`(?!'')`). This makes the configured + regex visibly reject lifetime-bound views such as `struct FooView<'tds>` and + match only non-lifetime generic parameters, preserving the intended #461 + View/Handle/Candidate convention. - `.github/workflows/rust-clippy.yml` now matches the hardened SARIF pipeline: `set -euo pipefail`, `clippy::cargo`, and guarded upload that skips missing SARIF files and forked pull-request uploads. @@ -297,6 +313,28 @@ Some causal-triangulations tooling remains project-specific and was not ported: was folded into that harness so `.github/workflows/profiling-benchmarks.yml` and `just profile-dev` exercise the same real construction, memory, validation, and traversal workloads. + +### Large-Scale Smoke Parameters + +`just perf-large-scale-smoke` is a Delaunay-specific local guard over the +release-mode `debug_large_scale_{2,3,4,5}d` tests, not a sibling-repository +tooling convention. It exists to catch obvious construction, repair, and +validation slowdowns before a PR leaves a developer machine, while keeping +benchmark-quality regression detection in `just perf-no-regressions` and the +Criterion harnesses. + +The current smoke sizes are calibrated per dimension: 32,000 vertices in 2D, +9,000 in 3D, 1,000 in 4D, and 160 in 5D. Lower dimensions use larger point +clouds because they need more vertices to expose traversal and repair costs; +higher dimensions scale down aggressively because simplex counts, exact +predicate work, and topology checks grow much faster. The progress chunks +of 2,000, 500, 100, and 20 vertices keep timeout/progress reporting visible +without turning logging and validation cadence into the measured workload. +These values are intentionally coarse canaries designed to finish in roughly +50 seconds per dimension, leaving headroom under the default 60-second cap. +They should be recalibrated only from same-machine local runs when the +construction envelope changes. + - CDT's concise `docs/dev/commands.md` structure; Delaunay keeps its more detailed benchmark-profile guidance because it documents the `perf` profile, local performance-regression guard, calibrated benchmark canaries, and release diff --git a/docs/invariants.md b/docs/invariants.md index 1bd2d9d7..73110075 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -85,7 +85,10 @@ Key combinatorial objects: [TDS_3](https://doc.cgal.org/latest/TDS_3/index.html)).[^cgal-tds3][^impl-tds] - **Boundary vs interior facets**: - An **interior facet** is incident to exactly two simplices. - - A **boundary facet** is incident to exactly one simplex. + - A **boundary facet** is one-sided and not an admissible periodic + self-identification. + - A periodic quotient facet may be incident to one stored simplex while a + self-neighbor pointer identifies it as closed topology rather than boundary. These are **combinatorial** notions: they depend only on incidence and adjacency relationships. Geometric predicates (orientation / in-sphere tests) are used to construct and validate the @@ -210,8 +213,9 @@ simplicial complexes for geometry: - **Pseudomanifold / manifold-with-boundary (codimension-1)**: enforce that each facet has the expected incidence count: - - boundary facets are incident to exactly 1 simplex - - interior facets are incident to exactly 2 simplices + - one-sided facets are incident to exactly 1 simplex + - two-sided facets are incident to exactly 2 simplices + Boundary classification then excludes admissible periodic self-identifications. This rules out the most obvious non-manifold failures (branching facets). - **Closed boundary condition (codimension-2 on the boundary)**: enforce “no boundary of boundary” @@ -303,8 +307,9 @@ facets are expected unless the simplex complex represents a closed manifold by c Toroidal workflows are integrated as first-class topology options: -- `.try_canonicalized_toroidal()` canonicalizes coordinates into the fundamental domain and uses - toroidal topology metadata for validation. +- `.try_canonicalized_toroidal()` canonicalizes coordinates into the fundamental domain, then + builds a Euclidean triangulation of the wrapped point set. It does not assign closed + toroidal manifold topology or identify opposite boundary facets. - `.try_toroidal(...)` constructs a periodic image-point triangulation over neighboring fundamental domains. The 2D and compact 3D paths are validated periodic quotients; 4D/5D periodic construction fails fast until quotient selection scales to routine release validation diff --git a/docs/topology.md b/docs/topology.md index 5619ef45..fab71c22 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -18,6 +18,7 @@ Relevant modules (lexicographically sorted): ```text src/ ├── core/ +│ ├── facet_incidence.rs │ ├── query.rs │ ├── triangulation.rs │ └── validation.rs @@ -32,6 +33,7 @@ src/ │ │ ├── euler.rs │ │ └── validation.rs │ ├── manifold.rs +│ ├── ridge.rs │ ├── spaces/ │ │ ├── euclidean.rs │ │ ├── spherical.rs @@ -63,14 +65,20 @@ For cumulative validation, use `Triangulation::validate()` (Levels 1–3) or Level 3 always checks: - **Codimension-1 facet degree** (pseudomanifold / manifold-with-boundary): - every (D−1)-facet is incident to exactly 1 (boundary) or 2 (interior) D-simplices. - (`topology::manifold::validate_facet_degree`) + every (D−1)-facet is incident to exactly 1 or 2 D-simplices. Public facet + incidence APIs parse this into the owner-bound `FacetToSimplicesIndex` via + `Tds::build_facet_to_simplices_index`; Level 3 validation builds one raw + `FacetToSimplicesMap`, parses it into `ValidatedFacetDegreeMap`, and reuses + that proof-bearing map so boundary, vertex-link, and Euler checks do not + rebuild or revalidate the same facet-degree evidence. Boundary classification + additionally excludes admissible periodic self-identifications, which are + closed quotient topology rather than boundary. - **Codimension-2 boundary manifoldness**: if a boundary exists, it is closed ("no boundary of boundary"). (`topology::manifold::validate_closed_boundary`) - **Connectedness**: a single connected component in the simplex neighbor graph. - **No isolated vertices**: every vertex is incident to at least one simplex. - **Euler characteristic** for the full D-dimensional simplicial complex. - (`topology::characteristics::validation::validate_triangulation_euler_with_facet_to_simplices_map`) + (`topology::characteristics::validation::validate_triangulation_euler_from_validated_facet_map`) ### `TopologyGuarantee`-dependent checks @@ -86,9 +94,43 @@ Implementation pointers: - Level 3 entry points and validation vocabulary: `src/core/validation.rs` (`Triangulation::is_valid`, `Triangulation::validate`) -- Manifold validators: `src/topology/manifold.rs` +- Public manifold validators: `src/topology/manifold.rs` + (`validate_closed_boundary`, `validate_vertex_links`, `validate_ridge_links`) +- Internal raw-map reuse helpers: `src/topology/manifold.rs` + (`ValidatedFacetDegreeMap::try_from_facet_map`, + `validate_closed_boundary_from_validated_facet_map`, + `validate_vertex_links_from_validated_facet_map`) - Euler characteristic helpers: `src/topology/characteristics/{euler.rs,validation.rs}` +## Boundary semantics + +Facet incidence by itself does **not** prove that a facet is a manifold boundary. +It only describes how many D-simplices share a canonical facet key in the TDS. +The current API keeps this distinction explicit: + +- `Tds::one_sided_facets()` and `Tds::number_of_one_sided_facets()` report raw + one-sided facet incidence. This is a Level 1–2/TDS fact. +- `Triangulation::boundary_facets()` and `DelaunayTriangulation::boundary_facets()` + report true boundary facets after interpreting the incidence under the + triangulation's `GlobalTopology`. +- `topology::manifold::classify_boundary_facet` is the semantic boundary + classifier used by validation and query code. + +This matters for periodic quotient topology. In a true toroidal triangulation, +a facet can be one-sided in the raw incidence index because the owning simplex +has an admissible periodic self-neighbor. That facet is a closed +self-identification, not a boundary. Conversely, an open one-sided facet in a +closed topology (`Toroidal`, `Spherical`, or `Hyperbolic`) is an invariant error, +not a valid boundary. + +The validation order is therefore: + +1. Parse facet incidence and reject non-manifold multiplicity. +2. Classify one-sided incidences against `GlobalTopology`. +3. Validate boundary closure, ridge links, vertex links, and connectedness. +4. Use Euler characteristic as a compatibility check for the already classified + topology. + ## Euler characteristic (`topology::characteristics`) Level 3 uses Euler characteristic (χ) as a global combinatorial consistency check. @@ -109,17 +151,25 @@ The Euler characteristic is: `TopologyCheckResult` containing χ, an expected value (when known), a coarse classification, the full f-vector, and diagnostic notes. -The expected χ is determined from a simple classification: +The expected χ is determined from declared topology metadata plus the +topology-aware boundary classification described above: - `Empty` (no simplices): expected χ = 0 - `SingleSimplex(D)`: expected χ = 1 - `Ball(D)` (has boundary): expected χ = 1 - `ClosedSphere(D)` (no boundary): expected χ = 1 + (-1)^D +- `ClosedToroid(D)` (periodic quotient): expected χ = 0 - `Unknown`: no expected χ (treated as "can't decide") For most finite Delaunay triangulations in Euclidean space, the complex has a boundary (convex hull), so the expected classification is `Ball(D)` and χ = 1. +Euler characteristic is not a topology detector by itself. It is an invariant +used after the manifold and boundary checks above. Many non-homeomorphic +manifolds share the same χ, especially in higher dimensions, so χ must not bless +an arbitrary gluing as toroidal or spherical without the corresponding +topological construction and local manifold checks. + ### Boundary-only χ (not used by Level 3) For research/debugging, `topology::characteristics::euler::count_boundary_simplices` @@ -131,7 +181,6 @@ simplicial complex). This is currently not part of Level 3 validation. `src/topology/manifold.rs` contains combinatorial validators for manifold and PL-manifold invariants (no geometric predicates): -- `validate_facet_degree` - `validate_closed_boundary` - `validate_ridge_links` - `validate_vertex_links` @@ -186,7 +235,7 @@ construct toroidal triangulations using `DelaunayTriangulationBuilder`: ```rust use delaunay::prelude::construction::{DelaunayTriangulationBuilder, vertex}; -// 2D canonicalized toroidal triangulation +// 2D Euclidean triangulation after wrapping inputs into a toroidal domain let vertices = vec![ vertex![0.1, 0.1]?, vertex![0.9, 0.9]?, @@ -199,9 +248,9 @@ let dt = DelaunayTriangulationBuilder::new(&vertices) ``` Canonicalized toroidal construction wraps coordinates into the fundamental -domain before building the Euclidean triangulation. Topology-aware operations can -use the toroidal domain for periodic distances, but `.try_canonicalized_toroidal([..])` does not -rewire opposite boundary facets. For a true periodic quotient, use +domain before building a Euclidean triangulation of the wrapped point set. It +does not attach toroidal manifold topology to the output and does not rewire +opposite boundary facets. For a true periodic quotient, use `.try_toroidal([..])`; the validated image-point path currently covers 2D and compact 3D fixtures. 4D/5D periodic quotients fail fast pending scalable construction work in issue #416. diff --git a/docs/validation.md b/docs/validation.md index d74e32d9..1b004ff6 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -274,7 +274,9 @@ Validates the combinatorial structure of the Triangulation Data Structure. 1. **UUID ↔ Key Mappings**: Bidirectional consistency for vertices and simplices 2. **No Duplicate Simplices**: No simplices with identical vertex sets 3. **Facet Sharing Invariant**: Each facet shared by at most 2 simplices -4. **Neighbor Consistency**: Mutual neighbor relationships are correct (boundary facets have no neighbor; interior facets have reciprocal neighbors) +4. **Neighbor Consistency**: Mutual neighbor relationships are correct: + manifold boundary facets are open, interior facets have reciprocal + neighbors, and admissible periodic self-neighbors are closed topology. `Tds::validate()` (Levels 1–2) additionally checks: diff --git a/docs/workflows.md b/docs/workflows.md index 65dc558d..14172cca 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -266,7 +266,7 @@ use delaunay::prelude::construction::{ }; fn main() -> DelaunayResult<()> { - // 2D canonicalized toroidal triangulation with unit square domain + // 2D Euclidean triangulation after wrapping inputs into a unit square domain let vertices = vec![ vertex![0.1, 0.1]?, vertex![0.9, 0.9]?, @@ -275,24 +275,25 @@ fn main() -> DelaunayResult<()> { let mut dt = DelaunayTriangulationBuilder::new(&vertices) .try_canonicalized_toroidal([1.0, 1.0]) - ? // canonicalized toroidal construction + ? // input coordinate canonicalization .build::<()>()?; - // Insert more points - they'll be wrapped to [0,1)×[0,1) - dt.insert(vertex![1.2, 0.3]?)?; // wraps to [0.2, 0.3] - dt.insert(vertex![-0.1, 0.7]?)?; // wraps to [0.9, 0.7] + // Subsequent insertions are standard Euclidean insertions; canonicalize + // additional points at the call site if they come from the same domain. + dt.insert(vertex![0.2, 0.3]?)?; + dt.insert(vertex![0.9, 0.7]?)?; Ok(()) } ``` **Key points:** -- **Domain wrapping**: Vertex coordinates are automatically canonicalized (wrapped) to the - fundamental domain `[0, period)` for each dimension -- **Distance computation**: Topology-aware operations can use the toroidal metric when the - triangulation carries toroidal domain metadata +- **Domain wrapping**: Initial vertex coordinates are canonicalized (wrapped) to the + fundamental domain `[0, period)` for each dimension before Euclidean construction +- **Manifold topology**: `.try_canonicalized_toroidal([..])` does not assign closed toroidal + topology or rewire opposite boundary facets; use `.try_toroidal([..])` for a true quotient - **Construction modes**: - - `.try_canonicalized_toroidal([..])`: canonicalized construction (wrap into fundamental domain) + - `.try_canonicalized_toroidal([..])`: Euclidean construction after wrapping inputs - `.try_toroidal([..])`: periodic image-point construction; currently validated as a true toroidal quotient in 2D and compact 3D; 4D/5D fail fast pending issue #416 @@ -316,7 +317,7 @@ fn main() -> DelaunayResult<()> { vertex![1.0, 0.0; data = 20]?, vertex![0.0, 1.0; data = 30]?, ]; - let mut dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + let mut dt = DelaunayTriangulationBuilder::new(&vertices).build::()?; // Read vertex data for (_key, vertex) in dt.vertices() { diff --git a/examples/triangulation_and_hull.rs b/examples/triangulation_and_hull.rs index 632c16f6..10bce762 100644 --- a/examples/triangulation_and_hull.rs +++ b/examples/triangulation_and_hull.rs @@ -101,7 +101,7 @@ fn run_case( facet .map(|_| count + 1) .map_err(|source| QueryError::TriangulationCorrupted { - source: source.into(), + source: Box::new(source.into()), }) })?; println!(" boundary facets: {boundary_facet_count}"); diff --git a/justfile b/justfile index ce7c2eba..6ff3e910 100644 --- a/justfile +++ b/justfile @@ -575,10 +575,10 @@ perf-large-scale-smoke max_secs="60": _ensure-nextest rm -f "$log_file" } - run_case "2D" "debug_large_scale_2d" "DELAUNAY_LARGE_DEBUG_N_2D" "36000" "2000" - run_case "3D" "debug_large_scale_3d" "DELAUNAY_LARGE_DEBUG_N_3D" "7500" "500" - run_case "4D" "debug_large_scale_4d" "DELAUNAY_LARGE_DEBUG_N_4D" "800" "100" - run_case "5D" "debug_large_scale_5d" "DELAUNAY_LARGE_DEBUG_N_5D" "140" "20" + run_case "2D" "debug_large_scale_2d" "DELAUNAY_LARGE_DEBUG_N_2D" "32000" "2000" + run_case "3D" "debug_large_scale_3d" "DELAUNAY_LARGE_DEBUG_N_3D" "9000" "500" + run_case "4D" "debug_large_scale_4d" "DELAUNAY_LARGE_DEBUG_N_4D" "1000" "100" + run_case "5D" "debug_large_scale_5d" "DELAUNAY_LARGE_DEBUG_N_5D" "160" "20" echo "" echo "Large-scale smoke summary:" diff --git a/semgrep.yaml b/semgrep.yaml index 45293d33..12d5ac06 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -745,42 +745,50 @@ rules: languages: - generic severity: WARNING - message: "Use RidgeHandle::try_new for raw omitted indices, or the crate-private validated constructor after bounds are already proven." + message: "Use try_from_vertices/try_new for fallible ridge candidates, queries, handles, and views." metadata: category: correctness tracking_issue: "https://github.com/acgetchell/delaunay/issues/442" rationale: >- - A RidgeHandle must identify a valid codimension-2 face in a live simplex. - Public raw index construction must reject duplicate or out-of-range - omitted indices instead of storing invalid ridge selectors for later - validation. + RidgeCandidate parses raw vertex keys into a codimension-2 candidate, + RidgeHandle identifies a valid codimension-2 face in a live simplex, + and RidgeQuery/RidgeView/RidgeLinkView validate ridge identity against + one live TDS before exposing borrowed topology. Constructor names must + expose those fallible boundaries. paths: include: - "/src/**/*.rs" - "/tests/**/*.rs" - "/benches/**/*.rs" - "/examples/**/*.rs" - pattern-regex: '\bRidgeHandle\s*::\s*new\s*\(' + pattern-either: + - pattern-regex: '\bRidgeCandidate\s*::\s*new\s*\(' + - pattern-regex: '\bRidgeHandle\s*::\s*new\s*\(' + - pattern-regex: '\bRidgeQuery\s*::\s*new\s*\(' + - pattern-regex: '\bRidgeView\s*::\s*new\s*\(' + - pattern-regex: '\bRidgeLinkView\s*::\s*new\s*\(' - id: delaunay.rust.no-edgekey-new-constructor languages: - generic severity: WARNING - message: "Use EdgeKey::try_new with a live TDS for raw endpoints, or the crate-private validated constructor after edge incidence is already proven." + message: "Use try_new for fallible edge keys/views, or the crate-private validated constructor after edge incidence is already proven." metadata: category: correctness tracking_issue: "https://github.com/acgetchell/delaunay/issues/454" rationale: >- - An EdgeKey must identify a real edge in a live TDS, so public raw - endpoint construction needs to reject duplicate, missing, or non-incident - vertex keys instead of storing an invalid edge selector. + EdgeKey must identify a real edge in a live TDS, and EdgeView must + validate that detached key against one live TDS before exposing borrowed + topology. Constructor names must expose those fallible boundaries. paths: include: - "/src/**/*.rs" - "/tests/**/*.rs" - "/benches/**/*.rs" - "/examples/**/*.rs" - pattern-regex: '\bEdgeKey\s*::\s*new\s*\(' + pattern-either: + - pattern-regex: '\bEdgeKey\s*::\s*new\s*\(' + - pattern-regex: '\bEdgeView\s*::\s*new\s*\(' - id: delaunay.rust.no-edgekey-try-new-without-tds languages: @@ -802,6 +810,52 @@ rules: - "/examples/**/*.rs" pattern-regex: '\bEdgeKey\s*::\s*try_new\s*\(\s*[^,\n()]+,\s*[^,\n()]+\s*\)' + - id: delaunay.rust.borrowed-view-types-require-lifetime + languages: + - generic + severity: WARNING + message: "Types named *View, and RidgeQuery, must be lifetime-bound borrowed observations of canonical topology." + metadata: + category: correctness + tracking_issue: "https://github.com/acgetchell/delaunay/issues/461" + rationale: >- + Names ending in View promise a borrowed observation of canonical + storage, not an owned snapshot or detached topology candidate. A view + type without a leading lifetime parameter can outlive the storage whose + topology it describes; detached values should be named Handle, Key, + Candidate, Snapshot, or Report instead. RidgeQuery follows the same + borrowed-view contract because it proves a ridge candidate against one + live TDS even when the ridge star may be empty. + paths: + include: + - "/src/**/*.rs" + - "/tests/**/*.rs" + - "/benches/**/*.rs" + - "/examples/**/*.rs" + pattern-regex: >- + (?m)^\s*(?:pub(?:\s*\([^)]*\))?\s+)?struct\s+(?:[A-Za-z0-9_]*View|RidgeQuery)\s*(?:<\s*(?!')[^>{}]*>|where|\{|;|\() + + - id: delaunay.rust.no-raw-facet-incidence-boundary-classification + languages: + - generic + severity: WARNING + message: "Do not treat raw one-sided facet incidence as manifold boundary; classify it against GlobalTopology." + metadata: + category: correctness + tracking_issue: "https://github.com/acgetchell/delaunay/issues/461" + rationale: >- + A facet with one incident simplex is only a TDS incidence fact. Periodic + quotient triangulations can encode closed self-identifications with + one-sided incidence, while closed topologies must reject open one-sided + facets. Boundary semantics must flow through topology::manifold + classification helpers such as has_boundary_facets_in_map or + boundary_facet_keys_from_index. + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/**/*.rs" + pattern-regex: '\bfacet_to_simplices\s*\.\s*values\s*\(\s*\)\s*\.\s*any\s*\(\s*\|\s*[A-Za-z_][A-Za-z0-9_]*\s*\|\s*[A-Za-z_][A-Za-z0-9_]*\s*\.\s*len\s*\(\s*\)\s*==\s*1\s*\)' # yamllint disable-line rule:line-length + - id: delaunay.rust.no-runtime-topology-handle-serde languages: - generic @@ -811,10 +865,12 @@ rules: category: correctness tracking_issue: "https://github.com/acgetchell/delaunay/issues/454" rationale: >- - FacetHandle, FacetView, and EdgeKey contain storage-local slotmap keys or - references into one live TDS. They are useful for in-memory traversal and - algorithms, but they are not durable interchange identifiers. Persistence - must flow through UUID-keyed TDS snapshots instead. + FacetHandle, FacetView, EdgeKey, EdgeView, RidgeCandidate, RidgeQuery, + RidgeView, RidgeLinkView, LiftedVertexId, and LiftedLinkEdge contain storage-local + slotmap keys, periodic runtime identities, or references into one live + TDS. They are useful for in-memory traversal and algorithms, but they + are not durable interchange identifiers. Persistence must flow through + UUID-keyed TDS snapshots instead. paths: include: - "/src/**/*.rs" @@ -822,10 +878,10 @@ rules: - "/benches/**/*.rs" - "/examples/**/*.rs" pattern-either: - - pattern-regex: '(?s)#\s*\[\s*derive\s*\([^)]*\bSerialize\b[^)]*\)\s*\]\s*[^;{]*\bstruct\s+(?:FacetHandle|FacetView|EdgeKey)\b' - - pattern-regex: '(?s)#\s*\[\s*derive\s*\([^)]*\bDeserialize\b[^)]*\)\s*\]\s*[^;{]*\bstruct\s+(?:FacetHandle|FacetView|EdgeKey)\b' - - pattern-regex: '\bimpl(?:\s*<[^>]*>)?\s+(?:serde\s*::\s*)?Serialize\s+for\s+(?:(?:crate|self|super)\s*::\s*)?(?:(?:tds|core\s*::\s*(?:facet|edge))\s*::\s*)?(?:FacetHandle|FacetView|EdgeKey)\b' # yamllint disable-line rule:line-length - - pattern-regex: '\bimpl(?:\s*<[^>]*>)?\s+(?:serde\s*::\s*)?Deserialize(?:\s*<[^>]*>)?\s+for\s+(?:(?:crate|self|super)\s*::\s*)?(?:(?:tds|core\s*::\s*(?:facet|edge))\s*::\s*)?(?:FacetHandle|FacetView|EdgeKey)\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?s)#\s*\[\s*derive\s*\([^)]*\bSerialize\b[^)]*\)\s*\]\s*[^;{]*\bstruct\s+(?:FacetHandle|FacetView|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b' # yamllint disable-line rule:line-length + - pattern-regex: '(?s)#\s*\[\s*derive\s*\([^)]*\bDeserialize\b[^)]*\)\s*\]\s*[^;{]*\bstruct\s+(?:FacetHandle|FacetView|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b' # yamllint disable-line rule:line-length + - pattern-regex: '\bimpl(?:\s*<[^>]*>)?\s+(?:serde\s*::\s*)?Serialize\s+for\s+(?:(?:crate|self|super)\s*::\s*)?(?:(?:tds|core\s*::\s*(?:facet|edge)|topology\s*::\s*(?:manifold|ridge|spaces\s*::\s*toroidal))\s*::\s*)?(?:FacetHandle|FacetView|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b' # yamllint disable-line rule:line-length + - pattern-regex: '\bimpl(?:\s*<[^>]*>)?\s+(?:serde\s*::\s*)?Deserialize(?:\s*<[^>]*>)?\s+for\s+(?:(?:crate|self|super)\s*::\s*)?(?:(?:tds|core\s*::\s*(?:facet|edge)|topology\s*::\s*(?:manifold|ridge|spaces\s*::\s*toroidal))\s*::\s*)?(?:FacetHandle|FacetView|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b' # yamllint disable-line rule:line-length - id: delaunay.rust.no-tds-storage-map-serde languages: @@ -858,14 +914,16 @@ rules: tracking_issue: "https://github.com/acgetchell/delaunay/issues/454" rationale: >- Snapshot-shaped records cross persistence and codec boundaries. Storing - VertexKey, SimplexKey, FacetHandle, FacetView, or EdgeKey there would - smuggle process-local slotmap identity into durable data instead of - rebuilding fresh keys from stable UUID relationships. + VertexKey, SimplexKey, FacetHandle, FacetView, EdgeKey, EdgeView, + RidgeCandidate, RidgeQuery, RidgeView, RidgeLinkView, LiftedVertexId, or + LiftedLinkEdge there would smuggle process-local topology identity into + durable data instead of rebuilding fresh keys from stable UUID + relationships. paths: include: - "/src/**/*.rs" - "/tests/semgrep/src/project_rules/**/*.rs" - pattern-regex: '(?s)\bstruct\s+(?:Raw)?[A-Za-z0-9_]*(?:Snapshot|Serialized)[A-Za-z0-9_]*\s*(?:<[^>{}]*>)?\s*\{[^{}]*(?:\b(?:VertexKey|SimplexKey|FacetHandle|FacetView|EdgeKey)\b|Vec\s*<\s*(?:Option\s*<\s*)?(?:VertexKey|SimplexKey)\b|NeighborBuffer\s*<\s*(?:Option\s*<\s*)?SimplexKey\b|SmallBuffer\s*<\s*(?:VertexKey|SimplexKey|FacetHandle|EdgeKey)\b)[^{}]*\}' # yamllint disable-line rule:line-length + pattern-regex: '(?s)\bstruct\s+(?:Raw)?[A-Za-z0-9_]*(?:Snapshot|Serialized)[A-Za-z0-9_]*\s*(?:<[^>{}]*>)?\s*\{[^{}]*(?:\b(?:VertexKey|SimplexKey|FacetHandle|FacetView|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b|Vec\s*<\s*(?:Option\s*<\s*)?(?:VertexKey|SimplexKey)\b|NeighborBuffer\s*<\s*(?:Option\s*<\s*)?SimplexKey\b|SmallBuffer\s*<\s*(?:VertexKey|SimplexKey|FacetHandle|EdgeKey|EdgeView|RidgeCandidate|RidgeQuery|RidgeView|RidgeLinkView|LiftedVertexId|LiftedLinkEdge)\b)[^{}]*\}' # yamllint disable-line rule:line-length - id: delaunay.rust.raw-tds-snapshot-uuid-maps-require-duplicate-key-deserializers languages: diff --git a/src/bench_fixtures.rs b/src/bench_fixtures.rs index f2744faf..00bf5324 100644 --- a/src/bench_fixtures.rs +++ b/src/bench_fixtures.rs @@ -19,7 +19,7 @@ pub mod pl_manifold { use crate::core::vertex::Vertex; use crate::geometry::traits::coordinate::CoordinateConversionError; use crate::geometry::util::safe_usize_to_scalar; - use crate::topology::manifold::validate_facet_degree; + use crate::topology::manifold::ValidatedFacetDegreeMap; use thiserror::Error; /// Fixture for benchmarking facet over-sharing repair plus orphan cleanup in 3D. @@ -153,7 +153,7 @@ pub mod pl_manifold { let facet_map = tds .build_facet_to_simplices_map() .map_err(|source| PlManifoldRepairFixtureError::StructuralValidation { source })?; - if validate_facet_degree(&facet_map).is_ok() { + if ValidatedFacetDegreeMap::try_from_facet_map(&facet_map).is_ok() { return Err(PlManifoldRepairFixtureError::MissingOversharedFacet { cluster_count }); } diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 19ac8015..2243e0cb 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -38,7 +38,7 @@ use crate::core::collections::{ FastHashMap, FastHashSet, FastHasher, MAX_PRACTICAL_DIMENSION_SIZE, PeriodicOffsetBuffer, SimplexKeyBuffer, SmallBuffer, }; -use crate::core::edge::EdgeKey; +use crate::core::edge::{EdgeKey, EdgeKeyError}; use crate::core::facet::{AllFacetsIter, FacetError, FacetHandle, facet_key_from_vertices}; use crate::core::operations::TopologicalOperation; use crate::core::simplex::{NeighborSlot, Simplex, SimplexValidationError}; @@ -3635,6 +3635,32 @@ pub enum FlipEdgeAdjacencyError { /// Second edge endpoint. v1: VertexKey, }, + /// Stored simplex data contains the edge, but the edge is missing from the maintained incidence index. + #[error("vertex incidence index does not list any simplex containing edge {v0:?}-{v1:?}")] + MissingEdgeIncidence { + /// First edge endpoint. + v0: VertexKey, + /// Second edge endpoint. + v1: VertexKey, + }, + /// A simplex contains an edge endpoint, but that endpoint's incidence index does not list it. + #[error("vertex incidence index for {vertex_key:?} is missing simplex {simplex_key:?}")] + MissingVertexIncidence { + /// Vertex whose incidence list is missing the simplex key. + vertex_key: VertexKey, + /// Simplex expected in the vertex's incidence list. + simplex_key: SimplexKey, + }, + /// A vertex incidence entry points to a simplex that does not contain that vertex. + #[error( + "vertex incidence index for {vertex_key:?} incorrectly references simplex {simplex_key:?}" + )] + VertexIncidenceMismatch { + /// Vertex whose incidence list contains the inconsistent simplex key. + vertex_key: VertexKey, + /// Simplex expected to contain the vertex. + simplex_key: SimplexKey, + }, /// Edge star has the wrong opposite-vertex incidence pattern. #[error( "edge star must have {expected_vertices} distinct opposite vertices each appearing {expected_occurrences} times, found {found_vertices} distinct vertices" @@ -5354,6 +5380,55 @@ fn simplex_from_vertex_incidence( simplex_key, }) } +/// Converts borrowed edge-view validation errors into k=2 flip context errors. +/// +/// [`build_k2_flip_context_from_edge`] exposes flip-specific error variants even +/// though it validates runtime edge handles through [`crate::core::edge::EdgeView`]. +/// This mapping preserves caller-visible distinctions such as stale endpoints, +/// dangling vertex incidence, and invalid edge multiplicity. +fn flip_error_from_edge_key_error(error: EdgeKeyError) -> FlipError { + match error { + EdgeKeyError::DuplicateEndpoint { endpoint } => { + FlipEdgeAdjacencyError::DuplicateEndpoints { + vertex_key: endpoint, + } + .into() + } + EdgeKeyError::MissingEndpoint { endpoint } => FlipError::MissingVertex { + vertex_key: endpoint, + }, + EdgeKeyError::EdgeNotFound { .. } => FlipError::InvalidEdgeMultiplicity { + found: 0, + expected: D, + }, + EdgeKeyError::MissingEdgeIncidence { v0, v1 } => { + FlipEdgeAdjacencyError::MissingEdgeIncidence { v0, v1 }.into() + } + EdgeKeyError::MissingVertexIncidence { + vertex_key, + simplex_key, + } => FlipEdgeAdjacencyError::MissingVertexIncidence { + vertex_key, + simplex_key, + } + .into(), + EdgeKeyError::DanglingVertexIncidence { + vertex_key, + simplex_key, + } => FlipError::DanglingVertexIncidence { + vertex_key, + simplex_key, + }, + EdgeKeyError::VertexIncidenceMismatch { + simplex_key, + vertex_key, + } => FlipEdgeAdjacencyError::VertexIncidenceMismatch { + vertex_key, + simplex_key, + } + .into(), + } +} /// Build inverse k=2 flip context from an edge and its incident simplices. /// @@ -5373,25 +5448,12 @@ where return Err(FlipError::UnsupportedDimension { dimension: D }); } - let (v0, v1) = edge.endpoints(); - if v0 == v1 { - return Err(FlipEdgeAdjacencyError::DuplicateEndpoints { vertex_key: v0 }.into()); - } - - if tds.vertex(v0).is_none() { - return Err(FlipError::MissingVertex { vertex_key: v0 }); - } - if tds.vertex(v1).is_none() { - return Err(FlipError::MissingVertex { vertex_key: v1 }); - } - - let mut removed_simplices: SimplexKeyBuffer = SimplexKeyBuffer::new(); - for simplex_key in tds.simplex_keys_containing_vertex(v0) { - let simplex = simplex_from_vertex_incidence(tds, v0, simplex_key)?; - if simplex.contains_vertex(v1) { - removed_simplices.push(simplex_key); - } - } + let edge_view = edge + .view(tds) + .map_err(flip_error_from_edge_key_error::)?; + let (v0, v1) = edge_view.endpoint_keys(); + let removed_simplices: SimplexKeyBuffer = + edge_view.incident_simplices().iter().copied().collect(); if removed_simplices.len() != D { return Err(FlipError::InvalidEdgeMultiplicity { @@ -15847,6 +15909,92 @@ mod tests { ); } + macro_rules! gen_k2_edge_adjacency_validation_tests { + ($dim:literal) => { + pastey::paste! { + #[test] + fn []() { + let mut tds: Tds<(), (), $dim> = Tds::empty(); + let vertices = insert_standard_simplex_vertices(&mut tds); + let simplex_key = insert_plain_simplex(&mut tds, vertices.clone()); + tds.clear_vertex_incidence_for_test(vertices[1]); + + let edge = EdgeKey::from_validated_endpoints(vertices[0], vertices[1]); + let err = build_k2_flip_context_from_edge(&tds, edge).unwrap_err(); + + assert_matches!( + err, + FlipError::InvalidEdgeAdjacency { reason } + if matches!( + reason.as_ref(), + FlipEdgeAdjacencyError::MissingVertexIncidence { + vertex_key, + simplex_key: reported_simplex, + } if *vertex_key == vertices[1] && *reported_simplex == simplex_key + ) + ); + } + + #[test] + fn []() { + let mut tds: Tds<(), (), $dim> = Tds::empty(); + let vertices = insert_standard_simplex_vertices(&mut tds); + insert_plain_simplex(&mut tds, vertices.clone()); + tds.clear_vertex_incidence_for_test(vertices[0]); + tds.clear_vertex_incidence_for_test(vertices[1]); + + let edge = EdgeKey::from_validated_endpoints(vertices[0], vertices[1]); + let err = build_k2_flip_context_from_edge(&tds, edge).unwrap_err(); + + assert_matches!( + err, + FlipError::InvalidEdgeAdjacency { reason } + if matches!( + reason.as_ref(), + FlipEdgeAdjacencyError::MissingEdgeIncidence { v0, v1 } + if (*v0, *v1) == edge.endpoints() + ) + ); + } + + #[test] + fn []() { + let mut tds: Tds<(), (), $dim> = Tds::empty(); + let vertices = insert_standard_simplex_vertices(&mut tds); + insert_plain_simplex(&mut tds, vertices.clone()); + let mut extra_coords = [0.0_f64; $dim]; + extra_coords[0] = 2.0; + let extra_vertex = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new(extra_coords).unwrap()) + .unwrap(); + let mut mismatched_vertices = vertices[1..].to_vec(); + mismatched_vertices.push(extra_vertex); + let mismatched_simplex = insert_plain_simplex(&mut tds, mismatched_vertices); + tds.add_simplex_to_vertex_incidence_for_test(vertices[0], mismatched_simplex); + + let edge = EdgeKey::from_validated_endpoints(vertices[0], vertices[1]); + let err = build_k2_flip_context_from_edge(&tds, edge).unwrap_err(); + + assert_matches!( + err, + FlipError::InvalidEdgeAdjacency { reason } + if matches!( + reason.as_ref(), + FlipEdgeAdjacencyError::VertexIncidenceMismatch { + vertex_key, + simplex_key, + } if *vertex_key == vertices[0] && *simplex_key == mismatched_simplex + ) + ); + } + } + }; + } + + gen_k2_edge_adjacency_validation_tests!(3); + gen_k2_edge_adjacency_validation_tests!(4); + gen_k2_edge_adjacency_validation_tests!(5); + macro_rules! gen_stale_incidence_context_tests { ($dim:literal) => { pastey::paste! { diff --git a/src/core/algorithms/incremental_insertion.rs b/src/core/algorithms/incremental_insertion.rs index 15abe091..57391106 100644 --- a/src/core/algorithms/incremental_insertion.rs +++ b/src/core/algorithms/incremental_insertion.rs @@ -45,8 +45,8 @@ use crate::core::tds::{ NeighborValidationError, SimplexKey, Tds, TdsConstructionError, TdsError, TdsErrorKind, TriangulationValidationErrorKind, VertexKey, }; -use crate::core::traits::boundary_analysis::BoundaryAnalysis; use crate::core::traits::data_type::DataType; +use crate::core::traits::facet_incidence_analysis::FacetIncidenceAnalysis; use crate::core::validation::TriangulationValidationError; use crate::core::vertex::VertexValidationError; use crate::geometry::kernel::Kernel; @@ -3793,7 +3793,7 @@ where #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { let total_boundary = tds - .boundary_facets() + .one_sided_facets() .map_err(|e| InsertionError::HullExtension { reason: HullExtensionReason::Tds(e), }) @@ -3926,7 +3926,7 @@ where let tol = DEFAULT_TOLERANCE_F64; let boundary_facets = tds - .boundary_facets() + .one_sided_facets() .map_err(|e| InsertionError::HullExtension { reason: HullExtensionReason::Tds(e), })?; @@ -4098,7 +4098,7 @@ where // Get all boundary facets let boundary_facets = tds - .boundary_facets() + .one_sided_facets() .map_err(|e| InsertionError::HullExtension { reason: HullExtensionReason::Tds(e), })?; diff --git a/src/core/algorithms/pl_manifold_repair.rs b/src/core/algorithms/pl_manifold_repair.rs index 722be976..4929d06d 100644 --- a/src/core/algorithms/pl_manifold_repair.rs +++ b/src/core/algorithms/pl_manifold_repair.rs @@ -33,7 +33,7 @@ use crate::core::tds::{SimplexKey, Tds, TdsError, VertexKey}; use crate::core::traits::data_type::DataType; use crate::core::vertex::Vertex; use crate::geometry::util::norms::hypot; -use crate::topology::manifold::validate_facet_degree; +use crate::topology::manifold::ValidatedFacetDegreeMap; use num_traits::NumCast; use slotmap::Key; use thiserror::Error; @@ -220,7 +220,7 @@ where // Fast path: if the facet-degree invariant already holds, nothing to do. let facet_map = tds.build_facet_to_simplices_map()?; - if validate_facet_degree(&facet_map).is_ok() { + if ValidatedFacetDegreeMap::try_from_facet_map(&facet_map).is_ok() { stats.succeeded = true; return Ok(stats); } @@ -302,7 +302,7 @@ where // Check if the invariant now holds. let facet_map = tds.build_facet_to_simplices_map()?; - if validate_facet_degree(&facet_map).is_ok() { + if ValidatedFacetDegreeMap::try_from_facet_map(&facet_map).is_ok() { stats.succeeded = true; // Rebuild full neighbor/incidence pointers before returning. rebuild_success_topology(tds)?; @@ -684,7 +684,7 @@ mod tests { // Sanity: at least one facet should now be over-shared. let facet_map = tds.build_facet_to_simplices_map().unwrap(); assert!( - validate_facet_degree(&facet_map).is_err(), + ValidatedFacetDegreeMap::try_from_facet_map(&facet_map).is_err(), "Expected over-shared facets after duplicating a simplex" ); @@ -752,7 +752,7 @@ mod tests { assert_eq!(stats.removed_simplices.len(), stats.simplices_removed); let facet_map = tds.build_facet_to_simplices_map().unwrap(); - assert!(validate_facet_degree(&facet_map).is_ok()); + assert!(ValidatedFacetDegreeMap::try_from_facet_map(&facet_map).is_ok()); } /// Verify that a tight simplex-removal budget triggers `BudgetExhausted`. diff --git a/src/core/collections/buffers.rs b/src/core/collections/buffers.rs index 64511f11..69b22af6 100644 --- a/src/core/collections/buffers.rs +++ b/src/core/collections/buffers.rs @@ -115,7 +115,7 @@ pub type FacetInfoBuffer = SmallBuffer; /// Buffer for storing simplices that share a facet. -/// Facets are shared by at most 2 simplices (boundary=1, interior=2). +/// Facets are incident to at most 2 simplices (one-sided=1, two-sided=2). /// /// # Optimization Rationale /// diff --git a/src/core/collections/triangulation_maps.rs b/src/core/collections/triangulation_maps.rs index fa26d796..d192a195 100644 --- a/src/core/collections/triangulation_maps.rs +++ b/src/core/collections/triangulation_maps.rs @@ -14,26 +14,20 @@ use crate::core::tds::{SimplexKey, VertexKey}; // TRIANGULATION-SPECIFIC OPTIMIZED TYPES // ============================================================================= -/// Facet-to-simplices mapping optimized for typical triangulation patterns. -/// Most facets are shared by at most 2 simplices (boundary facets = 1, interior facets = 2). +/// Internal facet-to-simplices mapping optimized for typical triangulation patterns. +/// +/// Public APIs expose [`FacetToSimplicesIndex`](crate::prelude::tds::FacetToSimplicesIndex) +/// instead, tying this derived map to the [`Tds`](crate::prelude::tds::Tds) that produced it. +/// Most facets are incident to 1 or 2 simplices (one-sided or two-sided incidence). /// /// # Optimization Rationale /// /// - **Key**: `u64` facet hash (from vertex combination) /// - **Value**: `SmallBuffer` - stack allocated for typical case -/// - **Typical Pattern**: 1 simplex (boundary) or 2 simplices (interior facet) +/// - **Typical Pattern**: 1 simplex (one-sided) or 2 simplices (two-sided) /// - **Performance**: Avoids heap allocation for >95% of facets /// - **Memory Efficiency**: `FacetHandle` uses u8 for facet index, same size as raw tuple -/// -/// # Examples -/// -/// ```rust -/// use delaunay::prelude::collections::FacetToSimplicesMap; -/// -/// let facet_map: FacetToSimplicesMap = FacetToSimplicesMap::default(); -/// assert!(facet_map.is_empty()); -/// ``` -pub type FacetToSimplicesMap = FastHashMap>; +pub(crate) type FacetToSimplicesMap = FastHashMap>; /// Map of over-shared facets detected during localized validation. /// diff --git a/src/core/edge.rs b/src/core/edge.rs index ec37a21f..f21b5ba6 100644 --- a/src/core/edge.rs +++ b/src/core/edge.rs @@ -7,6 +7,7 @@ //! - identifies an edge by two live endpoint [`VertexKey`]s that share a simplex //! - canonicalizes endpoint ordering so `(a, b)` and `(b, a)` map to the same edge //! - is `Copy`/`Hash`/`Ord` for fast use in sets and maps +//! - can be revalidated into an [`EdgeView`] for borrowed access to live topology //! //! ## Determinism //! @@ -16,8 +17,11 @@ #![forbid(unsafe_code)] -use crate::core::tds::{Tds, VertexKey}; +use crate::core::collections::SimplexKeyBuffer; +use crate::core::tds::{SimplexKey, Tds, VertexKey}; +use crate::core::vertex::Vertex; use slotmap::Key; +use std::fmt; use thiserror::Error; /// Error returned when constructing an [`EdgeKey`] from invalid endpoints. @@ -44,6 +48,40 @@ pub enum EdgeKeyError { /// Second endpoint. v1: VertexKey, }, + /// The maintained incidence index does not list any simplex for this edge. + #[error("Vertex incidence index does not list any simplex containing edge {v0:?}-{v1:?}")] + MissingEdgeIncidence { + /// First endpoint. + v0: VertexKey, + /// Second endpoint. + v1: VertexKey, + }, + /// The vertex incidence index references a simplex that is no longer present. + #[error("Vertex incidence index for {vertex_key:?} references missing simplex {simplex_key:?}")] + DanglingVertexIncidence { + /// Vertex whose incidence list contains the dangling simplex key. + vertex_key: VertexKey, + /// Missing simplex key referenced by the incidence index. + simplex_key: SimplexKey, + }, + /// A simplex contains an endpoint, but that endpoint's incidence index does not list it. + #[error("Vertex incidence index for {vertex_key:?} is missing simplex {simplex_key:?}")] + MissingVertexIncidence { + /// Vertex whose incidence list is missing the simplex key. + vertex_key: VertexKey, + /// Simplex expected in the vertex's incidence list. + simplex_key: SimplexKey, + }, + /// A vertex incidence entry points at a simplex that does not contain that vertex. + #[error( + "Vertex incidence index for {vertex_key:?} references simplex {simplex_key:?}, but the simplex does not contain that vertex" + )] + VertexIncidenceMismatch { + /// Vertex whose incidence list contains the inconsistent simplex key. + vertex_key: VertexKey, + /// Simplex expected to contain the vertex. + simplex_key: SimplexKey, + }, } /// Canonical identifier for an (undirected) edge. @@ -309,13 +347,321 @@ impl EdgeKey { pub const fn endpoints(self) -> (VertexKey, VertexKey) { (self.v0, self.v1) } + + /// Revalidates this runtime edge handle against a live TDS and returns a borrowed view. + /// + /// `EdgeKey` stores only storage-local endpoint keys. This method checks those + /// endpoints against `tds` before lending access to endpoint vertices and the + /// edge's incident simplex star. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if either endpoint is stale, the endpoints no + /// longer share a stored simplex, the edge has no live incidence entry, or + /// the maintained incidence index is inconsistent with simplex storage. + /// + /// # 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 Some((_simplex_key, simplex)) = dt.simplices().next() else { + /// return Ok(()); + /// }; + /// + /// let edge = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// let view = edge.view(dt.tds())?; + /// assert_eq!(view.endpoint_keys(), edge.endpoints()); + /// # Ok(()) + /// # } + /// ``` + pub fn view( + self, + tds: &Tds, + ) -> Result, EdgeKeyError> { + EdgeView::try_new(tds, self) + } } +/// Borrowed live-TDS view over an [`EdgeKey`]. +/// +/// `EdgeView` is a non-durable topology view. It borrows one in-memory [`Tds`] +/// and revalidates a copyable [`EdgeKey`] before exposing endpoint vertices and +/// the edge's incident D-simplices. Persist stable vertex UUIDs or a full TDS +/// snapshot instead of serializing edge views. +#[must_use] +pub struct EdgeView<'tds, U, V, const D: usize> { + tds: &'tds Tds, + key: EdgeKey, + vertices: (&'tds Vertex, &'tds Vertex), + incident_simplices: SimplexKeyBuffer, +} + +impl<'tds, U, V, const D: usize> EdgeView<'tds, U, V, D> { + /// Creates a borrowed edge view after validating `key` against `tds`. + /// + /// # Errors + /// + /// Returns [`EdgeKeyError`] if the key has stale endpoints, no longer + /// identifies a stored edge, does not have a live incidence entry, or the + /// maintained incidence index is inconsistent with simplex storage. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::tds::{EdgeKey, EdgeKeyError, EdgeView}; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Edge(#[from] EdgeKeyError), + /// # } + /// # 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 key = EdgeKey::try_new(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + /// let view = EdgeView::try_new(dt.tds(), key)?; + /// assert_eq!(view.key(), key); + /// # Ok(()) + /// # } + /// ``` + pub fn try_new(tds: &'tds Tds, key: EdgeKey) -> Result { + let (v0, v1) = key.endpoints(); + if v0 == v1 { + return Err(EdgeKeyError::DuplicateEndpoint { endpoint: v0 }); + } + let first = tds + .vertex(v0) + .ok_or(EdgeKeyError::MissingEndpoint { endpoint: v0 })?; + let second = tds + .vertex(v1) + .ok_or(EdgeKeyError::MissingEndpoint { endpoint: v1 })?; + + let key = EdgeKey::from_validated_endpoints(v0, v1); + let incident_simplices = Self::validated_incident_simplices(tds, key)?; + + Ok(Self { + tds, + key, + vertices: (first, second), + incident_simplices, + }) + } + + /// Returns the copyable runtime key represented by this view. + #[inline] + #[must_use] + pub const fn key(&self) -> EdgeKey { + self.key + } + + /// Returns the borrowed TDS backing this view. + #[inline] + #[must_use] + pub const fn tds(&self) -> &'tds Tds { + self.tds + } + + /// Returns the endpoint keys in canonical order. + #[inline] + #[must_use] + pub const fn endpoint_keys(&self) -> (VertexKey, VertexKey) { + self.key.endpoints() + } + + /// Returns borrowed endpoint vertices in canonical key order. + #[inline] + #[must_use] + pub const fn vertices(&self) -> (&'tds Vertex, &'tds Vertex) { + self.vertices + } + + /// Returns all D-simplices incident to this edge. + /// + /// The star was parsed and validated during [`Self::try_new`]. + #[must_use] + pub fn incident_simplices(&self) -> &[SimplexKey] { + 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 + /// incidence index is missing it; returning `false` means the live endpoints + /// do not currently form a stored edge. + fn endpoints_share_stored_simplex(tds: &Tds, v0: VertexKey, v1: VertexKey) -> bool { + tds.simplices().any(|(_simplex_key, simplex)| { + simplex.contains_vertex(v0) && simplex.contains_vertex(v1) + }) + } +} + +impl fmt::Debug for EdgeView<'_, U, V, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EdgeView") + .field("key", &self.key) + .field("vertices", &self.key.endpoints()) + .field("incident_simplices", &self.incident_simplices) + .field("dimension", &D) + .finish() + } +} + +impl Clone for EdgeView<'_, U, V, D> { + fn clone(&self) -> Self { + Self { + tds: self.tds, + key: self.key, + vertices: self.vertices, + incident_simplices: self.incident_simplices.clone(), + } + } +} + +impl PartialEq for EdgeView<'_, U, V, D> { + fn eq(&self, other: &Self) -> bool { + std::ptr::eq(self.tds, other.tds) && self.key == other.key + } +} + +impl Eq for EdgeView<'_, U, V, D> {} + #[cfg(test)] mod tests { use super::*; + use crate::core::simplex::Simplex; use crate::prelude::{DelaunayTriangulationBuilder, Vertex}; - use std::collections::{BTreeSet, HashSet}; + use std::{ + collections::{BTreeSet, HashSet}, + ptr, + }; fn with_triangle_tds(test: impl FnOnce(&Tds<(), (), 2>, [VertexKey; 3])) { let vertices = [ @@ -379,6 +725,17 @@ mod tests { }); } + #[test] + fn edge_key_rejects_missing_first_endpoint() { + with_triangle_tds(|tds, [_a, b, _c]| { + let missing = VertexKey::default(); + assert_eq!( + EdgeKey::try_new(tds, missing, b), + Err(EdgeKeyError::MissingEndpoint { endpoint: missing }) + ); + }); + } + #[test] fn edge_key_rejects_live_vertices_without_edge() { let vertices = [ @@ -423,4 +780,192 @@ mod tests { assert_eq!(btree_set.len(), 2); }); } + + #[test] + fn edge_view_exposes_endpoint_vertices_and_key() { + with_triangle_tds(|tds, [a, b, _c]| { + let key = EdgeKey::try_new(tds, a, b).unwrap(); + let view = key.view(tds).unwrap(); + let (first, second) = view.vertices(); + + assert_eq!(view.key(), key); + assert!(ptr::eq(view.tds(), tds)); + assert_eq!(view.endpoint_keys(), key.endpoints()); + assert_eq!(first.uuid(), tds.vertex(view.key().v0()).unwrap().uuid()); + assert_eq!(second.uuid(), tds.vertex(view.key().v1()).unwrap().uuid()); + }); + } + + #[test] + fn edge_view_clone_debug_and_equality_use_owner_and_key() { + with_triangle_tds(|tds, [a, b, c]| { + let first = EdgeKey::try_new(tds, a, b).unwrap().view(tds).unwrap(); + let first_clone = first.clone(); + let second = EdgeKey::try_new(tds, a, c).unwrap().view(tds).unwrap(); + + assert_eq!(first, first_clone); + assert_ne!(first, second); + + let debug = format!("{first:?}"); + assert!(debug.contains("EdgeView")); + assert!(debug.contains("incident_simplices")); + }); + } + + #[test] + fn edge_view_enumerates_incident_simplices_from_incidence_index() { + 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 c0 = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v1, v2]).unwrap()) + .unwrap(); + let c1 = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v1, v0, v3]).unwrap()) + .unwrap(); + let _only_v0 = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v2, v3]).unwrap()) + .unwrap(); + + let edge = EdgeKey::try_new(&tds, v1, v0).unwrap(); + let incident: HashSet<_> = edge + .view(&tds) + .unwrap() + .incident_simplices() + .iter() + .copied() + .collect(); + + assert_eq!(incident, HashSet::from([c0, c1])); + } + + #[test] + fn edge_view_rejects_stale_vertex_incidence() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + let v0 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) + .unwrap(); + let stale_simplex = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v1, v2, v3]).unwrap()) + .unwrap(); + tds.remove_simplex_storage_only_for_test(stale_simplex); + + let edge = EdgeKey::from_validated_endpoints(v0, v1); + assert_eq!( + edge.view(&tds), + Err(EdgeKeyError::DanglingVertexIncidence { + vertex_key: edge.v0(), + simplex_key: stale_simplex + }) + ); + } + + #[test] + fn edge_view_rejects_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 simplex_key = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v1, v2]).unwrap()) + .unwrap(); + let edge = EdgeKey::from_validated_endpoints(v0, v1); + let (_first, second) = edge.endpoints(); + + tds.clear_vertex_incidence_for_test(second); + + assert_eq!( + edge.view(&tds), + Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: second, + simplex_key + }) + ); + } + + #[test] + fn edge_view_rejects_missing_forward_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 simplex_key = tds + .insert_simplex_with_mapping(Simplex::try_new(vec![v0, v1, v2]).unwrap()) + .unwrap(); + let edge = EdgeKey::from_validated_endpoints(v0, v1); + let (first, _second) = edge.endpoints(); + + tds.clear_vertex_incidence_for_test(first); + + assert_eq!( + edge.view(&tds), + Err(EdgeKeyError::MissingVertexIncidence { + vertex_key: first, + simplex_key + }) + ); + } + + #[test] + fn edge_view_rejects_live_endpoints_without_stored_edge() { + 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 edge = EdgeKey::from_validated_endpoints(v0, v1); + let (v0, v1) = edge.endpoints(); + + assert_eq!(edge.view(&tds), Err(EdgeKeyError::EdgeNotFound { v0, v1 })); + } + + #[test] + fn edge_view_rejects_stale_endpoint_handles() { + with_triangle_tds(|_tds, [a, b, _c]| { + let stale = EdgeKey::from_validated_endpoints(a, b); + let empty: Tds<(), (), 2> = Tds::empty(); + + assert_eq!( + stale.view(&empty), + Err(EdgeKeyError::MissingEndpoint { + endpoint: stale.v0() + }) + ); + }); + } } diff --git a/src/core/facet.rs b/src/core/facet.rs index 2a63138f..36839787 100644 --- a/src/core/facet.rs +++ b/src/core/facet.rs @@ -6,25 +6,29 @@ //! //! # Key Features //! -//! - **Lightweight**: `FacetView` is ~18x smaller than the deprecated `Facet` struct +//! - **Lightweight**: `FacetView` stores borrowed references plus compact vertex-key buffers //! - **Dimensional Simplicity**: Represents co-dimension 1 sub-simplexes of d-dimensional simplexes //! - **Simplex Association**: Each facet resides within a specific simplex and is described by its opposite vertex //! - **Support for Delaunay Triangulations**: Facilitates operations fundamental to the //! [Bowyer-Watson algorithm](https://en.wikipedia.org/wiki/Bowyer–Watson_algorithm) //! - **On-demand Creation**: Facets are generated dynamically as needed rather than stored persistently in the TDS -//! - **Memory Efficient**: Stores only references and keys, accessing data on-demand from the TDS +//! - **Memory Efficient**: Parses facet storage once, then exposes infallible borrowed accessors //! - **Runtime-local Identity**: Facet handles and views contain slotmap keys and are not durable //! interchange identifiers. Serialize a full [`Tds`] snapshot when topology must cross an I/O //! boundary. //! //! # Fundamental Invariant //! -//! **A critical invariant of Delaunay triangulations is that each facet is shared by exactly two simplices, -//! except for boundary facets which belong to only one simplex.** +//! **A critical TDS invariant is that each facet is incident to one or two +//! simplices. One-sided incidence is not, by itself, a manifold boundary +//! classification: topology-aware triangulation queries decide whether a +//! one-sided facet is true boundary or an admissible closed self-identification.** //! //! This property ensures the triangulation forms a valid simplicial complex: -//! - **Interior facets**: Shared by exactly 2 simplices (defines proper adjacency) -//! - **Boundary facets**: Belong to exactly 1 simplex (lie on the convex hull) +//! - **Two-sided facets**: shared by exactly 2 simplices (defines proper adjacency) +//! - **One-sided facets**: incident to exactly 1 simplex +//! - **Boundary facets**: topology-approved one-sided facets in spaces that admit boundary +//! - **Periodic self-identifications**: one-sided in quotient storage, but closed rather than boundary //! - **Invalid configurations**: Facets shared by 0, 3, or more simplices indicate topological errors //! //! This invariant is fundamental to many algorithms and is actively validated during triangulation @@ -68,24 +72,31 @@ //! //! // Create a facet view (facet 0 excludes vertex 0) //! let facet = FacetView::try_new(dt.tds(), simplex_key, 0)?; -//! assert_eq!(facet.vertices()?.count(), 3); // Facet (triangle) in 3D has 3 vertices +//! assert_eq!(facet.vertices().count(), 3); // Facet (triangle) in 3D has 3 vertices //! # Ok(()) //! # } //! ``` #![forbid(unsafe_code)] -use super::collections::{FacetToSimplicesMap, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer}; +use super::collections::{ + FacetToSimplicesMap, FastHashMap, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer, + fast_hash_map_with_capacity, +}; use super::util::{stable_hash_u64_slice, usize_to_u8}; use super::{ simplex::Simplex, - tds::{SimplexKey, Tds, TdsError, VertexKey}, + tds::{NeighborValidationError, SimplexKey, Tds, TdsError, VertexKey}, vertex::Vertex, }; use crate::geometry::traits::coordinate::CoordinateConversionError; use slotmap::Key; -use std::fmt::{self, Debug}; -use std::sync::Arc; +use std::{ + fmt::{self, Debug}, + iter::FusedIterator, + sync::Arc, + vec::IntoIter, +}; use thiserror::Error; // ============================================================================= @@ -197,9 +208,22 @@ pub enum FacetError { /// The vertex key that was not found. key: VertexKey, }, - /// Facet has invalid multiplicity (should be 1 for boundary or 2 for internal). + /// A facet view was used with a different TDS than the one that produced it. + #[error( + "Facet view for simplex {simplex_key:?}, facet {facet_index} belongs to a different TDS" + )] + FacetOwnerMismatch { + /// The simplex key stored in the foreign facet view. + simplex_key: SimplexKey, + /// The facet index stored in the foreign facet view. + facet_index: u8, + }, + /// A facet-to-simplices index was used with a different TDS than the one that produced it. + #[error("Facet-to-simplices index belongs to a different TDS")] + FacetIndexOwnerMismatch, + /// Facet has invalid multiplicity (should be one-sided or two-sided). #[error( - "Facet with key {facet_key:016x} has invalid multiplicity {found}, expected 1 (boundary) or 2 (internal)" + "Facet with key {facet_key:016x} has invalid multiplicity {found}, expected 1 (one-sided) or 2 (two-sided)" )] InvalidFacetMultiplicity { /// The facet key with invalid multiplicity. @@ -207,6 +231,40 @@ pub enum FacetError { /// The actual multiplicity found. found: usize, }, + /// A two-sided facet incidence repeated the same simplex facet handle. + #[error( + "Facet with key {facet_key:016x} repeats incident simplex facet {handle:?}; expected distinct incident simplex facets" + )] + DuplicateFacetIncidentHandle { + /// The facet key with duplicate incident handles. + facet_key: u64, + /// The repeated simplex facet handle. + handle: FacetHandle, + }, + /// An incident facet handle derives a different canonical facet key than its index entry. + #[error( + "Facet handle {handle:?} derives key {actual_facet_key:016x}, but index entry expected {expected_facet_key:016x}" + )] + FacetHandleKeyMismatch { + /// The facet key under which the handle was stored. + expected_facet_key: u64, + /// The canonical facet key derived from the handle's live facet view. + actual_facet_key: u64, + /// The mismatched incident simplex facet handle. + handle: FacetHandle, + }, + /// A supplied boundary facet handle is not the parsed one-sided handle for its facet key. + #[error( + "Boundary facet handle {supplied_handle:?} is not the indexed one-sided handle {indexed_handle:?} for facet key {facet_key:016x}" + )] + BoundaryFacetHandleNotIndexed { + /// The canonical facet key derived from the supplied handle. + facet_key: u64, + /// The handle supplied to the boundary-facet iterator. + supplied_handle: FacetHandle, + /// The one-sided handle stored in the parsed facet index. + indexed_handle: FacetHandle, + }, /// Failed to retrieve boundary facets from triangulation. #[error("Failed to retrieve boundary facets: {source}")] BoundaryFacetRetrievalFailed { @@ -214,6 +272,13 @@ pub enum FacetError { #[source] source: Arc, }, + /// Failed to derive this facet's canonical key. + #[error("Failed to derive canonical facet key: {source}")] + FacetKeyDerivationFailed { + /// The underlying TDS validation error. + #[source] + source: Arc, + }, /// Simplex operation failed due to validation error. #[error("Simplex operation failed: {source}")] SimplexOperationFailed { @@ -242,7 +307,7 @@ pub enum FacetError { /// # Usage /// /// `FacetHandle` is commonly used in: -/// - Boundary facet analysis (convex hull extraction) +/// - Boundary-facet handles after topology-aware convex-hull extraction /// - Facet visibility testing /// - Cavity computation in Bowyer-Watson algorithm /// - Any operation requiring lightweight facet references @@ -267,6 +332,8 @@ pub enum FacetError { /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] +/// # Query(#[from] delaunay::query::QueryError), +/// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -507,9 +574,15 @@ impl FacetHandle { /// Lightweight facet implementation that replaces the heavyweight `Facet` struct /// with an ~18x memory reduction. /// -/// `FacetView` represents a facet (d-1 dimensional face) of a d-dimensional simplex -/// without storing any data directly. Instead, it maintains references to the TDS -/// and uses keys to access data on-demand. +/// `FacetView` represents a facet (d-1 dimensional face) of a d-dimensional simplex. +/// It validates the simplex key, facet index, and vertex keys at construction, +/// then carries borrowed references for infallible live-TDS access. +/// +/// Mathematically, a facet of a D-simplex is the `(D - 1)`-simplex obtained by +/// omitting exactly one of the simplex's `D + 1` vertices. The omitted vertex is +/// called the **opposite vertex**. Thus a triangle has three edge facets, a +/// tetrahedron has four triangular facets, and in general a D-simplex has one +/// facet opposite each vertex. /// /// Like [`FacetHandle`], this is a live view over one in-memory [`Tds`]. It is appropriate for /// traversal, validation, and local algorithms, but it is not a persistence format. A @@ -520,8 +593,8 @@ impl FacetHandle { /// /// Compared to the original `Facet`: /// - **Original**: Stores complete Simplex + Vertex objects (~hundreds of bytes) -/// - **`FacetView`**: Stores TDS reference + `SimplexKey` + `facet_index` (~17 bytes) -/// - **Memory reduction: ~18x smaller** +/// - **`FacetView`**: Stores TDS/simplex references, a handle, and compact facet vertex buffers +/// - **Memory reduction**: avoids owning simplex and vertex payloads /// /// # Type Parameters /// @@ -545,16 +618,17 @@ impl FacetHandle { /// // Create a facet view for the first facet of a simplex /// let facet_view = FacetView::try_new(tds, simplex_key, 0)?; /// -/// // Access vertices through the view (lazy evaluation) -/// for vertex in facet_view.vertices()? { +/// // Access vertices through the view +/// for vertex in facet_view.vertices() { /// println!("Vertex: {:?}", vertex.point()); /// } /// /// // Get the opposite vertex -/// let opposite = facet_view.opposite_vertex()?; +/// let opposite = facet_view.opposite_vertex(); /// /// // Compute facet key -/// let key = facet_view.key()?; +/// let key = facet_view.key(); +/// let _ = (opposite, key); /// Ok(()) /// } /// ``` @@ -562,6 +636,8 @@ impl FacetHandle { pub struct FacetView<'tds, U, V, const D: usize> { /// Reference to the triangulation data structure. tds: &'tds Tds, + /// Borrowed simplex containing this facet. + simplex: &'tds Simplex, /// Key of the simplex containing this facet. simplex_key: SimplexKey, /// Index of this facet within the simplex (0 <= `facet_index` < D+1). @@ -570,6 +646,14 @@ pub struct FacetView<'tds, U, V, const D: usize> { /// (the vertex not included in the facet). For a D-dimensional simplex with D+1 /// vertices, facet i excludes vertex i and includes all others. facet_index: u8, + /// Vertex keys that define this facet, excluding the opposite vertex. + facet_vertex_keys: SmallBuffer, + /// Canonical key matching the TDS facet-to-simplices index. + key: u64, + /// Borrowed vertices that define this facet, in containing-simplex order. + vertices: SmallBuffer<&'tds Vertex, MAX_PRACTICAL_DIMENSION_SIZE>, + /// Borrowed opposite vertex excluded from this facet. + opposite_vertex: &'tds Vertex, } impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { @@ -646,6 +730,10 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// * `simplex_key` - The key of the simplex containing the facet /// * `facet_index` - The index of the facet within the simplex (0 to D) /// + /// The `facet_index` is the index of the opposite vertex in the containing + /// simplex. Constructing the facet means borrowing every simplex vertex + /// except that opposite vertex, preserving the simplex's vertex order. + /// /// # Returns /// /// A `Result` containing the facet view if successful. @@ -696,12 +784,10 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { simplex_key: SimplexKey, facet_index: u8, ) -> Result { - // Validate simplex exists let simplex = tds .simplex(simplex_key) .ok_or(FacetError::SimplexNotFoundInTriangulation)?; - // Validate facet index let vertex_count = simplex.number_of_vertices(); if usize::from(facet_index) >= vertex_count { return Err(FacetError::InvalidFacetIndex { @@ -710,10 +796,45 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { }); } + let mut facet_vertex_keys: SmallBuffer = + SmallBuffer::with_capacity(vertex_count.saturating_sub(1)); + let mut vertices: SmallBuffer<&'tds Vertex, MAX_PRACTICAL_DIMENSION_SIZE> = + SmallBuffer::with_capacity(vertex_count.saturating_sub(1)); + let mut opposite_vertex = None; + let facet_index_usize = usize::from(facet_index); + for (index, &vertex_key) in simplex.vertices().iter().enumerate() { + let vertex = tds + .vertex(vertex_key) + .ok_or(FacetError::VertexKeyNotFoundInTriangulation { key: vertex_key })?; + if index == facet_index_usize { + opposite_vertex = Some(vertex); + } else { + facet_vertex_keys.push(vertex_key); + vertices.push(vertex); + } + } + let opposite_vertex = opposite_vertex.ok_or(FacetError::InvalidFacetIndex { + index: facet_index, + facet_count: vertex_count, + })?; + let key = Tds::::periodic_facet_key_from_simplex_vertices( + simplex, + simplex.vertices(), + facet_index_usize, + ) + .map_err(|source| FacetError::FacetKeyDerivationFailed { + source: Arc::new(source), + })?; + Ok(Self { tds, + simplex, simplex_key, facet_index, + facet_vertex_keys, + key, + vertices, + opposite_vertex, }) } @@ -725,16 +846,8 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// This method is available without coordinate or payload trait bounds, /// enabling usage in lightweight operations that only inspect topology. /// - /// # Returns - /// - /// A `Result` containing an iterator yielding references to vertices in the facet, - /// or a `FacetError` if the simplex is no longer present in the TDS. - /// - /// # Errors - /// - /// Returns `FacetError::SimplexNotFoundInTriangulation` if the simplex key is no longer - /// present in the TDS. This could happen if the TDS is modified after the `FacetView` - /// is created, though this should not occur under normal usage patterns. + /// This is infallible because [`Self::try_new`] validated the containing + /// simplex, facet index, and vertex keys while borrowing the TDS. /// /// # Examples /// @@ -764,48 +877,23 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// /// if let Some((simplex_key, _)) = dt.simplices().next() { /// let facet = FacetView::try_new(dt.tds(), simplex_key, 0)?; - /// let vertex_iter = facet.vertices()?; + /// let vertex_iter = facet.vertices(); /// assert_eq!(vertex_iter.count(), 3); // 3D facet has 3 vertices /// } /// # Ok(()) /// # } /// ``` - pub fn vertices(&self) -> Result>, FacetError> { - let simplex = self - .tds - .simplex(self.simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation)?; - let facet_index = usize::from(self.facet_index); - - // Collect first so missing vertex keys become an error, not silent drops. - // Use SmallBuffer for stack allocation (D vertices fit on stack for D ≤ 7) - let mut refs: SmallBuffer<&'tds Vertex, MAX_PRACTICAL_DIMENSION_SIZE> = - SmallBuffer::with_capacity(simplex.number_of_vertices().saturating_sub(1)); - for (i, &vkey) in simplex.vertices().iter().enumerate() { - if i == facet_index { - continue; - } - refs.push( - self.tds - .vertex(vkey) - .ok_or(FacetError::VertexKeyNotFoundInTriangulation { key: vkey })?, - ); - } - Ok(refs.into_iter()) + #[must_use] + pub fn vertices(&self) -> impl ExactSizeIterator> + '_ { + self.vertices.iter().copied() } /// Returns the opposite vertex (the vertex not included in the facet). /// - /// # Returns - /// - /// A `Result` containing a reference to the opposite vertex. + /// Returns a reference to the opposite vertex. /// - /// # Errors - /// - /// Returns [`FacetError::SimplexNotFoundInTriangulation`] if the simplex is no longer in the TDS, - /// [`FacetError::InvalidFacetIndex`] if the facet index is outside the simplex's vertex list, - /// or [`FacetError::VertexKeyNotFoundInTriangulation`] if the opposite vertex key no longer - /// resolves to a stored vertex. + /// This is infallible because [`Self::try_new`] validated and cached the + /// opposite vertex while borrowing the TDS. /// /// # Examples /// @@ -837,42 +925,22 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// }; /// /// let facet = FacetView::try_new(dt.tds(), simplex_key, 1)?; - /// let opposite = facet.opposite_vertex()?; + /// let opposite = facet.opposite_vertex(); /// assert_eq!(opposite.point().coords().len(), 3); /// # Ok(()) /// # } /// ``` - pub fn opposite_vertex(&self) -> Result<&'tds Vertex, FacetError> { - let simplex = self - .tds - .simplex(self.simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation)?; - - let vertices = simplex.vertices(); - let facet_index = usize::from(self.facet_index); - - let vkey = vertices - .get(facet_index) - .ok_or(FacetError::InvalidFacetIndex { - index: self.facet_index, - facet_count: vertices.len(), - })?; - - // Use get() to safely handle potentially invalid vertex keys - self.tds - .vertex(*vkey) - .ok_or(FacetError::VertexKeyNotFoundInTriangulation { key: *vkey }) + #[must_use] + pub const fn opposite_vertex(&self) -> &'tds Vertex { + self.opposite_vertex } /// Returns the simplex containing this facet. /// - /// # Returns - /// - /// A `Result` containing a reference to the containing simplex. - /// - /// # Errors + /// Returns a reference to the containing simplex. /// - /// Returns `FacetError::SimplexNotFoundInTriangulation` if the simplex is no longer in the TDS. + /// This is infallible because [`Self::try_new`] validated the simplex key + /// while borrowing the TDS. /// /// # Examples /// @@ -904,30 +972,25 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// }; /// /// let facet = FacetView::try_new(dt.tds(), simplex_key, 2)?; - /// let simplex = facet.simplex()?; + /// let simplex = facet.simplex(); /// assert_eq!(simplex.number_of_vertices(), 4); /// # Ok(()) /// # } /// ``` - pub fn simplex(&self) -> Result<&'tds Simplex, FacetError> { - self.tds - .simplex(self.simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation) + #[must_use] + pub const fn simplex(&self) -> &'tds Simplex { + self.simplex } - /// Computes a canonical key for this facet. + /// Returns the canonical key for this facet. /// - /// The key is computed from the vertex keys of the facet vertices, - /// providing a stable hash that's identical for any two facets - /// containing the same vertices. - /// - /// # Returns + /// The key matches the owning TDS facet-to-simplices index. For ordinary + /// Euclidean facets this is the bare vertex-key hash; for periodic quotient + /// facets it also incorporates normalized lattice offsets, so identified + /// lifted images share a key. /// - /// A `Result` containing the facet key as a `u64`. - /// - /// # Errors - /// - /// Returns `FacetError` if vertex keys cannot be retrieved. + /// This is infallible because [`Self::try_new`] validated and cached the + /// facet vertex keys while borrowing the TDS. /// /// # Examples /// @@ -959,19 +1022,414 @@ impl<'tds, U, V, const D: usize> FacetView<'tds, U, V, D> { /// }; /// /// let facet = FacetView::try_new(dt.tds(), simplex_key, 0)?; - /// let facet_key = facet.key()?; - /// let map = dt.tds().build_facet_to_simplices_map()?; - /// assert!(map.contains_key(&facet_key)); + /// let facet_key = facet.key(); + /// let index = dt.tds().build_facet_to_simplices_index()?; + /// assert!(index.get(&facet_key).is_some()); /// # Ok(()) /// # } /// ``` - pub fn key(&self) -> Result { + #[must_use] + pub const fn key(&self) -> u64 { + self.key + } +} + +/// Validated incident simplices for one canonical facet key. +/// +/// A valid D-dimensional triangulation facet is incident to either one or two +/// D-simplices. This crate-internal value carries that multiplicity proof after +/// a raw incidence map has been parsed. Public callers observe it through +/// [`FacetIncidenceView`], which keeps the proof borrowed from the +/// owner-bound [`FacetToSimplicesIndex`]. +/// +/// One-sided incidence is not the same as manifold boundary: periodic quotient +/// triangulations can encode a closed self-identification with one incident +/// simplex and a self-neighbor pointer. Boundary classification belongs to the +/// topology layer because it depends on the +/// [`GlobalTopology`](crate::prelude::topology::spaces::GlobalTopology) +/// declared by the surrounding triangulation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub(crate) struct FacetIncidence { + kind: FacetIncidenceKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FacetIncidenceKind { + OneSided(FacetHandle), + TwoSided([FacetHandle; 2]), +} + +impl FacetIncidence { + /// Parses a raw facet-index entry into validated incidence for one canonical facet key. + /// + /// # Errors + /// + /// Returns [`FacetError::InvalidFacetMultiplicity`] or + /// [`FacetError::DuplicateFacetIncidentHandle`] when the raw entry does not + /// contain one handle or two distinct handles. Returns + /// [`FacetError::FacetHandleKeyMismatch`] or a lower-level handle/view error + /// when an incident handle does not resolve to the facet key under the TDS + /// that produced the index. + fn try_from_index_entry( + tds: &Tds, + facet_key: u64, + handles: &SmallBuffer, + ) -> Result { + let incidence = Self::try_from_handles(facet_key, handles)?; + match incidence.kind { + FacetIncidenceKind::OneSided(handle) => { + let handle = try_incident_facet_view_for_facet_key(tds, facet_key, handle) + .map(|_| handle)?; + Ok(Self { + kind: FacetIncidenceKind::OneSided(handle), + }) + } + FacetIncidenceKind::TwoSided([first, second]) => { + let first = + try_incident_facet_view_for_facet_key(tds, facet_key, first).map(|_| first)?; + let second = try_incident_facet_view_for_facet_key(tds, facet_key, second) + .map(|_| second)?; + Ok(Self { + kind: FacetIncidenceKind::TwoSided([first, second]), + }) + } + } + } + + /// Parses raw incident handles into a multiplicity-proof facet incidence. + /// + /// # Errors + /// + /// Returns [`FacetError::InvalidFacetMultiplicity`] unless the entry has one + /// handle or two handles, and returns + /// [`FacetError::DuplicateFacetIncidentHandle`] when a two-sided entry + /// repeats the same handle. + fn try_from_handles( + facet_key: u64, + handles: &SmallBuffer, + ) -> Result { + match handles.as_slice() { + [handle] => Ok(Self { + kind: FacetIncidenceKind::OneSided(*handle), + }), + [first, second] if first != second => Ok(Self { + kind: FacetIncidenceKind::TwoSided([*first, *second]), + }), + [handle, _] => Err(FacetError::DuplicateFacetIncidentHandle { + facet_key, + handle: *handle, + }), + _ => Err(FacetError::InvalidFacetMultiplicity { + facet_key, + found: handles.len(), + }), + } + } + + /// Returns true when this facet is incident to exactly one D-simplex. + /// + /// One-sided facets are open incidence candidates only. Periodic quotient + /// triangulations may use one-sided self-identifications for closed + /// topology, so manifold boundary classification belongs to the topology + /// layer. + #[inline] + #[must_use] + pub(crate) const fn is_one_sided(self) -> bool { + matches!(self.kind, FacetIncidenceKind::OneSided(_)) + } + + /// Returns the number of incident D-simplices. + #[inline] + #[must_use] + pub(crate) const fn incident_simplex_count(self) -> usize { + match self.kind { + FacetIncidenceKind::OneSided(_) => 1, + FacetIncidenceKind::TwoSided(_) => 2, + } + } + + /// Returns the handle when this is a one-sided facet. + #[inline] + #[must_use] + pub(crate) const fn one_sided_handle(self) -> Option { + match self.kind { + FacetIncidenceKind::OneSided(handle) => Some(handle), + FacetIncidenceKind::TwoSided(_) => None, + } + } + + /// Returns the handles when this is a two-sided facet. + #[inline] + #[must_use] + pub(crate) const fn two_sided_handles(self) -> Option<[FacetHandle; 2]> { + match self.kind { + FacetIncidenceKind::OneSided(_) => None, + FacetIncidenceKind::TwoSided(handles) => Some(handles), + } + } +} + +/// Parses a raw incident handle as belonging to one canonical facet-key entry. +/// +/// # Errors +/// +/// Returns the same errors as [`FacetHandle::view`] when the handle cannot be +/// reborrowed from `tds`, or [`FacetError::FacetHandleKeyMismatch`] when the +/// live facet view derives a different canonical key than the index entry. +pub(crate) fn try_incident_facet_view_for_facet_key( + tds: &Tds, + expected_facet_key: u64, + handle: FacetHandle, +) -> Result, FacetError> { + let facet = handle.view(tds)?; + let actual_facet_key = facet.key(); + if actual_facet_key == expected_facet_key { + return Ok(facet); + } + + Err(FacetError::FacetHandleKeyMismatch { + expected_facet_key, + actual_facet_key, + handle, + }) +} + +/// Borrowed view over one parsed facet-incidence entry. +/// +/// The view borrows the [`FacetToSimplicesIndex`] entry and carries the [`Tds`] +/// that produced that index. This keeps the parsed multiplicity proof, facet +/// key, and canonical owner together for the lifetime of the index borrow. +#[must_use] +pub struct FacetIncidenceView<'idx, 'tds, U, V, const D: usize> { + tds: &'tds Tds, + facet_key: u64, + incidence: &'idx FacetIncidence, +} + +impl Clone for FacetIncidenceView<'_, '_, U, V, D> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for FacetIncidenceView<'_, '_, U, V, D> {} + +impl<'tds, U, V, const D: usize> FacetIncidenceView<'_, 'tds, U, V, D> { + /// Returns the TDS that produced the borrowed incidence index entry. + #[inline] + #[must_use] + pub const fn tds(self) -> &'tds Tds { + self.tds + } + + /// Returns the canonical facet key for this incidence entry. + #[inline] + #[must_use] + pub const fn facet_key(self) -> u64 { + self.facet_key + } + + /// Returns true when this facet is incident to exactly one D-simplex. + #[inline] + #[must_use] + pub const fn is_one_sided(self) -> bool { + self.incidence.is_one_sided() + } + + /// Returns the number of incident D-simplices. + #[inline] + #[must_use] + pub const fn incident_simplex_count(self) -> usize { + self.incidence.incident_simplex_count() + } + + /// Returns the handle when this is a one-sided facet. + #[inline] + #[must_use] + pub const fn one_sided_handle(self) -> Option { + self.incidence.one_sided_handle() + } + + /// Returns the handles when this is a two-sided facet. + #[inline] + #[must_use] + pub const fn two_sided_handles(self) -> Option<[FacetHandle; 2]> { + self.incidence.two_sided_handles() + } +} + +/// Owner-bound derived index from facet keys to validated incident simplex facets. +/// +/// The index owns its derived map but borrows the [`Tds`] that produced it. +/// Passing this wrapper instead of a raw map prevents boundary queries from +/// accidentally pairing a [`FacetView`] with incidence data from a different +/// triangulation. It also parses raw multiplicities into borrowed +/// [`FacetIncidenceView`] entries, so public boundary queries can operate on +/// proof-bearing incidence without detaching it from the producing index. +#[derive(Clone, Debug)] +#[must_use] +pub struct FacetToSimplicesIndex<'tds, U, V, const D: usize> { + tds: &'tds Tds, + map: FastHashMap, +} + +impl<'tds, U, V, const D: usize> FacetToSimplicesIndex<'tds, U, V, D> { + /// Parses a freshly built raw facet map and binds it to the TDS that produced it. + /// + /// # Errors + /// + /// Returns [`FacetError`] when any raw incidence entry has invalid + /// multiplicity, duplicate handles, stale handles, or handles whose live + /// facet key does not match the map entry. + #[inline] + pub(crate) fn try_from_map( + tds: &'tds Tds, + map: &FacetToSimplicesMap, + ) -> Result { + let mut parsed = fast_hash_map_with_capacity(map.len()); + for (facet_key, handles) in map { + let incidence = FacetIncidence::try_from_index_entry(tds, *facet_key, handles)?; + parsed.insert(*facet_key, incidence); + } + Ok(Self { tds, map: parsed }) + } + + /// Returns the borrowed TDS that produced this index. + #[inline] + #[must_use] + pub const fn tds(&self) -> &'tds Tds { self.tds - .facet_key_for_simplex_facet(self.simplex_key, usize::from(self.facet_index)) - .map_err(|e| FacetError::SimplexOperationFailed { - source: Arc::new(e), + } + + /// Returns the parsed incident simplex facets for a canonical facet key. + #[inline] + #[must_use] + pub fn get<'idx>( + &'idx self, + facet_key: &u64, + ) -> Option> { + self.map.get(facet_key).map(|incidence| FacetIncidenceView { + tds: self.tds, + facet_key: *facet_key, + incidence, + }) + } + + /// Returns true when `facet_key` has one-sided incidence. + #[inline] + #[must_use] + pub fn is_one_sided_facet_key(&self, facet_key: &u64) -> bool { + self.map + .get(facet_key) + .is_some_and(|incidence| incidence.is_one_sided()) + } + + /// Returns the number of indexed facet keys. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.map.len() + } + + /// Returns whether the index contains no facet keys. + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Iterates over borrowed facet-incidence entries. + #[inline] + pub fn iter<'idx>( + &'idx self, + ) -> impl Iterator> + 'idx { + let tds = self.tds; + self.map + .iter() + .map(move |(facet_key, incidence)| FacetIncidenceView { + tds, + facet_key: *facet_key, + incidence, }) } + + /// Iterates over handles for parsed one-sided facet incidences. + #[inline] + pub(crate) fn one_sided_handles(&self) -> impl Iterator + '_ { + self.map + .values() + .filter_map(|incidence| incidence.one_sided_handle()) + } +} + +/// Local neighbor metadata for a one-sided facet occurrence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub(crate) enum OneSidedFacetAdjacency { + /// The owning simplex has no neighbor across this facet. + Open, + /// The owning simplex points to itself with periodic vertex offsets. + PeriodicSelfIdentification, +} + +/// Classifies the local neighbor metadata for a parsed one-sided facet. +pub(crate) fn classify_one_sided_facet_adjacency( + facet: &FacetView<'_, U, V, D>, +) -> Result { + let facet_key = facet.key(); + let simplex_key = facet.simplex_key(); + let facet_index = usize::from(facet.facet_index()); + let simplex = facet.simplex(); + + if facet_index >= simplex.number_of_vertices() { + return Err(TdsError::IndexOutOfBounds { + index: facet_index, + bound: simplex.number_of_vertices(), + context: format!( + "one-sided facet adjacency classification for simplex {simplex_key:?}" + ), + }); + } + + let Some(neighbor) = simplex.neighbor_key(facet_index) else { + return Ok(OneSidedFacetAdjacency::Open); + }; + let Some(neighbor_key) = neighbor else { + return Ok(OneSidedFacetAdjacency::Open); + }; + + if neighbor_key == simplex_key { + if simplex_allows_periodic_self_neighbor(simplex) { + return Ok(OneSidedFacetAdjacency::PeriodicSelfIdentification); + } + return Err(TdsError::InvalidNeighbors { + reason: NeighborValidationError::BoundaryFacetHasNonPeriodicSelfNeighbor { + facet_key, + simplex_key, + simplex_uuid: simplex.uuid(), + facet_index, + }, + }); + } + + Err(TdsError::InvalidNeighbors { + reason: NeighborValidationError::BoundaryFacetHasNeighbor { + facet_key, + simplex_key, + simplex_uuid: simplex.uuid(), + facet_index, + neighbor_key, + }, + }) +} + +/// Mirrors TDS validation's periodic self-neighbor allowance for boundary queries. +fn simplex_allows_periodic_self_neighbor(simplex: &Simplex) -> bool { + let Some(offsets) = simplex.periodic_vertex_offsets() else { + return false; + }; + !offsets.is_empty() && offsets.len() == simplex.number_of_vertices() } // Trait implementations for FacetView @@ -980,27 +1438,28 @@ impl Debug for FacetView<'_, U, V, D> { f.debug_struct("FacetView") .field("simplex_key", &self.simplex_key) .field("facet_index", &self.facet_index) + .field("facet_vertex_keys", &self.facet_vertex_keys) + .field("key", &self.key) .field("dimension", &D) .finish() } } -#[expect( - clippy::non_canonical_clone_impl, - reason = "facet clone intentionally preserves cached view fields" -)] impl Clone for FacetView<'_, U, V, D> { fn clone(&self) -> Self { Self { tds: self.tds, + simplex: self.simplex, simplex_key: self.simplex_key, facet_index: self.facet_index, + facet_vertex_keys: self.facet_vertex_keys.clone(), + key: self.key, + vertices: self.vertices.clone(), + opposite_vertex: self.opposite_vertex, } } } -impl Copy for FacetView<'_, U, V, D> {} - impl PartialEq for FacetView<'_, U, V, D> { fn eq(&self, other: &Self) -> bool { // Two facet views are equal if they reference the same facet @@ -1012,31 +1471,23 @@ impl PartialEq for FacetView<'_, U, V, D> { impl Eq for FacetView<'_, U, V, D> {} -/// Utility function to create multiple `FacetView`s for all facets of a simplex. -/// -/// # Arguments -/// -/// * `tds` - Reference to the triangulation data structure -/// * `simplex_key` - Key of the simplex to create facet views for +/// Iterator over the facets of one simplex in a triangulation data structure. /// -/// # Returns +/// This iterator is lifetime-bound to the owning [`Tds`], so the returned +/// [`FacetView`] values cannot outlive the topology they observe. Construction is +/// fallible because the caller supplies a runtime [`SimplexKey`]; per-item +/// `FacetError`s still surface during iteration if the TDS is structurally +/// inconsistent. /// -/// A `Result` containing a `Vec` of `FacetView`s for all facets of the simplex. -/// -/// # Errors -/// -/// Returns `FacetError` if the simplex is not found or has invalid structure. -/// -/// # Note -/// -/// Removed unnecessary numeric bounds (`AddAssign`, `SubAssign`, `Sum`, `NumCast`, `Div`) -/// since this function doesn't perform any arithmetic operations. +/// Iteration follows the standard simplex boundary construction: for a simplex +/// with vertices `[v0, ..., vD]`, item `i` is the facet opposite `vi`, containing +/// all other vertices. The iterator therefore has exactly `D + 1` items for a +/// well-formed D-simplex. /// /// # Examples /// /// ```rust /// use delaunay::prelude::*; -/// use delaunay::prelude::tds::all_facets_for_simplex; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { @@ -1047,6 +1498,8 @@ impl Eq for FacetView<'_, U, V, D> {} /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] +/// # Query(#[from] delaunay::query::QueryError), +/// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -1061,31 +1514,90 @@ impl Eq for FacetView<'_, U, V, D> {} /// return Ok(()); /// }; /// -/// let facets = all_facets_for_simplex(dt.tds(), simplex_key)?; +/// let facets = dt.tds().try_simplex_facets(simplex_key)?; /// assert_eq!(facets.len(), 4); /// # Ok(()) /// # } /// ``` -pub fn all_facets_for_simplex( - tds: &Tds, +#[must_use] +#[derive(Clone)] +pub struct SimplexFacetsIter<'tds, U, V, const D: usize> { + tds: &'tds Tds, simplex_key: SimplexKey, -) -> Result>, FacetError> { - let simplex = tds - .simplex(simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation)?; + next_facet_index: u16, + facet_count: u16, +} + +impl<'tds, U, V, const D: usize> SimplexFacetsIter<'tds, U, V, D> { + /// Creates a new iterator over the facets of `simplex_key`. + /// + /// # Errors + /// + /// Returns [`FacetError::SimplexNotFoundInTriangulation`] if `simplex_key` + /// does not identify a simplex in `tds`, or + /// [`FacetError::InvalidFacetIndexOverflow`] if the simplex has more facets + /// than can be represented by the public `u8` facet-index storage. + pub(crate) fn try_new( + tds: &'tds Tds, + simplex_key: SimplexKey, + ) -> Result { + let simplex = tds + .simplex(simplex_key) + .ok_or(FacetError::SimplexNotFoundInTriangulation)?; + let facet_count_usize = simplex.number_of_vertices(); + let max_facet_count = usize::from(u8::MAX) + 1; + if facet_count_usize > max_facet_count { + return Err(FacetError::InvalidFacetIndexOverflow { + original_index: max_facet_count, + facet_count: facet_count_usize, + }); + } + let facet_count = u16::try_from(facet_count_usize).map_err(|_| { + FacetError::InvalidFacetIndexOverflow { + original_index: max_facet_count, + facet_count: facet_count_usize, + } + })?; + + Ok(Self { + tds, + simplex_key, + next_facet_index: 0, + facet_count, + }) + } +} + +impl<'tds, U, V, const D: usize> Iterator for SimplexFacetsIter<'tds, U, V, D> { + type Item = Result, FacetError>; + + fn next(&mut self) -> Option { + if self.next_facet_index >= self.facet_count { + return None; + } - let vertex_count = simplex.number_of_vertices(); - let mut facet_views = Vec::with_capacity(vertex_count); + let facet_index = usize_to_u8( + usize::from(self.next_facet_index), + usize::from(self.facet_count), + ); + self.next_facet_index += 1; + Some(facet_index.and_then(|idx| FacetView::try_new(self.tds, self.simplex_key, idx))) + } - for facet_index in 0..vertex_count { - let idx = facet_index; // usize - let facet_view = FacetView::try_new(tds, simplex_key, usize_to_u8(idx, vertex_count)?)?; - facet_views.push(facet_view); + fn size_hint(&self) -> (usize, Option) { + let remaining = self.len(); + (remaining, Some(remaining)) } +} - Ok(facet_views) +impl ExactSizeIterator for SimplexFacetsIter<'_, U, V, D> { + fn len(&self) -> usize { + usize::from(self.facet_count.saturating_sub(self.next_facet_index)) + } } +impl FusedIterator for SimplexFacetsIter<'_, U, V, D> {} + /// Iterator over all facets in a triangulation data structure. /// /// This iterator provides efficient access to all facets without allocating @@ -1108,6 +1620,8 @@ pub fn all_facets_for_simplex( /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] +/// # Query(#[from] delaunay::query::QueryError), +/// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -1119,7 +1633,7 @@ pub fn all_facets_for_simplex( /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let count = dt.tds().facets()? +/// let count = dt.tds().facets() /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(count, 4); /// # Ok(()) @@ -1135,6 +1649,7 @@ pub struct AllFacetsIter<'tds, U, V, const D: usize> { /// Encodes whether facet iteration is between simplices, inside one, or done. #[derive(Clone)] enum AllFacetsIterState { + PendingError(FacetError), PendingSimplex, InSimplex { simplex_key: SimplexKey, @@ -1145,6 +1660,28 @@ enum AllFacetsIterState { } impl<'tds, U, V, const D: usize> AllFacetsIter<'tds, U, V, D> { + /// Creates a new iterator over all facets in the TDS. + /// + /// The iterator itself is infallible to construct. Structural or dimension + /// errors are reported as iterator items. + #[must_use] + pub(crate) fn from_tds(tds: &'tds Tds) -> Self { + let state = if D > usize::from(u8::MAX) { + AllFacetsIterState::PendingError(FacetError::FacetIndexCapacityExceeded { + dimension: D, + max_dimension: usize::from(u8::MAX), + }) + } else { + AllFacetsIterState::PendingSimplex + }; + + Self { + tds, + simplex_keys: tds.simplex_key_iter(), + state, + } + } + /// Creates a new iterator over all facets in the TDS. /// /// # Errors @@ -1166,6 +1703,8 @@ impl<'tds, U, V, const D: usize> AllFacetsIter<'tds, U, V, D> { /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] + /// # Query(#[from] delaunay::query::QueryError), + /// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -1177,7 +1716,7 @@ impl<'tds, U, V, const D: usize> AllFacetsIter<'tds, U, V, D> { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// - /// let mut iter = dt.tds().facets()?; + /// let mut iter = dt.tds().facets(); /// assert!(iter.next().transpose()?.is_some()); /// # Ok(()) /// # } @@ -1191,27 +1730,23 @@ impl<'tds, U, V, const D: usize> AllFacetsIter<'tds, U, V, D> { }); } - Ok(Self { - tds, - simplex_keys: tds.simplex_key_iter(), - state: AllFacetsIterState::PendingSimplex, - }) + Ok(Self::from_tds(tds)) } } impl Tds { - /// Returns an iterator over all facets in the TDS. + /// Returns an iterator over all facets of one simplex in the TDS. /// - /// This is the TDS-level counterpart to - /// [`BoundaryAnalysis::boundary_facets`](crate::query::BoundaryAnalysis::boundary_facets): - /// it constructs the concrete [`AllFacetsIter`] while preserving construction - /// failures as [`TdsError`]. Individual iterator items return [`FacetError`] - /// if a facet view cannot be constructed from the current TDS state. + /// This is the owner-bound API for per-simplex facet views. It constructs no + /// `Vec`; callers that need an owned collection can collect the iterator and + /// decide how to handle per-item [`FacetError`] values. /// /// # Errors /// - /// Returns [`TdsError`] if the facet iterator cannot represent facet indices - /// for this dimension. + /// Returns [`FacetError::SimplexNotFoundInTriangulation`] if `simplex_key` + /// does not identify a simplex in this TDS, or [`FacetError::InvalidFacetIndex`] + /// if the simplex has more facets than can be represented by the public `u8` + /// facet-index storage. /// /// # Examples /// @@ -1237,17 +1772,70 @@ impl Tds { /// delaunay::vertex![0.0, 0.0, 1.0]?, /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let Some((simplex_key, _)) = dt.simplices().next() else { + /// return Ok(()); + /// }; + /// /// let facet_count = dt /// .tds() - /// .facets()? + /// .try_simplex_facets(simplex_key)? /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// /// assert_eq!(facet_count, 4); /// # Ok(()) /// # } /// ``` - pub fn facets(&self) -> Result, TdsError> { - AllFacetsIter::try_new(self).map_err(TdsError::from) + pub fn try_simplex_facets( + &self, + simplex_key: SimplexKey, + ) -> Result, FacetError> { + SimplexFacetsIter::try_new(self, simplex_key) + } + + /// Returns an iterator over all facets in the TDS. + /// + /// This is the TDS-level counterpart to + /// [`Triangulation::boundary_facets`](crate::Triangulation::boundary_facets). + /// The iterator itself is infallible to construct; individual iterator items + /// return [`FacetError`] if a facet view cannot be constructed from the + /// current TDS state. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Facet(#[from] delaunay::prelude::tds::FacetError), + /// # #[error(transparent)] + /// # Tds(#[from] delaunay::prelude::tds::TdsError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = vec![ + /// delaunay::vertex![0.0, 0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0, 0.0]?, + /// delaunay::vertex![0.0, 0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// let facet_count = dt + /// .tds() + /// .facets() + /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; + /// + /// assert_eq!(facet_count, 4); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn facets(&self) -> AllFacetsIter<'_, U, V, D> { + AllFacetsIter::from_tds(self) } } @@ -1257,6 +1845,11 @@ impl<'tds, U, V, const D: usize> Iterator for AllFacetsIter<'tds, U, V, D> { fn next(&mut self) -> Option { loop { match &mut self.state { + AllFacetsIterState::PendingError(error) => { + let error = error.clone(); + self.state = AllFacetsIterState::Exhausted; + return Some(Err(error)); + } AllFacetsIterState::InSimplex { simplex_key, next_facet_index, @@ -1293,12 +1886,13 @@ impl<'tds, U, V, const D: usize> Iterator for AllFacetsIter<'tds, U, V, D> { } } -/// Iterator over boundary facets in a triangulation. +/// Iterator over topology-approved boundary facets in a triangulation. /// -/// This iterator efficiently identifies and yields only the boundary facets -/// (facets that belong to only one simplex) without pre-computing all facets. -/// Each item is a `Result` so facet-view construction or key-derivation -/// failures propagate to the caller during iteration. +/// This iterator yields facets whose keys were preclassified as true manifold +/// boundary by the topology layer. +/// It owns the topology-approved handles in deterministic storage order while +/// borrowing the TDS that produced them. Each item is a `Result` so facet-view +/// construction failures propagate to the caller during iteration. /// /// # Examples /// @@ -1314,6 +1908,8 @@ impl<'tds, U, V, const D: usize> Iterator for AllFacetsIter<'tds, U, V, D> { /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] +/// # Query(#[from] delaunay::query::QueryError), +/// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -1324,16 +1920,17 @@ impl<'tds, U, V, const D: usize> Iterator for AllFacetsIter<'tds, U, V, D> { /// delaunay::vertex![0.0, 0.0, 1.0]?, /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; -/// let count = dt.tds().boundary_facets()? +/// let count = dt.boundary_facets()? /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(count, 4); /// # Ok(()) /// # } /// ``` +#[must_use] #[derive(Clone)] pub struct BoundaryFacetsIter<'tds, U, V, const D: usize> { - all_facets: AllFacetsIter<'tds, U, V, D>, - facet_to_simplices_map: crate::core::collections::FacetToSimplicesMap, + tds: &'tds Tds, + boundary_facet_handles: IntoIter, } impl<'tds, U, V, const D: usize> BoundaryFacetsIter<'tds, U, V, D> { @@ -1342,7 +1939,10 @@ impl<'tds, U, V, const D: usize> BoundaryFacetsIter<'tds, U, V, D> { /// # Errors /// /// Returns [`FacetError::FacetIndexCapacityExceeded`] if this dimension - /// cannot be represented by the current `u8` facet-index storage. + /// cannot be represented by the current `u8` facet-index storage. Also + /// returns [`FacetError`] if any supplied handle is stale, missing from the + /// parsed index, not the indexed one-sided handle for its facet key, or no + /// longer reborrows as a live [`FacetView`]. /// /// # Examples /// @@ -1358,6 +1958,8 @@ impl<'tds, U, V, const D: usize> BoundaryFacetsIter<'tds, U, V, D> { /// # #[error(transparent)] /// # Tds(#[from] delaunay::prelude::tds::TdsError), /// # #[error(transparent)] + /// # Query(#[from] delaunay::query::QueryError), + /// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } /// # fn main() -> Result<(), ExampleError> { @@ -1368,18 +1970,24 @@ impl<'tds, U, V, const D: usize> BoundaryFacetsIter<'tds, U, V, D> { /// delaunay::vertex![0.0, 0.0, 1.0]?, /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let mut iter = dt.tds().boundary_facets()?; + /// let mut iter = dt.boundary_facets()?; /// assert!(iter.next().transpose()?.is_some()); /// # Ok(()) /// # } /// ``` pub(crate) fn try_new( - tds: &'tds Tds, - facet_to_simplices_map: FacetToSimplicesMap, + facet_to_simplices_index: &FacetToSimplicesIndex<'tds, U, V, D>, + mut boundary_facet_handles: Vec, ) -> Result { + let tds = facet_to_simplices_index.tds(); + AllFacetsIter::try_new(tds)?; + for handle in &mut boundary_facet_handles { + *handle = try_one_sided_handle_from_index(facet_to_simplices_index, *handle)?; + } + sort_handles_by_storage_order(&mut boundary_facet_handles); Ok(Self { - all_facets: AllFacetsIter::try_new(tds)?, - facet_to_simplices_map, + tds, + boundary_facet_handles: boundary_facet_handles.into_iter(), }) } } @@ -1388,41 +1996,153 @@ impl<'tds, U, V, const D: usize> Iterator for BoundaryFacetsIter<'tds, U, V, D> type Item = Result, FacetError>; fn next(&mut self) -> Option { - for facet_result in self.all_facets.by_ref() { - let facet_view = match facet_result { - Ok(facet_view) => facet_view, - Err(err) => return Some(Err(err)), - }; - let facet_key = match facet_view.key() { - Ok(facet_key) => facet_key, - Err(err) => return Some(Err(err)), - }; - let Some(simplex_list) = self.facet_to_simplices_map.get(&facet_key) else { - let vertex_uuids = match facet_view.vertices() { - Ok(vertices) => vertices.map(Vertex::uuid).collect(), - Err(err) => return Some(Err(err)), - }; - return Some(Err(FacetError::FacetKeyNotFoundInCache { - facet_key, - cache_size: self.facet_to_simplices_map.len(), - vertex_uuids, - })); - }; - match simplex_list.len() { - 1 => return Some(Ok(facet_view)), - 2 => {} - found => { - return Some(Err(FacetError::InvalidFacetMultiplicity { - facet_key, - found, - })); - } - } - } - None + self.boundary_facet_handles + .next() + .map(|handle| handle.view(self.tds)) + } + + fn size_hint(&self) -> (usize, Option) { + self.boundary_facet_handles.size_hint() } } +impl ExactSizeIterator for BoundaryFacetsIter<'_, U, V, D> {} + +impl FusedIterator for BoundaryFacetsIter<'_, U, V, D> {} + +/// Iterator over one-sided facet incidences in a TDS. +/// +/// This is a TDS-level incidence traversal, not a topology-aware boundary +/// query. Closed periodic self-identifications can be one-sided in the quotient +/// incidence index without being manifold boundary. The iterator owns a sorted +/// handle list derived from the parsed incidence index while borrowing the TDS +/// that produced it. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Facet(#[from] delaunay::prelude::tds::FacetError), +/// # #[error(transparent)] +/// # Tds(#[from] delaunay::prelude::tds::TdsError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # } +/// # fn main() -> Result<(), ExampleError> { +/// let vertices = vec![ +/// delaunay::vertex![0.0, 0.0, 0.0]?, +/// delaunay::vertex![1.0, 0.0, 0.0]?, +/// delaunay::vertex![0.0, 1.0, 0.0]?, +/// delaunay::vertex![0.0, 0.0, 1.0]?, +/// ]; +/// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; +/// +/// let one_sided_count = dt +/// .tds() +/// .one_sided_facets()? +/// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; +/// assert_eq!(one_sided_count, 4); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone)] +pub struct OneSidedFacetsIter<'tds, U, V, const D: usize> { + tds: &'tds Tds, + one_sided_facet_handles: IntoIter, +} + +impl<'tds, U, V, const D: usize> OneSidedFacetsIter<'tds, U, V, D> { + /// Creates a new iterator over one-sided facet incidences. + /// + /// # Errors + /// + /// Returns [`FacetError::FacetIndexCapacityExceeded`] if this dimension + /// cannot be represented by the current `u8` facet-index storage. + pub(crate) fn try_new( + facet_to_simplices_index: &FacetToSimplicesIndex<'tds, U, V, D>, + ) -> Result { + let tds = facet_to_simplices_index.tds(); + AllFacetsIter::try_new(tds)?; + let mut one_sided_facet_handles = facet_to_simplices_index + .one_sided_handles() + .collect::>(); + sort_handles_by_storage_order(&mut one_sided_facet_handles); + Ok(Self { + tds, + one_sided_facet_handles: one_sided_facet_handles.into_iter(), + }) + } +} + +impl<'tds, U, V, const D: usize> Iterator for OneSidedFacetsIter<'tds, U, V, D> { + type Item = Result, FacetError>; + + fn next(&mut self) -> Option { + self.one_sided_facet_handles + .next() + .map(|handle| handle.view(self.tds)) + } + + fn size_hint(&self) -> (usize, Option) { + self.one_sided_facet_handles.size_hint() + } +} + +impl ExactSizeIterator for OneSidedFacetsIter<'_, U, V, D> {} + +impl FusedIterator for OneSidedFacetsIter<'_, U, V, D> {} + +/// Parses a supplied handle as the indexed one-sided handle for its facet key. +/// +/// # Errors +/// +/// Returns the same errors as [`FacetHandle::view`] when `handle` cannot be +/// reborrowed from the index's TDS. Returns +/// [`FacetError::FacetKeyNotFoundInCache`] when the handle's facet key is absent +/// from the parsed index, [`FacetError::BoundaryFacetHandleNotIndexed`] when a +/// different one-sided handle is indexed for that key, or +/// [`FacetError::InvalidAdjacentSimplexCount`] when the key is not one-sided. +fn try_one_sided_handle_from_index( + facet_to_simplices_index: &FacetToSimplicesIndex<'_, U, V, D>, + handle: FacetHandle, +) -> Result { + let facet = handle.view(facet_to_simplices_index.tds())?; + let facet_key = facet.key(); + let Some(incidence) = facet_to_simplices_index.get(&facet_key) else { + let vertex_uuids = facet.vertices().map(Vertex::uuid).collect(); + return Err(FacetError::FacetKeyNotFoundInCache { + facet_key, + cache_size: facet_to_simplices_index.len(), + vertex_uuids, + }); + }; + match incidence.one_sided_handle() { + Some(indexed_handle) if indexed_handle == handle => Ok(handle), + Some(indexed_handle) => Err(FacetError::BoundaryFacetHandleNotIndexed { + facet_key, + supplied_handle: handle, + indexed_handle, + }), + None => Err(FacetError::InvalidAdjacentSimplexCount { + found: incidence.incident_simplex_count(), + }), + } +} + +/// Sorts handles by storage key and local facet index for deterministic iteration. +fn sort_handles_by_storage_order(handles: &mut [FacetHandle]) { + handles.sort_unstable_by_key(|handle| { + (handle.simplex_key().data().as_ffi(), handle.facet_index()) + }); +} + // ============================================================================= // FACET KEY GENERATION FUNCTIONS // ============================================================================= @@ -1520,7 +2240,7 @@ mod tests { use crate::core::vertex::Vertex; use crate::geometry::kernel::AdaptiveKernel; use crate::triangulation::DelaunayTriangulation; - use slotmap::SlotMap; + use slotmap::{KeyData, SlotMap}; use std::assert_matches; use std::{collections::HashSet, mem}; @@ -1651,7 +2371,7 @@ mod tests { // Assert that the result is Ok assert!(result_2d.is_ok()); let facet_2d = result_2d.unwrap(); - assert_eq!(facet_2d.vertices().unwrap().count(), 2); // 2D facet should have 2 vertices + assert_eq!(facet_2d.vertices().count(), 2); // 2D facet should have 2 vertices // Test 1D case: Create an edge (1D simplex with 2 vertices) let vertices_1d = vec![ @@ -1665,7 +2385,7 @@ mod tests { // Assert that the result is Ok assert!(result_1d.is_ok()); let facet_1d = result_1d.unwrap(); - assert_eq!(facet_1d.vertices().unwrap().count(), 1); // 1D facet should have 1 vertex + assert_eq!(facet_1d.vertices().count(), 1); // 1D facet should have 1 vertex } #[test] @@ -1698,7 +2418,7 @@ mod tests { // Create facet view for facet 0 (excludes vertex 0) let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - assert_eq!(facet.vertices().unwrap().count(), 3); + assert_eq!(facet.vertices().count(), 3); } // ============================================================================= @@ -1739,7 +2459,7 @@ mod tests { let simplex_key = dt.simplices().next().unwrap().0; let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - let cloned_facet = facet; + let cloned_facet = facet.clone(); // Verify clones are equal assert_eq!(facet, cloned_facet); @@ -1747,12 +2467,12 @@ mod tests { assert_eq!(facet.facet_index(), cloned_facet.facet_index()); // Verify simplex and opposite vertex are accessible through both views - let simplex1 = facet.simplex().unwrap(); - let simplex2 = cloned_facet.simplex().unwrap(); + let simplex1 = facet.simplex(); + let simplex2 = cloned_facet.simplex(); assert_eq!(simplex1.uuid(), simplex2.uuid()); - let vertex1 = facet.opposite_vertex().unwrap(); - let vertex2 = cloned_facet.opposite_vertex().unwrap(); + let vertex1 = facet.opposite_vertex(); + let vertex2 = cloned_facet.opposite_vertex(); assert_eq!(vertex1.uuid(), vertex2.uuid()); } @@ -1806,7 +2526,7 @@ mod tests { // Create facet view for facet 0 (excludes vertex 0) let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); + let facet_vertices: Vec<_> = facet.vertices().collect(); assert_eq!(facet_vertices.len(), 3); // 3D facet should have 3 vertices (D) let simplex = dt.tds().simplex(simplex_key).expect("simplex exists"); for &vertex_key in simplex.vertices().iter().skip(1) { @@ -1851,7 +2571,7 @@ mod tests { let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); // Facet of D-dimensional simplex is (D-1)-dimensional with D vertices - assert_eq!(facet.vertices().unwrap().count(), $expected_facet_vertices, + assert_eq!(facet.vertices().count(), $expected_facet_vertices, "Facet of {}D {} should have {} vertices", $dim, $desc, $expected_facet_vertices); } @@ -1867,12 +2587,12 @@ mod tests { let facet1 = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); let facet2 = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - assert_eq!(facet1.key().unwrap(), facet2.key().unwrap(), + assert_eq!(facet1.key(), facet2.key(), "Same facet should produce same key"); // Create different facet let facet3 = FacetView::try_new(dt.tds(), simplex_key, 1).unwrap(); - assert_ne!(facet1.key().unwrap(), facet3.key().unwrap(), + assert_ne!(facet1.key(), facet3.key(), "Different facets should produce different keys"); } @@ -1904,7 +2624,7 @@ mod tests { for i in 0..expected_facets { let facet = FacetView::try_new(dt.tds(), simplex_key, u8::try_from(i).unwrap()).unwrap(); - facet_keys.insert(facet.key().unwrap()); + facet_keys.insert(facet.key()); } assert_eq!(facet_keys.len(), expected_facets, @@ -1963,7 +2683,7 @@ mod tests { let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); // Facet of 1D edge is a point (0D) with 1 vertex - assert_eq!(facet.vertices().unwrap().count(), 1); + assert_eq!(facet.vertices().count(), 1); } #[test] @@ -1982,6 +2702,45 @@ mod tests { ); } + #[test] + fn tds_facets_reports_dimension_capacity_as_iterator_item() { + let tds: Tds<(), (), 256> = Tds::empty(); + let mut facets = tds.facets(); + + assert_matches!( + facets.next(), + Some(Err(FacetError::FacetIndexCapacityExceeded { + dimension: 256, + max_dimension: 255, + })) + ); + assert!(facets.next().is_none()); + } + + #[test] + fn try_simplex_facets_supports_d255_full_u8_index_range() { + let mut tds: Tds<(), (), 255> = Tds::empty(); + let mut vertex_keys = Vec::with_capacity(usize::from(u8::MAX) + 1); + for i in 0..=usize::from(u8::MAX) { + let mut coords = [0.0; 255]; + coords[0] = f64::from(u32::try_from(i).unwrap()); + let vertex = Vertex::<(), 255>::try_new(coords).unwrap(); + vertex_keys.push(tds.insert_vertex_with_mapping(vertex).unwrap()); + } + let simplex_key = tds + .insert_simplex_with_mapping(Simplex::try_new_with_data(vertex_keys, None).unwrap()) + .unwrap(); + + let mut facets = tds.try_simplex_facets(simplex_key).unwrap(); + + assert_eq!(facets.len(), usize::from(u8::MAX) + 1); + for expected_index in 0..=u8::MAX { + let facet = facets.next().unwrap().unwrap(); + assert_eq!(facet.facet_index(), expected_index); + } + assert!(facets.next().is_none()); + } + /// Builds a deliberately corrupted 2D TDS whose lone simplex has more /// vertices than can be represented by the `u8` facet-index storage. fn overwide_simplex_tds() -> Tds<(), (), 2> { @@ -2017,13 +2776,13 @@ mod tests { } fn first_facet_view(tds: &Tds<(), (), 3>) -> FacetView<'_, (), (), 3> { - tds.facets().unwrap().next().unwrap().unwrap() + tds.facets().next().unwrap().unwrap() } #[test] fn all_facets_iter_yields_overflow_error() { let tds = overwide_simplex_tds(); - let mut iter = tds.facets().unwrap(); + let mut iter = tds.facets(); for facet in iter.by_ref().take(usize::from(u8::MAX) + 1) { assert!( @@ -2044,7 +2803,7 @@ mod tests { #[test] fn all_facets_iter_stays_exhausted_after_completion() { let tds = tetrahedron_tds(); - let mut iter = tds.facets().unwrap(); + let mut iter = tds.facets(); while iter.next().transpose().unwrap().is_some() {} @@ -2052,40 +2811,129 @@ mod tests { } #[test] - fn boundary_facets_iter_propagates_inner_errors() { - let tds = overwide_simplex_tds(); + fn boundary_facets_iter_yields_supplied_handles_in_storage_order() { + let tds = tetrahedron_tds(); let mut facet_to_simplices = FacetToSimplicesMap::default(); - for facet in tds.facets().unwrap().take(usize::from(u8::MAX) + 1) { + for facet in tds.facets() { let facet = facet.unwrap(); let mut incidents = SmallBuffer::new(); let handle = FacetHandle::from_validated(facet.simplex_key(), facet.facet_index()); incidents.push(handle); + facet_to_simplices.insert(facet.key(), incidents); + } + let facet_to_simplices_index = + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices).unwrap(); + let mut boundary_facet_handles = facet_to_simplices_index + .one_sided_handles() + .collect::>(); + boundary_facet_handles.reverse(); + let mut iter = + BoundaryFacetsIter::try_new(&facet_to_simplices_index, boundary_facet_handles).unwrap(); + + assert_eq!(iter.len(), 4); + for expected_index in 0..4 { + let facet = iter.next().transpose().unwrap().unwrap(); + assert_eq!(usize::from(facet.facet_index()), expected_index); + } + assert!(iter.next().is_none()); + } + + #[test] + fn one_sided_facets_iter_reports_len_and_storage_order() { + let tds = tetrahedron_tds(); + let mut facet_to_simplices = FacetToSimplicesMap::default(); + for facet in tds.facets() { + let facet = facet.unwrap(); + let mut incidents = SmallBuffer::new(); + let handle = FacetHandle::from_validated(facet.simplex_key(), facet.facet_index()); incidents.push(handle); - facet_to_simplices.insert(facet.key().unwrap(), incidents); + facet_to_simplices.insert(facet.key(), incidents); + } + let facet_to_simplices_index = + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices).unwrap(); + let mut iter = OneSidedFacetsIter::try_new(&facet_to_simplices_index).unwrap(); + + assert_eq!(iter.len(), 4); + for expected_index in 0..4 { + let facet = iter.next().transpose().unwrap().unwrap(); + assert_eq!(usize::from(facet.facet_index()), expected_index); } - let mut iter = BoundaryFacetsIter::try_new(&tds, facet_to_simplices).unwrap(); + assert!(iter.next().is_none()); + } - assert_matches!( - iter.next(), - Some(Err(FacetError::InvalidFacetIndexOverflow { - original_index: 256, - facet_count: 257, - })) - ); + #[test] + fn boundary_facets_iter_revalidates_supplied_handles() { + let tds = tetrahedron_tds(); + let facet_to_simplices_index = + FacetToSimplicesIndex::try_from_map(&tds, &FacetToSimplicesMap::default()).unwrap(); + let stale_handle = + FacetHandle::from_validated(SimplexKey::from(KeyData::from_ffi(0xDEAD)), 0); + let Err(error) = BoundaryFacetsIter::try_new(&facet_to_simplices_index, vec![stale_handle]) + else { + panic!("expected stale boundary handle to be rejected"); + }; + + assert_matches!(error, FacetError::SimplexNotFoundInTriangulation); } #[test] - fn boundary_facets_iter_errors_when_facet_map_is_missing_key() { + fn boundary_facets_iter_rejects_handle_missing_from_index() { let tds = tetrahedron_tds(); - let mut iter = BoundaryFacetsIter::try_new(&tds, FacetToSimplicesMap::default()).unwrap(); + let first_facet = first_facet_view(&tds); + let handle = first_facet.handle(); + let facet_to_simplices_index = + FacetToSimplicesIndex::try_from_map(&tds, &FacetToSimplicesMap::default()).unwrap(); + let Err(error) = BoundaryFacetsIter::try_new(&facet_to_simplices_index, vec![handle]) + else { + panic!("expected missing boundary handle to be rejected"); + }; assert_matches!( - iter.next(), - Some(Err(FacetError::FacetKeyNotFoundInCache { + error, + FacetError::FacetKeyNotFoundInCache { cache_size: 0, vertex_uuids, .. - })) if vertex_uuids.len() == 3 + } if vertex_uuids.len() == 3 + ); + } + + #[test] + fn boundary_facets_iter_rejects_same_key_handle_not_indexed() { + let mut tds = tetrahedron_tds(); + let simplex_key = tds.simplex_keys().next().unwrap(); + let duplicated_vertex = tds.simplex(simplex_key).unwrap().vertices()[0]; + { + let simplex = tds.simplex_mut(simplex_key).unwrap(); + simplex.push_vertex_key(duplicated_vertex); + } + + let indexed_handle = FacetHandle::from_validated(simplex_key, 0); + let supplied_handle = FacetHandle::from_validated(simplex_key, 4); + let facet_key = indexed_handle.view(&tds).unwrap().key(); + assert_eq!(supplied_handle.view(&tds).unwrap().key(), facet_key); + + let mut incidents = SmallBuffer::new(); + incidents.push(indexed_handle); + let mut facet_to_simplices = FacetToSimplicesMap::default(); + facet_to_simplices.insert(facet_key, incidents); + let facet_to_simplices_index = + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices).unwrap(); + let Err(error) = + BoundaryFacetsIter::try_new(&facet_to_simplices_index, vec![supplied_handle]) + else { + panic!("expected non-indexed boundary handle to be rejected"); + }; + + assert_matches!( + error, + FacetError::BoundaryFacetHandleNotIndexed { + facet_key: observed_facet_key, + supplied_handle: observed_supplied_handle, + indexed_handle: observed_indexed_handle, + } if observed_facet_key == facet_key + && observed_supplied_handle == supplied_handle + && observed_indexed_handle == indexed_handle ); } @@ -2094,12 +2942,10 @@ mod tests { let tds = tetrahedron_tds(); let first_facet = first_facet_view(&tds); let mut facet_to_simplices = FacetToSimplicesMap::default(); - facet_to_simplices.insert(first_facet.key().unwrap(), SmallBuffer::new()); - let mut iter = BoundaryFacetsIter::try_new(&tds, facet_to_simplices).unwrap(); - + facet_to_simplices.insert(first_facet.key(), SmallBuffer::new()); assert_matches!( - iter.next(), - Some(Err(FacetError::InvalidFacetMultiplicity { found: 0, .. })) + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices), + Err(FacetError::InvalidFacetMultiplicity { found: 0, .. }) ); } @@ -2114,12 +2960,55 @@ mod tests { incidents.push(handle); incidents.push(handle); let mut facet_to_simplices = FacetToSimplicesMap::default(); - facet_to_simplices.insert(first_facet.key().unwrap(), incidents); - let mut iter = BoundaryFacetsIter::try_new(&tds, facet_to_simplices).unwrap(); + facet_to_simplices.insert(first_facet.key(), incidents); + assert_matches!( + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices), + Err(FacetError::InvalidFacetMultiplicity { found: 3, .. }) + ); + } + + #[test] + fn facet_index_rejects_duplicate_two_sided_incident_handle() { + let tds = tetrahedron_tds(); + let first_facet = first_facet_view(&tds); + let handle = + FacetHandle::from_validated(first_facet.simplex_key(), first_facet.facet_index()); + let mut incidents = SmallBuffer::new(); + incidents.push(handle); + incidents.push(handle); + let mut facet_to_simplices = FacetToSimplicesMap::default(); + facet_to_simplices.insert(first_facet.key(), incidents); assert_matches!( - iter.next(), - Some(Err(FacetError::InvalidFacetMultiplicity { found: 3, .. })) + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices), + Err(FacetError::DuplicateFacetIncidentHandle { + facet_key, + handle: repeated + }) if facet_key == first_facet.key() && repeated == handle + ); + } + + #[test] + fn facet_index_rejects_handle_with_mismatched_facet_key() { + let tds = tetrahedron_tds(); + let mut facets = tds.facets(); + let first = facets.next().unwrap().unwrap(); + let second = facets.next().unwrap().unwrap(); + let wrong_handle = second.handle(); + let mut incidents = SmallBuffer::new(); + incidents.push(wrong_handle); + let mut facet_to_simplices = FacetToSimplicesMap::default(); + facet_to_simplices.insert(first.key(), incidents); + + assert_matches!( + FacetToSimplicesIndex::try_from_map(&tds, &facet_to_simplices), + Err(FacetError::FacetHandleKeyMismatch { + expected_facet_key, + actual_facet_key, + handle, + }) if expected_facet_key == first.key() + && actual_facet_key == second.key() + && handle == wrong_handle ); } @@ -2165,15 +3054,15 @@ mod tests { // Both facet1 and facet2 reference the same facet, so same key assert_eq!( - facet1.key().unwrap(), - facet2.key().unwrap(), + facet1.key(), + facet2.key(), "Keys should be consistent for the same facet" ); // facet3 is a different facet, so different key assert_ne!( - facet1.key().unwrap(), - facet3.key().unwrap(), + facet1.key(), + facet3.key(), "Keys should be different for facets with different vertices" ); } @@ -2190,11 +3079,11 @@ mod tests { // Create facet with vertex 0 as opposite - should have only vertex 1 in facet let facet = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - assert_eq!(facet.vertices().unwrap().count(), 1); + assert_eq!(facet.vertices().count(), 1); // Test the opposite case - vertex 1 as opposite should have only vertex 0 in facet let other_facet = FacetView::try_new(dt.tds(), simplex_key, 1).unwrap(); - assert_eq!(other_facet.vertices().unwrap().count(), 1); + assert_eq!(other_facet.vertices().count(), 1); } #[test] @@ -2213,7 +3102,7 @@ mod tests { let facet = FacetView::try_new(dt.tds(), simplex_key, 2).unwrap(); // Should have all vertices except vertex at index 2 - assert_eq!(facet.vertices().unwrap().count(), 3); + assert_eq!(facet.vertices().count(), 3); // Verify we have exactly 3 vertices (the D vertices of the D-1 dimensional facet) } @@ -2264,10 +3153,10 @@ mod tests { let facet3 = FacetView::try_new(dt.tds(), simplex_key, 1).unwrap(); // Test that facet keys are consistent for the same facet - assert_eq!(facet1.key().unwrap(), facet2.key().unwrap()); + assert_eq!(facet1.key(), facet2.key()); // Test that different facets have different keys - assert_ne!(facet1.key().unwrap(), facet3.key().unwrap()); + assert_ne!(facet1.key(), facet3.key()); } // ============================================================================= @@ -2354,7 +3243,7 @@ mod tests { let facet_view = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); // Facet opposite to vertex 0 should have 3 vertices (D vertices in D-1 facet) - let facet_vertices: Vec<_> = facet_view.vertices().unwrap().collect(); + let facet_vertices: Vec<_> = facet_view.vertices().collect(); assert_eq!(facet_vertices.len(), 3); let simplex = dt.tds().simplex(simplex_key).expect("simplex exists"); @@ -2384,7 +3273,7 @@ mod tests { let simplex_key = dt.simplices().next().unwrap().0; let facet_view = FacetView::try_new(dt.tds(), simplex_key, 1).unwrap(); - let opposite = facet_view.opposite_vertex().unwrap(); + let opposite = facet_view.opposite_vertex(); // The opposite vertex should be the vertex at index 1 let simplex = dt.tds().simplex(simplex_key).expect("simplex exists"); @@ -2409,14 +3298,14 @@ mod tests { let simplex_key = dt.simplices().next().unwrap().0; let facet_view = FacetView::try_new(dt.tds(), simplex_key, 0).unwrap(); - let key = facet_view.key().unwrap(); + let key = facet_view.key(); // Key should be non-zero for valid facet assert_ne!(key, 0); } #[test] - fn test_all_facets_for_simplex() { + fn test_try_simplex_facets() { let vertices = vec![ crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), @@ -2427,16 +3316,18 @@ mod tests { let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); let simplex_key = dt.simplices().next().unwrap().0; - let facet_views = all_facets_for_simplex(dt.tds(), simplex_key).unwrap(); + let facet_views = dt.tds().try_simplex_facets(simplex_key).unwrap(); + let facet_count = facet_views.len(); // 3D simplex (tetrahedron) should have 4 facets - assert_eq!(facet_views.len(), 4); + assert_eq!(facet_count, 4); // Each facet should have a different index - for (i, facet_view) in facet_views.iter().enumerate() { + for (i, facet_view) in facet_views.enumerate() { + let facet_view = facet_view.unwrap(); assert_eq!( facet_view.facet_index(), - usize_to_u8(i, facet_views.len()).unwrap() + usize_to_u8(i, facet_count).unwrap() ); assert_eq!(facet_view.simplex_key(), simplex_key); } @@ -2489,9 +3380,15 @@ mod tests { #[test] fn test_facet_view_memory_efficiency() { let lightweight_size = mem::size_of::>(); + let payload_independent_size = mem::size_of::>(); - // FacetView should be around 17 bytes (8 byte ref + 8 byte SimplexKey + 1 byte facet_index) - // Allow for some padding/alignment - assert!(lightweight_size <= 24); + assert_eq!( + lightweight_size, payload_independent_size, + "FacetView must borrow vertex/simplex payloads rather than owning them" + ); + assert!( + lightweight_size <= 256, + "FacetView should stay a compact borrowed view, got {lightweight_size} bytes" + ); } } diff --git a/src/core/boundary.rs b/src/core/facet_incidence.rs similarity index 54% rename from src/core/boundary.rs rename to src/core/facet_incidence.rs index 5152a521..ee69a222 100644 --- a/src/core/boundary.rs +++ b/src/core/facet_incidence.rs @@ -1,24 +1,27 @@ -//! Boundary and convex hull analysis functions +//! TDS-level facet-incidence analysis functions. //! -//! This module implements the `BoundaryAnalysis` trait for triangulation data structures, -//! providing methods to identify and analyze boundary facets in d-dimensional triangulations. +//! This module implements the `FacetIncidenceAnalysis` trait for triangulation +//! data structures, providing methods to identify and analyze one-sided facet +//! incidence in d-dimensional triangulations. #![forbid(unsafe_code)] use super::{ - collections::FacetToSimplicesMap, - facet::{BoundaryFacetsIter, FacetError, FacetView}, + facet::{FacetError, FacetToSimplicesIndex, FacetView, OneSidedFacetsIter}, tds::{Tds, TdsError}, - traits::boundary_analysis::BoundaryAnalysis, + traits::facet_incidence_analysis::FacetIncidenceAnalysis, }; +use std::ptr; -/// Counts facets with multiplicity one and rejects non-manifold multiplicities. +/// Counts one-sided raw facet incidences and rejects non-manifold multiplicities. /// -/// Boundary analysis treats multiplicity one as boundary and multiplicity two as -/// interior. Any other multiplicity is a topology error that callers need to see -/// rather than an interior facet to ignore. -fn number_of_boundary_facets_in_map( - facet_to_simplices: &FacetToSimplicesMap, +/// This test helper exercises raw multiplicity parsing only. Production +/// topology-aware boundary classification uses [`FacetToSimplicesIndex`] so +/// admissible periodic self-identifications remain closed topology instead of +/// being counted as boundary. +#[cfg(test)] +fn number_of_one_sided_facets_in_map( + facet_to_simplices: &super::collections::FacetToSimplicesMap, ) -> Result { let mut count = 0usize; for (&facet_key, simplices) in facet_to_simplices { @@ -33,22 +36,23 @@ fn number_of_boundary_facets_in_map( Ok(count) } -/// Implementation of `BoundaryAnalysis` trait for `Tds`. +/// Implementation of `FacetIncidenceAnalysis` trait for `Tds`. /// -/// This implementation provides efficient boundary facet identification and analysis +/// This implementation provides efficient one-sided facet incidence analysis /// for d-dimensional triangulations using the triangulation data structure. -impl BoundaryAnalysis for Tds { - /// Identifies all boundary facets in the triangulation. +impl FacetIncidenceAnalysis for Tds { + /// Identifies all one-sided facet incidences in the TDS. /// - /// A boundary facet is a facet that belongs to only one simplex, meaning it lies on the - /// boundary of the triangulation (convex hull). These facets are important for - /// convex hull computation and boundary analysis. + /// This is incidence analysis only: a one-sided facet can be a Euclidean + /// boundary facet, but topology-aware callers must still decide whether it + /// is true boundary or a closed periodic self-identification. /// /// # Triangulation Invariant /// /// This method relies on the fundamental invariant of Delaunay triangulations: - /// **every facet is shared by exactly two simplices, except boundary facets which belong to exactly one simplex.** - /// Any facet shared by 0, 3, or more simplices indicates a topological error in the triangulation. + /// **every facet is one-sided or two-sided.** Any facet shared by 0, 3, or + /// more simplices indicates a structural/topological error in the + /// triangulation. /// /// For a comprehensive discussion of all topological invariants in Delaunay triangulations, /// see the [Topological Invariants](crate::tds::Tds#topological-invariants) @@ -56,19 +60,21 @@ impl BoundaryAnalysis for Tds { /// /// # Returns /// - /// A `Result, TdsError>` containing an iterator over boundary facets. - /// The iterator yields `Result` items lazily without pre-allocating vectors, - /// providing better performance while still surfacing corrupted facet views during iteration. + /// A `Result, TdsError>` containing an + /// iterator over one-sided facet incidences. The iterator owns a sorted + /// handle list derived from the facet-incidence index and yields + /// `Result` items while still surfacing corrupted + /// facet views during iteration. /// /// # Errors /// /// Returns a [`TdsError`] (typically /// [`crate::prelude::tds::FacetError`]) if: - /// - The boundary-facet iterator cannot be constructed + /// - The one-sided-facet iterator cannot be constructed. /// - A facet index is out of bounds (indicates data corruption) /// - A referenced simplex is not found in the triangulation (indicates data corruption) /// - /// Individual iterator items return [`FacetError`] if a boundary facet cannot + /// Individual iterator items return [`FacetError`] if a facet view cannot /// be created or keyed from the simplices. /// /// # Examples @@ -105,33 +111,37 @@ impl BoundaryAnalysis for Tds { /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(high_level_count, 4); /// - /// // TDS-level API (fallible): returns `TdsError` on corruption. + /// // TDS-level API reports raw one-sided incidence. /// let count = dt /// .tds() - /// .boundary_facets()? + /// .one_sided_facets()? /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(count, 4); /// # Ok(()) /// # } /// ``` - fn boundary_facets(&self) -> Result, TdsError> { - // Build a map from facet keys to the simplices that contain them - let facet_to_simplices = self.build_facet_to_simplices_map()?; + fn one_sided_facets(&self) -> Result, TdsError> { + // Build an owner-bound index from facet keys to the simplices that contain them. + let facet_to_simplices = self.build_facet_to_simplices_index()?; - // Create the boundary facets iterator - BoundaryFacetsIter::try_new(self, facet_to_simplices).map_err(TdsError::from) + // Create the one-sided facets iterator. + OneSidedFacetsIter::try_new(&facet_to_simplices).map_err(TdsError::from) } - /// Checks if a specific facet is a boundary facet. + /// Checks if a specific facet has one-sided incidence. /// - /// A boundary facet is a facet that belongs to only one simplex in the triangulation. + /// This does not classify manifold boundary. Use + /// [`Triangulation::boundary_facets`](crate::Triangulation::boundary_facets) + /// or + /// [`DelaunayTriangulation::boundary_facets`](crate::DelaunayTriangulation::boundary_facets) + /// when the declared global topology matters. /// /// # Performance Note /// - /// This method rebuilds the facet-to-simplices map on every call, which has O(N·F) complexity. + /// This method rebuilds the facet-to-simplices index on every call, which has O(N·F) complexity. /// For checking multiple facets in hot paths, prefer using - /// [`BoundaryAnalysis::is_boundary_facet_with_map`] with a precomputed map to avoid - /// recomputation. + /// [`FacetIncidenceAnalysis::is_one_sided_facet_with_index`] with a + /// precomputed index to avoid recomputation. /// /// # Arguments /// @@ -139,13 +149,13 @@ impl BoundaryAnalysis for Tds { /// /// # Returns /// - /// `Ok(true)` if the facet is on the boundary (belongs to only one simplex), - /// `Ok(false)` if it is interior or absent from the facet map. + /// `Ok(true)` if the facet has one-sided incidence, `Ok(false)` if it is + /// two-sided or absent from the facet index. /// /// # Errors /// - /// Returns a [`TdsError`] if the facet map cannot be built, the facet cannot - /// be keyed, or the map contains an invalid multiplicity other than 1 or 2. + /// Returns a [`TdsError`] if the facet index cannot be built or the facet + /// view belongs to a different TDS. /// /// # Examples /// @@ -174,40 +184,43 @@ impl BoundaryAnalysis for Tds { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// - /// // Get boundary facets using the new iterator API - /// let Some(first_facet) = dt.boundary_facets()?.next().transpose()? else { + /// // Get one-sided facets using the TDS incidence API. + /// let Some(first_facet) = dt.tds().one_sided_facets()?.next().transpose()? else { /// return Ok(()); /// }; - /// // In a single tetrahedron, all facets are boundary facets - /// assert!(dt.tds().is_boundary_facet(&first_facet)?); + /// assert!(dt.tds().is_one_sided_facet(&first_facet)?); /// # Ok(()) /// # } /// ``` #[inline] - fn is_boundary_facet(&self, facet: &FacetView<'_, U, V, D>) -> Result { - let facet_to_simplices = self.build_facet_to_simplices_map()?; - self.is_boundary_facet_with_map(facet, &facet_to_simplices) + fn is_one_sided_facet(&self, facet: &FacetView<'_, U, V, D>) -> Result { + ensure_facet_view_owner(self, facet)?; + let facet_to_simplices = self.build_facet_to_simplices_index()?; + self.is_one_sided_facet_with_index(facet, &facet_to_simplices) } - /// Checks if a specific facet is a boundary facet using a precomputed facet map. + /// Checks if a specific facet has one-sided incidence using a precomputed facet index. /// - /// This is an optimized version of [`BoundaryAnalysis::is_boundary_facet`] that - /// accepts a prebuilt facet-to-simplices map to avoid recomputation in tight loops. + /// This is an optimized version of + /// [`FacetIncidenceAnalysis::is_one_sided_facet`] that accepts a prebuilt + /// owner-bound facet-to-simplices index to avoid recomputation in tight + /// loops. /// /// # Arguments /// /// * `facet` - The facet to check. - /// * `facet_to_simplices` - Precomputed map from facet keys to simplices containing them. + /// * `facet_to_simplices` - Precomputed index from facet keys to simplices containing them. /// /// # Returns /// - /// `Ok(true)` if the facet is on the boundary (belongs to only one simplex), - /// `Ok(false)` if it is interior or absent from the facet map. + /// `Ok(true)` if the facet has one-sided incidence, `Ok(false)` if it is + /// two-sided or absent from the facet index. /// /// # Errors /// - /// Returns a [`TdsError`] if the facet cannot be keyed or the supplied map - /// contains an invalid multiplicity other than 1 or 2 for the facet. + /// Returns a [`TdsError`] if the supplied index or facet view belongs to a + /// different TDS. A facet view borrowed from a different TDS is rejected + /// before its key is compared with the supplied index. /// /// # Examples /// @@ -236,54 +249,44 @@ impl BoundaryAnalysis for Tds { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// - /// // Build the facet map once for multiple queries - /// let facet_to_simplices = dt.tds().build_facet_to_simplices_map()?; + /// // Build the facet index once for multiple queries + /// let facet_to_simplices = dt.tds().build_facet_to_simplices_index()?; /// - /// // Check boundary facets efficiently using the iterator API - /// for facet in dt.boundary_facets()? { + /// // Check one-sided incidence efficiently using the iterator API. + /// for facet in dt.tds().one_sided_facets()? { /// let facet = facet?; - /// let is_boundary = dt.tds().is_boundary_facet_with_map(&facet, &facet_to_simplices)?; - /// println!("Facet is boundary: {is_boundary}"); + /// let is_one_sided = dt.tds().is_one_sided_facet_with_index(&facet, &facet_to_simplices)?; + /// println!("Facet is one-sided: {is_one_sided}"); /// } /// # Ok(()) /// # } /// ``` #[inline] - fn is_boundary_facet_with_map( + fn is_one_sided_facet_with_index( &self, facet: &FacetView<'_, U, V, D>, - facet_to_simplices: &FacetToSimplicesMap, + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, ) -> Result { - // Use FacetView's key() method which is more efficient - let facet_key = facet.key().map_err(TdsError::FacetError)?; - - match facet_to_simplices.get(&facet_key) { - Some(simplices) if simplices.len() == 1 => Ok(true), - Some(simplices) if simplices.len() == 2 => Ok(false), - Some(simplices) => Err(FacetError::InvalidFacetMultiplicity { - facet_key, - found: simplices.len(), - } - .into()), - None => Ok(false), - } + ensure_facet_view_owner(self, facet)?; + ensure_facet_index_owner(self, facet_to_simplices)?; + // Use FacetView's cached key path. + Ok(facet_to_simplices.is_one_sided_facet_key(&facet.key())) } - /// Returns the number of boundary facets in the triangulation. + /// Returns the number of one-sided facet incidences in the TDS. /// - /// This method efficiently counts boundary facets directly from the facet map + /// This method efficiently counts one-sided facets from the derived facet-incidence map /// without allocating or cloning `Facet` objects, making it O(|facets|) with /// no per-simplex `facets()` calls. /// /// # Returns /// - /// A `Result` containing the number of boundary facets in the triangulation, - /// or a [`TdsError`] if the facet map cannot be built or contains invalid topology. + /// A `Result` containing the number of one-sided facet incidences, + /// or a [`TdsError`] if the facet index cannot be built. /// /// # Errors /// - /// Returns a [`TdsError`] if the facet-to-simplices map cannot be built or - /// any facet has an invalid multiplicity other than 1 or 2. + /// Returns a [`TdsError`] if the facet-to-simplices index cannot be built. /// /// # Examples /// @@ -310,42 +313,88 @@ impl BoundaryAnalysis for Tds { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// - /// // A single tetrahedron has 4 boundary facets - /// assert_eq!(dt.tds().number_of_boundary_facets()?, 4); + /// // A single Euclidean tetrahedron has 4 one-sided facets. + /// assert_eq!(dt.tds().number_of_one_sided_facets()?, 4); /// # Ok(()) /// # } /// ``` - fn number_of_boundary_facets(&self) -> Result { - let facet_to_simplices = self.build_facet_to_simplices_map()?; - number_of_boundary_facets_in_map(&facet_to_simplices) + fn number_of_one_sided_facets(&self) -> Result { + let facet_to_simplices = self.build_facet_to_simplices_index()?; + Ok(facet_to_simplices + .iter() + .filter(|incidence| incidence.is_one_sided()) + .count()) + } +} + +fn ensure_facet_view_owner( + tds: &Tds, + facet: &FacetView<'_, U, V, D>, +) -> Result<(), TdsError> { + if ptr::eq(facet.tds(), tds) { + Ok(()) + } else { + Err(FacetError::FacetOwnerMismatch { + simplex_key: facet.simplex_key(), + facet_index: facet.facet_index(), + } + .into()) + } +} + +/// Rejects owner-bound facet indexes from another TDS before their keys are queried. +fn ensure_facet_index_owner( + tds: &Tds, + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, +) -> Result<(), TdsError> { + if ptr::eq(facet_to_simplices.tds(), tds) { + Ok(()) + } else { + Err(FacetError::FacetIndexOwnerMismatch.into()) } } #[cfg(test)] mod tests { - use super::{BoundaryAnalysis, number_of_boundary_facets_in_map}; - use crate::core::collections::FacetToSimplicesMap; - use crate::core::facet::{FacetError, FacetHandle}; + use super::{FacetIncidenceAnalysis, number_of_one_sided_facets_in_map}; + use crate::core::collections::{FacetToSimplicesMap, SmallBuffer}; + use crate::core::facet::{FacetError, FacetHandle, FacetToSimplicesIndex, FacetView}; use crate::core::query::QueryError; - use crate::core::tds::{SimplexKey, TdsError}; + use crate::core::simplex::Simplex; + use crate::core::tds::{SimplexKey, Tds, TdsError}; + use crate::core::vertex::Vertex; use crate::geometry::point::Point; use crate::triangulation::DelaunayTriangulation; use crate::try_vertices_from_points; use std::assert_matches; + #[cfg(feature = "diagnostics")] + macro_rules! test_debug { + ($($arg:tt)*) => {{ + tracing::debug!($($arg)*); + }}; + } + + #[cfg(not(feature = "diagnostics"))] + macro_rules! test_debug { + ($($arg:tt)*) => {{ + let _ = core::format_args!($($arg)*); + }}; + } + // ============================================================================= // SINGLE SIMPLEX TESTS // ============================================================================= #[expect( clippy::too_many_lines, - reason = "boundary regression test keeps topology setup and assertions together" + reason = "one-sided incidence regression test keeps setup and assertions together" )] #[test] - fn test_boundary_facets_single_simplices() { - // Test boundary analysis for single simplices in different dimensions + fn test_one_sided_facets_single_simplices() { + // Test one-sided incidence analysis for single simplices in different dimensions. - // Test Case 1: 2D triangle - all 3 edges should be boundary facets + // Test Case 1: 2D triangle - all 3 edges should be one-sided facets. { let points = vec![ Point::try_new([0.0, 0.0]).expect("finite point coordinates"), @@ -362,30 +411,31 @@ mod tests { ); assert_eq!(dt.dim(), 2, "Should be 2-dimensional"); - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 3, - "2D triangle should have 3 boundary facets" + one_sided_count, 3, + "2D triangle should have 3 one-sided facets" ); - // Verify all facets are boundary facets using cached map + // Verify all facets are one-sided using cached index. let facet_to_simplices = dt .tds() - .build_facet_to_simplices_map() - .expect("Should build facet map"); - assert!(dt.boundary_facets().unwrap().all(|f| { - let f = f.expect("valid boundary facet"); + .build_facet_to_simplices_index() + .expect("Should build facet index"); + assert!(dt.tds().one_sided_facets().unwrap().all(|f| { + let f = f.expect("valid one-sided facet"); dt.tds() - .is_boundary_facet_with_map(&f, &facet_to_simplices) + .is_one_sided_facet_with_index(&f, &facet_to_simplices) .expect("Should not fail for valid facets") })); } - // Test Case 2: 3D tetrahedron - all 4 faces should be boundary facets + // Test Case 2: 3D tetrahedron - all 4 faces should be one-sided facets. { let points = vec![ Point::try_new([0.0, 0.0, 0.0]).expect("finite point coordinates"), @@ -403,30 +453,31 @@ mod tests { ); assert_eq!(dt.dim(), 3, "Should be 3-dimensional"); - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 4, - "3D tetrahedron should have 4 boundary facets" + one_sided_count, 4, + "3D tetrahedron should have 4 one-sided facets" ); - // Verify all facets are boundary facets + // Verify all facets are one-sided. let facet_to_simplices = dt .tds() - .build_facet_to_simplices_map() - .expect("Should build facet map"); - assert!(dt.boundary_facets().unwrap().all(|f| { - let f = f.expect("valid boundary facet"); + .build_facet_to_simplices_index() + .expect("Should build facet index"); + assert!(dt.tds().one_sided_facets().unwrap().all(|f| { + let f = f.expect("valid one-sided facet"); dt.tds() - .is_boundary_facet_with_map(&f, &facet_to_simplices) + .is_one_sided_facet_with_index(&f, &facet_to_simplices) .expect("Should not fail for valid facets") })); } - // Test Case 3: 4D simplex - all 5 tetrahedra should be boundary facets + // Test Case 3: 4D simplex - all 5 tetrahedra should be one-sided facets. { let points = vec![ Point::try_new([0.0, 0.0, 0.0, 0.0]).expect("finite point coordinates"), @@ -445,35 +496,34 @@ mod tests { ); assert_eq!(dt.dim(), 4, "Should be 4-dimensional"); - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 5, - "4D simplex should have 5 boundary facets" + one_sided_count, 5, + "4D simplex should have 5 one-sided facets" ); - // Verify all facets are boundary facets + // Verify all facets are one-sided. let facet_to_simplices = dt .tds() - .build_facet_to_simplices_map() - .expect("Should build facet map"); - let confirmed_boundary = dt - .boundary_facets() + .build_facet_to_simplices_index() + .expect("Should build facet index"); + let confirmed_one_sided = dt + .tds() + .one_sided_facets() .unwrap() .filter(|f| { - let f = f.as_ref().expect("valid boundary facet"); + let f = f.as_ref().expect("valid one-sided facet"); dt.tds() - .is_boundary_facet_with_map(f, &facet_to_simplices) + .is_one_sided_facet_with_index(f, &facet_to_simplices) .expect("Should not fail for valid facets") }) .count(); - assert_eq!( - confirmed_boundary, 5, - "All facets should be boundary facets" - ); + assert_eq!(confirmed_one_sided, 5, "All facets should be one-sided"); } // Test Case 4: Empty triangulation @@ -485,18 +535,19 @@ mod tests { "Empty triangulation should have no simplices" ); - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 0, - "Empty triangulation should have no boundary facets" + one_sided_count, 0, + "Empty triangulation should have no one-sided facets" ); } - // Test Case 5: 5D simplex - all 6 facets should be boundary facets + // Test Case 5: 5D simplex - all 6 facets should be one-sided facets. { let points = vec![ Point::try_new([0.0, 0.0, 0.0, 0.0, 0.0]).expect("finite point coordinates"), @@ -516,43 +567,45 @@ mod tests { ); assert_eq!(dt.dim(), 5, "Should be 5-dimensional"); - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 6, - "5D simplex should have 6 boundary facets" + one_sided_count, 6, + "5D simplex should have 6 one-sided facets" ); let facet_to_simplices = dt .tds() - .build_facet_to_simplices_map() - .expect("Should build facet map"); - let confirmed_boundary = dt - .boundary_facets() + .build_facet_to_simplices_index() + .expect("Should build facet index"); + let confirmed_one_sided = dt + .tds() + .one_sided_facets() .unwrap() .filter(|f| { - let f = f.as_ref().expect("valid boundary facet"); + let f = f.as_ref().expect("valid one-sided facet"); dt.tds() - .is_boundary_facet_with_map(f, &facet_to_simplices) + .is_one_sided_facet_with_index(f, &facet_to_simplices) .expect("Should not fail for valid facets") }) .count(); assert_eq!( - confirmed_boundary, 6, - "All 5D simplex facets should be boundary facets" + confirmed_one_sided, 6, + "All 5D simplex facets should be one-sided" ); } - println!( - "✓ Single simplex boundary analysis works correctly in 2D, 3D, 4D, 5D, and empty cases" + test_debug!( + "✓ Single simplex one-sided incidence analysis works correctly in 2D, 3D, 4D, 5D, and empty cases" ); } #[test] - fn test_boundary_facets_method_coverage() { + fn test_one_sided_facets_method_coverage() { // Test method delegation and implementation path coverage // Test case 1: Basic method delegation and error propagation @@ -566,26 +619,24 @@ mod tests { let vertices = try_vertices_from_points(&points).expect("finite point coordinates"); let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); - // Test boundary_facets() normal path - let boundary_count = dt - .boundary_facets() + // Test one_sided_facets() normal path. + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, 4, - "Single tetrahedron has 4 boundary facets" + one_sided_count, 4, + "Single tetrahedron has 4 one-sided facets" ); - // Test is_boundary_facet() delegation (builds facet map internally) - if let Some(facet) = dt.boundary_facets().unwrap().next() { + // Test is_one_sided_facet() delegation (builds facet index internally). + if let Some(facet) = dt.tds().one_sided_facets().unwrap().next() { let facet = facet.unwrap(); - let result = dt.tds().is_boundary_facet(&facet); + let result = dt.tds().is_one_sided_facet(&facet); assert!(result.is_ok(), "Should not error on valid facet"); - assert!( - result.unwrap(), - "Facet should be boundary in single tetrahedron" - ); + assert!(result.unwrap(), "Facet should be one-sided"); } } @@ -608,19 +659,20 @@ mod tests { ); // Exercise capacity allocation, cache initialization, and vector push operations - let boundary_count = dt - .boundary_facets() + let one_sided_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); - assert!(boundary_count > 0, "Should have boundary facets"); + assert!(one_sided_count > 0, "Should have one-sided facets"); assert!( - boundary_count >= 4, - "Should have at least 4 boundary facets" + one_sided_count >= 4, + "Should have at least 4 one-sided facets" ); } - println!("✓ Boundary facets method coverage and delegation work correctly"); + test_debug!("✓ One-sided facets method coverage and delegation work correctly"); } // ============================================================================= @@ -629,7 +681,7 @@ mod tests { #[test] fn test_boundary_facets_invalid_facet_index_error() { - println!("Testing boundary_facets with invalid facet index error path"); + test_debug!("Testing boundary_facets with invalid facet index error path"); // Note: This error path (InvalidFacetIndex) is difficult to trigger in practice // because the facet-to-simplices mapping is built from valid facets. @@ -652,13 +704,13 @@ mod tests { "Error should contain the facet count" ); - println!(" Error structure: {error}"); - println!(" ✓ InvalidFacetIndex error path structure verified"); + test_debug!(" Error structure: {error}"); + test_debug!(" ✓ InvalidFacetIndex error path structure verified"); } #[test] fn test_boundary_facets_simplex_not_found_error() { - println!("Testing boundary_facets with simplex not found error path"); + test_debug!("Testing boundary_facets with simplex not found error path"); // Note: This error path (SimplexNotFoundInTriangulation) is also difficult to trigger // in practice because the mapping is built from existing simplices. @@ -674,13 +726,13 @@ mod tests { "Error should mention simplex: {error_string}" ); - println!(" Error structure: {error}"); - println!(" ✓ SimplexNotFoundInTriangulation error path structure verified"); + test_debug!(" Error structure: {error}"); + test_debug!(" ✓ SimplexNotFoundInTriangulation error path structure verified"); } #[test] - fn test_is_boundary_facet_with_map_consistency() { - println!("Testing is_boundary_facet_with_map consistency with boundary_facets"); + fn test_is_one_sided_facet_with_index_consistency() { + test_debug!("Testing is_one_sided_facet_with_index consistency with one_sided_facets"); // Create a valid triangulation let points = vec![ @@ -692,52 +744,51 @@ mod tests { let vertices = try_vertices_from_points(&points).expect("finite point coordinates"); let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); - // Build facet map + // Build facet index let facet_to_simplices = dt .tds() - .build_facet_to_simplices_map() - .expect("Should build map"); + .build_facet_to_simplices_index() + .expect("Should build index"); - // Get all boundary facets and verify they are correctly identified - let mut boundary_count = 0; + // Get all one-sided facets and verify they are correctly identified. + let mut one_sided_count = 0; - for boundary_facet in dt.boundary_facets().unwrap() { - let boundary_facet = boundary_facet.unwrap(); - let is_boundary = dt + for facet in dt.tds().one_sided_facets().unwrap() { + let facet = facet.unwrap(); + let is_one_sided = dt .tds() - .is_boundary_facet_with_map(&boundary_facet, &facet_to_simplices) - .expect("Should successfully check boundary status"); + .is_one_sided_facet_with_index(&facet, &facet_to_simplices) + .expect("Should successfully check one-sided status"); assert!( - is_boundary, - "All facets returned by boundary_facets() should be boundary facets" + is_one_sided, + "All facets returned by one_sided_facets() should be one-sided" ); - boundary_count += 1; + one_sided_count += 1; } - // Single tetrahedron should have 4 boundary facets assert_eq!( - boundary_count, 4, - "Single tetrahedron should have 4 boundary facets" + one_sided_count, 4, + "Single tetrahedron should have 4 one-sided facets" ); - // Verify consistency let reported_count = dt - .boundary_facets() + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); assert_eq!( - boundary_count, reported_count, - "Boundary facet count should be consistent" + one_sided_count, reported_count, + "One-sided facet count should be consistent" ); - println!(" ✓ All {boundary_count} boundary facets correctly identified"); - println!(" ✓ is_boundary_facet_with_map consistency verified"); + test_debug!(" ✓ All {one_sided_count} one-sided facets correctly identified"); + test_debug!(" ✓ is_one_sided_facet_with_index consistency verified"); } #[test] - fn test_boundary_facet_with_map_rejects_invalid_multiplicity() { + fn facet_index_rejects_invalid_multiplicity() { let points = vec![ Point::try_new([0.0, 0.0, 0.0]).expect("finite point coordinates"), Point::try_new([1.0, 0.0, 0.0]).expect("finite point coordinates"), @@ -747,16 +798,22 @@ mod tests { let vertices = try_vertices_from_points(&points).expect("finite point coordinates"); let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); let facet = dt.boundary_facets().unwrap().next().unwrap().unwrap(); - let facet_key = facet.key().unwrap(); + let facet_key = facet.key(); let mut facet_to_simplices = dt.tds().build_facet_to_simplices_map().unwrap(); facet_to_simplices.remove(&facet_key); + let facet_index = + FacetToSimplicesIndex::try_from_map(dt.tds(), &facet_to_simplices).unwrap(); assert!( !dt.tds() - .is_boundary_facet_with_map(&facet, &facet_to_simplices) + .is_one_sided_facet_with_index(&facet, &facet_index) .unwrap() ); + facet_to_simplices.insert(facet_key, SmallBuffer::new()); + let err = FacetToSimplicesIndex::try_from_map(dt.tds(), &facet_to_simplices).unwrap_err(); + assert_matches!(err, FacetError::InvalidFacetMultiplicity { found: 0, .. }); + facet_to_simplices.insert( facet_key, [ @@ -768,19 +825,68 @@ mod tests { .collect(), ); + let err = FacetToSimplicesIndex::try_from_map(dt.tds(), &facet_to_simplices).unwrap_err(); + + assert_matches!(err, FacetError::InvalidFacetMultiplicity { found: 3, .. }); + } + + #[test] + fn one_sided_facet_query_rejects_foreign_facet_view() { + let points = vec![ + Point::try_new([0.0, 0.0, 0.0]).expect("finite point coordinates"), + Point::try_new([1.0, 0.0, 0.0]).expect("finite point coordinates"), + Point::try_new([0.0, 1.0, 0.0]).expect("finite point coordinates"), + Point::try_new([0.0, 0.0, 1.0]).expect("finite point coordinates"), + ]; + let vertices = try_vertices_from_points(&points).expect("finite point coordinates"); + let dt: DelaunayTriangulation<_, (), (), 3> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + let foreign_dt: DelaunayTriangulation<_, (), (), 3> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + let foreign_facet = foreign_dt + .boundary_facets() + .unwrap() + .next() + .unwrap() + .unwrap(); + + let err = dt.tds().is_one_sided_facet(&foreign_facet).unwrap_err(); + + assert_matches!( + err, + TdsError::FacetError(FacetError::FacetOwnerMismatch { .. }) + ); + } + + #[test] + fn one_sided_facet_query_rejects_foreign_facet_index() { + let points = vec![ + Point::try_new([0.0, 0.0, 0.0]).expect("finite point coordinates"), + Point::try_new([1.0, 0.0, 0.0]).expect("finite point coordinates"), + Point::try_new([0.0, 1.0, 0.0]).expect("finite point coordinates"), + Point::try_new([0.0, 0.0, 1.0]).expect("finite point coordinates"), + ]; + let vertices = try_vertices_from_points(&points).expect("finite point coordinates"); + let dt: DelaunayTriangulation<_, (), (), 3> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + let foreign_dt: DelaunayTriangulation<_, (), (), 3> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + let facet = dt.boundary_facets().unwrap().next().unwrap().unwrap(); + let foreign_index = foreign_dt.tds().build_facet_to_simplices_index().unwrap(); + let err = dt .tds() - .is_boundary_facet_with_map(&facet, &facet_to_simplices) + .is_one_sided_facet_with_index(&facet, &foreign_index) .unwrap_err(); assert_matches!( err, - TdsError::FacetError(FacetError::InvalidFacetMultiplicity { found: 3, .. }) + TdsError::FacetError(FacetError::FacetIndexOwnerMismatch) ); } #[test] - fn test_boundary_facet_count_rejects_invalid_multiplicity() { + fn test_one_sided_facet_count_rejects_invalid_multiplicity() { let mut facet_to_simplices = FacetToSimplicesMap::default(); facet_to_simplices.insert( 0xCAFE, @@ -793,7 +899,7 @@ mod tests { .collect(), ); - let err = number_of_boundary_facets_in_map(&facet_to_simplices).unwrap_err(); + let err = number_of_one_sided_facets_in_map(&facet_to_simplices).unwrap_err(); assert_matches!( err, @@ -827,19 +933,19 @@ mod tests { match dt.boundary_facets() { Ok(_) => panic!("corrupted facet map should return a query error"), - Err(QueryError::TriangulationCorrupted { - source: TdsError::IndexOutOfBounds { .. }, - }) => {} + Err(QueryError::TriangulationCorrupted { source }) + if matches!(*source, TdsError::IndexOutOfBounds { .. }) => {} Err(err) => panic!("expected index-out-of-bounds query error, got {err:?}"), } } #[test] - fn test_number_of_boundary_facets_delegation() { - println!("Testing number_of_boundary_facets delegation to boundary_facets"); + fn test_number_of_one_sided_facets_delegation() { + test_debug!("Testing number_of_one_sided_facets delegation to one_sided_facets"); - // This test exercises the delegation to boundary_facets() and result transformation - // ensuring the method properly delegates and transforms the result + // This test exercises the delegation to one_sided_facets() and result + // transformation, ensuring the method properly delegates and transforms + // the result. let points = vec![ Point::try_new([0.0, 0.0, 0.0]).expect("finite point coordinates"), @@ -851,34 +957,76 @@ mod tests { let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); // Test both methods return consistent results - let boundary_facets_count = dt - .boundary_facets() + let one_sided_facets_count = dt + .tds() + .one_sided_facets() .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(); - let boundary_count = dt + let one_sided_count = dt .tds() - .number_of_boundary_facets() - .expect("Should get boundary count"); + .number_of_one_sided_facets() + .expect("Should get one-sided count"); assert_eq!( - boundary_facets_count, boundary_count, - "number_of_boundary_facets should equal boundary_facets().count()" + one_sided_facets_count, one_sided_count, + "number_of_one_sided_facets should equal one_sided_facets().count()" ); assert_eq!( - boundary_count, 4, - "Single tetrahedron should have 4 boundary facets" + one_sided_count, 4, + "Single tetrahedron should have 4 one-sided facets" ); - println!(" ✓ number_of_boundary_facets delegation working correctly"); - println!(" - boundary_facets().count(): {boundary_facets_count}"); - println!(" - number_of_boundary_facets(): {boundary_count}"); + test_debug!(" ✓ number_of_one_sided_facets delegation working correctly"); + test_debug!(" - one_sided_facets().count(): {one_sided_facets_count}"); + test_debug!(" - number_of_one_sided_facets(): {one_sided_count}"); + } + + #[test] + fn periodic_self_identified_one_sided_facet_is_not_boundary() { + 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 mut simplex = Simplex::try_new(vec![v0, v1, v2]).unwrap(); + simplex + .set_periodic_vertex_offsets(vec![[0, 0], [0, 0], [1, 0]]) + .unwrap(); + let simplex_key = tds.insert_simplex_with_mapping(simplex).unwrap(); + tds.simplex_mut(simplex_key) + .unwrap() + .set_neighbors_from_keys([Some(simplex_key), None, None]) + .unwrap(); + + let facet_index = tds.build_facet_to_simplices_index().unwrap(); + let self_identified_facet = FacetView::try_new(&tds, simplex_key, 0).unwrap(); + let self_identified_key = self_identified_facet.key(); + let boundary_keys: Vec<_> = tds + .one_sided_facets() + .unwrap() + .map(|facet| facet.unwrap().key()) + .collect(); + + assert!( + tds.is_one_sided_facet_with_index(&self_identified_facet, &facet_index) + .unwrap() + ); + assert!(boundary_keys.contains(&self_identified_key)); + assert_eq!(boundary_keys.len(), 3); + assert_eq!(tds.number_of_one_sided_facets().unwrap(), 3); } #[test] fn test_invalid_facet_multiplicity_error_creation() { - println!("Testing InvalidFacetMultiplicity error creation and formatting"); + test_debug!("Testing InvalidFacetMultiplicity error creation and formatting"); // Test that the error can be created with various multiplicity values let test_cases = [ @@ -887,17 +1035,17 @@ mod tests { (5, "excessive multiplicity"), ]; - for (multiplicity, description) in &test_cases { + for &(multiplicity, description) in &test_cases { let facet_key = 0x1234_5678_9ABC_DEF0_u64; // Example facet key let error = TdsError::FacetError(FacetError::InvalidFacetMultiplicity { facet_key, - found: *multiplicity, + found: multiplicity, }); // Verify error display includes all necessary information let error_string = format!("{error}"); assert!( - error_string.contains(&format!("{multiplicity:}").to_string()), + error_string.contains(&multiplicity.to_string()), "Error should contain multiplicity {multiplicity}: {error_string}" ); assert!( @@ -905,13 +1053,13 @@ mod tests { "Error should contain facet key in hex: {error_string}" ); assert!( - error_string.contains("expected 1 (boundary) or 2 (internal)"), + error_string.contains("expected 1 (one-sided) or 2 (two-sided)"), "Error should explain valid multiplicities: {error_string}" ); - println!(" ✓ {description}: {error}"); + test_debug!(" ✓ {description}: {error}"); } - println!(" ✓ InvalidFacetMultiplicity error creation and formatting verified"); + test_debug!(" ✓ InvalidFacetMultiplicity error creation and formatting verified"); } } diff --git a/src/core/orientation.rs b/src/core/orientation.rs index f3570d60..4b17e8ee 100644 --- a/src/core/orientation.rs +++ b/src/core/orientation.rs @@ -687,10 +687,12 @@ mod tests { let mut tri = Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); - tri.set_global_topology( + // This fixture is intentionally orientation-focused: the single simplex + // has Euclidean boundary facets, so the validated topology setter would + // reject closed toroidal metadata before lifted-coordinate handling runs. + tri.global_topology = GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) - .unwrap(), - ); + .unwrap(); assert!(tri.validate_geometric_simplex_orientation().is_ok()); diff --git a/src/core/query.rs b/src/core/query.rs index dbd5f5ad..c4ca89f9 100644 --- a/src/core/query.rs +++ b/src/core/query.rs @@ -17,6 +17,7 @@ use crate::core::simplex::Simplex; use crate::core::tds::{SimplexKey, TdsError, VertexKey}; use crate::core::triangulation::Triangulation; use crate::core::vertex::Vertex; +use crate::topology::manifold::{ManifoldError, boundary_facet_handles_from_index}; use std::marker::PhantomData; /// Errors returned by read-only triangulation queries. @@ -61,13 +62,40 @@ use std::marker::PhantomData; #[derive(Debug, Clone, thiserror::Error, PartialEq)] #[non_exhaustive] pub enum QueryError { - /// The triangulation could not build a facet map for a read-only query. + /// The triangulation could not build a facet index for a read-only query. #[error("Triangulation data structure is corrupted: {source}")] TriangulationCorrupted { /// Typed TDS validation or bookkeeping error that prevented the query. - #[from] - source: TdsError, + #[source] + source: Box, }, + + /// The triangulation's topology metadata rejects the requested boundary query. + #[error("Triangulation topology is invalid for this query: {source}")] + TopologyInvalid { + /// Typed topology validation error that prevented the query. + #[source] + source: Box, + }, +} + +impl From for QueryError { + fn from(source: TdsError) -> Self { + Self::TriangulationCorrupted { + source: Box::new(source), + } + } +} + +impl From for QueryError { + fn from(source: ManifoldError) -> Self { + match source { + ManifoldError::Tds(source) => Self::from(source), + source => Self::TopologyInvalid { + source: Box::new(source), + }, + } + } } impl Triangulation { @@ -264,12 +292,8 @@ impl Triangulation { /// An iterator yielding `Result` items for all facets /// in the triangulation. /// - /// # Errors - /// - /// Returns [`QueryError::TriangulationCorrupted`] if the facet iterator cannot - /// represent facet indices for this dimension. Individual iterator items - /// return [`FacetError`](crate::prelude::tds::FacetError) if a facet view - /// cannot be constructed from the current TDS state. + /// Individual iterator items return [`FacetError`](crate::prelude::tds::FacetError) + /// if a facet view cannot be constructed from the current TDS state. /// /// # Examples /// @@ -299,22 +323,22 @@ impl Triangulation { /// // Iterate over all facets /// let facet_count = dt /// .as_triangulation() - /// .facets()? + /// .facets() /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(facet_count, 4); // Tetrahedron has 4 facets /// # Ok(()) /// # } /// ``` - pub fn facets(&self) -> Result, QueryError> { - self.tds - .facets() - .map_err(|source| QueryError::TriangulationCorrupted { source }) + #[must_use] + pub fn facets(&self) -> AllFacetsIter<'_, U, V, D> { + self.tds.facets() } /// Returns an iterator over boundary (hull) facets in the triangulation. /// - /// Boundary facets are those that belong to exactly one simplex. This method - /// computes the facet-to-simplices map internally for convenience. + /// Boundary facets are one-sided facets not identified by closed periodic + /// topology. This method computes the facet-to-simplices index internally + /// for convenience. /// /// # Returns /// @@ -357,19 +381,26 @@ impl Triangulation { /// /// # Errors /// - /// Returns [`QueryError::TriangulationCorrupted`] if facet-map construction - /// detects invalid simplex or facet bookkeeping. The variant preserves the - /// underlying [`TdsError`] so callers can inspect the structural failure. + /// Returns [`QueryError::TriangulationCorrupted`] if facet-incidence index + /// construction detects invalid simplex or facet bookkeeping. The variant + /// preserves the underlying [`TdsError`] so callers can inspect the + /// structural failure. Returns [`QueryError::TopologyInvalid`] if + /// topology-aware boundary classification detects a closed topology that + /// cannot contain open boundary facets, or another manifold-boundary + /// inconsistency. /// Individual iterator items return [`FacetError`](crate::prelude::tds::FacetError) - /// if a boundary facet cannot be created or keyed from the simplices. + /// if a boundary facet handle cannot be reborrowed as a view. pub fn boundary_facets(&self) -> Result, QueryError> { - let facet_map = self + let facet_index = self .tds - .build_facet_to_simplices_map() - .map_err(|source| QueryError::TriangulationCorrupted { source })?; - BoundaryFacetsIter::try_new(&self.tds, facet_map) + .build_facet_to_simplices_index() + .map_err(QueryError::from)?; + let boundary_facet_handles = + boundary_facet_handles_from_index(&facet_index, self.global_topology) + .map_err(QueryError::from)?; + BoundaryFacetsIter::try_new(&facet_index, boundary_facet_handles) .map_err(TdsError::from) - .map_err(|source| QueryError::TriangulationCorrupted { source }) + .map_err(QueryError::from) } /// Returns an iterator over all unique edges in the triangulation. @@ -833,7 +864,6 @@ mod tests { assert_eq!( empty .facets() - .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(), 0 @@ -867,7 +897,6 @@ mod tests { assert_eq!(tri.vertices().count(), expected_vertex_count); assert_eq!( tri.facets() - .unwrap() .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) .unwrap(), expected_vertex_count @@ -939,13 +968,29 @@ mod tests { match tri.boundary_facets() { Ok(_) => panic!("corrupted facet map should return a query error"), - Err(QueryError::TriangulationCorrupted { - source: TdsError::IndexOutOfBounds { .. }, - }) => {} + Err(QueryError::TriangulationCorrupted { source }) + if matches!(*source, TdsError::IndexOutOfBounds { .. }) => {} Err(err) => panic!("expected index-out-of-bounds query error, got {err:?}"), } } + #[test] + fn query_error_preserves_tds_provenance_from_manifold_error() { + let source = TdsError::InconsistentDataStructure { + message: "facet incidence".to_string(), + }; + + assert_matches!( + QueryError::from(ManifoldError::Tds(source)), + QueryError::TriangulationCorrupted { source } + if matches!( + source.as_ref(), + TdsError::InconsistentDataStructure { message } + if message == "facet incidence" + ) + ); + } + #[test] fn topology_edges_triangle_2d() { let vertices = vec![ @@ -1454,7 +1499,7 @@ mod tests { assert_eq!(edges_collected.len(), edge_count); assert!(edge_count >= 6); - assert!(tri.facets().unwrap().next().transpose().unwrap().is_some()); + assert!(tri.facets().next().transpose().unwrap().is_some()); assert!( tri.boundary_facets() .unwrap() diff --git a/src/core/simplex.rs b/src/core/simplex.rs index dd9105de..bee6d58d 100644 --- a/src/core/simplex.rs +++ b/src/core/simplex.rs @@ -65,10 +65,9 @@ use super::vertex::{Vertex, VertexValidationError}; use super::{ - facet::{FacetError, FacetView}, tds::{EntityKind, SimplexKey, Tds, TdsConstructionError, VertexKey}, traits::{DataDeserialize, DataSerialize}, - util::{UuidValidationError, make_uuid, usize_to_u8, validate_uuid}, + util::{UuidValidationError, make_uuid, validate_uuid}, }; use crate::core::collections::{ FastHashMap, NeighborBuffer, PeriodicOffsetBuffer, SimplexVertexKeyBuffer, @@ -1722,144 +1721,6 @@ impl Simplex { // Advanced implementation block for Simplex methods impl Simplex { - /// Returns all facets (faces) of the simplex. - /// - /// A facet is a (D-1)-dimensional face of a D-dimensional simplex, obtained by removing - /// exactly one vertex from the original simplex. This operation creates all possible - /// (D-1)-dimensional boundary faces of the D-dimensional simplex. - /// - /// ## Mathematical Background - /// - /// For a D-dimensional simplex (D-simplex) with D+1 vertices: - /// - Each facet is a (D-1)-dimensional simplex with D vertices - /// - The total number of facets equals the number of vertices (D+1) - /// - Each vertex defines exactly one facet by its exclusion from the simplex - /// - /// ## Dimensional Examples - /// - /// - **1D simplex (line segment)**: 2 facets, each being a 0D point (vertex) - /// - **2D simplex (triangle)**: 3 facets, each being a 1D line segment (edge) - /// - **3D simplex (tetrahedron)**: 4 facets, each being a 2D triangle (face) - /// - **4D simplex (4-simplex)**: 5 facets, each being a 3D tetrahedron - /// - /// ## Facet Construction - /// - /// Each facet is constructed by: - /// 1. Taking all vertices from the original simplex - /// 2. Removing exactly one vertex (the "opposite" vertex) - /// 3. Creating a new (D-1)-dimensional simplex from the remaining D vertices - /// - /// The facet "opposite" to vertex `v` contains all vertices of the simplex except `v`. - /// - /// # Returns - /// - /// A `Result>, FacetError>` containing all facets of the simplex. - /// The returned vector has exactly D+1 facets, where each facet contains D vertices - /// (one fewer than the original simplex's D+1 vertices). - /// - /// The facets are returned in the same order as the vertices in the original simplex, - /// where `facets[i]` is the facet opposite to `vertices[i]`. - /// - /// # Errors - /// - /// Returns a [`FacetError`] if facet creation fails: - /// - [`FacetError::SimplexDoesNotContainVertex`]: Internal consistency error where - /// a vertex appears to be missing from the simplex during facet construction. - /// This should not occur under normal circumstances for properly constructed simplices. - /// - /// Note: For properly constructed simplices with D+1 distinct vertices, this method - /// should not fail under normal circumstances. - /// Returns all facets of a simplex as lightweight `FacetView` objects using only TDS and simplex key. - /// - /// This is a static method that provides a more robust alternative to `facet_views()` by avoiding - /// potential mismatches between `self` and the simplex retrieved by `simplex_key`. It accesses the simplex - /// data directly from the TDS using the provided key. - /// - /// # Arguments - /// - /// * `tds` - Reference to the triangulation data structure - /// * `simplex_key` - The key of the simplex in the TDS - /// - /// # Returns - /// - /// A `Result, FacetError>` containing all facets of the simplex. - /// Each facet is represented as a `FacetView` which provides efficient access - /// to facet properties without cloning simplex data. - /// - /// # Errors - /// - /// Returns a [`FacetError`] if: - /// - The simplex key is not found in the TDS - /// - Facet creation fails during the construction of `FacetView` objects - /// - The facet index cannot be represented as `u8` (very rare, only for extremely high dimensions) - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// use delaunay::prelude::tds::Simplex; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Simplex(#[from] delaunay::prelude::tds::SimplexValidationError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// delaunay::vertex![0.0, 0.0, 0.0]?, - /// delaunay::vertex![1.0, 0.0, 0.0]?, - /// delaunay::vertex![0.0, 1.0, 0.0]?, - /// delaunay::vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// let Some(simplex_key) = tds.simplex_keys().next() else { - /// return Ok(()); - /// }; - /// let facet_views = Simplex::facet_views_from_tds(tds, simplex_key)?; - /// - /// // Each facet should have 3 vertices (triangular faces of tetrahedron) - /// for facet_view in &facet_views { - /// assert_eq!(facet_view.vertices()?.count(), 3); - /// } - /// # Ok(()) - /// # } - /// ``` - pub fn facet_views_from_tds( - tds: &Tds, - simplex_key: SimplexKey, - ) -> Result>, FacetError> { - // Get the simplex from the TDS using the key - let simplex = tds - .simplex(simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation)?; - - let vertex_count = simplex.number_of_vertices(); - if vertex_count > u8::MAX as usize { - return Err(FacetError::InvalidFacetIndex { - index: u8::MAX, - facet_count: vertex_count, - }); - } - - let mut facet_views = Vec::with_capacity(vertex_count); - for idx in 0..vertex_count { - let facet_index = usize_to_u8(idx, vertex_count)?; - facet_views.push(FacetView::try_new(tds, simplex_key, facet_index)?); - } - Ok(facet_views) - } - /// Compare two simplices by their vertex sets (using `Vertex::PartialEq`) for cross-TDS equality checking. /// /// This method enables semantic comparison of simplices from different TDS instances by comparing @@ -2003,143 +1864,6 @@ impl Simplex { // Compare using Vertex::PartialEq (coordinate-based) self_vertices == other_vertices } - - /// Returns an iterator over all facets of a simplex as lightweight `FacetView` objects. - /// - /// This is a zero-allocation alternative to `facet_views_from_tds()` that returns an iterator - /// instead of collecting results into a `Vec`. This is more memory-efficient for large simplices - /// or when you don't need to store all facet views at once. - /// - /// # Arguments - /// - /// * `tds` - Reference to the triangulation data structure - /// * `simplex_key` - The key of the simplex in the TDS - /// - /// # Returns - /// - /// A `Result>, FacetError>` that yields all facets of the simplex. - /// The iterator implements `ExactSizeIterator`, so you can call `.len()` to get the number of facets - /// without consuming the iterator. - /// - /// # Errors - /// - /// Returns a [`FacetError`] if: - /// - The simplex key is not found in the TDS - /// - The facet count cannot be represented as `u8` (very rare, only for extremely high dimensions) - /// - /// Individual facet creation errors are yielded by the iterator as `Result` - /// items, not returned immediately from this method. - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// use delaunay::prelude::tds::Simplex; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Simplex(#[from] delaunay::prelude::tds::SimplexValidationError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// delaunay::vertex![0.0, 0.0, 0.0]?, - /// delaunay::vertex![1.0, 0.0, 0.0]?, - /// delaunay::vertex![0.0, 1.0, 0.0]?, - /// delaunay::vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// let Some(simplex_key) = tds.simplex_keys().next() else { - /// return Ok(()); - /// }; - /// let facet_iter = Simplex::facet_view_iter(tds, simplex_key)?; - /// - /// // Iterator knows the exact count - /// assert_eq!(facet_iter.len(), 4); // 4 facets for a tetrahedron - /// - /// // Process facets one at a time (zero allocation) - /// for facet_result in facet_iter { - /// let facet_view = facet_result?; - /// assert_eq!(facet_view.vertices()?.count(), 3); // Each facet has 3 vertices - /// } - /// # Ok(()) - /// # } - /// ``` - /// - /// ``` - /// use delaunay::prelude::*; - /// use delaunay::prelude::tds::Simplex; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Simplex(#[from] delaunay::prelude::tds::SimplexValidationError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// delaunay::vertex![0.0, 0.0, 0.0]?, - /// delaunay::vertex![1.0, 0.0, 0.0]?, - /// delaunay::vertex![0.0, 1.0, 0.0]?, - /// delaunay::vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// let Some(simplex_key) = tds.simplex_keys().next() else { - /// return Ok(()); - /// }; - /// let facet_iter = Simplex::facet_view_iter(tds, simplex_key)?; - /// - /// // Collect all facets and surface any construction error - /// let successful_facets: Vec<_> = facet_iter.collect::, _>>()?; - /// assert_eq!(successful_facets.len(), 4); - /// # Ok(()) - /// # } - /// ``` - pub fn facet_view_iter( - tds: &Tds, - simplex_key: SimplexKey, - ) -> Result, FacetError>>, FacetError> - { - // Get the simplex from the TDS using the key - let simplex = tds - .simplex(simplex_key) - .ok_or(FacetError::SimplexNotFoundInTriangulation)?; - - let vertex_count = simplex.number_of_vertices(); - if vertex_count > u8::MAX as usize { - return Err(FacetError::InvalidFacetIndex { - index: u8::MAX, - facet_count: vertex_count, - }); - } - - // Return a simple range-based iterator that maps indices to FacetView creation - Ok((0..vertex_count).map(move |idx| { - let facet_index = usize_to_u8(idx, vertex_count)?; - FacetView::try_new(tds, simplex_key, facet_index) - })) - } } // ============================================================================= @@ -3673,13 +3397,17 @@ mod tests { let simplex_key = dt.simplices().next().unwrap().0; // Test that we can get facet views for all facets - let facet_views = Simplex::facet_views_from_tds(dt.tds(), simplex_key) + let facet_views = dt + .tds() + .try_simplex_facets(simplex_key) + .expect("Failed to get facet iterator") + .collect::, _>>() .expect("Failed to get facet views"); assert_eq!(facet_views.len(), 4, "3D simplex should have 4 facets"); // Each facet should have 3 vertices (for 3D) for (i, facet_view) in facet_views.iter().enumerate() { - let facet_vertices = facet_view.vertices().expect("Failed to get facet vertices"); + let facet_vertices = facet_view.vertices(); assert_eq!( facet_vertices.count(), 3, @@ -3690,9 +3418,7 @@ mod tests { // Verify opposite vertices are correct let simplex = dt.tds().simplex(simplex_key).unwrap(); for (i, facet_view) in facet_views.iter().enumerate() { - let opposite_vertex = facet_view - .opposite_vertex() - .expect("Failed to get opposite vertex"); + let opposite_vertex = facet_view.opposite_vertex(); // The opposite vertex should be one of the simplex's vertices (by VertexKey) let opposite_key = dt .tds() @@ -3720,18 +3446,20 @@ mod tests { let simplex_key = dt.simplices().next().unwrap().0; // Get all facet views - let facet_views = Simplex::facet_views_from_tds(dt.tds(), simplex_key) + let facet_views = dt + .tds() + .try_simplex_facets(simplex_key) + .expect("Failed to get facet iterator") + .collect::, _>>() .expect("Failed to get facet views"); for facet_view in &facet_views { - let opposite_vertex = facet_view - .opposite_vertex() - .expect("Failed to get opposite vertex"); + let opposite_vertex = facet_view.opposite_vertex(); let opposite_vertex_key = dt .tds() .vertex_key_from_uuid(&opposite_vertex.uuid()) .unwrap(); - let facet_vertices = facet_view.vertices().expect("Failed to get facet vertices"); + let facet_vertices = facet_view.vertices(); // Collect facet vertex keys let facet_vertex_keys: Vec<_> = facet_vertices @@ -3778,7 +3506,8 @@ mod tests { assert_eq!(simplex.number_of_vertices(), 6); assert_eq!(simplex.dim(), 5); assert_eq!( - Simplex::facet_views_from_tds(dt.tds(), simplex_key) + dt.tds() + .try_simplex_facets(simplex_key) .expect("Failed to get facets") .len(), 6 @@ -3824,12 +3553,15 @@ mod tests { } assert_eq!(simplex.data.unwrap(), 42u32); - // Also verify we can access vertex data through facet_views - let facet_views = Simplex::facet_views_from_tds(dt.tds(), simplex_key) - .expect("Failed to get facet views"); - for facet_view in &facet_views { + // Also verify we can access vertex data through facet views + let facet_views = dt + .tds() + .try_simplex_facets(simplex_key) + .expect("Failed to get facet iterator"); + for facet_view in facet_views { + let facet_view = facet_view.expect("Failed to get facet view"); // Get vertices from the facet view - let vertices = facet_view.vertices().expect("Failed to get facet vertices"); + let vertices = facet_view.vertices(); // Verify all vertices have valid data for vertex in vertices { @@ -4208,31 +3940,25 @@ mod tests { .tds .simplex_mut(simplex_key) .expect("simplex key should be valid in test"); - while u8::try_from(simplex.number_of_vertices()).is_ok() { + while simplex.number_of_vertices() <= usize::from(u8::MAX) + 1 { simplex.push_vertex_key(vkey0); } - assert!(u8::try_from(simplex.number_of_vertices()).is_err()); + assert!(simplex.number_of_vertices() > usize::from(u8::MAX) + 1); } - // Both helpers should fail early (before attempting to build individual FacetViews). - let err = Simplex::facet_views_from_tds(dt.tds(), simplex_key).unwrap_err(); - assert_matches!( - err, - FacetError::InvalidFacetIndex { - index: u8::MAX, - facet_count, - } if u8::try_from(facet_count).is_err() - ); - - let err = Simplex::facet_view_iter(dt.tds(), simplex_key) + // The owner-bound iterator should fail early before building individual FacetViews. + let err = dt + .tds() + .try_simplex_facets(simplex_key) .err() - .expect("Expected facet_view_iter to fail on vertex_count overflow"); + .expect("Expected try_simplex_facets to fail on vertex_count overflow"); assert_matches!( err, - FacetError::InvalidFacetIndex { - index: u8::MAX, + FacetError::InvalidFacetIndexOverflow { + original_index, facet_count, - } if u8::try_from(facet_count).is_err() + } if original_index == usize::from(u8::MAX) + 1 + && facet_count > usize::from(u8::MAX) + 1 ); } @@ -4312,8 +4038,8 @@ mod tests { } #[test] - fn test_facet_view_iter() { - // Test the iterator-based facet_view_iter method + fn test_try_simplex_facets() { + // Test the owner-bound simplex facet iterator. let vertices = vec![ crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), @@ -4325,8 +4051,10 @@ mod tests { let simplex_key = dt.tds().simplex_keys().next().unwrap(); // Test the iterator method - let facet_iter = - Simplex::facet_view_iter(dt.tds(), simplex_key).expect("Failed to get facet iterator"); + let facet_iter = dt + .tds() + .try_simplex_facets(simplex_key) + .expect("Failed to get facet iterator"); // Should know the exact count upfront (implements ExactSizeIterator) assert_eq!( @@ -4344,12 +4072,14 @@ mod tests { let facet_view = facet_result .as_ref() .unwrap_or_else(|_| panic!("Facet {i} creation should succeed")); - let vertex_count = facet_view.vertices().unwrap().count(); + let vertex_count = facet_view.vertices().count(); assert_eq!(vertex_count, 3, "Facet {i} should have 3 vertices"); } // Test iterator is zero-allocation by using it without collect - let facet_iter2 = Simplex::facet_view_iter(dt.tds(), simplex_key) + let facet_iter2 = dt + .tds() + .try_simplex_facets(simplex_key) .expect("Failed to get second facet iterator"); let mut count = 0; @@ -4360,7 +4090,9 @@ mod tests { assert_eq!(count, 4, "Iterator should yield 4 facets"); // Test iterator combinators work correctly - let facet_iter3 = Simplex::facet_view_iter(dt.tds(), simplex_key) + let facet_iter3 = dt + .tds() + .try_simplex_facets(simplex_key) .expect("Failed to get third facet iterator"); let successful_facets: Vec<_> = facet_iter3 @@ -4371,15 +4103,6 @@ mod tests { 4, "All facets should be created successfully" ); - - // Compare with Vec-based method to ensure same results - let vec_facets = Simplex::facet_views_from_tds(dt.tds(), simplex_key) - .expect("Vec-based method should work"); - assert_eq!( - successful_facets.len(), - vec_facets.len(), - "Iterator and Vec methods should return same count" - ); } #[test] diff --git a/src/core/tds/errors.rs b/src/core/tds/errors.rs index b3c04ccd..e54c92c8 100644 --- a/src/core/tds/errors.rs +++ b/src/core/tds/errors.rs @@ -993,6 +993,12 @@ pub enum TriangulationValidationErrorKind { ManifoldFacetMultiplicity, /// A boundary ridge had invalid boundary-facet multiplicity. BoundaryRidgeMultiplicity, + /// A closed topology contained an open boundary facet. + BoundaryFacetInClosedTopology, + /// A non-periodic topology contained periodic self-identification metadata. + PeriodicIdentificationInNonPeriodicTopology, + /// A requested ridge candidate was not present in any simplex. + RidgeNotFound, /// A ridge link failed PL-manifold validation. RidgeLinkNotManifold, /// A vertex link failed PL-manifold validation. @@ -1016,6 +1022,13 @@ impl From<&TriangulationValidationError> for TriangulationValidationErrorKind { TriangulationValidationError::BoundaryRidgeMultiplicity { .. } => { Self::BoundaryRidgeMultiplicity } + TriangulationValidationError::BoundaryFacetInClosedTopology { .. } => { + Self::BoundaryFacetInClosedTopology + } + TriangulationValidationError::PeriodicIdentificationInNonPeriodicTopology { + .. + } => Self::PeriodicIdentificationInNonPeriodicTopology, + TriangulationValidationError::RidgeNotFound { .. } => Self::RidgeNotFound, TriangulationValidationError::RidgeLinkNotManifold { .. } => Self::RidgeLinkNotManifold, TriangulationValidationError::VertexLinkNotManifold { .. } => { Self::VertexLinkNotManifold @@ -1127,9 +1140,10 @@ mod tests { use crate::core::vertex::VertexValidationError; use crate::repair::DelaunayRepairOperation; use crate::topology::characteristics::euler::TopologyClassification; + use crate::topology::traits::topological_space::TopologyKind; use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; use slotmap::KeyData; - use std::assert_matches; + use std::{assert_matches, iter}; fn synthetic_delaunay_verification_error( message: &str, @@ -1333,6 +1347,32 @@ mod tests { }, TriangulationValidationErrorKind::BoundaryRidgeMultiplicity, ), + ( + TriangulationValidationError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + facet_key: 0x111, + simplex_key: SimplexKey::from(KeyData::from_ffi(5)), + simplex_uuid: Uuid::new_v4(), + facet_index: 1, + }, + TriangulationValidationErrorKind::BoundaryFacetInClosedTopology, + ), + ( + TriangulationValidationError::PeriodicIdentificationInNonPeriodicTopology { + topology: TopologyKind::Euclidean, + facet_key: 0x222, + simplex_key: SimplexKey::from(KeyData::from_ffi(6)), + simplex_uuid: Uuid::new_v4(), + facet_index: 2, + }, + TriangulationValidationErrorKind::PeriodicIdentificationInNonPeriodicTopology, + ), + ( + TriangulationValidationError::RidgeNotFound { + ridge_vertices: iter::once(vertex_key).collect(), + }, + TriangulationValidationErrorKind::RidgeNotFound, + ), ( TriangulationValidationError::RidgeLinkNotManifold { ridge_key: 0x123, diff --git a/src/core/tds/storage.rs b/src/core/tds/storage.rs index 9beb5d73..51ef6405 100644 --- a/src/core/tds/storage.rs +++ b/src/core/tds/storage.rs @@ -53,9 +53,10 @@ //! //! Valid Delaunay triangulations maintain several critical topological invariants: //! -//! - **Facet Sharing Invariant**: Every facet (D-1 dimensional face) is shared by exactly -//! two simplices, except for boundary facets which belong to exactly one simplex. This ensures -//! the triangulation forms a valid simplicial complex. +//! - **Facet Sharing Invariant**: Every facet (D-1 dimensional face) is one-sided or +//! two-sided. One-sided facets are boundary facets unless closed periodic topology +//! identifies them with their opposite side. This ensures the triangulation forms a +//! valid simplicial complex in its ambient topological space. //! - **Neighbor Consistency**: Adjacent simplices properly reference each other through their //! shared facets, maintaining bidirectional neighbor relationships. //! - **Vertex Incidence**: Each vertex is incident to a well-defined set of simplices that @@ -572,7 +573,7 @@ impl Tds { }; !offsets.is_empty() && offsets.len() == simplex.number_of_vertices() } - pub(in crate::core::tds) fn periodic_facet_key_from_simplex_vertices( + pub(crate) fn periodic_facet_key_from_simplex_vertices( simplex: &Simplex, vertices: &[VertexKey], facet_index: usize, @@ -2313,6 +2314,17 @@ mod test_support { self.vertex_to_simplices.clear_vertex_for_test(vertex_key); } + /// Adds a simplex to one vertex incidence buffer without changing simplex storage. + pub(in crate::core) fn add_simplex_to_vertex_incidence_for_test( + &mut self, + vertex_key: VertexKey, + simplex_key: SimplexKey, + ) { + self.vertex_to_simplices + .insert_simplex(simplex_key, &[vertex_key]) + .expect("test helper should receive an existing vertex incidence entry"); + } + /// Removes a simplex from storage while deliberately preserving incidence. /// /// Tests use this to model stale vertex-to-simplices entries that normal TDS diff --git a/src/core/tds/validation.rs b/src/core/tds/validation.rs index c80f2593..0f783738 100644 --- a/src/core/tds/validation.rs +++ b/src/core/tds/validation.rs @@ -10,31 +10,35 @@ use crate::core::collections::{ FacetToSimplicesMap, FastHashMap, MAX_PRACTICAL_DIMENSION_SIZE, NeighborBuffer, SimplexVerticesMap, SmallBuffer, VertexKeySet, fast_hash_map_with_capacity, }; -use crate::core::facet::FacetHandle; +use crate::core::facet::{FacetHandle, FacetToSimplicesIndex}; use crate::core::simplex::{NeighborSlot, Simplex}; use crate::core::util::{deduplication::coords_equal_exact, usize_to_u8}; use slotmap::Key; impl Tds { - /// Builds a `FacetToSimplicesMap` with strict error handling. + /// Builds an owner-bound facet-to-simplices index with strict error handling. /// - /// This method returns an error if any simplex has missing vertex keys, ensuring - /// complete and accurate facet topology information. This is the preferred method - /// for building facet-to-simplices mappings. + /// This method returns an error if any simplex has missing vertex keys or if + /// any facet has multiplicity other than 1 (one-sided) or 2 (two-sided), + /// ensuring complete and validated facet topology information. This is the + /// public method for repeated facet-incidence and boundary queries. /// /// # Returns /// /// A `Result` containing: - /// - `Ok(FacetToSimplicesMap)`: A complete mapping of facet keys to simplices - /// - `Err(TdsError)`: If any simplex has missing vertex keys + /// - `Ok(FacetToSimplicesIndex)`: A complete index of facet keys to simplices, + /// lifetime-bound to this TDS + /// - `Err(TdsError)`: If simplex vertices cannot be resolved or a facet has + /// invalid multiplicity /// /// # Errors /// - /// Returns [`TdsError`] if the map cannot be built: + /// Returns [`TdsError`] if the index cannot be built: /// - [`VertexNotFound`](TdsError::VertexNotFound) / [`SimplexNotFound`](TdsError::SimplexNotFound) — a simplex cannot resolve its vertex keys. /// - [`IndexOutOfBounds`](TdsError::IndexOutOfBounds) — a facet index exceeds the `u8` range. /// - [`DimensionMismatch`](TdsError::DimensionMismatch) — periodic offset count does not match vertex count. /// - [`InconsistentDataStructure`](TdsError::InconsistentDataStructure) — periodic facet key derivation fails. + /// - [`FacetError`](TdsError::FacetError) — a facet is incident to a number of simplices other than 1 or 2. /// /// # Performance /// @@ -66,12 +70,20 @@ impl Tds { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// let tds = dt.tds(); - /// let facet_map = tds.build_facet_to_simplices_map()?; - /// assert!(!facet_map.is_empty()); + /// let facet_index = tds.build_facet_to_simplices_index()?; + /// assert!(!facet_index.is_empty()); /// # Ok(()) /// # } /// ``` - pub fn build_facet_to_simplices_map(&self) -> Result { + pub fn build_facet_to_simplices_index( + &self, + ) -> Result, TdsError> { + self.build_facet_to_simplices_map() + .and_then(|map| FacetToSimplicesIndex::try_from_map(self, &map).map_err(TdsError::from)) + } + + /// Builds the raw facet-to-simplices map used by validation and cache internals. + pub(crate) fn build_facet_to_simplices_map(&self) -> Result { if D > usize::from(u8::MAX) { return Err(TdsError::DimensionMismatch { expected: usize::from(u8::MAX), diff --git a/src/core/traits/boundary_analysis.rs b/src/core/traits/boundary_analysis.rs deleted file mode 100644 index 74d15e73..00000000 --- a/src/core/traits/boundary_analysis.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Boundary analysis trait for triangulation data structures. - -use crate::core::{ - facet::{BoundaryFacetsIter, FacetView}, - tds::TdsError, -}; - -/// Trait for boundary analysis operations on triangulations. -/// -/// This trait provides methods to identify and analyze boundary facets -/// in d-dimensional triangulations. A boundary facet is a facet that -/// belongs to only one simplex, meaning it lies on the convex hull of -/// the triangulation. -/// -/// # Examples -/// -/// ``` -/// use delaunay::prelude::*; -/// -/// # #[derive(Debug, thiserror::Error)] -/// # enum ExampleError { -/// # #[error(transparent)] -/// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), -/// # #[error(transparent)] -/// # Query(#[from] delaunay::query::QueryError), -/// # #[error(transparent)] -/// # Tds(#[from] delaunay::prelude::tds::TdsError), -/// # #[error(transparent)] -/// # Facet(#[from] delaunay::prelude::tds::FacetError), -/// # #[error(transparent)] -/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), -/// # } -/// # fn main() -> Result<(), ExampleError> { -/// // Create a simple 3D triangulation (single tetrahedron) -/// let vertices = vec![ -/// vertex![0.0, 0.0, 0.0]?, -/// vertex![1.0, 0.0, 0.0]?, -/// vertex![0.0, 1.0, 0.0]?, -/// vertex![0.0, 0.0, 1.0]?, -/// ]; -/// let dt: DelaunayTriangulation<_, _, _, 3> = -/// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; -/// let tds = dt.tds(); -/// -/// // Use the trait methods -/// let boundary_count = tds -/// .boundary_facets()? -/// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; -/// assert_eq!(boundary_count, 4); // Tetrahedron has 4 boundary faces -/// -/// let count = tds.number_of_boundary_facets()?; -/// assert_eq!(count, 4); -/// # Ok(()) -/// # } -/// ``` -pub trait BoundaryAnalysis { - /// Identifies all boundary facets in the triangulation. - /// - /// A boundary facet is a facet that belongs to only one simplex, meaning it lies on the - /// boundary of the triangulation (convex hull). These facets are important for - /// convex hull computation and boundary analysis. - /// - /// # Returns - /// - /// A `Result, TdsError>` containing an iterator over boundary facets. - /// The iterator yields `Result` items lazily without pre-allocating a vector, - /// providing better performance while still surfacing corrupted facet views during iteration. - /// - /// # Errors - /// - /// Returns a [`TdsError`] if the boundary-facet iterator cannot be constructed. - /// Individual iterator items return [`FacetError`](crate::prelude::tds::FacetError) - /// if a boundary facet cannot be created or keyed from the simplices. - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Query(#[from] delaunay::query::QueryError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// // A single tetrahedron has 4 boundary facets (all facets are on the boundary) - /// let boundary_count = tds - /// .boundary_facets()? - /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; - /// assert_eq!(boundary_count, 4); - /// # Ok(()) - /// # } - /// ``` - fn boundary_facets(&self) -> Result, TdsError>; - - /// Checks if a specific facet is a boundary facet. - /// - /// A boundary facet is a facet that belongs to only one simplex in the triangulation. - /// - /// # Arguments - /// - /// * `facet` - The facet to check. - /// - /// # Returns - /// - /// `Ok(true)` if the facet is on the boundary (belongs to only one simplex), - /// `Ok(false)` if it's an interior facet (belongs to two simplices). - /// - /// # Errors - /// - /// Returns a [`TdsError`] if: - /// - Building the facet-to-simplices mapping fails due to data structure inconsistencies - /// - The facet cannot be keyed because it references missing vertices - /// - The facet-to-simplices map contains an invalid multiplicity other than 1 or 2 - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Query(#[from] delaunay::query::QueryError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// // Get a boundary facet using the new iterator API - /// let mut boundary_facets = tds.boundary_facets()?; - /// let Some(first_facet) = boundary_facets.next().transpose()? else { - /// return Ok(()); - /// }; - /// // In a single tetrahedron, all facets are boundary facets - /// assert!(tds.is_boundary_facet(&first_facet)?); - /// # Ok(()) - /// # } - /// ``` - fn is_boundary_facet(&self, facet: &FacetView<'_, U, V, D>) -> Result; - - /// Checks if a specific facet is a boundary facet using a precomputed facet map. - /// - /// This is an optimized version of [`Self::is_boundary_facet`] that accepts a prebuilt - /// facet-to-simplices map to avoid recomputation in tight loops. - /// - /// # Arguments - /// - /// * `facet` - The facet to check. - /// * `facet_to_simplices` - Precomputed map from facet keys to simplices containing them. - /// Obtain this by calling [`build_facet_to_simplices_map`] on the triangulation. - /// - /// # Returns - /// - /// `Ok(true)` if the facet is on the boundary (belongs to only one simplex), - /// `Ok(false)` if it is an interior facet (belongs to two simplices) or the - /// facet key is absent from the supplied map. - /// - /// # Errors - /// - /// Returns a [`TdsError`] if: - /// - The facet's vertices cannot be found in the triangulation (e.g., facet from different TDS) - /// - The facet key cannot be derived from its vertices - /// - The supplied map contains an invalid multiplicity other than 1 or 2 for the facet - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Query(#[from] delaunay::query::QueryError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// // Build the facet map once for multiple queries (efficient for batch operations) - /// let facet_to_simplices = tds.build_facet_to_simplices_map()?; - /// - /// // Check boundary facets efficiently using the iterator API and cached map - /// let boundary_facets = tds.boundary_facets()?; - /// for facet in boundary_facets { - /// let facet = facet?; - /// let is_boundary = tds.is_boundary_facet_with_map(&facet, &facet_to_simplices)?; - /// println!("Facet is boundary: {is_boundary}"); - /// // In a single tetrahedron, all facets are boundary facets - /// assert!(is_boundary); - /// } - /// # Ok(()) - /// # } - /// ``` - /// - /// [`build_facet_to_simplices_map`]: crate::prelude::tds::Tds::build_facet_to_simplices_map - fn is_boundary_facet_with_map( - &self, - facet: &FacetView<'_, U, V, D>, - facet_to_simplices: &crate::core::collections::FacetToSimplicesMap, - ) -> Result; - - /// Returns the number of boundary facets in the triangulation. - /// - /// This counts boundary facets directly from the facet-to-simplices map. - /// - /// # Returns - /// - /// A `Result` containing the number of boundary facets in the triangulation, - /// or a [`TdsError`] if the facet map cannot be built or contains invalid topology. - /// - /// # Errors - /// - /// Returns a [`TdsError`] if the facet-to-simplices map cannot be built or - /// any facet has an invalid multiplicity other than 1 or 2. - /// - /// # Examples - /// - /// ``` - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Query(#[from] delaunay::query::QueryError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Facet(#[from] delaunay::prelude::tds::FacetError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt: DelaunayTriangulation<_, _, _, 3> = - /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// let tds = dt.tds(); - /// - /// // Direct API call (recommended for single queries) - /// assert_eq!(tds.number_of_boundary_facets()?, 4); - /// - /// // Alternative: using iterator (useful for additional processing) - /// let count_via_iter = dt - /// .boundary_facets()? - /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; - /// assert_eq!(count_via_iter, 4); - /// # Ok(()) - /// # } - /// ``` - fn number_of_boundary_facets(&self) -> Result; -} diff --git a/src/core/traits/facet_cache.rs b/src/core/traits/facet_cache.rs index b69abd83..242a5714 100644 --- a/src/core/traits/facet_cache.rs +++ b/src/core/traits/facet_cache.rs @@ -1,10 +1,9 @@ -//! Facet caching trait for performance optimization +//! Internal facet-cache support for performance-sensitive algorithms. //! -//! This module provides the `FacetCacheProvider` trait that defines a common -//! interface for components that need to cache facet-to-simplices mappings for -//! performance optimization. +//! Public callers use owner-bound facet indexes. This module stays crate-private +//! because it stores raw derived maps behind generation checks for algorithms +//! that repeatedly query facet incidence. -use super::data_type::DataType; use crate::core::{ collections::FacetToSimplicesMap, tds::{Tds, TdsError}, @@ -15,7 +14,7 @@ use std::sync::{ atomic::{AtomicU64, Ordering}, }; -/// Trait for components that provide cached facet-to-simplices mappings. +/// Trait for components that provide cached internal facet-to-simplices mappings. /// /// This trait abstracts the common pattern of caching expensive facet-to-simplices /// mapping computations with atomic updates and cache invalidation. It's designed @@ -28,51 +27,7 @@ use std::sync::{ /// - **Thread-safe**: Atomic cache updates prevent race conditions /// - **Automatic invalidation**: Cache is invalidated when TDS changes /// - **Memory efficient**: Single shared instance across algorithm operations -/// -/// # Examples -/// -/// ``` -/// use delaunay::prelude::tds::FacetCacheProvider; -/// use delaunay::prelude::tds::Tds; -/// use delaunay::prelude::collections::FacetToSimplicesMap; -/// use delaunay::prelude::DataType; -/// use std::sync::Arc; -/// use std::sync::atomic::{AtomicU64, Ordering}; -/// use arc_swap::ArcSwapOption; -/// -/// struct MyAlgorithm { -/// facet_to_simplices_cache: ArcSwapOption, -/// cached_generation: AtomicU64, -/// } -/// -/// impl MyAlgorithm { -/// fn new() -> Self { -/// Self { -/// facet_to_simplices_cache: ArcSwapOption::empty(), -/// cached_generation: AtomicU64::new(0), -/// } -/// } -/// } -/// -/// impl FacetCacheProvider for MyAlgorithm -/// where -/// U: DataType, -/// V: DataType, -/// { -/// fn facet_cache(&self) -> &ArcSwapOption { -/// &self.facet_to_simplices_cache -/// } -/// -/// fn cached_generation(&self) -> &AtomicU64 { -/// &self.cached_generation -/// } -/// } -/// ``` -pub trait FacetCacheProvider -where - U: DataType, - V: DataType, -{ +pub(crate) trait FacetCacheProvider { /// Returns a reference to the facet cache storage. /// /// The cache stores precomputed facet-to-simplices mappings to avoid expensive @@ -176,43 +131,9 @@ where /// Returns a `TdsError` if the TDS has corrupted data /// (e.g., missing vertex keys) that prevents building a complete facet map. /// - /// # Examples - /// - /// ```rust - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// - /// // Build facet-to-simplices mapping - /// let facet_map = dt - /// .tds() - /// .build_facet_to_simplices_map() - /// ?; - /// - /// // Use the mapping for facet lookups - /// for (facet_key, adjacent_simplices) in facet_map.iter() { - /// // Each facet has at most 2 adjacent simplices - /// assert!(adjacent_simplices.len() <= 2); - /// } - /// # Ok(()) - /// # } - /// ``` + /// The public equivalent for user code is + /// [`Tds::build_facet_to_simplices_index`], which returns an owner-bound + /// index instead of exposing this raw cache representation. fn try_get_or_build_facet_cache( &self, tds: &Tds, @@ -306,35 +227,9 @@ where /// modified and the cache is no longer valid. It's typically called /// automatically by algorithms that modify the TDS. /// - /// # Examples - /// - /// ```rust - /// use delaunay::prelude::*; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Tds(#[from] delaunay::prelude::tds::TdsError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// vertex![0.0, 0.0, 0.0]?, - /// vertex![1.0, 0.0, 0.0]?, - /// vertex![0.0, 1.0, 0.0]?, - /// vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// - /// // Build facet-to-simplices mapping - /// let facet_map = dt.tds().build_facet_to_simplices_map()?; - /// assert!(!facet_map.is_empty(), "Facet map should contain entries"); - /// # Ok(()) - /// # } - /// ``` + /// This is only exposed to unit tests; production invalidation is tied to + /// algorithm mutation points. + #[cfg(test)] fn invalidate_facet_cache(&self) { // Clear the cache - next access will rebuild // ORDERING: The SeqCst store from ArcSwap ensures this None is visible @@ -385,6 +280,32 @@ mod tests { } } + struct NonDataTypePayload; + + struct NonDataTypeCacheProvider { + facet_to_simplices_cache: ArcSwapOption, + cached_generation: AtomicU64, + } + + impl NonDataTypeCacheProvider { + fn new() -> Self { + Self { + facet_to_simplices_cache: ArcSwapOption::empty(), + cached_generation: AtomicU64::new(0), + } + } + } + + impl FacetCacheProvider for NonDataTypeCacheProvider { + fn facet_cache(&self) -> &ArcSwapOption { + &self.facet_to_simplices_cache + } + + fn cached_generation(&self) -> &AtomicU64 { + &self.cached_generation + } + } + /// Create a simple test triangulation for testing fn create_test_triangulation() -> DelaunayTriangulation, (), (), 3> { let vertices = vec![ @@ -396,6 +317,21 @@ mod tests { DelaunayTriangulation::try_new(&vertices).expect("Failed to create test triangulation") } + #[test] + fn test_cache_provider_accepts_non_datatype_payloads() { + let provider = NonDataTypeCacheProvider::new(); + + assert!( + provider.facet_cache().load().is_none(), + "Cache should be empty initially" + ); + assert_eq!( + provider.cached_generation().load(Ordering::Relaxed), + 0, + "Generation should be 0 initially" + ); + } + #[test] fn test_initial_cache_state() { let provider = TestCacheProvider::new(); diff --git a/src/core/traits/facet_incidence_analysis.rs b/src/core/traits/facet_incidence_analysis.rs new file mode 100644 index 00000000..e1c88b59 --- /dev/null +++ b/src/core/traits/facet_incidence_analysis.rs @@ -0,0 +1,60 @@ +//! TDS-level facet-incidence analysis trait. + +use crate::core::{ + facet::{FacetToSimplicesIndex, FacetView, OneSidedFacetsIter}, + tds::TdsError, +}; + +/// Trait for TDS-level facet-incidence queries. +/// +/// The triangulation data structure can classify facet multiplicity: a facet is +/// incident to one or two D-simplices. That is deliberately weaker than manifold +/// boundary semantics. A one-sided facet can be a Euclidean boundary facet, but +/// in a periodic quotient triangulation it can also be a closed +/// self-identification. Use [`Triangulation::boundary_facets`] or +/// [`DelaunayTriangulation::boundary_facets`] for topology-aware boundary +/// queries. +/// +/// [`Triangulation::boundary_facets`]: crate::Triangulation::boundary_facets +/// [`DelaunayTriangulation::boundary_facets`]: crate::DelaunayTriangulation::boundary_facets +pub trait FacetIncidenceAnalysis { + /// Identifies all one-sided facet incidences in the TDS. + /// + /// Implementations may build and sort a derived handle list so iteration is + /// deterministic. This remains incidence analysis only; callers that need + /// semantic boundary facets should use the topology-aware boundary APIs. + /// + /// # Errors + /// + /// Returns a [`TdsError`] if the facet-incidence index cannot be built. + /// Individual iterator items return [`FacetError`](crate::prelude::tds::FacetError) + /// if a facet view cannot be constructed from the live TDS. + fn one_sided_facets(&self) -> Result, TdsError>; + + /// Checks whether a facet has one-sided incidence. + /// + /// # Errors + /// + /// Returns a [`TdsError`] if the facet-index cannot be built or if the + /// supplied facet view belongs to a different TDS. + fn is_one_sided_facet(&self, facet: &FacetView<'_, U, V, D>) -> Result; + + /// Checks whether a facet has one-sided incidence using a prebuilt index. + /// + /// # Errors + /// + /// Returns a [`TdsError`] if the supplied facet view or facet-incidence index + /// belongs to a different TDS. + fn is_one_sided_facet_with_index( + &self, + facet: &FacetView<'_, U, V, D>, + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + ) -> Result; + + /// Returns the number of one-sided facet incidences in the TDS. + /// + /// # Errors + /// + /// Returns a [`TdsError`] if the facet-incidence index cannot be built. + fn number_of_one_sided_facets(&self) -> Result; +} diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 138a407b..9a5f457f 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -69,22 +69,37 @@ where /// ``` #[must_use] pub fn new_empty(kernel: K) -> Self { + Self::new_empty_with_topology_context( + kernel, + TopologyGuarantee::DEFAULT, + GlobalTopology::DEFAULT, + ) + } + + /// Creates empty storage with explicit validation and global-topology context. + /// + /// Construction paths use this helper when topology metadata must be present + /// before later validation, boundary classification, or Euler checks run. + #[inline] + pub(crate) fn new_empty_with_topology_context( + kernel: K, + topology_guarantee: TopologyGuarantee, + global_topology: GlobalTopology, + ) -> Self { Self { kernel, tds: Tds::empty(), - global_topology: GlobalTopology::DEFAULT, - validation_policy: TopologyGuarantee::DEFAULT.default_validation_policy(), - topology_guarantee: TopologyGuarantee::DEFAULT, + global_topology, + validation_policy: topology_guarantee.default_validation_policy(), + topology_guarantee, } } + /// Test-only constructor for fixtures that need a prepared TDS without + /// crossing a public validation boundary. #[cfg(test)] #[inline] - #[expect( - clippy::missing_const_for_fn, - reason = "test-only constructor is not a pure math helper" - )] - pub(crate) fn new_with_tds(kernel: K, tds: Tds) -> Self { + pub(crate) const fn new_with_tds(kernel: K, tds: Tds) -> Self { Self { kernel, tds, diff --git a/src/core/util/facet_utils.rs b/src/core/util/facet_utils.rs index 235a5db0..3833bf33 100644 --- a/src/core/util/facet_utils.rs +++ b/src/core/util/facet_utils.rs @@ -3,7 +3,7 @@ #![forbid(unsafe_code)] use crate::core::collections::VertexUuidBuffer; -use crate::core::facet::{FacetError, FacetView}; +use crate::core::facet::FacetView; use crate::core::traits::data_type::DataType; use crate::core::vertex::Vertex; @@ -18,14 +18,8 @@ use crate::core::vertex::Vertex; /// /// # Returns /// -/// `Ok(true)` if the facets share the same vertices, `Ok(false)` if they have -/// different vertices, or `Err(FacetError)` if there was an error accessing -/// the facet data. -/// -/// # Errors -/// -/// Returns `FacetError` if either facet's vertices cannot be accessed, typically -/// due to missing simplices in the triangulation data structure. +/// `true` if the facets share the same vertices, or `false` if they have +/// different vertices. /// /// # Examples /// @@ -41,7 +35,7 @@ use crate::core::vertex::Vertex; /// let facet1 = FacetView::try_new(tds, simplex_keys[0], 0)?; /// let facet2 = FacetView::try_new(tds, simplex_keys[1], 0)?; /// -/// let adjacent = facet_views_are_adjacent(&facet1, &facet2)?; +/// let adjacent = facet_views_are_adjacent(&facet1, &facet2); /// match adjacent { /// true => println!("Facets are adjacent"), /// false => println!("Facets are not adjacent"), @@ -52,32 +46,33 @@ use crate::core::vertex::Vertex; /// } /// } /// ``` +#[must_use] pub fn facet_views_are_adjacent( facet1: &FacetView<'_, U, V, D>, facet2: &FacetView<'_, U, V, D>, -) -> Result +) -> bool where U: DataType, V: DataType, { - let vertices1 = sorted_facet_vertex_uuids(facet1)?; - let vertices2 = sorted_facet_vertex_uuids(facet2)?; + let vertices1 = sorted_facet_vertex_uuids(facet1); + let vertices2 = sorted_facet_vertex_uuids(facet2); - Ok(vertices1 == vertices2) + vertices1 == vertices2 } /// Canonicalizes facet vertex UUIDs so facet comparison stays allocation-light /// while remaining independent of local vertex order. fn sorted_facet_vertex_uuids( facet: &FacetView<'_, U, V, D>, -) -> Result +) -> VertexUuidBuffer where U: DataType, V: DataType, { - let mut vertices: VertexUuidBuffer = facet.vertices()?.map(Vertex::uuid).collect(); + let mut vertices: VertexUuidBuffer = facet.vertices().map(Vertex::uuid).collect(); vertices.sort_unstable(); - Ok(vertices) + vertices } /// Extracts owned vertices from a `FacetView` as a `Vec`. @@ -92,13 +87,7 @@ where /// /// # Returns /// -/// A `Result` containing a `Vec` of owned `Vertex` objects, or a `FacetError` if -/// the vertices cannot be accessed. -/// -/// # Errors -/// -/// Returns `FacetError` if the facet's vertices cannot be accessed, typically -/// due to missing simplices in the triangulation data structure. +/// A `Vec` of owned `Vertex` objects. /// /// # Examples /// @@ -122,7 +111,7 @@ where /// let facet_view = FacetView::try_new(tds, simplex_key, 0)?; /// /// // Extract owned vertices -/// let vertices = facet_view_to_vertices(&facet_view)?; +/// let vertices = facet_view_to_vertices(&facet_view); /// println!("Facet has {} vertices", vertices.len()); /// Ok(()) /// } @@ -133,14 +122,15 @@ where /// - Time Complexity: O(D) where D is the dimension (number of vertices in facet) /// - Space Complexity: O(D) for the returned vector /// - Uses `Copy` semantics so this is as efficient as possible for owned vertices +#[must_use] pub fn facet_view_to_vertices( facet_view: &FacetView<'_, U, V, D>, -) -> Result>, FacetError> +) -> Vec> where U: DataType, V: DataType, { - Ok(facet_view.vertices()?.copied().collect()) + facet_view.vertices().copied().collect() } /// Generates all unique combinations of `k` vertices for local regression tests. @@ -376,7 +366,7 @@ mod tests { for facet_idx2 in 0..4 { let fv1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let fv2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if facet_views_are_adjacent(&fv1, &fv2).unwrap() { + if facet_views_are_adjacent(&fv1, &fv2) { found_adjacent = true; facet_view1_adj = Some(fv1); break; @@ -402,7 +392,7 @@ mod tests { for facet_idx2 in 0..4 { let fv1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let fv2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if !facet_views_are_adjacent(&fv1, &fv2).unwrap() { + if !facet_views_are_adjacent(&fv1, &fv2) { found_non_adjacent = true; break; } @@ -423,7 +413,7 @@ mod tests { let facet_view1 = facet_view1_adj.unwrap(); assert!( - facet_views_are_adjacent(&facet_view1, &facet_view1).unwrap(), + facet_views_are_adjacent(&facet_view1, &facet_view1), "A facet should be adjacent to itself" ); println!(" ✓ Self-adjacency works correctly"); @@ -465,7 +455,7 @@ mod tests { for facet_idx2 in 0..3 { let facet_view1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let facet_view2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if facet_views_are_adjacent(&facet_view1, &facet_view2).unwrap() { + if facet_views_are_adjacent(&facet_view1, &facet_view2) { found_adjacent = true; break; } @@ -516,7 +506,7 @@ mod tests { for facet_idx2 in 0..2 { let fv1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let fv2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if facet_views_are_adjacent(&fv1, &fv2).unwrap() { + if facet_views_are_adjacent(&fv1, &fv2) { found_adjacent = true; } else { found_non_adjacent = true; @@ -562,19 +552,19 @@ mod tests { let facet3 = FacetView::try_new(tds, simplex_key, 3).unwrap(); // Each facet should be adjacent to itself - assert!(facet_views_are_adjacent(&facet0, &facet0).unwrap()); - assert!(facet_views_are_adjacent(&facet1, &facet1).unwrap()); - assert!(facet_views_are_adjacent(&facet2, &facet2).unwrap()); - assert!(facet_views_are_adjacent(&facet3, &facet3).unwrap()); + assert!(facet_views_are_adjacent(&facet0, &facet0)); + assert!(facet_views_are_adjacent(&facet1, &facet1)); + assert!(facet_views_are_adjacent(&facet2, &facet2)); + assert!(facet_views_are_adjacent(&facet3, &facet3)); // Different facets of the same tetrahedron should not be adjacent // (they have different sets of vertices) - assert!(!facet_views_are_adjacent(&facet0, &facet1).unwrap()); - assert!(!facet_views_are_adjacent(&facet0, &facet2).unwrap()); - assert!(!facet_views_are_adjacent(&facet0, &facet3).unwrap()); - assert!(!facet_views_are_adjacent(&facet1, &facet2).unwrap()); - assert!(!facet_views_are_adjacent(&facet1, &facet3).unwrap()); - assert!(!facet_views_are_adjacent(&facet2, &facet3).unwrap()); + assert!(!facet_views_are_adjacent(&facet0, &facet1)); + assert!(!facet_views_are_adjacent(&facet0, &facet2)); + assert!(!facet_views_are_adjacent(&facet0, &facet3)); + assert!(!facet_views_are_adjacent(&facet1, &facet2)); + assert!(!facet_views_are_adjacent(&facet1, &facet3)); + assert!(!facet_views_are_adjacent(&facet2, &facet3)); println!(" ✓ Single tetrahedron facet relationships correct"); } @@ -604,7 +594,7 @@ mod tests { for _ in 0..iterations { // This should be very fast since it just compares UUID sets - let _result = facet_views_are_adjacent(&facet1, &facet2).unwrap(); + let _result = facet_views_are_adjacent(&facet1, &facet2); } let duration = start.elapsed(); @@ -650,7 +640,7 @@ mod tests { // Facets from completely different geometries should not be adjacent assert!( - !facet_views_are_adjacent(&facet1, &facet2).unwrap(), + !facet_views_are_adjacent(&facet1, &facet2), "Facets from different geometries should not be adjacent" ); @@ -681,19 +671,11 @@ mod tests { let facet2 = FacetView::try_new(tds2, simplex2_key, 0).unwrap(); // Check if the UUID generation is deterministic based on coordinates - let facet1_vertex_uuids: FastHashSet<_> = facet1 - .vertices() - .expect("facet1 should have valid vertices") - .map(Vertex::uuid) - .collect(); - let facet2_vertex_uuids: FastHashSet<_> = facet2 - .vertices() - .expect("facet2 should have valid vertices") - .map(Vertex::uuid) - .collect(); + let facet1_vertex_uuids: FastHashSet<_> = facet1.vertices().map(Vertex::uuid).collect(); + let facet2_vertex_uuids: FastHashSet<_> = facet2.vertices().map(Vertex::uuid).collect(); let uuids_are_same = facet1_vertex_uuids == facet2_vertex_uuids; - let facets_are_adjacent = facet_views_are_adjacent(&facet1, &facet2).unwrap(); + let facets_are_adjacent = facet_views_are_adjacent(&facet1, &facet2); if uuids_are_same != facets_are_adjacent { let mut facet1_uuid_list: Vec<_> = facet1_vertex_uuids.iter().copied().collect(); @@ -759,7 +741,7 @@ mod tests { for facet_idx2 in 0..5 { let fv1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let fv2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if facet_views_are_adjacent(&fv1, &fv2).unwrap() { + if facet_views_are_adjacent(&fv1, &fv2) { found_adjacent = true; } else { found_non_adjacent = true; @@ -822,7 +804,7 @@ mod tests { for facet_idx2 in 0..6 { let fv1 = FacetView::try_new(tds1, simplex1_key, facet_idx1).unwrap(); let fv2 = FacetView::try_new(tds2, simplex2_key, facet_idx2).unwrap(); - if facet_views_are_adjacent(&fv1, &fv2).unwrap() { + if facet_views_are_adjacent(&fv1, &fv2) { found_adjacent = true; } else { found_non_adjacent = true; diff --git a/src/core/util/jaccard.rs b/src/core/util/jaccard.rs index 448ee8a4..f65fc562 100644 --- a/src/core/util/jaccard.rs +++ b/src/core/util/jaccard.rs @@ -4,8 +4,8 @@ use crate::core::facet::FacetError; use crate::core::tds::Tds; -use crate::core::traits::boundary_analysis::BoundaryAnalysis; use crate::core::traits::data_type::DataType; +use crate::core::traits::facet_incidence_analysis::FacetIncidenceAnalysis; use crate::core::triangulation::Triangulation; use crate::geometry::algorithms::convex_hull::{ConvexHull, ConvexHullConstructionError}; use crate::geometry::point::Point; @@ -385,10 +385,10 @@ where { let mut facet_ids = HashSet::new(); - // boundary_facets() returns Result + // one_sided_facets() returns TDS-level incidence candidates. // Wrap the underlying error for better diagnostics let boundary_facets = - tds.boundary_facets() + tds.one_sided_facets() .map_err(|e| FacetError::BoundaryFacetRetrievalFailed { source: std::sync::Arc::new(e), })?; @@ -396,7 +396,7 @@ where for facet_view in boundary_facets { let facet_view = facet_view?; // Use the existing FacetView::key() method - let facet_id = facet_view.key()?; + let facet_id = facet_view.key(); facet_ids.insert(facet_id); } @@ -461,11 +461,10 @@ pub fn extract_hull_facet_set( ) -> Result, ConvexHullConstructionError> { let mut facet_ids = HashSet::new(); - for facet_view in hull.facets(tri)? { + for facet_view in hull.try_facets(tri)? { let facet_id = facet_view .map_err(|source| ConvexHullConstructionError::FacetDataAccessFailed { source })? - .key() - .map_err(|source| ConvexHullConstructionError::FacetDataAccessFailed { source })?; + .key(); facet_ids.insert(facet_id); } diff --git a/src/core/validation.rs b/src/core/validation.rs index 6b26b93f..4d1c0b8b 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -43,8 +43,8 @@ //! //! - **Method**: [`Triangulation::is_valid()`](crate::prelude::triangulation::Triangulation::is_valid) //! - **Checks**: -//! - **Codimension-1 manifoldness**: exactly 1 boundary simplex or 2 interior simplices per facet -//! - **Codimension-2 boundary manifoldness**: the boundary is closed ("no boundary of boundary") +//! - **Codimension-1 incidence**: each facet is one-sided or two-sided +//! - **Topology-aware boundary manifoldness**: true boundary facets are closed ("no boundary of boundary") //! - Connectedness (single connected component in the simplex neighbor graph) //! - No isolated vertices (every vertex must be incident to at least one simplex) //! - Euler characteristic (χ = V - E + F - C matches expected topology) @@ -70,8 +70,8 @@ //! [`ValidationPolicy`](crate::prelude::validation::ValidationPolicy). //! //! Level 3 validation always checks: -//! - Codimension-1 facet degree (pseudomanifold condition: 1 boundary or 2 interior simplices per facet) -//! - Codimension-2 boundary manifoldness (closed boundary: "no boundary of boundary") +//! - Codimension-1 facet degree (pseudomanifold condition: one-sided or two-sided incidence) +//! - Topology-aware boundary manifoldness (closed true boundary: "no boundary of boundary") //! - Connectedness (single connected component in the simplex neighbor graph) //! - No isolated vertices (every vertex must be incident to at least one simplex) //! - Euler characteristic @@ -91,7 +91,8 @@ use crate::core::algorithms::incremental_insertion::{ InsertionError, InsertionTopologyValidationContext, }; use crate::core::collections::{ - FacetToSimplicesMap, FastHashSet, SimplexKeyBuffer, SimplexKeySet, fast_hash_set_with_capacity, + FacetToSimplicesMap, FastHashSet, SimplexKeyBuffer, SimplexKeySet, VertexKeyBuffer, + fast_hash_set_with_capacity, }; use crate::core::operations::{InsertionTelemetry, InsertionTelemetryMode, SuspicionFlags}; use crate::core::tds::{ @@ -101,14 +102,14 @@ use crate::core::tds::{ use crate::core::traits::data_type::DataType; use crate::core::triangulation::Triangulation; use crate::geometry::kernel::Kernel; -use crate::topology::characteristics::euler::{TopologyClassification, expected_chi_for}; -use crate::topology::characteristics::validation::validate_triangulation_euler_with_facet_to_simplices_map; +use crate::topology::characteristics::euler::TopologyClassification; +use crate::topology::characteristics::validation::validate_triangulation_euler_from_validated_facet_map; use crate::topology::manifold::{ - ManifoldError, validate_closed_boundary, validate_facet_degree, + ManifoldError, ValidatedFacetDegreeMap, validate_closed_boundary_from_validated_facet_map, validate_local_pseudomanifold_for_simplices, validate_ridge_links, - validate_ridge_links_for_simplices, validate_vertex_links, + validate_ridge_links_for_simplices, validate_vertex_links_from_validated_facet_map, }; -use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; +use crate::topology::traits::topological_space::{GlobalTopology, TopologyError, TopologyKind}; use std::time::Instant; use thiserror::Error; use uuid::Uuid; @@ -196,6 +197,47 @@ pub enum TriangulationValidationError { boundary_facet_count: usize, }, + /// A closed global topology contains a raw open one-sided facet. + #[error( + "Closed {topology:?} topology contains open boundary facet {facet_key:016x} at simplex {simplex_uuid}[{facet_index}]" + )] + BoundaryFacetInClosedTopology { + /// Declared global topology kind. + topology: TopologyKind, + /// Canonical facet key with open one-sided incidence. + facet_key: u64, + /// Simplex containing the open facet. + simplex_key: SimplexKey, + /// UUID of the simplex containing the open facet. + simplex_uuid: Uuid, + /// Facet index in the simplex. + facet_index: usize, + }, + + /// A non-periodic topology contains a periodic self-identification facet. + #[error( + "{topology:?} topology contains periodic self-identified facet {facet_key:016x} at simplex {simplex_uuid}[{facet_index}]" + )] + PeriodicIdentificationInNonPeriodicTopology { + /// Declared global topology kind. + topology: TopologyKind, + /// Canonical facet key with periodic self-identification. + facet_key: u64, + /// Simplex containing the periodic self-identification. + simplex_key: SimplexKey, + /// UUID of the simplex containing the periodic self-identification. + simplex_uuid: Uuid, + /// Facet index in the simplex. + facet_index: usize, + }, + + /// A live ridge candidate did not occur in any D-simplex. + #[error("Ridge candidate {ridge_vertices:?} is not present in the triangulation")] + RidgeNotFound { + /// Canonical quotient-space ridge vertices that had an empty simplex star. + ridge_vertices: VertexKeyBuffer, + }, + /// A ridge's link graph is not a 1-manifold (path or cycle). /// /// This is required for PL-manifold validation. @@ -314,6 +356,35 @@ impl TryFrom for TriangulationValidationError { ridge_key, boundary_facet_count, }), + ManifoldError::BoundaryFacetInClosedTopology { + topology, + facet_key, + simplex_key, + simplex_uuid, + facet_index, + } => Ok(Self::BoundaryFacetInClosedTopology { + topology, + facet_key, + simplex_key, + simplex_uuid, + facet_index, + }), + ManifoldError::PeriodicIdentificationInNonPeriodicTopology { + topology, + facet_key, + simplex_key, + simplex_uuid, + facet_index, + } => Ok(Self::PeriodicIdentificationInNonPeriodicTopology { + topology, + facet_key, + simplex_key, + simplex_uuid, + facet_index, + }), + ManifoldError::RidgeNotFound { ridge_vertices } => { + Ok(Self::RidgeNotFound { ridge_vertices }) + } ManifoldError::RidgeLinkNotManifold { ridge_key, link_vertex_count, @@ -359,6 +430,22 @@ impl From for InvariantError { } } +/// Preserves validation-layer error provenance from topology helper failures. +/// +/// [`Triangulation::validate`] exposes Level 1-2 failures as [`InvariantError::Tds`] +/// and Level 3 failures as [`InvariantError::Triangulation`]. Euler helpers +/// return [`TopologyError`], so this adapter maps support-data failures back to +/// TDS errors and semantic boundary failures back through [`ManifoldError`]. +fn invariant_error_from_topology_error(err: TopologyError) -> InvariantError { + match err { + TopologyError::FacetMapBuild { source } + | TopologyError::BoundaryFacetEnumeration { source } + | TopologyError::BoundaryFacetCount { source } => InvariantError::Tds(source), + TopologyError::BoundaryFacetSimplexAccess { source } => InvariantError::Tds(source.into()), + TopologyError::BoundaryClassification { source } => InvariantError::from(*source), + } +} + /// Policy controlling when the triangulation runs global validation passes. /// /// Validation can be expensive (O(N×D²) or worse), so this allows callers to trade @@ -691,10 +778,170 @@ impl Triangulation { self.global_topology.kind() } - /// Sets runtime global topology metadata on the triangulation. - #[inline] - pub const fn set_global_topology(&mut self, global_topology: GlobalTopology) { + /// Sets runtime global topology metadata after validating it against current topology. + /// + /// The update is atomic: if the current triangulation does not satisfy the + /// requested global topology, the previous metadata is restored before the + /// error is returned. + /// + /// # Errors + /// + /// Returns [`InvariantError::Tds`] if lower-level structure is invalid while + /// checking topology, or [`InvariantError::Triangulation`] when Level 3 + /// topology violates the requested metadata, for example when Euclidean + /// boundary facets are relabeled as closed spherical or toroidal topology. + /// The previous topology metadata is restored before the error is returned. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::Triangulation; + /// use delaunay::prelude::geometry::FastKernel; + /// use delaunay::prelude::tds::InvariantError; + /// use delaunay::prelude::topology::spaces::GlobalTopology; + /// + /// # fn main() -> Result<(), InvariantError> { + /// let mut tri: Triangulation, (), (), 2> = + /// Triangulation::new_empty(FastKernel::new()); + /// + /// tri.try_set_global_topology(GlobalTopology::Euclidean)?; + /// assert_eq!(tri.global_topology(), GlobalTopology::Euclidean); + /// # Ok(()) + /// # } + /// ``` + pub fn try_set_global_topology( + &mut self, + global_topology: GlobalTopology, + ) -> Result<(), InvariantError> { + let previous = self.global_topology; + if previous == global_topology { + return Ok(()); + } + self.global_topology = global_topology; + if let Err(err) = self.validate_topology_core() { + self.global_topology = previous; + return Err(err); + } + Ok(()) + } + + /// Shared Level-3 topology validation sequence used by both [`is_valid`](Self::is_valid) + /// and [`is_valid_topology_only`](Self::is_valid_topology_only). + /// + /// Checks connectedness, manifold facet degree, closed boundary, ridge/vertex + /// links (when required by the topology guarantee), isolated vertices, and + /// Euler characteristic. + fn validate_topology_core(&self) -> Result<(), InvariantError> { + // 1. Connectedness + // + // Checked first because it is cheaper than building the facet-to-simplices map + // (which requires O(N·D) hash-map insertions plus allocations) and avoids + // all subsequent work when the triangulation is disconnected. + self.validate_global_connectedness()?; + + // 2. Manifold facet multiplicity (codimension-1 pseudomanifold condition) + // + // Build the facet map once and reuse it for manifold validation and Euler counting. + let facet_to_simplices: FacetToSimplicesMap = self.tds.build_facet_to_simplices_map()?; + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices)?; + self.validate_topology_core_from_validated_facet_map(facet_to_simplices) + } + + fn validate_topology_core_from_validated_facet_map( + &self, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + ) -> Result<(), InvariantError> { + // 2b. Boundary manifoldness in codimension 2: the boundary must be "closed" + // (i.e., its ridges must have degree 2 within boundary facets). + validate_closed_boundary_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + )?; + + // 2c. Ridge-link validation for PLManifold/PLManifoldStrict (fast, catches many PL issues). + if self.topology_guarantee.requires_ridge_links() { + validate_ridge_links(&self.tds)?; + } + // 2d. PL-manifold vertex-link condition during insertion (strict mode). + if self + .topology_guarantee + .requires_vertex_links_during_insertion() + { + validate_vertex_links_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + )?; + } + + // 3. Vertex incidence (manifold invariant): every vertex must be incident to at least one simplex. + self.validate_no_isolated_vertices()?; + + // 4. Euler characteristic using the topology module + let topology_result = validate_triangulation_euler_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + ) + .map_err(invariant_error_from_topology_error)?; + + if let Some(exp) = topology_result.expected + && topology_result.chi != exp + { + return Err(TriangulationValidationError::EulerCharacteristicMismatch { + computed: topology_result.chi, + expected: exp, + classification: topology_result.classification, + } + .into()); + } + + Ok(()) + } + + /// Validates that the triangulation's simplex neighbor graph is a single connected component. + /// + /// Delegates to [`Tds::is_connected`](crate::prelude::tds::Tds::is_connected), an O(N·D) BFS + /// over neighbor pointers. + pub(crate) fn validate_global_connectedness(&self) -> Result<(), TriangulationValidationError> { + if !self.tds.is_connected() { + return Err(TriangulationValidationError::Disconnected { + simplex_count: self.tds.number_of_simplices(), + }); + } + Ok(()) + } + + /// Validates that every vertex is incident to at least one simplex. + /// + /// Isolated vertices are allowed at the TDS (structural) layer, but they violate the + /// manifold invariants checked at the topology (Level 3) layer. + pub(crate) fn validate_no_isolated_vertices(&self) -> Result<(), TriangulationValidationError> { + if self.tds.number_of_vertices() == 0 { + return Ok(()); + } + + let mut vertices_in_simplices: FastHashSet = + fast_hash_set_with_capacity(self.tds.number_of_vertices()); + + for (_simplex_key, simplex) in self.tds.simplices() { + for &vk in simplex.vertices() { + vertices_in_simplices.insert(vk); + } + } + + for (vk, vertex) in self.tds.vertices() { + if !vertices_in_simplices.contains(&vk) { + return Err(TriangulationValidationError::IsolatedVertex { + vertex_key: vk, + vertex_uuid: vertex.uuid(), + }); + } + } + + Ok(()) } /// Returns the insertion-time global topology validation policy used by the triangulation. @@ -898,8 +1145,8 @@ where /// Validates topological invariants of the triangulation (Level 3). /// /// This checks the triangulation/topology layer **only**: - /// - Codimension-1 pseudomanifold condition: each facet is incident to 1 (boundary) or 2 (interior) simplices - /// - Codimension-2 boundary manifoldness: the boundary must be closed ("no boundary of boundary") + /// - Codimension-1 pseudomanifold condition: each facet is one-sided or two-sided. + /// - Topology-aware boundary manifoldness: true boundary facets must be closed ("no boundary of boundary"). /// - Geometric orientation-sign consistency for stored simplices (signed determinant > 0) /// - Ridge-link validation (when `topology_guarantee.requires_ridge_links()`) /// - Vertex-link validation during insertion (when `topology_guarantee.requires_vertex_links_during_insertion()`) @@ -970,87 +1217,6 @@ where self.validate_topology_core() } - /// Shared Level-3 topology validation sequence used by both [`is_valid`](Self::is_valid) - /// and [`is_valid_topology_only`](Self::is_valid_topology_only). - /// - /// Checks connectedness, manifold facet degree, closed boundary, ridge/vertex - /// links (when required by the topology guarantee), isolated vertices, and - /// Euler characteristic. - fn validate_topology_core(&self) -> Result<(), InvariantError> { - // 1. Connectedness - // - // Checked first because it is cheaper than building the facet-to-simplices map - // (which requires O(N·D) hash-map insertions plus allocations) and avoids - // all subsequent work when the triangulation is disconnected. - self.validate_global_connectedness()?; - - // 2. Manifold facet multiplicity (codimension-1 pseudomanifold condition) - // - // Build the facet map once and reuse it for manifold validation and Euler counting. - let facet_to_simplices: FacetToSimplicesMap = self.tds.build_facet_to_simplices_map()?; - self.validate_topology_core_with_facet_to_simplices_map(&facet_to_simplices) - } - - fn validate_topology_core_with_facet_to_simplices_map( - &self, - facet_to_simplices: &FacetToSimplicesMap, - ) -> Result<(), InvariantError> { - validate_facet_degree(facet_to_simplices)?; - - // 2b. Boundary manifoldness in codimension 2: the boundary must be "closed" - // (i.e., its ridges must have degree 2 within boundary facets). - validate_closed_boundary(&self.tds, facet_to_simplices)?; - - // 2c. Ridge-link validation for PLManifold/PLManifoldStrict (fast, catches many PL issues). - if self.topology_guarantee.requires_ridge_links() { - validate_ridge_links(&self.tds)?; - } - // 2d. PL-manifold vertex-link condition during insertion (strict mode). - if self - .topology_guarantee - .requires_vertex_links_during_insertion() - { - validate_vertex_links(&self.tds, facet_to_simplices)?; - } - - // 3. Vertex incidence (manifold invariant): every vertex must be incident to at least one simplex. - self.validate_no_isolated_vertices()?; - - // 4. Euler characteristic using the topology module - let topology_result = - validate_triangulation_euler_with_facet_to_simplices_map(&self.tds, facet_to_simplices); - - // Override the heuristic classification when the caller has declared a - // non-Euclidean global topology. The heuristic classifies any closed - // mesh (no boundary facets) as `ClosedSphere(D)`, but a toroidal mesh - // also has no boundary — its expected χ is 0, not 1+(-1)^D. - let (classification, expected) = match self.global_topology { - GlobalTopology::Toroidal { .. } - if matches!( - topology_result.classification, - TopologyClassification::ClosedSphere(_) - ) => - { - let cls = TopologyClassification::ClosedToroid(D); - (cls, expected_chi_for(&cls)) - } - _ => (topology_result.classification, topology_result.expected), - }; - - if let Some(exp) = expected - && topology_result.chi != exp - { - return Err(TriangulationValidationError::EulerCharacteristicMismatch { - computed: topology_result.chi, - expected: exp, - classification, - } - .into()); - } - - Ok(()) - } - /// Validates vertex-link condition at construction completion. /// /// This should be called once after batch construction is complete to certify @@ -1099,13 +1265,14 @@ where } let facet_to_simplices: FacetToSimplicesMap = self.tds.build_facet_to_simplices_map()?; - self.validate_at_completion_with_facet_to_simplices_map(&facet_to_simplices)?; + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices)?; + self.validate_at_completion_from_validated_facet_map(facet_to_simplices)?; Ok(()) } - fn validate_at_completion_with_facet_to_simplices_map( + fn validate_at_completion_from_validated_facet_map( &self, - facet_to_simplices: &FacetToSimplicesMap, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, ) -> Result<(), InvariantError> { if !self .topology_guarantee @@ -1118,7 +1285,11 @@ where return Ok(()); } - validate_vertex_links(&self.tds, facet_to_simplices)?; + validate_vertex_links_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + )?; Ok(()) } @@ -1172,11 +1343,12 @@ where self.tds.validate()?; self.validate_global_connectedness()?; let facet_to_simplices: FacetToSimplicesMap = self.tds.build_facet_to_simplices_map()?; - self.validate_topology_core_with_facet_to_simplices_map(&facet_to_simplices)?; + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices)?; + self.validate_topology_core_from_validated_facet_map(facet_to_simplices)?; // Check geometric orientation after manifold/link checks so topology-specific // diagnostics surface first when multiple invariants are violated. self.validate_geometric_simplex_orientation()?; - self.validate_at_completion_with_facet_to_simplices_map(&facet_to_simplices) + self.validate_at_completion_from_validated_facet_map(facet_to_simplices) } /// Generate a comprehensive validation report for Levels 1–3. @@ -1256,49 +1428,6 @@ where } } - /// Validates that the triangulation's simplex neighbor graph is a single connected component. - /// - /// Delegates to [`Tds::is_connected`](crate::prelude::tds::Tds::is_connected), an O(N·D) BFS - /// over neighbor pointers. - pub(crate) fn validate_global_connectedness(&self) -> Result<(), TriangulationValidationError> { - if !self.tds.is_connected() { - return Err(TriangulationValidationError::Disconnected { - simplex_count: self.tds.number_of_simplices(), - }); - } - Ok(()) - } - - /// Validates that every vertex is incident to at least one simplex. - /// - /// Isolated vertices are allowed at the TDS (structural) layer, but they violate the - /// manifold invariants checked at the topology (Level 3) layer. - pub(crate) fn validate_no_isolated_vertices(&self) -> Result<(), TriangulationValidationError> { - if self.tds.number_of_vertices() == 0 { - return Ok(()); - } - - let mut vertices_in_simplices: FastHashSet = - fast_hash_set_with_capacity(self.tds.number_of_vertices()); - - for (_simplex_key, simplex) in self.tds.simplices() { - for &vk in simplex.vertices() { - vertices_in_simplices.insert(vk); - } - } - - for (vk, vertex) in self.tds.vertices() { - if !vertices_in_simplices.contains(&vk) { - return Err(TriangulationValidationError::IsolatedVertex { - vertex_key: vk, - vertex_uuid: vertex.uuid(), - }); - } - } - - Ok(()) - } - /// Convert an [`InvariantError`] into the appropriate [`InsertionError`] variant. /// /// - `InvariantError::Tds(e)` → `InsertionError::TopologyValidation(e)` @@ -1324,8 +1453,12 @@ where } let facet_to_simplices: FacetToSimplicesMap = self.tds.build_facet_to_simplices_map()?; - validate_facet_degree(&facet_to_simplices)?; - validate_closed_boundary(&self.tds, &facet_to_simplices)?; + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices)?; + validate_closed_boundary_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + )?; if self.topology_guarantee.requires_ridge_links() { validate_ridge_links(&self.tds)?; @@ -1335,7 +1468,11 @@ where .topology_guarantee .requires_vertex_links_during_insertion() { - validate_vertex_links(&self.tds, &facet_to_simplices)?; + validate_vertex_links_from_validated_facet_map( + &self.tds, + facet_to_simplices, + self.global_topology, + )?; } // Keep geometric orientation non-negotiable during incremental insertion, @@ -1467,7 +1604,7 @@ where self.tds .validate_coherent_orientation_for_simplices(simplices)?; - validate_local_pseudomanifold_for_simplices(&self.tds, simplices)?; + validate_local_pseudomanifold_for_simplices(&self.tds, self.global_topology, simplices)?; if self.topology_guarantee.requires_ridge_links() { validate_ridge_links_for_simplices(&self.tds, simplices.iter().copied())?; @@ -1596,6 +1733,7 @@ mod tests { use crate::core::algorithms::incremental_insertion::CavityFillingError; use crate::core::algorithms::incremental_insertion::repair_neighbor_pointers; use crate::core::collections::NeighborBuffer; + use crate::core::facet::FacetError; use crate::core::operations::InsertionOutcome; use crate::core::simplex::Simplex; use crate::core::tds::{GeometricError, NeighborValidationError, Tds}; @@ -1608,7 +1746,7 @@ mod tests { use crate::triangulation::DelaunayTriangulation; use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; use slotmap::KeyData; - use std::assert_matches; + use std::{assert_matches, iter}; fn synthetic_delaunay_verification_error( message: &str, @@ -1903,6 +2041,16 @@ mod tests { } ); + let ridge_vertex = VertexKey::from(KeyData::from_ffi(7)); + assert_matches!( + TriangulationValidationError::try_from(ManifoldError::RidgeNotFound { + ridge_vertices: iter::once(ridge_vertex).collect() + }) + .unwrap(), + TriangulationValidationError::RidgeNotFound { ridge_vertices } + if ridge_vertices.as_slice() == [ridge_vertex] + ); + assert_matches!( TriangulationValidationError::try_from(ManifoldError::RidgeLinkNotManifold { ridge_key: 0x00ab_cdef, @@ -1946,6 +2094,149 @@ mod tests { ); } + #[test] + fn triangulation_validation_error_preserves_boundary_classification_detail() { + let closed_topology_simplex_key = SimplexKey::from(KeyData::from_ffi(11)); + let closed_topology_simplex_uuid = Uuid::from_u128(0x1111); + assert_matches!( + TriangulationValidationError::try_from( + ManifoldError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + facet_key: 0xfeed_face, + simplex_key: closed_topology_simplex_key, + simplex_uuid: closed_topology_simplex_uuid, + facet_index: 2 + } + ) + .unwrap(), + TriangulationValidationError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + facet_key: 0xfeed_face, + simplex_key, + simplex_uuid, + facet_index: 2 + } if simplex_key == closed_topology_simplex_key + && simplex_uuid == closed_topology_simplex_uuid + ); + + let non_periodic_simplex_key = SimplexKey::from(KeyData::from_ffi(12)); + let non_periodic_simplex_uuid = Uuid::from_u128(0x2222); + assert_matches!( + TriangulationValidationError::try_from( + ManifoldError::PeriodicIdentificationInNonPeriodicTopology { + topology: TopologyKind::Euclidean, + facet_key: 0xdeca_fbad, + simplex_key: non_periodic_simplex_key, + simplex_uuid: non_periodic_simplex_uuid, + facet_index: 1 + } + ) + .unwrap(), + TriangulationValidationError::PeriodicIdentificationInNonPeriodicTopology { + topology: TopologyKind::Euclidean, + facet_key: 0xdeca_fbad, + simplex_key, + simplex_uuid, + facet_index: 1 + } if simplex_key == non_periodic_simplex_key + && simplex_uuid == non_periodic_simplex_uuid + ); + } + + #[test] + fn topology_error_adapter_preserves_validation_layering() { + let facet_map_err = TdsError::InconsistentDataStructure { + message: "facet map".to_string(), + }; + assert_eq!( + invariant_error_from_topology_error(TopologyError::FacetMapBuild { + source: facet_map_err.clone() + }), + InvariantError::Tds(facet_map_err) + ); + + let boundary_enumeration_err = TdsError::InconsistentDataStructure { + message: "boundary enumeration".to_string(), + }; + assert_eq!( + invariant_error_from_topology_error(TopologyError::BoundaryFacetEnumeration { + source: boundary_enumeration_err.clone() + }), + InvariantError::Tds(boundary_enumeration_err) + ); + + let boundary_count_err = TdsError::InconsistentDataStructure { + message: "boundary count".to_string(), + }; + assert_eq!( + invariant_error_from_topology_error(TopologyError::BoundaryFacetCount { + source: boundary_count_err.clone() + }), + InvariantError::Tds(boundary_count_err) + ); + + assert_eq!( + invariant_error_from_topology_error(TopologyError::BoundaryFacetSimplexAccess { + source: FacetError::SimplexNotFoundInTriangulation + }), + InvariantError::Tds(TdsError::FacetError( + FacetError::SimplexNotFoundInTriangulation + )) + ); + + let simplex_key = SimplexKey::from(KeyData::from_ffi(13)); + let simplex_uuid = Uuid::from_u128(0x3333); + assert_matches!( + invariant_error_from_topology_error(TopologyError::BoundaryClassification { + source: Box::new(ManifoldError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Toroidal, + facet_key: 0x1234_5678, + simplex_key, + simplex_uuid, + facet_index: 3 + }) + }), + InvariantError::Triangulation( + TriangulationValidationError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Toroidal, + facet_key: 0x1234_5678, + simplex_key: observed_simplex_key, + simplex_uuid: observed_simplex_uuid, + facet_index: 3 + } + ) if observed_simplex_key == simplex_key && observed_simplex_uuid == simplex_uuid + ); + } + + #[test] + fn try_global_topology_setter_rejects_closed_metadata_for_euclidean_boundary() { + let vertices: Vec> = vec![ + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + ]; + let tds = + Triangulation::, (), (), 2>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + + let err = tri + .try_set_global_topology(GlobalTopology::Spherical) + .unwrap_err(); + + assert_matches!( + err, + InvariantError::Triangulation( + TriangulationValidationError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + .. + } + ) + ); + assert_eq!(tri.global_topology(), GlobalTopology::Euclidean); + assert!(tri.is_valid().is_ok()); + } + #[test] fn validation_policy_should_validate_matrix() { let clean = SuspicionFlags::default(); @@ -2352,6 +2643,17 @@ mod tests { } ) ); + + let ridge_vertex = VertexKey::from(KeyData::from_ffi(42)); + let inv = InvariantError::from(ManifoldError::RidgeNotFound { + ridge_vertices: iter::once(ridge_vertex).collect(), + }); + assert_matches!( + inv, + InvariantError::Triangulation(TriangulationValidationError::RidgeNotFound { + ridge_vertices + }) if ridge_vertices.as_slice() == [ridge_vertex] + ); } #[test] @@ -2708,8 +3010,14 @@ mod tests { Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); tri.validate_global_connectedness().unwrap(); let facet_to_simplices = tri.tds.build_facet_to_simplices_map().unwrap(); - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tri.tds, &facet_to_simplices).unwrap(); + let facet_to_simplices = + ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices).unwrap(); + validate_closed_boundary_from_validated_facet_map( + &tri.tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); tri.set_topology_guarantee(TopologyGuarantee::PLManifoldStrict); @@ -2927,10 +3235,15 @@ mod tests { let tri = Triangulation::, (), (), 1>::new_with_tds(FastKernel::new(), tds); let facet_to_simplices = tri.tds.build_facet_to_simplices_map().unwrap(); - validate_facet_degree(&facet_to_simplices).unwrap(); + let facet_to_simplices = + ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices).unwrap(); - let topology = - validate_triangulation_euler_with_facet_to_simplices_map(&tri.tds, &facet_to_simplices); + let topology = validate_triangulation_euler_from_validated_facet_map( + &tri.tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); assert_eq!(topology.classification, TopologyClassification::Ball(1)); assert_eq!(topology.expected, Some(1)); assert_eq!(topology.chi, 1); diff --git a/src/delaunay/builder.rs b/src/delaunay/builder.rs index f1d2f576..61132eb4 100644 --- a/src/delaunay/builder.rs +++ b/src/delaunay/builder.rs @@ -30,7 +30,7 @@ //! vertices into the fundamental domain `[0, L_i)` before passing them to the standard //! Euclidean constructor. The resulting triangulation is a valid Euclidean Delaunay //! triangulation of the canonicalized point set; it does **not** identify opposite -//! boundary facets. +//! boundary facets and cannot be combined with non-Euclidean global topology metadata. //! //! # Examples //! @@ -606,13 +606,12 @@ pub struct DelaunayTriangulationBuilder<'v, U, const D: usize> { /// When set, the builder constructs a triangulation from the given vertices and /// simplices directly, bypassing point-insertion-based Delaunay construction. explicit_simplices: Option>, - /// Runtime global topology metadata. + /// Explicit runtime global topology metadata requested by the caller. /// - /// When set to a non-Euclidean topology (e.g. `Toroidal`), Euler characteristic - /// validation uses the appropriate expectation (e.g. χ = 0 for a torus). - /// This is **metadata only** and does not trigger any construction-time - /// coordinate transformation. - global_topology: GlobalTopology, + /// `None` means the construction path supplies its own default metadata: + /// Euclidean paths default to [`GlobalTopology::Euclidean`], while periodic + /// image-point construction derives closed toroidal metadata from its domain. + requested_global_topology: Option>, } enum BuilderTopology { @@ -670,7 +669,7 @@ impl<'v, U, const D: usize> DelaunayTriangulationBuilder<'v, U, D> { topology_guarantee: TopologyGuarantee::DEFAULT, construction_options: ConstructionOptions::default(), explicit_simplices: None, - global_topology: GlobalTopology::DEFAULT, + requested_global_topology: None, } } @@ -884,13 +883,13 @@ impl<'v, U, const D: usize> DelaunayTriangulationBuilder<'v, U, D> { self } - /// Enables canonicalized toroidal topology without periodic quotient rewiring. + /// Enables toroidal coordinate canonicalization without periodic quotient rewiring. /// /// Input vertices are canonicalized into `[0, L_i)` per dimension before the - /// triangulation is built. The resulting triangulation is a valid Euclidean - /// Delaunay triangulation of the wrapped point set; boundary facets are - /// **not** rewired. Use [`.try_toroidal()`](Self::try_toroidal) for the true - /// periodic quotient path. + /// triangulation is built. The resulting triangulation remains Euclidean: + /// boundary facets are **not** rewired, and non-Euclidean global topology + /// metadata is rejected at build time. Use [`.try_toroidal()`](Self::try_toroidal) + /// for the true periodic quotient path with closed toroidal topology. /// /// # Arguments /// @@ -933,7 +932,7 @@ impl<'v, U, const D: usize> DelaunayTriangulationBuilder<'v, U, D> { Ok(self) } - /// Enables canonicalized toroidal topology from an already-validated domain. + /// Enables toroidal coordinate canonicalization from an already-validated domain. /// /// This infallible setter is for callers that already hold a /// [`ToroidalDomain`]. Use [`Self::try_canonicalized_toroidal`] at raw @@ -1011,13 +1010,20 @@ impl<'v, U, const D: usize> DelaunayTriangulationBuilder<'v, U, D> { /// mesh instead of χ = 2 (the sphere default). /// /// This is **metadata only** and does not trigger any coordinate - /// canonicalization or image-point construction. Explicit non-Euclidean - /// connectivity is rejected until Level 4 validation supports quotient - /// topology. For construction-time toroidal processing, use - /// [`.try_toroidal()`](Self::try_toroidal) or - /// [`.try_canonicalized_toroidal()`](Self::try_canonicalized_toroidal) instead. - /// - /// Defaults to [`GlobalTopology::Euclidean`]. + /// canonicalization or image-point construction. Plain Euclidean, + /// canonicalized toroidal, and explicit-simplex construction reject + /// non-Euclidean metadata because those paths do not build closed quotient + /// connectivity. For construction-time toroidal processing, use + /// [`.try_toroidal()`](Self::try_toroidal) for true toroidal topology, or + /// [`.try_canonicalized_toroidal()`](Self::try_canonicalized_toroidal) for + /// wrapping-only Euclidean construction. If explicit metadata is supplied on + /// the periodic image-point path, it must exactly match the toroidal topology + /// derived from [`.try_toroidal()`](Self::try_toroidal). + /// + /// When this setter is not called, Euclidean, canonicalized, and explicit + /// construction paths use [`GlobalTopology::Euclidean`]. The periodic + /// image-point path derives [`GlobalTopology::Toroidal`] metadata from + /// [`.try_toroidal()`](Self::try_toroidal) instead. /// /// # Examples /// @@ -1051,7 +1057,7 @@ impl<'v, U, const D: usize> DelaunayTriangulationBuilder<'v, U, D> { /// ``` #[must_use] pub const fn global_topology(mut self, global_topology: GlobalTopology) -> Self { - self.global_topology = global_topology; + self.requested_global_topology = Some(global_topology); self } @@ -1349,23 +1355,26 @@ where self.vertices, simplices.as_slice(), self.topology_guarantee, - self.global_topology, + self.global_topology_or_default(), ); } match self.topology { BuilderTopology::Euclidean => { + Self::reject_euclidean_non_euclidean_topology(self.global_topology_or_default())?; // Euclidean path: delegate directly. - let mut dt = DelaunayTriangulation::try_with_topology_guarantee_and_options( + let dt = DelaunayTriangulation::try_with_topology_guarantee_and_options( kernel, self.vertices, self.topology_guarantee, self.construction_options, )?; - dt.set_global_topology(self.global_topology); Ok(dt) } BuilderTopology::Canonicalized(domain) => { + Self::reject_canonicalized_non_euclidean_topology( + self.global_topology_or_default(), + )?; let topology = GlobalTopology::Toroidal { domain, mode: ToroidalConstructionMode::Canonicalized, @@ -1374,20 +1383,20 @@ where Self::validate_topology_model(&topology_model)?; // Canonicalized toroidal construction: canonicalize then delegate. let canonical = Self::canonicalize_vertices(self.vertices, &topology_model)?; - let mut dt = DelaunayTriangulation::try_with_topology_guarantee_and_options( + let dt = DelaunayTriangulation::try_with_topology_guarantee_and_options( kernel, &canonical, self.topology_guarantee, self.construction_options, )?; - dt.set_global_topology(topology); Ok(dt) } BuilderTopology::PeriodicImagePoint(domain) => { - let topology = GlobalTopology::Toroidal { - domain, - mode: ToroidalConstructionMode::PeriodicImagePoint, - }; + let topology = Self::periodic_image_global_topology(domain); + Self::reject_periodic_conflicting_global_topology( + self.requested_global_topology, + topology, + )?; let topology_model = topology.model(); Self::validate_topology_model(&topology_model)?; if !topology_model.supports_periodic_facet_signatures() { @@ -1407,7 +1416,6 @@ where self.topology_guarantee, self.construction_options, )?; - dt.set_global_topology(topology); dt.tri .normalize_and_promote_positive_orientation() .map_err(|source| { @@ -1532,13 +1540,15 @@ where // Construct the DT first so the Triangulation-layer helpers // (orientation promotion, topology checks) operate on the assembled // complex. - let mut candidate = - DelaunayTriangulationCandidate::assemble(tds, kernel.clone(), topology_guarantee); - - // Set global topology metadata before validation so that + // Include global topology metadata before validation so that // validate_topology_core() uses the correct Euler characteristic // expectation (e.g. χ = 0 for toroidal instead of χ = 2 for sphere). - candidate.set_global_topology(global_topology); + let mut candidate = DelaunayTriangulationCandidate::assemble( + tds, + kernel.clone(), + topology_guarantee, + global_topology, + ); // --- Normalize orientation and promote to positive --- // @@ -1640,6 +1650,110 @@ where .into()) } + /// Returns the requested topology metadata or the Euclidean builder default. + const fn global_topology_or_default(&self) -> GlobalTopology { + match self.requested_global_topology { + Some(global_topology) => global_topology, + None => GlobalTopology::DEFAULT, + } + } + + /// Builds the global topology metadata derived by periodic image-point construction. + const fn periodic_image_global_topology(domain: ToroidalDomain) -> GlobalTopology { + GlobalTopology::Toroidal { + domain, + mode: ToroidalConstructionMode::PeriodicImagePoint, + } + } + + /// Rejects topology metadata that would misclassify Euclidean construction boundaries. + /// + /// The plain Euclidean builder path does not create quotient-space neighbor + /// links, so accepting closed metadata here would make boundary queries and + /// Euler validation describe topology that was never assembled. + const fn reject_euclidean_non_euclidean_topology( + global_topology: GlobalTopology, + ) -> Result<(), DelaunayTriangulationConstructionError> { + if global_topology.is_euclidean() { + return Ok(()); + } + + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::EuclideanUnsupportedGlobalTopology { + topology: global_topology.kind(), + }, + )) + } + + /// Rejects topology metadata that would misclassify canonicalized Euclidean boundaries. + /// + /// Canonicalization wraps coordinates into a toroidal domain but intentionally + /// leaves connectivity Euclidean. True closed toroidal topology must use the + /// periodic image-point builder path. + const fn reject_canonicalized_non_euclidean_topology( + global_topology: GlobalTopology, + ) -> Result<(), DelaunayTriangulationConstructionError> { + if global_topology.is_euclidean() { + return Ok(()); + } + + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { + topology: global_topology.kind(), + }, + )) + } + + /// Rejects explicit metadata that conflicts with derived periodic topology. + /// + /// Periodic image-point construction derives the only valid closed toroidal + /// metadata from its validated domain. An explicitly supplied matching value + /// is harmless, but a different value would make the builder silently discard + /// caller intent. + fn reject_periodic_conflicting_global_topology( + requested_global_topology: Option>, + derived_global_topology: GlobalTopology, + ) -> Result<(), DelaunayTriangulationConstructionError> { + let Some(requested_global_topology) = requested_global_topology else { + return Ok(()); + }; + + if requested_global_topology == derived_global_topology { + return Ok(()); + } + + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: requested_global_topology.kind(), + requested_mode: Self::toroidal_mode(requested_global_topology), + requested_periods: Self::toroidal_periods(requested_global_topology), + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods: Self::toroidal_periods(derived_global_topology) + .unwrap_or_default(), + }, + )) + } + + /// Extracts toroidal construction mode from topology metadata for diagnostics. + const fn toroidal_mode(global_topology: GlobalTopology) -> Option { + match global_topology { + GlobalTopology::Toroidal { mode, .. } => Some(mode), + GlobalTopology::Euclidean | GlobalTopology::Spherical | GlobalTopology::Hyperbolic => { + None + } + } + } + + /// Extracts toroidal periods from topology metadata for diagnostics. + fn toroidal_periods(global_topology: GlobalTopology) -> Option> { + match global_topology { + GlobalTopology::Toroidal { domain, .. } => Some(domain.periods().to_vec()), + GlobalTopology::Euclidean | GlobalTopology::Spherical | GlobalTopology::Hyperbolic => { + None + } + } + } + /// Builds a true periodic (toroidal) Delaunay triangulation using the 3^D image-point method. /// /// **Algorithm** (see module-level doc for periodic image-point details): @@ -1681,6 +1795,16 @@ where { // Keep `build_periodic` self-protecting even if future call paths bypass outer validation. Self::validate_topology_model(topology_model)?; + if D > 3 { + return Err( + TriangulationConstructionError::UnsupportedPeriodicDimension { + dimension: D, + max_validated_dimension: 3, + tracking_issue: 416, + } + .into(), + ); + } if !topology_model.supports_periodic_facet_signatures() { return Err( TriangulationConstructionError::PeriodicImageUnsupportedTopology { @@ -1695,16 +1819,11 @@ where topology: topology_model.kind(), } })?; - if D > 3 { - return Err( - TriangulationConstructionError::UnsupportedPeriodicDimension { - dimension: D, - max_validated_dimension: 3, - tracking_issue: 416, - } - .into(), - ); - } + let domain_periods = domain.into_periods(); + let global_topology = GlobalTopology::Toroidal { + domain, + mode: ToroidalConstructionMode::PeriodicImagePoint, + }; let n = canonical_vertices.len(); let min_points = 2 * D + 1; if n < min_points { @@ -1751,7 +1870,7 @@ where let orig_coords = v.point().coords(); let mut coords = [0_f64; D]; for i in 0..D { - let domain_i = domain[i]; + let domain_i = domain_periods[i]; let orig = orig_coords[i] .to_f64() .expect("canonical coordinate is finite and convertible"); @@ -1788,7 +1907,7 @@ where for (canon_idx, v) in canonical_vertices.iter().enumerate() { let mut new_coords = [0.0; D]; for i in 0..D { - let shift_f64 = >::from(offset[i]) * domain[i]; + let shift_f64 = >::from(offset[i]) * domain_periods[i]; let jitter_f64 = if is_canonical { 0.0 } else { @@ -1796,7 +1915,7 @@ where (::from(jitter_units) .expect("jitter fits in f64") / TWO_POW_52_F64) - * domain[i] + * domain_periods[i] }; new_coords[i] = canonical_f64[canon_idx][i] + shift_f64 + jitter_f64; } @@ -2020,7 +2139,7 @@ where let center = circumcenter(&points).ok()?; for (axis, coord) in center.coords().iter().enumerate() { let center_coord = coord.to_f64()?; - let period = domain[axis]; + let period = domain_periods[axis]; if !(center_coord >= 0.0 && center_coord < period) { return Some(false); } @@ -2592,8 +2711,12 @@ where .into()); } - let candidate = - DelaunayTriangulationCandidate::assemble(tds_mut, kernel.clone(), topology_guarantee); + let candidate = DelaunayTriangulationCandidate::assemble( + tds_mut, + kernel.clone(), + topology_guarantee, + global_topology, + ); let proof = candidate.validate_tds_structure().map_err(|e| { TriangulationConstructionError::FinalTopologyValidation { context: FinalTopologyValidationContext::PeriodicQuotientTopology, @@ -2649,6 +2772,18 @@ mod tests { ); } + fn periodic_fixture_vertices_2d() -> Vec> { + vec![ + Vertex::<(), _>::try_new([0.1_f64, 0.2]).unwrap(), + Vertex::<(), _>::try_new([0.4, 0.7]).unwrap(), + Vertex::<(), _>::try_new([0.7, 0.3]).unwrap(), + Vertex::<(), _>::try_new([0.2, 0.9]).unwrap(), + Vertex::<(), _>::try_new([0.8, 0.6]).unwrap(), + Vertex::<(), _>::try_new([0.5, 0.1]).unwrap(), + Vertex::<(), _>::try_new([0.3, 0.5]).unwrap(), + ] + } + #[derive(Clone, Copy, Debug)] struct ValidationFailureModel; @@ -2785,7 +2920,7 @@ mod tests { true } - fn periodic_domain(&self) -> Option<&[f64; 2]> { + fn periodic_domain(&self) -> Option> { None } } @@ -2824,6 +2959,27 @@ mod tests { assert!(dt.validate().is_ok()); } + #[test] + fn test_builder_euclidean_rejects_non_euclidean_global_topology() { + let vertices = vec![ + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + ]; + let result = DelaunayTriangulationBuilder::new(&vertices) + .global_topology(GlobalTopology::Spherical) + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::EuclideanUnsupportedGlobalTopology { + topology: TopologyKind::Spherical, + } + )) + ); + } + #[test] fn test_builder_topology_guarantee_propagated() { let vertices = vec![ @@ -2920,13 +3076,7 @@ mod tests { assert_eq!(dt.number_of_vertices(), 4); assert_eq!(dt.dim(), 2); assert!(dt.as_triangulation().validate().is_ok()); - assert_matches!( - dt.global_topology(), - GlobalTopology::Toroidal { - mode: ToroidalConstructionMode::Canonicalized, - .. - } - ); + assert_eq!(dt.global_topology(), GlobalTopology::Euclidean); } #[test] @@ -2945,6 +3095,34 @@ mod tests { assert_eq!(dt.number_of_vertices(), 4); assert_eq!(dt.dim(), 2); assert!(dt.as_triangulation().validate().is_ok()); + assert_eq!(dt.global_topology(), GlobalTopology::Euclidean); + } + + #[test] + fn test_builder_canonicalized_toroidal_rejects_non_euclidean_global_topology() { + let vertices = vec![ + Vertex::<(), _>::try_new([0.2, 0.3]).unwrap(), + Vertex::<(), _>::try_new([0.8, 0.1]).unwrap(), + Vertex::<(), _>::try_new([0.5, 0.7]).unwrap(), + Vertex::<(), _>::try_new([0.1, 0.9]).unwrap(), + ]; + let topology = + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::Canonicalized) + .unwrap(); + let result = DelaunayTriangulationBuilder::new(&vertices) + .try_canonicalized_toroidal([1.0, 1.0]) + .unwrap() + .global_topology(topology) + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { + topology: TopologyKind::Toroidal, + } + )) + ); } #[test] @@ -2986,15 +3164,7 @@ mod tests { #[test] fn test_builder_toroidal_2d_smoke() { - let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.1_f64, 0.2]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.4, 0.7]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.7, 0.3]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.2, 0.9]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.8, 0.6]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.1]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.3, 0.5]).unwrap(), - ]; + let vertices = periodic_fixture_vertices_2d(); let n = vertices.len(); let kernel = RobustKernel::new(); let dt = DelaunayTriangulationBuilder::new(&vertices) @@ -3013,6 +3183,143 @@ mod tests { ); } + #[test] + fn test_builder_toroidal_rejects_dimension_above_validated_range() { + let vertices = vec![Vertex::<(), _>::try_new([0.1_f64, 0.2, 0.3, 0.4]).unwrap()]; + let result = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0, 1.0, 1.0, 1.0]) + .unwrap() + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::UnsupportedPeriodicDimension { + dimension: 4, + max_validated_dimension: 3, + tracking_issue: 416, + } + )) + ); + } + + #[test] + fn test_builder_toroidal_rejects_global_topology_before_toroidal_setter() { + let vertices = periodic_fixture_vertices_2d(); + let result = DelaunayTriangulationBuilder::new(&vertices) + .global_topology(GlobalTopology::Spherical) + .try_toroidal([1.0, 1.0]) + .unwrap() + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Spherical, + requested_mode: None, + requested_periods: None, + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods, + } + )) if expected_periods.as_slice() == [1.0, 1.0] + ); + } + + #[test] + fn test_builder_toroidal_rejects_global_topology_after_toroidal_setter() { + let vertices = periodic_fixture_vertices_2d(); + let result = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0, 1.0]) + .unwrap() + .global_topology(GlobalTopology::Euclidean) + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Euclidean, + requested_mode: None, + requested_periods: None, + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods, + } + )) if expected_periods.as_slice() == [1.0, 1.0] + ); + } + + #[test] + fn test_builder_toroidal_rejects_conflicting_explicit_toroidal_mode() { + let vertices = periodic_fixture_vertices_2d(); + let requested_topology = + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::Canonicalized) + .unwrap(); + let result = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0, 1.0]) + .unwrap() + .global_topology(requested_topology) + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Toroidal, + requested_mode: Some(ToroidalConstructionMode::Canonicalized), + requested_periods: Some(requested_periods), + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods, + } + )) if requested_periods.as_slice() == [1.0, 1.0] + && expected_periods.as_slice() == [1.0, 1.0] + ); + } + + #[test] + fn test_builder_toroidal_rejects_conflicting_explicit_toroidal_domain() { + let vertices = periodic_fixture_vertices_2d(); + let requested_topology = + GlobalTopology::try_toroidal([2.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(); + let result = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0, 1.0]) + .unwrap() + .global_topology(requested_topology) + .build::<()>(); + + assert_matches!( + result, + Err(DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Toroidal, + requested_mode: Some(ToroidalConstructionMode::PeriodicImagePoint), + requested_periods: Some(requested_periods), + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods, + } + )) if requested_periods.as_slice() == [2.0, 1.0] + && expected_periods.as_slice() == [1.0, 1.0] + ); + } + + #[test] + fn test_builder_toroidal_accepts_matching_explicit_global_topology() { + let vertices = periodic_fixture_vertices_2d(); + let topology = + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(); + let dt = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0, 1.0]) + .unwrap() + .global_topology(topology) + .build::<()>() + .unwrap(); + + assert_eq!(dt.global_topology(), topology); + assert!(dt.validate().is_ok()); + } + #[test] fn test_builder_canonicalized_toroidal_idempotent_on_canonical_input() { let vertices = vec![ diff --git a/src/delaunay/construction.rs b/src/delaunay/construction.rs index 9f6fbda6..0af14243 100644 --- a/src/delaunay/construction.rs +++ b/src/delaunay/construction.rs @@ -94,7 +94,8 @@ use crate::locality::{ }; use crate::repair::DelaunayRepairPolicy; use crate::topology::traits::{ - GlobalTopology, GlobalTopologyModelError, TopologyKind, ToroidalDomainError, + GlobalTopology, GlobalTopologyModelError, TopologyKind, ToroidalConstructionMode, + ToroidalDomainError, }; use crate::triangulation::DelaunayTriangulation; use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; @@ -514,6 +515,41 @@ pub enum DelaunayConstructionFailure { source: GlobalTopologyModelError, }, + /// Euclidean construction was combined with non-Euclidean topology metadata. + #[error( + "Euclidean construction produces a triangulation with boundary; requested {topology:?} topology metadata is unsupported" + )] + EuclideanUnsupportedGlobalTopology { + /// Requested topology kind that would misclassify Euclidean boundary facets. + topology: TopologyKind, + }, + + /// Canonicalized toroidal construction was combined with non-Euclidean topology metadata. + #[error( + "canonicalized toroidal construction produces a Euclidean triangulation; requested {topology:?} topology metadata is unsupported" + )] + CanonicalizedUnsupportedGlobalTopology { + /// Requested topology kind that would misclassify Euclidean boundary facets. + topology: TopologyKind, + }, + + /// Periodic image-point construction was combined with conflicting explicit topology metadata. + #[error( + "periodic image-point construction derives {expected_mode:?} toroidal topology with domain {expected_periods:?}; requested {requested_topology:?} metadata conflicts (mode={requested_mode:?}, domain={requested_periods:?})" + )] + PeriodicImageConflictingGlobalTopology { + /// Explicit topology kind requested through the builder metadata setter. + requested_topology: TopologyKind, + /// Explicit toroidal construction mode, when the requested metadata was toroidal. + requested_mode: Option, + /// Explicit toroidal periods, when the requested metadata was toroidal. + requested_periods: Option>, + /// Periodic image-point mode required by this construction path. + expected_mode: ToroidalConstructionMode, + /// Periodic image-point periods derived from the construction path. + expected_periods: Vec, + }, + /// A topology model failed while canonicalizing an input vertex. #[error("failed to canonicalize vertex {vertex_index} during construction: {source}")] VertexCanonicalization { @@ -4637,13 +4673,11 @@ where /// ``` #[must_use] pub fn with_empty_kernel(kernel: K) -> Self { - let duplicate_tolerance = default_duplicate_tolerance(); - - Self { - tri: Triangulation::new_empty(kernel), - insertion_state: DelaunayInsertionState::new(), - spatial_index: HashGridIndex::try_new(duplicate_tolerance).ok(), - } + Self::with_empty_kernel_and_topology_context( + kernel, + TopologyGuarantee::DEFAULT, + GlobalTopology::DEFAULT, + ) } /// Creates an empty Delaunay triangulation with a topology guarantee. @@ -4665,14 +4699,32 @@ where pub fn with_empty_kernel_and_topology_guarantee( kernel: K, topology_guarantee: TopologyGuarantee, + ) -> Self { + Self::with_empty_kernel_and_topology_context( + kernel, + topology_guarantee, + GlobalTopology::DEFAULT, + ) + } + + /// Creates an empty Delaunay wrapper with explicit validation and topology context. + /// + /// Repair and builder paths use this before inserting vertices so subsequent + /// topology validation observes the same global topology as the source + /// triangulation or construction mode. + pub(crate) fn with_empty_kernel_and_topology_context( + kernel: K, + topology_guarantee: TopologyGuarantee, + global_topology: GlobalTopology, ) -> Self { let duplicate_tolerance = default_duplicate_tolerance(); - let mut tri = Triangulation::new_empty(kernel); - tri.topology_guarantee = topology_guarantee; - tri.validation_policy = topology_guarantee.default_validation_policy(); Self { - tri, + tri: Triangulation::new_empty_with_topology_context( + kernel, + topology_guarantee, + global_topology, + ), insertion_state: DelaunayInsertionState::new(), spatial_index: HashGridIndex::try_new(duplicate_tolerance).ok(), } @@ -5205,6 +5257,9 @@ where | DelaunayConstructionFailure::OrientationCanonicalizationInternal { .. } | DelaunayConstructionFailure::InsertionNeighborWiring { .. } | DelaunayConstructionFailure::UnsupportedPeriodicDimension { .. } + | DelaunayConstructionFailure::EuclideanUnsupportedGlobalTopology { .. } + | DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { .. } + | DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { .. } | DelaunayConstructionFailure::SpatialIndexConstruction { .. } | DelaunayConstructionFailure::InsertionTopologyValidation { .. } | DelaunayConstructionFailure::LocalRepairBudgetExceeded { .. } diff --git a/src/delaunay/insertion.rs b/src/delaunay/insertion.rs index 24de446b..58291620 100644 --- a/src/delaunay/insertion.rs +++ b/src/delaunay/insertion.rs @@ -845,6 +845,7 @@ mod tests { use crate::flips::BistellarFlips; use crate::geometry::kernel::{AdaptiveKernel, RobustKernel}; use crate::geometry::util::safe_usize_to_scalar; + use crate::topology::traits::topological_space::GlobalTopology; use slotmap::KeyData; use std::assert_matches; use std::sync::Once; @@ -1480,6 +1481,7 @@ mod tests { tds, AdaptiveKernel::new(), TopologyGuarantee::PLManifold, + GlobalTopology::DEFAULT, ) .into_repairable_delaunay_for_test(); let stats = DelaunayRepairStats { diff --git a/src/delaunay/query.rs b/src/delaunay/query.rs index 2e812ccc..b91eb5bf 100644 --- a/src/delaunay/query.rs +++ b/src/delaunay/query.rs @@ -14,13 +14,14 @@ use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::query::QueryError; use crate::core::simplex::Simplex; -use crate::core::tds::{SimplexKey, Tds, TdsError, TdsMutationError, VertexKey}; +use crate::core::tds::{InvariantError, SimplexKey, Tds, TdsError, TdsMutationError, VertexKey}; use crate::core::triangulation::Triangulation; use crate::core::validation::{TopologyGuarantee, ValidationConfigurationError, ValidationPolicy}; use crate::core::vertex::Vertex; use crate::repair::{DelaunayCheckPolicy, DelaunayRepairPolicy}; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::DelaunayTriangulation; +use crate::validation::DelaunayTriangulationValidationError; // ============================================================================= // QUERY, ACCESSORS, AND CONFIGURATION (Minimal Bounds) @@ -457,8 +458,9 @@ impl DelaunayTriangulation { /// Returns an iterator over boundary (hull) facets in the triangulation. /// - /// Boundary facets are those that belong to exactly one simplex. This method - /// computes the facet-to-simplices map internally for convenience. + /// Boundary facets are one-sided facets not identified by closed periodic + /// topology. This method computes the facet-to-simplices index internally + /// for convenience. /// /// # Returns /// @@ -500,11 +502,14 @@ impl DelaunayTriangulation { /// /// # Errors /// - /// Returns [`QueryError::TriangulationCorrupted`] if facet-map construction - /// detects invalid simplex or facet bookkeeping. The variant preserves the - /// lower-level [`TdsError`] for diagnostics. + /// Returns [`QueryError::TriangulationCorrupted`] if facet-incidence index + /// construction detects invalid simplex or facet bookkeeping. The variant + /// preserves the lower-level [`TdsError`] for diagnostics. Returns + /// [`QueryError::TopologyInvalid`] when topology-aware boundary + /// classification rejects the declared global topology or detects another + /// manifold-boundary inconsistency. /// Individual iterator items return [`FacetError`](crate::prelude::tds::FacetError) - /// if a boundary facet cannot be created or keyed from the simplices. + /// if a boundary facet handle cannot be reborrowed as a view. pub fn boundary_facets(&self) -> Result, QueryError> { self.tri.boundary_facets() } @@ -872,33 +877,51 @@ impl DelaunayTriangulation { self.tri.topology_kind() } - /// Sets runtime global topology metadata on this triangulation. + /// Sets runtime global topology metadata after validating it against current topology. + /// + /// The update is atomic: if the current triangulation does not satisfy the + /// requested global topology, the previous metadata is restored before the + /// error is returned. + /// + /// # Errors + /// + /// Returns [`DelaunayTriangulationValidationError::Tds`] if lower-level + /// structure is invalid while checking topology, or + /// [`DelaunayTriangulationValidationError::Triangulation`] when Level 3 + /// topology violates the requested metadata, for example when Euclidean + /// boundary facets are relabeled as closed spherical or toroidal topology. + /// The previous topology metadata is restored before the error is returned. /// /// # Examples /// /// ```rust /// use delaunay::prelude::construction::{ - /// DelaunayTriangulationBuilder, GlobalTopology, + /// DelaunayResult, DelaunayTriangulationBuilder, GlobalTopology, /// }; /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] - /// # Source(#[from] delaunay::DelaunayTriangulationConstructionError), - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![delaunay::vertex![0.0, 0.0]?, delaunay::vertex![1.0, 0.0]?, delaunay::vertex![0.0, 1.0]?]; + /// # fn main() -> DelaunayResult<()> { + /// let vertices = vec![ + /// delaunay::vertex![0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0]?, + /// ]; /// let mut dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - /// dt.set_global_topology(GlobalTopology::Euclidean); + /// dt.try_set_global_topology(GlobalTopology::Euclidean)?; /// assert!(dt.global_topology().is_euclidean()); /// # Ok(()) /// # } /// ``` #[inline] - pub const fn set_global_topology(&mut self, global_topology: GlobalTopology) { - self.tri.set_global_topology(global_topology); + pub fn try_set_global_topology( + &mut self, + global_topology: GlobalTopology, + ) -> Result<(), DelaunayTriangulationValidationError> { + match self.tri.try_set_global_topology(global_topology) { + Ok(()) => Ok(()), + Err(InvariantError::Tds(err)) => Err(err.into()), + Err(InvariantError::Triangulation(err)) => Err(err.into()), + Err(InvariantError::Delaunay(err)) => Err(err), + } } /// Sets the topology guarantee used for Level 3 topology validation. @@ -933,12 +956,9 @@ impl DelaunayTriangulation { /// /// An iterator yielding `Result` items for all facets. /// - /// # Errors - /// - /// Returns [`QueryError::TriangulationCorrupted`] if the facet iterator cannot - /// represent facet indices for this dimension. Individual iterator items - /// return [`FacetError`](crate::prelude::tds::FacetError) if a facet view - /// cannot be constructed from the current TDS state. + /// Individual iterator items return + /// [`FacetError`](crate::prelude::tds::FacetError) if a facet view cannot be + /// constructed from the current TDS state. /// /// # Examples /// @@ -968,13 +988,14 @@ impl DelaunayTriangulation { /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// /// let facet_count = dt - /// .facets()? + /// .facets() /// .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; /// assert_eq!(facet_count, 4); // Tetrahedron has 4 facets /// # Ok(()) /// # } /// ``` - pub fn facets(&self) -> Result, QueryError> { + #[must_use] + pub fn facets(&self) -> AllFacetsIter<'_, U, V, D> { self.tri.facets() } @@ -1287,6 +1308,7 @@ mod tests { use super::*; use crate::core::operations::DelaunayInsertionState; use crate::core::tds::TdsError; + use crate::core::validation::TriangulationValidationError; use crate::geometry::kernel::{AdaptiveKernel, FastKernel}; use std::{assert_matches, collections::HashSet, num::NonZeroUsize, sync::Once}; @@ -1308,9 +1330,9 @@ mod tests { fn test_delaunay_constructors_default_to_pl_manifold_mode() { init_tracing(); let vertices: Vec> = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), ]; let dt_new: DelaunayTriangulation<_, (), (), 2> = @@ -1349,6 +1371,36 @@ mod tests { assert_eq!(dt.tri.topology_guarantee, TopologyGuarantee::Pseudomanifold); } + #[test] + fn test_try_global_topology_setter_rejects_closed_metadata_for_euclidean_boundary() { + init_tracing(); + let vertices: Vec> = vec![ + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + ]; + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + + let err = dt + .try_set_global_topology(GlobalTopology::Spherical) + .unwrap_err(); + + assert_matches!( + err, + DelaunayTriangulationValidationError::Triangulation(source) + if matches!( + &*source, + TriangulationValidationError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + .. + } + ) + ); + assert_eq!(dt.global_topology(), GlobalTopology::Euclidean); + assert!(dt.validate().is_ok()); + } + #[test] fn test_set_delaunay_check_policy_updates_state() { init_tracing(); @@ -1382,9 +1434,8 @@ mod tests { match dt.boundary_facets() { Ok(_) => panic!("corrupted facet map should return a query error"), - Err(QueryError::TriangulationCorrupted { - source: TdsError::IndexOutOfBounds { .. }, - }) => {} + Err(QueryError::TriangulationCorrupted { source }) + if matches!(*source, TdsError::IndexOutOfBounds { .. }) => {} Err(err) => panic!("expected index-out-of-bounds query error, got {err:?}"), } } diff --git a/src/delaunay/repair.rs b/src/delaunay/repair.rs index c5812677..bc493e98 100644 --- a/src/delaunay/repair.rs +++ b/src/delaunay/repair.rs @@ -746,11 +746,11 @@ where // repair cannot escape. let topology_guarantee = self.tri.topology_guarantee(); let global_topology = self.tri.global_topology(); - let mut candidate = Self::with_empty_kernel_and_topology_guarantee( + let mut candidate = Self::with_empty_kernel_and_topology_context( self.tri.kernel.clone(), topology_guarantee, + global_topology, ); - candidate.set_global_topology(global_topology); // During rebuild, force local repair after every insertion. The caller's // policies are copied onto the finished candidate below. @@ -933,9 +933,9 @@ mod tests { use crate::core::validation::TopologyGuarantee; use crate::core::vertex::Vertex; use crate::geometry::kernel::{AdaptiveKernel, RobustKernel}; - use crate::topology::traits::topological_space::{GlobalTopology, ToroidalConstructionMode}; + use crate::topology::traits::topological_space::GlobalTopology; use crate::triangulation::DelaunayTriangulation; - use std::{num::NonZeroUsize, sync::Once}; + use std::{assert_matches, num::NonZeroUsize, sync::Once}; fn init_tracing() { static INIT: Once = Once::new(); @@ -1157,20 +1157,18 @@ mod tests { } #[test] - fn test_heuristic_rebuild_preserves_global_topology() { + fn test_heuristic_rebuild_preserves_default_global_topology() { init_tracing(); let vertices: Vec> = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), ]; let mut dt: DelaunayTriangulation<_, (), (), 2> = DelaunayTriangulation::try_new(&vertices).unwrap(); - let global_topology = - GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) - .unwrap(); - dt.set_global_topology(global_topology); + let global_topology = GlobalTopology::Euclidean; + dt.try_set_global_topology(global_topology).unwrap(); let _guard = ForceHeuristicRebuildGuard::enable(); let outcome = dt @@ -1185,6 +1183,31 @@ mod tests { assert_eq!(dt.topology_guarantee(), TopologyGuarantee::PLManifold); } + #[test] + fn test_heuristic_rebuild_threads_non_default_global_topology() { + init_tracing(); + let vertices: Vec> = vec![ + Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), + ]; + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::try_new(&vertices).unwrap(); + dt.tri.global_topology = GlobalTopology::Spherical; + + let _guard = ForceHeuristicRebuildGuard::enable(); + let result = + dt.repair_delaunay_with_flips_advanced(DelaunayRepairHeuristicConfig::default()); + + assert_matches!( + result, + Err(DelaunayRepairError::HeuristicRebuildFailed { .. }), + "forced rebuild should fail when threaded closed topology rejects Euclidean boundary" + ); + assert_eq!(dt.global_topology(), GlobalTopology::Spherical); + } + #[test] fn test_repair_delaunay_with_flips_allows_pl_manifold() { init_tracing(); @@ -1314,8 +1337,13 @@ mod tests { assert!(verify_delaunay_via_flip_predicates(&tds, &kernel).is_err()); assert!(verify_delaunay_via_flip_predicates(&tds, &robust_kernel).is_err()); let mut dt: DelaunayTriangulation, (), (), 2> = - DelaunayTriangulationCandidate::assemble(tds, kernel, TopologyGuarantee::PLManifold) - .into_repairable_delaunay_for_test(); + DelaunayTriangulationCandidate::assemble( + tds, + kernel, + TopologyGuarantee::PLManifold, + GlobalTopology::DEFAULT, + ) + .into_repairable_delaunay_for_test(); dt.set_topology_guarantee(TopologyGuarantee::PLManifold); // max_flips=0 should fail (flips are needed but budget is zero). @@ -1349,6 +1377,7 @@ mod tests { tds2, AdaptiveKernel::new(), TopologyGuarantee::PLManifold, + GlobalTopology::DEFAULT, ) .into_repairable_delaunay_for_test(); dt2.set_topology_guarantee(TopologyGuarantee::PLManifold); diff --git a/src/delaunay/validation.rs b/src/delaunay/validation.rs index e9ac0a6d..4f8237d4 100644 --- a/src/delaunay/validation.rs +++ b/src/delaunay/validation.rs @@ -45,11 +45,16 @@ pub(crate) struct DelaunayTriangulationCandidate { } impl DelaunayTriangulationCandidate { - /// Assembles a validation candidate from a TDS and topology guarantee. + /// Assembles a validation candidate with the topology context used for proof checks. + /// + /// The global topology is installed before any validation proof is minted so + /// boundary classification and Euler checks use the construction path's + /// intended topology rather than the Euclidean default. pub(crate) const fn assemble( tds: Tds, kernel: K, topology_guarantee: TopologyGuarantee, + global_topology: GlobalTopology, ) -> Self { let validation_policy = topology_guarantee.default_validation_policy(); Self { @@ -57,7 +62,7 @@ impl DelaunayTriangulationCandidate { tri: Triangulation { kernel, tds, - global_topology: GlobalTopology::DEFAULT, + global_topology, validation_policy, topology_guarantee, }, @@ -67,11 +72,6 @@ impl DelaunayTriangulationCandidate { } } - /// Sets runtime global-topology metadata before validation. - pub(crate) const fn set_global_topology(&mut self, global_topology: GlobalTopology) { - self.candidate.tri.set_global_topology(global_topology); - } - /// Validates Level 1–2 TDS structure and returns proof for structural-only assembly paths. pub(crate) fn validate_tds_structure(&self) -> Result { self.candidate.tri.tds.validate()?; @@ -863,9 +863,12 @@ where topology_guarantee: TopologyGuarantee, global_topology: GlobalTopology, ) -> Result { - let mut candidate = - DelaunayTriangulationCandidate::assemble(tds, kernel, topology_guarantee); - candidate.set_global_topology(global_topology); + let candidate = DelaunayTriangulationCandidate::assemble( + tds, + kernel, + topology_guarantee, + global_topology, + ); let proof = candidate.validate_delaunay_property()?; Ok(candidate.into_validated_delaunay(proof)) } diff --git a/src/geometry/algorithms/convex_hull.rs b/src/geometry/algorithms/convex_hull.rs index ded2fc58..69177115 100644 --- a/src/geometry/algorithms/convex_hull.rs +++ b/src/geometry/algorithms/convex_hull.rs @@ -50,8 +50,8 @@ use crate::core::collections::{ FacetToSimplicesMap, FastHashMap, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer, }; use crate::core::facet::{FacetError, FacetHandle, FacetView}; +use crate::core::query::QueryError; use crate::core::tds::TdsError; -use crate::core::traits::boundary_analysis::BoundaryAnalysis; use crate::core::traits::data_type::DataType; use crate::core::traits::facet_cache::FacetCacheProvider; use crate::core::triangulation::Triangulation; @@ -177,9 +177,9 @@ pub enum ConvexHullConstructionError { /// Failed to extract boundary facets from the triangulation. #[error("Failed to extract boundary facets from triangulation: {source}")] BoundaryFacetExtractionFailed { - /// The underlying triangulation data-structure error. + /// The underlying boundary query error. #[source] - source: TdsError, + source: Box, }, /// Failed to check facet visibility from a point. #[error("Failed to check facet visibility from point: {source}")] @@ -273,10 +273,11 @@ pub enum ConvexHullConstructionError { /// Generic d-dimensional convex hull operations. /// -/// This struct provides convex hull functionality by leveraging the existing -/// boundary facet analysis from the TDS. Since boundary facets in a Delaunay -/// triangulation lie on the convex hull, we can use the `BoundaryAnalysis` -/// trait to get the hull facets directly. +/// This struct provides convex hull functionality by leveraging topology-aware +/// boundary facet queries from the triangulation. Since boundary facets in a +/// Euclidean Delaunay triangulation lie on the convex hull, the high-level +/// boundary query gives the hull facets directly while still rejecting open +/// facets in closed global topologies. /// /// The implementation supports d-dimensional convex hull extraction from /// Delaunay triangulations, point-in-hull testing, and facet visibility @@ -433,7 +434,7 @@ pub struct ConvexHull { /// Use `is_valid_for_triangulation()` to check validity before use. /// /// This field is private to prevent external mutation. Use the provided read-only - /// accessors (`facets(triangulation)`, `facet_handles()`, `facet()`, `number_of_facets()`) + /// accessors (`try_facets(triangulation)`, `facet_handles()`, `facet()`, `number_of_facets()`) /// to access hull facets. hull_facets: Vec, /// Cache for the facet-to-simplices mapping to avoid rebuilding it for each facet check @@ -559,7 +560,7 @@ impl ConvexHull { /// Returns an iterator over the hull facet handles. /// /// These handles are detached, runtime-local references into the TDS state - /// captured when the hull was built. Use [`Self::facets`] when callers need + /// captured when the hull was built. Use [`Self::try_facets`] when callers need /// borrowed [`FacetView`] access with hull freshness checked at the boundary. /// /// # Examples @@ -601,8 +602,8 @@ impl ConvexHull { /// assert_eq!(facet_count, 4); // Tetrahedron has 4 faces /// /// // Check that all facets have the expected number of vertices. - /// for facet_view in hull.facets(dt.as_triangulation())? { - /// assert_eq!(facet_view?.vertices()?.count(), 3); // 3D facets have 3 vertices + /// for facet_view in hull.try_facets(dt.as_triangulation())? { + /// assert_eq!(facet_view?.vertices().count(), 3); // 3D facets have 3 vertices /// } /// # Ok(()) /// # } @@ -616,9 +617,9 @@ impl ConvexHull { /// This is the borrowed-view counterpart to [`Self::facet_handles`]. It first /// verifies that the hull still belongs to `tri` and that the triangulation /// generation matches the hull's creation generation, then yields - /// [`FacetView`] values lifetime-bound to the supplied triangulation. Holding - /// any returned view therefore keeps the source triangulation immutably - /// borrowed. + /// [`FacetView`] values lifetime-bound to the supplied triangulation. The + /// iterator borrows the hull while it is consumed, but collected facet views + /// borrow only `tri`. /// /// # Errors /// @@ -656,19 +657,22 @@ impl ConvexHull { /// DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// let hull = ConvexHull::try_from_triangulation(dt.as_triangulation())?; /// - /// for facet in hull.facets(dt.as_triangulation())? { - /// assert_eq!(facet?.vertices()?.count(), 3); + /// for facet in hull.try_facets(dt.as_triangulation())? { + /// assert_eq!(facet?.vertices().count(), 3); /// } /// # Ok(()) /// # } /// ``` - pub fn facets<'tds, K>( - &'tds self, - tri: &'tds Triangulation, + pub fn try_facets<'hull, 'tri, K>( + &'hull self, + tri: &'tri Triangulation, ) -> Result< - impl Iterator, FacetError>> + 'tds, + impl Iterator, FacetError>> + 'hull, ConvexHullConstructionError, - > { + > + where + 'tri: 'hull, + { self.ensure_current_for_construction(tri)?; let tds = &tri.tds; Ok(self @@ -914,7 +918,7 @@ impl ConvexHull { /// Builds a construction identity-mismatch error for a hull used with the wrong TDS. /// /// This is the construction-error counterpart to - /// [`Self::identity_mismatch_error`] for APIs such as [`Self::facets`] that + /// [`Self::identity_mismatch_error`] for APIs such as [`Self::try_facets`] that /// return borrowed views after checking same-owner freshness. #[inline] fn identity_mismatch_construction_error( @@ -940,16 +944,16 @@ impl ConvexHull { if self.is_empty() && self.creation_generation.get().is_none() { return Ok(()); } - let creation_generation = self.creation_generation.get().copied().unwrap_or(0); - if creation_generation != tri.tds.generation() { - return Err(self.stale_hull_construction_error(tri)); - } let Some(creation_identity) = self.creation_identity.get() else { return Err(self.identity_mismatch_construction_error(tri)); }; if !Arc::ptr_eq(creation_identity, tri.tds.identity()) { return Err(self.identity_mismatch_construction_error(tri)); } + let creation_generation = self.creation_generation.get().copied().unwrap_or(0); + if creation_generation != tri.tds.generation() { + return Err(self.stale_hull_construction_error(tri)); + } Ok(()) } @@ -965,16 +969,16 @@ impl ConvexHull { if self.is_empty() && self.creation_generation.get().is_none() { return Ok(()); } - let creation_generation = self.creation_generation.get().copied().unwrap_or(0); - if creation_generation != tri.tds.generation() { - return Err(self.stale_hull_error(tri)); - } let Some(creation_identity) = self.creation_identity.get() else { return Err(self.identity_mismatch_error(tri)); }; if !Arc::ptr_eq(creation_identity, tri.tds.identity()) { return Err(self.identity_mismatch_error(tri)); } + let creation_generation = self.creation_generation.get().copied().unwrap_or(0); + if creation_generation != tri.tds.generation() { + return Err(self.stale_hull_error(tri)); + } Ok(()) } } @@ -1149,18 +1153,22 @@ where }); } - // Use the existing boundary analysis to get hull facets - let hull_facets_iter = tds.boundary_facets().map_err(|source| { - ConvexHullConstructionError::BoundaryFacetExtractionFailed { source } + // Use the topology-aware triangulation boundary query to get hull facets. + let hull_facets_iter = tri.boundary_facets().map_err(|source| { + ConvexHullConstructionError::BoundaryFacetExtractionFailed { + source: Box::new(source), + } })?; // Collect detached facet handles for storage. Borrowed FacetViews are - // reconstructed later through ConvexHull::facets after freshness checks. + // reconstructed later through ConvexHull::try_facets after freshness checks. let hull_facets: Vec<_> = hull_facets_iter .map(|facet_view| { let facet_view = facet_view.map_err(|source| { ConvexHullConstructionError::BoundaryFacetExtractionFailed { - source: source.into(), + source: Box::new(QueryError::TriangulationCorrupted { + source: Box::new(source.into()), + }), } })?; Ok::<_, ConvexHullConstructionError>(FacetHandle::from_validated( @@ -1806,11 +1814,7 @@ where |source| ConvexHullConstructionError::FacetDataAccessFailed { source }, )?; // Extract points directly to avoid materializing Vertex copies - let facet_points: Vec> = facet_view - .vertices() - .map_err(|source| ConvexHullConstructionError::FacetDataAccessFailed { source })? - .map(|v| *v.point()) - .collect(); + let facet_points: Vec> = facet_view.vertices().map(|v| *v.point()).collect(); // Calculate distance from point to facet centroid as a simple heuristic let mut centroid_coords = [0.0; D]; @@ -2001,14 +2005,7 @@ where source, })?; - let vertices: Vec<_> = facet_view - .vertices() - .map_err(|source| ConvexHullValidationError::InvalidFacet { - facet_index: index, - source, - })? - .copied() - .collect(); + let vertices: Vec<_> = facet_view.vertices().copied().collect(); if vertices.len() != D { return Err(ConvexHullValidationError::InvalidFacet { facet_index: index, @@ -2056,14 +2053,9 @@ where } } -// Implementation of FacetCacheProvider trait for ConvexHull -// Separate impl block with FacetCacheProvider-specific trait bounds -// (main impl block has simpler bounds that don't include Sum, DeserializeOwned, etc.) -impl FacetCacheProvider for ConvexHull -where - U: DataType, - V: DataType, -{ +// Implementation of crate-private facet-cache plumbing for ConvexHull. +// Keep this payload-agnostic; cache storage and generation checks do not need `DataType`. +impl FacetCacheProvider for ConvexHull { fn facet_cache(&self) -> &ArcSwapOption { &self.facet_to_simplices_cache } @@ -2160,9 +2152,7 @@ mod tests { let facet_view = FacetView::try_new(tds, facet_handle.simplex_key(), facet_handle.facet_index()) .map_err(|source| ConvexHullConstructionError::FacetDataAccessFailed { source })?; - // Use the shared utility for extracting vertices - facet_view_to_vertices(&facet_view) - .map_err(|source| ConvexHullConstructionError::FacetDataAccessFailed { source }) + Ok(facet_view_to_vertices(&facet_view)) } // ============================================================================= @@ -2277,6 +2267,27 @@ mod tests { ); } + #[test] + fn [<$test_name _facet_views_outlive_hull_borrow>]() { + let vertices = $vertices; + let dt = create_triangulation(&vertices); + let facet_views: Vec<_> = { + let hull: ConvexHull<(), (), $dim> = + ConvexHull::try_from_triangulation(dt.as_triangulation()).unwrap(); + hull.try_facets(dt.as_triangulation()) + .unwrap() + .collect::, _>>() + .unwrap() + }; + + assert_eq!( + facet_views.len(), + $expected_facets, + "{}D facet views should borrow only the triangulation", + $dim + ); + } + #[test] fn [<$test_name _point_containment>]() { let vertices = $vertices; @@ -3045,7 +3056,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let vertices = facet_view.vertices().unwrap().count(); + let vertices = facet_view.vertices().count(); assert_eq!(vertices, 2, "2D facet {i} should have exactly 2 vertices"); } test_debug!( @@ -3082,7 +3093,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let vertices = facet_view.vertices().unwrap().count(); + let vertices = facet_view.vertices().count(); assert_eq!(vertices, 3, "3D facet {i} should have exactly 3 vertices"); } test_debug!( @@ -3120,7 +3131,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let vertices = facet_view.vertices().unwrap().count(); + let vertices = facet_view.vertices().count(); assert_eq!(vertices, 4, "4D facet {i} should have exactly 4 vertices"); } test_debug!( @@ -3159,7 +3170,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let vertices = facet_view.vertices().unwrap().count(); + let vertices = facet_view.vertices().count(); assert_eq!(vertices, 5, "5D facet {i} should have exactly 5 vertices"); } test_debug!( @@ -3494,9 +3505,9 @@ mod tests { ); // Verify all borrowed facet views are valid. - for facet_view in hull.facets(dt.as_triangulation()).unwrap() { + for facet_view in hull.try_facets(dt.as_triangulation()).unwrap() { let facet_view = facet_view.unwrap(); - let vertex_count = facet_view.vertices().unwrap().count(); + let vertex_count = facet_view.vertices().count(); assert!(vertex_count > 0, "Each facet should have vertices"); } } @@ -3856,7 +3867,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let facet_vertices = facet_view_to_vertices(&facet_view).unwrap(); + let facet_vertices = facet_view_to_vertices(&facet_view); // Test with a point very close to the facet (should not be visible) let close_point = Point::try_new([0.1, 0.1, 0.1]).expect("finite point coordinates"); @@ -4073,7 +4084,6 @@ mod tests { ) .unwrap() .vertices() - .unwrap() .count() }) .collect(); @@ -4707,7 +4717,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let facet_vertices = facet_view_to_vertices(&facet_view).unwrap(); + let facet_vertices = facet_view_to_vertices(&facet_view); // Get vertex keys from vertices via TDS let facet_vertex_keys: Vec<_> = facet_vertices @@ -5437,9 +5447,11 @@ mod tests { test_debug!(" Testing ConvexHullConstructionError variants..."); let boundary_error = ConvexHullConstructionError::BoundaryFacetExtractionFailed { - source: TdsError::InconsistentDataStructure { - message: "Test boundary extraction failure".to_string(), - }, + source: Box::new(QueryError::TriangulationCorrupted { + source: Box::new(TdsError::InconsistentDataStructure { + message: "Test boundary extraction failure".to_string(), + }), + }), }; let boundary_msg = format!("{boundary_error}"); assert!(boundary_msg.contains("Failed to extract boundary facets")); @@ -5757,7 +5769,7 @@ mod tests { facet_handle.facet_index(), ) .unwrap(); - let test_facet_vertices = facet_view_to_vertices(&facet_view).unwrap(); + let test_facet_vertices = facet_view_to_vertices(&facet_view); // Test points at different distance scales let test_cases = vec![ @@ -7468,7 +7480,7 @@ mod tests { ); test_debug!(" Testing facets..."); - let facets_result = hull.facets(dt.as_triangulation()); + let facets_result = hull.try_facets(dt.as_triangulation()); assert!( matches!( facets_result, diff --git a/src/geometry/util/measures.rs b/src/geometry/util/measures.rs index 6b43a927..2e648592 100644 --- a/src/geometry/util/measures.rs +++ b/src/geometry/util/measures.rs @@ -807,7 +807,7 @@ fn facet_measure_gram_matrix( /// let tds = dt.tds(); /// /// // Get boundary facets as FacetViews -/// let boundary_facets = tds.boundary_facets()?.collect::, _>>()?; +/// let boundary_facets = tds.one_sided_facets()?.collect::, _>>()?; /// /// // Calculate surface area /// let surface_area = surface_measure(&boundary_facets)?; @@ -825,13 +825,8 @@ where let mut total_measure = 0.0; for facet in facets { - let facet_vertices = facet.vertices(); - // Convert vertices to Points for measure calculation - let points: Vec> = facet_vertices - .map_err(SurfaceMeasureError::from)? - .map(|v| *v.point()) - .collect(); + let points: Vec> = facet.vertices().map(|v| *v.point()).collect(); let measure = facet_measure(&points).map_err(SurfaceMeasureError::from)?; total_measure += measure; @@ -845,7 +840,7 @@ mod tests { use super::*; use std::assert_matches; - use crate::core::traits::boundary_analysis::BoundaryAnalysis; + use crate::core::traits::facet_incidence_analysis::FacetIncidenceAnalysis; use crate::core::vertex::Vertex; use crate::geometry::matrix::LaError; use crate::geometry::point::Point; @@ -1614,7 +1609,7 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); @@ -1623,7 +1618,7 @@ mod tests { let tarfacet = boundary_facets .iter() .find(|facet| { - let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); + let facet_vertices: Vec<_> = facet.vertices().collect(); facet_vertices.len() == 3 && facet_vertices.iter().any(|v| { let coords = *v.point().coords(); @@ -1640,7 +1635,7 @@ mod tests { }) .expect("Should find the target facet"); - let surface_area = surface_measure(&[*tarfacet]).unwrap(); + let surface_area = surface_measure(std::slice::from_ref(tarfacet)).unwrap(); // Should be area of right triangle: 3 * 4 / 2 = 6.0 assert_relative_eq!(surface_area, 6.0, epsilon = 1e-10); @@ -1663,22 +1658,21 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); // Take first two boundary facets for testing - let facet1 = boundary_facets[0]; - let facet2 = boundary_facets[1]; + let facet1 = boundary_facets[0].clone(); + let facet2 = boundary_facets[1].clone(); // Calculate surface measure - let total_surface = surface_measure(&[facet1, facet2]).unwrap(); + let total_surface = surface_measure(&[facet1.clone(), facet2.clone()]).unwrap(); // Calculate individual facet measures and sum them let points1: Vec> = facet1 .vertices() - .unwrap() .map(|v| { let coords = *v.point().coords(); Point::try_new(coords).expect("finite point coordinates") @@ -1686,7 +1680,6 @@ mod tests { .collect(); let points2: Vec> = facet2 .vertices() - .unwrap() .map(|v| { let coords = *v.point().coords(); Point::try_new(coords).expect("finite point coordinates") @@ -1984,20 +1977,29 @@ mod tests { crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), // v3 crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), // v4 ]; + + // Create second triangulation with large right triangle (area = 24.0) + let vertices2: Vec> = vec![ + crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), // v5 + crate::core::vertex::Vertex::<(), _>::try_new([6.0, 0.0, 0.0]).unwrap(), // v6 + crate::core::vertex::Vertex::<(), _>::try_new([0.0, 8.0, 0.0]).unwrap(), // v7 + crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), // v8 + ]; let dt1: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices1).unwrap(); + let dt2: DelaunayTriangulation<_, (), (), 3> = + DelaunayTriangulation::try_new(&vertices2).unwrap(); + let boundary_facets1: Vec<_> = dt1 .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); - - // Find the facet opposite to v4 (triangle with v1, v2, v3) - area = 0.5 let small_facet = boundary_facets1 .iter() .find(|facet| { - let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); + let facet_vertices: Vec<_> = facet.vertices().collect(); facet_vertices.len() == 3 && facet_vertices.iter().any(|v| { let coords = *v.point().coords(); @@ -2012,20 +2014,12 @@ mod tests { coords == [0.0, 1.0, 0.0] }) }) - .expect("Should find small triangle facet"); + .expect("Should find small triangle facet") + .clone(); - // Create second triangulation with large right triangle (area = 24.0) - let vertices2: Vec> = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), // v5 - crate::core::vertex::Vertex::<(), _>::try_new([6.0, 0.0, 0.0]).unwrap(), // v6 - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 8.0, 0.0]).unwrap(), // v7 - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), // v8 - ]; - let dt2: DelaunayTriangulation<_, (), (), 3> = - DelaunayTriangulation::try_new(&vertices2).unwrap(); let boundary_facets2: Vec<_> = dt2 .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); @@ -2034,7 +2028,7 @@ mod tests { let large_facet = boundary_facets2 .iter() .find(|facet| { - let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); + let facet_vertices: Vec<_> = facet.vertices().collect(); facet_vertices.len() == 3 && facet_vertices.iter().any(|v| { let coords = *v.point().coords(); @@ -2049,9 +2043,10 @@ mod tests { coords == [0.0, 8.0, 0.0] }) }) - .expect("Should find large triangle facet"); + .expect("Should find large triangle facet") + .clone(); - let total_surface = surface_measure(&[*small_facet, *large_facet]).unwrap(); + let total_surface = surface_measure(&[small_facet, large_facet]).unwrap(); let expected_total = 0.5 + 24.0; assert_relative_eq!(total_surface, expected_total, epsilon = 1e-10); @@ -2076,7 +2071,7 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); @@ -2105,7 +2100,7 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); @@ -2141,7 +2136,7 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); @@ -2215,7 +2210,7 @@ mod tests { DelaunayTriangulation::try_new(&vertices).unwrap(); let boundary_facets: Vec<_> = dt .tds() - .boundary_facets() + .one_sided_facets() .unwrap() .collect::, _>>() .unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 3faf9e4c..bae23714 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,8 +62,8 @@ //! | Delaunayize workflow (repair + flip) | `use delaunay::prelude::delaunayize::*` | //! | Construction telemetry diagnostics | `use delaunay::prelude::diagnostics::*` | //! | Construction validation cadence/policy | `use delaunay::prelude::validation::*` | -//! | Topology validation, Euler characteristic | `use delaunay::prelude::topology::validation::*` | -//! | Topological spaces and topology traits | `use delaunay::prelude::topology::spaces::*` | +//! | Topology validation, Euler characteristic, ridge queries | `use delaunay::prelude::topology::validation::*` | +//! | Topological spaces, topology traits, lifted toroidal IDs | `use delaunay::prelude::topology::spaces::*` | //! | Low-level TDS simplices, facets, keys | `use delaunay::prelude::tds::*` | //! | Collection types (`FastHashMap`, etc.) | `use delaunay::prelude::collections::*` | //! | Broad convenience import for exploratory code | `use delaunay::prelude::*` | @@ -208,7 +208,8 @@ //! - **Vertex mappings** – every vertex UUID has a corresponding key and vice versa. //! - **Simplex mappings** – every simplex UUID has a corresponding key and vice versa. //! - **No duplicate simplices** – no two maximal simplices share the same vertex set. -//! - **Facet sharing** – each facet is shared by at most 2 simplices (1 on the boundary, 2 in the interior). +//! - **Facet incidence** – each facet is one-sided or two-sided; topology +//! metadata decides whether a one-sided facet is semantic boundary. //! - **Neighbor consistency** – neighbor relationships are mutual and reference a shared facet. //! //! These checks are surfaced via [`Tds::is_valid`](crate::tds::Tds::is_valid) @@ -221,8 +222,9 @@ //! Level 3 (topology) validation is performed by //! [`Triangulation::is_valid`](crate::Triangulation::is_valid) (Level 3 only) and //! [`Triangulation::validate`](crate::Triangulation::validate) (Levels 1–3), which: -//! - Strengthens facet sharing to the **manifold facet property**: each facet belongs to -//! exactly 1 simplex (boundary) or exactly 2 simplices (interior). +//! - Strengthens facet incidence to the **manifold facet property**: +//! one-sided facets are valid only when the declared topology admits +//! boundary; two-sided facets are interior. //! - Checks the **Euler characteristic** of the triangulation (using the topology module). //! //! - [`DelaunayTriangulation`] builds on @@ -430,7 +432,7 @@ mod core { } pub mod adjacency; - pub mod boundary; + pub mod facet_incidence; pub mod simplex; /// High-performance collection types optimized for computational geometry operations. /// @@ -488,7 +490,7 @@ mod core { /// The size parameters for `SmallVec` are chosen based on empirical analysis of /// typical triangulation patterns: /// - /// - **2 elements**: Facet sharing (boundary facets = 1 simplex, interior facets = 2 simplices) + /// - **2 elements**: Facet incidence (one-sided = 1 simplex, two-sided = 2 simplices) /// - **4 elements**: Small temporary collections during geometric operations /// - **8 elements**: Vertex degrees and simplex neighbor counts in typical triangulations /// - **16 elements**: Larger temporary buffers for batch operations @@ -505,18 +507,18 @@ mod core { /// # Examples /// /// ```rust - /// use delaunay::prelude::collections::{FastHashMap, FacetToSimplicesMap, SmallBuffer}; + /// use delaunay::prelude::collections::{FastHashMap, SmallBuffer}; /// /// // Use optimized HashMap for temporary mappings /// let mut temp_map: FastHashMap = FastHashMap::default(); + /// temp_map.insert(7, 3); /// /// // Use stack-allocated buffer for small collections /// let mut small_list: SmallBuffer = SmallBuffer::new(); /// small_list.push(1); /// small_list.push(2); /// - /// // Use domain-specific optimized collections - /// let facet_map: FacetToSimplicesMap = FacetToSimplicesMap::default(); + /// assert_eq!(temp_map.len(), 1); /// ``` /// /// ## Key-based internal operations @@ -616,10 +618,9 @@ mod core { /// Traits for Delaunay triangulation data structures. pub mod traits { - pub mod boundary_analysis; pub mod data_type; pub mod facet_cache; - pub use boundary_analysis::*; + pub mod facet_incidence_analysis; pub use data_type::*; } @@ -874,7 +875,7 @@ pub fn try_vertices_from_points( /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let result = validation::validate_triangulation_euler(dt.tds())?; +/// let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology())?; /// assert_eq!(result.chi, 1); // Tetrahedron has χ = 1 /// assert!(result.is_valid()); /// # Ok(()) @@ -899,6 +900,9 @@ pub mod topology { /// Manifold / simplicial-complex validity checks (topology-only). pub mod manifold; + /// Ridge candidates, borrowed ridge queries, and lifted ridge-link views. + pub mod ridge; + /// Concrete topological space implementations. /// /// This module contains the currently exposed Euclidean, spherical, and @@ -914,16 +918,19 @@ pub mod topology { pub use euclidean::EuclideanSpace; pub use spherical::SphericalSpace; - pub use toroidal::ToroidalSpace; + pub use toroidal::{LiftedLinkEdge, LiftedVertexId, ToroidalSpace}; } // Re-export commonly used types pub use crate::TopologyGuarantee; pub use characteristics::*; pub use manifold::{ - ManifoldError, RidgeVertices, RidgeVerticesError, ridge_star_simplices, - validate_closed_boundary, validate_facet_degree, validate_ridge_links, - validate_vertex_links, + BoundaryFacetClassification, ManifoldError, classify_boundary_facet, + validate_closed_boundary, validate_ridge_links, validate_vertex_links, + }; + pub use ridge::{ + RidgeCandidate, RidgeCandidateError, RidgeLinkView, RidgeQuery, RidgeView, + ridge_star_simplices, }; pub use traits::*; } @@ -950,8 +957,8 @@ pub mod topology { /// ``` pub mod collections { pub use crate::core::collections::{ - Entry, FacetIndex, FacetIssuesMap, FacetSharingSimplicesBuffer, FacetToSimplicesMap, - FacetVertexMap, FastBuildHasher, FastHashMap, FastHashSet, FastHasher, KeyBasedSimplexMap, + Entry, FacetIndex, FacetIssuesMap, FacetSharingSimplicesBuffer, FacetVertexMap, + FastBuildHasher, FastHashMap, FastHashSet, FastHasher, KeyBasedSimplexMap, KeyBasedVertexMap, MAX_PRACTICAL_DIMENSION_SIZE, NeighborBuffer, PeriodicOffsetBuffer, SecureHashMap, SecureHashSet, SimplexKeyBuffer, SimplexKeySet, SimplexNeighborsMap, SimplexSecondaryMap, SimplexToVertexUuidsMap, SimplexVertexBuffer, SimplexVertexKeyBuffer, @@ -1002,7 +1009,6 @@ pub mod tds { pub use crate::core::facet::*; pub use crate::core::simplex::*; pub use crate::core::tds::*; - pub use crate::core::traits::facet_cache::*; pub use crate::core::util::{ UuidValidationError, checked_facet_key_from_vertex_keys, facet_view_to_vertices, facet_views_are_adjacent, format_jaccard_report, jaccard_distance, jaccard_index, @@ -1071,10 +1077,10 @@ pub mod algorithms { pub mod query { pub use crate::assert_jaccard_gte; pub use crate::core::query::QueryError; - pub use crate::core::traits::boundary_analysis::BoundaryAnalysis; pub use crate::core::traits::data_type::{ DataCopy, DataDebug, DataDeserialize, DataIdentity, DataSerde, DataSerialize, DataType, }; + pub use crate::core::traits::facet_incidence_analysis::FacetIncidenceAnalysis; pub use crate::core::util::{ JaccardComputationError, extract_edge_set, extract_facet_identifier_set, extract_hull_facet_set, extract_vertex_coordinate_set, format_jaccard_report, @@ -1090,8 +1096,9 @@ 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, FacetView, - IncidenceView, Simplex, SimplexKey, SimplexNeighborIndex, TopologyIndexBuildError, + AllFacetsIter, BoundaryFacetsIter, EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, + FacetIncidenceView, FacetToSimplicesIndex, FacetView, IncidenceView, OneSidedFacetsIter, + Simplex, SimplexFacetsIter, SimplexKey, SimplexNeighborIndex, TopologyIndexBuildError, TriangulationAdjacency, Vertex, VertexKey, }; pub use crate::{DelaunayTriangulation, Triangulation}; @@ -1102,8 +1109,8 @@ pub mod query { pub mod prelude { // Re-export the public low-level facades. pub use crate::query::{ - BoundaryAnalysis, DataCopy, DataDebug, DataDeserialize, DataIdentity, DataSerde, - DataSerialize, DataType, QueryError, + DataCopy, DataDebug, DataDeserialize, DataIdentity, DataSerde, DataSerialize, DataType, + FacetIncidenceAnalysis, QueryError, }; pub use crate::tds::*; pub use crate::vertex; @@ -1150,6 +1157,10 @@ pub mod prelude { facet_views_are_adjacent, make_uuid, stable_hash_u64_slice, usize_to_u8, validate_uuid, verify_facet_index_consistency, }; + pub use crate::topology::{ + GlobalTopology, GlobalTopologyModelError, TopologyError, TopologyKind, + ToroidalConstructionMode, ToroidalDomain, ToroidalDomainError, + }; // Re-export point location algorithms from the public algorithms facade. pub use crate::algorithms::{ @@ -1186,9 +1197,9 @@ pub mod prelude { // Re-export commonly used collection types from the public collections facade. // These are frequently used in advanced examples and downstream code pub use crate::collections::{ - FacetToSimplicesMap, FastHashMap, FastHashSet, SecureHashMap, SecureHashSet, - SimplexNeighborsMap, SimplexSecondaryMap, SmallBuffer, VertexSecondaryMap, - VertexToSimplicesMap, fast_hash_map_with_capacity, fast_hash_set_with_capacity, + FastHashMap, FastHashSet, SecureHashMap, SecureHashSet, SimplexNeighborsMap, + SimplexSecondaryMap, SmallBuffer, VertexSecondaryMap, VertexToSimplicesMap, + fast_hash_map_with_capacity, fast_hash_set_with_capacity, }; // Re-export from geometry @@ -1302,9 +1313,10 @@ pub mod prelude { }; pub use crate::geometry::point::Point; pub use crate::query::{ - AllFacetsIter, BoundaryAnalysis, BoundaryFacetsIter, DataCopy, DataDebug, - DataDeserialize, DataIdentity, DataSerde, DataSerialize, DataType, EdgeIndex, EdgeKey, - EdgeKeyError, FacetView, IncidenceView, QueryError, SimplexNeighborIndex, + AllFacetsIter, BoundaryFacetsIter, DataCopy, DataDebug, DataDeserialize, DataIdentity, + DataSerde, DataSerialize, DataType, EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, + FacetIncidenceAnalysis, FacetIncidenceView, FacetToSimplicesIndex, FacetView, + IncidenceView, OneSidedFacetsIter, QueryError, SimplexFacetsIter, SimplexNeighborIndex, TopologyIndexBuildError, TriangulationAdjacency, }; pub use crate::tds::{ @@ -1457,10 +1469,10 @@ pub mod prelude { /// ``` pub mod collections { pub use crate::collections::{ - Entry, FacetIndex, FacetIssuesMap, FacetSharingSimplicesBuffer, FacetToSimplicesMap, - FastBuildHasher, FastHashMap, FastHashSet, FastHasher, KeyBasedSimplexMap, - KeyBasedVertexMap, MAX_PRACTICAL_DIMENSION_SIZE, NeighborBuffer, PeriodicOffsetBuffer, - SecureHashMap, SecureHashSet, SimplexKeyBuffer, SimplexKeySet, SimplexNeighborsMap, + Entry, FacetIndex, FacetIssuesMap, FacetSharingSimplicesBuffer, FastBuildHasher, + FastHashMap, FastHashSet, FastHasher, KeyBasedSimplexMap, KeyBasedVertexMap, + MAX_PRACTICAL_DIMENSION_SIZE, NeighborBuffer, PeriodicOffsetBuffer, SecureHashMap, + SecureHashSet, SimplexKeyBuffer, SimplexKeySet, SimplexNeighborsMap, SimplexSecondaryMap, SimplexToVertexUuidsMap, SimplexVertexBuffer, SimplexVertexKeyBuffer, SimplexVertexKeysMap, SimplexVertexUuidBuffer, SimplexVerticesMap, SmallBuffer, Uuid, UuidToSimplexKeyMap, UuidToVertexKeyMap, @@ -1637,8 +1649,9 @@ pub mod prelude { pub mod query { // Core read-only traversal / adjacency pub use crate::tds::{ - EdgeIndex, EdgeKey, EdgeKeyError, IncidenceView, SimplexKey, SimplexNeighborIndex, - TopologyIndexBuildError, TriangulationAdjacency, VertexKey, + EdgeIndex, EdgeKey, EdgeKeyError, EdgeView, FacetIncidenceView, FacetToSimplicesIndex, + IncidenceView, SimplexKey, SimplexNeighborIndex, TopologyIndexBuildError, + TriangulationAdjacency, VertexKey, }; pub use crate::{DelaunayTriangulation, Triangulation}; @@ -1649,9 +1662,9 @@ pub mod prelude { }; pub use crate::geometry::traits::coordinate::Coordinate; pub use crate::query::{ - AllFacetsIter, BoundaryAnalysis, BoundaryFacetsIter, DataCopy, DataDebug, - DataDeserialize, DataIdentity, DataSerde, DataSerialize, DataType, FacetView, - QueryError, Simplex, Vertex, + AllFacetsIter, BoundaryFacetsIter, DataCopy, DataDebug, DataDeserialize, DataIdentity, + DataSerde, DataSerialize, DataType, FacetIncidenceAnalysis, FacetView, + OneSidedFacetsIter, QueryError, Simplex, SimplexFacetsIter, Vertex, }; // Read-only predicates (useful in benchmarks / lightweight geometry checks) @@ -1753,9 +1766,13 @@ pub mod prelude { pub use crate::topology::characteristics::{euler, validation}; pub use crate::topology::characteristics::{euler::*, validation::*}; pub use crate::topology::manifold::{ - ManifoldError, RidgeVertices, RidgeVerticesError, ridge_star_simplices, - validate_closed_boundary, validate_facet_degree, validate_ridge_links, - validate_ridge_links_for_simplices, validate_vertex_links, + BoundaryFacetClassification, ManifoldError, classify_boundary_facet, + validate_closed_boundary, validate_ridge_links, validate_ridge_links_for_simplices, + validate_vertex_links, + }; + pub use crate::topology::ridge::{ + RidgeCandidate, RidgeCandidateError, RidgeLinkView, RidgeQuery, RidgeView, + ridge_star_simplices, }; pub use crate::topology::traits::{ GlobalTopology, GlobalTopologyModelError, TopologicalSpace, TopologyError, @@ -1911,8 +1928,7 @@ mod tests { let set_with_cap = fast_hash_set_with_capacity::(50); assert!(set_with_cap.capacity() >= 50); - // Test domain-specific types can be instantiated - let _facet_map: FacetToSimplicesMap = FacetToSimplicesMap::default(); + // Test domain-specific public types can be instantiated let _neighbors: SimplexNeighborsMap = SimplexNeighborsMap::default(); let _vertex_simplices: VertexToSimplicesMap = VertexToSimplicesMap::default(); } diff --git a/src/topology/characteristics/euler.rs b/src/topology/characteristics/euler.rs index 0f687f0c..d09b79ce 100644 --- a/src/topology/characteristics/euler.rs +++ b/src/topology/characteristics/euler.rs @@ -43,9 +43,9 @@ use crate::core::{ }, edge::EdgeKey, tds::{Tds, VertexKey}, - traits::BoundaryAnalysis, }; -use crate::topology::traits::topological_space::TopologyError; +use crate::topology::manifold::boundary_facet_keys_from_index; +use crate::topology::traits::topological_space::{GlobalTopology, TopologyError}; type LiftedCellVertex = (VertexKey, SmallBuffer); type LiftedCellKey = SmallBuffer; @@ -132,10 +132,12 @@ impl FVector { } } -/// Topological classification of a triangulation. +/// Euler-check classification of a triangulation. /// -/// Classifies the global topological structure to determine -/// the expected Euler characteristic. +/// This is a coarse classification used to choose an expected Euler +/// characteristic after boundary facets have been interpreted under the +/// declared global topology. It is not a complete topological invariant: +/// different manifolds can share the same Euler characteristic. /// /// # Variants /// @@ -196,7 +198,7 @@ pub enum TopologyClassification { /// /// - `f₀` (vertices): Direct count from Tds - O(1) /// - `f_D` (simplices): Direct count from Tds - O(1) -/// - `f_{D-1}` (facets): Use `build_facet_to_simplices_map()` - O(N·D²) +/// - `f_{D-1}` (facets): Use the internal facet-incidence map - O(N·D²) /// - Intermediate `k`: Enumerate combinations from simplices - O(N · C(D+1, k+1)) /// /// For practical dimensions (D ≤ 5), this is efficient. @@ -477,7 +479,7 @@ fn insert_simplices_of_size( /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let boundary_counts = euler::count_boundary_simplices(dt.tds())?; +/// let boundary_counts = euler::count_boundary_simplices(dt.tds(), dt.global_topology())?; /// let boundary_chi = euler::euler_characteristic(&boundary_counts); /// assert_eq!(boundary_chi, 2); // S² has χ = 2 /// # Ok(()) @@ -486,41 +488,71 @@ fn insert_simplices_of_size( /// /// # Errors /// -/// Returns [`TopologyError::BoundaryFacetEnumeration`] or -/// [`TopologyError::BoundaryFacetSimplexAccess`] if boundary enumeration fails. +/// Returns [`TopologyError::BoundaryFacetEnumeration`] if the facet index cannot +/// be built, [`TopologyError::BoundaryClassification`] if the declared global +/// topology is incompatible with the observed facet incidences, or +/// [`TopologyError::BoundaryFacetSimplexAccess`] if a matching facet view cannot +/// be reconstructed from the TDS. pub fn count_boundary_simplices( tds: &Tds, + global_topology: GlobalTopology, ) -> Result { - // Get boundary facets - let boundary_facets: Vec<_> = tds - .boundary_facets() - .map_err(|source| TopologyError::BoundaryFacetEnumeration { source })? - .map(|facet| facet.map_err(|source| TopologyError::BoundaryFacetSimplexAccess { source })) - .collect::, _>>()?; - - if boundary_facets.is_empty() { - // No boundary - return zero counts for (D-1)-dimensional complex - return Ok(FVector { by_dim: vec![0; D] }); - } - - // Collect unique vertices on the boundary + // Get topology-approved boundary facets. + let facet_index = tds + .build_facet_to_simplices_index() + .map_err(|source| TopologyError::BoundaryFacetEnumeration { source })?; + let boundary_facet_keys = boundary_facet_keys_from_index(&facet_index, global_topology) + .map_err(|source| TopologyError::BoundaryClassification { + source: Box::new(source), + })?; + // Count boundary facets and unique boundary vertices in one pass. Keep the + // facet-view construction in the pass so corrupt facet reconstruction still + // surfaces as BoundaryFacetSimplexAccess. + let mut num_boundary_facets = 0_usize; let mut boundary_vertices = FastHashSet::default(); - for facet in &boundary_facets { - let simplex = facet - .simplex() - .map_err(|source| TopologyError::BoundaryFacetSimplexAccess { source })?; + let mut intermediate_simplex_sets: Option< + Vec>>, + > = (D > 2).then(|| (0..(D - 2)).map(|_| FastHashSet::default()).collect()); + + for facet in tds.facets() { + let facet = facet.map_err(|source| TopologyError::BoundaryFacetSimplexAccess { source })?; + if !boundary_facet_keys.contains(&facet.key()) { + continue; + } + + num_boundary_facets += 1; + let simplex = facet.simplex(); let facet_index = usize::from(facet.facet_index()); + let mut facet_vertex_keys: SmallBuffer = + SmallBuffer::new(); - // Add all vertex keys except the opposite vertex - for (i, &v_key) in simplex.vertices().iter().enumerate() { - if i != facet_index { - boundary_vertices.insert(v_key); + for (vertex_position, &vertex_key) in simplex.vertices().iter().enumerate() { + if vertex_position != facet_index { + boundary_vertices.insert(vertex_key); + facet_vertex_keys.push(vertex_key); } } + + let Some(intermediate_simplex_sets) = intermediate_simplex_sets.as_mut() else { + continue; + }; + + // Sort once so every generated combination is already canonical, + // avoiding per-combination sorting. + facet_vertex_keys.sort(); + for simplex_dimension in 1..=D - 2 { + let simplex_set = &mut intermediate_simplex_sets[simplex_dimension.saturating_sub(1)]; + let simplex_size = simplex_dimension + 1; // k-simplex has k+1 vertices + insert_simplices_of_size(&facet_vertex_keys, simplex_size, simplex_set); + } + } + + if num_boundary_facets == 0 { + // No boundary - return zero counts for (D-1)-dimensional complex + return Ok(FVector { by_dim: vec![0; D] }); } let num_boundary_vertices = boundary_vertices.len(); - let num_boundary_facets = boundary_facets.len(); // These are (D-1)-simplices // Initialize counts for (D-1)-dimensional complex // by_dim[0] = vertices, by_dim[1] = edges, ..., by_dim[D-1] = (D-1)-simplices @@ -530,42 +562,7 @@ pub fn count_boundary_simplices( // Count intermediate k-simplices (1 ≤ k < D-1) by enumerating combinations // from boundary facets. - // - // We keep a set per k and fill them in a single pass over boundary facets, which is faster than - // re-iterating all facets once per k. - // Skip if D <= 2 (no intermediate dimensions in boundary) - if D > 2 { - let mut intermediate_simplex_sets: Vec< - FastHashSet>, - > = (0..(D - 2)).map(|_| FastHashSet::default()).collect(); - - for facet in &boundary_facets { - let simplex = facet - .simplex() - .map_err(|source| TopologyError::BoundaryFacetSimplexAccess { source })?; - let facet_index = usize::from(facet.facet_index()); - - // Collect vertex keys for this facet (excluding opposite vertex). - // - // We sort once so every generated combination is already in canonical order, avoiding - // per-combination sorting. - let mut facet_vertex_keys: SmallBuffer = - SmallBuffer::new(); - for (vertex_position, &v_key) in simplex.vertices().iter().enumerate() { - if vertex_position != facet_index { - facet_vertex_keys.push(v_key); - } - } - facet_vertex_keys.sort(); - - for simplex_dimension in 1..=D - 2 { - let simplex_set = - &mut intermediate_simplex_sets[simplex_dimension.saturating_sub(1)]; - let simplex_size = simplex_dimension + 1; // k-simplex has k+1 vertices - insert_simplices_of_size(&facet_vertex_keys, simplex_size, simplex_set); - } - } - + if let Some(intermediate_simplex_sets) = intermediate_simplex_sets { for simplex_dimension in 1..=D - 2 { by_dim[simplex_dimension] = intermediate_simplex_sets[simplex_dimension.saturating_sub(1)].len(); @@ -735,17 +732,19 @@ pub(crate) fn triangulated_surface_boundary_component_count( components } -/// Classify the triangulation topologically. +/// Classifies a triangulation for Euler-characteristic compatibility checks. /// -/// Determines the topological type based on the number of simplices -/// and boundary structure. +/// This is a coarse classification, not a complete topology detector. Boundary +/// structure is interpreted through the supplied [`GlobalTopology`] so raw +/// one-sided incidence in a periodic quotient is not mistaken for boundary. /// /// # Classification Logic /// /// - No simplices → `Empty` -/// - One simplex → `SingleSimplex(D)` -/// - Has boundary → `Ball(D)` -/// - No boundary → `ClosedSphere(D)` (rare) +/// - One simplex with true boundary → `SingleSimplex(D)` +/// - True boundary → `Ball(D)` +/// - No boundary with toroidal metadata → `ClosedToroid(D)` +/// - No boundary otherwise → `ClosedSphere(D)` (rare) /// /// # Examples /// @@ -771,7 +770,7 @@ pub(crate) fn triangulated_surface_boundary_component_count( /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let classification = classify_triangulation(dt.tds())?; +/// let classification = classify_triangulation(dt.tds(), dt.global_topology())?; /// assert_eq!(classification, TopologyClassification::SingleSimplex(3)); /// # Ok(()) /// # } @@ -779,9 +778,14 @@ pub(crate) fn triangulated_surface_boundary_component_count( /// /// # Errors /// -/// Returns [`TopologyError::BoundaryFacetCount`] if boundary detection fails. +/// Returns [`TopologyError::BoundaryFacetCount`] if the facet index cannot be +/// built, or [`TopologyError::BoundaryClassification`] if the declared global +/// topology is incompatible with the observed facet incidences. The latter +/// includes open one-sided facets in closed topology and periodic +/// self-identifications in non-periodic topology metadata. pub fn classify_triangulation( tds: &Tds, + global_topology: GlobalTopology, ) -> Result { let num_simplices = tds.number_of_simplices(); @@ -790,20 +794,21 @@ pub fn classify_triangulation( return Ok(TopologyClassification::Empty); } - // Single simplex - if num_simplices == 1 { - return Ok(TopologyClassification::SingleSimplex(D)); - } - - // Check boundary - let has_boundary = tds - .number_of_boundary_facets() - .map_err(|source| TopologyError::BoundaryFacetCount { source })? - > 0; - - if has_boundary { - // Has boundary → topological ball + let facet_index = tds + .build_facet_to_simplices_index() + .map_err(|source| TopologyError::BoundaryFacetCount { source })?; + let has_boundary = !boundary_facet_keys_from_index(&facet_index, global_topology) + .map_err(|source| TopologyError::BoundaryClassification { + source: Box::new(source), + })? + .is_empty(); + + if num_simplices == 1 && has_boundary { + Ok(TopologyClassification::SingleSimplex(D)) + } else if has_boundary { Ok(TopologyClassification::Ball(D)) + } else if global_topology.is_toroidal() { + Ok(TopologyClassification::ClosedToroid(D)) } else { // No boundary → closed manifold (assume sphere for now) Ok(TopologyClassification::ClosedSphere(D)) @@ -1144,9 +1149,7 @@ mod tests { fn test_classify_triangulation_closed_sphere_2d_surface() { let tds = build_closed_2d_surface_tds(); - assert_eq!(tds.number_of_boundary_facets().unwrap(), 0); - - let classification = classify_triangulation(&tds).unwrap(); + let classification = classify_triangulation(&tds, GlobalTopology::Euclidean).unwrap(); assert_eq!(classification, TopologyClassification::ClosedSphere(2)); let counts = count_simplices(&tds).unwrap(); @@ -1158,7 +1161,7 @@ mod tests { fn test_count_boundary_simplices_no_boundary_is_zero() { let tds = build_closed_2d_surface_tds(); - let boundary_counts = count_boundary_simplices(&tds).unwrap(); + let boundary_counts = count_boundary_simplices(&tds, GlobalTopology::Euclidean).unwrap(); assert_eq!(boundary_counts.by_dim, vec![0, 0]); assert_eq!(euler_characteristic(&boundary_counts), 0); } diff --git a/src/topology/characteristics/validation.rs b/src/topology/characteristics/validation.rs index a34a9651..216cb490 100644 --- a/src/topology/characteristics/validation.rs +++ b/src/topology/characteristics/validation.rs @@ -11,7 +11,8 @@ use crate::topology::{ FVector, TopologyClassification, count_simplices_with_facet_to_simplices_map, euler_characteristic, expected_chi_for, }, - traits::topological_space::TopologyError, + manifold::{ValidatedFacetDegreeMap, has_boundary_facets_in_validated_facet_map}, + traits::topological_space::{GlobalTopology, TopologyError}, }; /// Result of Euler characteristic validation. @@ -43,7 +44,7 @@ use crate::topology::{ /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let result = validation::validate_triangulation_euler(dt.tds())?; +/// let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology())?; /// assert_eq!(result.chi, 1); /// assert!(result.is_valid()); /// # Ok(()) @@ -139,7 +140,7 @@ impl TopologyCheckResult { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// -/// let result = validation::validate_triangulation_euler(dt.tds())?; +/// let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology())?; /// assert_eq!(result.chi, 1); /// assert_eq!(result.counts.count(0), 3); // 3 vertices /// assert_eq!(result.counts.count(1), 3); // 3 edges @@ -151,9 +152,13 @@ impl TopologyCheckResult { /// /// # Errors /// -/// Returns [`TopologyError`] if topology validation support data cannot be built. +/// Returns [`TopologyError::FacetMapBuild`] if the TDS facet-incidence map +/// cannot be built. Returns [`TopologyError::BoundaryClassification`] if the +/// declared [`GlobalTopology`] is incompatible with the observed facet +/// incidences, such as an open one-sided facet in a closed topology. pub fn validate_triangulation_euler( tds: &Tds, + global_topology: GlobalTopology, ) -> Result { // Precompute the facet map once and reuse it for both counting and classification. // @@ -165,29 +170,47 @@ pub fn validate_triangulation_euler( .map_err(|source| TopologyError::FacetMapBuild { source })? }; - Ok(validate_triangulation_euler_with_facet_to_simplices_map( - tds, - &facet_to_simplices, - )) + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices) + .map_err(|source| TopologyError::BoundaryClassification { + source: Box::new(source), + })?; + validate_triangulation_euler_from_validated_facet_map(tds, facet_to_simplices, global_topology) } -pub(crate) fn validate_triangulation_euler_with_facet_to_simplices_map( +/// Computes the Euler check while reusing an already-validated facet-degree map. +/// +/// This keeps [`Triangulation`](crate::prelude::triangulation::Triangulation) +/// Level-3 validation from rebuilding the same incidence map while preserving +/// the public boundary contract: one-sided incidence is classified against +/// [`GlobalTopology`] before it affects the expected χ. +/// +/// # Errors +/// +/// Returns [`TopologyError::BoundaryClassification`] if one-sided incidence is +/// incompatible with `global_topology`. +pub(crate) fn validate_triangulation_euler_from_validated_facet_map( tds: &Tds, - facet_to_simplices: &FacetToSimplicesMap, -) -> TopologyCheckResult { - let counts = count_simplices_with_facet_to_simplices_map(tds, facet_to_simplices); + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + global_topology: GlobalTopology, +) -> Result { + let counts = count_simplices_with_facet_to_simplices_map(tds, facet_to_simplices.as_map()); let chi = euler_characteristic(&counts); let num_simplices = tds.number_of_simplices(); + let has_boundary = num_simplices != 0 + && has_boundary_facets_in_validated_facet_map(tds, facet_to_simplices, global_topology) + .map_err(|source| TopologyError::BoundaryClassification { + source: Box::new(source), + })?; + let classification = if num_simplices == 0 { TopologyClassification::Empty - } else if num_simplices == 1 { + } else if num_simplices == 1 && has_boundary { TopologyClassification::SingleSimplex(D) - } else if facet_to_simplices - .values() - .any(|simplices| simplices.len() == 1) - { + } else if has_boundary { TopologyClassification::Ball(D) + } else if global_topology.is_toroidal() { + TopologyClassification::ClosedToroid(D) } else { TopologyClassification::ClosedSphere(D) }; @@ -203,13 +226,13 @@ pub(crate) fn validate_triangulation_euler_with_facet_to_simplices_map, -} - -type LiftedVertexBuffer = SmallBuffer; -type LinkSimplexBuffer = SmallBuffer; - -impl LiftedVertexId { - fn base(vertex_key: VertexKey) -> Self { - Self { - vertex_key, - offset: SmallBuffer::new(), - } - } - - fn is_base(&self) -> bool { - self.offset.is_empty() - } -} - -impl Ord for LiftedVertexId { - fn cmp(&self, other: &Self) -> Ordering { - self.vertex_key - .data() - .as_ffi() - .cmp(&other.vertex_key.data().as_ffi()) - .then_with(|| self.offset.as_slice().cmp(other.offset.as_slice())) - } -} - -impl PartialOrd for LiftedVertexId { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Hash for LiftedVertexId { - fn hash(&self, state: &mut H) { - self.vertex_key.data().as_ffi().hash(state); - self.offset.as_slice().hash(state); - } -} - -/// Creates a lifted vertex identity from a real TDS vertex key and periodic -/// lattice offset. -fn lifted_vertex_id(vk: VertexKey, offset: &[O]) -> LiftedVertexId -where - O: Copy + Into, -{ - if offset.is_empty() || offset.iter().all(|&o| o.into() == 0) { - return LiftedVertexId::base(vk); - } - LiftedVertexId { - vertex_key: vk, - offset: offset.iter().map(|&component| component.into()).collect(), - } -} - -/// Computes a periodic-aware simplex key from lifted vertex IDs. -fn periodic_simplex_key(lifted_vertices: &[LiftedVertexId]) -> u64 { - if lifted_vertices.iter().all(LiftedVertexId::is_base) { - let bare_vertices: VertexKeyBuffer = - lifted_vertices.iter().map(|id| id.vertex_key).collect(); - return facet_key_from_vertices(&bare_vertices); - } - - let keys = normalize_lifted_vertices(lifted_vertices); - let mut hasher = FastHasher::default(); - for key in &keys { - key.hash(&mut hasher); - } - hasher.finish() -} - -/// Computes an exact lifted simplex key without quotient translation normalization. -/// -/// Vertex links already express every lifted vertex relative to the linked -/// anchor so applying an additional global translation quotient can -/// collapse distinct link simplices. -fn anchored_lifted_simplex_key(lifted_vertices: &[LiftedVertexId]) -> u64 { - if lifted_vertices.iter().all(LiftedVertexId::is_base) { - let bare_vertices: VertexKeyBuffer = - lifted_vertices.iter().map(|id| id.vertex_key).collect(); - return facet_key_from_vertices(&bare_vertices); - } - - let mut keys: LiftedVertexBuffer = lifted_vertices.iter().cloned().collect(); - keys.sort_unstable(); - let mut hasher = FastHasher::default(); - for key in &keys { - key.hash(&mut hasher); - } - hasher.finish() -} - -/// Normalizes lifted vertices by subtracting the offset of the first sorted -/// lifted making periodic simplex identities translation invariant. -fn normalize_lifted_vertices(lifted_vertices: &[LiftedVertexId]) -> LiftedVertexBuffer { - let mut keys: LiftedVertexBuffer = lifted_vertices.iter().cloned().collect(); - keys.sort_unstable(); - let anchor_offset: SmallBuffer = keys - .first() - .map_or_else(SmallBuffer::new, |key| key.offset.clone()); - let axes = keys - .iter() - .map(|key| key.offset.len()) - .max() - .unwrap_or(0) - .max(anchor_offset.len()); - - let mut normalized = LiftedVertexBuffer::with_capacity(keys.len()); - for key in keys { - let mut offset: SmallBuffer = SmallBuffer::with_capacity(axes); - for axis in 0..axes { - let component = key.offset.get(axis).copied().unwrap_or(0) - - anchor_offset.get(axis).copied().unwrap_or(0); - offset.push(component); - } - normalized.push(lifted_vertex_id(key.vertex_key, &offset)); - } - normalized -} - -fn ordered_lifted_edge(a: &LiftedVertexId, b: &LiftedVertexId) -> (LiftedVertexId, LiftedVertexId) { - if b < a { - (b.clone(), a.clone()) - } else { - (a.clone(), b.clone()) - } -} - /// Errors that can occur during manifold (topology) validation. /// /// # Examples @@ -265,6 +136,13 @@ pub enum ManifoldError { #[error(transparent)] Tds(#[from] TdsError), + /// A live ridge candidate does not occur in any D-simplex. + #[error("Ridge candidate {ridge_vertices:?} is not present in the TDS")] + RidgeNotFound { + /// Canonical quotient-space ridge vertices that had an empty simplex star. + ridge_vertices: VertexKeyBuffer, + }, + /// A facet belongs to an unexpected number of simplices for a manifold-with-boundary. #[error( "Non-manifold facet: facet {facet_key:016x} belongs to {simplex_count} simplices (expected 1 or 2)" @@ -290,6 +168,40 @@ pub enum ManifoldError { boundary_facet_count: usize, }, + /// A topology declared as closed contains a raw open one-sided facet. + #[error( + "Closed {topology:?} topology contains open boundary facet {facet_key:016x} at simplex {simplex_uuid}[{facet_index}]" + )] + BoundaryFacetInClosedTopology { + /// Declared global topology kind. + topology: TopologyKind, + /// Canonical facet key with open one-sided incidence. + facet_key: u64, + /// Simplex containing the open facet. + simplex_key: SimplexKey, + /// UUID of the simplex containing the open facet. + simplex_uuid: uuid::Uuid, + /// Facet index in the simplex. + facet_index: usize, + }, + + /// A non-periodic topology contains a periodic self-identification facet. + #[error( + "{topology:?} topology contains periodic self-identified facet {facet_key:016x} at simplex {simplex_uuid}[{facet_index}]" + )] + PeriodicIdentificationInNonPeriodicTopology { + /// Declared global topology kind. + topology: TopologyKind, + /// Canonical facet key with periodic self-identification. + facet_key: u64, + /// Simplex containing the periodic self-identification. + simplex_key: SimplexKey, + /// UUID of the simplex containing the periodic self-identification. + simplex_uuid: uuid::Uuid, + /// Facet index in the simplex. + facet_index: usize, + }, + /// A ridge's link graph is not a 1-manifold (path or cycle). /// /// In a PL-manifold (with boundary), the link of every (D-2)-simplex is: @@ -334,58 +246,91 @@ pub enum ManifoldError { }, } -/// Errors returned when parsing raw vertex keys into a ridge vertex set. -#[derive(Clone, Debug, Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum RidgeVerticesError { - /// Ridge vertices are only meaningful for dimensions `D >= 2`. - #[error("ridge vertices require D >= 2, got D={dimension}")] - UnsupportedDimension { - /// Requested triangulation dimension. - dimension: usize, - }, +/// Borrowed proof that a raw facet map has one- or two-sided incidence only. +/// +/// Level-3 validation builds one [`FacetToSimplicesMap`] and reuses it across +/// topology checks. This wrapper carries the facet-degree proof so downstream +/// helpers cannot accidentally consume an unparsed raw map. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ValidatedFacetDegreeMap<'map> { + facet_to_simplices: &'map FacetToSimplicesMap, +} - /// The supplied vertex count does not match the ridge arity `D - 1`. - #[error("ridge vertex count mismatch for {dimension}D: expected {expected}, got {actual}")] - WrongArity { - /// Requested triangulation dimension. - dimension: usize, - /// Expected number of ridge vertices. - expected: usize, - /// Actual number of supplied vertices. - actual: usize, - }, +impl<'map> ValidatedFacetDegreeMap<'map> { + /// Parses a raw facet map into a facet-degree proof. + /// + /// # Errors + /// + /// Returns [`ManifoldError::ManifoldFacetMultiplicity`] if any facet key is + /// incident to zero, three, or more simplex facets. + pub(crate) fn try_from_facet_map( + facet_to_simplices: &'map FacetToSimplicesMap, + ) -> Result { + for (facet_key, simplex_facet_pairs) in facet_to_simplices { + match simplex_facet_pairs.as_slice() { + [_] | [_, _] => {} + _ => { + return Err(ManifoldError::ManifoldFacetMultiplicity { + facet_key: *facet_key, + simplex_count: simplex_facet_pairs.len(), + }); + } + } + } - /// A ridge cannot contain the same vertex more than once. - #[error("ridge vertices contain duplicate vertex key {vertex_key:?}")] - DuplicateVertex { - /// Duplicate vertex key. - vertex_key: VertexKey, - }, + Ok(Self { facet_to_simplices }) + } + + /// Returns the raw facet map whose degree invariant this value proves. + #[inline] + pub(crate) const fn as_map(self) -> &'map FacetToSimplicesMap { + self.facet_to_simplices + } +} + +/// Topology-aware boundary classification for one canonical facet key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub enum BoundaryFacetClassification { + /// The facet is a true manifold boundary facet. + Boundary(FacetHandle), + /// The facet is shared by two D-simplices. + Interior, + /// The facet is a closed periodic self-identification, not boundary. + ClosedIdentification, } -/// Validated vertex keys for a `(D - 2)`-simplex ridge. +/// Classifies parsed facet incidence under the declared global topology. +/// +/// This is the semantic boundary classifier: the TDS supplies incidence, while +/// the triangulation/topology layer decides whether one-sided incidence is +/// boundary, a closed periodic identification, or an invalid open facet in a +/// closed space. +/// +/// # Errors /// -/// This proof-bearing wrapper encodes the arity and uniqueness invariants for -/// ridge-star queries before they reach topology computation. It stores vertex -/// keys in canonical sorted order so the same ridge has the same identity -/// regardless of input order. It does not prove that the vertices exist in a -/// particular [`Tds`]; that dynamic check remains part of -/// [`ridge_star_simplices`]. +/// Returns [`ManifoldError::Tds`] if the facet handle references corrupt TDS +/// state, [`ManifoldError::BoundaryFacetInClosedTopology`] when an open +/// one-sided facet appears in closed topology, or +/// [`ManifoldError::PeriodicIdentificationInNonPeriodicTopology`] when a +/// periodic self-identification appears in non-periodic topology metadata. /// /// # Examples /// /// ```rust -/// use delaunay::prelude::construction::{ -/// DelaunayTriangulation, DelaunayTriangulationConstructionError, +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// BoundaryFacetClassification, classify_boundary_facet, /// }; -/// use delaunay::prelude::topology::validation::{RidgeVertices, RidgeVerticesError}; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { -/// # #[error(transparent)] Construction(#[from] DelaunayTriangulationConstructionError), -/// # #[error(transparent)] Ridge(#[from] RidgeVerticesError), -/// # #[error("constructed triangulation has no vertex keys")] Empty, +/// # #[error(transparent)] +/// # Construction(#[from] delaunay::DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Tds(#[from] delaunay::prelude::tds::TdsError), +/// # #[error(transparent)] +/// # Manifold(#[from] delaunay::prelude::topology::validation::ManifoldError), /// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), /// # } @@ -393,170 +338,164 @@ pub enum RidgeVerticesError { /// let vertices = vec![ /// delaunay::vertex![0.0, 0.0]?, /// delaunay::vertex![1.0, 0.0]?, -/// delaunay::vertex![0.0, 1.0]?, +/// delaunay::vertex![0.5, 1.0]?, /// ]; -/// let triangulation = DelaunayTriangulation::try_new(&vertices)?; -/// let Some(v0) = triangulation.tds().vertex_keys().next() else { -/// return Err(ExampleError::Empty); +/// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; +/// let facet_index = dt.tds().build_facet_to_simplices_index()?; +/// +/// let Some(incidence) = facet_index.iter().find(|incidence| incidence.is_one_sided()) +/// else { +/// return Ok(()); /// }; /// -/// // In 2D, a ridge is a so the validated ridge set has arity 1. -/// let ridge = RidgeVertices::<2>::try_from_vertices([v0])?; -/// assert_eq!(ridge.as_slice(), &[v0]); -/// assert_eq!(ridge.iter().collect::>(), vec![v0]); +/// let classification = classify_boundary_facet(incidence, dt.global_topology())?; +/// std::assert_matches!( +/// classification, +/// BoundaryFacetClassification::Boundary(_) +/// ); /// # Ok(()) /// # } /// ``` -#[must_use] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RidgeVertices { - vertices: VertexKeyBuffer, -} - -impl RidgeVertices { - /// Parses raw vertex keys into a validated ridge vertex set. - /// - /// Stored vertex keys are canonicalized into sorted order. - /// - /// # Errors - /// - /// Returns [`RidgeVerticesError::UnsupportedDimension`] when `D < 2`, - /// [`RidgeVerticesError::WrongArity`] when the input length is not `D - 1`, - /// or [`RidgeVerticesError::DuplicateVertex`] when a vertex key is repeated. - /// - /// # Examples - /// - /// ```rust - /// use delaunay::prelude::construction::{ - /// DelaunayTriangulation, DelaunayTriangulationConstructionError, - /// }; - /// use delaunay::prelude::topology::validation::{RidgeVertices, RidgeVerticesError}; - /// - /// # #[derive(Debug, thiserror::Error)] - /// # enum ExampleError { - /// # #[error(transparent)] Construction(#[from] DelaunayTriangulationConstructionError), - /// # #[error(transparent)] Ridge(#[from] RidgeVerticesError), - /// # #[error("constructed triangulation has fewer than two vertex keys")] TooFewVertices, - /// # #[error(transparent)] - /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), - /// # } - /// # fn main() -> Result<(), ExampleError> { - /// let vertices = vec![ - /// delaunay::vertex![0.0, 0.0, 0.0]?, - /// delaunay::vertex![1.0, 0.0, 0.0]?, - /// delaunay::vertex![0.0, 1.0, 0.0]?, - /// delaunay::vertex![0.0, 0.0, 1.0]?, - /// ]; - /// let triangulation = DelaunayTriangulation::try_new(&vertices)?; - /// let keys = triangulation.tds().vertex_keys().collect::>(); - /// let [v0, v1, ..] = keys.as_slice() else { - /// return Err(ExampleError::TooFewVertices); - /// }; - /// - /// // In 3D, a ridge is an edge and therefore has two vertices. - /// let ridge = RidgeVertices::<3>::try_from_vertices([*v1, *v0])?; - /// let mut expected = vec![*v0, *v1]; - /// expected.sort_unstable(); - /// assert_eq!(ridge.as_slice(), expected.as_slice()); - /// # Ok(()) - /// # } - /// ``` - pub fn try_from_vertices( - vertices: impl IntoIterator, - ) -> Result { - if D < 2 { - return Err(RidgeVerticesError::UnsupportedDimension { dimension: D }); - } +pub fn classify_boundary_facet( + incidence: FacetIncidenceView<'_, '_, U, V, D>, + global_topology: GlobalTopology, +) -> Result { + let Some(handle) = incidence.one_sided_handle() else { + return Ok(BoundaryFacetClassification::Interior); + }; - let mut vertices: VertexKeyBuffer = vertices.into_iter().collect(); - let expected = D - 1; - if vertices.len() != expected { - return Err(RidgeVerticesError::WrongArity { - dimension: D, - expected, - actual: vertices.len(), - }); - } + classify_boundary_facet_handle( + incidence.tds(), + global_topology, + incidence.facet_key(), + handle, + ) +} - vertices.sort_unstable(); - for duplicate_pair in vertices.windows(2) { - if duplicate_pair[0] == duplicate_pair[1] { - return Err(RidgeVerticesError::DuplicateVertex { - vertex_key: duplicate_pair[0], - }); - } +/// Applies topology-specific semantics to a parsed one-sided facet handle. +/// +/// This helper keeps the public [`classify_boundary_facet`] contract aligned +/// with validation paths that still operate on raw facet maps for performance. +fn classify_boundary_facet_handle( + tds: &Tds, + global_topology: GlobalTopology, + facet_key: u64, + handle: FacetHandle, +) -> Result { + let facet = + try_incident_facet_view_for_facet_key(tds, facet_key, handle).map_err(TdsError::from)?; + let simplex_key = facet.simplex_key(); + let facet_index = usize::from(facet.facet_index()); + let simplex = facet.simplex(); + let simplex_uuid = simplex.uuid(); + + match classify_one_sided_facet_adjacency(&facet)? { + OneSidedFacetAdjacency::Open if global_topology.allows_boundary() => { + Ok(BoundaryFacetClassification::Boundary(handle)) + } + OneSidedFacetAdjacency::Open => Err(ManifoldError::BoundaryFacetInClosedTopology { + topology: global_topology.kind(), + facet_key, + simplex_key, + simplex_uuid, + facet_index, + }), + OneSidedFacetAdjacency::PeriodicSelfIdentification if global_topology.is_periodic() => { + Ok(BoundaryFacetClassification::ClosedIdentification) + } + OneSidedFacetAdjacency::PeriodicSelfIdentification => { + Err(ManifoldError::PeriodicIdentificationInNonPeriodicTopology { + topology: global_topology.kind(), + facet_key, + simplex_key, + simplex_uuid, + facet_index, + }) } - - Ok(Self { vertices }) - } - - /// Returns the validated ridge vertex keys. - #[must_use] - pub fn as_slice(&self) -> &[VertexKey] { - &self.vertices } +} - /// Iterates over the validated ridge vertex keys. - pub fn iter(&self) -> impl Iterator + '_ { - self.vertices.iter().copied() - } +/// Builds the canonical set of true boundary facet keys for a triangulation. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] if facet handles reference corrupt TDS state, +/// [`ManifoldError::BoundaryFacetInClosedTopology`] when the declared topology +/// is closed but an open one-sided facet is present, or +/// [`ManifoldError::PeriodicIdentificationInNonPeriodicTopology`] when a +/// periodic self-identification is observed in non-periodic topology metadata. +pub(crate) fn boundary_facet_keys_from_index( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result, ManifoldError> { + let mut boundary_facet_keys: FastHashSet = + fast_hash_set_with_capacity(facet_to_simplices.len().min(64)); + for incidence in facet_to_simplices.iter() { + let facet_key = incidence.facet_key(); + if matches!( + classify_boundary_facet(incidence, global_topology)?, + BoundaryFacetClassification::Boundary(_) + ) { + boundary_facet_keys.insert(facet_key); + } + } + Ok(boundary_facet_keys) } -/// Validates that each (D-1)-facet has degree 1 (boundary) or 2 (interior). +/// Builds the topology-approved boundary facet handles for a triangulation. /// -/// This enforces the codimension-1 pseudomanifold condition and is not sufficient by itself -/// to guarantee full PL-manifoldness. +/// This is the view-iteration counterpart to [`boundary_facet_keys_from_index`]. +/// Returning handles lets boundary iterators construct exactly the facets +/// classified as true boundary, without rescanning every simplex facet. /// /// # Errors /// -/// Returns [`ManifoldError::ManifoldFacetMultiplicity`] if any facet is incident -/// to a number of simplices other than 1 or 2. -/// -/// # Examples +/// Returns the same topology-classification errors as +/// [`boundary_facet_keys_from_index`]. +pub(crate) fn boundary_facet_handles_from_index( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result, ManifoldError> { + let mut boundary_facet_handles = Vec::with_capacity(facet_to_simplices.len().min(64)); + for incidence in facet_to_simplices.iter() { + if let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet(incidence, global_topology)? + { + boundary_facet_handles.push(handle); + } + } + Ok(boundary_facet_handles) +} + +/// Returns whether a validated facet-degree map contains any true boundary facet. /// -/// ```rust -/// use delaunay::prelude::geometry::*; -/// use delaunay::prelude::*; -/// use delaunay::prelude::topology::validation::validate_facet_degree; +/// This is the map-reuse counterpart to [`boundary_facet_keys_from_index`]. +/// Euler validation uses it so one-sided incidence is never mistaken for a +/// semantic boundary without first checking [`GlobalTopology`]. /// -/// # #[derive(Debug, thiserror::Error)] -/// # enum ExampleError { -/// # #[error(transparent)] Tds(#[from] delaunay::prelude::tds::TdsError), -/// # #[error(transparent)] Construction(#[from] delaunay::prelude::triangulation::TriangulationConstructionError), -/// # #[error(transparent)] Manifold(#[from] delaunay::prelude::topology::validation::ManifoldError), -/// # #[error(transparent)] -/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), -/// # } -/// # fn main() -> Result<(), ExampleError> { -/// let vertices = vec![ -/// delaunay::vertex![0.0, 0.0, 0.0]?, -/// delaunay::vertex![1.0, 0.0, 0.0]?, -/// delaunay::vertex![0.0, 1.0, 0.0]?, -/// delaunay::vertex![0.0, 0.0, 1.0]?, -/// ]; -/// let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices)?; -/// let facet_to_simplices = tds.build_facet_to_simplices_map()?; +/// # Errors /// -/// validate_facet_degree(&facet_to_simplices)?; -/// # Ok(()) -/// # } -/// ``` -pub fn validate_facet_degree( - facet_to_simplices: &FacetToSimplicesMap, -) -> Result<(), ManifoldError> { - for (facet_key, simplex_facet_pairs) in facet_to_simplices { - match simplex_facet_pairs.as_slice() { - [_] | [_, _] => {} - _ => { - return Err(ManifoldError::ManifoldFacetMultiplicity { - facet_key: *facet_key, - simplex_count: simplex_facet_pairs.len(), - }); - } +/// Returns the same topology-classification errors as +/// [`boundary_facet_keys_from_index`] when a one-sided facet is incompatible +/// with the declared topology. +pub(crate) fn has_boundary_facets_in_validated_facet_map( + tds: &Tds, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + global_topology: GlobalTopology, +) -> Result { + for (facet_key, simplex_facet_pairs) in facet_to_simplices.as_map() { + let [handle] = simplex_facet_pairs.as_slice() else { + continue; + }; + if matches!( + classify_boundary_facet_handle(tds, global_topology, *facet_key, *handle)?, + BoundaryFacetClassification::Boundary(_) + ) { + return Ok(true); } } - Ok(()) + Ok(false) } /// Validates that the boundary (if present) is a closed (D-1)-manifold. @@ -583,6 +522,7 @@ pub fn validate_facet_degree( /// ```rust /// use delaunay::prelude::geometry::*; /// use delaunay::prelude::*; +/// use delaunay::prelude::topology::spaces::GlobalTopology; /// use delaunay::prelude::topology::validation::validate_closed_boundary; /// /// # #[derive(Debug, thiserror::Error)] @@ -601,16 +541,84 @@ pub fn validate_facet_degree( /// delaunay::vertex![0.0, 0.0, 1.0]?, /// ]; /// let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices)?; -/// let facet_to_simplices = tds.build_facet_to_simplices_map()?; +/// let facet_to_simplices = tds.build_facet_to_simplices_index()?; /// -/// validate_closed_boundary(&tds, &facet_to_simplices)?; +/// validate_closed_boundary(&facet_to_simplices, GlobalTopology::Euclidean)?; /// # Ok(()) /// # } /// ``` pub fn validate_closed_boundary( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result<(), ManifoldError> { + validate_closed_boundary_index(facet_to_simplices, global_topology) +} + +fn validate_closed_boundary_index( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result<(), ManifoldError> { + let tds = facet_to_simplices.tds(); + // The boundary is a (D-1)-complex. Codimension-2 manifoldness is only meaningful for D>=2. + if D < 2 { + return Ok(()); + } + + // First count boundary facets so we can reserve reasonably. Periodic + // self-neighbor facets are closed quotient identifications, not boundary. + let mut boundary_facet_count = 0usize; + for incidence in facet_to_simplices.iter() { + if matches!( + classify_boundary_facet(incidence, global_topology)?, + BoundaryFacetClassification::Boundary(_) + ) { + boundary_facet_count = boundary_facet_count.saturating_add(1); + } + } + + if boundary_facet_count == 0 { + return Ok(()); + } + + // Each boundary facet contributes D ridges; each boundary ridge is shared by exactly 2 + // boundary facets in a closed boundary manifold. + let estimated_boundary_ridges = boundary_facet_count + .saturating_mul(D) + .saturating_div(2) + .max(1); + + let mut ridge_to_boundary_facet_count: FastHashMap = + fast_hash_map_with_capacity(estimated_boundary_ridges); + + let mut facet_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D); + let mut ridge_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + + for incidence in facet_to_simplices.iter() { + let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet(incidence, global_topology)? + else { + continue; + }; + + count_boundary_facet_ridges( + tds, + handle, + &mut facet_vertices, + &mut ridge_vertices, + &mut ridge_to_boundary_facet_count, + )?; + } + + validate_boundary_ridge_counts(ridge_to_boundary_facet_count) +} + +/// Validates closed-boundary invariants from a validated facet-degree map. +pub(crate) fn validate_closed_boundary_from_validated_facet_map( tds: &Tds, - facet_to_simplices: &FacetToSimplicesMap, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + global_topology: GlobalTopology, ) -> Result<(), ManifoldError> { + let facet_to_simplices = facet_to_simplices.as_map(); // The boundary is a (D-1)-complex. Codimension-2 manifoldness is only meaningful for D>=2. if D < 2 { return Ok(()); @@ -619,11 +627,14 @@ pub fn validate_closed_boundary( // First count boundary facets so we can reserve reasonably. Periodic // self-neighbor facets are closed quotient identifications, not boundary. let mut boundary_facet_count = 0usize; - for simplex_facet_pairs in facet_to_simplices.values() { + for (facet_key, simplex_facet_pairs) in facet_to_simplices { let [handle] = simplex_facet_pairs.as_slice() else { continue; }; - if is_boundary_facet_handle(tds, *handle)? { + if matches!( + classify_boundary_facet_handle(tds, global_topology, *facet_key, *handle)?, + BoundaryFacetClassification::Boundary(_) + ) { boundary_facet_count = boundary_facet_count.saturating_add(1); } } @@ -645,64 +656,90 @@ pub fn validate_closed_boundary( let mut facet_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D); let mut ridge_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); - for simplex_facet_pairs in facet_to_simplices.values() { + for (facet_key, simplex_facet_pairs) in facet_to_simplices { // Only boundary facets (exactly one incident simplex). let [handle] = simplex_facet_pairs.as_slice() else { continue; }; - let simplex_key = handle.simplex_key(); - let facet_index = handle.facet_index() as usize; - if !is_boundary_facet_handle(tds, *handle)? { + let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet_handle(tds, global_topology, *facet_key, *handle)? + else { continue; - } + }; + count_boundary_facet_ridges( + tds, + handle, + &mut facet_vertices, + &mut ridge_vertices, + &mut ridge_to_boundary_facet_count, + )?; + } - // Derive the facet's vertex keys from the owning simplex. - let simplex_vertices = tds.simplex_vertices(simplex_key)?; - if facet_index >= simplex_vertices.len() { - return Err(TdsError::IndexOutOfBounds { - index: facet_index, - bound: simplex_vertices.len(), - context: format!("boundary facet index for simplex {simplex_key:?}"), - } - .into()); + validate_boundary_ridge_counts(ridge_to_boundary_facet_count) +} + +fn count_boundary_facet_ridges( + tds: &Tds, + handle: FacetHandle, + facet_vertices: &mut VertexKeyBuffer, + ridge_vertices: &mut VertexKeyBuffer, + ridge_to_boundary_facet_count: &mut FastHashMap, +) -> Result<(), ManifoldError> { + let simplex_key = handle.simplex_key(); + let facet_index = handle.facet_index() as usize; + + // Derive the facet's vertex keys from the owning simplex. + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + if facet_index >= simplex_vertices.len() { + return Err(TdsError::IndexOutOfBounds { + index: facet_index, + bound: simplex_vertices.len(), + context: format!("boundary facet index for simplex {simplex_key:?}"), } + .into()); + } - facet_vertices.clear(); - for (i, &vk) in simplex_vertices.iter().enumerate() { - if i == facet_index { - continue; - } - facet_vertices.push(vk); + facet_vertices.clear(); + for (i, &vk) in simplex_vertices.iter().enumerate() { + if i == facet_index { + continue; } + facet_vertices.push(vk); + } - if facet_vertices.len() != D { - return Err(TdsError::DimensionMismatch { - expected: D, - actual: facet_vertices.len(), - context: format!( - "boundary facet vertex count (simplex_key={simplex_key:?}, facet_index={facet_index})" - ), - } - .into()); + if facet_vertices.len() != D { + return Err(TdsError::DimensionMismatch { + expected: D, + actual: facet_vertices.len(), + context: format!( + "boundary facet vertex count (simplex_key={simplex_key:?}, facet_index={facet_index})" + ), } + .into()); + } - // Enumerate the (D-2)-faces (ridges) of this boundary facet by excluding each - // facet vertex in turn. - for omit in 0..facet_vertices.len() { - ridge_vertices.clear(); - for (j, &vk) in facet_vertices.iter().enumerate() { - if j == omit { - continue; - } - ridge_vertices.push(vk); + // Enumerate the (D-2)-faces (ridges) of this boundary facet by excluding each + // facet vertex in turn. + for omit in 0..facet_vertices.len() { + ridge_vertices.clear(); + for (j, &vk) in facet_vertices.iter().enumerate() { + if j == omit { + continue; } - - let ridge_key = facet_key_from_vertices(&ridge_vertices); - *ridge_to_boundary_facet_count.entry(ridge_key).or_insert(0) += 1; + ridge_vertices.push(vk); } + + let ridge_key = facet_key_from_vertices(ridge_vertices.as_slice()); + *ridge_to_boundary_facet_count.entry(ridge_key).or_insert(0) += 1; } + Ok(()) +} + +fn validate_boundary_ridge_counts( + ridge_to_boundary_facet_count: FastHashMap, +) -> Result<(), ManifoldError> { for (ridge_key, boundary_facet_count) in ridge_to_boundary_facet_count { if boundary_facet_count != 2 { return Err(ManifoldError::BoundaryRidgeMultiplicity { @@ -718,13 +755,14 @@ pub fn validate_closed_boundary( /// Validates pseudomanifold conditions for facets and boundary ridges touched /// by `simplices`. /// -/// This is the local counterpart to [`validate_facet_degree`] plus +/// This is the local counterpart to facet-index construction plus /// [`validate_closed_boundary`]. It expands each touched facet to its full /// incident-simplex star, then checks only boundary ridges incident to those /// touched facets. This keeps post-insertion checks local while preserving the /// same codimension-1 and codimension-2 invariants for the mutated region. pub(crate) fn validate_local_pseudomanifold_for_simplices( tds: &Tds, + global_topology: GlobalTopology, simplices: &[SimplexKey], ) -> Result<(), ManifoldError> { if D == 0 || simplices.is_empty() { @@ -732,8 +770,8 @@ pub(crate) fn validate_local_pseudomanifold_for_simplices( } let facet_to_simplices = build_local_facet_star_map(tds, simplices)?; - validate_facet_degree(&facet_to_simplices)?; - validate_closed_boundary_for_local_facets(tds, &facet_to_simplices) + let facet_to_simplices = ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices)?; + validate_closed_boundary_for_validated_local_facets(tds, global_topology, facet_to_simplices) } /// Builds full facet-incidence entries for facets owned by the supplied simplices. @@ -781,11 +819,25 @@ fn simplex_facet_vertex_ids( let offsets = tds .simplex(simplex_key) .and_then(|simplex| simplex.periodic_vertex_offsets()); - let mut lifted_vertices = - LiftedVertexBuffer::with_capacity(simplex_vertices.len().saturating_sub(1)); - let mut bare_vertices = - VertexKeyBuffer::with_capacity(simplex_vertices.len().saturating_sub(1)); - + if let Some(simplex_offsets) = offsets + && simplex_offsets.len() != simplex_vertices.len() + { + return Err(TdsError::DimensionMismatch { + expected: simplex_vertices.len(), + actual: simplex_offsets.len(), + context: format!( + "periodic offset count for {D}D simplex {simplex_key:?} \ + (local lifted facet vertex extraction)" + ), + } + .into()); + } + + let mut lifted_vertices = + LiftedVertexBuffer::with_capacity(simplex_vertices.len().saturating_sub(1)); + let mut bare_vertices = + VertexKeyBuffer::with_capacity(simplex_vertices.len().saturating_sub(1)); + for (idx, &vertex_key) in simplex_vertices.iter().enumerate() { if idx == facet_index { continue; @@ -793,7 +845,12 @@ fn simplex_facet_vertex_ids( bare_vertices.push(vertex_key); let lifted = offsets.map_or_else( || LiftedVertexId::base(vertex_key), - |simplex_offsets| lifted_vertex_id(vertex_key, &simplex_offsets[idx]), + |simplex_offsets| { + lifted_vertex_id( + vertex_key, + simplex_offsets[idx].iter().copied().map(i16::from), + ) + }, ); lifted_vertices.push(lifted); } @@ -835,22 +892,25 @@ fn facet_incident_handles( } /// Validates boundary closure for boundary facets present in a local facet map. -fn validate_closed_boundary_for_local_facets( +fn validate_closed_boundary_for_validated_local_facets( tds: &Tds, - facet_to_simplices: &FacetToSimplicesMap, + global_topology: GlobalTopology, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, ) -> Result<(), ManifoldError> { if D < 2 { return Ok(()); } let mut checked_ridges: FastHashSet = FastHashSet::default(); - for simplex_facet_pairs in facet_to_simplices.values() { + for (facet_key, simplex_facet_pairs) in facet_to_simplices.as_map() { let [handle] = simplex_facet_pairs.as_slice() else { continue; }; - if !is_boundary_facet_handle(tds, *handle)? { + let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet_handle(tds, global_topology, *facet_key, *handle)? + else { continue; - } + }; let (facet_vertices, facet_vertices_bare) = simplex_facet_vertex_ids(tds, handle.simplex_key(), handle.facet_index() as usize)?; @@ -861,8 +921,12 @@ fn validate_closed_boundary_for_local_facets( if !checked_ridges.insert(ridge_key) { continue; } - let boundary_facet_count = - boundary_facet_count_for_ridge(tds, &ridge_vertices, &ridge_vertices_bare)?; + let boundary_facet_count = boundary_facet_count_for_ridge( + tds, + global_topology, + &ridge_vertices, + &ridge_vertices_bare, + )?; if boundary_facet_count != 2 { return Err(ManifoldError::BoundaryRidgeMultiplicity { ridge_key, @@ -916,6 +980,7 @@ fn ridge_vertices_for_facet( /// Counts boundary facets in the full star of a ridge. fn boundary_facet_count_for_ridge( tds: &Tds, + global_topology: GlobalTopology, ridge_vertices: &[LiftedVertexId], ridge_vertices_bare: &[VertexKey], ) -> Result { @@ -942,7 +1007,15 @@ fn boundary_facet_count_for_ridge( let handles = facet_incident_handles(tds, facet_key, &facet_vertices_bare)?; match handles.len() { 1 => { - if is_boundary_facet_handle(tds, handles[0])? { + if matches!( + classify_boundary_facet_handle( + tds, + global_topology, + facet_key, + handles[0] + )?, + BoundaryFacetClassification::Boundary(_) + ) { count = count.saturating_add(1); } } @@ -960,82 +1033,6 @@ fn boundary_facet_count_for_ridge( Ok(count) } -/// Returns true when a one-sided facet occurrence is an actual boundary facet. -/// -/// Periodic quotient TDSs may encode a closed facet identification as a single -/// facet occurrence whose neighbor slot points back to the owning simplex. That -/// is valid closed-topology metadata, not a boundary facet. -fn is_boundary_facet_handle( - tds: &Tds, - handle: FacetHandle, -) -> Result { - let simplex_key = handle.simplex_key(); - let facet_index = handle.facet_index() as usize; - let simplex = tds - .simplex(simplex_key) - .ok_or_else(|| TdsError::SimplexNotFound { - simplex_key, - context: "boundary facet classification".to_string(), - })?; - - let is_periodic_self_identified = simplex - .neighbor_key(facet_index) - .is_some_and(|neighbor| neighbor == Some(simplex_key)) - && simplex.periodic_vertex_offsets().is_some_and(|offsets| { - !offsets.is_empty() && offsets.len() == simplex.number_of_vertices() - }); - - Ok(!is_periodic_self_identified) -} - -/// Computes the star of a simplex (a set of vertices) as the set of incident D-simplices. -/// -/// This is a local combinatorial query intended for reuse by topology validation and -/// (future) local topology mutations (e.g. bistellar flips). -/// -/// This helper does **not** call `tds.is_valid()`; it performs lightweight checks and -/// returns [`ManifoldError::Tds`] if the underlying TDS is internally inconsistent. -fn simplex_star_simplices( - tds: &Tds, - simplex_vertices: &[VertexKey], -) -> Result, ManifoldError> { - if simplex_vertices.is_empty() { - return Err(TdsError::InconsistentDataStructure { - message: "simplex_star_simplices requires at least one vertex".to_string(), - } - .into()); - } - - // Defensive: ensure all simplex vertices exist in the vertex store. - // - // Note: This is cheaper than `tds.is_valid()` and provides a clearer error when - // callers use this helper on stale keys. - for &vk in simplex_vertices { - if !tds.contains_vertex_key(vk) { - return Err(TdsError::VertexNotFound { - vertex_key: vk, - context: "simplex star computation".to_string(), - } - .into()); - } - } - - let candidates = tds.simplex_keys_containing_vertex(simplex_vertices[0]); - let mut star_simplices: SmallBuffer = SmallBuffer::new(); - - for simplex_key in candidates { - let candidate_vertices = tds.simplex_vertices(simplex_key)?; - if simplex_vertices - .iter() - .all(|&sv| candidate_vertices.contains(&sv)) - { - star_simplices.push(simplex_key); - } - } - - Ok(star_simplices) -} - /// Computes the link simplices induced by a simplex star. /// /// For each incident D-simplex, this returns the complementary vertex set (the vertices in the @@ -1047,8 +1044,10 @@ fn simplex_link_simplices_from_star( star_simplices: &[SimplexKey], ) -> Result { if simplex_vertices.is_empty() { - return Err(TdsError::InconsistentDataStructure { - message: "simplex_link_simplices_from_star requires at least one vertex".to_string(), + return Err(TdsError::DimensionMismatch { + expected: 1, + actual: 0, + context: "simplex_link_simplices_from_star requires at least one vertex".to_string(), } .into()); } @@ -1064,6 +1063,19 @@ fn simplex_link_simplices_from_star( let offsets = tds .simplex(simplex_key) .and_then(|c| c.periodic_vertex_offsets()); + if let Some(simplex_offsets) = offsets + && simplex_offsets.len() != candidate_vertices.len() + { + return Err(TdsError::DimensionMismatch { + expected: candidate_vertices.len(), + actual: simplex_offsets.len(), + context: format!( + "periodic offset count for {D}D simplex {simplex_key:?} \ + (simplex link extraction)" + ), + } + .into()); + } // Find the reference offset: the first simplex vertex's offset in // this simplex. All link vertex offsets are computed relative to this @@ -1090,7 +1102,7 @@ fn simplex_link_simplices_from_star( .zip(offs[r].iter()) .map(|(&a, &b)| i16::from(a) - i16::from(b)) .collect(); - lifted_vertex_id(vk, &rel) + lifted_vertex_id(vk, rel) } _ => LiftedVertexId::base(vk), }; @@ -1120,430 +1132,6 @@ fn simplex_link_simplices_from_star( /// Computes the star of a ridge (a (D-2)-simplex) as the set of incident D-simplices. /// -/// This is a local combinatorial query intended for reuse by topology validation and -/// (future) local topology mutations (e.g. bistellar flips). -/// -/// This helper does **not** call `tds.is_valid()`; it performs lightweight checks and -/// returns [`ManifoldError::Tds`] if the underlying TDS is internally inconsistent. -/// -/// # Errors -/// -/// Returns [`ManifoldError::Tds`] when any ridge vertex is missing from the -/// [`Tds`] or a candidate star simplex cannot resolve its vertex keys. -/// -/// # Examples -/// -/// ```rust -/// use delaunay::prelude::construction::{ -/// DelaunayTriangulation, DelaunayTriangulationConstructionError, -/// }; -/// use delaunay::prelude::topology::validation::{ -/// ManifoldError, RidgeVertices, RidgeVerticesError, ridge_star_simplices, -/// }; -/// -/// # #[derive(Debug, thiserror::Error)] -/// # enum ExampleError { -/// # #[error(transparent)] Construction(#[from] DelaunayTriangulationConstructionError), -/// # #[error(transparent)] Ridge(#[from] RidgeVerticesError), -/// # #[error(transparent)] Manifold(#[from] ManifoldError), -/// # #[error("constructed triangulation has no vertex keys")] Empty, -/// # #[error(transparent)] -/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), -/// # } -/// # fn main() -> Result<(), ExampleError> { -/// let vertices = vec![ -/// delaunay::vertex![0.0, 0.0]?, -/// delaunay::vertex![1.0, 0.0]?, -/// delaunay::vertex![0.0, 1.0]?, -/// ]; -/// let triangulation = DelaunayTriangulation::try_new(&vertices)?; -/// let Some(v0) = triangulation.tds().vertex_keys().next() else { -/// return Err(ExampleError::Empty); -/// }; -/// -/// let ridge = RidgeVertices::<2>::try_from_vertices([v0])?; -/// let star = ridge_star_simplices(triangulation.tds(), &ridge)?; -/// assert_eq!(star.len(), 1); -/// # Ok(()) -/// # } -/// ``` -pub fn ridge_star_simplices( - tds: &Tds, - ridge_vertices: &RidgeVertices, -) -> Result, ManifoldError> { - simplex_star_simplices(tds, ridge_vertices.as_slice()) -} - -fn ridge_link_edges_from_star( - tds: &Tds, - ridge_vertices: &[LiftedVertexId], - star_simplices: &[SimplexKey], -) -> Result, ManifoldError> { - // Ridge links are only meaningful for D>=2. - if D < 2 { - return Ok(SmallBuffer::new()); - } - - let expected_ridge_vertices = D.saturating_sub(1); - if ridge_vertices.len() != expected_ridge_vertices { - return Err(TdsError::DimensionMismatch { - expected: expected_ridge_vertices, - actual: ridge_vertices.len(), - context: format!("ridge vertex count for {D}D (link edges)"), - } - .into()); - } - - let mut link_edges: SmallBuffer<(LiftedVertexId, LiftedVertexId), 8> = - SmallBuffer::with_capacity(star_simplices.len()); - - let mut link_vertices: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(2); - - for &simplex_key in star_simplices { - let Some(simplex_vertices) = - normalized_simplex_vertices_for_lifted_target(tds, simplex_key, ridge_vertices)? - else { - return Err(TdsError::InconsistentDataStructure { - message: format!( - "ridge star simplex {simplex_key:?} does not contain normalized ridge vertices \ - {ridge_vertices:?}" - ), - } - .into()); - }; - - link_vertices.clear(); - for lifted in simplex_vertices { - if !ridge_vertices.contains(&lifted) { - link_vertices.push(lifted); - } - } - - if link_vertices.len() != 2 { - return Err(TdsError::DimensionMismatch { - expected: 2, - actual: link_vertices.len(), - context: format!("ridge link vertex count for {D}D (simplex_key={simplex_key:?})"), - } - .into()); - } - - if link_vertices[0] == link_vertices[1] { - return Err(TdsError::InconsistentDataStructure { - message: format!( - "Ridge link edge is a self-loop: link vertex {vk:?} repeated (simplex_key={simplex_key:?})", - vk = &link_vertices[0], - ), - } - .into()); - } - - link_edges.push((link_vertices[0].clone(), link_vertices[1].clone())); - } - - Ok(link_edges) -} - -#[derive(Clone, Debug)] -struct RidgeStar { - ridge_vertices: LiftedVertexBuffer, - star_simplices: SmallBuffer, -} - -// Performance: This builds a ridge → star incidence map by visiting every simplex and -// enumerating its ridges. -// -// In terms of D, each simplex contributes C(D+1, 2) = O(D²) ridges, each with O(D) vertices. -// Therefore this pass is O(#simplices × C(D+1,2) × D) time (i.e., O(#simplices × D³) in D) and -// O(#simplices × C(D+1,2)) additional memory for the incidence map. -// -// This is appropriate for Level 3 topology validation / debugging, but it can be expensive -// for extremely large triangulations (e.g., millions of simplices) or higher-dimensional complexes. -fn build_ridge_star_map( - tds: &Tds, -) -> Result, ManifoldError> { - let simplex_count = tds.number_of_simplices(); - if simplex_count == 0 { - return Ok(FastHashMap::default()); - } - - // Each D-simplex has C(D+1, 2) ridges (omit two vertices). - let ridges_per_simplex = (D + 1).saturating_mul(D) / 2; - - // A crude-but-safe estimate: in a manifold, ridges are typically incident to ~2 simplices, so the - // number of unique ridges is often around half the total ridge incidences. - let estimated_unique_ridges = simplex_count - .saturating_mul(ridges_per_simplex) - .saturating_div(2) - .max(1); - - // Map ridge key -> ridge star (incident simplices). - let mut ridge_to_star: FastHashMap = - fast_hash_map_with_capacity(estimated_unique_ridges); - - let mut ridge_vertices: LiftedVertexBuffer = - LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); - - for (simplex_key, simplex) in tds.simplices() { - let simplex_vertices = tds.simplex_vertices(simplex_key)?; - let offsets = simplex.periodic_vertex_offsets(); - - if simplex_vertices.len() != D + 1 { - return Err(TdsError::DimensionMismatch { - expected: D + 1, - actual: simplex_vertices.len(), - context: format!("simplex {simplex_key:?} vertex count for {D}D"), - } - .into()); - } - - // Enumerate ridges in this simplex by omitting two vertices. - for omit_a in 0..simplex_vertices.len() { - for omit_b in (omit_a + 1)..simplex_vertices.len() { - ridge_vertices.clear(); - for (i, &vk) in simplex_vertices.iter().enumerate() { - if i == omit_a || i == omit_b { - continue; - } - // Use lifted vertex ID when periodic offsets are present. - let lifted = offsets.map_or_else( - || LiftedVertexId::base(vk), - |offs| lifted_vertex_id(vk, &offs[i]), - ); - ridge_vertices.push(lifted); - } - - if ridge_vertices.len() != D.saturating_sub(1) { - return Err(TdsError::DimensionMismatch { - expected: D.saturating_sub(1), - actual: ridge_vertices.len(), - context: format!("ridge vertex count for {D}D (simplex_key={simplex_key:?}, omit_a={omit_a}, omit_b={omit_b})"), - } - .into()); - } - - let normalized_ridge_vertices = normalize_lifted_vertices(&ridge_vertices); - let ridge_key = periodic_simplex_key(&normalized_ridge_vertices); - let star = ridge_to_star.entry(ridge_key).or_insert_with(|| RidgeStar { - ridge_vertices: normalized_ridge_vertices.clone(), - star_simplices: SmallBuffer::new(), - }); - star.star_simplices.push(simplex_key); - } - } - } - - Ok(ridge_to_star) -} - -fn build_ridge_star_map_for_simplices( - tds: &Tds, - simplices: impl IntoIterator, -) -> Result, ManifoldError> { - if D < 2 { - return Ok(FastHashMap::default()); - } - - let simplices = simplices.into_iter(); - let (lower_bound, upper_bound) = simplices.size_hint(); - let estimated_simplex_count = upper_bound.unwrap_or(lower_bound); - - // Each D-simplex has C(D+1, 2) ridges (omit two vertices). - let ridges_per_simplex = (D + 1).saturating_mul(D) / 2; - let estimated_unique_ridges = estimated_simplex_count - .saturating_mul(ridges_per_simplex) - .max(1); - - // Build a set of ridges touched by the specified simplices. - // For periodic simplices we store both lifted vertices (for ridge identity and - // downstream link computation) and bare vertices (for `simplex_star_simplices` - // which looks up real TDS vertex keys). - let mut ridge_to_vertices: FastHashMap = - fast_hash_map_with_capacity(estimated_unique_ridges); - - let mut ridge_vertices_bare: VertexKeyBuffer = - VertexKeyBuffer::with_capacity(D.saturating_sub(1)); - let mut ridge_vertices_lifted: LiftedVertexBuffer = - LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); - - for simplex_key in simplices { - if !tds.contains_simplex(simplex_key) { - continue; - } - - let simplex_vertices = tds.simplex_vertices(simplex_key)?; - let offsets = tds - .simplex(simplex_key) - .and_then(|c| c.periodic_vertex_offsets()); - - if simplex_vertices.len() != D + 1 { - return Err(TdsError::DimensionMismatch { - expected: D + 1, - actual: simplex_vertices.len(), - context: format!("simplex {simplex_key:?} vertex count for {D}D (local ridge map)"), - } - .into()); - } - - // Enumerate ridges in this simplex by omitting two vertices. - for omit_a in 0..simplex_vertices.len() { - for omit_b in (omit_a + 1)..simplex_vertices.len() { - ridge_vertices_bare.clear(); - ridge_vertices_lifted.clear(); - for (i, &vk) in simplex_vertices.iter().enumerate() { - if i == omit_a || i == omit_b { - continue; - } - ridge_vertices_bare.push(vk); - // Use lifted vertex ID when periodic offsets are present. - let lifted = offsets.map_or_else( - || LiftedVertexId::base(vk), - |offs| lifted_vertex_id(vk, &offs[i]), - ); - ridge_vertices_lifted.push(lifted); - } - - if ridge_vertices_bare.len() != D.saturating_sub(1) { - return Err(TdsError::DimensionMismatch { - expected: D.saturating_sub(1), - actual: ridge_vertices_bare.len(), - context: format!("ridge vertex count for {D}D (simplex_key={simplex_key:?}, omit_a={omit_a}, omit_b={omit_b})"), - } - .into()); - } - - let normalized_ridge_vertices = normalize_lifted_vertices(&ridge_vertices_lifted); - let ridge_key = periodic_simplex_key(&normalized_ridge_vertices); - ridge_to_vertices - .entry(ridge_key) - .or_insert_with(|| (normalized_ridge_vertices, ridge_vertices_bare.clone())); - } - } - } - - // For each ridge touched by the local simplex set, compute its full star. - // Use bare ridge vertices for `simplex_star_simplices` (which looks up real TDS - // keys), then filter to simplices sharing the same periodic image, and store - // lifted ridge vertices in the `RidgeStar` for downstream link computation. - let mut ridge_to_star: FastHashMap = - fast_hash_map_with_capacity(ridge_to_vertices.len().max(1)); - - for (ridge_key, (lifted_vertices, bare_vertices)) in ridge_to_vertices { - let star_simplices = - periodic_aware_ridge_star(tds, ridge_key, &lifted_vertices, &bare_vertices)?; - - ridge_to_star.insert( - ridge_key, - RidgeStar { - ridge_vertices: lifted_vertices, - star_simplices, - }, - ); - } - - Ok(ridge_to_star) -} - -fn normalized_simplex_vertices_for_lifted_target( - tds: &Tds, - simplex_key: SimplexKey, - target_vertices: &[LiftedVertexId], -) -> Result, ManifoldError> { - let simplex_vertices = tds.simplex_vertices(simplex_key)?; - let offsets = tds - .simplex(simplex_key) - .and_then(|simplex| simplex.periodic_vertex_offsets()); - - let Some(offsets) = offsets else { - let vertices: LiftedVertexBuffer = simplex_vertices - .iter() - .copied() - .map(LiftedVertexId::base) - .collect(); - return Ok(Some(vertices)); - }; - - let Some(anchor) = target_vertices.first() else { - return Ok(Some(LiftedVertexBuffer::new())); - }; - let Some(anchor_index) = simplex_vertices - .iter() - .position(|&vertex_key| vertex_key == anchor.vertex_key) - else { - return Ok(None); - }; - let anchor_offset = offsets[anchor_index]; - let mut normalized = LiftedVertexBuffer::with_capacity(simplex_vertices.len()); - for (idx, &vertex_key) in simplex_vertices.iter().enumerate() { - let mut relative_offset: SmallBuffer = SmallBuffer::with_capacity(D); - for axis in 0..D { - relative_offset.push(i16::from(offsets[idx][axis]) - i16::from(anchor_offset[axis])); - } - normalized.push(lifted_vertex_id(vertex_key, &relative_offset)); - } - Ok(Some(normalized)) -} - -/// Computes the periodic-aware star of a ridge from its lifted and bare vertex -/// representations. -/// -/// Uses bare keys to find candidate simplices via [`simplex_star_simplices`], then -/// filters to simplices whose lifted ridge vertices match `lifted_vertices`. -/// For non-periodic simplices (no offsets) all candidates pass. -/// -/// # Errors -/// -/// Returns [`ManifoldError::Tds`] if: -/// - `simplex_star_simplices` fails (vertex not found, etc.). -/// - `simplex_vertices` fails for any candidate simplex. -/// - Periodic offset filtering produces an empty star, indicating inconsistent -/// offsets in the TDS. -fn periodic_aware_ridge_star( - tds: &Tds, - ridge_key: u64, - lifted_vertices: &[LiftedVertexId], - bare_vertices: &VertexKeyBuffer, -) -> Result, ManifoldError> { - let all_star_simplices = simplex_star_simplices(tds, bare_vertices)?; - - // For periodic simplices, keep only simplices whose lifted ridge vertices agree - // with this ridge's lifted vertices. For non-periodic simplices the check is - // a no-op (offsets are `None`). We use an explicit loop instead of - // `.filter()` so that `simplex_vertices` errors propagate. - let mut star_simplices: SmallBuffer = - SmallBuffer::with_capacity(all_star_simplices.len()); - for &ck in &all_star_simplices { - let Some(normalized_vertices) = - normalized_simplex_vertices_for_lifted_target(tds, ck, lifted_vertices)? - else { - continue; - }; - if lifted_vertices - .iter() - .all(|lv| normalized_vertices.contains(lv)) - { - star_simplices.push(ck); - } - } - - // A ridge enumerated from a simplex must be incident to at least that simplex. - // An empty star after periodic filtering indicates inconsistent offsets. - if star_simplices.is_empty() { - return Err(TdsError::InconsistentDataStructure { - message: format!( - "periodic offset filtering produced empty star for ridge \ - {ridge_key:016x}: {count} candidate simplices were all excluded \ - (lifted ridge vertices: {lifted:?})", - count = all_star_simplices.len(), - lifted = lifted_vertices, - ), - } - .into()); - } - - Ok(star_simplices) -} - /// Validates the ridge-link condition for a PL-manifold (with boundary). /// /// For a D-dimensional simplicial complex, the link of any (D-2)-simplex is a @@ -1560,9 +1148,9 @@ fn periodic_aware_ridge_star( /// /// # Performance /// -/// This is intentionally more expensive than basic codimension-1 manifold validation -/// (e.g., [`validate_facet_degree`]) because it must inspect ridge stars/links across the -/// entire complex. +/// This is intentionally more expensive than basic codimension-1 manifold +/// validation during facet-index construction because it must inspect ridge +/// stars/links across the entire complex. /// /// Roughly speaking, this requires a full pass over all simplices to build a ridge → star /// incidence map, which is O(#simplices × C(D+1,2) × D) time (linear in #simplices for fixed small D) @@ -1622,7 +1210,7 @@ pub fn validate_ridge_links(tds: &Tds) -> Result< ridge_link_edges_from_star(tds, &star.ridge_vertices, &star.star_simplices)?; if let Err(err) = validate_ridge_link_graph(ridge_key, &link_edges) { #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_RIDGE_LINK").is_some() { + if env::var_os("DELAUNAY_DEBUG_RIDGE_LINK").is_some() { let mut star_simplex_vertices: Vec<(SimplexKey, VertexKeyBuffer)> = Vec::with_capacity(star.star_simplices.len()); for &simplex_key in &star.star_simplices { @@ -1709,7 +1297,7 @@ pub fn validate_ridge_links_for_simplices( ridge_link_edges_from_star(tds, &star.ridge_vertices, &star.star_simplices)?; if let Err(err) = validate_ridge_link_graph(ridge_key, &link_edges) { #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_RIDGE_LINK").is_some() { + if env::var_os("DELAUNAY_DEBUG_RIDGE_LINK").is_some() { let mut star_simplex_vertices: Vec<(SimplexKey, VertexKeyBuffer)> = Vec::with_capacity(star.star_simplices.len()); for &simplex_key in &star.star_simplices { @@ -1743,8 +1331,9 @@ pub fn validate_ridge_links_for_simplices( /// for every vertex `v`, the link `Lk(v)` is a (D-1)-sphere when `v` is interior, or a /// (D-1)-ball when `v` lies on the boundary. /// -/// This validator treats a vertex as a *boundary vertex* if it participates in any -/// boundary facet of the original complex (a facet incident to exactly one D-simplex). +/// This validator treats a vertex as a *boundary vertex* if it participates in +/// any actual boundary facet of the original complex. One-sided periodic +/// self-identifications are closed topology and do not make a vertex boundary. /// /// # Performance /// @@ -1763,8 +1352,9 @@ pub fn validate_ridge_links_for_simplices( /// ```rust /// use delaunay::prelude::geometry::*; /// use delaunay::prelude::*; +/// use delaunay::prelude::topology::spaces::GlobalTopology; /// use delaunay::prelude::topology::validation::{ -/// validate_closed_boundary, validate_facet_degree, validate_vertex_links, +/// validate_closed_boundary, validate_vertex_links, /// }; /// /// # #[derive(Debug, thiserror::Error)] @@ -1783,18 +1373,25 @@ pub fn validate_ridge_links_for_simplices( /// delaunay::vertex![0.0, 0.0, 1.0]?, /// ]; /// let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices)?; -/// let facet_to_simplices = tds.build_facet_to_simplices_map()?; +/// let facet_to_simplices = tds.build_facet_to_simplices_index()?; /// -/// validate_facet_degree(&facet_to_simplices)?; -/// validate_closed_boundary(&tds, &facet_to_simplices)?; -/// validate_vertex_links(&tds, &facet_to_simplices)?; +/// validate_closed_boundary(&facet_to_simplices, GlobalTopology::Euclidean)?; +/// validate_vertex_links(&facet_to_simplices, GlobalTopology::Euclidean)?; /// # Ok(()) /// # } /// ``` pub fn validate_vertex_links( - tds: &Tds, - facet_to_simplices: &FacetToSimplicesMap, + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, ) -> Result<(), ManifoldError> { + validate_vertex_links_index(facet_to_simplices, global_topology) +} + +fn validate_vertex_links_index( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result<(), ManifoldError> { + let tds = facet_to_simplices.tds(); // Vertex links are only meaningful for D>=1. if D < 1 { return Ok(()); @@ -1804,66 +1401,131 @@ pub fn validate_vertex_links( return Ok(()); } - let boundary_vertices = build_boundary_vertex_set(tds, facet_to_simplices)?; + let boundary_vertices = + build_boundary_vertex_labels_from_index(facet_to_simplices, global_topology)?; for (vertex_key, _vertex) in tds.vertices() { - let interior_vertex = !boundary_vertices.contains(&vertex_key); + let interior_vertex = !boundary_vertices.contains_key(vertex_key); validate_single_vertex_link(tds, vertex_key, interior_vertex)?; } Ok(()) } -fn build_boundary_vertex_set( +/// Validates vertex links from a validated facet-degree map. +pub(crate) fn validate_vertex_links_from_validated_facet_map( tds: &Tds, - facet_to_simplices: &FacetToSimplicesMap, -) -> Result, ManifoldError> { - // Single pass: collect all vertices that appear on a boundary facet (a facet incident to exactly 1 D-simplex). - // - // NOTE: We intentionally avoid a pre-count pass over `facet_to_simplices` since Level-3 validation is already - // expensive and we only need a coarse set (it can grow dynamically). - let mut boundary_vertices: FastHashSet = FastHashSet::default(); - - for simplex_facet_pairs in facet_to_simplices.values() { + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + global_topology: GlobalTopology, +) -> Result<(), ManifoldError> { + // Vertex links are only meaningful for D>=1. + if D < 1 { + return Ok(()); + } + + if tds.number_of_simplices() == 0 { + return Ok(()); + } + + let boundary_vertices = build_boundary_vertex_labels_from_validated_facet_map( + tds, + facet_to_simplices, + global_topology, + )?; + + for (vertex_key, _vertex) in tds.vertices() { + let interior_vertex = !boundary_vertices.contains_key(vertex_key); + validate_single_vertex_link(tds, vertex_key, interior_vertex)?; + } + + Ok(()) +} + +fn build_boundary_vertex_labels_from_validated_facet_map( + tds: &Tds, + facet_to_simplices: ValidatedFacetDegreeMap<'_>, + global_topology: GlobalTopology, +) -> Result, ManifoldError> { + // Single pass: collect all vertices that appear on a boundary facet (a facet incident to exactly 1 D-simplex). + // + // NOTE: We intentionally avoid a pre-count pass over `facet_to_simplices` since Level-3 validation is already + // expensive and we only need a coarse set (it can grow dynamically). + let mut boundary_vertices: VertexSecondaryMap<()> = VertexSecondaryMap::new(); + + for (facet_key, simplex_facet_pairs) in facet_to_simplices.as_map() { let [handle] = simplex_facet_pairs.as_slice() else { continue; }; + let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet_handle(tds, global_topology, *facet_key, *handle)? + else { + continue; + }; - let simplex_key = handle.simplex_key(); - let facet_index = handle.facet_index() as usize; + insert_boundary_facet_vertices(tds, handle, &mut boundary_vertices)?; + } - let simplex_vertices = tds.simplex_vertices(simplex_key)?; - if facet_index >= simplex_vertices.len() { - return Err(TdsError::IndexOutOfBounds { - index: facet_index, - bound: simplex_vertices.len(), - context: format!("boundary facet index for simplex {simplex_key:?}"), - } - .into()); + Ok(boundary_vertices) +} + +fn build_boundary_vertex_labels_from_index( + facet_to_simplices: &FacetToSimplicesIndex<'_, U, V, D>, + global_topology: GlobalTopology, +) -> Result, ManifoldError> { + let tds = facet_to_simplices.tds(); + let mut boundary_vertices: VertexSecondaryMap<()> = VertexSecondaryMap::new(); + + for incidence in facet_to_simplices.iter() { + let BoundaryFacetClassification::Boundary(handle) = + classify_boundary_facet(incidence, global_topology)? + else { + continue; + }; + insert_boundary_facet_vertices(tds, handle, &mut boundary_vertices)?; + } + + Ok(boundary_vertices) +} + +fn insert_boundary_facet_vertices( + tds: &Tds, + handle: FacetHandle, + boundary_vertices: &mut VertexSecondaryMap<()>, +) -> Result<(), ManifoldError> { + let simplex_key = handle.simplex_key(); + let facet_index = handle.facet_index() as usize; + + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + if facet_index >= simplex_vertices.len() { + return Err(TdsError::IndexOutOfBounds { + index: facet_index, + bound: simplex_vertices.len(), + context: format!("boundary facet index for simplex {simplex_key:?}"), } + .into()); + } - let mut facet_vertex_count = 0usize; - for (i, &vk) in simplex_vertices.iter().enumerate() { - if i == facet_index { - continue; - } - boundary_vertices.insert(vk); - facet_vertex_count += 1; + let mut facet_vertex_count = 0usize; + for (i, &vk) in simplex_vertices.iter().enumerate() { + if i == facet_index { + continue; } + boundary_vertices.insert(vk, ()); + facet_vertex_count += 1; + } - if facet_vertex_count != D { - return Err(TdsError::DimensionMismatch { - expected: D, - actual: facet_vertex_count, - context: format!( - "boundary facet vertex count (simplex_key={simplex_key:?}, facet_index={facet_index})" - ), - } - .into()); + if facet_vertex_count != D { + return Err(TdsError::DimensionMismatch { + expected: D, + actual: facet_vertex_count, + context: format!( + "boundary facet vertex count (simplex_key={simplex_key:?}, facet_index={facet_index})" + ), } + .into()); } - Ok(boundary_vertices) + Ok(()) } fn validate_vertex_link_d1( @@ -2349,20 +2011,18 @@ fn triangulated_surface_boundary_component_count_for_link( } } - let boundary_edges: SmallBuffer<(LiftedVertexId, LiftedVertexId), 8> = edge_counts - .into_iter() - .filter_map(|(edge, count)| (count == 1).then_some(edge)) - .collect(); - if boundary_edges.is_empty() { - return 0; - } - let mut adjacency: FastHashMap = - fast_hash_map_with_capacity(boundary_edges.len().saturating_mul(2)); - for (a, b) in boundary_edges { + fast_hash_map_with_capacity(edge_counts.len().saturating_mul(2)); + for ((a, b), count) in edge_counts { + if count != 1 { + continue; + } adjacency.entry(a.clone()).or_default().push(b.clone()); adjacency.entry(b).or_default().push(a); } + if adjacency.is_empty() { + return 0; + } let mut visited: FastHashSet = fast_hash_set_with_capacity(adjacency.len()); let mut components = 0usize; @@ -2484,12 +2144,14 @@ fn validate_ridge_link_graph( #[cfg(test)] mod tests { use super::*; - use std::assert_matches; + use std::{assert_matches, iter}; - use crate::core::facet::FacetHandle; + use crate::core::facet::{FacetError, FacetHandle, FacetView}; use crate::core::simplex::Simplex; use crate::core::triangulation::Triangulation; + use crate::core::vertex::Vertex; use crate::geometry::kernel::FastKernel; + use crate::topology::traits::topological_space::ToroidalConstructionMode; use slotmap::KeyData; @@ -2503,29 +2165,65 @@ mod tests { s } + fn validated_facet_map( + facet_to_simplices: &FacetToSimplicesMap, + ) -> ValidatedFacetDegreeMap<'_> { + ValidatedFacetDegreeMap::try_from_facet_map(facet_to_simplices).unwrap() + } + + fn build_single_triangle_tds(periodic_self_neighbor: bool) -> (Tds<(), (), 2>, SimplexKey) { + 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 mut simplex = Simplex::try_new(vec![v0, v1, v2]).unwrap(); + if periodic_self_neighbor { + simplex + .set_periodic_vertex_offsets(vec![[0, 0], [0, 0], [1, 0]]) + .unwrap(); + } + let simplex_key = tds.insert_simplex_with_mapping(simplex).unwrap(); + if periodic_self_neighbor { + tds.simplex_mut(simplex_key) + .unwrap() + .set_neighbors_from_keys([Some(simplex_key), None, None]) + .unwrap(); + } + + (tds, simplex_key) + } + + fn facet_key_for_simplex_facet( + tds: &Tds<(), (), 2>, + simplex_key: SimplexKey, + facet_index: u8, + ) -> u64 { + let facet = FacetView::try_new(tds, simplex_key, facet_index).unwrap(); + facet.key() + } + fn build_closed_surface_s2_tds_2d() -> (Tds<(), (), 2>, [VertexKey; 4]) { // Closed 2D simplicial complex (topologically S²): boundary of a tetrahedron. let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 1.0]).unwrap()) .unwrap(); for tri in [[v0, v1, v2], [v0, v1, v3], [v0, v2, v3], [v1, v2, v3]] { @@ -2544,35 +2242,23 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let shared_edge_v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) .unwrap(); let shared_edge_v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) .unwrap(); let tet1_v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) .unwrap(); let tet1_v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) .unwrap(); let tet2_v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, -1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, -1.0, 0.0]).unwrap()) .unwrap(); let tet2_v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap()) .unwrap(); let touched_simplex = tds @@ -2601,17 +2287,17 @@ mod tests { #[test] fn test_validate_facet_degree_ok_for_single_tetrahedron() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); - assert!(validate_facet_degree(&facet_to_simplices).is_ok()); + assert!(ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices).is_ok()); } #[test] @@ -2621,31 +2307,21 @@ mod tests { // Shared triangle. let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) .unwrap(); // Apex points on opposite sides. let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap()) .unwrap(); let _ = tds @@ -2660,7 +2336,7 @@ mod tests { .unwrap(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); - assert!(validate_facet_degree(&facet_to_simplices).is_ok()); + assert!(ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices).is_ok()); } #[test] @@ -2669,35 +2345,23 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 2.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 2.0]).unwrap()) .unwrap(); let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 3.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 3.0]).unwrap()) .unwrap(); let _ = tds @@ -2719,7 +2383,7 @@ mod tests { let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); let expected_facet_key = facet_key_from_vertices(&[v0, v1, v2]); - match validate_facet_degree(&facet_to_simplices) { + match ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices) { Err(ManifoldError::ManifoldFacetMultiplicity { facet_key, simplex_count, @@ -2734,26 +2398,34 @@ mod tests { #[test] fn test_validate_closed_boundary_ok_for_single_tetrahedron() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); - assert!(validate_closed_boundary(&tds, &facet_to_simplices).is_ok()); + assert!( + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean + ) + .is_ok() + ); } #[test] fn test_validate_closed_boundary_errors_on_out_of_bounds_facet_index() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = @@ -2765,15 +2437,21 @@ mod tests { let mut handles: SmallBuffer = SmallBuffer::new(); handles.push(FacetHandle::from_validated(simplex_key, u8::MAX)); facet_to_simplices.insert(0_u64, handles); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); - match validate_closed_boundary(&tds, &facet_to_simplices) { - Err(ManifoldError::Tds(TdsError::IndexOutOfBounds { index, bound, .. })) => { - assert!( - index >= bound, - "Expected index ({index}) >= bound ({bound})" - ); + match validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) { + Err(ManifoldError::Tds(TdsError::FacetError(FacetError::InvalidFacetIndex { + index, + facet_count, + }))) => { + assert_eq!(index, u8::MAX); + assert_eq!(facet_count, 4); } - other => panic!("Expected IndexOutOfBounds error, got {other:?}"), + other => panic!("Expected InvalidFacetIndex error, got {other:?}"), } } @@ -2950,19 +2628,13 @@ mod tests { let mut tds: Tds<(), (), 1> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([2.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([2.0]).unwrap()) .unwrap(); tds.insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v0, v1], None).unwrap()) @@ -2980,14 +2652,10 @@ mod tests { let mut tds: Tds<(), (), 1> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0]).unwrap()) .unwrap(); tds.insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v0, v1], None).unwrap()) @@ -3004,19 +2672,13 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); tds.insert_simplex_with_mapping( @@ -3051,29 +2713,19 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let va = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let vb = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let vc = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 1.0]).unwrap()) .unwrap(); let vd = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); let center = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 0.5]).unwrap()) .unwrap(); for tri in [ @@ -3089,16 +2741,26 @@ mod tests { } let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); // Sanity: pseudomanifold-with-boundary checks pass. - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); - let boundary_vertices = build_boundary_vertex_set(&tds, &facet_to_simplices).unwrap(); + let boundary_vertices = build_boundary_vertex_labels_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); for boundary_vertex in [va, vb, vc, vd] { - assert!(boundary_vertices.contains(&boundary_vertex)); + assert!(boundary_vertices.contains_key(boundary_vertex)); } - assert!(!boundary_vertices.contains(¢er)); + assert!(!boundary_vertices.contains_key(center)); // Boundary vertex link is a path (two degree-1 vertices). let star_a = simplex_star_simplices(&tds, &[va]).unwrap(); @@ -3127,7 +2789,12 @@ mod tests { assert_eq!(vertex_count, 4); // Full vertex-link validation should succeed. - validate_vertex_links(&tds, &facet_to_simplices).unwrap(); + validate_vertex_links_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); // And misclassifications should be rejected (guards the interior/boundary distinction). assert_matches!( @@ -3143,16 +2810,24 @@ mod tests { #[test] fn test_validate_closed_boundary_noop_for_d_lt_2() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0]).unwrap(), ]; let tds = Triangulation::, (), (), 1>::build_initial_simplex(&vertices).unwrap(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); // Codimension-2 boundary manifoldness is only meaningful for D>=2. - assert!(validate_closed_boundary(&tds, &facet_to_simplices).is_ok()); + assert!( + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean + ) + .is_ok() + ); } #[test] @@ -3167,7 +2842,14 @@ mod tests { .all(|handles| handles.len() == 2) ); - assert!(validate_closed_boundary(&tds, &facet_to_simplices).is_ok()); + assert!( + validate_closed_boundary_from_validated_facet_map( + &tds, + validated_facet_map(&facet_to_simplices), + GlobalTopology::Euclidean + ) + .is_ok() + ); } #[test] @@ -3176,45 +2858,28 @@ mod tests { // Sanity: pseudomanifold checks pass. let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); assert!(validate_ridge_links(&tds).is_ok()); } - #[test] - fn test_simplex_star_simplices_errors_on_empty_simplex() { - let tds: Tds<(), (), 2> = Tds::empty(); - - match simplex_star_simplices(&tds, &[]) { - Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { ref message })) - if message.contains("at least one vertex") => {} - other => panic!("Expected InconsistentDataStructure for empty simplex, got {other:?}"), - } - } - - #[test] - fn test_simplex_star_simplices_returns_empty_for_isolated_vertex() { - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - - let star = simplex_star_simplices(&tds, &[v0]).unwrap(); - assert!(star.is_empty()); - } - #[test] fn test_simplex_link_simplices_from_star_errors_on_empty_simplex() { let tds: Tds<(), (), 2> = Tds::empty(); match simplex_link_simplices_from_star(&tds, &[], &[]) { - Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { ref message })) - if message.contains("at least one vertex") => {} - other => panic!("Expected InconsistentDataStructure for empty simplex, got {other:?}"), + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 1, + actual: 0, + .. + })) => {} + other => panic!("Expected DimensionMismatch for empty simplex, got {other:?}"), } } @@ -3226,24 +2891,16 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 10.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([10.0, 10.0]).unwrap()) .unwrap(); let simplex_key = tds @@ -3264,255 +2921,6 @@ mod tests { } } - #[test] - fn test_ridge_vertices_rejects_d_lt_2() { - match RidgeVertices::<1>::try_from_vertices([VertexKey::from(KeyData::from_ffi(0))]) { - Err(RidgeVerticesError::UnsupportedDimension { dimension }) => { - assert_eq!(dimension, 1); - } - other => panic!("Expected UnsupportedDimension for D<2, got {other:?}"), - } - } - - #[test] - fn test_ridge_link_edges_from_star_noop_for_d_lt_2() { - let tds: Tds<(), (), 1> = Tds::empty(); - - let edges = ridge_link_edges_from_star(&tds, &[], &[]).unwrap(); - assert!(edges.is_empty()); - } - - #[test] - fn test_ridge_vertices_rejects_too_few_vertices_in_3d() { - let v0 = VertexKey::from(KeyData::from_ffi(1)); - - // In 3D, ridges are edges (2 vertices). Passing a single vertex is invalid. - match RidgeVertices::<3>::try_from_vertices([v0]) { - Err(RidgeVerticesError::WrongArity { - dimension: 3, - expected: 2, - actual: 1, - }) => {} - other => panic!("Expected WrongArity(2, 1) for wrong ridge size, got {other:?}"), - } - } - - #[test] - fn test_ridge_link_edges_from_star_errors_on_wrong_vertex_count_in_3d() { - let mut tds: Tds<(), (), 3> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) - .unwrap(); - - let simplex_key = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), - ) - .unwrap(); - - // In 3D, ridges are edges (2 vertices). Passing a single vertex is invalid. - match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[simplex_key]) { - Err(ManifoldError::Tds(TdsError::DimensionMismatch { - expected: 2, - actual: 1, - .. - })) => {} - other => panic!("Expected DimensionMismatch(2, 1) for wrong ridge size, got {other:?}"), - } - } - - #[test] - fn test_ridge_star_simplices_returns_incident_simplices_for_vertex_ridge_in_2d() { - // In 2D, a ridge is a vertex and its star is the set of incident triangles. - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) - .unwrap(); - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), - ) - .unwrap(); - - let c012 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), - ) - .unwrap(); - let c013 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v3], None).unwrap(), - ) - .unwrap(); - let c023 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v2, v3], None).unwrap(), - ) - .unwrap(); - let _c123 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v1, v2, v3], None).unwrap(), - ) - .unwrap(); - - let ridge_vertices = RidgeVertices::<2>::try_from_vertices([v0]).unwrap(); - let star = ridge_star_simplices(&tds, &ridge_vertices).unwrap(); - let star_set: SimplexKeySet = star.iter().copied().collect(); - - let expected: SimplexKeySet = [c012, c013, c023].into_iter().collect(); - assert_eq!(star_set, expected); - } - - #[test] - fn test_ridge_star_simplices_returns_full_edge_star_in_3d() { - // In 3D, a ridge is an edge. This regression protects k=3 support - // collection from using only the anchor simplex. - let mut tds: Tds<(), (), 3> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) - .unwrap(); - let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, -1.0, 0.0]).unwrap(), - ) - .unwrap(); - - let c0123 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), - ) - .unwrap(); - let c0134 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v3, v4], None).unwrap(), - ) - .unwrap(); - let c0142 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v4, v2], None).unwrap(), - ) - .unwrap(); - - let ridge_vertices = RidgeVertices::<3>::try_from_vertices([v0, v1]).unwrap(); - let star = ridge_star_simplices(&tds, &ridge_vertices).unwrap(); - let star_set: SimplexKeySet = star.iter().copied().collect(); - - let expected: SimplexKeySet = [c0123, c0134, c0142].into_iter().collect(); - assert_eq!(star_set, expected); - } - - #[test] - fn test_ridge_star_simplices_errors_on_missing_vertex_key() { - let tds: Tds<(), (), 2> = Tds::empty(); - let missing = VertexKey::from(KeyData::from_ffi(u64::MAX)); - - let ridge_vertices = RidgeVertices::<2>::try_from_vertices([missing]).unwrap(); - match ridge_star_simplices(&tds, &ridge_vertices) { - Err(ManifoldError::Tds(TdsError::VertexNotFound { vertex_key, .. })) => { - assert_eq!(vertex_key, missing); - } - other => panic!("Expected VertexNotFound error, got {other:?}"), - } - } - - #[test] - fn test_ridge_link_edges_from_star_rejects_self_loop_edge() { - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) - .unwrap(); - - let simplex_key = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), - ) - .unwrap(); - - // Corrupt the simplex in-place: keep length == D+1 but introduce a duplicate link vertex. - { - let simplex = tds - .simplex_mut(simplex_key) - .expect("simplex key should be valid in test"); - simplex.clear_vertex_keys(); - simplex.push_vertex_key(v0); - simplex.push_vertex_key(v1); - simplex.push_vertex_key(v1); - } - - // For ridge (vertex) v0, the link edge becomes (v1, v1), which is not a simplicial edge. - match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[simplex_key]) { - Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { message })) => { - assert!( - message.contains("self-loop"), - "Unexpected message: {message}" - ); - } - other => panic!("Expected self-loop edge error, got {other:?}"), - } - } - #[test] fn test_validate_ridge_link_graph_deduplicates_parallel_edges() { // Triangle cycle a-b-c-a, but with a duplicated edge. @@ -3536,19 +2944,13 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); let simplex_key = tds @@ -3587,8 +2989,13 @@ mod tests { let (tds, _touched_simplex, expected_ridge_key) = build_non_manifold_boundary_ridge_tds_3d(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); - match validate_closed_boundary(&tds, &facet_to_simplices) { + match validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) { Err(ManifoldError::BoundaryRidgeMultiplicity { ridge_key, boundary_facet_count, @@ -3604,7 +3011,11 @@ mod tests { fn test_validate_local_pseudomanifold_for_simplices_errors_on_non_manifold_boundary_ridge() { let (tds, touched_simplex, expected_ridge_key) = build_non_manifold_boundary_ridge_tds_3d(); - match validate_local_pseudomanifold_for_simplices(&tds, &[touched_simplex]) { + match validate_local_pseudomanifold_for_simplices( + &tds, + GlobalTopology::Euclidean, + &[touched_simplex], + ) { Err(ManifoldError::BoundaryRidgeMultiplicity { ridge_key, boundary_facet_count, @@ -3619,17 +3030,21 @@ mod tests { #[test] fn test_validate_local_pseudomanifold_for_simplices_errors_on_missing_scope_simplex() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let mut tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); let simplex_key = tds.simplex_keys().next().unwrap(); assert_eq!(tds.remove_simplices_by_keys(&[simplex_key]).unwrap(), 1); - match validate_local_pseudomanifold_for_simplices(&tds, &[simplex_key]) { + match validate_local_pseudomanifold_for_simplices( + &tds, + GlobalTopology::Euclidean, + &[simplex_key], + ) { Err(ManifoldError::Tds(TdsError::SimplexNotFound { simplex_key: missing_key, .. @@ -3641,10 +3056,10 @@ mod tests { #[test] fn test_validate_ridge_links_ok_for_single_tetrahedron() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = @@ -3665,8 +3080,13 @@ mod tests { // Sanity: pseudomanifold-with-boundary checks pass (in fact, this complex is closed). let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); let expected_ridge_key = facet_key_from_vertices(&[v0]); @@ -3687,53 +3107,6 @@ mod tests { } } - fn build_two_tetrahedra_sharing_facet_tds_3d() - -> (Tds<(), (), 3>, [VertexKey; 5], [SimplexKey; 2]) { - let mut tds: Tds<(), (), 3> = Tds::empty(); - - // Shared triangle. - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) - .unwrap(); - - // Opposite vertices. - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) - .unwrap(); - let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap(), - ) - .unwrap(); - - let c1 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), - ) - .unwrap(); - let c2 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2, v4], None).unwrap(), - ) - .unwrap(); - - (tds, [v0, v1, v2, v3, v4], [c1, c2]) - } - fn build_wedge_two_spheres_share_vertex_tds_2d() -> (Tds<(), (), 2>, VertexKey, SimplexKey, SimplexKey) { // Two closed 2D spheres (boundaries of tetrahedra) that share a single vertex. @@ -3743,339 +3116,83 @@ mod tests { // Shared vertex. let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); // First tetrahedron boundary (4 triangles on 4 vertices). let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), - ) - .unwrap(); - - let c012 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), - ) - .unwrap(); - let _c013 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v3], None).unwrap(), - ) - .unwrap(); - let _c023 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v2, v3], None).unwrap(), - ) - .unwrap(); - let c123 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v1, v2, v3], None).unwrap(), - ) - .unwrap(); - - // Second tetrahedron boundary (shares only v0). - let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 10.0]).unwrap(), - ) - .unwrap(); - let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([11.0, 10.0]).unwrap(), - ) - .unwrap(); - let v6 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 11.0]).unwrap(), - ) - .unwrap(); - - let _c045 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v4, v5], None).unwrap(), - ) - .unwrap(); - let _c046 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v4, v6], None).unwrap(), - ) - .unwrap(); - let _c056 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v5, v6], None).unwrap(), - ) - .unwrap(); - let _c456 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v4, v5, v6], None).unwrap(), - ) - .unwrap(); - - (tds, v0, c012, c123) - } - - #[test] - fn test_build_ridge_star_map_empty_returns_empty() { - let tds: Tds<(), (), 3> = Tds::empty(); - - let map = build_ridge_star_map(&tds).unwrap(); - assert!(map.is_empty()); - } - - #[test] - fn test_build_ridge_star_map_errors_on_corrupted_simplex_vertex_count() { - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) - .unwrap(); - - let simplex_key = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), - ) - .unwrap(); - - // Corrupt the simplex in-place: change it to have only 2 vertices. - { - let simplex = tds - .simplex_mut(simplex_key) - .expect("simplex key should be valid in test"); - simplex.clear_vertex_keys(); - simplex.push_vertex_key(v0); - simplex.push_vertex_key(v1); - } - - match build_ridge_star_map(&tds) { - Err(ManifoldError::Tds(TdsError::DimensionMismatch { - expected: 3, - actual: 2, - .. - })) => {} - other => { - panic!("Expected DimensionMismatch(3, 2) for corrupted simplex, got {other:?}") - } - } - } - - #[test] - fn test_build_ridge_star_map_for_simplices_noop_for_d_lt_2() { - let tds: Tds<(), (), 1> = Tds::empty(); - let simplex_key = SimplexKey::from(KeyData::from_ffi(0)); - - let map = build_ridge_star_map_for_simplices(&tds, [simplex_key]).unwrap(); - assert!(map.is_empty()); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_empty_returns_empty() { - let mut tds: Tds<(), (), 3> = Tds::empty(); - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 1.0]).unwrap()) .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + + let c012 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), ) .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + let _c013 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v3], None).unwrap(), ) .unwrap(); - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + let _c023 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v2, v3], None).unwrap(), ) .unwrap(); - - let _ = tds + let c123 = tds .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + Simplex::try_new_with_data(vec![v1, v2, v3], None).unwrap(), ) .unwrap(); - let map = - build_ridge_star_map_for_simplices(&tds, std::iter::empty::()).unwrap(); - assert!(map.is_empty()); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_3d_single_simplex_includes_only_its_ridges_and_full_stars() - { - let (tds, [v0, v1, v2, v3, v4], [c1, c2]) = build_two_tetrahedra_sharing_facet_tds_3d(); - - // Include a missing simplex key to ensure it is skipped, not treated as an error. - let missing = SimplexKey::from(KeyData::from_ffi(u64::MAX)); - - let map = build_ridge_star_map_for_simplices(&tds, [c1, missing]).unwrap(); - - // In 3D, ridges are edges: a tetrahedron has C(4,2) = 6 edges. - assert_eq!(map.len(), 6); - - let star_set_for_edge = |a: VertexKey, b: VertexKey| -> SimplexKeySet { - let key = facet_key_from_vertices(&[a, b]); - let star = map - .get(&key) - .expect("expected ridge key in local ridge-star map"); - - // RidgeStar stores the ridge vertices; ensure its canonical key matches the map key. - assert_eq!(periodic_simplex_key(&star.ridge_vertices), key); - assert_eq!(star.ridge_vertices.len(), 2); - - star.star_simplices.iter().copied().collect() - }; - - let shared_star: SimplexKeySet = [c1, c2].into_iter().collect(); - let c1_only: SimplexKeySet = std::iter::once(c1).collect(); - - // Shared-facet edges should have a 2-simplex star (full star across the whole TDS). - assert_eq!(star_set_for_edge(v0, v1), shared_star); - assert_eq!(star_set_for_edge(v0, v2), shared_star); - assert_eq!(star_set_for_edge(v1, v2), shared_star); - - // Edges incident to the first tetrahedron's opposite vertex should have a 1-simplex star. - assert_eq!(star_set_for_edge(v0, v3), c1_only); - assert_eq!(star_set_for_edge(v1, v3), c1_only); - assert_eq!(star_set_for_edge(v2, v3), c1_only); - - // Edges involving v4 belong only to c2, so they should not appear when selecting only c1. - assert!(!map.contains_key(&facet_key_from_vertices(&[v0, v4]))); - assert!(!map.contains_key(&facet_key_from_vertices(&[v1, v4]))); - assert!(!map.contains_key(&facet_key_from_vertices(&[v2, v4]))); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_3d_two_simplices_includes_union_of_ridges() { - let (tds, [v0, v1, v2, v3, v4], [c1, c2]) = build_two_tetrahedra_sharing_facet_tds_3d(); - - let map = build_ridge_star_map_for_simplices(&tds, [c1, c2]).unwrap(); - - // Each tetrahedron has 6 edges and they share 3 edges on the shared facet => 6+6-3=9. - assert_eq!(map.len(), 9); - - let star_size_for_edge = |a: VertexKey, b: VertexKey| -> usize { - let key = facet_key_from_vertices(&[a, b]); - map.get(&key) - .expect("expected ridge key in local ridge-star map") - .star_simplices - .len() - }; - - // Shared edges have a 2-simplex star. - assert_eq!(star_size_for_edge(v0, v1), 2); - assert_eq!(star_size_for_edge(v0, v2), 2); - assert_eq!(star_size_for_edge(v1, v2), 2); - - // Opposite-vertex edges are unique to each tetrahedron. - assert_eq!(star_size_for_edge(v0, v3), 1); - assert_eq!(star_size_for_edge(v1, v3), 1); - assert_eq!(star_size_for_edge(v2, v3), 1); - - assert_eq!(star_size_for_edge(v0, v4), 1); - assert_eq!(star_size_for_edge(v1, v4), 1); - assert_eq!(star_size_for_edge(v2, v4), 1); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_2d_includes_full_star_for_shared_vertex() { - let (tds, v0, incident, _nonincident) = build_wedge_two_spheres_share_vertex_tds_2d(); - - // In 2D, ridges are vertices. A single triangle touches 3 ridges. - let map = build_ridge_star_map_for_simplices(&tds, [incident]).unwrap(); - assert_eq!(map.len(), 3); - - // The shared vertex v0 should have a star consisting of 6 incident triangles (3 from each sphere). - let ridge_key = facet_key_from_vertices(&[v0]); - let star = map - .get(&ridge_key) - .expect("expected ridge key for shared vertex"); - assert_eq!(star.star_simplices.len(), 6); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_errors_on_corrupted_simplex_vertex_count() { - // Corrupt a simplex's vertex list to violate the (D+1)-vertices invariant. - let mut tds: Tds<(), (), 2> = Tds::empty(); + // Second tetrahedron boundary (shares only v0). + let v4 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([10.0, 10.0]).unwrap()) + .unwrap(); + let v5 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([11.0, 10.0]).unwrap()) + .unwrap(); + let v6 = tds + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([10.0, 11.0]).unwrap()) + .unwrap(); - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + let _c045 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v4, v5], None).unwrap(), ) .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + let _c046 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v4, v6], None).unwrap(), ) .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + let _c056 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v5, v6], None).unwrap(), ) .unwrap(); - - let simplex_key = tds + let _c456 = tds .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + Simplex::try_new_with_data(vec![v4, v5, v6], None).unwrap(), ) .unwrap(); - // Corrupt the simplex in-place: change it to have only 2 vertices. - { - let simplex = tds - .simplex_mut(simplex_key) - .expect("simplex key should be valid in test"); - simplex.clear_vertex_keys(); - simplex.push_vertex_key(v0); - simplex.push_vertex_key(v1); - } - - match build_ridge_star_map_for_simplices(&tds, [simplex_key]) { - Err(ManifoldError::Tds(TdsError::DimensionMismatch { - expected: 3, - actual: 2, - .. - })) => {} - other => { - panic!("Expected DimensionMismatch(3, 2) for corrupted simplex, got {other:?}") - } - } + (tds, v0, c012, c123) } #[test] fn test_validate_ridge_links_for_simplices_ok_for_single_tetrahedron_in_3d() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = @@ -4085,7 +3202,7 @@ mod tests { validate_ridge_links_for_simplices(&tds, simplices.iter().copied()).unwrap(); // And it should be a no-op on empty simplex lists. - validate_ridge_links_for_simplices(&tds, std::iter::empty::()).unwrap(); + validate_ridge_links_for_simplices(&tds, iter::empty::()).unwrap(); } #[test] @@ -4102,14 +3219,10 @@ mod tests { let mut tds: Tds<(), (), 1> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0]).unwrap()) .unwrap(); let c01 = tds @@ -4170,21 +3283,17 @@ mod tests { let mut v: [[VertexKey; M]; N] = [[VertexKey::from(KeyData::from_ffi(0)); M]; N]; for (i, row) in v.iter_mut().enumerate() { for (j, slot) in row.iter_mut().enumerate() { - let i_f = >::from(u32::try_from(i).unwrap()); - let j_f = >::from(u32::try_from(j).unwrap()); + let i_f = f64::from(u32::try_from(i).unwrap()); + let j_f = f64::from(u32::try_from(j).unwrap()); *slot = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([i_f, j_f, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([i_f, j_f, 0.0]).unwrap()) .unwrap(); } } // Apex of the cone (interior vertex; not on any boundary facet). let apex = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 0.5, 1.0]).unwrap()) .unwrap(); // Triangulate each periodic square into two triangles, then cone to the apex. @@ -4221,14 +3330,23 @@ mod tests { let (tds, apex) = build_cone_on_torus_tds(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); // Ridge-link validation should *not* detect this singularity. assert!(validate_ridge_links(&tds).is_ok()); // Vertex-link validation MUST reject it: apex link is T^2, not S^2. - match validate_vertex_links(&tds, &facet_to_simplices) { + match validate_vertex_links_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) { Err(ManifoldError::VertexLinkNotManifold { vertex_key, interior_vertex, @@ -4242,63 +3360,6 @@ mod tests { } } - #[test] - fn test_simplex_star_simplices_rejects_missing_vertex() { - let tds: Tds<(), (), 2> = Tds::empty(); - let stale_key = VertexKey::from(KeyData::from_ffi(0xDEAD)); - match simplex_star_simplices(&tds, &[stale_key]) { - Err(ManifoldError::Tds(TdsError::VertexNotFound { - vertex_key, - ref context, - })) => { - assert_eq!(vertex_key, stale_key); - assert!(context.contains("simplex star")); - } - other => panic!("Expected VertexNotFound, got {other:?}"), - } - } - - #[test] - fn test_ridge_vertices_rejects_too_many_vertices_in_3d() { - // For D=3, ridges have D-1=2 vertices; pass 3 vertices instead. - let v0 = VertexKey::from(KeyData::from_ffi(1)); - let v1 = VertexKey::from(KeyData::from_ffi(2)); - let v2 = VertexKey::from(KeyData::from_ffi(3)); - match RidgeVertices::<3>::try_from_vertices([v0, v1, v2]) { - Err(RidgeVerticesError::WrongArity { - expected, actual, .. - }) => { - assert_eq!(expected, 2); - assert_eq!(actual, 3); - } - other => panic!("Expected WrongArity, got {other:?}"), - } - } - - #[test] - fn test_ridge_vertices_rejects_duplicate_vertices() { - let v0 = VertexKey::from(KeyData::from_ffi(1)); - - match RidgeVertices::<3>::try_from_vertices([v0, v0]) { - Err(RidgeVerticesError::DuplicateVertex { vertex_key }) => { - assert_eq!(vertex_key, v0); - } - other => panic!("Expected DuplicateVertex, got {other:?}"), - } - } - - #[test] - fn test_ridge_vertices_canonicalizes_permuted_vertices() { - let v0 = VertexKey::from(KeyData::from_ffi(1)); - let v1 = VertexKey::from(KeyData::from_ffi(2)); - - let forward = RidgeVertices::<3>::try_from_vertices([v0, v1]).unwrap(); - let reversed = RidgeVertices::<3>::try_from_vertices([v1, v0]).unwrap(); - - assert_eq!(forward, reversed); - assert_eq!(reversed.as_slice(), &[v0, v1]); - } - #[test] fn test_validate_closed_boundary_dimension_mismatch_on_corrupted_simplex() { // Create a 3D TDS with a simplex that has too few vertices (corrupted state), @@ -4306,24 +3367,16 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) .unwrap(); let simplex_key = tds @@ -4346,18 +3399,22 @@ mod tests { let mut handles: SmallBuffer = SmallBuffer::new(); handles.push(FacetHandle::from_validated(simplex_key, 0)); facet_to_simplices.insert(0_u64, handles); - - match validate_closed_boundary(&tds, &facet_to_simplices) { - Err(ManifoldError::Tds(TdsError::DimensionMismatch { - expected, actual, .. - })) => { - assert_eq!(expected, 3, "D=3: boundary facet should have 3 vertices"); - assert!( - actual != 3, - "Corrupted simplex should produce wrong vertex count" - ); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); + + match validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) { + Err(ManifoldError::Tds(TdsError::FacetError(FacetError::FacetHandleKeyMismatch { + expected_facet_key, + actual_facet_key, + .. + }))) => { + assert_eq!(expected_facet_key, 0); + assert_ne!(actual_facet_key, expected_facet_key); } - other => panic!("Expected DimensionMismatch, got {other:?}"), + other => panic!("Expected FacetHandleKeyMismatch, got {other:?}"), } } @@ -4382,6 +3439,104 @@ mod tests { assert!(err.to_string().contains("inner")); } + #[test] + fn classify_boundary_facet_euclidean_open_one_sided_is_boundary() { + let (tds, simplex_key) = build_single_triangle_tds(false); + let facet_key = facet_key_for_simplex_facet(&tds, simplex_key, 0); + let facet_to_simplices = tds.build_facet_to_simplices_index().unwrap(); + let incidence = facet_to_simplices.get(&facet_key).unwrap(); + + let classification = classify_boundary_facet(incidence, GlobalTopology::Euclidean).unwrap(); + + assert_matches!( + classification, + BoundaryFacetClassification::Boundary(handle) + if handle.simplex_key() == simplex_key && handle.facet_index() == 0 + ); + } + + #[test] + fn validated_facet_map_rejects_boundary_handle_under_wrong_facet_key() { + let (tds, simplex_key) = build_single_triangle_tds(false); + let actual_facet_key = facet_key_for_simplex_facet(&tds, simplex_key, 0); + let mismatched_facet_key = actual_facet_key.wrapping_add(1); + let handle = FacetHandle::from_validated(simplex_key, 0); + + let mut handles = SmallBuffer::new(); + handles.push(handle); + let mut facet_to_simplices = FacetToSimplicesMap::default(); + facet_to_simplices.insert(mismatched_facet_key, handles); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); + + let err = has_boundary_facets_in_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap_err(); + + assert_matches!( + err, + ManifoldError::Tds(TdsError::FacetError(FacetError::FacetHandleKeyMismatch { + expected_facet_key, + actual_facet_key: found_actual_facet_key, + handle: found_handle, + })) if expected_facet_key == mismatched_facet_key + && found_actual_facet_key == actual_facet_key + && found_handle == handle + ); + } + + #[test] + fn classify_boundary_facet_closed_topology_rejects_open_one_sided() { + let (tds, simplex_key) = build_single_triangle_tds(false); + let facet_key = facet_key_for_simplex_facet(&tds, simplex_key, 0); + let facet_to_simplices = tds.build_facet_to_simplices_index().unwrap(); + let incidence = facet_to_simplices.get(&facet_key).unwrap(); + + let err = classify_boundary_facet(incidence, GlobalTopology::Spherical).unwrap_err(); + + assert_matches!( + err, + ManifoldError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + facet_key: found_facet_key, + simplex_key: found_simplex_key, + facet_index: 0, + .. + } if found_facet_key == facet_key && found_simplex_key == simplex_key + ); + } + + #[test] + fn classify_boundary_facet_periodic_self_identification_requires_periodic_topology() { + let (tds, simplex_key) = build_single_triangle_tds(true); + let facet_key = facet_key_for_simplex_facet(&tds, simplex_key, 0); + let facet_to_simplices = tds.build_facet_to_simplices_index().unwrap(); + let incidence = facet_to_simplices.get(&facet_key).unwrap(); + let periodic_topology = + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(); + + let classification = classify_boundary_facet(incidence, periodic_topology).unwrap(); + assert_eq!( + classification, + BoundaryFacetClassification::ClosedIdentification + ); + + let err = classify_boundary_facet(incidence, GlobalTopology::Euclidean).unwrap_err(); + assert_matches!( + err, + ManifoldError::PeriodicIdentificationInNonPeriodicTopology { + topology: TopologyKind::Euclidean, + facet_key: found_facet_key, + simplex_key: found_simplex_key, + facet_index: 0, + .. + } if found_facet_key == facet_key && found_simplex_key == simplex_key + ); + } + #[test] fn test_validate_vertex_links_accepts_cone_on_sphere_in_3d() { // Cone on the boundary of a tetrahedron (S^2). @@ -4391,31 +3546,21 @@ mod tests { // Base tetrahedron vertices (triangulated S^2 boundary) let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap()) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap()) .unwrap(); // Apex of the cone let apex = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.3, 0.3, 0.3]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.3, 0.3, 0.3]).unwrap()) .unwrap(); // Each boundary triangle of the tetrahedron, coned to apex @@ -4434,158 +3579,24 @@ mod tests { } let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); // Sanity: pseudomanifold + ridge links pass - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); validate_ridge_links(&tds).unwrap(); // Vertex-link validation should ACCEPT this complex - validate_vertex_links(&tds, &facet_to_simplices).unwrap(); - } - - #[test] - fn test_build_ridge_star_map_for_simplices_identifies_translated_periodic_images() { - // Two 2D simplices share bare vertex keys but differ in periodic offsets. - // The ridge map identifies globally translated quotient ridges. - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 1.0]).unwrap(), - ) - .unwrap(); - - // c1: all vertices at base image [0,0]. - let mut simplex1 = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); - simplex1 - .set_periodic_vertex_offsets(vec![[0, 0], [0, 0], [0, 0]]) - .unwrap(); - let c1 = tds.insert_simplex_with_mapping(simplex1).unwrap(); - - // c2: v0 at periodic image [1,0]; v1 and v2 at base image. - let mut simplex2 = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); - simplex2 - .set_periodic_vertex_offsets(vec![[1, 0], [0, 0], [0, 0]]) - .unwrap(); - let c2 = tds.insert_simplex_with_mapping(simplex2).unwrap(); - - let map = build_ridge_star_map_for_simplices(&tds, [c1, c2]).unwrap(); - - // In 2D, ridges have D-1 = 1 vertex. Single-vertex ridges are identified - // modulo global periodic translation, so v0@base and v0@[1,0] represent - // the same quotient ridge. - assert_eq!(map.len(), 3, "expected 3 quotient-aware ridges"); - - // All quotient ridges should have a 2-simplex star (both c1 and c2). - let shared_count = map.values().filter(|s| s.star_simplices.len() == 2).count(); - assert_eq!(shared_count, 3, "three ridges should be shared"); - } - - #[test] - fn test_anchored_lifted_simplex_key_preserves_vertex_link_offsets() { - let mut tds: Tds<(), (), 3> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) - .unwrap(); - - let first_link_triangle: LiftedVertexBuffer = [ - lifted_vertex_id(v0, &[1_i16, 0, 0]), - lifted_vertex_id(v1, &[1_i16, 0, 0]), - lifted_vertex_id(v2, &[1_i16, 0, 0]), - ] - .into_iter() - .collect(); - let shifted_link_triangle: LiftedVertexBuffer = [ - lifted_vertex_id(v0, &[2_i16, 0, 0]), - lifted_vertex_id(v1, &[2_i16, 0, 0]), - lifted_vertex_id(v2, &[2_i16, 0, 0]), - ] - .into_iter() - .collect(); - - assert_eq!( - periodic_simplex_key(&first_link_triangle), - periodic_simplex_key(&shifted_link_triangle), - "quotient simplex keys intentionally identify global translations" - ); - assert_ne!( - anchored_lifted_simplex_key(&first_link_triangle), - anchored_lifted_simplex_key(&shifted_link_triangle), - "vertex-link keys must preserve offsets relative to the linked vertex" - ); - } - - #[test] - fn test_periodic_aware_ridge_star_empty_star_returns_error() { - // Call periodic_aware_ridge_star with lifted vertices that don't match - // any simplex's offsets, forcing an empty star after filtering. - let mut tds: Tds<(), (), 2> = Tds::empty(); - - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 1.0]).unwrap(), - ) - .unwrap(); - - let c1 = tds - .insert_simplex_with_mapping( - Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), - ) - .unwrap(); - tds.simplex_mut(c1) - .unwrap() - .set_periodic_vertex_offsets(vec![[0, 0], [0, 0], [0, 0]]) - .unwrap(); - - // Bare [v0] finds c1, but lifted_vertex_id(v0, [99,99]) won't match - // c1's lifted v0 (which is bare v0 since offset is [0,0]). - let synthetic = lifted_vertex_id(v0, &[99_i16, 99_i16]); - let bare: VertexKeyBuffer = std::iter::once(v0).collect(); - let lifted: LiftedVertexBuffer = std::iter::once(synthetic).collect(); - - match periodic_aware_ridge_star(&tds, 0x42, &lifted, &bare) { - Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { ref message })) => { - assert!( - message.contains("empty star"), - "error should mention empty star: {message}" - ); - } - other => panic!("Expected InconsistentDataStructure (empty star), got {other:?}"), - } + validate_vertex_links_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); } #[test] @@ -4595,19 +3606,13 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 0.0]).unwrap()) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 1.0]).unwrap()) .unwrap(); let mut simplex1 = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); @@ -4634,21 +3639,31 @@ mod tests { // Each boundary vertex has a link homeomorphic to a 2-ball. let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), + Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), ]; let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); let facet_to_simplices = tds.build_facet_to_simplices_map().unwrap(); + let facet_to_simplices = validated_facet_map(&facet_to_simplices); // All vertices are boundary vertices in a single tetrahedron - validate_facet_degree(&facet_to_simplices).unwrap(); - validate_closed_boundary(&tds, &facet_to_simplices).unwrap(); + validate_closed_boundary_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); // Vertex-link validation must succeed (links are 2-balls) - validate_vertex_links(&tds, &facet_to_simplices).unwrap(); + validate_vertex_links_from_validated_facet_map( + &tds, + facet_to_simplices, + GlobalTopology::Euclidean, + ) + .unwrap(); } } diff --git a/src/topology/ridge.rs b/src/topology/ridge.rs new file mode 100644 index 00000000..9050bbcc --- /dev/null +++ b/src/topology/ridge.rs @@ -0,0 +1,2696 @@ +//! Ridge candidates, queries, views, and lifted ridge-link views. +//! +//! Ridges are codimension-2 simplices. Unlike vertices and D-simplices, they +//! are not stored directly in the TDS, so this module separates detached +//! [`RidgeCandidate`] values from borrowed [`RidgeQuery`] and [`RidgeView`] +//! values over one live triangulation. Periodic ridge links preserve toroidal +//! covering-space identity with +//! [`LiftedVertexId`](crate::topology::spaces::toroidal::LiftedVertexId) and +//! [`LiftedLinkEdge`](crate::topology::spaces::toroidal::LiftedLinkEdge). +//! +//! Ridge-star and ridge-link validation is combinatorial PL topology: it +//! follows standard links-of-simplices criteria for PL manifolds (see +//! `REFERENCES.md`, "Topological Manifolds and PL Topology") and does not add +//! any new `f64` floating-point conditioning behavior. + +#![forbid(unsafe_code)] + +use super::manifold::ManifoldError; +use crate::core::{ + collections::{Entry, FastHashMap, SmallBuffer, VertexKeyBuffer, fast_hash_map_with_capacity}, + tds::{SimplexKey, Tds, TdsError, VertexKey}, + vertex::Vertex, +}; +use crate::topology::spaces::toroidal::{ + LiftedLinkEdge, LiftedVertexBuffer, LiftedVertexId, lifted_vertex_id, + normalize_lifted_vertices, periodic_simplex_key, +}; +use std::{fmt, ptr}; +use thiserror::Error; + +type RidgeVertexRefBuffer<'tds, U, const D: usize> = SmallBuffer<&'tds Vertex, 8>; + +/// Errors returned when parsing raw vertex keys into a ridge candidate. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum RidgeCandidateError { + /// Ridge vertices are only meaningful for dimensions `D >= 2`. + #[error("ridge candidates require D >= 2, got D={dimension}")] + UnsupportedDimension { + /// Requested triangulation dimension. + dimension: usize, + }, + + /// The supplied vertex count does not match the ridge arity `D - 1`. + #[error( + "ridge candidate vertex count mismatch for {dimension}D: expected {expected}, got {actual}" + )] + WrongArity { + /// Requested triangulation dimension. + dimension: usize, + /// Expected number of ridge vertices. + expected: usize, + /// Actual number of supplied vertices. + actual: usize, + }, + + /// A ridge cannot contain the same vertex more than once. + #[error("ridge candidate contains duplicate vertex key {vertex_key:?}")] + DuplicateVertex { + /// Duplicate vertex key. + vertex_key: VertexKey, + }, +} + +/// Validated vertex keys for a potential `(D - 2)`-simplex ridge. +/// +/// This proof-bearing wrapper encodes the arity and uniqueness invariants for +/// ridge-star queries before they reach topology computation. It stores vertex +/// keys in canonical sorted order so the same candidate has the same identity +/// regardless of input order. It does not prove that the vertices exist in a +/// particular [`Tds`] or that the ridge occurs in any D-simplex; use +/// [`Self::query`] for a borrowed possibly-empty query and [`Self::view`] for a +/// borrowed view that proves a non-empty star. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// ManifoldError, RidgeCandidate, RidgeCandidateError, ridge_star_simplices, +/// }; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # #[error(transparent)] +/// # Ridge(#[from] RidgeCandidateError), +/// # #[error(transparent)] +/// # Manifold(#[from] ManifoldError), +/// # } +/// # 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::<()>()?; +/// +/// // In 2D, a ridge is a vertex because it has arity D - 1. +/// let ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; +/// let star = ridge_star_simplices(dt.tds(), &ridge)?; +/// let view = ridge.view(dt.tds())?; +/// +/// assert_eq!(ridge.as_slice(), view.vertex_keys()); +/// assert_eq!(star.as_slice(), view.incident_simplices()); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RidgeCandidate { + vertices: VertexKeyBuffer, +} + +impl RidgeCandidate { + /// Parses raw vertex keys into a validated ridge candidate. + /// + /// Stored vertex keys are canonicalized into sorted order. + /// + /// # Errors + /// + /// Returns [`RidgeCandidateError::UnsupportedDimension`] when `D < 2`, + /// [`RidgeCandidateError::WrongArity`] when the input length is not `D - 1`, + /// or [`RidgeCandidateError::DuplicateVertex`] when a vertex key is repeated. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0, 0.0]?, + /// delaunay::vertex![0.0, 0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// let ridge = RidgeCandidate::<3>::try_from_vertices(dt.tds().vertex_keys().take(2))?; + /// assert_eq!(ridge.as_slice().len(), 2); + /// # Ok(()) + /// # } + /// ``` + pub fn try_from_vertices( + vertices: impl IntoIterator, + ) -> Result { + if D < 2 { + return Err(RidgeCandidateError::UnsupportedDimension { dimension: D }); + } + + let mut vertices: VertexKeyBuffer = vertices.into_iter().collect(); + let expected = D - 1; + if vertices.len() != expected { + return Err(RidgeCandidateError::WrongArity { + dimension: D, + expected, + actual: vertices.len(), + }); + } + + vertices.sort_unstable(); + for duplicate_pair in vertices.windows(2) { + if duplicate_pair[0] == duplicate_pair[1] { + return Err(RidgeCandidateError::DuplicateVertex { + vertex_key: duplicate_pair[0], + }); + } + } + + Ok(Self { vertices }) + } + + /// Returns the validated ridge vertex keys. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0, 0.0]?, + /// delaunay::vertex![0.0, 0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// let ridge = RidgeCandidate::<3>::try_from_vertices(dt.tds().vertex_keys().take(2))?; + /// assert_eq!(ridge.as_slice().len(), 2); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn as_slice(&self) -> &[VertexKey] { + &self.vertices + } + + /// Iterates over the validated ridge vertex keys. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # } + /// # fn main() -> Result<(), ExampleError> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0, 0.0]?, + /// delaunay::vertex![0.0, 0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// let ridge = RidgeCandidate::<3>::try_from_vertices(dt.tds().vertex_keys().take(2))?; + /// assert_eq!(ridge.iter().count(), ridge.as_slice().len()); + /// # Ok(()) + /// # } + /// ``` + pub fn iter(&self) -> impl Iterator + '_ { + self.vertices.iter().copied() + } + + /// Revalidates this candidate against a live TDS and returns a borrowed query. + /// + /// A [`RidgeQuery`] proves only that the candidate's vertices are live in + /// `tds`. Its incident star may be empty. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if any ridge vertex is stale. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let query = ridge.query(dt.tds())?; + /// assert_eq!(query.vertex_keys(), ridge.as_slice()); + /// # Ok(()) + /// # } + /// ``` + pub fn query<'tds, U, V>( + &self, + tds: &'tds Tds, + ) -> Result, ManifoldError> { + RidgeQuery::try_new(tds, self.clone()) + } + + /// Revalidates this candidate against a live TDS and returns a borrowed view. + /// + /// A [`RidgeView`] proves that the candidate's vertices are live and that + /// at least one D-simplex is incident to the ridge. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if any ridge vertex is stale or the TDS is + /// internally inconsistent while resolving the star. Returns + /// [`ManifoldError::RidgeNotFound`] if the live candidate has an empty star. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let view = ridge.view(dt.tds())?; + /// assert!(!view.incident_simplices().is_empty()); + /// # Ok(()) + /// # } + /// ``` + pub fn view<'tds, U, V>( + &self, + tds: &'tds Tds, + ) -> Result, ManifoldError> { + RidgeView::try_new(tds, self.clone()) + } +} + +/// Borrowed live-TDS query over a ridge candidate. +/// +/// `RidgeQuery` is a non-durable topology query. Construction proves the +/// candidate's vertices are live in one borrowed [`Tds`], but it deliberately +/// permits an empty incident star. Use [`RidgeView`] when the API requires a +/// ridge that is known to exist in the triangulation. +#[must_use] +pub struct RidgeQuery<'tds, U, V, const D: usize> { + tds: &'tds Tds, + ridge_candidate: RidgeCandidate, + vertices: RidgeVertexRefBuffer<'tds, U, D>, + star_simplices: SmallBuffer, +} + +impl<'tds, U, V, const D: usize> RidgeQuery<'tds, U, V, D> { + /// Creates a borrowed ridge query after validating `ridge_candidate` against `tds`. + /// + /// This checks that every candidate vertex is live in `tds`. It does not + /// require the candidate to occur in any D-simplex; [`Self::incident_simplices`] + /// and [`Self::links`] return empty buffers for isolated live candidates. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if any ridge vertex is stale or if the + /// TDS is internally inconsistent while resolving the candidate star. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, RidgeQuery, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let query = RidgeQuery::try_new(dt.tds(), ridge)?; + /// assert_eq!(query.vertices().len(), 1); + /// # Ok(()) + /// # } + /// ``` + pub fn try_new( + tds: &'tds Tds, + ridge_candidate: RidgeCandidate, + ) -> Result { + let vertices = resolve_ridge_vertices(tds, &ridge_candidate, "ridge query construction")?; + let star_simplices = simplex_star_simplices(tds, ridge_candidate.as_slice())?; + + Ok(Self { + tds, + ridge_candidate, + vertices, + star_simplices, + }) + } + + /// Returns the borrowed TDS backing this query. + #[inline] + #[must_use] + pub const fn tds(&self) -> &'tds Tds { + self.tds + } + + /// Returns the validated ridge candidate represented by this query. + #[inline] + pub const fn ridge_candidate(&self) -> &RidgeCandidate { + &self.ridge_candidate + } + + /// Returns the ridge vertex keys in canonical order. + #[inline] + #[must_use] + pub fn vertex_keys(&self) -> &[VertexKey] { + self.ridge_candidate.as_slice() + } + + /// Returns borrowed ridge vertices in canonical key order. + #[inline] + #[must_use] + pub fn vertices(&self) -> &[&'tds Vertex] { + self.vertices.as_slice() + } + + /// Returns all D-simplices incident to this candidate. + #[inline] + #[must_use] + pub fn incident_simplices(&self) -> &[SimplexKey] { + self.star_simplices.as_slice() + } + + /// Returns the lifted links represented by this quotient-space candidate. + /// + /// A non-periodic ridge normally has one link. In a periodic triangulation, + /// one [`RidgeCandidate`] value may correspond to multiple lifted ridge + /// images, so this returns one [`RidgeLinkView`] per lifted ridge identity. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if the backing TDS is internally + /// inconsistent while resolving the queried ridge's lifted stars. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let query = ridge.query(dt.tds())?; + /// let links = query.links()?; + /// assert!(!links.is_empty()); + /// # Ok(()) + /// # } + /// ``` + pub fn links(&self) -> Result, 8>, ManifoldError> { + ridge_links_from_star(self.tds, &self.ridge_candidate, &self.star_simplices) + } +} + +impl fmt::Debug for RidgeQuery<'_, U, V, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RidgeQuery") + .field("ridge_candidate", &self.ridge_candidate) + .field("dimension", &D) + .finish() + } +} + +impl Clone for RidgeQuery<'_, U, V, D> { + fn clone(&self) -> Self { + Self { + tds: self.tds, + ridge_candidate: self.ridge_candidate.clone(), + vertices: self.vertices.clone(), + star_simplices: self.star_simplices.clone(), + } + } +} + +impl PartialEq for RidgeQuery<'_, U, V, D> { + fn eq(&self, other: &Self) -> bool { + ptr::eq(self.tds, other.tds) && self.ridge_candidate == other.ridge_candidate + } +} + +impl Eq for RidgeQuery<'_, U, V, D> {} + +/// Borrowed live-TDS view over an existing ridge. +/// +/// `RidgeView` is a non-durable topology view. It borrows one in-memory [`Tds`], +/// owns the validated [`RidgeCandidate`] identity, and caches the non-empty +/// incident D-simplex star proven during construction. Persist stable vertex +/// UUIDs or a full TDS snapshot instead of serializing ridge views. +#[must_use] +pub struct RidgeView<'tds, U, V, const D: usize> { + tds: &'tds Tds, + ridge_candidate: RidgeCandidate, + vertices: RidgeVertexRefBuffer<'tds, U, D>, + star_simplices: SmallBuffer, +} + +impl<'tds, U, V, const D: usize> RidgeView<'tds, U, V, D> { + /// Creates a borrowed ridge view after validating `ridge_candidate` against `tds`. + /// + /// This checks that every ridge vertex is live in `tds` and that the + /// candidate occurs in at least one D-simplex. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if any ridge vertex is stale or if the TDS + /// is internally inconsistent while resolving the star. Returns + /// [`ManifoldError::RidgeNotFound`] if the live candidate has an empty star. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, RidgeView, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let view = RidgeView::try_new(dt.tds(), ridge)?; + /// assert_eq!(view.vertices().len(), 1); + /// # Ok(()) + /// # } + /// ``` + pub fn try_new( + tds: &'tds Tds, + ridge_candidate: RidgeCandidate, + ) -> Result { + let query = RidgeQuery::try_new(tds, ridge_candidate)?; + if query.star_simplices.is_empty() { + return Err(ManifoldError::RidgeNotFound { + ridge_vertices: query.ridge_candidate.as_slice().iter().copied().collect(), + }); + } + + Ok(Self { + tds: query.tds, + ridge_candidate: query.ridge_candidate, + vertices: query.vertices, + star_simplices: query.star_simplices, + }) + } + + /// Returns the borrowed TDS backing this view. + #[inline] + #[must_use] + pub const fn tds(&self) -> &'tds Tds { + self.tds + } + + /// Returns the validated ridge candidate represented by this view. + #[inline] + pub const fn ridge_candidate(&self) -> &RidgeCandidate { + &self.ridge_candidate + } + + /// Returns the ridge vertex keys in canonical order. + #[inline] + #[must_use] + pub fn vertex_keys(&self) -> &[VertexKey] { + self.ridge_candidate.as_slice() + } + + /// Returns borrowed ridge vertices in canonical key order. + #[inline] + #[must_use] + pub fn vertices(&self) -> &[&'tds Vertex] { + self.vertices.as_slice() + } + + /// Returns all D-simplices incident to this ridge. + #[inline] + #[must_use] + pub fn incident_simplices(&self) -> &[SimplexKey] { + self.star_simplices.as_slice() + } + + /// Returns the lifted links represented by this quotient-space ridge. + /// + /// A non-periodic ridge normally has one link. In a periodic triangulation, + /// one [`RidgeCandidate`] value may correspond to multiple lifted ridge + /// images, so this returns one [`RidgeLinkView`] per lifted ridge identity. + /// + /// # Errors + /// + /// Returns [`ManifoldError::Tds`] if the backing TDS is internally + /// inconsistent while resolving the queried ridge's lifted stars. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// use delaunay::prelude::topology::validation::{ + /// ManifoldError, RidgeCandidate, RidgeCandidateError, + /// }; + /// + /// # #[derive(Debug, thiserror::Error)] + /// # enum ExampleError { + /// # #[error(transparent)] + /// # Construction(#[from] DelaunayTriangulationConstructionError), + /// # #[error(transparent)] + /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Ridge(#[from] RidgeCandidateError), + /// # #[error(transparent)] + /// # Manifold(#[from] ManifoldError), + /// # } + /// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; + /// let view = ridge.view(dt.tds())?; + /// let links = view.links()?; + /// assert_eq!(links[0].quotient_ridge_candidate(), view.ridge_candidate()); + /// # Ok(()) + /// # } + /// ``` + pub fn links(&self) -> Result, 8>, ManifoldError> { + ridge_links_from_star(self.tds, &self.ridge_candidate, &self.star_simplices) + } +} + +impl fmt::Debug for RidgeView<'_, U, V, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RidgeView") + .field("ridge_candidate", &self.ridge_candidate) + .field("star_simplices", &self.star_simplices) + .field("dimension", &D) + .finish() + } +} + +impl Clone for RidgeView<'_, U, V, D> { + fn clone(&self) -> Self { + Self { + tds: self.tds, + ridge_candidate: self.ridge_candidate.clone(), + vertices: self.vertices.clone(), + star_simplices: self.star_simplices.clone(), + } + } +} + +impl PartialEq for RidgeView<'_, U, V, D> { + fn eq(&self, other: &Self) -> bool { + ptr::eq(self.tds, other.tds) + && self.ridge_candidate == other.ridge_candidate + && self.star_simplices == other.star_simplices + } +} + +impl Eq for RidgeView<'_, U, V, D> {} + +/// Borrowed view of one lifted ridge link. +/// +/// This view is lifetime-bound to the [`Tds`] that produced it, while owning the +/// small derived star for one lifted ridge image. Holding the borrow prevents +/// topology mutation while callers inspect lifted link edges. +/// +/// Callers obtain this view from [`RidgeQuery::links`] or [`RidgeView::links`]. +/// Its lifted vertices and +/// [`LiftedLinkEdge`] values are runtime topology observations, not durable +/// storage identities. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// ManifoldError, RidgeCandidate, RidgeCandidateError, +/// }; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # #[error(transparent)] +/// # Ridge(#[from] RidgeCandidateError), +/// # #[error(transparent)] +/// # Manifold(#[from] ManifoldError), +/// # } +/// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; +/// let view = ridge.view(dt.tds())?; +/// let links = view.links()?; +/// let link = &links[0]; +/// +/// assert_eq!(link.tds().number_of_vertices(), dt.tds().number_of_vertices()); +/// assert_eq!(link.quotient_ridge_candidate(), view.ridge_candidate()); +/// assert_eq!(link.lifted_ridge_vertices().len(), ridge.as_slice().len()); +/// assert_eq!(link.incident_simplices(), view.incident_simplices()); +/// assert!(!link.edges().is_empty()); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +pub struct RidgeLinkView<'tds, U, V, const D: usize> { + tds: &'tds Tds, + quotient_ridge_candidate: RidgeCandidate, + lifted_ridge_vertices: LiftedVertexBuffer, + star_simplices: SmallBuffer, + link_edges: SmallBuffer, +} + +impl<'tds, U, V, const D: usize> RidgeLinkView<'tds, U, V, D> { + /// Returns the borrowed TDS backing this link view. + #[inline] + #[must_use] + pub const fn tds(&self) -> &'tds Tds { + self.tds + } + + /// Returns the quotient-space ridge candidate that produced this link. + #[inline] + pub const fn quotient_ridge_candidate(&self) -> &RidgeCandidate { + &self.quotient_ridge_candidate + } + + /// Returns the lifted ridge vertices for this particular link image. + #[inline] + pub fn lifted_ridge_vertices(&self) -> &[LiftedVertexId] { + &self.lifted_ridge_vertices + } + + /// Returns the D-simplices incident to this lifted ridge image. + #[inline] + #[must_use] + pub fn incident_simplices(&self) -> &[SimplexKey] { + &self.star_simplices + } + + /// Returns lifted edges in this ridge's 1-dimensional link. + #[inline] + pub fn edges(&self) -> &[LiftedLinkEdge] { + &self.link_edges + } +} + +impl fmt::Debug for RidgeLinkView<'_, U, V, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RidgeLinkView") + .field("quotient_ridge_candidate", &self.quotient_ridge_candidate) + .field("lifted_ridge_vertices", &self.lifted_ridge_vertices) + .field("star_simplices", &self.star_simplices) + .field("link_edges", &self.link_edges) + .field("dimension", &D) + .finish() + } +} + +impl Clone for RidgeLinkView<'_, U, V, D> { + fn clone(&self) -> Self { + Self { + tds: self.tds, + quotient_ridge_candidate: self.quotient_ridge_candidate.clone(), + lifted_ridge_vertices: self.lifted_ridge_vertices.clone(), + star_simplices: self.star_simplices.clone(), + link_edges: self.link_edges.clone(), + } + } +} + +impl PartialEq for RidgeLinkView<'_, U, V, D> { + fn eq(&self, other: &Self) -> bool { + ptr::eq(self.tds, other.tds) + && self.quotient_ridge_candidate == other.quotient_ridge_candidate + && self.lifted_ridge_vertices == other.lifted_ridge_vertices + && self.star_simplices == other.star_simplices + && self.link_edges == other.link_edges + } +} + +impl Eq for RidgeLinkView<'_, U, V, D> {} + +/// Resolves a parsed ridge candidate to borrowed live vertices. +/// +/// This is the boundary that turns detached [`RidgeCandidate`] values into +/// live-TDS [`RidgeQuery`] and [`RidgeView`] values. After this succeeds, those +/// views can return borrowed ridge vertices infallibly for the lifetime of the +/// TDS borrow. +fn resolve_ridge_vertices<'tds, U, V, const D: usize>( + tds: &'tds Tds, + ridge_candidate: &RidgeCandidate, + context: &str, +) -> Result, ManifoldError> { + let mut vertices = RidgeVertexRefBuffer::with_capacity(ridge_candidate.as_slice().len()); + for &vertex_key in ridge_candidate.as_slice() { + let vertex = tds + .vertex(vertex_key) + .ok_or_else(|| TdsError::VertexNotFound { + vertex_key, + context: context.to_string(), + })?; + vertices.push(vertex); + } + + Ok(vertices) +} + +/// Groups a quotient ridge star into one view per lifted ridge image. +/// +/// Periodic triangulations can represent several covering-space ridge images +/// with one quotient [`RidgeCandidate`]. This helper preserves those images so +/// [`RidgeQuery::links`] and [`RidgeView::links`] do not collapse distinct +/// toroidal link components. +fn ridge_links_from_star<'tds, U, V, const D: usize>( + tds: &'tds Tds, + ridge_candidate: &RidgeCandidate, + incident_simplices: &[SimplexKey], +) -> Result, 8>, ManifoldError> { + let mut ridge_to_star: FastHashMap = + fast_hash_map_with_capacity(incident_simplices.len().max(1)); + + for &simplex_key in incident_simplices { + let lifted_vertex_images = + simplex_lifted_ridge_vertex_images(tds, simplex_key, ridge_candidate)?; + for lifted_vertices in lifted_vertex_images { + let ridge_key = periodic_simplex_key(&lifted_vertices); + match ridge_to_star.entry(ridge_key) { + Entry::Occupied(_) => {} + Entry::Vacant(entry) => { + let star_simplices = periodic_aware_ridge_star( + tds, + ridge_key, + &lifted_vertices, + ridge_candidate.as_slice(), + )?; + entry.insert(RidgeStar { + ridge_vertices: lifted_vertices, + star_simplices, + }); + } + } + } + } + + let mut links: SmallBuffer, 8> = SmallBuffer::new(); + for star in ridge_to_star.into_values() { + let link_edges = + lifted_link_edges_from_star(tds, &star.ridge_vertices, &star.star_simplices)?; + links.push(RidgeLinkView { + tds, + quotient_ridge_candidate: ridge_candidate.clone(), + lifted_ridge_vertices: star.ridge_vertices, + star_simplices: star.star_simplices, + link_edges, + }); + } + + links.sort_unstable_by(|a, b| { + a.lifted_ridge_vertices + .as_slice() + .cmp(b.lifted_ridge_vertices.as_slice()) + }); + Ok(links) +} + +/// Parses a lifted ridge star into the lifted link edges stored by +/// [`RidgeLinkView`]. +fn lifted_link_edges_from_star( + tds: &Tds, + ridge_vertices: &[LiftedVertexId], + star_simplices: &[SimplexKey], +) -> Result, ManifoldError> { + ridge_link_edges_from_star(tds, ridge_vertices, star_simplices).map(|edges| { + edges + .into_iter() + .map(|(a, b)| LiftedLinkEdge::from_unordered_endpoints(&a, &b)) + .collect() + }) +} + +/// Computes the star of a simplex (a set of vertices) as the set of incident D-simplices. +/// +/// This helper does **not** call `tds.is_valid()`; it performs lightweight checks and +/// returns [`ManifoldError::Tds`] if the underlying TDS is internally inconsistent. +pub(crate) fn simplex_star_simplices( + tds: &Tds, + simplex_vertices: &[VertexKey], +) -> Result, ManifoldError> { + if simplex_vertices.is_empty() { + return Err(TdsError::DimensionMismatch { + expected: 1, + actual: 0, + context: "simplex_star_simplices requires at least one vertex".to_string(), + } + .into()); + } + + for &vk in simplex_vertices { + if !tds.contains_vertex_key(vk) { + return Err(TdsError::VertexNotFound { + vertex_key: vk, + context: "simplex star computation".to_string(), + } + .into()); + } + } + + let candidates = tds.simplex_keys_containing_vertex(simplex_vertices[0]); + let mut star_simplices: SmallBuffer = SmallBuffer::new(); + + for simplex_key in candidates { + let candidate_vertices = tds.simplex_vertices(simplex_key)?; + if simplex_vertices + .iter() + .all(|&sv| candidate_vertices.contains(&sv)) + { + star_simplices.push(simplex_key); + } + } + + Ok(star_simplices) +} + +/// Computes the star of a ridge candidate as the set of incident D-simplices. +/// +/// Prefer [`RidgeCandidate::query`] or [`RidgeCandidate::view`] when a caller +/// also needs borrowed ridge vertices. This helper is the lightweight public +/// entry point for star enumeration. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] when any ridge vertex is missing from the +/// [`Tds`] or a candidate star simplex cannot resolve its vertex keys. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// ManifoldError, RidgeCandidate, RidgeCandidateError, ridge_star_simplices, +/// }; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # #[error(transparent)] +/// # Ridge(#[from] RidgeCandidateError), +/// # #[error(transparent)] +/// # Manifold(#[from] ManifoldError), +/// # } +/// # fn main() -> Result<(), ExampleError> { +/// let vertices = [ +/// delaunay::vertex![0.0, 0.0, 0.0]?, +/// delaunay::vertex![1.0, 0.0, 0.0]?, +/// delaunay::vertex![0.0, 1.0, 0.0]?, +/// delaunay::vertex![0.0, 0.0, 1.0]?, +/// ]; +/// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; +/// +/// // In 3D, a ridge is an edge because it has arity D - 1. +/// let ridge = RidgeCandidate::<3>::try_from_vertices(dt.tds().vertex_keys().take(2))?; +/// let star = ridge_star_simplices(dt.tds(), &ridge)?; +/// assert!(!star.is_empty()); +/// # Ok(()) +/// # } +/// ``` +pub fn ridge_star_simplices( + tds: &Tds, + ridge_candidate: &RidgeCandidate, +) -> Result, ManifoldError> { + simplex_star_simplices(tds, ridge_candidate.as_slice()) +} + +/// Extracts every lifted image of a ridge candidate in one simplex frame. +/// +/// The returned images are normalized so periodic ridge identity is stable +/// across adjacent quotient simplices. Public ridge-link APIs rely on this to +/// keep distinct toroidal covering-space images separate. +/// +/// If a quotient vertex occurs through multiple lifted simplex slots, this +/// helper returns every normalized image combination instead of collapsing to +/// the first matching slot. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] if the simplex vertex count or periodic offset +/// count is inconsistent, or if the simplex does not contain every quotient +/// vertex in the [`RidgeCandidate`]. +fn simplex_lifted_ridge_vertex_images( + tds: &Tds, + simplex_key: SimplexKey, + ridge_vertices: &RidgeCandidate, +) -> Result, ManifoldError> { + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + if simplex_vertices.len() != D + 1 { + return Err(TdsError::DimensionMismatch { + expected: D + 1, + actual: simplex_vertices.len(), + context: format!("simplex {simplex_key:?} vertex count for {D}D (ridge view)"), + } + .into()); + } + + let offsets = tds + .simplex(simplex_key) + .and_then(|simplex| simplex.periodic_vertex_offsets()); + if let Some(simplex_offsets) = offsets + && simplex_offsets.len() != simplex_vertices.len() + { + return Err(TdsError::DimensionMismatch { + expected: simplex_vertices.len(), + actual: simplex_offsets.len(), + context: format!("periodic offset count for {D}D ridge view simplex {simplex_key:?}"), + } + .into()); + } + + let mut lifted_vertex_images: SmallBuffer = SmallBuffer::new(); + lifted_vertex_images.push(LiftedVertexBuffer::new()); + + for &vertex_key in ridge_vertices.as_slice() { + let mut lifted_occurrences = LiftedVertexBuffer::new(); + for (vertex_index, &candidate) in simplex_vertices.iter().enumerate() { + if candidate != vertex_key { + continue; + } + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vertex_key), + |simplex_offsets| { + lifted_vertex_id( + vertex_key, + simplex_offsets[vertex_index].iter().copied().map(i16::from), + ) + }, + ); + lifted_occurrences.push(lifted); + } + + if lifted_occurrences.is_empty() { + return Err(TdsError::InconsistentDataStructure { + message: format!( + "ridge view simplex {simplex_key:?} does not contain ridge vertex {vertex_key:?}" + ), + } + .into()); + } + + let mut next_images: SmallBuffer = SmallBuffer::with_capacity( + lifted_vertex_images + .len() + .saturating_mul(lifted_occurrences.len()), + ); + for image in &lifted_vertex_images { + for lifted in &lifted_occurrences { + let mut next_image = image.clone(); + next_image.push(lifted.clone()); + next_images.push(next_image); + } + } + lifted_vertex_images = next_images; + } + + let mut normalized_images: SmallBuffer = + SmallBuffer::with_capacity(lifted_vertex_images.len()); + for lifted_vertices in lifted_vertex_images { + if lifted_vertices.len() != D.saturating_sub(1) { + return Err(TdsError::DimensionMismatch { + expected: D.saturating_sub(1), + actual: lifted_vertices.len(), + context: format!( + "ridge vertex count for {D}D (ridge view simplex {simplex_key:?})" + ), + } + .into()); + } + + let normalized = normalize_lifted_vertices(&lifted_vertices); + if normalized_images + .iter() + .all(|existing| existing.as_slice() != normalized.as_slice()) + { + normalized_images.push(normalized); + } + } + + Ok(normalized_images) +} + +/// Builds lifted link edges for the star of one lifted ridge image. +/// +/// This implements the construction-time parse boundary for +/// [`RidgeLinkView`]: every lifted occurrence of every star simplex must +/// contribute exactly two complementary link vertices, and those vertices must +/// form a valid lifted edge. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] if the ridge arity is wrong, a star simplex +/// does not contain the requested lifted ridge image, a complementary link does +/// not contain exactly two vertices, or a lifted link edge would be a self-loop. +pub(crate) fn ridge_link_edges_from_star( + tds: &Tds, + ridge_vertices: &[LiftedVertexId], + star_simplices: &[SimplexKey], +) -> Result, ManifoldError> { + if D < 2 { + return Ok(SmallBuffer::new()); + } + + let expected_ridge_vertices = D.saturating_sub(1); + if ridge_vertices.len() != expected_ridge_vertices { + return Err(TdsError::DimensionMismatch { + expected: expected_ridge_vertices, + actual: ridge_vertices.len(), + context: format!("ridge vertex count for {D}D (link edges)"), + } + .into()); + } + + let mut link_edges: SmallBuffer<(LiftedVertexId, LiftedVertexId), 8> = + SmallBuffer::with_capacity(star_simplices.len()); + let mut link_vertices: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(2); + + for &simplex_key in star_simplices { + let simplex_vertex_images = + normalized_simplex_vertices_for_lifted_target(tds, simplex_key, ridge_vertices)?; + if simplex_vertex_images.is_empty() { + return Err(TdsError::InconsistentDataStructure { + message: format!( + "ridge star simplex {simplex_key:?} does not contain normalized ridge vertices \ + {ridge_vertices:?}" + ), + } + .into()); + } + + for simplex_vertices in simplex_vertex_images { + link_vertices.clear(); + for lifted in simplex_vertices { + if !ridge_vertices.contains(&lifted) { + link_vertices.push(lifted); + } + } + + if link_vertices.len() != 2 { + return Err(TdsError::DimensionMismatch { + expected: 2, + actual: link_vertices.len(), + context: format!( + "ridge link vertex count for {D}D (simplex_key={simplex_key:?})" + ), + } + .into()); + } + + if link_vertices[0] == link_vertices[1] { + return Err(TdsError::InconsistentDataStructure { + message: format!( + "Ridge link edge is a self-loop: link vertex {vk:?} repeated (simplex_key={simplex_key:?})", + vk = &link_vertices[0], + ), + } + .into()); + } + + link_edges.push((link_vertices[0].clone(), link_vertices[1].clone())); + } + } + + Ok(link_edges) +} + +/// Lifted ridge identity paired with the simplices incident to that image. +/// +/// Validators and ridge views use this derived value to avoid collapsing +/// distinct toroidal covering-space stars that share the same quotient +/// [`VertexKey`] set. +#[derive(Clone, Debug)] +pub(crate) struct RidgeStar { + pub(crate) ridge_vertices: LiftedVertexBuffer, + pub(crate) star_simplices: SmallBuffer, +} + +/// Builds a complete ridge-to-star incidence map for one TDS. +/// +/// This is the shared topology-validation path for ridge multiplicity and link +/// checks. It visits every simplex once and enumerates its ridges, preserving +/// lifted toroidal identity in the map key. +pub(crate) fn build_ridge_star_map( + tds: &Tds, +) -> Result, ManifoldError> { + let simplex_count = tds.number_of_simplices(); + if D < 2 || simplex_count == 0 { + return Ok(FastHashMap::default()); + } + + let ridges_per_simplex = (D + 1).saturating_mul(D) / 2; + let estimated_unique_ridges = simplex_count + .saturating_mul(ridges_per_simplex) + .saturating_div(2) + .max(1); + + let mut ridge_to_star: FastHashMap = + fast_hash_map_with_capacity(estimated_unique_ridges); + let mut ridge_vertices: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); + + for (simplex_key, simplex) in tds.simplices() { + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + let offsets = simplex.periodic_vertex_offsets(); + + if simplex_vertices.len() != D + 1 { + return Err(TdsError::DimensionMismatch { + expected: D + 1, + actual: simplex_vertices.len(), + context: format!("simplex {simplex_key:?} vertex count for {D}D"), + } + .into()); + } + if let Some(simplex_offsets) = offsets + && simplex_offsets.len() != simplex_vertices.len() + { + return Err(TdsError::DimensionMismatch { + expected: simplex_vertices.len(), + actual: simplex_offsets.len(), + context: format!( + "periodic offset count for {D}D simplex {simplex_key:?} (ridge map)" + ), + } + .into()); + } + + for omit_a in 0..simplex_vertices.len() { + for omit_b in (omit_a + 1)..simplex_vertices.len() { + ridge_vertices.clear(); + for (i, &vk) in simplex_vertices.iter().enumerate() { + if i == omit_a || i == omit_b { + continue; + } + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vk), + |offs| lifted_vertex_id(vk, offs[i].iter().copied().map(i16::from)), + ); + ridge_vertices.push(lifted); + } + + if ridge_vertices.len() != D.saturating_sub(1) { + return Err(TdsError::DimensionMismatch { + expected: D.saturating_sub(1), + actual: ridge_vertices.len(), + context: format!("ridge vertex count for {D}D (simplex_key={simplex_key:?}, omit_a={omit_a}, omit_b={omit_b})"), + } + .into()); + } + + let normalized_ridge_vertices = normalize_lifted_vertices(&ridge_vertices); + let ridge_key = periodic_simplex_key(&normalized_ridge_vertices); + let star = ridge_to_star.entry(ridge_key).or_insert_with(|| RidgeStar { + ridge_vertices: normalized_ridge_vertices, + star_simplices: SmallBuffer::new(), + }); + star.star_simplices.push(simplex_key); + } + } + } + + Ok(ridge_to_star) +} + +/// Builds ridge stars whose seed ridges appear in the supplied simplices. +/// +/// Repair and localized validation use this to avoid a full incidence rebuild. +/// Each discovered ridge is expanded through [`periodic_aware_ridge_star`] so +/// the returned stars are complete for the corresponding lifted ridge image. +pub(crate) fn build_ridge_star_map_for_simplices( + tds: &Tds, + simplices: impl IntoIterator, +) -> Result, ManifoldError> { + if D < 2 { + return Ok(FastHashMap::default()); + } + + let simplices = simplices.into_iter(); + let (lower_bound, upper_bound) = simplices.size_hint(); + let estimated_simplex_count = upper_bound.unwrap_or(lower_bound); + let ridges_per_simplex = (D + 1).saturating_mul(D) / 2; + let estimated_unique_ridges = estimated_simplex_count + .saturating_mul(ridges_per_simplex) + .max(1); + + let mut ridge_to_vertices: FastHashMap = + fast_hash_map_with_capacity(estimated_unique_ridges); + let mut ridge_vertices_bare: VertexKeyBuffer = + VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + let mut ridge_vertices_lifted: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); + + for simplex_key in simplices { + if !tds.contains_simplex(simplex_key) { + continue; + } + + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + let offsets = tds + .simplex(simplex_key) + .and_then(|c| c.periodic_vertex_offsets()); + + if simplex_vertices.len() != D + 1 { + return Err(TdsError::DimensionMismatch { + expected: D + 1, + actual: simplex_vertices.len(), + context: format!("simplex {simplex_key:?} vertex count for {D}D (local ridge map)"), + } + .into()); + } + if let Some(simplex_offsets) = offsets + && simplex_offsets.len() != simplex_vertices.len() + { + return Err(TdsError::DimensionMismatch { + expected: simplex_vertices.len(), + actual: simplex_offsets.len(), + context: format!( + "periodic offset count for {D}D simplex {simplex_key:?} (local ridge map)" + ), + } + .into()); + } + + for omit_a in 0..simplex_vertices.len() { + for omit_b in (omit_a + 1)..simplex_vertices.len() { + ridge_vertices_bare.clear(); + ridge_vertices_lifted.clear(); + for (i, &vk) in simplex_vertices.iter().enumerate() { + if i == omit_a || i == omit_b { + continue; + } + ridge_vertices_bare.push(vk); + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vk), + |offs| lifted_vertex_id(vk, offs[i].iter().copied().map(i16::from)), + ); + ridge_vertices_lifted.push(lifted); + } + + if ridge_vertices_bare.len() != D.saturating_sub(1) { + return Err(TdsError::DimensionMismatch { + expected: D.saturating_sub(1), + actual: ridge_vertices_bare.len(), + context: format!("ridge vertex count for {D}D (simplex_key={simplex_key:?}, omit_a={omit_a}, omit_b={omit_b})"), + } + .into()); + } + + let normalized_ridge_vertices = normalize_lifted_vertices(&ridge_vertices_lifted); + let ridge_key = periodic_simplex_key(&normalized_ridge_vertices); + ridge_to_vertices + .entry(ridge_key) + .or_insert_with(|| (normalized_ridge_vertices, ridge_vertices_bare.clone())); + } + } + } + + let mut ridge_to_star: FastHashMap = + fast_hash_map_with_capacity(ridge_to_vertices.len().max(1)); + + for (ridge_key, (lifted_vertices, bare_vertices)) in ridge_to_vertices { + let star_simplices = + periodic_aware_ridge_star(tds, ridge_key, &lifted_vertices, &bare_vertices)?; + ridge_to_star.insert( + ridge_key, + RidgeStar { + ridge_vertices: lifted_vertices, + star_simplices, + }, + ); + } + + Ok(ridge_to_star) +} + +/// Expresses every matching simplex image in the frame of a lifted target ridge. +/// +/// Returns an empty buffer when the simplex is not incident to the target ridge +/// image. Otherwise, each returned buffer contains the simplex vertices +/// normalized against one actual matching lifted anchor occurrence. Public +/// ridge-link construction uses this to preserve periodic self-identification +/// links instead of anchoring every match to the first quotient key. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] if the simplex has malformed periodic offset +/// metadata. +pub(crate) fn normalized_simplex_vertices_for_lifted_target( + tds: &Tds, + simplex_key: SimplexKey, + target_vertices: &[LiftedVertexId], +) -> Result, ManifoldError> { + let simplex_vertices = tds.simplex_vertices(simplex_key)?; + let offsets = tds + .simplex(simplex_key) + .and_then(|simplex| simplex.periodic_vertex_offsets()); + let mut matching_images: SmallBuffer = SmallBuffer::new(); + + let Some(offsets) = offsets else { + let vertices: LiftedVertexBuffer = simplex_vertices + .iter() + .copied() + .map(LiftedVertexId::base) + .collect(); + if target_vertices + .iter() + .all(|target| vertices.contains(target)) + { + matching_images.push(vertices); + } + return Ok(matching_images); + }; + if offsets.len() != simplex_vertices.len() { + return Err(TdsError::DimensionMismatch { + expected: simplex_vertices.len(), + actual: offsets.len(), + context: format!( + "periodic offset count for {D}D simplex {simplex_key:?} \ + (lifted target normalization)" + ), + } + .into()); + } + + let Some(anchor) = target_vertices.first() else { + matching_images.push(LiftedVertexBuffer::new()); + return Ok(matching_images); + }; + + for (anchor_index, &anchor_key) in simplex_vertices.iter().enumerate() { + if anchor_key != anchor.vertex_key { + continue; + } + + let anchor_offset = offsets[anchor_index]; + let mut normalized = LiftedVertexBuffer::with_capacity(simplex_vertices.len()); + for (idx, &vertex_key) in simplex_vertices.iter().enumerate() { + let mut relative_offset: SmallBuffer = SmallBuffer::with_capacity(D); + for axis in 0..D { + let target_anchor_component = anchor.offset().get(axis).copied().unwrap_or(0); + relative_offset.push( + i16::from(offsets[idx][axis]) - i16::from(anchor_offset[axis]) + + target_anchor_component, + ); + } + normalized.push(lifted_vertex_id(vertex_key, relative_offset)); + } + if target_vertices + .iter() + .all(|target| normalized.contains(target)) + && matching_images + .iter() + .all(|existing| existing.as_slice() != normalized.as_slice()) + { + matching_images.push(normalized); + } + } + + Ok(matching_images) +} + +/// Filters a quotient ridge star down to one lifted toroidal ridge image. +/// +/// The bare vertex star supplies candidate simplices, while `ridge_key` and +/// `lifted_vertices` identify the covering-space image that public +/// [`RidgeLinkView`] values must preserve. +/// +/// # Errors +/// +/// Returns [`ManifoldError::Tds`] if the quotient star cannot be queried, if +/// candidate simplices have malformed periodic offset metadata, or if no +/// candidate simplex represents the requested lifted ridge image. +pub(crate) fn periodic_aware_ridge_star( + tds: &Tds, + ridge_key: u64, + lifted_vertices: &[LiftedVertexId], + bare_vertices: &[VertexKey], +) -> Result, ManifoldError> { + let all_star_simplices = simplex_star_simplices(tds, bare_vertices)?; + let mut star_simplices: SmallBuffer = + SmallBuffer::with_capacity(all_star_simplices.len()); + + for &simplex_key in &all_star_simplices { + if !normalized_simplex_vertices_for_lifted_target(tds, simplex_key, lifted_vertices)? + .is_empty() + { + star_simplices.push(simplex_key); + } + } + + if star_simplices.is_empty() { + return Err(TdsError::InconsistentDataStructure { + message: format!( + "periodic offset filtering produced empty star for ridge \ + {ridge_key:016x}: {count} candidate simplices were all excluded \ + (lifted ridge vertices: {lifted:?})", + count = all_star_simplices.len(), + lifted = lifted_vertices, + ), + } + .into()); + } + + Ok(star_simplices) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::core::{ + collections::{FastHashSet, SimplexKeySet}, + facet::facet_key_from_vertices, + simplex::Simplex, + }; + use slotmap::{Key, KeyData}; + use std::iter; + + type DuplicateLiftedAnchorFixture3d = + (Tds<(), (), 3>, SimplexKey, VertexKey, VertexKey, VertexKey); + + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { + Vertex::try_new(coords).unwrap() + } + + fn simplex(vertices: &[VertexKey]) -> LiftedVertexBuffer { + let mut simplex: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(vertices.len()); + simplex.extend(vertices.iter().copied().map(LiftedVertexId::base)); + simplex + } + + fn build_duplicate_lifted_anchor_fixture_3d() -> DuplicateLiftedAnchorFixture3d { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + { + let simplex = tds + .simplex_mut(simplex_key) + .expect("simplex key should be valid in test"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(v0); + simplex.push_vertex_key(v0); + simplex.push_vertex_key(v1); + simplex.push_vertex_key(v2); + simplex + .set_periodic_vertex_offsets(vec![[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 0, 0]]) + .unwrap(); + } + + (tds, simplex_key, v0, v1, v2) + } + + fn build_two_tetrahedra_sharing_facet_tds_3d() + -> (Tds<(), (), 3>, [VertexKey; 5], [SimplexKey; 2]) { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + let v4 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, -1.0])) + .unwrap(); + + let c1 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + let c2 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v4], None).unwrap(), + ) + .unwrap(); + + (tds, [v0, v1, v2, v3, v4], [c1, c2]) + } + + fn build_wedge_two_spheres_share_vertex_tds_2d() + -> (Tds<(), (), 2>, VertexKey, SimplexKey, SimplexKey) { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 1.0])) + .unwrap(); + + let c012 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + ) + .unwrap(); + let _c013 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v3], None).unwrap(), + ) + .unwrap(); + let _c023 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v2, v3], None).unwrap(), + ) + .unwrap(); + let c123 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v1, v2, v3], None).unwrap(), + ) + .unwrap(); + + let v4 = tds + .insert_vertex_with_mapping(test_vertex([10.0, 10.0])) + .unwrap(); + let v5 = tds + .insert_vertex_with_mapping(test_vertex([11.0, 10.0])) + .unwrap(); + let v6 = tds + .insert_vertex_with_mapping(test_vertex([10.0, 11.0])) + .unwrap(); + + let _c045 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v4, v5], None).unwrap(), + ) + .unwrap(); + let _c046 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v4, v6], None).unwrap(), + ) + .unwrap(); + let _c056 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v5, v6], None).unwrap(), + ) + .unwrap(); + let _c456 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v4, v5, v6], None).unwrap(), + ) + .unwrap(); + + (tds, v0, c012, c123) + } + + #[test] + fn test_simplex_star_simplices_errors_on_empty_simplex() { + let tds: Tds<(), (), 2> = Tds::empty(); + + match simplex_star_simplices(&tds, &[]) { + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 1, + actual: 0, + .. + })) => {} + other => panic!("Expected DimensionMismatch for empty simplex, got {other:?}"), + } + } + + #[test] + fn test_simplex_star_simplices_returns_empty_for_isolated_vertex() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + + let star = simplex_star_simplices(&tds, &[v0]).unwrap(); + assert!(star.is_empty()); + + let ridge_candidate = RidgeCandidate::<2>::try_from_vertices([v0]).unwrap(); + let ridge_query = ridge_candidate.query(&tds).unwrap(); + assert!(ridge_query.incident_simplices().is_empty()); + assert!(ridge_query.links().unwrap().is_empty()); + match ridge_candidate.view(&tds) { + Err(ManifoldError::RidgeNotFound { ridge_vertices }) => { + assert_eq!(ridge_vertices.as_slice(), &[v0]); + } + other => panic!("Expected RidgeNotFound for isolated live ridge, got {other:?}"), + } + } + + #[test] + fn test_ridge_candidate_rejects_d_lt_2() { + match RidgeCandidate::<1>::try_from_vertices([VertexKey::from(KeyData::from_ffi(0))]) { + Err(RidgeCandidateError::UnsupportedDimension { dimension }) => { + assert_eq!(dimension, 1); + } + other => panic!("Expected UnsupportedDimension for D<2, got {other:?}"), + } + } + + #[test] + fn test_ridge_link_edges_from_star_noop_for_d_lt_2() { + let tds: Tds<(), (), 1> = Tds::empty(); + + let edges = ridge_link_edges_from_star(&tds, &[], &[]).unwrap(); + assert!(edges.is_empty()); + } + + #[test] + fn test_ridge_candidate_rejects_too_few_vertices_in_3d() { + let v0 = VertexKey::from(KeyData::from_ffi(1)); + + match RidgeCandidate::<3>::try_from_vertices([v0]) { + Err(RidgeCandidateError::WrongArity { + dimension: 3, + expected: 2, + actual: 1, + }) => {} + other => panic!("Expected WrongArity(2, 1) for wrong ridge size, got {other:?}"), + } + } + + #[test] + fn test_ridge_link_edges_from_star_errors_on_wrong_vertex_count_in_3d() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + + match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[simplex_key]) { + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 2, + actual: 1, + .. + })) => {} + other => panic!("Expected DimensionMismatch(2, 1) for wrong ridge size, got {other:?}"), + } + } + + #[test] + fn test_normalized_simplex_vertices_for_lifted_target_empty_for_missing_vertex() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + let missing_from_simplex = tds + .insert_vertex_with_mapping(test_vertex([2.0, 2.0, 2.0])) + .unwrap(); + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + let target: LiftedVertexBuffer = [ + LiftedVertexId::base(v0), + LiftedVertexId::base(missing_from_simplex), + ] + .into_iter() + .collect(); + + assert!( + normalized_simplex_vertices_for_lifted_target(&tds, simplex_key, &target) + .unwrap() + .is_empty() + ); + + tds.simplex_mut(simplex_key) + .unwrap() + .set_periodic_vertex_offsets(vec![[0, 0, 0]; 4]) + .unwrap(); + + assert!( + normalized_simplex_vertices_for_lifted_target(&tds, simplex_key, &target) + .unwrap() + .is_empty() + ); + } + + #[test] + fn test_normalized_simplex_vertices_for_lifted_target_preserves_target_anchor_offset() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + + let mut simplex = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); + simplex + .set_periodic_vertex_offsets(vec![[1, 0], [2, 0], [1, 1]]) + .unwrap(); + let simplex_key = tds.insert_simplex_with_mapping(simplex).unwrap(); + + let target_anchor = lifted_vertex_id(v0, [3_i16, 0]); + let target: LiftedVertexBuffer = iter::once(target_anchor.clone()).collect(); + + let images = + normalized_simplex_vertices_for_lifted_target(&tds, simplex_key, &target).unwrap(); + + assert_eq!(images.len(), 1); + assert!(images[0].contains(&target_anchor)); + assert!(images[0].contains(&lifted_vertex_id(v1, [4_i16, 0]))); + assert!(images[0].contains(&lifted_vertex_id(v2, [3_i16, 1]))); + } + + #[test] + fn test_simplex_lifted_ridge_vertex_images_enumerates_duplicate_lifted_slots() { + let (tds, simplex_key, v0, v1, _v2) = build_duplicate_lifted_anchor_fixture_3d(); + let ridge_candidate = RidgeCandidate::<3>::try_from_vertices([v0, v1]).unwrap(); + + let images = + simplex_lifted_ridge_vertex_images(&tds, simplex_key, &ridge_candidate).unwrap(); + + assert_eq!( + images.len(), + 2, + "duplicate quotient vertex slots with different offsets should produce two lifted ridge images" + ); + assert!( + images.iter().any(|image| { + image.contains(&LiftedVertexId::base(v0)) + && image.contains(&LiftedVertexId::base(v1)) + }), + "one lifted ridge image should use the base occurrence" + ); + assert!( + images.iter().any(|image| { + image.contains(&LiftedVertexId::base(v0)) + && image.contains(&lifted_vertex_id(v1, [-1_i16, 0, 0])) + }), + "one lifted ridge image should be normalized against the translated occurrence" + ); + } + + #[test] + fn test_normalized_simplex_vertices_for_lifted_target_enumerates_duplicate_anchor_slots() { + let (tds, simplex_key, v0, v1, _v2) = build_duplicate_lifted_anchor_fixture_3d(); + let target: LiftedVertexBuffer = iter::once(LiftedVertexId::base(v0)).collect(); + + let images = + normalized_simplex_vertices_for_lifted_target(&tds, simplex_key, &target).unwrap(); + + assert_eq!( + images.len(), + 2, + "normalization should consider both lifted occurrences of the target anchor" + ); + assert!( + images + .iter() + .any(|image| image.contains(&LiftedVertexId::base(v1))), + "one simplex image should be anchored to the base v0 occurrence" + ); + assert!( + images + .iter() + .any(|image| image.contains(&lifted_vertex_id(v1, [-1_i16, 0, 0]))), + "one simplex image should be anchored to the translated v0 occurrence" + ); + } + + #[test] + fn test_ridge_star_simplices_returns_incident_simplices_for_vertex_ridge_in_2d() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 1.0])) + .unwrap(); + + let c012 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + ) + .unwrap(); + let c013 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v3], None).unwrap(), + ) + .unwrap(); + let c023 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v2, v3], None).unwrap(), + ) + .unwrap(); + let _c123 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v1, v2, v3], None).unwrap(), + ) + .unwrap(); + + let ridge_candidate = RidgeCandidate::<2>::try_from_vertices([v0]).unwrap(); + let star = ridge_star_simplices(&tds, &ridge_candidate).unwrap(); + let star_set: SimplexKeySet = star.iter().copied().collect(); + + let expected: SimplexKeySet = [c012, c013, c023].into_iter().collect(); + assert_eq!(star_set, expected); + } + + #[test] + fn test_ridge_star_simplices_returns_full_edge_star_in_3d() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + let v4 = tds + .insert_vertex_with_mapping(test_vertex([0.0, -1.0, 0.0])) + .unwrap(); + + let c0123 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + let c0134 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v3, v4], None).unwrap(), + ) + .unwrap(); + let c0142 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v4, v2], None).unwrap(), + ) + .unwrap(); + + let ridge_candidate = RidgeCandidate::<3>::try_from_vertices([v0, v1]).unwrap(); + let ridge_query = ridge_candidate.query(&tds).unwrap(); + let star = ridge_star_simplices(&tds, &ridge_candidate).unwrap(); + let query_star = ridge_query.incident_simplices(); + let star_set: SimplexKeySet = star.iter().copied().collect(); + let query_star_set: SimplexKeySet = query_star.iter().copied().collect(); + let ridge_view = ridge_candidate.view(&tds).unwrap(); + let view_star_set: SimplexKeySet = + ridge_view.incident_simplices().iter().copied().collect(); + let query_vertex_uuids: SmallBuffer<_, 8> = ridge_query + .vertices() + .iter() + .map(|vertex| vertex.uuid()) + .collect(); + let ridge_vertex_uuids: SmallBuffer<_, 8> = ridge_view + .vertices() + .iter() + .map(|vertex| vertex.uuid()) + .collect(); + let ridge_links = ridge_view.links().unwrap(); + let ridge_link = ridge_links + .first() + .expect("non-periodic ridge should have one lifted link"); + let link_edge_set: FastHashSet<_> = ridge_link + .edges() + .iter() + .map(LiftedLinkEdge::vertex_keys) + .collect(); + let edge_pair = |a: VertexKey, b: VertexKey| { + if a.data().as_ffi() <= b.data().as_ffi() { + (a, b) + } else { + (b, a) + } + }; + + let expected: SimplexKeySet = [c0123, c0134, c0142].into_iter().collect(); + let tds_ptr = ptr::from_ref(&tds); + assert!(ptr::eq(ptr::from_ref(ridge_query.tds()), tds_ptr)); + assert_eq!(ridge_query.ridge_candidate(), &ridge_candidate); + assert_eq!(ridge_query.vertex_keys(), ridge_candidate.as_slice()); + assert_eq!(star_set, expected); + assert_eq!(query_star_set, expected); + assert_eq!(view_star_set, expected); + assert!(ptr::eq(ptr::from_ref(ridge_view.tds()), tds_ptr)); + assert_eq!(ridge_view.ridge_candidate(), &ridge_candidate); + assert_eq!(ridge_view.vertex_keys(), ridge_candidate.as_slice()); + assert_eq!(query_vertex_uuids, ridge_vertex_uuids); + assert_eq!(ridge_vertex_uuids.len(), 2); + assert_eq!(ridge_links.len(), 1); + assert!(ptr::eq(ptr::from_ref(ridge_link.tds()), tds_ptr)); + assert_eq!(ridge_link.quotient_ridge_candidate(), &ridge_candidate); + assert_eq!(ridge_link.lifted_ridge_vertices().len(), 2); + assert_eq!( + link_edge_set, + [edge_pair(v2, v3), edge_pair(v3, v4), edge_pair(v4, v2)] + .into_iter() + .collect() + ); + } + + #[test] + fn test_ridge_star_simplices_errors_on_missing_vertex_key() { + let tds: Tds<(), (), 2> = Tds::empty(); + let missing = VertexKey::from(KeyData::from_ffi(u64::MAX)); + + let ridge_candidate = RidgeCandidate::<2>::try_from_vertices([missing]).unwrap(); + match ridge_candidate.view(&tds) { + Err(ManifoldError::Tds(TdsError::VertexNotFound { + vertex_key, + context, + })) => { + assert_eq!(vertex_key, missing); + assert!(context.contains("ridge query")); + } + other => panic!("Expected ridge view VertexNotFound error, got {other:?}"), + } + match ridge_star_simplices(&tds, &ridge_candidate) { + Err(ManifoldError::Tds(TdsError::VertexNotFound { vertex_key, .. })) => { + assert_eq!(vertex_key, missing); + } + other => panic!("Expected VertexNotFound error, got {other:?}"), + } + } + + #[test] + fn test_ridge_link_edges_from_star_rejects_self_loop_edge() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + ) + .unwrap(); + + { + let simplex = tds + .simplex_mut(simplex_key) + .expect("simplex key should be valid in test"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(v0); + simplex.push_vertex_key(v1); + simplex.push_vertex_key(v1); + } + + match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[simplex_key]) { + Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { message })) => { + assert!( + message.contains("self-loop"), + "Unexpected message: {message}" + ); + } + other => panic!("Expected self-loop edge error, got {other:?}"), + } + } + + #[test] + fn test_build_ridge_star_map_empty_returns_empty() { + let tds: Tds<(), (), 3> = Tds::empty(); + + let map = build_ridge_star_map(&tds).unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn test_build_ridge_star_map_noop_for_d_lt_2() { + let mut tds: Tds<(), (), 1> = Tds::empty(); + let v0 = tds.insert_vertex_with_mapping(test_vertex([0.0])).unwrap(); + let v1 = tds.insert_vertex_with_mapping(test_vertex([1.0])).unwrap(); + tds.insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v0, v1], None).unwrap()) + .unwrap(); + + let map = build_ridge_star_map(&tds).unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn test_build_ridge_star_map_errors_on_corrupted_simplex_vertex_count() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + ) + .unwrap(); + + { + let simplex = tds + .simplex_mut(simplex_key) + .expect("simplex key should be valid in test"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(v0); + simplex.push_vertex_key(v1); + } + + match build_ridge_star_map(&tds) { + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 3, + actual: 2, + .. + })) => {} + other => { + panic!("Expected DimensionMismatch(3, 2) for corrupted simplex, got {other:?}") + } + } + } + + #[test] + fn test_ridge_view_links_ignore_disjoint_corrupted_simplex() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + let mut insert_vertex = + |coords| tds.insert_vertex_with_mapping(test_vertex(coords)).unwrap(); + + let v0 = insert_vertex([0.0, 0.0, 0.0]); + let v1 = insert_vertex([1.0, 0.0, 0.0]); + let v2 = insert_vertex([0.0, 1.0, 0.0]); + let v3 = insert_vertex([0.0, 0.0, 1.0]); + let u0 = insert_vertex([10.0, 0.0, 0.0]); + let u1 = insert_vertex([11.0, 0.0, 0.0]); + let u2 = insert_vertex([10.0, 1.0, 0.0]); + let u3 = insert_vertex([10.0, 0.0, 1.0]); + + let target_simplex = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + let corrupted_simplex = tds + .insert_simplex_bypassing_topology_checks_for_test( + Simplex::try_new_with_data(vec![u0, u1, u2, u3], None).unwrap(), + ) + .unwrap(); + + { + let simplex = tds + .simplex_mut(corrupted_simplex) + .expect("simplex key should be valid in test"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(u0); + simplex.push_vertex_key(u1); + simplex.push_vertex_key(u2); + } + + match build_ridge_star_map(&tds) { + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 4, + actual: 3, + .. + })) => {} + other => panic!("Expected whole-TDS ridge map to fail, got {other:?}"), + } + + let ridge_candidate = RidgeCandidate::<3>::try_from_vertices([v0, v1]).unwrap(); + let ridge_links = ridge_candidate.view(&tds).unwrap().links().unwrap(); + + assert_eq!(ridge_links.len(), 1); + assert_eq!(ridge_links[0].incident_simplices(), &[target_simplex]); + assert_eq!(ridge_links[0].edges().len(), 1); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_noop_for_d_lt_2() { + let tds: Tds<(), (), 1> = Tds::empty(); + let simplex_key = SimplexKey::from(KeyData::from_ffi(0)); + + let map = build_ridge_star_map_for_simplices(&tds, [simplex_key]).unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_empty_returns_empty() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + + let _ = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + + let map = build_ridge_star_map_for_simplices(&tds, iter::empty::()).unwrap(); + assert!(map.is_empty()); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_3d_single_simplex_includes_only_its_ridges_and_full_stars() + { + let (tds, [v0, v1, v2, v3, v4], [c1, c2]) = build_two_tetrahedra_sharing_facet_tds_3d(); + + let missing = SimplexKey::from(KeyData::from_ffi(u64::MAX)); + + let map = build_ridge_star_map_for_simplices(&tds, [c1, missing]).unwrap(); + + assert_eq!(map.len(), 6); + + let star_set_for_edge = |a: VertexKey, b: VertexKey| -> SimplexKeySet { + let key = facet_key_from_vertices(&[a, b]); + let star = map + .get(&key) + .expect("expected ridge key in local ridge-star map"); + + assert_eq!(periodic_simplex_key(&star.ridge_vertices), key); + assert_eq!(star.ridge_vertices.len(), 2); + + star.star_simplices.iter().copied().collect() + }; + + let shared_star: SimplexKeySet = [c1, c2].into_iter().collect(); + let c1_only: SimplexKeySet = iter::once(c1).collect(); + + assert_eq!(star_set_for_edge(v0, v1), shared_star); + assert_eq!(star_set_for_edge(v0, v2), shared_star); + assert_eq!(star_set_for_edge(v1, v2), shared_star); + + assert_eq!(star_set_for_edge(v0, v3), c1_only); + assert_eq!(star_set_for_edge(v1, v3), c1_only); + assert_eq!(star_set_for_edge(v2, v3), c1_only); + + assert!(!map.contains_key(&facet_key_from_vertices(&[v0, v4]))); + assert!(!map.contains_key(&facet_key_from_vertices(&[v1, v4]))); + assert!(!map.contains_key(&facet_key_from_vertices(&[v2, v4]))); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_3d_two_simplices_includes_union_of_ridges() { + let (tds, [v0, v1, v2, v3, v4], [c1, c2]) = build_two_tetrahedra_sharing_facet_tds_3d(); + + let map = build_ridge_star_map_for_simplices(&tds, [c1, c2]).unwrap(); + + assert_eq!(map.len(), 9); + + let star_size_for_edge = |a: VertexKey, b: VertexKey| -> usize { + let key = facet_key_from_vertices(&[a, b]); + map.get(&key) + .expect("expected ridge key in local ridge-star map") + .star_simplices + .len() + }; + + assert_eq!(star_size_for_edge(v0, v1), 2); + assert_eq!(star_size_for_edge(v0, v2), 2); + assert_eq!(star_size_for_edge(v1, v2), 2); + + assert_eq!(star_size_for_edge(v0, v3), 1); + assert_eq!(star_size_for_edge(v1, v3), 1); + assert_eq!(star_size_for_edge(v2, v3), 1); + + assert_eq!(star_size_for_edge(v0, v4), 1); + assert_eq!(star_size_for_edge(v1, v4), 1); + assert_eq!(star_size_for_edge(v2, v4), 1); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_2d_includes_full_star_for_shared_vertex() { + let (tds, v0, incident, _nonincident) = build_wedge_two_spheres_share_vertex_tds_2d(); + + let map = build_ridge_star_map_for_simplices(&tds, [incident]).unwrap(); + assert_eq!(map.len(), 3); + + let ridge_key = facet_key_from_vertices(&[v0]); + let star = map + .get(&ridge_key) + .expect("expected ridge key for shared vertex"); + assert_eq!(star.star_simplices.len(), 6); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_errors_on_corrupted_simplex_vertex_count() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) + .unwrap(); + + let simplex_key = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(), + ) + .unwrap(); + + { + let simplex = tds + .simplex_mut(simplex_key) + .expect("simplex key should be valid in test"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(v0); + simplex.push_vertex_key(v1); + } + + match build_ridge_star_map_for_simplices(&tds, [simplex_key]) { + Err(ManifoldError::Tds(TdsError::DimensionMismatch { + expected: 3, + actual: 2, + .. + })) => {} + other => { + panic!("Expected DimensionMismatch(3, 2) for corrupted simplex, got {other:?}") + } + } + } + + #[test] + fn test_simplex_star_simplices_rejects_missing_vertex() { + let tds: Tds<(), (), 2> = Tds::empty(); + let stale_key = VertexKey::from(KeyData::from_ffi(0xDEAD)); + match simplex_star_simplices(&tds, &[stale_key]) { + Err(ManifoldError::Tds(TdsError::VertexNotFound { + vertex_key, + ref context, + })) => { + assert_eq!(vertex_key, stale_key); + assert!(context.contains("simplex star")); + } + other => panic!("Expected VertexNotFound, got {other:?}"), + } + } + + #[test] + fn test_ridge_candidate_rejects_too_many_vertices_in_3d() { + let v0 = VertexKey::from(KeyData::from_ffi(1)); + let v1 = VertexKey::from(KeyData::from_ffi(2)); + let v2 = VertexKey::from(KeyData::from_ffi(3)); + match RidgeCandidate::<3>::try_from_vertices([v0, v1, v2]) { + Err(RidgeCandidateError::WrongArity { + expected, actual, .. + }) => { + assert_eq!(expected, 2); + assert_eq!(actual, 3); + } + other => panic!("Expected WrongArity, got {other:?}"), + } + } + + #[test] + fn test_ridge_candidate_rejects_duplicate_vertices() { + let v0 = VertexKey::from(KeyData::from_ffi(1)); + + match RidgeCandidate::<3>::try_from_vertices([v0, v0]) { + Err(RidgeCandidateError::DuplicateVertex { vertex_key }) => { + assert_eq!(vertex_key, v0); + } + other => panic!("Expected DuplicateVertex, got {other:?}"), + } + } + + #[test] + fn test_ridge_candidate_canonicalizes_permuted_vertices() { + let v0 = VertexKey::from(KeyData::from_ffi(1)); + let v1 = VertexKey::from(KeyData::from_ffi(2)); + + let forward = RidgeCandidate::<3>::try_from_vertices([v0, v1]).unwrap(); + let reversed = RidgeCandidate::<3>::try_from_vertices([v1, v0]).unwrap(); + + assert_eq!(forward, reversed); + assert_eq!(reversed.as_slice(), &[v0, v1]); + } + + #[test] + fn test_ridge_query_view_and_link_trait_behavior() { + let (tds, [v0, v1, _v2, _v3, _v4], _) = build_two_tetrahedra_sharing_facet_tds_3d(); + + let ridge_candidate = RidgeCandidate::<3>::try_from_vertices([v1, v0]).unwrap(); + assert_eq!(ridge_candidate.as_slice(), &[v0, v1]); + + let query = ridge_candidate.query(&tds).unwrap(); + let query_clone = query.clone(); + assert_eq!(query, query_clone); + assert!(format!("{query:?}").contains("RidgeQuery")); + + let view = ridge_candidate.view(&tds).unwrap(); + let view_clone = view.clone(); + assert_eq!(view, view_clone); + assert!(format!("{view:?}").contains("RidgeView")); + + let links = view.links().unwrap(); + assert!(!links.is_empty()); + + let link = links[0].clone(); + assert_eq!(link, link.clone()); + assert_eq!(link.quotient_ridge_candidate(), view.ridge_candidate()); + assert!(!link.incident_simplices().is_empty()); + assert!(!link.edges().is_empty()); + assert!(format!("{link:?}").contains("RidgeLinkView")); + } + + #[test] + fn test_build_ridge_star_map_for_simplices_identifies_translated_periodic_images() { + let mut tds: Tds<(), (), 2> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.5, 1.0])) + .unwrap(); + + let mut simplex1 = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); + simplex1 + .set_periodic_vertex_offsets(vec![[0, 0], [0, 0], [0, 0]]) + .unwrap(); + let c1 = tds.insert_simplex_with_mapping(simplex1).unwrap(); + + let mut simplex2 = Simplex::try_new_with_data(vec![v0, v1, v2], None).unwrap(); + simplex2 + .set_periodic_vertex_offsets(vec![[1, 0], [0, 0], [0, 0]]) + .unwrap(); + let c2 = tds.insert_simplex_with_mapping(simplex2).unwrap(); + + let map = build_ridge_star_map_for_simplices(&tds, [c1, c2]).unwrap(); + + assert_eq!(map.len(), 3, "expected 3 quotient-aware ridges"); + + let shared_count = map.values().filter(|s| s.star_simplices.len() == 2).count(); + assert_eq!(shared_count, 3, "three ridges should be shared"); + + let ridge_candidate = RidgeCandidate::<2>::try_from_vertices([v0]).unwrap(); + let ridge_view = ridge_candidate.view(&tds).unwrap(); + let ridge_links = ridge_view.links().unwrap(); + assert_eq!( + ridge_links.len(), + 1, + "single-vertex translated ridges normalize to one lifted link" + ); + + let link_edges = ridge_links[0].edges(); + assert_eq!(link_edges.len(), 2); + + let quotient_edges: FastHashSet<_> = + link_edges.iter().map(LiftedLinkEdge::vertex_keys).collect(); + assert_eq!( + quotient_edges.len(), + 1, + "bare vertex keys collapse the two periodic link edges" + ); + + let lifted_offsets: FastHashSet<_> = link_edges + .iter() + .map(|edge| { + let (a, b) = edge.endpoints(); + (a.offset().to_vec(), b.offset().to_vec()) + }) + .collect(); + assert_eq!( + lifted_offsets, + [ + (Vec::::new(), Vec::::new()), + (vec![-1_i16, 0], vec![-1_i16, 0]), + ] + .into_iter() + .collect() + ); + } + + #[test] + fn test_periodic_aware_ridge_star_empty_star_returns_error() { + let mut tds: Tds<(), (), 3> = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) + .unwrap(); + + let c1 = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + tds.simplex_mut(c1) + .unwrap() + .set_periodic_vertex_offsets(vec![[0, 0, 0]; 4]) + .unwrap(); + + let bare: VertexKeyBuffer = [v0, v1].into_iter().collect(); + let lifted: LiftedVertexBuffer = [ + LiftedVertexId::base(v0), + lifted_vertex_id(v1, [99_i16, 99_i16, 99_i16]), + ] + .into_iter() + .collect(); + + match periodic_aware_ridge_star(&tds, 0x42, &lifted, &bare) { + Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { ref message })) => { + assert!( + message.contains("empty star"), + "error should mention empty star: {message}" + ); + } + other => panic!("Expected InconsistentDataStructure (empty star), got {other:?}"), + } + } +} diff --git a/src/topology/spaces/toroidal.rs b/src/topology/spaces/toroidal.rs index 7b180181..b28247f5 100644 --- a/src/topology/spaces/toroidal.rs +++ b/src/topology/spaces/toroidal.rs @@ -1,13 +1,345 @@ -//! Toroidal space topology implementation. +//! Toroidal topological space and periodic covering-space identities. //! -//! This module provides topological analysis for triangulations -//! on toroidal manifolds with periodic boundary conditions. +//! This module defines [`ToroidalSpace`] and the lifted runtime identities used +//! when topology validators inspect periodic triangulations. [`LiftedVertexId`] +//! and [`LiftedLinkEdge`] are not TDS storage keys; they identify images in a +//! local covering-space frame so ridge-link and vertex-link checks can preserve +//! toroidal adjacency instead of collapsing immediately to quotient +//! [`VertexKey`](crate::core::tds::VertexKey) values. #![forbid(unsafe_code)] +use crate::core::{ + collections::{FastHasher, SmallBuffer, VertexKeyBuffer}, + facet::facet_key_from_vertices, + tds::VertexKey, +}; use crate::topology::traits::topological_space::{ TopologicalSpace, TopologyKind, ToroidalDomain, ToroidalDomainError, }; +use slotmap::Key; +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; + +// ============================================================================= +// Periodic covering-space identities +// ============================================================================= + +/// Vertex identity in a periodic covering space. +/// +/// This deliberately is not a `VertexKey`: lifted periodic images are graph +/// identities used by topology validators, not entries in the TDS vertex store. +/// The value is runtime-local because it contains a storage-local [`VertexKey`]. +/// +/// Callers usually obtain these values from +/// [`crate::topology::ridge::RidgeLinkView::lifted_ridge_vertices`] or from +/// [`LiftedLinkEdge::endpoints`]. Use [`Self::vertex_key`] only when explicitly +/// choosing quotient-space semantics. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// ManifoldError, RidgeCandidate, RidgeCandidateError, +/// }; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # #[error(transparent)] +/// # Ridge(#[from] RidgeCandidateError), +/// # #[error(transparent)] +/// # Manifold(#[from] ManifoldError), +/// # } +/// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; +/// let view = ridge.view(dt.tds())?; +/// let links = view.links()?; +/// let Some(edge) = links.first().and_then(|link| link.edges().first()) else { +/// return Ok(()); +/// }; +/// let (endpoint, _) = edge.endpoints(); +/// +/// assert_eq!(endpoint.vertex_key(), edge.vertex_keys().0); +/// assert!(endpoint.is_base()); +/// assert!(endpoint.offset().is_empty()); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiftedVertexId { + pub(crate) vertex_key: VertexKey, + offset: SmallBuffer, +} + +pub(crate) type LiftedVertexBuffer = SmallBuffer; +pub(crate) type LinkSimplexBuffer = SmallBuffer; + +impl LiftedVertexId { + /// Creates the base periodic image for a quotient-space vertex key. + pub(crate) fn base(vertex_key: VertexKey) -> Self { + Self { + vertex_key, + offset: SmallBuffer::new(), + } + } + + /// Returns the quotient-space vertex key represented by this lifted image. + #[inline] + #[must_use] + pub const fn vertex_key(&self) -> VertexKey { + self.vertex_key + } + + /// Returns the periodic lattice offset for this lifted image. + /// + /// An empty slice means the base image. Offsets are interpreted relative to + /// the local anchor used by the topology query that produced this value. + #[inline] + #[must_use] + pub fn offset(&self) -> &[i16] { + &self.offset + } + + /// Returns whether this is the base image of its quotient vertex. + #[inline] + #[must_use] + pub fn is_base(&self) -> bool { + self.offset.is_empty() + } +} + +/// Edge in a lifted link whose endpoints preserve periodic image identity. +/// +/// `LiftedLinkEdge` is a runtime toroidal-topology value, not a durable +/// identifier. It may contain two endpoints with the same quotient +/// [`VertexKey`] but different periodic offsets; callers that collapse it to +/// bare keys are explicitly choosing quotient-space semantics. +/// +/// These edges are produced by [`crate::topology::ridge::RidgeLinkView::edges`]. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::*; +/// use delaunay::prelude::topology::validation::{ +/// ManifoldError, RidgeCandidate, RidgeCandidateError, +/// }; +/// +/// # #[derive(Debug, thiserror::Error)] +/// # enum ExampleError { +/// # #[error(transparent)] +/// # Construction(#[from] DelaunayTriangulationConstructionError), +/// # #[error(transparent)] +/// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), +/// # #[error(transparent)] +/// # Ridge(#[from] RidgeCandidateError), +/// # #[error(transparent)] +/// # Manifold(#[from] ManifoldError), +/// # } +/// # 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 ridge = RidgeCandidate::<2>::try_from_vertices(dt.tds().vertex_keys().take(1))?; +/// let view = ridge.view(dt.tds())?; +/// let links = view.links()?; +/// let Some(edge) = links.first().and_then(|link| link.edges().first()) else { +/// return Ok(()); +/// }; +/// +/// let (first, second) = edge.endpoints(); +/// assert_eq!(edge.vertex_keys(), (first.vertex_key(), second.vertex_key())); +/// assert!(!edge.is_quotient_self_loop()); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct LiftedLinkEdge { + endpoints: (LiftedVertexId, LiftedVertexId), +} + +impl LiftedLinkEdge { + pub(crate) fn from_unordered_endpoints(a: &LiftedVertexId, b: &LiftedVertexId) -> Self { + Self { + endpoints: ordered_lifted_edge(a, b), + } + } + + /// Returns the lifted endpoints in canonical order. + #[inline] + pub const fn endpoints(&self) -> (&LiftedVertexId, &LiftedVertexId) { + (&self.endpoints.0, &self.endpoints.1) + } + + /// Returns the quotient-space endpoint keys. + /// + /// This intentionally discards periodic image identity. Use + /// [`Self::endpoints`] when lifted topology matters. + #[inline] + #[must_use] + pub const fn vertex_keys(&self) -> (VertexKey, VertexKey) { + (self.endpoints.0.vertex_key, self.endpoints.1.vertex_key) + } + + /// Returns whether the lifted edge collapses to one quotient-space vertex. + #[inline] + #[must_use] + pub fn is_quotient_self_loop(&self) -> bool { + self.endpoints.0.vertex_key == self.endpoints.1.vertex_key + } +} + +impl Ord for LiftedVertexId { + fn cmp(&self, other: &Self) -> Ordering { + self.vertex_key + .data() + .as_ffi() + .cmp(&other.vertex_key.data().as_ffi()) + .then_with(|| self.offset.as_slice().cmp(other.offset.as_slice())) + } +} + +impl PartialOrd for LiftedVertexId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Hash for LiftedVertexId { + fn hash(&self, state: &mut H) { + self.vertex_key.data().as_ffi().hash(state); + self.offset.as_slice().hash(state); + } +} + +/// Creates a lifted vertex identity from a real TDS vertex key and periodic +/// lattice offset. +/// +/// Zero offsets are normalized to the base image so base-image identity remains +/// compact and comparisons do not depend on explicit all-zero offset storage. +pub(crate) fn lifted_vertex_id( + vk: VertexKey, + offset: impl IntoIterator, +) -> LiftedVertexId { + let mut offset_buffer: SmallBuffer = SmallBuffer::new(); + let mut has_nonzero_component = false; + for component in offset { + has_nonzero_component |= component != 0; + offset_buffer.push(component); + } + if !has_nonzero_component { + return LiftedVertexId::base(vk); + } + LiftedVertexId { + vertex_key: vk, + offset: offset_buffer, + } +} + +/// Computes a periodic-aware simplex key from lifted vertex IDs. +/// +/// The key is translation-invariant in the covering space: translating every +/// lifted vertex by the same lattice offset produces the same key. Base images +/// reuse [`facet_key_from_vertices`] so non-periodic topology keeps the same +/// hash path as quotient-space facets. +pub(crate) fn periodic_simplex_key(lifted_vertices: &[LiftedVertexId]) -> u64 { + if lifted_vertices.iter().all(LiftedVertexId::is_base) { + let bare_vertices: VertexKeyBuffer = + lifted_vertices.iter().map(|id| id.vertex_key).collect(); + return facet_key_from_vertices(&bare_vertices); + } + + let keys = normalize_lifted_vertices(lifted_vertices); + let mut hasher = FastHasher::default(); + for key in &keys { + key.hash(&mut hasher); + } + hasher.finish() +} + +/// Computes an exact lifted simplex key without quotient translation normalization. +/// +/// Vertex links already express every lifted vertex relative to the linked +/// anchor so applying an additional global translation quotient can collapse +/// distinct link simplices. +pub(crate) fn anchored_lifted_simplex_key(lifted_vertices: &[LiftedVertexId]) -> u64 { + if lifted_vertices.iter().all(LiftedVertexId::is_base) { + let bare_vertices: VertexKeyBuffer = + lifted_vertices.iter().map(|id| id.vertex_key).collect(); + return facet_key_from_vertices(&bare_vertices); + } + + let mut keys: LiftedVertexBuffer = lifted_vertices.iter().cloned().collect(); + keys.sort_unstable(); + let mut hasher = FastHasher::default(); + for key in &keys { + key.hash(&mut hasher); + } + hasher.finish() +} + +/// Normalizes lifted vertices by subtracting the offset of the first sorted +/// lifted vertex, making periodic simplex identities translation invariant. +pub(crate) fn normalize_lifted_vertices(lifted_vertices: &[LiftedVertexId]) -> LiftedVertexBuffer { + let mut keys: LiftedVertexBuffer = lifted_vertices.iter().cloned().collect(); + keys.sort_unstable(); + let anchor_offset: SmallBuffer = keys + .first() + .map_or_else(SmallBuffer::new, |key| key.offset.clone()); + let axes = keys + .iter() + .map(|key| key.offset.len()) + .max() + .unwrap_or(0) + .max(anchor_offset.len()); + + let mut normalized = LiftedVertexBuffer::with_capacity(keys.len()); + for key in keys { + let mut offset: SmallBuffer = SmallBuffer::with_capacity(axes); + for axis in 0..axes { + let component = key.offset.get(axis).copied().unwrap_or(0) + - anchor_offset.get(axis).copied().unwrap_or(0); + offset.push(component); + } + normalized.push(lifted_vertex_id(key.vertex_key, offset)); + } + normalized +} + +/// Returns a canonical ordering for a lifted link edge. +/// +/// Ordering includes both the quotient [`VertexKey`] and the periodic offset, +/// so quotient self-loops with distinct lifted endpoints remain distinct. +pub(crate) fn ordered_lifted_edge( + a: &LiftedVertexId, + b: &LiftedVertexId, +) -> (LiftedVertexId, LiftedVertexId) { + if b < a { + (b.clone(), a.clone()) + } else { + (a.clone(), b.clone()) + } +} /// Represents toroidal topological space with periodic boundaries. /// @@ -125,9 +457,8 @@ impl ToroidalSpace { /// Wraps a single coordinate value into the fundamental domain `[0, L_axis)` /// using `rem_euclid` arithmetic. /// - /// Converts `value` to `f64`, applies `rem_euclid(domain[axis])`, then converts - /// back to `T`. Returns `None` if either conversion fails (e.g. the input is not - /// finite, or the result is not representable in `T`). + /// Applies `rem_euclid(domain[axis])`. Returns `None` if `axis` is out of + /// range or `value` is not finite. /// /// # Arguments /// @@ -189,6 +520,71 @@ impl TopologicalSpace for ToroidalSpace { mod tests { use super::*; use approx::assert_relative_eq; + use slotmap::KeyData; + + #[test] + fn test_anchored_lifted_simplex_key_preserves_vertex_link_offsets() { + let v0 = VertexKey::from(KeyData::from_ffi(1)); + let v1 = VertexKey::from(KeyData::from_ffi(2)); + let v2 = VertexKey::from(KeyData::from_ffi(3)); + + let first_link_triangle: LiftedVertexBuffer = [ + lifted_vertex_id(v0, [1_i16, 0, 0]), + lifted_vertex_id(v1, [1_i16, 0, 0]), + lifted_vertex_id(v2, [1_i16, 0, 0]), + ] + .into_iter() + .collect(); + let shifted_link_triangle: LiftedVertexBuffer = [ + lifted_vertex_id(v0, [2_i16, 0, 0]), + lifted_vertex_id(v1, [2_i16, 0, 0]), + lifted_vertex_id(v2, [2_i16, 0, 0]), + ] + .into_iter() + .collect(); + + assert_eq!( + periodic_simplex_key(&first_link_triangle), + periodic_simplex_key(&shifted_link_triangle), + "quotient simplex keys intentionally identify global translations" + ); + assert_ne!( + anchored_lifted_simplex_key(&first_link_triangle), + anchored_lifted_simplex_key(&shifted_link_triangle), + "vertex-link keys must preserve offsets relative to the linked vertex" + ); + } + + #[test] + fn test_lifted_vertex_id_normalizes_zero_offsets_to_base_image() { + let vertex_key = VertexKey::from(KeyData::from_ffi(1)); + + let explicit_zero = lifted_vertex_id(vertex_key, [0_i16, 0, 0]); + let shifted = lifted_vertex_id(vertex_key, [0_i16, 1, 0]); + + assert_eq!(explicit_zero.vertex_key(), vertex_key); + assert!(explicit_zero.is_base()); + assert!(explicit_zero.offset().is_empty()); + assert_eq!(shifted.vertex_key(), vertex_key); + assert!(!shifted.is_base()); + assert_eq!(shifted.offset(), &[0, 1, 0]); + } + + #[test] + fn test_lifted_link_edge_preserves_periodic_self_loop_identity() { + let vertex_key = VertexKey::from(KeyData::from_ffi(1)); + let base = lifted_vertex_id(vertex_key, [0_i16, 0]); + let shifted = lifted_vertex_id(vertex_key, [1_i16, 0]); + + let edge = LiftedLinkEdge::from_unordered_endpoints(&shifted, &base); + let (first, second) = edge.endpoints(); + + assert!(edge.is_quotient_self_loop()); + assert_eq!(edge.vertex_keys(), (vertex_key, vertex_key)); + assert_eq!(first, &base); + assert_eq!(second, &shifted); + assert_ne!(first.offset(), second.offset()); + } #[test] fn test_new() { @@ -370,16 +766,4 @@ mod tests { assert!(space.wrap_coord(0, f64::NAN).is_none()); assert!(space.wrap_coord(0, f64::INFINITY).is_none()); } - - #[test] - fn test_zero_period_rejected_before_storage() { - let err = ToroidalSpace::<2>::try_new([0.0, 1.0]).unwrap_err(); - assert_eq!( - err, - ToroidalDomainError::InvalidPeriod { - axis: 0, - period: 0.0, - } - ); - } } diff --git a/src/topology/traits/global_topology_model.rs b/src/topology/traits/global_topology_model.rs index 12a1a023..0552e4d1 100644 --- a/src/topology/traits/global_topology_model.rs +++ b/src/topology/traits/global_topology_model.rs @@ -117,11 +117,11 @@ pub trait GlobalTopologyModel { periodic_offset: Option<[i8; D]>, ) -> Result<[f64; D], GlobalTopologyModelError>; - /// Returns the periodic domain when relevant. + /// Returns the validated periodic [`ToroidalDomain`] when relevant. /// - /// For periodic topologies (e.g., toroidal), this returns the fundamental domain periods. - /// For non-periodic topologies, returns `None`. - fn periodic_domain(&self) -> Option<&[f64; D]> { + /// For periodic topologies (e.g., toroidal), this returns the fundamental + /// domain by value. For non-periodic topologies, returns `None`. + fn periodic_domain(&self) -> Option> { None } @@ -180,19 +180,27 @@ impl GlobalTopologyModel for EuclideanModel { } } -/// Toroidal behavior model (domain wrapping + lattice-offset lifting). +/// Toroidal behavior model for domain wrapping and lattice-offset lifting. +/// +/// This crate-internal model is the behavior side of +/// [`GlobalTopology::Toroidal`]: +/// public metadata stores a validated domain and construction mode, while this +/// type applies those invariants to coordinate canonicalization and periodic +/// orientation lifting. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ToroidalModel { - /// Fundamental-domain periods. + /// Validated fundamental-domain periods. pub domain: ToroidalDomain, - /// Construction mode (canonicalized vs periodic image-point). + /// Construction mode controlling whether periodic offsets are meaningful. pub mode: ToroidalConstructionMode, } impl ToroidalModel { - /// Creates a toroidal model for the provided validated domain and construction mode. + /// Creates a toroidal model from an already-validated domain. /// - /// Note: `ToroidalModel` is internal; users should access via [`GlobalTopology::model()`]. + /// Use [`Self::try_new`] at raw numeric boundaries. This constructor is + /// infallible because [`ToroidalDomain`] already proves every period is + /// finite and strictly positive. #[must_use] pub const fn new(domain: ToroidalDomain, mode: ToroidalConstructionMode) -> Self { Self { domain, mode } @@ -241,6 +249,12 @@ impl GlobalTopologyModel for ToroidalModel { mut coords: [f64; D], periodic_offset: Option<[i8; D]>, ) -> Result<[f64; D], GlobalTopologyModelError> { + for (axis, coord) in coords.iter().copied().enumerate() { + if !coord.is_finite() { + return Err(GlobalTopologyModelError::NonFiniteCoordinate { axis, value: coord }); + } + } + // Canonicalized toroidal mode intentionally accepts optional periodic offsets but // does not apply them. This differs from `EuclideanModel`, which treats any // provided periodic offset as unsupported and returns an error. @@ -251,12 +265,6 @@ impl GlobalTopologyModel for ToroidalModel { return Ok(coords); }; - // Validate finiteness before performing arithmetic - for (axis, coord) in coords.iter().copied().enumerate() { - if !coord.is_finite() { - return Err(GlobalTopologyModelError::NonFiniteCoordinate { axis, value: coord }); - } - } for axis in 0..D { let period = self.domain.periods()[axis]; let lifted = f64::from(offset[axis]).mul_add(period, coords[axis]); @@ -271,8 +279,8 @@ impl GlobalTopologyModel for ToroidalModel { Ok(coords) } - fn periodic_domain(&self) -> Option<&[f64; D]> { - Some(self.domain.periods()) + fn periodic_domain(&self) -> Option> { + Some(self.domain) } fn supports_periodic_facet_signatures(&self) -> bool { @@ -472,7 +480,7 @@ impl GlobalTopologyModel for GlobalTopologyModelAdapter { } } - fn periodic_domain(&self) -> Option<&[f64; D]> { + fn periodic_domain(&self) -> Option> { match self { Self::Euclidean(model) => GlobalTopologyModel::::periodic_domain(model), Self::Toroidal(model) => GlobalTopologyModel::::periodic_domain(model), @@ -601,6 +609,32 @@ mod tests { ); } + #[test] + fn toroidal_model_lift_rejects_non_finite_coordinates_without_offset() { + let model = toroidal_model::<2>([2.0, 3.0], ToroidalConstructionMode::PeriodicImagePoint); + let err = model + .lift_for_orientation([0.5_f64, f64::INFINITY], None) + .unwrap_err(); + assert_matches!( + err, + GlobalTopologyModelError::NonFiniteCoordinate { axis: 1, value } + if value.is_infinite() && value.is_sign_positive() + ); + } + + #[test] + fn toroidal_model_lift_rejects_non_finite_coordinates_when_offsets_unsupported() { + let model = toroidal_model::<2>([2.0, 3.0], ToroidalConstructionMode::Canonicalized); + let err = model + .lift_for_orientation([f64::NAN, 0.5_f64], Some([1, 0])) + .unwrap_err(); + assert_matches!( + err, + GlobalTopologyModelError::NonFiniteCoordinate { axis: 0, value } + if value.is_nan() + ); + } + #[test] fn toroidal_model_lift_rejects_overflowed_lifted_coordinates() { let model = toroidal_model::<2>( @@ -865,7 +899,10 @@ mod tests { [2.0, 3.0], ToroidalConstructionMode::Canonicalized, )); - assert_eq!(toroidal.periodic_domain(), Some(&[2.0, 3.0])); + assert_eq!( + toroidal.periodic_domain(), + Some(ToroidalDomain::try_new([2.0, 3.0]).unwrap()) + ); } #[test] diff --git a/src/topology/traits/topological_space.rs b/src/topology/traits/topological_space.rs index 3729d0a7..31e6329c 100644 --- a/src/topology/traits/topological_space.rs +++ b/src/topology/traits/topological_space.rs @@ -7,6 +7,7 @@ //! `global_topology_model` adapter layer. use crate::core::{facet::FacetError, tds::TdsError}; +use crate::topology::manifold::ManifoldError; use thiserror::Error; /// Errors that can occur during topology computation or validation. @@ -62,21 +63,12 @@ pub enum TopologyError { source: TdsError, }, - /// Euler characteristic does not match expected value. - /// - /// NOTE: Currently unused - validation returns `TopologyCheckResult` with - /// structured diagnostics instead. This variant is reserved for future use - /// when more structured error reporting is needed at the error boundary. - #[error( - "Euler characteristic mismatch: computed χ={computed}, expected χ={expected} for {topology_type}" - )] - EulerMismatch { - /// The computed Euler characteristic. - computed: isize, - /// The expected Euler characteristic. - expected: isize, - /// Human-readable topology type description. - topology_type: String, + /// Failed to classify boundary facets under the declared global topology. + #[error("Failed to classify boundary facets during topology analysis: {source}")] + BoundaryClassification { + /// Underlying manifold-boundary classification failure. + #[source] + source: Box, }, } @@ -139,10 +131,12 @@ pub enum ToroidalConstructionMode { /// Periodic toroidal mode: 3^D image-point construction with periodic quotient /// neighbor rewiring. PeriodicImagePoint, - /// Explicit simplex construction: the caller provided combinatorial connectivity - /// directly and declared toroidal topology metadata for validation purposes. + /// Explicit quotient connectivity supplied directly by the caller. /// - /// No coordinate canonicalization or image-point expansion is performed. + /// No coordinate canonicalization or image-point expansion is performed. The + /// current Delaunay builder rejects non-Euclidean explicit connectivity + /// because Level 4 quotient Delaunay validation is not implemented for that + /// construction path. Explicit, } @@ -310,8 +304,11 @@ impl TryFrom<[f64; D]> for ToroidalDomain { /// Runtime metadata describing the global topological space associated with a triangulation. /// -/// This enum is stored on triangulations so callers can query whether a result was -/// constructed in Euclidean or toroidal mode after construction. +/// This enum is stored on triangulations so boundary queries, Euler checks, and +/// topology validation interpret facet incidence under the construction path's +/// intended space. The metadata does not itself canonicalize coordinates or +/// rewire adjacency; construction APIs decide whether quotient connectivity +/// exists. #[derive(Debug, Clone, Copy, PartialEq)] pub enum GlobalTopology { /// Euclidean (flat) space. @@ -396,6 +393,29 @@ impl GlobalTopology { } /// Returns whether boundary facets are allowed for this global topology. + /// + /// Euclidean triangulations may have convex-hull boundary facets. Closed + /// global topologies such as toroidal, spherical, and hyperbolic metadata do + /// not admit open boundary facets. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::topology::spaces::{ + /// GlobalTopology, ToroidalConstructionMode, ToroidalDomainError, + /// }; + /// + /// # fn main() -> Result<(), ToroidalDomainError> { + /// let toroidal = GlobalTopology::<2>::try_toroidal( + /// [1.0, 1.0], + /// ToroidalConstructionMode::PeriodicImagePoint, + /// )?; + /// + /// assert!(GlobalTopology::<2>::Euclidean.allows_boundary()); + /// assert!(!toroidal.allows_boundary()); + /// # Ok(()) + /// # } + /// ``` #[must_use] pub const fn allows_boundary(self) -> bool { match self { @@ -421,12 +441,56 @@ impl GlobalTopology { } /// Returns `true` for toroidal global topology metadata. + /// + /// This includes both canonicalized metadata and true periodic image-point + /// metadata; use [`Self::is_periodic`] when the distinction matters. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::topology::spaces::{ + /// GlobalTopology, ToroidalConstructionMode, ToroidalDomainError, + /// }; + /// + /// # fn main() -> Result<(), ToroidalDomainError> { + /// let canonicalized = GlobalTopology::<2>::try_toroidal( + /// [1.0, 1.0], + /// ToroidalConstructionMode::Canonicalized, + /// )?; + /// + /// assert!(canonicalized.is_toroidal()); + /// assert!(!canonicalized.is_periodic()); + /// # Ok(()) + /// # } + /// ``` #[must_use] pub const fn is_toroidal(self) -> bool { matches!(self, Self::Toroidal { .. }) } /// Returns `true` when this represents a true periodic image-point toroidal build. + /// + /// Canonicalized toroidal metadata wraps coordinates into a domain but leaves + /// connectivity Euclidean, so it is toroidal but not periodic in this sense. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::topology::spaces::{ + /// GlobalTopology, ToroidalConstructionMode, ToroidalDomainError, + /// }; + /// + /// # fn main() -> Result<(), ToroidalDomainError> { + /// let periodic = GlobalTopology::<2>::try_toroidal( + /// [1.0, 1.0], + /// ToroidalConstructionMode::PeriodicImagePoint, + /// )?; + /// + /// assert!(periodic.is_periodic()); + /// assert!(!GlobalTopology::<2>::Euclidean.is_periodic()); + /// # Ok(()) + /// # } + /// ``` #[must_use] pub const fn is_periodic(self) -> bool { matches!( @@ -690,16 +754,6 @@ mod tests { classification.to_string(), "Failed to count boundary facets during topology classification: Internal data structure inconsistency: another test" ); - - let euler = TopologyError::EulerMismatch { - computed: 2, - expected: 1, - topology_type: "sphere".to_string(), - }; - assert_eq!( - euler.to_string(), - "Euler characteristic mismatch: computed χ=2, expected χ=1 for sphere" - ); } #[test] diff --git a/tests/euler_characteristic.rs b/tests/euler_characteristic.rs index 6ca4a211..8ede820c 100644 --- a/tests/euler_characteristic.rs +++ b/tests/euler_characteristic.rs @@ -14,17 +14,20 @@ //! //! For property-based tests with random triangulations, see `proptest_euler_characteristic.rs`. +use std::assert_matches; + use delaunay::builder::DelaunayTriangulationBuilder; use delaunay::prelude::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, ExplicitConstructionError, TopologyGuarantee, }; use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::query::BoundaryAnalysis; +use delaunay::prelude::query::FacetIncidenceAnalysis; use delaunay::prelude::tds::Tds; +use delaunay::prelude::topology::validation::ManifoldError; use delaunay::topology::characteristics::{euler, validation}; use delaunay::topology::traits::topological_space::{ - GlobalTopology, TopologyKind, ToroidalConstructionMode, + GlobalTopology, TopologyError, TopologyKind, ToroidalConstructionMode, }; // ============================================================================= @@ -43,7 +46,7 @@ fn test_empty_triangulation_euler() { let chi = euler::euler_characteristic(&counts); assert_eq!(chi, 0, "Empty triangulation should have χ = 0"); - let classification = euler::classify_triangulation(&tds).unwrap(); + let classification = euler::classify_triangulation(&tds, GlobalTopology::Euclidean).unwrap(); assert_eq!(classification, euler::TopologyClassification::Empty); let expected = euler::expected_chi_for(&classification); @@ -64,7 +67,7 @@ fn test_2d_single_triangle() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 3, "Should have 3 vertices"); assert_eq!(result.counts.count(1), 3, "Should have 3 edges"); @@ -77,6 +80,49 @@ fn test_2d_single_triangle() { ); } +#[test] +fn test_euler_rejects_open_single_simplex_in_closed_topology() { + let vertices = vec![ + delaunay::prelude::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), + delaunay::prelude::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), + delaunay::prelude::Vertex::<(), _>::try_new([0.5, 1.0]).unwrap(), + ]; + + let dt = DelaunayTriangulation::try_new_with_topology_guarantee( + &vertices, + TopologyGuarantee::PLManifold, + ) + .unwrap(); + + let classify_err = + euler::classify_triangulation(dt.tds(), GlobalTopology::Spherical).unwrap_err(); + assert_matches!( + classify_err, + TopologyError::BoundaryClassification { source } + if matches!( + source.as_ref(), + ManifoldError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + .. + } + ) + ); + + let validation_err = + validation::validate_triangulation_euler(dt.tds(), GlobalTopology::Spherical).unwrap_err(); + assert_matches!( + validation_err, + TopologyError::BoundaryClassification { source } + if matches!( + source.as_ref(), + ManifoldError::BoundaryFacetInClosedTopology { + topology: TopologyKind::Spherical, + .. + } + ) + ); +} + #[test] fn test_2d_multiple_triangles() { // Four points forming multiple triangles @@ -92,7 +138,7 @@ fn test_2d_multiple_triangles() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 4, "Should have 4 vertices"); assert_eq!( @@ -121,7 +167,7 @@ fn test_3d_single_tetrahedron() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 4, "Should have 4 vertices"); assert_eq!(result.counts.count(1), 6, "Should have 6 edges"); @@ -151,7 +197,7 @@ fn test_3d_with_interior_vertex() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 5, "Should have 5 vertices"); assert_eq!( @@ -182,7 +228,7 @@ fn test_4d_single_simplex() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 5, "Should have 5 vertices"); assert_eq!(result.counts.count(1), 10, "Should have 10 edges"); @@ -214,7 +260,7 @@ fn test_5d_single_simplex() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let result = validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!(result.counts.count(0), 6, "Should have 6 vertices"); assert_eq!(result.chi, 1, "Single 5-simplex should have χ = 1"); @@ -385,7 +431,8 @@ macro_rules! test_complex_with_interior { .unwrap(); // Full complex should have χ = 1 (D-ball) - let full_result = validation::validate_triangulation_euler(dt.tds()).unwrap(); + let full_result = + validation::validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); assert_eq!( full_result.chi, 1, "Full {}-dimensional complex should have χ = 1 (D-ball)", @@ -398,7 +445,7 @@ macro_rules! test_complex_with_interior { ); // Verify we have boundary facets - let boundary_facet_count = dt.tds().number_of_boundary_facets().unwrap(); + let boundary_facet_count = dt.tds().number_of_one_sided_facets().unwrap(); assert!( boundary_facet_count > 0, "Should have boundary facets in dimension {}", @@ -420,7 +467,8 @@ macro_rules! test_complex_with_interior { // - 4D: boundary is S³ (3-sphere) → χ = 0 // - 5D: boundary is S⁴ (4-sphere) → χ = 2 // Generally: χ(S^k) = 1 + (-1)^k - let boundary_counts = euler::count_boundary_simplices(dt.tds()).unwrap(); + let boundary_counts = + euler::count_boundary_simplices(dt.tds(), dt.global_topology()).unwrap(); let boundary_chi = euler::euler_characteristic(&boundary_counts); let expected_boundary_chi = $expected_boundary_chi; diff --git a/tests/example_workflows.rs b/tests/example_workflows.rs index 8c9e551a..0e3b5804 100644 --- a/tests/example_workflows.rs +++ b/tests/example_workflows.rs @@ -29,7 +29,7 @@ fn triangulation_and_hull_workflow_remains_valid() -> Result<(), WorkflowTestErr .boundary_facets()? .map(|facet| { facet.map_err(|source| QueryError::TriangulationCorrupted { - source: source.into(), + source: Box::new(source.into()), }) }) .collect::, _>>()?; diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 0ad10bb1..b9b09a43 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -86,9 +86,12 @@ use delaunay::prelude::ordering::{ }; use delaunay::prelude::query::{ AllFacetsIter as QueryAllFacetsIter, BoundaryFacetsIter as QueryBoundaryFacetsIter, ConvexHull, - ConvexHullConstructionError, EdgeIndex as QueryEdgeIndex, IncidenceView as QueryIncidenceView, - QueryError, SimplexNeighborIndex as QuerySimplexNeighborIndex, TopologyIndexBuildError, - TriangulationAdjacency as QueryTriangulationAdjacency, + ConvexHullConstructionError, EdgeIndex as QueryEdgeIndex, EdgeKey as QueryEdgeKey, + EdgeView as QueryEdgeView, FacetIncidenceAnalysis as QueryFacetIncidenceAnalysis, + FacetIncidenceView as QueryFacetIncidenceView, IncidenceView as QueryIncidenceView, + OneSidedFacetsIter as QueryOneSidedFacetsIter, QueryError, + SimplexFacetsIter as QuerySimplexFacetsIter, SimplexNeighborIndex as QuerySimplexNeighborIndex, + TopologyIndexBuildError, TriangulationAdjacency as QueryTriangulationAdjacency, }; use delaunay::prelude::repair::{ DelaunayCheckPolicy, DelaunayRepairDiagnostics, DelaunayRepairError, @@ -101,27 +104,29 @@ use delaunay::prelude::repair::{ FlipOrientationCheckStage as RepairFlipOrientationCheckStage, FlipTriangleAdjacencyError, FlipVertexAdjacencyError, RepairQueueOrder, verify_delaunay_for_triangulation, }; -#[cfg(feature = "diagnostics")] -use delaunay::prelude::tds::Tds; use delaunay::prelude::tds::{ - AllFacetsIter as TdsAllFacetsIter, BoundaryFacetsIter as TdsBoundaryFacetsIter, FacetError, - FacetHandle, FacetView, InvariantError, NeighborSlot, SimplexKey, TdsConstructionError, - TdsError, VertexKey, + AllFacetsIter as TdsAllFacetsIter, BoundaryFacetsIter as TdsBoundaryFacetsIter, EdgeKey, + EdgeKeyError, EdgeView, FacetError, FacetHandle, FacetIncidenceView as TdsFacetIncidenceView, + FacetView, InvariantError, NeighborSlot, OneSidedFacetsIter as TdsOneSidedFacetsIter, + SimplexFacetsIter as TdsSimplexFacetsIter, SimplexKey, Tds, TdsConstructionError, TdsError, + VertexKey, }; use delaunay::prelude::topology::spaces::{ - GlobalTopology, GlobalTopologyModelError, TopologyKind, ToroidalConstructionMode, - ToroidalDomain, ToroidalDomainError, + GlobalTopology, GlobalTopologyModelError, LiftedLinkEdge, LiftedVertexId, TopologyKind, + ToroidalConstructionMode, ToroidalDomain, ToroidalDomainError, }; use delaunay::prelude::topology::validation::{ - GlobalTopology as TopologyValidationGlobalTopology, ManifoldError, RidgeVertices, - RidgeVerticesError, ridge_star_simplices, + GlobalTopology as TopologyValidationGlobalTopology, ManifoldError, RidgeCandidate, + RidgeCandidateError, RidgeLinkView, RidgeQuery, RidgeView, ridge_star_simplices, }; use delaunay::prelude::triangulation::{ AllFacetsIter as TriangulationAllFacetsIter, BoundaryFacetsIter as TriangulationBoundaryFacetsIter, EdgeIndex as GenericEdgeIndex, FacetIssuesMap as TriangulationFacetIssuesMap, FastKernel as TriangulationFastKernel, IncidenceView as GenericIncidenceView, InsertionError as TriangulationInsertionError, - QueryError as TriangulationQueryError, SimplexNeighborIndex as GenericSimplexNeighborIndex, + OneSidedFacetsIter as TriangulationOneSidedFacetsIter, QueryError as TriangulationQueryError, + SimplexFacetsIter as GenericSimplexFacetsIter, + SimplexNeighborIndex as GenericSimplexNeighborIndex, SpatialIndexConstructionFailure as GenericSpatialIndexConstructionFailure, TdsError as TriangulationTdsError, TopologyGuarantee as TriangulationTopologyGuarantee, Triangulation as GenericTriangulation, TriangulationAdjacency as GenericTriangulationAdjacency, @@ -139,17 +144,27 @@ use delaunay::prelude::{ CoordinateRange as RootCoordinateRange, DelaunayError as RootDelaunayError, DelaunayResult as RootDelaunayResult, EdgeIndex as RootEdgeIndex, FlipFailureKind as RootFlipFailureKind, - FlipOrientationCheckStage as RootFlipOrientationCheckStage, IncidenceView as RootIncidenceView, - SecureHashMap, SecureHashSet, SimplexNeighborIndex as RootSimplexNeighborIndex, + FlipOrientationCheckStage as RootFlipOrientationCheckStage, + GlobalTopology as RootGlobalTopology, GlobalTopologyModelError as RootGlobalTopologyModelError, + IncidenceView as RootIncidenceView, SecureHashMap, SecureHashSet, + SimplexNeighborIndex as RootSimplexNeighborIndex, TopologyError as RootTopologyError, + TopologyKind as RootTopologyKind, ToroidalConstructionMode as RootToroidalConstructionMode, + ToroidalDomain as RootToroidalDomain, ToroidalDomainError as RootToroidalDomainError, TriangulationAdjacency as RootTriangulationAdjacency, ValidationConfigurationError as RootValidationConfigurationError, vertex as root_vertex, }; use delaunay::query::{ AllFacetsIter as QueryFacadeAllFacetsIter, BoundaryFacetsIter as QueryFacadeBoundaryFacetsIter, EdgeIndex as QueryFacadeEdgeIndex, IncidenceView as QueryFacadeIncidenceView, + OneSidedFacetsIter as QueryFacadeOneSidedFacetsIter, + SimplexFacetsIter as QueryFacadeSimplexFacetsIter, SimplexNeighborIndex as QueryFacadeSimplexNeighborIndex, TriangulationAdjacency as QueryFacadeTriangulationAdjacency, }; +use delaunay::topology::{ + BoundaryFacetClassification as TopologyBoundaryFacetClassification, + classify_boundary_facet as topology_classify_boundary_facet, +}; #[derive(Debug, thiserror::Error)] enum RootApiExportTestError { #[error(transparent)] @@ -197,7 +212,11 @@ enum PreludeExportTestError { #[error(transparent)] Facet(#[from] FacetError), #[error(transparent)] - RidgeVertices(#[from] RidgeVerticesError), + Tds(#[from] TdsError), + #[error(transparent)] + Edge(#[from] EdgeKeyError), + #[error(transparent)] + RidgeCandidate(#[from] RidgeCandidateError), #[error(transparent)] ToroidalDomain(#[from] ToroidalDomainError), #[error(transparent)] @@ -213,6 +232,54 @@ const fn assert_root_bistellar_flips(_: &impl delaunay::flips::BistellarFlips<3, const fn assert_send_sync_unpin() {} +const fn assert_query_facet_incidence_trait_export(_: &T) +where + T: QueryFacetIncidenceAnalysis<(), (), 3> + ?Sized, +{ +} + +fn assert_construction_prelude_unsupported_topology_variants() { + let unsupported_euclidean_topology = + DelaunayConstructionFailure::EuclideanUnsupportedGlobalTopology { + topology: TopologyKind::Spherical, + }; + assert_matches!( + unsupported_euclidean_topology, + DelaunayConstructionFailure::EuclideanUnsupportedGlobalTopology { + topology: TopologyKind::Spherical, + } + ); + let unsupported_canonicalized_topology = + DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { + topology: TopologyKind::Toroidal, + }; + assert_matches!( + unsupported_canonicalized_topology, + DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { + topology: TopologyKind::Toroidal, + } + ); + let conflicting_periodic_topology = + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Euclidean, + requested_mode: None, + requested_periods: None, + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods: vec![1.0, 1.0], + }; + assert_matches!( + conflicting_periodic_topology, + DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { + requested_topology: TopologyKind::Euclidean, + requested_mode: None, + requested_periods: None, + expected_mode: ToroidalConstructionMode::PeriodicImagePoint, + expected_periods, + } + if expected_periods.as_slice() == [1.0, 1.0] + ); +} + #[test] fn construction_prelude_exports_common_delaunay_error_aliases() { let source = CoordinateConversionError::InvalidSimplexPointCount { @@ -373,6 +440,7 @@ fn construction_prelude_covers_typed_construction_failure_variants() { tracking_issue: 416, } ); + assert_construction_prelude_unsupported_topology_variants(); let topology_model_failure = DelaunayConstructionFailure::TopologyModelConfiguration { source: ConstructionGlobalTopologyModelError::PeriodicOffsetsUnsupported { kind: TopologyKind::Euclidean, @@ -568,6 +636,82 @@ fn flip_preludes_cover_orientation_check_stage() { ); } +fn assert_edge_view_exports( + tds: &Tds<(), (), 3>, + a: VertexKey, + b: VertexKey, +) -> Result<(), PreludeExportTestError> { + let edge_key = EdgeKey::try_new(tds, a, b)?; + let query_edge_key: QueryEdgeKey = edge_key; + let edge_view: EdgeView<'_, (), (), 3> = edge_key.view(tds)?; + let query_edge_view: QueryEdgeView<'_, (), (), 3> = edge_key.view(tds)?; + + assert_eq!(query_edge_key, edge_key); + assert_eq!(edge_view.key(), edge_key); + assert_eq!(query_edge_view.key(), edge_key); + assert!(!edge_view.incident_simplices().is_empty()); + Ok(()) +} + +fn assert_simplex_facet_iter_exports( + tds: &Tds<(), (), 3>, + simplex_key: SimplexKey, +) -> Result<(), FacetError> { + let _query_facade_simplex_facets: QueryFacadeSimplexFacetsIter<'_, (), (), 3> = + tds.try_simplex_facets(simplex_key)?; + let _query_simplex_facets: QuerySimplexFacetsIter<'_, (), (), 3> = + tds.try_simplex_facets(simplex_key)?; + let _tds_simplex_facets: TdsSimplexFacetsIter<'_, (), (), 3> = + tds.try_simplex_facets(simplex_key)?; + let _triangulation_simplex_facets: GenericSimplexFacetsIter<'_, (), (), 3> = + tds.try_simplex_facets(simplex_key)?; + Ok(()) +} + +fn assert_facet_incidence_exports( + tds: &Tds<(), (), 3>, + simplex_key: SimplexKey, +) -> Result<(), PreludeExportTestError> { + let facet_handle = FacetHandle::try_new(tds, simplex_key, 0)?; + let facet_view: FacetView<'_, (), (), 3> = facet_handle.view(tds)?; + assert_eq!(facet_view.handle(), facet_handle); + + let facet_index = tds.build_facet_to_simplices_index()?; + let incidence = facet_index + .get(&facet_view.key()) + .expect("fresh index should contain the facet view key"); + let root_incidence: delaunay::prelude::FacetIncidenceView<'_, '_, (), (), 3> = incidence; + let query_incidence: QueryFacetIncidenceView<'_, '_, (), (), 3> = incidence; + let tds_incidence: TdsFacetIncidenceView<'_, '_, (), (), 3> = incidence; + + assert_eq!(root_incidence.facet_key(), query_incidence.facet_key()); + assert_eq!(query_incidence.facet_key(), tds_incidence.facet_key()); + assert!(tds_incidence.is_one_sided()); + assert_query_facet_incidence_trait_export(tds); + + let query_facade_one_sided_facets: Option> = None; + let query_one_sided_facets: Option> = None; + let tds_one_sided_facets: Option> = None; + let triangulation_one_sided_facets: Option> = + None; + assert!(query_facade_one_sided_facets.is_none()); + assert!(query_one_sided_facets.is_none()); + assert!(tds_one_sided_facets.is_none()); + assert!(triangulation_one_sided_facets.is_none()); + let one_sided_count = tds + .one_sided_facets()? + .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; + assert!(one_sided_count > 0); + + let topology_classification = + topology_classify_boundary_facet(incidence, GlobalTopology::Euclidean)?; + assert_matches!( + topology_classification, + TopologyBoundaryFacetClassification::Boundary(_) + ); + Ok(()) +} + #[test] fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { let _generated_points: Vec> = try_generate_random_points_seeded(3, (0.0, 1.0), 42)?; @@ -587,39 +731,40 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { let dt = DelaunayTriangulation::try_new_with_options(&vertices, options)?; assert_eq!(dt.topology_guarantee(), TopologyGuarantee::PLManifold); - let _query_facade_all_facets: QueryFacadeAllFacetsIter<'_, (), (), 3> = dt.facets()?; + let _query_facade_all_facets: QueryFacadeAllFacetsIter<'_, (), (), 3> = dt.facets(); let _query_facade_boundary_facets: QueryFacadeBoundaryFacetsIter<'_, (), (), 3> = dt.boundary_facets()?; - let _query_prelude_all_facets: QueryAllFacetsIter<'_, (), (), 3> = dt.facets()?; + let _query_prelude_all_facets: QueryAllFacetsIter<'_, (), (), 3> = dt.facets(); let _query_prelude_boundary_facets: QueryBoundaryFacetsIter<'_, (), (), 3> = dt.boundary_facets()?; - let (simplex_key, _simplex) = dt + let (simplex_key, simplex) = dt .simplices() .next() .expect("constructed tetrahedron should contain a simplex"); - let facet_handle = FacetHandle::try_new(dt.tds(), simplex_key, 0)?; - let facet_view: FacetView<'_, (), (), 3> = facet_handle.view(dt.tds())?; - assert_eq!(facet_view.handle(), facet_handle); + assert_edge_view_exports(dt.tds(), simplex.vertices()[0], simplex.vertices()[1])?; + assert_simplex_facet_iter_exports(dt.tds(), simplex_key)?; + assert_facet_incidence_exports(dt.tds(), simplex_key)?; let boundary_facet_count = dt.boundary_facets()?.try_fold(0_usize, |count, facet| { facet .map(|_| count + 1) .map_err(|source| QueryError::TriangulationCorrupted { - source: source.into(), + source: Box::new(source.into()), }) })?; assert!(boundary_facet_count > 0); let hull = ConvexHull::try_from_triangulation(dt.as_triangulation())?; assert_eq!(hull.facet_handles().count(), boundary_facet_count); let hull_facet_view_count = hull - .facets(dt.as_triangulation())? + .try_facets(dt.as_triangulation())? .try_fold(0_usize, |count, facet| facet.map(|_| count + 1))?; assert_eq!(hull_facet_view_count, boundary_facet_count); dt.validate().unwrap(); assert_bistellar_flips(&dt); let mut empty_tds: InsertionTds<(), (), 2> = InsertionTds::empty(); - let _tds_all_facets: TdsAllFacetsIter<'_, (), (), 2> = empty_tds.facets().unwrap(); - let _tds_boundary_facets: Option> = None; + let _tds_all_facets: TdsAllFacetsIter<'_, (), (), 2> = empty_tds.facets(); + let tds_boundary_facets: Option> = None; + assert!(tds_boundary_facets.is_none()); assert_eq!( repair_neighbor_pointers_local(&mut empty_tds, &[], None)?, 0 @@ -1069,27 +1214,49 @@ fn assert_single_simplex_ridge_star( vertices: &[Vertex<(), D>], ) -> Result<(), PreludeExportTestError> { let dt = DelaunayTriangulation::try_new(vertices)?; - let ridge = RidgeVertices::::try_from_vertices(dt.tds().vertex_keys().take(D - 1))?; + let ridge = RidgeCandidate::::try_from_vertices(dt.tds().vertex_keys().take(D - 1))?; let star = ridge_star_simplices(dt.tds(), &ridge)?; + let ridge_query: RidgeQuery<'_, (), (), D> = ridge.query(dt.tds())?; + let query_star = ridge_query.incident_simplices(); + let ridge_view: RidgeView<'_, (), (), D> = ridge.view(dt.tds())?; + let view_star = ridge_view.incident_simplices(); + let ridge_vertices = ridge_view.vertices(); + let ridge_links = ridge_view.links()?; + let ridge_link: &RidgeLinkView<'_, (), (), D> = ridge_links + .first() + .expect("simplex ridge should have a link"); + let link_edges = ridge_link.edges(); + let link_edge: &LiftedLinkEdge = link_edges + .first() + .expect("single simplex ridge link should have an edge"); + let (first_endpoint, _second_endpoint): (&LiftedVertexId, &LiftedVertexId) = + link_edge.endpoints(); assert_eq!(star.len(), 1); + assert_eq!(query_star.len(), star.len()); + assert_eq!(view_star.len(), star.len()); + assert_eq!(ridge_vertices.len(), D - 1); + assert_eq!(ridge_links.len(), 1); + assert_eq!(ridge_link.incident_simplices().len(), star.len()); + assert_eq!(first_endpoint.vertex_key(), link_edge.vertex_keys().0); + assert_eq!(link_edges.len(), star.len()); Ok(()) } fn assert_cospherical_ridge_star() -> Result<(), PreludeExportTestError> { let vertices = cospherical_prelude_vertices::()?; let dt = DelaunayTriangulation::try_new(&vertices)?; - let ridge = RidgeVertices::::try_from_vertices(dt.tds().vertex_keys().take(D - 1))?; + let ridge = RidgeCandidate::::try_from_vertices(dt.tds().vertex_keys().take(D - 1))?; let star = ridge_star_simplices(dt.tds(), &ridge)?; assert!(!star.is_empty()); Ok(()) } -fn assert_ridge_vertices_reject_adversarial_keys(keys: &[VertexKey]) { +fn assert_ridge_candidate_reject_adversarial_keys(keys: &[VertexKey]) { assert_matches!( - RidgeVertices::::try_from_vertices(keys.iter().take(D.saturating_sub(2)).copied()), - Err(RidgeVerticesError::WrongArity { + RidgeCandidate::::try_from_vertices(keys.iter().take(D.saturating_sub(2)).copied()), + Err(RidgeCandidateError::WrongArity { expected, actual, .. @@ -1098,8 +1265,8 @@ fn assert_ridge_vertices_reject_adversarial_keys(keys: &[VertexK if D >= 3 { assert_matches!( - RidgeVertices::::try_from_vertices(std::iter::repeat_n(keys[0], D - 1)), - Err(RidgeVerticesError::DuplicateVertex { vertex_key }) if vertex_key == keys[0] + RidgeCandidate::::try_from_vertices(std::iter::repeat_n(keys[0], D - 1)), + Err(RidgeCandidateError::DuplicateVertex { vertex_key }) if vertex_key == keys[0] ); } } @@ -1113,7 +1280,7 @@ fn assert_topology_prelude_dimension() -> Result<(), PreludeExpo let dt = DelaunayTriangulation::try_new(&simplex_vertices)?; let keys = dt.tds().vertex_keys().collect::>(); - assert_ridge_vertices_reject_adversarial_keys::(&keys); + assert_ridge_candidate_reject_adversarial_keys::(&keys); assert_cospherical_ridge_star::()?; assert_matches!( @@ -1153,7 +1320,23 @@ fn topology_spaces_prelude_covers_toroidal_domain_api() -> Result<(), PreludeExp ToroidalConstructionMode::PeriodicImagePoint, )?; assert!(topology.is_toroidal()); + + let root_domain = RootToroidalDomain::<3>::try_new([1.0, 2.0, 3.0])?; + assert_relative_eq!( + root_domain.periods().as_slice(), + domain.periods().as_slice() + ); + let root_topology = RootGlobalTopology::try_toroidal( + [1.0, 2.0, 3.0], + RootToroidalConstructionMode::PeriodicImagePoint, + )?; + assert_eq!(root_topology.kind(), RootTopologyKind::Toroidal); + let root_topology_error: Option = None; + let root_topology_model_error: Option = None; + assert!(root_topology_error.is_none()); + assert!(root_topology_model_error.is_none()); assert_send_sync_unpin::(); + assert_send_sync_unpin::(); Ok(()) } @@ -1176,8 +1359,7 @@ fn triangulation_prelude_covers_generic_layer() -> Result<(), PreludeExportTestE tri.set_topology_guarantee(TriangulationTopologyGuarantee::Pseudomanifold); tri.set_validation_policy(TriangulationValidationPolicy::Never); tri.validate().unwrap(); - let _triangulation_all_facets: TriangulationAllFacetsIter<'_, (), (), 2> = - tri.facets().unwrap(); + let _triangulation_all_facets: TriangulationAllFacetsIter<'_, (), (), 2> = tri.facets(); let _triangulation_boundary_facets: TriangulationBoundaryFacetsIter<'_, (), (), 2> = tri.boundary_facets().unwrap(); diff --git a/tests/proptest_convex_hull.rs b/tests/proptest_convex_hull.rs index b40a7c5b..7212c0b6 100644 --- a/tests/proptest_convex_hull.rs +++ b/tests/proptest_convex_hull.rs @@ -113,7 +113,7 @@ macro_rules! test_convex_hull_properties { // Filter: Skip degenerate configurations (no boundary facets) // These are tested separately in dedicated degenerate case tests - let boundary_count = dt.tds().number_of_boundary_facets().unwrap_or(0); + let boundary_count = dt.tds().number_of_one_sided_facets().unwrap(); prop_assume!(boundary_count > 0); // Should be able to construct hull from valid triangulation @@ -212,7 +212,8 @@ macro_rules! test_convex_hull_properties { let mut dt = dt_result.expect("assumed valid random triangulation"); // Filter: Skip degenerate initial configurations - let initial_boundary_count = dt.tds().number_of_boundary_facets().unwrap_or(0); + let initial_boundary_count = + dt.tds().number_of_one_sided_facets().unwrap(); prop_assume!(initial_boundary_count > 0); let hull_result = ConvexHull::try_from_triangulation(dt.as_triangulation()); @@ -232,7 +233,8 @@ macro_rules! test_convex_hull_properties { prop_assume!(dt.insert(new_vertex[0]).is_ok()); // Filter: Skip if modification resulted in degenerate configuration - let modified_boundary_count = dt.tds().number_of_boundary_facets().unwrap_or(0); + let modified_boundary_count = + dt.tds().number_of_one_sided_facets().unwrap(); prop_assume!(modified_boundary_count > 0); // Hull should now be invalid (stale) diff --git a/tests/proptest_euler_characteristic.rs b/tests/proptest_euler_characteristic.rs index 06e28412..35968e3f 100644 --- a/tests/proptest_euler_characteristic.rs +++ b/tests/proptest_euler_characteristic.rs @@ -77,7 +77,8 @@ macro_rules! test_euler_properties { TopologyGuarantee::PLManifold, ) { // Validate Euler characteristic - let result = validation::validate_triangulation_euler(dt.tds())?; + let result = + validation::validate_triangulation_euler(dt.tds(), dt.global_topology())?; // Core property: χ must match expected value for the topology @@ -148,7 +149,8 @@ macro_rules! test_euler_properties { &vertices, TopologyGuarantee::PLManifold, ) { - let result = validation::validate_triangulation_euler(dt.tds())?; + let result = + validation::validate_triangulation_euler(dt.tds(), dt.global_topology())?; // If we have an expected χ, computed χ must match if let Some(expected_chi) = result.expected { @@ -179,7 +181,8 @@ fn test_seeded_random_generator_euler_consistent() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result_2d = validation::validate_triangulation_euler(dt_2d.tds()).unwrap(); + let result_2d = + validation::validate_triangulation_euler(dt_2d.tds(), dt_2d.global_topology()).unwrap(); assert!( result_2d.is_valid(), "2D seeded random triangulation Euler mismatch: χ={}, expected={:?}, classification={:?}, V={}, simplices={}", @@ -198,7 +201,8 @@ fn test_seeded_random_generator_euler_consistent() { TopologyGuarantee::PLManifold, ) .unwrap(); - let result_3d = validation::validate_triangulation_euler(dt_3d.tds()).unwrap(); + let result_3d = + validation::validate_triangulation_euler(dt_3d.tds(), dt_3d.global_topology()).unwrap(); assert!( result_3d.is_valid(), "3D seeded random triangulation Euler mismatch: χ={}, expected={:?}, classification={:?}, V={}, simplices={}", diff --git a/tests/proptest_facet.rs b/tests/proptest_facet.rs index 98fd3781..6de14665 100644 --- a/tests/proptest_facet.rs +++ b/tests/proptest_facet.rs @@ -4,7 +4,7 @@ //! operations in d-dimensional triangulations, including: //! - Facet vertex count correctness (D vertices for D-dimensional simplex) //! - Facet-simplex relationship validity -//! - Facet boundary multiplicity (1 for boundary, 2 for interior) +//! - Facet incidence multiplicity (one-sided or two-sided) //! //! Tests are generated for dimensions 2D-5D using macros to reduce duplication. @@ -51,16 +51,14 @@ macro_rules! test_facet_properties { // Each simplex has D+1 facets (one opposite each vertex) for facet_index in 0..=($dim as u8) { if let Ok(facet) = FacetView::try_new(&tds, simplex_key, facet_index) { - if let Ok(facet_vertices) = facet.vertices() { - let vertex_count = facet_vertices.count(); - prop_assert_eq!( - vertex_count, - $expected_facet_vertices, - "{}D facet should have exactly {} vertices", - $dim, - $expected_facet_vertices - ); - } + let vertex_count = facet.vertices().count(); + prop_assert_eq!( + vertex_count, + $expected_facet_vertices, + "{}D facet should have exactly {} vertices", + $dim, + $expected_facet_vertices + ); } } } @@ -91,15 +89,13 @@ macro_rules! test_facet_properties { for facet_index in 0..=($dim as u8) { if let Ok(facet) = FacetView::try_new(&tds, simplex_key, facet_index) { - if let Ok(facet_vertices) = facet.vertices() { - let facet_vertex_count = facet_vertices.count(); - prop_assert_eq!( - facet_vertex_count, - simplex_vertex_count - 1, - "{}D facet should have one fewer vertex than simplex", - $dim - ); - } + let facet_vertex_count = facet.vertices().count(); + prop_assert_eq!( + facet_vertex_count, + simplex_vertex_count - 1, + "{}D facet should have one fewer vertex than simplex", + $dim + ); } } } diff --git a/tests/proptest_triangulation.rs b/tests/proptest_triangulation.rs index 5a00fe28..b47e0d1f 100644 --- a/tests/proptest_triangulation.rs +++ b/tests/proptest_triangulation.rs @@ -674,7 +674,7 @@ test_quality_properties!(5, 7, 16, #[cfg(feature = "slow-tests")]); /// Macro to generate facet topology invariant property tests for a given dimension. /// /// These tests verify the **critical manifold topology invariant**: each facet -/// must be shared by at most 2 simplices (1 for boundary, 2 for interior). This +/// must be incident to at most 2 simplices (one-sided or two-sided). This /// invariant is essential for facet walking used in point location. /// /// The localized validation functions (`detect_local_facet_issues`, diff --git a/tests/semgrep/src/project_rules/rust_style.rs b/tests/semgrep/src/project_rules/rust_style.rs index ef029f51..c4532dd9 100644 --- a/tests/semgrep/src/project_rules/rust_style.rs +++ b/tests/semgrep/src/project_rules/rust_style.rs @@ -505,11 +505,75 @@ pub struct FacetHandle { // ruleid: delaunay.rust.no-runtime-topology-handle-serde #[derive(Debug, Deserialize)] +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime pub struct FacetView { simplex_key: SimplexKey, facet_index: u8, } +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Serialize)] +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime +pub struct EdgeView { + edge: EdgeKey, +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Deserialize)] +pub struct RidgeCandidate { + vertices: Vec, +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Serialize)] +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime +pub struct RidgeQuery { + ridge: RidgeCandidate, +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Serialize)] +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime +pub struct RidgeView { + ridge: RidgeCandidate, +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Serialize)] +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime +pub struct RidgeLinkView { + ridge: RidgeCandidate, +} + +// ruleid: delaunay.rust.borrowed-view-types-require-lifetime +pub struct CachedTopologyView { + cache: T, +} + +// ok: delaunay.rust.borrowed-view-types-require-lifetime +pub struct BorrowedTopologyView<'tds> { + tds: &'tds Tds, +} + +pub mod borrowed_query_ok { + // ok: delaunay.rust.borrowed-view-types-require-lifetime + pub struct RidgeQuery<'tds> { + tds: &'tds super::Tds, + } +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Deserialize)] +pub struct LiftedVertexId { + vertex_key: VertexKey, +} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +#[derive(Serialize)] +pub struct LiftedLinkEdge { + endpoint: LiftedVertexId, +} + // ok: delaunay.rust.no-runtime-topology-handle-serde #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub struct EdgeKey { @@ -529,12 +593,34 @@ impl Serialize for crate::core::edge::EdgeKey {} // ruleid: delaunay.rust.no-runtime-topology-handle-serde impl<'de> serde::Deserialize<'de> for crate::tds::FacetView {} +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +impl Serialize for crate::topology::ridge::RidgeView {} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +impl Serialize for crate::topology::ridge::RidgeLinkView {} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +impl Serialize for crate::topology::spaces::toroidal::LiftedVertexId {} + +// ruleid: delaunay.rust.no-runtime-topology-handle-serde +impl Serialize for crate::topology::spaces::toroidal::LiftedLinkEdge {} + // ruleid: delaunay.rust.no-runtime-topology-keys-in-snapshot-records pub struct RuntimeKeySnapshot { vertices: Vec, edge: EdgeKey, } +// ruleid: delaunay.rust.no-runtime-topology-keys-in-snapshot-records +pub struct SerializedRidgeRecord { + ridge: RidgeCandidate, +} + +// ruleid: delaunay.rust.no-runtime-topology-keys-in-snapshot-records +pub struct SerializedLiftedLinkRecord { + link: RidgeLinkView, +} + pub struct UuidRelationshipSnapshot { // ok: delaunay.rust.no-runtime-topology-keys-in-snapshot-records vertices: Vec, @@ -642,27 +728,27 @@ pub fn simplex_try_new_with_data_constructor_ok(vertex_keys: Vec) { pub fn facet_new_constructors_bad( tds: &TdsType, simplex_key: SimplexKeyType, - facet_map: FacetToSimplicesMap, + facet_index: FacetToSimplicesIndex<'_, (), (), 3>, ) { // ruleid: delaunay.rust.no-facet-new-constructors let _facet = FacetView::new(tds, simplex_key, 0); // ruleid: delaunay.rust.no-facet-new-constructors let _all_facets = AllFacetsIter::new(tds); // ruleid: delaunay.rust.no-facet-new-constructors - let _boundary_facets = BoundaryFacetsIter::new(tds, facet_map); + let _boundary_facets = BoundaryFacetsIter::new(facet_index); } pub fn facet_try_new_constructors_ok( tds: &TdsType, simplex_key: SimplexKeyType, - facet_map: FacetToSimplicesMap, + facet_index: FacetToSimplicesIndex<'_, (), (), 3>, ) { // ok: delaunay.rust.no-facet-new-constructors let _facet = FacetView::try_new(tds, simplex_key, 0); // ok: delaunay.rust.no-facet-new-constructors let _all_facets = AllFacetsIter::try_new(tds); // ok: delaunay.rust.no-facet-new-constructors - let _boundary_facets = BoundaryFacetsIter::try_new(tds, facet_map); + let _boundary_facets = BoundaryFacetsIter::try_new(&facet_index, Vec::new()); } pub fn facet_handle_new_constructor_bad(simplex_key: SimplexKey) { @@ -680,16 +766,56 @@ pub fn ridge_handle_new_constructor_bad(simplex_key: SimplexKey) { let _handle = RidgeHandle::new(simplex_key, 0, 1); } +pub fn ridge_candidate_new_constructor_bad(vertices: Vec) { + // ruleid: delaunay.rust.no-ridgehandle-new-constructor + let _candidate = RidgeCandidate::new(vertices); +} + +pub fn ridge_query_new_constructor_bad(tds: &TdsType, ridge: RidgeCandidate) { + // ruleid: delaunay.rust.no-ridgehandle-new-constructor + let _query = RidgeQuery::new(tds, ridge); +} + +pub fn ridge_view_new_constructor_bad(tds: &TdsType, ridge: RidgeCandidate) { + // ruleid: delaunay.rust.no-ridgehandle-new-constructor + let _view = RidgeView::new(tds, ridge); +} + +pub fn ridge_link_view_new_constructor_bad(tds: &TdsType, ridge: RidgeCandidate) { + // ruleid: delaunay.rust.no-ridgehandle-new-constructor + let _view = RidgeLinkView::new(tds, ridge); +} + pub fn ridge_handle_try_new_constructor_ok(tds: &TdsType, simplex_key: SimplexKey) { // ok: delaunay.rust.no-ridgehandle-new-constructor let _handle = RidgeHandle::try_new(tds, simplex_key, 0, 1); } +pub fn ridge_candidate_try_from_vertices_ok(vertices: Vec) { + // ok: delaunay.rust.no-ridgehandle-new-constructor + let _candidate = RidgeCandidate::try_from_vertices(vertices); +} + +pub fn ridge_query_try_new_constructor_ok(tds: &TdsType, ridge: RidgeCandidate) { + // ok: delaunay.rust.no-ridgehandle-new-constructor + let _query = RidgeQuery::try_new(tds, ridge); +} + +pub fn ridge_view_try_new_constructor_ok(tds: &TdsType, ridge: RidgeCandidate) { + // ok: delaunay.rust.no-ridgehandle-new-constructor + let _view = RidgeView::try_new(tds, ridge); +} + pub fn edgekey_new_constructor_bad(a: VertexKey, b: VertexKey) { // ruleid: delaunay.rust.no-edgekey-new-constructor let _edge = EdgeKey::new(a, b); } +pub fn edgeview_new_constructor_bad(tds: &TdsType, edge: EdgeKey) { + // ruleid: delaunay.rust.no-edgekey-new-constructor + let _view = EdgeView::new(tds, edge); +} + pub fn edgekey_try_new_without_tds_bad(a: VertexKey, b: VertexKey) { // ruleid: delaunay.rust.no-edgekey-try-new-without-tds let _edge = EdgeKey::try_new(a, b); @@ -701,6 +827,35 @@ pub fn edgekey_try_new_constructor_ok(tds: &TdsType, a: VertexKey, b: V let _edge = EdgeKey::try_new(tds, a, b); } +pub fn edgeview_try_new_constructor_ok(tds: &TdsType, edge: EdgeKey) { + // ok: delaunay.rust.no-edgekey-new-constructor + let _view = EdgeView::try_new(tds, edge); +} + +pub fn raw_facet_incidence_boundary_classification_bad( + facet_to_simplices: FacetToSimplicesMap, + global_topology: GlobalTopology<2>, +) -> bool { + // ruleid: delaunay.rust.no-raw-facet-incidence-boundary-classification + let has_raw_one_sided_incidence = facet_to_simplices + .values() + .any(|simplices| simplices.len() == 1); + global_topology.allows_boundary() && has_raw_one_sided_incidence +} + +pub fn topology_boundary_classification_ok( + tds: &Tds, + facet_to_simplices: FacetToSimplicesMap, + global_topology: GlobalTopology<2>, +) -> bool { + // ok: delaunay.rust.no-raw-facet-incidence-boundary-classification + ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices) + .and_then(|validated| { + has_boundary_facets_in_validated_facet_map(tds, validated, global_topology) + }) + .unwrap_or(false) +} + impl PublicVertexUuidConstructorFixture { // ruleid: delaunay.rust.no-public-vertex-new-with-uuid pub const fn new_with_uuid(point: Point<3>, uuid: Uuid, data: Option<()>) -> Self { diff --git a/tests/trait_bound_ergonomics.rs b/tests/trait_bound_ergonomics.rs index 86486a1a..b4194445 100644 --- a/tests/trait_bound_ergonomics.rs +++ b/tests/trait_bound_ergonomics.rs @@ -6,17 +6,31 @@ use delaunay::DelaunayTriangulation; use delaunay::prelude::Triangulation; use delaunay::prelude::construction::{GlobalTopology, TopologyGuarantee, TopologyKind}; use delaunay::prelude::geometry::{Coordinate, CoordinateValidationError, FastKernel, Point}; -use delaunay::prelude::query::BoundaryAnalysis; +use delaunay::prelude::query::FacetIncidenceAnalysis; use delaunay::prelude::tds::{ - Simplex, SimplexKey, Tds, TdsError, Vertex, VertexKey, verify_facet_index_consistency, + InvariantError, SimplexKey, Tds, TdsError, Vertex, VertexKey, verify_facet_index_consistency, }; use delaunay::prelude::topology::validation::validate_triangulation_euler; +use delaunay::prelude::validation::DelaunayTriangulationValidationError; use delaunay::query::{QueryError, TopologyIndexBuildError}; use uuid::Uuid; struct Payload; struct NotAKernel; +type NotAKernelTriangulation = Triangulation; +type NotAKernelDelaunay = DelaunayTriangulation; +type GenericTrySetTopologyFn = + fn(&mut NotAKernelTriangulation, GlobalTopology<2>) -> Result<(), InvariantError>; +type DelaunayTrySetTopologyFn = fn( + &mut NotAKernelDelaunay, + GlobalTopology<2>, +) -> Result<(), DelaunayTriangulationValidationError>; + +fn accepts_generic_try_set(_: GenericTrySetTopologyFn) {} + +fn accepts_delaunay_try_set(_: DelaunayTrySetTopologyFn) {} + #[derive(Debug, thiserror::Error)] enum TraitBoundErgonomicsError { #[error(transparent)] @@ -29,6 +43,11 @@ enum TraitBoundErgonomicsError { #[from] source: QueryError, }, + #[error(transparent)] + Validation { + #[from] + source: DelaunayTriangulationValidationError, + }, } struct MinimalCoordinate { @@ -90,13 +109,23 @@ fn vertex_uuid_constructor_accepts_non_datatype_payloads() { #[test] fn triangulation_types_do_not_require_kernel_bounds() { - let generic: Option> = None; - let delaunay: Option> = None; + let generic: Option = None; + let delaunay: Option = None; assert!(generic.is_none()); assert!(delaunay.is_none()); } +#[test] +fn topology_metadata_setters_do_not_require_kernel_bounds() { + accepts_generic_try_set( + Triangulation::::try_set_global_topology, + ); + accepts_delaunay_try_set( + DelaunayTriangulation::::try_set_global_topology, + ); +} + #[test] fn read_only_topology_apis_accept_non_datatype_payloads() { let tri: Triangulation, Payload, Payload, 2> = @@ -127,10 +156,10 @@ fn read_only_topology_apis_accept_non_datatype_payloads() { ); let tds: Tds = Tds::empty(); - assert!(tds.build_facet_to_simplices_map().unwrap().is_empty()); - assert_eq!(tds.number_of_boundary_facets().unwrap(), 0); + assert!(tds.build_facet_to_simplices_index().unwrap().is_empty()); + assert_eq!(tds.number_of_one_sided_facets().unwrap(), 0); - let topology = validate_triangulation_euler(&tds).unwrap(); + let topology = validate_triangulation_euler(&tds, GlobalTopology::Euclidean).unwrap(); assert!(topology.is_valid()); } @@ -154,11 +183,11 @@ fn delaunay_empty_query_wrappers_accept_non_datatype_payloads() assert_eq!(dt.global_topology(), GlobalTopology::Euclidean); assert_eq!(dt.topology_kind(), TopologyKind::Euclidean); - dt.set_global_topology(GlobalTopology::Euclidean); + dt.try_set_global_topology(GlobalTopology::Euclidean)?; dt.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); assert_eq!(dt.topology_guarantee(), TopologyGuarantee::Pseudomanifold); - assert!(dt.facets()?.next().is_none()); + assert!(dt.facets().next().is_none()); assert_eq!(dt.edges().count(), 0); assert_eq!(dt.incident_edges(VertexKey::default()).count(), 0); assert_eq!(dt.simplex_neighbors(SimplexKey::default()).count(), 0); @@ -211,6 +240,5 @@ fn facet_index_consistency_accepts_non_datatype_payloads() { fn facet_views_accept_non_datatype_payloads() { let tds: Tds = Tds::empty(); - assert!(Simplex::facet_views_from_tds(&tds, SimplexKey::default()).is_err()); - assert!(Simplex::facet_view_iter(&tds, SimplexKey::default()).is_err()); + assert!(tds.try_simplex_facets(SimplexKey::default()).is_err()); } diff --git a/tests/triangulation_builder.rs b/tests/triangulation_builder.rs index 2ebbee39..5eda2191 100644 --- a/tests/triangulation_builder.rs +++ b/tests/triangulation_builder.rs @@ -18,7 +18,9 @@ use delaunay::prelude::geometry::RobustKernel; use delaunay::prelude::insertion::InsertionError; use delaunay::prelude::tds::{InvariantError, TdsConstructionError, TdsError, VertexKey}; use delaunay::prelude::topology::spaces::{GlobalTopology, TopologyKind, ToroidalConstructionMode}; -use delaunay::prelude::topology::validation::{count_simplices, euler_characteristic}; +use delaunay::prelude::topology::validation::{ + TopologyClassification, count_simplices, euler_characteristic, validate_triangulation_euler, +}; use delaunay::prelude::validation::{TriangulationValidationError, ValidationPolicy}; // ============================================================================= @@ -199,7 +201,7 @@ fn test_builder_canonicalized_toroidal_canonicalizes_coordinates() { ); } -/// Full Levels 1–3 validation passes on a canonicalized toroidal triangulation. +/// Full Levels 1-3 validation passes after canonicalizing inputs into a toroidal domain. #[test] fn test_builder_canonicalized_toroidal_validates_2d() { let vertices = vec![ @@ -216,11 +218,11 @@ fn test_builder_canonicalized_toroidal_validates_2d() { assert!( dt.as_triangulation().validate().is_ok(), - "Levels 1-3 validation should pass for canonicalized toroidal triangulation" + "Levels 1-3 validation should pass after toroidal-domain input canonicalization" ); } -/// Level 4 (Delaunay property) validation passes on a canonicalized toroidal triangulation. +/// Level 4 (Delaunay property) validation passes after toroidal-domain input canonicalization. #[test] fn test_builder_canonicalized_toroidal_delaunay_property_valid_2d() { let vertices = vec![ @@ -237,7 +239,7 @@ fn test_builder_canonicalized_toroidal_delaunay_property_valid_2d() { assert!( dt.validate().is_ok(), - "Full Levels 1-4 validation should pass for canonicalized toroidal triangulation" + "Full Levels 1-4 validation should pass after toroidal-domain input canonicalization" ); } @@ -394,6 +396,13 @@ fn build_toroidal_triangulation() dt } +fn count_boundary_facets(dt: &DelaunayTriangulation) -> usize { + dt.boundary_facets() + .unwrap() + .try_fold(0_usize, |count, facet| facet.map(|_| count + 1)) + .unwrap() +} + /// `toroidal` builds a valid 2D periodic triangulation with χ = 0. /// /// Verifies TDS structural validity and χ = 0 directly. @@ -413,6 +422,46 @@ fn test_builder_toroidal_chi_zero_2d() { chi, 0, "Euler characteristic of periodic 2D triangulation must be 0 (torus)" ); + + let semantic_result = validate_triangulation_euler(dt.tds(), dt.global_topology()).unwrap(); + assert_eq!( + semantic_result.classification, + TopologyClassification::ClosedToroid(2), + "topology-aware Euler validation must classify the periodic quotient as closed toroidal", + ); + assert_eq!(semantic_result.chi, 0); + assert_eq!(semantic_result.expected, Some(0)); + assert!(semantic_result.is_valid()); +} + +/// The same input points have Euclidean hull boundary, but the periodic quotient is closed. +#[test] +fn test_builder_toroidal_boundary_query_is_topology_aware() { + let vertices = toroidal_vertices::<2>(); + let kernel = RobustKernel::new(); + + let euclidean = DelaunayTriangulationBuilder::new(&vertices) + .build_with_kernel::<_, ()>(&kernel) + .expect("Euclidean build should succeed for toroidal fixture points"); + let toroidal = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0_f64; 2]) + .unwrap() + .build_with_kernel::<_, ()>(&kernel) + .expect("periodic toroidal builder should succeed"); + + assert_eq!( + euclidean.number_of_vertices(), + toroidal.number_of_vertices() + ); + assert!( + count_boundary_facets(&euclidean) > 0, + "Euclidean triangulation of finite points should expose hull boundary facets", + ); + assert_eq!( + count_boundary_facets(&toroidal), + 0, + "periodic toroidal quotient should be closed with no boundary facets", + ); } /// `DelaunayTriangulation::builder()` uses `.try_toroidal()` for the periodic quotient path. @@ -539,6 +588,32 @@ macro_rules! gen_toroidal_high_dim_guardrail_test { gen_toroidal_high_dim_guardrail_test!(4); gen_toroidal_high_dim_guardrail_test!(5); +#[test] +fn test_builder_toroidal_large_dimension_fails_before_expansion_math() { + let vertices: Vec> = Vec::new(); + let kernel = RobustKernel::new(); + let err = DelaunayTriangulationBuilder::new(&vertices) + .try_toroidal([1.0_f64; 64]) + .unwrap() + .build_with_kernel::<_, ()>(&kernel) + .expect_err("64D periodic quotient should fail before computing 3^D image count"); + + match err { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::UnsupportedPeriodicDimension { + dimension, + max_validated_dimension, + tracking_issue, + }, + ) => { + assert_eq!(dimension, 64); + assert_eq!(max_validated_dimension, 3); + assert_eq!(tracking_issue, 416); + } + other => panic!("expected high-dimensional periodic guardrail, got {other:?}"), + } +} + /// Explicit 7-vertex torus (Heawood triangulation) with `GlobalTopology::Toroidal` /// is rejected until explicit non-Euclidean construction has Level 4 validation. ///