From da3fc577bfab57339ee45a974bf33a7470b162f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 18:36:01 +0000 Subject: [PATCH] feat: TestBuilder logoClothoid named clothoid halo Add logoClothoid / clothoidHalo around logoLines. A true clothoid offset of the strokes is not certified; return a stamped chord fallback (NAMED-APPROX / CHORD-PATH), not a silent flatten, not logoBuffer, and not a CIRCULARSTRING Qed. Co-authored-by: Jeroen Bloemscheer --- .../jtstest/function/JTSFunctions.java | 290 ++++++++++++++++++ .../JTSFunctionsLogoClothoidTest.java | 156 ++++++++++ 2 files changed, 446 insertions(+) create mode 100644 modules/app/src/test/java/org/locationtech/jtstest/function/JTSFunctionsLogoClothoidTest.java 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 14cd6ef2c6..b86348afd7 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 { @@ -80,6 +88,288 @@ public static Geometry logoBuffer(Geometry g, double distance) bufParams.setEndCapStyle(BufferParameters.CAP_SQUARE); 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.") + 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/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); + } + } +}