diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 97777c2..28c3ae1 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,6 @@ #!/bin/sh -# Regenerate READMEs from rustdoc + examples; stage anything that changed. +# Regenerate READMEs from rustdoc + examples, then the feature table from the +# `// feature-group:` pub-use tags; stage anything that changed. python3 .github/scripts/crate_readme.py >/dev/null || exit 1 +python3 .github/scripts/feature_table.py >/dev/null || exit 1 git add README.md crates/*/README.md diff --git a/.github/scripts/feature_table.py b/.github/scripts/feature_table.py new file mode 100644 index 0000000..6c60e82 --- /dev/null +++ b/.github/scripts/feature_table.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Regenerate the feature table in the workspace-root README.md between +`` markers from `feature-*:` tags on the +`pub use` lines of each crate's lib.rs. + +The table's one non-derivable fact — which capability group a function belongs +to — is written as a comment tag next to the export it describes, so it cannot +drift from the code: + + // feature-group: Measures + // feature-desc: Scalar quantities of a geometry + pub use area::{area, area_with, box_area}; + +Grammar (comment lines immediately above a `pub use`): + // feature-group: required — this pub use becomes one row under + // feature-desc: optional — group prose; continue with `// ` + // feature-keep: optional — surface a PascalCase type that would + otherwise be dropped (types are fn-table noise by default) + +Group DISPLAY ORDER is not a tag — it is presentation and lives in the +`GROUP_ORDER` map in this script. An unranked group sorts last, alphabetically. + +Everything else is derived: + * function names — parsed from the tagged `pub use path::{a, b, c}` block + * no_std status — read from the already-rendered no-std table in README + (keyed by the crate the export lives in) — no rebuild + * docs.rs link — facade path if the crate is re-exported by the + `boost_geometry` facade, else the leaf crate's page + +A `pub use` that exports a snake_case free function but carries no +`feature-group` tag, in a crate that participates in the table, is a hard +error: it forces the author to classify every new algorithm. + +Usage: python3 .github/scripts/feature_table.py +Run from the workspace root. CI runs this then `git diff --exit-code`. +""" + +import re +from pathlib import Path + +# Cargo.toml is parsed with regex (not tomllib) so this runs on the pre-commit +# hook's Python, which may predate 3.11 — matching crate_readme.py's approach. + +ROOT = Path(__file__).resolve().parent.parent.parent +README = ROOT / "README.md" +START = "" +END = "" +DOCS = "https://docs.rs" +FACADE = "geometry" # crate dir of the boost_geometry facade + +# Group display order — presentation, so it lives here, not scattered across +# lib.rs files. A group named by a `feature-group` tag but absent from this +# map sorts last, alphabetically (rank it here when you care where it lands). +# The group's NAME and DESCRIPTION stay in lib.rs, next to the code they +# describe; only the ordering is editorial. +GROUP_ORDER = { + "Measures": 1, + "Spatial predicates": 2, + "Boolean operations": 3, + "Construction & transformation": 4, + "Inspection": 5, + "Mutation & assembly": 6, + "Spatial index": 7, + "I/O — Well-Known Text": 8, + "I/O — Well-Known Binary": 9, + "I/O — GeoJSON": 10, + "I/O — SVG": 11, + "Reprojection": 12, +} + +# A crate "participates" in the table iff its lib.rs contains at least one +# feature-group tag — so scope is auto-discovered, not configured here. + + +def workspace_crate_dirs(): + text = (ROOT / "Cargo.toml").read_text() + members = re.search(r"members\s*=\s*\[(.*?)\]", text, re.S) + if not members: + raise SystemExit("Cargo.toml: no workspace members array") + return [ROOT / m for m in re.findall(r'"([^"]+)"', members.group(1))] + + +def crate_name(crate_dir: Path) -> str: + m = re.search(r'^name\s*=\s*"(.*)"', (crate_dir / "Cargo.toml").read_text(), re.M) + return m.group(1) + + +def facade_reexports() -> dict[str, str]: + """Map crate-name -> facade module (e.g. geometry-algorithm -> "algorithm") + for every crate the facade re-exports via `pub use geometry_x::*` inside a + `pub mod `. Used to decide whether a fn gets a docs.rs facade path.""" + text = (ROOT / "crates" / FACADE / "src" / "lib.rs").read_text() + out = {} + # pub mod algorithm { pub use geometry_algorithm::*; + for mod_name, dep in re.findall( + r"pub mod (\w+)\s*\{[^}]*?pub use (geometry_\w+)::\*", text, re.S + ): + out[dep.replace("_", "-")] = mod_name + return out + + +def no_std_map() -> dict[str, bool]: + """crate-name -> no_std, read from the rendered no-std table in README.""" + text = README.read_text() + out = {} + for name, mark in re.findall(r"\|\s*`([\w-]+)`\s*\|\s*([^\s|]+)\s*\|", text): + if mark in ("✅", "❌"): + out[name] = mark == "✅" + return out + + +TAG_RE = re.compile(r"//\s*feature-(group|desc|keep):\s*(.*)") +DESC_CONT_RE = re.compile(r"//\s{2,}(\S.*)") +PUB_USE_RE = re.compile(r"pub use (?:\w+::)?\{?([^;{}]*)\}?;?\s*$") + + +def parse_crate(lib_rs: Path): + """Yield (group, desc, [idents]) for each tagged pub use, plus the untagged + fn-exporting pub use lines (for the classify-everything error check). + Group ORDER is not read here — it is presentation, held in GROUP_ORDER.""" + lines = lib_rs.read_text().splitlines() + rows, untagged_fns = [], [] + i = 0 + while i < len(lines): + line = lines[i] + m = TAG_RE.search(line) + if not m: + # untagged pub use that exports a snake_case fn? + if line.strip().startswith("pub use ") and _has_fn(_join_use(lines, i)[0]): + untagged_fns.append((i + 1, line.strip())) + i += 1 + continue + # collect a tag block + group = None + desc_parts = [] + keep = set() + while i < len(lines): + tm = TAG_RE.search(lines[i]) + if tm: + kind, val = tm.group(1), tm.group(2).strip() + if kind == "group": + group = val + elif kind == "desc": + desc_parts.append(val) + elif kind == "keep": + keep.add(val) + i += 1 + continue + cont = DESC_CONT_RE.search(lines[i]) + if cont and desc_parts: # wrapped feature-desc continuation + desc_parts.append(cont.group(1).strip()) + i += 1 + continue + break + # the next non-blank line must be the pub use this block annotates + while i < len(lines) and not lines[i].strip(): + i += 1 + if i >= len(lines) or not lines[i].strip().startswith("pub use "): + raise SystemExit( + f"{lib_rs}:{i}: feature-group tag not followed by a `pub use`" + ) + block, i = _join_use(lines, i) + idents = _idents(block, keep) + if idents: + rows.append( + { + "group": group, + "desc": " ".join(desc_parts), + "idents": idents, + } + ) + return rows, untagged_fns + + +def _join_use(lines, i): + """Join a possibly multi-line `pub use ... { ... };` into one string; + return (joined, index_after).""" + buf = lines[i] + j = i + while ";" not in buf and j + 1 < len(lines): + j += 1 + buf += " " + lines[j].strip() + return buf, j + 1 + + +def _names(block: str): + m = PUB_USE_RE.search(block.strip()) + if not m: + return [] + out = [] + for raw in m.group(1).split(","): + n = raw.strip() + if not n: + continue + # `path as alias` — the alias is the public name + if " as " in n: + n = n.split(" as ")[-1].strip() + out.append(n.removeprefix("r#")) + return out + + +def _has_fn(block: str) -> bool: + return any(n[:1].islower() for n in _names(block)) + + +def _idents(block: str, keep: set[str]): + """snake_case fns always; PascalCase only if explicitly kept.""" + out = [] + for n in _names(block): + if n[:1].islower() or n in keep: + out.append(n) + return out + + +def docs_link(crate: str, first_ident: str, facade: dict[str, str]) -> str: + if crate in facade: + mod = facade[crate] + kind = "fn" if first_ident[:1].islower() else "struct" + return f"{DOCS}/boost_geometry/latest/boost_geometry/{mod}/{kind}.{first_ident}.html" + return f"{DOCS}/{crate}" + + +def render(groups) -> str: + out = ["| Function | `no_std` | Docs |", "|---|:---:|---|"] + for g in groups: + header = f"**{g['name']}**" + if g["desc"]: + header += f" — {g['desc']}" + out.append(f"| {header} |||") + for row in g["rows"]: + fns = " / ".join(f"`{n}`" for n in row["idents"]) + mark = "✅" if row["no_std"] else "❌" + out.append(f"| {fns} | {mark} | [→]({row['link']}) |") + return "\n".join(out) + + +def main(): + facade = facade_reexports() + nostd = no_std_map() + group_desc = {} # name -> description (first non-empty wins) + group_rows = {} # name -> list of rows + errors = [] + + for crate_dir in workspace_crate_dirs(): + lib_rs = crate_dir / "src" / "lib.rs" + if not lib_rs.exists(): + continue + name = crate_name(crate_dir) + rows, untagged = parse_crate(lib_rs) + if not rows: + continue # crate does not participate + for line_no, src in untagged: + errors.append(f"{lib_rs}:{line_no}: untagged fn export `{src}` " + f"(add `// feature-group: `)") + for r in rows: + g = r["group"] + if r["desc"] and not group_desc.get(g): + group_desc[g] = r["desc"] + group_rows.setdefault(g, []).append({ + "idents": r["idents"], + "no_std": nostd.get(name, False), + "link": docs_link(name, r["idents"][0], facade), + }) + + if errors: + raise SystemExit("feature-table: unclassified exports:\n " + "\n ".join(errors)) + + # Group sequence is editorial (GROUP_ORDER); unranked groups sort last, + # alphabetically. Rows within a group are always alphabetical by first ident. + ordered = sorted( + group_rows, + key=lambda g: (GROUP_ORDER.get(g, len(GROUP_ORDER) + 1), g), + ) + groups = [ + { + "name": g, + "desc": group_desc.get(g, ""), + "rows": sorted(group_rows[g], key=lambda r: r["idents"][0].lower()), + } + for g in ordered + ] + block = f"{START}\n{render(groups)}\n{END}" + + text = README.read_text() + pattern = re.compile(re.escape(START) + r".*?" + re.escape(END), re.DOTALL) + if not pattern.search(text): + raise SystemExit(f"README.md is missing {START} / {END} markers") + README.write_text(pattern.sub(lambda _: block, text)) + print(f"wrote feature table: {len(groups)} groups, " + f"{sum(len(g['rows']) for g in groups)} rows") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f657b..98feb9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: run: | python3 .github/scripts/no_std_support.py git diff --exit-code + - name: Check feature table is in sync with the pub-use tags + run: | + python3 .github/scripts/feature_table.py + git diff --exit-code coverage: name: Coverage diff --git a/.gitignore b/.gitignore index 095f6fc..7b3fae0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # This workspace ships libraries only, so the lockfile is not committed. Cargo.lock +# Coverage instrumentation output (cargo llvm-cov / -C instrument-coverage) +*.profraw + # IDE and editor cruft .idea/ .vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01c8f38..d27803e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,30 @@ those blocks by hand — edit the rustdoc or the example, then run: python3 .github/scripts/crate_readme.py ``` -Or let the pre-commit hook do it automatically: +The **feature table** in the root `README.md` (between the +`` markers) and the **`no_std` support +table** are generated too: + +```sh +python3 .github/scripts/no_std_support.py # probes each crate; needs cargo +python3 .github/scripts/feature_table.py # reads the no_std table, run it second +``` + +The feature table's rows come from the `// feature-group:` comment tags on +each crate's `pub use` lines. **Adding a public algorithm?** Tag its export +so it lands in the table — an untagged free-function export fails CI: + +```rust +// feature-group: Measures // which capability row it belongs to +// feature-desc: Scalar quantities // optional; the group's one-line prose +pub use area::{area, area_with}; +``` + +Group *display order* is not a tag — it lives in the `GROUP_ORDER` map in +`feature_table.py` (unranked groups sort last). See that script's header for +the full tag grammar (`feature-group` / `feature-desc` / `feature-keep`). + +Or let the pre-commit hook do all of the above automatically: ```sh git config core.hooksPath .githooks diff --git a/README.md b/README.md index eed0f63..ee8a42e 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,18 @@ [![MSRV](https://img.shields.io/crates/msrv/boost_geometry)](https://github.com/pentatonick/boost_geometry/blob/main/rust-toolchain.toml) [![license](https://img.shields.io/crates/l/boost_geometry.svg)](https://github.com/pentatonick/boost_geometry/blob/main/README.md#license) -A Rust port of [Boost.Geometry][boost-geometry] following its +A Rust port of [Boost.Geometry][boost-geometry], carrying over its design philosophy: dimension-agnostic, coordinate-system-agnostic, bring-your-own-type, strategy-pluggable. The library's algorithms are written against *concept traits* (`Point`, `Ring`, `Polygon`, …), not concrete structs — so your own domain types participate directly, exactly like -`BOOST_GEOMETRY_REGISTER_*` in C++. +`BOOST_GEOMETRY_REGISTER_*` in C++. There is no mandatory point or +polygon type to convert into: you register the types you already have +and call the algorithms on them. That makes `boost_geometry` a +**complement** to whatever geometry types your stack already uses, +rather than a container you must migrate onto. - **Edition:** Rust 2024, MSRV 1.85 - **Safety:** `unsafe_code = "forbid"` across the whole workspace @@ -30,7 +34,8 @@ Add the dependency: cargo add boost_geometry ``` -### With `#[derive(Point)]` +
+Coupled — with #[derive(Point)] Derive `Point` on your own coordinate struct, register your own ring and polygon types with one macro declaration each, run @@ -117,7 +122,10 @@ buffered valid: Ok(()) area 4.000 -> 15.141 ``` -### Without the derive +
+ +
+De-coupled — without the derive The derive is pure sugar: it emits the `Geometry` + `Point` (+ `PointMut`) impls below. Writing them by hand is the escape hatch @@ -228,7 +236,9 @@ The buffered area matches the closed form for a square grown by distance *d* with round corners: *s*² + 4·*s*·*d* + π·*d*² = 4 + 8 + π ≈ 15.14. -What the example shows: +
+ +What the example shows (identical for both paths above): - **`register_ring!` / `register_polygon!`** implement the concept traits for your structs (Rust's orphan rule forbids a blanket impl, @@ -247,6 +257,191 @@ What the example shows: - **`correct`** fixes ring closure and orientation in place — the Boost `bg::correct` counterpart. +## How it stays type-agnostic + +The library never sees your `Coord`, `Boundary`, or `Parcel` as a concrete +type. It only ever sees *"some `G` that satisfies the `Point` (or `Ring`, or +`Polygon`) concept trait"*, and reads it through that trait's methods. Your +struct keeps its own fields, layout, and ownership; the `register_*!` macros +just teach the trait how to read it. That is the whole trick — the same one +`BOOST_GEOMETRY_REGISTER_*` performs in C++, rebuilt from ordinary Rust +generics instead of template specialisation. + +The techniques below are worth stealing for any *bring-your-own-type* library. + +
+The Rust features that make it work + +**1. A concept is a trait; the data stays yours.** A geometry is anything +implementing the concept trait — the library owns *behaviour*, you own the +*bytes*. The read surface is tiny: + +```rust +pub trait Point: Geometry { + type Scalar: CoordinateScalar; // associated type — your f64, i32, fixed-point… + type Cs: CoordinateSystem; // associated type — Cartesian / Spherical / Geographic + const DIM: usize; // dimensions, known at compile time + fn get(&self) -> Self::Scalar; // read axis D +} +``` + +Because `Scalar` and `Cs` are **associated types** (not generic parameters), +a function written `fn distance(a: &P, b: &P)` carries the scalar and +coordinate system along for free — no `` soup at every call site. + +**2. Const generics move dimension checks to compile time.** `get::` +takes the axis as a const generic, so `p.get::<2>()` on a 2-D point is a +*compile* error, not a runtime panic — the bound check happens once, in the +type system. + +**3. Zero-sized marker tags + supertraits give free category dispatch.** Each +kind has a ZST tag (`PointTag`, `RingTag`, …) named by `Geometry::Kind`, and the +tags form a hierarchy with plain supertraits: + +```rust +pub trait Polylinear: Linear {} // reproduces C++'s `polylinear_tag : linear_tag` +``` + +So `fn f()` accepts segments, linestrings, *and* multi-linestrings +in one signature — category dispatch with no macro, no enum, no `dyn`. + +**4. The orphan rule is sidestepped with per-type macros.** Rust forbids a +blanket `impl Point for T`, and you can't `impl Point for Vec` +from this crate either (neither is yours). So `register_ring!` / `#[derive(Point)]` +**mint one concrete impl per type** at *your* call site, where the coherence +rules allow it — exactly the role the `BOOST_GEOMETRY_REGISTER_*` macros play. + +**5. Dispatch resolves statically, then vanishes.** "One function, many kinds" +(`within` on a ring vs. a polygon) is solved by a `Kind → zero-sized-strategy` +type-level picker; every layer is a ZST and a static trait resolution, so at +`-O` the whole chain collapses to a single direct call — no vtable, no branch. +This is the codebase's one recurring idiom; the full mechanism (and why the +obvious `impl` + `impl` approach hits `E0119`) is written +up in +**[docs/02-tag-dispatch-pattern.md](https://github.com/pentatonick/boost_geometry/blob/main/docs/02-tag-dispatch-pattern.md)**. + +The payoff: your types participate with **no wrapper, no conversion, and no +runtime cost** — the generic code monomorphises straight onto your struct's own +accessors. + +
+ +## Features + +Every capability below is a free function you call on your own registered +types — no conversion step. Adding the single `boost_geometry` crate brings +in all of the ✅ rows; the I/O and reprojection formats are separate crates +so a default build stays lean. The **Docs** link opens the rustdoc for that +item; the `no_std` column is the status of the crate the function lives in +(see the [full matrix](#no_std-support) below). + +Naming mirrors Boost.Geometry: a strategy-driven algorithm exposes a +strategy-less default (picks the right strategy for the coordinate system) +plus a `_with` companion that takes an explicit strategy. + + + +Auto-generated from the source; run `python3 .github/scripts/feature_table.py` after adding an export. + + +| Function | `no_std` | Docs | +|---|:---:|---| +| **Measures** — Scalar quantities of a geometry ||| +| `area` / `area_with` / `box_area` / `multi_polygon_area` / `ring_area` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.area.html) | +| `area_dyn` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.area_dyn.html) | +| `azimuth` / `azimuth_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.azimuth.html) | +| `centroid` / `centroid_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.centroid.html) | +| `closest_points` / `closest_points_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.closest_points.html) | +| `comparable_distance` / `comparable_distance_with` / `distance` / `distance_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.comparable_distance.html) | +| `discrete_frechet_distance` / `discrete_frechet_distance_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.discrete_frechet_distance.html) | +| `discrete_hausdorff_distance` / `discrete_hausdorff_distance_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.discrete_hausdorff_distance.html) | +| `distance_dyn` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.distance_dyn.html) | +| `length` / `length_with` / `perimeter` / `perimeter_with` / `ring_perimeter` / `ring_perimeter_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.length.html) | +| `length_dyn` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.length_dyn.html) | +| **Spatial predicates** — Boolean relationships between geometries ||| +| `contains_properly` / `crosses` / `overlaps` / `relate_matrix` / `relation` / `relate` / `touches` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.contains_properly.html) | +| `coordinate_position` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.coordinate_position.html) | +| `covered_by` / `within` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.covered_by.html) | +| `disjoint` / `disjoint_box_box` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.disjoint.html) | +| `equals` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.equals.html) | +| `intersects` / `intersects_reversed` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.intersects.html) | +| `within_dyn` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.within_dyn.html) | +| **Boolean operations** — Overlay and offset of areal geometries ||| +| `buffer` / `buffer_convex_polygon` / `buffer_point` / `buffer_with` / `buffer_with_strategy` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.buffer.html) | +| `difference` / `intersection` / `sym_difference` / `union` / `union_poly` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.difference.html) | +| `line_intersection` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.line_intersection.html) | +| `point_on_surface` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.point_on_surface.html) | +| **Construction & transformation** — Derive a new geometry from an existing one ||| +| `chaikin_smoothing` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.chaikin_smoothing.html) | +| `concave_hull` / `concave_hull_with` / `k_nearest_concave_hull` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.concave_hull.html) | +| `convex_hull` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.convex_hull.html) | +| `densify` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.densify.html) | +| `destination` / `destination_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.destination.html) | +| `envelope` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.envelope.html) | +| `envelope_dyn` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.envelope_dyn.html) | +| `expand` / `expand_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.expand.html) | +| `line_interpolate` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.line_interpolate.html) | +| `line_locate_point` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.line_locate_point.html) | +| `linestring_segmentize` / `linestring_segmentize_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.linestring_segmentize.html) | +| `map_coords` / `map_coords_in_place` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.map_coords.html) | +| `minimum_rotated_rect` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.minimum_rotated_rect.html) | +| `monotone_subdivision` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.monotone_subdivision.html) | +| `rhumb_azimuth` / `rhumb_azimuth_with` / `rhumb_destination` / `rhumb_destination_with` / `rhumb_distance` / `rhumb_distance_with` / `rhumb_length` / `rhumb_length_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.rhumb_azimuth.html) | +| `simplify` / `simplify_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.simplify.html) | +| `transform` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.transform.html) | +| `triangulate_earcut` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.triangulate_earcut.html) | +| **Inspection** — Query a geometry's shape or membership ||| +| `for_each_point` / `for_each_segment` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.for_each_point.html) | +| `is_convex` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.is_convex.html) | +| `is_empty` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.is_empty.html) | +| `is_simple` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.is_simple.html) | +| `is_valid` / `is_valid_polygon` / `is_valid_polygon_with` / `is_valid_ring` / `is_valid_ring_with` / `is_valid_with` / `validity_reason` / `validity_reason_with` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.is_valid.html) | +| `num_geometries` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.num_geometries.html) | +| `num_interior_rings` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.num_interior_rings.html) | +| `num_points` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.num_points.html) | +| `num_segments` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.num_segments.html) | +| **Mutation & assembly** — Build up or normalise a geometry in place ||| +| `append` / `append_to_ring` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.append.html) | +| `assign_values` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.assign_values.html) | +| `clear` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.clear.html) | +| `convert` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.convert.html) | +| `correct` / `correct_closure` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.correct.html) | +| `make_box` / `make_point` / `make_segment` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.make_box.html) | +| `merge_elements` / `merge_multipolygon` / `merge_polygons` / `stitch_triangles` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/overlay/fn.merge_elements.html) | +| `remove_spikes` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.remove_spikes.html) | +| `reverse` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.reverse.html) | +| `unique` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/algorithm/fn.unique.html) | +| **Spatial index** — Bulk-loadable R-tree with nearest-neighbour and predicate queries ||| +| `and` / `not` / `satisfies` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/rtree/fn.and.html) | +| `Rtree` | ✅ | [→](https://docs.rs/boost_geometry/latest/boost_geometry/rtree/struct.Rtree.html) | +| **I/O — Well-Known Text** — Parse and write the OGC WKT format ||| +| `from_wkt` / `parse_linestring` / `parse_multi_linestring` / `parse_multi_point` / `parse_multi_polygon` / `parse_point` / `parse_polygon` | ❌ | [→](https://docs.rs/geometry-io-wkt) | +| `to_wkt` / `to_wkt_polygon` / `write_wkt` | ❌ | [→](https://docs.rs/geometry-io-wkt) | +| **I/O — Well-Known Binary** — Parse and write the OGC WKB format ||| +| `from_wkb` | ❌ | [→](https://docs.rs/geometry-io-wkb) | +| `to_wkb` / `to_wkb_polygon` | ❌ | [→](https://docs.rs/geometry-io-wkb) | +| **I/O — GeoJSON** — Parse and write GeoJSON (RFC 7946) ||| +| `from_geojson` | ❌ | [→](https://docs.rs/geometry-io-geojson) | +| `to_geojson` / `to_geojson_polygon` | ❌ | [→](https://docs.rs/geometry-io-geojson) | +| **I/O — SVG** — Render geometries to SVG (debugging) ||| +| `SvgMapper` | ❌ | [→](https://docs.rs/geometry-io-svg) | +| **Reprojection** — CRS-to-CRS point reprojection (standalone crate) ||| +| `reproject` | ✅ | [→](https://docs.rs/geometry-proj) | + + +**Ecosystem adapters** register the types of other crates so the algorithms +above accept them directly: +[`geo-types`](https://docs.rs/geometry-adapt-geo-types) (`GeoPoint`, +`GeoPolygon`, …) and [`nalgebra`](https://docs.rs/geometry-adapt-nalgebra) +(`NaPoint2`, `NaVector3`, …). Both are `no_std`. + +Everything the `boost_geometry` facade re-exports is browsable from one +place: **[docs.rs/boost_geometry](https://docs.rs/boost_geometry)**. The I/O, +projection, and adapter crates are separate dependencies — their columns link +to their own docs.rs pages. + ## Workspace layout Nineteen crates form a dependency spine from foundational tag/coords @@ -257,7 +452,7 @@ projections. `boost_geometry` re-exports everything; depend on it alone unless you need a slimmer build. See [`docs/01-architecture.md`](https://github.com/pentatonick/boost_geometry/blob/main/docs/01-architecture.md) -for the full map. +for the full map, and the per-crate `no_std` status just below. ## `no_std` support diff --git a/crates/geometry-algorithm/src/area.rs b/crates/geometry-algorithm/src/area.rs index 113b595..e24c3c8 100644 --- a/crates/geometry-algorithm/src/area.rs +++ b/crates/geometry-algorithm/src/area.rs @@ -362,11 +362,7 @@ mod tests { ])); let got = area(&pg).abs(); let expected = 12_309e6; - assert!( - (got - expected).abs() / expected < 0.02, - "got {} km² expected ~12309 km²", - got / 1e6 - ); + assert!((got - expected).abs() / expected < 0.02); } /// `area_geo.cpp` — a geographic polygon with a hole wound opposite diff --git a/crates/geometry-algorithm/src/chaikin_smoothing.rs b/crates/geometry-algorithm/src/chaikin_smoothing.rs new file mode 100644 index 0000000..597a724 --- /dev/null +++ b/crates/geometry-algorithm/src/chaikin_smoothing.rs @@ -0,0 +1,196 @@ +//! Chaikin corner-cutting smoothing. +//! +//! Each iteration replaces an edge with points one quarter and three quarters +//! along it. Open lines retain their endpoints; rings remain rings and preserve +//! their declared closure and orientation. + +use alloc::vec::Vec; + +use geometry_cs::{CartesianFamily, CoordinateSystem}; +use geometry_model::{Linestring, Polygon, Ring}; +use geometry_tag::SameAs; +use geometry_trait::{Point, PointMut}; + +/// Apply `iterations` rounds of Chaikin corner cutting. +#[inline] +#[must_use] +pub fn chaikin_smoothing(geometry: &G, iterations: usize) -> G::Output +where + G: ChaikinSmoothing, +{ + geometry.chaikin_smoothing(iterations) +} + +/// Per-model Chaikin dispatch. +#[doc(hidden)] +pub trait ChaikinSmoothing { + /// Smoothed geometry type. + type Output; + + /// Apply the requested number of iterations. + fn chaikin_smoothing(&self, iterations: usize) -> Self::Output; +} + +impl

ChaikinSmoothing for Linestring

+where + P: Point + PointMut + Default + Copy, + ::Family: SameAs, +{ + type Output = Linestring

; + + fn chaikin_smoothing(&self, iterations: usize) -> Self::Output { + let mut points = self.0.clone(); + for _ in 0..iterations { + points = smooth_open(&points); + } + Linestring::from_vec(points) + } +} + +impl ChaikinSmoothing for Ring +where + P: Point + PointMut + Default + Copy, + ::Family: SameAs, +{ + type Output = Ring; + + fn chaikin_smoothing(&self, iterations: usize) -> Self::Output { + let mut points = self.0.clone(); + for _ in 0..iterations { + points = smooth_ring::(&points); + } + Ring::from_vec(points) + } +} + +impl ChaikinSmoothing for Polygon +where + P: Point + PointMut + Default + Copy, + ::Family: SameAs, +{ + type Output = Polygon; + + fn chaikin_smoothing(&self, iterations: usize) -> Self::Output { + Polygon::with_inners( + self.outer.chaikin_smoothing(iterations), + self.inners + .iter() + .map(|ring| ring.chaikin_smoothing(iterations)) + .collect(), + ) + } +} + +fn smooth_open

(points: &[P]) -> Vec

+where + P: Point + PointMut + Default + Copy, +{ + if points.len() < 2 { + return points.to_vec(); + } + let mut output = Vec::with_capacity(points.len() * 2); + output.push(points[0]); + for edge in points.windows(2) { + output.push(blend(&edge[0], &edge[1], 0.25)); + output.push(blend(&edge[0], &edge[1], 0.75)); + } + output.push(*points.last().expect("non-empty line checked above")); + output +} + +fn smooth_ring(points: &[P]) -> Vec

+where + P: Point + PointMut + Default + Copy, +{ + let unique_len = if points.len() > 1 && same_xy(&points[0], &points[points.len() - 1]) { + points.len() - 1 + } else { + points.len() + }; + if unique_len < 3 { + return points.to_vec(); + } + + let mut output = Vec::with_capacity(unique_len * 2 + usize::from(CLOSED)); + for index in 0..unique_len { + let next = (index + 1) % unique_len; + output.push(blend(&points[index], &points[next], 0.25)); + output.push(blend(&points[index], &points[next], 0.75)); + } + if CLOSED { + output.push(output[0]); + } + output +} + +fn blend

(first: &P, second: &P, fraction: f64) -> P +where + P: Point + PointMut + Default, +{ + let mut output = P::default(); + geometry_trait::fold_dims((), first, |(), _, dimension| { + let first_value = get_dimension(first, dimension); + let second_value = get_dimension(second, dimension); + set_dimension( + &mut output, + dimension, + first_value + fraction * (second_value - first_value), + ); + }); + output +} + +#[allow( + clippy::float_cmp, + reason = "ring closure is represented by exact endpoint identity, matching the model's closure convention" +)] +fn same_xy>(first: &P, second: &P) -> bool { + first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>() +} + +fn get_dimension>(point: &P, dimension: usize) -> f64 { + match dimension { + 0 => point.get::<0>(), + 1 => point.get::<1>(), + 2 => point.get::<2>(), + 3 => point.get::<3>(), + _ => unreachable!("point folds are limited to four dimensions"), + } +} + +fn set_dimension>(point: &mut P, dimension: usize, value: f64) { + match dimension { + 0 => point.set::<0>(value), + 1 => point.set::<1>(value), + 2 => point.set::<2>(value), + 3 => point.set::<3>(value), + _ => unreachable!("point folds are limited to four dimensions"), + } +} + +#[cfg(test)] +mod tests { + use super::chaikin_smoothing; + use geometry_cs::Cartesian; + use geometry_model::{Linestring, Point2D, Ring}; + + type P = Point2D; + + #[test] + fn open_line_keeps_endpoints_and_closed_ring_stays_closed() { + let line = Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(2.0, 0.0)]); + let smoothed = chaikin_smoothing(&line, 1); + assert_eq!(smoothed.0.first(), Some(&P::new(0.0, 0.0))); + assert_eq!(smoothed.0.last(), Some(&P::new(2.0, 0.0))); + + let ring: Ring

= Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 2.0), + P::new(2.0, 0.0), + P::new(0.0, 0.0), + ]); + let smoothed = chaikin_smoothing(&ring, 1); + assert_eq!(smoothed.0.first(), smoothed.0.last()); + assert_eq!(smoothed.0.len(), 7); + } +} diff --git a/crates/geometry-algorithm/src/closest_points.rs b/crates/geometry-algorithm/src/closest_points.rs index fc9000b..08426c3 100644 --- a/crates/geometry-algorithm/src/closest_points.rs +++ b/crates/geometry-algorithm/src/closest_points.rs @@ -42,6 +42,20 @@ where CartesianClosestPoints.closest_points(a, b) } +/// Return the nearest-point pair using an explicitly supplied strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "closest-point strategies are zero-sized or small Copy values, matching other _with entries" +)] +pub fn closest_points_with(a: &A, b: &B, strategy: S) -> (S::Out, S::Out) +where + S: ClosestPointsStrategy, +{ + strategy.closest_points(a, b) +} + #[cfg(test)] #[allow( clippy::float_cmp, diff --git a/crates/geometry-algorithm/src/concave_hull.rs b/crates/geometry-algorithm/src/concave_hull.rs new file mode 100644 index 0000000..00b68f1 --- /dev/null +++ b/crates/geometry-algorithm/src/concave_hull.rs @@ -0,0 +1,395 @@ +//! Concave hulls derived from a planar point set. +//! +//! Boost.Geometry has no concave-hull algorithm. The edge-refinement shape is +//! based on Park & Oh (2012), while the `k`-nearest entry exposes the candidate +//! breadth described by Moreira & Santos (2007). Both variants begin with the +//! existing convex hull and preserve a simple clockwise boundary. + +use alloc::vec::Vec; + +#[cfg(not(feature = "std"))] +use geometry_coords::math::Float; +use geometry_coords::precise_math; +use geometry_model::{Polygon, Ring}; +use geometry_strategy::{CollectPoints, ConvexHullStrategy, MonotoneChain}; +use geometry_trait::{Point, PointMut}; + +use crate::convex_hull::convex_hull; + +/// Parameters controlling edge-refinement concave hulls. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ConcaveHullParams { + /// Maximum ratio `(a→candidate + candidate→b) / a→b` accepted for an edge. + /// Values below `1` are clamped to `1` at the algorithm boundary. + pub concavity: f64, + /// Edges no longer than this threshold are left unchanged. + pub length_threshold: f64, +} + +impl Default for ConcaveHullParams { + fn default() -> Self { + Self { + concavity: 2.0, + length_threshold: 0.0, + } + } +} + +/// Construct a concave hull with [`ConcaveHullParams::default`]. +#[inline] +#[must_use] +pub fn concave_hull(geometry: &G) -> Polygon

+where + G: CollectPoints, + P: Point + PointMut + Default + Copy, + MonotoneChain: ConvexHullStrategy>, +{ + concave_hull_with(geometry, ConcaveHullParams::default()) +} + +/// Construct a concave hull using explicit edge-refinement parameters. +#[inline] +#[must_use] +pub fn concave_hull_with(geometry: &G, parameters: ConcaveHullParams) -> Polygon

+where + G: CollectPoints, + P: Point + PointMut + Default + Copy, + MonotoneChain: ConvexHullStrategy>, +{ + refine_hull(geometry, parameters, None) +} + +/// Construct a concave hull while considering at most `k` nearest candidates +/// for each boundary edge. +/// +/// Candidate distance is measured from the edge midpoint. `k == 0` leaves the +/// convex hull unchanged; larger values admit progressively more refinements. +#[inline] +#[must_use] +pub fn k_nearest_concave_hull(geometry: &G, k: usize) -> Polygon

+where + G: CollectPoints, + P: Point + PointMut + Default + Copy, + MonotoneChain: ConvexHullStrategy>, +{ + if k == 0 { + return Polygon::new(convex_hull(geometry)); + } + refine_hull( + geometry, + ConcaveHullParams { + concavity: f64::INFINITY, + length_threshold: 0.0, + }, + Some(k), + ) +} + +fn refine_hull( + geometry: &G, + parameters: ConcaveHullParams, + nearest_limit: Option, +) -> Polygon

+where + G: CollectPoints, + P: Point + PointMut + Default + Copy, + MonotoneChain: ConvexHullStrategy>, +{ + let mut all_points = Vec::new(); + geometry.collect_points(&mut all_points); + deduplicate(&mut all_points); + + let mut boundary = convex_hull(geometry).0; + while boundary.len() > 1 && same_xy(boundary.first(), boundary.last()) { + boundary.pop(); + } + if boundary.len() < 3 { + close(&mut boundary); + return Polygon::new(Ring::from_vec(boundary)); + } + + let mut candidates: Vec

= all_points + .into_iter() + .filter(|point| !boundary.iter().any(|hull| same_point(point, hull))) + .collect(); + let concavity = parameters.concavity.max(1.0); + let length_threshold = parameters.length_threshold.max(0.0); + + while !candidates.is_empty() { + let mut best: Option = None; + for edge in 0..boundary.len() { + let first = boundary[edge]; + let second = boundary[(edge + 1) % boundary.len()]; + let edge_length = distance(first, second); + if edge_length <= length_threshold.max(f64::EPSILON) { + continue; + } + + let mut candidate_indices: Vec = (0..candidates.len()).collect(); + candidate_indices.sort_by(|&left, &right| { + midpoint_distance(first, second, candidates[left]).total_cmp(&midpoint_distance( + first, + second, + candidates[right], + )) + }); + if let Some(limit) = nearest_limit { + candidate_indices.truncate(limit.min(candidate_indices.len())); + } + + for candidate_index in candidate_indices { + let candidate = candidates[candidate_index]; + let detour = + (distance(first, candidate) + distance(candidate, second)) / edge_length; + if detour > concavity + || point_segment_distance(candidate, first, second) <= f64::EPSILON + || !insertion_is_simple(&boundary, edge, candidate) + { + continue; + } + let score = point_segment_distance(candidate, first, second); + let insertion = Insertion { + edge, + candidate: candidate_index, + score, + }; + if best.is_none_or(|current| insertion.score < current.score) { + best = Some(insertion); + } + } + } + + let Some(insertion) = best else { + break; + }; + let point = candidates.swap_remove(insertion.candidate); + boundary.insert(insertion.edge + 1, point); + } + + close(&mut boundary); + Polygon::new(Ring::from_vec(boundary)) +} + +#[derive(Clone, Copy)] +struct Insertion { + edge: usize, + candidate: usize, + score: f64, +} + +fn deduplicate + Copy>(points: &mut Vec

) { + let mut unique = Vec::with_capacity(points.len()); + for point in points.iter().copied() { + if !unique.iter().any(|other| same_point(&point, other)) { + unique.push(point); + } + } + *points = unique; +} + +fn close(points: &mut Vec

) { + if let Some(first) = points.first().copied() { + points.push(first); + } +} + +fn insertion_is_simple

(boundary: &[P], edge: usize, candidate: P) -> bool +where + P: Point + Copy, +{ + let first = boundary[edge]; + let second_index = (edge + 1) % boundary.len(); + let second = boundary[second_index]; + for other_edge in 0..boundary.len() { + if other_edge == edge { + continue; + } + let other_first_index = other_edge; + let other_second_index = (other_edge + 1) % boundary.len(); + let other_first = boundary[other_first_index]; + let other_second = boundary[other_second_index]; + + let first_segment_shares_endpoint = other_first_index == edge || other_second_index == edge; + if !first_segment_shares_endpoint + && segments_intersect(first, candidate, other_first, other_second) + { + return false; + } + let second_segment_shares_endpoint = + other_first_index == second_index || other_second_index == second_index; + if !second_segment_shares_endpoint + && segments_intersect(candidate, second, other_first, other_second) + { + return false; + } + } + true +} + +fn segments_intersect

(a: P, b: P, c: P, d: P) -> bool +where + P: Point + Copy, +{ + let ab_c = orientation(a, b, c); + let ab_d = orientation(a, b, d); + let cd_a = orientation(c, d, a); + let cd_b = orientation(c, d, b); + if ab_c == 0.0 && on_segment(a, b, c) { + return true; + } + if ab_d == 0.0 && on_segment(a, b, d) { + return true; + } + if cd_a == 0.0 && on_segment(c, d, a) { + return true; + } + if cd_b == 0.0 && on_segment(c, d, b) { + return true; + } + (ab_c > 0.0) != (ab_d > 0.0) && (cd_a > 0.0) != (cd_b > 0.0) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "the hull operates on Copy point handles throughout" +)] +fn orientation>(first: P, second: P, third: P) -> f64 { + precise_math::orient2d( + [first.get::<0>(), first.get::<1>()], + [second.get::<0>(), second.get::<1>()], + [third.get::<0>(), third.get::<1>()], + ) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "the hull operates on Copy point handles throughout" +)] +fn on_segment>(first: P, second: P, point: P) -> bool { + point.get::<0>() >= first.get::<0>().min(second.get::<0>()) + && point.get::<0>() <= first.get::<0>().max(second.get::<0>()) + && point.get::<1>() >= first.get::<1>().min(second.get::<1>()) + && point.get::<1>() <= first.get::<1>().max(second.get::<1>()) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "the hull operates on Copy point handles throughout" +)] +fn midpoint_distance>(first: P, second: P, point: P) -> f64 { + let x = first.get::<0>() / 2.0 + second.get::<0>() / 2.0 - point.get::<0>(); + let y = first.get::<1>() / 2.0 + second.get::<1>() / 2.0 - point.get::<1>(); + x.hypot(y) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "the hull operates on Copy point handles throughout" +)] +fn point_segment_distance>(point: P, first: P, second: P) -> f64 { + let dx = second.get::<0>() - first.get::<0>(); + let dy = second.get::<1>() - first.get::<1>(); + let length_squared = dx * dx + dy * dy; + if length_squared <= f64::EPSILON { + return distance(point, first); + } + let projection = ((point.get::<0>() - first.get::<0>()) * dx + + (point.get::<1>() - first.get::<1>()) * dy) + / length_squared; + let projection = projection.clamp(0.0, 1.0); + let x = first.get::<0>() + projection * dx; + let y = first.get::<1>() + projection * dy; + (point.get::<0>() - x).hypot(point.get::<1>() - y) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "the hull operates on Copy point handles throughout" +)] +fn distance>(first: P, second: P) -> f64 { + (second.get::<0>() - first.get::<0>()).hypot(second.get::<1>() - first.get::<1>()) +} + +fn same_xy>(first: Option<&P>, second: Option<&P>) -> bool { + first + .zip(second) + .is_some_and(|(first, second)| same_point(first, second)) +} + +#[allow( + clippy::float_cmp, + reason = "coordinate identity, not approximate geometric equality, is required" +)] +fn same_point>(first: &P, second: &P) -> bool { + first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>() +} + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::{MultiPoint, Point2D}; + + use super::*; + use crate::area::area; + + #[test] + fn square_digs_toward_an_interior_point() { + type P = Point2D; + let points = MultiPoint::from_vec(alloc::vec![ + P::new(0.0, 0.0), + P::new(0.0, 4.0), + P::new(4.0, 4.0), + P::new(4.0, 0.0), + P::new(2.0, 1.0), + ]); + let hull = concave_hull_with( + &points, + ConcaveHullParams { + concavity: 1.2, + length_threshold: 0.0, + }, + ); + assert!(hull.outer.0.contains(&P::new(2.0, 1.0))); + assert!(area(&hull).abs() < 16.0); + } + + #[test] + fn private_intersection_guards_cover_invalid_insertions() { + type P = Point2D; + let boundary = [ + P::new(0.0, 0.0), + P::new(0.0, 4.0), + P::new(4.0, 4.0), + P::new(4.0, 0.0), + ]; + assert!(!insertion_is_simple(&boundary, 0, P::new(5.0, 2.0))); + assert!(!insertion_is_simple(&boundary, 0, P::new(5.0, -1.0))); + + assert!(segments_intersect( + P::new(0.0, 0.0), + P::new(2.0, 0.0), + P::new(1.0, 0.0), + P::new(1.0, 1.0), + )); + assert!(segments_intersect( + P::new(0.0, 0.0), + P::new(2.0, 0.0), + P::new(1.0, 1.0), + P::new(1.0, 0.0), + )); + assert!(segments_intersect( + P::new(1.0, 0.0), + P::new(1.0, 1.0), + P::new(0.0, 0.0), + P::new(2.0, 0.0), + )); + assert!(segments_intersect( + P::new(1.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, 0.0), + P::new(2.0, 0.0), + )); + let distance = point_segment_distance(P::new(3.0, 4.0), P::new(0.0, 0.0), P::new(0.0, 0.0)); + assert!((distance - 5.0).abs() < f64::EPSILON); + } +} diff --git a/crates/geometry-algorithm/src/coordinate_position.rs b/crates/geometry-algorithm/src/coordinate_position.rs new file mode 100644 index 0000000..468db16 --- /dev/null +++ b/crates/geometry-algorithm/src/coordinate_position.rs @@ -0,0 +1,80 @@ +//! Tri-state point-in-geometry classification. +//! +//! Boost's Cartesian winding strategy computes a three-way result before +//! `within` and `covered_by` reduce it to booleans. This module exposes that +//! distinction through the same public tag-dispatched strategy path. + +use geometry_strategy::{WithinStrategy, WithinStrategyForKind}; +use geometry_trait::{Geometry, Point}; + +use crate::{covered_by, within}; + +/// The position of a point relative to a geometry. +/// +/// Mirrors the `-1` / `0` / `+1` result of +/// `strategy/cartesian/point_in_poly_winding.hpp:69-74` with descriptive +/// variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CoordinatePosition { + /// The point lies in the strict interior. + Inside, + /// The point lies on the geometry boundary. + OnBoundary, + /// The point lies outside the geometry, including inside a polygon hole. + Outside, +} + +/// Classify a point as inside, on the boundary, or outside a geometry. +/// +/// This preserves the tri-state result that [`within()`] and [`covered_by()`] +/// expose as separate boolean predicates. +#[inline] +#[must_use] +pub fn coordinate_position(point: &P, geometry: &G) -> CoordinatePosition +where + P: Point, + G: Geometry, + G::Kind: WithinStrategyForKind, + ::S: WithinStrategy, +{ + if within(point, geometry) { + CoordinatePosition::Inside + } else if covered_by(point, geometry) { + CoordinatePosition::OnBoundary + } else { + CoordinatePosition::Outside + } +} + +#[cfg(test)] +mod tests { + use super::{CoordinatePosition, coordinate_position}; + use geometry_cs::Cartesian; + use geometry_model::{Point2D, Polygon, polygon}; + + type P = Point2D; + + fn square_with_hole() -> Polygon

{ + polygon![ + [(0.0, 0.0), (0.0, 5.0), (5.0, 5.0), (5.0, 0.0), (0.0, 0.0)], + [(2.0, 2.0), (3.0, 2.0), (3.0, 3.0), (2.0, 3.0), (2.0, 2.0)] + ] + } + + #[test] + fn distinguishes_interior_boundary_and_exterior() { + let polygon = square_with_hole(); + assert_eq!( + coordinate_position(&P::new(1.0, 1.0), &polygon), + CoordinatePosition::Inside + ); + assert_eq!( + coordinate_position(&P::new(0.0, 1.0), &polygon), + CoordinatePosition::OnBoundary + ); + assert_eq!( + coordinate_position(&P::new(2.5, 2.5), &polygon), + CoordinatePosition::Outside + ); + } +} diff --git a/crates/geometry-algorithm/src/destination.rs b/crates/geometry-algorithm/src/destination.rs new file mode 100644 index 0000000..dee5ae8 --- /dev/null +++ b/crates/geometry-algorithm/src/destination.rs @@ -0,0 +1,75 @@ +//! Point at a bearing and distance from an angular-coordinate point. +//! +//! The public entry wraps the direct geodesic formulas ported from Boost's +//! `formulas/*_direct.hpp` and selects a default by coordinate-system family. + +use geometry_cs::CoordinateSystem; +use geometry_strategy::{DefaultDestination, DefaultDestinationStrategy, DestinationStrategy}; +use geometry_trait::Point; + +type Family

= <

::Cs as CoordinateSystem>::Family; + +/// Compute the destination using the point's coordinate-system default. +/// +/// `bearing` is in radians, clockwise from north. `distance` uses the default +/// strategy's radius or spheroid units. The result uses the origin point's +/// coordinate system and angular unit. +#[inline] +#[must_use] +pub fn destination

( + origin: &P, + bearing: f64, + distance: f64, +) -> as DestinationStrategy

>::Output +where + P: Point, + Family

: DefaultDestination>, + DefaultDestinationStrategy

: DestinationStrategy

+ Default, +{ + DefaultDestinationStrategy::

::default().destination(origin, bearing, distance) +} + +/// Compute the destination using an explicitly supplied direct strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "direct strategies are small Copy configuration values, matching other _with entries" +)] +pub fn destination_with(origin: &P, bearing: f64, distance: f64, strategy: S) -> S::Output +where + P: Point, + S: DestinationStrategy

, +{ + strategy.destination(origin, bearing, distance) +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::{destination, destination_with}; + use geometry_cs::{Degree, Geographic, Spherical}; + use geometry_model::Point2D; + use geometry_strategy::{Haversine, VincentyDirect}; + use geometry_trait::Point as _; + + #[test] + fn spherical_and_geographic_defaults_return_input_units() { + type SphericalPoint = Point2D>; + type GeographicPoint = Point2D>; + + let spherical = destination( + &SphericalPoint::new(0.0, 0.0), + core::f64::consts::FRAC_PI_2, + Haversine::EARTH.radius, + ); + assert!((spherical.get::<0>().to_radians() - 1.0).abs() < 1e-12); + + let geographic = destination_with( + &GeographicPoint::new(0.0, 0.0), + core::f64::consts::FRAC_PI_2, + 100_000.0, + VincentyDirect::WGS84, + ); + assert!((geographic.get::<0>() - 0.898_315_284_1).abs() < 1e-6); + } +} diff --git a/crates/geometry-algorithm/src/distance.rs b/crates/geometry-algorithm/src/distance.rs index 55a2f18..44e3303 100644 --- a/crates/geometry-algorithm/src/distance.rs +++ b/crates/geometry-algorithm/src/distance.rs @@ -243,11 +243,7 @@ mod tests { let deg = |lon: f64, lat: f64| -> Gg { WithCs::new(Adapt([lon, lat])) }; let d = distance_with(°(1.0, 80.0), °(0.0, 90.0), Thomas::WGS84); - assert!( - (d / 1000.0 - 1_116.825_795).abs() < 0.012, - "{} km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_116.825_795).abs() < 0.012); } /// Vincenty: a pair straddling the antimeridian normalises Δλ into @@ -266,10 +262,6 @@ mod tests { let east = distance_with(°(170.0, 0.0), °(-170.0, 0.0), Vincenty::WGS84); let west = distance_with(°(-170.0, 0.0), °(170.0, 0.0), Vincenty::WGS84); assert!((east - west).abs() < 1e-6, "{east} vs {west}"); - assert!( - (east / 1000.0 - 2_226.0).abs() < 5.0, - "{} km", - east / 1000.0 - ); + assert!((east / 1000.0 - 2_226.0).abs() < 5.0); } } diff --git a/crates/geometry-algorithm/src/length.rs b/crates/geometry-algorithm/src/length.rs index 3b36977..22b3452 100644 --- a/crates/geometry-algorithm/src/length.rs +++ b/crates/geometry-algorithm/src/length.rs @@ -236,11 +236,7 @@ mod tests { let got = length(&ls); let direct = Andoyer::WGS84.distance(°(4.0, 52.0), °(3.0, 40.0)); assert!((got - direct).abs() < 1e-6); - assert!( - (got / 1000.0 - 1_336.039_890).abs() < 0.01, - "got {} km", - got / 1000.0 - ); + assert!((got / 1000.0 - 1_336.039_890).abs() < 0.01); } /// `length_with` with an explicit spherical strategy reaches the diff --git a/crates/geometry-algorithm/src/lib.rs b/crates/geometry-algorithm/src/lib.rs index 459d41e..d3149c0 100644 --- a/crates/geometry-algorithm/src/lib.rs +++ b/crates/geometry-algorithm/src/lib.rs @@ -21,12 +21,16 @@ pub mod area; pub mod assign; pub mod azimuth; pub mod centroid; +pub mod chaikin_smoothing; pub mod clear; pub mod closest_points; +pub mod concave_hull; pub mod convert; pub mod convex_hull; +pub mod coordinate_position; pub mod correct; pub mod densify; +pub mod destination; pub mod discrete_frechet; pub mod discrete_hausdorff; pub mod disjoint; @@ -41,15 +45,22 @@ pub mod is_empty; pub mod is_simple; pub mod length; pub mod line_interpolate; +pub mod line_locate_point; +pub mod linestring_segmentize; pub mod make; +pub mod map_coords; +pub mod minimum_rotated_rect; +pub mod monotone_subdivision; pub mod num_geometries; pub mod num_interior_rings; pub mod num_points; pub mod num_segments; pub mod remove_spikes; pub mod reverse; +pub mod rhumb; pub mod simplify; pub mod transform; +pub mod triangulate_earcut; pub mod unique; pub mod within; @@ -60,48 +71,121 @@ mod dyn_error; mod dyn_length; mod dyn_within; +// feature-group: Mutation & assembly +// feature-desc: Build up or normalise a geometry in place pub use append::{append, append_to_ring}; +// feature-group: Measures +// feature-desc: Scalar quantities of a geometry pub use area::{area, area_with, box_area, multi_polygon_area, ring_area}; +// feature-group: Mutation & assembly pub use assign::assign_values; +// feature-group: Measures pub use azimuth::{azimuth, azimuth_with}; +// feature-group: Measures pub use centroid::{centroid, centroid_with}; +// feature-group: Construction & transformation +// feature-desc: Derive a new geometry from an existing one +pub use chaikin_smoothing::{ChaikinSmoothing, chaikin_smoothing}; +// feature-group: Mutation & assembly pub use clear::clear; -pub use closest_points::closest_points; +// feature-group: Measures +pub use closest_points::{closest_points, closest_points_with}; +// feature-group: Construction & transformation +pub use concave_hull::{ + ConcaveHullParams, concave_hull, concave_hull_with, k_nearest_concave_hull, +}; +// feature-group: Mutation & assembly pub use convert::convert; +// feature-group: Construction & transformation pub use convex_hull::convex_hull; +// feature-group: Spatial predicates +// feature-desc: Boolean relationships between geometries +pub use coordinate_position::{CoordinatePosition, coordinate_position}; +// feature-group: Mutation & assembly pub use correct::{correct, correct_closure}; +// feature-group: Construction & transformation pub use densify::densify; +// feature-group: Construction & transformation +pub use destination::{destination, destination_with}; +// feature-group: Measures pub use discrete_frechet::{discrete_frechet_distance, discrete_frechet_distance_with}; +// feature-group: Measures pub use discrete_hausdorff::{discrete_hausdorff_distance, discrete_hausdorff_distance_with}; +// feature-group: Spatial predicates pub use disjoint::{disjoint, disjoint_box_box}; +// feature-group: Measures pub use distance::{comparable_distance, comparable_distance_with, distance, distance_with}; +// feature-group: Construction & transformation pub use envelope::envelope; +// feature-group: Spatial predicates pub use equals::equals; +// feature-group: Construction & transformation pub use expand::{expand, expand_with}; +// feature-group: Inspection +// feature-desc: Query a geometry's shape or membership pub use for_each::{for_each_point, for_each_segment}; +// feature-group: Spatial predicates pub use intersects::{intersects, intersects_reversed}; +// feature-group: Inspection pub use is_convex::is_convex; +// feature-group: Inspection pub use is_empty::is_empty; +// feature-group: Inspection pub use is_simple::is_simple; +// feature-group: Measures pub use length::{ length, length_with, perimeter, perimeter_with, ring_perimeter, ring_perimeter_with, }; +// feature-group: Construction & transformation pub use line_interpolate::line_interpolate; +// feature-group: Construction & transformation +pub use line_locate_point::line_locate_point; +// feature-group: Construction & transformation +pub use linestring_segmentize::{linestring_segmentize, linestring_segmentize_with}; +// feature-group: Mutation & assembly pub use make::{make_box, make_point, make_segment}; +// feature-group: Construction & transformation +pub use map_coords::{MapCoords, MapCoordsInPlace, map_coords, map_coords_in_place}; +// feature-group: Construction & transformation +pub use minimum_rotated_rect::minimum_rotated_rect; +// feature-group: Construction & transformation +pub use monotone_subdivision::monotone_subdivision; +// feature-group: Inspection pub use num_geometries::num_geometries; +// feature-group: Inspection pub use num_interior_rings::num_interior_rings; +// feature-group: Inspection pub use num_points::num_points; +// feature-group: Inspection pub use num_segments::num_segments; +// feature-group: Mutation & assembly pub use remove_spikes::remove_spikes; +// feature-group: Mutation & assembly pub use reverse::reverse; -pub use simplify::simplify; +// feature-group: Construction & transformation +pub use rhumb::{ + rhumb_azimuth, rhumb_azimuth_with, rhumb_destination, rhumb_destination_with, rhumb_distance, + rhumb_distance_with, rhumb_length, rhumb_length_with, +}; +// feature-group: Construction & transformation +pub use simplify::{simplify, simplify_with}; +// feature-group: Construction & transformation pub use transform::transform; +// feature-group: Construction & transformation +pub use triangulate_earcut::triangulate_earcut; +// feature-group: Mutation & assembly pub use unique::unique; +// feature-group: Spatial predicates pub use within::{covered_by, within}; +// feature-group: Measures pub use dyn_area::area_dyn; +// feature-group: Measures pub use dyn_distance::distance_dyn; +// feature-group: Construction & transformation pub use dyn_envelope::envelope_dyn; pub use dyn_error::DynKindMismatch; +// feature-group: Measures pub use dyn_length::length_dyn; +// feature-group: Spatial predicates pub use dyn_within::within_dyn; diff --git a/crates/geometry-algorithm/src/line_locate_point.rs b/crates/geometry-algorithm/src/line_locate_point.rs new file mode 100644 index 0000000..ae09db7 --- /dev/null +++ b/crates/geometry-algorithm/src/line_locate_point.rs @@ -0,0 +1,75 @@ +//! Locate the closest point as a fraction of Cartesian linestring length. + +use alloc::vec::Vec; + +use geometry_cs::{CartesianFamily, CoordinateSystem}; +use geometry_model::Segment; +use geometry_strategy::{ + CartesianClosestPoints, ClosestPointsStrategy, DistanceStrategy, Pythagoras, +}; +use geometry_tag::SameAs; +use geometry_trait::{Linestring, Point, PointMut}; + +/// Return the fractional arc-length position nearest to `point`. +/// +/// Returns `None` for an empty linestring and `Some(0.0)` for a single-point or +/// zero-length linestring. Ties retain the earliest position along the line. +#[must_use] +pub fn line_locate_point(line: &L, point: &P) -> Option +where + L: Linestring, + P: Point + PointMut + Default + Copy, + ::Family: SameAs, + CartesianClosestPoints: ClosestPointsStrategy, Out = P>, + Pythagoras: DistanceStrategy, +{ + let points: Vec

= line.points().copied().collect(); + if points.is_empty() { + return None; + } + if points.len() == 1 { + return Some(0.0); + } + + let total: f64 = points + .windows(2) + .map(|edge| Pythagoras.distance(&edge[0], &edge[1])) + .sum(); + if total == 0.0 { + return Some(0.0); + } + + let mut elapsed = 0.0; + let mut best_distance = f64::INFINITY; + let mut best_position = 0.0; + for edge in points.windows(2) { + let segment = Segment::new(edge[0], edge[1]); + let (_, projected) = CartesianClosestPoints.closest_points(point, &segment); + let candidate_distance = Pythagoras.distance(point, &projected); + if candidate_distance < best_distance { + best_distance = candidate_distance; + best_position = elapsed + Pythagoras.distance(&edge[0], &projected); + } + elapsed += Pythagoras.distance(&edge[0], &edge[1]); + } + Some((best_position / total).clamp(0.0, 1.0)) +} + +#[cfg(test)] +mod tests { + use super::line_locate_point; + use geometry_cs::Cartesian; + use geometry_model::{Linestring, Point2D}; + + type P = Point2D; + + #[test] + fn empty_and_bent_lines_have_defined_results() { + assert_eq!( + line_locate_point(&Linestring::

::new(), &P::new(0.0, 0.0)), + None + ); + let line = Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(2.0, 0.0), P::new(2.0, 2.0)]); + assert_eq!(line_locate_point(&line, &P::new(2.5, 1.0)), Some(0.75)); + } +} diff --git a/crates/geometry-algorithm/src/linestring_segmentize.rs b/crates/geometry-algorithm/src/linestring_segmentize.rs new file mode 100644 index 0000000..26ecac7 --- /dev/null +++ b/crates/geometry-algorithm/src/linestring_segmentize.rs @@ -0,0 +1,53 @@ +//! Split a linestring into equal-length pieces. +//! +//! The default is Cartesian. The explicit-strategy entry also supports +//! spherical Haversine measurement and great-circle interpolation. + +use geometry_strategy::{CartesianSegmentize, SegmentizeStrategy}; + +/// Split a linestring into equal Cartesian arc-length pieces. +#[inline] +#[must_use] +pub fn linestring_segmentize( + line: &L, + count: usize, +) -> >::Output +where + CartesianSegmentize: SegmentizeStrategy, +{ + CartesianSegmentize.segmentize(line, count) +} + +/// Split a linestring using an explicitly supplied measurement and +/// interpolation strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "segmentization strategies are zero-sized or small Copy values, matching other _with entries" +)] +pub fn linestring_segmentize_with(line: &L, count: usize, strategy: S) -> S::Output +where + S: SegmentizeStrategy, +{ + strategy.segmentize(line, count) +} + +#[cfg(test)] +mod tests { + use super::linestring_segmentize; + use geometry_cs::Cartesian; + use geometry_model::{Linestring, Point2D}; + + type P = Point2D; + + #[test] + fn equal_pieces_keep_original_corner_vertices() { + let line = Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(2.0, 0.0), P::new(2.0, 2.0)]); + let pieces = linestring_segmentize(&line, 2); + assert_eq!(pieces.0.len(), 2); + assert_eq!(pieces.0[0].0.last(), Some(&P::new(2.0, 0.0))); + assert_eq!(pieces.0[1].0.first(), Some(&P::new(2.0, 0.0))); + assert!(linestring_segmentize(&line, 0).0.is_empty()); + } +} diff --git a/crates/geometry-algorithm/src/map_coords.rs b/crates/geometry-algorithm/src/map_coords.rs new file mode 100644 index 0000000..fd2999c --- /dev/null +++ b/crates/geometry-algorithm/src/map_coords.rs @@ -0,0 +1,341 @@ +//! Coordinate mapping for the stock geometry models. + +use geometry_model::{ + Box as ModelBox, Linestring, MultiLinestring, MultiPoint, MultiPolygon, Point as ModelPoint, + Polygon, Ring, Segment, +}; +use geometry_trait::{Geometry, Point as PointTrait}; + +/// Map every point in a stock geometry into a newly constructed geometry. +/// +/// The point closure may change the scalar or coordinate-system type while the +/// geometry's topology and ring parameters are preserved. +pub fn map_coords(geometry: &G, mut map: F) -> G::Output +where + G: MapCoords, + Q: PointTrait, + F: FnMut(&G::Point) -> Q, +{ + geometry.map_coords(&mut map) +} + +/// Stock geometry support for [`map_coords`]. +#[doc(hidden)] +pub trait MapCoords: Geometry { + /// The mapped stock geometry. + type Output; + + /// Reconstruct this geometry by mapping each point. + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q; +} + +impl MapCoords for ModelPoint +where + A: geometry_coords::CoordinateScalar, + Cs: geometry_cs::CoordinateSystem, + Q: PointTrait, +{ + type Output = Q; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + map(self) + } +} + +impl MapCoords for Linestring

+where + P: PointTrait, + Q: PointTrait, +{ + type Output = Linestring; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + Linestring::from_vec(self.0.iter().map(map).collect()) + } +} + +impl MapCoords for Ring +where + P: PointTrait, + Q: PointTrait, +{ + type Output = Ring; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + Ring::from_vec(self.0.iter().map(map).collect()) + } +} + +impl MapCoords for Polygon +where + P: PointTrait, + Q: PointTrait, +{ + type Output = Polygon; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + Polygon::with_inners( + Ring::from_vec(self.outer.0.iter().map(&mut *map).collect()), + self.inners + .iter() + .map(|ring| Ring::from_vec(ring.0.iter().map(&mut *map).collect())) + .collect(), + ) + } +} + +impl MapCoords for MultiPoint

+where + P: PointTrait, + Q: PointTrait, +{ + type Output = MultiPoint; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + MultiPoint::from_vec(self.0.iter().map(map).collect()) + } +} + +impl MapCoords for MultiLinestring> +where + P: PointTrait, + Q: PointTrait, +{ + type Output = MultiLinestring>; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + MultiLinestring::from_vec( + self.0 + .iter() + .map(|line| Linestring::from_vec(line.0.iter().map(&mut *map).collect())) + .collect(), + ) + } +} + +impl MapCoords for MultiPolygon> +where + P: PointTrait, + Q: PointTrait, +{ + type Output = MultiPolygon>; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + MultiPolygon::from_vec( + self.0 + .iter() + .map(|polygon| polygon.map_coords(&mut *map)) + .collect(), + ) + } +} + +impl MapCoords for ModelBox

+where + P: PointTrait, + Q: PointTrait, +{ + type Output = ModelBox; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + ModelBox::from_corners(map(self.min()), map(self.max())) + } +} + +impl MapCoords for Segment

+where + P: PointTrait, + Q: PointTrait, +{ + type Output = Segment; + + fn map_coords(&self, map: &mut F) -> Self::Output + where + F: FnMut(&Self::Point) -> Q, + { + Segment::new(map(self.start()), map(self.end())) + } +} + +/// Mutate every stored point in a stock geometry in place. +pub fn map_coords_in_place(geometry: &mut G, mut map: F) +where + G: MapCoordsInPlace, + F: FnMut(&mut G::Point), +{ + geometry.map_coords_in_place(&mut map); +} + +/// Stock geometry support for [`map_coords_in_place`]. +#[doc(hidden)] +pub trait MapCoordsInPlace: Geometry { + /// Visit every stored point mutably. + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point); +} + +impl MapCoordsInPlace for ModelPoint +where + A: geometry_coords::CoordinateScalar, + Cs: geometry_cs::CoordinateSystem, +{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + map(self); + } +} + +impl MapCoordsInPlace for Linestring

{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + self.0.iter_mut().for_each(map); + } +} + +impl MapCoordsInPlace for Ring { + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + self.0.iter_mut().for_each(map); + } +} + +impl MapCoordsInPlace for Polygon { + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + self.outer.0.iter_mut().for_each(&mut *map); + for ring in &mut self.inners { + ring.0.iter_mut().for_each(&mut *map); + } + } +} + +impl MapCoordsInPlace for MultiPoint

{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + self.0.iter_mut().for_each(map); + } +} + +impl MapCoordsInPlace for MultiLinestring> { + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + for line in &mut self.0 { + line.0.iter_mut().for_each(&mut *map); + } + } +} + +impl MapCoordsInPlace + for MultiPolygon> +{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + for polygon in &mut self.0 { + polygon.map_coords_in_place(&mut *map); + } + } +} + +impl

MapCoordsInPlace for ModelBox

+where + P: PointTrait + Clone, +{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + let mut min = self.min().clone(); + let mut max = self.max().clone(); + map(&mut min); + map(&mut max); + *self = ModelBox::from_corners(min, max); + } +} + +impl

MapCoordsInPlace for Segment

+where + P: PointTrait + Clone, +{ + fn map_coords_in_place(&mut self, map: &mut F) + where + F: FnMut(&mut Self::Point), + { + let mut start = self.start().clone(); + let mut end = self.end().clone(); + map(&mut start); + map(&mut end); + *self = Segment::new(start, end); + } +} + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::{Linestring, Point2D}; + use geometry_trait::{Point as _, PointMut as _}; + + use super::{map_coords, map_coords_in_place}; + + #[test] + #[allow( + clippy::cast_possible_truncation, + reason = "small exact fixtures intentionally exercise scalar rebinding" + )] + fn linestring_rebinds_and_mutates() { + let line = Linestring::from_vec(alloc::vec![ + Point2D::::new(1.0, 2.0), + Point2D::new(3.0, 4.0), + ]); + let mapped: Linestring> = map_coords(&line, |point| { + Point2D::new(point.get::<0>() as f32, point.get::<1>() as f32) + }); + assert_eq!(mapped.0[0], Point2D::new(1.0, 2.0)); + + let mut shifted = line; + map_coords_in_place(&mut shifted, |point| { + point.set::<0>(point.get::<0>() + 1.0); + }); + assert_eq!(shifted.0[0], Point2D::new(2.0, 2.0)); + } +} diff --git a/crates/geometry-algorithm/src/minimum_rotated_rect.rs b/crates/geometry-algorithm/src/minimum_rotated_rect.rs new file mode 100644 index 0000000..08cbd70 --- /dev/null +++ b/crates/geometry-algorithm/src/minimum_rotated_rect.rs @@ -0,0 +1,166 @@ +//! Minimum-area rotated rectangle around a planar geometry. +//! +//! Boost.Geometry has no rotated-envelope entry. This implementation follows +//! Toussaint's rotating-calipers construction (1983): compute the convex hull, +//! align an orthogonal frame with every hull edge, and retain the frame with +//! minimum projected area. + +use alloc::vec; + +#[cfg(not(feature = "std"))] +use geometry_coords::math::Float; +use geometry_model::{Polygon, Ring}; +use geometry_strategy::{ConvexHullStrategy, MonotoneChain}; +use geometry_trait::{Point, PointMut}; + +use crate::convex_hull::convex_hull; + +/// Compute the minimum-area rotated rectangle enclosing `geometry`. +/// +/// This is Cartesian-only through the convex-hull strategy bound. Empty input +/// returns an empty polygon; one- and two-point inputs return a closed, +/// zero-area degenerate rectangle. +#[inline] +#[must_use] +pub fn minimum_rotated_rect(geometry: &G) -> Polygon

+where + P: Point + PointMut + Default + Copy, + MonotoneChain: ConvexHullStrategy>, +{ + let hull = convex_hull(geometry); + let mut points = hull.0; + while points.len() > 1 && same_xy(points.first(), points.last()) { + points.pop(); + } + match points.len() { + 0 => return Polygon::default(), + 1 => { + let point = points[0]; + return Polygon::new(Ring::from_vec(vec![point, point, point, point, point])); + } + 2 => { + let first = points[0]; + let second = points[1]; + return Polygon::new(Ring::from_vec(vec![first, first, second, second, first])); + } + _ => {} + } + + let mut best: Option = None; + for index in 0..points.len() { + let first = points[index]; + let second = points[(index + 1) % points.len()]; + let dx = second.get::<0>() - first.get::<0>(); + let dy = second.get::<1>() - first.get::<1>(); + let length = dx.hypot(dy); + if length <= f64::EPSILON { + continue; + } + let ux = dx / length; + let uy = dy / length; + let vx = -uy; + let vy = ux; + let mut min_u = f64::INFINITY; + let mut max_u = f64::NEG_INFINITY; + let mut min_v = f64::INFINITY; + let mut max_v = f64::NEG_INFINITY; + for point in &points { + let x = point.get::<0>(); + let y = point.get::<1>(); + let along = x * ux + y * uy; + let across = x * vx + y * vy; + min_u = min_u.min(along); + max_u = max_u.max(along); + min_v = min_v.min(across); + max_v = max_v.max(across); + } + let frame = Frame { + ux, + uy, + vx, + vy, + min_u, + max_u, + min_v, + max_v, + }; + if best.is_none_or(|current| frame.area() < current.area()) { + best = Some(frame); + } + } + + let Some(frame) = best else { + return Polygon::default(); + }; + let lower_left = frame.point::

(frame.min_u, frame.min_v); + let upper_left = frame.point::

(frame.min_u, frame.max_v); + let upper_right = frame.point::

(frame.max_u, frame.max_v); + let lower_right = frame.point::

(frame.max_u, frame.min_v); + Polygon::new(Ring::from_vec(vec![ + lower_left, + upper_left, + upper_right, + lower_right, + lower_left, + ])) +} + +#[derive(Clone, Copy)] +struct Frame { + ux: f64, + uy: f64, + vx: f64, + vy: f64, + min_u: f64, + max_u: f64, + min_v: f64, + max_v: f64, +} + +impl Frame { + fn area(self) -> f64 { + (self.max_u - self.min_u) * (self.max_v - self.min_v) + } + + fn point

(self, along: f64, across: f64) -> P + where + P: Point + PointMut + Default, + { + let mut point = P::default(); + point.set::<0>(along * self.ux + across * self.vx); + point.set::<1>(along * self.uy + across * self.vy); + point + } +} + +#[allow( + clippy::float_cmp, + reason = "coordinate identity is used only to detect the closing duplicate" +)] +fn same_xy>(first: Option<&P>, second: Option<&P>) -> bool { + first.zip(second).is_some_and(|(first, second)| { + first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>() + }) +} + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::{MultiPoint, Point2D}; + + use super::minimum_rotated_rect; + use crate::area::area; + + #[test] + fn diamond_has_area_two() { + type P = Point2D; + let points = MultiPoint::from_vec(alloc::vec![ + P::new(0.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, -1.0), + P::new(-1.0, 0.0), + ]); + let rectangle = minimum_rotated_rect(&points); + assert!((area(&rectangle).abs() - 2.0).abs() < 1e-12); + } +} diff --git a/crates/geometry-algorithm/src/monotone_subdivision.rs b/crates/geometry-algorithm/src/monotone_subdivision.rs new file mode 100644 index 0000000..9dbb124 --- /dev/null +++ b/crates/geometry-algorithm/src/monotone_subdivision.rs @@ -0,0 +1,56 @@ +//! Y-monotone subdivision of a Cartesian polygon. +//! +//! Boost.Geometry has no monotone-partition entry. Every triangle is +//! y-monotone, so the native ear-cut triangulation supplies a valid (though +//! finer than maximal) monotone subdivision without introducing a second +//! polygon-cutting kernel. + +use alloc::vec::Vec; + +use geometry_cs::{CartesianFamily, CoordinateSystem}; +use geometry_model::Polygon as ModelPolygon; +use geometry_tag::SameAs; +use geometry_trait::{Point, Polygon}; + +use crate::triangulate_earcut::triangulate_earcut; + +/// Subdivide a Cartesian polygon into y-monotone owned polygons. +/// +/// The current native contract returns triangular pieces; triangles are valid +/// y-monotone polygons and preserve the source area exactly up to floating-point +/// arithmetic. +#[inline] +#[must_use] +pub fn monotone_subdivision(polygon: &Pg) -> Vec> +where + Pg: Polygon, + P: Point + Copy, + P::Cs: CoordinateSystem, + ::Family: SameAs, +{ + triangulate_earcut(polygon) +} + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::{Point2D, Polygon, Ring}; + + use super::monotone_subdivision; + + #[test] + fn reflex_polygon_subdivides_to_triangles() { + type P = Point2D; + let polygon: Polygon

= Polygon::new(Ring::from_vec(alloc::vec![ + P::new(0.0, 0.0), + P::new(0.0, 2.0), + P::new(1.0, 1.0), + P::new(2.0, 2.0), + P::new(2.0, 0.0), + P::new(0.0, 0.0), + ])); + let pieces = monotone_subdivision(&polygon); + assert_eq!(pieces.len(), 3); + assert!(pieces.iter().all(|piece| piece.outer.0.len() == 4)); + } +} diff --git a/crates/geometry-algorithm/src/remove_spikes.rs b/crates/geometry-algorithm/src/remove_spikes.rs index 1a28e49..b9d8f52 100644 --- a/crates/geometry-algorithm/src/remove_spikes.rs +++ b/crates/geometry-algorithm/src/remove_spikes.rs @@ -197,13 +197,7 @@ mod tests { linestring![(0.0, 0.0), (2.0, 0.0), (5.0, 0.0), (3.0, 0.0), (1.0, 0.0)]; remove_spikes(&mut ls); let xs: Vec = ls.points().map(geometry_trait::Point::get::<0>).collect(); - // The sequence is strictly monotone (no reversal remains): each - // step moves in one direction only. - for w in xs.windows(3) { - let d1 = w[1] - w[0]; - let d2 = w[2] - w[1]; - assert!(d1 * d2 >= 0.0, "residual reversal in {xs:?}"); - } + assert_eq!(xs, vec![0.0, 1.0]); } /// A `Polygon` removes spikes from its exterior *and* every interior diff --git a/crates/geometry-algorithm/src/rhumb.rs b/crates/geometry-algorithm/src/rhumb.rs new file mode 100644 index 0000000..fb39a83 --- /dev/null +++ b/crates/geometry-algorithm/src/rhumb.rs @@ -0,0 +1,147 @@ +//! Named rhumb-line measurement entries. +//! +//! Boost.Geometry has no loxodrome entry points. These functions expose the +//! published constant-bearing formulas through the same default and `_with` +//! strategy shape as the Boost-origin measurement algorithms. + +use geometry_strategy::{ + AzimuthStrategy, DestinationStrategy, DistanceStrategy, LengthStrategy, Rhumb, +}; +use geometry_trait::{Geometry, Linestring, Point}; + +/// Rhumb-line distance using the mean-Earth-radius default. +#[inline] +#[must_use] +pub fn rhumb_distance(first: &A, second: &B) -> f64 +where + A: Geometry, + B: Geometry, + Rhumb: DistanceStrategy, +{ + rhumb_distance_with(first, second, Rhumb::default()) +} + +/// Rhumb-line distance using an explicit metric strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "rhumb strategies are small Copy radius configurations" +)] +pub fn rhumb_distance_with(first: &A, second: &B, strategy: S) -> S::Out +where + A: Geometry, + B: Geometry, + S: DistanceStrategy, +{ + strategy.distance(first, second) +} + +/// Constant compass bearing in radians, clockwise from north. +#[inline] +#[must_use] +pub fn rhumb_azimuth(first: &P1, second: &P2) -> f64 +where + P1: Point, + P2: Point, + Rhumb: AzimuthStrategy, +{ + rhumb_azimuth_with(first, second, Rhumb::default()) +} + +/// Constant compass bearing using an explicit rhumb strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "rhumb strategies are small Copy radius configurations" +)] +pub fn rhumb_azimuth_with(first: &P1, second: &P2, strategy: S) -> S::Out +where + P1: Point, + P2: Point, + S: AzimuthStrategy, +{ + strategy.azimuth(first, second) +} + +/// Destination reached along a constant-bearing rhumb line. +#[inline] +#[must_use] +pub fn rhumb_destination

(origin: &P, bearing: f64, distance: f64) -> PointOutput

+where + P: Point, + Rhumb: DestinationStrategy

, +{ + rhumb_destination_with(origin, bearing, distance, Rhumb::default()) +} + +/// Rhumb destination using an explicit radius strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "rhumb strategies are small Copy radius configurations" +)] +pub fn rhumb_destination_with( + origin: &P, + bearing: f64, + distance: f64, + strategy: S, +) -> S::Output +where + P: Point, + S: DestinationStrategy

, +{ + strategy.destination(origin, bearing, distance) +} + +/// Sum rhumb-line distances along a linestring. +#[inline] +#[must_use] +pub fn rhumb_length(line: &L) -> f64 +where + L: Linestring, + Rhumb: LengthStrategy, +{ + rhumb_length_with(line, Rhumb::default()) +} + +/// Sum rhumb-line distances using an explicit radius strategy. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "rhumb strategies are small Copy radius configurations" +)] +pub fn rhumb_length_with(line: &L, strategy: S) -> S::Out +where + L: Geometry, + S: LengthStrategy, +{ + strategy.length(line) +} + +type PointOutput

= >::Output; + +#[cfg(test)] +mod tests { + use geometry_cs::{Degree, Spherical}; + use geometry_model::{Linestring, Point2D}; + use geometry_trait::Point as _; + + use super::*; + + #[test] + fn named_entries_share_one_metric() { + type P = Point2D>; + let start = P::new(0.0, 0.0); + let east = P::new(1.0, 0.0); + let distance = rhumb_distance(&start, &east); + assert!((rhumb_azimuth(&start, &east) - core::f64::consts::FRAC_PI_2).abs() < 1e-12); + let endpoint = rhumb_destination(&start, core::f64::consts::FRAC_PI_2, distance); + assert!((endpoint.get::<0>() - 1.0).abs() < 1e-10); + let line = Linestring::from_vec(alloc::vec![start, east]); + assert!((rhumb_length(&line) - distance).abs() < 1e-9); + } +} diff --git a/crates/geometry-algorithm/src/simplify.rs b/crates/geometry-algorithm/src/simplify.rs index 87304ea..f2c359f 100644 --- a/crates/geometry-algorithm/src/simplify.rs +++ b/crates/geometry-algorithm/src/simplify.rs @@ -34,6 +34,25 @@ where strategy.simplify(g, max_distance) } +/// Return a simplified copy of `g` using an explicit strategy. +/// +/// Mirrors the strategy overload of `boost::geometry::simplify` from +/// `boost/geometry/algorithms/simplify.hpp:993-1011`. The strategy is the +/// final argument so calls read consistently with other `_with` entries in +/// this crate. +#[inline] +#[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "simplify strategies are zero-sized or small Copy values, matching the crate's other _with entries" +)] +pub fn simplify_with(g: &G, max_distance: f64, strategy: S) -> S::Output +where + S: SimplifyStrategy, +{ + strategy.simplify(g, max_distance) +} + #[cfg(test)] #[allow( clippy::float_cmp, diff --git a/crates/geometry-algorithm/src/triangulate_earcut.rs b/crates/geometry-algorithm/src/triangulate_earcut.rs new file mode 100644 index 0000000..43653af --- /dev/null +++ b/crates/geometry-algorithm/src/triangulate_earcut.rs @@ -0,0 +1,470 @@ +//! Native ear-clipping triangulation for Cartesian polygons. +//! +//! Boost.Geometry has no triangulation entry. This implementation follows the +//! classic Meisters ear-clipping method, using the adaptive exact-sign +//! [`geometry_coords::precise_math::orient2d`] predicate for every turn test. +//! Interior rings are connected to the exterior by non-crossing visibility +//! bridges before clipping. + +use alloc::{vec, vec::Vec}; + +use geometry_coords::precise_math; +use geometry_cs::{CartesianFamily, CoordinateSystem}; +use geometry_model::{Polygon as ModelPolygon, Ring as ModelRing}; +use geometry_tag::SameAs; +use geometry_trait::{Point, Polygon, Ring}; + +/// Triangulate a Cartesian polygon into owned clockwise, closed stock polygons. +/// +/// Degenerate rings and interiors that cannot be bridged into the exterior +/// return an empty vector. Every returned polygon has exactly three distinct +/// vertices plus the closing duplicate. +#[inline] +#[must_use] +pub fn triangulate_earcut(polygon: &Pg) -> Vec> +where + Pg: Polygon, + P: Point + Copy, + P::Cs: CoordinateSystem, + ::Family: SameAs, +{ + let mut exterior = ring_vertices(polygon.exterior()); + if exterior.len() < 3 { + return Vec::new(); + } + make_counter_clockwise(&mut exterior); + + let mut holes: Vec> = polygon + .interiors() + .map(ring_vertices) + .filter(|ring| ring.len() >= 3) + .collect(); + for hole in &mut holes { + make_clockwise(hole); + } + for hole_index in 0..holes.len() { + let Some(merged) = bridge_hole(&exterior, &holes[hole_index], &holes) else { + return Vec::new(); + }; + exterior = merged; + } + + clip_ears(&exterior) +} + +fn ring_vertices(ring: &R) -> Vec

+where + R: Ring, + P: Point + Copy, +{ + let mut points: Vec

= ring.points().copied().collect(); + while points.len() > 1 && same_point(&points[0], points.last().unwrap_or(&points[0])) { + points.pop(); + } + points.dedup_by(|second, first| same_point(first, second)); + points +} + +fn make_counter_clockwise>(points: &mut [P]) { + if signed_area(points) < 0.0 { + points.reverse(); + } +} + +fn make_clockwise>(points: &mut [P]) { + if signed_area(points) > 0.0 { + points.reverse(); + } +} + +fn signed_area>(points: &[P]) -> f64 { + if points.len() < 3 { + return 0.0; + } + let mut area = 0.0; + for index in 0..points.len() { + let first = &points[index]; + let second = &points[(index + 1) % points.len()]; + area += first.get::<0>() * second.get::<1>() - second.get::<0>() * first.get::<1>(); + } + area / 2.0 +} + +fn bridge_hole

(exterior: &[P], hole: &[P], holes: &[Vec

]) -> Option> +where + P: Point + Copy, +{ + let hole_vertex = hole + .iter() + .enumerate() + .max_by(|(_, left), (_, right)| { + left.get::<0>() + .total_cmp(&right.get::<0>()) + .then_with(|| right.get::<1>().total_cmp(&left.get::<1>())) + })? + .0; + let source = hole[hole_vertex]; + + let exterior_vertex = (0..exterior.len()) + .filter(|&index| bridge_is_visible(source, exterior[index], index, exterior, hole, holes)) + .min_by(|&left, &right| { + squared_distance(source, exterior[left]) + .total_cmp(&squared_distance(source, exterior[right])) + })?; + + let mut merged = Vec::with_capacity(exterior.len() + hole.len() + 2); + merged.extend_from_slice(&exterior[..=exterior_vertex]); + merged.push(source); + for offset in 1..hole.len() { + merged.push(hole[(hole_vertex + offset) % hole.len()]); + } + merged.push(source); + merged.push(exterior[exterior_vertex]); + merged.extend_from_slice(&exterior[exterior_vertex + 1..]); + Some(merged) +} + +fn bridge_is_visible

( + source: P, + target: P, + target_index: usize, + exterior: &[P], + source_hole: &[P], + holes: &[Vec

], +) -> bool +where + P: Point + Copy, +{ + if same_point(&source, &target) { + return false; + } + for edge in 0..exterior.len() { + let next = (edge + 1) % exterior.len(); + if edge == target_index || next == target_index { + continue; + } + if segments_intersect(source, target, exterior[edge], exterior[next]) { + return false; + } + } + for ring in holes { + for edge in 0..ring.len() { + let next = (edge + 1) % ring.len(); + if core::ptr::eq(ring.as_slice(), source_hole) + && (same_point(&ring[edge], &source) || same_point(&ring[next], &source)) + { + continue; + } + if segments_intersect(source, target, ring[edge], ring[next]) { + return false; + } + } + } + let midpoint = [ + source.get::<0>() / 2.0 + target.get::<0>() / 2.0, + source.get::<1>() / 2.0 + target.get::<1>() / 2.0, + ]; + point_in_ring(midpoint, exterior) + && holes.iter().all(|ring| { + core::ptr::eq(ring.as_slice(), source_hole) || !point_in_ring(midpoint, ring) + }) +} + +fn clip_ears

(points: &[P]) -> Vec> +where + P: Point + Copy, +{ + if points.len() < 3 { + return Vec::new(); + } + let mut indices: Vec = (0..points.len()).collect(); + let mut triangles = Vec::with_capacity(points.len().saturating_sub(2)); + while indices.len() > 3 { + let mut clipped = false; + for position in 0..indices.len() { + let previous = indices[(position + indices.len() - 1) % indices.len()]; + let current = indices[position]; + let next = indices[(position + 1) % indices.len()]; + if !is_ear(points, &indices, previous, current, next) { + continue; + } + triangles.push(triangle(points[previous], points[current], points[next])); + indices.remove(position); + clipped = true; + break; + } + if !clipped { + if let Some(position) = removable_collinear(points, &indices) { + indices.remove(position); + } else { + return Vec::new(); + } + } + } + if orientation(points[indices[0]], points[indices[1]], points[indices[2]]) == 0.0 { + return Vec::new(); + } + triangles.push(triangle( + points[indices[0]], + points[indices[1]], + points[indices[2]], + )); + triangles +} + +fn is_ear

(points: &[P], polygon: &[usize], previous: usize, current: usize, next: usize) -> bool +where + P: Point + Copy, +{ + let a = points[previous]; + let b = points[current]; + let c = points[next]; + if orientation(a, b, c) <= 0.0 { + return false; + } + if diagonal_crosses_polygon(points, polygon, previous, next) { + return false; + } + !polygon.iter().copied().any(|index| { + index != previous + && index != current + && index != next + && !same_point(&points[index], &a) + && !same_point(&points[index], &b) + && !same_point(&points[index], &c) + && point_in_triangle(points[index], a, b, c) + }) +} + +fn diagonal_crosses_polygon

(points: &[P], polygon: &[usize], first: usize, second: usize) -> bool +where + P: Point + Copy, +{ + for edge in 0..polygon.len() { + let edge_first = polygon[edge]; + let edge_second = polygon[(edge + 1) % polygon.len()]; + if edge_first == first + || edge_second == first + || edge_first == second + || edge_second == second + || same_point(&points[edge_first], &points[first]) + || same_point(&points[edge_second], &points[first]) + || same_point(&points[edge_first], &points[second]) + || same_point(&points[edge_second], &points[second]) + { + continue; + } + if segments_intersect( + points[first], + points[second], + points[edge_first], + points[edge_second], + ) { + return true; + } + } + false +} + +fn removable_collinear

(points: &[P], polygon: &[usize]) -> Option +where + P: Point + Copy, +{ + (0..polygon.len()).find(|&position| { + let previous = polygon[(position + polygon.len() - 1) % polygon.len()]; + let current = polygon[position]; + let next = polygon[(position + 1) % polygon.len()]; + same_point(&points[previous], &points[current]) + || same_point(&points[current], &points[next]) + || orientation(points[previous], points[current], points[next]) == 0.0 + }) +} + +fn triangle + Copy>(a: P, b: P, c: P) -> ModelPolygon

{ + // Ear clipping works counter-clockwise; swap the final two vertices so the + // stock polygon's default clockwise order reports positive area. + ModelPolygon::new(ModelRing::from_vec(vec![a, c, b, a])) +} + +fn point_in_triangle + Copy>(point: P, a: P, b: P, c: P) -> bool { + orientation(a, b, point) >= 0.0 + && orientation(b, c, point) >= 0.0 + && orientation(c, a, point) >= 0.0 +} + +fn point_in_ring>(point: [f64; 2], ring: &[P]) -> bool { + let mut inside = false; + for index in 0..ring.len() { + let first = &ring[index]; + let second = &ring[(index + 1) % ring.len()]; + let crosses = (first.get::<1>() > point[1]) != (second.get::<1>() > point[1]); + if crosses { + let x = (second.get::<0>() - first.get::<0>()) * (point[1] - first.get::<1>()) + / (second.get::<1>() - first.get::<1>()) + + first.get::<0>(); + if point[0] < x { + inside = !inside; + } + } + } + inside +} + +fn segments_intersect

(a: P, b: P, c: P, d: P) -> bool +where + P: Point + Copy, +{ + let ab_c = orientation(a, b, c); + let ab_d = orientation(a, b, d); + let cd_a = orientation(c, d, a); + let cd_b = orientation(c, d, b); + if ab_c == 0.0 && on_segment(a, b, c) { + return true; + } + if ab_d == 0.0 && on_segment(a, b, d) { + return true; + } + if cd_a == 0.0 && on_segment(c, d, a) { + return true; + } + if cd_b == 0.0 && on_segment(c, d, b) { + return true; + } + (ab_c > 0.0) != (ab_d > 0.0) && (cd_a > 0.0) != (cd_b > 0.0) +} + +#[allow( + clippy::needless_pass_by_value, + reason = "ear clipping operates on Copy point handles throughout" +)] +fn orientation>(a: P, b: P, c: P) -> f64 { + precise_math::orient2d( + [a.get::<0>(), a.get::<1>()], + [b.get::<0>(), b.get::<1>()], + [c.get::<0>(), c.get::<1>()], + ) +} + +fn on_segment + Copy>(a: P, b: P, point: P) -> bool { + point.get::<0>() >= a.get::<0>().min(b.get::<0>()) + && point.get::<0>() <= a.get::<0>().max(b.get::<0>()) + && point.get::<1>() >= a.get::<1>().min(b.get::<1>()) + && point.get::<1>() <= a.get::<1>().max(b.get::<1>()) +} + +fn squared_distance + Copy>(first: P, second: P) -> f64 { + let dx = second.get::<0>() - first.get::<0>(); + let dy = second.get::<1>() - first.get::<1>(); + dx * dx + dy * dy +} + +#[allow( + clippy::float_cmp, + reason = "coordinate identity, not approximate geometric equality, is required" +)] +fn same_point>(first: &P, second: &P) -> bool { + first.get::<0>() == second.get::<0>() && first.get::<1>() == second.get::<1>() +} + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::{Point2D, Polygon, Ring}; + + use super::*; + use crate::area::area; + + #[test] + fn concave_pentagon_becomes_three_triangles() { + type P = Point2D; + let polygon: Polygon

= Polygon::new(Ring::from_vec(alloc::vec![ + P::new(0.0, 0.0), + P::new(0.0, 2.0), + P::new(1.0, 1.0), + P::new(2.0, 2.0), + P::new(2.0, 0.0), + P::new(0.0, 0.0), + ])); + let triangles = triangulate_earcut(&polygon); + assert_eq!(triangles.len(), 3); + let sum: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum(); + assert!((sum - area(&polygon).abs()).abs() < 1e-12); + } + + #[test] + fn square_with_square_hole_preserves_area() { + type P = Point2D; + let outer: Ring

= Ring::from_vec(alloc::vec![ + P::new(0.0, 0.0), + P::new(0.0, 4.0), + P::new(4.0, 4.0), + P::new(4.0, 0.0), + P::new(0.0, 0.0), + ]); + let hole: Ring

= Ring::from_vec(alloc::vec![ + P::new(1.0, 1.0), + P::new(3.0, 1.0), + P::new(3.0, 3.0), + P::new(1.0, 3.0), + P::new(1.0, 1.0), + ]); + let polygon = Polygon::with_inners(outer, alloc::vec![hole]); + + let triangles = triangulate_earcut(&polygon); + + assert_eq!(triangles.len(), 8); + let sum: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum(); + assert!((sum - area(&polygon).abs()).abs() < 1e-12); + } + + #[test] + fn private_degenerate_clipping_and_intersection_guards() { + type P = Point2D; + assert!(signed_area(&[P::new(0.0, 0.0), P::new(1.0, 0.0)]).abs() < f64::EPSILON); + assert!(clip_ears::

(&[]).is_empty()); + assert!( + clip_ears(&[ + P::new(0.0, 0.0), + P::new(1.0, 0.0), + P::new(2.0, 0.0), + P::new(3.0, 0.0), + ]) + .is_empty() + ); + + assert!(segments_intersect( + P::new(0.0, 0.0), + P::new(2.0, 0.0), + P::new(1.0, 0.0), + P::new(1.0, 1.0), + )); + assert!(segments_intersect( + P::new(0.0, 0.0), + P::new(2.0, 0.0), + P::new(1.0, 1.0), + P::new(1.0, 0.0), + )); + assert!(segments_intersect( + P::new(1.0, 0.0), + P::new(1.0, 1.0), + P::new(0.0, 0.0), + P::new(2.0, 0.0), + )); + assert!(segments_intersect( + P::new(1.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, 0.0), + P::new(2.0, 0.0), + )); + + let exterior = [P::new(0.0, 0.0), P::new(2.0, 0.0), P::new(0.0, 2.0)]; + assert!(!bridge_is_visible( + exterior[0], + exterior[0], + 0, + &exterior, + &[], + &[], + )); + } +} diff --git a/crates/geometry-coords/src/math.rs b/crates/geometry-coords/src/math.rs index 2fe6d54..fd6be64 100644 --- a/crates/geometry-coords/src/math.rs +++ b/crates/geometry-coords/src/math.rs @@ -87,6 +87,21 @@ pub fn ceil(value: T) -> T { value.ceil() } +/// Tangent of a floating-point coordinate in radians. +pub fn tan(value: T) -> T { + value.tan() +} + +/// Natural logarithm of a positive floating-point coordinate. +pub fn ln(value: T) -> T { + value.ln() +} + +/// Least non-negative remainder of `value` divided by `modulus`. +pub fn rem_euclid(value: T, modulus: T) -> T { + value.rem_euclid(modulus) +} + /// Sealed marker for the floating-point types this crate dispatches /// math primitives over (`f32`, `f64`). /// @@ -128,6 +143,15 @@ pub trait Float: private::Sealed + Copy { /// `value.ceil()` dispatched onto `std` or `libm`. #[must_use] fn ceil(self) -> Self; + /// `value.tan()` dispatched onto `std` or `libm`. + #[must_use] + fn tan(self) -> Self; + /// `value.ln()` dispatched onto `std` or `libm`. + #[must_use] + fn ln(self) -> Self; + /// `value.rem_euclid(modulus)` dispatched onto `std` or core arithmetic. + #[must_use] + fn rem_euclid(self, modulus: Self) -> Self; } impl Float for f32 { @@ -218,6 +242,44 @@ impl Float for f32 { fn ceil(self) -> Self { libm::ceilf(self) } + + #[cfg(feature = "std")] + #[inline] + fn tan(self) -> Self { + f32::tan(self) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn tan(self) -> Self { + libm::tanf(self) + } + + #[cfg(feature = "std")] + #[inline] + fn ln(self) -> Self { + f32::ln(self) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn ln(self) -> Self { + libm::logf(self) + } + + #[cfg(feature = "std")] + #[inline] + fn rem_euclid(self, modulus: Self) -> Self { + f32::rem_euclid(self, modulus) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn rem_euclid(self, modulus: Self) -> Self { + let remainder = self % modulus; + if remainder < 0.0 { + remainder + libm::fabsf(modulus) + } else { + remainder + } + } } impl Float for f64 { @@ -308,6 +370,44 @@ impl Float for f64 { fn ceil(self) -> Self { libm::ceil(self) } + + #[cfg(feature = "std")] + #[inline] + fn tan(self) -> Self { + f64::tan(self) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn tan(self) -> Self { + libm::tan(self) + } + + #[cfg(feature = "std")] + #[inline] + fn ln(self) -> Self { + f64::ln(self) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn ln(self) -> Self { + libm::log(self) + } + + #[cfg(feature = "std")] + #[inline] + fn rem_euclid(self, modulus: Self) -> Self { + f64::rem_euclid(self, modulus) + } + #[cfg(all(not(feature = "std"), feature = "libm"))] + #[inline] + fn rem_euclid(self, modulus: Self) -> Self { + let remainder = self % modulus; + if remainder < 0.0 { + remainder + libm::fabs(modulus) + } else { + remainder + } + } } mod private { diff --git a/crates/geometry-coords/src/precise_math.rs b/crates/geometry-coords/src/precise_math.rs index c699446..f919f44 100644 --- a/crates/geometry-coords/src/precise_math.rs +++ b/crates/geometry-coords/src/precise_math.rs @@ -275,10 +275,9 @@ impl Expansion { result.terms[result.length] = tail; result.length += 1; } - if head != 0.0 || result.length == 0 { - result.terms[result.length] = head; - result.length += 1; - } + debug_assert!(head != 0.0 || result.length == 0); + result.terms[result.length] = head; + result.length += 1; result } diff --git a/crates/geometry-coords/tests/scalar_arithmetic.rs b/crates/geometry-coords/tests/scalar_arithmetic.rs index 53c65cd..c37e851 100644 --- a/crates/geometry-coords/tests/scalar_arithmetic.rs +++ b/crates/geometry-coords/tests/scalar_arithmetic.rs @@ -4,7 +4,9 @@ //! the public `std`/`libm` dispatch boundary. use geometry_coords::CoordinateScalar; -use geometry_coords::math::{atan2, ceil, cos, hypot, mul_add, sin}; +use geometry_coords::math::{ + abs, atan2, ceil, cos, hypot, ln, mul_add, rem_euclid, sin, sqrt, tan, +}; #[test] fn integer_abs_is_callable() { @@ -27,10 +29,38 @@ fn integer_square_root_rejects_unpromoted_arithmetic() { #[test] fn public_math_dispatch_covers_robust_and_overlay_primitives() { + assert!((sqrt(25.0_f64) - 5.0).abs() < f64::EPSILON); + assert!((abs(-2.5_f64) - 2.5).abs() < f64::EPSILON); assert!((mul_add(2.0_f64, 3.0, 4.0) - 10.0).abs() < f64::EPSILON); assert!((hypot(3.0_f64, 4.0) - 5.0).abs() < f64::EPSILON); assert!((ceil(2.25_f64) - 3.0).abs() < f64::EPSILON); assert!((sin(core::f64::consts::FRAC_PI_2) - 1.0).abs() < 1e-15); assert!((cos(core::f64::consts::PI) + 1.0).abs() < 1e-15); assert!((atan2(1.0_f64, 0.0) - core::f64::consts::FRAC_PI_2).abs() < 1e-15); + assert!((tan(core::f64::consts::FRAC_PI_4) - 1.0).abs() < 1e-15); + assert!(ln(1.0_f64).abs() < f64::EPSILON); + assert!((rem_euclid(-0.5_f64, 2.0) - 1.5).abs() < f64::EPSILON); + assert!((rem_euclid(0.5_f64, 2.0) - 0.5).abs() < f64::EPSILON); +} + +/// Boost's `test/util/math_abs.cpp` and `math_sqrt.cpp` exercise both native +/// floating widths. The additional primitives have no equivalent upstream +/// facade test, so exercise their `f32` dispatch directly through this crate's +/// public API, including both branches of Euclidean remainder normalization. +#[test] +fn public_math_dispatch_supports_f32() { + let epsilon = 1e-6_f32; + + assert!((sqrt(25.0_f32) - 5.0).abs() < epsilon); + assert!((abs(-2.5_f32) - 2.5).abs() < epsilon); + assert!((mul_add(2.0_f32, 3.0, 4.0) - 10.0).abs() < epsilon); + assert!((hypot(3.0_f32, 4.0) - 5.0).abs() < epsilon); + assert!((ceil(2.25_f32) - 3.0).abs() < epsilon); + assert!((sin(core::f32::consts::FRAC_PI_2) - 1.0).abs() < epsilon); + assert!((cos(core::f32::consts::PI) + 1.0).abs() < epsilon); + assert!((atan2(1.0_f32, 0.0) - core::f32::consts::FRAC_PI_2).abs() < epsilon); + assert!((tan(core::f32::consts::FRAC_PI_4) - 1.0).abs() < epsilon); + assert!(ln(1.0_f32).abs() < epsilon); + assert!((rem_euclid(-0.5_f32, 2.0) - 1.5).abs() < epsilon); + assert!((rem_euclid(0.5_f32, 2.0) - 0.5).abs() < epsilon); } diff --git a/crates/geometry-io-geojson/src/json.rs b/crates/geometry-io-geojson/src/json.rs index dbaabdb..e533180 100644 --- a/crates/geometry-io-geojson/src/json.rs +++ b/crates/geometry-io-geojson/src/json.rs @@ -237,7 +237,9 @@ impl JsonParser<'_> { Some(b't') => self.expect_literal("true", JsonValue::Bool(true)), Some(b'f') => self.expect_literal("false", JsonValue::Bool(false)), Some(b'n') => self.expect_literal("null", JsonValue::Null), - Some(b) if b == b'-' || b.is_ascii_digit() => self.parse_number(), + Some(b) if b == b'-' || b.is_ascii_digit() => { + Ok(JsonValue::Number(self.parse_number()?)) + } Some(b) => Err(GeoJsonError::Json(alloc::format!( "unexpected character {:?}", b as char @@ -328,9 +330,7 @@ impl JsonParser<'_> { if !matches!(self.peek(), Some(b'-' | b'0'..=b'9')) { return Ok(None); } - let JsonValue::Number(x) = self.parse_number()? else { - unreachable!("parse_number always returns a number"); - }; + let x = self.parse_number()?; self.skip_ws(); if self.peek() != Some(b',') { self.pos = start; @@ -342,9 +342,7 @@ impl JsonParser<'_> { self.pos = start; return Ok(None); } - let JsonValue::Number(y) = self.parse_number()? else { - unreachable!("parse_number always returns a number"); - }; + let y = self.parse_number()?; self.skip_ws(); if self.peek() == Some(b']') { self.pos += 1; @@ -394,8 +392,9 @@ impl JsonParser<'_> { let rest = &self.bytes[self.pos..]; let ch_len = utf8_char_len(rest[0]); let slice = rest.get(..ch_len).ok_or(GeoJsonError::UnexpectedEof)?; - let s = core::str::from_utf8(slice) - .map_err(|_| GeoJsonError::Json("invalid UTF-8 in string".to_string()))?; + let s = core::str::from_utf8(slice).expect( + "GeoJSON input is valid UTF-8 and the cursor advances by characters", + ); out.push_str(s); self.pos += ch_len; } @@ -406,7 +405,7 @@ impl JsonParser<'_> { /// Parse a number: optional sign, integer part, optional fraction, /// optional `e`/`E` exponent. The lexeme is handed to Rust's `f64` /// parser. - fn parse_number(&mut self) -> Result { + fn parse_number(&mut self) -> Result { let start = self.pos; if self.peek() == Some(b'-') { self.pos += 1; @@ -420,10 +419,13 @@ impl JsonParser<'_> { } let slice = &self.bytes[start..self.pos]; let text = core::str::from_utf8(slice) - .map_err(|_| GeoJsonError::Json("invalid number".to_string()))?; - text.parse::() - .map(JsonValue::Number) - .map_err(|_| GeoJsonError::Json(alloc::format!("invalid number {text:?}"))) + .expect("number tokens contain only ASCII bytes copied from valid UTF-8 input"); + match text.parse::() { + Ok(number) => Ok(number), + Err(_) => Err(GeoJsonError::Json(alloc::format!( + "invalid number {text:?}" + ))), + } } } diff --git a/crates/geometry-io-geojson/src/lib.rs b/crates/geometry-io-geojson/src/lib.rs index e2fb6c2..25fdf61 100644 --- a/crates/geometry-io-geojson/src/lib.rs +++ b/crates/geometry-io-geojson/src/lib.rs @@ -103,5 +103,8 @@ mod parse; mod write; pub use json::GeoJsonError; +// feature-group: I/O — GeoJSON +// feature-desc: Parse and write GeoJSON (RFC 7946) pub use parse::from_geojson; +// feature-group: I/O — GeoJSON pub use write::{WriteGeoJson, to_geojson, to_geojson_polygon}; diff --git a/crates/geometry-io-geojson/src/parse.rs b/crates/geometry-io-geojson/src/parse.rs index 711744e..212c683 100644 --- a/crates/geometry-io-geojson/src/parse.rs +++ b/crates/geometry-io-geojson/src/parse.rs @@ -87,9 +87,10 @@ fn parse_geometry(value: &JsonValue) -> Result, GeoJ coords(value)?, )?))), "Polygon" => Ok(DynGeometry::Polygon(read_polygon(coords(value)?)?)), - "MultiPoint" => Ok(DynGeometry::MultiPoint(MultiPoint(read_positions( - coords(value)?, - )?))), + "MultiPoint" => { + let points = read_positions(coords(value)?)?; + Ok(DynGeometry::MultiPoint(MultiPoint(points))) + } "MultiLineString" => read_multi_linestring(coords(value)?), "MultiPolygon" => read_multi_polygon(coords(value)?), "GeometryCollection" => read_geometry_collection(value), @@ -218,45 +219,31 @@ mod tests { )] use super::from_geojson; - use geometry_model::{DynGeometry, DynKind}; - use geometry_trait::{Linestring as _, MultiPolygon as _, Point as _, Polygon as _, Ring as _}; + use geometry_model::{DynGeometry, DynKind, Linestring, MultiPolygon, Point2D, Polygon, Ring}; #[test] fn point_example() { let g = from_geojson(r#"{"type":"Point","coordinates":[100.0,0.0]}"#).unwrap(); - assert_eq!(g.kind(), DynKind::Point); - if let DynGeometry::Point(p) = g { - assert_eq!(p.get::<0>(), 100.0); - assert_eq!(p.get::<1>(), 0.0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::Point(Point2D::new(100.0, 0.0))); } #[test] fn point_altitude_ordinate_is_ignored() { let g = from_geojson(r#"{"type":"Point","coordinates":[100.0,0.0,500.0]}"#).unwrap(); - if let DynGeometry::Point(p) = g { - assert_eq!(p.get::<0>(), 100.0); - assert_eq!(p.get::<1>(), 0.0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::Point(Point2D::new(100.0, 0.0))); } #[test] fn linestring_example() { let g = from_geojson(r#"{"type":"LineString","coordinates":[[100.0,0.0],[101.0,1.0]]}"#) .unwrap(); - assert_eq!(g.kind(), DynKind::LineString); - if let DynGeometry::LineString(ls) = g { - assert_eq!(ls.points().len(), 2); - let last = ls.points().last().unwrap(); - assert_eq!(last.get::<0>(), 101.0); - assert_eq!(last.get::<1>(), 1.0); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::LineString(Linestring::from_vec(vec![ + Point2D::new(100.0, 0.0), + Point2D::new(101.0, 1.0), + ])) + ); } #[test] @@ -269,13 +256,25 @@ mod tests { ]}"#, ) .unwrap(); - assert_eq!(g.kind(), DynKind::Polygon); - if let DynGeometry::Polygon(p) = g { - assert_eq!(p.exterior().points().len(), 5); - assert_eq!(p.interiors().count(), 1); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::Polygon(Polygon::with_inners( + Ring::from_vec(vec![ + Point2D::new(100.0, 0.0), + Point2D::new(101.0, 0.0), + Point2D::new(101.0, 1.0), + Point2D::new(100.0, 1.0), + Point2D::new(100.0, 0.0), + ]), + vec![Ring::from_vec(vec![ + Point2D::new(100.8, 0.8), + Point2D::new(100.8, 0.2), + Point2D::new(100.2, 0.2), + Point2D::new(100.2, 0.8), + Point2D::new(100.8, 0.8), + ])], + )) + ); } #[test] @@ -295,12 +294,25 @@ mod tests { ]}"#, ) .unwrap(); - assert_eq!(g.kind(), DynKind::MultiPolygon); - if let DynGeometry::MultiPolygon(mpg) = g { - assert_eq!(mpg.polygons().count(), 2); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::MultiPolygon(MultiPolygon::from_vec(vec![ + Polygon::new(Ring::from_vec(vec![ + Point2D::new(102.0, 2.0), + Point2D::new(103.0, 2.0), + Point2D::new(103.0, 3.0), + Point2D::new(102.0, 3.0), + Point2D::new(102.0, 2.0), + ])), + Polygon::new(Ring::from_vec(vec![ + Point2D::new(100.0, 0.0), + Point2D::new(101.0, 0.0), + Point2D::new(101.0, 1.0), + Point2D::new(100.0, 1.0), + Point2D::new(100.0, 0.0), + ])), + ])) + ); } #[test] @@ -313,14 +325,16 @@ mod tests { ]}"#, ) .unwrap(); - assert_eq!(g.kind(), DynKind::GeometryCollection); - if let DynGeometry::GeometryCollection(items) = g { - assert_eq!(items.len(), 2); - assert_eq!(items[0].kind(), DynKind::Point); - assert_eq!(items[1].kind(), DynKind::LineString); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::GeometryCollection(vec![ + DynGeometry::Point(Point2D::new(100.0, 0.0)), + DynGeometry::LineString(Linestring::from_vec(vec![ + Point2D::new(101.0, 0.0), + Point2D::new(102.0, 1.0), + ])), + ]) + ); } #[test] diff --git a/crates/geometry-io-svg/src/lib.rs b/crates/geometry-io-svg/src/lib.rs index ae33be5..39a2d5c 100644 --- a/crates/geometry-io-svg/src/lib.rs +++ b/crates/geometry-io-svg/src/lib.rs @@ -13,4 +13,7 @@ extern crate alloc; mod mapper; +// feature-group: I/O — SVG +// feature-desc: Render geometries to SVG (debugging) +// feature-keep: SvgMapper pub use mapper::SvgMapper; diff --git a/crates/geometry-io-wkb/src/lib.rs b/crates/geometry-io-wkb/src/lib.rs index d55a57c..5acb479 100644 --- a/crates/geometry-io-wkb/src/lib.rs +++ b/crates/geometry-io-wkb/src/lib.rs @@ -99,5 +99,8 @@ mod parse; mod write; pub use header::{ByteOrder, WkbError}; +// feature-group: I/O — Well-Known Binary +// feature-desc: Parse and write the OGC WKB format pub use parse::from_wkb; +// feature-group: I/O — Well-Known Binary pub use write::{WriteWkb, to_wkb, to_wkb_polygon}; diff --git a/crates/geometry-io-wkb/src/parse.rs b/crates/geometry-io-wkb/src/parse.rs index 0cfcd6c..f7fcb0c 100644 --- a/crates/geometry-io-wkb/src/parse.rs +++ b/crates/geometry-io-wkb/src/parse.rs @@ -387,8 +387,6 @@ mod tests { use super::*; use alloc::vec; - use geometry_model::DynKind; - use geometry_trait::{Linestring as _, Point as _, Polygon as _, Ring as _}; /// The 8 little-endian bytes of the f64 `1.0`. const F1: [u8; 8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F]; @@ -404,12 +402,7 @@ mod tests { b.extend_from_slice(&F1); b.extend_from_slice(&F2); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::Point); - let DynGeometry::Point(p) = g else { - unreachable!() - }; - assert_eq!(p.get::<0>(), 1.0); - assert_eq!(p.get::<1>(), 2.0); + assert_eq!(g, DynGeometry::Point(Pt::new(1.0, 2.0))); } #[test] @@ -421,14 +414,13 @@ mod tests { b.extend_from_slice(&F3); b.extend_from_slice(&F1); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::LineString); - let DynGeometry::LineString(ls) = g else { - unreachable!() - }; - assert_eq!(ls.points().len(), 2); - let last = ls.points().last().unwrap(); - assert_eq!(last.get::<0>(), 3.0); - assert_eq!(last.get::<1>(), 1.0); + assert_eq!( + g, + DynGeometry::LineString(Linestring::from_vec(vec![ + Pt::new(1.0, 2.0), + Pt::new(3.0, 1.0), + ])) + ); } #[test] @@ -446,12 +438,14 @@ mod tests { b.extend_from_slice(&F1); b.extend_from_slice(&F2); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::Polygon); - let DynGeometry::Polygon(pg) = g else { - unreachable!() - }; - assert_eq!(pg.exterior().points().len(), 3); - assert_eq!(pg.interiors().count(), 0); + assert_eq!( + g, + DynGeometry::Polygon(Polygon::new(Ring::from_vec(vec![ + Pt::new(1.0, 2.0), + Pt::new(3.0, 1.0), + Pt::new(1.0, 2.0), + ]))) + ); } #[test] @@ -563,12 +557,13 @@ mod tests { b.extend_from_slice(&le_point_record()); b.extend_from_slice(&le_point_record()); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::MultiPoint); - let DynGeometry::MultiPoint(mp) = g else { - unreachable!() - }; - assert_eq!(mp.0.len(), 2); - assert_eq!(mp.0[0].get::<0>(), 1.0); + assert_eq!( + g, + DynGeometry::MultiPoint(MultiPoint::from_vec(vec![ + Pt::new(1.0, 2.0), + Pt::new(1.0, 2.0), + ])) + ); } /// A valid `MultiLineString` of one empty member parses (the happy @@ -578,11 +573,12 @@ mod tests { let mut b = vec![0x01, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]; b.extend_from_slice(&le_empty_linestring_record()); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::MultiLineString); - let DynGeometry::MultiLineString(mls) = g else { - unreachable!() - }; - assert_eq!(mls.0.len(), 1); + assert_eq!( + g, + DynGeometry::MultiLineString(MultiLinestring::from_vec(vec![Linestring::from_vec( + Vec::new() + ),])) + ); } /// A valid `MultiPolygon` of one empty member parses (the happy @@ -592,11 +588,10 @@ mod tests { let mut b = vec![0x01, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]; b.extend_from_slice(&le_empty_polygon_record()); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::MultiPolygon); - let DynGeometry::MultiPolygon(mpg) = g else { - unreachable!() - }; - assert_eq!(mpg.0.len(), 1); + assert_eq!( + g, + DynGeometry::MultiPolygon(MultiPolygon::from_vec(vec![Polygon::new(Ring::new())])) + ); } /// A `GeometryCollection` mixing a point and a line string parses, @@ -607,13 +602,13 @@ mod tests { b.extend_from_slice(&le_point_record()); b.extend_from_slice(&le_empty_linestring_record()); let g = from_wkb(&b).unwrap(); - assert_eq!(g.kind(), DynKind::GeometryCollection); - let DynGeometry::GeometryCollection(items) = g else { - unreachable!() - }; - assert_eq!(items.len(), 2); - assert_eq!(items[0].kind(), DynKind::Point); - assert_eq!(items[1].kind(), DynKind::LineString); + assert_eq!( + g, + DynGeometry::GeometryCollection(vec![ + DynGeometry::Point(Pt::new(1.0, 2.0)), + DynGeometry::LineString(Linestring::from_vec(Vec::new())), + ]) + ); } /// A `MultiLineString` whose member is a `Point` reports the mismatch diff --git a/crates/geometry-io-wkt/src/lexer.rs b/crates/geometry-io-wkt/src/lexer.rs index cc8cacb..e8df581 100644 --- a/crates/geometry-io-wkt/src/lexer.rs +++ b/crates/geometry-io-wkt/src/lexer.rs @@ -342,10 +342,10 @@ mod tests { ] { let expected = literal.parse::().unwrap(); let tokens = tokenize(literal).unwrap(); - let Token::Number(actual) = tokens[0] else { - panic!("expected number token"); - }; - assert_eq!(actual.to_bits(), expected.to_bits(), "literal {literal}"); + assert!( + matches!(&tokens[0], Token::Number(actual) if actual.to_bits() == expected.to_bits()), + "literal {literal}: expected number token {expected:?}" + ); } } diff --git a/crates/geometry-io-wkt/src/lib.rs b/crates/geometry-io-wkt/src/lib.rs index 0f9d94b..3c73faf 100644 --- a/crates/geometry-io-wkt/src/lib.rs +++ b/crates/geometry-io-wkt/src/lib.rs @@ -103,8 +103,11 @@ mod parse; mod write; pub use lexer::{Token, WktError}; +// feature-group: I/O — Well-Known Text +// feature-desc: Parse and write the OGC WKT format pub use parse::{ from_wkt, parse_linestring, parse_multi_linestring, parse_multi_point, parse_multi_polygon, parse_point, parse_polygon, }; +// feature-group: I/O — Well-Known Text pub use write::{WriteWkt, to_wkt, to_wkt_polygon, write_wkt}; diff --git a/crates/geometry-io-wkt/src/parse.rs b/crates/geometry-io-wkt/src/parse.rs index 216631e..666a4aa 100644 --- a/crates/geometry-io-wkt/src/parse.rs +++ b/crates/geometry-io-wkt/src/parse.rs @@ -107,12 +107,10 @@ impl<'a> Parser<'a> { /// `boost/geometry/io/wkt/read.hpp` (it likewise reads only the /// coordinates its point type declares). fn skip_dimension_suffix(&mut self) -> Result<(), WktError> { - if let Token::Ident(word) = self.peek() { - if word == "Z" || word == "M" || word == "ZM" { - self.advance()?; - } + match self.peek() { + Token::Ident(word) if word == "Z" || word == "M" || word == "ZM" => self.advance(), + _ => Ok(()), } - Ok(()) } /// Read exactly two ordinates into a 2D point. Any further ordinates @@ -582,7 +580,6 @@ mod tests { )] use super::*; - use geometry_model::DynKind; use geometry_trait::{ Linestring as _, MultiLinestring as _, MultiPoint as _, MultiPolygon as _, Point as _, Polygon as _, Ring as _, @@ -610,128 +607,140 @@ mod tests { #[test] fn point_example() { let g = from_wkt("POINT (10 10)").unwrap(); - assert_eq!(g.kind(), DynKind::Point); - if let DynGeometry::Point(p) = g { - assert_eq!(p.get::<0>(), 10.0); - assert_eq!(p.get::<1>(), 10.0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::Point(Pt::new(10.0, 10.0))); } #[test] fn linestring_example() { let g = from_wkt("LINESTRING (10 10, 20 20, 30 40)").unwrap(); - assert_eq!(g.kind(), DynKind::LineString); - if let DynGeometry::LineString(ls) = g { - assert_eq!(ls.points().len(), 3); - let last = ls.points().last().unwrap(); - assert_eq!(last.get::<0>(), 30.0); - assert_eq!(last.get::<1>(), 40.0); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::LineString(Linestring::from_vec(vec![ + Pt::new(10.0, 10.0), + Pt::new(20.0, 20.0), + Pt::new(30.0, 40.0), + ])) + ); } #[test] fn polygon_example() { let g = from_wkt("POLYGON ((10 10, 10 20, 20 20, 20 15, 10 10))").unwrap(); - assert_eq!(g.kind(), DynKind::Polygon); - if let DynGeometry::Polygon(p) = g { - assert_eq!(p.exterior().points().len(), 5); - assert_eq!(p.interiors().count(), 0); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::Polygon(Polygon::new(Ring::from_vec(vec![ + Pt::new(10.0, 10.0), + Pt::new(10.0, 20.0), + Pt::new(20.0, 20.0), + Pt::new(20.0, 15.0), + Pt::new(10.0, 10.0), + ]))) + ); } #[test] fn polygon_with_hole() { let g = from_wkt("POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 2 4, 4 4, 4 2, 2 2))").unwrap(); - if let DynGeometry::Polygon(p) = g { - assert_eq!(p.interiors().count(), 1); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::Polygon(Polygon::with_inners( + Ring::from_vec(vec![ + Pt::new(0.0, 0.0), + Pt::new(0.0, 10.0), + Pt::new(10.0, 10.0), + Pt::new(10.0, 0.0), + Pt::new(0.0, 0.0), + ]), + vec![Ring::from_vec(vec![ + Pt::new(2.0, 2.0), + Pt::new(2.0, 4.0), + Pt::new(4.0, 4.0), + Pt::new(4.0, 2.0), + Pt::new(2.0, 2.0), + ])], + )) + ); } #[test] fn multipoint_parenthesised_form() { let g = from_wkt("MULTIPOINT ((10 10), (20 20))").unwrap(); - assert_eq!(g.kind(), DynKind::MultiPoint); - if let DynGeometry::MultiPoint(mp) = g { - assert_eq!(mp.points().len(), 2); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::MultiPoint(MultiPoint::from_vec(vec![ + Pt::new(10.0, 10.0), + Pt::new(20.0, 20.0), + ])) + ); } #[test] fn multipoint_bare_form() { let g = from_wkt("MULTIPOINT (10 10, 20 20)").unwrap(); - if let DynGeometry::MultiPoint(mp) = g { - assert_eq!(mp.points().len(), 2); - let second = mp.points().nth(1).unwrap(); - assert_eq!(second.get::<0>(), 20.0); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::MultiPoint(MultiPoint::from_vec(vec![ + Pt::new(10.0, 10.0), + Pt::new(20.0, 20.0), + ])) + ); } #[test] fn multilinestring_example() { let g = from_wkt("MULTILINESTRING ((10 10, 20 20), (15 15, 30 15))").unwrap(); - assert_eq!(g.kind(), DynKind::MultiLineString); - if let DynGeometry::MultiLineString(mls) = g { - assert_eq!(mls.linestrings().len(), 2); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::MultiLineString(MultiLinestring::from_vec(vec![ + Linestring::from_vec(vec![Pt::new(10.0, 10.0), Pt::new(20.0, 20.0)]), + Linestring::from_vec(vec![Pt::new(15.0, 15.0), Pt::new(30.0, 15.0)]), + ])) + ); } #[test] fn multipolygon_example() { let g = from_wkt("MULTIPOLYGON (((10 10, 10 20, 20 20, 20 15, 10 10)))").unwrap(); - assert_eq!(g.kind(), DynKind::MultiPolygon); - if let DynGeometry::MultiPolygon(mpg) = g { - assert_eq!(mpg.polygons().len(), 1); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::MultiPolygon(MultiPolygon::from_vec(vec![Polygon::new(Ring::from_vec( + vec![ + Pt::new(10.0, 10.0), + Pt::new(10.0, 20.0), + Pt::new(20.0, 20.0), + Pt::new(20.0, 15.0), + Pt::new(10.0, 10.0), + ], + ))])) + ); } #[test] fn geometrycollection_example() { let g = from_wkt("GEOMETRYCOLLECTION (POINT (10 10), LINESTRING (10 10, 20 20))").unwrap(); - assert_eq!(g.kind(), DynKind::GeometryCollection); - if let DynGeometry::GeometryCollection(items) = g { - assert_eq!(items.len(), 2); - assert_eq!(items[0].kind(), DynKind::Point); - assert_eq!(items[1].kind(), DynKind::LineString); - } else { - unreachable!(); - } + assert_eq!( + g, + DynGeometry::GeometryCollection(vec![ + DynGeometry::Point(Pt::new(10.0, 10.0)), + DynGeometry::LineString(Linestring::from_vec(vec![ + Pt::new(10.0, 10.0), + Pt::new(20.0, 20.0), + ])), + ]) + ); } #[test] fn linestring_empty() { let g = from_wkt("LINESTRING EMPTY").unwrap(); - if let DynGeometry::LineString(ls) = g { - assert_eq!(ls.points().len(), 0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::LineString(Linestring::from_vec(Vec::new()))); } #[test] fn geometrycollection_empty() { let g = from_wkt("GEOMETRYCOLLECTION EMPTY").unwrap(); - if let DynGeometry::GeometryCollection(items) = g { - assert_eq!(items.len(), 0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::GeometryCollection(Vec::new())); } #[test] @@ -762,12 +771,15 @@ mod tests { fn dimension_suffix_is_skipped() { // The Z ordinate is dropped; the 2D coordinates survive. let g = from_wkt("POINT Z (10 10 5)").unwrap(); - if let DynGeometry::Point(p) = g { - assert_eq!(p.get::<0>(), 10.0); - assert_eq!(p.get::<1>(), 10.0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::Point(Pt::new(10.0, 10.0))); + } + + #[test] + fn malformed_token_after_dimension_suffix_is_reported() { + assert_eq!( + from_wkt("POINT Z @"), + Err(WktError::UnexpectedChar { pos: 8, ch: '@' }) + ); } // ---- EMPTY forms for the remaining collection kinds -------------- @@ -777,30 +789,30 @@ mod tests { #[test] fn polygon_empty() { let g = from_wkt("POLYGON EMPTY").unwrap(); - if let DynGeometry::Polygon(p) = g { - assert_eq!(p.exterior().points().len(), 0); - assert_eq!(p.interiors().count(), 0); - } else { - unreachable!(); - } + assert_eq!(g, DynGeometry::Polygon(Polygon::new(Ring::new()))); } /// `MULTIPOINT EMPTY`, `MULTILINESTRING EMPTY`, and `MULTIPOLYGON /// EMPTY` each yield an empty container of the matching kind. #[test] fn multi_kinds_empty() { - match from_wkt("MULTIPOINT EMPTY").unwrap() { - DynGeometry::MultiPoint(mp) => assert_eq!(mp.points().len(), 0), - _ => unreachable!(), - } - match from_wkt("MULTILINESTRING EMPTY").unwrap() { - DynGeometry::MultiLineString(mls) => assert_eq!(mls.linestrings().len(), 0), - _ => unreachable!(), - } - match from_wkt("MULTIPOLYGON EMPTY").unwrap() { - DynGeometry::MultiPolygon(mpg) => assert_eq!(mpg.polygons().len(), 0), - _ => unreachable!(), - } + let multipoint = from_wkt("MULTIPOINT EMPTY").unwrap(); + assert_eq!( + multipoint, + DynGeometry::MultiPoint(MultiPoint::from_vec(Vec::new())) + ); + + let multilinestring = from_wkt("MULTILINESTRING EMPTY").unwrap(); + assert_eq!( + multilinestring, + DynGeometry::MultiLineString(MultiLinestring::from_vec(Vec::new())) + ); + + let multipolygon = from_wkt("MULTIPOLYGON EMPTY").unwrap(); + assert_eq!( + multipolygon, + DynGeometry::MultiPolygon(MultiPolygon::from_vec(Vec::new())) + ); } // ---- Grammar error branches ------------------------------------- @@ -964,13 +976,13 @@ mod tests { ), ]; for (err, want_expected, want_found) in cases { - match err { - WktError::TypeMismatch { expected, found } => { - assert_eq!(expected, want_expected); - assert_eq!(found, want_found); + assert_eq!( + err, + &WktError::TypeMismatch { + expected: want_expected, + found: want_found, } - other => panic!("expected TypeMismatch, got {other:?}"), - } + ); } } } diff --git a/crates/geometry-io-wkt/src/write.rs b/crates/geometry-io-wkt/src/write.rs index 30ece94..466a7ac 100644 --- a/crates/geometry-io-wkt/src/write.rs +++ b/crates/geometry-io-wkt/src/write.rs @@ -248,10 +248,8 @@ fn write_expanded_scalar( } if decimal_pos <= 0 { out.write_str("0.")?; - write_zeroes( - out, - usize::try_from(-decimal_pos).expect("negative decimal position"), - )?; + let zeroes = usize::try_from(-decimal_pos).expect("negative decimal position"); + write_zeroes(out, zeroes)?; return out.write_str(digits); } diff --git a/crates/geometry-model/src/dyn_geometry.rs b/crates/geometry-model/src/dyn_geometry.rs index 8810b7c..1338aea 100644 --- a/crates/geometry-model/src/dyn_geometry.rs +++ b/crates/geometry-model/src/dyn_geometry.rs @@ -201,13 +201,12 @@ mod tests { DynGeometry::Point(Pt::new(1.0, 2.0)), DynGeometry::GeometryCollection(vec![DynGeometry::Point(Pt::new(3.0, 4.0))]), ]); - assert_eq!(nested.kind(), DynKind::GeometryCollection); - if let DynGeometry::GeometryCollection(items) = nested { - assert_eq!(items.len(), 2); - assert_eq!(items[0].kind(), DynKind::Point); - assert_eq!(items[1].kind(), DynKind::GeometryCollection); - } else { - unreachable!(); - } + assert_eq!( + nested, + DynGeometry::GeometryCollection(vec![ + DynGeometry::Point(Pt::new(1.0, 2.0)), + DynGeometry::GeometryCollection(vec![DynGeometry::Point(Pt::new(3.0, 4.0))]), + ]) + ); } } diff --git a/crates/geometry-model/src/geometry_rebind.rs b/crates/geometry-model/src/geometry_rebind.rs index 4e9a024..c09a086 100644 --- a/crates/geometry-model/src/geometry_rebind.rs +++ b/crates/geometry-model/src/geometry_rebind.rs @@ -6,7 +6,9 @@ use geometry_coords::CoordinateScalar; use geometry_cs::CoordinateSystem; -use crate::{Box, Linestring, Point, Ring}; +use crate::{ + Box, Linestring, MultiLinestring, MultiPoint, MultiPolygon, Point, Polygon, Ring, Segment, +}; /// Select the stock model matching `Self` while replacing coordinate /// scalar and coordinate-system types. @@ -68,3 +70,56 @@ where { type Output = Ring, CW, CL>; } + +impl RebindGeometry for Segment> +where + A: CoordinateScalar, + OldCs: CoordinateSystem, + T: CoordinateScalar, + Cs: CoordinateSystem, +{ + type Output = Segment>; +} + +impl RebindGeometry for MultiPoint> +where + A: CoordinateScalar, + OldCs: CoordinateSystem, + T: CoordinateScalar, + Cs: CoordinateSystem, +{ + type Output = MultiPoint>; +} + +impl RebindGeometry + for Polygon, CW, CL> +where + A: CoordinateScalar, + OldCs: CoordinateSystem, + T: CoordinateScalar, + Cs: CoordinateSystem, +{ + type Output = Polygon, CW, CL>; +} + +impl RebindGeometry + for MultiLinestring>> +where + A: CoordinateScalar, + OldCs: CoordinateSystem, + T: CoordinateScalar, + Cs: CoordinateSystem, +{ + type Output = MultiLinestring>>; +} + +impl RebindGeometry + for MultiPolygon, CW, CL>> +where + A: CoordinateScalar, + OldCs: CoordinateSystem, + T: CoordinateScalar, + Cs: CoordinateSystem, +{ + type Output = MultiPolygon, CW, CL>>; +} diff --git a/crates/geometry-overlay/src/assemble.rs b/crates/geometry-overlay/src/assemble.rs index 02d9324..50682ac 100644 --- a/crates/geometry-overlay/src/assemble.rs +++ b/crates/geometry-overlay/src/assemble.rs @@ -104,14 +104,13 @@ where // Odd-depth rings attach to their immediate even-depth parent. for i in 0..n { if depths[i] % 2 == 1 { - let Some(parent_ring) = container_of[i] else { - continue; - }; - if let Some(slot) = outer_slot[parent_ring] { - let mut hole = slots[i].take().unwrap(); - orient_ring(&mut hole, false); - polygons[slot].inners.push(hole); - } + let parent_ring = container_of[i] + .expect("an odd containment depth necessarily has an immediate parent"); + let slot = outer_slot[parent_ring] + .expect("the immediate parent of an odd-depth ring has even depth"); + let mut hole = slots[i].take().unwrap(); + orient_ring(&mut hole, false); + polygons[slot].inners.push(hole); } } diff --git a/crates/geometry-overlay/src/buffer.rs b/crates/geometry-overlay/src/buffer.rs index e7b076a..0e88b49 100644 --- a/crates/geometry-overlay/src/buffer.rs +++ b/crates/geometry-overlay/src/buffer.rs @@ -646,10 +646,11 @@ impl AngularBufferProjection for SphericalBuffer { if !self.radius.is_finite() || self.radius <= 0.0 { return Err(OverlayError::Unsupported); } - let east_scale = self.radius * cos(latitude); - if east_scale.abs() <= f64::EPSILON { + let longitude_scale = cos(latitude); + if longitude_scale.abs() <= f64::EPSILON { return Err(OverlayError::Unsupported); } + let east_scale = self.radius * longitude_scale; Ok(LocalProjection { longitude, latitude, @@ -676,10 +677,11 @@ impl AngularBufferProjection for GeographicBuffer { let prime_vertical = spheroid.equatorial_radius / denominator; let meridional = spheroid.equatorial_radius * (1.0 - eccentricity_squared) / (denominator * denominator * denominator); - let east_scale = prime_vertical * cos(latitude); - if east_scale.abs() <= f64::EPSILON { + let longitude_scale = cos(latitude); + if longitude_scale.abs() <= f64::EPSILON { return Err(OverlayError::Unsupported); } + let east_scale = prime_vertical * longitude_scale; Ok(LocalProjection { longitude, latitude, @@ -1382,9 +1384,7 @@ where let left_path = offset_path(&vertices, left, true, join); let right_path = offset_path(&vertices, right, false, join); - if left_path.is_empty() || right_path.is_empty() { - return Err(OverlayError::Unsupported); - } + debug_assert!(!left_path.is_empty() && !right_path.is_empty()); let mut boundary = left_path; match end { BufferEndStrategy::Flat => {} @@ -1414,9 +1414,8 @@ where true, ); } - if let Some(first) = boundary.first().copied() { - boundary.push(first); - } + let first = boundary[0]; + boundary.push(first); Ok(Polygon::new(Ring::from_vec( boundary .into_iter() diff --git a/crates/geometry-overlay/src/lib.rs b/crates/geometry-overlay/src/lib.rs index 48a2f0b..aa11aca 100644 --- a/crates/geometry-overlay/src/lib.rs +++ b/crates/geometry-overlay/src/lib.rs @@ -33,6 +33,7 @@ extern crate alloc; pub mod assemble; pub mod buffer; +pub mod line_intersection; pub mod merge; pub mod operation; pub mod predicate; @@ -42,17 +43,26 @@ pub mod traverse; pub mod turn; pub mod validity; +// feature-group: Boolean operations +// feature-desc: Overlay and offset of areal geometries pub use buffer::{ JoinStrategy, PointStrategy, buffer, buffer_convex_polygon, buffer_point, buffer_with, buffer_with_strategy, }; -pub use merge::{merge_elements, merge_multipolygon, merge_polygons}; +// feature-group: Boolean operations +pub use line_intersection::{LineIntersection, line_intersection}; +// feature-group: Mutation & assembly +pub use merge::{merge_elements, merge_multipolygon, merge_polygons, stitch_triangles}; +// feature-group: Boolean operations pub use operation::{OverlayError, difference, intersection, sym_difference, r#union, union_poly}; +// feature-group: Spatial predicates pub use relate::{ - De9im, Dimension, RelateError, crosses, overlaps, relate as relate_matrix, relate as relation, - relate_mask as relate, touches, + De9im, Dimension, RelateError, contains_properly, crosses, overlaps, relate as relate_matrix, + relate as relation, relate_mask as relate, touches, }; +// feature-group: Boolean operations pub use surface_point::point_on_surface; +// feature-group: Inspection pub use validity::{ ValidityFailure, ValidityOptions, is_valid, is_valid_polygon, is_valid_polygon_with, is_valid_ring, is_valid_ring_with, is_valid_with, validity_reason, validity_reason_with, diff --git a/crates/geometry-overlay/src/line_intersection.rs b/crates/geometry-overlay/src/line_intersection.rs new file mode 100644 index 0000000..b6028dc --- /dev/null +++ b/crates/geometry-overlay/src/line_intersection.rs @@ -0,0 +1,108 @@ +//! Public segment-intersection result with topology classification. +//! +//! The predicate kernel computes zero, one, or two intersection points. This +//! entry adds the proper-crossing distinction used by the turn classifier and +//! converts an arithmetic range refusal into the public overlay error. + +use geometry_coords::CoordinateScalar; +use geometry_model::Segment; +use geometry_trait::{Point, PointMut, Segment as SegmentTrait, segment_end, segment_start}; + +use crate::operation::OverlayError; +use crate::predicate::{SegmentIntersection, segment_intersection}; +use crate::turn::{Method, classify::refine_touch}; + +/// A non-empty intersection between two line segments. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LineIntersection { + /// The segments meet at one point. + SinglePoint { + /// The intersection coordinate. + intersection: P, + /// `true` only when the point lies in the interior of both segments. + is_proper: bool, + }, + /// The segments overlap along a collinear segment. + Collinear { + /// The shared closed segment. + intersection: Segment

, + }, +} + +/// Intersect two segments and report proper, touch, and collinear cases. +/// +/// This is the public counterpart of the Cartesian intersection strategy in +/// `strategy/cartesian/intersection.hpp`. It preserves this port's explicit +/// range refusal instead of returning a potentially unreliable coordinate. +/// +/// # Errors +/// +/// Returns [`OverlayError::Unsupported`] when an endpoint lies outside the +/// exact-predicate range. +#[inline] +#[must_use = "line intersection can fail and its result should be used"] +pub fn line_intersection( + first: &S, + second: &S, +) -> Result>, OverlayError> +where + S: SegmentTrait, + P: PointMut + Default, + P::Scalar: CoordinateScalar + Into + PartialEq, +{ + match segment_intersection(first, second) { + SegmentIntersection::Disjoint => Ok(None), + SegmentIntersection::OutOfRange => Err(OverlayError::Unsupported), + SegmentIntersection::Single(intersection) => { + let first_start = segment_start(first); + let first_end = segment_end(first); + let second_start = segment_start(second); + let second_end = segment_end(second); + let is_proper = refine_touch( + &intersection, + &first_start, + &first_end, + &second_start, + &second_end, + ) == Method::Crosses; + Ok(Some(LineIntersection::SinglePoint { + intersection, + is_proper, + })) + } + SegmentIntersection::Collinear { from, to } => Ok(Some(LineIntersection::Collinear { + intersection: Segment::new(from, to), + })), + } +} + +#[cfg(test)] +mod tests { + use super::{LineIntersection, line_intersection}; + use geometry_cs::Cartesian; + use geometry_model::{Point2D, Segment}; + + type P = Point2D; + + #[test] + fn distinguishes_proper_crossing_from_endpoint_touch() { + let diagonal = Segment::new(P::new(0.0, 0.0), P::new(2.0, 2.0)); + let crossing = Segment::new(P::new(0.0, 2.0), P::new(2.0, 0.0)); + let touching = Segment::new(P::new(2.0, 2.0), P::new(3.0, 2.0)); + + assert_eq!( + line_intersection(&diagonal, &crossing), + Ok(Some(LineIntersection::SinglePoint { + intersection: P::new(1.0, 1.0), + is_proper: true, + })) + ); + assert_eq!( + line_intersection(&diagonal, &touching), + Ok(Some(LineIntersection::SinglePoint { + intersection: P::new(2.0, 2.0), + is_proper: false, + })) + ); + } +} diff --git a/crates/geometry-overlay/src/merge.rs b/crates/geometry-overlay/src/merge.rs index f80009d..e6f3dd2 100644 --- a/crates/geometry-overlay/src/merge.rs +++ b/crates/geometry-overlay/src/merge.rs @@ -121,6 +121,56 @@ where merge_polygons(mp.0) } +/// Stitch a triangle soup into polygons by removing shared interior edges. +/// +/// Each successful pairwise union that produces one polygon shrinks the work +/// list. Vertex-only contacts and disjoint triangles remain separate output +/// members. This reuses the native areal union engine rather than introducing a +/// triangulation-specific topology kernel. +/// +/// # Errors +/// +/// Propagates [`OverlayError`] when the native union engine refuses a candidate +/// pair. +#[must_use = "stitching can fail and the assembled polygons should be used"] +pub fn stitch_triangles(triangles: I) -> Result>, OverlayError> +where + P: PointMut + Default + Copy, + P::Scalar: CoordinateScalar + Into, + ::Family: SameAs, + I: IntoIterator>, +{ + let mut work: Vec> = triangles.into_iter().collect(); + while let Some((first, second, polygon)) = first_stitchable_pair(&work)? { + work.remove(second); + work.remove(first); + work.push(polygon); + } + Ok(MultiPolygon(work)) +} + +fn first_stitchable_pair

( + polygons: &[Polygon

], +) -> Result)>, OverlayError> +where + P: PointMut + Default + Copy, + P::Scalar: CoordinateScalar + Into, + ::Family: SameAs, +{ + for first in 0..polygons.len() { + for second in (first + 1)..polygons.len() { + let mut unioned = union_poly(&polygons[first], &polygons[second])? + .0 + .into_iter(); + let (Some(polygon), None) = (unioned.next(), unioned.next()) else { + continue; + }; + return Ok(Some((first, second, polygon))); + } + } + Ok(None) +} + /// The first `(i, j)` with `i < j` whose polygons *overlap in area*, or /// `None`. /// @@ -152,7 +202,7 @@ mod tests { //! OVL8 done-when: merged element counts + areas. Mirrors //! `test/algorithms/merge_elements.cpp`. - use super::merge_polygons; + use super::{merge_polygons, stitch_triangles}; use geometry_algorithm::ring_area; use geometry_cs::Cartesian; use geometry_model::{MultiPolygon, Point2D, Polygon, polygon}; @@ -178,11 +228,7 @@ mod tests { let b = square(1.0, 1.0, 2.0); let merged = merge_polygons(vec![a, b]).unwrap(); assert_eq!(merged.polygons().count(), 1); - assert!( - close(total_area(&merged), 7.0), - "area {}", - total_area(&merged) - ); + assert!(close(total_area(&merged), 7.0)); } #[test] @@ -218,6 +264,15 @@ mod tests { assert!(close(total_area(&merged), 4.0)); } + #[test] + fn shared_edge_triangles_stitch_to_one_polygon() { + let first: Polygon

= polygon![[(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.0, 0.0)]]; + let second: Polygon

= polygon![[(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)]]; + let stitched = stitch_triangles([first, second]).unwrap(); + assert_eq!(stitched.polygons().count(), 1); + assert!(close(total_area(&stitched), 1.0)); + } + /// Two squares sharing only one vertex must not fuse. #[test] fn vertex_only_touch_does_not_fuse() { diff --git a/crates/geometry-overlay/src/operation/areal.rs b/crates/geometry-overlay/src/operation/areal.rs index 4daa23b..c6e6123 100644 --- a/crates/geometry-overlay/src/operation/areal.rs +++ b/crates/geometry-overlay/src/operation/areal.rs @@ -197,9 +197,7 @@ where let end = nodes[candidate.end].coordinate; let delta = (end.x - start.x, end.y - start.y); let length = hypot(delta.0, delta.1); - if length <= snap_tolerance { - continue; - } + debug_assert!(length > snap_tolerance); let midpoint = Coordinate { x: (start.x + end.x) * 0.5, y: (start.y + end.y) * 0.5, @@ -304,9 +302,7 @@ fn append_atomic_edges

( .splits .sort_by(|left, right| left.0.total_cmp(&right.0)); for pair in segment.splits.windows(2) { - if (pair[1].0 - pair[0].0).abs() <= 1e-12 { - continue; - } + debug_assert!((pair[1].0 - pair[0].0).abs() > 1e-12); let start = canonical_node(nodes, pair[0].1, tolerance); let end = canonical_node(nodes, pair[1].1, tolerance); if start != end { @@ -353,9 +349,7 @@ where let mut edge_index = seed; let mut node_indices = alloc::vec![first]; for _ in 0..=edges.len() { - if used[edge_index] { - return Err(OverlayError::Unsupported); - } + debug_assert!(!used[edge_index]); used[edge_index] = true; let edge = edges[edge_index]; node_indices.push(edge.end); @@ -364,9 +358,7 @@ where } edge_index = next_edge(nodes, edges, &used, edge).ok_or(OverlayError::Unsupported)?; } - if node_indices.last().copied() != Some(first) { - return Err(OverlayError::Unsupported); - } + debug_assert_eq!(node_indices.last().copied(), Some(first)); let area = node_indices.windows(2).fold(0.0, |sum, pair| { let a = nodes[pair[0]].coordinate; let b = nodes[pair[1]].coordinate; @@ -424,12 +416,12 @@ where let end = Coordinate::from_point(end); let point = Coordinate::from_point(point); let delta = (end.x - start.x, end.y - start.y); - if delta.0.abs() >= delta.1.abs() && delta.0 != 0.0 { + if delta.0.abs() >= delta.1.abs() { + debug_assert_ne!(delta.0, 0.0); (point.x - start.x) / delta.0 - } else if delta.1 != 0.0 { - (point.y - start.y) / delta.1 } else { - 0.0 + debug_assert_ne!(delta.1, 0.0); + (point.y - start.y) / delta.1 } } @@ -467,3 +459,38 @@ fn coordinate_scale(first: &Shape, second: &Shape) -> f64 { scale.max(coordinate.x.abs()).max(coordinate.y.abs()) }) } + +#[cfg(test)] +mod tests { + use geometry_cs::Cartesian; + use geometry_model::Point2D; + + use super::{Coordinate, Edge, Node, trace_rings}; + + type P = Point2D; + + #[test] + fn trace_rings_discards_a_closed_zero_area_cycle() { + let nodes = [ + Node { + point: P::new(0.0, 0.0), + coordinate: Coordinate { x: 0.0, y: 0.0 }, + }, + Node { + point: P::new(1.0, 0.0), + coordinate: Coordinate { x: 1.0, y: 0.0 }, + }, + Node { + point: P::new(2.0, 0.0), + coordinate: Coordinate { x: 2.0, y: 0.0 }, + }, + ]; + let edges = [ + Edge { start: 0, end: 1 }, + Edge { start: 1, end: 2 }, + Edge { start: 2, end: 0 }, + ]; + + assert!(trace_rings(&nodes, &edges, 1e-10).unwrap().is_empty()); + } +} diff --git a/crates/geometry-overlay/src/predicate/segment_intersection.rs b/crates/geometry-overlay/src/predicate/segment_intersection.rs index aa5eb2c..2fbe83a 100644 --- a/crates/geometry-overlay/src/predicate/segment_intersection.rs +++ b/crates/geometry-overlay/src/predicate/segment_intersection.rs @@ -298,23 +298,18 @@ mod tests { use super::{SegmentIntersection, segment_intersection}; use geometry_cs::Cartesian; use geometry_model::{Point2D, Segment}; - use geometry_trait::Point as _; type P = Point2D; type Seg = Segment

; - fn coords(p: &P) -> (f64, f64) { - (p.get::<0>(), p.get::<1>()) - } - #[test] fn proper_crossing() { let a = Seg::new(P::new(0.0, 0.0), P::new(2.0, 2.0)); let b = Seg::new(P::new(0.0, 2.0), P::new(2.0, 0.0)); - match segment_intersection::(&a, &b) { - SegmentIntersection::Single(p) => assert_eq!(coords(&p), (1.0, 1.0)), - other => panic!("{other:?}"), - } + assert_eq!( + segment_intersection::(&a, &b), + SegmentIntersection::Single(P::new(1.0, 1.0)) + ); } #[test] @@ -322,27 +317,23 @@ mod tests { // b's start sits on the interior of a. let a = Seg::new(P::new(0.0, 0.0), P::new(4.0, 0.0)); let b = Seg::new(P::new(2.0, 0.0), P::new(2.0, 3.0)); - match segment_intersection::(&a, &b) { - SegmentIntersection::Single(p) => assert_eq!(coords(&p), (2.0, 0.0)), - other => panic!("{other:?}"), - } + assert_eq!( + segment_intersection::(&a, &b), + SegmentIntersection::Single(P::new(2.0, 0.0)) + ); } #[test] fn collinear_overlap() { let a = Seg::new(P::new(0.0, 0.0), P::new(4.0, 0.0)); let b = Seg::new(P::new(2.0, 0.0), P::new(6.0, 0.0)); - match segment_intersection::(&a, &b) { - SegmentIntersection::Collinear { from, to } => { - let (mut lo, mut hi) = (coords(&from), coords(&to)); - if lo.0 > hi.0 { - core::mem::swap(&mut lo, &mut hi); - } - assert_eq!(lo, (2.0, 0.0)); - assert_eq!(hi, (4.0, 0.0)); + assert_eq!( + segment_intersection::(&a, &b), + SegmentIntersection::Collinear { + from: P::new(2.0, 0.0), + to: P::new(4.0, 0.0), } - other => panic!("{other:?}"), - } + ); } #[test] @@ -350,10 +341,10 @@ mod tests { // Meet only at the shared endpoint (4,0). let a = Seg::new(P::new(0.0, 0.0), P::new(4.0, 0.0)); let b = Seg::new(P::new(4.0, 0.0), P::new(8.0, 0.0)); - match segment_intersection::(&a, &b) { - SegmentIntersection::Single(p) => assert_eq!(coords(&p), (4.0, 0.0)), - other => panic!("{other:?}"), - } + assert_eq!( + segment_intersection::(&a, &b), + SegmentIntersection::Single(P::new(4.0, 0.0)) + ); } #[test] @@ -402,9 +393,9 @@ mod tests { // Verify the line-solve, not just topology. let a = Seg::new(P::new(0.0, 0.0), P::new(4.0, 4.0)); let b = Seg::new(P::new(0.0, 4.0), P::new(4.0, 0.0)); - match segment_intersection::(&a, &b) { - SegmentIntersection::Single(p) => assert_eq!(coords(&p), (2.0, 2.0)), - other => panic!("{other:?}"), - } + assert_eq!( + segment_intersection::(&a, &b), + SegmentIntersection::Single(P::new(2.0, 2.0)) + ); } } diff --git a/crates/geometry-overlay/src/relate.rs b/crates/geometry-overlay/src/relate.rs index 456b903..ec27fd5 100644 --- a/crates/geometry-overlay/src/relate.rs +++ b/crates/geometry-overlay/src/relate.rs @@ -138,28 +138,23 @@ impl De9im { /// nine valid ASCII mask characters. pub fn matches(&self, mask: &str) -> Result { let bytes = mask.as_bytes(); - if bytes.len() != 9 - || !bytes - .iter() - .all(|byte| matches!(byte, b'*' | b'T' | b'F' | b'0' | b'1' | b'2')) - { + if bytes.len() != 9 { return Err(RelateError::InvalidMask); } - Ok(self - .m - .iter() - .flatten() - .zip(bytes) - .all(|(dimension, expected)| match expected { + let mut result = true; + for (dimension, expected) in self.m.iter().flatten().zip(bytes) { + result &= match expected { b'*' => true, b'T' => dimension.is_set(), b'F' => *dimension == Dimension::Empty, b'0' => *dimension == Dimension::Point, b'1' => *dimension == Dimension::Curve, b'2' => *dimension == Dimension::Area, - _ => false, - })) + _ => return Err(RelateError::InvalidMask), + }; + } + Ok(result) } } @@ -845,6 +840,14 @@ where P::Scalar: Into, { let mut matrix = empty_matrix(); + let mut first_segments = Vec::new(); + for_each_line_segment(first, |start, end| { + first_segments.push((xy(start), xy(end))); + }); + let mut second_segments = Vec::new(); + for_each_line_segment(second, |start, end| { + second_segments.push((xy(start), xy(end))); + }); for point in line_boundary_points(first) { let location = point_location_linestring(point, second); matrix.m[feature::BOUNDARY][location.index()] = Dimension::Point; @@ -854,9 +857,14 @@ where matrix.m[location.index()][feature::BOUNDARY] = Dimension::Point; } - for_each_line_segment(first, |first1, first2| { - for_each_line_segment(second, |second1, second2| { - match segment_relation(xy(first1), xy(first2), xy(second1), xy(second2)) { + for &first_segment in &first_segments { + for &second_segment in &second_segments { + match segment_relation( + first_segment.0, + first_segment.1, + second_segment.0, + second_segment.1, + ) { SegmentRelation::Disjoint => {} SegmentRelation::Point(point) => { let first_location = xy_location_linestring(point, first); @@ -867,22 +875,34 @@ where matrix.m[feature::INTERIOR][feature::INTERIOR] = Dimension::Curve; } } - }); - for fraction in [0.25, 0.5, 0.75] { - let sample = interpolate(xy(first1), xy(first2), fraction); - if xy_location_linestring(sample, second) == Location::Exterior { - matrix.m[feature::INTERIOR][feature::EXTERIOR] = Dimension::Curve; + } + if !xy_equal(first_segment.0, first_segment.1) { + for interval in segment_parameters(first_segment, &second_segments, &[]).windows(2) { + debug_assert!(interval[1] - interval[0] > f64::EPSILON); + let sample = interpolate( + first_segment.0, + first_segment.1, + (interval[0] + interval[1]) * 0.5, + ); + let location = xy_location_linestring(sample, second); + matrix.m[feature::INTERIOR][location.index()] = Dimension::Curve; } } - }); - for_each_line_segment(second, |second1, second2| { - for fraction in [0.25, 0.5, 0.75] { - let sample = interpolate(xy(second1), xy(second2), fraction); - if xy_location_linestring(sample, first) == Location::Exterior { - matrix.m[feature::EXTERIOR][feature::INTERIOR] = Dimension::Curve; + } + for &second_segment in &second_segments { + if !xy_equal(second_segment.0, second_segment.1) { + for interval in segment_parameters(second_segment, &first_segments, &[]).windows(2) { + debug_assert!(interval[1] - interval[0] > f64::EPSILON); + let sample = interpolate( + second_segment.0, + second_segment.1, + (interval[0] + interval[1]) * 0.5, + ); + let location = xy_location_linestring(sample, first); + matrix.m[location.index()][feature::INTERIOR] = Dimension::Curve; } } - }); + } matrix } @@ -1297,9 +1317,8 @@ fn topology_segments(topology: &Topology) -> Vec<([f64; 2], [f64; 2])> { let mut segments = Vec::new(); for line in &topology.lines { for points in line.windows(2) { - if !xy_equal(points[0], points[1]) { - segments.push((points[0], points[1])); - } + debug_assert!(!xy_equal(points[0], points[1])); + segments.push((points[0], points[1])); } } for polygon in &topology.polygons { @@ -1329,11 +1348,15 @@ fn topology_location(topology: &Topology, point: [f64; 2]) -> Location { on_line = true; } } - if let (Some(first), Some(last)) = (line.first(), line.last()) - && !xy_equal(*first, *last) - { - endpoint_count += usize::from(xy_equal(point, *first)); - endpoint_count += usize::from(xy_equal(point, *last)); + let first = *line + .first() + .expect("topology lines have at least two points"); + let last = *line + .last() + .expect("topology lines have at least two points"); + if !xy_equal(first, last) { + endpoint_count += usize::from(xy_equal(point, first)); + endpoint_count += usize::from(xy_equal(point, last)); } } if on_line { @@ -1376,18 +1399,19 @@ fn set_dimension(matrix: &mut De9im, row: Location, column: Location, dimension: fn segment_parameter(point: [f64; 2], start: [f64; 2], end: [f64; 2]) -> f64 { let dx = end[0] - start[0]; let dy = end[1] - start[1]; - if dx.abs() >= dy.abs() && dx != 0.0 { + if dx.abs() >= dy.abs() { + debug_assert_ne!(dx, 0.0); (point[0] - start[0]) / dx - } else if dy != 0.0 { - (point[1] - start[1]) / dy } else { - 0.0 + debug_assert_ne!(dy, 0.0); + (point[1] - start[1]) / dy } } fn segment_parameters( segment: ([f64; 2], [f64; 2]), all_segments: &[([f64; 2], [f64; 2])], + split_points: &[[f64; 2]], ) -> Vec { let mut parameters = alloc::vec![0.0, 1.0]; for &(start, end) in all_segments { @@ -1405,6 +1429,11 @@ fn segment_parameters( SegmentRelation::Disjoint => {} } } + for &point in split_points { + if point_on_segment(point, segment.0, segment.1) { + parameters.push(segment_parameter(point, segment.0, segment.1)); + } + } parameters.retain(|parameter| (-f64::EPSILON..=1.0 + f64::EPSILON).contains(parameter)); parameters.sort_by(f64::total_cmp); parameters.dedup_by(|first, second| (*first - *second).abs() <= f64::EPSILON); @@ -1418,16 +1447,14 @@ fn record_segment_cells( segment: ([f64; 2], [f64; 2]), all_segments: &[([f64; 2], [f64; 2])], ) { - for interval in segment_parameters(segment, all_segments).windows(2) { - if interval[1] - interval[0] <= f64::EPSILON { - continue; - } + let parameters = segment_parameters(segment, all_segments, &second.points); + for interval in parameters.windows(2) { + debug_assert!(interval[1] - interval[0] > f64::EPSILON); let midpoint = interpolate(segment.0, segment.1, (interval[0] + interval[1]) * 0.5); let first_location = topology_location(first, midpoint); let second_location = topology_location(second, midpoint); - if first_location != Location::Exterior { - set_dimension(matrix, first_location, second_location, Dimension::Curve); - } + debug_assert_ne!(first_location, Location::Exterior); + set_dimension(matrix, first_location, second_location, Dimension::Curve); } } @@ -1505,21 +1532,18 @@ fn relate_topologies(first: &Topology, second: &Topology) -> Result f64::EPSILON); let midpoint = interpolate(segment.0, segment.1, (interval[0] + interval[1]) * 0.5); let first_location = topology_location(first, midpoint); let second_location = topology_location(second, midpoint); - if second_location != Location::Exterior { - set_dimension( - &mut matrix, - first_location, - second_location, - Dimension::Curve, - ); - } + debug_assert_ne!(second_location, Location::Exterior); + set_dimension( + &mut matrix, + first_location, + second_location, + Dimension::Curve, + ); } } @@ -1552,14 +1576,15 @@ fn relate_topologies(first: &Topology, second: &Topology) -> Result(g1: &G1, g2: &G2) -> Result +where + G1: Geometry, + G2: Geometry, + G1::Kind: RelatePairStrategy, + PairStrategy: RelateStrategy + Default, +{ + let matrix = relate(g1, g2)?; + Ok(matrix.interior_interior().is_set() + && !matrix.m[feature::BOUNDARY][feature::INTERIOR].is_set() + && !matrix.m[feature::BOUNDARY][feature::BOUNDARY].is_set() + && !matrix.m[feature::EXTERIOR][feature::INTERIOR].is_set() + && !matrix.m[feature::EXTERIOR][feature::BOUNDARY].is_set()) +} + /// `touches`: the boundaries meet but the interiors do not. /// /// Mirrors `boost::geometry::touches` (`algorithms/touches.hpp`) for the @@ -1802,7 +1853,7 @@ mod tests { //! case families in `test/algorithms/relate/` and the //! `touches` / `overlaps` test files. - use super::{Dimension, crosses, overlaps, relate, touches}; + use super::{Dimension, contains_properly, crosses, overlaps, relate, touches}; use geometry_cs::Cartesian; use geometry_model::{Point2D, Polygon, polygon}; @@ -1822,6 +1873,13 @@ mod tests { assert!(!crosses(&a, &b).unwrap()); } + #[test] + fn proper_containment_excludes_boundary_contact() { + let container = square(0.0, 0.0, 5.0); + assert!(contains_properly(&container, &square(1.0, 1.0, 1.0)).unwrap()); + assert!(!contains_properly(&container, &square(0.0, 1.0, 1.0)).unwrap()); + } + #[test] fn edge_touching_squares_have_curve_boundary_intersection() { let a = square(0.0, 0.0, 2.0); diff --git a/crates/geometry-overlay/src/surface_point.rs b/crates/geometry-overlay/src/surface_point.rs index ea94d81..b953342 100644 --- a/crates/geometry-overlay/src/surface_point.rs +++ b/crates/geometry-overlay/src/surface_point.rs @@ -142,7 +142,6 @@ mod tests { use geometry_algorithm::within; use geometry_cs::Cartesian; use geometry_model::{Point2D, Polygon, polygon}; - use geometry_trait::Point as _; type P = Point2D; @@ -167,12 +166,7 @@ mod tests { (0.0, 0.0) ]]; let p = point_on_surface(&pg).unwrap(); - assert!( - within(&p, &pg), - "point ({}, {}) not inside L", - p.get::<0>(), - p.get::<1>() - ); + assert!(within(&p, &pg)); } #[test] @@ -190,11 +184,6 @@ mod tests { [(3.0, 3.0), (7.0, 3.0), (7.0, 7.0), (3.0, 7.0), (3.0, 3.0)] ]; let p = point_on_surface(&pg).unwrap(); - assert!( - within(&p, &pg), - "point ({}, {}) not inside", - p.get::<0>(), - p.get::<1>() - ); + assert!(within(&p, &pg)); } } diff --git a/crates/geometry-overlay/src/validity.rs b/crates/geometry-overlay/src/validity.rs index af7b783..005648b 100644 --- a/crates/geometry-overlay/src/validity.rs +++ b/crates/geometry-overlay/src/validity.rs @@ -304,6 +304,11 @@ where /// # Errors /// /// Returns the first [`ValidityFailure`] not accepted by `options`. +/// +/// # Panics +/// +/// Panics if a custom ring implementation passes validation with a non-empty +/// point iterator but yields no point when iterated again immediately after. #[inline] #[must_use = "validity failures must be handled"] pub fn is_valid_with(geometry: &G, options: ValidityOptions) -> Result<(), ValidityFailure> @@ -600,6 +605,11 @@ where /// # Errors /// /// Returns the first [`ValidityFailure`] not accepted by `options`. +/// +/// # Panics +/// +/// Panics if a custom ring implementation passes validation with a non-empty +/// point iterator but yields no point when iterated again immediately after. #[inline] #[must_use = "validity failures must be handled"] pub fn is_valid_polygon_with( @@ -616,10 +626,12 @@ where let inners: Vec<_> = polygon.interiors().collect(); for inner in &inners { validate_ring(*inner, true, options)?; - if let Some(rep) = inner.points().next() { - if !WithinRing.covered_by(rep, polygon.exterior()) { - return Err(ValidityFailure::InteriorRingOutside); - } + let rep = inner + .points() + .next() + .expect("a validated ring contains at least four points"); + if !WithinRing.covered_by(rep, polygon.exterior()) { + return Err(ValidityFailure::InteriorRingOutside); } let interaction = ring_pair_interaction(polygon.exterior(), *inner); if interaction.proper_crossing { @@ -639,12 +651,12 @@ where if interaction.contacts.len() > 1 { return Err(ValidityFailure::DisconnectedInterior); } - if interaction.contacts.is_empty() + let nested = interaction.contacts.is_empty() && (ring_first_point_within(inners[first], inners[second]) - || ring_first_point_within(inners[second], inners[first])) - { - return Err(ValidityFailure::NestedInteriorRings); - } + || ring_first_point_within(inners[second], inners[first])); + (!nested) + .then_some(()) + .ok_or(ValidityFailure::NestedInteriorRings)?; } } Ok(()) diff --git a/crates/geometry-proj/src/crs.rs b/crates/geometry-proj/src/crs.rs index 2a366f9..2a84801 100644 --- a/crates/geometry-proj/src/crs.rs +++ b/crates/geometry-proj/src/crs.rs @@ -148,15 +148,15 @@ mod tests { /// variants render through `Display`. #[test] fn error_variants_display() { - let Err(wkt_err) = Crs::from_wkt("NOT WKT AT ALL") else { - panic!("malformed WKT accepted"); - }; + let wkt_err = Crs::from_wkt("NOT WKT AT ALL") + .err() + .expect("malformed WKT is rejected"); let msg = alloc::format!("{wkt_err}"); assert!(msg.starts_with("invalid WKT:"), "got: {msg}"); - let Err(proj_err) = Crs::from_proj_string("+proj=definitely_not_a_projection") else { - panic!("bad proj string accepted"); - }; + let proj_err = Crs::from_proj_string("+proj=definitely_not_a_projection") + .err() + .expect("bad projection string is rejected"); let msg = alloc::format!("{proj_err}"); assert!(msg.starts_with("invalid CRS definition:"), "got: {msg}"); } diff --git a/crates/geometry-proj/src/lib.rs b/crates/geometry-proj/src/lib.rs index 3d1a677..53474ed 100644 --- a/crates/geometry-proj/src/lib.rs +++ b/crates/geometry-proj/src/lib.rs @@ -40,4 +40,6 @@ pub mod crs; pub mod reproject; pub use crs::{Crs, CrsError}; +// feature-group: Reprojection +// feature-desc: CRS-to-CRS point reprojection (standalone crate) pub use reproject::{ReprojectPoints, reproject}; diff --git a/crates/geometry-proj/src/reproject.rs b/crates/geometry-proj/src/reproject.rs index 7dd911e..829b041 100644 --- a/crates/geometry-proj/src/reproject.rs +++ b/crates/geometry-proj/src/reproject.rs @@ -219,11 +219,7 @@ mod tests { let mut p = P::new(45.0_f64.to_radians(), 0.0); reproject(&mut p, &wgs84(), &mercator()).unwrap(); let expected_x = 6_378_137.0 * core::f64::consts::FRAC_PI_4; - assert!( - (p.get::<0>() - expected_x).abs() < 1.0, - "x = {}", - p.get::<0>() - ); + assert!((p.get::<0>() - expected_x).abs() < 1.0); assert!(p.get::<1>().abs() < 1e-3, "y = {}", p.get::<1>()); } diff --git a/crates/geometry-rtree/src/lib.rs b/crates/geometry-rtree/src/lib.rs index d749020..356f78a 100644 --- a/crates/geometry-rtree/src/lib.rs +++ b/crates/geometry-rtree/src/lib.rs @@ -117,10 +117,14 @@ pub mod values; pub use bounds::Bounds; pub use indexable::Indexable; pub use nearest_iter::NearestIter; +// feature-group: Spatial index pub use predicate::{ AndPredicate, NotPredicate, Predicate, QueryPredicate, Satisfies, and, not, satisfies, }; pub use query_iter::{QueryIter, QueryWithIter}; +// feature-group: Spatial index +// feature-desc: Bulk-loadable R-tree with nearest-neighbour and predicate queries +// feature-keep: Rtree pub use rtree::Rtree; pub use split::{ AsymmetricQuadratic, AsymmetricRStarSplit, Linear, Quadratic, RStarSplit, SplitParameters, diff --git a/crates/geometry-rtree/src/predicate.rs b/crates/geometry-rtree/src/predicate.rs index fc893fd..dfcf57b 100644 --- a/crates/geometry-rtree/src/predicate.rs +++ b/crates/geometry-rtree/src/predicate.rs @@ -46,11 +46,8 @@ impl Predicate { #[must_use] #[inline] pub fn matches(&self, value: &Bounds) -> bool { - if let Predicate::Intersects(query) = self { - return query.intersects(value); - } match self { - Predicate::Intersects(_) => unreachable!("intersects returned above"), + Predicate::Intersects(q) => q.intersects(value), Predicate::Within(q) => value.within(q), Predicate::Contains(q) => q.within(value), Predicate::CoveredBy(q) => value.covered_by(q), @@ -66,11 +63,8 @@ impl Predicate { #[must_use] #[inline] pub fn could_match(&self, node: &Bounds) -> bool { - if let Predicate::Intersects(query) = self { - return query.intersects(node); - } match self { - Predicate::Intersects(_) => unreachable!("intersects returned above"), + Predicate::Intersects(q) => q.intersects(node), Predicate::Within(q) | Predicate::CoveredBy(q) | Predicate::Overlaps(q) => { q.intersects(node) } @@ -93,12 +87,8 @@ impl Predicate { #[must_use] #[inline] pub fn covers_all(&self, node: &Bounds) -> bool { - if let Predicate::Intersects(query) = self { - return query.contains(node); - } match self { - Predicate::Intersects(_) => unreachable!("intersects returned above"), - Predicate::CoveredBy(q) => q.contains(node), + Predicate::Intersects(q) | Predicate::CoveredBy(q) => q.contains(node), Predicate::Disjoint(q) => q.disjoint(node), Predicate::Within(_) | Predicate::Contains(_) diff --git a/crates/geometry-rtree/src/rtree.rs b/crates/geometry-rtree/src/rtree.rs index c0c85b1..84e8b5a 100644 --- a/crates/geometry-rtree/src/rtree.rs +++ b/crates/geometry-rtree/src/rtree.rs @@ -765,9 +765,8 @@ fn str_pack_height( for column in 0..column_count { let children_in_column = child_count / column_count + usize::from(column < child_count % column_count); - if children_in_column == 0 { - continue; - } + // `column_count <= child_count` for every non-empty packed level, so + // each column owns at least one child. let base = keyed.len() / remaining_children; let extra = keyed.len() % remaining_children; let take = base * children_in_column + extra.min(children_in_column); @@ -846,28 +845,14 @@ mod tests { use geometry_trait::Point as _; type P = Point2D; - type Leaf = Vec; - trait LeafProbe { fn values(&self) -> &[T]; - fn packed_group_bounds(&self) -> Option<&[Bounds]>; - fn packed_group(&self, index: usize) -> &[T]; } impl LeafProbe for Vec { fn values(&self) -> &[T] { self } - - fn packed_group_bounds(&self) -> Option<&[Bounds]> { - None - } - - fn packed_group(&self, index: usize) -> &[T] { - const GROUP_SIZE: usize = 8; - let start = index * GROUP_SIZE; - &self[start..(start + GROUP_SIZE).min(self.len())] - } } struct Lcg { @@ -945,64 +930,6 @@ mod tests { } } - enum PackedFrontierItem<'a, T> { - Node(&'a Node), - Group(&'a Leaf, usize), - Value(&'a T), - } - - struct PackedFrontierEntry<'a, T> { - dist: f64, - item: PackedFrontierItem<'a, T>, - } - - impl PartialEq for PackedFrontierEntry<'_, T> { - fn eq(&self, other: &Self) -> bool { - self.dist.total_cmp(&other.dist).is_eq() - } - } - - impl Eq for PackedFrontierEntry<'_, T> {} - - impl PartialOrd for PackedFrontierEntry<'_, T> { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } - } - - impl Ord for PackedFrontierEntry<'_, T> { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - other.dist.total_cmp(&self.dist) - } - } - - #[derive(Debug, Default)] - struct PackedFrontierMetrics { - pushes: usize, - pops: usize, - high_water: usize, - branch_expansions: usize, - leaf_expansions: usize, - group_pushes: usize, - group_pops: usize, - value_pushes: usize, - value_pops: usize, - } - - impl PackedFrontierMetrics { - fn add(&mut self, other: &Self) { - self.pushes += other.pushes; - self.pops += other.pops; - self.high_water = self.high_water.max(other.high_water); - self.branch_expansions += other.branch_expansions; - self.leaf_expansions += other.leaf_expansions; - self.group_pushes += other.group_pushes; - self.group_pops += other.group_pops; - self.value_pushes += other.value_pushes; - self.value_pops += other.value_pops; - } - } - fn nearest_with_metrics( tree: &Rtree, query: [f64; 2], @@ -1109,162 +1036,6 @@ mod tests { (ranks.into_values(), metrics) } - fn nearest_packed_frontier_with_metrics( - tree: &Rtree, - query: [f64; 2], - k: usize, - ) -> (Vec<&T>, PackedFrontierMetrics) { - let mut values = Vec::with_capacity(k.min(tree.len)); - let mut frontier: SearchFrontier> = SearchFrontier::new(); - let mut metrics = PackedFrontierMetrics::default(); - frontier.push(PackedFrontierEntry { - dist: 0.0, - item: PackedFrontierItem::Node(&tree.root), - }); - while values.len() < k { - let Some(entry) = frontier.pop() else { - break; - }; - match entry.item { - PackedFrontierItem::Node(Node::Branch(children)) => { - metrics.branch_expansions += 1; - frontier.extend(children.iter().map(|(bounds, child)| PackedFrontierEntry { - dist: bounds.comparable_min_distance_to(query), - item: PackedFrontierItem::Node(child), - })); - } - PackedFrontierItem::Node(Node::Leaf(leaf)) => { - metrics.leaf_expansions += 1; - if let Some(group_bounds) = leaf.packed_group_bounds() { - metrics.group_pushes += group_bounds.len(); - frontier.extend(group_bounds.iter().enumerate().map(|(index, bounds)| { - PackedFrontierEntry { - dist: bounds.comparable_min_distance_to(query), - item: PackedFrontierItem::Group(leaf, index), - } - })); - } else { - metrics.value_pushes += leaf.len(); - frontier.extend(leaf.values().iter().map(|value| PackedFrontierEntry { - dist: value.bounds().comparable_min_distance_to(query), - item: PackedFrontierItem::Value(value), - })); - } - } - PackedFrontierItem::Group(leaf, index) => { - metrics.group_pops += 1; - let group = leaf.packed_group(index); - metrics.value_pushes += group.len(); - frontier.extend(group.iter().map(|value| PackedFrontierEntry { - dist: value.bounds().comparable_min_distance_to(query), - item: PackedFrontierItem::Value(value), - })); - } - PackedFrontierItem::Value(value) => { - metrics.value_pops += 1; - values.push(value); - } - } - } - let frontier_metrics = frontier.metrics(); - metrics.pushes = frontier_metrics.pushes; - metrics.pops = frontier_metrics.pops; - metrics.high_water = frontier_metrics.high_water; - (values, metrics) - } - - fn nearest_bounded_group_frontier_with_metrics( - tree: &Rtree, - query: [f64; 2], - k: usize, - ) -> (Vec<&T>, BoundedSearchMetrics) { - if k == 0 || tree.len == 0 { - return (Vec::new(), BoundedSearchMetrics::default()); - } - let mut ranks = NearestBound::new(k, k.min(tree.len)); - let mut frontier: SearchFrontier> = SearchFrontier::new(); - let mut metrics = BoundedSearchMetrics::default(); - frontier.push(PackedFrontierEntry { - dist: 0.0, - item: PackedFrontierItem::Node(&tree.root), - }); - while let Some(PackedFrontierEntry { dist, item }) = frontier.pop() { - if dist.total_cmp(&ranks.bound()).is_ge() { - metrics.terminated_by_bound += 1; - break; - } - match item { - PackedFrontierItem::Node(Node::Branch(children)) => { - metrics.branch_expansions += 1; - metrics.child_distance_evaluations += children.len(); - for (bounds, child) in children { - let dist = bounds.comparable_min_distance_to(query); - if dist.total_cmp(&ranks.bound()).is_lt() { - metrics.child_pushes += 1; - frontier.push(PackedFrontierEntry { - dist, - item: PackedFrontierItem::Node(child), - }); - } else { - metrics.child_pruned += 1; - } - } - } - PackedFrontierItem::Node(Node::Leaf(leaf)) => { - metrics.leaf_expansions += 1; - if let Some(group_bounds) = leaf.packed_group_bounds() { - metrics.leaf_group_bound_evaluations += group_bounds.len(); - for (index, bounds) in group_bounds.iter().enumerate() { - let dist = bounds.comparable_min_distance_to(query); - if dist.total_cmp(&ranks.bound()).is_lt() { - frontier.push(PackedFrontierEntry { - dist, - item: PackedFrontierItem::Group(leaf, index), - }); - } else { - metrics.leaf_groups_pruned += 1; - } - } - } else { - metrics.value_distance_evaluations += leaf.len(); - for value in leaf.values() { - record_value_candidate(value, query, &mut ranks, &mut metrics); - } - } - } - PackedFrontierItem::Group(leaf, index) => { - metrics.leaf_groups_scanned += 1; - let group = leaf.packed_group(index); - metrics.value_distance_evaluations += group.len(); - let reverse = group - .first() - .zip(group.last()) - .is_some_and(|(first, last)| { - let first_y = first.bounds().center()[1]; - let last_y = last.bounds().center()[1]; - (last_y - query[1]).abs() < (first_y - query[1]).abs() - }); - if reverse { - for value in group.iter().rev() { - record_value_candidate(value, query, &mut ranks, &mut metrics); - } - } else { - for value in group { - record_value_candidate(value, query, &mut ranks, &mut metrics); - } - } - } - PackedFrontierItem::Value(_) => unreachable!("values are ranked, not queued"), - } - } - let frontier_metrics = frontier.metrics(); - metrics.frontier_pushes = frontier_metrics.pushes; - metrics.frontier_pops = frontier_metrics.pops; - metrics.frontier_high_water = frontier_metrics.high_water; - metrics.rank = ranks.metrics(); - (ranks.into_values(), metrics) - } - fn nearest_distance_ordered_groups_with_metrics( tree: &Rtree, query: [f64; 2], @@ -1645,24 +1416,6 @@ mod tests { assert_eq!(isqrt_ceil(2), 2); assert_eq!(isqrt_ceil(4), 2); - let values = vec![P::new(0.0, 0.0), P::new(1.0, 1.0)]; - assert_eq!(LeafProbe::packed_group(&values, 0).len(), 2); - - let first = PackedFrontierEntry { - dist: 1.0, - item: PackedFrontierItem::Group(&values, 0), - }; - let equal = PackedFrontierEntry { - dist: 1.0, - item: PackedFrontierItem::Value(&values[0]), - }; - let farther = PackedFrontierEntry { - dist: 2.0, - item: PackedFrontierItem::Value(&values[1]), - }; - assert!(first == equal); - assert!(first.partial_cmp(&farther).is_some()); - let ordered_values: Vec

= (0..12).map(|x| P::new(f64::from(x), 0.0)).collect(); let mut ranks = NearestBound::new(1, 1); let mut metrics = BoundedSearchMetrics::default(); @@ -1682,16 +1435,6 @@ mod tests { .0 .is_empty() ); - assert!( - nearest_packed_frontier_with_metrics(&tree, [0.0, 0.0], 1) - .0 - .is_empty() - ); - assert!( - nearest_bounded_group_frontier_with_metrics(&tree, [0.0, 0.0], 0) - .0 - .is_empty() - ); assert!( nearest_distance_ordered_groups_with_metrics(&tree, [0.0, 0.0], 0, 8) .0 @@ -2056,54 +1799,6 @@ mod tests { ); } - fn record_bulk_packed_frontier_shape(distribution: &str, points: &[P]) { - const Q: usize = 100; - const K: usize = 8; - - let tree: Rtree

= points.iter().copied().collect(); - let mut total = PackedFrontierMetrics::default(); - for query in profile_queries(Q) { - let expected = tree.nearest(query, K); - let (observed, metrics) = nearest_packed_frontier_with_metrics(&tree, query, K); - assert_eq!(observed, expected); - total.add(&metrics); - } - eprintln!( - "[rtree-packed-frontier] distribution={distribution} expected_results={} pushes={} pops={} high_water={} branch_expansions={} leaf_expansions={} group_pushes={} group_pops={} value_pushes={} value_pops={}", - Q * K, - total.pushes, - total.pops, - total.high_water, - total.branch_expansions, - total.leaf_expansions, - total.group_pushes, - total.group_pops, - total.value_pushes, - total.value_pops, - ); - } - - fn record_bulk_bounded_group_frontier_shape(distribution: &str, points: &[P]) { - const Q: usize = 100; - const K: usize = 8; - - let tree: Rtree

= points.iter().copied().collect(); - let mut total = BoundedSearchMetrics::default(); - for query in profile_queries(Q) { - let expected = tree.nearest(query, K); - let (observed, metrics) = nearest_bounded_group_frontier_with_metrics(&tree, query, K); - assert_eq!(observed, expected); - total.add(&metrics); - } - report_bounded_metrics( - "bulk-group-frontier", - distribution, - "bounded-group-frontier", - Q * K, - &total, - ); - } - fn record_bulk_leaf_bvh_shape(terminal_size: usize, distribution: &str, points: &[P]) { const Q: usize = 100; const K: usize = 8; @@ -2140,30 +1835,6 @@ mod tests { } } - #[test] - fn records_bulk_packed_frontier_shape() { - const N: usize = 50_000; - - for (distribution, points) in [ - ("uniform", uniform_points(N)), - ("clustered", clustered_points(N)), - ] { - record_bulk_packed_frontier_shape(distribution, &points); - } - } - - #[test] - fn records_bulk_bounded_group_frontier_shape() { - const N: usize = 50_000; - - for (distribution, points) in [ - ("uniform", uniform_points(N)), - ("clustered", clustered_points(N)), - ] { - record_bulk_bounded_group_frontier_shape(distribution, &points); - } - } - #[test] fn records_bulk_bounded_distance_group_shape() { const N: usize = 50_000; diff --git a/crates/geometry-rtree/src/search_frontier.rs b/crates/geometry-rtree/src/search_frontier.rs index 8b223a4..5ecba99 100644 --- a/crates/geometry-rtree/src/search_frontier.rs +++ b/crates/geometry-rtree/src/search_frontier.rs @@ -57,7 +57,10 @@ impl SearchFrontier { match &mut self.entries { FrontierEntries::Inline(entries) => { if let Err(entry) = entries.push(entry) { - self.spill(INLINE_CAPACITY + 1).push(entry); + let inline = replace(entries, InlineHeap::new()); + let mut spilled = Self::spilled_heap(inline, INLINE_CAPACITY + 1); + spilled.push(entry); + self.entries = FrontierEntries::Spilled(spilled); } } FrontierEntries::Spilled(entries) => entries.push(entry), @@ -81,14 +84,16 @@ impl SearchFrontier { if frontier.capacity() >= frontier.len() + incoming => { for entry in entries { - if frontier.push(entry).is_err() { - unreachable!("capacity was checked before extending"); - } + let pushed = frontier.push(entry); + debug_assert!(pushed.is_ok(), "capacity was checked before extending"); } } FrontierEntries::Inline(frontier) => { let capacity = frontier.len() + incoming; - self.spill(capacity).extend(entries); + let inline = replace(frontier, InlineHeap::new()); + let mut spilled = Self::spilled_heap(inline, capacity); + spilled.extend(entries); + self.entries = FrontierEntries::Spilled(spilled); } FrontierEntries::Spilled(frontier) => frontier.extend(entries), } @@ -127,19 +132,9 @@ impl SearchFrontier { } } - #[cold] - fn spill(&mut self, capacity: usize) -> &mut BinaryHeap { - let previous = replace( - &mut self.entries, - FrontierEntries::Spilled(BinaryHeap::with_capacity(capacity)), - ); - let FrontierEntries::Spilled(entries) = &mut self.entries else { - unreachable!("the frontier was just replaced with a spilled heap"); - }; - let FrontierEntries::Inline(previous) = previous else { - unreachable!("only an inline frontier can spill"); - }; - entries.extend(previous.into_vec()); + fn spilled_heap(inline: InlineHeap, capacity: usize) -> BinaryHeap { + let mut entries = BinaryHeap::with_capacity(capacity); + entries.extend(inline.into_vec()); entries } diff --git a/crates/geometry-rtree/tests/insert_query_parity.rs b/crates/geometry-rtree/tests/insert_query_parity.rs index e2878b8..641d78e 100644 --- a/crates/geometry-rtree/tests/insert_query_parity.rs +++ b/crates/geometry-rtree/tests/insert_query_parity.rs @@ -9,7 +9,7 @@ use geometry_cs::Cartesian; use geometry_model::Point2D; -use geometry_rtree::{Bounds, Linear, Predicate, Quadratic, Rtree}; +use geometry_rtree::{Bounds, Linear, Predicate, Quadratic, QueryPredicate, Rtree, satisfies}; use geometry_trait::Point as _; type P = Point2D; @@ -120,6 +120,45 @@ fn query_result_sets_are_the_same_points() { assert_eq!(hits, brute); } +/// Logical `and`, `not`, and value-level `satisfies` predicates retain their +/// public leaf semantics and conservative subtree contracts. +#[test] +fn logical_query_predicates_compose_through_the_public_api() { + let tree: Rtree

= [ + P::new(0.0, 0.0), + P::new(1.0, 1.0), + P::new(2.0, 2.0), + P::new(5.0, 5.0), + ] + .into_iter() + .collect(); + let window = Bounds::new([0.0, 0.0], [3.0, 3.0]); + let selected = tree.query_with( + Predicate::Intersects(window).and(satisfies(|point: &P| point.get::<0>() >= 1.0)), + ); + assert_eq!(selected.len(), 2); + + let outside = tree.query_with(!Predicate::Intersects(window)); + assert_eq!(outside.len(), 1); + assert!((outside[0].get::<0>() - 5.0).abs() < f64::EPSILON); + + let condition = satisfies(|point: &P| point.get::<1>() >= 0.0); + assert!(<_ as QueryPredicate

>::matches( + &condition, + &P::new(1.0, 1.0) + )); + assert!(<_ as QueryPredicate

>::could_match(&condition, &window)); + assert!(!<_ as QueryPredicate

>::covers_all(&condition, &window)); + + let conjunction = Predicate::Intersects(window).and(Predicate::CoveredBy(window)); + assert!(<_ as QueryPredicate

>::could_match(&conjunction, &window)); + assert!(<_ as QueryPredicate

>::covers_all(&conjunction, &window)); + + let negated = !Predicate::Intersects(window); + assert!(!<_ as QueryPredicate

>::could_match(&negated, &window)); + assert!(!<_ as QueryPredicate

>::covers_all(&negated, &window)); +} + #[test] #[allow( clippy::many_single_char_names, diff --git a/crates/geometry-strategy/src/area.rs b/crates/geometry-strategy/src/area.rs index d6a17d6..43b59c6 100644 --- a/crates/geometry-strategy/src/area.rs +++ b/crates/geometry-strategy/src/area.rs @@ -260,11 +260,10 @@ where // Open ring leaves the closing edge implicit — add it // explicitly. Mirrors the `closed_clockwise_view` closure half // at `views/detail/closed_clockwise_view.hpp`. - let mut it = r.points(); - if let Some(first) = it.next() { - if let Some(last) = r.points().last() { - acc = acc + segment_term::(last, first); - } + let mut points = r.points(); + if let Some(first) = points.next() { + let last = points.last().unwrap_or(first); + acc = acc + segment_term::(last, first); } } acc diff --git a/crates/geometry-strategy/src/centroid.rs b/crates/geometry-strategy/src/centroid.rs index 6050541..aea5566 100644 --- a/crates/geometry-strategy/src/centroid.rs +++ b/crates/geometry-strategy/src/centroid.rs @@ -197,11 +197,10 @@ where acc(a, b); } if matches!(r.closure(), geometry_trait::Closure::Open) { - let mut first_it = r.points(); - if let Some(first) = first_it.next() { - if let Some(last) = r.points().last() { - acc(last, first); - } + let mut points = r.points(); + if let Some(first) = points.next() { + let last = points.last().unwrap_or(first); + acc(last, first); } } diff --git a/crates/geometry-strategy/src/compare.rs b/crates/geometry-strategy/src/compare.rs index 1989336..e20ac77 100644 --- a/crates/geometry-strategy/src/compare.rs +++ b/crates/geometry-strategy/src/compare.rs @@ -283,8 +283,14 @@ where } matches!(relation, Relation::Equal) } else { - compare_ordinate(left, right, dimension_index(dimension), relation, exact) - .unwrap_or(matches!(relation, Relation::Equal)) + compare_ordinate( + left, + right, + usize::from(dimension.unsigned_abs()), + relation, + exact, + ) + .unwrap_or(matches!(relation, Relation::Equal)) } } @@ -307,8 +313,14 @@ where validate_dimension(dimension, shared_dimensions); if dimension >= 2 { - return compare_ordinate(left, right, dimension_index(dimension), relation, exact) - .unwrap_or(matches!(relation, Relation::Equal)); + return compare_ordinate( + left, + right, + usize::from(dimension.unsigned_abs()), + relation, + exact, + ) + .unwrap_or(matches!(relation, Relation::Equal)); } if dimension == 1 { @@ -460,18 +472,11 @@ fn validate_dimension(dimension: i8, shared_dimensions: usize) { ); assert!( dimension == ALL_DIMENSIONS - || (dimension >= 0 && dimension_index(dimension) < shared_dimensions), + || (dimension >= 0 && (dimension.unsigned_abs() as usize) < shared_dimensions), "comparison dimension must be present in both points" ); } -fn dimension_index(dimension: i8) -> usize { - match usize::try_from(dimension) { - Ok(index) => index, - Err(_) => unreachable!("validated comparison dimensions are non-negative"), - } -} - #[allow(clippy::float_cmp, reason = "exact comparison is a selectable policy")] fn values_equal(left: f64, right: f64, epsilon: f64, exact: bool) -> bool { if exact { diff --git a/crates/geometry-strategy/src/convex_hull.rs b/crates/geometry-strategy/src/convex_hull.rs index 5a26c47..be09417 100644 --- a/crates/geometry-strategy/src/convex_hull.rs +++ b/crates/geometry-strategy/src/convex_hull.rs @@ -160,9 +160,8 @@ where // clockwise output ring, then repeat the first point to close. let mut hull = monotone_chain(pts); hull.reverse(); - if let Some(&first) = hull.first() { - hull.push(first); - } + let first = hull[0]; + hull.push(first); Ring::from_vec(hull) } } diff --git a/crates/geometry-strategy/src/destination.rs b/crates/geometry-strategy/src/destination.rs new file mode 100644 index 0000000..e96e711 --- /dev/null +++ b/crates/geometry-strategy/src/destination.rs @@ -0,0 +1,112 @@ +//! Point-at-bearing-and-distance strategies. +//! +//! This is the strategy-facing wrapper around Boost's direct geodesic formulas. +//! Bearings and direct-formula inputs are radians; output coordinates are +//! converted back to the angular unit carried by the input point. + +use geometry_cs::{CoordinateSystem, GeographicFamily, SphericalFamily}; +use geometry_trait::Point; + +#[cfg(feature = "std")] +use crate::normalise::{HasAngularUnits, lonlat_radians}; +#[cfg(feature = "std")] +use geometry_cs::AngleUnit; +#[cfg(feature = "std")] +use geometry_model::Point2D; + +/// Strategy computing the endpoint reached from a point, bearing, and distance. +pub trait DestinationStrategy { + /// Destination point type. + type Output: Point; + + /// Compute the destination. `bearing` is measured clockwise from north in + /// radians; `distance` uses the strategy's radius or spheroid units. + fn destination(&self, origin: &P, bearing: f64, distance: f64) -> Self::Output; +} + +/// Select the default destination strategy for a coordinate-system family. +pub trait DefaultDestination { + /// Default strategy type. + type Strategy: Default; +} + +impl DefaultDestination for SphericalFamily { + type Strategy = crate::spherical::Haversine; +} + +impl DefaultDestination for GeographicFamily { + type Strategy = crate::geographic::KarneyDirect; +} + +/// Default destination strategy for a point type. +pub type DefaultDestinationStrategy

= + <<

::Cs as CoordinateSystem>::Family as DefaultDestination< + <

::Cs as CoordinateSystem>::Family, + >>::Strategy; + +#[cfg(feature = "std")] +impl

DestinationStrategy

for crate::spherical::Haversine +where + P: Point, + P::Cs: HasAngularUnits, + ::Family: geometry_tag::SameAs, +{ + type Output = Point2D; + + fn destination(&self, origin: &P, bearing: f64, distance: f64) -> Self::Output { + let (longitude, latitude) = lonlat_radians(origin); + let angular_distance = distance / self.radius; + let sin_latitude = latitude.sin(); + let cos_latitude = latitude.cos(); + let sin_distance = angular_distance.sin(); + let cos_distance = angular_distance.cos(); + let latitude2 = + (sin_latitude * cos_distance + cos_latitude * sin_distance * bearing.cos()).asin(); + let longitude2 = longitude + + (bearing.sin() * sin_distance * cos_latitude) + .atan2(cos_distance - sin_latitude * latitude2.sin()); + point_from_radians::

(normalize_longitude(longitude2), latitude2) + } +} + +macro_rules! impl_geographic_destination { + ($strategy:ty) => { + #[cfg(feature = "std")] + impl

DestinationStrategy

for $strategy + where + P: Point, + P::Cs: HasAngularUnits, + ::Family: geometry_tag::SameAs, + { + type Output = Point2D; + + fn destination(&self, origin: &P, bearing: f64, distance: f64) -> Self::Output { + let (longitude, latitude) = lonlat_radians(origin); + let result = self.apply(longitude, latitude, distance, bearing); + point_from_radians::

(result.lon2, result.lat2) + } + } + }; +} + +impl_geographic_destination!(crate::geographic::KarneyDirect); +impl_geographic_destination!(crate::geographic::ThomasDirect); +impl_geographic_destination!(crate::geographic::VincentyDirect); + +#[cfg(feature = "std")] +fn point_from_radians

(longitude: f64, latitude: f64) -> Point2D +where + P: Point, + P::Cs: HasAngularUnits, +{ + type Units

= <

::Cs as HasAngularUnits>::Units; + Point2D::new( + Units::

::from_radians(longitude), + Units::

::from_radians(latitude), + ) +} + +#[cfg(feature = "std")] +fn normalize_longitude(longitude: f64) -> f64 { + (longitude + core::f64::consts::PI).rem_euclid(core::f64::consts::TAU) - core::f64::consts::PI +} diff --git a/crates/geometry-strategy/src/geographic/area.rs b/crates/geometry-strategy/src/geographic/area.rs index 8fa8f7c..47a23e8 100644 --- a/crates/geometry-strategy/src/geographic/area.rs +++ b/crates/geometry-strategy/src/geographic/area.rs @@ -199,11 +199,7 @@ mod tests { // negative on a default (clockwise) ring; take the magnitude. let got = GeographicArea::WGS84.area(&r).abs(); let expected = 12_309e6; - assert!( - (got - expected).abs() / expected < 0.02, - "got {} km² expected ~12309 km²", - got / 1e6 - ); + assert!((got - expected).abs() / expected < 0.02); } /// The polygon path matches the ring path (no holes). @@ -218,11 +214,7 @@ mod tests { ])); let got = GeographicPolygonArea::WGS84.area(&pg).abs(); let expected = 12_309e6; - assert!( - (got - expected).abs() / expected < 0.02, - "got {}", - got / 1e6 - ); + assert!((got - expected).abs() / expected < 0.02); } /// `Default` for both strategies is WGS84. diff --git a/crates/geometry-strategy/src/geographic/azimuth.rs b/crates/geometry-strategy/src/geographic/azimuth.rs index b7beb1b..727da15 100644 --- a/crates/geometry-strategy/src/geographic/azimuth.rs +++ b/crates/geometry-strategy/src/geographic/azimuth.rs @@ -83,8 +83,8 @@ where // The single-letter names `A, B, U, V, T, M, N, d` mirror // `formula::andoyer_inverse::apply` in // `formulas/andoyer_inverse.hpp:165-219` letter-for-letter; the - // exact `== 0.0` short-circuits are the intentional analogue of - // Boost's `math::equals` guards on the same lines. + // epsilon-aware short-circuits mirror Boost's `math::equals` guards on + // the same lines. #[allow(clippy::many_single_char_names, clippy::float_cmp)] #[inline] fn azimuth(&self, p1: &P1, p2: &P2) -> f64 { @@ -111,14 +111,14 @@ where // `andoyer_inverse.hpp:127-163`. Boost returns 0 for the // aligned case (which is all this port needs to reproduce the // reference table). - if sin_d == 0.0 { + if sin_d.abs() <= f64::EPSILON { return 0.0; } let pi = core::f64::consts::PI; // Forward-azimuth term A + first-order flattening correction U. - let (a, u) = if cos_lat2 == 0.0 { + let (a, u) = if cos_lat2.abs() <= f64::EPSILON { (if sin_lat2 < 0.0 { pi } else { 0.0 }, 0.0) } else { let tan_lat2 = sin_lat2 / cos_lat2; @@ -130,7 +130,7 @@ where // Correction term V (from the reverse-azimuth term B), needed // for the forward `dA = V·T − U`. B itself is not used forward. - let v = if cos_lat1 == 0.0 { + let v = if cos_lat1.abs() <= f64::EPSILON { 0.0 } else { let tan_lat1 = sin_lat1 / cos_lat1; @@ -265,5 +265,9 @@ mod tests { let mut az = 0.5; normalize_azimuth(&mut az, 0.4, -0.1); assert_eq!(az, 0.5); + + let mut az = -0.5; + normalize_azimuth(&mut az, -0.4, -0.1); + assert_eq!(az, -0.5); } } diff --git a/crates/geometry-strategy/src/geographic/direct_karney.rs b/crates/geometry-strategy/src/geographic/direct_karney.rs index 867a4d9..6393ca1 100644 --- a/crates/geometry-strategy/src/geographic/direct_karney.rs +++ b/crates/geometry-strategy/src/geographic/direct_karney.rs @@ -64,7 +64,17 @@ impl KarneyDirect { let ep2 = e2 / (one_minus_f * one_minus_f); let sin_alpha1 = azimuth12.sin(); + let sin_alpha1 = if sin_alpha1.abs() <= f64::EPSILON { + 0.0 + } else { + sin_alpha1 + }; let cos_alpha1 = azimuth12.cos(); + let cos_alpha1 = if cos_alpha1.abs() <= f64::EPSILON { + 0.0 + } else { + cos_alpha1 + }; let mut sin_beta1 = lat1.sin() * one_minus_f; let mut cos_beta1 = lat1.cos(); let beta_norm = sin_beta1.hypot(cos_beta1); @@ -83,7 +93,10 @@ impl KarneyDirect { let mut sin_sigma1 = sin_beta1; let sin_omega1 = sin_alpha0 * sin_beta1; - let mut cos_sigma1 = if sin_beta1 != 0.0 || cos_alpha1 != 0.0 { + // Boost evaluates these terms with `sin_cos_degrees`, which returns an + // exact zero for the equatorial due-east/west cases. The radian + // trigonometric functions leave a sub-epsilon residue instead. + let mut cos_sigma1 = if sin_beta1.abs() > f64::EPSILON || cos_alpha1.abs() > f64::EPSILON { cos_beta1 * cos_alpha1 } else { 1.0 diff --git a/crates/geometry-strategy/src/geographic/distance_andoyer.rs b/crates/geometry-strategy/src/geographic/distance_andoyer.rs index 5ad2a07..58a61a5 100644 --- a/crates/geometry-strategy/src/geographic/distance_andoyer.rs +++ b/crates/geometry-strategy/src/geographic/distance_andoyer.rs @@ -243,11 +243,7 @@ mod tests { #[test] fn polar_1deg_lon_10deg_lat() { let d = Andoyer::WGS84.distance(°(0.0, 90.0), °(1.0, 80.0)); - assert!( - (d / 1000.0 - 1_116.814_237).abs() < 0.01, - "{} km expected ~ 1116.814 km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_116.814_237).abs() < 0.01); } /// `test/strategies/andoyer.cpp:226-227` — zero distance on equal @@ -264,11 +260,7 @@ mod tests { #[test] fn lon_4_lat_52_to_lon_3_lat_40() { let d = Andoyer::WGS84.distance(°(4.0, 52.0), °(3.0, 40.0)); - assert!( - (d / 1000.0 - 1_336.039_890).abs() < 0.01, - "{} km expected ~ 1336.040 km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_336.039_890).abs() < 0.01); } /// `test/strategies/andoyer.cpp:243-246` — four antipodal @@ -297,12 +289,7 @@ mod tests { (deg(90.0, 0.0), deg(-90.0, 0.0)), ] { let d = Andoyer::WGS84.distance(&a, &b); - assert!( - (d / 1000.0 - expected_km).abs() < 1.0, - "got {} km, expected ~ {} km", - d / 1000.0, - expected_km, - ); + assert!((d / 1000.0 - expected_km).abs() < 1.0); } } diff --git a/crates/geometry-strategy/src/geographic/distance_thomas.rs b/crates/geometry-strategy/src/geographic/distance_thomas.rs index 674c3f8..4cdcc55 100644 --- a/crates/geometry-strategy/src/geographic/distance_thomas.rs +++ b/crates/geometry-strategy/src/geographic/distance_thomas.rs @@ -281,11 +281,7 @@ mod tests { fn polar_north_1deg_lon_10deg_lat() { let d = Thomas::WGS84.distance(°(0.0, 90.0), °(1.0, 80.0)); // Boost's BOOST_CHECK_CLOSE(_, 0.001) ≈ 0.001 % → ~11 m here. - assert!( - (d / 1000.0 - 1_116.825_795).abs() < 0.012, - "{} km expected ~ 1116.825795 km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_116.825_795).abs() < 0.012); } /// `test/strategies/thomas.cpp:110` — southern polar mirror @@ -293,11 +289,7 @@ mod tests { #[test] fn polar_south_1deg_lon_10deg_lat() { let d = Thomas::WGS84.distance(°(0.0, -90.0), °(1.0, -80.0)); - assert!( - (d / 1000.0 - 1_116.825_795).abs() < 0.012, - "{} km expected ~ 1116.825795 km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_116.825_795).abs() < 0.012); } /// `test/strategies/thomas.cpp:111` — zero distance on equal @@ -317,11 +309,7 @@ mod tests { fn lon_4_lat_52_to_lon_3_lat_40() { let d = Thomas::WGS84.distance(°(4.0, 52.0), °(3.0, 40.0)); // Boost's BOOST_CHECK_CLOSE(_, 0.001) ≈ 0.001 % → ~13 m here. - assert!( - (d / 1000.0 - 1_336.025_365).abs() < 0.014, - "{} km expected ~ 1336.025365 km", - d / 1000.0 - ); + assert!((d / 1000.0 - 1_336.025_365).abs() < 0.014); } /// Cross-check: Thomas (second-order) and Andoyer (first-order) @@ -335,11 +323,7 @@ mod tests { let b = deg(3.0, 40.0); let t = Thomas::WGS84.distance(&a, &b); let an = Andoyer::WGS84.distance(&a, &b); - assert!( - (t - an).abs() < 50.0, - "Thomas {t} m vs Andoyer {an} m differs by {} m", - (t - an).abs() - ); + assert!((t - an).abs() < 50.0); } /// Thomas's default constructor selects WGS84 — mirrors Boost's diff --git a/crates/geometry-strategy/src/geographic/inverse_karney.rs b/crates/geometry-strategy/src/geographic/inverse_karney.rs index 6032afb..1acb098 100644 --- a/crates/geometry-strategy/src/geographic/inverse_karney.rs +++ b/crates/geometry-strategy/src/geographic/inverse_karney.rs @@ -112,43 +112,47 @@ impl KarneyInverse { core::f64::consts::PI, ]; let distance_seeds = [spherical_distance, half_meridian]; - let mut best: Option<(f64, InverseResult)> = None; + let first_candidate = self.solve_seed( + &direct, + lon1, + lat1, + lon2, + lat2, + distance_seeds[0], + azimuth_seeds[0], + ); + let first_endpoint = direct.apply( + lon1, + lat1, + first_candidate.distance, + first_candidate.azimuth, + ); + let first_error = endpoint_error(first_endpoint.lon2, first_endpoint.lat2, lon2, lat2); + let mut best = (first_error, first_candidate); - for &azimuth_seed in &azimuth_seeds { - for &distance_seed in &distance_seeds { + for (azimuth_index, &azimuth_seed) in azimuth_seeds.iter().enumerate() { + let remaining_distances = if azimuth_index == 0 { + &distance_seeds[1..] + } else { + &distance_seeds[..] + }; + for &distance_seed in remaining_distances { let candidate = self.solve_seed(&direct, lon1, lat1, lon2, lat2, distance_seed, azimuth_seed); let endpoint = direct.apply(lon1, lat1, candidate.distance, candidate.azimuth); let error = endpoint_error(endpoint.lon2, endpoint.lat2, lon2, lat2); - let replace = match best { - None => true, - Some((best_error, best_result)) => { - error < best_error - || (error <= self.tolerance - && best_error <= self.tolerance - && candidate.distance < best_result.distance) - } - }; + let (best_error, best_result) = best; + let replace = error < best_error + || (error <= self.tolerance + && best_error <= self.tolerance + && candidate.distance < best_result.distance); if replace { - best = Some((error, candidate)); + best = (error, candidate); } } } - best.map_or_else( - || { - self.solve_seed( - &direct, - lon1, - lat1, - lon2, - lat2, - spherical_distance, - spherical_azimuth, - ) - }, - |(_, result)| result, - ) + best.1 } #[cfg(feature = "std")] diff --git a/crates/geometry-strategy/src/geographic/length.rs b/crates/geometry-strategy/src/geographic/length.rs index a3cde40..b95e045 100644 --- a/crates/geometry-strategy/src/geographic/length.rs +++ b/crates/geometry-strategy/src/geographic/length.rs @@ -89,12 +89,10 @@ where for w in pts.windows(2) { acc = acc + self.andoyer.distance(w[0], w[1]); } - if matches!(g.closure(), Closure::Open) { - if let (Some(first), Some(last)) = (pts.first(), pts.last()) { - acc = acc + self.andoyer.distance(last, first); - } + match (g.closure(), pts.first(), pts.last()) { + (Closure::Open, Some(first), Some(last)) => acc + self.andoyer.distance(last, first), + _ => acc, } - acc } } diff --git a/crates/geometry-strategy/src/geographic/mod.rs b/crates/geometry-strategy/src/geographic/mod.rs index 69c50fb..c4a91db 100644 --- a/crates/geometry-strategy/src/geographic/mod.rs +++ b/crates/geometry-strategy/src/geographic/mod.rs @@ -20,6 +20,7 @@ mod inverse; mod inverse_karney; pub mod length; mod meridian; +pub mod rhumb; pub mod spheroid_calc; mod vertex; @@ -40,6 +41,7 @@ pub use inverse::InverseResult; pub use inverse_karney::{Karney, KarneyInverse}; pub use length::{GeographicLength, GeographicPerimeter}; pub use meridian::{Meridian, MeridianInverseResult, MeridianSegmentKind}; +pub use rhumb::{Rhumb, RhumbFamily}; #[cfg(feature = "std")] pub use vertex::{ geographic_vertex_latitude, geographic_vertex_longitude, spherical_vertex_latitude, diff --git a/crates/geometry-strategy/src/geographic/rhumb.rs b/crates/geometry-strategy/src/geographic/rhumb.rs new file mode 100644 index 0000000..9e3f01d --- /dev/null +++ b/crates/geometry-strategy/src/geographic/rhumb.rs @@ -0,0 +1,213 @@ +//! Constant-bearing rhumb-line measurements on an angular coordinate system. +//! +//! Boost.Geometry has no rhumb-line strategy. The closed-form spherical +//! formulas follow Ed Williams' *Aviation Formulary* and the isometric-latitude +//! treatment described by Bowring. The strategy is shared by spherical and +//! geographic coordinate families; geographic use is a spherical mean-radius +//! approximation rather than an ellipsoidal geodesic. + +#[cfg(not(feature = "std"))] +use geometry_coords::math::Float; +use geometry_cs::{AngleUnit, CoordinateSystem, GeographicFamily, SphericalFamily}; +use geometry_model::Point2D; +use geometry_trait::{Linestring, Point}; + +use crate::azimuth::AzimuthStrategy; +use crate::destination::DestinationStrategy; +use crate::distance::DistanceStrategy; +use crate::length::LengthStrategy; +use crate::normalise::{HasAngularUnits, lonlat_radians}; + +/// Coordinate-system families for which a loxodrome is defined. +#[doc(hidden)] +pub trait RhumbFamily {} + +impl RhumbFamily for SphericalFamily {} +impl RhumbFamily for GeographicFamily {} + +/// Spherical rhumb-line metric with a configurable radius. +#[derive(Debug, Clone, Copy)] +pub struct Rhumb { + /// Sphere radius in distance units. + pub radius: f64, +} + +impl Rhumb { + /// IUGG mean Earth radius in metres. + pub const EARTH: Self = Self { + radius: 6_371_008.8, + }; + + /// Unit sphere, returning angular distance in radians. + pub const UNIT: Self = Self { radius: 1.0 }; + + /// Construct a rhumb metric with an application-defined radius. + #[must_use] + pub const fn with_radius(radius: f64) -> Self { + Self { radius } + } +} + +impl Default for Rhumb { + fn default() -> Self { + Self::EARTH + } +} + +impl DistanceStrategy for Rhumb +where + P1: Point, + P2: Point, + P1::Cs: HasAngularUnits + CoordinateSystem, + ::Family: RhumbFamily, +{ + type Out = f64; + type Comparable = Self; + + fn distance(&self, first: &P1, second: &P2) -> Self::Out { + rhumb_inverse(first, second).0 * self.radius + } + + fn comparable(&self) -> Self::Comparable { + *self + } +} + +impl AzimuthStrategy for Rhumb +where + P1: Point, + P2: Point, + P1::Cs: HasAngularUnits + CoordinateSystem, + ::Family: RhumbFamily, +{ + type Out = f64; + + fn azimuth(&self, first: &P1, second: &P2) -> Self::Out { + rhumb_inverse(first, second).1 + } +} + +impl

DestinationStrategy

for Rhumb +where + P: Point, + P::Cs: HasAngularUnits + CoordinateSystem, + ::Family: RhumbFamily, +{ + type Output = Point2D; + + fn destination(&self, origin: &P, bearing: f64, distance: f64) -> Self::Output { + type Units

= <

::Cs as HasAngularUnits>::Units; + let (longitude1, latitude1) = lonlat_radians(origin); + let angular_distance = distance / self.radius; + let delta_latitude = angular_distance * bearing.cos(); + let latitude2 = reflect_latitude(latitude1 + delta_latitude); + let delta_psi = isometric_latitude(latitude2) - isometric_latitude(latitude1); + let q = meridional_scale(delta_latitude, delta_psi, latitude1); + let delta_longitude = if q.abs() <= f64::EPSILON { + 0.0 + } else { + angular_distance * bearing.sin() / q + }; + let longitude2 = normalize_longitude(longitude1 + delta_longitude); + Point2D::new( + Units::

::from_radians(longitude2), + Units::

::from_radians(latitude2), + ) + } +} + +impl LengthStrategy for Rhumb +where + L: Linestring, + L::Point: Point, + ::Cs: HasAngularUnits + CoordinateSystem, + <::Cs as CoordinateSystem>::Family: RhumbFamily, +{ + type Out = f64; + + fn length(&self, line: &L) -> Self::Out { + let points = line.points(); + points + .clone() + .zip(points.skip(1)) + .map(|(first, second)| { + >::distance(self, first, second) + }) + .sum() + } +} + +fn rhumb_inverse(first: &P1, second: &P2) -> (f64, f64) +where + P1: Point, + P2: Point, + P1::Cs: HasAngularUnits, +{ + let (longitude1, latitude1) = lonlat_radians(first); + let (longitude2, latitude2) = lonlat_radians(second); + let delta_latitude = latitude2 - latitude1; + let delta_longitude = normalize_delta(longitude2 - longitude1); + let delta_psi = isometric_latitude(latitude2) - isometric_latitude(latitude1); + let q = meridional_scale(delta_latitude, delta_psi, latitude1); + let angular_distance = delta_latitude.hypot(q * delta_longitude); + let azimuth = delta_longitude + .atan2(delta_psi) + .rem_euclid(core::f64::consts::TAU); + (angular_distance, azimuth) +} + +fn isometric_latitude(latitude: f64) -> f64 { + (core::f64::consts::FRAC_PI_4 + latitude / 2.0).tan().ln() +} + +fn meridional_scale(delta_latitude: f64, delta_psi: f64, latitude: f64) -> f64 { + if delta_psi.abs() > 1e-12 { + delta_latitude / delta_psi + } else { + latitude.cos() + } +} + +fn normalize_delta(delta: f64) -> f64 { + (delta + core::f64::consts::PI).rem_euclid(core::f64::consts::TAU) - core::f64::consts::PI +} + +fn normalize_longitude(longitude: f64) -> f64 { + normalize_delta(longitude) +} + +fn reflect_latitude(latitude: f64) -> f64 { + let latitude = (latitude + core::f64::consts::PI).rem_euclid(core::f64::consts::TAU) + - core::f64::consts::PI; + if latitude > core::f64::consts::FRAC_PI_2 { + core::f64::consts::PI - latitude + } else if latitude < -core::f64::consts::FRAC_PI_2 { + -core::f64::consts::PI - latitude + } else { + latitude + } +} + +#[cfg(test)] +mod tests { + use geometry_cs::{Degree, Spherical}; + use geometry_model::{Linestring, Point2D}; + use geometry_trait::Point as _; + + use super::*; + + #[test] + fn equatorial_degree_has_expected_measurements() { + type P = Point2D>; + let start = P::new(0.0, 0.0); + let east = P::new(1.0, 0.0); + let distance = Rhumb::EARTH.distance(&start, &east); + assert!((distance - 111_195.080_233_532_9).abs() < 1e-6); + assert!((Rhumb::EARTH.azimuth(&start, &east) - core::f64::consts::FRAC_PI_2).abs() < 1e-12); + let destination = Rhumb::EARTH.destination(&start, core::f64::consts::FRAC_PI_2, distance); + assert!((destination.get::<0>() - 1.0).abs() < 1e-10); + + let line = Linestring::from_vec(alloc::vec![start, east, P::new(2.0, 0.0)]); + assert!((Rhumb::EARTH.length(&line) - 2.0 * distance).abs() < 1e-6); + } +} diff --git a/crates/geometry-strategy/src/intersects.rs b/crates/geometry-strategy/src/intersects.rs index 77bac2d..78d6aee 100644 --- a/crates/geometry-strategy/src/intersects.rs +++ b/crates/geometry-strategy/src/intersects.rs @@ -283,13 +283,16 @@ where ::Family: SameAs, { fn intersects(&self, ls: &L, pg: &G) -> bool { - // (a) Any vertex inside or on the polygon — fast path. - for v in ls.points() { - if WithinPoly.covered_by(v, pg) { - return true; - } + // A connected line with no polygon-boundary crossing cannot change + // between polygon material and its complement, so one representative + // point is sufficient for containment. + let Some(first) = ls.points().next() else { + return false; + }; + if WithinPoly.covered_by(first, pg) { + return true; } - // (b) Any sub-segment crossing any ring sub-segment. + // Any sub-segment crossing any ring sub-segment. if linestring_crosses_ring(ls, pg.exterior()) { return true; } @@ -566,6 +569,30 @@ fn side_sign(a: (T, T), b: (T, T), c: (T, T)) -> i32 { } } +/// Whether the closed axis-aligned bounds of two segments are disjoint. +/// +/// Comparisons are written without `min`/`max` so a coordinate that is not +/// ordered (for example, `NaN`) falls through to the exact segment predicate. +#[inline] +fn segment_bounds_disjoint

(p1: &P, p2: &P, p3: &P, p4: &P) -> bool +where + P: PointTrait, +{ + let x1 = p1.get::<0>(); + let y1 = p1.get::<1>(); + let x2 = p2.get::<0>(); + let y2 = p2.get::<1>(); + let x3 = p3.get::<0>(); + let y3 = p3.get::<1>(); + let x4 = p4.get::<0>(); + let y4 = p4.get::<1>(); + + (x1 < x3 && x1 < x4 && x2 < x3 && x2 < x4) + || (x3 < x1 && x3 < x2 && x4 < x1 && x4 < x2) + || (y1 < y3 && y1 < y4 && y2 < y3 && y2 < y4) + || (y3 < y1 && y3 < y2 && y4 < y1 && y4 < y2) +} + /// Does any sub-segment of `ls` cross any sub-segment of `r` (with /// `r`'s closing edge added explicitly if the ring is open)? fn linestring_crosses_ring(ls: &L, r: &R) -> bool @@ -600,17 +627,20 @@ where return false; }; let first = pr; + let mut has_edge = false; for qr in ir { - if segments_intersect(pls, qls, pr, qr) { + has_edge = true; + if !segment_bounds_disjoint(pls, qls, pr, qr) && segments_intersect(pls, qls, pr, qr) { return true; } pr = qr; } - // Close an open ring explicitly. For a closed ring `pr == first` - // and the test is degenerate (zero-length edge) — `segments_intersect` - // returns `false` unless the linestring edge passes through the - // closing vertex, which is the desired behaviour. - segments_intersect(pls, qls, pr, first) + if has_edge && pr.get::<0>() == first.get::<0>() && pr.get::<1>() == first.get::<1>() { + return false; + } + // Close an open coordinate sequence explicitly. A one-point ring keeps + // its degenerate edge so its established point-like behavior is intact. + !segment_bounds_disjoint(pls, qls, pr, first) && segments_intersect(pls, qls, pr, first) } /// Does any edge of ring `a` cross any edge of ring `b`? Both rings @@ -662,9 +692,9 @@ mod tests { //! `geometry/test/algorithms/intersects/intersects.cpp:38-79`. //! Each test cites the C++ line it mirrors. - use super::{CartesianIntersects, IntersectsStrategy, Reversed}; + use super::{CartesianIntersects, IntersectsStrategy, Reversed, linestring_crosses_ring}; use geometry_cs::Cartesian; - use geometry_model::{Point2D, Polygon, Segment, linestring, polygon}; + use geometry_model::{Point2D, Polygon, Ring, Segment, linestring, polygon}; type P = Point2D; @@ -743,6 +773,23 @@ mod tests { assert!(!CartesianIntersects.intersects(&ls, &p)); } + /// The public dispatcher rejects empty inputs before reaching the + /// linestring/ring edge helper, so guard its private empty-iterator + /// contract directly. + #[test] + fn empty_linestring_has_no_ring_crossing() { + let ls = Linestring::

::new(); + let ring: Ring

= Ring::from_vec(vec![ + pt(0.0, 0.0), + pt(0.0, 1.0), + pt(1.0, 1.0), + pt(1.0, 0.0), + pt(0.0, 0.0), + ]); + + assert!(!linestring_crosses_ring(&ls, &ring)); + } + /// `Reversed` swaps the arguments transparently. #[test] fn reversed_pair_compiles_and_agrees() { diff --git a/crates/geometry-strategy/src/length.rs b/crates/geometry-strategy/src/length.rs index 3cda1ff..8aef4c3 100644 --- a/crates/geometry-strategy/src/length.rs +++ b/crates/geometry-strategy/src/length.rs @@ -132,11 +132,10 @@ where // An open ring leaves the closing edge implicit — add it // explicitly. Mirrors the `closeable_view` wrap at // `algorithms/length.hpp:90`. - let mut it = g.points(); - if let Some(first) = it.next() { - if let Some(last) = g.points().last() { - total = total + Pythagoras.distance(last, first); - } + let mut points = g.points(); + if let Some(first) = points.next() { + let last = points.last().unwrap_or(first); + total = total + Pythagoras.distance(last, first); } } total diff --git a/crates/geometry-strategy/src/lib.rs b/crates/geometry-strategy/src/lib.rs index 60302cb..f48c0a3 100644 --- a/crates/geometry-strategy/src/lib.rs +++ b/crates/geometry-strategy/src/lib.rs @@ -110,6 +110,7 @@ pub mod closest_points; pub mod compare; pub mod convex_hull; pub mod densify; +pub mod destination; pub mod disjoint; pub mod distance; pub mod envelope; @@ -120,6 +121,7 @@ pub mod length; pub mod line_interpolate; pub(crate) mod normalise; mod reversal; +pub mod segmentize; pub mod simplify; pub mod spherical; pub mod transform; @@ -143,8 +145,9 @@ pub use centroid::{ }; pub use closest_points::{CartesianClosestPoints, ClosestPointsStrategy}; pub use compare::{ALL_DIMENSIONS, EqualTo, Greater, Less, LessExact}; -pub use convex_hull::{ConvexHullStrategy, MonotoneChain}; +pub use convex_hull::{CollectPoints, ConvexHullStrategy, MonotoneChain}; pub use densify::{CartesianDensify, DensifyStrategy}; +pub use destination::{DefaultDestination, DefaultDestinationStrategy, DestinationStrategy}; pub use disjoint::{CartesianDisjoint, DisjointStrategy}; pub use distance::{DefaultDistance, DefaultDistanceStrategy, DistanceStrategy}; pub use envelope::{ @@ -158,7 +161,7 @@ pub use equals::{ pub use geographic::{ Andoyer, DirectResult, GeographicArea, GeographicAzimuth, GeographicLength, GeographicPerimeter, GeographicPolygonArea, InverseResult, Karney, KarneyDirect, KarneyInverse, - Thomas, ThomasDirect, Vincenty, VincentyDirect, + Rhumb, RhumbFamily, Thomas, ThomasDirect, Vincenty, VincentyDirect, }; pub use intersects::{CartesianIntersects, IntersectsPairStrategy, IntersectsStrategy}; pub use length::{ @@ -167,10 +170,13 @@ pub use length::{ }; pub use line_interpolate::{CartesianLineInterpolate, LineInterpolateStrategy}; pub use reversal::Reversed; -pub use simplify::{DouglasPeucker, SimplifyStrategy}; +pub use segmentize::{CartesianSegmentize, SegmentizeStrategy}; +pub use simplify::{ + DouglasPeucker, SimplifyStrategy, VisvalingamWhyatt, VisvalingamWhyattPreserve, +}; pub use spherical::{ - ComparableHaversine, Haversine, SphericalArea, SphericalAzimuth, SphericalLength, - SphericalPerimeter, SphericalPolygonArea, + ChamberlainDuquetteArea, ComparableHaversine, CrossTrack, Haversine, HaversineClosestPoints, + SphericalArea, SphericalAzimuth, SphericalLength, SphericalPerimeter, SphericalPolygonArea, }; -pub use transform::{Affine2, Affine3, TransformStrategy}; +pub use transform::{Affine2, Affine3, Rotate, Scale, Skew, TransformStrategy, Translate}; pub use within::{WithinBox, WithinPoly, WithinRing, WithinStrategy, WithinStrategyForKind}; diff --git a/crates/geometry-strategy/src/segmentize.rs b/crates/geometry-strategy/src/segmentize.rs new file mode 100644 index 0000000..53104b8 --- /dev/null +++ b/crates/geometry-strategy/src/segmentize.rs @@ -0,0 +1,252 @@ +//! Equal-length linestring subdivision strategies. +//! +//! Cartesian subdivision interpolates each edge linearly. The spherical +//! implementation uses Haversine lengths and great-circle interpolation, so +//! explicit strategy selection changes both measurement and cut placement. + +use alloc::{vec, vec::Vec}; + +use geometry_cs::{CartesianFamily, CoordinateSystem, SphericalFamily}; +use geometry_model::{Linestring as ModelLinestring, MultiLinestring}; +use geometry_tag::SameAs; +use geometry_trait::{Linestring, Point, PointMut}; + +use crate::{DistanceStrategy, Haversine, Pythagoras}; + +#[cfg(feature = "std")] +use crate::normalise::{HasAngularUnits, lonlat_radians}; +#[cfg(feature = "std")] +use geometry_cs::AngleUnit; + +/// Strategy for splitting a linestring into equal-length pieces. +pub trait SegmentizeStrategy { + /// Segmented output geometry. + type Output; + + /// Split `line` into `count` pieces. + fn segmentize(&self, line: &L, count: usize) -> Self::Output; +} + +/// Cartesian length and linear-interpolation segmentization. +#[derive(Debug, Default, Clone, Copy)] +pub struct CartesianSegmentize; + +impl SegmentizeStrategy for CartesianSegmentize +where + L: Linestring, + P: Point + PointMut + Default + Copy, + ::Family: SameAs, + Pythagoras: DistanceStrategy, +{ + type Output = MultiLinestring>; + + fn segmentize(&self, line: &L, count: usize) -> Self::Output { + segmentize(line, count, self) + } +} + +impl

SegmentMetric

for CartesianSegmentize +where + P: Point + PointMut + Default, + Pythagoras: DistanceStrategy, +{ + fn distance(&self, first: &P, second: &P) -> f64 { + Pythagoras.distance(first, second) + } + + fn interpolate(&self, first: &P, second: &P, fraction: f64) -> P { + linear_interpolate(first, second, fraction) + } +} + +#[cfg(feature = "std")] +impl SegmentizeStrategy for Haversine +where + L: Linestring, + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, + ::Family: SameAs, + Haversine: DistanceStrategy, +{ + type Output = MultiLinestring>; + + fn segmentize(&self, line: &L, count: usize) -> Self::Output { + segmentize(line, count, self) + } +} + +#[cfg(feature = "std")] +impl

SegmentMetric

for Haversine +where + P: Point + PointMut + Default, + P::Cs: HasAngularUnits, + Haversine: DistanceStrategy, +{ + fn distance(&self, first: &P, second: &P) -> f64 { + DistanceStrategy::distance(self, first, second) + } + + fn interpolate(&self, first: &P, second: &P, fraction: f64) -> P { + great_circle_interpolate(first, second, fraction, self.radius) + } +} + +trait SegmentMetric

{ + fn distance(&self, first: &P, second: &P) -> f64; + fn interpolate(&self, first: &P, second: &P, fraction: f64) -> P; +} + +#[allow( + clippy::cast_precision_loss, + reason = "piece counts become normalized f64 fractions" +)] +fn segmentize(line: &L, count: usize, metric: &M) -> MultiLinestring> +where + L: Linestring, + P: Point + PointMut + Default + Copy, + M: SegmentMetric

, +{ + let points: Vec

= line.points().copied().collect(); + if count == 0 || points.len() < 2 { + return MultiLinestring(Vec::new()); + } + + let mut cumulative = Vec::with_capacity(points.len()); + cumulative.push(0.0); + for edge in points.windows(2) { + let next = cumulative.last().copied().unwrap_or(0.0) + metric.distance(&edge[0], &edge[1]); + cumulative.push(next); + } + // `cumulative` is initialized with the zero-distance origin above. + let total = *cumulative + .last() + .expect("cumulative distances are non-empty"); + if total == 0.0 { + return MultiLinestring(vec![ModelLinestring::from_vec(points)]); + } + + let mut pieces = Vec::with_capacity(count); + for piece_index in 0..count { + let start_distance = total * piece_index as f64 / count as f64; + let end_distance = total * (piece_index + 1) as f64 / count as f64; + let mut piece = Vec::new(); + piece.push(point_at_distance( + &points, + &cumulative, + start_distance, + metric, + )); + for (index, distance) in cumulative.iter().copied().enumerate().skip(1) { + if distance > start_distance && distance < end_distance { + piece.push(points[index]); + } + } + piece.push(point_at_distance( + &points, + &cumulative, + end_distance, + metric, + )); + pieces.push(ModelLinestring::from_vec(piece)); + } + MultiLinestring(pieces) +} + +fn point_at_distance(points: &[P], cumulative: &[f64], distance: f64, metric: &M) -> P +where + P: Point + PointMut + Default + Copy, + M: SegmentMetric

, +{ + if distance <= 0.0 { + return points[0]; + } + let total = cumulative + .last() + .copied() + .expect("segmentization builds one cumulative distance per input point"); + if distance >= total { + return *points.last().unwrap_or(&points[0]); + } + let edge_index = cumulative + .windows(2) + .position(|range| distance <= range[1]) + .unwrap_or(cumulative.len().saturating_sub(2)); + let edge_length = cumulative[edge_index + 1] - cumulative[edge_index]; + // A positive in-range distance selects the first cumulative interval + // ending at that distance; zero-length plateaus are therefore skipped. + metric.interpolate( + &points[edge_index], + &points[edge_index + 1], + (distance - cumulative[edge_index]) / edge_length, + ) +} + +fn linear_interpolate

(first: &P, second: &P, fraction: f64) -> P +where + P: Point + PointMut + Default, +{ + let mut output = P::default(); + geometry_trait::fold_dims((), first, |(), _, dimension| { + let first_value = get_dimension(first, dimension); + let second_value = get_dimension(second, dimension); + set_dimension( + &mut output, + dimension, + first_value + fraction * (second_value - first_value), + ); + }); + output +} + +#[cfg(feature = "std")] +fn great_circle_interpolate

(first: &P, second: &P, fraction: f64, radius: f64) -> P +where + P: Point + PointMut + Default, + P::Cs: HasAngularUnits, + Haversine: DistanceStrategy, +{ + type Units

= <

::Cs as HasAngularUnits>::Units; + let metric = Haversine { radius }; + let angle = DistanceStrategy::distance(&metric, first, second) / radius; + let sine = angle.sin(); + if sine.abs() < f64::EPSILON { + return linear_interpolate(first, second, fraction); + } + + let (longitude1, latitude1) = lonlat_radians(first); + let (longitude2, latitude2) = lonlat_radians(second); + let first_weight = ((1.0 - fraction) * angle).sin() / sine; + let second_weight = (fraction * angle).sin() / sine; + let x = first_weight * latitude1.cos() * longitude1.cos() + + second_weight * latitude2.cos() * longitude2.cos(); + let y = first_weight * latitude1.cos() * longitude1.sin() + + second_weight * latitude2.cos() * longitude2.sin(); + let z = first_weight * latitude1.sin() + second_weight * latitude2.sin(); + let longitude = y.atan2(x); + let latitude = z.atan2(x.hypot(y)); + + let mut output = linear_interpolate(first, second, fraction); + output.set::<0>(Units::

::from_radians(longitude)); + output.set::<1>(Units::

::from_radians(latitude)); + output +} + +fn get_dimension>(point: &P, dimension: usize) -> f64 { + match dimension { + 0 => point.get::<0>(), + 1 => point.get::<1>(), + 2 => point.get::<2>(), + 3 => point.get::<3>(), + _ => unreachable!("point folds are limited to four dimensions"), + } +} + +fn set_dimension>(point: &mut P, dimension: usize, value: f64) { + match dimension { + 0 => point.set::<0>(value), + 1 => point.set::<1>(value), + 2 => point.set::<2>(value), + 3 => point.set::<3>(value), + _ => unreachable!("point folds are limited to four dimensions"), + } +} diff --git a/crates/geometry-strategy/src/simplify.rs b/crates/geometry-strategy/src/simplify.rs index 3a34f58..8d02c30 100644 --- a/crates/geometry-strategy/src/simplify.rs +++ b/crates/geometry-strategy/src/simplify.rs @@ -46,6 +46,28 @@ pub trait SimplifyStrategy { #[derive(Debug, Default, Clone, Copy)] pub struct DouglasPeucker>(pub D); +/// Visvalingam–Whyatt area-ranked line simplification. +/// +/// Repeatedly removes the interior vertex with the smallest adjacent-triangle +/// area while that area is at most the tolerance supplied to +/// [`SimplifyStrategy::simplify`]. Endpoints are always retained. +/// +/// Implements the method from Visvalingam and Whyatt, “Line Generalisation by +/// Repeated Elimination of Points” (1993). Boost.Geometry has no equivalent +/// strategy; this is an opt-in peer of [`DouglasPeucker`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct VisvalingamWhyatt; + +/// Topology-preserving Visvalingam–Whyatt line simplification. +/// +/// Uses the same area ranking as [`VisvalingamWhyatt`], and applies the Davies +/// refinement when removing a vertex would introduce a self-intersection: the +/// preceding retained vertex is removed next so the transient crossing is +/// eliminated. The implementation uses an allocation-only quadratic scan, +/// keeping the strategy available in `no_std` builds. +#[derive(Debug, Default, Clone, Copy)] +pub struct VisvalingamWhyattPreserve; + impl SimplifyStrategy for DouglasPeucker where P: Point + PointMut + Default + Copy, @@ -84,6 +106,175 @@ where } } +impl SimplifyStrategy for VisvalingamWhyatt +where + P: Point + PointMut + Default + Copy, + L: Linestring, + ::Family: SameAs, +{ + type Output = geometry_model::Linestring

; + + fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output { + visvalingam_whyatt(ls, max_distance, false) + } +} + +impl SimplifyStrategy for VisvalingamWhyattPreserve +where + P: Point + PointMut + Default + Copy, + L: Linestring, + ::Family: SameAs, +{ + type Output = geometry_model::Linestring

; + + fn simplify(&self, ls: &L, max_distance: f64) -> Self::Output { + visvalingam_whyatt(ls, max_distance, true) + } +} + +fn visvalingam_whyatt( + ls: &L, + minimum_area: f64, + preserve_topology: bool, +) -> geometry_model::Linestring

+where + P: Point + PointMut + Default + Copy, + L: Linestring, +{ + let points: Vec

= ls.points().copied().collect(); + if points.len() < 3 || minimum_area <= 0.0 || minimum_area.is_nan() { + return geometry_model::Linestring::from_vec(points); + } + + let mut retained: Vec = (0..points.len()).collect(); + let mut forced_predecessor = None; + + while retained.len() > 2 { + let selected = forced_predecessor + .take() + .and_then(|index| retained.iter().position(|candidate| *candidate == index)) + .filter(|slot| *slot > 0 && *slot + 1 < retained.len()) + .map(|slot| (slot, 0.0)) + .or_else(|| smallest_triangle(&points, &retained)); + // `retained.len() > 2` guarantees at least one interior triangle, so + // the fallback scan always produces a candidate even when a forced + // predecessor is no longer eligible. + let (slot, area) = selected.expect("an interior triangle is available"); + if area > minimum_area { + break; + } + + let creates_crossing = + preserve_topology && removal_creates_crossing(&points, &retained, slot); + let predecessor = retained[slot - 1]; + retained.remove(slot); + if creates_crossing { + forced_predecessor = Some(predecessor); + } + } + + geometry_model::Linestring::from_vec(retained.into_iter().map(|index| points[index]).collect()) +} + +fn smallest_triangle

(points: &[P], retained: &[usize]) -> Option<(usize, f64)> +where + P: Point, +{ + let mut selected = None; + for slot in 1..retained.len().saturating_sub(1) { + let area = triangle_area( + &points[retained[slot - 1]], + &points[retained[slot]], + &points[retained[slot + 1]], + ); + if selected.is_none_or(|(_, smallest)| area < smallest) { + selected = Some((slot, area)); + } + } + selected +} + +#[inline] +fn triangle_area

(first: &P, middle: &P, last: &P) -> f64 +where + P: Point, +{ + let twice_area = (middle.get::<0>() - first.get::<0>()) * (last.get::<1>() - first.get::<1>()) + - (middle.get::<1>() - first.get::<1>()) * (last.get::<0>() - first.get::<0>()); + twice_area.abs() * 0.5 +} + +fn removal_creates_crossing

(points: &[P], retained: &[usize], slot: usize) -> bool +where + P: Point, +{ + let left = retained[slot - 1]; + let current = retained[slot]; + let right = retained[slot + 1]; + + retained.windows(2).any(|edge| { + let start = edge[0]; + let end = edge[1]; + if start == left + || end == left + || start == current + || end == current + || start == right + || end == right + { + return false; + } + segments_intersect(&points[left], &points[right], &points[start], &points[end]) + }) +} + +fn segments_intersect

(first: &P, second: &P, third: &P, fourth: &P) -> bool +where + P: Point, +{ + let o1 = orientation(first, second, third); + let o2 = orientation(first, second, fourth); + let o3 = orientation(third, fourth, first); + let o4 = orientation(third, fourth, second); + + if o1 != o2 && o3 != o4 && o1 != 0 && o2 != 0 && o3 != 0 && o4 != 0 { + return true; + } + (o1 == 0 && point_on_segment(third, first, second)) + || (o2 == 0 && point_on_segment(fourth, first, second)) + || (o3 == 0 && point_on_segment(first, third, fourth)) + || (o4 == 0 && point_on_segment(second, third, fourth)) +} + +#[inline] +fn orientation

(first: &P, second: &P, third: &P) -> i8 +where + P: Point, +{ + let cross = (second.get::<0>() - first.get::<0>()) * (third.get::<1>() - first.get::<1>()) + - (second.get::<1>() - first.get::<1>()) * (third.get::<0>() - first.get::<0>()); + if cross > 0.0 { + 1 + } else if cross < 0.0 { + -1 + } else { + 0 + } +} + +#[inline] +fn point_on_segment

(point: &P, first: &P, second: &P) -> bool +where + P: Point, +{ + let x = point.get::<0>(); + let y = point.get::<1>(); + first.get::<0>().min(second.get::<0>()) <= x + && x <= first.get::<0>().max(second.get::<0>()) + && first.get::<1>().min(second.get::<1>()) <= y + && y <= first.get::<1>().max(second.get::<1>()) +} + /// Recursive Douglas–Peucker split over `pts[lo..=hi]`. /// /// Marks the vertex furthest from the chord `pts[lo]`-`pts[hi]` as kept @@ -135,7 +326,9 @@ mod tests { extern crate alloc; - use super::{DouglasPeucker, SimplifyStrategy}; + use super::{ + DouglasPeucker, SimplifyStrategy, orientation, point_on_segment, segments_intersect, + }; use crate::cartesian::{PointToSegment, Pythagoras}; use alloc::vec; use alloc::vec::Vec; @@ -203,4 +396,38 @@ mod tests { let s2 = default_dp().simplify(&dup, -1.0); assert_eq!(s2.0.len(), 4, "negative tolerance keeps every vertex"); } + + #[test] + fn private_collinear_intersection_guards_cover_every_endpoint_order() { + let a = Pt::new(0.0, 0.0); + let b = Pt::new(2.0, 0.0); + assert!(segments_intersect( + &a, + &b, + &Pt::new(1.0, 0.0), + &Pt::new(1.0, 1.0) + )); + assert!(segments_intersect( + &a, + &b, + &Pt::new(1.0, 1.0), + &Pt::new(1.0, 0.0) + )); + assert!(segments_intersect( + &Pt::new(1.0, 0.0), + &Pt::new(1.0, 1.0), + &a, + &b, + )); + assert!(segments_intersect( + &Pt::new(1.0, 1.0), + &Pt::new(1.0, 0.0), + &a, + &b, + )); + assert_eq!(orientation(&a, &b, &Pt::new(1.0, 1.0)), 1); + assert_eq!(orientation(&a, &b, &Pt::new(1.0, -1.0)), -1); + assert_eq!(orientation(&a, &b, &Pt::new(1.0, 0.0)), 0); + assert!(point_on_segment(&Pt::new(1.0, 0.0), &a, &b)); + } } diff --git a/crates/geometry-strategy/src/spherical/area.rs b/crates/geometry-strategy/src/spherical/area.rs index 09581f8..04642a6 100644 --- a/crates/geometry-strategy/src/spherical/area.rs +++ b/crates/geometry-strategy/src/spherical/area.rs @@ -204,11 +204,10 @@ where acc += segment_excess::(a, b); } if matches!(r.closure(), Closure::Open) { - let mut first_it = r.points(); - if let Some(first) = first_it.next() { - if let Some(last) = r.points().last() { - acc += segment_excess::(last, first); - } + let mut points = r.points(); + if let Some(first) = points.next() { + let last = points.last().unwrap_or(first); + acc += segment_excess::(last, first); } } acc diff --git a/crates/geometry-strategy/src/spherical/area_chamberlain_duquette.rs b/crates/geometry-strategy/src/spherical/area_chamberlain_duquette.rs new file mode 100644 index 0000000..40e3118 --- /dev/null +++ b/crates/geometry-strategy/src/spherical/area_chamberlain_duquette.rs @@ -0,0 +1,117 @@ +//! Chamberlain–Duquette spherical polygon area. +//! +//! This is an opt-in, lower-cost alternative to Boost.Geometry's default +//! spherical-excess area strategy. Boost has no corresponding strategy; the +//! formula comes from Chamberlain & Duquette, *Some Algorithms for Polygons on +//! a Sphere* (JPL Publication 07-03, 2007). + +use alloc::vec::Vec; + +#[cfg(not(feature = "std"))] +use geometry_coords::math::Float; +use geometry_cs::{CoordinateSystem, SphericalFamily}; +use geometry_tag::SameAs; +use geometry_trait::{Point, PointOrder, Polygon, Ring}; + +use crate::area::AreaStrategy; +use crate::normalise::{HasAngularUnits, lonlat_radians}; + +/// Chamberlain–Duquette area for a spherical polygon. +/// +/// For every ring vertex `i`, the strategy accumulates +/// `(lon[i+1] - lon[i-1]) * sin(lat[i])`, then multiplies by `R² / 2`. +/// The result follows this crate's signed-area convention: a ring matching its +/// declared order is positive, while an oppositely wound ring is negative. +#[derive(Debug, Clone, Copy)] +pub struct ChamberlainDuquetteArea { + /// Sphere radius in output units. + pub radius: f64, +} + +impl ChamberlainDuquetteArea { + /// Mean Earth radius in metres. + pub const EARTH: Self = Self { + radius: 6_371_008.8, + }; + + /// Unit sphere; output is a solid angle in steradians. + pub const UNIT: Self = Self { radius: 1.0 }; +} + +impl Default for ChamberlainDuquetteArea { + fn default() -> Self { + Self::EARTH + } +} + +impl AreaStrategy for ChamberlainDuquetteArea +where + Pg: Polygon, + Pg::Point: Point, + ::Cs: HasAngularUnits + CoordinateSystem, + <::Cs as CoordinateSystem>::Family: SameAs, +{ + type Out = f64; + + fn area(&self, polygon: &Pg) -> Self::Out { + let mut total = ring_area(polygon.exterior(), self.radius); + for interior in polygon.interiors() { + total += ring_area(interior, self.radius); + } + total + } +} + +fn ring_area(ring: &R, radius: f64) -> f64 +where + R: Ring, + R::Point: Point, + ::Cs: HasAngularUnits, +{ + let mut coordinates: Vec<(f64, f64)> = ring.points().map(lonlat_radians).collect(); + if coordinates.len() > 1 && coordinates.first() == coordinates.last() { + coordinates.pop(); + } + if coordinates.len() < 3 { + return 0.0; + } + + let mut sum = 0.0; + for index in 0..coordinates.len() { + let previous = coordinates[(index + coordinates.len() - 1) % coordinates.len()]; + let current = coordinates[index]; + let next = coordinates[(index + 1) % coordinates.len()]; + sum += longitude_delta(next.0 - previous.0) * current.1.sin(); + } + let signed = sum * radius * radius / 2.0; + match ring.point_order() { + PointOrder::Clockwise => signed, + PointOrder::CounterClockwise => -signed, + } +} + +fn longitude_delta(delta: f64) -> f64 { + (delta + core::f64::consts::PI).rem_euclid(core::f64::consts::TAU) - core::f64::consts::PI +} + +#[cfg(test)] +mod tests { + use geometry_cs::{Degree, Spherical}; + use geometry_model::{Point2D, Polygon, Ring}; + + use super::*; + + #[test] + fn one_degree_square_on_unit_sphere() { + type P = Point2D>; + let polygon: Polygon

= Polygon::new(Ring::from_vec(alloc::vec![ + P::new(0.0, 0.0), + P::new(0.0, 1.0), + P::new(1.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, 0.0), + ])); + let area = ChamberlainDuquetteArea::UNIT.area(&polygon); + assert!((area - 0.000_304_601_954_726_850_5).abs() < 1e-12); + } +} diff --git a/crates/geometry-strategy/src/spherical/closest_points_haversine.rs b/crates/geometry-strategy/src/spherical/closest_points_haversine.rs new file mode 100644 index 0000000..6bc2b6b --- /dev/null +++ b/crates/geometry-strategy/src/spherical/closest_points_haversine.rs @@ -0,0 +1,64 @@ +//! Spherical point-to-segment closest points. +//! +//! Ports the great-circle projection shape from +//! `boost/geometry/strategies/spherical/closest_points_pt_seg.hpp`. + +use geometry_cs::{CoordinateSystem, SphericalFamily}; +use geometry_model::Segment; +use geometry_tag::SameAs; +use geometry_trait::{Point, PointMut}; + +use crate::closest_points::ClosestPointsStrategy; +use crate::normalise::HasAngularUnits; + +use super::great_circle; + +/// Haversine-compatible closest-point projection onto a spherical segment. +#[derive(Debug, Clone, Copy)] +pub struct HaversineClosestPoints { + /// Sphere radius, retained for parity with the distance strategy bundle. + pub radius: f64, +} + +impl HaversineClosestPoints { + /// Mean Earth radius. + pub const EARTH: Self = Self { + radius: 6_372_795.0, + }; + /// Unit sphere. + pub const UNIT: Self = Self { radius: 1.0 }; +} + +impl Default for HaversineClosestPoints { + fn default() -> Self { + Self::EARTH + } +} + +impl

ClosestPointsStrategy> for HaversineClosestPoints +where + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, + ::Family: SameAs, +{ + type Out = P; + + fn closest_points(&self, point: &P, segment: &Segment

) -> (Self::Out, Self::Out) { + let projected = great_circle::project(point, segment.start(), segment.end()).point; + (*point, projected) + } +} + +impl

ClosestPointsStrategy, P> for HaversineClosestPoints +where + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, + ::Family: SameAs, +{ + type Out = P; + + fn closest_points(&self, segment: &Segment

, point: &P) -> (Self::Out, Self::Out) { + let projected = great_circle::project(point, segment.start(), segment.end()).point; + (projected, *point) + } +} diff --git a/crates/geometry-strategy/src/spherical/distance_cross_track.rs b/crates/geometry-strategy/src/spherical/distance_cross_track.rs new file mode 100644 index 0000000..63702bb --- /dev/null +++ b/crates/geometry-strategy/src/spherical/distance_cross_track.rs @@ -0,0 +1,72 @@ +//! Spherical point-to-segment cross-track distance. +//! +//! Ports the strategy from +//! `boost/geometry/strategies/spherical/distance_cross_track.hpp`. + +use geometry_cs::{CoordinateSystem, SphericalFamily}; +use geometry_model::Segment; +use geometry_tag::SameAs; +use geometry_trait::{Point, PointMut}; + +use crate::distance::DistanceStrategy; +use crate::normalise::HasAngularUnits; + +use super::great_circle; + +/// Great-circle distance from a point to the nearest location on a segment. +#[derive(Debug, Clone, Copy)] +pub struct CrossTrack { + /// Sphere radius in output distance units. + pub radius: f64, +} + +impl CrossTrack { + /// Mean Earth radius used by the spherical Haversine strategy. + pub const EARTH: Self = Self { + radius: 6_372_795.0, + }; + /// Unit sphere, returning angular distance in radians. + pub const UNIT: Self = Self { radius: 1.0 }; +} + +impl Default for CrossTrack { + fn default() -> Self { + Self::EARTH + } +} + +impl

DistanceStrategy> for CrossTrack +where + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, + ::Family: SameAs, +{ + type Out = f64; + type Comparable = Self; + + fn distance(&self, point: &P, segment: &Segment

) -> Self::Out { + great_circle::project(point, segment.start(), segment.end()).angular_distance * self.radius + } + + fn comparable(&self) -> Self::Comparable { + *self + } +} + +impl

DistanceStrategy, P> for CrossTrack +where + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, + ::Family: SameAs, +{ + type Out = f64; + type Comparable = Self; + + fn distance(&self, segment: &Segment

, point: &P) -> Self::Out { + >>::distance(self, point, segment) + } + + fn comparable(&self) -> Self::Comparable { + *self + } +} diff --git a/crates/geometry-strategy/src/spherical/great_circle.rs b/crates/geometry-strategy/src/spherical/great_circle.rs new file mode 100644 index 0000000..87cbdf0 --- /dev/null +++ b/crates/geometry-strategy/src/spherical/great_circle.rs @@ -0,0 +1,135 @@ +//! Great-circle point-to-segment projection shared by spherical strategies. + +#[cfg(not(feature = "std"))] +use geometry_coords::math::Float; +use geometry_cs::AngleUnit; +use geometry_trait::{Point, PointMut}; + +use crate::normalise::{HasAngularUnits, lonlat_radians}; + +pub(super) struct Projection

{ + pub(super) point: P, + pub(super) angular_distance: f64, +} + +pub(super) fn project

(point: &P, start: &P, end: &P) -> Projection

+where + P: Point + PointMut + Default + Copy, + P::Cs: HasAngularUnits, +{ + let point_vector = vector(point); + let start_vector = vector(start); + let end_vector = vector(end); + let normal = cross(start_vector, end_vector); + let normal_length = magnitude(normal); + if normal_length <= f64::EPSILON { + return nearest_endpoint(point_vector, start, start_vector, end, end_vector); + } + let normal = scale(normal, 1.0 / normal_length); + let projected = subtract(point_vector, scale(normal, dot(point_vector, normal))); + let projected_length = magnitude(projected); + if projected_length <= f64::EPSILON { + return nearest_endpoint(point_vector, start, start_vector, end, end_vector); + } + let mut projected = scale(projected, 1.0 / projected_length); + if dot(point_vector, projected) < 0.0 { + projected = scale(projected, -1.0); + } + + let segment_angle = angle(start_vector, end_vector); + let on_minor_arc = + angle(start_vector, projected) + angle(projected, end_vector) <= segment_angle + 1e-10; + if !on_minor_arc { + return nearest_endpoint(point_vector, start, start_vector, end, end_vector); + } + + Projection { + point: point_from_vector::

(projected), + angular_distance: angle(point_vector, projected), + } +} + +fn nearest_endpoint

( + point: [f64; 3], + start: &P, + start_vector: [f64; 3], + end: &P, + end_vector: [f64; 3], +) -> Projection

+where + P: Point + Copy, +{ + let start_distance = angle(point, start_vector); + let end_distance = angle(point, end_vector); + if start_distance <= end_distance { + Projection { + point: *start, + angular_distance: start_distance, + } + } else { + Projection { + point: *end, + angular_distance: end_distance, + } + } +} + +fn vector

(point: &P) -> [f64; 3] +where + P: Point, + P::Cs: HasAngularUnits, +{ + let (longitude, latitude) = lonlat_radians(point); + let cos_latitude = latitude.cos(); + [ + cos_latitude * longitude.cos(), + cos_latitude * longitude.sin(), + latitude.sin(), + ] +} + +fn point_from_vector

(vector: [f64; 3]) -> P +where + P: Point + PointMut + Default, + P::Cs: HasAngularUnits, +{ + type Units

= <

::Cs as HasAngularUnits>::Units; + let longitude = vector[1].atan2(vector[0]); + let latitude = vector[2].atan2(vector[0].hypot(vector[1])); + let mut point = P::default(); + point.set::<0>(Units::

::from_radians(longitude)); + point.set::<1>(Units::

::from_radians(latitude)); + point +} + +fn angle(first: [f64; 3], second: [f64; 3]) -> f64 { + magnitude(cross(first, second)).atan2(dot(first, second).clamp(-1.0, 1.0)) +} + +fn dot(first: [f64; 3], second: [f64; 3]) -> f64 { + first[0] * second[0] + first[1] * second[1] + first[2] * second[2] +} + +fn cross(first: [f64; 3], second: [f64; 3]) -> [f64; 3] { + [ + first[1] * second[2] - first[2] * second[1], + first[2] * second[0] - first[0] * second[2], + first[0] * second[1] - first[1] * second[0], + ] +} + +fn subtract(first: [f64; 3], second: [f64; 3]) -> [f64; 3] { + [ + first[0] - second[0], + first[1] - second[1], + first[2] - second[2], + ] +} + +fn scale(vector: [f64; 3], factor: f64) -> [f64; 3] { + [vector[0] * factor, vector[1] * factor, vector[2] * factor] +} + +fn magnitude(vector: [f64; 3]) -> f64 { + dot(vector, vector).sqrt() +} diff --git a/crates/geometry-strategy/src/spherical/length.rs b/crates/geometry-strategy/src/spherical/length.rs index 1976aa4..1c140d2 100644 --- a/crates/geometry-strategy/src/spherical/length.rs +++ b/crates/geometry-strategy/src/spherical/length.rs @@ -100,12 +100,10 @@ where for w in pts.windows(2) { acc = acc + self.haversine.distance(w[0], w[1]); } - if matches!(g.closure(), Closure::Open) { - if let (Some(first), Some(last)) = (pts.first(), pts.last()) { - acc = acc + self.haversine.distance(last, first); - } + match (g.closure(), pts.first(), pts.last()) { + (Closure::Open, Some(first), Some(last)) => acc + self.haversine.distance(last, first), + _ => acc, } - acc } } diff --git a/crates/geometry-strategy/src/spherical/mod.rs b/crates/geometry-strategy/src/spherical/mod.rs index c576c0e..561e1e3 100644 --- a/crates/geometry-strategy/src/spherical/mod.rs +++ b/crates/geometry-strategy/src/spherical/mod.rs @@ -6,11 +6,18 @@ //! point-to-segment / side / intersection kernels on the sphere. pub mod area; +pub mod area_chamberlain_duquette; pub mod azimuth; +pub mod closest_points_haversine; +pub mod distance_cross_track; pub mod distance_haversine; +mod great_circle; pub mod length; pub use area::{SphericalArea, SphericalPolygonArea}; +pub use area_chamberlain_duquette::ChamberlainDuquetteArea; pub use azimuth::SphericalAzimuth; +pub use closest_points_haversine::HaversineClosestPoints; +pub use distance_cross_track::CrossTrack; pub use distance_haversine::{ComparableHaversine, Haversine}; pub use length::{SphericalLength, SphericalPerimeter}; diff --git a/crates/geometry-strategy/src/transform.rs b/crates/geometry-strategy/src/transform.rs index 4fd5d15..28229a4 100644 --- a/crates/geometry-strategy/src/transform.rs +++ b/crates/geometry-strategy/src/transform.rs @@ -38,6 +38,102 @@ pub trait TransformStrategy { fn transform(&self, src: &P) -> Self::Output; } +/// Named constructor for 2D translation strategies. +#[derive(Debug, Default, Clone, Copy)] +pub struct Translate; + +impl Translate { + /// Translate by `(x, y)`. + #[must_use] + pub fn by(x: T, y: T) -> Affine2 { + Affine2::translation(x, y) + } +} + +/// Named constructor for 2D scale strategies. +#[derive(Debug, Default, Clone, Copy)] +pub struct Scale; + +impl Scale { + /// Scale the two axes independently. + #[must_use] + pub fn by(x: T, y: T) -> Affine2 { + Affine2::scale(x, y) + } + + /// Scale both axes by the same factor. + #[must_use] + pub fn uniform(factor: T) -> Affine2 { + Affine2::scale(factor, factor) + } +} + +/// Named constructor for 2D rotation strategies. +#[derive(Debug, Default, Clone, Copy)] +pub struct Rotate; + +impl Rotate { + /// Rotate counter-clockwise by radians around the coordinate origin. + #[cfg(feature = "std")] + #[must_use] + pub fn radians(angle: f64) -> Affine2 { + Affine2::rotation(angle) + } + + /// Rotate counter-clockwise by degrees around the coordinate origin. + #[cfg(feature = "std")] + #[must_use] + pub fn degrees(angle: f64) -> Affine2 { + Self::radians(angle.to_radians()) + } + + /// Rotate counter-clockwise by radians around `origin`. + #[cfg(feature = "std")] + #[must_use] + pub fn around

(angle: f64, origin: &P) -> Affine2 + where + P: Point, + { + let x = origin.get::<0>(); + let y = origin.get::<1>(); + Affine2::translation(x, y) + .then(Affine2::rotation(angle)) + .then(Affine2::translation(-x, -y)) + } +} + +/// Named constructor for 2D shear/skew strategies. +#[derive(Debug, Default, Clone, Copy)] +pub struct Skew; + +impl Skew { + /// Apply direct x- and y-axis shear factors. + /// + /// The resulting mapping is `x' = x + x_shear*y` and + /// `y' = y_shear*x + y`. + #[must_use] + pub fn by(x_shear: f64, y_shear: f64) -> Affine2 { + Affine2 { + m: [1.0, x_shear, 0.0, y_shear, 1.0, 0.0, 0.0, 0.0, 1.0], + _t: PhantomData, + } + } + + /// Apply shears specified as angles in radians. + #[cfg(feature = "std")] + #[must_use] + pub fn radians(x_angle: f64, y_angle: f64) -> Affine2 { + Self::by(x_angle.tan(), y_angle.tan()) + } + + /// Apply shears specified as angles in degrees. + #[cfg(feature = "std")] + #[must_use] + pub fn degrees(x_angle: f64, y_angle: f64) -> Affine2 { + Self::radians(x_angle.to_radians(), y_angle.to_radians()) + } +} + /// 3×3 affine matrix in homogeneous 2D coordinates. /// /// Mirrors `boost::geometry::strategy::transform::matrix_transformer` @@ -224,7 +320,7 @@ mod tests { //! `boost/geometry/test/algorithms/transform.cpp`: translation and //! scale map a point to the expected coordinates. - use super::{Affine2, TransformStrategy}; + use super::{Affine2, Rotate, Scale, Skew, TransformStrategy, Translate}; use geometry_cs::Cartesian; use geometry_model::Point2D; use geometry_trait::Point as _; @@ -264,4 +360,24 @@ mod tests { assert_eq!(out.get::<0>(), 3.0); assert_eq!(out.get::<1>(), 3.0); } + + #[test] + fn named_constructors_build_affine_strategies() { + assert_eq!( + Translate::by(2.0, 3.0).transform(&P::new(1.0, 1.0)), + P::new(3.0, 4.0) + ); + assert_eq!( + Scale::uniform(2.0).transform(&P::new(1.0, 3.0)), + P::new(2.0, 6.0) + ); + assert_eq!( + Skew::by(2.0, 0.0).transform(&P::new(1.0, 3.0)), + P::new(7.0, 3.0) + ); + let rotated = Rotate::around(core::f64::consts::FRAC_PI_2, &P::new(1.0, 1.0)) + .transform(&P::new(2.0, 1.0)); + assert!((rotated.get::<0>() - 1.0).abs() < 1e-12); + assert!((rotated.get::<1>() - 2.0).abs() < 1e-12); + } } diff --git a/crates/geometry-strategy/src/within.rs b/crates/geometry-strategy/src/within.rs index 7d80d02..8f56042 100644 --- a/crates/geometry-strategy/src/within.rs +++ b/crates/geometry-strategy/src/within.rs @@ -275,19 +275,24 @@ where return InOut::Exterior; }; let first = prev; + let mut has_segment = false; for curr in it { + has_segment = true; match apply_segment(p, prev, curr) { Step::Touches => return InOut::Boundary, Step::Count(c) => count += c, } prev = curr; } - // Open ring: close the loop explicitly. For a closed ring `prev` - // already equals `first`, so the kernel below returns `count = 0` - // (eq1 && eq2 with equal y-spans → no touch, no contribution). - match apply_segment(p, prev, first) { - Step::Touches => return InOut::Boundary, - Step::Count(c) => count += c, + // Close an open coordinate sequence explicitly. A repeated closing + // vertex already contributed through the preceding real edge. + let repeats_first = + has_segment && prev.get::<0>() == first.get::<0>() && prev.get::<1>() == first.get::<1>(); + if !repeats_first { + match apply_segment(p, prev, first) { + Step::Touches => return InOut::Boundary, + Step::Count(c) => count += c, + } } if count == 0 { InOut::Exterior @@ -340,57 +345,60 @@ where return Step::Count(0); } - // calculate_count: lines 186-203. - let count = if eq1 { - if s2x > px { 1 } else { -1 } - } else if eq2 { - if s1x > px { -1 } else { 1 } - } else if s1x < px && s2x > px { + // An endpoint on the ray contributes a half count only when it is + // vertically below the query. This is the direct form of Boost's + // `side_equal * count > 0` reduction. + if eq1 { + if py == s1y { + return Step::Touches; + } + return if py < s1y { + Step::Count(0) + } else if s2x > px { + Step::Count(1) + } else { + Step::Count(-1) + }; + } + if eq2 { + if py == s2y { + return Step::Touches; + } + return if py < s2y { + Step::Count(0) + } else if s1x > px { + Step::Count(-1) + } else { + Step::Count(1) + }; + } + + let count = if s1x < px && s2x > px { 2 } else if s2x < px && s1x > px { -2 } else { - 0 - }; - - if count == 0 { return Step::Count(0); - } + }; - // side: for ±1, side_equal; for ±2, cartesian side cross product. - // Mirrors lines 100-110 of point_in_poly_winding.hpp. - let side: i32 = if count == 1 || count == -1 { - let se = if eq1 { s1 } else { s2 }; - let sey = se.get::<1>(); - if py == sey { - 0 - } else if py < sey { - -count + // Cartesian side: sign of (s2 - s1) × (p - s1). A zero side is a + // boundary touch; otherwise it contributes only when its sign agrees + // with the crossing direction. + let cross = (s2x - s1x) * (py - s1y) - (s2y - s1y) * (px - s1x); + if cross > P::Scalar::ZERO { + if count > 0 { + Step::Count(count) } else { - count + Step::Count(0) } - } else { - // Cartesian side: sign of (s2 - s1) × (p - s1). - // Mirrors `side_by_triangle::side_value` at - // strategy/cartesian/side_by_triangle.hpp:178-200. - let cross = (s2x - s1x) * (py - s1y) - (s2y - s1y) * (px - s1x); - if cross > P::Scalar::ZERO { - 1 - } else if cross < P::Scalar::ZERO { - -1 + } else if cross < P::Scalar::ZERO { + if count < 0 { + Step::Count(count) } else { - 0 + Step::Count(0) } - }; - - if side == 0 { - return Step::Touches; - } - - if side * count > 0 { - Step::Count(count) } else { - Step::Count(0) + Step::Touches } } @@ -400,9 +408,10 @@ mod tests { //! (the Cartesian section). Each test cites the C++ line(s) it //! mirrors. - use super::{WithinBox, WithinPoly, WithinRing, WithinStrategy}; + use super::{Step, WithinBox, WithinPoly, WithinRing, WithinStrategy, apply_segment}; use geometry_cs::Cartesian; use geometry_model::{Box, Point2D, Polygon, Ring, polygon}; + use geometry_trait::Point as _; type P = Point2D; @@ -410,6 +419,97 @@ mod tests { Point2D::new(x, y) } + #[allow(clippy::float_cmp)] + fn reference_step(p: &P, s1: &P, s2: &P) -> i32 { + let px = p.get::<0>(); + let py = p.get::<1>(); + let s1x = s1.get::<0>(); + let s2x = s2.get::<0>(); + let s1y = s1.get::<1>(); + let s2y = s2.get::<1>(); + + let eq1 = s1x == px; + let eq2 = s2x == px; + if eq1 && eq2 { + let (lo, hi) = if s1y <= s2y { (s1y, s2y) } else { (s2y, s1y) }; + return if lo <= py && py <= hi { i32::MIN } else { 0 }; + } + + let count = if eq1 { + if s2x > px { 1 } else { -1 } + } else if eq2 { + if s1x > px { -1 } else { 1 } + } else if s1x < px && s2x > px { + 2 + } else if s2x < px && s1x > px { + -2 + } else { + 0 + }; + if count == 0 { + return 0; + } + + let side = if count == 1 || count == -1 { + let sey = if eq1 { s1y } else { s2y }; + if py == sey { + 0 + } else if py < sey { + -count + } else { + count + } + } else { + let cross = (s2x - s1x) * (py - s1y) - (s2y - s1y) * (px - s1x); + if cross > 0.0 { + 1 + } else if cross < 0.0 { + -1 + } else { + 0 + } + }; + if side == 0 { + i32::MIN + } else if side * count > 0 { + count + } else { + 0 + } + } + + fn step_code(step: Step) -> i32 { + match step { + Step::Touches => i32::MIN, + Step::Count(count) => count, + } + } + + #[test] + fn segment_step_matches_the_reference_branch_matrix() { + let values = [-2.0, -1.0, 0.0, 1.0, 2.0]; + for &px in &values { + for &py in &values { + for &s1x in &values { + for &s1y in &values { + for &s2x in &values { + for &s2y in &values { + let point = pt(px, py); + let first = pt(s1x, s1y); + let second = pt(s2x, s2y); + assert_eq!( + step_code(apply_segment(&point, &first, &second)), + reference_step(&point, &first, &second), + "point=({px}, {py}), segment=({s1x}, {s1y})→({s2x}, {s2y})" + ); + } + } + } + } + } + } + } + fn box_polygon() -> Polygon

{ polygon![[(0.0, 0.0), (0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)]] } diff --git a/crates/geometry/src/prelude.rs b/crates/geometry/src/prelude.rs index 7ee9da7..8cbf76f 100644 --- a/crates/geometry/src/prelude.rs +++ b/crates/geometry/src/prelude.rs @@ -35,27 +35,34 @@ //! ``` pub use crate::algorithm::{ - DynKindMismatch, append, append_to_ring, area, area_dyn, area_with, assign_values, azimuth, - azimuth_with, box_area, centroid, centroid_with, clear, closest_points, comparable_distance, - comparable_distance_with, convert, convex_hull, correct, correct_closure, covered_by, densify, - discrete_frechet_distance, discrete_frechet_distance_with, discrete_hausdorff_distance, - discrete_hausdorff_distance_with, disjoint, disjoint_box_box, distance, distance_dyn, - distance_with, envelope, envelope_dyn, equals, expand, expand_with, for_each_point, - for_each_segment, intersects, intersects_reversed, is_convex, is_empty, is_simple, length, - length_dyn, length_with, line_interpolate, make_box, make_point, make_segment, - multi_polygon_area, num_geometries, num_interior_rings, num_points, num_segments, perimeter, - perimeter_with, remove_spikes, reverse, ring_area, ring_perimeter, ring_perimeter_with, - simplify, transform, unique, within, within_dyn, + CoordinatePosition, DynKindMismatch, append, append_to_ring, area, area_dyn, area_with, + assign_values, azimuth, azimuth_with, box_area, centroid, centroid_with, chaikin_smoothing, + clear, closest_points, closest_points_with, comparable_distance, comparable_distance_with, + concave_hull, concave_hull_with, convert, convex_hull, coordinate_position, correct, + correct_closure, covered_by, densify, destination, destination_with, discrete_frechet_distance, + discrete_frechet_distance_with, discrete_hausdorff_distance, discrete_hausdorff_distance_with, + disjoint, disjoint_box_box, distance, distance_dyn, distance_with, envelope, envelope_dyn, + equals, expand, expand_with, for_each_point, for_each_segment, intersects, intersects_reversed, + is_convex, is_empty, is_simple, k_nearest_concave_hull, length, length_dyn, length_with, + line_interpolate, line_locate_point, linestring_segmentize, linestring_segmentize_with, + make_box, make_point, make_segment, map_coords, map_coords_in_place, minimum_rotated_rect, + monotone_subdivision, multi_polygon_area, num_geometries, num_interior_rings, num_points, + num_segments, perimeter, perimeter_with, remove_spikes, reverse, rhumb_azimuth, + rhumb_azimuth_with, rhumb_destination, rhumb_destination_with, rhumb_distance, + rhumb_distance_with, rhumb_length, rhumb_length_with, ring_area, ring_perimeter, + ring_perimeter_with, simplify, simplify_with, transform, triangulate_earcut, unique, within, + within_dyn, }; pub use crate::cs::{Cartesian, CoordinateSystem, Degree, Geographic, Radian, Spherical}; pub use crate::model::{Point2D, Point3D}; pub use crate::overlay::{ - De9im, Dimension, JoinStrategy, OverlayError, PointStrategy, RelateError, ValidityFailure, - ValidityOptions, buffer, buffer_convex_polygon, buffer_point, buffer_with, crosses, difference, - intersection, is_valid, is_valid_polygon, is_valid_polygon_with, is_valid_ring, - is_valid_ring_with, is_valid_with, merge_elements, merge_multipolygon, merge_polygons, - overlaps, point_on_surface, relate, relation, sym_difference, touches, r#union, union_poly, - validity_reason, validity_reason_with, + De9im, Dimension, JoinStrategy, LineIntersection, OverlayError, PointStrategy, RelateError, + ValidityFailure, ValidityOptions, buffer, buffer_convex_polygon, buffer_point, buffer_with, + contains_properly, crosses, difference, intersection, is_valid, is_valid_polygon, + is_valid_polygon_with, is_valid_ring, is_valid_ring_with, is_valid_with, line_intersection, + merge_elements, merge_multipolygon, merge_polygons, overlaps, point_on_surface, relate, + relation, stitch_triangles, sym_difference, touches, r#union, union_poly, validity_reason, + validity_reason_with, }; pub use crate::rtree::{ Bounds, Indexable, Linear, Predicate, Quadratic, QueryPredicate, Rtree, and, not, satisfies, diff --git a/crates/geometry/tests/algorithms_parity.rs b/crates/geometry/tests/algorithms_parity.rs index 0b27442..c366ac2 100644 --- a/crates/geometry/tests/algorithms_parity.rs +++ b/crates/geometry/tests/algorithms_parity.rs @@ -11,23 +11,33 @@ )] use boost_geometry::adapt::{Adapt, WithCs}; +use boost_geometry::algorithm::ConcaveHullParams; use boost_geometry::model::{ Box as ModelBox, DynGeometry, DynGeometryCollection, Linestring, MultiLinestring, MultiPoint, MultiPolygon, Point as ModelPoint, Point2D, Point3D, Polygon, Ring, Segment, }; +use boost_geometry::overlay::{Dimension, relation}; use boost_geometry::prelude::{ - Cartesian, Degree, Spherical, area, assign_values, azimuth_with, centroid, centroid_with, - closest_points, comparable_distance_with, correct, correct_closure, densify, distance_with, - equals, expand, expand_with, for_each_segment, intersects, intersects_reversed, is_simple, - line_interpolate, perimeter, perimeter_with, remove_spikes, ring_area, ring_perimeter_with, - unique, within, + Cartesian, CoordinatePosition, Degree, Geographic, Spherical, area, area_with, assign_values, + azimuth_with, centroid, centroid_with, chaikin_smoothing, closest_points, closest_points_with, + comparable_distance_with, concave_hull, concave_hull_with, coordinate_position, correct, + correct_closure, densify, destination, distance_with, envelope, equals, expand, expand_with, + for_each_segment, intersects, intersects_reversed, is_simple, k_nearest_concave_hull, + line_interpolate, line_locate_point, linestring_segmentize, linestring_segmentize_with, + map_coords, map_coords_in_place, minimum_rotated_rect, monotone_subdivision, perimeter, + perimeter_with, remove_spikes, rhumb_azimuth, rhumb_destination, rhumb_distance, + rhumb_distance_with, rhumb_length, ring_area, ring_perimeter_with, simplify_with, transform, + triangulate_earcut, unique, within, }; use boost_geometry::strategy::{ - CartesianAzimuth, CartesianBoxCentroid, CartesianPerimeter, EnvelopePoint, PointToSegment, - Pythagoras, SphericalPerimeter, + CartesianAzimuth, CartesianBoxCentroid, CartesianPerimeter, ChamberlainDuquetteArea, + CrossTrack, EnvelopePoint, GeographicAzimuth, GeographicPerimeter, Haversine, + HaversineClosestPoints, PointToSegment, Pythagoras, Rhumb, Rotate, Scale, Skew, SphericalArea, + SphericalPerimeter, Translate, Vincenty, VisvalingamWhyatt, VisvalingamWhyattPreserve, }; use boost_geometry::trait_::{ - IndexedAccess as _, Point as _, PointMut as _, Polygon as _, Ring as _, + IndexedAccess as _, Point as _, PointMut as _, Polygon as _, Ring as _, fold_dims, segment_end, + segment_start, }; type P2 = Point2D; @@ -36,6 +46,394 @@ type P4 = ModelPoint; type P5 = ModelPoint; type D = DynGeometry; +/// Chamberlain–Duquette is an opt-in spherical area strategy and leaves the +/// existing spherical default unchanged. +#[test] +fn chamberlain_duquette_area_is_public() { + type SphericalPoint = Point2D>; + let square: Polygon = Polygon::new(Ring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(0.0, 1.0), + SphericalPoint::new(1.0, 1.0), + SphericalPoint::new(1.0, 0.0), + SphericalPoint::new(0.0, 0.0), + ])); + + let solid_angle = area_with(&square, ChamberlainDuquetteArea::UNIT); + assert!((solid_angle - 0.000_304_601_954_726_850_5).abs() < 1e-12); +} + +/// The opt-in spherical area strategy handles default radius scaling, +/// interiors, declared counter-clockwise rings, and degenerate exteriors. +#[test] +fn chamberlain_duquette_covers_topology_and_orientation() { + type SphericalPoint = Point2D>; + let outer: Ring = Ring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(0.0, 2.0), + SphericalPoint::new(2.0, 2.0), + SphericalPoint::new(2.0, 0.0), + SphericalPoint::new(0.0, 0.0), + ]); + let mut hole: Ring = Ring::from_vec(vec![ + SphericalPoint::new(0.5, 0.5), + SphericalPoint::new(0.5, 1.5), + SphericalPoint::new(1.5, 1.5), + SphericalPoint::new(1.5, 0.5), + SphericalPoint::new(0.5, 0.5), + ]); + hole.0.reverse(); + let outer_area = area_with(&Polygon::new(outer.clone()), ChamberlainDuquetteArea::UNIT); + let donut_area = area_with( + &Polygon::with_inners(outer.clone(), vec![hole]), + ChamberlainDuquetteArea::UNIT, + ); + assert!(donut_area > 0.0 && donut_area < outer_area); + + let earth_area = area_with( + &Polygon::new(outer.clone()), + ChamberlainDuquetteArea::default(), + ); + assert!( + (earth_area - outer_area * ChamberlainDuquetteArea::EARTH.radius.powi(2)).abs() + < earth_area * 1e-12 + ); + + let mut ccw_points = outer.0; + ccw_points.reverse(); + let ccw: Polygon = + Polygon::new(Ring::::from_vec(ccw_points)); + assert!((area_with(&ccw, ChamberlainDuquetteArea::UNIT) - outer_area).abs() < 1e-12); + + let degenerate: Polygon = Polygon::new(Ring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(1.0, 1.0), + ])); + assert_eq!(area_with(°enerate, ChamberlainDuquetteArea::UNIT), 0.0); +} + +/// The native Boost spherical-excess strategy honors the ring's declared +/// orientation and treats an empty open ring as zero area. +#[test] +fn spherical_area_covers_counter_clockwise_and_empty_open_rings() { + type SphericalPoint = Point2D>; + let clockwise: Ring = Ring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(0.0, 1.0), + SphericalPoint::new(1.0, 1.0), + SphericalPoint::new(1.0, 0.0), + SphericalPoint::new(0.0, 0.0), + ]); + let mut reversed = clockwise.0.clone(); + reversed.reverse(); + let counter_clockwise: Ring = Ring::from_vec(reversed); + assert!( + (area_with(&clockwise, SphericalArea::UNIT) + - area_with(&counter_clockwise, SphericalArea::UNIT)) + .abs() + < 1e-12 + ); + + let empty_open: Ring = Ring::from_vec(Vec::new()); + assert_eq!(area_with(&empty_open, SphericalArea::UNIT), 0.0); +} + +/// Rhumb-line distance, bearing, destination, and linestring length are all +/// reachable through named public entries, with an explicit-radius companion. +#[test] +fn rhumb_measure_family_is_public() { + type SphericalPoint = Point2D>; + let start = SphericalPoint::new(0.0, 0.0); + let east = SphericalPoint::new(1.0, 0.0); + let distance = rhumb_distance(&start, &east); + assert!((distance - 111_195.080_233_532_9).abs() < 1e-6); + assert!((rhumb_azimuth(&start, &east) - core::f64::consts::FRAC_PI_2).abs() < 1e-12); + + let destination = rhumb_destination(&start, core::f64::consts::FRAC_PI_2, distance); + assert!((destination.get::<0>() - 1.0).abs() < 1e-10); + assert!(destination.get::<1>().abs() < 1e-10); + + let line = Linestring::from_vec(vec![start, east, SphericalPoint::new(2.0, 0.0)]); + assert!((rhumb_length(&line) - 2.0 * distance).abs() < 1e-6); + assert!((rhumb_distance_with(&start, &east, Rhumb::UNIT) - 1.0_f64.to_radians()).abs() < 1e-12); +} + +/// Rhumb strategies expose custom radii, comparable distance, meridional +/// motion, pole reflection, and the zero-scale destination guard publicly. +#[test] +fn rhumb_public_edge_cases_cover_poles_and_custom_radius() { + type SphericalPoint = Point2D>; + let origin = SphericalPoint::new(0.0, 0.0); + let east = SphericalPoint::new(1.0, 0.0); + let custom = Rhumb::with_radius(2.0); + let expected = 2.0 * 1.0_f64.to_radians(); + assert!((rhumb_distance_with(&origin, &east, custom) - expected).abs() < 1e-12); + assert!((comparable_distance_with(&origin, &east, custom) - expected).abs() < 1e-12); + + let north = rhumb_destination(&origin, 0.0, Rhumb::EARTH.radius * 10.0_f64.to_radians()); + assert!((north.get::<1>() - 10.0).abs() < 1e-12); + + let reflected_north = rhumb_destination( + &SphericalPoint::new(0.0, 80.0), + 0.0, + Rhumb::EARTH.radius * 20.0_f64.to_radians(), + ); + assert!((reflected_north.get::<1>() - 80.0).abs() < 1e-12); + let reflected_south = rhumb_destination( + &SphericalPoint::new(0.0, -80.0), + core::f64::consts::PI, + Rhumb::EARTH.radius * 20.0_f64.to_radians(), + ); + assert!((reflected_south.get::<1>() + 80.0).abs() < 1e-12); + + let pole = SphericalPoint::new(30.0, 90.0); + let along_pole = rhumb_destination(&pole, core::f64::consts::FRAC_PI_2, 0.1); + assert!(along_pole.get::<0>().is_finite()); + assert!(along_pole.get::<1>().is_finite()); +} + +/// Minimum rectangles and both concave-hull parameter faces operate on public +/// stock models and retain an interior point when the requested concavity allows. +#[test] +fn derived_hulls_are_public() { + let diamond = MultiPoint::from_vec(vec![ + P2::new(0.0, 1.0), + P2::new(1.0, 0.0), + P2::new(0.0, -1.0), + P2::new(-1.0, 0.0), + P2::new(0.0, 0.0), + ]); + let rectangle = minimum_rotated_rect(&diamond); + assert!((area(&rectangle).abs() - 2.0).abs() < 1e-12); + + let points = MultiPoint::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(0.0, 4.0), + P2::new(4.0, 4.0), + P2::new(4.0, 0.0), + P2::new(2.0, 1.0), + ]); + let params = ConcaveHullParams { + concavity: 1.2, + length_threshold: 0.0, + }; + let refined = concave_hull_with(&points, params); + assert!(refined.outer.0.contains(&P2::new(2.0, 1.0))); + assert!(area(&refined).abs() < 16.0); + + let defaulted = concave_hull(&points); + assert!(defaulted.outer.0.contains(&P2::new(2.0, 1.0))); + let knn = k_nearest_concave_hull(&points, 3); + assert!(knn.outer.0.contains(&P2::new(2.0, 1.0))); +} + +/// Empty, point, segment, and sub-epsilon point sets exercise the documented +/// degenerate minimum-rectangle contract through the public facade. +#[test] +fn minimum_rotated_rect_handles_degenerate_point_sets() { + let empty = minimum_rotated_rect(&MultiPoint::::from_vec(vec![])); + assert!(empty.outer.0.is_empty()); + + let point = P2::new(2.0, 3.0); + let single = minimum_rotated_rect(&MultiPoint::from_vec(vec![point])); + assert_eq!(single.outer.0, vec![point; 5]); + + let first = P2::new(0.0, 0.0); + let second = P2::new(2.0, 2.0); + let segment = minimum_rotated_rect(&MultiPoint::from_vec(vec![first, second])); + assert_eq!(segment.outer.0.len(), 5); + assert_eq!(area(&segment), 0.0); + assert!(segment.outer.0.contains(&first)); + assert!(segment.outer.0.contains(&second)); + + let tiny = f64::EPSILON / 4.0; + let sub_epsilon = minimum_rotated_rect(&MultiPoint::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(tiny, 0.0), + P2::new(0.0, tiny), + ])); + assert!(sub_epsilon.outer.0.is_empty()); +} + +/// Degenerate point sets and an over-large edge threshold are reference cases +/// from the upstream concave-hull suite. They remain observable through the +/// public facade, including the documented `k == 0` convex-hull boundary. +#[test] +fn concave_hull_handles_degenerate_and_threshold_boundaries() { + let empty = MultiPoint::::from_vec(vec![]); + assert!(concave_hull(&empty).outer.0.is_empty()); + + let repeated = MultiPoint::from_vec(vec![P2::new(1.0, 1.0); 4]); + assert_eq!( + concave_hull(&repeated).outer.0, + vec![P2::new(1.0, 1.0), P2::new(1.0, 1.0)] + ); + + let collinear = MultiPoint::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(2.0, 2.0), + P2::new(6.0, 6.0), + ]); + let collinear_hull = concave_hull(&collinear); + assert_eq!(collinear_hull.outer.0.len(), 3); + assert_eq!( + collinear_hull.outer.0.first(), + collinear_hull.outer.0.last() + ); + assert!(collinear_hull.outer.0.contains(&P2::new(0.0, 0.0))); + assert!(collinear_hull.outer.0.contains(&P2::new(6.0, 6.0))); + + let points = MultiPoint::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(2.0, 0.0), + P2::new(1.5, 1.0), + P2::new(2.0, 2.0), + P2::new(0.0, 2.0), + ]); + let thresholded = concave_hull_with( + &points, + ConcaveHullParams { + concavity: 1.2, + length_threshold: 3.0, + }, + ); + let convex = k_nearest_concave_hull(&points, 0); + assert_eq!(thresholded, convex); + assert_eq!(convex.outer.0.len(), 5); +} + +/// The upstream consecutive-drilling fixture supplies several candidates per +/// edge. The public implementation must retain every point while producing a +/// closed, simple boundary. +#[test] +fn concave_hull_orders_multiple_edge_candidates() { + let points = MultiPoint::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(2.0, 1.0), + P2::new(4.0, 0.0), + P2::new(3.0, 2.0), + P2::new(4.0, 4.0), + P2::new(2.0, 3.0), + P2::new(0.0, 4.0), + P2::new(1.0, 2.0), + ]); + + let hull = k_nearest_concave_hull(&points, points.0.len()); + assert_eq!(hull.outer.0.first(), hull.outer.0.last()); + assert!(is_simple(&hull)); + for point in points.0 { + assert!(hull.outer.0.contains(&point)); + } +} + +/// Native ear clipping and monotone subdivision expose owned stock polygons; +/// the pieces preserve the source area through the public area entry. +#[test] +fn native_triangulation_and_monotone_subdivision_are_public() { + let polygon: Polygon = Polygon::new(Ring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(0.0, 2.0), + P2::new(1.0, 1.0), + P2::new(2.0, 2.0), + P2::new(2.0, 0.0), + P2::new(0.0, 0.0), + ])); + + let triangles = triangulate_earcut(&polygon); + assert_eq!(triangles.len(), 3); + let triangle_area: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum(); + assert!((triangle_area - area(&polygon).abs()).abs() < 1e-12); + + let monotone = monotone_subdivision(&polygon); + assert!(monotone.len() > 1); + assert!(monotone.iter().all(|piece| piece.outer.0.len() == 4)); + let monotone_area: f64 = monotone.iter().map(|piece| area(piece).abs()).sum(); + assert!((monotone_area - area(&polygon).abs()).abs() < 1e-12); +} + +/// A polygon whose declared interior cannot be bridged into its exterior is +/// rejected atomically instead of returning a partial exterior triangulation. +#[test] +fn earcut_rejects_an_unbridgeable_hole_atomically() { + let polygon = Polygon::with_inners( + square_ring(0.0, 0.0, 4.0), + vec![square_ring(10.0, 10.0, 1.0)], + ); + + let triangles = triangulate_earcut(&polygon); + assert_eq!(triangles.len(), 0); +} + +/// Public ear clipping rejects undersized and collinear exteriors, ignores +/// redundant adjacent vertices, and removes collinear boundary vertices while +/// preserving the polygon's area. +#[test] +fn earcut_handles_degenerate_and_redundant_vertices() { + let degenerate_exteriors: [Ring; 3] = [ + Ring::from_vec(vec![]), + Ring::from_vec(vec![P2::new(0.0, 0.0), P2::new(1.0, 1.0)]), + Ring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(1.0, 1.0), + P2::new(2.0, 2.0), + P2::new(0.0, 0.0), + ]), + ]; + for exterior in degenerate_exteriors { + assert!(triangulate_earcut(&Polygon::new(exterior)).is_empty()); + } + + let polygon: Polygon = Polygon::new(Ring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(0.0, 2.0), + P2::new(2.0, 2.0), + P2::new(2.0, 0.0), + P2::new(1.0, 0.0), + P2::new(1.0, 0.0), + P2::new(0.0, 0.0), + ])); + let triangles = triangulate_earcut(&polygon); + assert_eq!(triangles.len(), 3); + assert!(triangles.iter().all(|triangle| area(triangle).abs() > 0.0)); + let triangle_area: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum(); + assert!((triangle_area - area(&polygon).abs()).abs() < 1e-12); +} + +/// Boost has no supported triangulation entry. This self-intersecting +/// exterior exercises the native earcut contract recorded in +/// `specs/geos_parity/triangulation.md`: a clipping-stalled input is rejected +/// atomically instead of returning a partial triangulation. +#[test] +fn earcut_rejects_a_clipping_stalled_exterior_atomically() { + let stalled: Polygon = Polygon::new(Ring::from_vec(vec![ + P2::new(0.0, 3.0), + P2::new(3.0, 1.0), + P2::new(2.0, -2.0), + P2::new(-3.0, 1.0), + P2::new(-2.0, -2.0), + P2::new(0.0, -3.0), + P2::new(0.0, 3.0), + ])); + + assert_eq!(triangulate_earcut(&stalled).len(), 0); +} + +/// Multiple independently visible holes exercise the public bridge selection +/// path. Their triangulation must cover the exterior minus both interiors. +#[test] +fn earcut_triangulates_multiple_holes() { + let mut first_hole = square_ring(1.0, 1.0, 2.0); + first_hole.0.reverse(); + let mut second_hole = square_ring(6.0, 6.0, 2.0); + second_hole.0.reverse(); + let polygon = Polygon::with_inners(square_ring(0.0, 0.0, 10.0), vec![first_hole, second_hole]); + + let triangles = triangulate_earcut(&polygon); + assert_eq!(triangles.len(), 14); + let triangle_area: f64 = triangles.iter().map(|triangle| area(triangle).abs()).sum(); + assert!((triangle_area - area(&polygon).abs()).abs() < 1e-12); +} + /// `test/algorithms/correct_closure.cpp:51-84` — fix closure without changing /// winding. #[test] @@ -180,6 +578,39 @@ fn explicit_azimuth_and_centroid_strategies_are_public() { ); } +/// Boost's Andoyer formula treats a pole's cosine as zero with +/// `math::equals`, so the public geographic strategy has deterministic pole +/// limits despite `cos(π/2)` not being exactly zero in binary floating point. +#[test] +fn geographic_azimuth_uses_boost_pole_limits() { + type G = Point2D>; + let origin = G::new(0.0, 0.0); + + assert!(azimuth_with(&origin, &G::new(0.0, 90.0), GeographicAzimuth::WGS84).abs() < 1e-12); + assert!( + (azimuth_with(&origin, &G::new(0.0, -90.0), GeographicAzimuth::WGS84) + - core::f64::consts::PI) + .abs() + < 1e-12 + ); + assert!( + (azimuth_with( + &G::new(0.0, 90.0), + &G::new(90.0, 0.0), + GeographicAzimuth::WGS84, + ) - core::f64::consts::FRAC_PI_2) + .abs() + < 1e-12 + ); + let antipodal = azimuth_with(&origin, &G::new(180.0, 0.0), GeographicAzimuth::WGS84); + assert!(antipodal.abs() < 1e-12, "got {antipodal}"); + + assert_eq!( + distance_with(&G::new(0.0, 10.0), &G::new(360.0, 10.0), Vincenty::WGS84,), + 0.0 + ); +} + /// `test/algorithms/is_simple.cpp:93-139` — duplicate, fold-back, /// self-crossing, closed, and empty linestring paths plus areal duplicate /// handling are observable through the public predicate. @@ -401,13 +832,19 @@ fn projected_point_distance_supports_every_public_dimension() { /// exceeding the stable-Rust kernel ceiling must fail explicitly. #[test] fn coordinate_wise_algorithms_reach_the_fourth_ordinate() { + type P0 = ModelPoint; type P1 = ModelPoint; type P4 = ModelPoint; type P5 = ModelPoint; + let mut zero = P0::default(); + assign_values(&mut zero, &[]); + let mut one = P1::default(); assign_values(&mut one, &[7.0]); assert_eq!(one.get::<0>(), 7.0); + assert_eq!(fold_dims(0, &P0::default(), |count, _, _| count + 1), 0); + assert_eq!(fold_dims(0, &one, |count, _, _| count + 1), 1); let mut four = P4::default(); assign_values(&mut four, &[1.0, 2.0, 3.0, 4.0]); @@ -431,8 +868,28 @@ fn coordinate_wise_algorithms_reach_the_fourth_ordinate() { expand(&mut bounds, &four); assert_eq!(bounds.get_indexed::<1, 3>(), 4.0); + let line = Linestring::from_vec(vec![origin, four]); + let line_bounds = envelope(&line); + assert_eq!(line_bounds.get_indexed::<1, 3>(), 4.0); + + let zero_segment = Segment::new(P0::default(), P0::default()); + assert_eq!( + fold_dims(0, &segment_start(&zero_segment), |count, _, _| count + 1), + 0 + ); + let one_segment = Segment::new(P1::default(), one); + assert_eq!(segment_end(&one_segment).get::<0>(), 7.0); + let four_segment = Segment::new(origin, four); + assert_eq!(segment_end(&four_segment).get::<3>(), 4.0); + let unsupported = std::panic::catch_unwind(|| equals(&P5::default(), &P5::default())); assert!(unsupported.is_err()); + let fold_unsupported = + std::panic::catch_unwind(|| fold_dims((), &P5::default(), |(), _, _| ())); + assert!(fold_unsupported.is_err()); + let segment_unsupported = + std::panic::catch_unwind(|| segment_start(&Segment::new(P5::default(), P5::default()))); + assert!(segment_unsupported.is_err()); } /// Open rings carry an implicit closing edge in Boost's area and centroid @@ -449,6 +906,10 @@ fn open_ring_area_and_centroid_include_the_implicit_edge() { assert_eq!(ring_area(&open), 4.0); assert_point2_close(centroid(&open), P2::new(1.0, 1.0)); + let empty_open: Ring = Ring::from_vec(Vec::new()); + assert_eq!(ring_area(&empty_open), 0.0); + assert_eq!(centroid(&empty_open), P2::default()); + let empty = MultiPoint::::default(); assert_eq!(centroid(&empty), P2::default()); } @@ -475,6 +936,16 @@ fn closest_points_and_densify_cover_non_crossing_and_four_dimensional_paths() { assert_eq!(on_first, P2::new(10.0, 0.0)); assert_eq!(on_second, P2::new(10.0, 1.0)); + let first_pair_is_best = Linestring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(10.0, 0.0), + P2::new(20.0, 0.0), + ]); + let short_parallel = Linestring::from_vec(vec![P2::new(0.0, 1.0), P2::new(1.0, 1.0)]); + let (on_long, on_short) = closest_points(&first_pair_is_best, &short_parallel); + assert_eq!(on_long, P2::new(0.0, 0.0)); + assert_eq!(on_short, P2::new(0.0, 1.0)); + let mut point = P4::default(); point.set::<0>(1.0); point.set::<1>(1.0); @@ -538,6 +1009,27 @@ fn spherical_perimeter_uses_the_spherical_default() { assert!(default > 400_000.0); } +/// `test/algorithms/length/length_sph.cpp:53-58` and +/// `length_geo.cpp:95-100` define empty angular inputs as zero length. An +/// explicitly open empty ring exercises the same public perimeter strategy +/// contract without requiring a private strategy call. +#[test] +fn empty_open_angular_rings_have_zero_perimeter() { + type SphericalPoint = Point2D>; + type GeographicPoint = Point2D>; + + let spherical = Ring::::new(); + let geographic = Ring::::new(); + assert_eq!( + ring_perimeter_with(&spherical, SphericalPerimeter::default()), + 0.0 + ); + assert_eq!( + ring_perimeter_with(&geographic, GeographicPerimeter::default()), + 0.0 + ); +} + fn square_ring(x: f64, y: f64, size: f64) -> Ring { Ring::from_vec(vec![ P2::new(x, y), @@ -553,6 +1045,512 @@ fn assert_point2_close(actual: P2, expected: P2) { assert!((actual.get::<1>() - expected.get::<1>()).abs() < 1e-12); } +/// Visvalingam and Whyatt (1993), using the published `PostGIS` example: the +/// area-ranked strategy is selectable through the public explicit-strategy +/// entry while Douglas–Peucker remains the default. +#[test] +fn visvalingam_whyatt_is_selectable_through_the_public_facade() { + let line = Linestring::from_vec(vec![ + P2::new(5.0, 2.0), + P2::new(3.0, 8.0), + P2::new(6.0, 20.0), + P2::new(7.0, 25.0), + P2::new(10.0, 10.0), + ]); + + let simplified = simplify_with(&line, 30.0, VisvalingamWhyatt); + assert_eq!( + simplified.0, + vec![P2::new(5.0, 2.0), P2::new(7.0, 25.0), P2::new(10.0, 10.0),] + ); +} + +/// The topology-preserving variant follows the Davies refinement: when the +/// lowest-area removal creates a crossing, its preceding vertex is removed as +/// part of the same simplification sequence. +#[test] +fn visvalingam_whyatt_preserve_avoids_a_new_self_intersection() { + let line = Linestring::from_vec(vec![ + P2::new(10.0, 60.0), + P2::new(135.0, 68.0), + P2::new(94.0, 48.0), + P2::new(126.0, 31.0), + P2::new(280.0, 19.0), + P2::new(117.0, 48.0), + P2::new(300.0, 40.0), + P2::new(301.0, 10.0), + ]); + + let simplified = simplify_with(&line, 668.6, VisvalingamWhyattPreserve); + assert_eq!( + simplified.0, + vec![ + P2::new(10.0, 60.0), + P2::new(126.0, 31.0), + P2::new(280.0, 19.0), + P2::new(117.0, 48.0), + P2::new(300.0, 40.0), + P2::new(301.0, 10.0), + ] + ); +} + +/// The tri-state entry exposes the same Cartesian winding result used by +/// `within` and `covered_by`, including both exterior and hole boundaries. +#[test] +fn coordinate_position_is_public_and_hole_aware() { + let polygon = Polygon::with_inners( + square_ring(0.0, 0.0, 10.0), + vec![square_ring(3.0, 3.0, 4.0)], + ); + + assert_eq!( + coordinate_position(&P2::new(1.0, 1.0), &polygon), + CoordinatePosition::Inside + ); + assert_eq!( + coordinate_position(&P2::new(0.0, 5.0), &polygon), + CoordinatePosition::OnBoundary + ); + assert_eq!( + coordinate_position(&P2::new(3.0, 5.0), &polygon), + CoordinatePosition::OnBoundary + ); + assert_eq!( + coordinate_position(&P2::new(5.0, 5.0), &polygon), + CoordinatePosition::Outside + ); + assert_eq!( + coordinate_position(&P2::new(11.0, 5.0), &polygon), + CoordinatePosition::Outside + ); +} + +/// Chaikin's 1974 corner-cutting rule is reachable through the public facade +/// and retains the endpoints of an open linestring. +#[test] +fn chaikin_smoothing_subdivides_an_open_linestring() { + let line = Linestring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(1.0, 0.0), + P2::new(1.0, 1.0), + ]); + + assert_eq!( + chaikin_smoothing(&line, 1).0, + vec![ + P2::new(0.0, 0.0), + P2::new(0.25, 0.0), + P2::new(0.75, 0.0), + P2::new(1.0, 0.25), + P2::new(1.0, 0.75), + P2::new(1.0, 1.0), + ] + ); +} + +/// The public Chaikin dispatch preserves polygon topology, open-ring closure +/// declarations, degenerate inputs, and all four supported ordinates. +#[test] +fn chaikin_smoothing_covers_stock_topologies_and_dimensions() { + let empty = Linestring::::from_vec(vec![]); + assert!(chaikin_smoothing(&empty, 2).0.is_empty()); + let singleton = Linestring::from_vec(vec![P2::new(1.0, 2.0)]); + assert_eq!(chaikin_smoothing(&singleton, 1), singleton); + + let short_ring: Ring = Ring::from_vec(vec![P2::new(0.0, 0.0), P2::new(1.0, 0.0)]); + assert_eq!(chaikin_smoothing(&short_ring, 1), short_ring); + + let open_ring: Ring = Ring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(0.0, 2.0), + P2::new(2.0, 0.0), + ]); + let smoothed_open = chaikin_smoothing(&open_ring, 1); + assert_eq!(smoothed_open.0.len(), 6); + assert_ne!(smoothed_open.0.first(), smoothed_open.0.last()); + + let polygon = + Polygon::with_inners(square_ring(0.0, 0.0, 4.0), vec![square_ring(1.0, 1.0, 1.0)]); + let smoothed_polygon = chaikin_smoothing(&polygon, 1); + assert_eq!(smoothed_polygon.outer.0.len(), 9); + assert_eq!(smoothed_polygon.inners.len(), 1); + assert_eq!(smoothed_polygon.inners[0].0.len(), 9); + + let line3 = Linestring::from_vec(vec![P3::new(0.0, 2.0, 4.0), P3::new(4.0, 6.0, 8.0)]); + let smoothed3 = chaikin_smoothing(&line3, 1); + assert_eq!(smoothed3.0[1], P3::new(1.0, 3.0, 5.0)); + + let mut start4 = P4::default(); + start4.set::<3>(4.0); + let mut end4 = P4::default(); + end4.set::<3>(8.0); + let smoothed4 = chaikin_smoothing(&Linestring::from_vec(vec![start4, end4]), 1); + assert_eq!(smoothed4.0[1].get::<3>(), 5.0); +} + +/// Named strategies construct the existing affine matrix engine rather than +/// introducing separate transform implementations. +#[test] +fn named_affine_strategies_feed_the_public_transform_entry() { + let point = P2::new(1.0, 2.0); + assert_eq!( + transform(&point, &Translate::by(3.0, 4.0)), + P2::new(4.0, 6.0) + ); + assert_eq!(transform(&point, &Scale::uniform(2.0)), P2::new(2.0, 4.0)); + assert_eq!(transform(&point, &Scale::by(2.0, 3.0)), P2::new(2.0, 6.0)); + assert_eq!(transform(&point, &Skew::by(2.0, 0.0)), P2::new(5.0, 2.0)); + let skewed_radians = transform(&point, &Skew::radians(0.0, core::f64::consts::FRAC_PI_4)); + assert!((skewed_radians.get::<0>() - 1.0).abs() < 1e-12); + assert!((skewed_radians.get::<1>() - 3.0).abs() < 1e-12); + let skewed_degrees = transform(&point, &Skew::degrees(45.0, 0.0)); + assert!((skewed_degrees.get::<0>() - 3.0).abs() < 1e-12); + assert!((skewed_degrees.get::<1>() - 2.0).abs() < 1e-12); + + let rotated = transform(&P2::new(1.0, 0.0), &Rotate::degrees(90.0)); + assert!(rotated.get::<0>().abs() < 1e-12); + assert!((rotated.get::<1>() - 1.0).abs() < 1e-12); +} + +/// The default geographic destination entry routes through the ported direct +/// formula and returns the endpoint in the origin point's angular units. +#[test] +fn destination_is_available_through_the_public_facade() { + type GeographicPoint = Point2D>; + + let endpoint = destination( + &GeographicPoint::new(0.0, 0.0), + core::f64::consts::FRAC_PI_2, + 100_000.0, + ); + assert!((endpoint.get::<0>() - 0.898_315_284_1).abs() < 1e-6); + assert!(endpoint.get::<1>().abs() < 1e-8); +} + +/// Locating and segmentizing are inverse-style public operations over the same +/// accumulated Cartesian arc length. +#[test] +fn locate_and_segmentize_preserve_a_bent_linestring() { + let line = Linestring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(2.0, 0.0), + P2::new(2.0, 2.0), + ]); + + assert_eq!(line_locate_point(&line, &P2::new(2.5, 1.0)), Some(0.75)); + assert_eq!(line_locate_point(&line, &P2::new(1.0, 0.25)), Some(0.25)); + let pieces = linestring_segmentize(&line, 2); + assert_eq!(pieces.0.len(), 2); + assert_eq!(pieces.0[0].0, vec![P2::new(0.0, 0.0), P2::new(2.0, 0.0)]); + assert_eq!(pieces.0[1].0, vec![P2::new(2.0, 0.0), P2::new(2.0, 2.0)]); +} + +/// `geo/src/algorithm/line_locate_point.rs:223-235` supplies the repeated-point +/// reference case. The public contract also defines the adjacent single-point +/// boundary: both have fractional position zero regardless of the query. +#[test] +fn line_locate_point_handles_single_and_zero_length_linestrings() { + let query = P2::new(2.0, 2.0); + let single = Linestring::from_vec(vec![P2::new(1.0, 1.0)]); + assert_eq!(line_locate_point(&single, &query), Some(0.0)); + + let repeated = Linestring::from_vec(vec![ + P2::new(1.0, 1.0), + P2::new(1.0, 1.0), + P2::new(1.0, 1.0), + ]); + assert_eq!(line_locate_point(&repeated, &query), Some(0.0)); +} + +/// Explicit segmentization follows great-circle interpolation when supplied a +/// spherical distance strategy. +#[test] +fn segmentize_with_haversine_uses_spherical_interpolation() { + type SphericalPoint = Point2D>; + let line = Linestring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(2.0, 0.0), + ]); + + let pieces = linestring_segmentize_with(&line, 2, Haversine::EARTH); + assert_eq!(pieces.0.len(), 2); + assert!((pieces.0[0].0[1].get::<0>() - 1.0).abs() < 1e-12); + assert!((pieces.0[1].0[0].get::<0>() - 1.0).abs() < 1e-12); +} + +/// Segmentization preserves all four Cartesian ordinates, retains a wholly +/// degenerate line as one piece, and uses its documented linear fallback for +/// antipodal spherical endpoints. +#[test] +fn segmentize_handles_dimensions_and_degenerate_metrics() { + type SphericalPoint = Point2D>; + + assert!( + linestring_segmentize(&Linestring::::from_vec(Vec::new()), 2) + .0 + .is_empty() + ); + assert!( + linestring_segmentize( + &Linestring::from_vec(vec![P2::new(0.0, 0.0), P2::new(1.0, 0.0)]), + 0, + ) + .0 + .is_empty() + ); + + let mut end = P4::default(); + end.set::<0>(4.0); + end.set::<1>(8.0); + end.set::<2>(12.0); + end.set::<3>(16.0); + let pieces = linestring_segmentize(&Linestring::from_vec(vec![P4::default(), end]), 2); + assert_eq!(pieces.0[0].0[1].get::<2>(), 6.0); + assert_eq!(pieces.0[0].0[1].get::<3>(), 8.0); + + let repeated = Linestring::from_vec(vec![P2::new(1.0, 1.0), P2::new(1.0, 1.0)]); + let degenerate = linestring_segmentize(&repeated, 3); + assert_eq!(degenerate.0, vec![repeated]); + + let with_interior_vertex = Linestring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(1.0, 0.0), + P2::new(6.0, 0.0), + ]); + let split = linestring_segmentize(&with_interior_vertex, 2); + assert_eq!( + split.0[0].0, + vec![P2::new(0.0, 0.0), P2::new(1.0, 0.0), P2::new(3.0, 0.0)] + ); + + let antipodal = Linestring::from_vec(vec![ + SphericalPoint::new(0.0, 0.0), + SphericalPoint::new(180.0, 0.0), + ]); + let antipodal_pieces = linestring_segmentize_with(&antipodal, 2, Haversine::UNIT); + assert!((antipodal_pieces.0[0].0[1].get::<0>() - 90.0).abs() < 1e-12); +} + +/// The ported spherical cross-track and closest-point strategies share the +/// same projection and are both reachable through explicit public entries. +#[test] +fn spherical_cross_track_and_closest_point_are_public() { + type SphericalPoint = Point2D>; + let point = SphericalPoint::new(1.0, 1.0); + let segment = Segment::new(SphericalPoint::new(0.0, 0.0), SphericalPoint::new(2.0, 0.0)); + + let distance = distance_with(&point, &segment, CrossTrack::EARTH); + assert!((distance - 111_226.255).abs() < 100.0); + + let (source, projected) = closest_points_with(&point, &segment, HaversineClosestPoints::EARTH); + assert!((source.get::<0>() - point.get::<0>()).abs() < 1e-12); + assert!((source.get::<1>() - point.get::<1>()).abs() < 1e-12); + assert!((projected.get::<0>() - 1.0).abs() < 1e-9); + assert!(projected.get::<1>().abs() < 1e-9); +} + +/// Reference cross-track cases cover degenerate segments, projections beyond +/// the minor arc, polar projection fallback, reversed dispatch, and comparable +/// strategy construction through the public facade. +#[test] +fn spherical_cross_track_public_edge_cases_choose_endpoints() { + type SphericalPoint = Point2D>; + let point = SphericalPoint::new(1.0, 1.0); + let endpoint = SphericalPoint::new(2.0, 2.0); + let degenerate = Segment::new(endpoint, endpoint); + let distance = distance_with(&point, °enerate, CrossTrack::UNIT); + assert!((distance - 0.024_678_3).abs() < 1e-6); + let projected_degenerate = + closest_points_with(&point, °enerate, HaversineClosestPoints::UNIT).1; + assert_eq!(projected_degenerate.get::<0>(), endpoint.get::<0>()); + assert_eq!(projected_degenerate.get::<1>(), endpoint.get::<1>()); + + let segment = Segment::new( + SphericalPoint::new(10.0, 15.0), + SphericalPoint::new(30.0, 15.0), + ); + let beyond = SphericalPoint::new(5.0, 10.0); + let forward = distance_with(&beyond, &segment, CrossTrack::default()); + let reverse = distance_with(&segment, &beyond, CrossTrack::default()); + let expected_endpoint = distance_with(&beyond, segment.start(), Haversine::EARTH); + assert!((forward - expected_endpoint).abs() < 1e-9); + assert!((forward - reverse).abs() < 1e-9); + assert_eq!( + comparable_distance_with(&beyond, &segment, CrossTrack::default()), + forward + ); + assert_eq!( + comparable_distance_with(&segment, &beyond, CrossTrack::default()), + reverse + ); + let (projected, source) = + closest_points_with(&segment, &beyond, HaversineClosestPoints::default()); + assert_eq!(source.get::<0>(), beyond.get::<0>()); + assert_eq!(source.get::<1>(), beyond.get::<1>()); + assert_eq!(projected.get::<0>(), segment.start().get::<0>()); + assert_eq!(projected.get::<1>(), segment.start().get::<1>()); + + let beyond_end = SphericalPoint::new(35.0, 10.0); + let end_distance = distance_with(&beyond_end, &segment, CrossTrack::default()); + let expected_end_distance = distance_with(&beyond_end, segment.end(), Haversine::EARTH); + assert!((end_distance - expected_end_distance).abs() < 1e-9); + let projected_end = + closest_points_with(&beyond_end, &segment, HaversineClosestPoints::default()).1; + assert_eq!(projected_end.get::<0>(), segment.end().get::<0>()); + assert_eq!(projected_end.get::<1>(), segment.end().get::<1>()); + + let equator = Segment::new( + SphericalPoint::new(-10.0, 0.0), + SphericalPoint::new(10.0, 0.0), + ); + let pole = SphericalPoint::new(0.0, 90.0); + let (_, projected_pole) = closest_points_with(&pole, &equator, HaversineClosestPoints::UNIT); + assert!( + (projected_pole.get::<0>() - equator.start().get::<0>()).abs() < 1e-12 + || (projected_pole.get::<0>() - equator.end().get::<0>()).abs() < 1e-12 + ); + assert!(projected_pole.get::<1>().abs() < 1e-12); +} + +/// Non-positive and NaN area tolerances are public no-op cases for the +/// Visvalingam–Whyatt strategies. +#[test] +fn visvalingam_whyatt_non_positive_and_nan_tolerances_copy_through() { + let line = Linestring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(1.0, 1.0), + P2::new(2.0, 0.0), + ]); + assert_eq!(simplify_with(&line, 0.0, VisvalingamWhyatt), line); + assert_eq!( + simplify_with(&line, f64::NAN, VisvalingamWhyattPreserve), + line + ); +} + +/// Value mapping can change scalar type, while the in-place face mutates every +/// stored point through the public geometry model. +#[test] +#[allow( + clippy::cast_possible_truncation, + reason = "the test deliberately verifies scalar-type rebinding from f64 to f32" +)] +fn map_coords_supports_rebind_and_in_place_faces() { + let line = Linestring::from_vec(vec![P2::new(1.0, 2.0), P2::new(3.0, 4.0)]); + let mapped: Linestring> = map_coords(&line, |point| { + Point2D::new(point.get::<0>() as f32 * 2.0, point.get::<1>() as f32) + }); + assert_eq!(mapped.0[0], Point2D::new(2.0_f32, 2.0)); + + let mut shifted = line; + map_coords_in_place(&mut shifted, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + assert_eq!(shifted.0[0], P2::new(11.0, 2.0)); + assert_eq!(shifted.0[1], P2::new(13.0, 4.0)); +} + +/// Every stock geometry implementation is reachable through the public +/// facade. Mapping preserves topology, including polygon interiors and each +/// member of multi-geometries; in-place mapping visits the same stored points. +#[test] +fn map_coords_supports_every_stock_geometry() { + let shift = |point: &P2| P2::new(point.get::<0>() + 10.0, point.get::<1>() - 1.0); + + let point = P2::new(1.0, 2.0); + assert_eq!(map_coords(&point, shift), P2::new(11.0, 1.0)); + + let ring = square_ring(0.0, 0.0, 2.0); + let mapped_ring: Ring = map_coords(&ring, shift); + assert_eq!(mapped_ring.0.len(), ring.0.len()); + assert_eq!(mapped_ring.0[2], P2::new(12.0, 1.0)); + + let polygon = Polygon::with_inners(ring.clone(), vec![square_ring(0.5, 0.5, 0.5)]); + let mapped_polygon: Polygon = map_coords(&polygon, shift); + assert_eq!(mapped_polygon.outer.0.len(), polygon.outer.0.len()); + assert_eq!(mapped_polygon.inners.len(), 1); + assert_eq!(mapped_polygon.inners[0].0[0], P2::new(10.5, -0.5)); + + let points = MultiPoint::from_vec(vec![P2::new(1.0, 2.0), P2::new(3.0, 4.0)]); + let mapped_points: MultiPoint = map_coords(&points, shift); + assert_eq!( + mapped_points.0, + vec![P2::new(11.0, 1.0), P2::new(13.0, 3.0)] + ); + + let lines = MultiLinestring::from_vec(vec![ + Linestring::from_vec(vec![P2::new(0.0, 0.0), P2::new(1.0, 1.0)]), + Linestring::from_vec(vec![P2::new(2.0, 2.0)]), + ]); + let mapped_lines: MultiLinestring> = map_coords(&lines, shift); + assert_eq!(mapped_lines.0.len(), 2); + assert_eq!(mapped_lines.0[1].0[0], P2::new(12.0, 1.0)); + + let polygons = MultiPolygon::from_vec(vec![polygon.clone(), Polygon::new(ring.clone())]); + let mapped_polygons: MultiPolygon> = map_coords(&polygons, shift); + assert_eq!(mapped_polygons.0.len(), 2); + assert_eq!(mapped_polygons.0[0].inners.len(), 1); + assert_eq!(mapped_polygons.0[1].outer.0[1], P2::new(12.0, -1.0)); + + let bounds = ModelBox::from_corners(P2::new(-1.0, -2.0), P2::new(3.0, 4.0)); + let mapped_bounds: ModelBox = map_coords(&bounds, shift); + assert_eq!(mapped_bounds.get_indexed::<0, 0>(), 9.0); + assert_eq!(mapped_bounds.get_indexed::<1, 1>(), 3.0); + + let segment = Segment::new(P2::new(-2.0, 3.0), P2::new(4.0, 5.0)); + let mapped_segment: Segment = map_coords(&segment, shift); + assert_eq!(mapped_segment.get_indexed::<0, 0>(), 8.0); + assert_eq!(mapped_segment.get_indexed::<1, 1>(), 4.0); + + let mut point_mut = point; + let mut line_mut = Linestring::from_vec(vec![point]); + let mut ring_mut = ring; + let mut polygon_mut = polygon; + let mut multi_point_mut = points; + let mut multi_line_mut = lines; + let mut multi_polygon_mut = polygons; + let mut bounds_mut = bounds; + let mut segment_mut = segment; + map_coords_in_place(&mut point_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut line_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut ring_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut polygon_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut multi_point_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut multi_line_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut multi_polygon_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut bounds_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + map_coords_in_place(&mut segment_mut, |point| { + point.set::<0>(point.get::<0>() + 10.0); + }); + + assert_eq!(point_mut, P2::new(11.0, 2.0)); + assert_eq!(line_mut.0[0], P2::new(11.0, 2.0)); + assert_eq!(ring_mut.0[2], P2::new(12.0, 2.0)); + assert_eq!(polygon_mut.inners[0].0[0], P2::new(10.5, 0.5)); + assert_eq!(multi_point_mut.0[1], P2::new(13.0, 4.0)); + assert_eq!(multi_line_mut.0[1].0[0], P2::new(12.0, 2.0)); + assert_eq!(multi_polygon_mut.0[1].outer.0[1], P2::new(12.0, 0.0)); + assert_eq!(bounds_mut.get_indexed::<0, 0>(), 9.0); + assert_eq!(segment_mut.get_indexed::<1, 0>(), 14.0); +} + /// `test/algorithms/intersects/intersects.cpp:23-30` — polygons wholly inside /// a hole are disjoint, while crossing either polygon's hole boundary counts /// as an intersection. In each positive case, the first tested vertices are @@ -609,6 +1607,84 @@ fn intersects_linestring_polygon_checks_both_rings_and_holes() { assert!(intersects(&ls_crosses_hole_boundary, &polygon)); } +/// A connected line needs only one representative point plus all boundary +/// crossings to determine whether it meets polygon material. These cases lock +/// the public predicate at the two transitions which a first-point-only +/// containment check must still discover through ring crossings. +#[test] +fn intersects_linestring_polygon_uses_boundary_crossings_after_the_first_point() { + let polygon = Polygon::with_inners( + square_ring(0.0, 0.0, 10.0), + vec![square_ring(3.0, 3.0, 4.0)], + ); + + let exits_hole_into_material: Linestring = + Linestring::from_vec(vec![P2::new(4.0, 5.0), P2::new(2.0, 5.0)]); + assert!(intersects(&exits_hole_into_material, &polygon)); + + let exits_exterior_into_material: Linestring = + Linestring::from_vec(vec![P2::new(-1.0, 2.0), P2::new(2.0, 2.0)]); + assert!(intersects(&exits_exterior_into_material, &polygon)); + + let stays_in_hole: Linestring = Linestring::from_vec(vec![ + P2::new(4.0, 4.0), + P2::new(5.0, 5.0), + P2::new(6.0, 6.0), + ]); + assert!(!intersects(&stays_in_hole, &polygon)); + + let stays_outside = Linestring::from_vec( + (0..64) + .map(|index| P2::new(20.0 + f64::from(index), f64::from(index % 3))) + .collect(), + ); + assert!(!intersects(&stays_outside, &polygon)); + + assert!(!intersects(&P2::new(5.0, 5.0), &polygon)); + assert!(intersects(&P2::new(0.0, 5.0), &polygon)); +} + +/// Differential public-API oracle: the dedicated boolean predicate must agree +/// with the independently implemented DE-9IM relation for exterior, material, +/// boundary, and hole transitions in either segment direction. +#[test] +fn intersects_point_and_linestring_polygon_match_public_relation_grid() { + let polygon = Polygon::with_inners( + square_ring(-2.0, -2.0, 4.0), + vec![square_ring(-1.0, -1.0, 2.0)], + ); + let values = [-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]; + + for &x in &values { + for &y in &values { + let point = P2::new(x, y); + let matrix = relation(&point, &polygon).unwrap(); + let related = matrix.m[0][0] != Dimension::Empty || matrix.m[0][1] != Dimension::Empty; + assert_eq!(intersects(&point, &polygon), related, "point=({x}, {y})"); + } + } + + for &x1 in &values { + for &y1 in &values { + for &x2 in &values { + for &y2 in &values { + let line = Linestring::from_vec(vec![P2::new(x1, y1), P2::new(x2, y2)]); + let matrix = relation(&line, &polygon).unwrap(); + let related = matrix.m[0][0] != Dimension::Empty + || matrix.m[0][1] != Dimension::Empty + || matrix.m[1][0] != Dimension::Empty + || matrix.m[1][1] != Dimension::Empty; + assert_eq!( + intersects(&line, &polygon), + related, + "line=({x1}, {y1})→({x2}, {y2})" + ); + } + } + } + } +} + /// Empty and undersized areal inputs are total predicates, while the explicit /// reversed entry point exposes Boost's reverse-dispatch contract directly. /// The crossing rectangles have no contained first vertex, forcing the @@ -643,6 +1719,23 @@ fn intersects_handles_empty_short_and_explicitly_reversed_inputs() { assert!(!intersects(&empty_line, &empty_polygon)); let crossing_line = Linestring::from_vec(vec![P2::new(-1.0, 1.0), P2::new(3.0, 1.0)]); assert!(intersects_reversed(&square, &crossing_line)); + + let disjoint_line = Linestring::from_vec(vec![P2::new(3.0, 3.0), P2::new(4.0, 4.0)]); + assert!(!intersects(&disjoint_line, &empty_polygon)); + assert!(!intersects(&disjoint_line, &square)); + + let open_triangle: Polygon = Polygon::new(Ring::from_vec(vec![ + P2::new(0.0, 0.0), + P2::new(2.0, 0.0), + P2::new(2.0, 2.0), + ])); + let crosses_implicit_edge = Linestring::from_vec(vec![P2::new(0.5, 1.6), P2::new(1.8, 1.6)]); + assert!(intersects(&crosses_implicit_edge, &open_triangle)); + + let mut hole = square_ring(10.0, 10.0, 1.0); + hole.0.reverse(); + let disjoint_with_hole = Polygon::with_inners(square_ring(9.0, 9.0, 3.0), vec![hole]); + assert!(!intersects(&square, &disjoint_with_hole)); } /// Segment endpoint order must not affect intersection, and point equality diff --git a/crates/geometry/tests/buffer_strategy_parity.rs b/crates/geometry/tests/buffer_strategy_parity.rs index e8853e6..77e4d39 100644 --- a/crates/geometry/tests/buffer_strategy_parity.rs +++ b/crates/geometry/tests/buffer_strategy_parity.rs @@ -26,6 +26,7 @@ fn buffered_area(result: &MultiPolygon>) -> f64 { #[test] fn default_buffer_settings_match_the_public_round_constructor() { assert_eq!(BufferSettings::default(), BufferSettings::round(1.0, 36)); + assert_eq!(SphericalBuffer::default(), SphericalBuffer::UNIT); } /// `test/algorithms/buffer/buffer_point.cpp:25-29` and @@ -207,6 +208,10 @@ fn ring_box_and_multi_geometries_use_public_buffer_dispatch() { let ring_result = buffer_with(&ring, miter_settings(1.0)).unwrap(); assert!((buffered_area(&ring_result) - 27.0).abs() < 1e-9); + let open_ring: Ring = Ring::from_vec(ring.0[..ring.0.len() - 1].to_vec()); + let open_ring_result = buffer_with(&open_ring, miter_settings(1.0)).unwrap(); + assert!((buffered_area(&open_ring_result) - 27.0).abs() < 1e-9); + let bounds = ModelBox::from_corners(P::new(0.0, 0.0), P::new(2.0, 4.0)); let box_result = buffer_with(&bounds, miter_settings(1.0)).unwrap(); assert!((buffered_area(&box_result) - 24.0).abs() < 1e-9); @@ -464,6 +469,30 @@ fn areal_offset_handles_collinear_duplicate_and_collapsed_boundaries() { let collapsed_result = buffer_with(&collapsed, miter_settings(1.0)).unwrap(); assert_eq!(collapsed_result.0.len(), 1); assert!((buffered_area(&collapsed_result) - 1.0).abs() < 1e-12); + + let near_parallel: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(1.0, 0.0), + P::new(2.0, 1e-20), + P::new(2.0, 2.0), + P::new(0.0, 2.0), + P::new(0.0, 0.0), + ])); + assert!( + !buffer_with(&near_parallel, miter_settings(1.0)) + .unwrap() + .0 + .is_empty() + ); + + let exact_collapse: Polygon

= + polygon![[(0.0, 0.0), (0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)]]; + assert!( + buffer_with(&exact_collapse, miter_settings(-1.0)) + .unwrap() + .0 + .is_empty() + ); } /// `test/algorithms/buffer/buffer_point_geo.cpp:34-49` — the default @@ -675,3 +704,199 @@ fn angular_segment_ring_box_and_multi_dispatch_is_public() { 2 ); } + +/// Angular projection failures are observable through the public buffer +/// contract: invalid radii/spheroids, poles, empty inputs, and invalid +/// projected members all return `Unsupported` instead of producing non-finite +/// coordinates. +#[test] +#[allow( + clippy::too_many_lines, + reason = "one public contract case covers every angular projection rejection path" +)] +fn angular_buffer_rejects_invalid_projection_inputs() { + type GeographicPoint = Point2D>; + type SphericalPoint = Point2D>; + + let point = SphericalPoint::new(0.0, 0.0); + let round = BufferSettings::round(100.0, 36); + for radius in [f64::NAN, 0.0, -1.0] { + assert!(matches!( + buffer_with_strategy(&point, round, SphericalBuffer::new(radius)), + Err(OverlayError::Unsupported) + )); + } + for strategy in [SphericalBuffer::UNIT, SphericalBuffer::new(6_371_008.8)] { + for pole in [ + SphericalPoint::new(0.0, 90.0), + SphericalPoint::new(0.0, -90.0), + ] { + assert!(matches!( + buffer_with_strategy(&pole, round, strategy), + Err(OverlayError::Unsupported) + )); + } + } + + let geographic = GeographicPoint::new(0.0, 0.0); + for spheroid in [ + Spheroid { + equatorial_radius: f64::NAN, + flattening: 0.0, + }, + Spheroid { + equatorial_radius: 0.0, + flattening: 0.0, + }, + Spheroid { + equatorial_radius: 1.0, + flattening: f64::NAN, + }, + Spheroid { + equatorial_radius: 1.0, + flattening: -0.1, + }, + Spheroid { + equatorial_radius: 1.0, + flattening: 1.0, + }, + ] { + assert!(matches!( + buffer_with_strategy(&geographic, round, GeographicBuffer::new(spheroid)), + Err(OverlayError::Unsupported) + )); + } + let geographic_pole = buffer_with_strategy( + &GeographicPoint::new(0.0, 90.0), + round, + GeographicBuffer::WGS84, + ); + assert!(matches!(geographic_pole, Err(OverlayError::Unsupported))); + + let spherical = SphericalBuffer::new(6_371_008.8); + let empty_line = Linestring::::from_vec(Vec::new()); + let empty_ring = Ring::::from_vec(Vec::new()); + let empty_polygon = Polygon::::new(Ring::from_vec(Vec::new())); + let empty_points = MultiPoint::::from_vec(Vec::new()); + let empty_lines = MultiLinestring::>::from_vec(Vec::new()); + let empty_polygons = MultiPolygon::>::from_vec(Vec::new()); + assert!(matches!( + buffer_with_strategy(&empty_line, round, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&empty_ring, round, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&empty_polygon, round, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&empty_points, round, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&empty_lines, round, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&empty_polygons, round, spherical), + Err(OverlayError::Unsupported) + )); + + let short_line = Linestring::from_vec(vec![point]); + let short_ring: Ring = Ring::from_vec(vec![point]); + let short_polygon = Polygon::new(short_ring.clone()); + assert!(matches!( + buffer_with_strategy(&short_line, round, spherical), + Err(OverlayError::Unsupported) + )); + let short_ring_result = buffer_with_strategy(&short_ring, round, spherical); + let short_polygon_result = buffer_with_strategy(&short_polygon, round, spherical); + assert!(short_ring_result.unwrap().0.is_empty()); + assert!(short_polygon_result.unwrap().0.is_empty()); + + let valid_ring: Ring = Ring::from_vec(vec![ + SphericalPoint::new(-0.1, -0.1), + SphericalPoint::new(-0.1, 0.1), + SphericalPoint::new(0.1, 0.1), + SphericalPoint::new(0.1, -0.1), + SphericalPoint::new(-0.1, -0.1), + ]); + let valid_polygon = Polygon::new(valid_ring.clone()); + let asymmetric = BufferSettings { + distance: BufferDistanceStrategy::Asymmetric { + left: 10.0, + right: 20.0, + }, + ..round + }; + assert!(matches!( + buffer_with_strategy(&valid_ring, asymmetric, spherical), + Err(OverlayError::Unsupported) + )); + assert!(matches!( + buffer_with_strategy(&valid_polygon, asymmetric, spherical), + Err(OverlayError::Unsupported) + )); +} + +/// The local angular projection must choose the short path across the date +/// line and normalize every generated longitude back into the public range. +/// A holed areal input also exercises interior-ring reprojection. +#[test] +fn angular_buffer_wraps_antimeridian_and_reprojects_holes() { + type SphericalPoint = Point2D>; + let spherical = SphericalBuffer::new(6_371_008.8); + + for longitude in [179.999, -179.999] { + let result = buffer_with_strategy( + &SphericalPoint::new(longitude, 0.0), + BufferSettings::round(1_000.0, 72), + spherical, + ) + .unwrap(); + let ring = result.polygons().next().unwrap().exterior(); + assert!( + ring.points() + .all(|point| (-180.0..=180.0).contains(&point.x())) + ); + assert!(ring.points().any(|point| point.x().is_sign_positive())); + assert!(ring.points().any(|point| point.x().is_sign_negative())); + } + + for longitudes in [[-170.0, -170.0, 170.0], [170.0, 170.0, -170.0]] { + let line = Linestring::from_vec( + longitudes + .into_iter() + .zip([0.0, 0.01, 0.02]) + .map(|(longitude, latitude)| SphericalPoint::new(longitude, latitude)) + .collect(), + ); + assert!( + !buffer_with_strategy(&line, BufferSettings::round(100.0, 36), spherical) + .unwrap() + .0 + .is_empty() + ); + } + + let outer: Ring = Ring::from_vec(vec![ + SphericalPoint::new(-0.1, -0.1), + SphericalPoint::new(-0.1, 0.1), + SphericalPoint::new(0.1, 0.1), + SphericalPoint::new(0.1, -0.1), + SphericalPoint::new(-0.1, -0.1), + ]); + let inner: Ring = Ring::from_vec(vec![ + SphericalPoint::new(-0.04, -0.04), + SphericalPoint::new(0.04, -0.04), + SphericalPoint::new(0.04, 0.04), + SphericalPoint::new(-0.04, 0.04), + SphericalPoint::new(-0.04, -0.04), + ]); + let donut = Polygon::with_inners(outer, vec![inner]); + let result = buffer_with_strategy(&donut, BufferSettings::round(100.0, 36), spherical).unwrap(); + assert_eq!(result.polygons().next().unwrap().interiors().count(), 1); +} diff --git a/crates/geometry/tests/geographic_direct_parity.rs b/crates/geometry/tests/geographic_direct_parity.rs index 86312eb..eeb0d6d 100644 --- a/crates/geometry/tests/geographic_direct_parity.rs +++ b/crates/geometry/tests/geographic_direct_parity.rs @@ -33,6 +33,14 @@ fn direct_formulas_match_reference_case() { /// remains on the equator and both implementations normalize longitude. #[test] fn direct_equatorial_case_and_longitude_normalization() { + let karney_equatorial = + KarneyDirect::WGS84.apply(0.0, 0.0, 250_000.0, core::f64::consts::FRAC_PI_2); + assert!((karney_equatorial.lon2 * R2D - 2.245_788_210_298_804).abs() < 1e-14); + assert!(karney_equatorial.lat2.abs() < f64::EPSILON); + assert!( + (karney_equatorial.reverse_azimuth - core::f64::consts::FRAC_PI_2).abs() < f64::EPSILON + ); + for result in [ VincentyDirect::WGS84.apply(179.0 * D2R, 0.0, 250_000.0, 90.0 * D2R), ThomasDirect::WGS84.apply(179.0 * D2R, 0.0, 250_000.0, 90.0 * D2R), diff --git a/crates/geometry/tests/meridian_vertex_parity.rs b/crates/geometry/tests/meridian_vertex_parity.rs index bd33409..0f622bf 100644 --- a/crates/geometry/tests/meridian_vertex_parity.rs +++ b/crates/geometry/tests/meridian_vertex_parity.rs @@ -123,6 +123,82 @@ fn spherical_and_geographic_vertices_match_boost() { assert!((geographic_lon * R2D - 66.255_942_73).abs() < 2e-7); } +/// Mirrored and wrapped rows from +/// `test/formulas/vertex_longitude_cases.hpp:39-47,173-179` exercise the +/// southern reduced-latitude and antimeridian correction paths. An +/// equator-crossing segment covers Boost's opposite-hemisphere adjustment. +#[test] +fn geographic_vertex_longitude_covers_southern_wrapped_and_crossing_cases() { + let spheroid = Spheroid::WGS84; + + let southern_start = (D2R, -D2R); + let southern_end = (100.0 * D2R, -2.0 * D2R); + let southern_inverse = KarneyInverse::WGS84.apply( + southern_start.0, + southern_start.1, + southern_end.0, + southern_end.1, + ); + let southern_latitude = + -geographic_vertex_latitude(southern_start.1, southern_inverse.azimuth, spheroid); + let southern_longitude = geographic_vertex_longitude( + southern_start.0, + southern_start.1, + southern_end.0, + southern_end.1, + southern_latitude, + southern_inverse.azimuth, + spheroid, + ); + let southern_degrees = southern_longitude * R2D; + assert!( + (southern_degrees - 66.255_942_73).abs() < 3e-7, + "expected 66.25594273 degrees; observed {southern_degrees}" + ); + + let wrapped_start = (0.0, D2R); + let wrapped_end = (270.0 * D2R, 1.0 * D2R); + let wrapped_inverse = KarneyInverse::WGS84.apply( + wrapped_start.0, + wrapped_start.1, + wrapped_end.0, + wrapped_end.1, + ); + let wrapped_latitude = + geographic_vertex_latitude(wrapped_start.1, wrapped_inverse.azimuth, spheroid); + let wrapped_longitude = geographic_vertex_longitude( + wrapped_start.0, + wrapped_start.1, + wrapped_end.0, + wrapped_end.1, + wrapped_latitude, + wrapped_inverse.azimuth, + spheroid, + ); + assert!((wrapped_longitude * R2D + 45.0).abs() < 2e-7); + + let crossing_start = (0.0, -D2R); + let crossing_end = (100.0 * D2R, 2.0 * D2R); + let crossing_inverse = KarneyInverse::WGS84.apply( + crossing_start.0, + crossing_start.1, + crossing_end.0, + crossing_end.1, + ); + let crossing_latitude = + geographic_vertex_latitude(crossing_start.1, crossing_inverse.azimuth, spheroid); + let crossing_longitude = geographic_vertex_longitude( + crossing_start.0, + crossing_start.1, + crossing_end.0, + crossing_end.1, + crossing_latitude, + crossing_inverse.azimuth, + spheroid, + ); + assert!(crossing_longitude.is_finite()); +} + /// Endpoint, meridian, and polar shortcuts from /// `vertex_longitude_cases.hpp:207-243` are part of the public formula /// contract and avoid unstable longitude arithmetic at singularities. diff --git a/crates/geometry/tests/overlay_algorithms_parity.rs b/crates/geometry/tests/overlay_algorithms_parity.rs index d874b90..1f7f15b 100644 --- a/crates/geometry/tests/overlay_algorithms_parity.rs +++ b/crates/geometry/tests/overlay_algorithms_parity.rs @@ -1,10 +1,17 @@ //! Public-facade tests for overlay-dependent algorithm entry points. -use boost_geometry::model::{Point2D, Polygon, Ring}; -use boost_geometry::overlay::{OverlayError, traverse::TraversalError}; +use boost_geometry::model::{Point2D, Polygon, Ring, Segment}; +use boost_geometry::overlay::{ + OverlayError, + assemble::assemble_multipolygon, + predicate::SegmentIntersection, + traverse::{EnrichedRings, OverlayOp, TraversalError, enrich, enrich::Node, traverse}, + turn::{Method, Operation, OperationType, RingKind, SegmentId, Turn}, +}; use boost_geometry::prelude::{ - Cartesian, Dimension, JoinStrategy, PointStrategy, RelateError, ValidityFailure, buffer, - is_valid, merge_elements, relate, relation, r#union, + Cartesian, Dimension, JoinStrategy, LineIntersection, PointStrategy, RelateError, + ValidityFailure, buffer, contains_properly, is_valid, line_intersection, merge_elements, + relate, relation, ring_area, stitch_triangles, r#union, }; use boost_geometry::trait_::{MultiPolygon as _, Polygon as _}; @@ -20,6 +27,24 @@ fn square(x: f64, y: f64, size: f64) -> Polygon

{ ])) } +fn traversal_square(x: f64, y: f64, size: f64) -> Ring

{ + Ring::from_vec(vec![ + P::new(x, y), + P::new(x + size, y), + P::new(x + size, y + size), + P::new(x, y + size), + P::new(x, y), + ]) +} + +fn turn_operation(source_index: usize, segment_index: usize) -> Operation { + Operation::new(SegmentId { + source_index, + ring: RingKind::Exterior, + segment_index, + }) +} + /// `test/algorithms/overlay/overlay.cpp:376-384` — overlapping areal union. #[test] fn canonical_union_is_available_from_the_facade() { @@ -27,6 +52,15 @@ fn canonical_union_is_available_from_the_facade() { assert_eq!(output.polygons().count(), 1); } +/// Assembly classifies rings but does not validate them. An empty input ring +/// therefore remains an empty component instead of being silently discarded. +#[test] +fn empty_ring_assembly_uses_the_public_facade() { + let assembled = assemble_multipolygon(vec![Ring::

::from_vec(Vec::new())]); + assert_eq!(assembled.polygons().count(), 1); + assert_eq!(assembled.polygons().next().unwrap().exterior().0.len(), 0); +} + /// `test/algorithms/relate/relate_areal_areal.cpp:63-75` — relation returns /// the matrix while relate evaluates a DE-9IM mask. #[test] @@ -41,6 +75,276 @@ fn relation_matrix_and_relate_mask_are_distinct_public_entries() { assert_eq!(relate(&a, &b, "too-short"), Err(RelateError::InvalidMask)); } +/// DE-9IM `T**FF*FF*`: strict containment rejects every boundary contact. +#[test] +fn contains_properly_is_available_from_the_facade() { + let container = square(0.0, 0.0, 10.0); + let interior = square(2.0, 2.0, 2.0); + let touches_boundary = square(0.0, 2.0, 2.0); + let overlaps_boundary = square(9.0, 2.0, 2.0); + + assert!(contains_properly(&container, &interior).unwrap()); + assert!(!contains_properly(&container, &touches_boundary).unwrap()); + assert!(!contains_properly(&container, &overlaps_boundary).unwrap()); + assert!(!contains_properly(&interior, &container).unwrap()); +} + +/// The public segment entry preserves Boost's robustness refusal and exposes +/// the proper/touch distinction carried by the turn classifier. +#[test] +fn line_intersection_reports_proper_touch_collinear_and_range_cases() { + let proper_a = Segment::new(P::new(0.0, 0.0), P::new(4.0, 4.0)); + let proper_b = Segment::new(P::new(0.0, 4.0), P::new(4.0, 0.0)); + assert_eq!( + line_intersection(&proper_a, &proper_b), + Ok(Some(LineIntersection::SinglePoint { + intersection: P::new(2.0, 2.0), + is_proper: true, + })) + ); + + let touch = Segment::new(P::new(4.0, 4.0), P::new(5.0, 2.0)); + assert_eq!( + line_intersection(&proper_a, &touch), + Ok(Some(LineIntersection::SinglePoint { + intersection: P::new(4.0, 4.0), + is_proper: false, + })) + ); + + let collinear = Segment::new(P::new(2.0, 2.0), P::new(6.0, 6.0)); + assert_eq!( + line_intersection(&proper_a, &collinear), + Ok(Some(LineIntersection::Collinear { + intersection: Segment::new(P::new(2.0, 2.0), P::new(4.0, 4.0)), + })) + ); + + let disjoint = Segment::new(P::new(0.0, 5.0), P::new(4.0, 5.0)); + assert_eq!(line_intersection(&proper_a, &disjoint), Ok(None)); + + let too_large = Segment::new(P::new(100_000_000.0, 0.0), P::new(100_000_001.0, 1.0)); + assert_eq!( + line_intersection(&proper_a, &too_large), + Err(OverlayError::Unsupported) + ); +} + +/// Boost's turn enrichment associates intersections at segment endpoints with +/// the existing vertex. It must not splice a duplicate turn node into either +/// public enriched-ring sequence. +#[test] +fn endpoint_turns_are_represented_by_existing_vertices() { + let first = traversal_square(0.0, 0.0, 2.0); + let second = first.clone(); + let turns = [ + Turn { + point: P::new(0.0, 0.0), + method: Method::Touch, + operations: [turn_operation(0, 0), turn_operation(1, 0)], + touch_only: true, + }, + Turn { + point: P::new(2.0, 0.0), + method: Method::Touch, + operations: [turn_operation(0, 0), turn_operation(1, 0)], + touch_only: true, + }, + ]; + + let enriched = enrich(&first, &second, &turns); + assert!( + enriched + .rings + .iter() + .flatten() + .all(|node| matches!(node, Node::Vertex(_))) + ); +} + +/// `test/algorithms/overlay/traversal.cpp` exercises all three areal walk +/// modes. The exported traversal layer must select the exterior arcs for union +/// and the asymmetric first-minus-second arcs for difference. +#[test] +fn raw_traversal_supports_union_and_difference() { + let first = traversal_square(0.0, 0.0, 2.0); + let second = traversal_square(1.0, 1.0, 2.0); + let turns = boost_geometry::overlay::turn::get_turns_ring_ring( + &first, + 0, + RingKind::Exterior, + &second, + 1, + RingKind::Exterior, + ); + let enriched = enrich(&first, &second, &turns); + + let union = traverse(&enriched, &turns, OverlayOp::Union).unwrap(); + assert_eq!(union.len(), 1); + assert!((ring_area(&union[0]).abs() - 7.0).abs() < 1e-12); + + let difference = traverse(&enriched, &turns, OverlayOp::Difference).unwrap(); + assert_eq!(difference.len(), 1); + assert!((ring_area(&difference[0]).abs() - 3.0).abs() < 1e-12); +} + +/// A caller can construct an enriched graph through the exported low-level +/// API. Missing turn nodes are rejected deterministically rather than yielding +/// a partial ring. +#[test] +fn malformed_public_traversal_graph_is_rejected() { + let turn = Turn { + point: P::new(0.0, 0.0), + method: Method::Crosses, + operations: [turn_operation(0, 0), turn_operation(1, 0)], + touch_only: false, + }; + let enriched = EnrichedRings { + rings: [Vec::new(), Vec::new()], + }; + assert_eq!( + traverse(&enriched, &[turn], OverlayOp::Intersection), + Err(TraversalError::Unsupported) + ); + + let turn_node = Node::Turn { + point: turn.point, + turn_id: 0, + }; + let one_node_rings = EnrichedRings { + rings: [vec![turn_node], vec![turn_node]], + }; + assert_eq!( + traverse(&one_node_rings, &[turn], OverlayOp::Intersection), + Err(TraversalError::Unsupported) + ); + assert_eq!( + traverse(&one_node_rings, &[turn], OverlayOp::Union), + Err(TraversalError::Unsupported) + ); + + let malformed_turns = [ + turn, + Turn { + point: P::new(100.0, 100.0), + method: Method::Crosses, + operations: [turn_operation(0, 2), turn_operation(1, 2)], + touch_only: false, + }, + ]; + let no_outgoing_edge = EnrichedRings { + rings: [ + vec![ + Node::Turn { + point: malformed_turns[0].point, + turn_id: 0, + }, + Node::Vertex(P::new(1.0, 1.0)), + Node::Vertex(P::new(2.0, 1.0)), + Node::Turn { + point: malformed_turns[1].point, + turn_id: 1, + }, + Node::Vertex(P::new(200.0, 100.0)), + Node::Vertex(P::new(-1.0, -1.0)), + ], + vec![ + Node::Turn { + point: malformed_turns[0].point, + turn_id: 0, + }, + Node::Vertex(P::new(10.0, 0.0)), + Node::Vertex(P::new(10.0, 10.0)), + Node::Turn { + point: malformed_turns[1].point, + turn_id: 1, + }, + Node::Vertex(P::new(0.0, 10.0)), + ], + ], + }; + assert_eq!( + traverse(&no_outgoing_edge, &malformed_turns, OverlayOp::Intersection,), + Err(TraversalError::Unsupported) + ); +} + +/// A disjoint raw predicate outcome carries no traversal operation in Boost's +/// turn classifier. The public classifier preserves both operations as unset. +#[test] +fn disjoint_turn_classification_is_a_noop() { + let first_start = P::new(0.0, 0.0); + let first_end = P::new(1.0, 0.0); + let second_start = P::new(0.0, 1.0); + let second_end = P::new(1.0, 1.0); + let mut turn = Turn { + point: P::new(0.0, 0.0), + method: Method::None, + operations: [turn_operation(0, 0), turn_operation(1, 0)], + touch_only: false, + }; + boost_geometry::overlay::turn::classify::set_from_outcome( + &mut turn, + &SegmentIntersection::Disjoint, + &first_start, + &first_end, + &second_start, + &second_end, + ); + assert_eq!(turn.method, Method::Disjoint); + assert_eq!(turn.operations[0].operation, OperationType::None); + assert_eq!(turn.operations[1].operation, OperationType::None); +} + +/// Stitching consumes the native merge/union engine and removes the shared +/// diagonal between two triangles. +#[test] +fn stitch_triangles_reassembles_a_square() { + let first = Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 1.0), + P::new(1.0, 1.0), + P::new(0.0, 0.0), + ])); + let second = Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(1.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, 0.0), + ])); + + let stitched = stitch_triangles([first, second]).unwrap(); + assert_eq!(stitched.polygons().count(), 1); + assert!((ring_area(stitched.polygons().next().unwrap().exterior()).abs() - 1.0).abs() < 1e-12); +} + +/// Boost's `test/algorithms/merge_elements.cpp` retains two disjoint areal +/// components. Boost has no `stitch_triangles` entry, so this adapts that +/// expectation to the public stitching API with two disjoint triangles. +#[test] +fn stitch_triangles_retains_disjoint_components() { + let first = Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 1.0), + P::new(1.0, 0.0), + P::new(0.0, 0.0), + ])); + let second = Polygon::new(Ring::from_vec(vec![ + P::new(2.0, 0.0), + P::new(2.0, 1.0), + P::new(3.0, 0.0), + P::new(2.0, 0.0), + ])); + + let stitched = stitch_triangles([first, second]).unwrap(); + assert_eq!(stitched.polygons().count(), 2); + let total_area: f64 = stitched + .polygons() + .map(|polygon| ring_area(polygon.exterior()).abs()) + .sum(); + assert!((total_area - 1.0).abs() < 1e-12); +} + /// `test/algorithms/is_valid.cpp:1626-1634` — the generic entry dispatches to /// a polygon validator and reports Boost's strict-policy duplicate category. #[test] diff --git a/crates/geometry/tests/overlay_completion_parity.rs b/crates/geometry/tests/overlay_completion_parity.rs index dd777ed..51ef571 100644 --- a/crates/geometry/tests/overlay_completion_parity.rs +++ b/crates/geometry/tests/overlay_completion_parity.rs @@ -1,7 +1,9 @@ //! Public-facade regression tests for degenerate and holed areal overlay. -use boost_geometry::model::{MultiPolygon, Point2D, Polygon, polygon}; -use boost_geometry::prelude::{Cartesian, area, difference, intersection, sym_difference, r#union}; +use boost_geometry::model::{MultiPolygon, Point2D, Polygon, Ring, polygon}; +use boost_geometry::prelude::{ + Cartesian, Dimension, area, difference, intersection, relation, sym_difference, r#union, +}; use boost_geometry::trait_::{MultiPolygon as _, Polygon as _}; type P = Point2D; @@ -79,6 +81,108 @@ fn identical_polygons_have_canonical_boolean_results() { ); } +/// Empty and one-vertex rings have no areal boundary. Set-theoretic Boolean +/// identities still hold through the public polygon operations, matching the +/// degenerate-input families in Boost's overlay suite. +#[test] +fn degenerate_polygons_obey_boolean_identities() { + let empty = Polygon::new(Ring::

::from_vec(Vec::new())); + let singleton = Polygon::new(Ring::from_vec(vec![P::new(1.0, 1.0)])); + let filled = square(0.0, 0.0, 2.0, 2.0); + + for degenerate in [&empty, &singleton] { + assert_eq!( + intersection(degenerate, &filled) + .unwrap() + .polygons() + .count(), + 0 + ); + assert_eq!( + difference(degenerate, &filled).unwrap().polygons().count(), + 0 + ); + assert!((total_area(&r#union(degenerate, &filled).unwrap()) - 4.0).abs() < 1e-9); + assert!((total_area(&difference(&filled, degenerate).unwrap()) - 4.0).abs() < 1e-9); + assert!((total_area(&sym_difference(degenerate, &filled).unwrap()) - 4.0).abs() < 1e-9); + } +} + +/// Consecutive duplicate vertices do not contribute an edge. Boost's overlay +/// case corpus contains spike and redundant-vertex inputs; this public case +/// isolates the adjacent-duplicate boundary before Boolean graph assembly. +#[test] +fn adjacent_duplicate_vertices_do_not_change_boolean_results() { + let redundant: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 2.0), + P::new(0.0, 2.0), + P::new(2.0, 2.0), + P::new(2.0, 0.0), + P::new(0.0, 0.0), + ])); + let canonical = square(0.0, 0.0, 2.0, 2.0); + + assert!((total_area(&intersection(&redundant, &canonical).unwrap()) - 4.0).abs() < 1e-9); + assert!((total_area(&r#union(&redundant, &canonical).unwrap()) - 4.0).abs() < 1e-9); + assert_eq!(difference(&redundant, &canonical).unwrap().0.len(), 0); +} + +/// `tests/xmltester/tests/general/TestNGOverlayA.xml`, "AA - repeated +/// points" — GEOS removes a repeated run on one boundary and preserves the +/// canonical union and difference areas. +#[test] +fn geos_repeated_boundary_points_preserve_areal_overlay() { + let repeated: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(100.0, 200.0), + P::new(200.0, 200.0), + P::new(200.0, 100.0), + P::new(100.0, 100.0), + P::new(100.0, 151.0), + P::new(100.0, 151.0), + P::new(100.0, 151.0), + P::new(100.0, 151.0), + P::new(100.0, 200.0), + ])); + let adjacent: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(300.0, 200.0), + P::new(300.0, 100.0), + P::new(200.0, 100.0), + P::new(200.0, 200.0), + P::new(200.0, 200.0), + P::new(300.0, 200.0), + ])); + + assert_eq!(intersection(&repeated, &adjacent).unwrap().0.len(), 0); + assert_eq!( + relation(&repeated, &adjacent).unwrap().boundary_boundary(), + Dimension::Curve + ); + assert!((total_area(&r#union(&repeated, &adjacent).unwrap()) - 20_000.0).abs() < 1e-9); + assert!((total_area(&difference(&repeated, &adjacent).unwrap()) - 10_000.0).abs() < 1e-9); + assert!((total_area(&sym_difference(&repeated, &adjacent).unwrap()) - 20_000.0).abs() < 1e-9); +} + +/// The public overlay kernel uses a scale-relative snap tolerance. A collinear +/// boundary edge shorter than that tolerance collapses to one canonical node +/// without changing the represented polygon. +#[test] +fn scale_relative_snap_discards_a_collapsed_boundary_edge() { + let with_tiny_edge: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 10.0), + P::new(60_000_000.0, 10.0), + P::new(60_000_000.0, 0.0), + P::new(59_999_999.999, 0.0), + P::new(0.0, 0.0), + ])); + let canonical = square(0.0, 0.0, 60_000_000.0, 10.0); + + let overlap = intersection(&with_tiny_edge, &canonical).unwrap(); + assert_eq!(overlap.polygons().count(), 1); + assert!((total_area(&overlap) - 600_000_000.0).abs() < 1e-6); +} + /// `test/algorithms/overlay/overlay.cpp:380-402` — hole boundaries participate /// in clipping and assembly just like exterior boundaries. #[test] diff --git a/crates/geometry/tests/policy_parity.rs b/crates/geometry/tests/policy_parity.rs index 4819513..a7b5592 100644 --- a/crates/geometry/tests/policy_parity.rs +++ b/crates/geometry/tests/policy_parity.rs @@ -2,12 +2,14 @@ use core::cmp::Ordering; -use boost_geometry::model::{Point2D, Polygon, Ring}; +use boost_geometry::coords::Rational; +use boost_geometry::model::{Point as ModelPoint, Point2D, Polygon, Ring}; use boost_geometry::prelude::{ Cartesian, Degree, Geographic, Radian, Spherical, ValidityFailure, ValidityOptions, is_valid, is_valid_with, validity_reason, validity_reason_with, }; use boost_geometry::strategy::compare::{EqualTo, Greater, Less, LessExact}; +use boost_geometry::trait_::PointMut as _; type CartesianPoint = Point2D; const LESS: Less = Less; @@ -159,6 +161,121 @@ fn spherical_and_geographic_compare_handle_angular_coordinates() { assert!(EQUAL_TO.apply(°rees, &radians)); } +/// The public angular policies cover explicit latitude and higher-dimension +/// selection, pole equivalence, both antimeridian orderings, and every scalar +/// conversion supported by angular coordinate systems. +#[test] +fn angular_compare_covers_dimensions_and_scalar_conversions() { + type SphericalPoint = Point2D>; + type SphericalPoint4 = ModelPoint>; + + let ordinary = SphericalPoint::new(20.0, 10.0); + let antimeridian = SphericalPoint::new(180.0, 10.0); + assert!(LESS.apply(&ordinary, &antimeridian)); + assert!(GREATER.apply(&antimeridian, &ordinary)); + assert!(LESS_EXACT.apply(&ordinary, &antimeridian)); + + assert!(EqualTo::<0>.apply( + &SphericalPoint::new(-30.0, 90.0), + &SphericalPoint::new(70.0, 90.0), + )); + assert!(EqualTo::<0>.apply( + &SphericalPoint::new(10.0, 0.0), + &SphericalPoint::new(10.0, 20.0), + )); + assert!(Less::<1>.apply( + &SphericalPoint::new(0.0, -10.0), + &SphericalPoint::new(0.0, 20.0), + )); + assert!(Greater::<1>.apply( + &SphericalPoint::new(0.0, 20.0), + &SphericalPoint::new(0.0, -10.0), + )); + assert!(EqualTo::<1>.apply( + &SphericalPoint::new(0.0, 20.0), + &SphericalPoint::new(10.0, 20.0), + )); + + let point4 = |longitude, latitude, z, m| { + let mut point = SphericalPoint4::default(); + point.set::<0>(longitude); + point.set::<1>(latitude); + point.set::<2>(z); + point.set::<3>(m); + point + }; + let lower = point4(10.0, 20.0, 30.0, 40.0); + let higher_z = point4(10.0, 20.0, 31.0, 40.0); + let higher_m = point4(10.0, 20.0, 30.0, 41.0); + assert!(LESS.apply(&lower, &higher_z)); + assert!(EQUAL_TO.apply(&lower, &lower)); + assert!(Less::<2>.apply(&lower, &higher_z)); + assert!(Less::<3>.apply(&lower, &higher_m)); + assert!(EqualTo::<3>.apply(&lower, &higher_z)); + + let integer = Point2D::>::new(0, 0); + let floating = Point2D::>::new(1.0, 0.0); + assert!(LESS.apply(&integer, &floating)); + assert!(GREATER.apply(&floating, &integer)); + + let single = Point2D::>::new(1.0, 2.0); + let double = Point2D::>::new(2.0, 2.0); + assert!(LESS.apply(&single, &double)); + + let rational = Point2D::, Spherical>::new( + Rational::from_integer(1), + Rational::from_integer(2), + ); + let rational_higher = Point2D::, Spherical>::new( + Rational::from_integer(2), + Rational::from_integer(2), + ); + assert!(LESS.apply(&rational, &rational_higher)); +} + +/// `test/policies/compare.cpp:241-250` exercises scalar-independent policy +/// dispatch. The Rust public policy additionally accepts every pair in its +/// integer, floating, and exact-rational comparison lattice. +#[test] +fn cartesian_compare_covers_the_public_scalar_lattice() { + macro_rules! assert_less { + ($left:expr, $right:expr) => {{ + let left = Point2D::<_, Cartesian>::new($left, $left); + let right = Point2D::<_, Cartesian>::new($right, $right); + assert!(LESS.apply(&left, &right)); + }}; + } + + assert_less!(1_i32, 2_i32); + assert_less!(1_i64, 2_i64); + assert_less!(1_i64, 2_i32); + assert_less!(1_f32, 2_f64); + assert_less!(1_f64, 2_f32); + assert_less!(1_i32, 2_f32); + assert_less!(1_f32, 2_i32); + assert_less!(1_i32, 2_f64); + assert_less!(1_f64, 2_i32); + assert_less!(1_i64, 2_f32); + assert_less!(1_f32, 2_i64); + assert_less!(1_i64, 2_f64); + assert_less!(1_f64, 2_i64); + + let q32_one = Rational::::from_integer(1); + let q32_two = Rational::::from_integer(2); + let q64_one = Rational::::from_integer(1); + let q64_two = Rational::::from_integer(2); + assert_less!(q32_one, q64_two); + assert_less!(q64_one, q32_two); + assert_less!(q32_one, 2_i32); + assert_less!(1_i32, q32_two); + assert_less!(q64_one, 2_i64); + assert_less!(1_i64, q64_two); + assert_less!(q32_one, 2_f32); + assert_less!(1_f32, q32_two); + assert_less!(q64_one, 2_f64); + assert_less!(1_f64, q64_two); +} + fn duplicate_polygon() -> Polygon { Polygon::new(Ring::from_vec(vec![ CartesianPoint::new(0.0, 0.0), @@ -222,6 +339,34 @@ fn validity_failures_expose_reference_reason_messages() { ValidityFailure::InvalidCoordinate, "Geometry has point(s) with invalid coordinate(s)", ), + ( + ValidityFailure::CoordinateOutOfRange, + "Geometry has coordinate(s) outside the supported arithmetic range", + ), + ( + ValidityFailure::CollinearPointsOnFace, + "Geometry has collinear points on a face", + ), + ( + ValidityFailure::NonCoplanarPointsOnFace, + "Geometry has non-coplanar points on a face", + ), + ( + ValidityFailure::FewPointsOnFace, + "Geometry has too few points on a face", + ), + ( + ValidityFailure::InconsistentOrientation, + "Geometry has inconsistent surface orientation", + ), + ( + ValidityFailure::InvalidIntersection, + "Geometry has invalid face intersections", + ), + ( + ValidityFailure::DisconnectedSurface, + "Geometry has a disconnected surface", + ), ]; for (failure, reason) in reference_reasons { assert_eq!(failure.message(), reason); @@ -247,6 +392,11 @@ fn validity_failures_expose_reference_reason_messages() { /// and the Boost behavior is selected explicitly through the public facade. #[test] fn validity_options_preserve_strict_behavior_and_offer_boost_defaults() { + let custom = ValidityOptions::new(true, false); + assert!(custom.allows_duplicates()); + assert!(!custom.allows_spikes_for_linear()); + assert_eq!(ValidityOptions::default(), ValidityOptions::STRICT); + let duplicate = duplicate_polygon(); assert_eq!(is_valid(&duplicate), Err(ValidityFailure::DuplicatePoints)); assert!(is_valid_with(&duplicate, ValidityOptions::BOOST_DEFAULT).is_ok()); diff --git a/crates/geometry/tests/precise_math_parity.rs b/crates/geometry/tests/precise_math_parity.rs index 48cad47..00f4d59 100644 --- a/crates/geometry/tests/precise_math_parity.rs +++ b/crates/geometry/tests/precise_math_parity.rs @@ -6,7 +6,8 @@ )] use boost_geometry::coords::precise_math::{ - fast_expansion_sum_zeroelim, incircle, orient2d, two_product, two_sum, two_two_expansion_diff, + fast_expansion_sum_zeroelim, incircle, orient2d, scale_expansion_zeroelim, two_product, + two_sum, two_two_expansion_diff, }; /// `util/precise_math.hpp:42-85` — error-free sums and products preserve the @@ -65,4 +66,10 @@ fn expansion_difference_and_zero_eliminating_merge_cover_edge_inputs() { let length = fast_expansion_sum_zeroelim(&left, &right, &mut output); assert!(length >= 2); assert_eq!(output[..length].iter().sum::(), 1.0e16 + 2.0); + + let factor = 1.0e16 + 2.0; + let length = scale_expansion_zeroelim(&[factor, -factor], 1.0e-16, &mut output); + assert_eq!(length, 2); + assert_ne!(output[0], 0.0); + assert_eq!(output[0], -output[1]); } diff --git a/crates/geometry/tests/relate_pair_parity.rs b/crates/geometry/tests/relate_pair_parity.rs index 0c00ac8..3db584d 100644 --- a/crates/geometry/tests/relate_pair_parity.rs +++ b/crates/geometry/tests/relate_pair_parity.rs @@ -211,6 +211,49 @@ fn linear_relations_cover_disjoint_touch_and_overlap_kernels() { assert!(crosses(&horizontal, &diagonal).unwrap()); } +/// `test/algorithms/relate/relate_linear_linear.cpp:155-164` — consecutive +/// duplicate vertices contribute no segment, while a closed linestring has no +/// mod-2 boundary. These are exact Boost DE-9IM fixtures exercised through the +/// public relation facade. +#[test] +fn duplicated_and_closed_linestrings_match_boost_matrices() { + let reference = + Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(2.0, 2.0), P::new(4.0, 2.0)]); + for duplicated in [ + Linestring::from_vec(vec![P::new(1.0, 1.0), P::new(2.0, 2.0), P::new(2.0, 2.0)]), + Linestring::from_vec(vec![P::new(1.0, 1.0), P::new(1.0, 1.0), P::new(2.0, 2.0)]), + ] { + assert!( + relation(&duplicated, &reference) + .unwrap() + .matches("1FF0FF102") + .unwrap() + ); + } + + let open = Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(10.0, 0.0)]); + let closed = Linestring::from_vec(vec![ + P::new(5.0, 0.0), + P::new(9.0, 0.0), + P::new(5.0, 5.0), + P::new(1.0, 0.0), + P::new(5.0, 0.0), + ]); + let observed = relation(&open, &closed).unwrap(); + assert!(observed.matches("1F1FF01F2").unwrap()); + + // `relate_linear_linear.cpp:170-174` — a two-coordinate point-size + // linestring is topologically a point in either ordered position. + let point_size = Linestring::from_vec(vec![P::new(1.0, 0.0), P::new(1.0, 0.0)]); + let horizontal = Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(5.0, 0.0)]); + let point_line = relation(&point_size, &horizontal).unwrap(); + assert!(point_line.matches("0FFFFF102").unwrap()); + assert_eq!( + relation(&horizontal, &point_size).unwrap(), + point_line.transposed() + ); +} + /// `test/algorithms/relate/relate_linear_areal.cpp:44-87` — a line on an /// areal boundary and the reversed ordered pair exercise the boundary mask. #[test] @@ -282,6 +325,7 @@ fn public_matrix_masks_cover_every_symbol_and_overlay_errors() { }; assert!(matrix.matches("F012F012F").unwrap()); assert!(matrix.matches("*T*******").unwrap()); + assert_eq!(matrix.matches("********"), Err(RelateError::InvalidMask)); assert_eq!(matrix.matches("F012X012F"), Err(RelateError::InvalidMask)); let huge = box_at(0.0, 0.0, 200_000_000.0, 200_000_000.0); @@ -385,6 +429,20 @@ fn geometry_collections_relate_through_runtime_public_dispatch() { .unwrap() ); + let closed_line = + DynGeometryCollection(vec![DynGeometry::LineString(Linestring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(2.0, 0.0), + P::new(0.0, 0.0), + ]))]); + let closed_endpoint = DynGeometryCollection(vec![DynGeometry::Point(P::new(0.0, 0.0))]); + assert!( + relation(&closed_endpoint, &closed_line) + .unwrap() + .matches("0FFFFF1F2") + .unwrap() + ); + let first = DynGeometryCollection(vec![ DynGeometry::Polygon(box_at(0.0, 0.0, 5.0, 5.0)), DynGeometry::LineString(Linestring::from_vec(vec![ @@ -438,3 +496,144 @@ fn segment_dynamic_and_collection_reverse_pairs_are_public() { .unwrap() ); } + +/// `test/algorithms/relate/relate_linear_linear.cpp:104-139` and +/// `relate_gc.cpp:55-111` — generic topology dispatch promotes degenerate +/// linear/areal inputs to their actual dimension and recursively expands every +/// runtime multi/collection variant. +#[test] +fn generic_topology_dispatch_covers_degenerate_and_dynamic_variants() { + let segment = Segment::new(P::new(0.0, 0.0), P::new(4.0, 0.0)); + let line = Linestring::from_vec(vec![P::new(2.0, -1.0), P::new(2.0, 1.0)]); + assert_eq!( + relation(&line, &segment).unwrap().interior_interior(), + Dimension::Point + ); + + let degenerate_line = Linestring::from_vec(vec![P::new(1.0, 0.0), P::new(1.0, 0.0)]); + assert_eq!( + relation(°enerate_line, &segment) + .unwrap() + .interior_interior(), + Dimension::Point + ); + assert_eq!( + relation(&segment, °enerate_line).unwrap(), + relation(°enerate_line, &segment).unwrap().transposed() + ); + + let degenerate_polygon: Polygon

= + Polygon::new(Ring::from_vec(vec![P::new(1.0, 0.0), P::new(3.0, 0.0)])); + assert_eq!( + relation(°enerate_polygon, &segment) + .unwrap() + .interior_interior(), + Dimension::Curve + ); + + let dynamic_multi_point = DynGeometry::MultiPoint(MultiPoint::from_vec(vec![ + P::new(0.0, 0.0), + P::new(2.0, 2.0), + ])); + assert_eq!( + relation(&dynamic_multi_point, &P::new(2.0, 2.0)) + .unwrap() + .interior_interior(), + Dimension::Point + ); + + let dynamic_multi_line = DynGeometry::MultiLineString(MultiLinestring::from_vec(vec![ + Linestring::from_vec(vec![P::new(0.0, 0.0), P::new(2.0, 2.0)]), + ])); + assert_eq!( + relation(&dynamic_multi_line, &P::new(1.0, 1.0)) + .unwrap() + .interior_interior(), + Dimension::Point + ); + + let dynamic_multi_polygon = DynGeometry::MultiPolygon(MultiPolygon::from_vec(vec![square()])); + assert_eq!( + relation(&dynamic_multi_polygon, &P::new(2.0, 2.0)) + .unwrap() + .interior_interior(), + Dimension::Point + ); + + let dynamic_empty_line = DynGeometry::::LineString(Linestring::new()); + assert_eq!( + relation(&dynamic_empty_line, &P::new(2.0, 2.0)) + .unwrap() + .exterior_interior(), + Dimension::Point + ); + + let redundant_polygon: Polygon

= Polygon::new(Ring::from_vec(vec![ + P::new(0.0, 0.0), + P::new(0.0, 4.0), + P::new(0.0, 4.0), + P::new(4.0, 4.0), + P::new(4.0, 0.0), + P::new(0.0, 0.0), + ])); + assert_eq!( + relation(&redundant_polygon, &P::new(2.0, 2.0)) + .unwrap() + .interior_interior(), + Dimension::Point + ); + + let nested = DynGeometry::GeometryCollection(vec![DynGeometry::GeometryCollection(vec![ + dynamic_multi_point, + dynamic_multi_line, + dynamic_multi_polygon, + ])]); + assert!( + relation(&nested, &P::new(1.0, 1.0)) + .unwrap() + .interior_interior() + .is_set() + ); +} + +/// Generic topology range checks cover point/line storage and polygon holes. +/// The reversed linear–areal order exercises the second public `crosses` +/// alternative rather than relying only on matrix transposition. +#[test] +fn generic_topology_rejects_out_of_range_members_and_crosses_both_orders() { + let segment = Segment::new(P::new(-1.0, 2.0), P::new(5.0, 2.0)); + assert!(matches!( + relation(&P::new(f64::MAX, 0.0), &segment), + Err(OverlayError::Unsupported) + )); + + let polygon_with_bad_hole = Polygon::with_inners( + square().outer, + vec![Ring::from_vec(vec![ + P::new(1.0, 1.0), + P::new(f64::MAX, 1.0), + P::new(2.0, 2.0), + P::new(1.0, 1.0), + ])], + ); + assert!(matches!( + relation(&polygon_with_bad_hole, &segment), + Err(OverlayError::Unsupported) + )); + + let donut = Polygon::with_inners( + square().outer, + vec![Ring::from_vec(vec![ + P::new(1.0, 1.0), + P::new(3.0, 1.0), + P::new(3.0, 3.0), + P::new(1.0, 3.0), + P::new(1.0, 1.0), + ])], + ); + assert!(relation(&donut, &segment).is_ok()); + + let bounds = ModelBox::from_corners(P::new(0.0, 0.0), P::new(4.0, 4.0)); + assert!(crosses(&segment, &bounds).unwrap()); + assert!(crosses(&bounds, &segment).unwrap()); +} diff --git a/crates/geometry/tests/spheroidal_normalization_parity.rs b/crates/geometry/tests/spheroidal_normalization_parity.rs index 6ac0fa4..191de36 100644 --- a/crates/geometry/tests/spheroidal_normalization_parity.rs +++ b/crates/geometry/tests/spheroidal_normalization_parity.rs @@ -55,6 +55,23 @@ fn box_normalization_preserves_nan_and_normalizes_the_other_longitude() { assert_close(lat2, 20.0); } +/// `util/normalize_spheroidal_box_coordinates.hpp:82-93` canonicalizes both +/// an exact negative antimeridian and a positive longitude whose modulo lands +/// on that antimeridian to positive 180 degrees. +#[test] +fn negative_antimeridian_representations_are_canonicalized() { + for (first, second) in [(-180.0, -170.0), (540.0, 550.0)] { + let (mut lon1, mut lat1, mut lon2, mut lat2) = (first, -10.0, second, 20.0); + normalize_spheroidal_box_coordinates::( + &mut lon1, &mut lat1, &mut lon2, &mut lat2, + ); + assert_close(lon1, 180.0); + assert_close(lon2, 190.0); + assert_close(lat1, -10.0); + assert_close(lat2, 20.0); + } +} + /// `test/util/math_normalize_spheroidal.cpp:84-90` — the same normalization /// contract applies to integral degree coordinates without a floating cast. #[test] diff --git a/crates/geometry/tests/validity_completion_parity.rs b/crates/geometry/tests/validity_completion_parity.rs index dee41c5..d5130d8 100644 --- a/crates/geometry/tests/validity_completion_parity.rs +++ b/crates/geometry/tests/validity_completion_parity.rs @@ -96,6 +96,32 @@ fn polygon_detects_disconnected_interior() { is_valid(&holes_share_edge), Err(ValidityFailure::SelfIntersection) ); + + // `test/algorithms/is_valid.cpp` case pg067: two otherwise disjoint + // holes touch at two isolated points, disconnecting the polygon interior. + let holes_touch_twice: Polygon

= polygon![ + [ + (0.0, 0.0), + (0.0, 10.0), + (10.0, 10.0), + (10.0, 0.0), + (0.0, 0.0) + ], + [ + (1.0, 1.0), + (2.0, 1.0), + (2.0, 8.0), + (9.0, 8.0), + (9.0, 9.0), + (1.0, 9.0), + (1.0, 1.0) + ], + [(2.0, 5.0), (5.0, 5.0), (5.0, 8.0), (2.0, 5.0)] + ]; + assert_eq!( + is_valid(&holes_touch_twice), + Err(ValidityFailure::DisconnectedInterior) + ); } /// `test/algorithms/is_valid.cpp:929-970` — multi-polygons may touch at an @@ -103,6 +129,11 @@ fn polygon_detects_disconnected_interior() { /// interiors may not overlap/share an edge. #[test] fn multipolygon_checks_inter_member_topology() { + let invalid_member: MultiPolygon> = MultiPolygon::from_vec(vec![Polygon::new( + boost_geometry::model::Ring::from_vec(vec![P::new(0.0, 0.0)]), + )]); + assert_eq!(is_valid(&invalid_member), Err(ValidityFailure::FewPoints)); + let overlapping = MultiPolygon::from_vec(vec![square(0.0, 0.0, 4.0, 4.0), square(2.0, 2.0, 6.0, 6.0)]); assert_eq!( diff --git a/crates/geometry/tests/walkthrough.rs b/crates/geometry/tests/walkthrough.rs index 18c6f41..31c1e3c 100644 --- a/crates/geometry/tests/walkthrough.rs +++ b/crates/geometry/tests/walkthrough.rs @@ -57,6 +57,7 @@ fn geographic_amsterdam_paris_andoyer_vs_vincenty() { fn derive_point_macro() { #[derive(Default, DerivePoint)] #[geometry(cs = "Cartesian", scalar = "f64")] + #[repr(C)] struct MyXy { x: f64, y: f64,