Skip to content

Commit 37e3dfe

Browse files
feat(curve): M.5 concentric-ring continuous Frechet + ML.2 half-disc MIC
M.5: DiscreteFrechetDistance closed form for concentric CircularString rings (F=|R-r|). ML.2: CurveExact.halfDiscMic / mic() r=R/2 on axis. Tests + program sequence docs. Skip M.4 / R.3. Co-authored-by: Jeroen Bloemscheer <grootstebozewolf@users.noreply.github.com>
1 parent 1cd9f49 commit 37e3dfe

7 files changed

Lines changed: 230 additions & 45 deletions

File tree

doc/CHAINSAW_LASER_PROGRAM.md

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,60 @@
11
# Chainsaw → Laser · arc-native program
22

3-
Tip pin: `feature/sfa-curve-rgr` / session @ `c293fe97` (Sync → M.3 → R.2 → ML.4 landed).
3+
Tip pin: session @ `1cd9f498` · product Sync→M.3→R.2→ML.4 @ `c293fe97`.
4+
**Active sequence:** **M.5 → ML.2 → HP.4 → N-SS expand (Option B) → stop**
5+
46
Contract: parity or named densify-shim · `t_laser ≤ 1.15 × t_chainsaw` · no silent ConcaveHull · OverlayNGCurve never *Curved*.
57

68
Scoreboard (user board): **green 16 · chainsaw-only 3 · measured 11 hold**. See [LASER_RATCHET.md](LASER_RATCHET.md).
79

8-
## Holds (do not start)
9-
10-
- **ML.2** convex hole-free CurvePolygon MIC
11-
- **HP.4** HotPixel walk
12-
- **N-SS expand** / P2.5.5 as a start signal
13-
- **M.5** continuous Fréchet before DHD owns Curve*
14-
- Full **D-HF TAG** green (keep `fail()` in `CurveAwarenessSpecTest`)
10+
## Holds (still bind)
1511

16-
## Sequence locked (this branch)
12+
- Full **D-HF TAG** green (`fail()` kept)
13+
- **M.4** stadium HD · **R.3** stadium relate · **ML.3** MIC miss catalog · **HP.5** core HotPixel
14+
- 74-file N-SS Option B *lie* · curvature-order face walk
1715

18-
**Sync → M.3 → R.2 → ML.4 → stop**
16+
## Sequence (this branch)
1917

2018
| Step | Rung | Do |
2119
|------|------|----|
22-
| Sync | docs | **Landed** — this file + ratchet / seams / epic pointers |
23-
| M.3 | D-HF-IWD | **Landed**`isFullyWithinDistance` via `exactOrientedPoints` |
24-
| R.2 | DE-9IM | **Landed** — half-disc / open CircularString vs Point / Line / same-circle disc |
25-
| ML.4 | LEC | **Landed**`ObstacleDistance` hole-free filled shell assembly |
20+
| M.5 | Continuous Fréchet | One more continuous closed-form cell; general coupling leftover |
21+
| ML.2 | Convex MIC | HALF_DISC closed form (centre on axis, r=R/2); nonconvex null |
22+
| HP.4 | Faces after snap | Wire `CurveHotPixelSnap`; stamp if `SHARED_SNAPPED_RAY` |
23+
| N-SS | Option B expand | One more `OverlayNGCircle` exact cell; not 74-file |
24+
| stop || Retip; await PO |
2625

27-
**Stop.** No further rungs on this sequence. Holds above still bind.
26+
## Prior sequence (landed)
27+
28+
Sync → M.3 IWD → R.2 half-disc → ML.4 shell @ `c293fe97` / docs `1cd9f498`.
2829

2930
## Kit map (abbreviated)
3031

3132
### Metric (M.X)
3233

