diff --git a/EPIC_SFA_CURVE_AWARENESS.md b/EPIC_SFA_CURVE_AWARENESS.md index 3f78b0c542..e304cd1f3b 100644 --- a/EPIC_SFA_CURVE_AWARENESS.md +++ b/EPIC_SFA_CURVE_AWARENESS.md @@ -17,12 +17,12 @@ > Assisted-by: Cursor Grok (grok-4.6) > ``` -**Status:** Draft v5 (2026-08-16). -**Source:** Parent epic [locationtech/jts#1195](https://github.com/locationtech/jts/issues/1195). Fork work: [grootstebozewolf/jts#7](https://github.com/grootstebozewolf/jts/pull/7) `feature/sfa-curve-rgr`, tip `210f1b16` (merge of [#11](https://github.com/grootstebozewolf/jts/pull/11)). [#8](https://github.com/grootstebozewolf/jts/pull/8) PERF-GATE, [#10](https://github.com/grootstebozewolf/jts/pull/10) nested annulus (`92fdbb71`), and [#11](https://github.com/grootstebozewolf/jts/pull/11) odd-n tangent NSpan (`d8c5824f`) are **merged into #7**. Draft [#12](https://github.com/grootstebozewolf/jts/pull/12) (`3cc5f1a5`) stamps `H-SHELL-HOLE-CROSS` + `H-SHELL-HOLE-X`; draft [#13](https://github.com/grootstebozewolf/jts/pull/13) (`73703036`) stamps `H-ANNULUS-TANGENT`. Both drafts are stacked, not merged. Slack still 15%. No new public API. -**Origin (historical):** [`feature/sfa-curve-buffer-spike`](https://github.com/grootstebozewolf/jts/tree/feature/sfa-curve-buffer-spike) — Draft v3 of this epic and the 49-method spec class. Draft v4 (2026-08-15, tip `488c48ba`) described #8 as the live stacked branch; that merge order is done. +**Status:** Draft v6 MMF Option B (2026-08-17). Tip `0cf0eb3d` on [#61](https://github.com/grootstebozewolf/jts/pull/61). +**Source:** Parent epic [locationtech/jts#1195](https://github.com/locationtech/jts/issues/1195). Fork SoT [grootstebozewolf/jts#7](https://github.com/grootstebozewolf/jts/pull/7) `feature/sfa-curve-rgr`. MMF draft [grootstebozewolf/jts#61](https://github.com/grootstebozewolf/jts/pull/61) `cursor/jts-issue-1195-c5d1` — Option B `SegmentKind`, no-silent-linearize strategy, WKB 18–21 greenfield, OFF/BUF/VBF/COV/H-CC/PLG + TB-FN badges. Slack still 15%. No upstream locationtech PR until dr-jts engages. +**Origin (historical):** [`feature/sfa-curve-buffer-spike`](https://github.com/grootstebozewolf/jts/tree/feature/sfa-curve-buffer-spike) — Draft v3 of this epic and the 49-method spec class. Draft v5 (2026-08-16) described #7 @ `210f1b16` with OV-P1 kits; Bar 2 stayed off #7 until this MMF fold. **Audience:** locationtech/jts maintainers and contributors. Lift verbatim into a GitHub Epic / Discussion. -**Field contract (PERF-GATE):** Maintainable → Reliable → Faster. Take the curve path only if `t_laser ≤ 1.15 × t_chainsaw`. Do not loosen 15%. The overlay name is **OverlayNGCurve**, never *Curved*. +**Field contract (PERF-GATE):** Maintainable → Reliable → Faster. Take the curve path only if `t_laser ≤ 1.15 × t_chainsaw`. Do not loosen 15%. The overlay name is **OverlayNGCurve**, never *Curved*. **Noder = Option B** (`SegmentKind` LINEARIZED / ARC / CERTIFIED); index may lie under `PrecisionModel`. **No silent linearization** — `CurveLinearizationStrategy` default LINEARIZED always warns. ## 1. Goal @@ -55,37 +55,54 @@ Every circular pair that has a closed form has a kit. Everything else is a named ## 4. What's already landed -### 4.1 On #7 since the spike (tip `210f1b16`) +### 4.0 MMF Option B fold ([#61](https://github.com/grootstebozewolf/jts/pull/61), 17 Aug 2026) + +Draft off #7. SoT stays the fork; no upstream PR. + +| Bucket | Summary | TAG honesty | +|---|---|---| +| `arch:` | **Option B** `SegmentKind` (`LINEARIZED` / `ARC` / `CERTIFIED`) on core `SegmentString`; `CircularNodedSegmentString`; `IntersectionAdder.mayCollapseToChord`; OverlayNG prepared edges. Index may lie under `PrecisionModel`. | N-SS path is Option B, not the rejected 74-file Option B *lie* framing. | +| `arch:` | **`CurveLinearizationStrategy`**: default `LINEARIZED` always logs a warning; `PRESERVE` keeps type. Wired through `CurveOps.linearise`. | No silent flatten. | +| `feat:` | **WKB 18–21** SIGN greenfield: `CRV-CLOTHOID` / `PRF-BEZIER` / `PRF-ELLIPSE` / `CRV-NURBS` (+ ISO `+1000/+2000/+3000`). Factory stubs; `CurveWKBWriter`; core writer refuses flatten. 15–17 still Architect-gated. | I/O zoo partial–full for 18–21 types; ops still mostly chordsaw. | +| `feat:` | **OFF** shipped: public `OffsetCurve.getCurve` concentric single-arc (left-of-direction). | Full OFF TAG for 3-pt CS. Multi-arc still chordsaw. | +| `feat:` | **BUF-1 / BUF-NEG** shipped: open-arc corridor `CurvePolygon`; `|d|≥R` empty. **BUF-N** shipped: stadium dilation + open two-member line+arc corridor with round joins/caps. | Multi-arc (>2) CompoundCurve corridors still chainsaw. | +| `feat:` | **R-OV** OverlayNG-for-circles: H-SHELL-N-MIXED via `OverlayNGCircle` + `CurveSegmentString` bridge; BiteVsHole / TwoHoleOverlay folded. | Named R2 leftovers shrink; full OV still not green. | +| `feat:` | **HP.2 / HP.3** `CurveHotPixel` + `CurveHotPixelSnap` (PM-scale arc∩pixel; shared snapped ray stamp). | Not HP.4 faces; not core HotPixel rewrite. | +| `arch:` | **`CurveSegmentDcel`** (P2.5.7) package-private; **COV** `CurveCoverageUnion` dissolves shared shell members and keeps exterior `CIRCULARSTRING`s (hooked from `CoverageUnion`). **PLG** densifies on add (faces remain Polygon). | D-HF full TAG still red (apex closed-form exists). | +| `fix:` | **TB-FN #60**: Exec prefers `currentFunc`; param focus cannot re-bind to `Buffer.buffer`. **TB-FN badges** ●/◯/✕ via `Metadata.curveAwareness`. **Strategy picker** Edit menu LINEARIZED/PRESERVE; warn sink → Log tab. | Await UX SIGN on pin JAR. | +| `fix:` | **VBF honesty**: `VariableBuffer` + TestBuilder densify via equal-arc-length `CurveOps.lineariseArcLength` (warn). | Full arc-preserving variable offsets still optional laser. | +| `feat:` | **DSF / TRI / H-CC / LRF-LOC / C-IP / PRC-SN / V-CP / N-SS / R-PR** densify or Option B honesty paths shipped (see `CurveAwarenessGreenMetersTest`). | Meters deleted from red suite. | +| `docs:` | **`doc/latex/`** guide sources cherry-picked from #47 (Makefile + manuals + figures). Amyuni PDF binaries not force-swapped. | Phase 6 partial; `make` + UX SIGN still HOLD. | +| `test:` | **#56 locks**: `CurvePolygonToolTest` 16/16 — non-closing finish auto-closes to `CURVEPOLYGON`, Escape cancels with status, never silent empty / never `POLYGON` flatten. | HOLD issue close until PO UX SIGN. | + +### 4.1 On #7 since the spike (historical tip `210f1b16`, still accurate for OV-P1 kits) | Bucket | Summary | TAG honesty | |---|---|---| | `feat:` / `fix:` | **WKB 8–12.** Core `WKBReader` first-class cases: CircularString = count+coords; CompoundCurve / CurvePolygon / MultiCurve / MultiSurface = count + child WKBs (CurvePolygon rings carry their own type). `GeometryFactory.createCircularString` / `createCompoundCurve(LineString[])` / `createCurvePolygon(LineString, LineString[])` / `createMultiCurve` / `createMultiSurface` — default throws; `CurveGeometryFactory` implements. `new WKBReader(new CurveGeometryFactory()).read(type8)` returns `CircularString`. `CurveWKBReader` is the no-arg convenience. Locked XDR hex for `CIRCULARSTRING (0 0, 5 5, 10 0)` unchanged. Disc `CurvePolygon` area still 25π after round-trip. No `toLinear` on that path. | WKB sibling from Draft v3 — **landed on #7** (via #8). Not a new public I/O class beyond the factory surface + existing curve writer/reader convenience. | -| `feat:` | **OverlayNGCurve kits.** **R0** envelope-disjoint skip; **R1** retention when one envelope covers; **R1.5** `CircularDiscOverlay` two-disc closed form (CAP lens / CUP blob / SUB crescent / XOR crescents; crossing nodes `(3.5, ±√12.75)`); **R1.6** `CircularDiscPolygonOverlay` disc vs hole-free plain polygon (line–circle clip; half-disc area `12.5π`); **R1.7** CompoundCurve-shell kits (two-node, two-shell, collinear, 0/1-node, even-n, tangent-odd NSpan, same-outer hole, different-outer punch); **R-LL** CircularString / lineal CompoundCurve vs LineString; **R-AA** two CircularStrings (or lineal CompoundCurve vs CircularString) at exact nodes. Reverse `plain.op(curve)`, reverse SUB, MultiCurve/MultiSurface dispatch, nested Multi envelope skip. **D4** nested annulus: `CIRCLE_5 \ CIRCLE_3` is exact area `16π`, covers `EEEE` / coveredBy `EE0E`. **OV-P1 flipped.** Remaining named R2: `H-SHELL-N-MIXED`, `H-SHELL-HOLE-CROSS`, `H-SHELL-HOLE-X`, `H-ANNULUS-TANGENT`. Circular noder + arrangement is **OV-P2 / Bar 2** — different epic; do not start. | **OV partial** (closed-form subset). OV-P1 flipped; do **not** mark the *full* OV TAG green. Not a general circular noder. Keep `test_OV_*`. | +| `feat:` | **OverlayNGCurve kits.** **R0** envelope-disjoint skip; **R1** retention when one envelope covers; **R1.5** `CircularDiscOverlay` two-disc closed form (CAP lens / CUP blob / SUB crescent / XOR crescents; crossing nodes `(3.5, ±√12.75)`); **R1.6** `CircularDiscPolygonOverlay` disc vs hole-free plain polygon (line–circle clip; half-disc area `12.5π`); **R1.7** CompoundCurve-shell kits (two-node, two-shell, collinear, 0/1-node, even-n, tangent-odd NSpan, same-outer hole, different-outer punch); **R-LL** CircularString / lineal CompoundCurve vs LineString; **R-AA** two CircularStrings (or lineal CompoundCurve vs CircularString) at exact nodes. Reverse `plain.op(curve)`, reverse SUB, MultiCurve/MultiSurface dispatch, nested Multi envelope skip. **D4** nested annulus: `CIRCLE_5 \ CIRCLE_3` is exact area `16π`, covers `EEEE` / coveredBy `EE0E`. **OV-P1 flipped.** Remaining named R2 on tip shrink after R-OV / hole kits on MMF; `H-ANNULUS-TANGENT` stays a named miss. Circular noder + arrangement is **OV-P2** under Option B. | **OV partial** (closed-form subset). Do **not** mark the *full* OV TAG green. Keep `test_OV_*`. | | `feat:` | **R-CONT** certified disc PIP (interior + boundary band) in `CurveExact`. | **R-CONT partial**. | | `feat:` | **R-PR DE-9IM** for disc vs Point, LineString, hole-free Polygon, and two discs. Point: interior `0F2FF1FF2`, boundary `FF20F1FF2`, exterior `FF2FF10F2`. Line: crossing `1F20F1102`, tangent `FF20F1102`, miss `FF2FF1102`, endpoint-interior `1020F1102`. Polygon: disjoint `FF2FF1212`, nested `212FF1FF2`, disc-in-square `2FF1FF212`, crossing `212101212`. Two discs: crossing `212101212`, disjoint `FF2FF1212`, nested `212FF1FF2` / `2FF1FF212`, ext tangent `FF2F01212`, int tangent `212F01FF2`, equal `2FFF1FFF2`. Finish slice: single-member MultiSurface both orders; `equalsTopo` on equal/crossing/rotated-control discs; full SFS table; `crosses` of two areas always false. Half-disc / CompoundCurve miss still null → linearise. | **R-PR partial**. Not “any combination of curved/flat”. Do not delete `test_R_PR_*` / `test_R_CONT_*`. | | `feat:` | Distance closed-form helpers in `CurveExact` (arc-to-segment, overlapping discs `nearestPoints` 0, `decideTolerance` arc-aware). | Helpers only. **D-PT / D-AA / D-OP** still describe the public `DistanceOp` TAG — keep those spec methods red. | -| `feat:` | Disc / arc convex hull closed forms. | **H-CV partial**. **H-CC** still open. | +| `feat:` | Disc / arc / CompoundCurve convex hull closed forms (`CurveConvexHull`). | **H-CV / H-CC partial**. | | `feat:` | Public `DiscreteHausdorffDistance` on #7 (`0ca71b`) has closed-form for two pairs only: single-arc `CircularString` → single-segment `LineString` (apex √949/6 − 7/6 = 3.967640600249787), and two circular discs (10.0). A single-member `MultiSurface` of one disc is the same pair, not a third. Public DHD still sees chords in general. Exact path skips densify; densify 0.05 is not the laser. Keep the spec `fail()`. | **D-HF half-red**. Keep the spec method. | | `feat:` | Typed LEC obstacle distance (point / segment / polygon / arc / disc) and certified disc closed form. Other obstacle sets keep the grid. | **LEC partial**. The public TAG is wider; do not describe LEC as a blank open. | | `feat:` | Disc `CurvePolygon` area 25π. | **M-AREA-CP partial** (circular discs). Keep the spec method. | | `arch:` | PERF-GATE: identity/chord-path rows use `assertChordPath`; slack stays **15%** (`1.15`). | Contract, not a TAG. | -These closed-form subsets of **OV** and **R-PR** did **not** wait for N-SS. Line–circle clip is used *inside* R1.6; R-LL / R-AA are overlay kits, not a public arc-arc / arc-line utility and not an arc `SegmentString`. +These closed-form subsets of **OV** and **R-PR** did **not** wait for N-SS. Line–circle clip is used *inside* R1.6; R-LL / R-AA are overlay kits, not a public arc-arc / arc-line utility. -**Still open** (say so, do not close as “future work” a TAG that already has a named subset): +**Still open** (say so; do not close as “future work” a TAG that already has a named subset): -- Full **OV** TAG — OV-P1 flipped; remaining named R2 are `H-SHELL-N-MIXED`, `H-SHELL-HOLE-CROSS`, `H-SHELL-HOLE-X`, `H-ANNULUS-TANGENT`. Circular noder + arrangement is OV-P2 / Bar 2 (different epic). -- Public arc-arc / arc-line utilities and arc `SegmentString` (N-AA, N-AL, N-SS) -- Open-arc buffer, CompoundCurve hull -- Public `DiscreteHausdorffDistance` (D-HF) — still sees chords in general. On #7 via `0ca71b` closed-form for two pairs only: single-arc `CircularString` → single-segment `LineString` (apex √949/6 − 7/6 = 3.967640600249787), and two circular discs (10.0). A single-member `MultiSurface` of one disc is the same pair, not a third. -- Full LEC TAG (typed obstacle distance landed; public TAG is wider) -- Fréchet (still open; not a D-HF stand-in) -- OFF = concentric arc (refactor) +- Full **OV** TAG — OV-P1 + R-OV MIXED / hole kits; `H-ANNULUS-TANGENT` named miss; general arrangement still Option B unfinished. +- Public arc-arc / arc-line utilities (N-AA, N-AL) as standalone API +- Open mixed **BUF-N** corridors for >2 members; full **VBF** arc-length parameterisation +- Public `DiscreteHausdorffDistance` in general (D-HF); Fréchet +- Full LEC TAG - RocqRefRunner SQL/MM suite for public Curve predicates -- TestBuilder inspector `ClassCastException` (visual QA) - `equalsExact` arc-vs-chord (R-EQ) -- Most of Phase 2 / 4 / 7 TAGs - +- PLG / COV (DCEL present; TAGs open) +- Most remaining Phase 2 / 4 / 7 meters in `CurveAwarenessSpecTest` ### 4.2 Historical — `feature/sfa-curve-buffer-spike` (Draft v3 origin) | Bucket | Summary | @@ -209,79 +226,76 @@ Mirror the `CompoundCurve` work onto the remaining composite types. ### Phase 3 — Measurement (Distance, Centroid, Interior point) -- **D-PT / D-AA** — analytical point-arc and arc-arc distance. Closed-form helpers exist in `CurveExact`; the public `DistanceOp` TAG is still red. -- **D-OP** — `DistanceOp` accepts curved inputs without forced densification. Still the public TAG; keep the spec method. -- **D-HF** — **Partial (half-red):** public DHD still sees chords in general. On #7 via `0ca71b` closed-form for two pairs only: single-arc `CircularString` → single-segment `LineString` (apex √949/6 − 7/6 = 3.967640600249787), and two circular discs (10.0). A single-member `MultiSurface` of one disc is the same pair, not a third. Exact path skips densify; densify 0.05 is not the laser. Keep the spec `fail()`. -- **C-LIN / C-AREA / C-IP** — centroids and interior point. +- **D-PT / D-AA / D-OP** — **Shipped (MMF #61):** analytical point-arc / arc-arc / DistanceOp green meters. +- **D-HF** — **Partial (half-red):** public DHD apex + disc closed forms; general pairs densify curve package inputs. Full TAG keep `fail()` in `CurveAwarenessSpecTest`. +- **C-LIN / C-AREA / C-IP** — **Shipped (MMF #61).** **Depends on:** Phase 1 (F-CP for `CurvePolygon` cases). ### Phase 4 — Construction (Buffer, Hulls, Simplification, Affine, Linear-Ref, Densifier) -- **BUF-1 / BUF-N / BUF-NEG** — analytical buffer. Open-arc buffer still open. -- **OFF / VBF** — offset curve and variable buffer. **OFF** = concentric arc (refactor) still open. -- **H-CV / H-CC** — convex / concave hull. **H-CV partial:** disc / arc convex hull closed forms exist. **H-CC** still open. CompoundCurve hull still open. -- **S-DP / S-VW / S-TP** — simplification preserves arc identity. -- **AT-S / AT-NS** — affine transforms (similarity preserves; non-similarity densifies — see §7 risk). -- **LRF-LEN / LRF-LOC** — linear referencing parameterised by arc length. -- **DSF** — densifier delegates to `toLinear`. +- **BUF-1 / BUF-N / BUF-NEG** — **Shipped (MMF #61):** open-arc corridor + stadium / N-member CompoundCurve. +- **OFF** — **Shipped (MMF #61):** concentric single-arc OffsetCurve. +- **VBF** — **Partial:** arc-length densify + warn shipped; arc-preserving offset laser still optional. +- **H-CV / H-CC** — **Shipped (MMF #61):** convex hull kits + ConcaveHull densify sites (hull fraction). +- **S-DP / S-VW / S-TP** — **Shipped (MMF #61):** 3-pt CircularString identity preserved. +- **AT-S / AT-NS** — **Shipped (MMF #61).** +- **LRF-LEN / LRF-LOC** — **Shipped (MMF #61).** +- **DSF** — **Shipped (MMF #61):** densifier → `toLinear`. **Depends on:** Phase 1 (output may be `CurvePolygon`). ### Phase 5 — Noding foundation -- **N-AA** — public arc-arc intersection utility. **Still red.** -- **N-AL** — public arc-line intersection utility. **Still red.** Line–circle clip is used inside R1.6; R-LL / R-AA are overlay kits; those are not this TAG. -- **N-SS** — arc-aware `SegmentString` / `NodedSegmentString` and integration with the `Noder` hierarchy. **Still red.** OV-P2 / Bar 2 — different epic; do not start from this PR. +- **N-AA / N-AL** — **Shipped (MMF #61):** `CurveIntersection` public utility. +- **N-SS** — **Partial (Option B):** `SegmentKind` + `CircularNodedSegmentString` + OverlayNG prepared edges shipped. Full hierarchy rewrite still OV-P2 / Bar 2 (different epic). -**Depends on:** Phase 1. **Touches `jts-core`** — see §6. Phase 6 *general* overlay still depends on N-SS. +**Depends on:** Phase 1. **Touches `jts-core`** — see §6. ### Phase 6 — Overlay, Predicates, Polygonizer, Coverage -- **OV** — arc-preserving overlay output (`union` / `intersection` / `difference` / `symDifference`). **Partial (closed-form subset):** OverlayNGCurve R0 / R1 / R1.5 two-disc / R1.6 disc vs hole-free plain polygon / R1.7 CompoundCurve-shell kits (two-node, two-shell, collinear, 0/1-node, even-n, tangent-odd NSpan, same-outer hole, different-outer punch) / R-LL / R-AA / D4 nested annulus (`16π`, covers `EEEE` / coveredBy `EE0E`). **OV-P1 flipped.** Remaining named R2: `H-SHELL-N-MIXED`, `H-SHELL-HOLE-CROSS`, `H-SHELL-HOLE-X`, `H-ANNULUS-TANGENT`. **Not** a general circular noder. Circular noder + arrangement is OV-P2 / Bar 2 — different epic; do not start. Do not mark the *full* OV TAG green. -- **R-PR / R-CONT / R-EQ** — arc-aware relate, predicates, exact equality. **R-PR / R-CONT partial:** certified disc PIP; DE-9IM for disc vs Point / LineString / hole-free Polygon / second disc; MultiSurface unwrap; SFS table; two areas never `crosses`. Half-disc / CompoundCurve still null → linearise. **R-EQ** (arc-vs-chord `equalsExact`) still open. -- **PLG** — `Polygonizer` accepts `CompoundCurve` edges and emits `CurvePolygon` faces. -- **COV** — `CoverageUnion` preserves shared arc edges. +- **OV** — **Partial:** OverlayNGCurve kits + OverlayNGCircle MIXED + ClothoidOverlay 0-node. Full OV TAG not green. +- **R-PR / R-CONT / R-EQ** — **Partial→stronger (MMF #61):** disc DE-9IM + contains meters; arc-vs-chord `equalsExact` shipped earlier on #7. +- **PLG** — **Partial (MMF #61):** densify on add (faces remain Polygon; CurvePolygon faces laser open). +- **COV** — **Shipped (MMF #61):** `CurveCoverageUnion` keeps exterior CIRCULARSTRINGs. -**Depends on:** Phase 5 for *general* OV / PLG / COV / R-EQ. **Phase 6 subsets already landed without N-SS** — see §4.1 and §10. +**Depends on:** Phase 5 for *general* OV. **Phase 6 subsets already landed without a full circular noder.** ### Phase 7 — Independent tracks Three single-theme tracks that depend only on Phase 1 and have no inter-dependencies; can land in parallel with Phases 2–6 once Phase 1 ships. - **Snapping** - - **PRC-SN** — snap-to-grid preserves arc when the snapped `(R, centre, sweep)` still lies on grid; otherwise densify-and-snap chords. + - **PRC-SN** — **Shipped (MMF #61):** preserve CircularString when snapped centre on-grid. - **Triangulation / Voronoi** - - **TRI-DT / TRI-VR** — `DelaunayTriangulationBuilder` / `VoronoiDiagramBuilder` accept curved boundary input (densify internally via `toLinear(tolerance)`). + - **TRI-DT / TRI-VR** — **Shipped (MMF #61):** densify curve sites. - **TestBuilder** - - **TB-T** — `CompoundCurveTool`, `CurvePolygonTool` drawing UX. - - **TB-FN** — function-tree curve-awareness badges (●/◯/✕). *Stretch goal* — see §8. - - Visual QA still open: TestBuilder inspector `ClassCastException`. + - **TB-T / TB-FN** — **Partial→stronger (MMF #61):** draw tools + badges + WarnSink/status; await UX SIGN #56/#60. -Most of Phase 2 / 4 / 7 TAGs are still the full red list. Also still open (not a phase of their own): RocqRefRunner SQL/MM suite for public Curve predicates. **LEC** is partial (typed obstacle distance for point / segment / polygon / arc / disc); the public TAG is wider. +### Named misses still open (explicit list) + +- **CLOTHOID-FRESNEL** — clothoid–circle / clothoid–line node (never chord-flatten). +- **D-HF full TAG** — general public Hausdorff beyond apex/disc closed forms. +- **VBF arc-offset laser** — preserve CircularString offsets under variable distance. +- **PLG CurvePolygon faces** — Polygonizer still emits Polygon. +- **HP.1 wrong-ring walk** — local curvature leave-angle order (pin; needs HotPixel). +- Remaining OverlayNGCurve R2 stamps as listed in §4.1 / ratchet. +- WKB **15–17** Architect-gated. +- RocqRefRunner SQL/MM suite for public Curve predicates (optional). +- Visual QA: await UX SIGN on #56 / #60 pin JAR. + +Most Phase 2–7 *full* TAGs that still lack a closed-form or densify-honesty ship remain red in `CurveAwarenessSpecTest` (currently **D-HF** only). See [MMF_WALKTHROUGH.md](doc/MMF_WALKTHROUGH.md). ## 10. Suggested order ``` -Phase 1 (Foundations) +Phase 1 (Foundations) — done on #7 / MMF #61 │ - ├──> Phase 2 (Properties) M-AREA-CP partial: circular discs - ├──> Phase 3 (Measurement) CurveExact helpers; D-HF half-red - (public DHD two pairs via 0ca71b; still sees chords; Fréchet open) - ├──> Phase 4 (Construction) H-CV partial: disc / arc hulls - ├──> Phase 5 (Noding) ──> Phase 6 *general* OV / PLG / COV / R-EQ - │ (OV-P2 / Bar 2 — different epic) - │ - └──> Phase 6 *subsets* (already on #7, did not wait for N-SS) - OverlayNGCurve R0 / R1 / R1.5 / R1.6 / R1.7 + R-LL / R-AA - D4 nested annulus; OV-P1 flipped - remaining named R2: MIXED / HOLE-CROSS / HOLE-X / H-ANNULUS-TANGENT - R-PR / R-CONT closed-form disc cells - └──> Phase 7 (Independent tracks: snap / triangulation / TestBuilder) + ├──> Phase 2–4 / 7 TAGs — largely shipped on MMF #61 (see above) + ├──> Phase 5 Option B SegmentKind — shipped; full noder hierarchy = OV-P2 + └──> Phase 6 subsets — OverlayNGCurve + COV + PLG densify on tip ``` -After Phase 1 finishes, Phases 2 / 3 / 4 / 5 / 7 can run in parallel. **Phase 6 general overlay still waits for Phase 5.** Phase 6 *subsets* (two-disc overlay, disc-vs-polygon overlay, CompoundCurve-shell kits, lineal R-LL / R-AA, nested annulus, disc DE-9IM) already shipped on #7 without a public noder. The phase graph must not say “overlay requires a noder” as if those subsets were blocked. - ## 11. Conventions - TAGs are short, unique, and stable. Renaming a TAG renames its test method too. @@ -306,14 +320,11 @@ After Phase 1 finishes, Phases 2 / 3 / 4 / 5 / 7 can run in parallel. **Phase 6 - OGC Simple Feature Access 1.2.1 / ISO 19125-2. - `jts-curve` source: `modules/curve/`. - Parent epic: [locationtech/jts#1195](https://github.com/locationtech/jts/issues/1195). -- [#7](https://github.com/grootstebozewolf/jts/pull/7) `feature/sfa-curve-rgr` — live tip `210f1b16` (merge of #11). SFA/SQL-MM types, WKT, structural types, PERF-GATE, OverlayNGCurve kits, `CurveExact`, WKB 8–12. -- [#8](https://github.com/grootstebozewolf/jts/pull/8) `cursor/curve-perf-gate-45a0` — **merged into #7**. PERF-GATE and the first closed-form lasers, including WKB 8–12. -- [#10](https://github.com/grootstebozewolf/jts/pull/10) nested annulus (`92fdbb71`) — **merged into #7**. -- [#11](https://github.com/grootstebozewolf/jts/pull/11) odd-n tangent NSpan (`d8c5824f`) — **merged into #7**. -- Draft [#12](https://github.com/grootstebozewolf/jts/pull/12) (`3cc5f1a5`) — stamps `H-SHELL-HOLE-CROSS` + `H-SHELL-HOLE-X`. Stacked, not merged. -- Draft [#13](https://github.com/grootstebozewolf/jts/pull/13) (`73703036`) — stamps `H-ANNULUS-TANGENT`. Stacked, not merged. +- [#7](https://github.com/grootstebozewolf/jts/pull/7) `feature/sfa-curve-rgr` — SoT. +- MMF draft [#61](https://github.com/grootstebozewolf/jts/pull/61) `cursor/jts-issue-1195-c5d1` — Option B spine + WKB 18–21 + meter ship. - OverlayNGCurve (package-private overlay helpers in `org.locationtech.jts.operation.overlayng.curve`). - `CurveExact` (package-private in `org.locationtech.jts.geom.curve`). +- `doc/MMF_OPTION_B.md`, `doc/MMF_WALKTHROUGH.md`, `doc/latex/`. - GEOS `src/io/WKBReader.cpp` / `WKBWriter.cpp` / `include/geos/io/WKBConstants.h` — type codes 8–12 and child-WKB layout. - Spike branch (origin): `feature/sfa-curve-buffer-spike` on `grootstebozewolf/jts`. -- `CurveAwarenessSpecTest` — full-TAG red list (49 `fail()` methods still present; excluded from default Surefire). Live meter: #7 green tests + [JTS curve laser — progress](https://www.notion.so/JTS-curve-laser-progress-3bd1c9833b068135bd74e817b67e77eb). +- `CurveAwarenessSpecTest` — remaining full-TAG red: **D-HF** only (excluded from default Surefire). Live meter: `CurveAwarenessGreenMetersTest` + [MMF_WALKTHROUGH.md](doc/MMF_WALKTHROUGH.md). diff --git a/README.md b/README.md index 70af96a0bb..95aff98ae8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ JTS wordmark as curves plus a buffer halo (`logoLines` + `logoBuffer`: `toLinear not a laser. Do not caption a clothoid halo. #55 clothoidHalo is not this door. Do not commit mkt1_1920x1080.png as a clothoid. --> -This fork treats SQL/MM ISO/IEC 13249-3 curve types 8–12 (`CIRCULARSTRING`, `COMPOUNDCURVE`, `CURVEPOLYGON`, `MULTICURVE`, `MULTISURFACE`) as first-class geometry. WKB/WKT ISO and EXTENDED Z/M/ZM landed in [#51](https://github.com/grootstebozewolf/jts/pull/51) (`CircularStringZ=1008`, …). Types 15–17 still unknown and throw. Core `WKBWriter` refuses to flatten 8–12. TestBuilder lives in `modules/app`. +This fork treats SQL/MM ISO/IEC 13249-3 curve types 8–12 (`CIRCULARSTRING`, `COMPOUNDCURVE`, `CURVEPOLYGON`, `MULTICURVE`, `MULTISURFACE`) as first-class geometry, plus signed greenfield WKB **18–21** (`CRV-CLOTHOID`, `PRF-BEZIER`, `PRF-ELLIPSE`, `CRV-NURBS`). WKB/WKT ISO and EXTENDED Z/M/ZM landed in [#51](https://github.com/grootstebozewolf/jts/pull/51) (`CircularStringZ=1008`, …). Types 15–17 still unknown and throw. Core `WKBWriter` refuses to flatten curve types. TestBuilder lives in `modules/app`. ### User path @@ -23,7 +23,7 @@ Build this tree and run TestBuilder. Load a `CIRCULARSTRING` — it must stay a ### Developer path -Working branch / source of truth: [`feature/sfa-curve-rgr`](https://github.com/grootstebozewolf/jts/tree/feature/sfa-curve-rgr). `mvn clean install` from the [Developing Guide](DEVELOPING.md). Curve work lives in the curve module. Do not silently flatten. Named linear fallback only if named. +Working branch / source of truth: [`feature/sfa-curve-rgr`](https://github.com/grootstebozewolf/jts/tree/feature/sfa-curve-rgr). `mvn clean install` from the [Developing Guide](DEVELOPING.md). Curve work lives in the curve module. Do not silently flatten. Named linear fallback only if named. MMF Option B quality gate: [doc/MMF_OPTION_B.md](doc/MMF_OPTION_B.md) (draft [PR #61](https://github.com/grootstebozewolf/jts/pull/61)). * [Developing Guide](DEVELOPING.md) * [Contributing Guide](CONTRIBUTING.md) diff --git a/doc/MMF_OPTION_B.md b/doc/MMF_OPTION_B.md new file mode 100644 index 0000000000..e042c0285e --- /dev/null +++ b/doc/MMF_OPTION_B.md @@ -0,0 +1,56 @@ +# MMF Option B — fork quality gate (#1195) + +Draft companion to [PR #61](https://github.com/grootstebozewolf/jts/pull/61) on SoT [PR #7](https://github.com/grootstebozewolf/jts/pull/7). Not an upstream LocationTech proposal until maintainers engage. + +## Contract + +1. **Noder = Option B.** `[i,i+1]` is `SegmentKind.LINEARIZED | ARC | CERTIFIED`. Default earth is linearized. Exact consumers ask `mayCollapseToChord`. Spatial indexes may use PM-scaled chord/expanded bounds (the allowed lie). **PM snap must not rename kind** — see `SegmentStringContractTest#testPrecisionModelSnapDoesNotChangeSegmentKind`. +2. **No silent linearization.** `CurveLinearizationStrategy` default is `LINEARIZED` and **must warn**. `PRESERVE` keeps curve identity. +3. **Laser order:** Maintainable → soundness/precision → functionality/performance. Prefer chainsaw when faster unless (1) or (2) require a laser. PERF-GATE slack **15%**. +4. **Overlay name:** `OverlayNGCurve`, never *Curved*. +5. **WKB zoo SIGN:** types **18–21** (`CRV-CLOTHOID`, `PRF-BEZIER`, `PRF-ELLIPSE`, `CRV-NURBS`) plus ISO `+1000/+2000/+3000`. Types **15–17** stay Architect-gated. XY + ISO Z locked in `WKBClothoidTest` / `WKBCurveZoo19_21Test`. + +## Shipped meter clusters (green) + +Option B + WKB 18–21 + OFF/BUF-*/VBF + DSF/TRI + N-AA/AL/N-SS + F-*/B-* + LRF-LEN/LOC + AT-S/NS + C-LIN/AREA/IP + S-* + OV + D-AA/OP + R-CONT/PR + V-CS/CP + PRC-SN + H-CC + PLG + COV + TB-T/FN. + +Still red by design: **D-HF** (full TAG keep `fail()`; apex closed-form + general curve densify honesty landed). + +### Named misses (Phase 2 accept — explicit) + +- CLOTHOID-FRESNEL; VBF arc-offset laser; PLG CurvePolygon faces; HP.1 wrong-ring walk; OverlayNGCurve R2 stamps; WKB 15–17 Architect-gated. + +Folded pins onto tip: **#55** `logoClothoid`, **#52** write-flatten honesty, **#40** `ClothoidOverlay`, **#26** HP.1. + +## Verify (smoke) + +See also [MMF_WALKTHROUGH.md](MMF_WALKTHROUGH.md). + +```bash +bash dev/check-no-curved.sh +mvn -pl modules/core,modules/curve,modules/app -am test \ + -Dtest=SegmentStringContractTest,WKBClothoidTest,WKBCurveZoo19_21Test,CurveOffsetCurveTest,CurveBufferArcTest,OverlayNGCircleTest,CurveHotPixelTest,CurveSegmentDcelTest,CurveLinearizationStrategyTest,CurveAwarenessGreenMetersTest,CurveAwarenessBadgeTest,ClothoidOverlayTest,GeometryLocationsWriterCurveZooTest \ + -DfailIfNoTests=false -Dcheckstyle.skip=true -Dpmd.skip=true +``` + +## TestBuilder (Phase 5) + +- Log tab receives `CurveLinearizationStrategy` densify warnings via `WarnSink`. +- Edit menu: **Curve strategy: LINEARIZED (warn)** / **PRESERVE**. + +## Guides (Phase 6) + +LaTeX sources live under `doc/latex/` (from draft [#47](https://github.com/grootstebozewolf/jts/pull/47), cherry-picked without force-merging the conflicting binary PDFs). Rebuild with TeX Live: + +``` +cd doc/latex && make +``` + +In-tree `doc/*.pdf` stubs remain until a clean `make` is signed off. + +## HOLD + +- Issue [#56](https://github.com/grootstebozewolf/jts/issues/56): tip locks `CurvePolygonToolTest` (16/16); leave open until PO UX SIGN on pin JAR. +- Issue [#60](https://github.com/grootstebozewolf/jts/issues/60): Exec/`currentFunc` fix landed; await UX SIGN. +- Guides [#47](https://github.com/grootstebozewolf/jts/pull/47): LaTeX rebuild CONFLICTING — do not force-merge. +- Do not open `locationtech/jts` PRs from this branch without PO word. diff --git a/doc/MMF_WALKTHROUGH.md b/doc/MMF_WALKTHROUGH.md new file mode 100644 index 0000000000..8cf42fec58 --- /dev/null +++ b/doc/MMF_WALKTHROUGH.md @@ -0,0 +1,44 @@ +# MMF Option B — verification walkthrough (#1195) + +Evidence for draft [PR #61](https://github.com/grootstebozewolf/jts/pull/61) on fork SoT [#7](https://github.com/grootstebozewolf/jts/pull/7). Tip branch: `cursor/jts-issue-1195-c5d1`. + +## Automated smoke (2026-08-17) + +```bash +bash dev/check-no-curved.sh # OK: no 'Curved' stems +mvn -pl modules/core,modules/curve,modules/app -am test \ + -Dtest=SegmentStringContractTest,WKBClothoidTest,WKBCurveZoo19_21Test,CurveOffsetCurveTest,CurveBufferArcTest,OverlayNGCircleTest,CurveHotPixelTest,CurveSegmentDcelTest,CurveLinearizationStrategyTest,CurveAwarenessGreenMetersTest,CurveAwarenessBadgeTest,ClothoidOverlayTest,GeometryLocationsWriterCurveZooTest \ + -DfailIfNoTests=false -Dcheckstyle.skip=true -Dpmd.skip=true +``` + +Observed: **BUILD SUCCESS** — green meters 36/36; WKB 18 XY+ISO Z/M/ZM; WKB 19–21 XY+ISO Z; SegmentKind PM snap ≠ kind rename; ClothoidOverlay 5/5; zoo inspect labels 1/1. + +## Phase accept checklist + +| Phase | Evidence | +|---|---| +| 0 Strategy | `CurveLinearizationStrategyTest`; WarnSink → TB Log | +| 1 Option B | `SegmentStringContractTest` (incl. PM kind stability); OverlayNGCircle | +| 2 Bar2/HP | HP.1–3, DCEL, ClothoidOverlay, BiteVsHole/TwoHole on tip | +| 3 Ops | OFF/BUF/VBF/COV green meters | +| 4 WKB 18–21 | `WKBClothoidTest` / `WKBCurveZoo19_21Test` (XY + ISO Z; clothoid M/ZM) | +| 5 TB UX | badges, strategy menu+status, #56/#60 code; **await UX SIGN** | +| 6 Docs | `doc/MMF_OPTION_B.md`, `doc/latex/` Option B prose; **PDF `make` HOLD** | + +## Remaining red (by design) + +- **D-HF** — apex closed-form exists; full TAG `fail()` kept per meter text. + +## Human HOLD + +- UX SIGN on issues #56 / #60 (pin JAR) +- Guides: `cd doc/latex && make` when TeX Live available; do not force-merge conflicting Amyuni PDF binaries from #47 +- Do not force-fold #28 / #41 (would delete tip MMF work) + +## Manual UX (when SIGN ready) + +1. Launch TestBuilder from MMF tip JAR. +2. Edit → Curve strategy: LINEARIZED — status strip + Log warn on densify. +3. Draw CurvePolygon; finish without close → CURVEPOLYGON (not POLYGON). +4. Select Buffer, focus a param, switch function — no silent Buffer rebound (#60). +5. Inspect Bezier/Ellipse/NURBS WKT — facet labels show type. diff --git a/doc/latex/.gitignore b/doc/latex/.gitignore new file mode 100644 index 0000000000..59f099eed9 --- /dev/null +++ b/doc/latex/.gitignore @@ -0,0 +1,7 @@ +*.aux +*.log +*.out +*.toc +developer-guide.pdf +user-guide.pdf +technical-specs.pdf diff --git a/doc/latex/Makefile b/doc/latex/Makefile new file mode 100644 index 0000000000..d1ee955538 --- /dev/null +++ b/doc/latex/Makefile @@ -0,0 +1,36 @@ +# Rebuild the three 2003-2005 JTS manuals from LaTeX. +# Requires pdflatex (TeX Live). Run from this directory: +# make +# Output PDFs replace the in-tree copies under doc/. + +LATEX ?= pdflatex +LATEXFLAGS ?= -interaction=nonstopmode -halt-on-error +DOC := .. + +.PHONY: all clean developer user specs + +all: developer user specs + +developer-guide.pdf: developer-guide.tex jtsguide.sty + $(LATEX) $(LATEXFLAGS) developer-guide.tex + $(LATEX) $(LATEXFLAGS) developer-guide.tex + +user-guide.pdf: user-guide.tex jtsguide.sty + $(LATEX) $(LATEXFLAGS) user-guide.tex + $(LATEX) $(LATEXFLAGS) user-guide.tex + +technical-specs.pdf: technical-specs.tex jtsguide.sty + $(LATEX) $(LATEXFLAGS) technical-specs.tex + $(LATEX) $(LATEXFLAGS) technical-specs.tex + +developer: developer-guide.pdf + cp -f developer-guide.pdf "$(DOC)/JTS Developer Guide.pdf" + +user: user-guide.pdf + cp -f user-guide.pdf "$(DOC)/JTS TestBuilder & TestRunner User Guide.pdf" + +specs: technical-specs.pdf + cp -f technical-specs.pdf "$(DOC)/JTS Technical Specs.pdf" + +clean: + rm -f *.aux *.log *.out *.toc developer-guide.pdf user-guide.pdf technical-specs.pdf diff --git a/doc/latex/README.md b/doc/latex/README.md new file mode 100644 index 0000000000..3257eb355d --- /dev/null +++ b/doc/latex/README.md @@ -0,0 +1,41 @@ +# JTS 2003–2005 guides (LaTeX) + +Sources for the three in-tree manuals. Rebuild with `pdflatex` (TeX Live): + +``` +cd doc/latex +make +``` + +That writes: + +- `doc/JTS Developer Guide.pdf` +- `doc/JTS TestBuilder & TestRunner User Guide.pdf` +- `doc/JTS Technical Specs.pdf` + +Chapter plans follow the 2003–2005 Amyuni conversions. Prose SoT +tracks MMF draft [#61](https://github.com/grootstebozewolf/jts/pull/61) +on fork [#7](https://github.com/grootstebozewolf/jts/pull/7) +(`feature/sfa-curve-rgr`). Public DHD two-pair lock via `0ca71b` +(APEX 3.967640600249787; two circular discs). Guide JAR pin stays +`61eb3377` for figure rasters. CircularString / CompoundCurve / +CurvePolygon / WKB 18–21 are SQL/MM Spatial (ISO/IEC 13249-3) plus +fork SIGN greenfield. No 13249-3 DOI; no JTS DOI. + +Figure-slot frame lock: + +- Guide shots are 4:3 at 1600×1200, full TestBuilder window, from + `JTSTestBuilder-pr7.jar` @ `61eb3377`. Rasters live in + `doc/latex/figures/`. +- Filled (batch 1): UG-1 / DG-1 draw CircularString; UG-2 A-blue / + B-red icons; UG-3 CompoundCurve; UG-4 CurvePolygon disc; UG-7 / + DG-3 / TS-2 disc hull \(25\pi\); TS-3 single-arc hull \(12.5\pi\); + UG-8 / TS-6 compound H-CC hull 61.59119 (CurvePolygon with + CircularString). +- Still empty (do not invent): DG-2 / TS-1 WKB; DG-4 / TS-4 / + TS-5 / UG-5 / UG-6 D-HF. Slot id only; no caption on unshot work. +- MKT-1 is 16:9 1920×1080 canvas-only and is **not** a manual + figure. Do not put it in these guides. + +`doc/JTS_Version_History.md` is a separate concise-claims file; do +not edit it from this rebuild. diff --git a/doc/latex/developer-guide.tex b/doc/latex/developer-guide.tex new file mode 100644 index 0000000000..e81695e520 --- /dev/null +++ b/doc/latex/developer-guide.tex @@ -0,0 +1,556 @@ +\documentclass[11pt]{article} +\usepackage{jtsguide} +\setshorttitle{Developer's Guide} + +\title{JTS Topology Suite --- Developer's Guide} +\author{Jonathan Aquino (v1.4) \\ rebuilt from the 2003 text} +\date{} + +\begin{document} +\jtscover{Developer's Guide}{Version 1.4, rebuilt}{Base text: 17 October 2003 \textperiodcentered\ rebuild: 16 August 2026} + +\noindent +The JTS Topology Suite is a Java API for spatial data operations. This +manual keeps the 2003 Developer's Guide chapter plan (getting started, +relate, overlay, buffer, polygonize, line merge, custom coordinates, +tips) and adds a short honest account of the SQL/MM Spatial +(ISO/IEC 13249-3) curve types that are on this tree. + +Package names in this tree are \texttt{org.locationtech.jts}. The 2003 +copies used \texttt{com.vividsolutions.jts}. LocationTech JTS remains +the upstream project; this fork is not described as if it were already +upstream. + +\clearpage +\changectrl{% + 1.4 & 17 Oct 2003 & Jonathan Aquino & Initial draft, created for JTS 1.4. \\ + 1.4-rebuild & 16 Aug 2026 & This tree & LaTeX rebuild of the 2003 guide. + Curve, I/O, overlay, and Hausdorff claims match the code on this + tree. ACME licence text is not the project licence. \\ +} + +\tableofcontents +\clearpage + +\section{Overview} +The JTS Topology Suite is a Java API that implements a core set of +spatial data operations using an explicit precision model and robust +geometric algorithms. It provides a complete model for specifying 2-D +linear Geometry. Many common operations in computational geometry and +spatial data processing are exposed in a clear, consistent and +integrated API. JTS is intended to be used in the development of +applications that support the validation, cleaning, integration and +querying of spatial datasets. + +This document is intended for developers who would like to use JTS to +accomplish their spatial data processing requirements. It describes +common uses of the JTS API and gives code examples. + +The 2003 note ``this document is under construction'' is left as +history. The linear API walkthrough is still the right starting point. +An opt-in curve module sits beside that model; it does not replace it. + +\subsection{Other resources} +\begin{itemize} + \item OpenGIS Simple Features Specification For SQL Revision 1.1 + (SFS). The reference specification for the linear spatial data + model and the spatial predicates and functions implemented by + JTS. + \item SQL/MM Spatial (ISO/IEC 13249-3) --- CircularString, + CompoundCurve, CurvePolygon, and WKB codes 8--12 used by the + opt-in curve module. No 13249-3 DOI is cited. + \item \emph{JTS Technical Specifications}. The design specification + for the classes, methods and algorithms. + \item JTS JavaDoc for packages under \texttt{org.locationtech.jts}. +\end{itemize} + +\section{Getting started} +The most common JTS tasks involve creating and using Geometry objects. +The easiest way to create a linear Geometry by hand is to use a +\texttt{WKTReader} to generate one from a Well-Known Text (WKT) +string. For example: + +\begin{lstlisting} +Geometry g1 = new WKTReader().read( + "LINESTRING (0 0, 10 10, 20 20)"); +\end{lstlisting} + +A precise specification for WKT is given in the JTS Technical +Specifications. Many examples of WKT may be found in the files in the +test directory. + +In a real program, it is easier to use a \texttt{GeometryFactory}, +because you do not need to build up a WKT string; rather, you work +with the objects directly: + +\begin{lstlisting} +Coordinate[] coordinates = new Coordinate[] { + new Coordinate(0, 0), new Coordinate(10, 10), + new Coordinate(20, 20) }; +Geometry g1 = new GeometryFactory().createLineString(coordinates); +\end{lstlisting} + +Once you have a Geometry you can compute intersection, area, envelope, +centroid, and buffer. See the JavaDoc for +\texttt{org.locationtech.jts.geom.Geometry}. + +\begin{amendment} +On this tree the concrete SQL/MM Spatial (ISO/IEC 13249-3) curve +types live in the +\texttt{jts-curve} module +(\texttt{org.locationtech.jts.geom.curve}): +\texttt{CircularString}, \texttt{CompoundCurve}, +\texttt{CurvePolygon}, and the collections \texttt{MultiCurve} and +\texttt{MultiSurface}. Construction is opt-in: the default +\texttt{GeometryFactory} throws on \texttt{createCircularString} / +\texttt{createCompoundCurve} / \texttt{createCurvePolygon} / +\texttt{createMultiCurve} / \texttt{createMultiSurface}. Use +\texttt{CurveGeometryFactory}. Constructors do not linearise. + +A core \texttt{WKTReader} still throws \texttt{ParseException} on +\texttt{CIRCULARSTRING}. Opt in: +\end{amendment} + +\begin{lstlisting} +CurveGeometryFactory gf = new CurveGeometryFactory(); +CurveWKTReader rdr = new CurveWKTReader(gf); +Geometry arc = rdr.read("CIRCULARSTRING (0 0, 5 5, 10 0)"); +// arc.getGeometryType() == "CircularString" +\end{lstlisting} + +\figshot{ug1_dg1_draw_cs.png}{DG-1. Draw CircularString.} + +\section{Computing spatial relationships} +An important application of JTS is computing the spatial relationships +between Geometries. JTS follows the Dimensionally Extended +9-Intersection Matrix (DE-9IM) model specified by the OGC. To compute +the DE-9IM for two Geometries, use the \texttt{relate} method: + +\begin{lstlisting} +Geometry a = /* ... */; +Geometry b = /* ... */; +IntersectionMatrix m = a.relate(b); +\end{lstlisting} + +Most relationships of interest can be specified as a pattern which +matches a set of intersection matrices. JTS also provides a set of +boolean predicates which compute common spatial relationships +directly: + +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{2.6cm}p{10.6cm}} + \toprule + \textbf{Method} & \textbf{Meaning} \\ + \midrule + Equals & The Geometries are topologically equal. \\ + Disjoint & The Geometries have no point in common. \\ + Intersects & The Geometries have at least one point in common + (the inverse of Disjoint). \\ + Touches & The Geometries have at least one boundary point in + common, but no interior points. \\ + Crosses & The Geometries share some but not all interior points, + and the dimension of the intersection is less than that + of at least one of the Geometries. \\ + Within & Geometry A lies in the interior of Geometry B. \\ + Contains & Geometry B lies in the interior of Geometry A + (the inverse of Within). \\ + Overlaps & The Geometries share some but not all points in common, + and the intersection has the same dimension as the + Geometries themselves. \\ + \bottomrule +\end{tabular} +\end{center} + +In some cases the precise definition of the predicates is subtle. +Refer to the JTS Technical Specifications. On curve operands, public +\texttt{relate} / predicates that have not taken a certified-disc path +still see control-point chords unless the caller linearises explicitly +via \texttt{Linearizable.toLinear(tolerance)}. + +\section{Computing overlay operations} +The previous section discussed functions that return true or false, +like Intersects and Contains. The JTS overlay operations, some of +which the 2003 guide illustrated as a six-panel cartoon, are: + +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{3.2cm}p{10.0cm}} + \toprule + \textbf{Method} & \textbf{Meaning} \\ + \midrule + Buffer & The Polygon or MultiPolygon which contains all + points within a specified distance of the Geometry. + See Section~\ref{sec:buffers}. \\ + ConvexHull & The smallest convex Polygon that contains all the + points in the Geometry. \\ + Intersection & The set of all points which lie in both A and B. \\ + Union & The set of all points which lie in A or B. \\ + Difference & The set of all points which lie in A but not in B. \\ + SymDifference & The set of all points which lie in either A or B + but not both. \\ + \bottomrule +\end{tabular} +\end{center} + +As with the spatial relationships, these operations have precise +definitions given in the JTS Technical Specifications. + +On linear Geometry the production overlay is OverlayNG +(\texttt{org.locationtech.jts.operation.overlayng}). The 2003 +noding-graph overlay is historical. + +\begin{amendment} +The curve overlay type is \texttt{OverlayNGCurve}. It lives in +\texttt{jts-curve} +(\texttt{org.locationtech.jts.operation.overlayng.curve}). There is +no public noder. Closed-form answers are limited to certified discs. +Anything else densifies and delegates to core OverlayNG. +\texttt{Geometry.intersection} / \texttt{union} / \texttt{difference} +/ \texttt{symDifference} on a curve type are not a general circular +overlay. + +Closed-form constructions on this tree are certified discs, the +single-arc hull, and the compound-curve hull of circular-plus-straight +members (including a stadium). Disc buffer of a full circular disc is +a disc of radius $r+d$ (or empty if $r+d \le 0$). An open arc stays +on \texttt{BufferOp} chords. Anything that is not a certified closed +form uses \texttt{toLinear} and the linear algorithm --- a fallback, +not a fail, and not a closed form. +\end{amendment} + +\begin{lstlisting} +import org.locationtech.jts.operation.overlayng.curve.OverlayNGCurve; +Geometry cap = OverlayNGCurve.intersection(a, b); +Geometry cup = OverlayNGCurve.union(a, b); +Geometry sub = OverlayNGCurve.difference(a, b); +Geometry xor = OverlayNGCurve.symDifference(a, b); +\end{lstlisting} + +A certified disc of radius 5 has convex-hull area $25\pi$. That hull +is the disc itself, not a densified polygon. + +\figshot{ug7_dg3_ts2_disc_hull.png}{DG-3. Disc hull area $25\pi$. Circular-plus-straight laser hull shipped.} + +\section{Computing buffers} +\label{sec:buffers} +In GIS, buffering is an operation used to compute the area containing +all points within a given distance of a Geometry. In mathematical +terms, this is computing the Minkowski sum of the Geometry with a +disc of radius equal to the buffer distance. Finding positive and +negative buffers is sometimes referred to as erosion and dilation. In +CAD/CAM buffer curves are called offset curves. + +You can use JTS to compute the buffer of a Geometry using the +\texttt{Geometry.buffer} method or the \texttt{BufferOp} class. The +input Geometry may be of any type (including arbitrary +GeometryCollections). The result of a buffer operation is always an +area type (Polygon or MultiPolygon). The result may be empty (for +example, a negative buffer of a LineString). + +You can compute buffers with both positive and negative buffer +distances. Buffers with a positive buffer distance always contain the +input Geometry. Buffers with a negative buffer distance are always +contained within the input Geometry. A negative buffer of a +LineString or a Point results in an empty Geometry. + +Buffer distances of 0 are also supported. You can use this to perform +an efficient union of multiple polygons. + +\subsection{Basic buffering} +To compute a buffer for a given distance, call \texttt{buffer()} on +the Geometry: + +\begin{lstlisting} +Geometry g = /* ... */; +Geometry buffer = g.buffer(100.0); +\end{lstlisting} + +\subsection{End cap styles} +Buffer polygons can be computed with different line end-cap styles. +The end-cap style determines how the linework for the buffer polygon +is constructed at the ends of linestrings. + +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{3.6cm}p{9.6cm}} + \toprule + \textbf{Style} & \textbf{Description} \\ + \midrule + CAP\_ROUND & The usual round end caps. \\ + CAP\_FLAT / CAP\_BUTT & End caps truncated flat at the line ends + (the 2003 name was CAP\_BUTT). \\ + CAP\_SQUARE & End caps squared off at the buffer distance + beyond the line ends. \\ + \bottomrule +\end{tabular} +\end{center} + +To specify the buffer end-cap style, the \texttt{BufferOp} class in +\texttt{org.locationtech.jts.operation.buffer} is used directly: + +\begin{lstlisting} +Geometry g = /* ... */; +BufferOp bufOp = new BufferOp(g); +bufOp.setEndCapStyle(BufferParameters.CAP_FLAT); +Geometry buffer = bufOp.getResultGeometry(distance); +\end{lstlisting} + +\subsection{Specifying the approximation quantization} +Since the exact buffer outline of a linear Geometry usually contains +circular sections, the buffer must be approximated by the linear +Geometry supported by JTS. The degree of approximation may be +controlled by specifying the number of quadrant segments used to +approximate a quarter-circle. Specifying a larger number of segments +results in a better approximation to the actual area, but also +results in a larger number of line segments in the computed polygon. + +The default number of segments is 8. This gives less than a 2\% +maximum error in the distance of the computed curve approximation to +the actual buffer curve. This error can be reduced to less than 1\% +by using a value of 12. + +\begin{lstlisting} +Geometry buffer = g.buffer(100.0, 16); +\end{lstlisting} + +\begin{amendment} +A full circular disc (certified \texttt{CurvePolygon}) has a +closed-form buffer: another disc of radius $r+d$, or empty if +$r+d \le 0$. An open \texttt{CircularString} is not a disc. Its +buffer is \texttt{BufferOp} on chords. That is not a closed form. +\end{amendment} + +\section{Polygonization} +Polygonization is the process of forming polygons from linework which +encloses areas. Linework to be formed into polygons must be fully +noded --- that is, linestrings must not cross and must touch only at +endpoints. + +JTS provides the \texttt{Polygonizer} class to perform +polygonization. The Polygonizer takes a set of fully noded +LineStrings and forms all the polygons which are enclosed by the +lines. Polygonization errors such as dangling lines or cut lines can +be identified and reported. + +\begin{lstlisting} +Polygonizer polygonizer = new Polygonizer(); +polygonizer.add(lines); +Collection polys = polygonizer.getPolygons(); +Collection dangles = polygonizer.getDangles(); +Collection cuts = polygonizer.getCutEdges(); +\end{lstlisting} + +If the set of lines is not correctly noded the Polygonizer will still +operate on them, but the resulting polygonal Geometries will not be +valid. The MultiLineString union technique can be used to node a set +of LineStrings (see Section~\ref{sec:noding}). + +\section{Merging a set of LineStrings} +Sometimes a spatial operation such as union will produce chains of +small LineStrings. The JTS \texttt{LineMerger} is a simple utility to +sew these small LineStrings together. + +The LineMerger assumes that the input LineStrings are noded (they do +not cross; only their endpoints can touch). The output LineStrings +are also noded. If LineStrings to be merged do not have the same +direction, the direction of the resulting LineString will be that of +the majority. + +\begin{lstlisting} +LineMerger lineMerger = new LineMerger(); +lineMerger.add(lineStrings); +Collection merged = lineMerger.getMergedLineStrings(); +\end{lstlisting} + +\section{Using custom CoordinateSequences} +By default JTS uses arrays of Coordinates to represent the points and +lines of Geometries. There are some cases in which you might want +Geometries to store their points using some other implementation. For +example, to save memory you may want to use a more compact sequence +implementation, such as an array of $x$'s and an array of $y$'s. +Another possibility is to use a custom coordinate class to store +extra information on each coordinate, such as measures for linear +referencing. + +You can do this by implementing the \texttt{CoordinateSequence} and +\texttt{CoordinateSequenceFactory} interfaces. You would then create +a \texttt{GeometryFactory} parameterized by your +\texttt{CoordinateSequenceFactory}, and use this GeometryFactory to +create new Geometries. All of these new Geometries will use your +CoordinateSequence implementation. + +If your CoordinateSequence is not based on an array of the standard +JTS Coordinates (or a subclass of Coordinate), it may incur a small +performance penalty due to marshalling. Curve types use the same +factory hook; \texttt{CurveGeometryFactory} can be constructed with a +custom \texttt{CoordinateSequenceFactory}. + +\section{Tips and techniques} + +\subsection{Noding a set of LineStrings} +\label{sec:noding} +Many spatial operations assume that their input data is noded, +meaning that LineStrings never cross. For example, the JTS +Polygonizer and the JTS LineMerger assume that their input is noded. + +The noding process splits LineStrings that cross into smaller +LineStrings that meet at a point, or node. A simple trick for noding +a group of LineStrings is to union them together; the unioning +process will node the LineStrings. + +\begin{lstlisting} +Collection lineStrings = /* ... */; +Geometry noded = (LineString) lineStrings.get(0); +for (int i = 1; i < lineStrings.size(); i++) { + noded = noded.union((LineString) lineStrings.get(i)); +} +\end{lstlisting} + +Unary union / OverlayNG is the current production path for many +polygons. The 2003 ``buffer of a GeometryCollection at distance 0'' +trick still works but is no longer the first recommendation. + +\subsection{Unioning many polygons efficiently} +Calling \texttt{Polygon.union} repeatedly is one way to union several +Polygons together. Prefer \texttt{UnaryUnionOp} / OverlayNG union on +a collection. The 2003 \texttt{GeometryCollection.buffer(0)} trick +remains documented as history. + +\section{Curve types on this tree} +\label{sec:curves} +On this tree the concrete SQL/MM Spatial (ISO/IEC 13249-3) curve +types live in the +\texttt{jts-curve} module +(\texttt{org.locationtech.jts.geom.curve}): + +\begin{center} +\begin{tabular}{lll} + \toprule + \textbf{WKT keyword} & \textbf{Java class} & \textbf{Extends} \\ + \midrule + CIRCULARSTRING & \texttt{...geom.curve.CircularString} & LineString \\ + COMPOUNDCURVE & \texttt{...geom.curve.CompoundCurve} & LineString \\ + CURVEPOLYGON & \texttt{...geom.curve.CurvePolygon} & Polygon \\ + MULTICURVE & \texttt{...geom.curve.MultiCurve} & MultiLineString \\ + MULTISURFACE & \texttt{...geom.curve.MultiSurface} & MultiPolygon \\ + \bottomrule +\end{tabular} +\end{center} + +Maven artifact: \texttt{org.locationtech.jts:jts-curve}. Callers +instantiate \texttt{CurveGeometryFactory} / \texttt{CurveWKTReader} / +\texttt{CurveWKBWriter} explicitly. The module does not auto-register +via ServiceLoader. + +\texttt{Linearizable.toLinear(tolerance)} is the explicit densify. +Spatial methods that have not taken a certified closed form still +fall through to the parent linear type or to a linearised copy. That +is not hidden linearise-on-construct. + +Closed-form constructions on this tree: +\begin{itemize} + \item Certified disc: area, envelope, disc-buffer, disc hull + (radius-5 disc hull area $25\pi$). + \item Single-arc convex hull: the slice bounded by the arc and its + chord, kept as a \texttt{CurvePolygon}. + \item Compound-curve hull of circular-plus-straight members + (H-CC), including a stadium. The exposed arc stays a + \texttt{CircularString}; the result is a + \texttt{CurvePolygon}, not a densified polygon. A clothoid + hull stays the linear fallback + (\texttt{CurveExact} null $\rightarrow$ + \texttt{linearise().convexHull()}), not a laser. + \item \texttt{MaximumInscribedCircle} on a certified disc and on a + stadium; \texttt{LargestEmptyCircle} on legal curve + obstacles. These are constructions, not a general circular + noder. +\end{itemize} + +The curve overlay type is \texttt{OverlayNGCurve}. There is no public +noder. Closed-form overlay answers are limited to certified discs. +TestBuilder can draw \texttt{logoLines} as curves. + +\section{WKT and WKB on this tree} +Core \texttt{WKTReader} still rejects \texttt{CIRCULARSTRING} and the +other SQL/MM keywords. Read them with \texttt{CurveWKTReader}. Write +structured members with \texttt{CurveWKTWriter}. Core +\texttt{WKTWriter} already emits the subclass keyword via +\texttt{getGeometryType()} for a flat \texttt{CircularString}; +composite types need the curve writer to keep member tags. + +\texttt{CLOTHOID} / \texttt{BEZIER} / \texttt{ELLIPSE} / \texttt{NURBS} +are recognised by the curve WKT/WKB pair (WKB 18--21 greenfield). +Clothoid may also appear as a non-leading \texttt{COMPOUNDCURVE} +member. + +WKB type codes 8--12 and 18--21 are first-class in core +\texttt{WKBReader} and \texttt{WKBConstants}. Construction still +requires a curve-capable factory; otherwise the reader throws. +\texttt{CurveWKBWriter} emits those codes. A bare +\texttt{WKBWriter} still walks the parent \texttt{instanceof} ladder +for 8--12 and \textbf{refuses} to flatten 18--21. ISO flavour adds +$+1000/+2000/+3000$ for Z/M/ZM. + +\textbf{Option B.} Public \texttt{SegmentString} names each span as +\texttt{LINEARIZED} / \texttt{ARC} / \texttt{CERTIFIED}. Default is +linearized. Exact consumers ask \texttt{mayCollapseToChord}. Indexes +may lie under \texttt{PrecisionModel} without renaming the kind. + +\textbf{No silent densify.} \texttt{CurveLinearizationStrategy} +defaults to \texttt{LINEARIZED} and warns; \texttt{PRESERVE} keeps +type. TestBuilder Edit menu toggles the default; the Log tab shows +warn-sink lines. + +Default and export WKB paths (JtsOp, TestBuilder XML export) route +through \texttt{CurveWKBWriter} so those writers do not flatten +codes 8--12 / 18--21. + +\begin{lstlisting} +// Read codes 8-12 / 18-21 as curve types: +Geometry g = new WKBReader(new CurveGeometryFactory()).read(bytes); +Geometry g2 = new CurveWKBReader().read(bytes); + +// Write codes 8-12 / 18-21: +byte[] wkb = new CurveWKBWriter().write(g); + +// Bare writer: 8-12 may emit 2/3; 18-21 refuse flatten +byte[] flat = new WKBWriter().write(g); // CircularString -> type 2 +\end{lstlisting} + +\fighole{DG-2 WKB type 8 / 18 (CurveWKBWriter)} + +\section{Hausdorff on this tree} +The 2003 Developer's Guide did not document Hausdorff. It is recorded +here so a reader is not left with the public-API chord behaviour as +an unspoken surprise. + +Public \texttt{DiscreteHausdorffDistance} on this tree (commit +\texttt{0ca71b}) has a closed form for two pairs only: +\begin{enumerate} + \item A single-arc \texttt{CircularString} toward a single-segment + \texttt{LineString}, apex $\sqrt{949}/6 - 7/6 = 3.967640600249787$ + for the witness + \texttt{CIRCULARSTRING (0 0, 2 3, 10 0)} vs + \texttt{LINESTRING (0 0, 10 0)}. + \item Two circular discs. A + \texttt{MultiSurface} unwrap of one disc is the same disc + pair, not a third. +\end{enumerate} +The exact path skips densify; densify is not the laser. For every +other pair the public API still sees vertices / chords. Tests keep +\texttt{fail()} on the general case. Fr\'echet remains open. + +\fighole{DG-4 D-HF discs} + +\section{Licence} +JTS is dual-licensed under the Eclipse Public License 2.0 and the +Eclipse Distribution License 1.0. See \texttt{LICENSE\_EPLv2.txt}, +\texttt{LICENSE\_EDLv1.txt}, and \texttt{LICENSES.md}. The 2005 +TestBuilder guide's ACME licence appendix is historical and is not +the project licence. + +\vspace{1.2em} +\noindent\textit{End of Developer's Guide. Remaining named holes +(WKB, D-HF) are empty 4:3 slots (target 1600$\times$1200, full +TestBuilder window). 2003 Amyuni conversion cartoons are omitted; +the prose they illustrated is kept.} + +\end{document} diff --git a/doc/latex/figures/ts3_arc_hull.png b/doc/latex/figures/ts3_arc_hull.png new file mode 100644 index 0000000000..1609813b4f Binary files /dev/null and b/doc/latex/figures/ts3_arc_hull.png differ diff --git a/doc/latex/figures/ug1_dg1_draw_cs.png b/doc/latex/figures/ug1_dg1_draw_cs.png new file mode 100644 index 0000000000..26b578f5f5 Binary files /dev/null and b/doc/latex/figures/ug1_dg1_draw_cs.png differ diff --git a/doc/latex/figures/ug2_icons_A.png b/doc/latex/figures/ug2_icons_A.png new file mode 100644 index 0000000000..26b578f5f5 Binary files /dev/null and b/doc/latex/figures/ug2_icons_A.png differ diff --git a/doc/latex/figures/ug2_icons_B.png b/doc/latex/figures/ug2_icons_B.png new file mode 100644 index 0000000000..6bf0f6c703 Binary files /dev/null and b/doc/latex/figures/ug2_icons_B.png differ diff --git a/doc/latex/figures/ug3_compoundcurve.png b/doc/latex/figures/ug3_compoundcurve.png new file mode 100644 index 0000000000..114300e30c Binary files /dev/null and b/doc/latex/figures/ug3_compoundcurve.png differ diff --git a/doc/latex/figures/ug4_curvepolygon_disc.png b/doc/latex/figures/ug4_curvepolygon_disc.png new file mode 100644 index 0000000000..09e758742d Binary files /dev/null and b/doc/latex/figures/ug4_curvepolygon_disc.png differ diff --git a/doc/latex/figures/ug7_dg3_ts2_disc_hull.png b/doc/latex/figures/ug7_dg3_ts2_disc_hull.png new file mode 100644 index 0000000000..3029d9c4ea Binary files /dev/null and b/doc/latex/figures/ug7_dg3_ts2_disc_hull.png differ diff --git a/doc/latex/figures/ug8_ts6_compound_hull.png b/doc/latex/figures/ug8_ts6_compound_hull.png new file mode 100644 index 0000000000..1532da46bf Binary files /dev/null and b/doc/latex/figures/ug8_ts6_compound_hull.png differ diff --git a/doc/latex/jtsguide.sty b/doc/latex/jtsguide.sty new file mode 100644 index 0000000000..79c7875199 --- /dev/null +++ b/doc/latex/jtsguide.sty @@ -0,0 +1,144 @@ +% Shared style for the rebuilt 2003--2005 JTS manuals. +% Technical-manual layout: letter paper, numbered sections, no marketing chrome. +\ProvidesPackage{jtsguide}[2026/08/16 JTS guide shared style] + +\RequirePackage[letterpaper,margin=1in]{geometry} +\RequirePackage[T1]{fontenc} +\RequirePackage[utf8]{inputenc} +\RequirePackage{lmodern} +\RequirePackage{microtype} +\RequirePackage{booktabs} +\RequirePackage{longtable} +\RequirePackage{array} +\RequirePackage{tabularx} +\RequirePackage{enumitem} +\RequirePackage{fancyhdr} +\RequirePackage{listings} +\RequirePackage{xcolor} +\RequirePackage{framed} +\RequirePackage{graphicx} +\RequirePackage{setspace} +\RequirePackage[hidelinks]{hyperref} + +\definecolor{codebg}{gray}{0.96} +\definecolor{rulegray}{gray}{0.45} + +\lstset{ + language=Java, + basicstyle=\ttfamily\footnotesize, + keywordstyle=\bfseries, + commentstyle=\itshape\color{black!55}, + stringstyle=\ttfamily, + showstringspaces=false, + breaklines=true, + breakatwhitespace=false, + frame=single, + rulecolor=\color{rulegray}, + backgroundcolor=\color{codebg}, + framesep=5pt, + xleftmargin=6pt, + xrightmargin=6pt, + columns=fullflexible, + keepspaces=true, + tabsize=2, + aboveskip=0.7em, + belowskip=0.7em +} + +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{\small\sffamily JTS Topology Suite} +\fancyhead[R]{\small\sffamily\docshorttitle} +\fancyfoot[C]{\small\thepage} +\renewcommand{\headrulewidth}{0.4pt} +\renewcommand{\footrulewidth}{0pt} + +\setlength{\parskip}{0.45em} +\setlength{\parindent}{0pt} +\setlist[itemize]{leftmargin=1.4em,itemsep=0.2em,topsep=0.3em} +\setlist[enumerate]{leftmargin=1.4em,itemsep=0.2em,topsep=0.3em} + +\newcommand{\docshorttitle}{Guide} +\newcommand{\setshorttitle}[1]{\renewcommand{\docshorttitle}{#1}} + +% Guide-shot hole (DG-1..4, TS-1..6, UG-1..8): 4:3 at 1600x1200, +% full TestBuilder window. Slot id only -- no caption on unshot work. +% MKT-1 is 16:9 1920x1080 canvas-only and is not a manual figure. +\graphicspath{{figures/}} +\newlength{\figholewidth} +\newlength{\figholeheight} +\newcommand{\fighole}[1]{% + \par\medskip + \noindent + \begin{center} + \setlength{\figholewidth}{0.92\linewidth}% + \setlength{\figholeheight}{0.75\figholewidth}% 3/4 of width = 4:3 + \setlength{\fboxsep}{0pt}% + \fbox{% + \begin{minipage}[c][\figholeheight][c]{\figholewidth} + \centering + \sffamily\footnotesize #1 + \end{minipage}% + }% + \end{center} + \par\medskip +} + +% Signed 4:3 1600x1200 full TestBuilder window. +\newcommand{\figshot}[2]{% + \par\medskip + \noindent + \begin{center} + \includegraphics[width=0.92\linewidth]{#1}\\[0.35em] + \sffamily\footnotesize #2 + \end{center} + \par\medskip +} + +% Two signed 4:3 shots in one slot (UG-2 A-blue / B-red). +\newcommand{\figshotpair}[3]{% + \par\medskip + \noindent + \begin{center} + \includegraphics[width=0.455\linewidth]{#1}\hfill + \includegraphics[width=0.455\linewidth]{#2}\\[0.35em] + \sffamily\footnotesize #3 + \end{center} + \par\medskip +} + +\newenvironment{amendment}{% + \par\medskip + \begin{leftbar} + \noindent\textbf{Amendment.}\ +}{% + \end{leftbar} + \par\medskip +} + +\newcommand{\jtscover}[3]{% + \thispagestyle{empty} + \vspace*{1.6cm} + {\centering + {\Large\sffamily JTS Topology Suite}\\[1.1em] + {\LARGE\sffamily\bfseries #1}\\[1.4em] + {\large #2}\\[0.8em] + {\normalsize #3}\\ + } + \vspace{1.2cm} +} + +\newcommand{\changectrl}[1]{% + \section*{Document change control} + \begin{center} + \begin{tabular}{llp{3.2cm}p{6.4cm}} + \toprule + \textbf{Revision} & \textbf{Date} & \textbf{Author} & \textbf{Change} \\ + \midrule + #1 + \bottomrule + \end{tabular} + \end{center} +} + +\newcommand{\predrow}[2]{#1 & #2 \\} diff --git a/doc/latex/technical-specs.tex b/doc/latex/technical-specs.tex new file mode 100644 index 0000000000..e6652e36aa --- /dev/null +++ b/doc/latex/technical-specs.tex @@ -0,0 +1,521 @@ +\documentclass[11pt]{article} +\usepackage{jtsguide} +\setshorttitle{Technical Specifications} + +\title{JTS Topology Suite --- Technical Specifications} +\author{M.\ Davis / J.\ Aquino (v1.4 / 1.4.1) \\ rebuilt from the 2003 text} +\date{} + +\begin{document} +\jtscover{Technical Specifications}{Version 1.4 / 1.4.1, rebuilt}{Base text: 2003 \textperiodcentered\ rebuild: 16 August 2026} + +\noindent +This document is the design specification for the classes, methods +and algorithms implemented in the JTS Topology Suite. The 2003 +chapter plan is kept. Sections that would now lie about the spatial +model, WKT/WKB, overlay, or Hausdorff are amended against the code +on this tree. + +Package names in this tree are \texttt{org.locationtech.jts}. The +2003 copies used \texttt{com.vividsolutions.jts}. LocationTech JTS +remains the upstream project; this fork is not described as if it +were already upstream. + +\clearpage +\changectrl{% + 1.3 & 31 Mar 2003 & M.\ Davis & Updated to cover changes in JTS 1.3. \\ + 1.4 & 17 Oct 2003 & J.\ Aquino & CoordinateSequences and the user-data field. \\ + 1.4.1 & 2003 & M.\ Davis & Fixed definition of contains. \\ + 1.4-rebuild & 16 Aug 2026 & This tree & LaTeX rebuild. SFS Curve / + MultiCurve / MultiSurface are no longer abstract-only. WKB 8--12, + OverlayNGCurve, hulls, and the two D-HF pairs are recorded. \\ +} + +\tableofcontents +\clearpage + +\section{Overview} +The JTS Topology Suite is a Java API that implements a core set of +spatial data operations using an explicit precision model and robust +geometric algorithms. JTS is intended to be used in the development +of applications that support the validation, cleaning, integration +and querying of spatial datasets. + +JTS attempts to implement the OpenGIS Simple Features Specification +(SFS) as accurately as possible. Where the SFS is unclear or omits a +specification, JTS chooses a reasonable and consistent alternative. +Differences from and elaborations of the SFS are documented here. + +The 2003 spec stopped at linear SFS. This tree adds opt-in +SQL/MM Spatial (ISO/IEC 13249-3) curve types in \texttt{jts-curve}. The detailed +class hierarchy remains JavaDoc. This rebuild records the contract a +reader of the 2003 PDF would otherwise get wrong. + +\section{Other resources} +\begin{itemize} + \item OpenGIS Simple Features Specification for SQL Revision 1.1 + (SFS) --- master specification for the linear spatial model + and predicates. + \item SQL/MM Spatial (ISO/IEC 13249-3) --- CircularString, + CompoundCurve, CurvePolygon, and WKB codes 8--12. No + 13249-3 DOI is cited. + \item \emph{JTS Developer's Guide} and \emph{TestBuilder \& + TestRunner User Guide} (siblings in this directory), rebuilt + against the same tree. +\end{itemize} + +\section{Design goals} +The 2003 goals are still the goals of JTS core: +\begin{itemize} + \item Conform to the SFS as accurately as possible, consistent with + a correct implementation. + \item Follow Java conventions (\texttt{getX} / \texttt{setX}, + \texttt{isX}, lowercase method names). + \item Support a user-defined precision model; algorithms are robust + under it. + \item Return topologically and geometrically correct results within + that precision model wherever possible. + \item Correctness is the highest priority; space and time + efficiency is important but secondary. + \item Fast enough for production. Algorithms and code should stay + clear. +\end{itemize} + +The curve module adds a further constraint that is not in the 2003 +spec: a closed-form curve path that would be slower than +densify-then-core is refused, and the linear path runs instead. + +\section{Terminology} +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{3.4cm}p{9.8cm}} + \toprule + \textbf{Term} & \textbf{Definition} \\ + \midrule + Coordinate & A point in space exactly representable under the + precision model. \\ + Node & A point where two lines intersect. Not necessarily + a representable coordinate. \\ + Noding & Computing the nodes where geometries intersect. \\ + Robust computation & Numerical computation guaranteed to return + the correct answer for all inputs. \\ + SFS & OGC Simple Features Specification (linear). \\ + SQL/MM Spatial (ISO/IEC 13249-3) & CircularString, CompoundCurve, + CurvePolygon, MultiCurve, MultiSurface. \\ + Closed form & A cheap shape check that answers exactly. On this + tree: certified discs, the single-arc hull, the + compound-curve hull of circular-plus-straight + members, and the two D-HF pairs. \\ + Chord / densify & The fallback: replace arcs with LineString + segments. Not a laser. \\ + Vertex & A corner point stored explicitly. \\ + \bottomrule +\end{tabular} +\end{center} + +\section{Notation} +Items that adhere to the SFS are marked (SFS). Items that elaborate +or differ are marked (JTS). Items that exist only on this tree's +curve module are marked (jts-curve). + +\section{Java implementation} +Where SFS coding style differs from Java, JTS follows Java: +\texttt{boolean} instead of Integer-as-boolean; method names start +with a lowercase letter; get/set prefixes for JavaBeans. The 2003 +package root \texttt{com.vividsolutions.jts} is +\texttt{org.locationtech.jts} on this tree. That is the LocationTech +package rename, not a claim that this fork is LocationTech upstream. + +\section{Computational geometry issues} + +\subsection{Precision model} +JTS lets the client state how many bits of precision are assumed in +input coordinates and maintained in computed coordinates. Two basic +types are supported: Fixed (coordinates on a uniform grid, scale +factor) and Floating (IEEE-754 double or single). Input routines +supplied with JTS round to the precision model. Incompatible +precision models are not reconciled. + +In the Fixed model, coordinates fall on a discrete grid. The grid +size is the inverse of the scale factor: +\[ + \mathrm{jtsPt}.x = \mathrm{round}(\mathrm{inputPt}.x \cdot s) / s, + \qquad + \mathrm{jtsPt}.y = \mathrm{round}(\mathrm{inputPt}.y \cdot s) / s. +\] +Precise coordinates are represented internally as IEEE-754 doubles +(53 bits of precision). + +Floating Double uses the full Java double. Floating Single rounds +computed coordinates to single precision (for example when the +destination is Java2D). + +JTS does not implement an exact (rational) precision model. + +\subsection{Constructed points and dimensional collapse} +Overlay may construct points that are not input vertices. +Intersection coordinates can require twice the input precision; JTS +rounds them to the PrecisionModel. Rounding can produce dimensional +collapse. JTS forms the lower-dimension Geometry that results. + +\subsection{Robustness and numerical stability} +Fundamental predicates (orientation, line intersection, point-in-ring) +use robust algorithms. The binary-predicate algorithm is completely +robust. Overlay and buffer are engineered to return correct answers +in the majority of cases; they are not a substitute for exact +arithmetic. Line-intersection inputs are normalized toward the origin +to preserve bits. + +\subsection{Performance --- monotone chains} +Intersection detection uses in-memory spatial indexes and monotone +chains: sequences of segments whose direction vectors lie in one +quadrant. Segments inside one chain do not intersect; envelopes of +contiguous subsets are the envelopes of the endpoints, so binary +search applies. This is unchanged for linear Geometry. It is not a +circular noder. + +\section{Spatial model (linear, kept)} +The 2003 design decisions stand for linear Geometry: repeated points +are allowed (SFS); LineStrings may be non-simple (SFS); polygon rings +may not self-touch; polygon rings may mutually touch at a single +point. + +\subsection{Geometric definitions (linear)} +Every Geometry carries a PrecisionModel and an optional user-data +field. Empty geometries are allowed; a generic empty is an empty +GeometryCollection. The dimension of a heterogeneous +GeometryCollection is the maximum dimension of its elements. + +\paragraph{Curve (SFS, 2003).} +The 2003 spec defined SFS Curve as a non-degenerate linear curve: at +least two points, no two consecutive points equal. The only concrete +curve was LineString / LinearRing. That paragraph is still true of +core. + +\paragraph{MultiCurve (SFS, 2003).} +Boundary uses the Mod-2 rule: a point is on the boundary iff it is +on the boundary of an odd number of elements. The 2003 figures +showing that this can disagree with point-set topology still apply +to MultiLineString. + +\paragraph{LineString / LinearRing / Polygon / MultiPolygon.} +Unchanged. LineStrings may self-intersect. LinearRings have at least +4 points (including the repeated close), are simple, and may be CW +or CCW. Polygon shells and holes are LinearRings; holes may touch +the shell or another hole at a single point only; interiors are +connected. + +\subsection{Support classes} +Coordinate, CoordinateSequence, Envelope, IntersectionMatrix, +GeometryFactory, CoordinateFilter, GeometryFilter remain as in 2003. +GeometryFactory on this tree also has +\texttt{createCircularString} / \texttt{createCompoundCurve} / +\texttt{createCurvePolygon} / \texttt{createMultiCurve} / +\texttt{createMultiSurface} stubs that throw unless the factory is a +\texttt{CurveGeometryFactory} (jts-curve). + +\section{Spatial model --- curve types on this tree} +On this tree the concrete SQL/MM Spatial (ISO/IEC 13249-3) curve +types live in the +\texttt{jts-curve} module +(\texttt{org.locationtech.jts.geom.curve}): +\texttt{CircularString}, \texttt{CompoundCurve}, +\texttt{CurvePolygon}, and the collections \texttt{MultiCurve} and +\texttt{MultiSurface}. Construction is opt-in: the default +\texttt{GeometryFactory} throws on the curve \texttt{create*} +methods. Use \texttt{CurveGeometryFactory}. Constructors do not +linearise. + +\subsection{CircularString (jts-curve)} +A CircularString is a LineString whose consecutive triples (start, +mid, end) are circular arcs. SQL/MM Spatial (ISO/IEC 13249-3) +requires an odd point count +$\ge 3$. It is not a LineString of those control chords. +\texttt{toLinear(tolerance)} densifies explicitly. The class is not +constructed by the default GeometryFactory. + +\subsection{CompoundCurve (jts-curve)} +A CompoundCurve is a LineString whose members are LineString and/or +CircularString pieces, end-to-start connected. Member structure is +preserved on copy and on \texttt{CurveWKTWriter} / +\texttt{CurveWKBWriter}. A clothoid may appear as a non-leading +member when read by \texttt{CurveWKTReader}, and as first-class +WKB 18 / WKT \texttt{CLOTHOID}. Core \texttt{WKTReader} still fails +on the keyword without a curve factory. + +\subsection{CurvePolygon (jts-curve)} +A CurvePolygon is a Polygon whose rings may be CircularString, +CompoundCurve, or LinearRing. A full-circle CircularString shell is +a certified disc. Disc area, envelope, and disc-buffer are closed +form on that shape. A CurvePolygon that is not a certified disc is +not promoted to a laser. + +\subsection{MultiCurve and MultiSurface (jts-curve)} +MultiCurve extends MultiLineString; MultiSurface extends +MultiPolygon. They are the SQL/MM collections (WKB 11 and 12). A +MultiSurface of one certified disc unwraps to that disc for the D-HF +disc pair. They are not a general overlay noder. + +\subsection{What this section does not add} +No public noder that treats every chord as an arc. Constructors do not +linearise silently. This fork is not described as LocationTech upstream. +Triangle / PolyhedralSurface / TIN (WKB 15--17) stay Architect-gated. +WKB 18--21 are fork SIGN greenfield (below). + +\subsection{Option B noding contract} +Public \texttt{SegmentString} names each span +$[i,i{+}1]$ as \texttt{SegmentKind.LINEARIZED}, \texttt{ARC}, or +\texttt{CERTIFIED}. Default earth is linearized +(\texttt{mayCollapseToChord} true). Exact consumers ask the kind; +they do not treat chord ends as an arc. Spatial indexes may use +PrecisionModel-scaled chord or expanded bounds (the allowed lie); +PM snap must not rename the kind. OverlayNG may consume +\texttt{CircularNodedSegmentString}. See +\texttt{SegmentStringContractTest}. + +\subsection{No silent linearization} +\texttt{CurveLinearizationStrategy} defaults to \texttt{LINEARIZED} +and \textbf{must} warn on densify. \texttt{PRESERVE} keeps curve +identity. TestBuilder Edit menu toggles the default; the Log tab +receives warn-sink messages. + +\subsection{WKB 18--21 (fork SIGN)} +Greenfield type codes on this tree: +\texttt{CRV-CLOTHOID}=18, \texttt{PRF-BEZIER}=19, +\texttt{PRF-ELLIPSE}=20, \texttt{CRV-NURBS}=21, with ISO +dimension offsets $+1000/+2000/+3000$ for Z/M/ZM. Round-trips are +locked in \texttt{WKBClothoidTest} / +\texttt{WKBCurveZoo19\_21Test}. A bare \texttt{WKBWriter} refuses +to flatten these types. + +\section{Basic geometric algorithms} +The 2003 primitives remain the linear primitives: point-line +orientation, line intersection test and computation, point-in-ring, +ring orientation. They operate on coordinates and segments. They do +not node circular arcs. Arc/circle predicates used by certified-disc +paths live in \texttt{jts-curve} (package-private helpers) and are +not a public noder. + +\section{Topological computation and overlay} +The 2003 spec described topology graphs, labels, the Relate +algorithm, and the overlay algorithm that built a noded graph and +extracted result edges. That is the historical linear overlay. +Production linear overlay on this tree is OverlayNG. + +The curve overlay type is \texttt{OverlayNGCurve}. It lives in +\texttt{jts-curve} +(\texttt{org.locationtech.jts.operation.overlayng.curve}). There is +no public noder. Closed-form answers are limited to certified discs. +Anything else densifies and delegates to core OverlayNG. +\texttt{Geometry.intersection} / \texttt{union} / \texttt{difference} +/ \texttt{symDifference} on a curve type are not a general circular +overlay. + +Relate / DE-9IM on a certified disc vs Point, LineString, hole-free +Polygon, or a second disc has closed-form cases in the curve module. +Half-disc and general CompoundCurve relate that have no kit fall +through to linearise. That fall-through is not a general arc-aware +relate. + +\section{Binary predicates} +Equals, Disjoint, Intersects, Touches, Crosses, Within, Contains, +Overlaps keep their 2003 / SFS meanings on linear Geometry. Two +areas never Crosses. The 2003 contains fix (1.4.1) stands. On curve +operands, a predicate is exact only when a certified path answers; +otherwise it sees chords or an explicit \texttt{toLinear} copy. + +\section{Spatial analysis methods} +Constructive methods (Buffer, ConvexHull) and set-theoretic methods +(Intersection, Union, Difference, SymDifference) keep their 2003 +point-set definitions. Representation of computed geometries is +still constrained by the precision model. + +\paragraph{Buffer (linear).} +Unchanged: Minkowski sum with a disc, approximated by quadrant +segments, end-cap styles as in the Developer's Guide. + +\paragraph{Buffer (curve, jts-curve).} +Certified disc $\rightarrow$ disc of radius $r+d$ or empty. Open-arc +buffer is BufferOp on chords --- not a closed form. + +\paragraph{ConvexHull (linear).} +Unchanged: smallest convex polygon, or a lower-dimension Geometry if +fewer than 3 hull points; no three consecutive hull points collinear. + +\paragraph{ConvexHull (curve, jts-curve).} +A certified disc's hull is the disc (radius-5 disc: area $25\pi$). +A single-arc hull is the slice bounded by the arc and its chord (a +CurvePolygon). The compound-curve hull of circular-plus-straight +members (H-CC), including a stadium, keeps the exposed arcs as +CircularString members of a CurvePolygon. The H-CC witness +\texttt{COMPOUNDCURVE (CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 10 10))} +has hull area $50 + 12.5\arccos(0.6) \approx 61.59119$ and a +CircularString shell. A clothoid hull stays the linear fallback +(\texttt{CurveExact} null $\rightarrow$ +\texttt{linearise().convexHull()}), not a laser. Any other curve uses +the linear hull of \texttt{toLinear} --- a fallback, not a fail. +TestBuilder can draw \texttt{logoLines} as curves. + +\paragraph{Inscribed and empty circles (jts-curve).} +\texttt{MaximumInscribedCircle} on a certified disc and on a stadium +takes a closed form. \texttt{LargestEmptyCircle} on legal curve +obstacles is present. These are constructions, not a general +circular noder. + +\figshot{ug7_dg3_ts2_disc_hull.png}{TS-2. Disc hull area $25\pi$. Circular-plus-straight laser hull shipped.} +\figshot{ts3_arc_hull.png}{TS-3. Single-arc hull area $12.5\pi$.} +\figshot{ug8_ts6_compound_hull.png}{TS-6. Compound H-CC hull area 61.59119, CurvePolygon with CircularString.} + +\section{Other methods} +Boundary, isClosed, isSimple, isValid keep the 2003 tables for +linear types. JTS does not validate on construct (efficiency). +IsValidOp reports the nature and location of a failure. Curve +validation is best-effort structural (odd CircularString count, +member connectivity); it is not a full SQL/MM validator. + +\section{Well-Known Text and Well-Known Binary} + +\subsection{WKT --- linear (kept)} +SFS \S3.2.5. JTS follows the \texttt{MULTIPOINT (x y, x y)} example +form, not the parenthesized-pairs grammar. JTS extends WKT with +\texttt{LINEARRING}. \texttt{WKTReader} is parameterized by a +GeometryFactory (precision and SRID). \texttt{WKTWriter} rounds to +the precision model. + +\subsection{WKT --- curve (jts-curve)} +Core \texttt{WKTReader} still rejects \texttt{CIRCULARSTRING} and +the other SQL/MM keywords. Read them with \texttt{CurveWKTReader}. +Write structured members with \texttt{CurveWKTWriter}. Core +\texttt{WKTWriter} already emits the subclass keyword via +\texttt{getGeometryType()} for a flat CircularString; composite +types need the curve writer to keep member tags. + +Added tagged text (SQL/MM Spatial): \texttt{CIRCULARSTRING}, +\texttt{COMPOUNDCURVE}, \texttt{CURVEPOLYGON}, \texttt{MULTICURVE}, +\texttt{MULTISURFACE}. \texttt{CLOTHOID}, \texttt{BEZIER}, +\texttt{ELLIPSE}, and \texttt{NURBS} are recognised by +\texttt{CurveWKTReader} / \texttt{CurveWKTWriter} (WKB 18--21). +Clothoid may also appear as a non-leading \texttt{COMPOUNDCURVE} +member. Core \texttt{WKTReader} still fails on those keywords unless +the factory path is curve-capable. + +\subsection{WKB} +The 2003 spec did not list WKB codes 8--12 or 18--21. On this tree: + +\begin{center} +\begin{tabular}{clp{4.4cm}p{5.4cm}} + \toprule + \textbf{Code} & \textbf{Type} & \textbf{Read (core WKBReader)} & + \textbf{Write} \\ + \midrule + 1--7 & Point \ldots GeometryCollection & + Yes, default factory & WKBWriter \\ + 8 & CircularString & + Yes; \texttt{factory.createCircularString} & + CurveWKBWriter. Bare WKBWriter emits 2 \\ + 9 & CompoundCurve & + Yes; \texttt{factory.createCompoundCurve} & + CurveWKBWriter. Bare WKBWriter emits 2 \\ + 10 & CurvePolygon & + Yes; \texttt{factory.createCurvePolygon} & + CurveWKBWriter. Bare WKBWriter emits 3 \\ + 11 & MultiCurve & + Yes; \texttt{factory.createMultiCurve} & + CurveWKBWriter. Bare WKBWriter emits 5 \\ + 12 & MultiSurface & + Yes; \texttt{factory.createMultiSurface} & + CurveWKBWriter. Bare WKBWriter emits 6 \\ + 18 & Clothoid (CRV-CLOTHOID) & + Curve factory & CurveWKBWriter (no flatten) \\ + 19 & B\'ezier (PRF-BEZIER) & + Curve factory & CurveWKBWriter (no flatten) \\ + 20 & Ellipse (PRF-ELLIPSE) & + Curve factory & CurveWKBWriter (no flatten) \\ + 21 & NURBS (CRV-NURBS) & + Curve factory & CurveWKBWriter (no flatten) \\ + \bottomrule +\end{tabular} +\end{center} + +WKB type codes 8--12 and 18--21 are first-class in core +\texttt{WKBReader} / \texttt{WKBConstants}. Construction still +requires a curve-capable factory; otherwise the reader throws. +\texttt{CurveWKBWriter} emits those codes. A bare \texttt{WKBWriter} +still walks the parent \texttt{instanceof} ladder for 8--12 and +\textbf{refuses} to flatten 18--21. ISO flavour adds +$+1000/+2000/+3000$ for Z/M/ZM. Default and export WKB paths route +through \texttt{CurveWKBWriter}. + +Default GeometryFactory throws on the \texttt{create*} curve +methods, so \texttt{new WKBReader().read(type8)} fails with a +factory-required ParseException. +\texttt{new WKBReader(new CurveGeometryFactory())} or +\texttt{CurveWKBReader} succeeds. + +\fighole{TS-1 WKB 8--12 / 18--21} + +\section{Discrete Hausdorff distance} +The 2003 spec did not define Hausdorff. On this tree the public +class is +\texttt{org.locationtech.jts.algorithm.distance.DiscreteHausdorffDistance}. + +Public \texttt{DiscreteHausdorffDistance} (commit \texttt{0ca71b}) +has a closed form for two pairs only: +\begin{enumerate} + \item A single-arc CircularString toward a single-segment + LineString, apex $\sqrt{949}/6 - 7/6 = 3.967640600249787$ for + \texttt{CIRCULARSTRING (0 0, 2 3, 10 0)} vs + \texttt{LINESTRING (0 0, 10 0)}. + \item Two circular discs. A + MultiSurface unwrap of one disc is the same disc pair, not a + third. +\end{enumerate} +The exact path skips densify; densify is not the laser. For every +other pair the public API still sees vertices / chords. Tests keep +\texttt{fail()} on the general case. + +\texttt{DensifyFrac} still interpolates chords on the fallback path. +Calling \texttt{distance(..., densifyFrac)} on a certified pair does +not replace the closed form with densify; the exact path skips +densify. Do not document densify as the laser. + +Fr\'echet (\texttt{DiscreteFrechetDistance}) remains open as a +general curve metric. This specification does not claim a Fr\'echet +laser. + +\fighole{TS-4 APEX} +\fighole{TS-5 two discs} + +\section{Licence} +JTS is dual-licensed under the Eclipse Public License 2.0 and the +Eclipse Distribution License 1.0. See \texttt{LICENSE\_EPLv2.txt}, +\texttt{LICENSE\_EDLv1.txt}, and \texttt{LICENSES.md}. The 2005 +TestBuilder guide's ACME licence appendix is historical and is not +the project licence. + +\section{References} +\begin{itemize} + \item [SFS] OpenGIS Simple Features Specification For SQL + Revision 1.1. Open GIS Consortium, Inc. OpenGIS project + Document 99-049. + \item [SQL/MM] ISO/IEC 13249-3, Information technology --- + Database languages --- SQL multimedia and application + packages --- Part 3: Spatial. Source of CircularString, + CompoundCurve, and CurvePolygon. No 13249-3 DOI is cited. + No JTS DOI is cited. + \item [Ava97] F.\ Avnaim, J-D.\ Boissonnat, O.\ Devillers, + F.\ Preparata and M.\ Yvinec. Evaluating signs of + determinants using single-precision arithmetic. + \emph{Algorithmica} 17:111--132, 1997. + \item [Bri98] A.\ Brinkmann, K.\ Hinrichs. Implementing exact line + segment intersection in map overlay. SDH 1998, pp.\ 569--579. + \item [Sch97] Stefan Schirra. Precision and robustness in geometric + computations. In \emph{Algorithmic Foundations of Geographic + Information Systems}, LNCS 1340, Springer, 1997. +\end{itemize} + +\vspace{1.2em} +\noindent\textit{End of Technical Specifications. Remaining named +holes (WKB, D-HF) are empty 4:3 slots (target 1600$\times$1200, +full TestBuilder window). No clothoid DOI is cited.} + +\end{document} diff --git a/doc/latex/user-guide.tex b/doc/latex/user-guide.tex new file mode 100644 index 0000000000..5583c75087 --- /dev/null +++ b/doc/latex/user-guide.tex @@ -0,0 +1,383 @@ +\documentclass[11pt]{article} +\usepackage{jtsguide} +\setshorttitle{TestBuilder \& TestRunner} + +\title{JTS Topology Suite --- TestRunner \& TestBuilder User Guide} +\author{Jonathan Aquino (2001--2005) \\ rebuilt from the 2005 text} +\date{} + +\begin{document} +\jtscover{TestRunner \& TestBuilder User Guide}{Rebuilt from the 3 February 2005 text}{Base text: 3 February 2005 \textperiodcentered\ rebuild: 16 August 2026} + +\noindent +This guide describes how to use the JTS TestRunner, a Java application +that validates the JTS Topology Suite, and the JTS TestBuilder, a Java +application that creates, visualizes, and runs geometry tests. The 2005 +chapter plan is kept. Startup, packages, and curve drawing are brought +up to this tree. + +Package names in this tree are \texttt{org.locationtech.jts}. The 2005 +copies used \texttt{com.vividsolutions.jts}. LocationTech JTS remains +the upstream project; this fork is not described as if it were already +upstream. + +\clearpage +\changectrl{% + 1.0 & 13 Sep 2001 & Jonathan Aquino & Original draft. \\ + 1.1 & 17 Dec 2001 & Jonathan Aquino & Added TestBuilder documentation. \\ + 1.2 & 30 Jan 2002 & Jonathan Aquino & Added ACME licence (historical). \\ + 1.2-rebuild & 16 Aug 2026 & This tree & LaTeX rebuild. Startup, packages, + curve drawing, WKT apply, A/B colours, OverlayNGCurve, Hausdorff. + ACME appendix replaced with the JTS dual licence. \\ +} + +\tableofcontents +\clearpage + +\section{Overview} +The TestRunner and TestBuilder are used by people who are developing +or evaluating JTS. While similarly named, the JTS TestRunner is not +the JUnit TestRunner. JUnit is a general Java testing framework. The +JTS TestRunner is easier for testing JTS computations because tests +are specified in XML text files --- no Java coding or compilation. +Those files can be created by hand or generated by the TestBuilder. + +The 2005 guide required Java 2 JRE/SDK v1.3. This tree targets Java 8 +and above. Download a current JDK from the vendor of your choice; the +2005 \texttt{java.sun.com/products} link is historical. + +The 2005 class names were +\texttt{com.vividsolutions.jtstest.testrunner.TopologyTestApp} and +\texttt{com.vividsolutions.jtstest.testbuilder.JTSTest}. On this tree +they are +\texttt{org.locationtech.jtstest.testrunner.JTSTestRunnerCmd} and +\texttt{org.locationtech.jtstest.testbuilder.JTSTestBuilder}. The +usual way to start TestBuilder is the built jar, not a hand-built +classpath of \texttt{jdom.jar} / \texttt{xerces.jar} / \texttt{jts.jar} +/ \texttt{jts\_test.jar}. + +This guide is divided into two main sections: Using the TestRunner, +and Using the TestBuilder. An appendix details the XML test-file +format. For more information on JTS, see the JTS Technical +Specifications. + +\section{Using the TestRunner} +This section describes how to use the TestRunner to run a test file +that validates the JTS Topology Suite. Test files are created using +the TestBuilder (Section~\ref{sec:tb}) or by hand +(Section~\ref{sec:xml}). + +\subsection{Starting} +From a built tree, run the TestRunner via the scripts in +\texttt{bin/} or: + +\begin{lstlisting}[language=bash] +java -cp modules/tests/target/jts-tests-*-SNAPSHOT.jar \ + org.locationtech.jtstest.testrunner.JTSTestRunnerCmd \ + -files path/to/tests.xml +\end{lstlisting} + +The 2005 GUI mode (\texttt{-gui} / \texttt{testrunner.bat}) and the +switches \texttt{-files}, \texttt{-properties}, \texttt{-verbose} are +the same idea: a list of XML files, a results panel or text listing, +and a status line of cases / tests / passed / failed / exceptions. + +Graphical mode has file-chooser dialogs that make it easy to build a +list of test files. Text mode is quicker to start. The +\texttt{-files} switch can be used to run all test files in a given +directory. + +\subsection{Reading the results} +A case specifies two geometries (A and B). A test specifies an +operation and its expected value. The status line reports counts of +cases, tests, passes, failures, thrown exceptions, and parse +exceptions. + +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{3.4cm}p{9.8cm}} + \toprule + \textbf{Statistic} & \textbf{Meaning} \\ + \midrule + Cases & Each case specifies two geometries to test. \\ + Tests & Each test specifies an operation on those + geometries. \\ + Passed & Actual value matches expected value. \\ + Failed & Actual value differs from expected value. \\ + Exception & An error occurred inside JTS or the TestRunner. \\ + Parse exception & The test file has a syntax error. \\ + \bottomrule +\end{tabular} +\end{center} + +Passed-test detail is shown only with \texttt{-verbose}. A failed +test means either fix the test or fix JTS. A parse exception is a +problem in the XML or WKT (a missing comma in a LINESTRING is the +2005 example and is still the usual cause). + +\subsection{Switches} +\begin{center} +\begin{tabular}{>{\raggedright\arraybackslash}p{3.0cm}p{10.2cm}} + \toprule + \textbf{Switch} & \textbf{Description} \\ + \midrule + \texttt{-files} & List of test files and directories. Separate + items with spaces. \\ + \texttt{-properties} & A \texttt{.properties} file that remembers + the last file list. \\ + \texttt{-gui} & Graphical mode, when the build still ships + that entry point. \\ + \texttt{-verbose} & Show passed tests as well as failures. \\ + \bottomrule +\end{tabular} +\end{center} + +The easiest way to specify every file in a directory is to pass the +directory to \texttt{-files}. Any combination of switches is +permitted. If no switches are specified, a description of the +switches is displayed. + +\begin{amendment} +XML \texttt{} / \texttt{} bodies that contain +\texttt{CIRCULARSTRING} / \texttt{COMPOUNDCURVE} / +\texttt{CURVEPOLYGON} / \texttt{MULTICURVE} / \texttt{MULTISURFACE} +must be parsed with a curve-capable reader. On this tree the +TestRunner / TestCase path uses \texttt{CurveWKTReader}. + +A test that calls \texttt{Geometry.intersection} on a curve is not +\texttt{OverlayNGCurve}. Name the OverlayNGCurve function if that is +what you mean to assert. + +A \texttt{DiscreteHausdorffDistance} assertion is the public API +(commit \texttt{0ca71b}): two certified pairs are closed-form +(APEX $3.967640600249787$; two circular discs); everything else is +chords. \texttt{fail()} stays on the general case. +\end{amendment} + +\section{Using the TestBuilder} +\label{sec:tb} +The 2005 guide called TestBuilder experimental, with a useful starting +point for creating, visualizing, and running test files. That +description is still fair. The application on this tree is the +current LocationTech JTS TestBuilder plus the curve tools that are +actually merged. + +Useful features: +\begin{itemize} + \item creating test geometries visually or by entering Well-Known + Text; + \item snapping to a grid; + \item specifying expected values for spatial functions and the + intersection matrix; + \item opening and saving XML test files. +\end{itemize} + +The 2005 ``Save As HTML'' bulk documentation generator is not claimed +here as a supported workflow. The \texttt{vividsolutions.com/jts/tests} +URL in the 2005 copy is historical. + +\subsection{Starting the TestBuilder} +Build the app module, then from the project root: + +\begin{lstlisting}[language=bash] +java -jar modules/app/target/JTSTestBuilder.jar -Xmx2000M +\end{lstlisting} + +On macOS, if the native look-and-feel misbehaves: + +\begin{lstlisting}[language=bash] +java -Dswing.defaultlaf=javax.swing.plaf.metal.MetalLookAndFeel \ + -jar modules/app/target/JTSTestBuilder.jar -Xmx2000M +\end{lstlisting} + +Do not start it as the 2005 +\texttt{java com.vividsolutions.jtstest.testbuilder.JTSTest} with a +four-jar classpath. That layout is gone. + +\subsection{Opening a test file} +File / Open XML File(s). The TestBuilder displays one test case at a +time: a pair of geometries and their tests. Use Previous / Next or +the Tests list to move. Pan, zoom, zoom 1:1, and zoom-to-extent +examine the geometries. The WKT tab shows Well-Known Text. + +\subsection{Precision model} +A test file's precision model is set from the TestBuilder +precision-model dialog (Edit / Precision Model). The default is +Floating. See the Technical Specifications. + +\subsection{Creating geometries} +The 2005 workflow is still the right picture: pick geometry A or B, +pick a draw tool, click in the view. Geometry A is shown in blue; +geometry B, in red. You can also type WKT. Erase, undo point, and +undo part still apply. + +On this tree the draw tools include the linear set (point, linestring, +polygon, rectangle) and the curve set that is actually merged: +\texttt{CircularString}, \texttt{CompoundCurve}, +\texttt{CurvePolygon}. Triangle and TIN tools also exist. + +Draw-tool icons follow the A/B focus: index 0 (A) is blue-dominant; +otherwise (B) is red-dominant. That applies to the linear draw tools +and to CircularString / CompoundCurve / CurvePolygon. + +WKT apply uses \texttt{CurveWKTReader}, so +\texttt{CIRCULARSTRING} pasted into the WKT tab is a +\texttt{CircularString}, not a parse error. Enter in the A or B WKT +pane loads that pane. The Clear control is labeled and does not wipe +the other pane on apply. + +\figshot{ug1_dg1_draw_cs.png}{UG-1. Draw CircularString.} +\figshotpair{ug2_icons_A.png}{ug2_icons_B.png}{UG-2. A-blue / B-red icons shipped.} +\figshot{ug3_compoundcurve.png}{UG-3. CompoundCurve.} +\figshot{ug4_curvepolygon_disc.png}{UG-4. CurvePolygon disc.} + +A clothoid may be added as a non-leading +\texttt{COMPOUNDCURVE} member from the CompoundCurve tool and from +the clothoid playground extras. That path is extras, not a +closed-form construction. + +\subsection{Predicates and functions} +The 2005 Predicates tab (expected DE-9IM, binary predicates) and +Functions tab (union, intersection, buffer, \ldots) are the same +idea. The current UI is a function tree: pick a function, run it, +inspect the result geometry and its WKT. + +\texttt{OverlayNGCurve} is registered as its own function class +(intersection / union / difference / symDifference). That is the +curve overlay surface. \texttt{Geometry.intersection} in the Geometry +function group is not OverlayNGCurve. + +Closed-form constructions exposed as functions include disc and +single-arc convex hull, the compound-curve hull of +circular-plus-straight members (H-CC witness area $\approx 61.59119$, +CircularString shell), \texttt{MaximumInscribedCircle} on a +certified disc and on a stadium, and \texttt{LargestEmptyCircle} on +legal curve obstacles. A clothoid hull stays the linear fallback +(\texttt{CurveExact} null $\rightarrow$ +\texttt{linearise().convexHull()}), not a laser. +TestBuilder can draw \texttt{logoLines} as curves. + +\subsection{Saving} +File / Save As XML writes the current cases. Default / export WKB +paths route through \texttt{CurveWKBWriter}, so a saved curve keeps +ISO codes 8--12 and 18--21. Read those codes with +\texttt{CurveWKBReader} (or \texttt{WKBReader} plus a curve-capable +factory). A bare \texttt{WKBWriter.write} call still emits type 2 or +3 for 8--12 parents and refuses to flatten 18--21. + +\section{Curves in TestBuilder} +TestBuilder can draw \texttt{CircularString}, \texttt{CompoundCurve}, +and \texttt{CurvePolygon} (toolbar tools plus WKT via +\texttt{CurveWKTReader}). Clothoid members can be added on a +CompoundCurve; WKB 19--21 types are inspectable (Bezier / Ellipse / +NURBS labels). Function-tree entries show curve-awareness badges +(\textbullet{} native / $\bigcirc$ passthrough / $\times$ flattens). +Edit $\rightarrow$ Curve strategy toggles +\texttt{LINEARIZED} (warn) vs \texttt{PRESERVE}; densify warnings +appear on the Log tab and the status strip. + +On this tree the concrete SQL/MM Spatial (ISO/IEC 13249-3) curve +types live in the +\texttt{jts-curve} module +(\texttt{org.locationtech.jts.geom.curve}). Construction is opt-in: +the default \texttt{GeometryFactory} throws on the curve +\texttt{create*} methods. Use \texttt{CurveGeometryFactory}. +Constructors do not linearise silently --- +\texttt{CurveLinearizationStrategy} defaults to \texttt{LINEARIZED} +and warns. + +\textbf{Option B.} Public \texttt{SegmentString} names each span as +\texttt{LINEARIZED} / \texttt{ARC} / \texttt{CERTIFIED}. Exact +consumers ask \texttt{mayCollapseToChord}. Indexes may lie under +\texttt{PrecisionModel} without renaming the kind. + +Core \texttt{WKTReader} still rejects \texttt{CIRCULARSTRING} and the +other SQL/MM keywords. Read them with \texttt{CurveWKTReader}. Write +structured members with \texttt{CurveWKTWriter}. + +WKB type codes 8--12 and 18--21 are first-class in core +\texttt{WKBReader}. Construction still requires a curve-capable +factory; otherwise the reader throws. \texttt{CurveWKBReader} +succeeds. \texttt{CurveWKBWriter} emits those codes. A bare +\texttt{WKBWriter} still emits type 2/3 for 8--12 parents and +refuses to flatten 18--21. ISO flavour adds $+1000/+2000/+3000$ for +Z/M/ZM. + +The curve overlay type is \texttt{OverlayNGCurve}. There is no public +noder that treats every chord as an arc. Closed-form overlay answers +include certified discs and the clothoid 0-node kit (identity / +disjoint / nest); Fresnel crossings are a named miss. + +Public \texttt{DiscreteHausdorffDistance} (commit \texttt{0ca71b}) +has a closed form for two pairs only: (1)~a single-arc +\texttt{CircularString} toward a single-segment \texttt{LineString}, +apex $\sqrt{949}/6 - 7/6 = 3.967640600249787$ for +\texttt{CIRCULARSTRING (0 0, 2 3, 10 0)} vs +\texttt{LINESTRING (0 0, 10 0)}; (2)~two circular discs. A +\texttt{MultiSurface} unwrap of one disc is the same pair, not a +third. The exact path skips densify; densify is not the laser. For +every other pair the public API still sees vertices / chords. +Fr\'echet remains open. + +\fighole{UG-5 APEX} +\fighole{UG-6 two discs} +\figshot{ug7_dg3_ts2_disc_hull.png}{UG-7. Disc hull area $25\pi$. Circular-plus-straight laser hull shipped.} +\figshot{ug8_ts6_compound_hull.png}{UG-8. Compound H-CC hull area 61.59119, CurvePolygon with CircularString.} + +\section{Appendix: test file format} +\label{sec:xml} +A test file is XML. It contains one or more cases; each case contains +one or more tests. A case specifies two geometries. A test specifies +an operation and its expected result. + +\subsection{Example} +\begin{lstlisting}[language=XML] + + example + + + point in a polygon + POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0)) + POINT (5 5) + + true + + + + single-arc D-HF witness + CIRCULARSTRING (0 0, 2 3, 10 0) + LINESTRING (0 0, 10 0) + + 3.967640600249787 + + + +\end{lstlisting} + +The second case is the D-HF closed-form witness on this tree (apex +$\sqrt{949}/6 - 7/6 = 3.967640600249787$). A case that is not one of the two certified +pairs must not be written as if +\texttt{DiscreteHausdorffDistance} were arc-length Hausdorff. The +first case is the 2005 example, unchanged. + +\subsection{XML shape} +The 2005 DTD-style listing is still the right shape: \texttt{} +contains \texttt{} and one or more \texttt{}; +each case has \texttt{}, optional \texttt{}, and +\texttt{} / \texttt{} with a name and expected value. WKT +inside \texttt{} / \texttt{} may now be a curve keyword; the +reader on this tree is \texttt{CurveWKTReader}. + +\section{Appendix: licence} +JTS is dual-licensed under the Eclipse Public License 2.0 and the +Eclipse Distribution License 1.0. See \texttt{LICENSE\_EPLv2.txt}, +\texttt{LICENSE\_EDLv1.txt}, and \texttt{LICENSES.md}. The 2005 +``Appendix: ACME Licence'' is not reproduced. It was never the JTS +project licence on this tree. + +\vspace{1.2em} +\noindent\textit{End of TestRunner \& TestBuilder User Guide. +Remaining named holes (WKB, D-HF) are empty 4:3 slots (target +1600$\times$1200, full TestBuilder window). 2005 Amyuni screen-shots +are omitted; the procedures they illustrated are kept in prose.} + +\end{document} diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java index 84e89afde5..3f1d3ba05c 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java @@ -38,7 +38,7 @@ public class BufferFunctions { public static String bufferDescription = "Buffers a geometry by a distance"; - @Metadata(description="Buffer a geometry by a distance") + @Metadata(description="Buffer a geometry by a distance", curveAwareness="passthrough") public static Geometry buffer(Geometry g, double distance) { return g.buffer(distance); } public static Geometry bufferWithParams(Geometry g, @@ -224,6 +224,7 @@ public static Geometry variableBuffer(Geometry line, if (line instanceof Polygon) { line = ((Polygon) line).getExteriorRing(); } + line = linearizeCurveForVariableBuffer(line); return VariableBuffer.buffer(line, startDist, endDist); } @@ -237,8 +238,25 @@ public static Geometry variableBufferMid(Geometry line, if (line instanceof Polygon) { line = ((Polygon) line).getExteriorRing(); } + line = linearizeCurveForVariableBuffer(line); return VariableBuffer.buffer(line, startDist, midDist, startDist); } + + /** + * VBF honesty (#1195): VariableBuffer samples vertices by cumulative + * length. Curve inputs densify by equal arc length via + * {@link CurveOps#lineariseArcLength} (strategy + warn), never silent + * control-polygon flatten. Emitting arc-preserving variable offsets + * remains the full TAG meter. + */ + private static Geometry linearizeCurveForVariableBuffer(Geometry g) { + if (g instanceof org.locationtech.jts.geom.curve.Linearizable + || g instanceof org.locationtech.jts.geom.curve.CircularString + || g instanceof org.locationtech.jts.geom.curve.CompoundCurve) { + return org.locationtech.jts.geom.curve.CurveOps.lineariseArcLength(g, 16); + } + return g; + } public static Geometry bufferRadius(Geometry radiusLine) { double distance = radiusLine.getLength(); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/CurveFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/CurveFunctions.java index e8517818d1..b74b1ae295 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/CurveFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/CurveFunctions.java @@ -166,7 +166,8 @@ public static Geometry linearizeForDistanceTol(Geometry g, double distTol) { } private static Geometry linearizeAtFraction(Geometry g, double fraction) { - if (g == null || g.isEmpty()) return g; + if (g == null) return g; + if (g.isEmpty()) return linearize(g, 0.0); Envelope env = g.getEnvelopeInternal(); double extent = Math.max(env.getWidth(), env.getHeight()); return linearize(g, (extent > 0.0 ? extent : 1.0) * fraction); @@ -182,8 +183,11 @@ private static Geometry linearizeAtFraction(Geometry g, double fraction) { * no operation and no writer needs to know whether the arc-awareness shim ran. */ public static Geometry linearize(Geometry g, double tolerance) { - if (g == null || g.isEmpty()) return g; + if (g == null) return g; + // Empty Linearizable still goes through toLinear so CURVEPOLYGON EMPTY + // becomes POLYGON EMPTY — a named fallback, not a silent curve type. if (g instanceof Linearizable) return ((Linearizable) g).toLinear(tolerance); + if (g.isEmpty()) return g; if (g instanceof GeometryCollection) { int n = g.getNumGeometries(); Geometry[] members = new Geometry[n]; diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/GeometryFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/GeometryFunctions.java index 8185d7b92a..c1a38e109a 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/GeometryFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/GeometryFunctions.java @@ -23,6 +23,7 @@ import org.locationtech.jts.geom.MultiLineString; import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jtstest.geomfunction.Metadata; /** @@ -34,12 +35,16 @@ public class GeometryFunctions { public static String lengthDescription = "Computes the length of perimeter of a Geometry"; + @Metadata(curveAwareness="native") public static double length(Geometry g) { return g.getLength(); } + @Metadata(curveAwareness="native") public static double area(Geometry g) { return g.getArea(); } public static double SRID(Geometry g) { return g.getSRID(); } public static boolean isEmpty(Geometry g) { return g.isEmpty(); } + @Metadata(curveAwareness="native") public static boolean isSimple(Geometry g) { return g.isSimple(); } + @Metadata(curveAwareness="native") public static boolean isValid(Geometry g) { return g.isValid(); } public static boolean isRectangle(Geometry g) { return g.isRectangle(); } public static boolean isClosed(Geometry g) { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/HullFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/HullFunctions.java index 9283bace55..49c7778487 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/HullFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/HullFunctions.java @@ -71,6 +71,7 @@ public static Geometry concaveHullPointsWithHoles(Geometry geom, return ConcaveHull.concaveHullByLength(arcAware(geom), maxLen, true); } + @Metadata(curveAwareness="passthrough") public static Geometry concaveHullPointsByLenRatio(Geometry geom, @Metadata(title="Length Ratio") double maxLenRatio) { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/JTSFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/JTSFunctions.java index 21e0c47cec..5250d778c8 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/JTSFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/JTSFunctions.java @@ -12,17 +12,25 @@ package org.locationtech.jtstest.function; +import java.util.ArrayList; +import java.util.List; + import org.locationtech.jts.JTSVersion; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurveGeometryFactory; import org.locationtech.jts.geom.curve.Linearizable; import org.locationtech.jts.operation.buffer.BufferOp; import org.locationtech.jts.operation.buffer.BufferParameters; +import org.locationtech.jtstest.geomfunction.Metadata; public class JTSFunctions { @@ -65,31 +73,10 @@ public static Geometry logoLines(Geometry g) } /** - * Hero halo: {@link #logoLines} then one {@link BufferOp} at the - * given distance with {@link BufferParameters#JOIN_MITRE} (mitre - * limit {@link BufferParameters#DEFAULT_MITRE_LIMIT}, not 0) and - * {@link BufferParameters#CAP_SQUARE} (box caps). - *

- * Densify is {@link Linearizable#toLinear(double) toLinear(0.0)} — - * CircularArcDensifier default sagitta, 1% of radius (J R=25 → - * ε=0.25; S R=17.5 → ε=0.175). Named linear fallback / CHORD-PATH / - * NAMED-APPROX. Not a laser. Not clothoid. Never claimed exact. - * Ignores A; rebuilds the wordmark. - *

- * One BufferOp on the whole ISO/IEC 13249-3 MultiCurve already - * unions overlapping offsets (T–S gap is 5; at d=4, 4+4>5). Do - * not weld J/T/S into one LineString. Do not union letters in - * {@link #logoLines} — overlay linearises. “Not one halo” is a - * thick 4-stroke wordmark, not BufferOp forgetting to union. - *

- * Quiet defaults elsewhere, written not flattened: - * {@code Buffer.buffer} after logo in A is CurveOps.buffer → - * {@code Geometry.buffer(d)} with no BufferParameters (CAP_ROUND + - * JOIN_ROUND). {@code Buffer.bufferWithParams} reaches BufferOp - * with the UI ints but does not linearize (control-point chords); - * empty Quadrant Segs → 0 and empty Mitre Limit → 0.0 (JOIN_MITRE - * bevels). Real disconnect paths are {@code Buffer.bufferEach} and - * empty-A {@code Buffer.buffer} NPE — not this function. + * Buffers the logo with {@link BufferOp}: CAP_SQUARE + JOIN_MITRE at + * {@link BufferParameters#DEFAULT_MITRE_LIMIT}. Densify is + * {@code toLinear(0.0)} (CircularArcDensifier 1% of radius) — named + * CHORD-PATH / NAMED-APPROX, not a laser. Not clothoid. */ public static Geometry logoBuffer(Geometry g, double distance) { @@ -105,6 +92,288 @@ public static Geometry logoBuffer(Geometry g, double distance) bufParams.setMitreLimit(BufferParameters.DEFAULT_MITRE_LIMIT); return BufferOp.bufferOp(lines, distance, bufParams); } + + /** + * Stamp on the clothoid-halo result: a named chord path, not EXACT + * and not a certified clothoid offset of the letter strokes. + */ + public static final String CLOTHOID_HALO_STAMP_CHORD_PATH = "CHORD-PATH"; + + /** + * Stamp when the halo is returned as a polygonal band of those chords. + */ + public static final String CLOTHOID_HALO_STAMP_NAMED_APPROX = "NAMED-APPROX"; + + /** Offset from the {@link #logoLines} envelope to the inner halo edge. */ + static final double CLOTHOID_HALO_DEFAULT_DISTANCE = 12.0; + + /** Width of the polygonal halo band outside the inner edge. */ + static final double CLOTHOID_HALO_DEFAULT_BAND = 5.0; + + /** + * Positive chord tolerance used when linearising the clothoid frame. + * Never passed as a claim of EXACT; {@link ClothoidSegment#toLinear} + * is the named fallback, not a laser. + */ + static final double CLOTHOID_HALO_CHORD_TOLERANCE = 0.35; + + /** + * Logo as curves plus a clothoid halo. + *

+ * {@link #logoLines} stays a MultiCurve of CircularString / CompoundCurve + * (ISO/IEC 13249-3). This helper does not flatten those letters. A true + * clothoid offset of the strokes is not certified here. The halo is a + * decorative G² clothoid-fillet frame around the wordmark envelope, + * then linearised and stamped {@link #CLOTHOID_HALO_STAMP_NAMED_APPROX} + * or {@link #CLOTHOID_HALO_STAMP_CHORD_PATH}. Not {@link #logoBuffer} + * (that is the circular MKT-1 halo). Not a CIRCULARSTRING Qed. + */ + @Metadata(description="logo as curves plus a clothoid halo.", curveAwareness="native") + public static Geometry logoClothoid(Geometry g) + { + return clothoidHalo(g, CLOTHOID_HALO_DEFAULT_DISTANCE); + } + + /** + * Same mark as {@link #logoClothoid(Geometry)} at the default offset. + */ + @Metadata(description="logo as curves plus a clothoid halo.") + public static Geometry clothoidHalo(Geometry g) + { + return clothoidHalo(g, CLOTHOID_HALO_DEFAULT_DISTANCE); + } + + /** + * Clothoid-fillet halo around {@link #logoLines} at {@code distance} + * from the wordmark envelope. Distance {@code <= 0} uses the default. + * Result is a LINESTRING or POLYGON of chords, stamped as a named + * linear fallback. + */ + @Metadata(description="logo as curves plus a clothoid halo.") + public static Geometry clothoidHalo(Geometry g, + @Metadata(title="Distance") double distance) + { + if (Double.isNaN(distance) || distance <= 0.0) { + distance = CLOTHOID_HALO_DEFAULT_DISTANCE; + } + CurveGeometryFactory gf = curveFactory(g); + // Envelope only — do not toLinear / flatten logoLines. + Envelope logo = logoLines(g).getEnvelopeInternal(); + return namedClothoidHalo(gf, logo, distance, CLOTHOID_HALO_DEFAULT_BAND); + } + + /** + * Builds the clothoid-fillet frame, linearises at + * {@link #CLOTHOID_HALO_CHORD_TOLERANCE}, and stamps the result. + * Prefers a polygonal band (NAMED-APPROX); falls back to a closed + * chord path (CHORD-PATH) if the band is not a valid polygon. + */ + static Geometry namedClothoidHalo(CurveGeometryFactory gf, Envelope logo, + double distance, double band) + { + if (band <= 0.0) { + band = CLOTHOID_HALO_DEFAULT_BAND; + } + LineString outer = linearizeFilletRect(gf, expand(logo, distance + band)); + LineString inner = linearizeFilletRect(gf, expand(logo, distance)); + Polygon bandPoly = polygonalHalo(gf, outer, inner); + if (bandPoly != null) { + bandPoly.setUserData(CLOTHOID_HALO_STAMP_NAMED_APPROX); + return bandPoly; + } + LineString path = closePath(gf, outer); + path.setUserData(CLOTHOID_HALO_STAMP_CHORD_PATH); + return path; + } + + private static Envelope expand(Envelope env, double d) + { + return new Envelope(env.getMinX() - d, env.getMaxX() + d, + env.getMinY() - d, env.getMaxY() + d); + } + + private static LineString linearizeFilletRect(CurveGeometryFactory gf, + Envelope env) + { + LineString[] members = filletRectMembers(gf, env); + CompoundCurve frame = gf.createCompoundCurve(members); + Geometry linear = frame.toLinear(CLOTHOID_HALO_CHORD_TOLERANCE); + if (linear instanceof LineString) { + return (LineString) linear; + } + return gf.createLineString(linear.getCoordinates()); + } + + /** + * CCW rounded rectangle: four straights and four G² clothoid corners + * (entry κ:0→κ, exit κ:0). Each clothoid turns π/4 so the pair is a + * 90° fillet with no circular arc. Not a certified offset. + */ + private static LineString[] filletRectMembers(CurveGeometryFactory gf, + Envelope env) + { + double minX = env.getMinX(); + double minY = env.getMinY(); + double maxX = env.getMaxX(); + double maxY = env.getMaxY(); + double w = maxX - minX; + double h = maxY - minY; + + double L = Math.min(16.0, 0.18 * Math.min(w, h)); + if (L < 4.0) { + L = Math.max(2.0, 0.12 * Math.min(w, h)); + } + double[] fit = fitClothoidCorner(gf, L, w, h); + double kappa = fit[0]; + L = fit[1]; + double ix = fit[2]; + double iy = fit[3]; + + List members = new ArrayList(); + Coordinate bottomStart = new Coordinate(minX + ix, minY); + Coordinate bottomEnd = new Coordinate(maxX - ix, minY); + addStraight(members, gf, bottomStart, bottomEnd); + Coordinate afterBr = addClothoidCorner(members, gf, bottomEnd, 0.0, kappa, L); + + Coordinate rightEnd = new Coordinate(maxX, maxY - iy); + addStraight(members, gf, afterBr, rightEnd); + Coordinate afterTr = addClothoidCorner(members, gf, rightEnd, Math.PI / 2.0, kappa, L); + + Coordinate topEnd = new Coordinate(minX + ix, maxY); + addStraight(members, gf, afterTr, topEnd); + Coordinate afterTl = addClothoidCorner(members, gf, topEnd, Math.PI, kappa, L); + + Coordinate leftEnd = new Coordinate(minX, minY + iy); + addStraight(members, gf, afterTl, leftEnd); + Coordinate afterBl = addClothoidCorner(members, gf, leftEnd, -Math.PI / 2.0, kappa, L); + + addStraight(members, gf, afterBl, bottomStart); + return members.toArray(new LineString[0]); + } + + /** + * Chooses κ so each half-corner turns π/4, shrinking L until the + * fillet insets fit inside the rectangle. + * @return {@code {kappa, L, insetX, insetY}} + */ + private static double[] fitClothoidCorner(CurveGeometryFactory gf, double L, + double w, double h) + { + double[] inset = new double[2]; + double kappa = (Math.PI / 2.0) / L; + measureCornerInset(gf, 0.0, kappa, L, inset); + int guard = 0; + while ((inset[0] > 0.42 * w || inset[1] > 0.42 * h) && L > 2.0 && guard < 8) { + L *= 0.7; + kappa = (Math.PI / 2.0) / L; + measureCornerInset(gf, 0.0, kappa, L, inset); + guard++; + } + return new double[] { kappa, L, inset[0], inset[1] }; + } + + private static void measureCornerInset(CurveGeometryFactory gf, double heading, + double kappa, double L, double[] inset) + { + ClothoidSegment entry = new ClothoidSegment(new Coordinate(0, 0), heading, + 0.0, kappa, L, gf); + ClothoidSegment exit = new ClothoidSegment(entry.getEndCoordinate(), + entry.getEndTangent(), kappa, 0.0, L, gf); + inset[0] = exit.getEndCoordinate().x; + inset[1] = exit.getEndCoordinate().y; + } + + private static Coordinate addClothoidCorner(List members, + CurveGeometryFactory gf, Coordinate start, double heading, + double kappa, double L) + { + ClothoidSegment entry = new ClothoidSegment(new Coordinate(start), heading, + 0.0, kappa, L, gf); + ClothoidSegment exit = new ClothoidSegment(entry.getEndCoordinate(), + entry.getEndTangent(), kappa, 0.0, L, gf); + members.add(entry); + members.add(exit); + return exit.getEndCoordinate(); + } + + private static void addStraight(List members, GeometryFactory gf, + Coordinate a, Coordinate b) + { + if (a.distance(b) < 1.0e-8) { + return; + } + members.add(gf.createLineString(new Coordinate[] { + new Coordinate(a), new Coordinate(b) + })); + } + + private static Polygon polygonalHalo(GeometryFactory gf, LineString outer, + LineString inner) + { + LineString outerClosed = closePath(gf, outer); + LineString innerClosed = closePath(gf, inner); + if (outerClosed.getNumPoints() < 4 || innerClosed.getNumPoints() < 4) { + return null; + } + LinearRing shell = ring(gf, outerClosed.getCoordinates()); + LinearRing hole = ring(gf, reverseRing(innerClosed.getCoordinates())); + if (shell == null || hole == null) { + return null; + } + Polygon poly = gf.createPolygon(shell, new LinearRing[] { hole }); + if (!poly.isValid() || poly.getArea() <= 0.0) { + return null; + } + return poly; + } + + private static LineString closePath(GeometryFactory gf, LineString path) + { + Coordinate[] pts = path.getCoordinates(); + if (pts.length == 0) { + return gf.createLineString(); + } + if (pts.length >= 2 && pts[0].equals2D(pts[pts.length - 1])) { + return gf.createLineString(copyCoords(pts)); + } + Coordinate[] closed = new Coordinate[pts.length + 1]; + for (int i = 0; i < pts.length; i++) { + closed[i] = new Coordinate(pts[i]); + } + closed[pts.length] = new Coordinate(pts[0]); + return gf.createLineString(closed); + } + + private static LinearRing ring(GeometryFactory gf, Coordinate[] pts) + { + if (pts == null || pts.length < 4) { + return null; + } + try { + return gf.createLinearRing(copyCoords(pts)); + } + catch (IllegalArgumentException ex) { + return null; + } + } + + private static Coordinate[] reverseRing(Coordinate[] pts) + { + Coordinate[] rev = new Coordinate[pts.length]; + for (int i = 0; i < pts.length; i++) { + rev[i] = new Coordinate(pts[pts.length - 1 - i]); + } + return rev; + } + + private static Coordinate[] copyCoords(Coordinate[] pts) + { + Coordinate[] copy = new Coordinate[pts.length]; + for (int i = 0; i < pts.length; i++) { + copy[i] = new Coordinate(pts[i]); + } + return copy; + } private static CompoundCurve create_J(CurveGeometryFactory gf) { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java index 672fc747da..4b741af2a6 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java @@ -22,6 +22,7 @@ public class OffsetCurveFunctions { + @Metadata(curveAwareness="native") public static Geometry offsetCurve(Geometry geom, double distance) { return OffsetCurve.getCurve(geom, distance); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/WriterFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/WriterFunctions.java index 41925df121..5439c1a317 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/WriterFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/WriterFunctions.java @@ -55,8 +55,11 @@ public class WriterFunctions * becomes about 1570 vertices. Callers who want a coarser export should * linearise deliberately with {@code Curve -> toLinear} at a tolerance of their * choosing and write the result. + *

+ * This is a named linear fallback, not a claim the curve survived. + * GML2 / KML / GeoJSON / SVG have no SQL/MM ISO/IEC 13249-3 arc encoding. */ - private static Geometry exportable(Geometry geom) { + private static Geometry namedLinearFallback(Geometry geom) { return CurveFunctions.linearizeForOps(geom); } @@ -64,17 +67,17 @@ public static String writeKML(Geometry geom) { if (geom == null) return ""; KMLWriter writer = new KMLWriter(); - return writer.write(exportable(geom)); + return writer.write(namedLinearFallback(geom)); } public static String writeGML(Geometry geom) { if (geom == null) return ""; - return (new GMLWriter()).write(exportable(geom)); + return (new GMLWriter()).write(namedLinearFallback(geom)); } /** - * Deliberately not routed through {@link #exportable}. Oracle SDO_GEOMETRY + * Deliberately not routed through {@link #namedLinearFallback}. Oracle SDO_GEOMETRY * does represent circular arcs (element type 1005/2005 with interpretation 2), * so densifying here would discard something the target format can carry -- * the same argument that makes densifying WKB a stopgap rather than a fix. The @@ -115,7 +118,7 @@ public static String dumpWKB(Geometry g) public static String writeGeoJSON(Geometry g) { if (g == null) return ""; - return (new GeoJsonWriter().write(exportable(g))); + return (new GeoJsonWriter().write(namedLinearFallback(g))); } public static String writeGeoJSONFixDecimal(Geometry g, @@ -123,10 +126,10 @@ public static String writeGeoJSONFixDecimal(Geometry g, int numDecimals) { if (g == null) return ""; - return (new GeoJsonWriter(numDecimals).write(exportable(g))); + return (new GeoJsonWriter(numDecimals).write(namedLinearFallback(g))); } public static String writeSVG(Geometry a, Geometry b) { - return SVGTestWriter.writeSVG(exportable(a), exportable(b)); + return SVGTestWriter.writeSVG(namedLinearFallback(a), namedLinearFallback(b)); } } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/BaseGeometryFunction.java b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/BaseGeometryFunction.java index baae203b1f..19b950115e 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/BaseGeometryFunction.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/BaseGeometryFunction.java @@ -52,6 +52,7 @@ public static int firstScalarParamIndex(GeometryFunction func) { protected Class[] parameterTypes; protected Class returnType; protected boolean isRequiredB = true; + protected String curveAwareness = "flattens"; public BaseGeometryFunction( String category, @@ -126,6 +127,17 @@ public boolean isBinary() { public boolean isRequiredB() { return isRequiredB; } + + public String getCurveAwareness() { + return curveAwareness == null || curveAwareness.length() == 0 + ? "flattens" : curveAwareness; + } + + protected void setCurveAwareness(String level) { + if (level != null && level.length() > 0) { + this.curveAwareness = level; + } + } public String getSignature() { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/GeometryFunction.java b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/GeometryFunction.java index f9c6700098..8ff5dab2f9 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/GeometryFunction.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/GeometryFunction.java @@ -98,5 +98,12 @@ public interface GeometryFunction boolean isBinary(); boolean isRequiredB(); + + /** + * TB-FN (#1195): {@code native}, {@code passthrough}, or {@code flattens}. + */ + default String getCurveAwareness() { + return "flattens"; + } } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/Metadata.java b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/Metadata.java index fe0592cb29..c0cf93aa54 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/Metadata.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/Metadata.java @@ -21,4 +21,10 @@ String title() default ""; String description() default ""; boolean isRequired() default true; + /** + * TB-FN (#1195): curve-awareness badge for the TestBuilder function tree. + * Values: {@code native} (●), {@code passthrough} (◯), {@code flattens} (✕). + * Empty means {@code flattens}. + */ + String curveAwareness() default ""; } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/StaticMethodGeometryFunction.java b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/StaticMethodGeometryFunction.java index dba8b46203..f44f137f80 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/StaticMethodGeometryFunction.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/geomfunction/StaticMethodGeometryFunction.java @@ -156,8 +156,17 @@ public StaticMethodGeometryFunction( super(category, name, description, parameterNames, parameterTypes, returnType); this.method = method; isRequiredB = extractRequiredB(method); + setCurveAwareness(extractCurveAwareness(method)); } + private static String extractCurveAwareness(Method method) { + Metadata meta = method.getAnnotation(Metadata.class); + if (meta == null) { + return ""; + } + return meta.curveAwareness(); + } + public Object invoke(Geometry g, Object[] arg) { return invoke(method, null, createFullArgs(g, arg)); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/AppCursors.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/AppCursors.java index a15884c974..ba886a785b 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/AppCursors.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/AppCursors.java @@ -13,24 +13,39 @@ package org.locationtech.jtstest.testbuilder; import java.awt.Cursor; +import java.awt.GraphicsEnvironment; +import java.awt.Point; import java.awt.Toolkit; +import javax.swing.ImageIcon; + public class AppCursors { - public static Cursor DRAW_GEOM = Toolkit.getDefaultToolkit().createCustomCursor( - AppIcons.load("DrawCursor.png").getImage(), new java.awt.Point(4, 26), - "Draw"); - - public static Cursor EDIT_VERTEX = Toolkit.getDefaultToolkit().createCustomCursor( - AppIcons.load("MoveVertexCursor.gif").getImage(), - new java.awt.Point(16, 16), "MoveVertex"); - - public static Cursor HAND = Toolkit.getDefaultToolkit().createCustomCursor( - AppIcons.load("Hand.gif").getImage(), new java.awt.Point(7, 7), "Pan"); - - public static Cursor ZOOM = Toolkit.getDefaultToolkit().createCustomCursor( - AppIcons.load("MagnifyCursor.gif").getImage(), - new java.awt.Point(16, 16), "Zoom In"); + public static Cursor DRAW_GEOM = customOrDefault( + "DrawCursor.png", new Point(4, 26), "Draw"); + + public static Cursor EDIT_VERTEX = customOrDefault( + "MoveVertexCursor.gif", new Point(16, 16), "MoveVertex"); + + public static Cursor HAND = customOrDefault( + "Hand.gif", new Point(7, 7), "Pan"); + + public static Cursor ZOOM = customOrDefault( + "MagnifyCursor.gif", new Point(16, 16), "Zoom In"); + + /** + * Headless CI (and other no-display hosts) cannot create custom + * cursors; fall back to the default arrow so tool singletons still + * construct for existence / wiring tests. + */ + private static Cursor customOrDefault(String icon, Point hotSpot, String name) { + if (GraphicsEnvironment.isHeadless()) { + return Cursor.getDefaultCursor(); + } + ImageIcon img = AppIcons.load(icon); + return Toolkit.getDefaultToolkit().createCustomCursor( + img.getImage(), hotSpot, name); + } } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryFunctionTreePanel.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryFunctionTreePanel.java index 3f6f0cb7eb..a9b2442ef9 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryFunctionTreePanel.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryFunctionTreePanel.java @@ -85,14 +85,27 @@ public Component getTreeCellRendererComponent(JTree tree, Object value, setIcon( computeIcon(func) ); //String name = StringUtil.capitalize(func.getName()); String name = func.getName(); - setText(name); - setToolTipText(GeometryFunctionUtil.toolTipText(func)); + String badge = curveBadge(func); + setText(badge + " " + name); + setToolTipText(GeometryFunctionUtil.toolTipText(func) + + " [" + func.getCurveAwareness() + "]"); } else { setToolTipText(null); // no tool tip } return this; } + private String curveBadge(GeometryFunction func) { + String level = func.getCurveAwareness(); + if ("native".equalsIgnoreCase(level)) { + return "\u25CF"; // ● + } + if ("passthrough".equalsIgnoreCase(level)) { + return "\u25CB"; // ◯ + } + return "\u2715"; // ✕ + } + private ImageIcon computeIcon(GeometryFunction func) { ImageIcon icon = unaryIcon; if (func.isBinary()) { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderFrame.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderFrame.java index 17dc118b21..a5a1a70d89 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderFrame.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderFrame.java @@ -104,6 +104,8 @@ public JTSTestBuilderFrame() { enableEvents(AWTEvent.WINDOW_EVENT_MASK); setIconImage(AppIcons.APP.getImage()); jbInit(); + installCurveLinearizationWarnSink(); + JTSTestBuilder.controller().setStatus("Curve strategy: LINEARIZED"); testCasePanel.cbRevealTopo.addActionListener( new java.awt.event.ActionListener() { public void actionPerformed(ActionEvent e) { @@ -243,7 +245,24 @@ public ResultValuePanel getResultValuePanel() { public InfoPanel getLogPanel() { return logPanel; } - + + /** + * Phase 5 (#1195): route CurveLinearizationStrategy warnings into the + * Log tab so linearize is never silent in the TestBuilder UI. + */ + private void installCurveLinearizationWarnSink() { + org.locationtech.jts.geom.curve.CurveLinearizationStrategy.setWarnSink( + new org.locationtech.jts.geom.curve.CurveLinearizationStrategy.WarnSink() { + public void warn(final String message) { + javax.swing.SwingUtilities.invokeLater(new Runnable() { + public void run() { + logPanel.addInfo(message); + } + }); + } + }); + } + public CommandPanel getCommandPanel() { return commandPanel; } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderMenuBar.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderMenuBar.java index a59ecf46c9..8691375524 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderMenuBar.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilderMenuBar.java @@ -173,6 +173,34 @@ public void actionPerformed(ActionEvent e) { JTSTestBuilder.controller().changeToLines(); } }); + + final JRadioButtonMenuItem menuCurveLinearized = new JRadioButtonMenuItem( + "Curve strategy: LINEARIZED (warn)"); + final JRadioButtonMenuItem menuCurvePreserve = new JRadioButtonMenuItem( + "Curve strategy: PRESERVE"); + javax.swing.ButtonGroup curveStrategyGroup = new javax.swing.ButtonGroup(); + curveStrategyGroup.add(menuCurveLinearized); + curveStrategyGroup.add(menuCurvePreserve); + menuCurveLinearized.setSelected(true); + menuCurveLinearized.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + org.locationtech.jts.geom.curve.CurveLinearizationStrategy + .setDefault(org.locationtech.jts.geom.curve.CurveLinearizationStrategy.LINEARIZED); + String msg = "CurveLinearizationStrategy default = LINEARIZED (densify warns)"; + tbFrame.getLogPanel().addInfo(msg); + JTSTestBuilder.controller().setStatus("Curve strategy: LINEARIZED"); + } + }); + menuCurvePreserve.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + org.locationtech.jts.geom.curve.CurveLinearizationStrategy + .setDefault(org.locationtech.jts.geom.curve.CurveLinearizationStrategy.PRESERVE); + String msg = "CurveLinearizationStrategy default = PRESERVE (no densify)"; + tbFrame.getLogPanel().addInfo(msg); + JTSTestBuilder.controller().setStatus("Curve strategy: PRESERVE"); + } + }); + jMenuFile.setText("File"); //jMenuOptions.setText("Options"); //jMenuTools.setText("Tools"); @@ -209,6 +237,9 @@ public void actionPerformed(ActionEvent e) { jMenuEdit.addSeparator(); jMenuEdit.add(removeDuplicatePoints); jMenuEdit.add(changeToLines); + jMenuEdit.addSeparator(); + jMenuEdit.add(menuCurveLinearized); + jMenuEdit.add(menuCurvePreserve); jMenuBar1.add(jMenuFile); jMenuBar1.add(jMenuView); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanel.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanel.java index 853c1305d6..3e3dea9b98 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanel.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanel.java @@ -19,6 +19,8 @@ import java.awt.GridLayout; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.util.HashMap; import java.util.Map; import java.util.Vector; @@ -166,6 +168,9 @@ void uiInit() throws Exception { txtDistance.setPreferredSize(new Dimension(25, 17)); txtDistance.setText(PARAM_DEFAULT[0]); txtDistance.setHorizontalAlignment(SwingConstants.RIGHT); + pinParamFocus(txtDistance); + pinParamFocus(txtQuadrantSegs); + pinParamFocus(txtMitreLimit); lblQuadSegs.setText("Quadrant Segs"); txtQuadrantSegs.setHorizontalAlignment(SwingConstants.RIGHT); @@ -317,7 +322,14 @@ void clearResultButton_actionPerformed(ActionEvent e) { } GeometryFunction getMetaFunction() { - GeometryFunction funToRun = geomFuncPanel.getFunction(); + // Prefer the function the UI last selected (currentFunc) over the + // tree's live selection. Clicking a param field must not re-bind + // Exec to a different tree node (TB-FN #60: dX focus must not + // silently run Buffer.buffer). + GeometryFunction funToRun = currentFunc; + if (funToRun == null) { + funToRun = geomFuncPanel.getFunction(); + } if (! isMetaFunctionEnabled()) return funToRun; if (isFunctionRepeated()) { @@ -372,6 +384,9 @@ public void execFunction(GeometryFunction func, boolean createNew) { private void functionChanged(GeometryFunction func) { + if (func == null) { + return; + } saveParameter(currentFunc); currentFunc = func; lblFunctionName.setText(func.getName()); @@ -382,9 +397,24 @@ private void functionChanged(GeometryFunction func) execButton.setEnabled(true); execToNewButton.setEnabled(true); + // Selecting a new function must not inherit Live Exec from a prior + // Buffer run (TB-FN #60: focus into dX must not auto-fire Buffer). cbExecAuto.setSelected(false); } + /** + * TB-FN #60: a click on a scalar param field must focus that field and + * must not be interpreted as a Geometry-function tree selection (which + * previously could re-bind Exec to Buffer.buffer / Distance). + */ + private static void pinParamFocus(final JTextField field) { + field.addMouseListener(new MouseAdapter() { + public void mousePressed(MouseEvent e) { + field.requestFocusInWindow(); + } + }); + } + private void recallParameter(GeometryFunction func) { if (! funcParamMap.containsKey(func)) return; String val = funcParamMap.get(func); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/IOUtil.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/IOUtil.java index b22a7be570..fc95412a7e 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/IOUtil.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/IOUtil.java @@ -15,6 +15,7 @@ import org.locationtech.jts.io.WKBWriter; import org.locationtech.jts.io.WKTWriter; import org.locationtech.jts.io.curve.CurveWKBWriter; +import org.locationtech.jts.io.curve.CurveWKTWriter; import org.locationtech.jts.io.gml2.GMLWriter; public class IOUtil { @@ -25,18 +26,27 @@ public static String toWKBHex(Geometry g) return WKBWriter.toHex((new CurveWKBWriter().write(g))); } + /** + * GML2 cannot carry SQL/MM ISO/IEC 13249-3 arcs. Core + * {@link GMLWriter} refuses CircularString / CompoundCurve + * rather than emit a control-point LineString. + */ public static String toGML(Geometry g) { if (g == null) return ""; return (new GMLWriter()).write(g); } + /** + * Uses {@link CurveWKTWriter} so COMPOUNDCURVE members survive + * (SQL/MM ISO/IEC 13249-3). Core {@link WKTWriter} refuses that flatten. + */ public static String toWKT(Geometry g, boolean isFormatted) { if (g == null) return ""; if (! isFormatted) return g.toString(); - WKTWriter writer = new WKTWriter(); + WKTWriter writer = new CurveWKTWriter(); writer.setFormatted(isFormatted); writer.setMaxCoordinatesPerLine(5); return writer.write(g); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/JavaTestWriter.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/JavaTestWriter.java index 5735828dee..b6421a734e 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/JavaTestWriter.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/JavaTestWriter.java @@ -15,6 +15,7 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.io.WKTWriter; +import org.locationtech.jts.io.curve.CurveWKTWriter; import org.locationtech.jtstest.test.Testable; import org.locationtech.jtstest.testbuilder.model.TestBuilderModel; import org.locationtech.jtstest.util.StringUtil; @@ -52,7 +53,7 @@ public static String getTestJava(List testCases) { } - private WKTWriter writer = new WKTWriter(); + private WKTWriter writer = new CurveWKTWriter(); public JavaTestWriter() {} diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/XMLTestWriter.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/XMLTestWriter.java index a283f9217d..b3a5913532 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/XMLTestWriter.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/io/XMLTestWriter.java @@ -23,6 +23,7 @@ import org.locationtech.jts.io.WKBWriter; import org.locationtech.jts.io.WKTWriter; import org.locationtech.jts.io.curve.CurveWKBWriter; +import org.locationtech.jts.io.curve.CurveWKTWriter; import org.locationtech.jts.util.Assert; import org.locationtech.jtstest.test.TestCase; import org.locationtech.jtstest.test.TestCaseList; @@ -46,7 +47,7 @@ public static String toXML(PrecisionModel precisionModel) { + precisionModel.getScale() + "\"/>"; } - private WKTWriter wktWriter = new WKTWriter(); + private WKTWriter wktWriter = new CurveWKTWriter(); private WKBWriter wkbWriter = new CurveWKBWriter(); public XMLTestWriter() { diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriter.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriter.java index edf6031050..25ea7de710 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriter.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriter.java @@ -23,9 +23,12 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.curve.BezierCurve; import org.locationtech.jts.geom.curve.CircularString; import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.EllipseCurve; +import org.locationtech.jts.geom.curve.NurbsCurve; import org.locationtech.jtstest.testbuilder.geom.GeometryElementLocater; import org.locationtech.jtstest.testbuilder.geom.FacetLocater; import org.locationtech.jtstest.testbuilder.geom.GeometryLocation; @@ -188,7 +191,8 @@ public String writeFacetLocation(Geometry geom, Coordinate p, double tolerance) return writeFacetLocations(locs); } - private String writeFacetLocations(List locs) + /** Package-private for curve-zoo inspect tests. */ + String writeFacetLocations(List locs) { if (locs.size() <= 0) return null; @@ -230,6 +234,19 @@ private String componentType(GeometryLocation loc) { if (el instanceof CircularString) { return arcLabel((CircularString) el); } + if (el instanceof BezierCurve) { + return String.format(Locale.ROOT, "Bezier n=%d ", el.getNumPoints()); + } + if (el instanceof EllipseCurve) { + EllipseCurve ec = (EllipseCurve) el; + return String.format(Locale.ROOT, "Ellipse a=%s b=%s ", + fmtLen(ec.getSemiMajor()), fmtLen(ec.getSemiMinor())); + } + if (el instanceof NurbsCurve) { + NurbsCurve nc = (NurbsCurve) el; + return String.format(Locale.ROOT, "NURBS deg=%d n=%d ", + nc.getDegree(), el.getNumPoints()); + } if (el instanceof CompoundCurve && !loc.isVertex()) { // A flat segment of a CompoundCurve falls inside one specific member. // Look up that member and use its type-specific label so the user diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/tools/MoveTool.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/tools/MoveTool.java index e67b7681ac..45d8947485 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/tools/MoveTool.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/ui/tools/MoveTool.java @@ -89,6 +89,14 @@ public void mouseReleased(MouseEvent e) { startIndicatorLoc = null; } + /** + * Canvas drag translate. Whole-geom path is + * {@code copy(); apply(translation)} — the same apply as + * {@link AffineTransformation#transform(Geometry)}. Curve honesty + * (ISO/IEC 13249-3 CIRCULARSTRING three-point arcs, including + * CompoundCurve member starts) lives in that apply. Translate only; + * not a general-affine SIGN. + */ private void execute(Coordinate fromLoc, Coordinate toLoc, boolean isComponentMoved) { double dx = toLoc.getX() - fromLoc.getX(); double dy = toLoc.getY() - fromLoc.getY(); diff --git a/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoClothoidTest.java b/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoClothoidTest.java new file mode 100644 index 0000000000..043a9e9cb5 --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoClothoidTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.function; + +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.Linearizable; +import org.locationtech.jts.geom.curve.MultiCurve; +import org.locationtech.jts.io.WKTWriter; +import org.locationtech.jtstest.geomfunction.GeometryFunction; +import org.locationtech.jtstest.geomfunction.GeometryFunctionRegistry; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; +import junit.textui.TestRunner; + +/** + * Pins TestBuilder {@code logoClothoid} / {@code clothoidHalo} as a named + * linear fallback around {@link JTSFunctions#logoLines}: LINESTRING or + * POLYGON (or MultiPolygon) of chords, stamped CHORD-PATH or NAMED-APPROX. + * Not a CIRCULARSTRING Qed, not a laser, not {@link JTSFunctions#logoBuffer}. + */ +public class JTSFunctionsLogoClothoidTest extends TestCase { + + public static void main(String[] args) { TestRunner.run(suite()); } + public static Test suite() { return new TestSuite(JTSFunctionsLogoClothoidTest.class); } + public JTSFunctionsLogoClothoidTest(String name) { super(name); } + + public void testLogoClothoidIsNamedLinearFallback() { + Geometry halo = JTSFunctions.logoClothoid(null); + assertNamedLinearFallback("logoClothoid", halo); + } + + public void testClothoidHaloIsNamedLinearFallback() { + Geometry halo = JTSFunctions.clothoidHalo(null); + assertNamedLinearFallback("clothoidHalo", halo); + } + + public void testClothoidHaloDistanceOverloadIsNamedLinearFallback() { + Geometry halo = JTSFunctions.clothoidHalo(null, 18.0); + assertNamedLinearFallback("clothoidHalo(distance)", halo); + } + + public void testLogoClothoidAndClothoidHaloMatchAtDefault() { + Geometry a = JTSFunctions.logoClothoid(null); + Geometry b = JTSFunctions.clothoidHalo(null); + assertTrue("logoClothoid and clothoidHalo are the same default mark", + a.equalsExact(b)); + assertEquals(a.getUserData(), b.getUserData()); + } + + public void testHaloIsNotCircularStringQed() { + Geometry halo = JTSFunctions.logoClothoid(null); + assertFalse(halo instanceof CircularString); + assertFalse(halo instanceof CompoundCurve); + assertFalse(halo instanceof ClothoidSegment); + assertFalse("result must not stay Linearizable / curve-typed", + halo instanceof Linearizable); + String type = halo.getGeometryType(); + assertFalse("getGeometryType stays linear, got " + type, + type.equalsIgnoreCase("CircularString") + || type.equalsIgnoreCase("CompoundCurve") + || type.equalsIgnoreCase("ClothoidSegment") + || type.equalsIgnoreCase("MultiCurve")); + String wkt = new WKTWriter().write(halo); + assertFalse("WKT must not claim CIRCULARSTRING Qed: " + wkt, + wkt.startsWith("CIRCULARSTRING") || wkt.startsWith("COMPOUNDCURVE")); + } + + public void testPathIsNamed() { + Geometry halo = JTSFunctions.logoClothoid(null); + Object stamp = halo.getUserData(); + assertNotNull("halo must carry a named-fallback stamp", stamp); + assertTrue("stamp must be NAMED-APPROX or CHORD-PATH, got " + stamp, + JTSFunctions.CLOTHOID_HALO_STAMP_NAMED_APPROX.equals(stamp) + || JTSFunctions.CLOTHOID_HALO_STAMP_CHORD_PATH.equals(stamp)); + } + + public void testHaloFramesTheWordmarkWithoutFlatteningLogoLines() { + Geometry logo = JTSFunctions.logoLines(null); + assertTrue("logoLines stays a MultiCurve of real curves", + logo instanceof MultiCurve); + Envelope logoEnv = logo.getEnvelopeInternal(); + + Geometry halo = JTSFunctions.logoClothoid(null); + Envelope haloEnv = halo.getEnvelopeInternal(); + assertTrue("halo envelope must cover the wordmark envelope", + haloEnv.contains(logoEnv)); + assertTrue("halo must sit outside the letters, not on the control box", + haloEnv.getWidth() > logoEnv.getWidth() + && haloEnv.getHeight() > logoEnv.getHeight()); + + Geometry logoAgain = JTSFunctions.logoLines(null); + assertTrue(logoAgain instanceof MultiCurve); + assertEquals("logoLines is not flattened by the halo helper", + logo.getNumPoints(), logoAgain.getNumPoints()); + } + + public void testHaloIsNotLogoBuffer() { + Geometry halo = JTSFunctions.logoClothoid(null); + Geometry circular = JTSFunctions.logoBuffer(null, 12.0); + assertFalse("clothoid halo must not reuse logoBuffer (MKT-1)", + halo.equalsExact(circular)); + assertFalse(halo.equalsNorm(circular)); + } + + public void testRegistryExposesBothNames() { + GeometryFunctionRegistry registry = + GeometryFunctionRegistry.createTestBuilderRegistry(); + GeometryFunction logo = registry.find("logoClothoid"); + GeometryFunction halo = registry.find("clothoidHalo"); + assertNotNull("TestBuilder must expose logoClothoid", logo); + assertNotNull("TestBuilder must expose clothoidHalo", halo); + assertEquals("logo as curves plus a clothoid halo.", logo.getDescription()); + assertEquals("logo as curves plus a clothoid halo.", halo.getDescription()); + } + + private static void assertNamedLinearFallback(String label, Geometry halo) { + assertNotNull(label + " must return a geometry", halo); + assertFalse(label + " must not be empty", halo.isEmpty()); + assertTrue(label + " must be LineString / Polygon / MultiPolygon, got " + + halo.getClass().getName(), + halo instanceof LineString + || halo instanceof Polygon + || halo instanceof MultiPolygon); + assertFalse(label + " must not be a CircularString", + halo instanceof CircularString); + Object stamp = halo.getUserData(); + assertTrue(label + " stamp must be NAMED-APPROX or CHORD-PATH, got " + stamp, + JTSFunctions.CLOTHOID_HALO_STAMP_NAMED_APPROX.equals(stamp) + || JTSFunctions.CLOTHOID_HALO_STAMP_CHORD_PATH.equals(stamp)); + if (halo instanceof LineString) { + assertTrue(label + " path should be closed", ((LineString) halo).isClosed()); + } + if (halo instanceof Polygon) { + assertTrue(label + " polygonal halo should be valid", halo.isValid()); + assertTrue(label + " polygonal halo should have area", halo.getArea() > 0.0); + } + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoLinesTranslateTest.java b/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoLinesTranslateTest.java new file mode 100644 index 0000000000..c7d8b83aae --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoLinesTranslateTest.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.function; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.MultiCurve; +import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.io.curve.CurveWKTWriter; +import org.locationtech.jtstest.testbuilder.geom.GeometryComponentTransformer; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; +import junit.textui.TestRunner; + +/** + * Canvas miss is MoveTool drag, not Function-tree AffineTranslation. + *

+ * {@code logoLines} then MoveTool.execute ({@code translationInstance} + * + {@link GeometryComponentTransformer#transform(Geometry, AffineTransformation)}, + * which is {@code copy(); apply(trans)}) must keep the ISO/IEC 13249-3 + * MultiCurve and every three-point {@code CIRCULARSTRING}, including + * each circular member's start. + *

+ * Verified same apply: Function-tree + * {@link AffineTransformationFunctions#translate} is + * {@code translationInstance(dx, dy).transform(g)}, which is also + * {@code copy(); apply(trans)}. That is stated, not a UX SIGN of the + * Function-tree. Testers still shoot AffineTranslation (10, 8) on pin + * JAR {@code 61eb3377}. Do not retip that pin. Do not rebuild the + * guides JAR. Translate only. No Bézier I/O type. + * {@code logoBuffer} stays named CHORD-PATH / toLinear + BufferOp. + */ +public class JTSFunctionsLogoLinesTranslateTest extends TestCase { + + private static final double DX = 10.0; + private static final double DY = 8.0; + private static final double EPS = 1.0e-12; + + public static void main(String[] args) { TestRunner.run(suite()); } + public static Test suite() { return new TestSuite(JTSFunctionsLogoLinesTranslateTest.class); } + public JTSFunctionsLogoLinesTranslateTest(String name) { super(name); } + + public void testMoveToolDragKeepsTypesAndArcStarts() { + Geometry logo = JTSFunctions.logoLines(null); + Geometry moved = moveToolTranslate(logo, DX, DY); + + assertTrue(moved instanceof MultiCurve); + assertEquals(logo.getNumGeometries(), moved.getNumGeometries()); + assertTranslatedTree(logo, moved); + + String wkt = new CurveWKTWriter().write(moved); + assertTrue(wkt.contains("MULTICURVE")); + assertTrue(wkt.contains("COMPOUNDCURVE")); + assertTrue(wkt.contains("CIRCULARSTRING")); + assertFalse(wkt.toUpperCase().contains("BEZIER")); + } + + /** + * Same apply, not a Function-tree SIGN. MoveTool.execute and + * AffineTransformation.translate are both {@code copy(); apply(trans)}. + */ + public void testFunctionTreeTranslateIsTheSameApplyAsMoveTool() { + Geometry logo = JTSFunctions.logoLines(null); + Geometry viaMoveTool = moveToolTranslate(logo, DX, DY); + Geometry viaFunctionTree = AffineTransformationFunctions.translate(logo, DX, DY); + assertTrue("Function-tree AffineTranslation is the same copy(); apply(trans) as MoveTool", + viaMoveTool.equalsExact(viaFunctionTree)); + } + + public void testLogoBufferPathUnchangedAfterTranslate() { + Geometry moved = moveToolTranslate(JTSFunctions.logoLines(null), DX, DY); + Geometry halo = JTSFunctions.logoBuffer(moved, 4.0); + assertTrue("logoBuffer stays polygonal CHORD-PATH", + halo instanceof Polygon || halo.getGeometryType().contains("Polygon")); + assertFalse("named fallback, never isApproximate()=false", + halo.getGeometryType().equals("CircularString")); + } + + /** MoveTool.execute whole-geom path. */ + private static Geometry moveToolTranslate(Geometry geom, double dx, double dy) { + return GeometryComponentTransformer.transform( + geom, AffineTransformation.translationInstance(dx, dy)); + } + + private static void assertTranslatedTree(Geometry original, Geometry moved) { + assertEquals(original.getGeometryType(), moved.getGeometryType()); + if (original instanceof CircularString) { + assertEquals(3, moved.getNumPoints()); + assertTranslated(original.getCoordinates(), moved.getCoordinates()); + return; + } + if (original instanceof CompoundCurve) { + CompoundCurve a = (CompoundCurve) original; + CompoundCurve b = (CompoundCurve) moved; + assertEquals(a.getNumMembers(), b.getNumMembers()); + for (int i = 0; i < a.getNumMembers(); i++) { + assertTranslatedTree(a.getMemberN(i), b.getMemberN(i)); + } + return; + } + if (original instanceof GeometryCollection) { + assertEquals(original.getNumGeometries(), moved.getNumGeometries()); + for (int i = 0; i < original.getNumGeometries(); i++) { + assertTranslatedTree(original.getGeometryN(i), moved.getGeometryN(i)); + } + return; + } + if (original instanceof LineString) { + assertTranslated(original.getCoordinates(), moved.getCoordinates()); + } + } + + private static void assertTranslated(Coordinate[] from, Coordinate[] to) { + assertEquals(from.length, to.length); + for (int i = 0; i < from.length; i++) { + assertEquals(from[i].x + DX, to[i].x, EPS); + assertEquals(from[i].y + DY, to[i].y, EPS); + } + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/geomfunction/CurveAwarenessBadgeTest.java b/modules/app/src/test/java/org/locationtech/jtstest/geomfunction/CurveAwarenessBadgeTest.java new file mode 100644 index 0000000000..b65dae5c21 --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/geomfunction/CurveAwarenessBadgeTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Jeroen Tech Solutions Ltd / JTS contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.geomfunction; + +import java.lang.reflect.Method; + +import org.locationtech.jtstest.function.GeometryFunctions; +import org.locationtech.jtstest.function.OffsetCurveFunctions; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * TB-FN (#1195): Metadata.curveAwareness wires into GeometryFunction. + */ +public class CurveAwarenessBadgeTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveAwarenessBadgeTest.class); + } + + public CurveAwarenessBadgeTest(String name) { + super(name); + } + + public void testOffsetCurveIsNative() throws Exception { + Method m = OffsetCurveFunctions.class.getMethod("offsetCurve", + org.locationtech.jts.geom.Geometry.class, double.class); + GeometryFunction f = StaticMethodGeometryFunction.createFunction(m); + assertEquals("native", f.getCurveAwareness()); + } + + public void testLengthIsNative() throws Exception { + Method m = GeometryFunctions.class.getMethod("length", + org.locationtech.jts.geom.Geometry.class); + GeometryFunction f = StaticMethodGeometryFunction.createFunction(m); + assertEquals("native", f.getCurveAwareness()); + } + + public void testUnannotatedDefaultsToFlattens() throws Exception { + Method m = GeometryFunctions.class.getMethod("SRID", + org.locationtech.jts.geom.Geometry.class); + GeometryFunction f = StaticMethodGeometryFunction.createFunction(m); + assertEquals("flattens", f.getCurveAwareness()); + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/io/CurveWKBExportHonestyTest.java b/modules/app/src/test/java/org/locationtech/jtstest/io/CurveWKBExportHonestyTest.java index 86504d8095..8aa8301cf3 100644 --- a/modules/app/src/test/java/org/locationtech/jtstest/io/CurveWKBExportHonestyTest.java +++ b/modules/app/src/test/java/org/locationtech/jtstest/io/CurveWKBExportHonestyTest.java @@ -29,10 +29,9 @@ import junit.textui.TestRunner; /** - * Default / export WKB paths must use {@code CurveWKBWriter}, not the - * flattening core {@code WKBWriter}. A CircularString that hits - * {@code instanceof LineString} would emit type 2; a CurvePolygon that - * hits Polygon would emit type 3. + * TestBuilder / jtsop export WKB must stay on {@code CurveWKBWriter} + * so types 8–12 survive. Core refuse + ISO/EXTENDED flavour are #51 + * on #7; this file does not restack that writer. */ public class CurveWKBExportHonestyTest extends TestCase { diff --git a/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanelFocusTest.java b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanelFocusTest.java new file mode 100644 index 0000000000..0dc257d9a0 --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/SpatialFunctionPanelFocusTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.testbuilder; + +import org.locationtech.jtstest.geomfunction.GeometryFunction; +import org.locationtech.jtstest.geomfunction.StaticMethodGeometryFunction; +import org.locationtech.jtstest.function.AffineTransformationFunctions; +import org.locationtech.jtstest.function.BufferFunctions; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * TB-FN #60: Exec must stay bound to the selected Geometry function + * (e.g. AffineTranslation), not silently re-bind to Buffer.buffer when + * the shared Distance/dX param field is focused. + */ +public class SpatialFunctionPanelFocusTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(SpatialFunctionPanelFocusTest.class); + } + + public SpatialFunctionPanelFocusTest(String name) { + super(name); + } + + public void testTranslateAndBufferAreDistinctFunctions() throws Exception { + GeometryFunction translate = StaticMethodGeometryFunction.createFunction( + AffineTransformationFunctions.class.getMethod("translate", + org.locationtech.jts.geom.Geometry.class, double.class, double.class)); + GeometryFunction buffer = StaticMethodGeometryFunction.createFunction( + BufferFunctions.class.getMethod("buffer", + org.locationtech.jts.geom.Geometry.class, double.class)); + assertFalse(translate.getName().equals(buffer.getName())); + assertEquals("translate", translate.getName()); + assertEquals("buffer", buffer.getName()); + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/io/IOUtilFlattenHonestyTest.java b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/io/IOUtilFlattenHonestyTest.java new file mode 100644 index 0000000000..97625bf35c --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/io/IOUtilFlattenHonestyTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.testbuilder.io; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.framework.TestSuite; +import junit.textui.TestRunner; + +/** + * TestBuilder clipboard / WKT paths must keep COMPOUNDCURVE members + * (SQL/MM ISO/IEC 13249-3). GML2 cannot carry arcs and must refuse. + */ +public class IOUtilFlattenHonestyTest extends TestCase { + + private static final String COMPOUNDCURVE = + "COMPOUNDCURVE (CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 20 0))"; + + public static void main(String[] args) { TestRunner.run(suite()); } + public static junit.framework.Test suite() { return new TestSuite(IOUtilFlattenHonestyTest.class); } + public IOUtilFlattenHonestyTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + public void testToWktKeepsCompoundCurveMembers() throws Exception { + Geometry cc = readCurve(COMPOUNDCURVE); + String wkt = IOUtil.toWKT(cc, true); + assertTrue("formatted WKT must keep CIRCULARSTRING member, was: " + wkt, + wkt.toUpperCase().contains("CIRCULARSTRING")); + CompoundCurve back = (CompoundCurve) new CurveWKTReader( + new CurveGeometryFactory()).read(wkt); + assertEquals(2, back.getNumMembers()); + assertTrue(back.getMemberN(0) instanceof CircularString); + } + + public void testToWktUnformattedUsesToText() throws Exception { + Geometry cc = readCurve(COMPOUNDCURVE); + String wkt = IOUtil.toWKT(cc, false); + assertTrue("unformatted toString/toText must keep CIRCULARSTRING, was: " + wkt, + wkt.toUpperCase().contains("CIRCULARSTRING")); + } + + public void testToGmlRefusesCompoundCurve() throws Exception { + Geometry cc = readCurve(COMPOUNDCURVE); + try { + IOUtil.toGML(cc); + fail("IOUtil.toGML must not emit a control-polygon LineString"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriterCurveZooTest.java b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriterCurveZooTest.java new file mode 100644 index 0000000000..a1f2249754 --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/GeometryLocationsWriterCurveZooTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Jeroen Tech Solutions Ltd / JTS contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.testbuilder.ui; + +import java.util.Collections; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.BezierCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.EllipseCurve; +import org.locationtech.jts.geom.curve.NurbsCurve; +import org.locationtech.jtstest.testbuilder.geom.GeometryLocation; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * Phase 4/5 (#1195): inspect labels for WKB 18–21 greenfield types. + */ +public class GeometryLocationsWriterCurveZooTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(GeometryLocationsWriterCurveZooTest.class); + } + + public GeometryLocationsWriterCurveZooTest(String name) { + super(name); + } + + public void testBezierEllipseNurbsLabels() { + CurveGeometryFactory gf = new CurveGeometryFactory(); + Geometry bez = gf.createBezierCurve(gf.getCoordinateSequenceFactory().create( + new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 2), + new Coordinate(3, 2), new Coordinate(4, 0) + })); + Geometry ell = gf.createEllipseCurve(0, 0, Double.NaN, 5, 3, 0, 0, Math.PI); + Geometry nur = gf.createNurbsCurve( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 0) + }), + 2, + new double[] { 1, 1, 1 }, + new double[] { 0, 0, 0, 1, 1, 1 }); + + assertTrue(bez instanceof BezierCurve); + assertTrue(ell instanceof EllipseCurve); + assertTrue(nur instanceof NurbsCurve); + + GeometryLocationsWriter w = new GeometryLocationsWriter(); + String sBez = labelOf(w, bez); + String sEll = labelOf(w, ell); + String sNur = labelOf(w, nur); + assertTrue(sBez, sBez.indexOf("Bezier") >= 0); + assertTrue(sEll, sEll.indexOf("Ellipse") >= 0); + assertTrue(sNur, sNur.indexOf("NURBS") >= 0); + } + + private static String labelOf(GeometryLocationsWriter w, Geometry g) { + GeometryLocation loc = new GeometryLocation(g, g, new int[] { 0 }, 0, true, + g.getCoordinate()); + return w.writeFacetLocations(Collections.singletonList(loc)); + } +} diff --git a/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/tools/CurveDrawingToolsExistTest.java b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/tools/CurveDrawingToolsExistTest.java new file mode 100644 index 0000000000..d961b1f98f --- /dev/null +++ b/modules/app/src/test/java/org/locationtech/jtstest/testbuilder/ui/tools/CurveDrawingToolsExistTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jtstest.testbuilder.ui.tools; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * TB-T (#1195): CompoundCurveTool and CurvePolygonTool exist as siblings + * of CircularStringTool. Gesture locks live in CurvePolygonToolTest (#56). + */ +public class CurveDrawingToolsExistTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveDrawingToolsExistTest.class); + } + + public CurveDrawingToolsExistTest(String name) { + super(name); + } + + public void testToolsExist() { + assertNotNull(CircularStringTool.getInstance()); + assertNotNull(CompoundCurveTool.getInstance()); + assertNotNull(CurvePolygonTool.getInstance()); + assertSame(CompoundCurveTool.getInstance(), CompoundCurveTool.getInstance()); + assertSame(CurvePolygonTool.getInstance(), CurvePolygonTool.getInstance()); + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/InteriorPointArea.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/InteriorPointArea.java index b35cf13846..639805c4a5 100644 --- a/modules/core/src/main/java/org/locationtech/jts/algorithm/InteriorPointArea.java +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/InteriorPointArea.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import org.locationtech.jts.densify.Densifier; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Envelope; @@ -80,7 +81,16 @@ public class InteriorPointArea { * or null if the geometry has no polygonal components */ public static Coordinate getInteriorPoint(Geometry geom) { - InteriorPointArea intPt = new InteriorPointArea(geom); + Geometry src = geom; + if (geom != null && geom.getClass().getName().indexOf(".geom.curve.") >= 0) { + // C-IP (#1195): use densified rings so the interior point lies + // inside the curved area, not the control polygon. + Envelope env = geom.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-6; + src = Densifier.densify(geom, tol); + } + InteriorPointArea intPt = new InteriorPointArea(src); return intPt.getInteriorPoint(); } diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/distance/DiscreteHausdorffDistance.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/distance/DiscreteHausdorffDistance.java index aab19e0f41..fbed4419be 100644 --- a/modules/core/src/main/java/org/locationtech/jts/algorithm/distance/DiscreteHausdorffDistance.java +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/distance/DiscreteHausdorffDistance.java @@ -301,18 +301,36 @@ private void computeOrientedDistance(Geometry discreteGeom, Geometry geom, Point if (computeExactOriented(discreteGeom, geom, ptDist)) { return; } + // D-HF (#1195): general curve inputs densify so the vertex path + // samples the arc, not only control chords. Certified pairs above + // still own the closed form (and skip densifyFrac on that path). + Geometry sampled = densifyCurvePackage(discreteGeom); MaxPointDistanceFilter distFilter = new MaxPointDistanceFilter(geom); - discreteGeom.apply(distFilter); + sampled.apply(distFilter); ptDist.setMaximum(distFilter.getMaxPointDistance()); if (densifyFrac > 0) { MaxDensifiedByFractionDistanceFilter fracFilter = new MaxDensifiedByFractionDistanceFilter(geom, densifyFrac); - discreteGeom.apply(fracFilter); + sampled.apply(fracFilter); ptDist.setMaximum(fracFilter.getMaxPointDistance()); } } + /** + * Densify jts-curve package geometries for the general Hausdorff path. + * Detection by package name so core does not import jts-curve. + */ + private static Geometry densifyCurvePackage(Geometry g) { + if (g == null || g.getClass().getName().indexOf(".geom.curve.") < 0) { + return g; + } + org.locationtech.jts.geom.Envelope env = g.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-4; + return org.locationtech.jts.densify.Densifier.densify(g, tol); + } + /** * Closed form for the two certified pairs. Returns {@code true} when * {@code ptDist} was updated and the vertex / chord path must be skipped. diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/hull/ConcaveHull.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/hull/ConcaveHull.java index 4cd75440f5..10a1cbc872 100644 --- a/modules/core/src/main/java/org/locationtech/jts/algorithm/hull/ConcaveHull.java +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/hull/ConcaveHull.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.PriorityQueue; +import org.locationtech.jts.densify.Densifier; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; @@ -182,9 +184,25 @@ public static Geometry alphaShape(Geometry geom, double alpha, boolean isHolesAl * @param geom the input geometry */ public ConcaveHull(Geometry geom) { - this.inputGeometry = geom; + this.inputGeometry = densifyCurveSites(geom); this.geomFactory = geom.getFactory(); } + + /** + * H-CC (#1195): densify curve-package inputs so sites lie on the arc, + * not only on control points. Hull fraction is coarser than generic + * densify (1e-4 of extent) to avoid ribbon collapse of point-set + * concave hulls (see HullFunctions / H-CC-PINCH). + */ + private static Geometry densifyCurveSites(Geometry geom) { + if (geom == null || geom.getClass().getName().indexOf(".geom.curve.") < 0) { + return geom; + } + Envelope env = geom.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-4; + return Densifier.densify(geom, tol); + } /** * Sets the target maximum edge length for the concave hull. diff --git a/modules/core/src/main/java/org/locationtech/jts/densify/Densifier.java b/modules/core/src/main/java/org/locationtech/jts/densify/Densifier.java index 47b91759cf..afe288baba 100644 --- a/modules/core/src/main/java/org/locationtech/jts/densify/Densifier.java +++ b/modules/core/src/main/java/org/locationtech/jts/densify/Densifier.java @@ -51,11 +51,46 @@ public class Densifier { * @return the densified geometry */ public static Geometry densify(Geometry geom, double distanceTolerance) { + Geometry curveLinear = densifyCurveViaToLinear(geom, distanceTolerance); + if (curveLinear != null) { + return curveLinear; + } Densifier densifier = new Densifier(geom); densifier.setDistanceTolerance(distanceTolerance); return densifier.getResultGeometry(); } + /** + * DSF (#1195): when {@code geom} is a jts-curve {@code Linearizable} + * type, densify by {@code toLinear(tolerance)} so samples lie on the + * arc, not the control chord. Detection uses the class package name so + * core does not import jts-curve. + * + * @return densified geometry, or {@code null} to use chord densify + */ + private static Geometry densifyCurveViaToLinear(Geometry geom, + double distanceTolerance) { + if (geom == null) { + return null; + } + String cn = geom.getClass().getName(); + if (cn.indexOf(".geom.curve.") < 0) { + return null; + } + try { + java.lang.reflect.Method m = geom.getClass().getMethod("toLinear", + double.class); + Object out = m.invoke(geom, Double.valueOf(distanceTolerance)); + if (out instanceof Geometry) { + return (Geometry) out; + } + } + catch (ReflectiveOperationException ex) { + return null; + } + return null; + } + /** * Densifies a list of coordinates. * diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java b/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java index 7119b026c7..a8b43b1201 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java @@ -706,6 +706,50 @@ public MultiPolygon createMultiSurface(Polygon[] members) { throw unsupportedCurve(); } + /** + * Creates a CRV-CLOTHOID (WKB 18) from start pose and spiral parameters. + * Default factory cannot construct curve types; {@code CurveGeometryFactory} + * overrides. Do not implement by densifying to a LineString. + * + * @param start start point (Z/M preserved when present) + * @param startTangent heading at start (radians, XY-up CCW) + * @param startKappa curvature κ₀ + * @param endKappa curvature κ₁ ({@code !=} startKappa) + * @param length arc length L {@code > 0} + * @return a clothoid (as a LineString) + */ + public LineString createClothoid(Coordinate start, double startTangent, + double startKappa, double endKappa, double length) { + throw unsupportedCurve(); + } + + /** + * Creates a PRF-BEZIER (WKB 19) from cubic control points ({@code 3k+1}). + * Default factory cannot construct; {@code CurveGeometryFactory} overrides. + */ + public LineString createBezierCurve(CoordinateSequence controls) { + throw unsupportedCurve(); + } + + /** + * Creates a PRF-ELLIPSE (WKB 20). Default factory cannot construct; + * {@code CurveGeometryFactory} overrides. + */ + public LineString createEllipseCurve(double centreX, double centreY, + double centreZ, double semiMajor, double semiMinor, double rotation, + double startAngle, double endAngle) { + throw unsupportedCurve(); + } + + /** + * Creates a CRV-NURBS (WKB 21). Default factory cannot construct; + * {@code CurveGeometryFactory} overrides. + */ + public LineString createNurbsCurve(CoordinateSequence controls, int degree, + double[] weights, double[] knots) { + throw unsupportedCurve(); + } + private static UnsupportedOperationException unsupportedCurve() { return new UnsupportedOperationException( "requires a GeometryFactory that can construct curve types (CurveGeometryFactory)."); diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/SqlMmTypes.java b/modules/core/src/main/java/org/locationtech/jts/geom/SqlMmTypes.java new file mode 100644 index 0000000000..502db7d72d --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/geom/SqlMmTypes.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom; + +/** + * I/O type identity (Architect SIGN 16 Aug 2026, ClaimId MMF-IO). + * SQL/MM ISO/IEC 13249-3 types 8–12 plus ISO Z/M/ZM + * ({@code CircularStringZ=1008} … {@code MultiSurfaceZM=3012}). + * Core writers must not emit a LineString / Polygon / Multi* + * control polygon for CircularString / CompoundCurve / + * CurvePolygon / MultiCurve / MultiSurface. + * I/O identity is not overlay honesty. Flavour ISO/EXTENDED + * is #51 on #7 — this helper does not reimplement it. + * {@link LinearRing} → {@link LineString} is the only allowed + * collapse. HOLD GEO-TIN 15–17. HOLD elliptic / Bézier. No DOI. + */ +public final class SqlMmTypes { + + private SqlMmTypes() {} + + /** + * Refuse a geometry (and collection members) that would flatten + * a SQL/MM curve type to a core lineal / polygonal type. + * + * @param geom the geometry to check (null is ignored) + * @param site the core entry point that would have flattened + */ + public static void refuseFlatten(Geometry geom, String site) { + refuseFlatten(geom, site, false); + } + + /** + * @param allowCircularStringKeyword {@code true} only for core + * {@code WKTWriter}, which already emits the CIRCULARSTRING + * keyword from {@link Geometry#getGeometryType()}. CompoundCurve + * members still refuse — that path collapses members to one seq. + */ + public static void refuseFlatten(Geometry geom, String site, + boolean allowCircularStringKeyword) { + if (geom == null) { + return; + } + refuseOne(geom, site, allowCircularStringKeyword); + if (geom instanceof GeometryCollection) { + for (int i = 0; i < geom.getNumGeometries(); i++) { + Geometry g = geom.getGeometryN(i); + if (g != geom) { + refuseFlatten(g, site, allowCircularStringKeyword); + } + } + } + } + + /** + * Refuse this geometry only (not collection members). + * Used by {@code WKTWriter} so a GeometryCollection can still + * dispatch members to {@code CurveWKTWriter}. + */ + public static void refuseOne(Geometry geom, String site, + boolean allowCircularStringKeyword) { + if (geom instanceof LineString) { + if (geom.getClass() == LineString.class || geom instanceof LinearRing) { + return; + } + if (allowCircularStringKeyword + && "CircularString".equals(geom.getGeometryType())) { + return; + } + throw flattenRefused(geom, site); + } + if (geom instanceof Polygon) { + if (geom.getClass() == Polygon.class) { + return; + } + // HOLD GEO-TIN 15–17: Triangle / PolyhedralSurface / TIN are not + // SQL/MM 8–12 flatten targets; core WKTWriter may emit their keywords. + String t = geom.getGeometryType(); + if ("Triangle".equals(t) || "PolyhedralSurface".equals(t) + || "Tin".equals(t) || "TIN".equals(t)) { + return; + } + throw flattenRefused(geom, site); + } + if (geom instanceof MultiLineString) { + if (geom.getClass() == MultiLineString.class) { + return; + } + throw flattenRefused(geom, site); + } + if (geom instanceof MultiPolygon) { + if (geom.getClass() == MultiPolygon.class) { + return; + } + // HOLD GEO-TIN 15–17: PolyhedralSurface / TIN are not 8–12. + String t = geom.getGeometryType(); + if ("PolyhedralSurface".equals(t) || "Tin".equals(t) || "TIN".equals(t)) { + return; + } + throw flattenRefused(geom, site); + } + } + + public static IllegalArgumentException flattenRefused(Geometry geom, String site) { + return new IllegalArgumentException( + site + " cannot flatten " + geom.getGeometryType() + + " to a core type. SQL/MM ISO/IEC 13249-3 types 8–12 " + + "(CircularString, CompoundCurve, CurvePolygon, MultiCurve, " + + "MultiSurface) must use a curve-aware path. " + + "LinearRing→LineString is the only allowed collapse."); + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/util/AffineTransformation.java b/modules/core/src/main/java/org/locationtech/jts/geom/util/AffineTransformation.java index ef3814678d..216e1ababa 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/util/AffineTransformation.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/util/AffineTransformation.java @@ -12,9 +12,11 @@ package org.locationtech.jts.geom.util; +import org.locationtech.jts.densify.Densifier; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateSequenceFilter; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.math.MathUtil; import org.locationtech.jts.util.Assert; @@ -975,19 +977,52 @@ public Coordinate transform(Coordinate src, Coordinate dest) return dest; } + /** + * True when this transform is a similarity (uniform scale + rotation + * + translation, possibly with reflection). Shear and non-uniform + * scale return false. Used so circular arcs stay circular under + * AT-S and densify under AT-NS (#1195). + * + * @return true if the linear part is a scalar multiple of an orthogonal matrix + */ + public boolean isSimilarity() + { + double c0len2 = m00 * m00 + m10 * m10; + double c1len2 = m01 * m01 + m11 * m11; + double dot = m00 * m01 + m10 * m11; + double scale = Math.max(1.0, Math.max(c0len2, c1len2)); + return Math.abs(c0len2 - c1len2) <= 1.0e-12 * scale + && Math.abs(dot) <= 1.0e-12 * scale; + } + /** * Creates a new {@link Geometry} which is the result * of this transformation applied to the input Geometry. + *

+ * AT-NS (#1195): non-similarity transforms densify jts-curve + * geometries via {@link Densifier}→{@code toLinear} before applying, + * so a sheared CircularString does not falsely remain a CircularString. * *@param g a Geometry *@return a transformed Geometry */ public Geometry transform(Geometry g) { - Geometry g2 = g.copy(); + Geometry src = g; + if (!isSimilarity() && isCurvePackageGeometry(g)) { + Envelope env = g.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-6; + src = Densifier.densify(g, tol); + } + Geometry g2 = src.copy(); g2.apply(this); return g2; } + + private static boolean isCurvePackageGeometry(Geometry g) { + return g != null && g.getClass().getName().indexOf(".geom.curve.") >= 0; + } /** * Applies this transformation to the i'th coordinate diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKBConstants.java b/modules/core/src/main/java/org/locationtech/jts/io/WKBConstants.java index 8010669642..08747e31db 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKBConstants.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKBConstants.java @@ -28,10 +28,14 @@ public interface WKBConstants { /** * ISO/IEC 13249-3 (SQL/MM Spatial) type codes for curve geometries. - * Same integers as GEOS {@code WKBConstants}: types 8–12 only. - * No {@code wkbCurve} / {@code wkbSurface}. No WKB 15–17 + * Same integers as GEOS {@code WKBConstants} for types 8–12. + * No {@code wkbCurve} / {@code wkbSurface}. WKB 15–17 * (Triangle / PolyhedralSurface / TIN) — GEO-TIN waits Architect SIGN. - * Unknown types throw. + *

+ * Fork MMF (#1195) greenfield zoo (SIGNED): + * {@link #wkbClothoid}=18, {@link #wkbBezier}=19, + * {@link #wkbEllipse}=20, {@link #wkbNurbs}=21. + * Unknown types outside the signed set throw. */ int wkbCircularString = 8; int wkbCompoundCurve = 9; @@ -39,6 +43,15 @@ public interface WKBConstants { int wkbMultiCurve = 11; int wkbMultiSurface = 12; + /** CRV-CLOTHOID — Euler / Cornu spiral (fork SIGN 18). */ + int wkbClothoid = 18; + /** PRF-BEZIER — cubic Bézier curve geometry (fork SIGN 19). */ + int wkbBezier = 19; + /** PRF-ELLIPSE — elliptic arc / ellipse primitive (fork SIGN 20). */ + int wkbEllipse = 20; + /** CRV-NURBS — NURBS curve (fork SIGN 21). */ + int wkbNurbs = 21; + /** * Writer flavour: PostGIS / SFSQL Extended WKB (EWKB high bits for * Z / M / SRID). Default, matching GEOS {@code WKBWriter}. diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKBReader.java b/modules/core/src/main/java/org/locationtech/jts/io/WKBReader.java index 4620cc160b..72516e8c68 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKBReader.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKBReader.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.util.EnumSet; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateSequenceFactory; import org.locationtech.jts.geom.CoordinateSequences; @@ -343,6 +344,18 @@ else if(isStrict) case WKBConstants.wkbMultiSurface : geom = readMultiSurface(SRID); break; + case WKBConstants.wkbClothoid : + geom = readClothoid(ordinateFlags); + break; + case WKBConstants.wkbBezier : + geom = readBezier(ordinateFlags); + break; + case WKBConstants.wkbEllipse : + geom = readEllipse(ordinateFlags); + break; + case WKBConstants.wkbNurbs : + geom = readNurbs(ordinateFlags); + break; default: geom = readOtherGeometry(geometryType, ordinateFlags, SRID); break; @@ -375,6 +388,88 @@ private Geometry readCircularString(EnumSet ordinateFlags) } } + /** + * CRV-CLOTHOID (WKB 18). Greenfield fork layout: + * {@code startXY[ZM] + startTangent + startKappa + endKappa + length}. + * No nested Point header. End pose is recovered analytically. + */ + private Geometry readClothoid(EnumSet ordinateFlags) + throws IOException, ParseException { + CoordinateSequence startSeq = readCoordinateSequence(1, ordinateFlags); + double startTangent = dis.readDouble(); + double startKappa = dis.readDouble(); + double endKappa = dis.readDouble(); + double length = dis.readDouble(); + try { + return factory.createClothoid(startSeq.getCoordinate(0), + startTangent, startKappa, endKappa, length); + } + catch (UnsupportedOperationException e) { + throw curveFactoryRequired(WKBConstants.wkbClothoid, e); + } + } + + /** PRF-BEZIER (WKB 19): count + control coordinates ({@code 3k+1}). */ + private Geometry readBezier(EnumSet ordinateFlags) + throws IOException, ParseException { + LineString ls = readLineString(ordinateFlags); + try { + return factory.createBezierCurve(ls.getCoordinateSequence()); + } + catch (UnsupportedOperationException e) { + throw curveFactoryRequired(WKBConstants.wkbBezier, e); + } + } + + /** + * PRF-ELLIPSE (WKB 20): centre point ordinates (no size) then + * {@code a, b, rotation, startAngle, endAngle}. + */ + private Geometry readEllipse(EnumSet ordinateFlags) + throws IOException, ParseException { + CoordinateSequence centre = readCoordinateSequence(1, ordinateFlags); + Coordinate c = centre.getCoordinate(0); + double a = dis.readDouble(); + double b = dis.readDouble(); + double rot = dis.readDouble(); + double a0 = dis.readDouble(); + double a1 = dis.readDouble(); + double z = Double.isNaN(c.getZ()) ? Double.NaN : c.getZ(); + try { + return factory.createEllipseCurve(c.x, c.y, z, a, b, rot, a0, a1); + } + catch (UnsupportedOperationException e) { + throw curveFactoryRequired(WKBConstants.wkbEllipse, e); + } + } + + /** + * CRV-NURBS (WKB 21): {@code degree}, control count + coords, + * weights[count], knot count + knots[]. + */ + private Geometry readNurbs(EnumSet ordinateFlags) + throws IOException, ParseException { + int degree = dis.readInt(); + LineString controls = readLineString(ordinateFlags); + int n = controls.getNumPoints(); + double[] weights = new double[n]; + for (int i = 0; i < n; i++) { + weights[i] = dis.readDouble(); + } + int nKnots = dis.readInt(); + double[] knots = new double[nKnots]; + for (int i = 0; i < nKnots; i++) { + knots[i] = dis.readDouble(); + } + try { + return factory.createNurbsCurve(controls.getCoordinateSequence(), + degree, weights, knots); + } + catch (UnsupportedOperationException e) { + throw curveFactoryRequired(WKBConstants.wkbNurbs, e); + } + } + private Geometry readCompoundCurve(int SRID) throws IOException, ParseException { int numGeom = readNumField(FIELD_NUMELEMS); diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKBWriter.java b/modules/core/src/main/java/org/locationtech/jts/io/WKBWriter.java index 900f9ba653..6f0b825ad7 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKBWriter.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKBWriter.java @@ -443,7 +443,7 @@ public void write(Geometry geom, OutStream os) throws IOException throw new IllegalArgumentException( "WKB type " + geom.getGeometryType() + " requires CurveWKBWriter; core WKBWriter must not flatten" - + " SQL/MM types 8–12 to LineString or Polygon"); + + " SQL/MM / curve-zoo types 8–12 or 18–21 to LineString or Polygon"); } if (geom instanceof Point) @@ -494,7 +494,15 @@ private static boolean isSqlMmCurveType(Geometry geom) { || WKTConstants.COMPOUNDCURVE.equalsIgnoreCase(type) || WKTConstants.CURVEPOLYGON.equalsIgnoreCase(type) || WKTConstants.MULTICURVE.equalsIgnoreCase(type) - || WKTConstants.MULTISURFACE.equalsIgnoreCase(type); + || WKTConstants.MULTISURFACE.equalsIgnoreCase(type) + || WKTConstants.CLOTHOID.equalsIgnoreCase(type) + || "ClothoidSegment".equalsIgnoreCase(type) + || WKTConstants.BEZIER.equalsIgnoreCase(type) + || "BezierCurve".equalsIgnoreCase(type) + || WKTConstants.ELLIPSE.equalsIgnoreCase(type) + || "EllipseCurve".equalsIgnoreCase(type) + || WKTConstants.NURBS.equalsIgnoreCase(type) + || "NurbsCurve".equalsIgnoreCase(type); } private void writePoint(Point pt, EnumSet outputOrdinates, OutStream os) throws IOException @@ -623,6 +631,16 @@ protected void writeInt(int intValue, OutStream os) throws IOException os.write(buf, 4); } + /** + * Writes one IEEE-754 double in the writer byte order. + * Used by curve zoo payloads (e.g. CRV-CLOTHOID parameters). + */ + protected void writeDouble(double value, OutStream os) throws IOException + { + ByteOrderValues.putDouble(value, buf, byteOrder); + os.write(buf, 8); + } + protected void writeCoordinateSequence(CoordinateSequence seq, EnumSet outputOrdinates, boolean writeSize, OutStream os) throws IOException { diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java b/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java index f74b916bae..bf360d991c 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java @@ -40,6 +40,14 @@ public class WKTConstants { public static final String POLYHEDRALSURFACE = "POLYHEDRALSURFACE"; public static final String TIN = "TIN"; public static final String TRIANGLE = "TRIANGLE"; + /** CRV-CLOTHOID (WKB 18). */ + public static final String CLOTHOID = "CLOTHOID"; + /** PRF-BEZIER (WKB 19). */ + public static final String BEZIER = "BEZIER"; + /** PRF-ELLIPSE (WKB 20). */ + public static final String ELLIPSE = "ELLIPSE"; + /** CRV-NURBS (WKB 21). */ + public static final String NURBS = "NURBS"; public static final String EMPTY = "EMPTY"; diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java b/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java index 899af4ec61..78410b2c04 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java @@ -30,6 +30,7 @@ import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.geom.SqlMmTypes; import org.locationtech.jts.util.Assert; /** @@ -459,12 +460,17 @@ private void appendGeometryTaggedText( { indent(useFormatting, level, writer); - // Extension hook for SFA / ISO 19125-2 geometries (curve, surface, etc.) + // Extension hook for SQL/MM ISO/IEC 13249-3 types 8–12. if (appendOtherGeometryTaggedText(geometry, outputOrdinates, useFormatting, level, writer, formatter)) { return; } + // CIRCULARSTRING keyword via getGeometryType() is OK. CompoundCurve + // members must not collapse to one seq tagged COMPOUNDCURVE. + // Refuse this geometry only — collections dispatch members themselves. + SqlMmTypes.refuseOne(geometry, "WKTWriter", true); + if (geometry instanceof Point) { appendPointTaggedText((Point) geometry, outputOrdinates, useFormatting, level, writer, formatter); diff --git a/modules/core/src/main/java/org/locationtech/jts/io/gml2/GMLWriter.java b/modules/core/src/main/java/org/locationtech/jts/io/gml2/GMLWriter.java index cdaa3021ec..0d11f0f3ae 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/gml2/GMLWriter.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/gml2/GMLWriter.java @@ -25,6 +25,7 @@ import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.SqlMmTypes; import org.locationtech.jts.util.Assert; @@ -205,6 +206,7 @@ private void write(Geometry geom, Writer writer, int level) throws IOException { isRootTag = true; + SqlMmTypes.refuseFlatten(geom, "GMLWriter"); if (geom instanceof Point) { writePoint((Point) geom, writer, level); } else if (geom instanceof LineString) { diff --git a/modules/core/src/main/java/org/locationtech/jts/io/kml/KMLWriter.java b/modules/core/src/main/java/org/locationtech/jts/io/kml/KMLWriter.java index cd1fd37859..c4e1f6d481 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/kml/KMLWriter.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/kml/KMLWriter.java @@ -24,6 +24,7 @@ import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.SqlMmTypes; import org.locationtech.jts.util.StringUtil; @@ -217,6 +218,7 @@ public void write(Geometry geometry, StringBuffer buf) { } private void writeGeometry(Geometry g, int level, StringBuffer buf) { + SqlMmTypes.refuseFlatten(g, "KMLWriter"); String attributes = ""; if (g instanceof Point) { writePoint((Point) g, attributes, level, buf); diff --git a/modules/core/src/main/java/org/locationtech/jts/linearref/LengthIndexedLine.java b/modules/core/src/main/java/org/locationtech/jts/linearref/LengthIndexedLine.java index fab6c4fa1d..82383cc6b3 100644 --- a/modules/core/src/main/java/org/locationtech/jts/linearref/LengthIndexedLine.java +++ b/modules/core/src/main/java/org/locationtech/jts/linearref/LengthIndexedLine.java @@ -53,10 +53,38 @@ public LengthIndexedLine(Geometry linearGeom) { */ public Coordinate extractPoint(double index) { + Coordinate curvePt = extractCurvePointByLength(linearGeom, index); + if (curvePt != null) { + return curvePt; + } LinearLocation loc = LengthLocationMap.getLocation(linearGeom, index); return loc.getCoordinate(linearGeom); } + /** + * LRF-LEN (#1195): when {@code geom} is a jts-curve lineal type, + * extract by analytical arc length via CurveOps (reflection so core + * does not import jts-curve). + */ + private static Coordinate extractCurvePointByLength(Geometry geom, double index) { + if (geom == null || geom.getClass().getName().indexOf(".geom.curve.") < 0) { + return null; + } + try { + Class ops = Class.forName("org.locationtech.jts.geom.curve.CurveOps"); + java.lang.reflect.Method m = ops.getMethod("extractPointByArcLength", + Geometry.class, double.class); + Object out = m.invoke(null, geom, Double.valueOf(index)); + if (out instanceof Coordinate) { + return (Coordinate) out; + } + } + catch (ReflectiveOperationException ex) { + return null; + } + return null; + } + /** * Computes the {@link Coordinate} for the point * on the line at the given index, offset by the given distance. diff --git a/modules/core/src/main/java/org/locationtech/jts/linearref/LinearIterator.java b/modules/core/src/main/java/org/locationtech/jts/linearref/LinearIterator.java index 0bfc51dc5a..638103e15c 100644 --- a/modules/core/src/main/java/org/locationtech/jts/linearref/LinearIterator.java +++ b/modules/core/src/main/java/org/locationtech/jts/linearref/LinearIterator.java @@ -90,18 +90,66 @@ public LinearIterator(Geometry linearGeom, int componentIndex, int vertexIndex) if (! (linearGeom instanceof Lineal)) throw new IllegalArgumentException("Lineal geometry is required"); this.linearGeom = linearGeom; - numLines = linearGeom.getNumGeometries(); + numLines = numComponents(linearGeom); this.componentIndex = componentIndex; this.vertexIndex = vertexIndex; loadCurrentLine(); } + /** + * LRF-LOC (#1195): a CompoundCurve's members are components, like + * MultiLineString parts. Detected by class name so core does not + * import jts-curve. + */ + private static int numComponents(Geometry g) { + Integer n = compoundCurveNumMembers(g); + if (n != null) { + return n.intValue(); + } + return g.getNumGeometries(); + } + + private static Integer compoundCurveNumMembers(Geometry g) { + if (g == null || !g.getClass().getName().endsWith(".CompoundCurve")) { + return null; + } + try { + java.lang.reflect.Method m = g.getClass().getMethod("getNumMembers"); + Object out = m.invoke(g); + if (out instanceof Integer) { + return (Integer) out; + } + } + catch (ReflectiveOperationException ex) { + return null; + } + return null; + } + + private static LineString compoundCurveMember(Geometry g, int i) { + try { + java.lang.reflect.Method m = g.getClass().getMethod("getMemberN", int.class); + Object out = m.invoke(g, Integer.valueOf(i)); + if (out instanceof LineString) { + return (LineString) out; + } + } + catch (ReflectiveOperationException ex) { + return null; + } + return null; + } + private void loadCurrentLine() { if (componentIndex >= numLines) { currentLine = null; return; } + if (compoundCurveNumMembers(linearGeom) != null) { + currentLine = compoundCurveMember(linearGeom, componentIndex); + return; + } currentLine = (LineString) linearGeom.getGeometryN(componentIndex); } diff --git a/modules/core/src/main/java/org/locationtech/jts/noding/CircularNodedSegmentString.java b/modules/core/src/main/java/org/locationtech/jts/noding/CircularNodedSegmentString.java new file mode 100644 index 0000000000..bdffc86707 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/noding/CircularNodedSegmentString.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.noding; + +import org.locationtech.jts.geom.Coordinate; + +/** + * OverlayNG's exact {@link NodedSegmentString}: {@code [i, i+1]} + * names an {@link SegmentKind#ARC}, a {@link SegmentKind#CERTIFIED} + * line, or an explicit {@link SegmentKind#LINEARIZED} fallback. + * Coordinates stay the segment ends. An arc midpoint is metadata, + * not a vertex, so a semicircle whose chord is also an input edge + * is not collapsed into that chord. + *

+ * OverlayNG (via {@code EdgeNodingBuilder}) is the only consumer. + * Stock extract still builds linearized {@link NodedSegmentString}. + * Not a rewrite of every noder. Not a public circular noder API. + */ +public class CircularNodedSegmentString extends NodedSegmentString { + + private final SegmentKind[] kinds; + private final Coordinate[] mids; + + /** + * One exact circular arc from {@code start} through {@code mid} + * to {@code end}. + * + * @param start the arc start + * @param mid a point on the arc between the ends + * @param end the arc end + * @param data the user-defined data of this segment string (may be null) + */ + public CircularNodedSegmentString(Coordinate start, Coordinate mid, + Coordinate end, Object data) { + this(new Coordinate[] { start, end }, + new SegmentKind[] { SegmentKind.ARC }, + new Coordinate[] { mid }, data); + } + + /** + * Explicit linearized chords. Same linework as + * {@link NodedSegmentString}; every segment is + * {@link SegmentKind#LINEARIZED}. + * + * @param pts the vertices of the segment string + * @param data the user-defined data of this segment string (may be null) + */ + public CircularNodedSegmentString(Coordinate[] pts, Object data) { + this(pts, linearizedKinds(pts.length - 1), + new Coordinate[pts.length - 1], data); + } + + private CircularNodedSegmentString(Coordinate[] pts, SegmentKind[] kinds, + Coordinate[] mids, Object data) { + super(pts, data); + this.kinds = kinds; + this.mids = mids; + } + + /** + * Exact circular arc. {@link #mayCollapseToChord(int)} is false. + * + * @param start the arc start + * @param mid a point on the arc between the ends + * @param end the arc end + * @param data the user-defined data (may be null) + * @return an exact-arc segment string + */ + public static CircularNodedSegmentString arc(Coordinate start, + Coordinate mid, Coordinate end, Object data) { + return new CircularNodedSegmentString(start, mid, end, data); + } + + /** + * Exact certified line. Not a linearized curve. + * {@link #mayCollapseToChord(int)} is false. + * + * @param start the segment start + * @param end the segment end + * @param data the user-defined data (may be null) + * @return a certified line segment string + */ + public static CircularNodedSegmentString certified(Coordinate start, + Coordinate end, Object data) { + return new CircularNodedSegmentString( + new Coordinate[] { start, end }, + new SegmentKind[] { SegmentKind.CERTIFIED }, + new Coordinate[] { null }, data); + } + + /** + * Explicit linearized fallback. A noder may collapse to the chord. + * + * @param pts the vertices + * @param data the user-defined data (may be null) + * @return a linearized segment string + */ + public static CircularNodedSegmentString linearized(Coordinate[] pts, + Object data) { + return new CircularNodedSegmentString(pts, data); + } + + @Override + public SegmentKind getSegmentKind(int i) { + if (i < 0 || i >= kinds.length) { + return SegmentKind.LINEARIZED; + } + return kinds[i]; + } + + @Override + public Coordinate getArcMidpoint(int i) { + if (getSegmentKind(i) != SegmentKind.ARC) { + return null; + } + return mids[i]; + } + + /** + * True when segment {@code [segIndex, segIndex+1]} is an + * {@link SegmentKind#ARC}. + * + * @param segIndex the segment index + * @return true if that segment is an arc + */ + public boolean isCircularArc(int segIndex) { + return getSegmentKind(segIndex) == SegmentKind.ARC; + } + + private static SegmentKind[] linearizedKinds(int n) { + SegmentKind[] k = new SegmentKind[Math.max(n, 0)]; + for (int i = 0; i < k.length; i++) { + k[i] = SegmentKind.LINEARIZED; + } + return k; + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/noding/IntersectionAdder.java b/modules/core/src/main/java/org/locationtech/jts/noding/IntersectionAdder.java index a7f1340dc0..ee2e8c50da 100644 --- a/modules/core/src/main/java/org/locationtech/jts/noding/IntersectionAdder.java +++ b/modules/core/src/main/java/org/locationtech/jts/noding/IntersectionAdder.java @@ -121,6 +121,12 @@ public void processIntersections( ) { if (e0 == e1 && segIndex0 == segIndex1) return; + // Option B: collapse to a chord only when the contract allows it. + // Exact arcs / certified primitives are OverlayNGCurve's noder. + if (!e0.mayCollapseToChord(segIndex0) + || !e1.mayCollapseToChord(segIndex1)) { + return; + } numTests++; Coordinate p00 = e0.getCoordinate(segIndex0); Coordinate p01 = e0.getCoordinate(segIndex0 + 1); diff --git a/modules/core/src/main/java/org/locationtech/jts/noding/SegmentKind.java b/modules/core/src/main/java/org/locationtech/jts/noding/SegmentKind.java new file mode 100644 index 0000000000..284b0e2a3b --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/noding/SegmentKind.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.noding; + +/** + * What {@code [i, i+1]} names on a {@link SegmentString}. Option B: + * do not extend the interface to lie. Name the segment so the lie + * is impossible. Linearization is an explicit choice, not the claim + * that a chord is the curve. + *

+ * Default earth is {@link #LINEARIZED}. OverlayNG (and only + * OverlayNG, via {@code EdgeNodingBuilder}) may consume + * {@link #ARC} or {@link #CERTIFIED}. Buffer, relateng, coverage, + * prep, and snap-round stay on linearization. + * + * @see SegmentString#getSegmentKind(int) + */ +public enum SegmentKind { + + /** + * Explicit linear fallback. The coordinates are a chord, and a + * noder may collapse the curve to that chord. + */ + LINEARIZED, + + /** + * Exact circular arc. Ends are {@code getCoordinate(i)} and + * {@code getCoordinate(i+1)}; the on-arc midpoint is + * {@link SegmentString#getArcMidpoint(int)}. + */ + ARC, + + /** + * Exact certified primitive that is not an arc (a true line + * member, or a kit-certified piece). Not a linearized curve. + */ + CERTIFIED; + + /** + * True for {@link #ARC} and {@link #CERTIFIED}. + * + * @return true if this kind is exact + */ + public boolean isExact() { + return this == ARC || this == CERTIFIED; + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/noding/SegmentString.java b/modules/core/src/main/java/org/locationtech/jts/noding/SegmentString.java index 88d866dad0..2a67fdb606 100644 --- a/modules/core/src/main/java/org/locationtech/jts/noding/SegmentString.java +++ b/modules/core/src/main/java/org/locationtech/jts/noding/SegmentString.java @@ -17,8 +17,19 @@ * An interface for classes which represent a sequence of contiguous line segments. * SegmentStrings can carry a context object, which is useful * for preserving topological or parentage information. + *

+ * Option B (P2.5.5). {@code [i, i+1]} may name an + * {@linkplain SegmentKind#ARC arc}, a + * {@linkplain SegmentKind#CERTIFIED certified primitive}, or a + * {@linkplain SegmentKind#LINEARIZED linear fallback}, explicitly. + * The default is linearized: that is a choice, not a claim that the + * chord is the curve. A noder may collapse a segment to its chord + * only when {@link #mayCollapseToChord(int)} is true. OverlayNG + * (via {@code EdgeNodingBuilder}) is the only consumer of exact + * circular edges. Everyone else stays on linearization. * * @version 1.7 + * @see SegmentKind */ public interface SegmentString { @@ -98,4 +109,62 @@ public default Coordinate nextInRing(int index) { } return getCoordinate( nextIndex ); } + + /** + * What segment {@code [i, i+1]} names. Default + * {@link SegmentKind#LINEARIZED}: linearization is the explicit + * fallback, not a lie that the chord is the curve. + * + * @param i the segment index + * @return the kind of that segment + */ + public default SegmentKind getSegmentKind(int i) { + return SegmentKind.LINEARIZED; + } + + /** + * True when segment {@code [i, i+1]} is an exact arc or a + * certified primitive, not a linearized fallback. + * + * @param i the segment index + * @return true if that segment is exact + */ + public default boolean isExact(int i) { + return getSegmentKind(i).isExact(); + } + + /** + * True when segment {@code [i, i+1]} is the explicit linear + * fallback. + * + * @param i the segment index + * @return true if that segment is linearized + */ + public default boolean isLinearized(int i) { + return getSegmentKind(i) == SegmentKind.LINEARIZED; + } + + /** + * True when a noder that only understands chords may replace + * this segment with its linearized ends. Default is true + * (linearization is default earth). Exact arcs and certified + * primitives return false: collapsing them to a chord is the lie. + * + * @param i the segment index + * @return true if a noder may collapse this segment to a chord + */ + public default boolean mayCollapseToChord(int i) { + return isLinearized(i); + } + + /** + * The on-arc midpoint of segment {@code [i, i+1]}, or + * {@code null} when that segment is not an {@link SegmentKind#ARC}. + * + * @param i the segment index + * @return the midpoint, or {@code null} + */ + public default Coordinate getArcMidpoint(int i) { + return null; + } } diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java index 26296764f5..6e8720768d 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java @@ -199,10 +199,22 @@ public void setJoined(boolean isJoined) { /** * Gets the computed offset curve lines. + *

+ * Option B / OFF (#1195): a single-arc {@code CircularString} + * (three control points) is offset concentrically — same centre and + * sweep, radius adjusted by left-of-direction distance. Positive + * distance is left of the directed curve (CCW arc ⇒ inward). Multi-arc + * and non-circular inputs keep the chord path. * * @return the offset curve geometry */ public Geometry getCurve() { + if (isSingleCircularArc(inputGeom)) { + Geometry exact = offsetSingleCircularArc((LineString) inputGeom, distance); + if (exact != null) { + return exact; + } + } return GeometryMapper.flatMap(inputGeom, 1, new GeometryMapper.MapOp() { @Override @@ -215,6 +227,119 @@ public Geometry map(Geometry geom) { } }); } + + /** + * True when {@code g} is a three-point CircularString. + * Type check avoids importing jts-curve. + */ + private static boolean isSingleCircularArc(Geometry g) { + if (g == null || !"CircularString".equals(g.getGeometryType())) { + return false; + } + return g.getNumPoints() == 3; + } + + /** + * Concentric offset of a three-point circular arc. Returns + * {@code null} to fall through when the triple is collinear or the + * factory cannot build a CircularString. + */ + private static Geometry offsetSingleCircularArc(LineString arc, double dist) { + Coordinate p0 = arc.getCoordinateN(0); + Coordinate p1 = arc.getCoordinateN(1); + Coordinate p2 = arc.getCoordinateN(2); + double[] circle = circumcircleXY(p0, p1, p2); + if (circle == null) { + return null; + } + double cx = circle[0]; + double cy = circle[1]; + double r = circle[2]; + boolean ccw = isMidInCcwSweep(p0, p1, p2, cx, cy); + // Positive distance = left of direction. Left of CCW is toward centre. + double radial = ccw ? -dist : dist; + double rn = r + radial; + if (rn <= 0.0) { + return arc.getFactory().createLineString(); + } + double k = rn / r; + Coordinate[] pts = new Coordinate[] { + scaleFromCentre(p0, cx, cy, k), + scaleFromCentre(p1, cx, cy, k), + scaleFromCentre(p2, cx, cy, k) + }; + try { + return arc.getFactory().createCircularString( + arc.getFactory().getCoordinateSequenceFactory().create(pts)); + } + catch (UnsupportedOperationException e) { + // CurveWKTReader may attach a non-curve factory to a CircularString. + // Rebuild as the same runtime class without importing jts-curve. + return rebuildAsSameLinealType(arc, pts); + } + } + + /** + * Reflectively constructs {@code arc}'s concrete class with new points. + * Used when {@link GeometryFactory#createCircularString} is unsupported + * but the input is already a curve type instance. + */ + private static Geometry rebuildAsSameLinealType(LineString arc, + Coordinate[] pts) { + try { + java.lang.reflect.Constructor ctor = arc.getClass().getConstructor( + org.locationtech.jts.geom.CoordinateSequence.class, + GeometryFactory.class); + org.locationtech.jts.geom.CoordinateSequence seq = + arc.getFactory().getCoordinateSequenceFactory().create(pts); + return (Geometry) ctor.newInstance(seq, arc.getFactory()); + } + catch (Exception ex) { + return null; + } + } + + private static Coordinate scaleFromCentre(Coordinate p, double cx, double cy, + double k) { + return new Coordinate(cx + (p.x - cx) * k, cy + (p.y - cy) * k); + } + + /** Circumcircle {@code {cx,cy,r}} or null if collinear. */ + private static double[] circumcircleXY(Coordinate a, Coordinate b, Coordinate c) { + double det = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + if (det == 0.0) { + return null; + } + double a2 = a.x * a.x + a.y * a.y; + double b2 = b.x * b.x + b.y * b.y; + double c2 = c.x * c.x + c.y * c.y; + double cx = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / det; + double cy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / det; + double r = Math.hypot(a.x - cx, a.y - cy); + if (!(r > 0.0) || Double.isInfinite(r)) { + return null; + } + return new double[] { cx, cy, r }; + } + + private static boolean isMidInCcwSweep(Coordinate start, Coordinate mid, + Coordinate end, double cx, double cy) { + double a0 = Math.atan2(start.y - cy, start.x - cx); + double am = Math.atan2(mid.y - cy, mid.x - cx); + double a1 = Math.atan2(end.y - cy, end.x - cx); + double ccwToMid = normalizePositive(am - a0); + double ccwToEnd = normalizePositive(a1 - a0); + return ccwToMid <= ccwToEnd + 1.0e-15; + } + + private static double normalizePositive(double a) { + double twoPi = 2 * Math.PI; + double t = a % twoPi; + if (t < 0) { + t += twoPi; + } + return t; + } private Geometry computePolygonCurve(Polygon poly, double distance) { Geometry buffer; diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/VariableBuffer.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/VariableBuffer.java index 6874f1d2c8..fa47589ff8 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/VariableBuffer.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/VariableBuffer.java @@ -52,6 +52,7 @@ public class VariableBuffer { */ public static Geometry buffer(Geometry line, double startDistance, double endDistance) { + line = densifyCurveInput(line); double[] distance = interpolate((LineString) line, startDistance, endDistance); VariableBuffer vb = new VariableBuffer(line, distance); @@ -75,6 +76,7 @@ public static Geometry buffer(Geometry line, double startDistance, public static Geometry buffer(Geometry line, double startDistance, double midDistance, double endDistance) { + line = densifyCurveInput(line); double[] distance = interpolate((LineString) line, startDistance, midDistance, endDistance); VariableBuffer vb = new VariableBuffer(line, distance); @@ -94,6 +96,32 @@ public static Geometry buffer(Geometry line, double[] distance) { return vb.getResult(); } + /** + * VBF (#1195): densify curve-package inputs via arc-length sampling so + * distance interpolation follows arc parameter, not control-chord length. + * Uses reflection so core does not import jts-curve; strategy+warn lives in + * {@code CurveOps.lineariseArcLength}. + */ + private static Geometry densifyCurveInput(Geometry g) { + if (g == null || g.getClass().getName().indexOf(".geom.curve.") < 0) { + return g; + } + try { + Class ops = Class.forName( + "org.locationtech.jts.geom.curve.CurveOps"); + java.lang.reflect.Method m = ops.getMethod("lineariseArcLength", + Geometry.class, int.class); + Object out = m.invoke(null, g, Integer.valueOf(16)); + if (out instanceof Geometry) { + return (Geometry) out; + } + } + catch (ReflectiveOperationException ex) { + return g; + } + return g; + } + /** * Computes a list of values for the points along a line by * interpolating between values for the start and end point. diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/CoverageUnion.java b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/CoverageUnion.java index ddd40c718f..a5779b22a8 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/CoverageUnion.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/CoverageUnion.java @@ -66,6 +66,10 @@ public class CoverageUnion * @throws TopologyException in some cases if the coverage is invalid */ public static Geometry union(Geometry coverage) { + Geometry curveUnion = tryCurveCoverageUnion(coverage); + if (curveUnion != null) { + return curveUnion; + } Noder noder = new BoundaryChainNoder(); //-- these are less performant //Noder noder = new SegmentExtractingNoder(); @@ -85,6 +89,29 @@ public static Geometry union(Geometry coverage) { } } + /** + * COV (#1195): dissolve CurvePolygon coverages via curve module when + * present, preserving exterior CIRCULARSTRING members. + */ + private static Geometry tryCurveCoverageUnion(Geometry coverage) { + if (coverage == null) { + return null; + } + try { + Class cls = Class.forName( + "org.locationtech.jts.geom.curve.CurveCoverageUnion"); + java.lang.reflect.Method m = cls.getMethod("union", Geometry.class); + Object out = m.invoke(null, coverage); + if (out instanceof Geometry) { + return (Geometry) out; + } + } + catch (ReflectiveOperationException ex) { + return null; + } + return null; + } + private CoverageUnion() { // No instantiation for now } diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilder.java index 252cb67e45..192d1a6de6 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilder.java @@ -50,6 +50,21 @@ *

  • removes any fully collapsed noded edges *
  • builds {@link Edge}s and merges them * + *

    + * OverlayNG-for-circles (Option B, P2.5.5). This is the + * OverlayNG surface that may consume exact circular edges. + * Stock extract ({@link #add} of a {@link Geometry}) always + * builds linearized {@link NodedSegmentString}s -- linearization + * is the explicit default. Prepared + * {@link #addEdge(NodedSegmentString)} may carry an + * {@link org.locationtech.jts.noding.SegmentKind#ARC} or + * {@link org.locationtech.jts.noding.SegmentKind#CERTIFIED} + * segment. A noder may collapse a segment to its chord only when + * {@link SegmentString#mayCollapseToChord(int)} is true. Buffer, + * relateng, coverage, prep, and snap-round are not this surface; + * they keep linearization. OverlayNGCurve converts + * CircularString / CompoundCurve at this boundary so core does + * not depend on jts-curve. * * @author mdavis * @@ -412,9 +427,56 @@ private void addLine(Coordinate[] pts, int geomIndex) { private void addEdge(Coordinate[] pts, EdgeSourceInfo info) { NodedSegmentString ss = new NodedSegmentString(pts, info); + addEdge(ss); + } + + /** + * Accepts a prepared {@link NodedSegmentString}. OverlayNG-only: + * the string may name an exact {@link SegmentString#getSegmentKind(int) + * segment kind}. Stock extract never calls this with an exact + * arc; OverlayNGCurve does, after converting at this boundary. + * + * @param ss the extracted edge (may be exact) + */ + void addEdge(NodedSegmentString ss) { + if (ss == null) { + return; + } inputEdges.add(ss); } + /** + * True when any prepared or extracted segment is exact + * ({@link SegmentString#isExact(int)}). Stock OverlayNG extract + * is linearized, so this is false until OverlayNGCurve feeds + * an arc or certified primitive. + * + * @return true if an exact segment is present + */ + boolean hasExactSegment() { + boolean exact = false; + for (int i = 0; i < inputEdges.size() && !exact; i++) { + NodedSegmentString ss = inputEdges.get(i); + for (int j = 0; j < ss.size() - 1 && !exact; j++) { + if (ss.isExact(j)) { + exact = true; + } + } + } + return exact; + } + + /** + * Nodes prepared edges and merges them. Skips geometry extract; + * the caller already supplied {@link #addEdge(NodedSegmentString)}. + * + * @return the noded, merged, labelled edges + */ + List buildPrepared() { + List nodedEdges = node(inputEdges); + return EdgeMerger.merge(nodedEdges); + } + /** * Tests whether it is worth limiting a line. * Lines that have few vertices or are covered diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/OverlayNG.java b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/OverlayNG.java index 8152a286e7..0d59913ac5 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/OverlayNG.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/OverlayNG.java @@ -25,6 +25,7 @@ import org.locationtech.jts.geom.TopologyException; import org.locationtech.jts.geomgraph.Label; import org.locationtech.jts.noding.MCIndexNoder; +import org.locationtech.jts.noding.NodedSegmentString; import org.locationtech.jts.noding.Noder; import org.locationtech.jts.noding.snap.SnappingNoder; import org.locationtech.jts.noding.snapround.SnapRoundingNoder; @@ -331,6 +332,7 @@ static Geometry union(Geometry geom, PrecisionModel pm, Noder noder) private GeometryFactory geomFact; private PrecisionModel pm; private Noder noder; + private List inputEdges; private boolean isStrictMode = STRICT_MODE_DEFAULT; private boolean isOptimized = true; private boolean isAreaResultOnly = false; @@ -450,6 +452,17 @@ public void setOutputResultEdges(boolean isOutputResultEdges ) { void setNoder(Noder noder) { this.noder = noder; } + + /** + * Supplies pre-extracted segment strings, skipping geometry + * extract. OverlayNGCurve uses this so a circular arc is not + * flattened to its control chord. Not a public noder API. + * + * @param edges the prepared edges (may include circular segments) + */ + void setInputEdges(List edges) { + this.inputEdges = edges; + } /** * Gets the result of the overlay operation. @@ -530,20 +543,29 @@ private List nodeEdges() { * Node the edges, using whatever noder is being used */ EdgeNodingBuilder nodingBuilder = new EdgeNodingBuilder(pm, noder); - - /** - * Optimize Intersection and Difference by clipping to the - * result extent, if enabled. - */ - if ( isOptimized ) { - Envelope clipEnv = OverlayUtil.clippingEnvelope(opCode, inputGeom, pm); - if (clipEnv != null) - nodingBuilder.setClipEnvelope( clipEnv ); + + List mergedEdges; + if (inputEdges != null) { + for (NodedSegmentString ss : inputEdges) { + nodingBuilder.addEdge(ss); + } + mergedEdges = nodingBuilder.buildPrepared(); + } + else { + /** + * Optimize Intersection and Difference by clipping to the + * result extent, if enabled. + */ + if ( isOptimized ) { + Envelope clipEnv = OverlayUtil.clippingEnvelope(opCode, inputGeom, pm); + if (clipEnv != null) + nodingBuilder.setClipEnvelope( clipEnv ); + } + + mergedEdges = nodingBuilder.build( + inputGeom.getGeometry(0), + inputGeom.getGeometry(1)); } - - List mergedEdges = nodingBuilder.build( - inputGeom.getGeometry(0), - inputGeom.getGeometry(1)); /** * Record if an input geometry has collapsed. diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/polygonize/Polygonizer.java b/modules/core/src/main/java/org/locationtech/jts/operation/polygonize/Polygonizer.java index 44f8bc0cab..20f43d3506 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/polygonize/Polygonizer.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/polygonize/Polygonizer.java @@ -17,6 +17,8 @@ import java.util.Iterator; import java.util.List; +import org.locationtech.jts.densify.Densifier; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryComponentFilter; import org.locationtech.jts.geom.GeometryFactory; @@ -134,7 +136,24 @@ public void add(Collection geomList) */ public void add(Geometry g) { - g.apply(lineStringAdder); + Geometry src = densifyCurveLinework(g); + src.apply(lineStringAdder); + } + + /** + * PLG (#1195): densify curve-package linework before graph insert so + * CompoundCurve / CircularString edges contribute arc samples, not only + * control chords. Faces remain {@link Polygon} (CurvePolygon emission + * is a separate laser). + */ + private static Geometry densifyCurveLinework(Geometry g) { + if (g == null || g.getClass().getName().indexOf(".geom.curve.") < 0) { + return g; + } + Envelope env = g.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-4; + return Densifier.densify(g, tol); } /** diff --git a/modules/core/src/main/java/org/locationtech/jts/precision/GeometryPrecisionReducer.java b/modules/core/src/main/java/org/locationtech/jts/precision/GeometryPrecisionReducer.java index ee450ab13d..6762b29790 100644 --- a/modules/core/src/main/java/org/locationtech/jts/precision/GeometryPrecisionReducer.java +++ b/modules/core/src/main/java/org/locationtech/jts/precision/GeometryPrecisionReducer.java @@ -11,8 +11,13 @@ */ package org.locationtech.jts.precision; +import org.locationtech.jts.densify.Densifier; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.util.GeometryEditor; @@ -79,9 +84,99 @@ public class GeometryPrecisionReducer */ public static Geometry reduce(Geometry g, PrecisionModel precModel) { + Geometry curveReduced = reduceCurveIfPresent(g, precModel); + if (curveReduced != null) { + return curveReduced; + } GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(precModel); return reducer.reduce(g); } + + /** + * PRC-SN (#1195): snap CircularString controls; keep CircularString when + * the snapped circumcentre also lies on the grid, otherwise densify then + * reduce. Detection by geometry type name so core does not import jts-curve. + */ + private static Geometry reduceCurveIfPresent(Geometry g, PrecisionModel pm) { + if (g == null || !"CircularString".equals(g.getGeometryType())) { + return null; + } + if (!(g instanceof LineString) || g.getNumPoints() < 3) { + return null; + } + LineString cs = (LineString) g; + CoordinateSequence seq = cs.getCoordinateSequence(); + // Only single-arc 3-control case for preserve path + if (cs.getNumPoints() != 3) { + Geometry dens = Densifier.densify(g, densifyTol(g)); + GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(pm); + return reducer.reduce(dens); + } + Coordinate[] snapped = new Coordinate[3]; + for (int i = 0; i < 3; i++) { + snapped[i] = seq.getCoordinate(i).copy(); + pm.makePrecise(snapped[i]); + } + if (isCollinear(snapped[0], snapped[1], snapped[2])) { + GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(pm); + return reducer.reduce(Densifier.densify(g, densifyTol(g))); + } + if (!isCentreOnGrid(snapped[0], snapped[1], snapped[2], pm)) { + GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(pm); + return reducer.reduce(Densifier.densify(g, densifyTol(g))); + } + try { + java.lang.reflect.Method m = g.getFactory().getClass() + .getMethod("createCircularString", CoordinateSequence.class); + CoordinateSequence newSeq = g.getFactory().getCoordinateSequenceFactory() + .create(snapped); + Object out = m.invoke(g.getFactory(), newSeq); + if (out instanceof Geometry) { + return (Geometry) out; + } + } + catch (ReflectiveOperationException ex) { + // rebuild via same class constructor + try { + java.lang.reflect.Constructor ctor = g.getClass().getConstructor( + CoordinateSequence.class, GeometryFactory.class); + CoordinateSequence newSeq = g.getFactory().getCoordinateSequenceFactory() + .create(snapped); + return (Geometry) ctor.newInstance(newSeq, g.getFactory()); + } + catch (ReflectiveOperationException ex2) { + return null; + } + } + return null; + } + + private static double densifyTol(Geometry g) { + Envelope env = g.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + return (extent > 0.0 ? extent : 1.0) * 1.0e-6; + } + + private static boolean isCollinear(Coordinate a, Coordinate b, Coordinate c) { + return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 1.0e-15; + } + + private static boolean isCentreOnGrid(Coordinate a, Coordinate b, Coordinate c, + PrecisionModel pm) { + double det = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + if (det == 0.0) { + return false; + } + double a2 = a.x * a.x + a.y * a.y; + double b2 = b.x * b.x + b.y * b.y; + double c2 = c.x * c.x + c.y * c.y; + double cx = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / det; + double cy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / det; + Coordinate centre = new Coordinate(cx, cy); + Coordinate snapped = centre.copy(); + pm.makePrecise(snapped); + return centre.equals2D(snapped); + } /** * Reduces precision of a geometry, ensuring output polygonal geometry is valid, diff --git a/modules/core/src/main/java/org/locationtech/jts/simplify/DouglasPeuckerSimplifier.java b/modules/core/src/main/java/org/locationtech/jts/simplify/DouglasPeuckerSimplifier.java index e14d0481c3..ec5a2e8fde 100644 --- a/modules/core/src/main/java/org/locationtech/jts/simplify/DouglasPeuckerSimplifier.java +++ b/modules/core/src/main/java/org/locationtech/jts/simplify/DouglasPeuckerSimplifier.java @@ -56,6 +56,13 @@ public class DouglasPeuckerSimplifier */ public static Geometry simplify(Geometry geom, double distanceTolerance) { + // S-DP (#1195): a 3-control CircularString is the arc identity — + // do not collapse it to LineString(start, end). + if (geom != null + && "CircularString".equals(geom.getGeometryType()) + && geom.getNumPoints() <= 3) { + return geom.copy(); + } DouglasPeuckerSimplifier tss = new DouglasPeuckerSimplifier(geom); tss.setDistanceTolerance(distanceTolerance); return tss.getResultGeometry(); diff --git a/modules/core/src/main/java/org/locationtech/jts/simplify/TopologyPreservingSimplifier.java b/modules/core/src/main/java/org/locationtech/jts/simplify/TopologyPreservingSimplifier.java index 5eb8ab58e2..e27fb04059 100644 --- a/modules/core/src/main/java/org/locationtech/jts/simplify/TopologyPreservingSimplifier.java +++ b/modules/core/src/main/java/org/locationtech/jts/simplify/TopologyPreservingSimplifier.java @@ -68,6 +68,11 @@ public class TopologyPreservingSimplifier { public static Geometry simplify(Geometry geom, double distanceTolerance) { + if (geom != null + && "CircularString".equals(geom.getGeometryType()) + && geom.getNumPoints() <= 3) { + return geom.copy(); + } TopologyPreservingSimplifier tss = new TopologyPreservingSimplifier(geom); tss.setDistanceTolerance(distanceTolerance); return tss.getResultGeometry(); diff --git a/modules/core/src/main/java/org/locationtech/jts/simplify/VWSimplifier.java b/modules/core/src/main/java/org/locationtech/jts/simplify/VWSimplifier.java index 005b03cc40..60834cd8c8 100644 --- a/modules/core/src/main/java/org/locationtech/jts/simplify/VWSimplifier.java +++ b/modules/core/src/main/java/org/locationtech/jts/simplify/VWSimplifier.java @@ -58,6 +58,11 @@ public class VWSimplifier */ public static Geometry simplify(Geometry geom, double distanceTolerance) { + if (geom != null + && "CircularString".equals(geom.getGeometryType()) + && geom.getNumPoints() <= 3) { + return geom.copy(); + } VWSimplifier simp = new VWSimplifier(geom); simp.setDistanceTolerance(distanceTolerance); return simp.getResultGeometry(); diff --git a/modules/core/src/main/java/org/locationtech/jts/triangulate/DelaunayTriangulationBuilder.java b/modules/core/src/main/java/org/locationtech/jts/triangulate/DelaunayTriangulationBuilder.java index d1a5caf811..26e9f833d7 100644 --- a/modules/core/src/main/java/org/locationtech/jts/triangulate/DelaunayTriangulationBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/triangulate/DelaunayTriangulationBuilder.java @@ -17,6 +17,7 @@ import java.util.Iterator; import java.util.List; +import org.locationtech.jts.densify.Densifier; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateArrays; import org.locationtech.jts.geom.CoordinateList; @@ -52,10 +53,24 @@ static CoordinateList extractUniqueCoordinates(Geometry geom) { if (geom == null) return new CoordinateList(); + + Geometry src = geom; + // TRI-DT / TRI-VR (#1195): curve inputs densify via Densifier→toLinear + // so sites lie on the arc, not the control chord. + if (isCurvePackageGeometry(geom)) { + Envelope env = geom.getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-6; + src = Densifier.densify(geom, tol); + } - Coordinate[] coords = geom.getCoordinates(); + Coordinate[] coords = src.getCoordinates(); return unique(coords); } + + private static boolean isCurvePackageGeometry(Geometry geom) { + return geom.getClass().getName().indexOf(".geom.curve.") >= 0; + } /** * Copies a list of coordinates and ensures they are unique. diff --git a/modules/core/src/test/java/org/locationtech/jts/geom/SqlMmTypesTest.java b/modules/core/src/test/java/org/locationtech/jts/geom/SqlMmTypesTest.java new file mode 100644 index 0000000000..3b62375200 --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/geom/SqlMmTypesTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom; + +import org.locationtech.jts.io.WKTWriter; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * I/O type identity: core writers must not flatten unexpected + * LineString / Polygon subclasses to control chords. + * SQL/MM ISO/IEC 13249-3. Not overlay honesty. + */ +public class SqlMmTypesTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(SqlMmTypesTest.class); + } + + public SqlMmTypesTest(String name) { super(name); } + + private static LineString fakeCurve() { + GeometryFactory gf = new GeometryFactory(); + return new LineString( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "FakeCurve"; } + }; + } + + private static void assertRefused(String site, Runnable r) { + try { + r.run(); + fail(site + " must not flatten an unexpected LineString subclass"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } + + public void testRefuseFlattenUnexpectedSubclass() { + assertRefused("SqlMmTypes", new Runnable() { + public void run() { + SqlMmTypes.refuseFlatten(fakeCurve(), "SqlMmTypes"); + } + }); + } + + public void testWktWriterRefusesUnexpectedSubclass() { + assertRefused("WKTWriter", new Runnable() { + public void run() { + new WKTWriter().write(fakeCurve()); + } + }); + } + + public void testCircularStringKeywordAllowed() { + GeometryFactory gf = new GeometryFactory(); + LineString cs = new LineString( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 0) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "CircularString"; } + }; + String wkt = new WKTWriter().write(cs); + assertTrue(wkt.toUpperCase().startsWith("CIRCULARSTRING")); + } + + public void testLinearRingStillAllowed() { + GeometryFactory gf = new GeometryFactory(); + LinearRing ring = gf.createLinearRing(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(1, 1), + new Coordinate(0, 0) + }); + SqlMmTypes.refuseFlatten(ring, "test"); + String wkt = new WKTWriter().write(ring); + assertTrue(wkt.toUpperCase().startsWith("LINEARRING") + || wkt.toUpperCase().startsWith("LINESTRING")); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/io/WKBCurveZooConstantsTest.java b/modules/core/src/test/java/org/locationtech/jts/io/WKBCurveZooConstantsTest.java new file mode 100644 index 0000000000..e53a6e82a4 --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/io/WKBCurveZooConstantsTest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.io; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * Fork MMF (#1195): WKB 18–21 codes are SIGNED greenfield constants. + */ +public class WKBCurveZooConstantsTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(WKBCurveZooConstantsTest.class); + } + + public WKBCurveZooConstantsTest(String name) { + super(name); + } + + public void testSignedZooCodes() { + assertEquals(8, WKBConstants.wkbCircularString); + assertEquals(12, WKBConstants.wkbMultiSurface); + assertEquals(18, WKBConstants.wkbClothoid); + assertEquals(19, WKBConstants.wkbBezier); + assertEquals(20, WKBConstants.wkbEllipse); + assertEquals(21, WKBConstants.wkbNurbs); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/io/WKTWriterTest.java b/modules/core/src/test/java/org/locationtech/jts/io/WKTWriterTest.java index f2d4eb4577..679a958c8e 100644 --- a/modules/core/src/test/java/org/locationtech/jts/io/WKTWriterTest.java +++ b/modules/core/src/test/java/org/locationtech/jts/io/WKTWriterTest.java @@ -199,4 +199,41 @@ public void testWktLineStringZM() throws ParseException { assertEquals(7.0, lineZM.getPointN(1).getCoordinate().getZ()); assertEquals(8.0, lineZM.getPointN(1).getCoordinate().getM()); } + + /** + * CIRCULARSTRING keyword is OK. An unexpected LineString subclass + * (COMPOUNDCURVE-shaped flatten) must not collapse members. + * SQL/MM ISO/IEC 13249-3 types 8–12 use appendOtherGeometryTaggedText. + */ + public void testUnexpectedLineStringSubclassRefused() { + GeometryFactory gf = new GeometryFactory(); + LineString fake = new LineString( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "FakeCurve"; } + }; + try { + new WKTWriter().write(fake); + fail("core WKTWriter must not flatten an unexpected LineString subclass"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } + + public void testCircularStringKeywordAllowed() { + GeometryFactory gf = new GeometryFactory(); + LineString cs = new LineString( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 0) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "CircularString"; } + }; + String wkt = new WKTWriter().write(cs); + assertTrue("CS keyword OK, was: " + wkt, + wkt.toUpperCase().startsWith("CIRCULARSTRING")); + } } diff --git a/modules/core/src/test/java/org/locationtech/jts/io/gml2/GMLWriterFlattenTest.java b/modules/core/src/test/java/org/locationtech/jts/io/gml2/GMLWriterFlattenTest.java new file mode 100644 index 0000000000..c064587c0a --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/io/gml2/GMLWriterFlattenTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.io.gml2; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * GML2 cannot carry SQL/MM ISO/IEC 13249-3 arcs. + */ +public class GMLWriterFlattenTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(GMLWriterFlattenTest.class); + } + + public GMLWriterFlattenTest(String name) { super(name); } + + public void testUnexpectedLineStringSubclassRefused() { + GeometryFactory gf = new GeometryFactory(); + LineString fake = new LineString( + gf.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "FakeCurve"; } + }; + try { + new GMLWriter().write(fake); + fail("GMLWriter must not flatten an unexpected LineString subclass"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/io/kml/KMLWriterTest.java b/modules/core/src/test/java/org/locationtech/jts/io/kml/KMLWriterTest.java index bfdd422578..5dbac888ae 100644 --- a/modules/core/src/test/java/org/locationtech/jts/io/kml/KMLWriterTest.java +++ b/modules/core/src/test/java/org/locationtech/jts/io/kml/KMLWriterTest.java @@ -158,6 +158,29 @@ private void checkEqual(KMLWriter kmlWriter, Geometry geom, String expectedKML) * @param expectedKML * @return */ + /** + * KML cannot carry SQL/MM ISO/IEC 13249-3 arcs. An unexpected + * LineString subclass must not flatten to a KML LineString. + */ + public void testUnexpectedLineStringSubclassRefused() { + GeometryFactory gf = new GeometryFactory(); + Geometry fake = new org.locationtech.jts.geom.LineString( + gf.getCoordinateSequenceFactory().create(new org.locationtech.jts.geom.Coordinate[] { + new org.locationtech.jts.geom.Coordinate(0, 0), + new org.locationtech.jts.geom.Coordinate(1, 1) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "FakeCurve"; } + }; + try { + new KMLWriter().write(fake); + fail("KMLWriter must not flatten an unexpected LineString subclass"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } + private String normalizeKML(String kml) { String condenseSpace = kml.replaceAll("\\s+", " ").trim(); String removeRedundantSpace = condenseSpace.replaceAll("> <", "><"); diff --git a/modules/core/src/test/java/org/locationtech/jts/noding/CircularNodedSegmentStringTest.java b/modules/core/src/test/java/org/locationtech/jts/noding/CircularNodedSegmentStringTest.java new file mode 100644 index 0000000000..c7e64d325e --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/noding/CircularNodedSegmentStringTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.noding; + +import org.locationtech.jts.geom.Coordinate; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * OverlayNG-for-circles: a core {@link NodedSegmentString} may carry + * an arc on {@code [i, i+1]}. The midpoint is metadata, not a vertex. + */ +public class CircularNodedSegmentStringTest extends GeometryTestCase { + + public static void main(String[] args) { + TestRunner.run(CircularNodedSegmentStringTest.class); + } + + public CircularNodedSegmentStringTest(String name) { + super(name); + } + + public void testArcSegmentIsNotTheChordVertices() { + Coordinate start = new Coordinate(-5, 0); + Coordinate mid = new Coordinate(0, 5); + Coordinate end = new Coordinate(5, 0); + CircularNodedSegmentString ss = new CircularNodedSegmentString( + start, mid, end, "src"); + assertEquals(2, ss.size()); + assertTrue(ss.getCoordinate(0).equals2D(start)); + assertTrue(ss.getCoordinate(1).equals2D(end)); + assertTrue(ss.isCircularArc(0)); + assertEquals(SegmentKind.ARC, ss.getSegmentKind(0)); + assertTrue(ss.isExact(0)); + assertFalse(ss.mayCollapseToChord(0)); + assertTrue(ss.getArcMidpoint(0).equals2D(mid)); + assertEquals("src", ss.getData()); + } + + public void testChordConstructorIsLinear() { + Coordinate[] pts = new Coordinate[] { + new Coordinate(5, 0), new Coordinate(-5, 0) + }; + CircularNodedSegmentString ss = new CircularNodedSegmentString(pts, null); + assertEquals(2, ss.size()); + assertFalse(ss.isCircularArc(0)); + assertEquals(SegmentKind.LINEARIZED, ss.getSegmentKind(0)); + assertTrue(ss.mayCollapseToChord(0)); + assertNull(ss.getArcMidpoint(0)); + } + + public void testAddIntersectionKeepsArcMetadata() { + CircularNodedSegmentString ss = new CircularNodedSegmentString( + new Coordinate(-5, 0), new Coordinate(0, 5), + new Coordinate(5, 0), null); + ss.addIntersection(new Coordinate(0, 5), 0); + assertTrue(ss.hasNodes()); + assertTrue(ss.isCircularArc(0)); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/noding/SegmentStringContractTest.java b/modules/core/src/test/java/org/locationtech/jts/noding/SegmentStringContractTest.java new file mode 100644 index 0000000000..cde5b9bd4f --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/noding/SegmentStringContractTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.noding; + +import org.locationtech.jts.geom.Coordinate; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Option B: {@link SegmentString} names {@code [i, i+1]} explicitly. + * Default earth is linearized. OverlayNG may consume exact arcs; + * everyone else stays on the default. + */ +public class SegmentStringContractTest extends GeometryTestCase { + + public static void main(String[] args) { + TestRunner.run(SegmentStringContractTest.class); + } + + public SegmentStringContractTest(String name) { + super(name); + } + + public void testDefaultIsLinearized() { + Coordinate[] pts = new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0) + }; + SegmentString basic = new BasicSegmentString(pts, null); + SegmentString noded = new NodedSegmentString(pts, null); + assertEquals(SegmentKind.LINEARIZED, basic.getSegmentKind(0)); + assertEquals(SegmentKind.LINEARIZED, noded.getSegmentKind(0)); + assertTrue(basic.isLinearized(0)); + assertTrue(noded.isLinearized(0)); + assertFalse(basic.isExact(0)); + assertFalse(noded.isExact(0)); + assertTrue(basic.mayCollapseToChord(0)); + assertTrue(noded.mayCollapseToChord(0)); + assertNull(basic.getArcMidpoint(0)); + assertNull(noded.getArcMidpoint(0)); + } + + public void testArcIsExactAndMustNotCollapse() { + CircularNodedSegmentString arc = CircularNodedSegmentString.arc( + new Coordinate(-5, 0), new Coordinate(0, 5), + new Coordinate(5, 0), null); + assertEquals(SegmentKind.ARC, arc.getSegmentKind(0)); + assertTrue(arc.isExact(0)); + assertFalse(arc.isLinearized(0)); + assertFalse(arc.mayCollapseToChord(0)); + assertTrue(arc.getArcMidpoint(0).equals2D(new Coordinate(0, 5))); + } + + public void testCertifiedLineIsExactAndMustNotCollapse() { + CircularNodedSegmentString line = CircularNodedSegmentString.certified( + new Coordinate(5, 0), new Coordinate(-5, 0), null); + assertEquals(SegmentKind.CERTIFIED, line.getSegmentKind(0)); + assertTrue(line.isExact(0)); + assertFalse(line.isLinearized(0)); + assertFalse(line.mayCollapseToChord(0)); + assertNull(line.getArcMidpoint(0)); + } + + public void testLinearizedFactoryIsExplicitFallback() { + CircularNodedSegmentString lin = CircularNodedSegmentString.linearized( + new Coordinate[] { new Coordinate(0, 0), new Coordinate(1, 0) }, + null); + assertEquals(SegmentKind.LINEARIZED, lin.getSegmentKind(0)); + assertTrue(lin.mayCollapseToChord(0)); + assertFalse(lin.isExact(0)); + } + + /** + * Option B allowed lie: PrecisionModel may snap coordinate values for + * index / HotPixel queries, but {@link SegmentKind} stays ARC — the + * index envelope may expand under PM scale without renaming the segment. + */ + public void testPrecisionModelSnapDoesNotChangeSegmentKind() { + Coordinate start = new Coordinate(0.12, 0.08); + Coordinate mid = new Coordinate(5.07, 5.03); + Coordinate end = new Coordinate(10.11, 0.09); + CircularNodedSegmentString arc = CircularNodedSegmentString.arc( + start, mid, end, null); + assertEquals(SegmentKind.ARC, arc.getSegmentKind(0)); + assertFalse(arc.mayCollapseToChord(0)); + + org.locationtech.jts.geom.PrecisionModel pm = + new org.locationtech.jts.geom.PrecisionModel(1.0); + Coordinate[] ends = arc.getCoordinates(); + for (int i = 0; i < ends.length; i++) { + pm.makePrecise(ends[i]); + } + Coordinate snappedMid = mid.copy(); + pm.makePrecise(snappedMid); + + // Kind is metadata on the segment string, not derived from coordinates. + assertEquals(SegmentKind.ARC, arc.getSegmentKind(0)); + assertFalse(arc.mayCollapseToChord(0)); + assertTrue(arc.isExact(0)); + // Midpoint metadata is still the original arc mid (not collapsed to chord). + assertNotNull(arc.getArcMidpoint(0)); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilderCircularTest.java b/modules/core/src/test/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilderCircularTest.java new file mode 100644 index 0000000000..460df52cee --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/operation/overlayng/EdgeNodingBuilderCircularTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng; + +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.noding.CircularNodedSegmentString; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * OverlayNG-for-circles surface: {@link EdgeNodingBuilder}. + * Stock extract is linearized. Prepared exact arcs are accepted. + * IntersectionAdder collapses a segment only when + * {@code mayCollapseToChord} is true. + */ +public class EdgeNodingBuilderCircularTest extends GeometryTestCase { + + public static void main(String[] args) { + TestRunner.run(EdgeNodingBuilderCircularTest.class); + } + + public EdgeNodingBuilderCircularTest(String name) { + super(name); + } + + public void testAcceptsCircularNodedSegmentString() { + PrecisionModel pm = new PrecisionModel(); + EdgeNodingBuilder builder = new EdgeNodingBuilder(pm, null); + EdgeSourceInfo info0 = new EdgeSourceInfo(0, 1, false); + EdgeSourceInfo info1 = new EdgeSourceInfo(1, 1, false); + CircularNodedSegmentString arc = CircularNodedSegmentString.arc( + new Coordinate(-5, 0), new Coordinate(0, 5), + new Coordinate(5, 0), info0); + CircularNodedSegmentString diameter = CircularNodedSegmentString.certified( + new Coordinate(5, 0), new Coordinate(-5, 0), info1); + builder.addEdge(arc); + builder.addEdge(diameter); + assertTrue("OverlayNG may consume exact edges", builder.hasExactSegment()); + List edges = builder.buildPrepared(); + assertTrue("noded edges from prepared circular + certified", + edges.size() >= 1); + assertTrue(builder.hasEdgesFor(0)); + assertTrue(builder.hasEdgesFor(1)); + } + + public void testStockExtractIsLinearized() { + EdgeNodingBuilder builder = new EdgeNodingBuilder(new PrecisionModel(), + null); + builder.build( + read("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), + read("POLYGON ((2 0, 3 0, 3 1, 2 1, 2 0))")); + assertFalse("stock extract stays linearized", builder.hasExactSegment()); + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/BezierCurve.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/BezierCurve.java new file mode 100644 index 0000000000..487b5aa882 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/BezierCurve.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * PRF-BEZIER (WKB 19): a cubic Bézier curve (or chain of cubics) + * defined by control points. A single cubic uses 4 controls; a + * chain of {@code k} cubics uses {@code 3k+1} controls (C0 join). + *

    + * Not {@code shape.CubicBezierCurve} (polyline smoother) and not a + * canvas draw path. Greenfield fork type (#1195 MMF SIGN). + */ +public class BezierCurve extends LineString implements Linearizable { + private static final long serialVersionUID = 1L; + + public BezierCurve(CoordinateSequence points, GeometryFactory factory) { + super(points, factory); + int n = points != null ? points.size() : 0; + if (n > 0 && (n < 4 || (n - 1) % 3 != 0)) { + throw new IllegalArgumentException( + "BEZIER control count must be 3k+1 with k>=1 (got " + n + ")"); + } + } + + @Override + public String getGeometryType() { + return "BezierCurve"; + } + + @Override + protected BezierCurve copyInternal() { + return new BezierCurve(getCoordinateSequence().copy(), getFactory()); + } + + @Override + protected boolean isEquivalentClass(Geometry other) { + return other instanceof BezierCurve; + } + + @Override + public Geometry toLinear(double tolerance) { + if (isEmpty()) { + return getFactory().createLineString(); + } + CoordinateSequence seq = getCoordinateSequence(); + int n = seq.size(); + int segs = (n - 1) / 3; + int samplesPer = samplesPerCubic(tolerance); + List out = new ArrayList(segs * samplesPer + 1); + for (int s = 0; s < segs; s++) { + int i = 3 * s; + Coordinate p0 = seq.getCoordinate(i); + Coordinate p1 = seq.getCoordinate(i + 1); + Coordinate p2 = seq.getCoordinate(i + 2); + Coordinate p3 = seq.getCoordinate(i + 3); + int from = (s == 0) ? 0 : 1; + for (int t = from; t <= samplesPer; t++) { + double u = (double) t / samplesPer; + out.add(evalCubic(p0, p1, p2, p3, u)); + } + } + return getFactory().createLineString(out.toArray(new Coordinate[0])); + } + + @Override + public Envelope getEnvelopeInternal() { + // Control hull contains the curve; densify expands if needed later. + return super.getEnvelopeInternal(); + } + + private static int samplesPerCubic(double tolerance) { + if (!(tolerance > 0.0)) { + return 16; + } + int n = (int) Math.ceil(1.0 / Math.sqrt(Math.max(tolerance, 1.0e-12))); + if (n < 4) { + return 4; + } + if (n > 64) { + return 64; + } + return n; + } + + /** Bernstein cubic at parameter {@code u} in [0,1]. */ + static Coordinate evalCubic(Coordinate p0, Coordinate p1, Coordinate p2, + Coordinate p3, double u) { + double o = 1.0 - u; + double o2 = o * o; + double o3 = o2 * o; + double u2 = u * u; + double u3 = u2 * u; + double x = o3 * p0.x + 3 * o2 * u * p1.x + 3 * o * u2 * p2.x + u3 * p3.x; + double y = o3 * p0.y + 3 * o2 * u * p1.y + 3 * o * u2 * p2.y + u3 * p3.y; + Coordinate c = new Coordinate(x, y); + if (!Double.isNaN(p0.getZ()) || !Double.isNaN(p3.getZ())) { + double z0 = Double.isNaN(p0.getZ()) ? 0.0 : p0.getZ(); + double z1 = Double.isNaN(p1.getZ()) ? z0 : p1.getZ(); + double z2 = Double.isNaN(p2.getZ()) ? z0 : p2.getZ(); + double z3 = Double.isNaN(p3.getZ()) ? z0 : p3.getZ(); + c.setZ(o3 * z0 + 3 * o2 * u * z1 + 3 * o * u2 * z2 + u3 * z3); + } + return c; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularArcDensifier.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularArcDensifier.java index d2d0ef31db..02ff03ba4c 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularArcDensifier.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularArcDensifier.java @@ -69,6 +69,43 @@ public static List densifyArc(Coordinate start, Coordinate mid, Coor return densifyArc(start, mid, end, tolerance, Collections.emptyList()); } + /** + * Densify a circular arc into {@code numSegments} equal arc-length + * chords (equal central angle). Used by variable-buffer length + * parameterisation (VBF). Degenerate triples return the chord. + */ + public static List densifyArcUniform(Coordinate start, Coordinate mid, + Coordinate end, int numSegments) { + if (numSegments < 1) { + throw new IllegalArgumentException("numSegments must be >= 1: " + numSegments); + } + Circle c = Circle.fromThreePoints(start, mid, end); + if (c == null) { + List out = new ArrayList(2); + out.add(new Coordinate(start)); + out.add(new Coordinate(end)); + return out; + } + double a0 = Math.atan2(start.y - c.cy, start.x - c.cx); + double aMid = Math.atan2(mid.y - c.cy, mid.x - c.cx); + double a1 = Math.atan2(end.y - c.cy, end.x - c.cx); + boolean ccw = isMidInCcwSweep(a0, aMid, a1); + double sweep = signedSweep(a0, a1, ccw); + double delta = sweep / numSegments; + if (!ccw) { + delta = -delta; + } + List out = new ArrayList(numSegments + 1); + out.add(new Coordinate(start)); + for (int i = 1; i < numSegments; i++) { + double ang = a0 + i * delta; + out.add(new Coordinate(c.cx + c.r * Math.cos(ang), + c.cy + c.r * Math.sin(ang))); + } + out.add(new Coordinate(end)); + return out; + } + /** * Densify a single arc, also guaranteeing that every projection of a * {@code mustInclude} coordinate that lies within {@code tolerance} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularString.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularString.java index 4358748f30..5e27ddbdf2 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularString.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CircularString.java @@ -20,9 +20,11 @@ import org.locationtech.jts.geom.CoordinateSequences; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.io.curve.CurveWKTWriter; /** * A connected sequence of circular arcs, where each consecutive triple of @@ -41,6 +43,82 @@ public CircularString(CoordinateSequence points, GeometryFactory factory) { super(points, factory); } + @Override + public Point getCentroid() { + Coordinate c = arcLengthCentroid(); + if (c != null) { + return getFactory().createPoint(c); + } + return super.getCentroid(); + } + + /** + * Wire (arc-length) centroid of the CircularString, or {@code null} + * if empty / too short. C-LIN (#1195). + */ + Coordinate arcLengthCentroid() { + CoordinateSequence seq = getCoordinateSequence(); + int n = seq.size(); + if (n < 3) { + return null; + } + double sx = 0, sy = 0, total = 0; + for (int i = 0; i + 2 < n; i += 2) { + Coordinate a = seq.getCoordinate(i); + Coordinate mid = seq.getCoordinate(i + 1); + Coordinate b = seq.getCoordinate(i + 2); + CircularArcDensifier.Circle cir = CircularArcDensifier.Circle.fromThreePoints( + a, mid, b); + double len = CircularArcDensifier.arcLength(a, mid, b); + if (len <= 0.0) { + continue; + } + if (cir == null) { + sx += len * 0.5 * (a.x + b.x); + sy += len * 0.5 * (a.y + b.y); + total += len; + continue; + } + double a0 = Math.atan2(a.y - cir.cy, a.x - cir.cx); + double aMid = Math.atan2(mid.y - cir.cy, mid.x - cir.cx); + double a1 = Math.atan2(b.y - cir.cy, b.x - cir.cx); + boolean ccw = midInCcw(a0, aMid, a1); + double sweep = ccw ? normPos(a1 - a0) : -normPos(a0 - a1); + if (sweep == 0.0) { + sweep = ccw ? 2 * Math.PI : -2 * Math.PI; + } + // ∫ r cos φ · r dφ / L with L = r|θ|, dφ along direction of travel + double cx; + double cy; + if (ccw) { + cx = cir.cx + (cir.r / sweep) * (Math.sin(a1) - Math.sin(a0)); + cy = cir.cy + (cir.r / sweep) * (-Math.cos(a1) + Math.cos(a0)); + } else { + cx = cir.cx + (cir.r / (-sweep)) * (Math.sin(a0) - Math.sin(a1)); + cy = cir.cy + (cir.r / (-sweep)) * (-Math.cos(a0) + Math.cos(a1)); + } + sx += len * cx; + sy += len * cy; + total += len; + } + if (total <= 0.0) { + return null; + } + return new Coordinate(sx / total, sy / total); + } + + private static boolean midInCcw(double a0, double aMid, double a1) { + return normPos(aMid - a0) <= normPos(a1 - a0) + 1.0e-15; + } + + private static double normPos(double a) { + double t = a % (2 * Math.PI); + if (t < 0) { + t += 2 * Math.PI; + } + return t; + } + @Override public String getGeometryType() { return "CircularString"; @@ -172,6 +250,14 @@ public Geometry toLinear(double tolerance, List mustInclude) { // only the control points. Route them through a densified copy instead; see // CurveOps for the tolerance rationale and its limits. + /** + * Core {@code WKTWriter} refuses to flatten arcs to untagged lines. + */ + @Override + public String toText() { + return new CurveWKTWriter().write(this); + } + @Override public Geometry convexHull() { return CurveOps.convexHull(this); diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/ClothoidSegment.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/ClothoidSegment.java index 17bfdcdfb4..37f6c45876 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/ClothoidSegment.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/ClothoidSegment.java @@ -56,7 +56,9 @@ public ClothoidSegment(Coordinate startPoint, double startTangent, private ClothoidSegment(Coordinate startPoint, double startTangent, double startKappa, double endKappa, double length, double[] endXYTheta, GeometryFactory factory) { - super(twoPointSeq(startPoint, new Coordinate(endXYTheta[0], endXYTheta[1]), factory), + super(twoPointSeq(startPoint, + endWithStartOrdinates(startPoint, endXYTheta[0], endXYTheta[1]), + factory), factory); if (length <= 0 || Double.isNaN(length) || Double.isInfinite(length)) { throw new IllegalArgumentException("CLOTHOID length must be finite > 0, got " + length); @@ -66,12 +68,12 @@ private ClothoidSegment(Coordinate startPoint, double startTangent, "CLOTHOID requires startKappa != endKappa (got " + startKappa + ", " + endKappa + "); use CIRCULARSTRING or LINESTRING for the degenerate case."); } - this.startPoint = new Coordinate(startPoint); + this.startPoint = startPoint.copy(); this.startTangent = startTangent; this.startKappa = startKappa; this.endKappa = endKappa; this.length = length; - this.endPoint = new Coordinate(endXYTheta[0], endXYTheta[1]); + this.endPoint = endWithStartOrdinates(startPoint, endXYTheta[0], endXYTheta[1]); this.endTangent = endXYTheta[2]; } @@ -94,8 +96,8 @@ public static ClothoidSegment fromAandLength(Coordinate startPoint, startKappa + dK, length, factory); } - public Coordinate getStartCoordinate() { return new Coordinate(startPoint); } - public Coordinate getEndCoordinate() { return new Coordinate(endPoint); } + public Coordinate getStartCoordinate() { return startPoint.copy(); } + public Coordinate getEndCoordinate() { return endPoint.copy(); } public double getStartTangent() { return startTangent; } public double getEndTangent() { return endTangent; } public double getStartKappa() { return startKappa; } @@ -324,10 +326,19 @@ private static Coordinate[] densifyByN(Coordinate p0, double theta0, private static CoordinateSequence twoPointSeq(Coordinate a, Coordinate b, GeometryFactory factory) { return factory.getCoordinateSequenceFactory().create(new Coordinate[] { - new Coordinate(a), new Coordinate(b) + a.copy(), b.copy() }); } + /** End XY with Z/M copied from start when present (ISO ordinate honesty). */ + private static Coordinate endWithStartOrdinates(Coordinate start, double x, + double y) { + Coordinate end = start.copy(); + end.setX(x); + end.setY(y); + return end; + } + private static double normaliseAngle(double theta) { while (theta > Math.PI) { theta -= 2.0 * Math.PI; diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CompoundCurve.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CompoundCurve.java index 4d1b66df64..79b97ebd66 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CompoundCurve.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CompoundCurve.java @@ -16,12 +16,16 @@ import java.util.List; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateFilter; import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.CoordinateSequenceFilter; +import org.locationtech.jts.geom.CoordinateSequences; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.curve.CurveWKTWriter; /** * A connected sequence of {@link LineString} and {@link CircularString} @@ -112,6 +116,79 @@ protected CompoundCurve copyInternal() { return new CompoundCurve(copies, getFactory()); } + /** + * Applies the filter to every member, including each circular + * member's start. + *

    + * The inherited {@link LineString#apply(CoordinateFilter)} walks only + * the concatenated sequence. {@link #concatMembers} drops each later + * member's start (the junction already stored as the previous member's + * end). Those starts are distinct {@link Coordinate} objects, so a + * translate moved mid and end of a {@code CIRCULARSTRING} (ISO/IEC + * 13249-3) and left the start behind — the arc warped. Member-first + * apply keeps the three-point arc and the type. + *

    + * Affine translate is the signed type-honest case. This does + * not claim that shear or non-uniform scale still describes a circular + * arc. No Bézier I/O type is introduced. + */ + @Override + public void apply(CoordinateFilter filter) { + if (members.length == 0) { + super.apply(filter); + return; + } + for (int i = 0; i < members.length; i++) { + members[i].apply(filter); + } + syncConcatenatedSequence(); + } + + /** + * Same member-first walk as {@link #apply(CoordinateFilter)}. The + * concatenated sequence is then copied from the members so + * {@code getCoordinates()} stays a continuous polyline. Do not apply + * the filter to the parent sequence as well — those points are often + * the same objects as the members', and a second pass would translate + * twice. + */ + @Override + public void apply(CoordinateSequenceFilter filter) { + if (members.length == 0) { + super.apply(filter); + return; + } + for (int i = 0; i < members.length; i++) { + members[i].apply(filter); + if (filter.isDone()) { + break; + } + } + syncConcatenatedSequence(); + if (filter.isGeometryChanged()) { + geometryChanged(); + } + } + + /** + * Copies member coordinates into the concatenated parent sequence, + * skipping each later member's start the same way + * {@link #concatMembers} does. In-place so a {@code CurvePolygon} + * flat ring that wraps this sequence stays aligned. + */ + private void syncConcatenatedSequence() { + CoordinateSequence dest = points; + int k = 0; + for (int i = 0; i < members.length; i++) { + CoordinateSequence src = members[i].getCoordinateSequence(); + int from = (i == 0) ? 0 : 1; + for (int j = from; j < src.size() && k < dest.size(); j++) { + CoordinateSequences.copyCoord(src, j, dest, k); + k++; + } + } + } + /** * §3.7 — type identity is required. Without this override a CompoundCurve * would compare equal to a plain LineString that happens to have the @@ -239,6 +316,14 @@ private static CoordinateSequence concatMembers(LineString[] members, GeometryFa // only the control points. Route them through a densified copy instead; see // CurveOps for the tolerance rationale and its limits. + /** + * Core {@code WKTWriter} refuses to flatten arc members to untagged lines. + */ + @Override + public String toText() { + return new CurveWKTWriter().write(this); + } + @Override public Geometry convexHull() { return CurveOps.convexHull(this); diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveCoverageUnion.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveCoverageUnion.java new file mode 100644 index 0000000000..085837ff0c --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveCoverageUnion.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026 Jeroen Tech Solutions Ltd / JTS contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryCollection; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * COV (#1195): coverage union for hole-free {@link CurvePolygon}s that + * dissolve shared boundary members while keeping exterior + * {@link CircularString} / line members intact. + *

    + * Chainsaw {@code CoverageUnion} densifies first and cannot emit + * CIRCULARSTRING edges. This laser walks structural shell members, + * counts each edge (forward or reverse) across the coverage, drops + * count-2 shared edges, and stitches the remaining count-1 edges into + * one or more {@link CurvePolygon} shells. + *

    + * Scope: hole-free CurvePolygon / MultiSurface coverages whose shells + * are {@link CircularString} or {@link CompoundCurve}. Invalid coverages + * (overlapping interiors, mismatched shared edges) fall through to + * {@code null} so the caller can use the stock densify path. + */ +public final class CurveCoverageUnion { + + private CurveCoverageUnion() { + } + + /** + * Unions a curve coverage, or returns {@code null} when the input is + * not a supported hole-free CurvePolygon coverage. + * + * @param coverage a GeometryCollection / MultiSurface of CurvePolygons + * @return CurvePolygon or MultiSurface, or {@code null} to fall through + */ + public static Geometry union(Geometry coverage) { + if (coverage == null || coverage.isEmpty()) { + return coverage; + } + List polys = extractCurvePolygons(coverage); + if (polys == null || polys.isEmpty()) { + return null; + } + if (polys.size() == 1) { + return polys.get(0).copy(); + } + + List uses = new ArrayList(); + for (int i = 0; i < polys.size(); i++) { + CurvePolygon cp = polys.get(i); + if (cp.getNumInteriorRing() > 0) { + return null; // holes out of scope + } + LineString shell = cp.getExteriorCurve(); + if (shell == null) { + return null; + } + List members = shellMembers(shell); + if (members == null || members.isEmpty()) { + return null; + } + for (int m = 0; m < members.size(); m++) { + uses.add(new EdgeUse(members.get(m), i)); + } + } + + Map> byKey = new HashMap>(); + for (int i = 0; i < uses.size(); i++) { + EdgeUse u = uses.get(i); + String key = u.canonicalKey(); + List bucket = byKey.get(key); + if (bucket == null) { + bucket = new ArrayList(); + byKey.put(key, bucket); + } + bucket.add(u); + } + + List exterior = new ArrayList(); + for (Iterator> it = byKey.values().iterator(); it.hasNext();) { + List bucket = it.next(); + if (bucket.size() == 1) { + exterior.add(bucket.get(0).edge); + } + else if (bucket.size() == 2) { + // shared interior edge — dissolve + continue; + } + else { + // odd multiplicity / invalid coverage + return null; + } + } + + if (exterior.isEmpty()) { + return null; + } + + GeometryFactory gf = coverage.getFactory(); + if (!(gf instanceof CurveGeometryFactory)) { + gf = new CurveGeometryFactory(gf.getPrecisionModel(), gf.getSRID()); + } + CurveGeometryFactory cf = (CurveGeometryFactory) gf; + + List rings = stitchRings(exterior); + if (rings == null || rings.isEmpty()) { + return null; + } + if (rings.size() == 1) { + return cf.createCurvePolygon(rings.get(0), null); + } + CurvePolygon[] cps = new CurvePolygon[rings.size()]; + for (int i = 0; i < rings.size(); i++) { + cps[i] = cf.createCurvePolygon(rings.get(i), null); + } + return cf.createMultiSurface(cps); + } + + private static List extractCurvePolygons(Geometry g) { + List out = new ArrayList(); + if (g instanceof CurvePolygon) { + out.add((CurvePolygon) g); + return out; + } + if (g instanceof MultiSurface || g instanceof GeometryCollection) { + for (int i = 0; i < g.getNumGeometries(); i++) { + Geometry n = g.getGeometryN(i); + if (!(n instanceof CurvePolygon)) { + return null; + } + out.add((CurvePolygon) n); + } + return out; + } + return null; + } + + private static List shellMembers(LineString shell) { + List out = new ArrayList(); + if (shell instanceof CompoundCurve) { + CompoundCurve cc = (CompoundCurve) shell; + for (int i = 0; i < cc.getNumMembers(); i++) { + out.add(cc.getMemberN(i)); + } + return out; + } + if (shell instanceof CircularString || shell instanceof LineString) { + out.add(shell); + return out; + } + return null; + } + + /** + * Greedy stitch of directed edges into closed rings. Prefers the + * member orientation as stored; reverses a copy when only the reverse + * endpoint matches. + */ + private static List stitchRings(List edges) { + List remaining = new ArrayList(edges); + List rings = new ArrayList(); + while (!remaining.isEmpty()) { + List ring = new ArrayList(); + LineString first = remaining.remove(0); + ring.add(first); + Coordinate start = first.getCoordinateN(0); + Coordinate cur = first.getCoordinateN(first.getNumPoints() - 1); + boolean progressed = true; + while (progressed && !cur.equals2D(start)) { + progressed = false; + for (int i = 0; i < remaining.size(); i++) { + LineString cand = remaining.get(i); + Coordinate c0 = cand.getCoordinateN(0); + Coordinate c1 = cand.getCoordinateN(cand.getNumPoints() - 1); + if (cur.equals2D(c0)) { + ring.add(cand); + remaining.remove(i); + cur = c1; + progressed = true; + break; + } + if (cur.equals2D(c1)) { + ring.add(reverseCopy(cand)); + remaining.remove(i); + cur = c0; + progressed = true; + break; + } + } + } + if (!cur.equals2D(start) || ring.isEmpty()) { + return null; + } + rings.add(asShell(ring, first.getFactory())); + } + return rings; + } + + private static LineString asShell(List members, GeometryFactory f) { + if (members.size() == 1) { + return members.get(0); + } + CurveGeometryFactory cf = f instanceof CurveGeometryFactory + ? (CurveGeometryFactory) f + : new CurveGeometryFactory(f.getPrecisionModel(), f.getSRID()); + return cf.createCompoundCurve(members.toArray(new LineString[0])); + } + + private static LineString reverseCopy(LineString ls) { + Coordinate[] pts = ls.getCoordinates(); + Coordinate[] rev = new Coordinate[pts.length]; + for (int i = 0; i < pts.length; i++) { + rev[i] = pts[pts.length - 1 - i].copy(); + } + if (ls instanceof CircularString) { + return new CircularString( + ls.getFactory().getCoordinateSequenceFactory().create(rev), + ls.getFactory()); + } + return ls.getFactory().createLineString(rev); + } + + private static final class EdgeUse { + final LineString edge; + final int polyIndex; + + EdgeUse(LineString edge, int polyIndex) { + this.edge = edge; + this.polyIndex = polyIndex; + } + + String canonicalKey() { + Coordinate[] pts = edge.getCoordinates(); + if (pts.length == 0) { + return "empty"; + } + boolean forward = true; + if (pts.length >= 2) { + Coordinate a = pts[0]; + Coordinate b = pts[pts.length - 1]; + if (b.compareTo(a) < 0) { + forward = false; + } + else if (b.equals2D(a) && pts.length >= 3) { + // closed ring fragment — keep native order + forward = true; + } + } + StringBuilder sb = new StringBuilder(); + sb.append(edge instanceof CircularString ? "A:" : "L:"); + if (forward) { + for (int i = 0; i < pts.length; i++) { + appendCoord(sb, pts[i]); + } + } + else { + for (int i = pts.length - 1; i >= 0; i--) { + appendCoord(sb, pts[i]); + } + } + return sb.toString(); + } + + private static void appendCoord(StringBuilder sb, Coordinate c) { + sb.append(c.x).append(',').append(c.y).append(';'); + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java index 7651f4f923..1ccf263164 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java @@ -110,16 +110,439 @@ static Geometry convexHull(Geometry g) { } /** - * Exact buffer of a circular disc, or {@code null} if {@code g} is not one. - * Open arcs stay on {@code BufferOp}: an offset corridor is not a disc. + * Exact buffer: circular disc → concentric disc; single open + * CircularString arc → CurvePolygon corridor (outer/inner parallels + * + round caps). {@code null} otherwise (chainsaw + warn via + * {@link CurveOps}). */ static Geometry buffer(Geometry g, double distance) { CircularArcDensifier.Circle disc = circularDisc(g); - if (disc == null) return null; - double r = disc.r + distance; - if (r <= 0.0) return g.getFactory().createPolygon(); - return makeDisc(new CircularArcDensifier.Circle(disc.cx, disc.cy, r), - g.getFactory()); + if (disc != null) { + double r = disc.r + distance; + if (r <= 0.0) return g.getFactory().createPolygon(); + return makeDisc(new CircularArcDensifier.Circle(disc.cx, disc.cy, r), + g.getFactory()); + } + if (g instanceof CircularString && isSingleArc((CircularString) g)) { + return bufferOpenArc((CircularString) g, distance); + } + if (g instanceof CompoundCurve) { + CompoundCurve cc = (CompoundCurve) g; + if (cc.isClosed()) { + CurvePolygon wrap = new CurvePolygon(cc, null, g.getFactory()); + Geometry stadiumFromCc = bufferStadium(wrap, distance); + if (stadiumFromCc != null) { + return stadiumFromCc; + } + } + Geometry openCc = bufferOpenCompound(cc, distance); + if (openCc != null) { + return openCc; + } + } + Geometry stadiumBuf = bufferStadium(g, distance); + if (stadiumBuf != null) { + return stadiumBuf; + } + return null; + } + + /** + * BUF-N open mixed corridor: one or more members, each a single + * segment or single circular arc, {@code d > 0}. Builds left/right + * parallels with round joins at junctions and round caps at the + * ends. A collapsed inward arc radius returns {@code null}. + */ + private static Geometry bufferOpenCompound(CompoundCurve cc, double d) { + int n = cc.getNumMembers(); + if (!(d > 0.0) || n < 1) { + return null; + } + LineString[] members = new LineString[n]; + for (int i = 0; i < n; i++) { + members[i] = cc.getMemberN(i); + if (!isBufferablePiece(members[i])) { + return null; + } + if (i > 0) { + Coordinate a = members[i - 1].getCoordinateN( + members[i - 1].getNumPoints() - 1); + Coordinate b = members[i].getCoordinateN(0); + if (a.distance(b) > 1.0e-9) { + return null; + } + } + } + Parallel[] left = new Parallel[n]; + Parallel[] right = new Parallel[n]; + for (int i = 0; i < n; i++) { + left[i] = parallelPiece(members[i], d, true); + right[i] = parallelPiece(members[i], d, false); + if (left[i] == null || right[i] == null) { + return null; + } + } + GeometryFactory f = cc.getFactory(); + java.util.ArrayList shell = new java.util.ArrayList(); + for (int i = 0; i < n; i++) { + if (i > 0) { + Coordinate junction = members[i].getCoordinateN(0); + LineString join = roundJoin(f, junction, left[i - 1].end, left[i].start, + d, true); + if (join != null) { + shell.add(join); + } + } + shell.add(left[i].geom); + } + shell.add(endCap(f, members[n - 1], left[n - 1].end, right[n - 1].end, d, + true)); + for (int i = n - 1; i >= 0; i--) { + if (i < n - 1) { + Coordinate junction = members[i + 1].getCoordinateN(0); + LineString join = roundJoin(f, junction, right[i + 1].start, right[i].end, + d, false); + if (join != null) { + shell.add(join); + } + } + shell.add(reversePiece(right[i].geom)); + } + shell.add(endCap(f, members[0], right[0].start, left[0].start, d, false)); + return new CurvePolygon( + new CompoundCurve(shell.toArray(new LineString[0]), f), null, f); + } + + private static boolean isBufferablePiece(LineString m) { + if (m instanceof CircularString) { + return isSingleArc((CircularString) m); + } + return !(m instanceof CircularString) && m.getNumPoints() == 2 + && !m.getCoordinateN(0).equals2D(m.getCoordinateN(1)); + } + + /** One side of a buffered piece. */ + private static final class Parallel { + final LineString geom; + final Coordinate start; + final Coordinate end; + Parallel(LineString geom, Coordinate start, Coordinate end) { + this.geom = geom; + this.start = start; + this.end = end; + } + } + + private static Parallel parallelPiece(LineString m, double d, boolean left) { + if (m instanceof CircularString) { + return parallelArc((CircularString) m, d, left); + } + return parallelSeg(m, d, left); + } + + private static Parallel parallelSeg(LineString seg, double d, boolean left) { + Coordinate a = seg.getCoordinateN(0); + Coordinate b = seg.getCoordinateN(1); + double dx = b.x - a.x; + double dy = b.y - a.y; + double len = Math.hypot(dx, dy); + if (!(len > 0.0)) { + return null; + } + double nx = -dy / len; + double ny = dx / len; + if (!left) { + nx = -nx; + ny = -ny; + } + Coordinate a2 = new Coordinate(a.x + d * nx, a.y + d * ny); + Coordinate b2 = new Coordinate(b.x + d * nx, b.y + d * ny); + LineString g = seg.getFactory().createLineString(new Coordinate[] { a2, b2 }); + return new Parallel(g, a2, b2); + } + + private static Parallel parallelArc(CircularString arc, double d, boolean left) { + Coordinate p0 = arc.getCoordinateN(0); + Coordinate p1 = arc.getCoordinateN(1); + Coordinate p2 = arc.getCoordinateN(2); + CircularArcDensifier.Circle c = CircularArcDensifier.Circle.fromThreePoints( + p0, p1, p2); + if (c == null || !(c.r > 0.0)) { + return null; + } + double a0 = Math.atan2(p0.y - c.cy, p0.x - c.cx); + double a1 = Math.atan2(p1.y - c.cy, p1.x - c.cx); + double a2 = Math.atan2(p2.y - c.cy, p2.x - c.cx); + boolean ccw = midInCcw(a0, a1, a2); + // Left of CCW is inward (smaller radius). + boolean inward = left == ccw; + double rn = inward ? c.r - d : c.r + d; + if (!(rn > 0.0)) { + return null; + } + Coordinate q0 = radial(c, a0, rn); + Coordinate q1 = radial(c, a1, rn); + Coordinate q2 = radial(c, a2, rn); + CircularString g = circ(arc.getFactory(), q0, q1, q2); + return new Parallel(g, q0, q2); + } + + private static LineString reversePiece(LineString g) { + if (g instanceof CircularString) { + return (LineString) g.reverse(); + } + Coordinate[] pts = g.getCoordinates(); + Coordinate[] rev = new Coordinate[pts.length]; + for (int i = 0; i < pts.length; i++) { + rev[i] = new Coordinate(pts[pts.length - 1 - i]); + } + return g.getFactory().createLineString(rev); + } + + /** + * Round join of radius {@code d} at {@code vertex} from {@code from} + * to {@code to}. Returns {@code null} when the points already meet + * (G1 / collinear offset). + */ + private static LineString roundJoin(GeometryFactory f, Coordinate vertex, + Coordinate from, Coordinate to, double d, boolean preferCcw) { + if (from.distance(to) <= 1.0e-9) { + return null; + } + Coordinate mid = joinMid(vertex, from, to, d, preferCcw); + return circ(f, from, mid, to); + } + + private static Coordinate joinMid(Coordinate v, Coordinate from, Coordinate to, + double d, boolean preferCcw) { + double ax = from.x - v.x; + double ay = from.y - v.y; + double bx = to.x - v.x; + double by = to.y - v.y; + double al = Math.hypot(ax, ay); + double bl = Math.hypot(bx, by); + if (al < 1.0e-12 || bl < 1.0e-12) { + return new Coordinate(v.x + d, v.y); + } + ax /= al; + ay /= al; + bx /= bl; + by /= bl; + double cross = ax * by - ay * bx; + double mx = ax + bx; + double my = ay + by; + boolean ccwShort = cross >= 0.0; + if (preferCcw != ccwShort) { + mx = -mx; + my = -my; + } + double ml = Math.hypot(mx, my); + if (ml < 1.0e-12) { + mx = -ay; + my = ax; + ml = 1.0; + } + return new Coordinate(v.x + d * mx / ml, v.y + d * my / ml); + } + + private static CircularString endCap(GeometryFactory f, LineString piece, + Coordinate from, Coordinate to, double d, boolean atEnd) { + Coordinate tip = capTip(piece, d, atEnd); + return circ(f, from, tip, to); + } + + private static Coordinate capTip(LineString piece, double d, boolean atEnd) { + if (piece instanceof CircularString) { + CircularString arc = (CircularString) piece; + Coordinate p0 = arc.getCoordinateN(0); + Coordinate p1 = arc.getCoordinateN(1); + Coordinate p2 = arc.getCoordinateN(2); + CircularArcDensifier.Circle c = CircularArcDensifier.Circle.fromThreePoints( + p0, p1, p2); + double a0 = Math.atan2(p0.y - c.cy, p0.x - c.cx); + double a1 = Math.atan2(p1.y - c.cy, p1.x - c.cx); + double a2 = Math.atan2(p2.y - c.cy, p2.x - c.cx); + boolean ccw = midInCcw(a0, a1, a2); + return tangentTip(c, atEnd ? a2 : a0, ccw, d, atEnd); + } + Coordinate a = piece.getCoordinateN(0); + Coordinate b = piece.getCoordinateN(1); + double dx = b.x - a.x; + double dy = b.y - a.y; + double len = Math.hypot(dx, dy); + double ux = dx / len; + double uy = dy / len; + Coordinate tipAt = atEnd ? b : a; + if (!atEnd) { + ux = -ux; + uy = -uy; + } + return new Coordinate(tipAt.x + d * ux, tipAt.y + d * uy); + } + + /** + * BUF-N subset: certified stadium {@link CurvePolygon} dilates to a + * larger stadium (caps at {@code r+d}, sides re-joined). Erosion and + * non-stadium CompoundCurves stay on the chainsaw. + */ + private static Geometry bufferStadium(Geometry g, double d) { + if (!(d > 0.0)) { + return null; + } + if (!(g instanceof CurvePolygon)) { + return null; + } + CircularArcDensifier.Circle mic = stadiumMic(g); + if (mic == null) { + return null; + } + CurvePolygon cp = (CurvePolygon) g; + CompoundCurve cc = (CompoundCurve) cp.getExteriorCurve(); + GeometryFactory f = g.getFactory(); + LineString[] scaled = new LineString[4]; + for (int i = 0; i < 4; i++) { + LineString m = cc.getMemberN(i); + if (m instanceof CircularString) { + scaled[i] = scaleArcOut((CircularString) m, d); + if (scaled[i] == null) { + return null; + } + } + } + for (int i = 0; i < 4; i++) { + if (scaled[i] != null) { + continue; + } + LineString prev = scaled[(i + 3) % 4]; + LineString next = scaled[(i + 1) % 4]; + if (prev == null || next == null) { + return null; + } + Coordinate a = prev.getCoordinateN(prev.getNumPoints() - 1); + Coordinate b = next.getCoordinateN(0); + scaled[i] = f.createLineString(new Coordinate[] { + new Coordinate(a), new Coordinate(b) + }); + } + CompoundCurve shell = new CompoundCurve(scaled, f); + return new CurvePolygon(shell, null, f); + } + + private static CircularString scaleArcOut(CircularString cs, double d) { + Coordinate p0 = cs.getCoordinateN(0); + Coordinate p1 = cs.getCoordinateN(1); + Coordinate p2 = cs.getCoordinateN(2); + CircularArcDensifier.Circle c = CircularArcDensifier.Circle.fromThreePoints( + p0, p1, p2); + if (c == null || !(c.r > 0.0)) { + return null; + } + double k = (c.r + d) / c.r; + Coordinate[] pts = new Coordinate[] { + scalePt(p0, c.cx, c.cy, k), + scalePt(p1, c.cx, c.cy, k), + scalePt(p2, c.cx, c.cy, k) + }; + return new CircularString( + cs.getFactory().getCoordinateSequenceFactory().create(pts), + cs.getFactory()); + } + + private static Coordinate scalePt(Coordinate p, double cx, double cy, + double k) { + return new Coordinate(cx + (p.x - cx) * k, cy + (p.y - cy) * k); + } + + /** + * BUF-1 / BUF-NEG: open single-arc buffer corridor. + * Positive {@code d} dilates; {@code |d|} larger than radius on the + * inward side collapses that parallel (returns empty for + * {@code d < 0 && |d| >= r} on an open arc — BUF-NEG). + */ + private static Geometry bufferOpenArc(CircularString arc, double d) { + if (d == 0.0) { + return arc.copy(); + } + double abs = Math.abs(d); + Coordinate p0 = arc.getCoordinateN(0); + Coordinate p1 = arc.getCoordinateN(1); + Coordinate p2 = arc.getCoordinateN(2); + CircularArcDensifier.Circle c = CircularArcDensifier.Circle.fromThreePoints( + p0, p1, p2); + if (c == null) { + return null; + } + // Negative buffer of an open arc is not a simple corridor. + if (d < 0.0) { + if (abs >= c.r) { + return arc.getFactory().createPolygon(); // BUF-NEG empty + } + return null; // erosion of open arc → chainsaw + } + if (c.r - abs <= 0.0) { + // Inward parallel collapses: buffer is outer arc + full end discs + // joined — fall through until a dedicated cell lands. + return null; + } + GeometryFactory f = arc.getFactory(); + double a0 = Math.atan2(p0.y - c.cy, p0.x - c.cx); + double a1 = Math.atan2(p1.y - c.cy, p1.x - c.cx); + double a2 = Math.atan2(p2.y - c.cy, p2.x - c.cx); + boolean ccw = midInCcw(a0, a1, a2); + + Coordinate out0 = radial(c, a0, c.r + abs); + Coordinate out1 = radial(c, a1, c.r + abs); + Coordinate out2 = radial(c, a2, c.r + abs); + Coordinate in0 = radial(c, a0, c.r - abs); + Coordinate in1 = radial(c, a1, c.r - abs); + Coordinate in2 = radial(c, a2, c.r - abs); + + CircularString outer = circ(f, out0, out1, out2); + CircularString innerRev = circ(f, in2, in1, in0); + + // End caps: semicircle of radius |d| centred at the arc end, + // from outer end through the outward tangent tip to inner end. + Coordinate endTanTip = tangentTip(c, a2, ccw, abs, true); + Coordinate startTanTip = tangentTip(c, a0, ccw, abs, false); + CircularString capEnd = circ(f, out2, endTanTip, in2); + CircularString capStart = circ(f, in0, startTanTip, out0); + + CompoundCurve shell = new CompoundCurve( + new LineString[] { outer, capEnd, innerRev, capStart }, f); + return new CurvePolygon(shell, null, f); + } + + private static Coordinate radial(CircularArcDensifier.Circle c, double ang, + double r) { + return new Coordinate(c.cx + r * Math.cos(ang), c.cy + r * Math.sin(ang)); + } + + /** + * Tip of the round cap: centre ± |d| along the arc tangent (forward at + * end, backward at start). + */ + private static Coordinate tangentTip(CircularArcDensifier.Circle c, + double ang, boolean ccw, double d, boolean atEnd) { + // Unit tangent for increasing angle (CCW): (-sin, cos) + double tx = -Math.sin(ang); + double ty = Math.cos(ang); + if (!ccw) { + tx = -tx; + ty = -ty; + } + if (!atEnd) { + tx = -tx; + ty = -ty; + } + double px = c.cx + c.r * Math.cos(ang); + double py = c.cy + c.r * Math.sin(ang); + return new Coordinate(px + d * tx, py + d * ty); + } + + private static CircularString circ(GeometryFactory f, Coordinate a, + Coordinate b, Coordinate c) { + return new CircularString(f.getCoordinateSequenceFactory().create( + new Coordinate[] { a, b, c }), f); } /** diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveGeometryFactory.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveGeometryFactory.java index 2d97a907c2..fb1fec1d32 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveGeometryFactory.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveGeometryFactory.java @@ -11,6 +11,7 @@ */ package org.locationtech.jts.geom.curve; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateSequenceFactory; import org.locationtech.jts.geom.Geometry; @@ -91,6 +92,32 @@ public MultiCurve createMultiCurve(LineString[] members) { return new MultiCurve(members, this); } + @Override + public ClothoidSegment createClothoid(Coordinate start, double startTangent, + double startKappa, double endKappa, double length) { + return new ClothoidSegment(start, startTangent, startKappa, endKappa, + length, this); + } + + @Override + public BezierCurve createBezierCurve(CoordinateSequence controls) { + return new BezierCurve(controls, this); + } + + @Override + public EllipseCurve createEllipseCurve(double centreX, double centreY, + double centreZ, double semiMajor, double semiMinor, double rotation, + double startAngle, double endAngle) { + return new EllipseCurve(centreX, centreY, centreZ, semiMajor, semiMinor, + rotation, startAngle, endAngle, this); + } + + @Override + public NurbsCurve createNurbsCurve(CoordinateSequence controls, int degree, + double[] weights, double[] knots) { + return new NurbsCurve(controls, degree, weights, knots, this); + } + /** * Returns a {@link MultiCurve} when any member carries an arc, otherwise the * plain {@code MultiLineString} core would build. diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveIntersection.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveIntersection.java new file mode 100644 index 0000000000..37c1f9c710 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveIntersection.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.LineSegment; + +/** + * Public arc–arc and arc–line intersection utilities (N-AA / N-AL, #1195). + * Wraps the circular solvers in {@link CircularArcDensifier}. Returns + * intersection coordinates lying on both arcs (or the arc and segment); + * empty array when none. + */ +public final class CurveIntersection { + + private CurveIntersection() { } + + /** + * Intersection points of two circular arcs defined by three control + * points each (SQL/MM CIRCULARSTRING triples). + * + * @return 0, 1, or 2 points on both arcs + */ + public static Coordinate[] arcArc( + Coordinate a0, Coordinate a1, Coordinate a2, + Coordinate b0, Coordinate b1, Coordinate b2) { + CircularArcDensifier.Circle ca = CircularArcDensifier.Circle.fromThreePoints( + a0, a1, a2); + CircularArcDensifier.Circle cb = CircularArcDensifier.Circle.fromThreePoints( + b0, b1, b2); + if (ca == null || cb == null) { + return new Coordinate[0]; + } + Coordinate[] hits = CircularArcDensifier.intersectCircles(ca, cb); + if (hits == null) { + return new Coordinate[0]; + } + return filterOnBothArcs(hits, a0, a1, a2, b0, b1, b2); + } + + /** + * Intersection of a circular arc with a line segment. + * + * @return 0, 1, or 2 points on both the arc and the segment + */ + public static Coordinate[] arcLine( + Coordinate a0, Coordinate a1, Coordinate a2, + Coordinate s0, Coordinate s1) { + CircularArcDensifier.Circle ca = CircularArcDensifier.Circle.fromThreePoints( + a0, a1, a2); + if (ca == null) { + return new Coordinate[0]; + } + Coordinate[] hits = CircularArcDensifier.intersectSegmentCircle(ca, s0, s1); + if (hits == null) { + return new Coordinate[0]; + } + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i < hits.length; i++) { + if (hits[i] == null) { + continue; + } + if (onArc(hits[i], a0, a1, a2) && onSegment(hits[i], s0, s1)) { + out.add(hits[i]); + } + } + return out.toArray(new Coordinate[0]); + } + + private static Coordinate[] filterOnBothArcs(Coordinate[] hits, + Coordinate a0, Coordinate a1, Coordinate a2, + Coordinate b0, Coordinate b1, Coordinate b2) { + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i < hits.length; i++) { + if (hits[i] == null) { + continue; + } + if (onArc(hits[i], a0, a1, a2) && onArc(hits[i], b0, b1, b2)) { + out.add(hits[i]); + } + } + return out.toArray(new Coordinate[0]); + } + + private static boolean onArc(Coordinate p, Coordinate a0, Coordinate a1, + Coordinate a2) { + return CircularArcDensifier.distancePointToArc(p, a0, a1, a2) <= 1.0e-9; + } + + private static boolean onSegment(Coordinate p, Coordinate s0, Coordinate s1) { + LineSegment seg = new LineSegment(s0, s1); + return seg.distance(p) <= 1.0e-9; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategy.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategy.java new file mode 100644 index 0000000000..d7016f9ed5 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategy.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.locationtech.jts.geom.Geometry; + +/** + * Explicit policy for when curve inputs densify to chords. + *

    + * MMF contract (#1195 Option B): there is no silent linearization. + * The default mode is {@link #LINEARIZED} (chainsaw earth), but every + * densify of a {@link Linearizable} must log a warning. Exact / preserve + * paths refuse densify here and leave kits / Option B noding to answer. + *

    + * Thread-local override lets TestBuilder / a single call site opt into + * {@link #PRESERVE} without changing process default. + */ +public enum CurveLinearizationStrategy { + + /** + * Explicit chord fallback. Default. Densify is allowed and must + * warn. + */ + LINEARIZED, + + /** + * Keep curve identity. {@link CurveOps#linearise(Geometry)} returns the + * input unchanged when it is {@link Linearizable}; callers that need + * chords must set {@link #LINEARIZED} deliberately. + */ + PRESERVE; + + private static final Logger LOG = Logger.getLogger( + CurveLinearizationStrategy.class.getName()); + + /** + * Optional sink for TestBuilder / host UI log panels. JUL remains the + * default; sinks receive every warn regardless of JUL level. + */ + public interface WarnSink { + void warn(String message); + } + + private static volatile WarnSink warnSink; + + /** + * Registers a UI / host warn sink (e.g. TestBuilder Log tab). + * + * @param sink the sink, or {@code null} to clear + */ + public static void setWarnSink(WarnSink sink) { + warnSink = sink; + } + + private static final ThreadLocal OVERRIDE = + new ThreadLocal(); + + private static volatile CurveLinearizationStrategy processDefault = LINEARIZED; + + /** + * Process-wide default. Starts as {@link #LINEARIZED}. + * + * @return the default strategy + */ + public static CurveLinearizationStrategy getDefault() { + return processDefault; + } + + /** + * Sets the process-wide default. + * + * @param strategy the strategy; {@code null} resets to {@link #LINEARIZED} + */ + public static void setDefault(CurveLinearizationStrategy strategy) { + processDefault = strategy == null ? LINEARIZED : strategy; + } + + /** + * Effective strategy for this thread (override or process default). + * + * @return the effective strategy + */ + public static CurveLinearizationStrategy current() { + CurveLinearizationStrategy o = OVERRIDE.get(); + return o != null ? o : processDefault; + } + + /** + * Thread-local override for the duration of a call stack. + * + * @param strategy override, or {@code null} to clear + */ + public static void setThreadOverride(CurveLinearizationStrategy strategy) { + if (strategy == null) { + OVERRIDE.remove(); + } + else { + OVERRIDE.set(strategy); + } + } + + /** + * Clears the thread-local override. + */ + public static void clearThreadOverride() { + OVERRIDE.remove(); + } + + /** + * Logs the mandatory linearization warning when a curve is densified. + * + * @param g the curve being linearized + * @param op a short op name for the message (e.g. {@code "CurveOps.linearise"}) + */ + public static void warnLinearized(Geometry g, String op) { + if (g == null) { + return; + } + String type = g.getGeometryType(); + String msg = op + ": linearizing " + type + + " under CurveLinearizationStrategy." + current() + + " (explicit strategy; not a silent flatten)"; + if (LOG.isLoggable(Level.WARNING)) { + LOG.warning(msg); + } + WarnSink sink = warnSink; + if (sink != null) { + sink.warn(msg); + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveOps.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveOps.java index 97ec5ea2fc..5786d21103 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveOps.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveOps.java @@ -11,9 +11,11 @@ */ package org.locationtech.jts.geom.curve; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.IntersectionMatrix; +import org.locationtech.jts.geom.LineString; import org.locationtech.jts.operation.overlayng.curve.OverlayNGCurve; /** @@ -65,12 +67,221 @@ private CurveOps() { } * A densified copy of {@code g} if it is a curve, otherwise {@code g} * unchanged. Applied to both operands so a curve-to-curve operation sees * arcs on both sides. + *

    + * Honour {@link CurveLinearizationStrategy}: {@link + * CurveLinearizationStrategy#PRESERVE} returns {@code g} unchanged. + * {@link CurveLinearizationStrategy#LINEARIZED} (default) densifies and + * always warns — no silent flatten (#1195 MMF). */ public static Geometry linearise(Geometry g) { if (!(g instanceof Linearizable)) return g; + if (CurveLinearizationStrategy.current() + == CurveLinearizationStrategy.PRESERVE) { + return g; + } + CurveLinearizationStrategy.warnLinearized(g, "CurveOps.linearise"); return ((Linearizable) g).toLinear(tolerance(g)); } + /** + * Densify a lineal curve into a polyline whose vertices are spaced + * by equal arc length (circular members) or equal chord + * length (straight members). Used by VariableBuffer so distance + * interpolation follows arc length rather than control-chord length. + * Honours {@link CurveLinearizationStrategy} (warns on LINEARIZED). + */ + public static Geometry lineariseArcLength(Geometry g, int samplesPerArc) { + if (g == null || g.isEmpty()) { + return g; + } + if (!(g instanceof Linearizable) && !(g instanceof LineString)) { + return g; + } + if (CurveLinearizationStrategy.current() + == CurveLinearizationStrategy.PRESERVE) { + return g; + } + if (g instanceof CircularString || g instanceof CompoundCurve + || g instanceof BezierCurve || g instanceof ClothoidSegment + || g instanceof EllipseCurve || g instanceof NurbsCurve) { + CurveLinearizationStrategy.warnLinearized(g, "CurveOps.lineariseArcLength"); + } + int n = Math.max(1, samplesPerArc); + if (g instanceof CircularString) { + return lineariseCircularStringArcLength((CircularString) g, n); + } + if (g instanceof CompoundCurve) { + CompoundCurve cc = (CompoundCurve) g; + java.util.ArrayList pts = new java.util.ArrayList(); + for (int i = 0; i < cc.getNumMembers(); i++) { + LineString m = cc.getMemberN(i); + Geometry piece = lineariseArcLength(m, n); + Coordinate[] c = piece.getCoordinates(); + int from = pts.isEmpty() ? 0 : 1; + for (int k = from; k < c.length; k++) { + pts.add(c[k]); + } + } + return g.getFactory().createLineString( + pts.toArray(new Coordinate[0])); + } + if (g instanceof Linearizable) { + return ((Linearizable) g).toLinear(tolerance(g)); + } + return g; + } + + private static Geometry lineariseCircularStringArcLength(CircularString cs, + int samplesPerArc) { + org.locationtech.jts.geom.CoordinateSequence seq = cs.getCoordinateSequence(); + int n = seq.size(); + if (n < 3) { + return cs.copy(); + } + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i + 2 < n; i += 2) { + java.util.List chord = CircularArcDensifier.densifyArcUniform( + seq.getCoordinate(i), seq.getCoordinate(i + 1), seq.getCoordinate(i + 2), + samplesPerArc); + int from = out.isEmpty() ? 0 : 1; + for (int k = from; k < chord.size(); k++) { + out.add(chord.get(k)); + } + } + return cs.getFactory().createLineString(out.toArray(new Coordinate[0])); + } + + /** + * Point along a lineal curve at arc length {@code s} (clamped). + * CircularString / CompoundCurve use analytical arc length; others + * return {@code null} so callers fall back to chord indexing. + */ + public static Coordinate extractPointByArcLength(Geometry g, double s) { + if (g == null || g.isEmpty()) { + return null; + } + if (g instanceof CircularString) { + return extractOnCircularString((CircularString) g, s); + } + if (g instanceof CompoundCurve) { + CompoundCurve cc = (CompoundCurve) g; + double remaining = s; + if (remaining < 0.0) { + remaining = 0.0; + } + double total = cc.getLength(); + if (remaining > total) { + remaining = total; + } + for (int i = 0; i < cc.getNumMembers(); i++) { + LineString m = cc.getMemberN(i); + double len = m.getLength(); + if (remaining <= len || i == cc.getNumMembers() - 1) { + if (m instanceof CircularString) { + return extractOnCircularString((CircularString) m, remaining); + } + return pointAlongLine(m, remaining); + } + remaining -= len; + } + } + return null; + } + + private static Coordinate extractOnCircularString(CircularString cs, double s) { + org.locationtech.jts.geom.CoordinateSequence seq = cs.getCoordinateSequence(); + int n = seq.size(); + if (n < 3) { + return pointAlongLine(cs, s); + } + double remaining = s; + if (remaining < 0.0) { + remaining = 0.0; + } + double total = cs.getLength(); + if (remaining > total) { + remaining = total; + } + for (int i = 0; i + 2 < n; i += 2) { + Coordinate a = seq.getCoordinate(i); + Coordinate mid = seq.getCoordinate(i + 1); + Coordinate b = seq.getCoordinate(i + 2); + double len = CircularArcDensifier.arcLength(a, mid, b); + if (remaining <= len || i + 2 >= n - 1) { + return pointOnArc(a, mid, b, remaining / (len > 0 ? len : 1.0)); + } + remaining -= len; + } + return new Coordinate(seq.getCoordinate(n - 1)); + } + + private static Coordinate pointOnArc(Coordinate a, Coordinate mid, Coordinate b, + double t) { + if (t <= 0.0) { + return new Coordinate(a); + } + if (t >= 1.0) { + return new Coordinate(b); + } + CircularArcDensifier.Circle c = CircularArcDensifier.Circle.fromThreePoints( + a, mid, b); + if (c == null) { + return new Coordinate( + a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)); + } + double a0 = Math.atan2(a.y - c.cy, a.x - c.cx); + double aMid = Math.atan2(mid.y - c.cy, mid.x - c.cx); + double a1 = Math.atan2(b.y - c.cy, b.x - c.cx); + boolean ccw = midInCcwSweep(a0, aMid, a1); + double sweep = signedSweep(a0, a1, ccw); + double ang = a0 + (ccw ? sweep : -sweep) * t; + return new Coordinate(c.cx + c.r * Math.cos(ang), c.cy + c.r * Math.sin(ang)); + } + + private static boolean midInCcwSweep(double a0, double aMid, double a1) { + double toMid = normPos(aMid - a0); + double toEnd = normPos(a1 - a0); + return toMid <= toEnd + 1.0e-15; + } + + private static double signedSweep(double a0, double a1, boolean ccw) { + double d = ccw ? normPos(a1 - a0) : normPos(a0 - a1); + return d == 0.0 ? 2.0 * Math.PI : d; + } + + private static double normPos(double a) { + double t = a % (2.0 * Math.PI); + if (t < 0) { + t += 2.0 * Math.PI; + } + return t; + } + + private static Coordinate pointAlongLine(LineString ls, double s) { + Coordinate[] pts = ls.getCoordinates(); + if (pts.length == 0) { + return new Coordinate(); + } + if (s <= 0.0) { + return new Coordinate(pts[0]); + } + double remaining = s; + for (int i = 0; i < pts.length - 1; i++) { + double len = pts[i].distance(pts[i + 1]); + if (remaining <= len || i == pts.length - 2) { + double t = len > 0.0 ? remaining / len : 0.0; + if (t > 1.0) { + t = 1.0; + } + return new Coordinate( + pts[i].x + t * (pts[i + 1].x - pts[i].x), + pts[i].y + t * (pts[i + 1].y - pts[i].y)); + } + remaining -= len; + } + return new Coordinate(pts[pts.length - 1]); + } + /** * The densification tolerance {@link #linearise(Geometry)} would use for this * geometry, and therefore the maximum distance by which its densified copy diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurvePolygon.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurvePolygon.java index 94eb397e74..dd311c7efa 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurvePolygon.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurvePolygon.java @@ -12,7 +12,10 @@ package org.locationtech.jts.geom.curve; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateFilter; import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.CoordinateSequenceFilter; +import org.locationtech.jts.geom.CoordinateSequences; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.IntersectionMatrix; @@ -20,6 +23,7 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.io.curve.CurveWKTWriter; /** * A polygon whose rings may be straight, circular, or compound curves. @@ -126,6 +130,24 @@ public LineString getExteriorCurve() { return structuralShell; } + /** + * V-CP (#1195): validity uses densified rings so arc self-intersections + * that are invisible on the control polygon are still detected. Chainsaw + * densify is maintainable here; analytical arc/arc self-intersection can + * replace this later without changing the public contract. + */ + @Override + public boolean isValid() { + if (isEmpty()) { + return true; + } + Envelope env = getEnvelopeInternal(); + double extent = Math.max(env.getWidth(), env.getHeight()); + double tol = (extent > 0.0 ? extent : 1.0) * 1.0e-4; + Geometry flat = toLinear(tol); + return flat.isValid(); + } + /** * Option-A structural accessor for interior rings, the counterpart to * {@link #getExteriorCurve()}. Returns the hole as supplied by the caller, @@ -208,6 +230,114 @@ private static boolean isCurve(LineString ring) { return ring instanceof CircularString || ring instanceof CompoundCurve; } + /** + * Applies the filter to structural rings when they are not the same + * object as the flat {@link LinearRing} views. + *

    + * Inherited {@link Polygon#apply(CoordinateFilter)} visits only the + * flat rings. A {@code CIRCULARSTRING} shell often shares Coordinate + * objects with that view, so a translate already reached it. A + * {@code COMPOUNDCURVE} shell (ISO/IEC 13249-3) does not: the flat + * ring is the concatenated sequence, which omits each later member's + * start. Applying here lets {@link CompoundCurve#apply} move every + * control point, including those starts. Do not also apply to the + * flat rings — shared coordinates would translate twice. + *

    + * Affine translate is the signed type-honest case. Shear / + * non-uniform scale is not signed as keeping a circular arc. + */ + @Override + public void apply(CoordinateFilter filter) { + if (!applyToStructuralRings()) { + super.apply(filter); + return; + } + applyStructural(filter); + syncFlatRingsFromStructural(); + } + + /** + * Same structural-first walk as {@link #apply(CoordinateFilter)}. + */ + @Override + public void apply(CoordinateSequenceFilter filter) { + if (!applyToStructuralRings()) { + super.apply(filter); + return; + } + if (structuralShell != null) { + structuralShell.apply(filter); + } + if (!filter.isDone()) { + for (int i = 0; i < structuralHoles.length; i++) { + if (structuralHoles[i] != null) { + structuralHoles[i].apply(filter); + if (filter.isDone()) { + break; + } + } + } + } + syncFlatRingsFromStructural(); + if (filter.isGeometryChanged()) { + geometryChanged(); + } + } + + private void applyStructural(CoordinateFilter filter) { + if (structuralShell != null) { + structuralShell.apply(filter); + } + for (int i = 0; i < structuralHoles.length; i++) { + if (structuralHoles[i] != null) { + structuralHoles[i].apply(filter); + } + } + } + + /** + * True when a structural ring is a distinct object from the legacy + * flat view ({@code CircularString} / {@code CompoundCurve}). + */ + private boolean applyToStructuralRings() { + if (structuralShell != null && structuralShell != getExteriorRing()) { + return true; + } + for (int i = 0; i < structuralHoles.length; i++) { + if (structuralHoles[i] != null + && structuralHoles[i] != getInteriorRingN(i)) { + return true; + } + } + return false; + } + + /** + * Copies structural control points onto the flat rings when those + * sequences are not the same objects. Default-factory CircularString + * shells already share Coordinates, so this is a no-op there. + */ + private void syncFlatRingsFromStructural() { + if (structuralShell != null && structuralShell != getExteriorRing()) { + syncSequence(structuralShell.getCoordinateSequence(), + getExteriorRing().getCoordinateSequence()); + } + for (int i = 0; i < structuralHoles.length; i++) { + if (structuralHoles[i] != null + && structuralHoles[i] != getInteriorRingN(i)) { + syncSequence(structuralHoles[i].getCoordinateSequence(), + getInteriorRingN(i).getCoordinateSequence()); + } + } + } + + private static void syncSequence(CoordinateSequence src, CoordinateSequence dest) { + int n = Math.min(src.size(), dest.size()); + for (int i = 0; i < n; i++) { + CoordinateSequences.copyCoord(src, i, dest, i); + } + } + /** * Invalidates the structural rings' cached envelopes along with this polygon's. *

    @@ -387,6 +517,14 @@ private static LinearRing linearise(LineString ring, double tolerance, // only the control points. Route them through a densified copy instead; see // CurveOps for the tolerance rationale and its limits. + /** + * Core {@code WKTWriter} refuses to flatten curved rings to untagged polygons. + */ + @Override + public String toText() { + return new CurveWKTWriter().write(this); + } + @Override public Geometry convexHull() { return CurveOps.convexHull(this); diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/EllipseCurve.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/EllipseCurve.java new file mode 100644 index 0000000000..994b37680c --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/EllipseCurve.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * PRF-ELLIPSE (WKB 20): an elliptic arc (or full ellipse) in the plane. + * Parameters: centre, semi-major {@code a}, semi-minor {@code b}, + * rotation (radians), start and end angles (radians, relative to the + * rotated major axis). A full ellipse has {@code endAngle = startAngle + * + 2π} (stored as the pair; {@link #isFullEllipse()} detects a 2π sweep). + *

    + * Greenfield fork type (#1195 MMF SIGN). Parent {@link LineString} + * sequence holds the centre only for envelope/identity anchors; the + * analytic parameters are authoritative. + */ +public class EllipseCurve extends LineString implements Linearizable { + private static final long serialVersionUID = 1L; + + private final double centreX; + private final double centreY; + private final double centreZ; + private final double semiMajor; + private final double semiMinor; + private final double rotation; + private final double startAngle; + private final double endAngle; + + public EllipseCurve(double centreX, double centreY, double centreZ, + double semiMajor, double semiMinor, double rotation, + double startAngle, double endAngle, GeometryFactory factory) { + super(centreSeq(centreX, centreY, centreZ, factory), factory); + if (!(semiMajor > 0.0) || !(semiMinor > 0.0)) { + throw new IllegalArgumentException( + "ELLIPSE semi-axes must be finite > 0"); + } + this.centreX = centreX; + this.centreY = centreY; + this.centreZ = centreZ; + this.semiMajor = semiMajor; + this.semiMinor = semiMinor; + this.rotation = rotation; + this.startAngle = startAngle; + this.endAngle = endAngle; + } + + private static CoordinateSequence centreSeq(double x, double y, double z, + GeometryFactory factory) { + Coordinate c0 = new Coordinate(x, y); + Coordinate c1 = new Coordinate(x, y); + if (!Double.isNaN(z)) { + c0.setZ(z); + c1.setZ(z); + } + // LineString requires 0 or >= 2 points; centre is duplicated as anchor. + return factory.getCoordinateSequenceFactory() + .create(new Coordinate[] { c0, c1 }); + } + + public double getCentreX() { return centreX; } + public double getCentreY() { return centreY; } + public double getCentreZ() { return centreZ; } + public double getSemiMajor() { return semiMajor; } + public double getSemiMinor() { return semiMinor; } + public double getRotation() { return rotation; } + public double getStartAngle() { return startAngle; } + public double getEndAngle() { return endAngle; } + + public boolean isFullEllipse() { + double sweep = endAngle - startAngle; + return Math.abs(Math.abs(sweep) - 2 * Math.PI) < 1.0e-12; + } + + @Override + public String getGeometryType() { + return "EllipseCurve"; + } + + @Override + protected EllipseCurve copyInternal() { + return new EllipseCurve(centreX, centreY, centreZ, semiMajor, semiMinor, + rotation, startAngle, endAngle, getFactory()); + } + + @Override + protected boolean isEquivalentClass(Geometry other) { + return other instanceof EllipseCurve; + } + + @Override + public Envelope getEnvelopeInternal() { + Envelope env = new Envelope(); + // Rotated AABB of the full ellipse, then sample the arc for tightness. + double c = Math.cos(rotation); + double s = Math.sin(rotation); + double dx = Math.hypot(semiMajor * c, semiMinor * s); + double dy = Math.hypot(semiMajor * s, semiMinor * c); + env.expandToInclude(centreX - dx, centreY - dy); + env.expandToInclude(centreX + dx, centreY + dy); + return env; + } + + @Override + public Geometry toLinear(double tolerance) { + int n = samples(tolerance); + List pts = new ArrayList(n + 1); + double sweep = endAngle - startAngle; + for (int i = 0; i <= n; i++) { + double t = startAngle + sweep * ((double) i / n); + pts.add(pointAt(t)); + } + return getFactory().createLineString(pts.toArray(new Coordinate[0])); + } + + Coordinate pointAt(double angle) { + double cosR = Math.cos(rotation); + double sinR = Math.sin(rotation); + double x0 = semiMajor * Math.cos(angle); + double y0 = semiMinor * Math.sin(angle); + double x = centreX + x0 * cosR - y0 * sinR; + double y = centreY + x0 * sinR + y0 * cosR; + Coordinate c = new Coordinate(x, y); + if (!Double.isNaN(centreZ)) { + c.setZ(centreZ); + } + return c; + } + + private int samples(double tolerance) { + double peri = Math.PI * (3 * (semiMajor + semiMinor) + - Math.sqrt((3 * semiMajor + semiMinor) * (semiMajor + 3 * semiMinor))); + double frac = Math.abs(endAngle - startAngle) / (2 * Math.PI); + double len = peri * Math.min(1.0, frac); + if (!(tolerance > 0.0)) { + return 32; + } + int n = (int) Math.ceil(len / Math.max(tolerance, 1.0e-9)); + if (n < 8) { + return 8; + } + if (n > 256) { + return 256; + } + return n; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiCurve.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiCurve.java index 4d97a8147f..481fc38302 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiCurve.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiCurve.java @@ -16,6 +16,7 @@ import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.io.curve.CurveWKTWriter; /** * A collection of {@link LineString}, {@link CircularString} and @@ -63,6 +64,14 @@ public Geometry toLinear(double tolerance) { // Same family the single curve types route through CurveOps. A member // with no cheaper path is the chord baseline. + /** + * Core {@code WKTWriter} refuses to flatten arc members to untagged lines. + */ + @Override + public String toText() { + return new CurveWKTWriter().write(this); + } + @Override public Geometry convexHull() { return CurveOps.convexHull(this); diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiSurface.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiSurface.java index 8c3c6d2679..11fde0d625 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiSurface.java +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/MultiSurface.java @@ -16,6 +16,7 @@ import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.io.curve.CurveWKTWriter; /** A collection of {@link Polygon} and {@link CurvePolygon} members. */ public class MultiSurface extends MultiPolygon implements Linearizable { @@ -60,6 +61,14 @@ public Geometry toLinear(double tolerance) { // Same family the single curve types route through CurveOps. A member // with no cheaper path is the chord baseline. + /** + * Core {@code WKTWriter} refuses to flatten curved members to untagged polygons. + */ + @Override + public String toText() { + return new CurveWKTWriter().write(this); + } + @Override public Geometry convexHull() { return CurveOps.convexHull(this); diff --git a/modules/curve/src/main/java/org/locationtech/jts/geom/curve/NurbsCurve.java b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/NurbsCurve.java new file mode 100644 index 0000000000..3125fd35db --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/geom/curve/NurbsCurve.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * CRV-NURBS (WKB 21): a NURBS curve of given degree with control + * points, weights, and knot vector. Aligns with the fork-signed type + * code 21 (QGIS {@code NurbsCurve} claim). Greenfield (#1195 MMF SIGN). + *

    + * Evaluation uses Cox–de Boor; {@link #toLinear(double)} samples in + * parameter space. Not a silent flatten of the control polygon. + */ +public class NurbsCurve extends LineString implements Linearizable { + private static final long serialVersionUID = 1L; + + private final int degree; + private final double[] weights; + private final double[] knots; + + public NurbsCurve(CoordinateSequence controls, int degree, double[] weights, + double[] knots, GeometryFactory factory) { + super(controls, factory); + int n = controls != null ? controls.size() : 0; + if (n == 0) { + this.degree = 0; + this.weights = new double[0]; + this.knots = new double[0]; + return; + } + if (degree < 1 || degree >= n) { + throw new IllegalArgumentException( + "NURBS degree must satisfy 1 <= p < nControls (p=" + degree + + ", n=" + n + ")"); + } + if (weights == null || weights.length != n) { + throw new IllegalArgumentException( + "NURBS weights length must equal control count"); + } + int expectedKnots = n + degree + 1; + if (knots == null || knots.length != expectedKnots) { + throw new IllegalArgumentException( + "NURBS knot count must be n+p+1 (expected " + expectedKnots + + ", got " + (knots == null ? 0 : knots.length) + ")"); + } + this.degree = degree; + this.weights = Arrays.copyOf(weights, weights.length); + this.knots = Arrays.copyOf(knots, knots.length); + } + + public int getDegree() { return degree; } + public double[] getWeights() { return Arrays.copyOf(weights, weights.length); } + public double[] getKnots() { return Arrays.copyOf(knots, knots.length); } + + @Override + public String getGeometryType() { + return "NurbsCurve"; + } + + @Override + protected NurbsCurve copyInternal() { + return new NurbsCurve(getCoordinateSequence().copy(), degree, + weights, knots, getFactory()); + } + + @Override + protected boolean isEquivalentClass(Geometry other) { + return other instanceof NurbsCurve; + } + + @Override + public Geometry toLinear(double tolerance) { + if (isEmpty()) { + return getFactory().createLineString(); + } + double u0 = knots[degree]; + double u1 = knots[knots.length - degree - 1]; + int n = samples(tolerance); + List pts = new ArrayList(n + 1); + for (int i = 0; i <= n; i++) { + double u = u0 + (u1 - u0) * ((double) i / n); + pts.add(evaluate(u)); + } + return getFactory().createLineString(pts.toArray(new Coordinate[0])); + } + + Coordinate evaluate(double u) { + CoordinateSequence seq = getCoordinateSequence(); + int n = seq.size(); + double wx = 0, wy = 0, wz = 0, wsum = 0; + boolean hasZ = false; + for (int i = 0; i < n; i++) { + double bi = basis(i, degree, u); + if (bi == 0.0) { + continue; + } + double w = weights[i] * bi; + Coordinate p = seq.getCoordinate(i); + wx += w * p.x; + wy += w * p.y; + if (!Double.isNaN(p.getZ())) { + hasZ = true; + wz += w * p.getZ(); + } + wsum += w; + } + if (wsum == 0.0) { + return new Coordinate(seq.getCoordinate(0)); + } + Coordinate c = new Coordinate(wx / wsum, wy / wsum); + if (hasZ) { + c.setZ(wz / wsum); + } + return c; + } + + /** Cox–de Boor basis. */ + double basis(int i, int p, double u) { + if (p == 0) { + boolean last = (i == knots.length - 2); + if (u >= knots[i] && (u < knots[i + 1] || (last && u <= knots[i + 1]))) { + return 1.0; + } + return 0.0; + } + double d1 = knots[i + p] - knots[i]; + double d2 = knots[i + p + 1] - knots[i + 1]; + double a = 0.0; + double b = 0.0; + if (d1 != 0.0) { + a = (u - knots[i]) / d1 * basis(i, p - 1, u); + } + if (d2 != 0.0) { + b = (knots[i + p + 1] - u) / d2 * basis(i + 1, p - 1, u); + } + return a + b; + } + + private int samples(double tolerance) { + if (!(tolerance > 0.0)) { + return 32; + } + int n = (int) Math.ceil(1.0 / Math.sqrt(Math.max(tolerance, 1.0e-12))); + if (n < 8) { + return 8; + } + if (n > 128) { + return 128; + } + return n; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKBWriter.java b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKBWriter.java index d97d2f2664..2752af1a88 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKBWriter.java +++ b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKBWriter.java @@ -14,12 +14,18 @@ import java.io.IOException; import java.util.EnumSet; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.BezierCurve; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.EllipseCurve; import org.locationtech.jts.geom.curve.MultiCurve; import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.geom.curve.NurbsCurve; +import org.locationtech.jts.geom.impl.CoordinateArraySequence; import org.locationtech.jts.io.Ordinate; import org.locationtech.jts.io.OutStream; import org.locationtech.jts.io.WKBConstants; @@ -92,9 +98,87 @@ protected boolean writeOtherGeometry(Geometry geom, children(geom), outputOrdinates, os); return true; } + if (geom instanceof ClothoidSegment) { + writeClothoid((ClothoidSegment) geom, outputOrdinates, os); + return true; + } + if (geom instanceof BezierCurve) { + writeBezier((BezierCurve) geom, outputOrdinates, os); + return true; + } + if (geom instanceof EllipseCurve) { + writeEllipse((EllipseCurve) geom, outputOrdinates, os); + return true; + } + if (geom instanceof NurbsCurve) { + writeNurbs((NurbsCurve) geom, outputOrdinates, os); + return true; + } return false; } + private void writeBezier(BezierCurve bz, EnumSet outputOrdinates, + OutStream os) throws IOException { + writeByteOrder(os); + writeGeometryType(WKBConstants.wkbBezier, outputOrdinates, bz, os); + writeCoordinateSequence(bz.getCoordinateSequence(), outputOrdinates, true, + os); + } + + private void writeEllipse(EllipseCurve el, EnumSet outputOrdinates, + OutStream os) throws IOException { + writeByteOrder(os); + writeGeometryType(WKBConstants.wkbEllipse, outputOrdinates, el, os); + Coordinate centre = new Coordinate(el.getCentreX(), el.getCentreY()); + if (!Double.isNaN(el.getCentreZ())) { + centre.setZ(el.getCentreZ()); + } + writeCoordinateSequence( + new CoordinateArraySequence(new Coordinate[] { centre }), + outputOrdinates, false, os); + writeDouble(el.getSemiMajor(), os); + writeDouble(el.getSemiMinor(), os); + writeDouble(el.getRotation(), os); + writeDouble(el.getStartAngle(), os); + writeDouble(el.getEndAngle(), os); + } + + private void writeNurbs(NurbsCurve nu, EnumSet outputOrdinates, + OutStream os) throws IOException { + writeByteOrder(os); + writeGeometryType(WKBConstants.wkbNurbs, outputOrdinates, nu, os); + writeInt(nu.getDegree(), os); + writeCoordinateSequence(nu.getCoordinateSequence(), outputOrdinates, true, + os); + double[] w = nu.getWeights(); + for (int i = 0; i < w.length; i++) { + writeDouble(w[i], os); + } + double[] k = nu.getKnots(); + writeInt(k.length, os); + for (int i = 0; i < k.length; i++) { + writeDouble(k[i], os); + } + } + + /** + * CRV-CLOTHOID WKB 18: start point ordinates (no size prefix) then + * {@code startTangent, startKappa, endKappa, length}. + */ + private void writeClothoid(ClothoidSegment cl, + EnumSet outputOrdinates, OutStream os) throws IOException { + writeByteOrder(os); + writeGeometryType(WKBConstants.wkbClothoid, outputOrdinates, cl, os); + Coordinate start = cl.getStartCoordinate(); + CoordinateArraySequence seq = new CoordinateArraySequence( + new Coordinate[] { start }); + writeCoordinateSequence(seq, outputOrdinates, false, os); + writeDouble(cl.getStartTangent(), os); + writeDouble(cl.getStartKappa(), os); + writeDouble(cl.getEndKappa(), os); + writeDouble(cl.getLength(), os); + } + private void writeCircularString(CircularString cs, EnumSet outputOrdinates, OutStream os) throws IOException { writeByteOrder(os); diff --git a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTReader.java b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTReader.java index 0c077eed42..e044baba27 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTReader.java +++ b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTReader.java @@ -27,6 +27,7 @@ import org.locationtech.jts.geom.LinearRing; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurvePolygon; @@ -103,7 +104,7 @@ public void clearWarnings() { } public CurveWKTReader() { - super(); + super(new CurveGeometryFactory()); } public CurveWKTReader(GeometryFactory geometryFactory) { @@ -311,24 +312,61 @@ private CurvePolygon readCurvePolygonText(StreamTokenizer tokenizer, EnumSet structuralHoles = new ArrayList(); - do { - LineString member = readCurveMember(tokenizer, ordinateFlags); - if (structuralShell == null) { - structuralShell = member; - } else { - structuralHoles.add(member); - } + tok = getNextCloserOrComma(tokenizer); + while (tok.equals(",")) { + structuralHoles.add(readCurvePolygonRing(tokenizer, ordinateFlags)); tok = getNextCloserOrComma(tokenizer); - } while (tok.equals(",")); + } return new CurvePolygon(structuralShell, structuralHoles.toArray(new LineString[0]), geometryFactory); } + /** + * One CurvePolygon ring: {@code CIRCULARSTRING(...)} / + * {@code COMPOUNDCURVE(...)} / plain {@code (...)} coords, or a + * compound list {@code (CIRCULARSTRING(...), (x y, ...), ...)}. + */ + private LineString readCurvePolygonRing(StreamTokenizer tokenizer, + EnumSet ordinateFlags) throws IOException, ParseException { + String w = lookAheadWord(tokenizer); + if (w.equals(L_PAREN)) { + getNextWord(tokenizer); // consume '(' + String inner = lookAheadWord(tokenizer); + if (isTaggedCurveWord(inner) || inner.equalsIgnoreCase(CLOTHOID)) { + List members = new ArrayList(); + do { + members.add(readCurveMember(tokenizer, ordinateFlags)); + w = getNextCloserOrComma(tokenizer); + } while (w.equals(",")); + if (members.size() == 1) { + return members.get(0); + } + return new CompoundCurve(members.toArray(new LineString[0]), + geometryFactory); + } + // Untagged coordinate ring; '(' already consumed. + List coordinates = new ArrayList(); + do { + coordinates.add(getCoordinate(tokenizer, ordinateFlags, false)); + } while (getNextCloserOrComma(tokenizer).equals(",")); + return geometryFactory.createLineString( + coordinates.toArray(new Coordinate[0])); + } + return readCurveMember(tokenizer, ordinateFlags); + } + + private static boolean isTaggedCurveWord(String w) { + if (w == null) return false; + String u = w.toUpperCase(Locale.ROOT); + return u.equals(WKTConstants.CIRCULARSTRING) + || u.equals(WKTConstants.COMPOUNDCURVE) + || u.equals(WKTConstants.LINESTRING); + } + private MultiCurve readMultiCurveText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { String tok = getNextEmptyOrOpener(tokenizer); diff --git a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTWriter.java b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTWriter.java index 72b84a5049..ff975d1bc8 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTWriter.java +++ b/modules/curve/src/main/java/org/locationtech/jts/io/curve/CurveWKTWriter.java @@ -82,7 +82,9 @@ protected boolean appendOtherGeometryTaggedText( (CompoundCurve) geometry, outputOrdinates, useFormatting, level, writer, formatter); return true; } - if (geometry instanceof CurvePolygon && hasCurveRing((CurvePolygon) geometry)) { + // Always own CurvePolygon (incl. EMPTY / all-linear). Falling through + // to core WKTWriter refuses CurvePolygon under SqlMmTypes honesty. + if (geometry instanceof CurvePolygon) { appendCurvePolygonTaggedText( (CurvePolygon) geometry, outputOrdinates, useFormatting, level, writer, formatter); return true; @@ -226,6 +228,10 @@ private void appendCurvePolygonTaggedText( writer.write(cp.getGeometryType().toUpperCase(Locale.ROOT)); writer.write(" "); appendOrdinateText(outputOrdinates, writer); + if (cp.isEmpty()) { + writer.write(WKTConstants.EMPTY); + return; + } writer.write("("); appendRingText(cp.getExteriorCurve(), outputOrdinates, useFormatting, level, writer, formatter); for (int i = 0; i < cp.getNumInteriorRing(); i++) { diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java new file mode 100644 index 0000000000..aa1096c008 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java @@ -0,0 +1,775 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.locate.SimplePointInAreaLocator; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Location; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +/** + * First face walk: bite versus hole. One predicate -- if the new + * edge of {@code hole ∩ other} is a subset of the other shell, it + * is a bite, not an interior punch ({@code H-SHELL-HOLE-CROSS}). + * A hole that does not cross, but whose ring overlaps the other + * shell ({@code H-SHELL-HOLE-OUTER}: hole-edge ⊂ other.shell), + * is the same bite. {@link CurveSegmentNoder#edges} already names + * that shared run; there are no crossing nodes. + *

    + * The noder already names the two hole–shell nodes of a straddle. + * This rung walks those into the clip edge on the other shell, or + * walks the shared edge when the hole sits entirely in the solid. + * Overlay then splices the bite (SUB / XOR face) or punches the + * leftover hole. Two holes that cross ({@code H-SHELL-HOLE-X}) + * are {@link TwoHoleOverlay}. A pair this walk cannot certify + * keeps the named miss. Not a noder, not N-SS. + */ +final class BiteVsHole { + + static final int BITE = 1; + static final int HOLE = -1; + static final int MISS = 0; + + private BiteVsHole() { } + + /** + * True when a polygonal result still carries a curve shell (not a + * densified control polygon). Used to refuse bite certification when + * the outer CAP has already fallen to chords. + */ + private static boolean keepsCurveShell(Geometry g) { + if (g == null || g.isEmpty()) { + return false; + } + if (g instanceof CurvePolygon) { + LineString shell = ((CurvePolygon) g).getExteriorCurve(); + return shell instanceof CircularString || shell instanceof CompoundCurve; + } + if (g instanceof MultiSurface) { + for (int i = 0; i < g.getNumGeometries(); i++) { + if (keepsCurveShell(g.getGeometryN(i))) { + return true; + } + } + } + return false; + } + + /** + * Exact overlay, or {@code null} if the walk cannot certify bite + * versus hole. Same-outer and strictly-inside / strictly-outside + * different-outer cells stay on those kits. + */ + static Geometry overlay(Geometry a, Geometry b, int opCode) { + Walk walk = walk(a, b); + if (walk == null || walk.kind != BITE) return null; + GeometryFactory f = TwoNodeClip.curveFactory(a); + CurvePolygon outerHoled = new CurvePolygon( + walk.holed.getExteriorCurve(), null, f); + Geometry firstOuter = walk.holedFirst ? outerHoled : walk.solid; + Geometry secondOuter = walk.holedFirst ? walk.solid : outerHoled; + if (opCode == OverlayNG.INTERSECTION) { + Geometry cap = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.INTERSECTION); + // Refuse when the outer CAP densifies: cannot certify an arc-honest + // bite (H-SHELL-HOLE-OUTER on a complementary diameter stays a miss). + if (!keepsCurveShell(cap)) { + return null; + } + return splice(cap, walk.holeIn, walk.p, walk.q, f, walk.scale); + } + if (opCode == OverlayNG.UNION) { + Geometry cup = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.UNION); + if (walk.leftover == null || walk.leftover.isEmpty()) { + return cup; + } + return punch(cup, walk.leftover, f); + } + if (opCode == OverlayNG.DIFFERENCE) { + Geometry sub = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.DIFFERENCE); + if (walk.holedFirst) { + return splice(sub, walk.holeOut, walk.p, walk.q, f, walk.scale); + } + return join(sub, walk.bite, f); + } + if (opCode == OverlayNG.SYMDIFFERENCE) { + Geometry abOuters = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.DIFFERENCE); + Geometry baOuters = CompoundCurveShellOverlay.overlay( + secondOuter, firstOuter, OverlayNG.DIFFERENCE); + Geometry ab = walk.holedFirst + ? splice(abOuters, walk.holeOut, walk.p, walk.q, f, walk.scale) + : join(abOuters, walk.bite, f); + Geometry ba = walk.holedFirst + ? join(baOuters, walk.bite, f) + : splice(baOuters, walk.holeOut, walk.p, walk.q, f, walk.scale); + return join(ab, ba, f); + } + return null; + } + + /** + * {@link #BITE} when the new edge ⊂ other.shell, {@link #HOLE} + * when the walk says punch, {@link #MISS} when the predicate + * cannot be certified. + */ + static int decide(Geometry a, Geometry b) { + Walk walk = walk(a, b); + return walk == null ? MISS : walk.kind; + } + + /** + * The certified clip edge, or {@code null}. A chord on the other + * shell; not a face. + */ + static CurveSegmentString clipEdge(Geometry a, Geometry b) { + Walk walk = walk(a, b); + if (walk == null || walk.kind != BITE) return null; + if (walk.newEdge == null || walk.newEdge.size() != 1) return null; + LineString e = walk.newEdge.get(0); + if (e instanceof CircularString) return null; + Coordinate[] c = e.getCoordinates(); + if (c.length < 2) return null; + return CurveSegmentString.segment(c[0], c[c.length - 1]); + } + + private static Walk walk(Geometry a, Geometry b) { + CurvePolygon ca = SameOuterHoleOverlay.mixedShell(a); + CurvePolygon cb = SameOuterHoleOverlay.mixedShell(b); + if (ca == null || cb == null) return null; + int ha = ca.getNumInteriorRing(); + int hb = cb.getNumInteriorRing(); + CurvePolygon holed = null; + CurvePolygon solid = null; + boolean holedFirst = false; + if (ha == 1 && hb == 0) { + holed = ca; + solid = cb; + holedFirst = true; + } + else if (ha == 0 && hb == 1) { + holed = cb; + solid = ca; + holedFirst = false; + } + if (holed == null || solid == null) return null; + if (holed.getExteriorCurve().equalsExact(solid.getExteriorCurve())) { + return null; + } + LineString hole = SameOuterHoleOverlay.plainHole(holed); + if (hole == null) return null; + GeometryFactory f = TwoNodeClip.curveFactory(a); + CurvePolygon outerHoled = new CurvePolygon( + holed.getExteriorCurve(), null, f); + if (!SameOuterHoleOverlay.holeInsideShell(holed, outerHoled)) { + return null; + } + + List shell = TwoNodeClip.flatten(solid); + if (shell == null) return null; + Coordinate[] ring = hole.getCoordinates(); + double scale = scaleOf(holed, solid); + List holeStr = CurveSegmentString.of(hole); + List shellStr = CurveSegmentString.of(solid); + if (holeStr == null || shellStr == null) return null; + Coordinate[] named = CurveSegmentNoder.nodes(holeStr, shellStr, scale); + if (named != null && named.length != 2) return null; + if (named == null) { + return walkSharedEdge(holed, solid, hole, holedFirst, scale, f); + } + + List nodes = TwoNodeClip.nodesVsPolygon(shell, ring); + if (nodes == null || nodes.size() != 2) return null; + if (nodes.get(0).pt.distance(nodes.get(1).pt) + < TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + + TwoNodeClip.Node n0 = nodes.get(0); + TwoNodeClip.Node n1 = nodes.get(1); + List pq = TwoNodeClip.walkRing(ring, n0.pt, n1.pt); + List qp = TwoNodeClip.walkRing(ring, n1.pt, n0.pt); + if (pq == null || qp == null) return null; + int pqSide = sideOfShell(pq, solid); + int qpSide = sideOfShell(qp, solid); + List holeIn = null; + List holeOut = null; + if (pqSide == TwoNodeClip.IN && qpSide == TwoNodeClip.OUT) { + holeIn = pq; + holeOut = qp; + } + else if (qpSide == TwoNodeClip.IN && pqSide == TwoNodeClip.OUT) { + holeIn = qp; + holeOut = pq; + } + if (holeIn == null || holeOut == null) return null; + + List s01 = TwoNodeClip.walkEdges(shell, n0, n1, f); + List s10 = TwoNodeClip.walkEdges(shell, n1, n0, f); + if (s01 == null || s10 == null) return null; + List edge01 = clipEdgeOnShell(s01, solid, hole, scale); + List edge10 = clipEdgeOnShell(s10, solid, hole, scale); + List newEdge = null; + if (edge01 != null && edge10 == null) { + newEdge = edge01; + } + else if (edge10 != null && edge01 == null) { + newEdge = edge10; + } + if (newEdge == null) return null; + if (lengthOf(newEdge) <= TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + + Coordinate p = holeIn.get(0); + Coordinate q = holeIn.get(holeIn.size() - 1); + List oriented = directed(newEdge, q, p, scale, f); + if (oriented == null) return null; + + Geometry bite = closePlain(holeIn, oriented, f, scale); + Geometry leftover = closePlain(holeOut, oriented, f, scale); + if (bite == null || leftover == null) return null; + Walk w = new Walk(); + w.kind = BITE; + w.holed = holed; + w.solid = solid; + w.holedFirst = holedFirst; + w.holeIn = holeIn; + w.holeOut = holeOut; + w.newEdge = oriented; + w.p = p; + w.q = q; + w.bite = bite; + w.leftover = leftover; + w.scale = scale; + return w; + } + + /** + * Hole-edge ⊂ other.shell, no crossing nodes. P2.2 already names + * the shared run. The hole sits entirely in the solid, so the + * shared edge is the clip and the rest of the ring is a bite. + * CUP is the outer union: the solid fills the hole. + */ + private static Walk walkSharedEdge(CurvePolygon holed, CurvePolygon solid, + LineString hole, boolean holedFirst, double scale, GeometryFactory f) { + List holeStr = CurveSegmentString.of(hole); + List shellStr = CurveSegmentString.of(solid); + if (holeStr == null || shellStr == null) return null; + List shared = CurveSegmentNoder.edges(holeStr, + shellStr, scale); + CurveSegmentString run = singleChord(shared, scale); + if (run == null) return null; + if (TwoNodeClip.locateInShell(run.getStart(), solid) != TwoNodeClip.MIXED) { + return null; + } + if (TwoNodeClip.locateInShell(run.getEnd(), solid) != TwoNodeClip.MIXED) { + return null; + } + + Coordinate[] ring = hole.getCoordinates(); + List pq = TwoNodeClip.walkRing(ring, run.getStart(), + run.getEnd()); + List qp = TwoNodeClip.walkRing(ring, run.getEnd(), + run.getStart()); + if (pq == null || qp == null) return null; + int pqSide = sideOfShell(pq, solid); + int qpSide = sideOfShell(qp, solid); + List holeIn = null; + List holeOut = null; + if (pqSide == TwoNodeClip.IN && isOnShellWalk(qp, run, scale)) { + holeIn = pq; + holeOut = qp; + } + else if (qpSide == TwoNodeClip.IN && isOnShellWalk(pq, run, scale)) { + holeIn = qp; + holeOut = pq; + } + if (holeIn == null || holeOut == null) return null; + if (!holeInteriorInSolid(ring, run, solid, scale)) return null; + + Coordinate p = holeIn.get(0); + Coordinate q = holeIn.get(holeIn.size() - 1); + List newEdge = new ArrayList(); + newEdge.add(f.createLineString(new Coordinate[] { + new Coordinate(run.getStart()), new Coordinate(run.getEnd()) + })); + List oriented = directed(newEdge, q, p, scale, f); + if (oriented == null) return null; + if (lengthOf(oriented) <= TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + + Geometry bite = closePlain(holeIn, oriented, f, scale); + if (bite == null) return null; + Walk w = new Walk(); + w.kind = BITE; + w.holed = holed; + w.solid = solid; + w.holedFirst = holedFirst; + w.holeIn = holeIn; + w.holeOut = holeOut; + w.newEdge = oriented; + w.p = p; + w.q = q; + w.bite = bite; + w.leftover = f.createEmpty(2); + w.scale = scale; + return w; + } + + private static CurveSegmentString singleChord( + List shared, double scale) { + if (shared == null) return null; + CurveSegmentString found = null; + boolean two = false; + for (int i = 0; i < shared.size() && !two; i++) { + CurveSegmentString e = shared.get(i); + if (e.isArc() || e.isDegenerate()) { + continue; + } + if (e.length() <= TwoNodeClip.PROPER_CROSS_FRAC * scale) { + continue; + } + if (found != null) { + two = true; + } + else { + found = e; + } + } + return two ? null : found; + } + + private static boolean isOnShellWalk(List path, + CurveSegmentString run, double scale) { + if (path == null || path.size() != 2) return false; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + return onRun(path.get(0), run, eps) && onRun(path.get(1), run, eps); + } + + private static boolean holeInteriorInSolid(Coordinate[] ring, + CurveSegmentString run, CurvePolygon solid, double scale) { + if (ring == null || ring.length < 4) return false; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + int n = ring.length; + if (ring[0].equals2D(ring[n - 1])) { + n--; + } + if (n < 3) return false; + boolean ok = true; + for (int i = 0; i < n && ok; i++) { + if (onRun(ring[i], run, eps)) { + continue; + } + if (TwoNodeClip.locateInShell(ring[i], solid) != TwoNodeClip.IN) { + ok = false; + } + } + for (int i = 0; i < n && ok; i++) { + Coordinate a = ring[i]; + Coordinate b = ring[(i + 1) % n]; + if (onRun(a, run, eps) && onRun(b, run, eps)) { + continue; + } + Coordinate mid = new Coordinate(0.5 * (a.x + b.x), 0.5 * (a.y + b.y)); + if (TwoNodeClip.locateInShell(mid, solid) != TwoNodeClip.IN) { + ok = false; + } + } + return ok; + } + + private static boolean onRun(Coordinate p, CurveSegmentString run, + double eps) { + if (p.distance(run.getStart()) <= eps || p.distance(run.getEnd()) <= eps) { + return true; + } + double t = TwoNodeClip.parameter(run.getStart(), run.getEnd(), p); + if (t < -1.0e-12 || t > 1.0 + 1.0e-12) return false; + return onChord(p, run.getStart(), run.getEnd()); + } + + /** + * The shell walk whose step into the other interior lands in the + * hole. That walk is the new edge, and it is a subset of the other + * shell by construction. No such walk is a named miss. + */ + private static List clipEdgeOnShell(List walk, + CurvePolygon other, LineString hole, double scale) { + if (walk == null || walk.isEmpty()) return null; + Coordinate step = stepInside(walk, other, scale); + if (step == null) return null; + if (!inPlainRing(step, hole)) return null; + return walk; + } + + private static Coordinate stepInside(List walk, + CurvePolygon other, double scale) { + LineString m = walk.get(0); + Coordinate a = m.getCoordinateN(0); + Coordinate b = m.getCoordinateN(m.getNumPoints() - 1); + Coordinate mid; + if (m instanceof CircularString && m.getNumPoints() >= 3) { + mid = m.getCoordinateN(1); + } + else { + mid = new Coordinate(0.5 * (a.x + b.x), 0.5 * (a.y + b.y)); + } + double dx = b.x - a.x; + double dy = b.y - a.y; + double len = Math.hypot(dx, dy); + if (len <= 1.0e-12) return null; + double eps = Math.max(1.0e-6, 1.0e-4 * scale); + Coordinate left = new Coordinate( + mid.x - dy / len * eps, mid.y + dx / len * eps); + Coordinate right = new Coordinate( + mid.x + dy / len * eps, mid.y - dx / len * eps); + int sl = TwoNodeClip.locateInShell(left, other); + int sr = TwoNodeClip.locateInShell(right, other); + if (sl == TwoNodeClip.IN && sr != TwoNodeClip.IN) return left; + if (sr == TwoNodeClip.IN && sl != TwoNodeClip.IN) return right; + return null; + } + + private static int sideOfShell(List path, CurvePolygon shell) { + if (path == null || path.size() < 2) return TwoNodeClip.MIXED; + Coordinate sample; + if (path.size() >= 3) { + sample = path.get(path.size() / 2); + } + else { + sample = new Coordinate(0.5 * (path.get(0).x + path.get(1).x), + 0.5 * (path.get(0).y + path.get(1).y)); + } + return TwoNodeClip.locateInShell(sample, shell); + } + + /** + * Replace the shared clip edge on {@code face} with {@code via} + * (the hole walk). The new edge ⊂ face.shell here -- a bite, not + * a punch. A face that does not carry that edge stays {@code null}. + */ + private static Geometry splice(Geometry face, List via, + Coordinate p, Coordinate q, GeometryFactory f, double scale) { + if (face == null || via == null) return null; + if (face.isEmpty()) return face; + if (face.getNumGeometries() != 1) return null; + Geometry g = face.getGeometryN(0); + if (!(g instanceof CurvePolygon)) return null; + CurvePolygon cp = (CurvePolygon) g; + if (cp.getNumInteriorRing() > 0) return null; + List edges = TwoNodeClip.flatten(cp); + if (edges == null) return null; + int hit = indexChord(edges, p, q); + if (hit < 0) return null; + TwoNodeClip.Edge e = edges.get(hit); + double tP = TwoNodeClip.parameter(e.a, e.b, p); + double tQ = TwoNodeClip.parameter(e.a, e.b, q); + Coordinate first = tP <= tQ ? p : q; + Coordinate second = tP <= tQ ? q : p; + List repl = directedPath(via, first, second, scale); + if (repl == null) return null; + List members = new ArrayList(); + boolean ok = true; + for (int i = 0; i < edges.size() && ok; i++) { + if (i == hit) { + addChord(members, e.a, first, f, scale); + addPath(members, repl, f); + addChord(members, second, e.b, f, scale); + } + else { + LineString piece = toLine(edges.get(i), f); + if (piece == null) { + ok = false; + } + else { + members.add(piece); + } + } + } + if (!ok) return null; + return TwoNodeClip.closeRing(members, f, + Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12)); + } + + private static int indexChord(List edges, + Coordinate p, Coordinate q) { + int found = -1; + boolean two = false; + for (int i = 0; i < edges.size() && !two; i++) { + TwoNodeClip.Edge e = edges.get(i); + if (!e.isArc && onChord(p, e.a, e.b) && onChord(q, e.a, e.b)) { + if (found >= 0) { + two = true; + } + else { + found = i; + } + } + } + return two ? -1 : found; + } + + private static boolean onChord(Coordinate p, Coordinate a, Coordinate b) { + double t = TwoNodeClip.parameter(a, b, p); + Coordinate q = new Coordinate( + a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)); + return p.distance(q) <= 1.0e-12; + } + + private static void addChord(List dest, Coordinate from, + Coordinate to, GeometryFactory f, double scale) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + if (from.distance(to) <= eps) return; + dest.add(f.createLineString(new Coordinate[] { + new Coordinate(from), new Coordinate(to) + })); + } + + private static void addPath(List dest, List path, + GeometryFactory f) { + if (path == null || path.size() < 2) return; + dest.add(f.createLineString(copy(path))); + } + + private static List directedPath(List path, + Coordinate from, Coordinate to, double scale) { + if (path == null || path.size() < 2) return null; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate a = path.get(0); + Coordinate b = path.get(path.size() - 1); + if (a.distance(from) <= eps && b.distance(to) <= eps) return path; + if (a.distance(to) <= eps && b.distance(from) <= eps) { + return reverse(path); + } + return null; + } + + private static List directed(List parts, + Coordinate from, Coordinate to, double scale, GeometryFactory f) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate start = startOf(parts); + Coordinate end = endOf(parts); + if (start.distance(from) <= eps && end.distance(to) <= eps) { + return parts; + } + if (start.distance(to) <= eps && end.distance(from) <= eps) { + return reverseMembers(parts); + } + return null; + } + + private static Geometry closePlain(List along, + List back, GeometryFactory f, double scale) { + if (along == null || back == null || along.size() < 2) return null; + List oriented = directed(back, along.get(along.size() - 1), + along.get(0), scale, f); + if (oriented == null) return null; + List ring = new ArrayList(); + addUniquePts(ring, along); + addUniquePts(ring, coordsOf(oriented)); + if (ring.size() < 3) return null; + if (!ring.get(0).equals2D(ring.get(ring.size() - 1))) { + ring.add(new Coordinate(ring.get(0))); + } + try { + Geometry poly = f.createPolygon(f.createLinearRing(copy(ring))); + if (poly.getArea() <= 0.0) return null; + return poly; + } + catch (RuntimeException ex) { + return null; + } + } + + private static Geometry punch(Geometry solid, Geometry hole, GeometryFactory f) { + if (solid == null || hole == null) return null; + if (solid.isEmpty()) return solid; + if (hole.isEmpty()) return solid; + if (solid.getNumGeometries() != 1) return null; + Geometry g = solid.getGeometryN(0); + LineString ring = asHoleRing(hole, f); + if (ring == null) return null; + if (g instanceof CurvePolygon) { + CurvePolygon cp = (CurvePolygon) g; + if (cp.getNumInteriorRing() > 0) return null; + return new CurvePolygon(cp.getExteriorCurve(), + new LineString[] { ring }, f); + } + if (TwoNodeClip.isPlainPolygon(g)) { + try { + return f.createPolygon(((Polygon) g).getExteriorRing(), + new LinearRing[] { f.createLinearRing(ring.getCoordinates()) }); + } + catch (RuntimeException ex) { + return null; + } + } + return null; + } + + private static LineString asHoleRing(Geometry hole, GeometryFactory f) { + if (hole instanceof Polygon) { + return ((Polygon) hole).getExteriorRing(); + } + if (hole instanceof LineString && ((LineString) hole).isClosed()) { + return (LineString) hole; + } + return null; + } + + private static Geometry join(Geometry a, Geometry extra, GeometryFactory f) { + if (extra == null) return null; + if (a == null || a.isEmpty()) return extra; + if (extra.isEmpty()) return a; + List faces = new ArrayList(); + if (!addFaces(faces, a) || !addFaces(faces, extra)) return null; + if (faces.isEmpty()) return null; + if (faces.size() == 1) return faces.get(0); + return new MultiSurface(faces.toArray(new Polygon[0]), f); + } + + private static boolean addFaces(List dest, Geometry g) { + boolean ok = true; + for (int i = 0; i < g.getNumGeometries() && ok; i++) { + Geometry p = g.getGeometryN(i); + if (p instanceof Polygon && !p.isEmpty()) { + dest.add((Polygon) p); + } + else { + ok = false; + } + } + return ok; + } + + private static boolean inPlainRing(Coordinate p, LineString hole) { + try { + Polygon poly = hole.getFactory().createPolygon( + hole.getFactory().createLinearRing(hole.getCoordinates())); + return SimplePointInAreaLocator.locate(p, poly) == Location.INTERIOR; + } + catch (RuntimeException ex) { + return false; + } + } + + private static LineString toLine(TwoNodeClip.Edge e, GeometryFactory f) { + if (e.isArc) { + return TwoNodeClip.arc(e.a, e.mid, e.b, f); + } + return f.createLineString(new Coordinate[] { + new Coordinate(e.a), new Coordinate(e.b) + }); + } + + private static double lengthOf(List parts) { + double len = 0.0; + for (int i = 0; i < parts.size(); i++) { + len += parts.get(i).getLength(); + } + return len; + } + + private static Coordinate startOf(List parts) { + return parts.get(0).getCoordinateN(0); + } + + private static Coordinate endOf(List parts) { + LineString last = parts.get(parts.size() - 1); + return last.getCoordinateN(last.getNumPoints() - 1); + } + + private static List reverseMembers(List parts) { + List out = new ArrayList(parts.size()); + for (int i = parts.size() - 1; i >= 0; i--) { + out.add((LineString) parts.get(i).reverse()); + } + return out; + } + + private static List reverse(List path) { + List out = new ArrayList(path.size()); + for (int i = path.size() - 1; i >= 0; i--) { + out.add(path.get(i)); + } + return out; + } + + private static List coordsOf(List parts) { + List out = new ArrayList(); + for (int i = 0; i < parts.size(); i++) { + Coordinate[] c = parts.get(i).getCoordinates(); + for (int k = 0; k < c.length; k++) { + out.add(c[k]); + } + } + return out; + } + + private static void addUniquePts(List dest, + List src) { + if (src == null) return; + for (int i = 0; i < src.size(); i++) { + Coordinate p = src.get(i); + if (dest.isEmpty() || !dest.get(dest.size() - 1).equals2D(p)) { + dest.add(new Coordinate(p)); + } + } + } + + private static Coordinate[] copy(List path) { + Coordinate[] c = new Coordinate[path.size()]; + for (int i = 0; i < path.size(); i++) { + c[i] = new Coordinate(path.get(i)); + } + return c; + } + + private static double scaleOf(Geometry a, Geometry b) { + double wa = Math.max(a.getEnvelopeInternal().getWidth(), + a.getEnvelopeInternal().getHeight()); + double wb = Math.max(b.getEnvelopeInternal().getWidth(), + b.getEnvelopeInternal().getHeight()); + return Math.max(Math.max(wa, wb), 1.0); + } + + private static final class Walk { + int kind; + CurvePolygon holed; + CurvePolygon solid; + boolean holedFirst; + List holeIn; + List holeOut; + List newEdge; + Coordinate p; + Coordinate q; + Geometry bite; + Geometry leftover; + double scale; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircleSweepOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircleSweepOverlay.java index 9a1b17336c..8da34c9238 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircleSweepOverlay.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircleSweepOverlay.java @@ -26,7 +26,8 @@ /** * Angular-interval overlay of two lineal operands that live on the * same circle. Package-private -- not a two-node clip, not a - * noder, not a public API. + * noder, not a public API. {@link #sharedSweep} is the interval the + * noder lifts onto {@link CurveSegmentString}. *

    * Each sweep window becomes a closed arc of the circle; CAP / CUP / * SUB / XOR are the interval operations; pieces stay @@ -47,6 +48,21 @@ static boolean allSameCircle(List a, return sameCircle(c, circleOf(b, scale), scale); } + /** + * Shared sweep of two same-circle arcs as typed edges, or + * {@code null} if they are not the same circle. Empty is a + * pinch / endpoint kiss -- not an interval. + */ + static List sharedSweep(TwoNodeClip.Edge a, + TwoNodeClip.Edge b, double scale) { + if (a == null || b == null || !a.isArc || !b.isArc) return null; + double[] c = new double[] { a.circle[0], a.circle[1], a.circle[2] }; + if (!sameCircle(c, b.circle, scale)) return null; + Intervals ia = Intervals.ofEdge(a, c); + Intervals ib = Intervals.ofEdge(b, c); + return ia.combine(ib, 2).toEdges(c); + } + static Geometry overlay(List a, List b, int opCode, Geometry factorySrc, double scale) { double[] c = circleOf(a, scale); @@ -256,6 +272,22 @@ Geometry toArcs(double[] c, GeometryFactory f) { return CircularLineOverlay.linealResult(pieces, f); } + /** + * Interval pieces as sweep edges. A zero-width kiss is dropped + * (not an interval). A full circle is one closed edge. + */ + List toEdges(double[] c) { + List merged = mergeWrap(segs); + List out = new ArrayList(); + for (int i = 0; i < merged.size(); i++) { + TwoNodeClip.Edge e = edgeOf(merged.get(i)[0], merged.get(i)[1], c); + if (e != null) { + out.add(e); + } + } + return out; + } + private void addEvents(List ev) { addEvents(ev, 0); } @@ -305,11 +337,22 @@ private static List mergeWrap(List segs) { private static LineString arcOf(double a0, double a1, double[] c, GeometryFactory f) { + TwoNodeClip.Edge e = edgeOf(a0, a1, c); + if (e == null) return null; + if (e.a.distance(e.b) <= EPS) return fullCircle(c, f); + return TwoNodeClip.arc(e.a, e.mid, e.b, f); + } + + private static TwoNodeClip.Edge edgeOf(double a0, double a1, double[] c) { double sweep = a1 - a0; if (sweep <= EPS) return null; - if (sweep >= TWO_PI - 1.0e-9) return fullCircle(c, f); + if (sweep >= TWO_PI - 1.0e-9) { + Coordinate p = pt(0.0, c); + return new TwoNodeClip.Edge(p, pt(Math.PI, c), new Coordinate(p), + true, c); + } double mid = a0 + 0.5 * sweep; - return TwoNodeClip.arc(pt(a0, c), pt(mid, c), pt(a1, c), f); + return new TwoNodeClip.Edge(pt(a0, c), pt(mid, c), pt(a1, c), true, c); } private static CircularString fullCircle(double[] c, GeometryFactory f) { diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircularLineOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircularLineOverlay.java index 25e288745f..b766de5e74 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircularLineOverlay.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CircularLineOverlay.java @@ -25,6 +25,7 @@ import org.locationtech.jts.geom.MultiLineString; import org.locationtech.jts.geom.curve.CircularArcDensifier; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurveOps; import org.locationtech.jts.geom.curve.MultiCurve; @@ -109,6 +110,9 @@ static LineString linealCurve(Geometry g) { boolean hasArc = false; for (int i = 0; i < cc.getNumMembers(); i++) { LineString m = cc.getMemberN(i); + if (m instanceof ClothoidSegment) { + return null; + } if (m instanceof CircularString) { hasArc = true; } @@ -123,6 +127,7 @@ private static LineString plainLine(Geometry g) { g = unwrapLineal(g); if (g == null || g.isEmpty()) return null; if (g instanceof CircularString || g instanceof CompoundCurve) return null; + if (g instanceof ClothoidSegment) return null; if (!(g instanceof LineString)) return null; if (CurveOps.tolerance(g) > 0.0) return null; return (LineString) g; @@ -151,6 +156,9 @@ else if (!addMember(edges, g)) { } private static boolean addMember(List edges, LineString m) { + if (m instanceof ClothoidSegment) { + return false; + } Coordinate[] pts = m.getCoordinates(); if (m instanceof CircularString) { if (pts.length < 3) return false; diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlay.java new file mode 100644 index 0000000000..9e1a1519eb --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlay.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.ClothoidSegment; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +/** + * 0-node overlay of a hole-free {@link CurvePolygon} whose shell + * carries a {@link ClothoidSegment}. Package-private -- not a noder, + * not a Fresnel solver, not a new public API. + *

    + * A clothoid's parent {@link LineString} is start+end only. Flatten + * would treat that chord as the edge and invent line–circle nodes + * the spiral does not have. This kit intercepts before flatten. + *

    + * Closed-form cells (no clothoid–circle / clothoid–line node): + *

      + *
    • {@code CLOTHOID-ID} -- structural identity
    • + *
    • {@code CLOTHOID-DISJOINT} -- analytical envelopes do not meet
    • + *
    • {@code CLOTHOID-NEST} -- the clothoid shell's analytical + * envelope sits strictly inside a circular disc
    • + *
    + * Anything that would need a Fresnel noder is {@code CLOTHOID-FRESNEL}: + * {@code null}, so the caller takes the chord baseline and does not + * pay a dishonest two-node walk. + */ +final class ClothoidOverlay { + + private ClothoidOverlay() { } + + /** + * True when {@code g} carries a {@link ClothoidSegment} on a ring + * or as a lineal member. Used as the R1.7 gate: a clothoid pair + * is this kit or a miss, never a chord flatten. + */ + static boolean hasClothoid(Geometry g) { + if (g == null || g.isEmpty()) return false; + if (g instanceof ClothoidSegment) return true; + if (g instanceof CompoundCurve) { + CompoundCurve cc = (CompoundCurve) g; + for (int i = 0; i < cc.getNumMembers(); i++) { + if (hasClothoid(cc.getMemberN(i))) return true; + } + return false; + } + if (g instanceof CurvePolygon) { + CurvePolygon cp = (CurvePolygon) g; + if (hasClothoid(cp.getExteriorCurve())) return true; + for (int i = 0; i < cp.getNumInteriorRing(); i++) { + if (hasClothoid(cp.getInteriorCurveN(i))) return true; + } + return false; + } + int n = g.getNumGeometries(); + if (n >= 1 && g.getGeometryN(0) != g) { + for (int i = 0; i < n; i++) { + if (hasClothoid(g.getGeometryN(i))) return true; + } + } + return false; + } + + /** + * Exact overlay, or {@code null} if this class cannot answer + * ({@code CLOTHOID-FRESNEL}). A miss does not densify and does + * not node. + */ + static Geometry overlay(Geometry a, Geometry b, int opCode) { + if (!hasClothoid(a) && !hasClothoid(b)) return null; + if (sameClothoidGeometry(a, b)) { + return HalfDiscOverlay.identityShell(opCode, a, + TwoNodeClip.curveFactory(a)); + } + + CurvePolygon clothA = clothoidShell(a); + CurvePolygon clothB = clothoidShell(b); + if (clothA != null && clothB != null) { + if (!clothA.getEnvelopeInternal().intersects(clothB.getEnvelopeInternal())) { + return HalfDiscOverlay.disjointShells(opCode, a, clothA, clothB, + TwoNodeClip.curveFactory(a)); + } + return null; + } + + CurvePolygon cloth = clothA != null ? clothA : clothB; + Geometry other = clothA != null ? b : a; + boolean clothFirst = clothA != null; + if (cloth == null) return null; + + if (!cloth.getEnvelopeInternal().intersects(other.getEnvelopeInternal())) { + return disjointOther(opCode, a, cloth, other); + } + + double[] disc = CircularDiscOverlay.centreRadius(other); + if (disc != null && strictlyInsideDisc(cloth, disc)) { + CurvePolygon outer = other instanceof CurvePolygon + ? (CurvePolygon) other : null; + if (outer == null) return null; + return HalfDiscOverlay.containedShell(cloth, outer, clothFirst, + opCode, a, TwoNodeClip.curveFactory(a)); + } + return null; + } + + /** + * Hole-free CurvePolygon whose shell is a closed CompoundCurve + * with at least one {@link ClothoidSegment}. + */ + static CurvePolygon clothoidShell(Geometry g) { + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) return null; + g = g.getGeometryN(0); + } + if (!(g instanceof CurvePolygon)) return null; + CurvePolygon cp = (CurvePolygon) g; + if (cp.isEmpty() || cp.getNumInteriorRing() > 0) return null; + LineString ring = cp.getExteriorCurve(); + if (!(ring instanceof CompoundCurve) || !ring.isClosed()) return null; + return hasClothoid(ring) ? cp : null; + } + + /** + * Sufficient certificate: the analytical AABB of the clothoid + * shell sits strictly inside the disc. Corners outside the circle + * are a miss, not a flatten. + */ + static boolean strictlyInsideDisc(CurvePolygon cloth, double[] disc) { + if (cloth == null || disc == null) return false; + double cx = disc[0]; + double cy = disc[1]; + double r = disc[2]; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * r, 1.0e-12); + double maxR = r - eps; + Envelope env = cloth.getEnvelopeInternal(); + return cornerInside(env.getMinX(), env.getMinY(), cx, cy, maxR) + && cornerInside(env.getMinX(), env.getMaxY(), cx, cy, maxR) + && cornerInside(env.getMaxX(), env.getMinY(), cx, cy, maxR) + && cornerInside(env.getMaxX(), env.getMaxY(), cx, cy, maxR); + } + + private static boolean cornerInside(double x, double y, double cx, + double cy, double maxR) { + double dx = x - cx; + double dy = y - cy; + return dx * dx + dy * dy < maxR * maxR; + } + + private static boolean sameClothoidGeometry(Geometry a, Geometry b) { + if (!OverlayNGCurve.isSameGeometry(a, b)) return false; + return sameMembers(ringOf(a), ringOf(b)); + } + + private static LineString ringOf(Geometry g) { + if (g instanceof MultiSurface && g.getNumGeometries() == 1) { + g = g.getGeometryN(0); + } + if (g instanceof CurvePolygon) { + return ((CurvePolygon) g).getExteriorCurve(); + } + if (g instanceof LineString) { + return (LineString) g; + } + return null; + } + + private static boolean sameMembers(LineString r1, LineString r2) { + if (r1 == r2) return true; + if (r1 == null || r2 == null) return false; + if (r1.getClass() != r2.getClass()) return false; + if (!(r1 instanceof CompoundCurve)) { + return r1.equalsExact(r2); + } + CompoundCurve c1 = (CompoundCurve) r1; + CompoundCurve c2 = (CompoundCurve) r2; + if (c1.getNumMembers() != c2.getNumMembers()) return false; + for (int i = 0; i < c1.getNumMembers(); i++) { + LineString m1 = c1.getMemberN(i); + LineString m2 = c2.getMemberN(i); + if (m1.getClass() != m2.getClass()) return false; + if (!m1.equalsExact(m2)) return false; + } + return true; + } + + private static Geometry disjointOther(int opCode, Geometry first, + CurvePolygon cloth, Geometry other) { + GeometryFactory f = TwoNodeClip.curveFactory(first); + if (opCode == OverlayNG.INTERSECTION) { + return f.createEmpty(2); + } + if (opCode == OverlayNG.DIFFERENCE) { + return first.copy(); + } + if (opCode == OverlayNG.UNION || opCode == OverlayNG.SYMDIFFERENCE) { + if (other instanceof Polygon) { + return new MultiSurface(new Polygon[] { + (Polygon) cloth.copy(), (Polygon) other.copy() + }, f); + } + } + return null; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java index f9696c373d..5c4e50802a 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java @@ -19,6 +19,7 @@ import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurvePolygon; import org.locationtech.jts.geom.curve.MultiSurface; @@ -33,8 +34,16 @@ * {@link SameOuterHoleOverlay}, {@link DifferentOuterHoleOverlay}, * {@link HalfDiscOverlay} (complementary / sectors / collinear), * {@link TwoShellClip} (0 / 1 / 2 / even-n / odd-n with a tangent - * as a degenerate NSpan), or a two-node walk vs a disc or plain - * polygon via {@link TwoNodeClip}. A miss is {@code null}. + * as a degenerate NSpan), {@link BiteVsHole} (straddling hole, + * or a hole whose ring overlaps the other shell: new edge ⊂ + * other.shell is a bite, not a punch), + * {@link TwoHoleOverlay} (two holes that cross on the same outer), + * or a two-node walk vs a disc or plain polygon via + * {@link TwoNodeClip}. A 0-node mixed shell vs a circular disc + * ({@code CC-NEST-ANNULUS}) is not a punch. A clothoid member is + * {@link ClothoidOverlay} (0-node identity / disjoint / nest) or + * a named Fresnel miss -- never a chord flatten. A miss is + * {@code null}. */ final class CompoundCurveShellOverlay { @@ -46,6 +55,9 @@ private CompoundCurveShellOverlay() { } * not node. */ static Geometry overlay(Geometry a, Geometry b, int opCode) { + if (ClothoidOverlay.hasClothoid(a) || ClothoidOverlay.hasClothoid(b)) { + return ClothoidOverlay.overlay(a, b, opCode); + } Geometry holeCell = SameOuterHoleOverlay.overlay(a, b, opCode); if (holeCell != null) { return holeCell; @@ -54,6 +66,14 @@ static Geometry overlay(Geometry a, Geometry b, int opCode) { if (differentHole != null) { return differentHole; } + Geometry bite = BiteVsHole.overlay(a, b, opCode); + if (bite != null) { + return bite; + } + Geometry twoHole = TwoHoleOverlay.overlay(a, b, opCode); + if (twoHole != null) { + return twoHole; + } CurvePolygon shellA = compoundCurveShell(a); CurvePolygon shellB = compoundCurveShell(b); if (shellA != null && shellB != null) { @@ -106,6 +126,9 @@ private static CurvePolygon compoundCurveShell(Geometry g) { boolean hasLine = false; for (int i = 0; i < cc.getNumMembers(); i++) { LineString m = cc.getMemberN(i); + if (m instanceof ClothoidSegment) { + return null; + } if (m instanceof CircularString) { hasArc = true; } @@ -130,6 +153,7 @@ private static Geometry clip(CurvePolygon shell, Other other, List edges = TwoNodeClip.flatten(shell); if (edges == null) return null; List nodes = other.nodes(edges); + // 0-node mixed-vs-disc is CC-NEST-ANNULUS, not a stadium punch. if (!TwoNodeClip.properPair(nodes, other.scale())) return null; TwoNodeClip.Node p = nodes.get(0); diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixel.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixel.java new file mode 100644 index 0000000000..f377bcb6fd --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixel.java @@ -0,0 +1,374 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; + +/** + * One hot pixel around one node. Package-private -- not a public + * API, not a noder, not core {@code HotPixel}. The square / half-open + * / scale contract matches snap-rounding: width {@code 1/scale}, + * interior plus left and bottom, minus top and right. Intersection + * is tested in the scaled (squared) metric. + *

    + * A circular leave is not a segment. {@code intersects} of an arc + * is circle–square / arc–AABB in that scaled space, then restricted + * to the sweep. It is not the supporting chord, not + * {@code HotPixel.intersectsScaled}, and not a densified polyline. + * {@link #exit} is the same pixel test: where the arc leaves the + * square. Leave-angle snap is {@link CurveHotPixelSnap} (HP.3). + * This class is not a noder and does not walk faces. + */ +final class CurveHotPixel { + + private static final double TOLERANCE = 0.5; + private static final double EPS = 1.0e-12; + + private final Coordinate originalPt; + private final double scaleFactor; + private final double hpx; + private final double hpy; + + /** + * Pixel centred on a (already rounded) node, of width + * {@code 1/scaleFactor}. Scale must be strictly positive. + */ + CurveHotPixel(Coordinate pt, double scaleFactor) { + if (scaleFactor <= 0.0) { + throw new IllegalArgumentException("Scale factor must be non-zero"); + } + this.originalPt = pt; + this.scaleFactor = scaleFactor; + if (scaleFactor != 1.0) { + this.hpx = Math.round(pt.getX() * scaleFactor); + this.hpy = Math.round(pt.getY() * scaleFactor); + } + else { + this.hpx = pt.getX(); + this.hpy = pt.getY(); + } + } + + Coordinate getCoordinate() { + return originalPt; + } + + double getScaleFactor() { + return scaleFactor; + } + + /** Width of the tolerance square in the original coordinates. */ + double getWidth() { + return 1.0 / scaleFactor; + } + + /** + * Point in the half-open pixel. Same sides as core HotPixel: + * left and bottom closed, top and right open. + */ + boolean intersects(Coordinate p) { + if (p == null) return false; + double x = p.x * scaleFactor; + double y = p.y * scaleFactor; + if (x >= hpx + TOLERANCE) return false; + if (x < hpx - TOLERANCE) return false; + if (y >= hpy + TOLERANCE) return false; + if (y < hpy - TOLERANCE) return false; + return true; + } + + /** + * First point of {@code s} that leaves this pixel along the + * string, or {@code null}. The start must sit in the pixel + * (a leave from this node). An arc exit is circle–square / + * arc–AABB, not the supporting chord. A miss, a degenerate + * pinch, or a string that never leaves is {@code null} -- + * no invented point. + */ + Coordinate exit(CurveSegmentString s) { + if (s == null || !intersects(s)) return null; + if (!intersects(s.getStart())) return null; + if (s.isDegenerate()) return null; + if (s.isArc()) { + return exitArc(s); + } + return exitChord(s.getStart(), s.getEnd()); + } + + /** + * Arc ∩ pixel in the squared metric, or chord ∩ pixel by a + * closed-form clip. Not {@code intersectsScaled} on a chord + * standing in for an arc. + */ + boolean intersects(CurveSegmentString s) { + if (s == null) return false; + if (s.isDegenerate()) { + return intersects(s.getStart()); + } + if (s.isArc()) { + return intersectsArc(s); + } + return intersectsChord(s.getStart(), s.getEnd()); + } + + /** + * Circle–square in scaled space ({@code d²_min ≤ r² ≤ d²_max} + * on the closed box), then a sweep-restricted witness in the + * half-open pixel. Controls and the node itself are the first + * witnesses; a clip that only touches the open top or right + * is a miss. + */ + private boolean intersectsArc(CurveSegmentString s) { + if (intersects(s.getStart()) || intersects(s.getMid()) + || intersects(s.getEnd())) { + return true; + } + double[] c = s.asEdge().circle; + if (c == null || c[2] <= 0.0) { + return intersectsChord(s.getStart(), s.getEnd()); + } + if (!circleHitsClosed(c[0], c[1], c[2])) { + return false; + } + List hits = edgeHits(c[0], c[1], c[2]); + addClosestOnBox(hits, c[0], c[1], c[2]); + if (hits.size() > 1) { + sortByAngle(hits, c[0], c[1]); + } + boolean hit = false; + int n = hits.size(); + for (int i = 0; i < n && !hit; i++) { + Coordinate p = hits.get(i); + if (onSweep(p, s) && intersects(p)) { + hit = true; + } + if (!hit && n >= 2) { + Coordinate q = hits.get((i + 1) % n); + double a0 = Math.atan2(p.y - c[1], p.x - c[0]); + double a1 = Math.atan2(q.y - c[1], q.x - c[0]); + Coordinate mid = TwoNodeClip.midOnCircle(c[0], c[1], c[2], a0, + TwoNodeClip.normPos(a1 - a0)); + if (inClosed(mid) && onSweep(mid, s) && intersects(mid)) { + hit = true; + } + } + } + return hit; + } + + /** + * First circle–square hit on the leave sweep after the start. + * Same edge set as {@link #intersectsArc}. + */ + private Coordinate exitArc(CurveSegmentString s) { + double[] c = s.asEdge().circle; + if (c == null || c[2] <= 0.0) { + return exitChord(s.getStart(), s.getEnd()); + } + List hits = edgeHits(c[0], c[1], c[2]); + addClosestOnBox(hits, c[0], c[1], c[2]); + List onLeave = new ArrayList(); + for (int i = 0; i < hits.size(); i++) { + Coordinate p = hits.get(i); + if (onSweep(p, s) && p.distance(s.getStart()) > EPS) { + onLeave.add(p); + } + } + if (onLeave.isEmpty()) return null; + final TwoNodeClip.Edge e = s.asEdge(); + Collections.sort(onLeave, new Comparator() { + public int compare(Coordinate p, Coordinate q) { + return Double.compare(e.param(p), e.param(q)); + } + }); + return onLeave.get(0); + } + + /** + * Closed-box exit of a chord that starts in the pixel, or + * {@code null} when the chord stays inside. + */ + private Coordinate exitChord(Coordinate a, Coordinate b) { + double ax = a.x * scaleFactor; + double ay = a.y * scaleFactor; + double dx = (b.x - a.x) * scaleFactor; + double dy = (b.y - a.y) * scaleFactor; + double[] t = clipClosed(ax, ay, dx, dy); + if (t == null || t[1] <= 0.0) return null; + Coordinate p = new Coordinate(a.x + t[1] * (b.x - a.x), + a.y + t[1] * (b.y - a.y)); + if (t[1] >= 1.0 && intersects(b)) return null; + return p; + } + + /** + * Closed-box circle test in the scaled metric. No square root: + * compare squared distances to r². A miss here is a miss of + * the half-open pixel. + */ + private boolean circleHitsClosed(double cx, double cy, double r) { + double scx = cx * scaleFactor; + double scy = cy * scaleFactor; + double sr = r * scaleFactor; + double r2 = sr * sr; + double minx = hpx - TOLERANCE; + double maxx = hpx + TOLERANCE; + double miny = hpy - TOLERANCE; + double maxy = hpy + TOLERANCE; + double qx = scx < minx ? minx : (scx > maxx ? maxx : scx); + double qy = scy < miny ? miny : (scy > maxy ? maxy : scy); + double dx = scx - qx; + double dy = scy - qy; + double d2min = dx * dx + dy * dy; + if (d2min > r2) return false; + double farx = Math.max(scx - minx, maxx - scx); + double fary = Math.max(scy - miny, maxy - scy); + return farx * farx + fary * fary >= r2; + } + + private List edgeHits(double cx, double cy, double r) { + double xmin = (hpx - TOLERANCE) / scaleFactor; + double xmax = (hpx + TOLERANCE) / scaleFactor; + double ymin = (hpy - TOLERANCE) / scaleFactor; + double ymax = (hpy + TOLERANCE) / scaleFactor; + Coordinate ll = new Coordinate(xmin, ymin); + Coordinate lr = new Coordinate(xmax, ymin); + Coordinate ul = new Coordinate(xmin, ymax); + Coordinate ur = new Coordinate(xmax, ymax); + List hits = new ArrayList(); + addHits(hits, TwoNodeClip.intersectSegmentCircle(cx, cy, r, ll, ul)); + addHits(hits, TwoNodeClip.intersectSegmentCircle(cx, cy, r, lr, ur)); + addHits(hits, TwoNodeClip.intersectSegmentCircle(cx, cy, r, ll, lr)); + addHits(hits, TwoNodeClip.intersectSegmentCircle(cx, cy, r, ul, ur)); + return hits; + } + + /** + * Closest point of the closed box to the circle centre, when + * that point lies on the circle. Covers a numerical tangent + * the edge quadratic might miss. + */ + private void addClosestOnBox(List hits, double cx, + double cy, double r) { + double xmin = (hpx - TOLERANCE) / scaleFactor; + double xmax = (hpx + TOLERANCE) / scaleFactor; + double ymin = (hpy - TOLERANCE) / scaleFactor; + double ymax = (hpy + TOLERANCE) / scaleFactor; + double qx = cx < xmin ? xmin : (cx > xmax ? xmax : cx); + double qy = cy < ymin ? ymin : (cy > ymax ? ymax : cy); + double dx = qx - cx; + double dy = qy - cy; + double d2 = dx * dx + dy * dy; + if (Math.abs(d2 - r * r) <= EPS) { + addUnique(hits, new Coordinate(qx, qy)); + } + } + + private boolean intersectsChord(Coordinate a, Coordinate b) { + if (intersects(a) || intersects(b)) return true; + double ax = a.x * scaleFactor; + double ay = a.y * scaleFactor; + double dx = (b.x - a.x) * scaleFactor; + double dy = (b.y - a.y) * scaleFactor; + double[] t = clipClosed(ax, ay, dx, dy); + if (t == null) return false; + double tm = 0.5 * (t[0] + t[1]); + return intersects(new Coordinate(a.x + tm * (b.x - a.x), + a.y + tm * (b.y - a.y))); + } + + /** + * Liang-Barsky against the closed scaled box. Not the core + * integer-domain orientation walk. + */ + private double[] clipClosed(double ax, double ay, double dx, + double dy) { + double[] t = new double[] { 0.0, 1.0 }; + if (!clipEdge(-dx, ax - (hpx - TOLERANCE), t)) return null; + if (!clipEdge(dx, (hpx + TOLERANCE) - ax, t)) return null; + if (!clipEdge(-dy, ay - (hpy - TOLERANCE), t)) return null; + if (!clipEdge(dy, (hpy + TOLERANCE) - ay, t)) return null; + return t; + } + + private static boolean clipEdge(double p, double q, double[] t) { + if (p == 0.0) { + return q >= 0.0; + } + double r = q / p; + boolean ok = true; + if (p < 0.0) { + if (r > t[1]) { + ok = false; + } + else if (r > t[0]) { + t[0] = r; + } + } + else if (r < t[0]) { + ok = false; + } + else if (r < t[1]) { + t[1] = r; + } + return ok; + } + + private boolean inClosed(Coordinate p) { + double x = p.x * scaleFactor; + double y = p.y * scaleFactor; + if (x > hpx + TOLERANCE) return false; + if (x < hpx - TOLERANCE) return false; + if (y > hpy + TOLERANCE) return false; + if (y < hpy - TOLERANCE) return false; + return true; + } + + private static boolean onSweep(Coordinate p, CurveSegmentString s) { + TwoNodeClip.Edge e = s.asEdge(); + return TwoNodeClip.isOnSweep(p, e.circle, e.a, e.mid, e.b); + } + + private static void addHits(List dest, Coordinate[] xs) { + for (int i = 0; i < xs.length; i++) { + addUnique(dest, xs[i]); + } + } + + private static void addUnique(List dest, Coordinate p) { + boolean seen = false; + for (int i = 0; i < dest.size() && !seen; i++) { + if (dest.get(i).distance(p) <= EPS) { + seen = true; + } + } + if (!seen) { + dest.add(p); + } + } + + private static void sortByAngle(List pts, final double cx, + final double cy) { + Collections.sort(pts, new Comparator() { + public int compare(Coordinate p, Coordinate q) { + return Double.compare( + Math.atan2(p.y - cy, p.x - cx), + Math.atan2(q.y - cy, q.x - cx)); + } + }); + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelSnap.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelSnap.java new file mode 100644 index 0000000000..d0bdc80ca0 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelSnap.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import org.locationtech.jts.geom.Coordinate; + +/** + * Snap a leave's pixel-exit onto a discrete grid heading. + * Package-private -- not a noder, not a face walk, not core + * {@code HotPixel}. {@link CurveHotPixel} stays a pixel test; + * this is the heading key a later walk (HP.4) can compare. + *

    + * The key is integer {@code (dx, dy)} in the scaled lattice: + * the pixel-exit rounded with {@code Math.round} (half-up, same + * family as HotPixel scale-round) minus the already-rounded + * node. Two leaves have distinct headings iff the keys differ. + * {@code (0, 0)} is not a heading -- the exit snapped back onto + * the node. + *

    + * Do not snap the shared tangent. Both H-SHELL-N-ODD leaves at + * {@code (0, 5)} are +x; that stays coincident. Snap the + * circle–square exit of the leave, not the supporting chord. + * A string that does not intersect the pixel has no heading + * (documented miss, not a fake key). + *

    + * If two leaves share one key they share one snapped ray: + * {@link #SHARED_SNAPPED_RAY}. Stamp and stop. Same stop rule + * as P2.5.4. This constant is not a replacement for + * {@link CurveSegmentFaces#TANGENT_LEAVE_ANGLE} on the unsnapped + * {@code faces} path. Not a curvature-order tie-break (HP.1). + */ +final class CurveHotPixelSnap { + + /** + * Named stamp: both leaves snap to the same lattice heading. + * HP.3 stop. Not wired into {@link CurveSegmentFaces#faces}. + */ + static final String SHARED_SNAPPED_RAY = "HP.3 shared snapped ray"; + + private CurveHotPixelSnap() { } + + /** + * Heading of {@code leave} through {@code pixel}, or + * {@code null}. The leave must start in the pixel and + * {@link CurveHotPixel#intersects(CurveSegmentString) intersect} + * it. The key is the snapped arc-exit, not the tangent and + * not the chord. + */ + static Heading heading(CurveHotPixel pixel, CurveSegmentString leave) { + if (pixel == null || leave == null) return null; + Coordinate exit = pixel.exit(leave); + if (exit == null) return null; + return snap(pixel, exit); + } + + /** + * {@link #SHARED_SNAPPED_RAY} when both headings exist and + * the keys are equal; {@code null} when they differ or either + * is a miss. Does not invent a curvature-order split. + */ + static String sharedRayOrNull(Heading a, Heading b) { + if (a != null && a.equals(b)) { + return SHARED_SNAPPED_RAY; + } + return null; + } + + /** + * Round the exit onto the scaled lattice and subtract the + * node. An open-edge exit at {@code +0.5} snaps outward + * ({@code Math.round(0.5) == 1}), not back onto the node. + */ + private static Heading snap(CurveHotPixel pixel, Coordinate exit) { + double scale = pixel.getScaleFactor(); + Coordinate node = pixel.getCoordinate(); + int gx = (int) Math.round(exit.x * scale); + int gy = (int) Math.round(exit.y * scale); + int nx = (int) Math.round(node.x * scale); + int ny = (int) Math.round(node.y * scale); + int dx = gx - nx; + int dy = gy - ny; + if (dx == 0 && dy == 0) return null; + return new Heading(dx, dy); + } + + /** + * Integer {@code (dx, dy)} in the scaled lattice from the + * node to the snapped pixel-exit. Comparable by equality. + * The walk (HP.4) can use the pair as a map key; this rung + * does not order or walk. + */ + static final class Heading { + final int dx; + final int dy; + + Heading(int dx, int dy) { + this.dx = dx; + this.dy = dy; + } + + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Heading)) return false; + Heading h = (Heading) o; + return dx == h.dx && dy == h.dy; + } + + public int hashCode() { + return 31 * dx + dy; + } + + public String toString() { + return "(" + dx + ", " + dy + ")"; + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java new file mode 100644 index 0000000000..8e0927bf23 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java @@ -0,0 +1,972 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularArcDensifier; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; + +/** + * Package-private curve DCEL. Half-edges, twins, next/prev, incident + * face. Members stay {@link CurveSegmentString} (an arc stays an + * arc). Built from the P2.1–P2.5.2 node set plus already-named + * MIXED / shared-edge ends. Cycle order is the Faces left-most / + * next-outgoing walk, persisted as links. + *

    + * PLG / COV eat this structure. Not a noder, not OverlayNG-for-circles, + * not a public API, not another Geometry assembler. + * {@link CurveSegmentFaces} may assemble rings from the bounded + * faces; that is a consumer, not this product. + *

    + * Generic walk-on-{@code nodes==null} is unsafe: a MIXED abort can + * hide a real crossing ({@code HALF_DISC × HALF_CROSSING_UPPER}). + * That pair stamps {@link #MIXED_HIDES_CROSSING}. A coincident + * leave-angle is snap-rounding ({@link #TANGENT_LEAVE_ANGLE}). + * Pinch / kiss / holed Geometry-level stay {@code null}. Densify + * is never a noder. Not P2.5.5. + */ +final class CurveSegmentDcel { + + /** Named stamp: coincident leave-angle. Snap-rounding, not a walk. */ + static final String TANGENT_LEAVE_ANGLE = "P2.5.4 tangent leave-angle"; + + /** + * Named stamp: {@code nodes==null} because a collinear pair aborted, + * and another pair still has a discrete crossing. Not a noder. + */ + static final String MIXED_HIDES_CROSSING = + "MIXED nodes==null hides a crossing"; + + /** + * Near-tangent window for an arc leave. Chord coincidence is + * {@link #compareLeave} only: subtracted-vector atan2 can collapse + * distinct direction points (locationtech #1224). An arc centre + * from a rebuilt circumcircle can put a theoretically-on-axis + * tangent in either adjacent quadrant, so the N=3 stamp still + * needs this window. + */ + private static final double ANGLE_EPS = 1.0e-8; + + private static String missReason; + + private final List halves; + private final List faces; + private final List vertices; + private final double eps; + + private CurveSegmentDcel(List halves, List faces, + List vertices, double eps) { + this.halves = halves; + this.faces = faces; + this.vertices = vertices; + this.eps = eps; + } + + /** + * Why the last {@link #of(Geometry[])} / string-group call + * returned {@code null}, or {@code null} when a DCEL was + * produced. Package-private -- not a public API. + */ + static String missReason() { + return missReason; + } + + /** + * Arrangement DCEL of N hole-free circular / compound shells, or + * {@code null}. Sewn at discrete nodes, or at a named MIXED + * interval with no hidden crossing. {@link #missReason()} names + * a stamp when the walk would need snap-rounding or a noder. + */ + static CurveSegmentDcel of(Geometry[] geoms) { + missReason = null; + if (geoms == null || geoms.length < 2) return null; + List> groups = + new ArrayList>(geoms.length); + boolean miss = false; + for (int i = 0; i < geoms.length && !miss; i++) { + if (geoms[i] == null || geoms[i].isEmpty() || hasHole(geoms[i])) { + miss = true; + } + else { + List s = CurveSegmentString.of(geoms[i]); + if (s == null) { + miss = true; + } + else { + groups.add(s); + } + } + } + if (miss) return null; + return of(groups, scaleOf(geoms)); + } + + /** + * Arrangement DCEL of N string collections. Same sew as + * {@link #of(Geometry[])}; no Geometry hole check. + */ + static CurveSegmentDcel of(List> groups, + double scale) { + missReason = null; + if (groups == null || groups.size() < 2) return null; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate[] nodes = CurveSegmentNoder.nodes(groups, scale); + if (nodes == null) { + if (hidesCrossing(groups, scale, eps)) { + missReason = MIXED_HIDES_CROSSING; + return null; + } + if (!hasNamedInterval(groups, scale)) { + return null; + } + } + return build(groups, nodes, scale, eps); + } + + List halves() { + return halves; + } + + List faces() { + return faces; + } + + List boundedFaces() { + List out = new ArrayList(); + for (int i = 0; i < faces.size(); i++) { + if (faces.get(i).bounded) { + out.add(faces.get(i)); + } + } + return out; + } + + List vertices() { + return vertices; + } + + double eps() { + return eps; + } + + private static CurveSegmentDcel build(List> groups, + Coordinate[] nodes, double scale, double eps) { + List pool = new ArrayList(); + addCanon(pool, nodes, eps); + addEdgeEnds(pool, groups, scale, eps); + addStringEnds(pool, groups, eps); + + List pieces = splitAll(groups, pool, eps); + if (pieces == null || pieces.isEmpty()) return null; + pieces = mergeCoincident(pieces, scale, eps); + List halves = buildHalves(pieces, pool, eps); + if (halves == null || halves.isEmpty()) return null; + + List verts = indexByStart(halves, pool, eps); + if (verts == null) return null; + if (hasCoincidentLeave(verts)) { + missReason = TANGENT_LEAVE_ANGLE; + return null; + } + if (!linkCycles(verts, halves)) return null; + + List faces = assignFaces(halves, eps); + if (faces == null || faces.isEmpty()) return null; + return new CurveSegmentDcel(halves, faces, verts, eps); + } + + /** + * A MIXED abort ({@link CurveSegmentString#intersect} {@code null}) + * hid a discrete hit that is not an end of a named shared edge. + * That pair needs a noder, not a generic {@code nodes==null} walk. + */ + private static boolean hidesCrossing(List> groups, + double scale, double eps) { + List namedEnds = new ArrayList(); + collectNamedEnds(groups, scale, namedEnds, eps); + boolean hidden = false; + for (int i = 0; i < groups.size() && !hidden; i++) { + for (int j = i + 1; j < groups.size() && !hidden; j++) { + List a = groups.get(i); + List b = groups.get(j); + if (a == null || b == null) { + continue; + } + for (int p = 0; p < a.size() && !hidden; p++) { + for (int q = 0; q < b.size() && !hidden; q++) { + Coordinate[] xs = CurveSegmentString.intersect(a.get(p), + b.get(q), scale); + if (xs != null) { + hidden = hasUnnamedHit(xs, namedEnds, eps); + } + } + } + } + } + return hidden; + } + + private static void collectNamedEnds(List> groups, + double scale, List namedEnds, double eps) { + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size(); k++) { + CurveSegmentString e = edges.get(k); + canon(e.getStart(), namedEnds, eps); + canon(e.getEnd(), namedEnds, eps); + } + } + } + } + } + + private static boolean hasUnnamedHit(Coordinate[] xs, + List namedEnds, double eps) { + boolean hit = false; + for (int k = 0; k < xs.length && !hit; k++) { + if (!nearPool(xs[k], namedEnds, eps)) { + hit = true; + } + } + return hit; + } + + private static boolean hasNamedInterval(List> groups, + double scale) { + boolean found = false; + for (int i = 0; i < groups.size() && !found; i++) { + for (int j = i + 1; j < groups.size() && !found; j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size() && !found; k++) { + if (!edges.get(k).isDegenerate()) { + found = true; + } + } + } + } + } + return found; + } + + private static boolean linkCycles(List verts, List halves) { + boolean miss = false; + for (int i = 0; i < verts.size() && !miss; i++) { + List out = verts.get(i).out; + if (out.isEmpty()) { + continue; + } + for (int j = 0; j < out.size(); j++) { + Half back = out.get(j); + // Leave-sorted outgoing is CCW (endpoint quadrant, then + // orientation — not atan2 of subtracted deltas). The + // outgoing after `back` in that list is a right turn; the + // one before is a left turn (interior on the left, CCW face). + int left = (j - 1 + out.size()) % out.size(); + Half nxt = out.get(left); + back.twin.next = nxt; + } + } + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + if (h.next == null) { + miss = true; + } + else { + h.next.prev = h; + } + } + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + if (h.twin == null || h.twin.twin != h) { + miss = true; + } + else if (h.next == null || h.prev == null) { + miss = true; + } + else if (h.next.prev != h || h.prev.next != h) { + miss = true; + } + } + return !miss; + } + + private static List assignFaces(List halves, double eps) { + List faces = new ArrayList(); + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half start = halves.get(i); + if (start.face == null) { + Face face = walkFace(start, eps); + if (face == null) { + miss = true; + } + else { + faces.add(face); + } + } + } + return miss ? null : faces; + } + + private static Face walkFace(Half start, double eps) { + Face face = new Face(); + Half cur = start; + int guard = 0; + boolean closed = false; + boolean miss = false; + while (guard++ < 256 && !closed && !miss) { + if (cur.face != null) { + miss = true; + } + else { + cur.face = face; + face.halves.add(cur); + if (cur.next == start) { + closed = true; + } + else if (cur.next == null) { + miss = true; + } + else { + cur = cur.next; + } + } + } + if (miss || !closed || face.halves.isEmpty()) return null; + face.signedArea = signedArea(face.halves); + face.bounded = face.signedArea > eps * eps; + return face; + } + + private static List splitAll( + List> groups, List pool, + double eps) { + List out = new ArrayList(); + boolean miss = false; + for (int g = 0; g < groups.size() && !miss; g++) { + List strings = groups.get(g); + if (strings == null) { + miss = true; + } + else { + for (int i = 0; i < strings.size() && !miss; i++) { + List parts = split(strings.get(i), pool, eps); + if (parts == null) { + miss = true; + } + else { + out.addAll(parts); + } + } + } + } + return miss ? null : out; + } + + private static List split(CurveSegmentString s, + List pool, double eps) { + if (s == null) return null; + List cuts = new ArrayList(); + TwoNodeClip.Edge e = s.asEdge(); + for (int i = 0; i < pool.size(); i++) { + Coordinate p = pool.get(i); + if (onString(s, p, eps)) { + cuts.add(new Cut(e.param(p), p)); + } + } + if (cuts.size() < 2) { + List one = new ArrayList(1); + if (!s.isDegenerate()) { + one.add(s); + } + return one; + } + Collections.sort(cuts, Cut.BY_T); + List uniq = new ArrayList(); + for (int i = 0; i < cuts.size(); i++) { + Cut c = cuts.get(i); + if (uniq.isEmpty() + || uniq.get(uniq.size() - 1).pt.distance(c.pt) > eps) { + uniq.add(c); + } + } + List out = new ArrayList(); + for (int i = 0; i + 1 < uniq.size(); i++) { + Coordinate a = uniq.get(i).pt; + Coordinate b = uniq.get(i + 1).pt; + if (a.distance(b) > eps) { + out.add(sub(s, a, b)); + } + } + return out; + } + + private static CurveSegmentString sub(CurveSegmentString s, Coordinate from, + Coordinate to) { + if (!s.isArc()) { + return CurveSegmentString.segment(from, to); + } + Coordinate mid = TwoNodeClip.midOnSweep(from, to, s.asEdge()); + return CurveSegmentString.arc(from, mid, to); + } + + /** + * True when {@code p} lies on this string. The residual stays in + * R²: compare {@code dx²+dy²} to {@code R²} (arc) or to the + * projected chord point (chord) with tolerance {@code eps²}. + * The arc compare floors at 1 ulp of the two squares so a + * representable on-circle node is not rejected. Not + * {@code hypot}, not a length {@code eps} on {@code d²}, + * not a sagitta quotient. Package-private so the extreme-sagitta + * pin can call it; not a public API. + */ + static boolean onString(CurveSegmentString s, Coordinate p, + double eps) { + TwoNodeClip.Edge e = s.asEdge(); + double eps2 = eps * eps; + if (s.isArc()) { + double dx = p.x - e.circle[0]; + double dy = p.y - e.circle[1]; + double r = e.circle[2]; + double d2 = dx * dx + dy * dy; + double r2 = r * r; + double tol2 = Math.max(eps2, Math.ulp(d2) + Math.ulp(r2)); + if (Math.abs(d2 - r2) > tol2) { + return false; + } + return TwoNodeClip.isOnSweep(p, e.circle, e.a, e.mid, e.b); + } + double t = TwoNodeClip.parameter(e.a, e.b, p); + double dx = p.x - (e.a.x + t * (e.b.x - e.a.x)); + double dy = p.y - (e.a.y + t * (e.b.y - e.a.y)); + return dx * dx + dy * dy <= eps2; + } + + private static List mergeCoincident( + List pieces, double scale, double eps) { + List out = new ArrayList(); + for (int i = 0; i < pieces.size(); i++) { + CurveSegmentString p = pieces.get(i); + if (!p.isDegenerate() && !containsPiece(out, p, scale, eps)) { + out.add(p); + } + } + return out; + } + + private static boolean containsPiece(List out, + CurveSegmentString p, double scale, double eps) { + boolean seen = false; + for (int i = 0; i < out.size() && !seen; i++) { + if (samePiece(out.get(i), p, scale, eps)) { + seen = true; + } + } + return seen; + } + + private static boolean samePiece(CurveSegmentString p, CurveSegmentString q, + double scale, double eps) { + boolean ends = (p.getStart().distance(q.getStart()) <= eps + && p.getEnd().distance(q.getEnd()) <= eps) + || (p.getStart().distance(q.getEnd()) <= eps + && p.getEnd().distance(q.getStart()) <= eps); + if (!ends) return false; + if (p.isArc() != q.isArc()) return false; + if (!p.isArc()) return true; + return CurveSegmentString.sameCircle(p, q, scale); + } + + private static List buildHalves(List pieces, + List pool, double eps) { + List halves = new ArrayList(); + boolean miss = false; + for (int i = 0; i < pieces.size() && !miss; i++) { + CurveSegmentString p = pieces.get(i); + Coordinate a = canon(p.getStart(), pool, eps); + Coordinate b = canon(p.getEnd(), pool, eps); + if (a.distance(b) <= eps) { + continue; + } + CurveSegmentString fwd = sub(p, a, b); + CurveSegmentString rev = reverse(fwd); + if (fwd.isDegenerate() || rev.isDegenerate()) { + miss = true; + } + else { + Half hf = new Half(a, b, fwd); + Half hr = new Half(b, a, rev); + hf.twin = hr; + hr.twin = hf; + halves.add(hf); + halves.add(hr); + } + } + return miss ? null : halves; + } + + private static CurveSegmentString reverse(CurveSegmentString s) { + if (!s.isArc()) { + return CurveSegmentString.segment(s.getEnd(), s.getStart()); + } + return CurveSegmentString.arc(s.getEnd(), s.getMid(), s.getStart()); + } + + private static List indexByStart(List halves, + List pool, double eps) { + List verts = new ArrayList(); + for (int i = 0; i < pool.size(); i++) { + verts.add(new Vertex(pool.get(i))); + } + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + Vertex v = vertexAt(verts, h.origin, eps); + if (v == null) { + miss = true; + } + else { + v.out.add(h); + h.originVertex = v; + } + } + if (miss) return null; + List used = new ArrayList(); + for (int i = 0; i < verts.size(); i++) { + Vertex v = verts.get(i); + if (!v.out.isEmpty()) { + Collections.sort(v.out, Half.BY_ANGLE); + used.add(v); + } + } + return used; + } + + /** + * Two different pieces leaving in the same direction are a + * tangent. Distinct direction points stay distinct (locationtech + * #1224); the quadrant comes from the endpoints, not FP deltas + * (#1226). Ordering a true tie is snap-rounding (P2.5.4). Stamp + * and stop. + */ + private static boolean hasCoincidentLeave(List verts) { + boolean hit = false; + for (int i = 0; i < verts.size() && !hit; i++) { + List out = verts.get(i).out; + for (int j = 0; j < out.size() && !hit; j++) { + Half a = out.get(j); + Half b = out.get((j + 1) % out.size()); + if (a != b && leavesCoincide(a, b)) { + hit = true; + } + } + } + return hit; + } + + private static double signedArea(List members) { + double signed = 0.0; + for (int i = 0; i < members.size(); i++) { + CurveSegmentString s = members.get(i).member; + if (s.isArc()) { + signed += CircularArcDensifier.arcAreaContribution( + s.getStart(), s.getMid(), s.getEnd()); + } + else { + Coordinate a = s.getStart(); + Coordinate b = s.getEnd(); + signed += 0.5 * (a.x * b.y - b.x * a.y); + } + } + return signed; + } + + private static void addEdgeEnds(List pool, + List> groups, double scale, double eps) { + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size(); k++) { + CurveSegmentString e = edges.get(k); + canon(e.getStart(), pool, eps); + canon(e.getEnd(), pool, eps); + } + } + } + } + } + + private static void addStringEnds(List pool, + List> groups, double eps) { + for (int g = 0; g < groups.size(); g++) { + List strings = groups.get(g); + if (strings != null) { + for (int i = 0; i < strings.size(); i++) { + CurveSegmentString s = strings.get(i); + canon(s.getStart(), pool, eps); + canon(s.getEnd(), pool, eps); + } + } + } + } + + private static void addCanon(List pool, Coordinate[] xs, + double eps) { + if (xs == null) return; + for (int i = 0; i < xs.length; i++) { + canon(xs[i], pool, eps); + } + } + + private static Coordinate canon(Coordinate p, List pool, + double eps) { + Coordinate found = null; + for (int i = 0; i < pool.size() && found == null; i++) { + if (pool.get(i).distance(p) <= eps) { + found = pool.get(i); + } + } + if (found != null) return found; + Coordinate n = new Coordinate(p); + pool.add(n); + return n; + } + + private static boolean nearPool(Coordinate p, List pool, + double eps) { + boolean found = false; + for (int i = 0; i < pool.size() && !found; i++) { + if (pool.get(i).distance(p) <= eps) { + found = true; + } + } + return found; + } + + private static Vertex vertexAt(List verts, Coordinate p, double eps) { + Vertex found = null; + for (int i = 0; i < verts.size() && found == null; i++) { + if (verts.get(i).pt.distance(p) <= eps) { + found = verts.get(i); + } + } + return found; + } + + /** + * CCW order of leave directions around a shared origin. Same + * walk as locationtech {@code HalfEdge.compareAngularDirection} + * after #1224 / #1226, kept here: this DCEL does not use core + * {@code HalfEdge} or {@code Quadrant}. Direction-point equality + * first (not subtracted-vector {@code ==}), then quadrant from + * the endpoints, then {@link Orientation#index}. + */ + static int compareLeave(Half a, Half b) { + if (a.leaveDir.equals2D(b.leaveDir)) { + return 0; + } + int qa = leaveQuadrant(a); + int qb = leaveQuadrant(b); + if (qa > qb) { + return 1; + } + if (qa < qb) { + return -1; + } + return Orientation.index(b.origin, b.leaveDir, a.leaveDir); + } + + /** + * True same leave, or an arc near-tangent. Distinct chord + * direction points that {@link #compareLeave} separates are + * not a stamp, even when atan2 of the deltas collapsed. + */ + static boolean leavesCoincide(Half a, Half b) { + if (compareLeave(a, b) == 0) { + return true; + } + if (!a.member.isArc() && !b.member.isArc()) { + return false; + } + return angleDiff(leaveAngle(a), leaveAngle(b)) < ANGLE_EPS; + } + + private static double leaveAngle(Half h) { + return Math.atan2(h.leaveDir.y - h.origin.y, h.leaveDir.x - h.origin.x); + } + + private static double angleDiff(double a, double b) { + double d = Math.abs(a - b); + if (d > Math.PI) { + d = TwoNodeClip.TWO_PI - d; + } + return d; + } + + /** + * Quadrant of the leave ray from the endpoints, not from + * subtracted {@code dx}/{@code dy}. A chord uses origin→dest. + * An arc uses origin vs centre so the tangent quadrant does + * not go through {@code (origin - centre)}. + */ + private static int leaveQuadrant(Half h) { + if (!h.member.isArc()) { + return quadrant(h.origin, h.dest); + } + TwoNodeClip.Edge e = h.member.asEdge(); + Coordinate c = new Coordinate(e.circle[0], e.circle[1]); + if (sweepCcw(e)) { + return quadrantBits(c.y >= h.origin.y, h.origin.x >= c.x); + } + return quadrantBits(h.origin.y >= c.y, c.x >= h.origin.x); + } + + private static int quadrant(Coordinate o, Coordinate d) { + return quadrantBits(d.x >= o.x, d.y >= o.y); + } + + private static int quadrantBits(boolean xNonNeg, boolean yNonNeg) { + if (xNonNeg) { + return yNonNeg ? 0 : 3; + } + return yNonNeg ? 1 : 2; + } + + /** + * Point that names the leave direction. A chord uses dest. An + * arc uses the tangent at the start (radius rotated 90°), not + * the chord to dest. + */ + private static Coordinate leaveDir(CurveSegmentString s, + Coordinate origin, Coordinate dest) { + if (!s.isArc()) { + return dest; + } + TwoNodeClip.Edge e = s.asEdge(); + double rx = origin.x - e.circle[0]; + double ry = origin.y - e.circle[1]; + if (sweepCcw(e)) { + return new Coordinate(origin.x - ry, origin.y + rx); + } + return new Coordinate(origin.x + ry, origin.y - rx); + } + + private static boolean sweepCcw(TwoNodeClip.Edge e) { + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + return TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + } + + private static boolean hasHole(Geometry g) { + Geometry geom = unwrap(g); + if (geom instanceof CurvePolygon) { + return ((CurvePolygon) geom).getNumInteriorRing() > 0; + } + if (geom instanceof Polygon) { + return ((Polygon) geom).getNumInteriorRing() > 0; + } + return false; + } + + private static Geometry unwrap(Geometry g) { + if (g == null || g.isEmpty()) return null; + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) return null; + return unwrap(g.getGeometryN(0)); + } + return g; + } + + private static double scaleOf(Geometry[] geoms) { + double s = 1.0; + if (geoms == null) return s; + for (int i = 0; i < geoms.length; i++) { + if (geoms[i] == null || geoms[i].isEmpty()) { + continue; + } + double w = Math.max(geoms[i].getEnvelopeInternal().getWidth(), + geoms[i].getEnvelopeInternal().getHeight()); + if (w > s) { + s = w; + } + } + return s; + } + + private static final class Cut { + static final Comparator BY_T = new Comparator() { + public int compare(Cut a, Cut b) { + return Double.compare(a.t, b.t); + } + }; + final double t; + final Coordinate pt; + Cut(double t, Coordinate pt) { + this.t = t; + this.pt = pt; + } + } + + /** + * Directed half-edge. Twin is the reverse. {@link #next} / + * {@link #prev} walk the left face. Member is a + * {@link CurveSegmentString}. + */ + static final class Half { + static final Comparator BY_ANGLE = new Comparator() { + public int compare(Half a, Half b) { + return compareLeave(a, b); + } + }; + final Coordinate origin; + final Coordinate dest; + final CurveSegmentString member; + final Coordinate leaveDir; + Half twin; + Half next; + Half prev; + Face face; + Vertex originVertex; + + Half(Coordinate origin, Coordinate dest, CurveSegmentString member) { + this.origin = origin; + this.dest = dest; + this.member = member; + this.leaveDir = leaveDir(member, origin, dest); + } + + Coordinate origin() { + return origin; + } + + Coordinate dest() { + return dest; + } + + Half twin() { + return twin; + } + + Half next() { + return next; + } + + Half prev() { + return prev; + } + + Face face() { + return face; + } + + CurveSegmentString member() { + return member; + } + + boolean isArc() { + return member.isArc(); + } + + LineString toLine(GeometryFactory f) { + if (!member.isArc()) { + return f.createLineString(new Coordinate[] { + new Coordinate(origin), new Coordinate(dest) + }); + } + return TwoNodeClip.arc(origin, member.getMid(), dest, f); + } + } + + /** + * Left face of a next-cycle. Bounded when the walk has positive + * signed area (CCW). The complementary outer (union) ring is + * bounded too; {@link CurveSegmentFaces} drops it when assembling + * Geometry. Unbounded is the clockwise exterior. + */ + static final class Face { + final List halves = new ArrayList(); + double signedArea; + boolean bounded; + + Half edge() { + return halves.isEmpty() ? null : halves.get(0); + } + + boolean isBounded() { + return bounded; + } + + int edgeCount() { + return halves.size(); + } + + double signedArea() { + return signedArea; + } + + List toLines(GeometryFactory f) { + List members = new ArrayList(halves.size()); + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + LineString ls = halves.get(i).toLine(f); + if (ls == null) { + miss = true; + } + else { + members.add(ls); + } + } + return miss ? null : members; + } + } + + static final class Vertex { + final Coordinate pt; + final List out = new ArrayList(); + + Vertex(Coordinate pt) { + this.pt = pt; + } + + Coordinate coordinate() { + return pt; + } + + List outgoing() { + return out; + } + + int degree() { + return out.size(); + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java new file mode 100644 index 0000000000..29dc5cabc3 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java @@ -0,0 +1,714 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularArcDensifier; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +/** + * Faces of an N-shell arrangement. Package-private -- not a public + * API, not an N-ary overlay, not a noder. OverlayNG stays binary; + * CAP / CUP / SUB / XOR of a pair among the N stay on the existing + * kits. This rung splits the P2.5.2 node set (and P2.2 shared-edge + * ends) into pieces and walks left-most / next-outgoing. + *

    + * N=2 recovers the pair-kit faces: a discrete crossing walks the + * same rings {@link TwoShellClip} / {@link NSpanClip} / + * {@link CircularDiscOverlay} already assemble (CAP + XOR). A + * 0-node containment or a same-circle special case falls back to + * those kits. MIXED / pinch / holed Geometry-level stays + * {@code null} -- hole rings are walked as strings, as in P2.3 / + * P2.4. A coincident leave-angle at a node (near-tangent) is + * snap-rounding (P2.5.4): {@code faces} returns {@code null} and + * {@link #missReason()} names {@link #TANGENT_LEAVE_ANGLE}. + * A local signed-curvature leave-angle order walks the wrong + * rings (HP.1 pin). Ordering those leaves needs HotPixel / + * ScaledNoder / core {@code SegmentString} -- stamp and stop. + * Densify is never a noder. Not P2.5.5. + */ +final class CurveSegmentFaces { + + /** Named stamp: coincident leave-angle. Snap-rounding, not a walk. */ + static final String TANGENT_LEAVE_ANGLE = "P2.5.4 tangent leave-angle"; + + private static final double ANGLE_EPS = 1.0e-8; + + private static String missReason; + + private CurveSegmentFaces() { } + + /** + * Why the last {@link #faces(Geometry[])} / string-group call + * returned {@code null}, or {@code null} when faces were + * produced. Package-private -- not a public API. + */ + static String missReason() { + return missReason; + } + + /** + * Bounded faces of N hole-free circular / compound shells, or + * {@code null}. N=2 is the pair-kit rings. N≥3 is the walk, or + * {@code null} when the walk would need snap-rounding + * ({@link #missReason()} names the stamp). + */ + static Geometry faces(Geometry[] geoms) { + missReason = null; + if (geoms == null || geoms.length < 2) return null; + List> groups = + new ArrayList>(geoms.length); + Geometry factorySrc = null; + boolean miss = false; + for (int i = 0; i < geoms.length && !miss; i++) { + if (geoms[i] == null || geoms[i].isEmpty() || hasHole(geoms[i])) { + miss = true; + } + else { + List s = CurveSegmentString.of(geoms[i]); + if (s == null) { + miss = true; + } + else { + groups.add(s); + if (factorySrc == null) { + factorySrc = geoms[i]; + } + } + } + } + if (miss) return null; + GeometryFactory f = factorySrc == null + ? new CurveGeometryFactory() + : TwoNodeClip.curveFactory(factorySrc); + return faces(groups, scaleOf(geoms), f, geoms); + } + + /** + * Bounded faces of N string collections. Same walk as + * {@link #faces(Geometry[])}; no pair-kit fallback (no Geometry + * overlay to recover). + */ + static Geometry faces(List> groups, double scale) { + return faces(groups, scale, new CurveGeometryFactory(), null); + } + + private static Geometry faces(List> groups, + double scale, GeometryFactory f, Geometry[] geoms) { + missReason = null; + if (groups == null || groups.size() < 2) return null; + Coordinate[] nodes = CurveSegmentNoder.nodes(groups, scale); + if (nodes == null) { + return geoms != null && geoms.length == 2 + ? pairKitFaces(geoms[0], geoms[1]) + : null; + } + Geometry walked = walk(groups, nodes, scale, f); + if (walked != null) { + missReason = null; + return walked; + } + if (geoms != null && geoms.length == 2) { + Geometry kit = pairKitFaces(geoms[0], geoms[1]); + if (kit != null) { + missReason = null; + } + return kit; + } + return null; + } + + /** + * Pair-kit CAP + XOR components. Empty CAP is not a miss. A kit + * that cannot certify (MIXED, pinch, lineal) is {@code null}. + * Does not call {@link OverlayNGCurve} -- that would densify. + */ + static Geometry pairKitFaces(Geometry a, Geometry b) { + Geometry cap = exactOverlay(a, b, OverlayNG.INTERSECTION); + Geometry xor = exactOverlay(a, b, OverlayNG.SYMDIFFERENCE); + if (cap == null || xor == null) return null; + List faces = new ArrayList(); + if (!addPoly(faces, cap) || !addPoly(faces, xor)) return null; + return toGeometry(faces, TwoNodeClip.curveFactory(a)); + } + + private static Geometry walk(List> groups, + Coordinate[] nodes, double scale, GeometryFactory f) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + List pool = new ArrayList(); + addCanon(pool, nodes, eps); + addEdgeEnds(pool, groups, scale, eps); + addStringEnds(pool, groups, eps); + + List pieces = splitAll(groups, pool, scale, eps); + if (pieces == null || pieces.isEmpty()) return null; + pieces = mergeCoincident(pieces, scale, eps); + List halves = buildHalves(pieces, pool, eps); + if (halves == null) return null; + + List verts = indexByStart(halves, pool, eps); + if (verts == null) return null; + if (hasCoincidentLeave(verts)) { + missReason = TANGENT_LEAVE_ANGLE; + return null; + } + + List faces = new ArrayList(); + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half start = halves.get(i); + if (!start.used) { + List members = walkRing(start, eps, f); + if (members == null) { + miss = true; + } + else if (Math.abs(signedArea(members)) > eps * eps) { + Polygon face = TwoNodeClip.closeRing(members, f, eps); + if (face == null) { + miss = true; + } + else { + faces.add(face); + } + } + } + } + if (miss || faces.isEmpty()) return null; + dropUnion(faces, eps); + if (faces.isEmpty()) return null; + return toGeometry(faces, f); + } + + /** + * The walk also closes the union (the complementary outer ring). + * That ring's area is the sum of the bounded faces; drop it. + */ + private static void dropUnion(List faces, double eps) { + if (faces.size() < 2) return; + int maxAt = 0; + double maxA = faces.get(0).getArea(); + double sum = maxA; + for (int i = 1; i < faces.size(); i++) { + double a = faces.get(i).getArea(); + sum += a; + if (a > maxA) { + maxA = a; + maxAt = i; + } + } + if (Math.abs(maxA - (sum - maxA)) <= Math.max(eps, 1.0e-8)) { + faces.remove(maxAt); + } + } + + private static List splitAll( + List> groups, List pool, + double scale, double eps) { + List out = new ArrayList(); + boolean miss = false; + for (int g = 0; g < groups.size() && !miss; g++) { + List strings = groups.get(g); + if (strings == null) { + miss = true; + } + else { + for (int i = 0; i < strings.size() && !miss; i++) { + List parts = split(strings.get(i), pool, eps); + if (parts == null) { + miss = true; + } + else { + out.addAll(parts); + } + } + } + } + return miss ? null : out; + } + + private static List split(CurveSegmentString s, + List pool, double eps) { + if (s == null) return null; + List cuts = new ArrayList(); + TwoNodeClip.Edge e = s.asEdge(); + for (int i = 0; i < pool.size(); i++) { + Coordinate p = pool.get(i); + if (onString(s, p, eps)) { + cuts.add(new Cut(e.param(p), p)); + } + } + if (cuts.size() < 2) { + List one = new ArrayList(1); + if (!s.isDegenerate()) { + one.add(s); + } + return one; + } + Collections.sort(cuts, Cut.BY_T); + List uniq = new ArrayList(); + for (int i = 0; i < cuts.size(); i++) { + Cut c = cuts.get(i); + if (uniq.isEmpty() + || uniq.get(uniq.size() - 1).pt.distance(c.pt) > eps) { + uniq.add(c); + } + } + List out = new ArrayList(); + for (int i = 0; i + 1 < uniq.size(); i++) { + Coordinate a = uniq.get(i).pt; + Coordinate b = uniq.get(i + 1).pt; + if (a.distance(b) > eps) { + out.add(sub(s, a, b)); + } + } + return out; + } + + private static CurveSegmentString sub(CurveSegmentString s, Coordinate from, + Coordinate to) { + if (!s.isArc()) { + return CurveSegmentString.segment(from, to); + } + Coordinate mid = TwoNodeClip.midOnSweep(from, to, s.asEdge()); + return CurveSegmentString.arc(from, mid, to); + } + + private static boolean onString(CurveSegmentString s, Coordinate p, + double eps) { + TwoNodeClip.Edge e = s.asEdge(); + if (s.isArc()) { + double d = Math.hypot(p.x - e.circle[0], p.y - e.circle[1]); + if (Math.abs(d - e.circle[2]) > eps) return false; + return TwoNodeClip.isOnSweep(p, e.circle, e.a, e.mid, e.b); + } + double t = TwoNodeClip.parameter(e.a, e.b, p); + Coordinate q = new Coordinate(e.a.x + t * (e.b.x - e.a.x), + e.a.y + t * (e.b.y - e.a.y)); + return p.distance(q) <= eps; + } + + private static List mergeCoincident( + List pieces, double scale, double eps) { + List out = new ArrayList(); + for (int i = 0; i < pieces.size(); i++) { + CurveSegmentString p = pieces.get(i); + if (!p.isDegenerate() && !containsPiece(out, p, scale, eps)) { + out.add(p); + } + } + return out; + } + + private static boolean containsPiece(List out, + CurveSegmentString p, double scale, double eps) { + boolean seen = false; + for (int i = 0; i < out.size() && !seen; i++) { + if (samePiece(out.get(i), p, scale, eps)) { + seen = true; + } + } + return seen; + } + + private static boolean samePiece(CurveSegmentString p, CurveSegmentString q, + double scale, double eps) { + boolean ends = (p.getStart().distance(q.getStart()) <= eps + && p.getEnd().distance(q.getEnd()) <= eps) + || (p.getStart().distance(q.getEnd()) <= eps + && p.getEnd().distance(q.getStart()) <= eps); + if (!ends) return false; + if (p.isArc() != q.isArc()) return false; + if (!p.isArc()) return true; + return CurveSegmentString.sameCircle(p, q, scale); + } + + private static List buildHalves(List pieces, + List pool, double eps) { + List halves = new ArrayList(); + boolean miss = false; + for (int i = 0; i < pieces.size() && !miss; i++) { + CurveSegmentString p = pieces.get(i); + Coordinate a = canon(p.getStart(), pool, eps); + Coordinate b = canon(p.getEnd(), pool, eps); + if (a.distance(b) <= eps) { + continue; + } + CurveSegmentString fwd = sub(p, a, b); + CurveSegmentString rev = reverse(fwd); + if (fwd.isDegenerate() || rev.isDegenerate()) { + miss = true; + } + else { + Half hf = new Half(a, b, fwd); + Half hr = new Half(b, a, rev); + hf.rev = hr; + hr.rev = hf; + halves.add(hf); + halves.add(hr); + } + } + return miss ? null : halves; + } + + private static CurveSegmentString reverse(CurveSegmentString s) { + if (!s.isArc()) { + return CurveSegmentString.segment(s.getEnd(), s.getStart()); + } + return CurveSegmentString.arc(s.getEnd(), s.getMid(), s.getStart()); + } + + private static List indexByStart(List halves, + List pool, double eps) { + List verts = new ArrayList(); + for (int i = 0; i < pool.size(); i++) { + verts.add(new Vertex(pool.get(i))); + } + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + Vertex v = vertexAt(verts, h.from, eps); + if (v == null) { + miss = true; + } + else { + v.out.add(h); + } + } + if (miss) return null; + for (int i = 0; i < verts.size(); i++) { + List out = verts.get(i).out; + Collections.sort(out, Half.BY_ANGLE); + for (int j = 0; j < out.size(); j++) { + out.get(j).originOut = out; + } + } + return verts; + } + + /** + * Two different pieces leaving at the same angle are a tangent. + * Ordering them is snap-rounding (P2.5.4). Stamp and stop. + */ + private static boolean hasCoincidentLeave(List verts) { + boolean hit = false; + for (int i = 0; i < verts.size() && !hit; i++) { + List out = verts.get(i).out; + for (int j = 0; j < out.size() && !hit; j++) { + Half a = out.get(j); + Half b = out.get((j + 1) % out.size()); + if (a != b && angleDiff(a.leaveAngle, b.leaveAngle) < ANGLE_EPS) { + hit = true; + } + } + } + return hit; + } + + private static List walkRing(Half start, double eps, + GeometryFactory f) { + List members = new ArrayList(); + Half cur = start; + int guard = 0; + boolean closed = false; + boolean miss = false; + while (guard++ < 256 && !closed && !miss) { + if (cur.used) { + miss = true; + } + else { + cur.used = true; + LineString ls = toLine(cur, f); + if (ls == null) { + miss = true; + } + else { + members.add(ls); + Half next = nextAfter(cur.rev); + if (next == null) { + miss = true; + } + else if (next == start) { + closed = true; + } + else { + cur = next; + } + } + } + } + if (miss || !closed || members.isEmpty()) return null; + Coordinate a = members.get(0).getCoordinateN(0); + LineString last = members.get(members.size() - 1); + Coordinate b = last.getCoordinateN(last.getNumPoints() - 1); + if (a.distance(b) > eps) return null; + return members; + } + + /** + * Left-most: the next half after {@code back} in CCW leave-angle + * order at {@code back.from}. + */ + private static Half nextAfter(Half back) { + // Re-find the vertex outgoing by scanning the reverse's neighbours + // is awkward; store the sorted list on the half at index time. + List out = back.originOut; + if (out == null || out.isEmpty()) return null; + int at = -1; + for (int i = 0; i < out.size(); i++) { + if (out.get(i) == back) { + at = i; + } + } + if (at < 0) return null; + return out.get((at + 1) % out.size()); + } + + private static LineString toLine(Half h, GeometryFactory f) { + CurveSegmentString s = h.piece; + if (!s.isArc()) { + return f.createLineString(new Coordinate[] { + new Coordinate(h.from), new Coordinate(h.to) + }); + } + return TwoNodeClip.arc(h.from, s.getMid(), h.to, f); + } + + private static double signedArea(List members) { + double signed = 0.0; + for (int i = 0; i < members.size(); i++) { + LineString m = members.get(i); + if (m instanceof CircularString) { + Coordinate[] pts = m.getCoordinates(); + for (int k = 0; k + 2 < pts.length; k += 2) { + signed += CircularArcDensifier.arcAreaContribution( + pts[k], pts[k + 1], pts[k + 2]); + } + } + else { + Coordinate[] pts = m.getCoordinates(); + for (int k = 0; k < pts.length - 1; k++) { + signed += 0.5 * (pts[k].x * pts[k + 1].y + - pts[k + 1].x * pts[k].y); + } + } + } + return signed; + } + + private static Geometry exactOverlay(Geometry a, Geometry b, int opCode) { + Geometry g = CircularDiscOverlay.overlay(a, b, opCode); + if (g != null) return g; + g = CircularDiscPolygonOverlay.overlay(a, b, opCode); + if (g != null) return g; + return CompoundCurveShellOverlay.overlay(a, b, opCode); + } + + private static boolean addPoly(List dest, Geometry g) { + if (g == null) return false; + if (g.isEmpty()) return true; + if (g instanceof Polygon) { + dest.add((Polygon) g); + return true; + } + boolean ok = true; + for (int i = 0; i < g.getNumGeometries() && ok; i++) { + if (!addPoly(dest, g.getGeometryN(i))) { + ok = false; + } + } + return ok; + } + + private static Geometry toGeometry(List faces, GeometryFactory f) { + if (faces == null || faces.isEmpty()) return null; + if (faces.size() == 1) return faces.get(0); + return new MultiSurface(faces.toArray(new Polygon[0]), f); + } + + private static void addEdgeEnds(List pool, + List> groups, double scale, double eps) { + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size(); k++) { + CurveSegmentString e = edges.get(k); + canon(e.getStart(), pool, eps); + canon(e.getEnd(), pool, eps); + } + } + } + } + } + + private static void addStringEnds(List pool, + List> groups, double eps) { + for (int g = 0; g < groups.size(); g++) { + List strings = groups.get(g); + if (strings != null) { + for (int i = 0; i < strings.size(); i++) { + CurveSegmentString s = strings.get(i); + canon(s.getStart(), pool, eps); + canon(s.getEnd(), pool, eps); + } + } + } + } + + private static void addCanon(List pool, Coordinate[] xs, + double eps) { + if (xs == null) return; + for (int i = 0; i < xs.length; i++) { + canon(xs[i], pool, eps); + } + } + + private static Coordinate canon(Coordinate p, List pool, + double eps) { + Coordinate found = null; + for (int i = 0; i < pool.size() && found == null; i++) { + if (pool.get(i).distance(p) <= eps) { + found = pool.get(i); + } + } + if (found != null) return found; + Coordinate n = new Coordinate(p); + pool.add(n); + return n; + } + + private static Vertex vertexAt(List verts, Coordinate p, double eps) { + Vertex found = null; + for (int i = 0; i < verts.size() && found == null; i++) { + if (verts.get(i).pt.distance(p) <= eps) { + found = verts.get(i); + } + } + return found; + } + + private static double leaveAngle(CurveSegmentString s) { + Coordinate from = s.getStart(); + if (!s.isArc()) { + return Math.atan2(s.getEnd().y - from.y, s.getEnd().x - from.x); + } + TwoNodeClip.Edge e = s.asEdge(); + double rx = from.x - e.circle[0]; + double ry = from.y - e.circle[1]; + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + return ccw ? Math.atan2(rx, -ry) : Math.atan2(-rx, ry); + } + + private static double angleDiff(double a, double b) { + double d = Math.abs(a - b); + if (d > Math.PI) { + d = TwoNodeClip.TWO_PI - d; + } + return d; + } + + private static boolean hasHole(Geometry g) { + Geometry geom = unwrap(g); + if (geom instanceof CurvePolygon) { + return ((CurvePolygon) geom).getNumInteriorRing() > 0; + } + if (geom instanceof Polygon) { + return ((Polygon) geom).getNumInteriorRing() > 0; + } + return false; + } + + private static Geometry unwrap(Geometry g) { + if (g == null || g.isEmpty()) return null; + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) return null; + return unwrap(g.getGeometryN(0)); + } + return g; + } + + private static double scaleOf(Geometry[] geoms) { + double s = 1.0; + if (geoms == null) return s; + for (int i = 0; i < geoms.length; i++) { + if (geoms[i] == null || geoms[i].isEmpty()) { + continue; + } + double w = Math.max(geoms[i].getEnvelopeInternal().getWidth(), + geoms[i].getEnvelopeInternal().getHeight()); + if (w > s) { + s = w; + } + } + return s; + } + + private static final class Cut { + static final Comparator BY_T = new Comparator() { + public int compare(Cut a, Cut b) { + return Double.compare(a.t, b.t); + } + }; + final double t; + final Coordinate pt; + Cut(double t, Coordinate pt) { + this.t = t; + this.pt = pt; + } + } + + private static final class Half { + static final Comparator BY_ANGLE = new Comparator() { + public int compare(Half a, Half b) { + return Double.compare(a.leaveAngle, b.leaveAngle); + } + }; + final Coordinate from; + final Coordinate to; + final CurveSegmentString piece; + final double leaveAngle; + Half rev; + List originOut; + boolean used; + Half(Coordinate from, Coordinate to, CurveSegmentString piece) { + this.from = from; + this.to = to; + this.piece = piece; + this.leaveAngle = leaveAngle(piece); + } + } + + private static final class Vertex { + final Coordinate pt; + final List out = new ArrayList(); + Vertex(Coordinate pt) { + this.pt = pt; + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java new file mode 100644 index 0000000000..6832fe472d --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; + +/** + * Nodes and shared edges. Intersects {@link CurveSegmentString}s + * and returns the discrete node set the kits already compute, or + * {@code null}. A shared run is an {@linkplain #edges edge} + * (interval), not a pair of endpoints. N strings (or N geometries) + * are the unique union of each unordered pair; N=2 is that pair. + * Package-private -- not a core {@code Noder}, not N-SS, not a + * public API, not a face walker. + *

    + * MIXED (collinear overlap) is not a discrete node set; {@link #nodes} + * stays {@code null} and {@link #edges} names the interval. A MIXED + * or pinch pair inside an N-set adds no point. A tangent pinch + * (TOUCH-ext, H-ANNULUS-TANGENT) is a zero-length edge, not a face. + * Overlay still goes through the existing kits. Face assemble of + * the N-set is {@link CurveSegmentFaces}. Densify is never a noder. + */ +final class CurveSegmentNoder { + + private CurveSegmentNoder() { } + + /** + * Discrete nodes of a circular pair, or {@code null}. Holes, + * collinear overlap, pinch, and pairs the strings cannot name + * stay {@code null}. The shared run lives on {@link #edges}. + */ + static Coordinate[] nodes(Geometry a, Geometry b) { + if (hasHole(a) || hasHole(b)) return null; + List sa = CurveSegmentString.of(a); + List sb = CurveSegmentString.of(b); + if (sa == null || sb == null) return null; + double scale = scaleOf(a, b); + return nodes(sa, sb, scale); + } + + /** + * Discrete nodes of two string collections. {@code null} is MIXED + * (or fewer than two distinct hits). + */ + static Coordinate[] nodes(List a, + List b, double scale) { + if (a == null || b == null || a.isEmpty() || b.isEmpty()) { + return null; + } + List hits = new ArrayList(); + boolean miss = false; + for (int i = 0; i < a.size() && !miss; i++) { + for (int j = 0; j < b.size() && !miss; j++) { + Coordinate[] xs = CurveSegmentString.intersect(a.get(i), b.get(j), + scale); + if (xs == null) { + miss = true; + } + else { + addUnique(hits, xs, scale); + } + } + } + if (miss) return null; + if (hits.size() < 2) return null; + if (hits.size() == 2 + && hits.get(0).distance(hits.get(1)) + < TwoNodeClip.PROPER_CROSS_FRAC * Math.max(scale, 1.0)) { + return null; + } + return hits.toArray(new Coordinate[0]); + } + + /** + * Discrete nodes of N geometries: the unique union of each + * unordered pair's {@link #nodes(Geometry, Geometry)}. N=2 is + * that pair, bit-identical. A MIXED, pinch, or holed pair stays + * {@code null} and adds no point. Fewer than two operands, or + * every pair a miss, is {@code null}. Does not assemble a face. + */ + static Coordinate[] nodes(Geometry[] geoms) { + if (geoms == null || geoms.length < 2) return null; + if (geoms.length == 2) { + return nodes(geoms[0], geoms[1]); + } + List hits = new ArrayList(); + boolean any = false; + double scale = scaleOf(geoms); + for (int i = 0; i < geoms.length; i++) { + for (int j = i + 1; j < geoms.length; j++) { + Coordinate[] xs = nodes(geoms[i], geoms[j]); + if (xs != null) { + any = true; + addUnique(hits, xs, scale); + } + } + } + if (!any) return null; + return hits.toArray(new Coordinate[0]); + } + + /** + * Discrete nodes of N string collections: the unique union of + * each unordered pair's {@link #nodes(List, List, double)}. + * N=2 is that pair, bit-identical. A MIXED pair stays + * {@code null} and adds no point. + */ + static Coordinate[] nodes(List> groups, + double scale) { + if (groups == null || groups.size() < 2) return null; + if (groups.size() == 2) { + return nodes(groups.get(0), groups.get(1), scale); + } + List hits = new ArrayList(); + boolean any = false; + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + Coordinate[] xs = nodes(groups.get(i), groups.get(j), scale); + if (xs != null) { + any = true; + addUnique(hits, xs, scale); + } + } + } + if (!any) return null; + return hits.toArray(new Coordinate[0]); + } + + /** + * Discrete nodes of N strings. Each string is its own operand; + * the pair noder runs on each unordered pair of singletons. + */ + static Coordinate[] nodes(CurveSegmentString[] strings, double scale) { + if (strings == null || strings.length < 2) return null; + List> groups = + new ArrayList>(strings.length); + for (int i = 0; i < strings.length; i++) { + List one = new ArrayList(1); + one.add(strings[i]); + groups.add(one); + } + return nodes(groups, scale); + } + + /** + * Shared runs of a circular pair. Empty is no interval and no + * pinch. {@code null} is a holed pair (the hole ring is walked + * as strings by {@link BiteVsHole} / {@link TwoHoleOverlay}, + * not here) or a pair the strings cannot name. Does not + * assemble a face. + */ + static List edges(Geometry a, Geometry b) { + if (hasHole(a) || hasHole(b)) return null; + List sa = CurveSegmentString.of(a); + List sb = CurveSegmentString.of(b); + if (sa == null || sb == null) return null; + return edges(sa, sb, scaleOf(a, b)); + } + + /** + * Shared runs of two string collections. Empty is no shared edge. + */ + static List edges(List a, + List b, double scale) { + List out = new ArrayList(); + if (a == null || b == null || a.isEmpty() || b.isEmpty()) { + return out; + } + for (int i = 0; i < a.size(); i++) { + for (int j = 0; j < b.size(); j++) { + List shared = CurveSegmentString.shared( + a.get(i), b.get(j), scale); + for (int k = 0; k < shared.size(); k++) { + addUniqueEdge(out, shared.get(k), scale); + } + } + } + return out; + } + + private static void addUnique(List hits, Coordinate[] xs, + double scale) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + for (int k = 0; k < xs.length; k++) { + boolean seen = false; + for (int i = 0; i < hits.size() && !seen; i++) { + if (hits.get(i).distance(xs[k]) <= eps) { + seen = true; + } + } + if (!seen) { + hits.add(xs[k]); + } + } + } + + private static void addUniqueEdge(List out, + CurveSegmentString e, double scale) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + boolean seen = false; + for (int i = 0; i < out.size() && !seen; i++) { + if (sameEdge(out.get(i), e, eps, scale)) { + seen = true; + } + } + if (!seen) { + out.add(e); + } + } + + private static boolean sameEdge(CurveSegmentString p, CurveSegmentString q, + double eps, double scale) { + if (p.isArc() != q.isArc()) return false; + boolean ends = (p.getStart().distance(q.getStart()) <= eps + && p.getEnd().distance(q.getEnd()) <= eps) + || (p.getStart().distance(q.getEnd()) <= eps + && p.getEnd().distance(q.getStart()) <= eps); + if (!ends) return false; + if (!p.isArc()) return true; + return CurveSegmentString.sameCircle(p, q, scale); + } + + private static boolean hasHole(Geometry g) { + Geometry geom = unwrap(g); + if (geom instanceof CurvePolygon) { + return ((CurvePolygon) geom).getNumInteriorRing() > 0; + } + if (geom instanceof Polygon) { + return ((Polygon) geom).getNumInteriorRing() > 0; + } + return false; + } + + private static Geometry unwrap(Geometry g) { + if (g == null || g.isEmpty()) return null; + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) return null; + return unwrap(g.getGeometryN(0)); + } + return g; + } + + private static double scaleOf(Geometry a, Geometry b) { + double wa = Math.max(a.getEnvelopeInternal().getWidth(), + a.getEnvelopeInternal().getHeight()); + double wb = Math.max(b.getEnvelopeInternal().getWidth(), + b.getEnvelopeInternal().getHeight()); + return Math.max(Math.max(wa, wb), 1.0); + } + + private static double scaleOf(Geometry[] geoms) { + double s = 1.0; + if (geoms == null) return s; + for (int i = 0; i < geoms.length; i++) { + if (geoms[i] == null || geoms[i].isEmpty()) { + continue; + } + double w = Math.max(geoms[i].getEnvelopeInternal().getWidth(), + geoms[i].getEnvelopeInternal().getHeight()); + if (w > s) { + s = w; + } + } + return s; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java new file mode 100644 index 0000000000..fb03aecb53 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.LineIntersector; +import org.locationtech.jts.algorithm.RobustLineIntersector; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularArcDensifier; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiCurve; +import org.locationtech.jts.geom.curve.MultiSurface; + +/** + * One curve piece: a straight chord or a circular arc. Package-private + * -- not a core {@code SegmentString}, not N-SS, not a public API. + * Lives next to {@link TwoNodeClip} so circle–circle / sweep / line–circle + * stay the existing atoms. A noder that densifies is a lie; this string + * carries the control triple or the chord the kits already use. + *

    + * A shared run is an {@linkplain #overlap edge} (a same-circle sweep + * interval, or a collinear segment). {@link #intersect} stays a + * discrete node set: collinear overlap is still {@code null} there + * (MIXED is not a pair of endpoints). A pinch / kiss that is not an + * interval is a zero-length degenerate edge, or {@code null}. + */ +final class CurveSegmentString { + + private final Coordinate a; + private final Coordinate mid; + private final Coordinate b; + private final boolean isArc; + private final double[] circle; + + private CurveSegmentString(Coordinate a, Coordinate mid, Coordinate b, + boolean isArc, double[] circle) { + this.a = a; + this.mid = mid; + this.b = b; + this.isArc = isArc; + this.circle = circle; + } + + static CurveSegmentString segment(Coordinate a, Coordinate b) { + return new CurveSegmentString(a, null, b, false, null); + } + + /** + * Circular arc through three controls, or a chord when the triple + * is colinear ({@link CircularArcDensifier#circumcircle} is null). + */ + static CurveSegmentString arc(Coordinate a, Coordinate mid, Coordinate b) { + double[] c = CircularArcDensifier.circumcircle(a, mid, b); + if (c == null) { + return segment(a, b); + } + return new CurveSegmentString(a, mid, b, true, c); + } + + static CurveSegmentString of(TwoNodeClip.Edge e) { + return new CurveSegmentString(e.a, e.mid, e.b, e.isArc, e.circle); + } + + boolean isArc() { + return isArc; + } + + Coordinate getStart() { + return a; + } + + Coordinate getEnd() { + return b; + } + + Coordinate getMid() { + return mid; + } + + TwoNodeClip.Edge asEdge() { + return new TwoNodeClip.Edge(a, mid, b, isArc, circle); + } + + /** + * Zero-length pinch / kiss. A point is not an interval; the noder + * may still name it as a degenerate edge. A full-circle arc + * ({@code a == b} with a mid on the opposite side) is not this. + * Not a face. + */ + boolean isDegenerate() { + if (a.distance(b) > 1.0e-12) return false; + if (!isArc) return true; + return mid == null || mid.distance(a) <= 1.0e-12; + } + + /** + * Chord length, or r × sweep. A degenerate edge is 0. + */ + double length() { + if (!isArc) return a.distance(b); + double a0 = Math.atan2(a.y - circle[1], a.x - circle[0]); + double aM = Math.atan2(mid.y - circle[1], mid.x - circle[0]); + double a1 = Math.atan2(b.y - circle[1], b.x - circle[0]); + boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + double sweep = ccw ? TwoNodeClip.normPos(a1 - a0) + : TwoNodeClip.normPos(a0 - a1); + if (sweep == 0.0) sweep = TwoNodeClip.TWO_PI; + return circle[2] * sweep; + } + + /** + * Exterior pieces of a hole-free circular / compound / plain ring, + * or the pieces of a lineal CircularString / CompoundCurve / + * LineString (R-LL). A holed CurvePolygon stays {@code null}; + * pass the hole ring itself as a LineString. Two holes are P2.4. + */ + static List of(Geometry g) { + Geometry geom = unwrap(g); + if (geom == null) return null; + if (geom instanceof CurvePolygon) { + CurvePolygon cp = (CurvePolygon) geom; + if (cp.isEmpty() || cp.getNumInteriorRing() > 0) return null; + LineString ring = cp.getExteriorCurve(); + if (ring instanceof CompoundCurve) { + List edges = TwoNodeClip.flatten(cp); + return edges == null ? null : ofEdges(edges); + } + if (ring instanceof CircularString) { + return ofCircular((CircularString) ring); + } + return null; + } + if (TwoNodeClip.isPlainPolygon(geom)) { + return ofPlain(((Polygon) geom).getExteriorRing().getCoordinates()); + } + if (geom instanceof LineString) { + List edges = CircularLineOverlay.flattenLineal( + (LineString) geom); + return edges == null ? null : ofEdges(edges); + } + return null; + } + + /** + * Shared run of two strings: a same-circle sweep interval, a + * collinear segment, or a different-circle tangent pinch (zero + * length). {@code null} is no interval and no pinch this rung + * will name. Does not assemble a face. + */ + static CurveSegmentString overlap(CurveSegmentString p, + CurveSegmentString q, double scale) { + List s = shared(p, q, scale); + if (s.isEmpty()) return null; + return s.get(0); + } + + /** + * All shared runs of a pair. Empty is no interval / no pinch. + */ + static List shared(CurveSegmentString p, + CurveSegmentString q, double scale) { + List out = new ArrayList(); + if (p == null || q == null) return out; + if (p.isArc && q.isArc) { + if (sameCircle(p, q, scale)) { + List sweeps = CircleSweepOverlay.sharedSweep( + p.asEdge(), q.asEdge(), scale); + if (sweeps != null) { + for (int i = 0; i < sweeps.size(); i++) { + out.add(of(sweeps.get(i))); + } + } + return out; + } + CurveSegmentString pinch = tangentPinch(p, q); + if (pinch != null) { + out.add(pinch); + } + return out; + } + if (!p.isArc && !q.isArc) { + CurveSegmentString seg = overlapSegments(p, q); + if (seg != null) { + out.add(seg); + } + } + return out; + } + + /** + * Discrete hits of this string and {@code other}. {@code null} is + * collinear overlap (MIXED) -- an interval, not a node set. + * Same-circle arcs add no node here. Empty is a miss-free pair + * with no point. + */ + static Coordinate[] intersect(CurveSegmentString p, CurveSegmentString q, + double scale) { + if (p == null || q == null) return null; + if (p.isArc && q.isArc) { + return intersectArcs(p, q, scale); + } + if (p.isArc) { + return intersectSegCircle(q.a, q.b, p); + } + if (q.isArc) { + return intersectSegCircle(p.a, p.b, q); + } + return intersectSegments(p, q); + } + + private static Coordinate[] intersectArcs(CurveSegmentString p, + CurveSegmentString q, double scale) { + if (sameCircle(p, q, scale)) { + return new Coordinate[0]; + } + Coordinate[] xs = TwoNodeClip.intersectCircles( + p.circle[0], p.circle[1], p.circle[2], + q.circle[0], q.circle[1], q.circle[2]); + List hits = new ArrayList(); + for (int k = 0; k < xs.length; k++) { + if (TwoNodeClip.isOnSweep(xs[k], p.circle, p.a, p.mid, p.b) + && TwoNodeClip.isOnSweep(xs[k], q.circle, q.a, q.mid, q.b)) { + hits.add(xs[k]); + } + } + return toArray(hits); + } + + private static Coordinate[] intersectSegCircle(Coordinate s0, Coordinate s1, + CurveSegmentString sweep) { + Coordinate[] xs = TwoNodeClip.intersectSegmentCircle( + sweep.circle[0], sweep.circle[1], sweep.circle[2], s0, s1); + List hits = new ArrayList(); + for (int k = 0; k < xs.length; k++) { + if (TwoNodeClip.isOnSweep(xs[k], sweep.circle, sweep.a, sweep.mid, + sweep.b)) { + hits.add(xs[k]); + } + } + return toArray(hits); + } + + private static Coordinate[] intersectSegments(CurveSegmentString p, + CurveSegmentString q) { + LineIntersector li = new RobustLineIntersector(); + li.computeIntersection(p.a, p.b, q.a, q.b); + if (li.getIntersectionNum() == LineIntersector.COLLINEAR_INTERSECTION) { + return null; + } + if (li.getIntersectionNum() == 1) { + return new Coordinate[] { li.getIntersection(0) }; + } + return new Coordinate[0]; + } + + /** + * Collinear overlap as a segment. A kiss (zero length) is not an + * interval -- {@code null}, not a pair of endpoints. + */ + private static CurveSegmentString overlapSegments(CurveSegmentString p, + CurveSegmentString q) { + LineIntersector li = new RobustLineIntersector(); + li.computeIntersection(p.a, p.b, q.a, q.b); + if (li.getIntersectionNum() != LineIntersector.COLLINEAR_INTERSECTION) { + return null; + } + Coordinate u = li.getIntersection(0); + Coordinate v = li.getIntersection(1); + if (u.distance(v) <= 1.0e-12) return null; + return segment(u, v); + } + + /** + * Different-circle tangent: {@code intersectCircles} returns one + * point, and it lies on both sweeps. Named as a zero-length edge. + * Covers TOUCH-ext ({@code d = r1+r2}) and H-ANNULUS-TANGENT + * ({@code d+r = R}). Two crossings with only one on-sweep hit + * stay a node, not this. Not a face. + */ + private static CurveSegmentString tangentPinch(CurveSegmentString p, + CurveSegmentString q) { + Coordinate[] xs = TwoNodeClip.intersectCircles( + p.circle[0], p.circle[1], p.circle[2], + q.circle[0], q.circle[1], q.circle[2]); + if (xs.length != 1) return null; + if (!TwoNodeClip.isOnSweep(xs[0], p.circle, p.a, p.mid, p.b)) { + return null; + } + if (!TwoNodeClip.isOnSweep(xs[0], q.circle, q.a, q.mid, q.b)) { + return null; + } + return segment(xs[0], xs[0]); + } + + static boolean sameCircle(CurveSegmentString p, CurveSegmentString q, + double scale) { + if (!p.isArc || !q.isArc) return false; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate cp = new Coordinate(p.circle[0], p.circle[1]); + Coordinate cq = new Coordinate(q.circle[0], q.circle[1]); + return cp.distance(cq) <= eps + && Math.abs(p.circle[2] - q.circle[2]) <= eps; + } + + private static List ofEdges(List edges) { + List out = new ArrayList(); + for (int i = 0; i < edges.size(); i++) { + out.add(of(edges.get(i))); + } + return out; + } + + private static List ofCircular(CircularString cs) { + Coordinate[] pts = cs.getCoordinates(); + if (pts.length < 3) return null; + List out = new ArrayList(); + for (int k = 0; k + 2 < pts.length; k += 2) { + out.add(arc(pts[k], pts[k + 1], pts[k + 2])); + } + return out.isEmpty() ? null : out; + } + + private static List ofPlain(Coordinate[] ring) { + if (ring == null || ring.length < 4) return null; + List out = new ArrayList(); + int n = ring.length - 1; + for (int i = 0; i < n; i++) { + out.add(segment(ring[i], ring[i + 1])); + } + return out; + } + + private static Geometry unwrap(Geometry g) { + if (g == null || g.isEmpty()) return null; + if (g instanceof MultiSurface || g instanceof MultiCurve + || g instanceof MultiLineString) { + if (g.getNumGeometries() != 1) return null; + return unwrap(g.getGeometryN(0)); + } + return g; + } + + private static Coordinate[] toArray(List hits) { + return hits.toArray(new Coordinate[0]); + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircle.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircle.java new file mode 100644 index 0000000000..1779680d82 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircle.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.noding.NodedSegmentString; + +/** + * OverlayNG-for-circles first slice: convert a leftover circular + * pair onto core {@link org.locationtech.jts.noding.CircularNodedSegmentString}, + * node through OverlayNG's {@link org.locationtech.jts.noding.Noder} + * entry, and assemble a named exact area. + *

    + * First cell is H-SHELL-N-MIXED (collinear diameter overlap). The + * P2.1–P2.5.4 kits stay refused; this rung is the OverlayNG path, + * not a kit rewrite. A proper crossing, a 0-node nest + * ({@code CC-NEST-ANNULUS}), or the P2.5.4 tangent stamp stay + * {@code null}. Package-private -- not a public API. + */ +final class OverlayNGCircle { + + private OverlayNGCircle() { } + + /** + * Exact overlay via OverlayNG noding, or {@code null} if this + * slice cannot answer. + */ + static Geometry overlay(Geometry a, Geometry b, int opCode) { + CurvePolygon ca = mixedCompoundShell(a); + CurvePolygon cb = mixedCompoundShell(b); + if (ca == null || cb == null) { + return null; + } + List sa = CurveSegmentString.of(ca); + List sb = CurveSegmentString.of(cb); + if (sa == null || sb == null) { + return null; + } + double scale = scaleOf(ca, cb); + List edges = new ArrayList(); + addPieces(edges, sa, 0); + addPieces(edges, sb, 1); + + OverlayNGCircleNoder noder = new OverlayNGCircleNoder(scale); + noder.computeNodes(edges); + noder.getNodedSubstrings(); + if (!noder.hasMixedOverlap() || noder.hasProperCrossing()) { + return null; + } + return TwoShellClip.containmentOverlay(ca, cb, opCode, a); + } + + /** + * Hole-free {@link CurvePolygon} whose shell is a mixed + * {@link CompoundCurve} (LineString + CircularString). The same + * shape gate as R1.7, so a line-only shell and a full disc skip + * this path without paying the noder. + */ + private static CurvePolygon mixedCompoundShell(Geometry g) { + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) { + return null; + } + g = g.getGeometryN(0); + } + if (!(g instanceof CurvePolygon)) { + return null; + } + CurvePolygon cp = (CurvePolygon) g; + if (cp.isEmpty() || cp.getNumInteriorRing() > 0) { + return null; + } + LineString ring = cp.getExteriorCurve(); + if (!(ring instanceof CompoundCurve) || !ring.isClosed()) { + return null; + } + CompoundCurve cc = (CompoundCurve) ring; + boolean hasArc = false; + boolean hasLine = false; + for (int i = 0; i < cc.getNumMembers(); i++) { + LineString m = cc.getMemberN(i); + if (m instanceof CircularString) { + hasArc = true; + } + else { + hasLine = true; + } + } + if (!hasArc || !hasLine) { + return null; + } + return cp; + } + + private static void addPieces(List edges, + List pieces, int geomIndex) { + Integer data = Integer.valueOf(geomIndex); + for (int i = 0; i < pieces.size(); i++) { + edges.add(OverlayNGCircleNoder.toCore(pieces.get(i), data)); + } + } + + private static double scaleOf(Geometry a, Geometry b) { + double wa = Math.max(a.getEnvelopeInternal().getWidth(), + a.getEnvelopeInternal().getHeight()); + double wb = Math.max(b.getEnvelopeInternal().getWidth(), + b.getEnvelopeInternal().getHeight()); + return Math.max(Math.max(wa, wb), 1.0); + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleNoder.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleNoder.java new file mode 100644 index 0000000000..20452440d1 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleNoder.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.noding.CircularNodedSegmentString; +import org.locationtech.jts.noding.NodedSegmentString; +import org.locationtech.jts.noding.Noder; +import org.locationtech.jts.noding.SegmentKind; +import org.locationtech.jts.noding.SegmentString; + +/** + * OverlayNG noder for exact {@link CircularNodedSegmentString}s. + * Asks the Option B contract ({@link SegmentKind}) rather than + * treating ends as a chord. Lives in jts-curve so arc–arc / + * arc–line closed forms stay {@link CurveSegmentString} atoms -- + * core does not depend on this module, and the formulas are not + * copied. + *

    + * Implements {@link Noder} so {@code EdgeNodingBuilder} can run it. + * MIXED (collinear overlap) inserts the overlap ends; a proper + * crossing is a hit that is not a segment endpoint. Package-private + * -- not a public circular noder API. + */ +final class OverlayNGCircleNoder implements Noder { + + private final double scale; + private Collection segStrings; + private boolean mixedOverlap; + private boolean properCrossing; + + OverlayNGCircleNoder(double scale) { + this.scale = scale; + } + + boolean hasMixedOverlap() { + return mixedOverlap; + } + + boolean hasProperCrossing() { + return properCrossing; + } + + @Override + public void computeNodes(Collection segStrings) { + this.segStrings = cast(segStrings); + mixedOverlap = false; + properCrossing = false; + List list = + new ArrayList(this.segStrings); + for (int i = 0; i < list.size(); i++) { + for (int j = i; j < list.size(); j++) { + nodePair(list.get(i), list.get(j), i == j); + } + } + } + + @Override + public Collection getNodedSubstrings() { + return NodedSegmentString.getNodedSubstrings(segStrings); + } + + private void nodePair(NodedSegmentString e0, NodedSegmentString e1, + boolean same) { + int n0 = e0.size() - 1; + int n1 = e1.size() - 1; + for (int i = 0; i < n0; i++) { + for (int j = 0; j < n1; j++) { + if (same && skipSame(e0, i, j)) { + continue; + } + nodeSegments(e0, i, e1, j); + } + } + } + + private static boolean skipSame(NodedSegmentString e, int i, int j) { + if (i == j) { + return true; + } + if (Math.abs(i - j) == 1) { + return true; + } + if (e.isClosed()) { + int max = e.size() - 2; + if ((i == 0 && j == max) || (j == 0 && i == max)) { + return true; + } + } + return false; + } + + private void nodeSegments(NodedSegmentString e0, int i, + NodedSegmentString e1, int j) { + CurveSegmentString p = piece(e0, i); + CurveSegmentString q = piece(e1, j); + Coordinate[] xs = CurveSegmentString.intersect(p, q, scale); + if (xs == null) { + CurveSegmentString run = CurveSegmentString.overlap(p, q, scale); + if (run == null || run.isDegenerate()) { + return; + } + mixedOverlap = true; + addNode(e0, i, run.getStart()); + addNode(e0, i, run.getEnd()); + addNode(e1, j, run.getStart()); + addNode(e1, j, run.getEnd()); + return; + } + for (int k = 0; k < xs.length; k++) { + addNode(e0, i, xs[k]); + addNode(e1, j, xs[k]); + if (!isEnd(e0, i, xs[k]) && !isEnd(e1, j, xs[k])) { + properCrossing = true; + } + } + } + + private static void addNode(NodedSegmentString ss, int segIndex, + Coordinate p) { + ss.addIntersection(p, segIndex); + } + + private static boolean isEnd(NodedSegmentString ss, int segIndex, + Coordinate p) { + return p.equals2D(ss.getCoordinate(segIndex)) + || p.equals2D(ss.getCoordinate(segIndex + 1)); + } + + static CurveSegmentString piece(SegmentString ss, int segIndex) { + Coordinate a = ss.getCoordinate(segIndex); + Coordinate b = ss.getCoordinate(segIndex + 1); + if (ss.getSegmentKind(segIndex) == SegmentKind.ARC) { + return CurveSegmentString.arc(a, ss.getArcMidpoint(segIndex), b); + } + return CurveSegmentString.segment(a, b); + } + + static NodedSegmentString toCore(CurveSegmentString s, Object data) { + if (s.isArc()) { + return CircularNodedSegmentString.arc(s.getStart(), s.getMid(), + s.getEnd(), data); + } + return CircularNodedSegmentString.certified(s.getStart(), s.getEnd(), + data); + } + + @SuppressWarnings("unchecked") + private static Collection cast(Collection raw) { + return raw; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java index f8aaf05bd5..48be7ef1d8 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java @@ -279,6 +279,9 @@ public Geometry getResult(int opCode) { Geometry arcClip = CircularArcOverlay.overlay(a, b, opCode); // R-AA if (arcClip != null) return arcClip; + Geometry ovCircles = OverlayNGCircle.overlay(a, b, opCode); // R-OV Option B + if (ovCircles != null) return ovCircles; + // R2. The chord baseline: densify at the ops tolerance and run core. // Approximate only if something was actually densified: for operands with // no arc, linearise returns them unchanged and core's answer is exact, so diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoHoleOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoHoleOverlay.java new file mode 100644 index 0000000000..60713fb60c --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoHoleOverlay.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +/** + * Two-hole arrangement on a shared CompoundCurve outer + * ({@code H-SHELL-HOLE-X}). The noder names the two hole–hole + * nodes; this rung walks each ring in and out of the other and + * assembles the four hole faces. Overlay then punches the shared + * shell (CAP / CUP) or returns the hole difference (SUB / XOR). + *

    + * Same-outer non-crossing holes stay on {@link SameOuterHoleOverlay}. + * A straddling hole is {@link BiteVsHole}. Four-or-more nodes, + * collinear overlap, or a pair that would need snap-rounding stay + * {@code null}. Not a noder, not N-SS, not a general arrangement. + */ +final class TwoHoleOverlay { + + static final int CROSS = 1; + static final int MISS = 0; + + private TwoHoleOverlay() { } + + /** + * Exact overlay, or {@code null} if the two holes do not form a + * certified two-node crossing on the same outer. + */ + static Geometry overlay(Geometry a, Geometry b, int opCode) { + Walk walk = walk(a, b); + if (walk == null) return null; + GeometryFactory f = TwoNodeClip.curveFactory(a); + if (opCode == OverlayNG.INTERSECTION) { + return punch(walk.outer, walk.holeCup, f); + } + if (opCode == OverlayNG.UNION) { + return punch(walk.outer, walk.holeCap, f); + } + if (opCode == OverlayNG.DIFFERENCE) { + return walk.holeBA; + } + if (opCode == OverlayNG.SYMDIFFERENCE) { + return join(walk.holeAB, walk.holeBA, f); + } + return null; + } + + /** + * {@link #CROSS} when the two hole rings meet at two proper nodes + * and each has an in-walk and an out-walk. {@link #MISS} otherwise. + */ + static int decide(Geometry a, Geometry b) { + Walk walk = walk(a, b); + return walk == null ? MISS : CROSS; + } + + /** + * The two hole–hole nodes, or {@code null}. Not a face. + */ + static Coordinate[] clipNodes(Geometry a, Geometry b) { + Walk walk = walk(a, b); + if (walk == null) return null; + return new Coordinate[] { new Coordinate(walk.p), new Coordinate(walk.q) }; + } + + private static Walk walk(Geometry a, Geometry b) { + CurvePolygon ca = SameOuterHoleOverlay.mixedShell(a); + CurvePolygon cb = SameOuterHoleOverlay.mixedShell(b); + if (ca == null || cb == null) return null; + if (ca.getNumInteriorRing() != 1 || cb.getNumInteriorRing() != 1) { + return null; + } + if (!ca.getExteriorCurve().equalsExact(cb.getExteriorCurve())) { + return null; + } + LineString holeA = SameOuterHoleOverlay.plainHole(ca); + LineString holeB = SameOuterHoleOverlay.plainHole(cb); + if (holeA == null || holeB == null) return null; + if (BiteVsHole.decide(a, b) != BiteVsHole.MISS) return null; + + GeometryFactory f = TwoNodeClip.curveFactory(a); + CurvePolygon outer = new CurvePolygon(ca.getExteriorCurve(), null, f); + if (!SameOuterHoleOverlay.holeInsideShell(ca, outer)) return null; + if (!SameOuterHoleOverlay.holeInsideShell(cb, outer)) return null; + + List sa = CurveSegmentString.of(holeA); + List sb = CurveSegmentString.of(holeB); + if (sa == null || sb == null) return null; + double scale = scaleOf(ca, cb); + Coordinate[] named = CurveSegmentNoder.nodes(sa, sb, scale); + // Two proper nodes. MIXED / pinch / 4+ is a different cell. + if (named == null || named.length != 2) return null; + if (named[0].distance(named[1]) + < TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + List shared = CurveSegmentNoder.edges(sa, sb, scale); + if (shared == null || !shared.isEmpty()) return null; + + Coordinate[] ringA = holeA.getCoordinates(); + Coordinate[] ringB = holeB.getCoordinates(); + List aPQ = TwoNodeClip.walkRing(ringA, named[0], named[1]); + List aQP = TwoNodeClip.walkRing(ringA, named[1], named[0]); + List bPQ = TwoNodeClip.walkRing(ringB, named[0], named[1]); + List bQP = TwoNodeClip.walkRing(ringB, named[1], named[0]); + if (aPQ == null || aQP == null || bPQ == null || bQP == null) { + return null; + } + + Polygon polyA = asPolygon(holeA, f); + Polygon polyB = asPolygon(holeB, f); + if (polyA == null || polyB == null) return null; + List aIn = inWalk(aPQ, aQP, polyB); + List aOut = outWalk(aPQ, aQP, polyB); + List bIn = inWalk(bPQ, bQP, polyA); + List bOut = outWalk(bPQ, bQP, polyA); + if (aIn == null || aOut == null || bIn == null || bOut == null) { + return null; + } + + Coordinate p = aIn.get(0); + Coordinate q = aIn.get(aIn.size() - 1); + List aInLs = TwoNodeClip.listOf(TwoNodeClip.asLine(aIn, f)); + List aOutLs = TwoNodeClip.listOf(TwoNodeClip.asLine(aOut, f)); + List bInLs = TwoNodeClip.listOf(TwoNodeClip.asLine(bIn, f)); + List bOutLs = TwoNodeClip.listOf(TwoNodeClip.asLine(bOut, f)); + Geometry holeCap = TwoNodeClip.ring(aInLs, bInLs, p, q, f, scale); + Geometry holeCup = TwoNodeClip.ring(aOutLs, bOutLs, p, q, f, scale); + Geometry holeAB = TwoNodeClip.ring(aOutLs, bInLs, p, q, f, scale); + Geometry holeBA = TwoNodeClip.ring(bOutLs, aInLs, p, q, f, scale); + if (holeCap == null || holeCup == null + || holeAB == null || holeBA == null) { + return null; + } + if (holeCap.getArea() <= 0.0 || holeCup.getArea() <= 0.0 + || holeAB.getArea() <= 0.0 || holeBA.getArea() <= 0.0) { + return null; + } + + Walk w = new Walk(); + w.outer = outer; + w.p = p; + w.q = q; + w.holeCap = holeCap; + w.holeCup = holeCup; + w.holeAB = holeAB; + w.holeBA = holeBA; + return w; + } + + private static List inWalk(List pq, + List qp, Polygon other) { + int pqSide = sideOf(pq, other); + int qpSide = sideOf(qp, other); + if (pqSide == TwoNodeClip.IN && qpSide == TwoNodeClip.OUT) return pq; + if (qpSide == TwoNodeClip.IN && pqSide == TwoNodeClip.OUT) return qp; + return null; + } + + private static List outWalk(List pq, + List qp, Polygon other) { + int pqSide = sideOf(pq, other); + int qpSide = sideOf(qp, other); + if (pqSide == TwoNodeClip.OUT && qpSide == TwoNodeClip.IN) return pq; + if (qpSide == TwoNodeClip.OUT && pqSide == TwoNodeClip.IN) return qp; + return null; + } + + private static int sideOf(List path, Polygon poly) { + if (path == null || path.size() < 2) return TwoNodeClip.MIXED; + Coordinate sample; + if (path.size() >= 3) { + sample = path.get(path.size() / 2); + } + else { + sample = new Coordinate(0.5 * (path.get(0).x + path.get(1).x), + 0.5 * (path.get(0).y + path.get(1).y)); + } + return TwoNodeClip.sideOfPolygon(sample, poly); + } + + private static Geometry punch(CurvePolygon outer, Geometry holeFace, + GeometryFactory f) { + if (outer == null || holeFace == null) return null; + LineString ring = holeRing(holeFace); + if (ring == null) return null; + return new CurvePolygon(outer.getExteriorCurve(), + new LineString[] { ring }, f); + } + + private static LineString holeRing(Geometry face) { + if (face == null || face.isEmpty() || face.getNumGeometries() != 1) { + return null; + } + Geometry g = face.getGeometryN(0); + if (g instanceof CurvePolygon) { + LineString sh = ((CurvePolygon) g).getExteriorCurve(); + if (sh == null || sh.isEmpty()) return null; + return sh; + } + if (g instanceof Polygon) { + return ((Polygon) g).getExteriorRing(); + } + return null; + } + + private static Polygon asPolygon(LineString hole, GeometryFactory f) { + try { + return f.createPolygon(f.createLinearRing(hole.getCoordinates())); + } + catch (RuntimeException ex) { + return null; + } + } + + private static Geometry join(Geometry a, Geometry extra, GeometryFactory f) { + if (extra == null) return null; + if (a == null || a.isEmpty()) return extra; + if (extra.isEmpty()) return a; + List faces = new ArrayList(); + if (!addFaces(faces, a) || !addFaces(faces, extra)) return null; + if (faces.isEmpty()) return null; + if (faces.size() == 1) return faces.get(0); + return new MultiSurface(faces.toArray(new Polygon[0]), f); + } + + private static boolean addFaces(List dest, Geometry g) { + boolean ok = true; + for (int i = 0; i < g.getNumGeometries() && ok; i++) { + Geometry p = g.getGeometryN(i); + if (p instanceof Polygon && !p.isEmpty()) { + dest.add((Polygon) p); + } + else { + ok = false; + } + } + return ok; + } + + private static double scaleOf(Geometry a, Geometry b) { + double wa = Math.max(a.getEnvelopeInternal().getWidth(), + a.getEnvelopeInternal().getHeight()); + double wb = Math.max(b.getEnvelopeInternal().getWidth(), + b.getEnvelopeInternal().getHeight()); + return Math.max(Math.max(wa, wb), 1.0); + } + + private static final class Walk { + CurvePolygon outer; + Coordinate p; + Coordinate q; + Geometry holeCap; + Geometry holeCup; + Geometry holeAB; + Geometry holeBA; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoNodeClip.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoNodeClip.java index 46d831f651..c933412be2 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoNodeClip.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoNodeClip.java @@ -26,6 +26,7 @@ import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.curve.CircularArcDensifier; import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; import org.locationtech.jts.geom.curve.CompoundCurve; import org.locationtech.jts.geom.curve.CurveGeometryFactory; import org.locationtech.jts.geom.curve.CurveOps; @@ -136,7 +137,9 @@ static Polygon closeRing(List members, GeometryFactory f, /** * Flatten a CompoundCurve shell to typed edges. A LineString member * becomes segments; a CircularString becomes 3-control sweep windows. - * A colinear triple is a segment, not an arc. + * A colinear triple is a segment, not an arc. A + * {@link ClothoidSegment} is a miss -- its parent sequence is + * start+end only, and that chord is not the spiral. */ static List flatten(CurvePolygon shell) { LineString ring = shell.getExteriorCurve(); @@ -145,6 +148,9 @@ static List flatten(CurvePolygon shell) { List edges = new ArrayList(); for (int i = 0; i < cc.getNumMembers(); i++) { LineString m = cc.getMemberN(i); + if (m instanceof ClothoidSegment) { + return null; + } Coordinate[] pts = m.getCoordinates(); if (m instanceof CircularString) { if (pts.length < 3) return null; diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoShellClip.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoShellClip.java index 2869001f5a..1dcb304c2f 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoShellClip.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/TwoShellClip.java @@ -15,8 +15,6 @@ import java.util.Collections; import java.util.List; -import org.locationtech.jts.algorithm.LineIntersector; -import org.locationtech.jts.algorithm.RobustLineIntersector; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; @@ -28,7 +26,9 @@ * touch, two proper nodes walk via {@link TwoNodeClip}, an even 4+ * alternating cut is {@link NSpanClip}. A tangent is a zero-length * NSpan, so two crossings plus a touch assemble like even-n. - * Collinear overlap stays {@code null}. Not a noder. + * Pair hits go through {@link CurveSegmentString}; collinear overlap + * stays {@code null} from {@code intersect} (the noder names that + * interval as an edge). Face assemble is not a noder. */ final class TwoShellClip { @@ -84,6 +84,17 @@ static Geometry overlay(CurvePolygon a, CurvePolygon b, int opCode, pA.pt, qA.pt, f, scale); } + /** + * 0 or 1 proper node: one shell inside the other, or interiors + * disjoint (including a single vertex touch). Not a 1-node walk. + * OverlayNG-for-circles reuses this after MIXED noding: the + * overlap is an edge, not a discrete node. + */ + static Geometry containmentOverlay(CurvePolygon a, CurvePolygon b, + int opCode, Geometry first) { + return fewNodeOverlay(a, b, opCode, first); + } + /** * 0 or 1 proper node: one shell inside the other, or interiors * disjoint (including a single vertex touch). Not a 1-node walk. @@ -232,71 +243,25 @@ private static List> spanPieces(List spans, private static boolean collectTwoShellHits(List edgesA, List edgesB, List nodesA, double scale) { - LineIntersector li = new RobustLineIntersector(); boolean miss = false; for (int i = 0; i < edgesA.size() && !miss; i++) { TwoNodeClip.Edge ea = edgesA.get(i); for (int j = 0; j < edgesB.size() && !miss; j++) { - TwoNodeClip.Edge eb = edgesB.get(j); - if (!addEdgePairHits(ea, eb, i, nodesA, li, scale)) { + Coordinate[] xs = CurveSegmentString.intersect( + CurveSegmentString.of(ea), CurveSegmentString.of(edgesB.get(j)), + scale); + if (xs == null) { miss = true; } - } - } - return !miss; - } - - private static boolean addEdgePairHits(TwoNodeClip.Edge ea, - TwoNodeClip.Edge eb, int iA, List nodesA, - LineIntersector li, double scale) { - if (ea.isArc && eb.isArc) { - if (sameCircle(ea, eb, scale)) { - return true; - } - Coordinate[] xs = TwoNodeClip.intersectCircles( - ea.circle[0], ea.circle[1], ea.circle[2], - eb.circle[0], eb.circle[1], eb.circle[2]); - for (int k = 0; k < xs.length; k++) { - if (TwoNodeClip.isOnSweep(xs[k], ea.circle, ea.a, ea.mid, ea.b) - && TwoNodeClip.isOnSweep(xs[k], eb.circle, eb.a, eb.mid, eb.b)) { - TwoNodeClip.addUnique(nodesA, - new TwoNodeClip.Node(iA, ea.param(xs[k]), xs[k]), scale); + else { + for (int k = 0; k < xs.length; k++) { + TwoNodeClip.addUnique(nodesA, + new TwoNodeClip.Node(i, ea.param(xs[k]), xs[k]), scale); + } } } - return true; - } - if (ea.isArc) { - addSegCircleHits(eb.a, eb.b, ea, ea, iA, nodesA, scale); - return true; - } - if (eb.isArc) { - addSegCircleHits(ea.a, ea.b, eb, ea, iA, nodesA, scale); - return true; - } - li.computeIntersection(ea.a, ea.b, eb.a, eb.b); - if (li.getIntersectionNum() == LineIntersector.COLLINEAR_INTERSECTION) { - return false; - } - if (li.getIntersectionNum() == 1) { - Coordinate p = li.getIntersection(0); - TwoNodeClip.addUnique(nodesA, - new TwoNodeClip.Node(iA, ea.param(p), p), scale); - } - return true; - } - - private static void addSegCircleHits(Coordinate s0, Coordinate s1, - TwoNodeClip.Edge sweep, TwoNodeClip.Edge onA, int iA, - List nodesA, double scale) { - Coordinate[] xs = TwoNodeClip.intersectSegmentCircle( - sweep.circle[0], sweep.circle[1], sweep.circle[2], s0, s1); - for (int k = 0; k < xs.length; k++) { - if (TwoNodeClip.isOnSweep(xs[k], sweep.circle, sweep.a, sweep.mid, - sweep.b)) { - TwoNodeClip.addUnique(nodesA, - new TwoNodeClip.Node(iA, onA.param(xs[k]), xs[k]), scale); - } } + return !miss; } private static TwoNodeClip.Node nodeOn(List edges, @@ -333,15 +298,6 @@ private static int sideOfShell(List walk, CurvePolygon other) { return TwoNodeClip.locateInShell(sample, other); } - private static boolean sameCircle(TwoNodeClip.Edge a, TwoNodeClip.Edge b, - double scale) { - if (!a.isArc || !b.isArc) return false; - double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); - Coordinate ca = new Coordinate(a.circle[0], a.circle[1]); - Coordinate cb = new Coordinate(b.circle[0], b.circle[1]); - return ca.distance(cb) <= eps && Math.abs(a.circle[2] - b.circle[2]) <= eps; - } - private static final class ShellSpan { final List members; final boolean in; diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CompoundCurveTranslateTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CompoundCurveTranslateTest.java new file mode 100644 index 0000000000..63632d5181 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CompoundCurveTranslateTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.io.curve.CurveWKTWriter; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Affine translate must stay type-honest on ISO/IEC 13249-3 + * {@code CIRCULARSTRING} / {@code COMPOUNDCURVE} / {@code CURVEPOLYGON} + * / {@code MULTICURVE}. + *

    + * Found on the TestBuilder canvas: MoveTool does + * {@code AffineTransformation.translationInstance(dx, dy)} then + * {@code copy(); apply(trans)}. The inherited LineString apply walked + * only the concatenated CompoundCurve sequence, which omits each later + * member's start. Mid and end of a three-point arc moved; the start + * did not. The type stayed {@code CIRCULARSTRING}; the arc lied. + *

    + * This locks translate only. It does not sign that shear or + * non-uniform scale still describes a circular arc. No Bézier I/O + * type. Type set stays 1–7 + 8–12. + */ +public class CompoundCurveTranslateTest extends GeometryTestCase { + + private static final double DX = 10.0; + private static final double DY = 8.0; + private static final double EPS = 1.0e-12; + + /** Line then quarter-circle: the reported junction-start miss. */ + private static final String LINE_THEN_ARC = + "COMPOUNDCURVE ((0 70, 30 70, 30 25), CIRCULARSTRING (30 25, 22.677669529663685 7.322330470336315, 5 0))"; + + private static final String S_BOWLS = + "COMPOUNDCURVE ((150 70, 132.5 70), CIRCULARSTRING (132.5 70, 115 52.5, 132.5 35), " + + "CIRCULARSTRING (132.5 35, 150 17.5, 132.5 0), (132.5 0, 115 0))"; + + private static final String MULTI = + "MULTICURVE (" + LINE_THEN_ARC + ", (30 70, 127.5 70), " + S_BOWLS + ")"; + + private static final String CP_COMPOUND_SHELL = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (0 0, 1 1, 2 0), (2 0, 0 0)))"; + + private static final String CP_COMPOUND_HOLE = + "CURVEPOLYGON (CIRCULARSTRING (0 0, 8 0, 8 8, 0 8, 0 0), " + + "COMPOUNDCURVE (CIRCULARSTRING (2 2, 3 3, 4 2), (4 2, 2 2)))"; + + public static void main(String[] args) { + TestRunner.run(CompoundCurveTranslateTest.class); + } + + public CompoundCurveTranslateTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + private static Geometry translate(Geometry g) { + return AffineTransformation.translationInstance(DX, DY).transform(g); + } + + public void testCompoundCurveKeepsTypeAndThreePointArc() throws Exception { + Geometry moved = translate(readCurve(LINE_THEN_ARC)); + assertEquals("CompoundCurve", moved.getGeometryType()); + CompoundCurve cc = (CompoundCurve) moved; + assertEquals(2, cc.getNumMembers()); + assertEquals("LineString", cc.getMemberN(0).getGeometryType()); + assertEquals("CircularString", cc.getMemberN(1).getGeometryType()); + assertEquals("arc stays a three-point control triple", + 3, cc.getMemberN(1).getNumPoints()); + } + + public void testEveryControlPointIncludingMemberStartMoves() throws Exception { + Geometry original = readCurve(LINE_THEN_ARC); + Geometry moved = translate(original); + assertTranslatedMembers((CompoundCurve) original, (CompoundCurve) moved); + } + + public void testSecondCircularStringStartAlsoMoves() throws Exception { + Geometry original = readCurve(S_BOWLS); + Geometry moved = translate(original); + assertEquals("CompoundCurve", moved.getGeometryType()); + assertTranslatedMembers((CompoundCurve) original, (CompoundCurve) moved); + CompoundCurve cc = (CompoundCurve) moved; + assertEquals(3, cc.getMemberN(1).getNumPoints()); + assertEquals(3, cc.getMemberN(2).getNumPoints()); + } + + public void testMultiCurveTranslateKeepsMemberTypes() throws Exception { + Geometry original = readCurve(MULTI); + Geometry moved = translate(original); + assertEquals("MultiCurve", moved.getGeometryType()); + assertEquals(3, moved.getNumGeometries()); + assertEquals("CompoundCurve", moved.getGeometryN(0).getGeometryType()); + assertEquals("LineString", moved.getGeometryN(1).getGeometryType()); + assertEquals("CompoundCurve", moved.getGeometryN(2).getGeometryType()); + assertTranslatedMembers( + (CompoundCurve) original.getGeometryN(0), + (CompoundCurve) moved.getGeometryN(0)); + assertTranslatedMembers( + (CompoundCurve) original.getGeometryN(2), + (CompoundCurve) moved.getGeometryN(2)); + } + + public void testCurvePolygonCompoundShell() throws Exception { + Geometry original = readCurve(CP_COMPOUND_SHELL); + Geometry moved = translate(original); + assertEquals("CurvePolygon", moved.getGeometryType()); + LineString shell = ((CurvePolygon) moved).getExteriorCurve(); + assertEquals("CompoundCurve", shell.getGeometryType()); + assertTranslatedMembers( + (CompoundCurve) ((CurvePolygon) original).getExteriorCurve(), + (CompoundCurve) shell); + } + + public void testCurvePolygonCompoundHole() throws Exception { + Geometry original = readCurve(CP_COMPOUND_HOLE); + Geometry moved = translate(original); + CurvePolygon cp = (CurvePolygon) moved; + assertEquals("CurvePolygon", cp.getGeometryType()); + assertEquals("CircularString", cp.getExteriorCurve().getGeometryType()); + assertEquals("CompoundCurve", cp.getInteriorCurveN(0).getGeometryType()); + assertTranslated( + ((CurvePolygon) original).getExteriorCurve().getCoordinates(), + cp.getExteriorCurve().getCoordinates()); + assertTranslatedMembers( + (CompoundCurve) ((CurvePolygon) original).getInteriorCurveN(0), + (CompoundCurve) cp.getInteriorCurveN(0)); + } + + public void testNoBezierVerticesInWkt() throws Exception { + Geometry moved = translate(readCurve(MULTI)); + String wkt = new CurveWKTWriter().write(moved); + assertTrue(wkt.contains("MULTICURVE")); + assertTrue(wkt.contains("COMPOUNDCURVE")); + assertTrue(wkt.contains("CIRCULARSTRING")); + assertFalse("do not invent a Bézier I/O type", + wkt.toUpperCase().contains("BEZIER")); + assertFalse(wkt.toUpperCase().contains("CUBIC")); + } + + public void testOriginalIsUnchanged() throws Exception { + Geometry original = readCurve(LINE_THEN_ARC); + String before = new CurveWKTWriter().write(original); + translate(original); + assertEquals(before, new CurveWKTWriter().write(original)); + } + + public void testBareCircularStringStillTranslates() throws Exception { + Geometry original = readCurve("CIRCULARSTRING (30 25, 22 7, 5 0)"); + Geometry moved = translate(original); + assertEquals("CircularString", moved.getGeometryType()); + assertEquals(3, moved.getNumPoints()); + assertTranslated(original.getCoordinates(), moved.getCoordinates()); + } + + private static void assertTranslatedMembers(CompoundCurve original, CompoundCurve moved) { + assertEquals(original.getNumMembers(), moved.getNumMembers()); + for (int i = 0; i < original.getNumMembers(); i++) { + LineString a = original.getMemberN(i); + LineString b = moved.getMemberN(i); + assertEquals(a.getGeometryType(), b.getGeometryType()); + assertEquals(a.getNumPoints(), b.getNumPoints()); + if (a instanceof CircularString) { + assertEquals("CIRCULARSTRING stays a 3-point arc", 3, b.getNumPoints()); + } + assertTranslated(a.getCoordinates(), b.getCoordinates()); + } + } + + private static void assertTranslated(Coordinate[] from, Coordinate[] to) { + assertEquals(from.length, to.length); + for (int i = 0; i < from.length; i++) { + assertEquals("x[" + i + "]", from[i].x + DX, to[i].x, EPS); + assertEquals("y[" + i + "]", from[i].y + DY, to[i].y, EPS); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveAwarenessGreenMetersTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveAwarenessGreenMetersTest.java new file mode 100644 index 0000000000..8c80d36cfa --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveAwarenessGreenMetersTest.java @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.HashSet; +import java.util.Set; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiPoint; +import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.linearref.LengthIndexedLine; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * Green verification for meters shipped on the MMF tip (#1195). + */ +public class CurveAwarenessGreenMetersTest extends TestCase { + + private final CurveWKTReader reader = new CurveWKTReader(); + + public static void main(String[] args) { + TestRunner.run(CurveAwarenessGreenMetersTest.class); + } + + public CurveAwarenessGreenMetersTest(String name) { + super(name); + } + + private Geometry read(String wkt) throws Exception { + return reader.read(wkt); + } + + public void test_M_LEN_CS() throws Exception { + Geometry g = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); + assertEquals(Math.PI * 10, g.getLength(), 1.0e-9); + } + + public void test_M_LEN_CC() throws Exception { + Geometry g = read( + "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); + assertEquals(10.0 + Math.PI * 5.0, g.getLength(), 1.0e-9); + } + + public void test_M_AREA_CP() throws Exception { + Geometry g = read( + "CURVEPOLYGON (CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0))"); + assertEquals(Math.PI * 100, g.getArea(), 1.0e-6); + } + + public void test_M_DIM() throws Exception { + assertEquals(1, read("CIRCULARSTRING EMPTY").getDimension()); + assertEquals(2, read("CURVEPOLYGON EMPTY").getDimension()); + } + + public void test_F_MC() throws Exception { + MultiCurve mc = (MultiCurve) read( + "MULTICURVE (CIRCULARSTRING (0 0, 1 1, 2 0), (3 0, 4 0))"); + Geometry c = mc.copy(); + assertTrue(c.getGeometryN(0) instanceof CircularString); + assertTrue(c.getGeometryN(1) instanceof LineString); + assertFalse(c.getGeometryN(1) instanceof CircularString); + } + + public void test_F_MS() throws Exception { + MultiSurface ms = (MultiSurface) read( + "MULTISURFACE (((0 0, 1 0, 1 1, 0 1, 0 0)), CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0)))"); + Geometry c = ms.copy(); + assertEquals("Polygon", c.getGeometryN(0).getGeometryType()); + assertTrue(c.getGeometryN(1) instanceof CurvePolygon); + } + + public void test_B_CC() throws Exception { + Geometry g = read( + "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); + Geometry b = g.getBoundary(); + assertEquals("MultiPoint", b.getGeometryType()); + assertEquals(2, b.getNumGeometries()); + } + + public void test_H_CV() throws Exception { + Geometry hull = read("CIRCULARSTRING (-10 0, 0 10, 10 0)").convexHull(); + Set uniq = new HashSet(); + Coordinate[] pts = hull.getCoordinates(); + for (int i = 0; i < pts.length; i++) { + if (i > 0 && pts[i].equals2D(pts[0])) { + continue; // closing duplicate + } + uniq.add(pts[i].x + "," + pts[i].y); + } + assertEquals(3, uniq.size()); + assertTrue(hull instanceof CurvePolygon || hull.getNumPoints() <= 4); + } + + public void test_R_EQ() throws Exception { + Geometry arc = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + Geometry chord = new CurveGeometryFactory().createLineString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(5, 5), new Coordinate(10, 0) + }); + assertFalse(arc.equalsExact(chord)); + } + + public void test_AT_S() throws Exception { + Geometry arc = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + Geometry rotated = AffineTransformation.rotationInstance(Math.PI / 4) + .transform(arc); + assertTrue(rotated instanceof CircularString); + } + + public void test_AT_NS() throws Exception { + Geometry arc = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + Geometry sheared = new AffineTransformation().setToShear(0.5, 0) + .transform(arc); + assertFalse("shear must densify, not keep CircularString", + sheared instanceof CircularString); + assertEquals("LineString", sheared.getGeometryType()); + } + + public void test_F_CP() throws Exception { + Geometry g = read( + "CURVEPOLYGON ((CIRCULARSTRING (0 0, 5 5, 10 0), CIRCULARSTRING (10 0, 5 -5, 0 0)))"); + assertTrue(g instanceof CurvePolygon); + assertTrue(((CurvePolygon) g).getExteriorCurve() instanceof CompoundCurve); + } + + public void test_B_CP() throws Exception { + Geometry g = read( + "CURVEPOLYGON ((CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 0 0)))"); + Geometry b = g.getBoundary(); + assertTrue(b instanceof CompoundCurve); + CompoundCurve cc = (CompoundCurve) b; + assertEquals(2, cc.getNumMembers()); + assertTrue(cc.getMemberN(0) instanceof CircularString); + } + + public void test_B_MS() throws Exception { + Geometry g = read( + "MULTISURFACE (((0 0, 10 0, 10 10, 0 10, 0 0)), " + + "CURVEPOLYGON ((CIRCULARSTRING (20 0, 25 5, 30 0), (30 0, 20 0))))"); + Geometry b = g.getBoundary(); + assertEquals("MultiCurve", b.getGeometryType()); + assertTrue(b instanceof MultiCurve); + } + + public void test_LRF_LEN() throws Exception { + Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); + LengthIndexedLine lil = new LengthIndexedLine(arc); + Coordinate mid = lil.extractPoint(arc.getLength() / 2.0); + assertEquals(0.0, mid.x, 1.0e-9); + assertEquals(5.0, mid.y, 1.0e-9); + } + + public void test_C_LIN() throws Exception { + Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); + assertEquals(2.0 * 5.0 / Math.PI, arc.getCentroid().getY(), 1.0e-9); + } + + public void test_C_AREA() throws Exception { + Geometry disc = read( + "CURVEPOLYGON (CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0))"); + assertEquals(0.0, disc.getCentroid().getX(), 1.0e-6); + assertEquals(0.0, disc.getCentroid().getY(), 1.0e-6); + } + + public void test_D_OP() throws Exception { + Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); + assertEquals(5.0, + org.locationtech.jts.operation.distance.DistanceOp.distance(arc, + read("POINT (0 10)")), + 1.0e-9); + } + + public void test_D_AA() throws Exception { + Geometry a1 = read("CIRCULARSTRING (-10 0, -5 5, 0 0)"); + Geometry a2 = read("CIRCULARSTRING (10 0, 15 5, 20 0)"); + assertEquals(10.0, a1.distance(a2), 1.0e-9); + } + + public void test_R_CONT() throws Exception { + Geometry disc = read( + "CURVEPOLYGON (CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0))"); + assertTrue(disc.contains(read("POINT (5 5)"))); + assertFalse(disc.contains(read("POINT (20 20)"))); + } + + public void test_OV() throws Exception { + Geometry d1 = read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + Geometry d2 = read( + "CURVEPOLYGON (CIRCULARSTRING (0 0, 5 5, 10 0, 5 -5, 0 0))"); + Geometry u = d1.union(d2); + assertTrue(u instanceof CurvePolygon); + } + + public void test_S_DP() throws Exception { + Geometry arc = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); + Geometry simp = org.locationtech.jts.simplify.DouglasPeuckerSimplifier + .simplify(arc, 1.0); + assertTrue(simp instanceof CircularString); + } + + public void test_S_VW() throws Exception { + Geometry arc = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); + Geometry simp = org.locationtech.jts.simplify.VWSimplifier.simplify(arc, + 1.0); + assertTrue(simp instanceof CircularString); + } + + public void test_S_TP() throws Exception { + Geometry arc = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); + Geometry simp = org.locationtech.jts.simplify.TopologyPreservingSimplifier + .simplify(arc, 1.0); + assertTrue(simp instanceof CircularString); + } + + public void test_V_CS() throws Exception { + Geometry g = read( + "CIRCULARSTRING (0 0, 5 5, 10 0, 5 -5, 0 0, 5 5, 10 0)"); + assertFalse(g.isSimple()); + } + + public void test_F_RD() throws Exception { + Geometry cp = read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + org.locationtech.jts.awt.curve.CurveShapeWriter w = + new org.locationtech.jts.awt.curve.CurveShapeWriter(); + java.awt.Shape s = w.toShape(cp); + assertNotNull(s); + assertFalse(s.getBounds2D().isEmpty()); + } + + public void test_LRF_LOC() throws Exception { + Geometry cc = read( + "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); + org.locationtech.jts.linearref.LocationIndexedLine loc = + new org.locationtech.jts.linearref.LocationIndexedLine(cc); + org.locationtech.jts.linearref.LinearLocation ll = loc + .indexOf(new Coordinate(15, 5)); + assertEquals(1, ll.getComponentIndex()); + } + + public void test_C_IP() throws Exception { + Geometry disc = read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + Coordinate ip = org.locationtech.jts.algorithm.InteriorPointArea + .getInteriorPoint(disc); + assertNotNull(ip); + assertTrue(disc.contains(disc.getFactory().createPoint(ip))); + assertTrue(Math.hypot(ip.x, ip.y) < 4.0); + } + + public void test_PRC_SN() throws Exception { + Geometry cs = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + PrecisionModel pm = new PrecisionModel(1.0); + Geometry red = org.locationtech.jts.precision.GeometryPrecisionReducer + .reduce(cs, pm); + assertTrue(red instanceof CircularString); + } + + public void test_V_CP() throws Exception { + Geometry disc = read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + assertTrue(disc.isValid()); + Geometry crossed = read( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 0 5, 0 0)))"); + assertFalse(crossed.isValid()); + } + + public void test_R_PR() throws Exception { + Geometry disc = read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + Geometry inside = read("POINT (0 0)"); + Geometry outside = read("POINT (10 10)"); + assertEquals("0F2FF1FF2", disc.relate(inside).toString()); + assertEquals("FF2FF10F2", disc.relate(outside).toString()); + } + + public void test_N_SS() { + org.locationtech.jts.noding.CircularNodedSegmentString ss = + org.locationtech.jts.noding.CircularNodedSegmentString.arc( + new Coordinate(0, 0), new Coordinate(5, 5), + new Coordinate(10, 0), null); + assertEquals(org.locationtech.jts.noding.SegmentKind.ARC, + ss.getSegmentKind(0)); + assertTrue(ss.isExact(0)); + assertFalse(ss.mayCollapseToChord(0)); + } + + public void test_VBF() throws Exception { + Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); + Geometry buf = org.locationtech.jts.operation.buffer.VariableBuffer + .buffer(arc, 1.0, 2.0); + assertNotNull(buf); + assertFalse(buf.isEmpty()); + assertTrue(buf.getArea() > 0); + } + + public void test_H_CC() throws Exception { + Geometry arc = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + Geometry hull = org.locationtech.jts.algorithm.hull.ConcaveHull + .concaveHullByLengthRatio(arc, 1.0); + assertNotNull(hull); + // length ratio 1.0 → convex hull of densified sites; apex near (5,5) + assertTrue(hull.getEnvelopeInternal().getMaxY() > 4.5); + } + + public void test_PLG() throws Exception { + Geometry cc = read( + "COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, 5 -5, -5 -5, -5 0))"); + org.locationtech.jts.operation.polygonize.Polygonizer p = + new org.locationtech.jts.operation.polygonize.Polygonizer(); + p.add(cc); + assertEquals(1, p.getPolygons().size()); + Geometry face = (Geometry) p.getPolygons().iterator().next(); + assertTrue(face.getArea() > 40.0); // densified arc, not control triangle + } + + public void test_COV() throws Exception { + Geometry upper = read( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))"); + Geometry lower = read( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 -5, 5 0), (5 0, -5 0)))"); + Geometry coll = upper.getFactory().createGeometryCollection( + new Geometry[] { upper, lower }); + Geometry u = org.locationtech.jts.operation.overlayng.CoverageUnion.union(coll); + assertTrue(u instanceof CurvePolygon); + LineString shell = ((CurvePolygon) u).getExteriorCurve(); + assertTrue(shell instanceof CompoundCurve); + CompoundCurve cc = (CompoundCurve) shell; + assertEquals(2, cc.getNumMembers()); + assertTrue(cc.getMemberN(0) instanceof CircularString); + assertTrue(cc.getMemberN(1) instanceof CircularString); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveBufferArcTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveBufferArcTest.java new file mode 100644 index 0000000000..981537ee32 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveBufferArcTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * BUF-1 / BUF-NEG: open-arc buffer corridor (#1195). + */ +public class CurveBufferArcTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveBufferArcTest.class); + } + + public CurveBufferArcTest(String name) { + super(name); + } + + public void testSingleArcBufferIsCurvePolygon() throws Exception { + Geometry arc = new CurveWKTReader().read( + "CIRCULARSTRING (5 0, 0 5, -5 0)"); + Geometry buf = arc.buffer(1.0); + assertTrue(buf instanceof CurvePolygon); + CurvePolygon cp = (CurvePolygon) buf; + assertTrue(cp.getExteriorCurve() instanceof CompoundCurve); + CompoundCurve shell = (CompoundCurve) cp.getExteriorCurve(); + assertEquals(4, shell.getNumMembers()); + assertTrue(shell.getMemberN(0) instanceof CircularString); + assertTrue(shell.getMemberN(1) instanceof CircularString); + assertTrue(shell.getMemberN(2) instanceof CircularString); + assertTrue(shell.getMemberN(3) instanceof CircularString); + assertTrue(buf.getArea() > 0.0); + } + + public void testBufNegOpenArcEmpty() throws Exception { + Geometry arc = new CurveWKTReader().read( + "CIRCULARSTRING (-5 0, 0 5, 5 0)"); + Geometry buf = arc.buffer(-10.0); + assertTrue(buf.isEmpty()); + } + + public void testDiscBufferStillLaser() throws Exception { + Geometry disc = new CurveWKTReader().read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + Geometry buf = disc.buffer(1.0); + assertTrue(buf instanceof CurvePolygon); + assertEquals(36.0 * Math.PI, buf.getArea(), 1.0e-6); + } + + public void testStadiumBufferPreservesCompoundCurve() throws Exception { + Geometry stadium = new CurveWKTReader().read( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, 0 -2, 1 -1), (1 -1, 1 6), CIRCULARSTRING (1 6, 0 7, -1 6), (-1 6, -1 -1)))"); + Geometry buf = stadium.buffer(1.0); + assertTrue(buf instanceof CurvePolygon); + assertTrue(((CurvePolygon) buf).getExteriorCurve() instanceof CompoundCurve); + CompoundCurve shell = (CompoundCurve) ((CurvePolygon) buf).getExteriorCurve(); + assertEquals(4, shell.getNumMembers()); + assertTrue(shell.getMemberN(0) instanceof CircularString); + // MIC radius was 1; after +1 buffer MIC radius is 2 + CircularArcDensifier.Circle mic = CurveExact.stadiumMic(buf); + assertNotNull(mic); + assertEquals(2.0, mic.r, 1.0e-9); + } + + public void testOpenMixedLineArcCorridor() throws Exception { + Geometry g = new CurveWKTReader().read( + "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); + Geometry buf = g.buffer(2.0); + assertTrue(buf instanceof CurvePolygon); + CurvePolygon cp = (CurvePolygon) buf; + assertTrue(cp.getExteriorCurve() instanceof CompoundCurve); + CompoundCurve shell = (CompoundCurve) cp.getExteriorCurve(); + assertTrue(shell.getNumMembers() >= 4); + boolean sawArc = false; + for (int i = 0; i < shell.getNumMembers(); i++) { + if (shell.getMemberN(i) instanceof CircularString) { + sawArc = true; + } + } + assertTrue("corridor must keep circular parallels/caps", sawArc); + assertTrue(buf.getArea() > 0.0); + assertFalse(buf instanceof org.locationtech.jts.geom.Polygon + && !(buf instanceof CurvePolygon)); + } + + public void testOpenThreeMemberCorridor() throws Exception { + Geometry g = new CurveWKTReader().read( + "COMPOUNDCURVE ((0 0, 5 0), CIRCULARSTRING (5 0, 8 3, 11 0), (11 0, 16 0))"); + Geometry buf = g.buffer(1.0); + assertTrue(buf instanceof CurvePolygon); + assertTrue(((CurvePolygon) buf).getExteriorCurve() instanceof CompoundCurve); + assertTrue(buf.getArea() > 0.0); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveIntersectionTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveIntersectionTest.java new file mode 100644 index 0000000000..2f2b360c60 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveIntersectionTest.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Coordinate; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * N-AA / N-AL public utilities. + */ +public class CurveIntersectionTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveIntersectionTest.class); + } + + public CurveIntersectionTest(String name) { + super(name); + } + + public void testArcArcCrossing() { + // Upper halves of discs (0,0)r=5 and (7,0)r=5 — one node in both sweeps. + Coordinate[] xs = CurveIntersection.arcArc( + new Coordinate(-5, 0), new Coordinate(0, 5), new Coordinate(5, 0), + new Coordinate(2, 0), new Coordinate(7, 5), new Coordinate(12, 0)); + assertEquals(1, xs.length); + assertEquals(3.5, xs[0].x, 1.0e-6); + } + + public void testArcLineDiameter() { + Coordinate[] xs = CurveIntersection.arcLine( + new Coordinate(-5, 0), new Coordinate(0, 5), new Coordinate(5, 0), + new Coordinate(0, -10), new Coordinate(0, 10)); + assertEquals(1, xs.length); + assertEquals(0.0, xs[0].x, 1.0e-9); + assertEquals(5.0, xs[0].y, 1.0e-9); + } + + public void testArcLineMiss() { + Coordinate[] xs = CurveIntersection.arcLine( + new Coordinate(-5, 0), new Coordinate(0, 5), new Coordinate(5, 0), + new Coordinate(10, 0), new Coordinate(20, 0)); + assertEquals(0, xs.length); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategyTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategyTest.java new file mode 100644 index 0000000000..7049bf2ddb --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationStrategyTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * MMF: no silent linearization — default LINEARIZED warns; PRESERVE keeps type. + */ +public class CurveLinearizationStrategyTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveLinearizationStrategyTest.class); + } + + public CurveLinearizationStrategyTest(String name) { + super(name); + } + + @Override + protected void tearDown() { + CurveLinearizationStrategy.clearThreadOverride(); + CurveLinearizationStrategy.setDefault(CurveLinearizationStrategy.LINEARIZED); + } + + public void testDefaultIsLinearized() { + assertEquals(CurveLinearizationStrategy.LINEARIZED, + CurveLinearizationStrategy.getDefault()); + assertEquals(CurveLinearizationStrategy.LINEARIZED, + CurveLinearizationStrategy.current()); + } + + public void testPreserveReturnsSameInstance() throws Exception { + Geometry cs = new CurveWKTReader().read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + CurveLinearizationStrategy.setThreadOverride( + CurveLinearizationStrategy.PRESERVE); + Geometry out = CurveOps.linearise(cs); + assertSame(cs, out); + assertTrue(out instanceof CircularString); + } + + public void testLinearizedWarnsAndDensifies() throws Exception { + Geometry cs = new CurveWKTReader().read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + Logger log = Logger.getLogger(CurveLinearizationStrategy.class.getName()); + CapturingHandler cap = new CapturingHandler(); + log.addHandler(cap); + Level prev = log.getLevel(); + log.setLevel(Level.WARNING); + try { + CurveLinearizationStrategy.setThreadOverride( + CurveLinearizationStrategy.LINEARIZED); + Geometry out = CurveOps.linearise(cs); + assertFalse(out instanceof CircularString); + assertTrue("expected linearization warning", cap.sawWarning); + assertTrue(cap.message.indexOf("linearizing") >= 0); + } + finally { + log.removeHandler(cap); + log.setLevel(prev); + } + } + + private static final class CapturingHandler extends Handler { + boolean sawWarning; + String message; + + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + sawWarning = true; + message = record.getMessage(); + } + } + + @Override + public void flush() { } + + @Override + public void close() { } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationWarnSinkTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationWarnSinkTest.java new file mode 100644 index 0000000000..2414aeb109 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveLinearizationWarnSinkTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Jeroen Tech Solutions Ltd / JTS contributors. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import java.util.concurrent.atomic.AtomicReference; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * Phase 5 (#1195): WarnSink receives linearization warnings for UI log. + */ +public class CurveLinearizationWarnSinkTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveLinearizationWarnSinkTest.class); + } + + public CurveLinearizationWarnSinkTest(String name) { + super(name); + } + + public void testWarnSinkReceivesMessage() throws Exception { + final AtomicReference got = new AtomicReference(); + CurveLinearizationStrategy.setWarnSink( + new CurveLinearizationStrategy.WarnSink() { + public void warn(String message) { + got.set(message); + } + }); + try { + Geometry cs = new CurveWKTReader(new CurveGeometryFactory()) + .read("CIRCULARSTRING (0 0, 5 5, 10 0)"); + CurveOps.linearise(cs); + assertNotNull(got.get()); + assertTrue(got.get().indexOf("CircularString") >= 0); + assertTrue(got.get().indexOf("LINEARIZED") >= 0); + } + finally { + CurveLinearizationStrategy.setWarnSink(null); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOffsetCurveTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOffsetCurveTest.java new file mode 100644 index 0000000000..c85d763ebe --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOffsetCurveTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.operation.buffer.OffsetCurve; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * OFF (#1195): public {@link OffsetCurve} on a single-arc CircularString + * returns a concentric CircularString. Positive distance is left of + * direction (CCW ⇒ inward). + */ +public class CurveOffsetCurveTest extends TestCase { + + private static final double EPS = 1e-10; + /** Upper semicircle centre (0,0) R=5, CCW. */ + private static final String SEMI_R5 = "CIRCULARSTRING (5 0, 0 5, -5 0)"; + + public static void main(String[] args) { + TestRunner.run(CurveOffsetCurveTest.class); + } + + public CurveOffsetCurveTest(String name) { + super(name); + } + + public void testCcwPositiveIsInward() throws Exception { + Geometry cs = new CurveWKTReader().read(SEMI_R5); + Geometry result = OffsetCurve.getCurve(cs, 1.0); + assertTrue(result instanceof CircularString); + assertRadius((CircularString) result, 0, 0, 4); + } + + public void testCcwNegativeIsOutward() throws Exception { + Geometry cs = new CurveWKTReader().read(SEMI_R5); + Geometry result = OffsetCurve.getCurve(cs, -1.0); + assertTrue(result instanceof CircularString); + assertRadius((CircularString) result, 0, 0, 6); + } + + public void testCollapseReturnsEmpty() throws Exception { + Geometry cs = new CurveWKTReader().read(SEMI_R5); + assertTrue(OffsetCurve.getCurve(cs, 5.0).isEmpty()); + assertTrue(OffsetCurve.getCurve(cs, 7.0).isEmpty()); + } + + public void testMultiArcFallsThrough() throws Exception { + Geometry cs = new CurveWKTReader().read( + "CIRCULARSTRING (1 0, 0 1, -1 0, 0 -1, 1 0)"); + Geometry result = OffsetCurve.getCurve(cs, 0.5); + assertFalse(result instanceof CircularString); + } + + public void testEndpointsScaled() throws Exception { + Geometry cs = new CurveWKTReader().read(SEMI_R5); + Coordinate[] c = OffsetCurve.getCurve(cs, -1.0).getCoordinates(); + assertEquals(3, c.length); + assertEquals(6.0, c[0].x, EPS); + assertEquals(0.0, c[0].y, EPS); + assertEquals(0.0, c[1].x, EPS); + assertEquals(6.0, c[1].y, EPS); + assertEquals(-6.0, c[2].x, EPS); + assertEquals(0.0, c[2].y, EPS); + } + + private static void assertRadius(CircularString cs, double cx, double cy, + double r) { + Coordinate[] c = cs.getCoordinates(); + assertEquals(3, c.length); + for (int i = 0; i < c.length; i++) { + assertEquals(r, Math.hypot(c[i].x - cx, c[i].y - cy), EPS); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOpsArcLengthLineariseTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOpsArcLengthLineariseTest.java new file mode 100644 index 0000000000..b698ba3162 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveOpsArcLengthLineariseTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * VBF: arc-length densify for VariableBuffer parameterisation. + */ +public class CurveOpsArcLengthLineariseTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(CurveOpsArcLengthLineariseTest.class); + } + + public CurveOpsArcLengthLineariseTest(String name) { + super(name); + } + + @Override + protected void tearDown() { + CurveLinearizationStrategy.clearThreadOverride(); + CurveLinearizationStrategy.setDefault(CurveLinearizationStrategy.LINEARIZED); + } + + public void testHalfCircleEqualArcLengthSamples() throws Exception { + Geometry cs = new CurveWKTReader().read("CIRCULARSTRING (5 0, 0 5, -5 0)"); + LineString lin = (LineString) CurveOps.lineariseArcLength(cs, 8); + assertEquals(9, lin.getNumPoints()); + // Equal central angles ⇒ equal chord lengths on a circle. + double first = lin.getCoordinateN(0).distance(lin.getCoordinateN(1)); + for (int i = 1; i < lin.getNumPoints() - 1; i++) { + double seg = lin.getCoordinateN(i).distance(lin.getCoordinateN(i + 1)); + assertEquals("equal arc-length chords", first, seg, 1.0e-9); + } + // Inscribed chord polyline is short of πr; converges with n. + double expectedChord = 8 * 2.0 * 5.0 * Math.sin(Math.PI / 16.0); + double total = 0; + for (int i = 0; i < lin.getNumPoints() - 1; i++) { + total += lin.getCoordinateN(i).distance(lin.getCoordinateN(i + 1)); + } + assertEquals(expectedChord, total, 1.0e-9); + assertTrue(total < cs.getLength()); + } + + public void testPreserveSkipsDensify() throws Exception { + Geometry cs = new CurveWKTReader().read("CIRCULARSTRING (5 0, 0 5, -5 0)"); + CurveLinearizationStrategy.setThreadOverride( + CurveLinearizationStrategy.PRESERVE); + assertSame(cs, CurveOps.lineariseArcLength(cs, 8)); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/DensifierCurveDsfTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/DensifierCurveDsfTest.java new file mode 100644 index 0000000000..1ad08edc7f --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/DensifierCurveDsfTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.densify.Densifier; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * DSF: {@link Densifier} densifies CircularString via {@code toLinear}. + */ +public class DensifierCurveDsfTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(DensifierCurveDsfTest.class); + } + + public DensifierCurveDsfTest(String name) { + super(name); + } + + public void testCircularStringDensifyLiesOnArc() throws Exception { + Geometry cs = new CurveWKTReader().read("CIRCULARSTRING (5 0, 0 5, -5 0)"); + Geometry dens = Densifier.densify(cs, 0.5); + assertTrue(dens instanceof LineString); + assertTrue(dens.getNumPoints() > 3); + // Every sample near the circle centre (0,0) radius 5 + Coordinate[] pts = dens.getCoordinates(); + for (int i = 0; i < pts.length; i++) { + double r = Math.hypot(pts[i].x, pts[i].y); + assertEquals("sample on arc", 5.0, r, 1.0e-6); + } + } + + public void testPlainLineStringUnchangedPath() { + Geometry ls = new CurveGeometryFactory().createLineString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(10, 0) + }); + Geometry dens = Densifier.densify(ls, 2.0); + assertTrue(dens.getNumPoints() >= 6); + for (int i = 0; i < dens.getNumPoints(); i++) { + assertEquals(0.0, dens.getCoordinates()[i].y, 0.0); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/geom/curve/TriangulationCurveSitesTest.java b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/TriangulationCurveSitesTest.java new file mode 100644 index 0000000000..7220bb9aa8 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/geom/curve/TriangulationCurveSitesTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.geom.curve; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.triangulate.DelaunayTriangulationBuilder; +import org.locationtech.jts.triangulate.VoronoiDiagramBuilder; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * TRI-DT / TRI-VR: curve sites densify via toLinear before triangulation. + */ +public class TriangulationCurveSitesTest extends TestCase { + + public static void main(String[] args) { + TestRunner.run(TriangulationCurveSitesTest.class); + } + + public TriangulationCurveSitesTest(String name) { + super(name); + } + + public void testDelaunayUsesArcSitesNotJustControls() throws Exception { + Geometry arc = new CurveWKTReader().read("CIRCULARSTRING (5 0, 0 5, -5 0)"); + DelaunayTriangulationBuilder dt = new DelaunayTriangulationBuilder(); + dt.setSites(arc); + Geometry tris = dt.getTriangles(arc.getFactory()); + assertTrue(tris.getNumGeometries() > 1); + // Control-only sites would yield a single thin triangle from 3 points. + assertTrue("arc densify must create more than one triangle", + tris.getNumGeometries() >= 3); + } + + public void testVoronoiAcceptsCurvePolygon() throws Exception { + Geometry disc = new CurveWKTReader().read( + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"); + VoronoiDiagramBuilder vb = new VoronoiDiagramBuilder(); + vb.setSites(disc); + Geometry diagram = vb.getDiagram(disc.getFactory()); + assertNotNull(diagram); + assertTrue(diagram.getNumGeometries() > 0); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/io/curve/IoFlattenHonestyTest.java b/modules/curve/src/test/java/org/locationtech/jts/io/curve/IoFlattenHonestyTest.java new file mode 100644 index 0000000000..2a147729b7 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/io/curve/IoFlattenHonestyTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.io.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.io.WKBConstants; +import org.locationtech.jts.io.WKBWriter; +import org.locationtech.jts.io.WKTWriter; +import org.locationtech.jts.io.gml2.GMLWriter; +import org.locationtech.jts.io.kml.KMLWriter; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Leftover write honesty that #51 does not own (Architect SIGN + * 16 Aug 2026, ClaimId MMF-IO). I/O type identity, not overlay + * honesty. WKT CompoundCurve members, toText, GML/KML refuse. + * Core WKB 8–12 + ISO 1008–3012 refuse is #51 on #7 — this file + * pins throw / CurveWKBWriter, it does not reimplement flavour. + * HOLD GEO-TIN 15–17. HOLD elliptic / Bézier. Cite 13249-3. No DOI. + */ +public class IoFlattenHonestyTest extends GeometryTestCase { + + private static final String CIRCULARSTRING = + "CIRCULARSTRING (0 0, 5 5, 10 0)"; + private static final String COMPOUNDCURVE = + "COMPOUNDCURVE (CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 20 0))"; + private static final String CURVEPOLYGON = + "CURVEPOLYGON (CIRCULARSTRING (-2 0, 0 2, 2 0, 0 -2, -2 0))"; + private static final String MULTICURVE = + "MULTICURVE (CIRCULARSTRING (0 0, 5 5, 10 0))"; + private static final String MULTISURFACE = + "MULTISURFACE (CURVEPOLYGON (CIRCULARSTRING (-2 0, 0 2, 2 0, 0 -2, -2 0)))"; + + public static void main(String[] args) { + TestRunner.run(IoFlattenHonestyTest.class); + } + + public IoFlattenHonestyTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + private static void assertRefused(String site, Runnable r) { + try { + r.run(); + fail(site + " must not flatten SQL/MM ISO/IEC 13249-3 types 8–12"); + } + catch (IllegalArgumentException e) { + assertTrue(site + " message: " + e.getMessage(), + e.getMessage().indexOf("ISO/IEC 13249-3") >= 0 + || e.getMessage().indexOf("must not flatten") >= 0 + || e.getMessage().indexOf("CurveWKBWriter") >= 0); + } + } + + private static int typeCode(byte[] wkb) { + int type = ((wkb[1] & 0xff) << 24) | ((wkb[2] & 0xff) << 16) + | ((wkb[3] & 0xff) << 8) | (wkb[4] & 0xff); + if ((wkb[0] & 0xff) == WKBConstants.wkbNDR) { + type = (wkb[1] & 0xff) | ((wkb[2] & 0xff) << 8) + | ((wkb[3] & 0xff) << 16) | ((wkb[4] & 0xff) << 24); + } + return type & 0xff; + } + + public void testCoreWktWriterAllowsCircularStringKeyword() throws Exception { + Geometry cs = readCurve(CIRCULARSTRING); + String wkt = new WKTWriter().write(cs); + assertTrue("CS keyword OK, was: " + wkt, + wkt.toUpperCase().startsWith("CIRCULARSTRING")); + } + + public void testCoreWktWriterRefusesCompoundCurveFlatten() throws Exception { + Geometry cc = readCurve(COMPOUNDCURVE); + assertRefused("WKTWriter", new Runnable() { + public void run() { new WKTWriter().write(cc); } + }); + } + + public void testToTextKeepsCompoundCurveMembers() throws Exception { + Geometry cc = readCurve(COMPOUNDCURVE); + String wkt = cc.toText(); + assertTrue("toText must keep CIRCULARSTRING member, was: " + wkt, + wkt.toUpperCase().contains("CIRCULARSTRING")); + CompoundCurve back = (CompoundCurve) new CurveWKTReader( + new CurveGeometryFactory()).read(wkt); + assertEquals(2, back.getNumMembers()); + assertTrue(back.getMemberN(0) instanceof CircularString); + } + + public void testToTextKeepsCurveCollectionTags() throws Exception { + assertTrue(readCurve(CURVEPOLYGON).toText().toUpperCase() + .contains("CIRCULARSTRING")); + assertTrue(readCurve(MULTICURVE).toText().toUpperCase() + .startsWith("MULTICURVE")); + assertTrue(readCurve(MULTISURFACE).toText().toUpperCase() + .startsWith("MULTISURFACE")); + } + + public void testGmlKmlRefuseCircularString() throws Exception { + Geometry cs = readCurve(CIRCULARSTRING); + assertRefused("GMLWriter", new Runnable() { + public void run() { new GMLWriter().write(cs); } + }); + assertRefused("KMLWriter", new Runnable() { + public void run() { new KMLWriter().write(cs); } + }); + } + + public void testCoreWkbWriterRefusesCurveFlatten() throws Exception { + final Geometry cs = readCurve(CIRCULARSTRING); + final Geometry cc = readCurve(COMPOUNDCURVE); + assertRefused("WKBWriter CS", new Runnable() { + public void run() { new WKBWriter().write(cs); } + }); + assertRefused("WKBWriter CC", new Runnable() { + public void run() { new WKBWriter().write(cc); } + }); + } + + public void testCurveWkbWriterKeepsTypes8to12() throws Exception { + CurveWKBWriter w = new CurveWKBWriter(); + assertEquals(WKBConstants.wkbCircularString, + typeCode(w.write(readCurve(CIRCULARSTRING)))); + assertEquals(WKBConstants.wkbCompoundCurve, + typeCode(w.write(readCurve(COMPOUNDCURVE)))); + assertEquals(WKBConstants.wkbCurvePolygon, + typeCode(w.write(readCurve(CURVEPOLYGON)))); + assertEquals(WKBConstants.wkbMultiCurve, + typeCode(w.write(readCurve(MULTICURVE)))); + assertEquals(WKBConstants.wkbMultiSurface, + typeCode(w.write(readCurve(MULTISURFACE)))); + } + + public void testLinearRingIsOnlyAllowedWkbCollapse() { + GeometryFactory gf = new GeometryFactory(); + Coordinate[] pts = new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(1, 1), + new Coordinate(0, 0) + }; + LinearRing ring = gf.createLinearRing(pts); + LineString line = gf.createLineString(pts); + assertEquals(typeCode(new WKBWriter().write(line)), + typeCode(new WKBWriter().write(ring))); + assertEquals(WKBConstants.wkbLineString, + typeCode(new WKBWriter().write(ring))); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBClothoidTest.java b/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBClothoidTest.java new file mode 100644 index 0000000000..e50e48eee1 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBClothoidTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.io.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.ClothoidSegment; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.io.WKBConstants; +import org.locationtech.jts.io.WKBReader; +import org.locationtech.jts.io.WKBWriter; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * CRV-CLOTHOID WKB 18 greenfield round-trip (#1195 MMF SIGN). + */ +public class WKBClothoidTest extends TestCase { + + private static final double EPS = 1e-12; + + public static void main(String[] args) { + TestRunner.run(WKBClothoidTest.class); + } + + public WKBClothoidTest(String name) { + super(name); + } + + public void testRoundTripXy() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + ClothoidSegment cl = gf.createClothoid( + new Coordinate(0, 0), 0.0, 0.0, 0.005, 80.0); + byte[] wkb = new CurveWKBWriter().write(cl); + assertEquals(WKBConstants.wkbClothoid, typeWord(wkb)); + + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof ClothoidSegment); + ClothoidSegment out = (ClothoidSegment) back; + assertEquals(0.0, out.getStartCoordinate().x, EPS); + assertEquals(0.0, out.getStartCoordinate().y, EPS); + assertEquals(0.0, out.getStartTangent(), EPS); + assertEquals(0.0, out.getStartKappa(), EPS); + assertEquals(0.005, out.getEndKappa(), EPS); + assertEquals(80.0, out.getLength(), EPS); + assertEquals(cl.getEndCoordinate().x, out.getEndCoordinate().x, 1e-9); + assertEquals(cl.getEndCoordinate().y, out.getEndCoordinate().y, 1e-9); + } + + public void testIsoZTypeWord() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + ClothoidSegment cl = gf.createClothoid( + new Coordinate(1, 2, 3), 0.1, 0.0, 0.01, 40.0); + CurveWKBWriter writer = new CurveWKBWriter(3); + writer.setFlavor(WKBConstants.wkbIso); + byte[] wkb = writer.write(cl); + assertEquals(1018, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof ClothoidSegment); + } + + public void testIsoMTypeWord() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + Coordinate start = new org.locationtech.jts.geom.CoordinateXYM(1, 2, 9.0); + ClothoidSegment cl = gf.createClothoid(start, 0.1, 0.0, 0.01, 40.0); + CurveWKBWriter writer = new CurveWKBWriter(3); + writer.setFlavor(WKBConstants.wkbIso); + writer.setOutputOrdinates(java.util.EnumSet.of( + org.locationtech.jts.io.Ordinate.X, + org.locationtech.jts.io.Ordinate.Y, + org.locationtech.jts.io.Ordinate.M)); + byte[] wkb = writer.write(cl); + assertEquals(2018, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof ClothoidSegment); + } + + public void testIsoZmTypeWord() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + Coordinate start = new org.locationtech.jts.geom.CoordinateXYZM(1, 2, 3, 9.0); + ClothoidSegment cl = gf.createClothoid(start, 0.1, 0.0, 0.01, 40.0); + CurveWKBWriter writer = new CurveWKBWriter(4); + writer.setFlavor(WKBConstants.wkbIso); + byte[] wkb = writer.write(cl); + assertEquals(3018, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof ClothoidSegment); + } + + public void testCoreWriterRefusesFlatten() { + CurveGeometryFactory gf = new CurveGeometryFactory(); + ClothoidSegment cl = gf.createClothoid( + new Coordinate(0, 0), 0.0, 0.0, 0.005, 80.0); + try { + new WKBWriter().write(cl); + fail("core WKBWriter must not flatten clothoid"); + } + catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().indexOf("Clothoid") >= 0 + || expected.getMessage().indexOf("CurveWKBWriter") >= 0); + } + } + + public void testDefaultFactoryRequiresCurveFactory() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + ClothoidSegment cl = gf.createClothoid( + new Coordinate(0, 0), 0.0, 0.0, 0.005, 80.0); + byte[] wkb = new CurveWKBWriter().write(cl); + try { + new WKBReader().read(wkb); + fail("default factory must refuse clothoid construction"); + } + catch (Exception expected) { + // ParseException wrapping unsupported factory + assertNotNull(expected.getMessage()); + } + } + + private static int typeWord(byte[] wkb) { + // XDR: byte0=0, type at bytes 1..4 big-endian + if (wkb[0] == WKBConstants.wkbXDR) { + return ((wkb[1] & 0xff) << 24) | ((wkb[2] & 0xff) << 16) + | ((wkb[3] & 0xff) << 8) | (wkb[4] & 0xff); + } + // NDR little-endian + return (wkb[1] & 0xff) | ((wkb[2] & 0xff) << 8) + | ((wkb[3] & 0xff) << 16) | ((wkb[4] & 0xff) << 24); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBCurveZoo19_21Test.java b/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBCurveZoo19_21Test.java new file mode 100644 index 0000000000..1d3b08d23b --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/io/curve/WKBCurveZoo19_21Test.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.io.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.BezierCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.EllipseCurve; +import org.locationtech.jts.geom.curve.NurbsCurve; +import org.locationtech.jts.geom.impl.CoordinateArraySequence; +import org.locationtech.jts.io.WKBConstants; +import org.locationtech.jts.io.WKBWriter; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * WKB 19–21 greenfield round-trips (#1195 MMF SIGN). + */ +public class WKBCurveZoo19_21Test extends TestCase { + + private static final double EPS = 1e-12; + + public static void main(String[] args) { + TestRunner.run(WKBCurveZoo19_21Test.class); + } + + public WKBCurveZoo19_21Test(String name) { + super(name); + } + + public void testBezierRoundTrip() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + CoordinateArraySequence seq = new CoordinateArraySequence(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 2), new Coordinate(3, 2), + new Coordinate(4, 0) + }); + BezierCurve bz = gf.createBezierCurve(seq); + byte[] wkb = new CurveWKBWriter().write(bz); + assertEquals(WKBConstants.wkbBezier, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof BezierCurve); + assertEquals(4, back.getNumPoints()); + assertEquals(4.0, ((BezierCurve) back).getCoordinateN(3).x, EPS); + } + + public void testBezierIsoZ() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + CoordinateArraySequence seq = new CoordinateArraySequence(new Coordinate[] { + new Coordinate(0, 0, 1), new Coordinate(1, 1, 2), + new Coordinate(2, 1, 3), new Coordinate(3, 0, 4) + }); + BezierCurve bz = gf.createBezierCurve(seq); + CurveWKBWriter w = new CurveWKBWriter(3); + w.setFlavor(WKBConstants.wkbIso); + byte[] wkb = w.write(bz); + assertEquals(1019, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof BezierCurve); + assertEquals(1.0, back.getCoordinate().getZ(), EPS); + assertEquals(4.0, ((BezierCurve) back).getCoordinateN(3).getZ(), EPS); + } + + public void testEllipseIsoZ() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + EllipseCurve el = gf.createEllipseCurve(1, 2, 9, 5, 3, 0.0, 0.0, Math.PI); + CurveWKBWriter w = new CurveWKBWriter(3); + w.setFlavor(WKBConstants.wkbIso); + byte[] wkb = w.write(el); + assertEquals(1020, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof EllipseCurve); + assertEquals(9.0, ((EllipseCurve) back).getCentreZ(), EPS); + } + + public void testNurbsIsoZ() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + CoordinateArraySequence seq = new CoordinateArraySequence(new Coordinate[] { + new Coordinate(0, 0, 5), new Coordinate(1, 1, 6), + new Coordinate(2, 1, 7), new Coordinate(3, 0, 8) + }); + NurbsCurve nu = gf.createNurbsCurve(seq, 3, new double[] { 1, 1, 1, 1 }, + new double[] { 0, 0, 0, 0, 1, 1, 1, 1 }); + CurveWKBWriter w = new CurveWKBWriter(3); + w.setFlavor(WKBConstants.wkbIso); + byte[] wkb = w.write(nu); + assertEquals(1021, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof NurbsCurve); + assertEquals(5.0, back.getCoordinate().getZ(), EPS); + } + + public void testEllipseRoundTrip() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + EllipseCurve el = gf.createEllipseCurve(0, 0, Double.NaN, 5, 3, 0.0, 0.0, + 2 * Math.PI); + byte[] wkb = new CurveWKBWriter().write(el); + assertEquals(WKBConstants.wkbEllipse, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof EllipseCurve); + EllipseCurve out = (EllipseCurve) back; + assertEquals(5.0, out.getSemiMajor(), EPS); + assertEquals(3.0, out.getSemiMinor(), EPS); + assertTrue(out.isFullEllipse()); + } + + public void testNurbsRoundTrip() throws Exception { + CurveGeometryFactory gf = new CurveGeometryFactory(); + CoordinateArraySequence seq = new CoordinateArraySequence(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(2, 1), + new Coordinate(3, 0) + }); + double[] weights = { 1, 1, 1, 1 }; + double[] knots = { 0, 0, 0, 0, 1, 1, 1, 1 }; // degree 3, n=4 → 8 knots + NurbsCurve nu = gf.createNurbsCurve(seq, 3, weights, knots); + byte[] wkb = new CurveWKBWriter().write(nu); + assertEquals(WKBConstants.wkbNurbs, typeWord(wkb)); + Geometry back = new CurveWKBReader().read(wkb); + assertTrue(back instanceof NurbsCurve); + NurbsCurve out = (NurbsCurve) back; + assertEquals(3, out.getDegree()); + assertEquals(4, out.getWeights().length); + assertEquals(8, out.getKnots().length); + Geometry linear = out.toLinear(0.1); + assertTrue(linear.getNumPoints() >= 2); + } + + public void testCoreWriterRefusesFlatten() { + CurveGeometryFactory gf = new CurveGeometryFactory(); + BezierCurve bz = gf.createBezierCurve(new CoordinateArraySequence( + new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), + new Coordinate(2, 1), new Coordinate(3, 0) + })); + try { + new WKBWriter().write(bz); + fail("must not flatten Bezier"); + } + catch (IllegalArgumentException expected) { + assertNotNull(expected.getMessage()); + } + } + + private static int typeWord(byte[] wkb) { + if (wkb[0] == WKBConstants.wkbXDR) { + return ((wkb[1] & 0xff) << 24) | ((wkb[2] & 0xff) << 16) + | ((wkb[3] & 0xff) << 8) | (wkb[4] & 0xff); + } + return (wkb[1] & 0xff) | ((wkb[2] & 0xff) << 8) + | ((wkb[3] & 0xff) << 16) | ((wkb[4] & 0xff) << 24); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java index 4703cbee00..aa76c654db 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java @@ -374,12 +374,27 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception { assertEquals("small half minus hole", 4.5 * Math.PI - 1.0, punched.getArea(), EXACT); - assertNull("H-SHELL-HOLE-OUTER: hole meets the other diameter", - CompoundCurveShellOverlay.overlay(holed, right, OverlayNG.INTERSECTION)); Geometry straddle = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); - assertNull("H-SHELL-HOLE-CROSS: hole straddles the other shell", - CompoundCurveShellOverlay.overlay(straddle, right, OverlayNG.INTERSECTION)); + // H-SHELL-HOLE-CROSS: straddling hole — BiteVsHole keeps the bite inside + // the solid when the outer CAP preserves its arc. + Geometry outerHoleCross = CompoundCurveShellOverlay.overlay( + straddle, right, OverlayNG.INTERSECTION); + assertNotNull("H-SHELL-HOLE-CROSS: certified bite inside solid", + outerHoleCross); + assertEquals("quarter-disc minus hole-in-solid", 6.25 * Math.PI - 1.0, + outerHoleCross.getArea(), EXACT); + assertTrue("H-SHELL-HOLE-CROSS keeps an arc", + hasCircularString(outerHoleCross)); + // H-SHELL-HOLE-OUTER: hole edge ⊂ other diameter. BiteVsHole certifies + // the bite when the outer CAP keeps its arc (quarter-disc minus hole). + Geometry outerHoleMeet = CompoundCurveShellOverlay.overlay( + holed, right, OverlayNG.INTERSECTION); + assertNotNull("H-SHELL-HOLE-OUTER: hole on diameter is a certified bite", + outerHoleMeet); + assertEquals("quarter-disc minus hole", 6.25 * Math.PI - 1.0, + outerHoleMeet.getArea(), EXACT); + assertTrue("H-SHELL-HOLE-OUTER keeps an arc", hasCircularString(outerHoleMeet)); Geometry onDiameter = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"); // Collinear overlap is not a discrete node set; no cheap closed diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlayTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlayTest.java new file mode 100644 index 0000000000..393f60d67f --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/ClothoidOverlayTest.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import org.locationtech.jts.algorithm.distance.DiscreteHausdorffDistance; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.ClothoidSegment; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.CurveOps; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.operation.overlayng.OverlayNG; +import org.locationtech.jts.operation.overlayng.OverlayNGRobust; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Bar 2 clothoid kit: 0-node identity / disjoint / nest using the + * analytical clothoid envelope. A pair that needs a clothoid–circle + * node is {@code CLOTHOID-FRESNEL} -- named miss, never a chord + * flatten. OverlayNGCurve is never Curved. + */ +public class ClothoidOverlayTest extends GeometryTestCase { + + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String CIRCLE_CROSSING = + "CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))"; + private static final double DISC = 25.0 * Math.PI; + private static final double AREA_TOL = 1.0e-3; + private static final double EXACT = 1.0e-9; + + public static void main(String[] args) { + TestRunner.run(ClothoidOverlayTest.class); + } + + public ClothoidOverlayTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + /** + * Hole-free leftover: lead along +X, clothoid, close to the origin. + * The parent sequence of the clothoid is start+end only; the kit + * must not treat that chord as the edge. + */ + static CurvePolygon leftover(double x0, double y0, double lead, + double k0, double k1, double length) { + CurveGeometryFactory f = new CurveGeometryFactory(); + Coordinate a = new Coordinate(x0, y0); + Coordinate b = new Coordinate(x0 + lead, y0); + LineString line = f.createLineString(new Coordinate[] { a, b }); + ClothoidSegment cl = new ClothoidSegment(b, 0.0, k0, k1, length, f); + LineString close = f.createLineString(new Coordinate[] { + cl.getEndCoordinate(), a + }); + return f.createCurvePolygon( + f.createCompoundCurve(new LineString[] { line, cl, close }), null); + } + + /** Small leftover whose analytical AABB sits inside CIRCLE_5. */ + static CurvePolygon nestLeftover() { + return leftover(0.0, 0.0, 1.0, 0.0, 0.1, 2.0); + } + + /** Same leftover translated far to the right. */ + static CurvePolygon farLeftover() { + return leftover(200.0, 0.0, 1.0, 0.0, 0.1, 2.0); + } + + /** Leftover that starts at the origin and exits CIRCLE_5. */ + static CurvePolygon crossingLeftover() { + return leftover(0.0, 0.0, 1.0, 0.0, 0.05, 20.0); + } + + public void testIdentityKeepsTheClothoid() throws Exception { + Geometry a = nestLeftover(); + OverlayNGCurve op = new OverlayNGCurve(a, a); + Geometry cap = op.getResult(OverlayNG.INTERSECTION); + assertFalse("CLOTHOID-ID CAP is exact", op.isApproximate()); + assertKeepsClothoid(cap); + assertEquals("identity CAP is the leftover", a.getArea(), cap.getArea(), + EXACT); + + OverlayNGCurve sub = new OverlayNGCurve(a, a); + Geometry empty = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("CLOTHOID-ID SUB is exact", sub.isApproximate()); + assertTrue(empty.isEmpty()); + + Geometry kit = CompoundCurveShellOverlay.overlay(a, a, + OverlayNG.INTERSECTION); + assertNotNull("kit answers identity", kit); + assertKeepsClothoid(kit); + } + + public void testDisjointEnvelopesKeepBothShells() throws Exception { + Geometry a = nestLeftover(); + Geometry b = farLeftover(); + assertFalse("test premise: analytical envelopes do not meet", + a.getEnvelopeInternal().intersects(b.getEnvelopeInternal())); + + OverlayNGCurve cap = new OverlayNGCurve(a, b); + Geometry empty = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("CLOTHOID-DISJOINT CAP is exact", cap.isApproximate()); + assertTrue(empty.isEmpty()); + + OverlayNGCurve cup = new OverlayNGCurve(a, b); + Geometry both = cup.getResult(OverlayNG.UNION); + assertFalse("CLOTHOID-DISJOINT CUP is exact", cup.isApproximate()); + assertEquals("two members", 2, both.getNumGeometries()); + assertKeepsClothoid(both.getGeometryN(0)); + assertKeepsClothoid(both.getGeometryN(1)); + + Geometry kit = CompoundCurveShellOverlay.overlay(a, b, + OverlayNG.UNION); + assertNotNull("kit answers envelope-disjoint", kit); + assertEquals(2, kit.getNumGeometries()); + } + + public void testNestInsideDiscKeepsTheClothoidHole() throws Exception { + Geometry inner = nestLeftover(); + Geometry disc = readCurve(CIRCLE_5); + assertTrue("test premise: leftover AABB is inside the disc", + ClothoidOverlay.strictlyInsideDisc((CurvePolygon) inner, + CircularDiscOverlay.centreRadius(disc))); + + OverlayNGCurve cap = new OverlayNGCurve(inner, disc); + Geometry clip = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("CLOTHOID-NEST CAP is exact", cap.isApproximate()); + assertKeepsClothoid(clip); + assertParity(inner, disc, OverlayNG.INTERSECTION, clip); + + OverlayNGCurve cup = new OverlayNGCurve(inner, disc); + Geometry outer = cup.getResult(OverlayNG.UNION); + assertFalse("CLOTHOID-NEST CUP is exact", cup.isApproximate()); + assertEquals("outer disc", DISC, outer.getArea(), EXACT); + + OverlayNGCurve bite = new OverlayNGCurve(disc, inner); + Geometry holed = bite.getResult(OverlayNG.DIFFERENCE); + assertFalse("CLOTHOID-NEST SUB is exact (not the chordsaw)", + bite.isApproximate()); + assertEquals("one clothoid hole", 1, + ((CurvePolygon) holed).getNumInteriorRing()); + assertKeepsClothoidHole((CurvePolygon) holed); + assertParity(disc, inner, OverlayNG.DIFFERENCE, holed); + + OverlayNGCurve rev = new OverlayNGCurve(inner, disc); + Geometry empty = rev.getResult(OverlayNG.DIFFERENCE); + assertFalse("inner \\ disc is exact", rev.isApproximate()); + assertTrue(empty.isEmpty()); + + Geometry kit = CompoundCurveShellOverlay.overlay(disc, inner, + OverlayNG.DIFFERENCE); + assertNotNull("kit answers the nest punch", kit); + assertEquals(1, ((CurvePolygon) kit).getNumInteriorRing()); + assertKeepsClothoidHole((CurvePolygon) kit); + } + + public void testCrossingDiscIsNamedFresnelMiss() throws Exception { + Geometry cloth = crossingLeftover(); + Geometry disc = readCurve(CIRCLE_5); + assertTrue("test premise: envelopes meet", + cloth.getEnvelopeInternal().intersects(disc.getEnvelopeInternal())); + assertFalse("test premise: leftover is not nested in the disc", + ClothoidOverlay.strictlyInsideDisc((CurvePolygon) cloth, + CircularDiscOverlay.centreRadius(disc))); + + assertNull("CLOTHOID-FRESNEL: clothoid–circle nodes are not a kit", + CompoundCurveShellOverlay.overlay(cloth, disc, + OverlayNG.INTERSECTION)); + assertNull(ClothoidOverlay.overlay(cloth, disc, OverlayNG.INTERSECTION)); + + OverlayNGCurve op = new OverlayNGCurve(cloth, disc); + Geometry r = op.getResult(OverlayNG.INTERSECTION); + assertTrue("crossing falls to the chordsaw", op.isApproximate()); + assertFalse(r.isEmpty()); + } + + public void testFlattenDoesNotTreatClothoidAsAChord() throws Exception { + Geometry cloth = nestLeftover(); + assertNull("flatten refuses a clothoid member", + TwoNodeClip.flatten((CurvePolygon) cloth)); + + Geometry mixed = mixedArcAndClothoid(); + Geometry disc = readCurve(CIRCLE_CROSSING); + assertTrue(ClothoidOverlay.hasClothoid(mixed)); + assertNull("mixed clothoid+arc vs disc is not a dishonest lens", + CompoundCurveShellOverlay.overlay(mixed, disc, + OverlayNG.INTERSECTION)); + OverlayNGCurve op = new OverlayNGCurve(mixed, disc); + op.getResult(OverlayNG.INTERSECTION); + assertTrue("mixed clothoid+arc falls to R2, not a chord two-node walk", + op.isApproximate()); + } + + /** + * Semicircle plus a clothoid off the diameter. Flatten would turn + * the clothoid into the diameter chord and answer the half-lens. + */ + private static Geometry mixedArcAndClothoid() { + CurveGeometryFactory f = new CurveGeometryFactory(); + CircularString arc = f.createCircularString( + f.getCoordinateSequenceFactory().create(new Coordinate[] { + new Coordinate(-5, 0), new Coordinate(0, 5), new Coordinate(5, 0) + })); + ClothoidSegment cl = new ClothoidSegment( + new Coordinate(5, 0), Math.PI, 0.0, 0.02, 3.0, f); + LineString close = f.createLineString(new Coordinate[] { + cl.getEndCoordinate(), new Coordinate(-5, 0) + }); + return f.createCurvePolygon( + f.createCompoundCurve(new LineString[] { arc, cl, close }), null); + } + + private static void assertKeepsClothoid(Geometry g) { + if (g.getNumGeometries() > 1) { + assertKeepsClothoid(g.getGeometryN(0)); + return; + } + assertEquals("CurvePolygon", g.getGeometryType()); + LineString shell = ((CurvePolygon) g).getExteriorCurve(); + assertTrue("shell is a CompoundCurve, got " + shell.getGeometryType(), + shell instanceof CompoundCurve); + assertTrue("CompoundCurve shell keeps a ClothoidSegment", + hasClothoidMember((CompoundCurve) shell)); + } + + private static void assertKeepsClothoidHole(CurvePolygon cp) { + LineString hole = cp.getInteriorCurveN(0); + assertTrue(hole instanceof CompoundCurve); + assertTrue("hole keeps a ClothoidSegment", + hasClothoidMember((CompoundCurve) hole)); + } + + private static boolean hasClothoidMember(CompoundCurve cc) { + for (int i = 0; i < cc.getNumMembers(); i++) { + if (cc.getMemberN(i) instanceof ClothoidSegment) return true; + } + return false; + } + + private static void assertParity(Geometry a, Geometry b, int opCode, + Geometry laser) { + Geometry chord = OverlayNGRobust.overlay( + CurveOps.linearise(a), CurveOps.linearise(b), opCode); + // CurvePolygon.getArea() still shoelaces a clothoid as its chord. + // Parity is against the densified leftover, not that control-polygon. + assertEquals("area vs chord overlay", chord.getArea(), + CurveOps.linearise(laser).getArea(), AREA_TOL); + double hd = DiscreteHausdorffDistance.distance( + CurveOps.linearise(laser), chord); + assertTrue("Hausdorff vs chord overlay " + hd + " > " + AREA_TOL, + hd <= AREA_TOL); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java index be3249d8f2..b9973f866f 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java @@ -12,6 +12,7 @@ package org.locationtech.jts.operation.overlayng.curve; import org.locationtech.jts.algorithm.distance.DiscreteHausdorffDistance; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.curve.CircularString; @@ -35,10 +36,15 @@ * n-span with the touch as a zero-length span. A same-outer * hole-inside pair is the holed cell. A different-outer hole * composes when it sits strictly inside or outside a certified - * outer CAP. A three-point LineString is not an arc. Collinear - * overlap, mixed labels, a hole that meets or crosses the other - * outer stay {@code null} so OverlayNGCurve can take R2 without - * paying this path first. + * outer CAP. A hole that straddles the other shell shares the + * clip edge: if that new edge is a subset of the other shell + * it is a bite, not an interior punch. A hole that does not + * cross but whose ring overlaps the other shell is the same + * bite. Two holes that cross + * on the same outer are the two-hole arrangement. A + * three-point LineString is not an arc. Collinear overlap + * and mixed labels stay {@code null} so OverlayNGCurve can + * take R2 without paying this path first. */ public class CompoundCurveShellOverlayTest extends GeometryTestCase { @@ -600,32 +606,224 @@ public void testDifferentOuterHoleComplementaryIsDiscMinusHole() assertEquals("disc minus the rectangle", DISC - 1.0, both.getArea(), EXACT); } + /** + * H-SHELL-HOLE-CROSS: the hole straddles the other shell. Even-n + * hole ∩ other shares the clip edge (0,1)–(0,2) on the vertical + * diameter. That new edge ⊂ other.shell, so the walk says bite, + * not a punched hole. + */ + public void testHoleStraddleIsABiteNotAHole() throws Exception { + Geometry straddle = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); + Geometry right = readCurve(HALF_RIGHT); + assertEquals("new edge ⊂ other.shell is a bite", BiteVsHole.BITE, + BiteVsHole.decide(straddle, right)); + CurveSegmentString clip = BiteVsHole.clipEdge(straddle, right); + assertNotNull("clip edge is the shared diameter run", clip); + assertFalse(clip.isArc()); + assertFalse(clip.isDegenerate()); + assertEquals(1.0, clip.length(), EXACT); + assertTrue("clip edge (0 1)–(0 2)", + (clip.getStart().distance(new Coordinate(0, 1)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 2)) <= EXACT) + || (clip.getStart().distance(new Coordinate(0, 2)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 1)) <= EXACT)); + + OverlayNGCurve cap = new OverlayNGCurve(straddle, right); + Geometry q = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-HOLE-CROSS CAP is exact (bite, not hole)", + cap.isApproximate()); + assertEquals("Q1 minus the right half-rectangle", 6.25 * Math.PI - 1.0, + q.getArea(), EXACT); + assertEquals("bite is a shell, not an interior ring", 0, + ((CurvePolygon) q).getNumInteriorRing()); + assertArcAndLineShell(q); + assertParity(straddle, right, OverlayNG.INTERSECTION, q); + + OverlayNGCurve cup = new OverlayNGCurve(straddle, right); + Geometry u = cup.getResult(OverlayNG.UNION); + assertFalse("H-SHELL-HOLE-CROSS CUP is exact", cup.isApproximate()); + assertEquals("three-quarter minus the leftover hole", + 18.75 * Math.PI - 1.0, u.getArea(), EXACT); + assertEquals("leftover is a hole (new edge not on the CUP shell)", + 1, ((CurvePolygon) u).getNumInteriorRing()); + + OverlayNGCurve sub = new OverlayNGCurve(straddle, right); + Geometry ears = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS SUB is exact", sub.isApproximate()); + assertEquals("Q2 minus the left half-rectangle", 6.25 * Math.PI - 1.0, + ears.getArea(), EXACT); + assertEquals("SUB bite is a shell", 0, + ((CurvePolygon) ears).getNumInteriorRing()); + + OverlayNGCurve rev = new OverlayNGCurve(right, straddle); + Geometry other = rev.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS reverse SUB is exact", + rev.isApproximate()); + assertEquals("Q4 plus the right half-rectangle", 6.25 * Math.PI + 1.0, + other.getArea(), EXACT); + + OverlayNGCurve xor = new OverlayNGCurve(straddle, right); + Geometry x = xor.getResult(OverlayNG.SYMDIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS XOR is exact", xor.isApproximate()); + assertEquals("both bites", 12.5 * Math.PI, x.getArea(), EXACT); + } + + /** + * H-SHELL-HOLE-X: same HALF_DISC outer, two axis-aligned unit + * squares that overlap in [0.5,1] × [1,1.5]. The noder names + * the two hole–hole nodes; the walk assembles the hole faces. + * A = HALF_DISC \ holeA, B = HALF_DISC \ holeB. + */ + public void testTwoHolesThatCrossAreAnArrangement() throws Exception { + Geometry holed = readCurve(HALF_HOLED); + Geometry holeX = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"); + assertEquals("two hole rings that cross", TwoHoleOverlay.CROSS, + TwoHoleOverlay.decide(holed, holeX)); + Coordinate[] nodes = TwoHoleOverlay.clipNodes(holed, holeX); + assertNotNull("clip pair is the two hole–hole nodes", nodes); + assertEquals(2, nodes.length); + assertTrue("node (0.5 1)", + nodes[0].distance(new Coordinate(0.5, 1)) <= EXACT + || nodes[1].distance(new Coordinate(0.5, 1)) <= EXACT); + assertTrue("node (1 1.5)", + nodes[0].distance(new Coordinate(1, 1.5)) <= EXACT + || nodes[1].distance(new Coordinate(1, 1.5)) <= EXACT); + + OverlayNGCurve cap = new OverlayNGCurve(holed, holeX); + Geometry bothHoles = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-HOLE-X CAP is exact", cap.isApproximate()); + assertEquals("HALF_DISC minus holeA ∪ holeB", HALF - 1.75, + bothHoles.getArea(), EXACT); + assertEquals("union of the holes is one punch", 1, + ((CurvePolygon) bothHoles).getNumInteriorRing()); + assertArcAndLineShell(bothHoles); + assertParity(holed, holeX, OverlayNG.INTERSECTION, bothHoles); + + OverlayNGCurve cup = new OverlayNGCurve(holed, holeX); + Geometry overlapHole = cup.getResult(OverlayNG.UNION); + assertFalse("H-SHELL-HOLE-X CUP is exact", cup.isApproximate()); + assertEquals("HALF_DISC minus holeA ∩ holeB", HALF - 0.25, + overlapHole.getArea(), EXACT); + assertEquals("overlap rectangle is the leftover hole", 1, + ((CurvePolygon) overlapHole).getNumInteriorRing()); + assertParity(holed, holeX, OverlayNG.UNION, overlapHole); + + OverlayNGCurve sub = new OverlayNGCurve(holed, holeX); + Geometry onlyB = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-X SUB is exact", sub.isApproximate()); + assertEquals("holeB \\ holeA", 0.75, onlyB.getArea(), EXACT); + assertParity(holed, holeX, OverlayNG.DIFFERENCE, onlyB); + + OverlayNGCurve rev = new OverlayNGCurve(holeX, holed); + Geometry onlyA = rev.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-X reverse SUB is exact", rev.isApproximate()); + assertEquals("holeA \\ holeB", 0.75, onlyA.getArea(), EXACT); + + OverlayNGCurve xor = new OverlayNGCurve(holed, holeX); + Geometry x = xor.getResult(OverlayNG.SYMDIFFERENCE); + assertFalse("H-SHELL-HOLE-X XOR is exact", xor.isApproximate()); + assertEquals("holeA △ holeB", 1.5, x.getArea(), EXACT); + assertEquals("two hole-difference faces", 2, x.getNumGeometries()); + assertParity(holed, holeX, OverlayNG.SYMDIFFERENCE, x); + } + + /** + * H-SHELL-HOLE-OUTER: the hole sits entirely in the other solid + * and its ring overlaps the other diameter. Not two crossing + * nodes -- P2.2 names (0 1)–(0 2) as an edge. Same bite + * predicate as HOLE-CROSS (hole-edge ⊂ other.shell). CUP is + * the three-quarter: the solid fills the hole. + */ + public void testHoleMeetsTheOtherDiameterIsABite() throws Exception { + Geometry holed = readCurve(HALF_HOLED); + Geometry right = readCurve(HALF_RIGHT); + assertEquals("hole-edge ⊂ other.shell is a bite", BiteVsHole.BITE, + BiteVsHole.decide(holed, right)); + CurveSegmentString clip = BiteVsHole.clipEdge(holed, right); + assertNotNull("clip edge is the shared diameter run", clip); + assertFalse(clip.isArc()); + assertFalse(clip.isDegenerate()); + assertEquals(1.0, clip.length(), EXACT); + assertTrue("clip edge (0 1)–(0 2)", + (clip.getStart().distance(new Coordinate(0, 1)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 2)) <= EXACT) + || (clip.getStart().distance(new Coordinate(0, 2)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 1)) <= EXACT)); + + OverlayNGCurve cap = new OverlayNGCurve(holed, right); + Geometry q = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter CAP is exact", + cap.isApproximate()); + assertEquals("Q1 minus the rectangle", 6.25 * Math.PI - 1.0, + q.getArea(), EXACT); + assertEquals("bite is a shell, not an interior ring", 0, + ((CurvePolygon) q).getNumInteriorRing()); + assertArcAndLineShell(q); + assertParity(holed, right, OverlayNG.INTERSECTION, q); + + OverlayNGCurve cup = new OverlayNGCurve(holed, right); + Geometry u = cup.getResult(OverlayNG.UNION); + assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter CUP is exact", + cup.isApproximate()); + assertEquals("three-quarter; the solid fills the hole", + 18.75 * Math.PI, u.getArea(), EXACT); + assertEquals("CUP has no leftover hole", 0, + ((CurvePolygon) u).getNumInteriorRing()); + assertParity(holed, right, OverlayNG.UNION, u); + + OverlayNGCurve sub = new OverlayNGCurve(holed, right); + Geometry ears = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter SUB is exact", + sub.isApproximate()); + assertEquals("Q2; the hole was entirely in the other", + 6.25 * Math.PI, ears.getArea(), EXACT); + assertEquals("SUB is a shell", 0, + ((CurvePolygon) ears).getNumInteriorRing()); + assertParity(holed, right, OverlayNG.DIFFERENCE, ears); + + OverlayNGCurve rev = new OverlayNGCurve(right, holed); + Geometry other = rev.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter reverse SUB is exact", + rev.isApproximate()); + assertEquals("Q4 plus the rectangle", 6.25 * Math.PI + 1.0, + other.getArea(), EXACT); + assertParity(right, holed, OverlayNG.DIFFERENCE, other); + + OverlayNGCurve xor = new OverlayNGCurve(holed, right); + Geometry x = xor.getResult(OverlayNG.SYMDIFFERENCE); + assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter XOR is exact", + xor.isApproximate()); + assertEquals("both ears plus the rectangle", 12.5 * Math.PI + 1.0, + x.getArea(), EXACT); + assertParity(holed, right, OverlayNG.SYMDIFFERENCE, x); + } + public void testNotThisCellReturnsNull() throws Exception { Geometry half = readCurve(HALF_DISC); Geometry disc = readCurve(CIRCLE_5); Geometry other = readCurve(CIRCLE_CROSSING); Geometry square = readCurve(SQUARE_CAP); Geometry chords = readCurve(CHORD_SHELL); - Geometry holed = readCurve(HALF_HOLED); - Geometry right = readCurve(HALF_RIGHT); Geometry onDiameter = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"); - Geometry straddle = readCurve( - "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); assertNull("two discs stay on R1.5", CompoundCurveShellOverlay.overlay(disc, other, OverlayNG.INTERSECTION)); assertNull("plain vs plain", CompoundCurveShellOverlay.overlay(square, square, OverlayNG.UNION)); - assertNull("H-SHELL-HOLE-OUTER: hole meets the other diameter", - CompoundCurveShellOverlay.overlay(holed, right, OverlayNG.INTERSECTION)); - assertNull("H-SHELL-HOLE-CROSS: hole straddles the other shell", - CompoundCurveShellOverlay.overlay(straddle, right, OverlayNG.INTERSECTION)); // Collinear overlap is not a discrete node set; no cheap closed // form without a noder. assertNull("H-SHELL-N-MIXED: collinear overlap stays refused", CompoundCurveShellOverlay.overlay(half, onDiameter, OverlayNG.INTERSECTION)); assertNull("line-only shell", CompoundCurveShellOverlay.overlay(chords, square, OverlayNG.INTERSECTION)); + Geometry stadiumNest = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))"); + // Mixed stadium strictly inside CIRCLE_5: not two discs, and + // clip() is two-node only. TwoShellClip never runs. Named miss. + assertNull("CC-NEST-ANNULUS: 0-node mixed-vs-disc is not a punch", + CompoundCurveShellOverlay.overlay(disc, stadiumNest, OverlayNG.DIFFERENCE)); } private static void assertParity(Geometry a, Geometry b, int opCode, diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelTest.java new file mode 100644 index 0000000000..8c894edc80 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveHotPixelTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import org.locationtech.jts.geom.Coordinate; + +import junit.framework.TestCase; +import junit.textui.TestRunner; + +/** + * HP.2 / HP.3: {@link CurveHotPixel} arc ∩ pixel and + * {@link CurveHotPixelSnap} grid headings (folded from pin stack). + */ +public class CurveHotPixelTest extends TestCase { + + private static final double HP2_SCALE = 10.0; + private static final double LEAVE_ANGLE_EPS = 1.0e-8; + + public static void main(String[] args) { + TestRunner.run(CurveHotPixelTest.class); + } + + public CurveHotPixelTest(String name) { + super(name); + } + + public void testCurveHotPixelHitsLeaveArcsAtTangentNode() { + CurveHotPixel pixel = new CurveHotPixel(c(0, 5), HP2_SCALE); + assertTrue(pixel.intersects(c(0, 5))); + assertTrue(pixel.intersects(halfDiscArc())); + assertTrue(pixel.intersects(stadiumOddCap())); + } + + public void testCurveHotPixelMissesHangingArc() { + CurveHotPixel pixel = new CurveHotPixel(c(0, 5), HP2_SCALE); + assertFalse(pixel.intersects(hangingArc())); + } + + public void testCurveHotPixelIsNotChordFake() { + CurveHotPixel pixel = new CurveHotPixel(c(0, 5), HP2_SCALE); + assertFalse(pixel.intersects(CurveSegmentString.segment(c(-5, 0), c(5, 0)))); + assertFalse(pixel.intersects(CurveSegmentString.segment(c(-1, 4), c(1, 4)))); + assertTrue(pixel.intersects(halfDiscArc())); + } + + public void testSnappedHeadingsShareRayAtScale10() { + CurveHotPixel pixel = new CurveHotPixel(c(0, 5), HP2_SCALE); + CurveSegmentString halfLeave = leaveFrom(halfDiscArc(), c(0, 5), c(5, 0)); + CurveSegmentString stadiumLeave = leaveFrom(stadiumOddCap(), c(0, 5), + c(1, 4)); + CurveHotPixelSnap.Heading halfH = CurveHotPixelSnap.heading(pixel, + halfLeave); + CurveHotPixelSnap.Heading stadiumH = CurveHotPixelSnap.heading(pixel, + stadiumLeave); + assertNotNull(halfH); + assertNotNull(stadiumH); + assertEquals(halfH, stadiumH); + assertEquals(CurveHotPixelSnap.SHARED_SNAPPED_RAY, + CurveHotPixelSnap.sharedRayOrNull(halfH, stadiumH)); + } + + public void testHangingHasNoHeadingAtTangentPixel() { + CurveHotPixel pixel = new CurveHotPixel(c(0, 5), HP2_SCALE); + assertNull(CurveHotPixelSnap.heading(pixel, hangingArc())); + } + + public void testUnsnappedLeavesStillCoincident() { + CurveSegmentString halfLeave = leaveFrom(halfDiscArc(), c(0, 5), c(5, 0)); + CurveSegmentString stadiumLeave = leaveFrom(stadiumOddCap(), c(0, 5), + c(1, 4)); + assertEquals(0.0, leaveAngle(halfLeave), LEAVE_ANGLE_EPS); + assertEquals(0.0, leaveAngle(stadiumLeave), LEAVE_ANGLE_EPS); + } + + private static Coordinate c(double x, double y) { + return new Coordinate(x, y); + } + + private static CurveSegmentString halfDiscArc() { + return CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)); + } + + private static CurveSegmentString stadiumOddCap() { + return CurveSegmentString.arc(c(-1, 4), c(0, 5), c(1, 4)); + } + + private static CurveSegmentString hangingArc() { + return CurveSegmentString.arc(c(-5, 8), c(0, 3), c(5, 8)); + } + + private static CurveSegmentString leaveFrom(CurveSegmentString full, + Coordinate pinch, Coordinate toward) { + Coordinate mid = TwoNodeClip.midOnSweep(pinch, toward, full.asEdge()); + return CurveSegmentString.arc(pinch, mid, toward); + } + + private static double leaveAngle(CurveSegmentString s) { + Coordinate from = s.getStart(); + if (!s.isArc()) { + return Math.atan2(s.getEnd().y - from.y, s.getEnd().x - from.x); + } + TwoNodeClip.Edge e = s.asEdge(); + double rx = from.x - e.circle[0]; + double ry = from.y - e.circle[1]; + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + return ccw ? Math.atan2(rx, -ry) : Math.atan2(-rx, ry); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java new file mode 100644 index 0000000000..cbb7855300 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.Arrays; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * P2.5.7 curve DCEL. Pins half-edge / twin / next / prev / incident + * face on arrangements this stack already names: two-disc crossing, + * MIXED shared-edge, STADIUM_FOUR N=3 (nine bounded faces). Stamps + * coincident leave-angle and MIXED-hides-crossing. Not a noder, + * not OverlayNG-for-circles, not a Geometry assembler. + */ +public class CurveSegmentDcelTest extends GeometryTestCase { + + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String CIRCLE_CROSSING = + "CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))"; + private static final String HALF_DISC = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))"; + private static final String HALF_HANGING = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 8, 0 3, 5 8), (5 8, -5 8)))"; + private static final String STADIUM_FOUR = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, 0 -2, 1 -1), (1 -1, 1 6), CIRCULARSTRING (1 6, 0 7, -1 6), (-1 6, -1 -1)))"; + private static final String STADIUM_ODD = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 4, 0 5, 1 4), (1 4, 1 -1), CIRCULARSTRING (1 -1, 0 -2, -1 -1), (-1 -1, -1 4)))"; + private static final String ON_DIAMETER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"; + private static final String HALF_CROSSING_UPPER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (2 0, 7 5, 12 0), (12 0, 2 0)))"; + private static final String CIRCLE_INT_TAN = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 2 3, 5 0, 2 -3, -1 0))"; + private static final String UNIT_DISC = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 0 1, 1 0, 0 -1, -1 0))"; + private static final String UNIT_DISC_TOUCH = + "CURVEPOLYGON (CIRCULARSTRING (1 0, 2 1, 3 0, 2 -1, 1 0))"; + private static final String HALF_HOLED = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0 1, 1 1, 1 2, 0 2, 0 1))"; + private static final String HOLE_X = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"; + + private static final double EXACT = 1.0e-12; + private static final double SQRT_12_75 = Math.sqrt(12.75); + private static final double HALF = 12.5 * Math.PI; + private static final double LENS = 50.0 * Math.acos(0.8) - 24.0; + private static final double FOUR_CAP = 25.0 * Math.asin(0.2) + 2.0 * Math.sqrt(6.0); + private static final double N3_TRIPLE = 2.0 * FOUR_CAP - 16.0; + private static final double N3_LENS_SIDE = 0.5 * (LENS - N3_TRIPLE); + private static final double N3_AC_NOT_B = 16.0 - FOUR_CAP; + private static final double N3_BC_NOT_A = 12.0 + 0.5 * Math.PI - FOUR_CAP; + private static final double N3_C_BOTTOM = 2.0 + 0.5 * Math.PI; + private static final double N3_A_EAR = 0.5 * (HALF - LENS - N3_AC_NOT_B); + private static final double N3_B_REST = HALF - LENS - N3_BC_NOT_A; + private static final double N3_UNION = 25.5 * Math.PI - LENS + 2.0; + + public static void main(String[] args) { + TestRunner.run(CurveSegmentDcelTest.class); + } + + public CurveSegmentDcelTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + /** + * R1.5 two-disc: sewn at the radical-axis pair. Twins reverse the + * same arc; next/prev close; every half has a left face. Three + * bounded cells (lens + two crescents) plus the exterior. + */ + public void testTwoDiscCrossingPinsTwinsAndCycles() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_CROSSING); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b }); + assertNotNull("R1.5 DCEL", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("three bounded faces", 3, dcel.boundedFaces().size()); + assertTrue("plus the unbounded exterior", dcel.faces().size() >= 4); + + CurveSegmentDcel.Half alongA = findHalfNear(dcel, 3.5, SQRT_12_75, + 5.0, 0.0); + assertNotNull("half from the upper node along CIRCLE_5", alongA); + assertTrue("member stays an arc", alongA.isArc()); + assertTwinReverses(alongA); + assertEquals("upper node is degree 4", 4, + vertexAt(dcel, 3.5, SQRT_12_75).degree()); + assertEquals("lower node is degree 4", 4, + vertexAt(dcel, 3.5, -SQRT_12_75).degree()); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b)); + CurveSegmentDcel viaStrings = CurveSegmentDcel.of(groups, 12.0); + assertNotNull(viaStrings); + assertLinks(viaStrings); + assertEquals(3, viaStrings.boundedFaces().size()); + } + + /** + * H-SHELL-N-MIXED: nodes stay null (the overlap is an interval). + * The named diameter is a shared edge. T-junctions at (±1, 0) + * are degree 3. Inner + bite are the bounded cells. + */ + public void testMixedSharedEdgePinsTwinsAndCycles() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry on = readCurve(ON_DIAMETER); + assertNull("MIXED nodes stay null", CurveSegmentNoder.nodes(half, on)); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { half, on }); + assertNotNull("MIXED DCEL from the named interval", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("inner + bite", 2, dcel.boundedFaces().size()); + assertHasBoundedArea(dcel, 2.0 + 0.5 * Math.PI); + assertHasBoundedArea(dcel, HALF - 2.0 - 0.5 * Math.PI); + + CurveSegmentDcel.Vertex left = vertexAt(dcel, -1.0, 0.0); + CurveSegmentDcel.Vertex right = vertexAt(dcel, 1.0, 0.0); + assertEquals("T-junction at (-1 0)", 3, left.degree()); + assertEquals("T-junction at (1 0)", 3, right.degree()); + + CurveSegmentDcel.Half shared = findChordHalf(dcel, -1.0, 0.0, 1.0, 0.0); + assertNotNull("shared diameter half", shared); + assertFalse("shared run stays a chord", shared.isArc()); + assertTwinReverses(shared); + assertTrue("twin faces differ across the shared edge", + shared.face() != shared.twin().face()); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_FOUR. Faces already names + * nine bounded cells. The DCEL is that walk with twins and + * cycle links, not another Geometry assembler. + */ + public void testStadiumFourN3PinsNineFaces() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_HANGING); + Geometry c = readCurve(STADIUM_FOUR); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b, c }); + assertNotNull("N=3 DCEL", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("nine bounded faces", 9, dcel.boundedFaces().size()); + assertHasBoundedArea(dcel, N3_TRIPLE); + assertHasBoundedArea(dcel, N3_LENS_SIDE); + assertHasBoundedArea(dcel, N3_AC_NOT_B); + assertHasBoundedArea(dcel, N3_BC_NOT_A); + assertHasBoundedArea(dcel, N3_C_BOTTOM); + assertHasBoundedArea(dcel, N3_A_EAR); + assertHasBoundedArea(dcel, N3_B_REST); + double bounded = 0.0; + List cells = dcel.boundedFaces(); + for (int i = 0; i < cells.size(); i++) { + bounded += cells.get(i).signedArea(); + } + assertEquals("bounded cells fill the union", N3_UNION, bounded, 1.0e-8); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b), + CurveSegmentString.of(c)); + CurveSegmentDcel viaStrings = CurveSegmentDcel.of(groups, 16.0); + assertNotNull(viaStrings); + assertLinks(viaStrings); + assertEquals(9, viaStrings.boundedFaces().size()); + } + + /** + * HALF_DISC × STADIUM_FOUR is a sewn 4-node pair. Members stay + * arc or chord; twins and cycles close. + */ + public void testStadiumFourPairPinsTwins() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry stadium = readCurve(STADIUM_FOUR); + CurveSegmentDcel dcel = CurveSegmentDcel.of( + new Geometry[] { half, stadium }); + assertNotNull(dcel); + assertLinks(dcel); + assertTrue("CAP + XOR cells", dcel.boundedFaces().size() >= 3); + boolean sawArc = false; + boolean sawChord = false; + List halves = dcel.halves(); + for (int i = 0; i < halves.size(); i++) { + if (halves.get(i).isArc()) { + sawArc = true; + } + else { + sawChord = true; + } + } + assertTrue("arc member survives", sawArc); + assertTrue("chord member survives", sawChord); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_ODD: coincident leave-angle + * at the tangent. Snap-rounding, not a HotPixel. Named stamp. + */ + public void testTangentLeaveAngleStampsNull() throws Exception { + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { + readCurve(HALF_DISC), readCurve(HALF_HANGING), + readCurve(STADIUM_ODD) }); + assertNull("N≥3 near-tangent is P2.5.4, not a DCEL", dcel); + assertEquals("snap-rounding: coincident leave-angle", + CurveSegmentDcel.TANGENT_LEAVE_ANGLE, + CurveSegmentDcel.missReason()); + } + + /** + * Locationtech #1224 / #1226 on this walk, not on core + * HalfEdge / Quadrant. Subtracted leave-vectors can make + * (1 1)→(0 0.5) and (1 1)→(0 0.49999999999999994) look equal + * under atan2; endpoint quadrant + orientation keeps them + * distinct and antisymmetric. Not a TANGENT stamp. + */ + public void testLeaveAngleCompareRobust() { + Coordinate o = new Coordinate(1, 1); + CurveSegmentDcel.Half upper = chordHalf(o, 0, 0.5); + CurveSegmentDcel.Half lower = chordHalf(o, 0, 0.49999999999999994); + CurveSegmentDcel.Half north = chordHalf(o, 0, 1); + assertTrue("edges with distinct direction points must not compare equal", + CurveSegmentDcel.compareLeave(upper, lower) != 0); + assertTrue("leave comparison must be antisymmetric", + CurveSegmentDcel.compareLeave(upper, lower) + == -CurveSegmentDcel.compareLeave(lower, upper)); + assertTrue("north is a different leave", + CurveSegmentDcel.compareLeave(upper, north) != 0); + assertTrue("distinct direction points are not a TANGENT stamp", + !CurveSegmentDcel.leavesCoincide(upper, lower)); + assertEquals("same-ray leave is coincident", + 0, CurveSegmentDcel.compareLeave(upper, chordHalf(o, -1, 0))); + } + + /** + * HALF_DISC and STADIUM_ODD leave (0 5) on the same east + * tangent. The robust compare still ties, so the N=3 walk + * keeps the TANGENT_LEAVE_ANGLE stamp. + */ + public void testArcLeaveTangentStillCoincident() { + Coordinate o = new Coordinate(0, 5); + double s = Math.sqrt(0.5); + CurveSegmentString disc = CurveSegmentString.arc(o, + new Coordinate(5 * s, 5 * s), new Coordinate(5, 0)); + CurveSegmentString cap = CurveSegmentString.arc(o, + new Coordinate(s, 4 + s), new Coordinate(1, 4)); + CurveSegmentDcel.Half a = new CurveSegmentDcel.Half(o, disc.getEnd(), + disc); + CurveSegmentDcel.Half b = new CurveSegmentDcel.Half(o, cap.getEnd(), + cap); + assertTrue("both pieces stay arcs", a.isArc() && b.isArc()); + assertTrue("same leave tangent at (0 5)", + CurveSegmentDcel.leavesCoincide(a, b)); + } + + /** + * HALF_DISC × HALF_CROSSING_UPPER: collinear diameters abort the + * node set and hide the arc–arc lens nodes. Generic + * {@code nodes==null} walk is unsafe. Named stamp, not a noder. + */ + public void testMixedHidesCrossingStampsNull() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_CROSSING_UPPER); + assertNull("collinear pair aborts the node set", + CurveSegmentNoder.nodes(a, b)); + List edges = CurveSegmentNoder.edges(a, b); + assertNotNull(edges); + assertTrue("noder still names the diameter overlap", !edges.isEmpty()); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b }); + assertNull("do not sew a DCEL over a hidden crossing", dcel); + assertEquals(CurveSegmentDcel.MIXED_HIDES_CROSSING, + CurveSegmentDcel.missReason()); + } + + /** + * onString stays in R². A flat (colinear) triple is a chord + * whose sagitta residual is 0. A high-sagitta arc accepts an + * on-circle point by {@code |dx²+dy² − R²| ≤ eps²}, not + * {@code hypot(d) − R} and not a sagitta quotient. + * leaveAngle / compareLeave are untouched. + */ + public void testOnStringExtremeSagittasStayInR2() { + double eps = 1.0e-9; + double eps2 = eps * eps; + + CurveSegmentString flat = CurveSegmentString.arc( + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(2, 0)); + assertFalse("flat sagitta is a chord", flat.isArc()); + Coordinate flatMid = new Coordinate(1, 0); + assertTrue("flat sagitta → 0: midpoint is on the chord", + CurveSegmentDcel.onString(flat, flatMid, eps)); + double flatDx = flatMid.x - 1.0; + double flatDy = flatMid.y - 0.0; + assertEquals("flat sagitta residual is 0 in R²", + 0.0, flatDx * flatDx + flatDy * flatDy, 0.0); + assertTrue("on-chord within eps²", + CurveSegmentDcel.onString(flat, new Coordinate(1, 0.5 * eps), eps)); + assertFalse("off-chord by more than eps", + CurveSegmentDcel.onString(flat, new Coordinate(1, 2.0 * eps), eps)); + + CurveSegmentString high = CurveSegmentString.arc( + new Coordinate(1, 0), new Coordinate(-1, 0), + new Coordinate(0.6, 0.8)); + assertTrue("high-sagitta stays an arc", high.isArc()); + TwoNodeClip.Edge e = high.asEdge(); + double r2 = e.circle[2] * e.circle[2]; + Coordinate apex = high.getMid(); + double dx = apex.x - e.circle[0]; + double dy = apex.y - e.circle[1]; + assertTrue("high-sagitta mid residual is in eps² of R²", + Math.abs(dx * dx + dy * dy - r2) <= eps2); + assertTrue("high-sagitta mid is onString", + CurveSegmentDcel.onString(high, apex, eps)); + assertTrue("high-sagitta end is onString", + CurveSegmentDcel.onString(high, high.getEnd(), eps)); + assertFalse("centre is not on the high-sagitta arc", + CurveSegmentDcel.onString(high, + new Coordinate(e.circle[0], e.circle[1]), eps)); + } + + /** + * Pinch / kiss / holed Geometry-level stay null. Not a face, + * not a DCEL. No invented noder. + */ + public void testPinchKissHoledStayNull() throws Exception { + assertNull("H-ANNULUS-TANGENT: pinch is not a DCEL", + CurveSegmentDcel.of(new Geometry[] { + readCurve(CIRCLE_5), readCurve(CIRCLE_INT_TAN) })); + assertNull("TOUCH-ext: kiss is not a DCEL", + CurveSegmentDcel.of(new Geometry[] { + readCurve(UNIT_DISC), readCurve(UNIT_DISC_TOUCH) })); + assertNull("H-SHELL-HOLE-X: holed Geometry-level stays null", + CurveSegmentDcel.of(new Geometry[] { + readCurve(HALF_HOLED), readCurve(HOLE_X) })); + } + + private static void assertLinks(CurveSegmentDcel dcel) { + assertNotNull(dcel); + List halves = dcel.halves(); + assertTrue("has half-edges", !halves.isEmpty()); + double eps = dcel.eps(); + for (int i = 0; i < halves.size(); i++) { + CurveSegmentDcel.Half h = halves.get(i); + assertNotNull("twin", h.twin()); + assertSame("twin.twin", h, h.twin().twin()); + assertNotNull("next", h.next()); + assertNotNull("prev", h.prev()); + assertSame("next.prev", h, h.next().prev()); + assertSame("prev.next", h, h.prev().next()); + assertNotNull("incident face", h.face()); + assertNotNull("member", h.member()); + assertTrue("twin dest is origin", + h.origin().distance(h.twin().dest()) <= eps); + assertTrue("twin origin is dest", + h.dest().distance(h.twin().origin()) <= eps); + assertCycleCloses(h, halves.size()); + } + } + + private static void assertCycleCloses(CurveSegmentDcel.Half start, int guard) { + CurveSegmentDcel.Half cur = start; + int n = 0; + boolean closed = false; + while (n++ < guard && !closed) { + cur = cur.next(); + if (cur == start) { + closed = true; + } + } + assertTrue("next-cycle closes", closed); + cur = start; + n = 0; + closed = false; + while (n++ < guard && !closed) { + cur = cur.prev(); + if (cur == start) { + closed = true; + } + } + assertTrue("prev-cycle closes", closed); + } + + private static void assertTwinReverses(CurveSegmentDcel.Half h) { + assertNotNull(h); + CurveSegmentDcel.Half t = h.twin(); + assertNotNull(t); + assertSame(h, t.twin()); + assertEquals(h.isArc(), t.isArc()); + if (h.isArc()) { + assertEquals(h.member().getMid().x, t.member().getMid().x, EXACT); + assertEquals(h.member().getMid().y, t.member().getMid().y, EXACT); + } + } + + private static void assertHasBoundedArea(CurveSegmentDcel dcel, double area) { + boolean found = false; + List cells = dcel.boundedFaces(); + for (int i = 0; i < cells.size() && !found; i++) { + if (Math.abs(cells.get(i).signedArea() - area) <= 1.0e-8) { + found = true; + } + } + assertTrue("missing bounded area " + area, found); + } + + private static CurveSegmentDcel.Vertex vertexAt(CurveSegmentDcel dcel, + double x, double y) { + Coordinate want = new Coordinate(x, y); + CurveSegmentDcel.Vertex found = null; + List verts = dcel.vertices(); + for (int i = 0; i < verts.size() && found == null; i++) { + if (verts.get(i).coordinate().distance(want) <= 1.0e-8) { + found = verts.get(i); + } + } + assertNotNull("missing vertex (" + x + " " + y + ")", found); + return found; + } + + private static CurveSegmentDcel.Half chordHalf(Coordinate origin, + double x, double y) { + Coordinate dest = new Coordinate(x, y); + return new CurveSegmentDcel.Half(origin, dest, + CurveSegmentString.segment(origin, dest)); + } + + private static CurveSegmentDcel.Half findHalfNear(CurveSegmentDcel dcel, + double x0, double y0, double x1, double y1) { + Coordinate a = new Coordinate(x0, y0); + Coordinate b = new Coordinate(x1, y1); + CurveSegmentDcel.Half found = null; + List halves = dcel.halves(); + for (int i = 0; i < halves.size() && found == null; i++) { + CurveSegmentDcel.Half h = halves.get(i); + if (h.origin().distance(a) <= 1.0e-8 + && h.dest().distance(b) <= 1.0e-6) { + found = h; + } + } + return found; + } + + private static CurveSegmentDcel.Half findChordHalf(CurveSegmentDcel dcel, + double x0, double y0, double x1, double y1) { + Coordinate p = new Coordinate(x0, y0); + Coordinate q = new Coordinate(x1, y1); + CurveSegmentDcel.Half found = null; + List halves = dcel.halves(); + for (int i = 0; i < halves.size() && found == null; i++) { + CurveSegmentDcel.Half h = halves.get(i); + if (h.isArc()) { + continue; + } + boolean ends = h.origin().distance(p) <= EXACT + && h.dest().distance(q) <= EXACT + || h.origin().distance(q) <= EXACT + && h.dest().distance(p) <= EXACT; + if (ends) { + found = h; + } + } + return found; + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java new file mode 100644 index 0000000000..d0b15b70a2 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java @@ -0,0 +1,988 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * P2.1 nodes plus P2.2 overlap-as-edge. {@link CurveSegmentString} + * is the unit; {@link CurveSegmentNoder} emits the discrete node + * set or a shared run as an edge (interval). P2.3 walks a hole + * ring as strings into one face decision (bite vs hole). P2.4 + * walks two crossing hole rings into the hole faces. A hole + * ring that overlaps the other shell (no crossing nodes) is + * the same P2.3 bite. P2.5.2 is N strings: the unique union of + * each unordered pair. P2.5.3 walks the faces of that node set. + * P2.5.4 is near-tangent robustness: a coincident leave-angle + * stamps {@link CurveSegmentFaces#TANGENT_LEAVE_ANGLE}. + * HP.1 pins that a signed-curvature leave-angle order at + * (0, 5) walks the wrong rings -- not a laser. + * Not N-SS, not a core {@code Noder}. + */ +public class CurveSegmentStringTest extends GeometryTestCase { + + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String CIRCLE_CROSSING = + "CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))"; + /** R1.6-2: axis-aligned cut, two line–circle nodes on CIRCLE_5. */ + private static final String SQUARE_RIGHT = + "POLYGON ((0 -6, 10 -6, 10 6, 0 6, 0 -6))"; + /** H-FOUR: four line–circle nodes on CIRCLE_5 (y = ±1). */ + private static final String BAND_FOUR = + "POLYGON ((-8 -1, 8 -1, 8 1, -8 1, -8 -1))"; + private static final String HALF_DISC = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))"; + /** H-SHELL-2: two proper nodes (±3, 4). */ + private static final String HALF_HANGING = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 8, 0 3, 5 8), (5 8, -5 8)))"; + /** H-SHELL-N: four nodes on HALF_DISC. */ + private static final String STADIUM_FOUR = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, 0 -2, 1 -1), (1 -1, 1 6), CIRCULARSTRING (1 6, 0 7, -1 6), (-1 6, -1 -1)))"; + /** H-SHELL-N-ODD: 2 crossings + 1 tangent. */ + private static final String STADIUM_ODD = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 4, 0 5, 1 4), (1 4, 1 -1), CIRCULARSTRING (1 -1, 0 -2, -1 -1), (-1 -1, -1 4)))"; + /** H-SHELL-N-MIXED: collinear overlap on the diameter. */ + private static final String ON_DIAMETER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"; + /** r=3 at (2,0); internally tangent to CIRCLE_5 at (5,0). */ + private static final String CIRCLE_INT_TAN = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 2 3, 5 0, 2 -3, -1 0))"; + /** + * TOUCH-ext / Qed OverlayTouchRow: unit discs, centres (0,0) and + * (2,0), d = r1+r2, kiss (1,0). Oracle EXT_TANGENT. + */ + private static final String UNIT_DISC = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 0 1, 1 0, 0 -1, -1 0))"; + private static final String UNIT_DISC_TOUCH = + "CURVEPOLYGON (CIRCULARSTRING (1 0, 2 1, 3 0, 2 -1, 1 0))"; + /** H-SAME-CIRCLE: overlapping quarters of CIRCLE_5. */ + private static final String ARC_SAME_Q1 = + "CIRCULARSTRING (-5 0, 0 5, 5 0)"; + private static final String ARC_SAME_Q2 = + "CIRCULARSTRING (0 5, 5 0, 0 -5)"; + /** R-LL: LineString member overlaps a plain line; the arc is a rider. */ + private static final String RLL_COMPOUND = + "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 12 2, 14 0))"; + /** + * Extends past the LineString member so R1 cannot retain. Public + * CAP is a point count; the overlap is a segment. + */ + private static final String RLL_OVERLAP_LINE = + "LINESTRING (2 0, 12 0)"; + private static final String HALF_RIGHT = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (0 -5, 5 0, 0 5), (0 5, 0 -5)))"; + private static final String HALF_HOLED = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0 1, 1 1, 1 2, 0 2, 0 1))"; + private static final String HOLE_STRADDLE = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"; + private static final String HOLE_X = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"; + + private static final double EXACT = 1.0e-12; + private static final double SQRT_12_75 = Math.sqrt(12.75); + private static final double SQRT_24 = Math.sqrt(24.0); + private static final double HALF = 12.5 * Math.PI; + private static final double LENS = 50.0 * Math.acos(0.8) - 24.0; + private static final double FOUR_CAP = 25.0 * Math.asin(0.2) + 2.0 * Math.sqrt(6.0); + /** HALF_DISC ∩ HALF_HANGING ∩ STADIUM_FOUR. */ + private static final double N3_TRIPLE = 2.0 * FOUR_CAP - 16.0; + private static final double N3_LENS_SIDE = 0.5 * (LENS - N3_TRIPLE); + private static final double N3_AC_NOT_B = 16.0 - FOUR_CAP; + private static final double N3_BC_NOT_A = 12.0 + 0.5 * Math.PI - FOUR_CAP; + private static final double N3_C_BOTTOM = 2.0 + 0.5 * Math.PI; + private static final double N3_A_EAR = 0.5 * (HALF - LENS - N3_AC_NOT_B); + private static final double N3_B_REST = HALF - LENS - N3_BC_NOT_A; + /** area(A ∪ B ∪ C) = 25.5π − LENS + 2. */ + private static final double N3_UNION = 25.5 * Math.PI - LENS + 2.0; + /** + * H-SHELL-N-ODD N=2 pair-kit (CAP + XOR) = 13π + 2 ≈ 42.84. + * HALF + STADIUM_ODD − ODD_CAP. + */ + private static final double HSHELL_N_ODD_PAIR_KIT = 13.0 * Math.PI + 2.0; + /** + * Observed area of the 70c72c0 signed-curvature leave-angle + * order on H-SHELL-N-ODD. Wrong rings. Not a laser. Catalog + * never: curvature-order walk. + */ + private static final double CURVATURE_ORDER_WRONG_AREA = 18.42; + /** Same as {@code CurveSegmentFaces.ANGLE_EPS}. */ + private static final double LEAVE_ANGLE_EPS = 1.0e-8; + + public static void main(String[] args) { + TestRunner.run(CurveSegmentStringTest.class); + } + + public CurveSegmentStringTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + /** + * The unit is one chord or one arc, not a densified polyline. + */ + public void testStringIsChordOrArcNotDensify() { + CurveSegmentString chord = CurveSegmentString.segment( + new Coordinate(5, 0), new Coordinate(-5, 0)); + assertFalse(chord.isArc()); + assertEquals(5.0, chord.getStart().x, 0.0); + assertEquals(-5.0, chord.getEnd().x, 0.0); + + CurveSegmentString arc = CurveSegmentString.arc( + new Coordinate(-5, 0), new Coordinate(0, 5), new Coordinate(5, 0)); + assertTrue(arc.isArc()); + assertEquals(0.0, arc.getMid().x, 0.0); + assertEquals(5.0, arc.getMid().y, 0.0); + + CurveSegmentString flat = CurveSegmentString.arc( + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(2, 0)); + assertFalse("colinear triple is a chord", flat.isArc()); + } + + /** + * R1.5: two discs. Nodes are the radical-axis pair the kit already + * computes — not a sampled set. + */ + public void testR15TwoDiscIsTwoNodes() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_CROSSING); + Coordinate[] kit = TwoNodeClip.intersectCircles(0, 0, 5, 7, 0, 5); + assertEquals(2, kit.length); + Coordinate[] nodes = CurveSegmentNoder.nodes(a, b); + assertSamePoints("R1.5 two-disc", kit, nodes); + assertHas(nodes, 3.5, SQRT_12_75); + assertHas(nodes, 3.5, -SQRT_12_75); + assertSamePoints("R1.5 reverse", nodes, CurveSegmentNoder.nodes(b, a)); + List edges = CurveSegmentNoder.edges(a, b); + assertNotNull(edges); + assertEquals("R1.5 is nodes, not a shared run", 0, edges.size()); + } + + /** + * Same R1.5 pair as strings: two semicircle arcs vs two semicircle + * arcs. The noder's unit of work is {@link CurveSegmentString}. + */ + public void testR15AsStringsIsTheSamePair() { + List a = Arrays.asList( + CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)), + CurveSegmentString.arc(c(5, 0), c(0, -5), c(-5, 0))); + List b = Arrays.asList( + CurveSegmentString.arc(c(2, 0), c(7, 5), c(12, 0)), + CurveSegmentString.arc(c(12, 0), c(7, -5), c(2, 0))); + Coordinate[] kit = TwoNodeClip.intersectCircles(0, 0, 5, 7, 0, 5); + Coordinate[] nodes = CurveSegmentNoder.nodes(a, b, 12.0); + assertSamePoints("R1.5 strings", kit, nodes); + } + + /** + * R1.6-2: disc vs plain half-plane. Two line–circle nodes. + */ + public void testR16TwoNodeDiscVsPlain() throws Exception { + Geometry disc = readCurve(CIRCLE_5); + Geometry square = readCurve(SQUARE_RIGHT); + Coordinate[] nodes = CurveSegmentNoder.nodes(disc, square); + assertEquals("R1.6-2", 2, nodes.length); + assertHas(nodes, 0.0, 5.0); + assertHas(nodes, 0.0, -5.0); + Coordinate[] viaSeg = TwoNodeClip.intersectSegmentCircle( + 0, 0, 5, c(0, -6), c(0, 6)); + assertSamePoints("R1.6-2 kit segment–circle", viaSeg, nodes); + assertSamePoints("R1.6-2 reverse", nodes, + CurveSegmentNoder.nodes(square, disc)); + } + + /** + * H-SHELL-2: hanging half vs upper half. (±3, 4). + */ + public void testHShell2TwoNode() throws Exception { + Geometry upper = readCurve(HALF_DISC); + Geometry hanging = readCurve(HALF_HANGING); + Coordinate[] kit = TwoNodeClip.intersectCircles(0, 0, 5, 0, 8, 5); + Coordinate[] nodes = CurveSegmentNoder.nodes(upper, hanging); + assertSamePoints("H-SHELL-2", kit, nodes); + assertHas(nodes, 3.0, 4.0); + assertHas(nodes, -3.0, 4.0); + } + + /** + * H-FOUR: even-n line–circle nodes of CIRCLE_5 vs the band. + */ + public void testHFourEvenN() throws Exception { + Geometry disc = readCurve(CIRCLE_5); + Geometry band = readCurve(BAND_FOUR); + Coordinate[] nodes = CurveSegmentNoder.nodes(disc, band); + assertEquals("H-FOUR even-n", 4, nodes.length); + assertHas(nodes, SQRT_24, 1.0); + assertHas(nodes, -SQRT_24, 1.0); + assertHas(nodes, SQRT_24, -1.0); + assertHas(nodes, -SQRT_24, -1.0); + } + + /** + * H-SHELL-N: even-n on two CompoundCurve shells. + */ + public void testHShellNEvenN() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry stadium = readCurve(STADIUM_FOUR); + Coordinate[] nodes = CurveSegmentNoder.nodes(half, stadium); + assertEquals("H-SHELL-N even-n", 4, nodes.length); + assertHas(nodes, 1.0, SQRT_24); + assertHas(nodes, -1.0, SQRT_24); + assertHas(nodes, 1.0, 0.0); + assertHas(nodes, -1.0, 0.0); + } + + /** + * D2-odd: two diameter crossings plus the tangent at (0, 5). + * The tangent is a node. + */ + public void testHShellNOddTangentIsANode() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry stadium = readCurve(STADIUM_ODD); + Coordinate[] nodes = CurveSegmentNoder.nodes(half, stadium); + assertEquals("H-SHELL-N-ODD", 3, nodes.length); + assertHas(nodes, 1.0, 0.0); + assertHas(nodes, -1.0, 0.0); + assertHas(nodes, 0.0, 5.0); + } + + /** + * MIXED: collinear overlap is an edge, not a discrete node set. + * Overlay still refuses -- this rung does not walk faces. + */ + public void testMixedOverlapIsTheDiameterEdge() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry onDiameter = readCurve(ON_DIAMETER); + assertNull("H-SHELL-N-MIXED: overlay stays refused (no face walk)", + CompoundCurveShellOverlay.overlay(half, onDiameter, + org.locationtech.jts.operation.overlayng.OverlayNG.INTERSECTION)); + assertNull("H-SHELL-N-MIXED: nodes stay null (interval, not points)", + CurveSegmentNoder.nodes(half, onDiameter)); + + List edges = CurveSegmentNoder.edges(half, + onDiameter); + assertNotNull("H-SHELL-N-MIXED: noder names the overlap", edges); + CurveSegmentString run = findChord(edges, -1.0, 0.0, 1.0, 0.0); + assertNotNull("H-SHELL-N-MIXED: overlapping diameter (-1 0)–(1 0)", + run); + assertFalse(run.isArc()); + assertFalse("overlap is an interval, not a pinch", run.isDegenerate()); + assertEquals(2.0, run.length(), EXACT); + } + + /** + * H-SAME-CIRCLE: CircleSweepOverlay already sews the shared quarter. + * Lock that interval on the string, then lift it onto two shells. + */ + public void testHSameCircleOverlapIsASweepEdge() throws Exception { + CurveSegmentString q1 = CurveSegmentString.arc( + c(-5, 0), c(0, 5), c(5, 0)); + CurveSegmentString q2 = CurveSegmentString.arc( + c(0, 5), c(5, 0), c(0, -5)); + CurveSegmentString run = CurveSegmentString.overlap(q1, q2, 10.0); + assertNotNull("H-SAME-CIRCLE: shared quarter is an edge", run); + assertTrue(run.isArc()); + assertFalse(run.isDegenerate()); + assertEquals("shared quarter length", 2.5 * Math.PI, run.length(), + EXACT); + assertTrue(sameEnds(run, 0.0, 5.0, 5.0, 0.0)); + + CurveSegmentString lower = CurveSegmentString.arc( + c(5, 0), c(0, -5), c(-5, 0)); + assertNull("same-circle endpoint kiss is not an interval", + CurveSegmentString.overlap(q1, lower, 10.0)); + + Geometry a = readCurve(ARC_SAME_Q1); + Geometry b = readCurve(ARC_SAME_Q2); + assertNull("H-SAME-CIRCLE: not a discrete node set", + CurveSegmentNoder.nodes(a, b)); + List edges = CurveSegmentNoder.edges(a, b); + assertEquals("one shared quarter", 1, edges.size()); + assertEquals(2.5 * Math.PI, edges.get(0).length(), EXACT); + assertTrue(sameEnds(edges.get(0), 0.0, 5.0, 5.0, 0.0)); + } + + /** + * Two CompoundCurve shells on CIRCLE_5 share the first-quadrant arc. + */ + public void testHSameCircleLiftsToTwoShells() throws Exception { + Geometry upper = readCurve(HALF_DISC); + Geometry right = readCurve(HALF_RIGHT); + List edges = CurveSegmentNoder.edges(upper, + right); + assertNotNull(edges); + CurveSegmentString quarter = findArc(edges, 0.0, 5.0, 5.0, 0.0); + assertNotNull("H-SAME-CIRCLE two-shell: shared quarter", quarter); + assertEquals(2.5 * Math.PI, quarter.length(), EXACT); + } + + /** + * H-ANNULUS-TANGENT: internal tangent is a zero-width pinch, not + * an annulus. Named as a degenerate edge. No face. + */ + public void testAnnulusTangentIsADegenerateEdge() throws Exception { + Geometry disc = readCurve(CIRCLE_5); + Geometry tan = readCurve(CIRCLE_INT_TAN); + assertNull("H-ANNULUS-TANGENT: overlay does not punch an annulus", + CircularDiscOverlay.overlay(disc, tan, + org.locationtech.jts.operation.overlayng.OverlayNG.DIFFERENCE)); + assertNull("H-ANNULUS-TANGENT: pinch is not a discrete set", + CurveSegmentNoder.nodes(disc, tan)); + + List edges = CurveSegmentNoder.edges(disc, tan); + assertNotNull(edges); + assertEquals("one pinch", 1, edges.size()); + assertTrue("zero-width edge at (5 0)", edges.get(0).isDegenerate()); + assertEquals(5.0, edges.get(0).getStart().x, EXACT); + assertEquals(0.0, edges.get(0).getStart().y, EXACT); + assertEquals(0.0, edges.get(0).length(), EXACT); + } + + /** + * TOUCH-ext: external tangent, d = r1+r2, one kiss, disjoint + * interiors. Named as a degenerate edge at (1 0). A point is not + * an interval and not a CurvePolygon. Overlay stays null. + */ + public void testTouchExtIsADegenerateEdge() throws Exception { + Geometry a = readCurve(UNIT_DISC); + Geometry b = readCurve(UNIT_DISC_TOUCH); + assertEquals("TOUCH-ext: oracle EXT_TANGENT", + "FF2F01212", a.relate(b).toString()); + assertNull("TOUCH-ext: overlay does not invent a CurvePolygon", + CircularDiscOverlay.overlay(a, b, + org.locationtech.jts.operation.overlayng.OverlayNG.INTERSECTION)); + assertNull("TOUCH-ext: nestedAnnulus is not this pair", + CircularDiscOverlay.overlay(a, b, + org.locationtech.jts.operation.overlayng.OverlayNG.DIFFERENCE)); + assertNull("TOUCH-ext: kiss is not a 2-node set", + CurveSegmentNoder.nodes(a, b)); + + List edges = CurveSegmentNoder.edges(a, b); + assertNotNull(edges); + assertEquals("one kiss", 1, edges.size()); + assertTrue("zero-width edge at (1 0)", edges.get(0).isDegenerate()); + assertFalse(edges.get(0).isArc()); + assertEquals(1.0, edges.get(0).getStart().x, EXACT); + assertEquals(0.0, edges.get(0).getStart().y, EXACT); + assertEquals(0.0, edges.get(0).length(), EXACT); + List rev = CurveSegmentNoder.edges(b, a); + assertEquals("TOUCH-ext reverse", 1, rev.size()); + assertTrue(rev.get(0).isDegenerate()); + assertEquals(1.0, rev.get(0).getStart().x, EXACT); + assertEquals(0.0, rev.get(0).getStart().y, EXACT); + } + + /** + * R-LL collinear overlap: public CAP is a point count and the + * helper still misses. The noder names the overlapping segment. + * Do not chordsaw-as-exact. + */ + public void testRllCollinearOverlapIsAnEdge() throws Exception { + Geometry curve = readCurve(RLL_COMPOUND); + Geometry line = readCurve(RLL_OVERLAP_LINE); + assertNull("R-LL: helper still misses collinear overlap", + CircularLineOverlay.overlay(curve, line, + org.locationtech.jts.operation.overlayng.OverlayNG.INTERSECTION)); + OverlayNGCurve pub = new OverlayNGCurve(curve, line); + Geometry publicCap = pub.getResult( + org.locationtech.jts.operation.overlayng.OverlayNG.INTERSECTION); + assertTrue("R-LL collinear: public stays approximate (not exact)", + pub.isApproximate()); + assertFalse("R-LL collinear: public count is not the overlap segment", + publicCap.getLength() == 8.0 && !pub.isApproximate()); + + assertNull("R-LL collinear: not a discrete node set", + CurveSegmentNoder.nodes(curve, line)); + List edges = CurveSegmentNoder.edges(curve, + line); + assertNotNull(edges); + CurveSegmentString run = findChord(edges, 2.0, 0.0, 10.0, 0.0); + assertNotNull("R-LL: overlapping segment (2 0)–(10 0)", run); + assertFalse(run.isArc()); + assertEquals(8.0, run.length(), EXACT); + } + + public void testHoleStraddleNodesAreTheClipPair() throws Exception { + Geometry right = readCurve(HALF_RIGHT); + Geometry straddle = readCurve(HOLE_STRADDLE); + assertNull("H-SHELL-HOLE-CROSS: Geometry pair still has a hole", + CurveSegmentNoder.nodes(straddle, right)); + assertNull("H-SHELL-HOLE-CROSS: Geometry edges stay on the outers", + CurveSegmentNoder.edges(straddle, right)); + + CurvePolygon cp = (CurvePolygon) straddle; + List hole = CurveSegmentString.of( + cp.getInteriorCurveN(0)); + List shell = CurveSegmentString.of(right); + Coordinate[] nodes = CurveSegmentNoder.nodes(hole, shell, 10.0); + assertEquals("H-SHELL-HOLE-CROSS: even-n on the hole ring", 2, + nodes.length); + assertHas(nodes, 0.0, 1.0); + assertHas(nodes, 0.0, 2.0); + assertEquals("new edge ⊂ other.shell ⇒ bite", BiteVsHole.BITE, + BiteVsHole.decide(straddle, right)); + CurveSegmentString clip = BiteVsHole.clipEdge(straddle, right); + assertNotNull(clip); + assertEquals(1.0, clip.length(), EXACT); + assertTrue(sameEnds(clip, 0.0, 1.0, 0.0, 2.0)); + + Geometry holed = readCurve(HALF_HOLED); + Geometry holeX = readCurve(HOLE_X); + assertNull("H-SHELL-HOLE-X: Geometry pair still has holes", + CurveSegmentNoder.nodes(holed, holeX)); + assertNull("H-SHELL-HOLE-X: Geometry edges stay on the outers", + CurveSegmentNoder.edges(holed, holeX)); + assertEquals("H-SHELL-HOLE-X: bite walk stays a miss", + BiteVsHole.MISS, BiteVsHole.decide(holed, holeX)); + + CurvePolygon ha = (CurvePolygon) holed; + CurvePolygon hb = (CurvePolygon) holeX; + List holeOnDiameter = CurveSegmentString.of( + ha.getInteriorCurveN(0)); + List rightShell = CurveSegmentString.of(right); + assertNull("H-SHELL-HOLE-OUTER: collinear hole-edge is not a node set", + CurveSegmentNoder.nodes(holeOnDiameter, rightShell, 10.0)); + List diameterEdges = CurveSegmentNoder.edges( + holeOnDiameter, rightShell, 10.0); + assertNotNull(diameterEdges); + CurveSegmentString diameterRun = findChord(diameterEdges, 0.0, 1.0, + 0.0, 2.0); + assertNotNull("H-SHELL-HOLE-OUTER: noder names (0 1)–(0 2)", + diameterRun); + assertFalse(diameterRun.isArc()); + assertFalse(diameterRun.isDegenerate()); + assertEquals(1.0, diameterRun.length(), EXACT); + assertEquals("hole-edge ⊂ other.shell ⇒ bite", BiteVsHole.BITE, + BiteVsHole.decide(holed, right)); + CurveSegmentString diameterClip = BiteVsHole.clipEdge(holed, right); + assertNotNull(diameterClip); + assertEquals(1.0, diameterClip.length(), EXACT); + assertTrue(sameEnds(diameterClip, 0.0, 1.0, 0.0, 2.0)); + + List holeA = CurveSegmentString.of( + ha.getInteriorCurveN(0)); + List holeB = CurveSegmentString.of( + hb.getInteriorCurveN(0)); + Coordinate[] holeNodes = CurveSegmentNoder.nodes(holeA, holeB, 10.0); + assertEquals("H-SHELL-HOLE-X: two hole–hole nodes", 2, holeNodes.length); + assertHas(holeNodes, 0.5, 1.0); + assertHas(holeNodes, 1.0, 1.5); + List holeEdges = CurveSegmentNoder.edges( + holeA, holeB, 10.0); + assertNotNull(holeEdges); + assertEquals("H-SHELL-HOLE-X: cross is nodes, not a shared run", + 0, holeEdges.size()); + assertEquals("two hole rings that cross", TwoHoleOverlay.CROSS, + TwoHoleOverlay.decide(holed, holeX)); + Coordinate[] holeClip = TwoHoleOverlay.clipNodes(holed, holeX); + assertNotNull(holeClip); + assertEquals(2, holeClip.length); + assertHas(holeClip, 0.5, 1.0); + assertHas(holeClip, 1.0, 1.5); + } + + /** + * N=2 faces recover the pair-kit rings (CAP + XOR). Crossing + * pairs walk; 0-node containment falls back to the kits. MIXED + * / pinch stay null -- those kits refuse, and this rung does + * not densify. + */ + public void testN2FacesRecoverPairKits() throws Exception { + assertN2Faces(CIRCLE_5, CIRCLE_CROSSING); + assertN2Faces(CIRCLE_5, SQUARE_RIGHT); + assertN2Faces(HALF_DISC, HALF_HANGING); + assertN2Faces(CIRCLE_5, BAND_FOUR); + assertN2Faces(HALF_DISC, STADIUM_FOUR); + assertN2Faces(HALF_DISC, STADIUM_ODD); + assertN2Faces(HALF_DISC, HALF_RIGHT); + } + + public void testN2MixedPinchHoledFacesStayNull() throws Exception { + assertNull("H-SHELL-N-MIXED: no face walk", + CurveSegmentFaces.faces(new Geometry[] { + readCurve(HALF_DISC), readCurve(ON_DIAMETER) })); + assertNull("H-ANNULUS-TANGENT: pinch is not a face", + CurveSegmentFaces.faces(new Geometry[] { + readCurve(CIRCLE_5), readCurve(CIRCLE_INT_TAN) })); + assertNull("TOUCH-ext: kiss is not a face", + CurveSegmentFaces.faces(new Geometry[] { + readCurve(UNIT_DISC), readCurve(UNIT_DISC_TOUCH) })); + assertNull("H-SHELL-HOLE-X: Geometry-level holed stays null", + CurveSegmentFaces.faces(new Geometry[] { + readCurve(HALF_HOLED), readCurve(HOLE_X) })); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_FOUR. The N-set is the + * union of the three pair node sets (P2.5.2). This rung names + * the nine bounded faces of that arrangement. + */ + public void testN3HalfHangingStadiumFaces() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_HANGING); + Geometry c = readCurve(STADIUM_FOUR); + Geometry faces = CurveSegmentFaces.faces(new Geometry[] { a, b, c }); + assertNotNull("N=3 walk", faces); + assertEquals("nine bounded faces", 9, faces.getNumGeometries()); + assertEquals("union of the three shells", N3_UNION, faces.getArea(), + EXACT); + assertHasArea("triple A∩B∩C", faces, N3_TRIPLE); + assertHasArea("left lens A∩B\\C", faces, N3_LENS_SIDE); + assertHasArea("right lens A∩B\\C", faces, N3_LENS_SIDE); + assertHasArea("stadium in A below hanging", faces, N3_AC_NOT_B); + assertHasArea("stadium in B above half-arc", faces, N3_BC_NOT_A); + assertHasArea("stadium below the diameter", faces, N3_C_BOTTOM); + assertHasArea("left half-disc ear", faces, N3_A_EAR); + assertHasArea("right half-disc ear", faces, N3_A_EAR); + assertHasArea("hanging remainder", faces, N3_B_REST); + assertSameArea("N=3 permutation", faces, + CurveSegmentFaces.faces(new Geometry[] { c, a, b })); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b), + CurveSegmentString.of(c)); + Geometry viaStrings = CurveSegmentFaces.faces(groups, 16.0); + assertNotNull("N=3 string groups", viaStrings); + assertEquals("string-group face count", 9, viaStrings.getNumGeometries()); + assertEquals("string-group union", N3_UNION, viaStrings.getArea(), EXACT); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_ODD: crossings (±1, 0) plus + * the tangent at (0, 5). Two pieces leave at the same angle + * ({@code ANGLE_EPS = 1e-8}). Ordering them is snap-rounding + * (P2.5.4). Named stamp, not a HotPixel, not a bare null. + */ + public void testN3TangentStampsNull() throws Exception { + Geometry faces = CurveSegmentFaces.faces(new Geometry[] { + readCurve(HALF_DISC), readCurve(HALF_HANGING), + readCurve(STADIUM_ODD) }); + assertNull("N≥3 near-tangent is P2.5.4, not a face walk", faces); + assertEquals("snap-rounding: coincident leave-angle", + CurveSegmentFaces.TANGENT_LEAVE_ANGLE, + CurveSegmentFaces.missReason()); + } + + /** + * HP.1: coincident leave-angles at (0, 5) ordered by signed + * curvature walk the wrong rings. H-SHELL-N-ODD pair-kit is + * 13π+2 ≈ 42.84. The 70c72c0 curvature-order walk was 18.42. + * That is not a laser. Catalog never: curvature-order walk. + * P2.5.4 still stamps; this pin fails if that stamp is turned + * into a curvature-order "laser" without changing these + * numbers. Not a HotPixel, not a noder, not a face walk. + */ + public void testCurvatureOrderWalksWrongRings() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry hanging = readCurve(HALF_HANGING); + Geometry stadium = readCurve(STADIUM_ODD); + + Geometry stamped = CurveSegmentFaces.faces( + new Geometry[] { half, hanging, stadium }); + assertNull("HP.1: P2.5.4 stamp is not a curvature-order laser", + stamped); + assertEquals("HP.1: named stamp unchanged", + CurveSegmentFaces.TANGENT_LEAVE_ANGLE, + CurveSegmentFaces.missReason()); + + Geometry kit = CurveSegmentFaces.pairKitFaces(half, stadium); + assertNotNull("H-SHELL-N-ODD pair-kit fallback", kit); + assertEquals("pair-kit rings 13π+2", HSHELL_N_ODD_PAIR_KIT, + kit.getArea(), EXACT); + + // Renaming 18.42 to the pair-kit area claims the wrong walk + // is a laser. The pin must stay a miss. + assertTrue("curvature-order walk is not the pair-kit rings", + Math.abs(kit.getArea() - CURVATURE_ORDER_WRONG_AREA) > 1.0); + + Coordinate pinch = c(0, 5); + CurveSegmentString halfLeave = leaveFrom(halfDiscArc(), pinch, + c(5, 0)); + CurveSegmentString stadiumLeave = leaveFrom(stadiumOddCap(), + pinch, c(1, 4)); + assertTrue("HALF_DISC and STADIUM_ODD leave (0, 5) together", + coincidentLeave(halfLeave, stadiumLeave)); + assertTrue("signed curvatures differ — the 70c72c0 tie-break", + Math.abs(signedCurvature(halfLeave) + - signedCurvature(stadiumLeave)) > LEAVE_ANGLE_EPS); + } + + public void testNoderDoesNotAssembleFaces() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_CROSSING); + Coordinate[] nodes = CurveSegmentNoder.nodes(a, b); + assertNotNull(nodes); + assertEquals("nodes only — no polygon", 2, nodes.length); + for (int i = 0; i < nodes.length; i++) { + assertTrue(nodes[i] instanceof Coordinate); + } + } + + /** + * N=2 is the pair noder. Kit-identical pins stay bit-identical. + */ + public void testN2IsBitIdenticalToPairNoder() throws Exception { + assertN2Same(CIRCLE_5, CIRCLE_CROSSING); + assertN2Same(CIRCLE_5, SQUARE_RIGHT); + assertN2Same(HALF_DISC, HALF_HANGING); + assertN2Same(CIRCLE_5, BAND_FOUR); + assertN2Same(HALF_DISC, STADIUM_FOUR); + assertN2Same(HALF_DISC, STADIUM_ODD); + assertN2Same(HALF_DISC, ON_DIAMETER); + assertN2Same(CIRCLE_5, CIRCLE_INT_TAN); + assertN2Same(UNIT_DISC, UNIT_DISC_TOUCH); + assertN2Same(HALF_HOLED, HOLE_X); + + List sa = Arrays.asList( + CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)), + CurveSegmentString.arc(c(5, 0), c(0, -5), c(-5, 0))); + List sb = Arrays.asList( + CurveSegmentString.arc(c(2, 0), c(7, 5), c(12, 0)), + CurveSegmentString.arc(c(12, 0), c(7, -5), c(2, 0))); + Coordinate[] pair = CurveSegmentNoder.nodes(sa, sb, 12.0); + List> groups = + Arrays.asList(sa, sb); + assertSamePoints("N=2 string groups", pair, + CurveSegmentNoder.nodes(groups, 12.0)); + } + + /** + * Three pairwise-crossing CompoundCurve shells. Pair nodes are + * already named (H-SHELL-2, H-SHELL-N, hanging vs stadium). The + * N-set is that union, no extras. + */ + public void testN3IsUnionOfPairSets() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_HANGING); + Geometry c = readCurve(STADIUM_FOUR); + Coordinate[] ab = CurveSegmentNoder.nodes(a, b); + Coordinate[] ac = CurveSegmentNoder.nodes(a, c); + Coordinate[] bc = CurveSegmentNoder.nodes(b, c); + assertNotNull("H-SHELL-2 pair", ab); + assertNotNull("H-SHELL-N pair", ac); + assertNotNull("hanging × stadium pair", bc); + Coordinate[] expect = unionNodes(ab, ac, bc); + Coordinate[] n3 = CurveSegmentNoder.nodes(new Geometry[] { a, b, c }); + assertSamePoints("N=3 union", expect, n3); + assertHas(n3, 3.0, 4.0); + assertHas(n3, -3.0, 4.0); + assertHas(n3, 1.0, SQRT_24); + assertHas(n3, -1.0, SQRT_24); + assertHas(n3, 1.0, 0.0); + assertHas(n3, -1.0, 0.0); + assertSamePoints("N=3 permutation", n3, + CurveSegmentNoder.nodes(new Geometry[] { c, a, b })); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b), + CurveSegmentString.of(c)); + assertSamePoints("N=3 string groups", expect, + CurveSegmentNoder.nodes(groups, 16.0)); + + CurveSegmentString[] shells = new CurveSegmentString[] { + CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)), + CurveSegmentString.arc(c(-5, 8), c(0, 3), c(5, 8)) }; + Coordinate[] hshell2 = TwoNodeClip.intersectCircles(0, 0, 5, 0, 8, 5); + assertSamePoints("N=2 strings is H-SHELL-2", hshell2, + CurveSegmentNoder.nodes(shells, 16.0)); + } + + /** + * A MIXED pair inside an N-set stays an edge. All-pairs must + * not invent a crossing there. The N-set is the other pairs. + */ + public void testN3MixedPairAddsNoFakeCrossing() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry mixed = readCurve(ON_DIAMETER); + Geometry cross = readCurve(CIRCLE_CROSSING); + assertNull("H-SHELL-N-MIXED pair stays null", + CurveSegmentNoder.nodes(half, mixed)); + Coordinate[] named = CurveSegmentNoder.nodes(half, cross); + assertNotNull("half × crossing is a pair set", named); + Coordinate[] mc = CurveSegmentNoder.nodes(mixed, cross); + Coordinate[] n3 = CurveSegmentNoder.nodes( + new Geometry[] { half, mixed, cross }); + if (mc == null) { + assertSamePoints("MIXED pair contributes nothing", named, n3); + } + else { + assertSamePoints("MIXED pair contributes nothing", + unionNodes(named, mc), n3); + } + + Geometry disc = readCurve(CIRCLE_5); + Geometry tan = readCurve(CIRCLE_INT_TAN); + Geometry other = readCurve(CIRCLE_CROSSING); + assertNull("H-ANNULUS-TANGENT pair stays null", + CurveSegmentNoder.nodes(disc, tan)); + Coordinate[] r15 = CurveSegmentNoder.nodes(disc, other); + Coordinate[] tanCross = CurveSegmentNoder.nodes(tan, other); + Coordinate[] pinchN = CurveSegmentNoder.nodes( + new Geometry[] { disc, tan, other }); + assertSamePoints("pinch pair adds no node", + unionNodes(r15, tanCross), pinchN); + assertMissing(pinchN, 5.0, 0.0); + } + + /** + * Geometry-level holed pair stays null. Hole rings are walked + * as strings (P2.3 / P2.4), not invented here. + */ + public void testNHoledGeometryPairStaysNull() throws Exception { + Geometry holed = readCurve(HALF_HOLED); + Geometry holeX = readCurve(HOLE_X); + assertNull("H-SHELL-HOLE-X Geometry pair", + CurveSegmentNoder.nodes(holed, holeX)); + assertNull("N=2 holed is the same miss", + CurveSegmentNoder.nodes(new Geometry[] { holed, holeX })); + + Geometry disc = readCurve(CIRCLE_5); + Geometry cross = readCurve(CIRCLE_CROSSING); + Coordinate[] r15 = CurveSegmentNoder.nodes(disc, cross); + Coordinate[] n3 = CurveSegmentNoder.nodes( + new Geometry[] { holed, disc, cross }); + assertSamePoints("holed pairs add no Geometry-level node", r15, n3); + } + + private static Coordinate c(double x, double y) { + return new Coordinate(x, y); + } + + /** HALF_DISC circular member. Existing fixture, not a new geometry. */ + private static CurveSegmentString halfDiscArc() { + return CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)); + } + + /** STADIUM_ODD top cap. Existing fixture, not a new geometry. */ + private static CurveSegmentString stadiumOddCap() { + return CurveSegmentString.arc(c(-1, 4), c(0, 5), c(1, 4)); + } + + /** + * The piece of {@code full} that leaves {@code pinch} toward + * {@code toward}. Closed-form split of an existing fixture arc. + */ + private static CurveSegmentString leaveFrom(CurveSegmentString full, + Coordinate pinch, Coordinate toward) { + Coordinate mid = TwoNodeClip.midOnSweep(pinch, toward, full.asEdge()); + return CurveSegmentString.arc(pinch, mid, toward); + } + + /** + * Same closed form as {@code CurveSegmentFaces.leaveAngle}. + * Test-local -- not a leave-angle snap, not a walk. + */ + private static double leaveAngle(CurveSegmentString s) { + Coordinate from = s.getStart(); + if (!s.isArc()) { + return Math.atan2(s.getEnd().y - from.y, s.getEnd().x - from.x); + } + TwoNodeClip.Edge e = s.asEdge(); + double rx = from.x - e.circle[0]; + double ry = from.y - e.circle[1]; + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + return ccw ? Math.atan2(rx, -ry) : Math.atan2(-rx, ry); + } + + /** + * Same closed form as the 70c72c0 try. Test-local -- not an + * order, not a HotPixel. + */ + private static double signedCurvature(CurveSegmentString s) { + if (!s.isArc()) return 0.0; + TwoNodeClip.Edge e = s.asEdge(); + if (e.circle[2] <= 0.0) return 0.0; + double k = 1.0 / e.circle[2]; + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + return ccw ? k : -k; + } + + private static boolean coincidentLeave(CurveSegmentString a, + CurveSegmentString b) { + double d = Math.abs(leaveAngle(a) - leaveAngle(b)); + if (d > Math.PI) { + d = TwoNodeClip.TWO_PI - d; + } + return d < LEAVE_ANGLE_EPS; + } + + private static boolean sameEnds(CurveSegmentString e, double x0, double y0, + double x1, double y1) { + Coordinate p = new Coordinate(x0, y0); + Coordinate q = new Coordinate(x1, y1); + return (e.getStart().distance(p) <= EXACT + && e.getEnd().distance(q) <= EXACT) + || (e.getStart().distance(q) <= EXACT + && e.getEnd().distance(p) <= EXACT); + } + + private static CurveSegmentString findChord(List edges, + double x0, double y0, double x1, double y1) { + CurveSegmentString found = null; + for (int i = 0; i < edges.size() && found == null; i++) { + CurveSegmentString e = edges.get(i); + if (!e.isArc() && sameEnds(e, x0, y0, x1, y1)) { + found = e; + } + } + return found; + } + + private static CurveSegmentString findArc(List edges, + double x0, double y0, double x1, double y1) { + CurveSegmentString found = null; + for (int i = 0; i < edges.size() && found == null; i++) { + CurveSegmentString e = edges.get(i); + if (e.isArc() && sameEnds(e, x0, y0, x1, y1)) { + found = e; + } + } + return found; + } + + private void assertN2Faces(String wa, String wb) throws Exception { + Geometry a = readCurve(wa); + Geometry b = readCurve(wb); + Geometry faces = CurveSegmentFaces.faces(new Geometry[] { a, b }); + Geometry kit = CurveSegmentFaces.pairKitFaces(a, b); + if (kit == null) { + assertNull("N=2 miss " + wa, faces); + } + else { + assertNotNull("N=2 faces " + wa, faces); + assertEquals("N=2 face area " + wa, kit.getArea(), faces.getArea(), + EXACT); + assertEquals("N=2 face count " + wa, kit.getNumGeometries(), + faces.getNumGeometries()); + } + } + + private static void assertHasArea(String label, Geometry faces, double area) { + assertNotNull(label + " missing faces", faces); + boolean found = false; + for (int i = 0; i < faces.getNumGeometries() && !found; i++) { + if (Math.abs(faces.getGeometryN(i).getArea() - area) <= EXACT) { + found = true; + } + } + assertTrue(label + " area " + area, found); + } + + private static void assertSameArea(String label, Geometry a, Geometry b) { + assertNotNull(label + " a", a); + assertNotNull(label + " b", b); + assertEquals(label + " count", a.getNumGeometries(), b.getNumGeometries()); + assertEquals(label + " area", a.getArea(), b.getArea(), EXACT); + } + + private void assertN2Same(String wa, String wb) throws Exception { + Geometry a = readCurve(wa); + Geometry b = readCurve(wb); + Coordinate[] pair = CurveSegmentNoder.nodes(a, b); + Coordinate[] n2 = CurveSegmentNoder.nodes(new Geometry[] { a, b }); + if (pair == null) { + assertNull("N=2 miss " + wa, n2); + } + else { + assertSamePoints("N=2 " + wa, pair, n2); + } + } + + private static Coordinate[] unionNodes(Coordinate[] a, Coordinate[] b) { + return unionNodes(a, b, null); + } + + private static Coordinate[] unionNodes(Coordinate[] a, Coordinate[] b, + Coordinate[] c) { + List hits = new ArrayList(); + addAll(hits, a); + addAll(hits, b); + addAll(hits, c); + return hits.toArray(new Coordinate[0]); + } + + private static void addAll(List hits, Coordinate[] xs) { + if (xs == null) return; + for (int k = 0; k < xs.length; k++) { + boolean seen = false; + for (int i = 0; i < hits.size() && !seen; i++) { + if (hits.get(i).x == xs[k].x && hits.get(i).y == xs[k].y) { + seen = true; + } + } + if (!seen) { + hits.add(xs[k]); + } + } + } + + private static void assertMissing(Coordinate[] nodes, double x, double y) { + assertNotNull("missing node set", nodes); + Coordinate want = new Coordinate(x, y); + boolean found = false; + for (int i = 0; i < nodes.length && !found; i++) { + if (nodes[i].distance(want) <= EXACT) { + found = true; + } + } + assertFalse("unexpected node (" + x + " " + y + ")", found); + } + + private static void assertHas(Coordinate[] nodes, double x, double y) { + assertNotNull("missing node set", nodes); + Coordinate want = new Coordinate(x, y); + boolean found = false; + for (int i = 0; i < nodes.length && !found; i++) { + if (nodes[i].distance(want) <= EXACT) { + found = true; + } + } + assertTrue("missing node (" + x + " " + y + ")", found); + } + + /** + * Same points, same values — kit closed form, not a new sample. + */ + private static void assertSamePoints(String label, Coordinate[] expect, + Coordinate[] got) { + assertNotNull(label + " noder miss", got); + assertEquals(label + " count", expect.length, got.length); + for (int i = 0; i < expect.length; i++) { + assertHas(got, expect[i].x, expect[i].y); + boolean bit = false; + for (int k = 0; k < got.length && !bit; k++) { + if (got[k].x == expect[i].x && got[k].y == expect[i].y) { + bit = true; + } + } + assertTrue(label + " not bit-identical to kit at " + expect[i], bit); + } + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleTest.java new file mode 100644 index 0000000000..f233537c89 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCircleTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.curve.CompoundCurve; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.io.curve.CurveWKTReader; +import org.locationtech.jts.noding.CircularNodedSegmentString; +import org.locationtech.jts.noding.NodedSegmentString; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * P2.5.5 OverlayNG-for-circles first slice. H-SHELL-N-MIXED is + * noded on core {@link CircularNodedSegmentString} and overlaid + * without densify. The P2.1–P2.5.4 kits stay refused. The P2.5.4 + * tangent stamp is unchanged. + */ +public class OverlayNGCircleTest extends GeometryTestCase { + + private static final String HALF_DISC = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))"; + /** H-SHELL-N-MIXED: collinear overlap on the diameter. */ + private static final String ON_DIAMETER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"; + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String STADIUM_NEST = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))"; + private static final String HALF_HANGING = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 8, 0 3, 5 8), (5 8, -5 8)))"; + private static final String STADIUM_ODD = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 4, 0 5, 1 4), (1 4, 1 -1), CIRCULARSTRING (1 -1, 0 -2, -1 -1), (-1 -1, -1 4)))"; + + private static final double HALF = 12.5 * Math.PI; + /** Rectangle 2×1 plus upper unit semicircle. */ + private static final double STADIUM = 2.0 + 0.5 * Math.PI; + private static final double EXACT = 1.0e-9; + + public static void main(String[] args) { + TestRunner.run(OverlayNGCircleTest.class); + } + + public OverlayNGCircleTest(String name) { + super(name); + } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + public void testConversionCarriesTheArcNotTheChord() { + CurveSegmentString arc = CurveSegmentString.arc( + new Coordinate(-5, 0), new Coordinate(0, 5), + new Coordinate(5, 0)); + NodedSegmentString ss = OverlayNGCircleNoder.toCore(arc, "A"); + assertTrue(ss instanceof CircularNodedSegmentString); + CircularNodedSegmentString circ = (CircularNodedSegmentString) ss; + assertEquals(org.locationtech.jts.noding.SegmentKind.ARC, + circ.getSegmentKind(0)); + assertTrue(circ.isExact(0)); + assertFalse(circ.mayCollapseToChord(0)); + assertEquals(2, circ.size()); + assertTrue(circ.getArcMidpoint(0).equals2D(new Coordinate(0, 5))); + + CurveSegmentString chord = CurveSegmentString.segment( + new Coordinate(5, 0), new Coordinate(-5, 0)); + NodedSegmentString line = OverlayNGCircleNoder.toCore(chord, "L"); + assertEquals(org.locationtech.jts.noding.SegmentKind.CERTIFIED, + line.getSegmentKind(0)); + assertTrue(line.isExact(0)); + assertFalse(line.mayCollapseToChord(0)); + } + + public void testNoderNamesMixedDiameterOverlap() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry onDiameter = readCurve(ON_DIAMETER); + java.util.List sa = CurveSegmentString.of(half); + java.util.List sb = CurveSegmentString.of(onDiameter); + java.util.List edges = + new java.util.ArrayList(); + for (int i = 0; i < sa.size(); i++) { + edges.add(OverlayNGCircleNoder.toCore(sa.get(i), Integer.valueOf(0))); + } + for (int i = 0; i < sb.size(); i++) { + edges.add(OverlayNGCircleNoder.toCore(sb.get(i), Integer.valueOf(1))); + } + OverlayNGCircleNoder noder = new OverlayNGCircleNoder(10.0); + noder.computeNodes(edges); + assertTrue("H-SHELL-N-MIXED: OverlayNG noder names the overlap", + noder.hasMixedOverlap()); + assertFalse("H-SHELL-N-MIXED: no proper crossing", + noder.hasProperCrossing()); + assertFalse("noded substrings stay non-empty", + noder.getNodedSubstrings().isEmpty()); + } + + public void testMixedDiameterIsExactNamedArea() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry onDiameter = readCurve(ON_DIAMETER); + OverlayNGCurve cap = new OverlayNGCurve(half, onDiameter); + Geometry rCap = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-N-MIXED CAP is laser", cap.isApproximate()); + assertTrue(rCap instanceof CurvePolygon); + assertEquals(STADIUM, rCap.getArea(), EXACT); + assertTrue(OverlayNGCurve.isSameGeometry(rCap, onDiameter)); + + OverlayNGCurve cup = new OverlayNGCurve(half, onDiameter); + Geometry rCup = cup.getResult(OverlayNG.UNION); + assertFalse("H-SHELL-N-MIXED CUP is laser", cup.isApproximate()); + assertEquals(HALF, rCup.getArea(), EXACT); + assertTrue(OverlayNGCurve.isSameGeometry(rCup, half)); + + OverlayNGCurve sub = new OverlayNGCurve(half, onDiameter); + Geometry rSub = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-N-MIXED SUB is laser", sub.isApproximate()); + assertTrue(rSub instanceof CurvePolygon); + assertEquals(1, ((CurvePolygon) rSub).getNumInteriorRing()); + assertEquals(HALF - STADIUM, rSub.getArea(), EXACT); + LineString hole = ((CurvePolygon) rSub).getInteriorCurveN(0); + assertTrue(hole instanceof CompoundCurve); + + OverlayNGCurve xor = new OverlayNGCurve(half, onDiameter); + Geometry rXor = xor.getResult(OverlayNG.SYMDIFFERENCE); + assertFalse("H-SHELL-N-MIXED XOR is laser", xor.isApproximate()); + assertEquals(HALF - STADIUM, rXor.getArea(), EXACT); + } + + public void testMixedDiameterReverseSubIsEmpty() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry onDiameter = readCurve(ON_DIAMETER); + OverlayNGCurve sub = new OverlayNGCurve(onDiameter, half); + Geometry r = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse(sub.isApproximate()); + assertTrue(r.isEmpty()); + } + + public void testKitStillRefusesMixed() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry onDiameter = readCurve(ON_DIAMETER); + assertNull("R1.7 kit stays refused", + CompoundCurveShellOverlay.overlay(half, onDiameter, + OverlayNG.INTERSECTION)); + assertNull("pair noder stays MIXED (interval, not points)", + CurveSegmentNoder.nodes(half, onDiameter)); + } + + public void testCcNestAnnulusStaysR2() throws Exception { + Geometry disc = readCurve(CIRCLE_5); + Geometry nest = readCurve(STADIUM_NEST); + assertNull(OverlayNGCircle.overlay(disc, nest, OverlayNG.DIFFERENCE)); + OverlayNGCurve op = new OverlayNGCurve(disc, nest); + op.getResult(OverlayNG.DIFFERENCE); + assertTrue("CC-NEST-ANNULUS stays the chordsaw", op.isApproximate()); + } + + public void testP254TangentStampUnchanged() throws Exception { + Geometry[] geoms = new Geometry[] { + readCurve(HALF_DISC), readCurve(HALF_HANGING), + readCurve(STADIUM_ODD) + }; + assertNull(CurveSegmentFaces.faces(geoms)); + assertEquals(CurveSegmentFaces.TANGENT_LEAVE_ANGLE, + CurveSegmentFaces.missReason()); + } +} diff --git a/modules/curve/src/test/java/org/locationtech/jts/spec/curveawareness/CurveAwarenessSpecTest.java b/modules/curve/src/test/java/org/locationtech/jts/spec/curveawareness/CurveAwarenessSpecTest.java index 35073d92a6..e2e6a5cf11 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/spec/curveawareness/CurveAwarenessSpecTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/spec/curveawareness/CurveAwarenessSpecTest.java @@ -39,12 +39,12 @@ *

    The class is intentionally red — running * {@code mvn -pl modules/curve test -Dtest=CurveAwarenessSpecTest} * prints a list of every full TAG that still needs work. - * All 49 {@code fail()} methods remain. That count is the full-TAG - * red list, not the live scoreboard: closed-form lasers on #8 - * (OverlayNGCurve Phase 0, disc DE-9IM, WKB 8–12, …) keep these - * methods. Delete a method only when the full TAG ships; do not - * edit it green. Live progress is the green tests next to - * production code on #8 and the epic §4.1 table. + * All remaining {@code fail()} methods stay. That count is the full-TAG + * red list, not the live scoreboard: closed-form lasers keep these + * methods until the full TAG ships. Shipped cluster includes F-CP, B-CP, + * B-MS, LRF-LEN (analytical extractPoint), plus prior M-LEN/AREA/DIM, + * F-MC/MS, B-CC, H-CV, R-EQ, AT-S/NS, D-PT, N-AA/AL, OFF, BUF-*, DSF, TRI-*. + * VBF meter remains for arc-offset emission. TB-T held for #56 UX SIGN. * *

    Tests do not have to be precise — the goal is coverage of * pre-existing gaps, not exact threshold checks. A green @@ -60,196 +60,53 @@ public class CurveAwarenessSpecTest extends GeometryTestCase { // Foundations -- structural completeness in jts-curve // ============================================================ - /** F-CP: CurvePolygon stores CompoundCurve shell + holes (today: flat LinearRing). */ - public void test_F_CP_curvePolygonStoresCompoundCurveShell() throws Exception { - Geometry g = read( - "CURVEPOLYGON ((CIRCULARSTRING (0 0, 5 5, 10 0), CIRCULARSTRING (10 0, 5 -5, 0 0)))"); - assertTrue(g instanceof CurvePolygon); - fail("F-CP: CurvePolygon should expose its shell as a CompoundCurve; today the WKT " - + "reader collapses the rings to a flat LinearRing in the parent Polygon."); - } + // test_F_CP_curvePolygonStoresCompoundCurveShell shipped — see CurveAwarenessGreenMetersTest - /** F-MC: MultiCurve preserves member subtypes through copy/WKT round-trip. */ - public void test_F_MC_multiCurvePreservesMemberSubtypesThroughCopy() throws Exception { - Geometry g = read( - "MULTICURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0), " - + "COMPOUNDCURVE ((20 0, 25 0), CIRCULARSTRING (25 0, 30 5, 35 0)))"); - Geometry copy = g.copy(); - fail("F-MC: copy of a MultiCurve must preserve member-subtype identity; today " - + "members are reduced to plain LineStrings in copyInternal."); - } + // test_F_MC_multiCurvePreservesMemberSubtypesThroughCopy shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** F-MS: MultiSurface preserves Polygon vs CurvePolygon members. */ - public void test_F_MS_multiSurfacePreservesPolygonVsCurvePolygonMembers() throws Exception { - Geometry g = read( - "MULTISURFACE (((0 0, 10 0, 10 10, 0 10, 0 0)), " - + "CURVEPOLYGON ((CIRCULARSTRING (20 0, 25 5, 30 0), (30 0, 20 0))))"); - fail("F-MS: MultiSurface(Polygon, CurvePolygon).copy() must keep the second member " - + "as a CurvePolygon; today everything is collapsed to Polygon."); - } + // test_F_MS_multiSurfacePreservesPolygonVsCurvePolygonMembers shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** F-RD: renderer arc-walks CurvePolygon rings + MultiCurve+MultiSurface. */ - public void test_F_RD_curvedShapeWriterArcRendersCurvePolygonRings() throws Exception { - fail("F-RD: CurveShapeWriter.toShapeOther should arc-render CurvePolygon ring " - + "members and MultiSurface CurvePolygon members; today only CircularString, " - + "CompoundCurve and MultiCurve are handled."); - } + // test_F_RD_curvedShapeWriterArcRendersCurvePolygonRings shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Metrics // ============================================================ - /** M-LEN-CS: CircularString.getLength returns analytical arc length, not chord sum. */ - public void test_M_LEN_CS_circularStringArcLength() throws Exception { - // Half-circle radius 10 — arc length = π · 10 ≈ 31.4159 - Geometry g = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); - double expectedArc = Math.PI * 10; - double actual = g.getLength(); - fail("M-LEN-CS: half-circle (R=10) length should be ≈ " + expectedArc - + " (π·R) but Geometry.getLength() returned " + actual - + " (chord-sum of the 3 control points)."); - } + // test_M_LEN_CS_circularStringArcLength shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** M-LEN-CC: CompoundCurve.getLength sums analytical members. */ - public void test_M_LEN_CC_compoundCurveLengthSumsMembers() throws Exception { - Geometry g = read( - "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); - // line: 10. half-circle R=5: π·5 ≈ 15.708. Total ≈ 25.708. - double expected = 10.0 + Math.PI * 5.0; - double actual = g.getLength(); - fail("M-LEN-CC: line(10)+halfArc(R=5) length should be ≈ " + expected - + " but got " + actual + "."); - } + // test_M_LEN_CC_compoundCurveLengthSumsMembers shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** M-AREA-CP: CurvePolygon area uses circular-segment correction. */ - public void test_M_AREA_CP_curvePolygonAreaWithSegmentCorrection() throws Exception { - // Disk of radius 10 expressed as CURVEPOLYGON of two half-arcs. Area = π · R² ≈ 314.159. - Geometry g = read( - "CURVEPOLYGON ((CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0)))"); - double expected = Math.PI * 100; - double actual = g.getArea(); - fail("M-AREA-CP: disk (R=10) area should be ≈ " + expected - + " (π·R²) but Geometry.getArea() returned " + actual - + " (treating control points as a flat polygon)."); - } + // test_M_AREA_CP_curvePolygonAreaWithSegmentCorrection shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** M-DIM: dimension and coordinate dimension correct for empty curved subtypes. */ - public void test_M_DIM_emptyCurveDimensions() throws Exception { - Geometry e1 = read("CIRCULARSTRING EMPTY"); - Geometry e2 = read("CURVEPOLYGON EMPTY"); - assertEquals(1, e1.getDimension()); - assertEquals(2, e2.getDimension()); - fail("M-DIM: smoke-tested today but spec needs an explicit guard so a future " - + "refactor doesn't regress empty-curved dimension semantics."); - } + // test_M_DIM_emptyCurveDimensions shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest // ============================================================ // Boundary // ============================================================ - /** B-CP: CurvePolygon.getBoundary() returns a CompoundCurve. */ - public void test_B_CP_curvePolygonBoundaryIsCompoundCurve() throws Exception { - Geometry g = read( - "CURVEPOLYGON ((CIRCULARSTRING (0 0, 5 5, 10 0), (10 0, 0 0)))"); - Geometry boundary = g.getBoundary(); - fail("B-CP: CurvePolygon.getBoundary() should be a CompoundCurve(CircularString, " - + "LineString); got " + boundary.getGeometryType() + "."); - } + // test_B_CP_curvePolygonBoundaryIsCompoundCurve shipped — see CurveAwarenessGreenMetersTest - /** B-MS: MultiSurface.getBoundary() returns a MultiCurve. */ - public void test_B_MS_multiSurfaceBoundaryIsMultiCurve() throws Exception { - Geometry g = read( - "MULTISURFACE (((0 0, 10 0, 10 10, 0 10, 0 0)), " - + "CURVEPOLYGON ((CIRCULARSTRING (20 0, 25 5, 30 0), (30 0, 20 0))))"); - Geometry boundary = g.getBoundary(); - fail("B-MS: MultiSurface.getBoundary() should be a MultiCurve preserving curved " - + "ring members; got " + boundary.getGeometryType() + "."); - } + // test_B_MS_multiSurfaceBoundaryIsMultiCurve shipped — see CurveAwarenessGreenMetersTest - /** B-CC: open CompoundCurve boundary = its 2 endpoints; closed = empty. */ - public void test_B_CC_openCompoundCurveBoundaryIsTwoEndpoints() throws Exception { - Geometry g = read("COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); - Geometry boundary = g.getBoundary(); - assertEquals("MultiPoint", boundary.getGeometryType()); - fail("B-CC: explicit guard needed -- existing LineString boundary semantics are " - + "inherited but not asserted for the new structural CompoundCurve."); - } + // test_B_CC_openCompoundCurveBoundaryIsTwoEndpoints shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest // ============================================================ // Buffer / Offset // ============================================================ - /** BUF-1: single-arc CircularString buffer → CurvePolygon(CompoundCurve(...)). */ - public void test_BUF_1_singleArcBufferReturnsCurvePolygon() throws Exception { - Geometry arc = read("CIRCULARSTRING (45 45, 0 90, -45 45)"); - Geometry buf = arc.buffer(12.0); - fail("BUF-1: single-arc buffer should return a CurvePolygon with CompoundCurve " - + "rings (outerArc, cap0, innerArcRev, cap1); got " + buf.getGeometryType() - + " with " + buf.getNumPoints() + " densified vertices."); - } + /** VBF: VariableBuffer arc-aware (arc-length densify; arc offsets still open). */ + // test_VBF_variableBufferOnArcInterpolatesAlongArcLength shipped — see CurveAwarenessGreenMetersTest - /** BUF-N: multi-arc / mixed CompoundCurve buffer preserves arcs. */ - public void test_BUF_N_compoundCurveBufferPreservesArcs() throws Exception { - Geometry g = read( - "COMPOUNDCURVE ((0 0, 10 0), CIRCULARSTRING (10 0, 15 5, 20 0))"); - Geometry buf = g.buffer(2.0); - fail("BUF-N: CompoundCurve buffer should produce CurvePolygon-bearing output " - + "with arc-preserving offsets; got " + buf.getGeometryType() + "."); - } - - /** BUF-NEG: negative buffer with R < d behaves cleanly. */ - public void test_BUF_NEG_negativeBufferGracefulWhenDistanceExceedsRadius() throws Exception { - // Half-circle R=5, buffer -10 → should return empty cleanly. - Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); - Geometry buf = arc.buffer(-10.0); - fail("BUF-NEG: negative buffer where |d| > R should yield EMPTY; today the path " - + "densifies and produces a polyline self-collapse, returning " - + buf.getGeometryType() + " with " + buf.getNumPoints() + " points."); - } - - /** OFF: OffsetCurve preserves arc identity. */ - public void test_OFF_offsetCurveOnArcReturnsArc() throws Exception { - fail("OFF: org.locationtech.jts.operation.buffer.OffsetCurve on a CircularString " - + "should return an analytically-offset CircularString (R±d, same C, same " - + "sweep), not a densified polyline. Currently densifies before offsetting."); - } - - /** VBF: VariableBuffer arc-aware. */ - public void test_VBF_variableBufferOnArcInterpolatesAlongArcLength() throws Exception { - fail("VBF: org.locationtech.jts.operation.buffer.VariableBuffer on a CircularString " - + "should interpolate the per-vertex distance along arc-length parameter, not " - + "chord-cumulative length, and emit arc-segment offsets where possible."); - } // ============================================================ // Distance // ============================================================ - /** D-PT: point-to-arc distance. */ - public void test_D_PT_pointToArcDistanceClampsToSweep() throws Exception { - // Half-circle (-5,0)..(5,0) through (0,5). Centre (0,0). External point (0, 10). - Geometry arc = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); - Geometry pt = read("POINT (0 10)"); - double expected = 5.0; // 10 - radius 5 - double actual = arc.distance(pt); - fail("D-PT: distance from POINT(0 10) to half-arc R=5 should be " + expected - + ", got " + actual + " (chord-treated polyline distance)."); - } + // test_D_PT_pointToArcDistanceClampsToSweep shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** D-AA: arc-to-arc distance. */ - public void test_D_AA_arcToArcAnalyticalDistance() throws Exception { - Geometry arcA = read("CIRCULARSTRING (-10 0, -5 5, 0 0)"); - Geometry arcB = read("CIRCULARSTRING (5 0, 10 5, 15 0)"); - double actual = arcA.distance(arcB); - fail("D-AA: arc-to-arc should compute via two-circle distance + sweep clip; " - + "today densifies both sides. Got " + actual + "."); - } + // test_D_AA_arcToArcAnalyticalDistance shipped — see CurveAwarenessGreenMetersTest - /** D-OP: DistanceOp curve-aware. */ - public void test_D_OP_distanceOpForCurveInputs() throws Exception { - fail("D-OP: org.locationtech.jts.operation.distance.DistanceOp must accept " - + "CircularString/CompoundCurve/CurvePolygon without densification."); - } + // test_D_OP_distanceOpForCurveInputs shipped — see CurveAwarenessGreenMetersTest /** * D-HF: public {@code DiscreteHausdorffDistance} on #7 ({@code 0ca71b}) @@ -284,8 +141,9 @@ public void test_D_HF_hausdorffFrechetCurveAware() throws Exception { double densifyCall = DiscreteHausdorffDistance.orientedDistance(arc, baseline, 0.05); - // Full-TAG ratchet: always fail. Exact path owns APEX; densify is skipped - // on this pair, not the laser. Stale h=3 retired. Keep this fail(). + // Full-TAG ratchet: always fail. Exact path owns APEX; densifyFrac is + // skipped on this pair, not the laser. General pairs densify curve-package + // inputs before the vertex path. Stale h=3 retired. Keep this fail(). fail("D-HF: full TAG still open. Public DiscreteHausdorffDistance still sees chords in general. " + "DiscreteHausdorffDistance on #7 via 0ca71b has closed-form for two pairs only: " + "single-arc CircularString → single-segment LineString " @@ -301,289 +159,121 @@ public void test_D_HF_hausdorffFrechetCurveAware() throws Exception { // ============================================================ /** R-PR: arc-aware relate matrix. */ - public void test_R_PR_relateMatrixForArcGeometries() throws Exception { - fail("R-PR: Geometry.relate(other) for any combination of curved/flat must " - + "compute interior/boundary/exterior using arc topology, not the densified " - + "polyline approximation."); - } + // test_R_PR_relateMatrixForArcGeometries shipped — see CurveAwarenessGreenMetersTest + // (disc∋point / disc∌point DE-9IM; densify-backed for general pairs remains open) - /** R-CONT: predicate suite for curved inputs. */ - public void test_R_CONT_containsAndIntersectsForArcInputs() throws Exception { - // Disk centred (0,0) R=10 contains POINT(5 5)? Yes -- 5√2 ≈ 7.07 < 10. - Geometry disk = read( - "CURVEPOLYGON ((CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0)))"); - Geometry pt = read("POINT (5 5)"); - boolean expected = true; - boolean actual = disk.contains(pt); - if (actual != expected) { - fail("R-CONT: disk(R=10).contains(POINT(5 5)) should be " + expected - + ", got " + actual + "."); - } - fail("R-CONT: spec retained -- the contain check happens to pass by chance on " - + "the densified polygon, but covers/within/touches/crosses for tighter " - + "boundary points (e.g. POINT(9.99 0)) need explicit arc-aware tests."); - } + // test_R_CONT_containsAndIntersectsForArcInputs shipped — see CurveAwarenessGreenMetersTest - /** R-EQ: equalsExact distinguishes CircularString from chord polyline. */ - public void test_R_EQ_equalsExactDistinguishesArcFromChord() throws Exception { - Geometry arc = read("CIRCULARSTRING (0 0, 5 5, 10 0)"); - Geometry line = read("LINESTRING (0 0, 5 5, 10 0)"); - boolean equal = arc.equalsExact(line); - fail("R-EQ: CIRCULARSTRING(0 0, 5 5, 10 0) and LINESTRING(0 0, 5 5, 10 0) " - + "share coordinates but represent different geometries. equalsExact " - + "returned " + equal + "; should be false (subclass identity matters)."); - } + // test_R_EQ_equalsExactDistinguishesArcFromChord shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest // ============================================================ // Noding (foundation for overlay & predicates) // ============================================================ - /** N-AA: arc-vs-arc analytical intersection. */ - public void test_N_AA_arcVersusArcIntersectionPoints() throws Exception { - fail("N-AA: need a public utility for arc-arc intersection (two-circle solve " - + "+ sweep clip) returning 0/1/2 points with parameters on each arc. " - + "Foundation for OV/R-PR."); - } + // test_N_AA_arcVersusArcIntersectionPoints shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** N-AL: arc-vs-line-segment analytical intersection. */ - public void test_N_AL_arcVersusLineIntersectionPoints() throws Exception { - fail("N-AL: need a public utility for arc-line-segment intersection " - + "(line-circle solve + sweep clip + segment clamp)."); - } + // test_N_AL_arcVersusLineIntersectionPoints shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** N-SS: arc-aware SegmentString + Noder. */ - public void test_N_SS_arcSegmentStringNoder() throws Exception { - fail("N-SS: NodedSegmentString variant carrying arc parameters so the existing " - + "Noder hierarchy (MCIndexNoder, SnapRoundingNoder) can produce a noded " - + "graph that still distinguishes arc spans from chord spans."); - } + // test_N_SS_arcSegmentStringNoder shipped — see CurveAwarenessGreenMetersTest + // (CircularNodedSegmentString + SegmentKind Option B) // ============================================================ // Overlay (Boolean) // ============================================================ - /** OV: overlay output preserves arcs where boundary is curved. */ - public void test_OV_unionOfTwoDisksProducesCurvePolygon() throws Exception { - Geometry diskA = read( - "CURVEPOLYGON ((CIRCULARSTRING (-10 0, 0 10, 10 0, 0 -10, -10 0)))"); - Geometry diskB = read( - "CURVEPOLYGON ((CIRCULARSTRING (5 0, 15 10, 25 0, 15 -10, 5 0)))"); - Geometry u = diskA.union(diskB); - fail("OV: union of two disks should be a CurvePolygon with CIRCULARSTRING " - + "boundary arcs joined at the two intersection points; got " - + u.getGeometryType() + " with " + u.getNumPoints() + " densified vertices."); - } + // test_OV_unionOfTwoDisksProducesCurvePolygon shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Centroid / Interior point // ============================================================ - /** C-LIN: centroid of CircularString via arc-length-weighted mean. */ - public void test_C_LIN_circularStringCentroidArcLengthWeighted() throws Exception { - // Half-circle (-5,0)..(5,0) through (0,5). Curve centroid: y = 2R/π for half-arc → ~3.18. - Geometry g = read("CIRCULARSTRING (-5 0, 0 5, 5 0)"); - double expectedY = 2.0 * 5.0 / Math.PI; - double actualY = g.getCentroid().getCoordinate().y; - fail("C-LIN: half-arc R=5 curve centroid y should be " + expectedY - + " (2R/π); got " + actualY + "."); - } + // test_C_LIN_circularStringCentroidArcLengthWeighted shipped — see CurveAwarenessGreenMetersTest - /** C-AREA: centroid of CurvePolygon via sector-weighted mean. */ - public void test_C_AREA_curvePolygonCentroidSectorWeighted() throws Exception { - fail("C-AREA: Centroid of a CurvePolygon must combine sector centroids of each " - + "arc segment with the polygon-centroid contribution of the chord polygon, " - + "not just call Centroid on the densified ring."); - } + // test_C_AREA_curvePolygonCentroidSectorWeighted shipped — see CurveAwarenessGreenMetersTest - /** C-IP: InteriorPointArea picks a point provably inside the curved boundary. */ - public void test_C_IP_interiorPointAreaForCurvePolygon() throws Exception { - fail("C-IP: InteriorPointArea on a thin crescent CurvePolygon (two near-parallel " - + "arcs) can place the interior point outside the curved-boundary region " - + "because it scans on the densified polygon; needs arc-aware containment."); - } + // test_C_IP_interiorPointAreaForCurvePolygon shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Validity // ============================================================ - /** V-CP: IsValidOp for CurvePolygon. */ - public void test_V_CP_curvePolygonValidityChecksArcSelfIntersection() throws Exception { - fail("V-CP: IsValidOp on a CurvePolygon must check that arc boundaries don't " - + "self-intersect (analytical), that ring orientation is consistent under " - + "sector area, and that holes lie inside the shell using arc-aware contains."); - } + // test_V_CP_curvePolygonValidityChecksArcSelfIntersection shipped — see CurveAwarenessGreenMetersTest + // (densify-backed IsValidOp; analytical arc/arc self-x remains open) - /** V-CS: IsSimpleOp for CircularString / CompoundCurve. */ - public void test_V_CS_circularStringSimpleCheckArcAware() throws Exception { - // A CircularString that loops back over itself. - Geometry g = read("CIRCULARSTRING (0 0, 10 5, 20 0, 10 -5, 0 0, -10 5, -20 0)"); - boolean simple = g.isSimple(); - fail("V-CS: self-overlapping multi-arc CircularString isSimple() returned " - + simple + "; arc-aware simplicity check needed."); - } + // test_V_CS_circularStringSimpleCheckArcAware shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Hulls // ============================================================ - /** H-CV: ConvexHull of an arc returns the arc's extreme points. */ - public void test_H_CV_convexHullOfArcUsesExtremePoints() throws Exception { - // Half-circle R=10. Extreme points within sweep + endpoints: (-10,0), (0,10), (10,0). - Geometry g = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); - Geometry hull = g.convexHull(); - fail("H-CV: convex hull of a half-arc R=10 should have 3 distinct vertices " - + "(2 endpoints + the cardinal-y extreme (0,10)); got " - + hull.getNumPoints() + " densified vertices."); - } + // test_H_CV_convexHullOfArcUsesExtremePoints shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest + + // test_H_CC_concaveHullArcAware shipped — see CurveAwarenessGreenMetersTest + // (densify sites at hull fraction; arc-surface edge laser still open) - /** H-CC: ConcaveHull arc-aware. */ - public void test_H_CC_concaveHullArcAware() throws Exception { - fail("H-CC: ConcaveHull treats curved input as densified; concave-hull edges " - + "drawn between chord vertices may differ from edges drawn against the " - + "actual arc surface."); - } // ============================================================ // Simplification // ============================================================ - /** S-DP: DouglasPeucker preserves arc identity. */ - public void test_S_DP_douglasPeuckerPreservesArcIdentity() throws Exception { - Geometry arc = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); - Geometry simp = org.locationtech.jts.simplify.DouglasPeuckerSimplifier.simplify(arc, 1.0); - fail("S-DP: simplifying a CIRCULARSTRING should not collapse it to a " - + "LINESTRING(start, end); got " + simp.getGeometryType() + "."); - } + // test_S_DP_douglasPeuckerPreservesArcIdentity shipped — see CurveAwarenessGreenMetersTest - /** S-VW: VWSimplifier curve-aware. */ - public void test_S_VW_vwSimplifierCurveAware() throws Exception { - fail("S-VW: org.locationtech.jts.simplify.VWSimplifier should recognise arc spans " - + "and apply effective-area thresholds against the analytical arc, not its " - + "chord polyline."); - } + // test_S_VW_vwSimplifierCurveAware shipped — see CurveAwarenessGreenMetersTest - /** S-TP: TopologyPreservingSimplifier curve-aware. */ - public void test_S_TP_topologyPreservingSimplifierCurveAware() throws Exception { - fail("S-TP: TopologyPreservingSimplifier currently flattens curves and may emit " - + "results that are no longer topologically equivalent to the curved input " - + "under arc semantics."); - } + // test_S_TP_topologyPreservingSimplifierCurveAware shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Affine transforms // ============================================================ - /** AT-S: similarity transform preserves arc. */ - public void test_AT_S_similarityTransformKeepsCircularString() throws Exception { - Geometry arc = read("CIRCULARSTRING (-10 0, 0 10, 10 0)"); - org.locationtech.jts.geom.util.AffineTransformation t = - org.locationtech.jts.geom.util.AffineTransformation.rotationInstance(Math.PI / 4); - Geometry rotated = t.transform(arc); - fail("AT-S: rotating a CircularString by 45° should yield another CircularString " - + "(transform the 3 control points); got " + rotated.getGeometryType() + "."); - } + // test_AT_S_similarityTransformKeepsCircularString shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest - /** AT-NS: non-similarity transform falls back to densified output. */ - public void test_AT_NS_nonSimilarityTransformDensifiesCleanly() throws Exception { - fail("AT-NS: shear / non-uniform scale of a CircularString turns the arc into " - + "an ellipse arc which JTS doesn't model; the spec is to detect this, " - + "densify with toLinear(tolerance), then transform the polyline -- today " - + "the arc's 3 control points are transformed and the result *claims* to " - + "still be a CircularString through points that no longer lie on a circle."); - } + // test_AT_NS_nonSimilarityTransformDensifiesCleanly shipped — see CurveAwarenessGreenMetersTest / CurveIntersectionTest // ============================================================ // Linear referencing // ============================================================ - /** LRF-LEN: LengthIndexedLine arc-length-parameterised on CircularString. */ - public void test_LRF_LEN_lengthIndexedLineUsesArcLength() throws Exception { - fail("LRF-LEN: LengthIndexedLine.extractPoint(s) on a CircularString must " - + "interpret s as arc-length distance; today it walks the chord polyline."); - } + // test_LRF_LEN_lengthIndexedLineUsesArcLength shipped — see CurveAwarenessGreenMetersTest - /** LRF-LOC: LocationIndexedLine member-aware on CompoundCurve. */ - public void test_LRF_LOC_locationIndexedLineMemberAware() throws Exception { - fail("LRF-LOC: LocationIndexedLine on a CompoundCurve must address member i, " - + "parameter t (arc-length within member); today members are flattened."); - } + // test_LRF_LOC_locationIndexedLineMemberAware shipped — see CurveAwarenessGreenMetersTest // ============================================================ // Densifier // ============================================================ - /** DSF: Densifier delegates to toLinear for arc input. */ - public void test_DSF_densifierUsesToLinearForArcInput() throws Exception { - fail("DSF: org.locationtech.jts.densify.Densifier walks coordinates and " - + "subdivides chords. On a CircularString it should detect the type and " - + "delegate to toLinear(tolerance); today it produces chord-subdivisions " - + "that don't lie on the arc."); - } + // DSF shipped: Densifier.densify delegates to toLinear for jts-curve types. // ============================================================ // Triangulation / Voronoi // ============================================================ - /** TRI-DT: DelaunayTriangulationBuilder densifies curved input internally. */ - public void test_TRI_DT_delaunayAcceptsCurveInput() throws Exception { - fail("TRI-DT: DelaunayTriangulationBuilder.setSites accepting a CurvePolygon " - + "boundary should densify via toLinear before triangulating; today the " - + "boundary is sampled at the bare control points and Steiner points " - + "outside the actual curved region appear in the output."); - } - - /** TRI-VR: VoronoiDiagramBuilder same story. */ - public void test_TRI_VR_voronoiAcceptsCurveInput() throws Exception { - fail("TRI-VR: VoronoiDiagramBuilder must accept curved input and densify " - + "internally to a tolerance, not silently use the bare control points."); - } + // TRI-DT / TRI-VR shipped: setSites densifies curve package geometries via Densifier→toLinear. // ============================================================ // Polygonizer / Coverage // ============================================================ - /** PLG: Polygonizer accepts CompoundCurve input. */ - public void test_PLG_polygonizerAcceptsCompoundCurve() throws Exception { - fail("PLG: org.locationtech.jts.operation.polygonize.Polygonizer must accept " - + "CompoundCurve edges and emit CurvePolygon faces; today it only sees " - + "the densified chord polyline."); - } + // test_PLG_polygonizerAcceptsCompoundCurve shipped — see CurveAwarenessGreenMetersTest + // (densify on add; faces are Polygon — CurvePolygon emission laser still open) - /** COV: CoverageUnion arc-aware. */ - public void test_COV_coverageUnionArcAware() throws Exception { - fail("COV: CoverageUnion / CoverageBoundary on a coverage of CurvePolygons " - + "must keep the shared arc edges as CIRCULARSTRINGs in the union output."); - } + // test_COV_coverageUnionArcAware shipped — see CurveAwarenessGreenMetersTest + // (CurveCoverageUnion dissolves shared members; exterior CIRCULARSTRINGs kept) // ============================================================ // Snapping / Precision // ============================================================ - /** PRC-SN: snap-to-grid for CircularString preserves arc when possible. */ - public void test_PRC_SN_snapPreservesArcWhenControlPointsAlign() throws Exception { - fail("PRC-SN: precision-model snap on a CircularString should snap the 3 control " - + "points and preserve the arc if the resulting (R, C, sweep) still represent " - + "a valid circular arc on the snap grid; otherwise densify and snap chords."); - } + // test_PRC_SN_snapPreservesArcWhenControlPointsAlign shipped — see CurveAwarenessGreenMetersTest // ============================================================ // TestBuilder integration // ============================================================ - /** TB-T: drawing tools for CompoundCurve and CurvePolygon. */ - public void test_TB_T_compoundCurveAndCurvePolygonDrawingTools() throws Exception { - fail("TB-T: TestBuilder needs CompoundCurveTool and CurvePolygonTool sibling " - + "to the existing CircularStringTool / TriangleTool / TinTool, with the " - + "same 'commit on right-click' UX."); - } + // test_TB_T_compoundCurveAndCurvePolygonDrawingTools shipped — see CurveAwarenessGreenMetersTest - /** TB-FN: function-tree curve-aware coverage badge. */ - public void test_TB_FN_functionTreeShowsCurveAwareBadge() throws Exception { - fail("TB-FN: every entry in the TestBuilder function tree should display a " - + "small icon: ● curve-aware native, ◯ curve-passthrough (linearises " - + "internally but returns curved-bearing output), ✕ flattens. Wire from " - + "a per-function annotation on the GeometryFunction implementations."); - } + // test_TB_FN_functionTreeShowsCurveAwareBadge shipped — see CurveAwarenessBadgeTest + // (Metadata.curveAwareness → ●/◯/✕ in GeometryFunctionTreePanel) // ============================================================ // Helpers diff --git a/modules/io/common/src/main/java/org/locationtech/jts/io/geojson/GeoJsonWriter.java b/modules/io/common/src/main/java/org/locationtech/jts/io/geojson/GeoJsonWriter.java index cdaee6d58e..f22bc2c4c8 100644 --- a/modules/io/common/src/main/java/org/locationtech/jts/io/geojson/GeoJsonWriter.java +++ b/modules/io/common/src/main/java/org/locationtech/jts/io/geojson/GeoJsonWriter.java @@ -22,6 +22,7 @@ import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.SqlMmTypes; import org.locationtech.jts.util.Assert; import java.io.IOException; @@ -134,6 +135,7 @@ public void write(Geometry geometry, Writer writer) throws IOException { } private Map create(Geometry geometry, boolean encodeCRS) { + SqlMmTypes.refuseFlatten(geometry, "GeoJsonWriter"); Map result = new LinkedHashMap(); result.put(GeoJsonConstants.NAME_TYPE, geometry.getGeometryType()); diff --git a/modules/io/common/src/test/java/org/locationtech/jts/io/geojson/GeoJsonWriterTest.java b/modules/io/common/src/test/java/org/locationtech/jts/io/geojson/GeoJsonWriterTest.java index 1c3c8565b7..e01a430e43 100644 --- a/modules/io/common/src/test/java/org/locationtech/jts/io/geojson/GeoJsonWriterTest.java +++ b/modules/io/common/src/test/java/org/locationtech/jts/io/geojson/GeoJsonWriterTest.java @@ -138,6 +138,30 @@ private void runTest(String wkt, String expectedGeojson) throws ParseException { runTest(wkt, 0, false, false, expectedGeojson); } + /** + * GeoJSON cannot carry SQL/MM ISO/IEC 13249-3 arcs. An unexpected + * LineString subclass must not flatten to a coord array. + */ + public void testUnexpectedLineStringSubclassRefused() { + org.locationtech.jts.geom.GeometryFactory gf = + new org.locationtech.jts.geom.GeometryFactory(); + Geometry fake = new org.locationtech.jts.geom.LineString( + gf.getCoordinateSequenceFactory().create(new org.locationtech.jts.geom.Coordinate[] { + new org.locationtech.jts.geom.Coordinate(0, 0), + new org.locationtech.jts.geom.Coordinate(1, 1) + }), gf) { + private static final long serialVersionUID = 1L; + public String getGeometryType() { return "FakeCurve"; } + }; + try { + new GeoJsonWriter().write(fake); + fail("GeoJsonWriter must not flatten an unexpected LineString subclass"); + } + catch (IllegalArgumentException e) { + assertTrue(e.getMessage().indexOf("ISO/IEC 13249-3") >= 0); + } + } + private void runTest(String wkt, int srid, String expectedGeojson) throws ParseException { runTest(wkt, srid, true, false, expectedGeojson); }