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);
+ }
+ }
+}