3334
| Rung | Status |
3435
|------|--------|
35-
| M.0 | Done — distance helpers + two-pair D-HF / Fréchet subset |
36-
| M.1 | Landed — DHD owns Curve* (arc→segment; discs) |
37-
| M.2 | Landed — bulge sensitivity |
38-
| **M.3** | **Landed** — D-HF-IWD `isFullyWithinDistance` |
39-
| M.4+ | Hold / later (stadium HD, Fréchet, DistanceOp TAG) |
36+
| M.0–M.3 | Landed |
37+
| M.4 | Hold |
38+
| **M.5** | **This sequence** — continuous Fréchet |
39+
| M.6+ | Later |
4040

41-
### Relate (R.X)
41+
### MIC / LEC (ML.X)
4242

4343
| Rung | Status |
4444
|------|--------|
45-
| R.0 | Done — disc DE-9IM lock |
46-
| R.1 | Landed — TOUCH `FF2F01212` public relate |
47-
| **R.2** | **Landed** — half-disc / CircularString |
48-
| R.3+ | Stadium / CompoundCurve shell — not this stop |
45+
| ML.0–ML.1 | Landed |
46+
| **ML.2** | **This sequence** — convex hole-free MIC |
47+
| ML.3 | Named misses — keep grid |
48+
| ML.4 | Landed |
4949

50-
### MIC / LEC (ML.X)
50+
### HotPixel / N-SS
5151

5252
| Rung | Status |
5353
|------|--------|
54-
| ML.0 | Done — disc identity + LEC disc-over-disk |
55-
| ML.1 | Landed — stadium MIC |
56-
| ML.2 | **Hold** |
57-
| ML.3 | Named misses (holed / pinch / annulus) — keep grid |
58-
| **ML.4** | **Landed** — ObstacleDistance hole-free shell assembly |
59-
| ML.5+ | Apollonius / IsRadiusWithin — after ML.4 |
60-
61-
## Named leftovers (not this stop)
62-
63-
Bar-1 style leftovers may still include: H-DISC (open arcs) · CC-NEST-ANNULUS · R1.6-honesty · TOUCH-int · edge-constrained weighted · HP seams. R-PR-HALF sewn by R.2.
64-
Do not expand DiscreteHausdorff named pairs. Do not densify-then-flag-exact.
54+
| HP.0–HP.3 | Landed |
55+
| **HP.4** | **This sequence** — faces after snap / honest stamp |
56+
| HP.5 | Off |
57+
| N-SS | Started OverlayNGCircle; **expand** this sequence |
6558

6659
## Related
6760

modules/core/src/main/java/org/locationtech/jts/algorithm/distance/DiscreteFrechetDistance.java

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ public static double distance(Geometry g0, Geometry g1) {
7979

8080
/**
8181
* True when {@link #distance(Geometry, Geometry)} uses a certified
82-
* continuous closed form (two circular discs, or an endpoint-aligned
83-
* minor circular arc over its chord) instead of the control-point matrix.
82+
* continuous closed form (two circular discs, an endpoint-aligned
83+
* minor circular arc over its chord, or two concentric full-circle
84+
* rings) instead of the control-point matrix.
8485
*
8586
* @param g0 the 1st geometry
8687
* @param g1 the 2nd geometry
@@ -99,6 +100,9 @@ public static boolean hasCertifiedClosedForm(Geometry g0, Geometry g1) {
99100
return DiscreteHausdorffDistance.circularDisc(g0) != null
100101
&& DiscreteHausdorffDistance.circularDisc(g1) != null;
101102
}
103+
if ("CircularString".equals(t0) && "CircularString".equals(t1)) {
104+
return fillConcentricCircularRings(g0, g1, null);
105+
}
102106
return false;
103107
}
104108

@@ -169,9 +173,10 @@ public Coordinate[] getCoordinates() {
169173
}
170174

171175
/**
172-
* Continuous Fréchet equals Hausdorff on the two certified pairs.
176+
* Continuous Fréchet equals Hausdorff on the certified pairs.
173177
* Two discs reuse {@link DiscreteHausdorffDistance#circleToCircle}.
174178
* The aligned minor arc uses the sagitta of that same Hausdorff.
179+
* Two concentric full-circle rings use {@code |R−r|}.
175180
*/
176181
private boolean applyCertifiedClosedForm() {
177182
if (g0 == null || g1 == null) {
@@ -200,9 +205,51 @@ private boolean applyCertifiedClosedForm() {
200205
db[0], db[1], db[2], da[0], da[1], da[2], ptDist);
201206
return true;
202207
}
208+
if ("CircularString".equals(t0) && "CircularString".equals(t1)) {
209+
ptDist = new PointPairDistance();
210+
if (fillConcentricCircularRings(g0, g1, ptDist)) {
211+
return true;
212+
}
213+
ptDist = null;
214+
return false;
215+
}
203216
return false;
204217
}
205218

219+
/**
220+
* M.5: two concentric full-circle {@code CircularString} rings.
221+
* Continuous Fréchet equals Hausdorff equals {@code |R−r|}. Off-centre
222+
* rings miss (not this cell). Control polylines of the same points
223+
* stay on the discrete matrix.
224+
*
225+
* @param dest filled with a farthest pair when non-null
226+
* @return {@code true} when the pair is certified
227+
*/
228+
private static boolean fillConcentricCircularRings(Geometry a, Geometry b,
229+
PointPairDistance dest) {
230+
double[] ra = DiscreteHausdorffDistance.circularRing(a);
231+
double[] rb = DiscreteHausdorffDistance.circularRing(b);
232+
if (ra == null || rb == null) {
233+
return false;
234+
}
235+
if (Math.hypot(ra[0] - rb[0], ra[1] - rb[1]) > 1.0e-9) {
236+
return false;
237+
}
238+
if (dest == null) {
239+
return true;
240+
}
241+
double cx = ra[0];
242+
double cy = ra[1];
243+
// Farthest pair: outer point and its radial projection on the inner.
244+
double rOut = Math.max(ra[2], rb[2]);
245+
double rIn = Math.min(ra[2], rb[2]);
246+
Coordinate outer = new Coordinate(cx + rOut, cy);
247+
Coordinate inner = new Coordinate(cx + rIn, cy);
248+
dest.initialize();
249+
dest.setMaximum(outer, inner);
250+
return true;
251+
}
252+
206253
private static boolean isArcSegmentTypePair(String t0, String t1) {
207254
return ("CircularString".equals(t0) && "LineString".equals(t1))
208255
|| ("LineString".equals(t0) && "CircularString".equals(t1));

modules/curve/src/main/java/org/locationtech/jts/geom/curve/CurveExact.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,31 @@ private static boolean isCurveCollection(Geometry g) {
12981298
static CircularArcDensifier.Circle mic(Geometry g) {
12991299
CircularArcDensifier.Circle disc = circularDisc(g);
13001300
if (disc != null) return disc;
1301-
return stadiumMic(g);
1301+
CircularArcDensifier.Circle stadium = stadiumMic(g);
1302+
if (stadium != null) return stadium;
1303+
return halfDiscMic(g);
1304+
}
1305+
1306+
/**
1307+
* ML.2: MIC of a certified half-disc. Centre sits on the symmetry
1308+
* ray through the arc mid-control; radius is {@code R/2}. Matches
1309+
* densify-{@code MaximumInscribedCircle} on HALF_DISC (R=5 → (0, 2.5)).
1310+
* Non-half-disc convex shells and nonconvex shapes miss.
1311+
*/
1312+
static CircularArcDensifier.Circle halfDiscMic(Geometry g) {
1313+
HalfDisc half = HalfDisc.of(g);
1314+
if (half == null) return null;
1315+
double r = half.circle.r;
1316+
if (r <= 0.0) return null;
1317+
double ux = half.mid.x - half.circle.cx;
1318+
double uy = half.mid.y - half.circle.cy;
1319+
double len = Math.hypot(ux, uy);
1320+
if (len <= 1.0e-12) return null;
1321+
ux /= len;
1322+
uy /= len;
1323+
double micR = 0.5 * r;
1324+
return new CircularArcDensifier.Circle(
1325+
half.circle.cx + micR * ux, half.circle.cy + micR * uy, micR);
13021326
}
13031327

13041328
/**

modules/curve/src/test/java/org/locationtech/jts/algorithm/distance/DiscreteFrechetDistanceCurveTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,28 @@ public void testReversedSegmentIsNotClosedForm() throws Exception {
113113
assertEquals(10.0, frechet, TOL);
114114
assertTrue(Math.abs(frechet - APEX) > 0.1);
115115
}
116+
117+
/**
118+
* M.5: concentric full-circle rings — continuous F = |R−r|.
119+
*/
120+
public void testConcentricRingsContinuousFrechetIsRadiusGap() throws Exception {
121+
Geometry outer = readCurve(
122+
"CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0)");
123+
Geometry inner = readCurve(
124+
"CIRCULARSTRING (-3 0, 0 3, 3 0, 0 -3, -3 0)");
125+
assertTrue(DiscreteFrechetDistance.hasCertifiedClosedForm(outer, inner));
126+
assertEquals(2.0, DiscreteFrechetDistance.distance(outer, inner), TOL);
127+
assertEquals(2.0, DiscreteFrechetDistance.distance(inner, outer), TOL);
128+
// Discrete Hausdorff on control diamonds is not the continuous answer.
129+
assertTrue(Math.abs(DiscreteHausdorffDistance.distance(outer, inner) - 2.0)
130+
> 0.1);
131+
}
132+
133+
public void testOffCentreRingsAreNotConcentricCell() throws Exception {
134+
Geometry a = readCurve(
135+
"CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0)");
136+
Geometry b = readCurve(
137+
"CIRCULARSTRING (-3 2, 0 5, 3 2, 0 -1, -3 2)");
138+
assertTrue(!DiscreteFrechetDistance.hasCertifiedClosedForm(a, b));
139+
}
116140
}

modules/curve/src/test/java/org/locationtech/jts/algorithm/distance/DiscreteFrechetDistancePerfGateTest.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,15 @@ public void testPlainLineIsChordPath() throws Exception {
126126
() -> DiscreteFrechetDistance.distance(line, seg),
127127
() -> DiscreteFrechetDistance.distance(line, seg));
128128
}
129+
130+
/** M.5: concentric rings continuous closed form. */
131+
public void testConcentricRingsNotSlowerThanControlPoints() throws Exception {
132+
Geometry outer = readCurve(
133+
"CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0)");
134+
Geometry inner = readCurve(
135+
"CIRCULARSTRING (-3 0, 0 3, 3 0, 0 -3, -3 0)");
136+
assertLaserNotSlower("Frechet concentric rings",
137+
() -> DiscreteFrechetDistance.distance(outer, inner),
138+
() -> controlPointDistance(outer, inner));
139+
}
129140
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright (c) 2026 grootstebozewolf
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
7+
* The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
8+
* and the Eclipse Distribution License is available at
9+
*
10+
* http://www.eclipse.org/org/documents/edl-v10.php.
11+
*/
12+
package org.locationtech.jts.geom.curve;
13+
14+
import org.locationtech.jts.geom.Geometry;
15+
import org.locationtech.jts.io.curve.CurveWKTReader;
16+
17+
import junit.textui.TestRunner;
18+
import test.jts.GeometryTestCase;
19+
20+
/**
21+
* ML.2: convex hole-free CurvePolygon MIC. HALF_DISC is the witness
22+
* (centre on the symmetry ray, r = R/2). Disc (ML.0) and stadium (ML.1)
23+
* stay first. Nonconvex / holed miss.
24+
*/
25+
public class CurveExactConvexMicTest extends GeometryTestCase {
26+
27+
private static final String HALF_DISC =
28+
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))";
29+
private static final String HALF_LOWER =
30+
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 -5, 5 0), (5 0, -5 0)))";
31+
private static final String CIRCLE_5 =
32+
"CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))";
33+
private static final String NONCONVEX_PINCH =
34+
"CURVEPOLYGON (COMPOUNDCURVE ("
35+
+ "CIRCULARSTRING (0 0, -2 2, 0 4), (0 4, 0 3), "
36+
+ "CIRCULARSTRING (0 3, 2 2, 0 1), (0 1, 0 0)))";
37+
38+
public static void main(String[] args) {
39+
TestRunner.run(CurveExactConvexMicTest.class);
40+
}
41+
42+
public CurveExactConvexMicTest(String name) {
43+
super(name);
44+
}
45+
46+
private static Geometry readCurve(String wkt) throws Exception {
47+
return new CurveWKTReader(new CurveGeometryFactory()).read(wkt);
48+
}
49+
50+
public void testHalfDiscMicIsHalfRadiusOnAxis() throws Exception {
51+
CircularArcDensifier.Circle mic = CurveExact.halfDiscMic(readCurve(HALF_DISC));
52+
assertNotNull(mic);
53+
assertEquals(0.0, mic.cx, 0.0);
54+
assertEquals(2.5, mic.cy, 0.0);
55+
assertEquals(2.5, mic.r, 0.0);
56+
assertEquals(mic.cx, CurveExact.mic(readCurve(HALF_DISC)).cx, 0.0);
57+
assertEquals(mic.cy, CurveExact.mic(readCurve(HALF_DISC)).cy, 0.0);
58+
assertEquals(mic.r, CurveExact.mic(readCurve(HALF_DISC)).r, 0.0);
59+
}
60+
61+
public void testLowerHalfDiscMic() throws Exception {
62+
CircularArcDensifier.Circle mic = CurveExact.mic(readCurve(HALF_LOWER));
63+
assertNotNull(mic);
64+
assertEquals(0.0, mic.cx, 0.0);
65+
assertEquals(-2.5, mic.cy, 0.0);
66+
assertEquals(2.5, mic.r, 0.0);
67+
}
68+
69+
public void testDiscStillMl0First() throws Exception {
70+
CircularArcDensifier.Circle mic = CurveExact.mic(readCurve(CIRCLE_5));
71+
assertEquals(0.0, mic.cx, 0.0);
72+
assertEquals(0.0, mic.cy, 0.0);
73+
assertEquals(5.0, mic.r, 0.0);
74+
assertNull(CurveExact.halfDiscMic(readCurve(CIRCLE_5)));
75+
}
76+
77+
public void testNonconvexPinchMisses() throws Exception {
78+
assertNull(CurveExact.halfDiscMic(readCurve(NONCONVEX_PINCH)));
79+
assertNull(CurveExact.mic(readCurve(NONCONVEX_PINCH)));
80+
}
81+
}

modules/curve/src/test/java/org/locationtech/jts/geom/curve/CurveExactStadiumMicTest.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
/**
2121
* ML.1: certified stadium MIC. Radius is the cap radius; centre is
2222
* the midpoint of the two cap centres. Disc MIC (ML.0) is tried first
23-
* and stays bit-identical. {@code HALF_DISC} is two members -- a named
24-
* miss, not a half-disc diamond claimed exact.
23+
* and stays bit-identical. {@code HALF_DISC} is ML.2 (r=R/2), not a
24+
* stadium.
2525
*/
2626
public class CurveExactStadiumMicTest extends GeometryTestCase {
2727

@@ -101,8 +101,13 @@ public void testHalfDiscIsNotAStadium() throws Exception {
101101
Geometry half = readCurve(HALF_DISC);
102102
assertNull("HALF_DISC is one semicircle + diameter",
103103
CurveExact.stadiumMic(half));
104-
assertNull("HALF_DISC is not a disc either -- chordsaw, not a diamond laser",
105-
CurveExact.mic(half));
104+
// ML.2: half-disc is the convex MIC cell, not a stadium miss.
105+
CircularArcDensifier.Circle mic = CurveExact.mic(half);
106+
assertNotNull(mic);
107+
assertEquals(0.0, mic.cx, 0.0);
108+
assertEquals(2.5, mic.cy, 0.0);
109+
assertEquals(2.5, mic.r, 0.0);
110+
assertEquals(mic.cx, CurveExact.halfDiscMic(half).cx, 0.0);
106111
}
107112

108113
public void testHoledStadiumStamps() throws Exception {

0 commit comments

Comments
 (0)