shapes, Coloring
break;
case RLF_BRUTE_FORCE_4COLOR :
int iterations = 0;
- long seed = 1337;
+ long seed = SEED; // init as default
do {
coloring = new RLFColoring<>(graph, seed).getColoring();
- seed = ThreadLocalRandom.current().nextLong();
+ seed = ThreadLocalRandom.current().nextLong(); // randomise seed
iterations++;
} while (coloring.getNumberColors() > 4 && iterations < 250);
break;
case RLF :
default :
- coloring = new RLFColoring<>(graph, 1337).getColoring(); // NOTE fixed seed of 1337
+ coloring = new RLFColoring<>(graph, SEED).getColoring(); // NOTE fixed seed of 1337
}
return coloring;
}
diff --git a/src/main/java/micycle/pgs/PGS_Construction.java b/src/main/java/micycle/pgs/PGS_Construction.java
index 6b7d5fd3..0299f0a4 100644
--- a/src/main/java/micycle/pgs/PGS_Construction.java
+++ b/src/main/java/micycle/pgs/PGS_Construction.java
@@ -3,6 +3,7 @@
import static micycle.pgs.PGS_Conversion.toPShape;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Random;
@@ -26,6 +27,7 @@
import org.locationtech.jts.util.GeometricShapeFactory;
import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
+import micycle.hobbycurves.HobbyCurve;
import micycle.pgs.PGS_Contour.OffsetStyle;
import micycle.pgs.color.Colors;
import micycle.pgs.commons.BezierShapeGenerator;
@@ -134,20 +136,35 @@ public static PShape createRegularPolyon(int n, double centerX, double centerY,
shapeFactory.setCentre(new Coordinate(centerX, centerY));
shapeFactory.setWidth(width * 2);
shapeFactory.setHeight(width * 2);
+ double ia = (Math.PI * (n - 2)) / n;
+ // flat edge facing down
+ shapeFactory.setRotation(ia / 2);
return toPShape(shapeFactory.createCircle());
}
/**
- * Creates a supercircle shape.
+ * Generates a supercircle (also known as a superellipse or Lamé curve)
+ * shape centered at the specified coordinates.
+ *
+ * The supercircle is defined by the equation |x/a|n +
+ * |y/a|n = 1, where the power (n) controls the shape's
+ * roundness:
+ *
+ * - power < 1: produces star-like (concave) forms
+ * - power = 1: produces a square (with rounded corners due to
+ * sampling)
+ * - power > 1: increasingly resembles a circle as power
+ * increases
+ *
*
- * @param centerX centre point X
- * @param centerY centre point Y
- * @param diameter
- * @param power circularity of the super circle. Values less than 1 create
- * star-like shapes; power=1 is a square; values>1 are
- * increasingly circular.
- * @return
+ * @param centerX the x-coordinate of the supercircle center
+ * @param centerY the y-coordinate of the supercircle center
+ * @param diameter the diameter of the supercircle (distance from side to side)
+ * @param power the exponent controlling the "circularity" (roundness or
+ * squareness) of the shape; values < 1 yield star-like
+ * shapes, 1 is a square, values > 1 become more circular
+ * @return a {@link PShape} representing the generated supercircle
*/
public static PShape createSupercircle(double centerX, double centerY, double diameter, double power) {
GeometricShapeFactory shapeFactory = new GeometricShapeFactory();
@@ -163,31 +180,34 @@ public static PShape createSupercircle(double centerX, double centerY, double di
}
/**
- * Creates a supershape PShape. The parameters feed into the superformula, which
- * is a simple 2D analytical expression allowing to draw a wide variety of
- * geometric and natural shapes (starfish, petals, snowflakes) by choosing
- * suitable values relevant to few parameters.
+ * Generates a supershape using the superformula, centered at the
+ * specified coordinates.
+ *
+ * The superformula is a versatile 2D mathematical equation capable of
+ * describing an enormous variety of shapes—ranging from rounded polygons and
+ * starfish to petals and snowflakes—depending on parameter choices. See
+ * Superformula
+ * (Wikipedia) or Paul
+ * Bourke's page for details.
+ *
+ * Brief parameter effects:
*
- * - As the n's are kept equal but reduced the form becomes increasingly
- * pinched.
- * - If n1 is slightly larger than n2 and n3 then bloated forms result.
- * - Polygonal shapes are achieved with very large values of n1 and large but
- * equal values for n2 and n3.
- * - Asymmetric forms can be created by using different values for the
- * n's.
- * - Smooth starfish shapes result from smaller values of n1 than the n2 and
- * n3.
+ * - Equal n-values pinch the form; reducing them increases pinching.
+ * - n₁ larger than n₂/n₃ produces bloated forms.
+ * - Very large n₁, n₂, and n₃ create polygonal shapes.
+ * - Unequal n-values yield asymmetric forms.
+ * - n₁ smaller than n₂/n₃ yields smooth starfish shapes.
*
- *
- * @param centerX centre point X
- * @param centerY centre point Y
- * @param radius maximum radius
- * @param m specifies the rotational symmetry of the shape (3 = 3 sided; 4
- * = 4 sided)
- * @param n1 supershape parameter 1
- * @param n2 supershape parameter 2
- * @param n3 supershape parameter 3
- * @return
+ *
+ * @param centerX the x-coordinate for the center of the supershape
+ * @param centerY the y-coordinate for the center of the supershape
+ * @param radius maximum radius of the supershape (outermost point)
+ * @param m symmetry number (e.g. 3 for 3-pointed, 4 for 4-pointed shapes)
+ * @param n1 superformula parameter controlling general shape (pinching,
+ * inflation)
+ * @param n2 superformula parameter affecting shape symmetry
+ * @param n3 superformula parameter affecting shape symmetry
+ * @return a {@link PShape} instance representing the generated supershape
*/
public static PShape createSuperShape(double centerX, double centerY, double radius, double m, double n1, double n2, double n3) {
// http://paulbourke.net/geometry/supershape/
@@ -226,17 +246,27 @@ public static PShape createSuperShape(double centerX, double centerY, double rad
}
/**
- * Creates an elliptical arc polygon (a slice of a circle). The polygon is
- * formed from the specified arc of an ellipse and the two radii connecting the
- * endpoints to the centre of the ellipse.
- *
- * @param centerX centre point X
- * @param centerY centre point Y
- * @param width
- * @param height
- * @param orientation start angle/orientation in radians (where 0 is 12 o'clock)
- * @param angle size of the arc angle in radians
- * @return
+ * Creates an elliptical arc polygon—a filled "slice" of an ellipse defined by
+ * the specified arc and the two radii connecting its endpoints to the ellipse
+ * center.
+ *
+ * The arc begins at the given orientation angle (measured from the 12 o'clock
+ * position, in radians) and extends counterclockwise by the specified angular
+ * size. If the arc angle is equal to or greater than 2π, a full ellipse is
+ * generated.
+ *
+ * The resulting {@link PShape} is suitable for rendering a pie-slice or sector
+ * from an ellipse or circle.
+ *
+ * @param centerX the x-coordinate of the ellipse center
+ * @param centerY the y-coordinate of the ellipse center
+ * @param width the full width (diameter on the x-axis) of the ellipse
+ * @param height the full height (diameter on the y-axis) of the ellipse
+ * @param orientation the starting angle (in radians), where 0 corresponds to
+ * "12 o'clock" (upwards)
+ * @param angle the arc angle (in radians), defining the sweep of the arc;
+ * if equal or greater than 2π, a full ellipse is produced
+ * @return a {@link PShape} representing the elliptical arc polygon
*/
public static PShape createArc(double centerX, double centerY, double width, double height, double orientation, double angle) {
if (angle == 0) {
@@ -357,18 +387,33 @@ public static PShape createStar(double centerX, double centerY, int numRays, dou
}
/**
- * Creates a "blob"-like shape.
+ * Generates a "blobbie" shape—a deformable, organic, blobby closed curve
+ * defined by four parameters.
*
- * In order for the shape to not self intersect a + b should be less than 1.
- *
- * @param centerX The x coordinate of the center
- * @param centerY The y coordinate of the center
- * @param maxWidth
- * @param a blob parameter. a + b should be less than 1
- * @param b blob parameter.a + b should be less than 1
- * @param c blob parameter
- * @param d blob parameter
- * @return
+ * The blobbie shape is based on functions involving cosine waves of different
+ * frequencies, allowing for smooth, natural-looking deformations. Typical
+ * results are lobed or petal-like closed shapes useful in generative graphics
+ * or modeling organic phenomena.
+ *
+ * To avoid self-intersections, the sum of the parameters a and
+ * b should be less than 1. If self-intersection occurs, the result
+ * will attempt to be geometrically fixed but may yield unexpected forms. See
+ * Paul Bourke: Blobbie
+ * for background.
+ *
+ *
{@code
+ * r(theta) = (maxWidth/2) * [1 + a*cos(2θ + c) + b*cos(3θ + d)]
+ * }
+ *
+ * @param centerX the x-coordinate of the blobbie center
+ * @param centerY the y-coordinate of the blobbie center
+ * @param maxWidth the maximum width (diameter) of the blobbie
+ * @param a 2-lobed deformation parameter; together with b, controls the
+ * main shape undulations (a + b < 1 for simple forms)
+ * @param b 3-lobed deformation parameter; see note above
+ * @param c phase offset for the 2-lobed term
+ * @param d phase offset for the 3-lobed term
+ * @return a {@link PShape} representing the generated blobbie shape
* @since 1.3.0
*/
public static PShape createBlobbie(double centerX, double centerY, double maxWidth, double a, double b, double c, double d) {
@@ -404,12 +449,22 @@ public static PShape createBlobbie(double centerX, double centerY, double maxWid
}
/**
- * Creates a heart shape.
- *
- * @param centerX The x coordinate of the center of the heart
- * @param centerY The y coordinate of the center of the heart
- * @param width Maximum width of the widest part of the heart
- * @return
+ * Creates a classic "heart" shape using a parametric curve, centered and scaled
+ * as specified.
+ *
+ * The curve is based on the well-known heart equations, producing a symmetric
+ * heart that is widest at the center and comes to a point below.
+ *
+ * The maximum width parameter controls the distance across the heart at its
+ * widest part.
+ *
+ * See Heart Curve
+ * (MathWorld) for the mathematical reference.
+ *
+ * @param centerX the x-coordinate for the center of the heart shape
+ * @param centerY the y-coordinate for the center of the heart shape
+ * @param width the maximum width (horizontal extent) of the heart
+ * @return a {@link PShape} representing the generated heart curve
* @since 1.1.0
*/
public static PShape createHeart(final double centerX, final double centerY, final double width) {
@@ -439,13 +494,26 @@ public static PShape createHeart(final double centerX, final double centerY, fin
}
/**
- * Creates a teardrop shape from a parametric curve.
- *
- * @param centerX The x coordinate of the center of the teardrop
- * @param centerY The y coordinate of the center of the teardrop
- * @param height height of the teardrop
- * @param m order of the curve. Values of [2...5] give good results
- * @return
+ * Creates a teardrop shape using a parametric polar curve, centered and scaled
+ * as specified.
+ *
+ * This method generates a classic teardrop or droplet outline, where the
+ * parameter {@code m} controls the taper and sharpness of the pointed end.
+ * Lower values for {@code m} (such as 2) create softer drops, while higher
+ * values (up to 5) yield sharper, pointier ends.
+ *
+ * The generated {@link PShape} is suitable for use in generative design,
+ * infographics, and iconography.
+ *
+ * See Teardrop Curve
+ * (MathWorld) for mathematical background.
+ *
+ * @param centerX the x-coordinate of the center of the teardrop shape
+ * @param centerY the y-coordinate of the center of the teardrop shape
+ * @param height the full vertical height of the teardrop, from its base to tip
+ * @param m the order/tapering factor of the curve; recommended range is
+ * 2–5 for visually pleasing shapes
+ * @return a {@link PShape} representing the teardrop outline
* @since 1.4.0
*/
public static PShape createTeardrop(final double centerX, final double centerY, double height, final double m) {
@@ -587,23 +655,23 @@ public static PShape createSponge(double width, double height, int generators, d
// A Simple and Effective Geometric Representation for Irregular Porous
// Structure Modeling
List points = PGS_PointSet.random(thickness, thickness / 2, width - thickness / 2, height - thickness / 2, generators, seed);
- if (points.size() < 6) {
- return new PShape();
- }
- PShape voro = PGS_Voronoi.innerVoronoi(points, 2);
+ PShape voro = PGS_Voronoi.innerVoronoi(points, new double[] { 0, 0, width, height }, 2);
- List blobs = PGS_Conversion.getChildren(PGS_Meshing.stochasticMerge(voro, classes, seed)).stream().map(c -> {
- c = PGS_Morphology.buffer(c, -thickness / 2, OffsetStyle.MITER);
- c = PGS_Morphology.smoothGaussian(c, smoothing);
- return c;
- }).collect(Collectors.toList());
+ var merged = PGS_Meshing.stochasticMerge(voro, classes, seed);
+ var blobs = PGS_Processing.transform(merged, blob -> {
+ blob = PGS_Morphology.buffer(blob, -thickness / 2, OffsetStyle.MITER);
+ if (smoothing != 0) {
+ blob = PGS_Morphology.smoothGaussian(blob, smoothing);
+ }
+ return blob;
+ });
/*
* Although faster, can't use .simpleSubtract() here because holes (cell
* islands) are *sometimes* nested.
*/
- PShape s = PGS_ShapeBoolean.subtract(PGS.createRect(0, 0, width, height), PGS_Conversion.flatten(blobs));
- s.setStroke(false);
+ PShape s = PGS_ShapeBoolean.subtract(PGS.createRect(0, 0, width, height), blobs);
+ PGS_Conversion.disableAllStroke(s);
return s;
}
@@ -719,35 +787,29 @@ public static PShape createFermatSpiral(double centerX, double centerY, double c
* @return a stroked PATH PShape with SQUARE stroke cap and MITER joins
* @since 1.3.0
*/
- public static PShape createRectangularSpiral(float x, float y, float width, float height, float spacing) {
- float xx = -width / 2;
- float yy = -height / 2;
+ public static PShape createRectangularSpiral(double x, double y, double width, double height, double spacing) {
+ double xx = -width / 2.0;
+ double yy = -height / 2.0;
int count = 0;
// below is used to dictate spiral orientation & starting point
-// if (count == 1) {
-// xx *= -1;
-// }
-// if (count == 2) {
-// xx *= -1;
-// yy *= -1;
-// }
-// if (count == 3) {
-// yy *= -1;
-// }
- final float offX = x + width / 2;
- final float offY = y + height / 2;
+ // if (count == 1) { xx *= -1; }
+ // if (count == 2) { xx *= -1; yy *= -1; }
+ // if (count == 3) { yy *= -1; }
+ final double offX = x + width / 2.0;
+ final double offY = y + height / 2.0;
final PShape spiral = new PShape(PShape.PATH);
spiral.setFill(false);
spiral.setStroke(true);
- spiral.setStrokeWeight(5);
-// spiral.setStrokeWeight(spacing * 0.333f);
+ spiral.setStrokeWeight(5f);
+ // spiral.setStrokeWeight((float)(spacing * 0.333));
spiral.setStroke(Colors.WHITE);
spiral.setStrokeJoin(PConstants.MITER);
spiral.setStrokeCap(PConstants.SQUARE);
spiral.beginShape();
- while (width > 0 && height > 0) {
+ while (width > 0.0 && height > 0.0) {
int dir = count % 4;
- spiral.vertex(xx + offX, yy + offY);
+ // cast to float for Processing's vertex API
+ spiral.vertex((float) (xx + offX), (float) (yy + offY));
if (dir == 0) {
xx += width;
width -= spacing;
@@ -796,7 +858,7 @@ public static PShape createRandomSFCurve(int nColumns, int nRows, double cellWid
* @param cellWidth visual/pixel width of each cell
* @param cellHeight visual/pixel width of each cell
* @param seed random seed
- * @return a stroked PATH PShape
+ * @return a mitered stroked PATH PShape
* @see #createRandomSFCurve(int, int, double, double)
* @since 1.4.0
*/
@@ -945,6 +1007,74 @@ public static PShape createHilbertCurve(double width, double height, int order)
return out;
}
+ /**
+ * Creates a Hobby Curve from the given list of control points using the
+ * specified tension. This is a convenience overload that uses a default
+ * endpoint curl of 0.5.
+ *
+ * The first and last points may be equal; in that case the curve will be
+ * treated as closed.
+ *
+ * @param points the list of vertices to use as the basis for the Hobby Curve.
+ * Must be non-null and contain at least two points.
+ * @param tension a parameter controlling the tightness of the curve. Higher
+ * values generally produce tighter curves. A suitable domain is
+ * approximately [0.666..., 3]. Values below 0.1 are clamped to
+ * 0.1 to avoid degeneracy.
+ * @return a PShape representing the resulting Hobby Curve composed of cubic
+ * Bezier segments.
+ * @since 2.1
+ */
+ public static PShape createHobbyCurve(List points, double tension) {
+ return createHobbyCurve(points, tension, 0.5);
+ }
+
+ /**
+ * Create a Hobby Curve from the given list of control points using the
+ * specified tension and endpoint curl parameter.
+ *
+ * The curve is constructed from cubic Bezier segments. If the first and last
+ * points are equal the curve will be treated as closed (continuous), and the
+ * endpoint curl parameter is effectively ignored for continuity.
+ *
+ * @param points the list of vertices to use as the basis for the Hobby
+ * Curve. Must be non-null and contain at least two points.
+ * @param tension a parameter controlling the tightness of the curve.
+ * Higher values generally produce tighter curves. A
+ * suitable domain is approximately [0.666..., 3]. Values
+ * below 0.1 are clamped to 0.1 to avoid degeneracy.
+ * @param endPointCurl a tuning parameter that controls the "curl" at the start
+ * and end of the curve; typical default is 0.5. When the
+ * curve is closed this value is ignored.
+ * @return a PShape representing the resulting Hobby Curve composed of cubic
+ * Bezier segments.
+ * @since 2.1
+ */
+ public static PShape createHobbyCurve(List points, double tension, double endPointCurl) {
+ tension = Math.max(tension, 0.1); // prevent degeneracy
+ final boolean closed = points.get(0).equals(points.get(points.size() - 1));
+ double[][] vertices = PGS_Conversion.toArray(points.subList(0, points.size() - (closed ? 1 : 0)));
+ // param start/end curve
+ HobbyCurve curve = new HobbyCurve(vertices, tension, closed, endPointCurl, endPointCurl);
+
+ double[][] beziers = curve.getBeziers();
+
+ List curveVertices = Arrays.stream(beziers).parallel().flatMap(b -> {
+ // unpack the array into the 4 PVector points
+ int i = 0;
+ PVector p1 = new PVector((float) b[i++], (float) b[i++]);
+ PVector cp1 = new PVector((float) b[i++], (float) b[i++]);
+ PVector cp2 = new PVector((float) b[i++], (float) b[i++]);
+ PVector p2 = new PVector((float) b[i++], (float) b[i]);
+
+ PShape bezierShape = PGS_Conversion.fromCubicBezier(p1, cp1, cp2, p2);
+
+ return PGS_Conversion.toPVector(bezierShape).stream(); // to stream (for flattening)
+ }).toList();
+
+ return PGS_Conversion.fromPVector(curveVertices);
+ }
+
/**
* Creates a Sierpiński Carpet shape, a type of plane fractal.
*
@@ -1033,13 +1163,166 @@ public static PShape createSierpinskiTriCurve(SierpinskiTriCurveType type, doubl
return out;
}
+ /**
+ * Shortcut for creating a rectangle with uniformly rounded corners, using
+ * {@link PConstants#CORNER} mode.
+ *
+ * This convenience method creates a rectangle where all four corners have the
+ * same radius {@code r}. The rectangle uses Processing's
+ * {@link PConstants#CORNER} mode coordinates: {@code (a, b)} specify the
+ * top-left corner, and {@code c} and {@code d} specify width and height,
+ * respectively.
+ *
+ * @param a the x-coordinate of the top-left corner of the rectangle
+ * @param b the y-coordinate of the top-left corner of the rectangle
+ * @param c the width of the rectangle
+ * @param d the height of the rectangle
+ * @param r the uniform radius to be applied to all four corners (0
+ * gives a regular rectangle)
+ * @return a {@link PShape} representing the rounded rectangle
+ * @since 2.1
+ */
+ public static PShape createRect(double a, double b, double c, double d, double r) {
+ return createRect(PConstants.CORNER, a, b, c, d, r);
+ }
+
+ /**
+ * Creates a rectangle with specified corner radii, in any Processing-style
+ * rectangle mode.
+ *
+ * The meaning of the {@code a}, {@code b}, {@code c}, and {@code d} parameters
+ * depends on the {@code rectMode}:
+ *
+ * - {@link PConstants#CORNER}: {@code (a, b)} is the top-left corner;
+ * {@code c} is width, {@code d} is height
+ * - {@link PConstants#CORNERS}: {@code (a, b)} is the top-left corner;
+ * {@code (c, d)} is the bottom-right corner
+ * - {@link PConstants#CENTER}: {@code (a, b)} is the rectangle center;
+ * {@code c} is width, {@code d} is height
+ * - {@link PConstants#RADIUS}: {@code (a, b)} is the center; {@code c} is
+ * half width, {@code d} is half height
+ *
+ * Each corner radius parameter refers to a specific corner:
+ *
+ * - {@code tl} – top-left corner radius
+ * - {@code tr} – top-right corner radius
+ * - {@code br} – bottom-right corner radius
+ * - {@code bl} – bottom-left corner radius
+ *
+ *
+ * @param rectMode rectangle mode as in Processing; one of
+ * {@link PConstants#CORNER}, {@link PConstants#CORNERS},
+ * {@link PConstants#CENTER}, or {@link PConstants#RADIUS}
+ * @param a first coordinate: x (or center x, depending on mode)
+ * @param b second coordinate: y (or center y, depending on mode)
+ * @param c width, x2, or half-width (see mode above)
+ * @param d height, y2, or half-height (see mode above)
+ * @param r the uniform radius to be applied to all four corners
+ * @return a {@link PShape} representing the rounded rectangle
+ * @since 2.1
+ */
+ public static PShape createRect(int rectMode, double a, double b, double c, double d, double r) {
+ return rect(rectMode, a, b, c, d, r, r, r, r);
+ }
+
+ static PShape rect(int rectMode, double a, double b, double c, double d, double tl, double tr, double br, double bl) {
+ double hradius, vradius;
+ switch (rectMode) {
+ case PConstants.CORNERS :
+ break;
+ case PConstants.CORNER :
+ c += a;
+ d += b;
+ break;
+ case PConstants.RADIUS :
+ hradius = c;
+ vradius = d;
+ c = a + hradius;
+ d = b + vradius;
+ a -= hradius;
+ b -= vradius;
+ break;
+ case PConstants.CENTER :
+ hradius = c / 2.0;
+ vradius = d / 2.0;
+ c = a + hradius;
+ d = b + vradius;
+ a -= hradius;
+ b -= vradius;
+ break;
+ }
+ if (a > c) {
+ double t = a;
+ a = c;
+ c = t;
+ }
+ if (b > d) {
+ double t = b;
+ b = d;
+ d = t;
+ }
+ double maxRounding = Math.min((c - a) / 2, (d - b) / 2);
+ tl = Math.min(tl, maxRounding);
+ tr = Math.min(tr, maxRounding);
+ br = Math.min(br, maxRounding);
+ bl = Math.min(bl, maxRounding);
+ return rectImpl((float) a, (float) b, (float) c, (float) d, (float) tl, (float) tr, (float) br, (float) bl);
+ }
+
+ private static PShape rectImpl(float x1, float y1, float x2, float y2, float tl, float tr, float br, float bl) {
+ PShape sh = new PShape(PShape.PATH);
+ sh.setFill(true);
+ sh.setFill(Colors.WHITE);
+ sh.beginShape();
+ // Top edge and top-right corner
+ if (tr != 0) {
+ sh.vertex(x2 - tr, y1);
+ sh.quadraticVertex(x2, y1, x2, y1 + tr);
+ } else {
+ sh.vertex(x2, y1);
+ }
+ // Right edge and bottom-right
+ if (br != 0) {
+ sh.vertex(x2, y2 - br);
+ sh.quadraticVertex(x2, y2, x2 - br, y2);
+ } else {
+ sh.vertex(x2, y2);
+ }
+ // Bottom edge and bottom-left
+ if (bl != 0) {
+ sh.vertex(x1 + bl, y2);
+ sh.quadraticVertex(x1, y2, x1, y2 - bl);
+ } else {
+ sh.vertex(x1, y2);
+ }
+ // Left edge and top-left
+ if (tl != 0) {
+ sh.vertex(x1, y1 + tl);
+ sh.quadraticVertex(x1, y1, x1 + tl, y1);
+ } else {
+ sh.vertex(x1, y1);
+ }
+ sh.endShape(PConstants.CLOSE);
+ return sh;
+ }
+
/**
* Creates a polygon finely approximating a circle.
*
* @since 2.0
*/
static Polygon createCircle(Coordinate c, double r) {
- return createCircle(c.x, c.y, r, 0.5); // 0.5 still very generous
+ return createCircle(c.x, c.y, r, 0.5);
+ }
+
+ /**
+ * Creates a geometric circle of radius r (.z) centered on (x,y).
+ *
+ * @param c the PVector representing a circle
+ * @since 2.1
+ */
+ public static PShape createCircle(PVector c) {
+ return createCircle(c.x, c.y, c.z);
}
/**
@@ -1048,7 +1331,7 @@ static Polygon createCircle(Coordinate c, double r) {
* @since 2.0
*/
public static PShape createCircle(double x, double y, double r) {
- return toPShape(createCirclePoly(x, y, r)); // 0.5 still very generous
+ return toPShape(createCirclePoly(x, y, r));
}
/**
@@ -1099,6 +1382,62 @@ static Polygon createCircle(double x, double y, double r, final double maxDeviat
return PGS.GEOM_FACTORY.createPolygon(pts);
}
+ /**
+ * Sample a circular arc from startAngle→endAngle (radians), producing a List of
+ * PVectors so that no straight‐line chord deviates by more than maxDev pixels
+ * from the true circle.
+ *
+ * @param cx center x
+ * @param cy center y
+ * @param r radius (>0)
+ * @param startAngle start angle in radians
+ * @param endAngle end angle in radians
+ * @param maxDev maximum sagitta error in pixels (>0)
+ * @return List of PVectors, inclusive of both endpoints.
+ * @since 2.1
+ */
+ static List arcPoints(double cx, double cy, double r, double startAngle, double endAngle, double maxDev) {
+ List pts = new ArrayList<>();
+
+ // 1) Compute raw sweep and direction
+ double rawRange = endAngle - startAngle;
+ int dir = rawRange >= 0 ? +1 : -1;
+ double range = Math.abs(rawRange);
+
+ // 2) Cap at a full circle if the user overshoots
+ if (range > 2.0 * Math.PI) {
+ range = 2.0 * Math.PI;
+ }
+
+ // 3) Solve for maximum step so that sagitta s = R*(1–cos(dθ/2)) ≤ maxDev
+ // ⇒ dθ ≤ 2 * acos(1 – maxDev / R)
+ double cosArg = 1.0 - maxDev / r;
+ // clamp to avoid NaNs
+ cosArg = Math.max(-1.0, Math.min(1.0, cosArg));
+ double maxStep = 2.0 * Math.acos(cosArg);
+
+ // 4) If that step is wider than the entire arc, just use one segment
+ if (maxStep >= range) {
+ maxStep = range;
+ }
+
+ // 5) Determine how many segments are needed (at least 1)
+ int nSeg = Math.max(1, (int) Math.ceil(range / maxStep));
+
+ // 6) Compute the actual signed step
+ double step = dir * (range / nSeg);
+
+ // 7) Sample from i=0…nSeg (inclusive) so endpoints are exact
+ for (int i = 0; i <= nSeg; i++) {
+ double a = startAngle + i * step;
+ double px = cx + r * FastMath.cos(a);
+ double py = cy + r * FastMath.sin(a);
+ pts.add(new PVector((float) px, (float) py));
+ }
+
+ return pts;
+ }
+
/**
* Sierpinski curve subdivide.
*/
diff --git a/src/main/java/micycle/pgs/PGS_Contour.java b/src/main/java/micycle/pgs/PGS_Contour.java
index 10456f2e..d7750cc9 100644
--- a/src/main/java/micycle/pgs/PGS_Contour.java
+++ b/src/main/java/micycle/pgs/PGS_Contour.java
@@ -7,20 +7,21 @@
import java.util.ArrayList;
import java.util.Collection;
-import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import javax.vecmath.Point3d;
+import org.jgrapht.alg.interfaces.ShortestPathAlgorithm;
+import org.jgrapht.alg.shortestpath.BFSShortestPath;
import org.jgrapht.graph.DefaultEdge;
import org.jgrapht.graph.SimpleGraph;
-import org.joml.Vector2d;
-import org.joml.Vector2dc;
import org.locationtech.jts.algorithm.Angle;
import org.locationtech.jts.algorithm.Orientation;
import org.locationtech.jts.dissolve.LineDissolver;
@@ -29,13 +30,14 @@
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.MultiLineString;
import org.locationtech.jts.geom.Polygon;
-import org.locationtech.jts.geom.prep.PreparedGeometry;
-import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
+import org.locationtech.jts.geom.util.GeometryExtracter;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.locationtech.jts.operation.buffer.OffsetCurve;
+import org.locationtech.jts.operation.distance.IndexedFacetDistance;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.tinfour.common.IIncrementalTin;
import org.tinfour.common.IQuadEdge;
@@ -44,6 +46,7 @@
import org.tinfour.contour.Contour;
import org.tinfour.contour.ContourBuilderForTin;
import org.tinfour.standard.IncrementalTin;
+import org.tinfour.utils.HilbertSort;
import org.tinfour.utils.SmoothingFilter;
import org.twak.camp.Corner;
import org.twak.camp.Edge;
@@ -52,13 +55,11 @@
import org.twak.utils.collections.Loop;
import org.twak.utils.collections.LoopL;
+import com.github.micycle1.geoblitz.YStripesPointInAreaLocator;
import com.google.common.collect.Lists;
-import kendzi.math.geometry.skeleton.SkeletonConfiguration;
-import kendzi.math.geometry.skeleton.SkeletonOutput;
import micycle.medialAxis.MedialAxis;
import micycle.medialAxis.MedialAxis.MedialDisk;
-import micycle.pgs.PGS.GeometryIterator;
import micycle.pgs.PGS.LinearRingIterator;
import micycle.pgs.color.ColorUtils;
import micycle.pgs.color.Colors;
@@ -69,12 +70,13 @@
import processing.core.PVector;
/**
- * Methods for producing different kinds of shape contours.
+ * Methods for producing different kinds of shape contours. *
*
- * A 2D contour is a closed sequence (a cycle) of 3 or more connected 2D
- * oriented straight line segments called contour edges. The endpoints of the
- * contour edges are called vertices. Each contour edge shares its endpoints
- * with at least two other contour edges.
+ * Contours produced by this class are always computed within the interior of
+ * shapes. Contour lines and features (such as isolines, medial axes, and
+ * fields) are extracted as vector linework following the topology or scalar
+ * properties of the enclosed shape area, rather than operations that modify the
+ * shape boundary.
*
* @author Michael Carleton
*
@@ -120,9 +122,16 @@ private PGS_Contour() {
public static PShape medialAxis(PShape shape, double axialThreshold, double distanceThreshold, double areaThreshold) {
final Geometry g = fromPShape(shape);
final MedialAxis m = new MedialAxis(g);
- return PGS_SegmentSet.dissolve(m.getPrunedEdges(axialThreshold, distanceThreshold, areaThreshold).stream()
- .map(e -> new PEdge(e.head.position.x, e.head.position.y, e.tail.position.x, e.tail.position.y))
- .collect(Collectors.toList()));
+ var medialEdges = m.getPrunedEdges(axialThreshold, distanceThreshold, areaThreshold);
+ var medialSegments = medialEdges.stream().map(e -> {
+ var head = e.head.position;
+ var tail = e.tail.position;
+ if (head.equals2D(tail)) {
+ return null;
+ }
+ return new PEdge(head.x, head.y, tail.x, tail.y);
+ }).filter(Objects::nonNull).toList();
+ return PGS_SegmentSet.dissolve(medialSegments);
}
/**
@@ -163,8 +172,8 @@ public static PShape chordalAxis(PShape shape) {
switch (graph.outDegreeOf(t)) {
case 1 : // Terminal triangle (2 edges in perimeter)
final IQuadEdge interiorEdge; // one edge is interior
- if (t.getEdgeA().isConstrainedRegionBorder()) {
- if (t.getEdgeB().isConstrainedRegionBorder()) {
+ if (t.getEdgeA().isConstraintRegionBorder()) {
+ if (t.getEdgeB().isConstraintRegionBorder()) {
interiorEdge = t.getEdgeC();
} else {
interiorEdge = t.getEdgeB();
@@ -178,10 +187,10 @@ public static PShape chordalAxis(PShape shape) {
case 2 : // Sleeve triangle (one edge in perimeter)
final IQuadEdge interiorEdgeA; // 2 edges are interior
final IQuadEdge interiorEdgeB;
- if (t.getEdgeA().isConstrainedRegionBorder()) {
+ if (t.getEdgeA().isConstraintRegionBorder()) {
interiorEdgeA = t.getEdgeB();
interiorEdgeB = t.getEdgeC();
- } else if (t.getEdgeB().isConstrainedRegionBorder()) {
+ } else if (t.getEdgeB().isConstraintRegionBorder()) {
interiorEdgeA = t.getEdgeA();
interiorEdgeB = t.getEdgeC();
} else {
@@ -232,52 +241,74 @@ public static PShape chordalAxis(PShape shape) {
* consisting of straight-line segments only. Roughly, it is the geometric graph
* whose edges are the traces of vertices of shrinking mitered offset curves of
* the polygon.
- *
+ *
+ * For a single polygon, this method returns a GROUP PShape containing three
+ * children:
+ *
+ * - Child 0: GROUP PShape consisting of skeleton faces.
+ * - Child 1: LINES PShape representing branches, which are lines connecting
+ * the skeleton to the polygon's edge.
+ * - Child 2: LINES PShape composed of bones, depicting the pure straight
+ * skeleton of the polygon.
+ *
+ *
+ * For multi-polygons, the method returns a master GROUP PShape. This master
+ * shape includes multiple skeleton GROUP shapes, each corresponding to a single
+ * polygon and structured as described above.
+ *
* @param shape a single polygon (that can contain holes), or a multi polygon
* (whose polygons can contain holes)
- * @return when the input is a single polygon, returns a GROUP PShape containing
- * 3 children: child 1 = GROUP PShape of skeleton faces; child 2 = LINES
- * PShape of branches (lines that connect skeleton to edge); child 3 =
- * LINES PShape of bones (the pure straight skeleton). For
- * multi-polygons, a master GROUP shape of skeleton GROUP shapes
- * (described above) is returned.
+ *
+ * @return PShape based on the input polygon structure, either as a single or
+ * multi-polygon skeleton representation.
*/
public static PShape straightSkeleton(PShape shape) {
- final Geometry g = fromPShape(shape);
- if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON)) {
- PShape group = new PShape(PConstants.GROUP);
- GeometryIterator gi = new GeometryIterator(g);
- gi.forEach(p -> group.addChild(straightSkeleton((Polygon) p)));
- return group;
- } else if (g.getGeometryType().equals(Geometry.TYPENAME_POLYGON)) {
- return straightSkeleton((Polygon) g);
- }
- return shape;
+ return straightSkeleton(shape, Integer.MAX_VALUE);
}
/**
+ * Computes the straight skeleton for a shape. This method signature accepts an
+ * integer to control the number of nearest neighboring edges considered during
+ * collision detection. In practice this can speed up computation considerably.
+ *
+ * A straight skeleton is a skeletal structure similar to the medial axis,
+ * consisting of straight-line segments only. Roughly, it is the geometric graph
+ * whose edges are the traces of vertices of shrinking mitered offset curves of
+ * the polygon.
+ *
+ * For a single polygon, this method returns a GROUP PShape containing three
+ * children:
+ *
+ * - Child 0: GROUP PShape consisting of skeleton faces.
+ * - Child 1: LINES PShape representing branches, which are lines connecting
+ * the skeleton to the polygon's edge.
+ * - Child 2: LINES PShape composed of bones, depicting the pure straight
+ * skeleton of the polygon.
+ *
+ *
+ * For multi-polygons, the method returns a master GROUP PShape. This master
+ * shape includes multiple skeleton GROUP shapes, each corresponding to a single
+ * polygon and structured as described above.
*
- * @param polygon a single polygon that can contain holes
- * @return
+ * @param shape a single polygon (that can contain holes), or a multi polygon
+ * (whose polygons can contain holes)
+ * @param k The number of nearest neighboring edges to consider when
+ * searching for collisions using the spatial index. This parameter
+ * balances performance and correctness: too few neighbors may miss
+ * collisions, while too many may reduce the performance benefits
+ * of the spatial index.
+ * @return PShape based on the input polygon structure, either as a single or
+ * multi-polygon skeleton representation.
+ * @since 2.1
*/
- private static PShape straightSkeleton(Polygon polygon) {
- /*
- * Kenzi implementation (since PGS 1.3.0) is much faster (~50x!) but can fail on
- * more complicated inputs. Therefore try Kenzi implementation first, but fall
- * back to Twak implementation if it fails.
- */
- try {
- return straightSkeletonKendzi(polygon);
- } catch (Exception e) {
- return straightSkeletonTwak(polygon);
- }
+ @SuppressWarnings("unchecked")
+ public static PShape straightSkeleton(PShape shape, int k) {
+ final Geometry g = fromPShape(shape);
+ var skeletons = GeometryExtracter.extract(g, Geometry.TYPENAME_POLYGON).parallelStream().map(p -> straightSkeleton((Polygon) p, k)).toList();
+ return PGS_Conversion.flatten(skeletons);
}
- private static PShape straightSkeletonTwak(Polygon polygon) {
- if (polygon.getCoordinates().length > 1000) {
- polygon = (Polygon) DouglasPeuckerSimplifier.simplify(polygon, 2);
- }
-
+ private static PShape straightSkeleton(Polygon polygon, int k) {
final Set edgeCoordsSet = new HashSet<>();
final Skeleton skeleton;
final LoopL loops = new LoopL<>(); // list of loops
@@ -297,11 +328,11 @@ private static PShape straightSkeletonTwak(Polygon polygon) {
final Set branchEdges = new HashSet<>();
final Set boneEdges = new HashSet<>();
try {
- skeleton = new Skeleton(loops, true);
+ skeleton = new Skeleton(loops, k);
skeleton.skeleton(); // compute skeleton
skeleton.output.faces.values().forEach(f -> {
- final List vertices = f.getLoopL().iterator().next().asList();
+ List vertices = f.getLoopL().iterator().next().stream().toList();
List faceVertices = new ArrayList<>();
for (int i = 0; i < vertices.size(); i++) {
@@ -321,7 +352,7 @@ private static PShape straightSkeletonTwak(Polygon polygon) {
PShape face = PGS_Conversion.fromPVector(faceVertices);
face.setStroke(true);
- face.setStrokeWeight(2);
+ face.setStrokeWeight(1);
face.setStroke(ColorUtils.composeColor(147, 112, 219));
faces.addChild(face);
});
@@ -329,7 +360,7 @@ private static PShape straightSkeletonTwak(Polygon polygon) {
// hide init or collision errors from console
}
- final PShape bones = prepareLinesPShape(null, null, 4);
+ final PShape bones = prepareLinesPShape(null, null, 2);
boneEdges.forEach(e -> {
bones.vertex(e.a.x, e.a.y);
bones.vertex(e.b.x, e.b.y);
@@ -350,95 +381,20 @@ private static PShape straightSkeletonTwak(Polygon polygon) {
return lines;
}
- private static PShape straightSkeletonKendzi(Polygon polygon) {
- final LinearRing[] rings = new LinearRingIterator(polygon).getLinearRings();
- Set edgeCoordsSet = new HashSet<>();
- final List points = ringToVec(rings[0], edgeCoordsSet);
- final List> holes = new ArrayList<>();
- for (int i = 1; i < rings.length; i++) {
- holes.add(ringToVec(rings[i], edgeCoordsSet));
- }
-
- final SkeletonOutput so = kendzi.math.geometry.skeleton.Skeleton.skeleton(points, holes, new SkeletonConfiguration());
- final PShape skeleton = new PShape(PConstants.GROUP);
- final PShape faces = new PShape(PConstants.GROUP);
- /*
- * Create PEdges first to prevent lines being duplicated in output shapes since
- * faces share branches and bones.
- */
- final Set branchEdges = new HashSet<>();
- final Set boneEdges = new HashSet<>();
- so.getFaces().forEach(f -> {
- /*
- * q stores the index of second vertex of the face that is a shape vertex. This
- * is used to rotate f.getPoints() so that the vertices of every face PShape
- * begin at the shape edge.
- */
- int q = 0;
- for (int i = 0; i < f.getPoints().size(); i++) {
- final Vector2dc p1 = f.getPoints().get(i);
- final Vector2dc p2 = f.getPoints().get((i + 1) % f.getPoints().size());
- final boolean a = edgeCoordsSet.contains(p1);
- final boolean b = edgeCoordsSet.contains(p2);
- if (a ^ b) { // branch (xor)
- branchEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y()));
- q = i;
- } else {
- if (!a) { // bone
- boneEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y()));
- } else {
- q = i;
- }
- }
- }
-
- List faceVertices = new ArrayList<>(f.getPoints().size());
- Collections.rotate(f.getPoints(), -q + 1);
- f.getPoints().forEach(p -> faceVertices.add(new PVector((float) p.x(), (float) p.y())));
-
- PShape face = PGS_Conversion.fromPVector(faceVertices);
- face.setStroke(true);
- face.setStrokeWeight(2);
- face.setStroke(ColorUtils.composeColor(147, 112, 219));
- faces.addChild(face);
- });
-
- final PShape bones = prepareLinesPShape(null, null, 4);
- boneEdges.forEach(e -> {
- bones.vertex(e.a.x, e.a.y);
- bones.vertex(e.b.x, e.b.y);
- });
- bones.endShape();
-
- final PShape branches = prepareLinesPShape(ColorUtils.composeColor(40, 235, 180), null, null);
- branchEdges.forEach(e -> {
- branches.vertex(e.a.x, e.a.y);
- branches.vertex(e.b.x, e.b.y);
- });
- branches.endShape();
-
- skeleton.addChild(faces);
- skeleton.addChild(branches);
- skeleton.addChild(bones);
-
- return skeleton;
- }
-
/**
- * Generates a topographic-like isoline contour map from the shape's vertices.
- * The "elevation" (or z value) of points is the euclidean distance between a
- * point in the shape and the given "high" point.
+ * Generates a topographic-like isoline contour map from the shape's vertices
+ * and a given "high point". Isolines represent the "elevation", or euclidean
+ * distance, between a location in the shape and the "high point".
*
* Assigns each point feature a number equal to the distance between geometry's
* centroid and the point.
*
- * @param shape
+ * @param shape the bounds in which to draw isolines
* @param highPoint position of "high" point within the shape
* @param intervalSpacing distance between successive isolines
- * @return PShape containing isolines linework
+ * @return PShape containing isolines linework
*/
public static PShape isolines(PShape shape, PVector highPoint, double intervalSpacing) {
-
/*
* Also See:
* https://github.com/hageldave/JPlotter/blob/master/jplotter/src/main/java/
@@ -451,40 +407,28 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp
if (g.getCoordinates().length > 2000) {
g = DouglasPeuckerSimplifier.simplify(g, 1);
}
- final int buffer = (int) Math.max(10, Math.round(intervalSpacing) + 1);
- PreparedGeometry cache = PreparedGeometryFactory.prepare(g.buffer(10));
+ final int buffer = (int) Math.max(10, Math.ceil(intervalSpacing));
+ YStripesPointInAreaLocator cache = new YStripesPointInAreaLocator(g.buffer(buffer));
- final List tinVertices = new ArrayList<>(200);
- double maxDist = 0;
-
- /**
- * Poisson a little faster, but isolines are more rough
- */
-// ArrayList randomPoints = pd.generate(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer,
-// e[1].y + buffer, intervalSpacing, 6);
-// PoissonDistribution pd = new PoissonDistribution(0);
Coordinate[] e = g.getEnvelope().getCoordinates(); // envelope/bounding box of shape
- ArrayList randomPoints = generateGrid(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer, e[1].y + buffer, intervalSpacing,
- intervalSpacing);
+ List randomPoints = generateGrid(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer, e[1].y + buffer, intervalSpacing / 3, intervalSpacing / 3);
+
+ final List tinVertices = new ArrayList<>(randomPoints.size());
+ double maxDist = 0;
for (PVector v : randomPoints) {
- /**
+ /*
* Major bottleneck of method is isoline computation so reduce points to only
* those needed.
*/
- if (cache.covers(PGS.pointFromPVector(v))) {
- double d = highPoint.dist(v);
+ if (cache.locate(PGS.coordFromPVector(v)) != Location.EXTERIOR) {
+ double d = Math.sqrt(PGS.distanceSq(highPoint, v));
maxDist = Math.max(d, maxDist);
tinVertices.add(new Vertex(v.x, v.y, d));
}
-// if (g.isWithinDistance(PTS.pointFromPVector(v), 10)) {
-// double d = highPoint.dist(v);
-// maxDist = Math.max(d, maxDist);
-// tinVertices.add(new Vertex(v.x, v.y, d, 0));
-// }
}
- final IncrementalTin tin = new IncrementalTin(intervalSpacing);
+ final IncrementalTin tin = new IncrementalTin(intervalSpacing / 2);
tin.add(tinVertices, null); // insert point set; points are triangulated upon insertion
double[] intervals = generateDoubleSequence(0, maxDist, intervalSpacing);
@@ -493,38 +437,63 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp
* "A null valuator tells the builder to just use the z values from the vertices
* rather than applying any adjustments to their values."
*/
- final ContourBuilderForTin builder = new ContourBuilderForTin(tin, null, intervals, true);
+ final ContourBuilderForTin builder = new ContourBuilderForTin(tin, null, intervals, false);
List contours = builder.getContours();
- PShape parent = new PShape(PConstants.GROUP);
- parent.setKind(PConstants.GROUP);
+ var contourGeom = GEOM_FACTORY.createMultiLineString(contours.stream().map(c -> contourToLineString(c)).toArray(LineString[]::new));
+
+ PShape out = toPShape(DouglasPeuckerSimplifier.simplify(contourGeom, 0.25).intersection(g));
+ PGS_Conversion.disableAllFill(out);
+ PGS_Conversion.setAllStrokeColor(out, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
+
+ return out;
+ }
- LineDissolver ld = new LineDissolver();
- for (Contour contour : contours) {
- Coordinate[] coords = new Coordinate[contour.getCoordinates().length / 2];
- for (int i = 0; i < contour.getCoordinates().length; i += 2) {
- float vx = (float) contour.getCoordinates()[i];
- float vy = (float) contour.getCoordinates()[i + 1];
- coords[i / 2] = new Coordinate(vx, vy);
+ /**
+ * Generates a topographic-like isoline contour map from the given points. This
+ * method uses the Z value of each PVector point as the "elevation" of that
+ * location in the map, and uses a specified number of contour intervals.
+ *
+ * The function finds the minimum and maximum Z values in the given points,
+ * divides the Z range into the specified number of intervals, and generates
+ * isolines or contour curves at corresponding heights. Smoothing can be applied
+ * to the isolines for better visual results.
+ *
+ * @param points Collection of PVectors representing sample locations. The
+ * z coordinate of each PVector defines the
+ * elevation at that point.
+ * @param intervals The number of contour levels (isolines) to generate between
+ * the minimum and maximum Z values in the input points. Must
+ * be greater than zero.
+ * @param smoothing The amount of smoothing to apply to the generated isolines.
+ * The smoothing algorithm and valid range depend on
+ * implementation.
+ * @return A Map where the keys are PShape objects representing the
+ * isolines, and the values are Float numbers giving the Z
+ * value (height) for each isoline.
+ *
+ * @since 2.1
+ *
+ * @see #isolines(Collection, double, double, double, int)
+ */
+ public static Map isolines(Collection points, int intervals, int smoothing) {
+ double minZ = Double.MAX_VALUE;
+ double maxZ = Double.MIN_VALUE;
+
+ for (PVector point : points) {
+ if (point.z < minZ) {
+ minZ = point.z;
+ }
+ if (point.z > maxZ) {
+ maxZ = point.z;
}
- ld.add(GEOM_FACTORY.createLineString(coords));
}
- PShape out = new PShape();
- try {
- /*
- * Need to use intersection() rather than checkling whether vertices are
- * contained within the shape (faster) because vertices of longer (straight)
- * line segments may lie within the shape when the segment extends outside the
- * shape
- */
- out = toPShape(DouglasPeuckerSimplifier.simplify(ld.getResult(), 1).intersection(g));
- out.setStrokeCap(PConstants.SQUARE);
- } catch (Exception e2) {
- // catch non-noded intersection
- }
- return out;
+ // Step 2: Compute interval spacing
+ double diff = maxZ - minZ;
+ double intervalSpacing = diff / intervals;
+ return isolines(points, intervalSpacing, minZ, maxZ, smoothing);
}
/**
@@ -542,8 +511,7 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp
* @param isolineMax maximum value represented by isolines
* @return a map of {isoline -> height of the isoline}
*/
- public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin,
- double isolineMax) {
+ public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax) {
return isolines(points, intervalValueSpacing, isolineMin, isolineMax, 0);
}
@@ -567,10 +535,13 @@ public static Map isolines(Collection points, double int
* investigation.
* @return a map of {isoline -> height of the isoline}
*/
- public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax,
- int smoothing) {
- final IncrementalTin tin = new IncrementalTin(10);
- points.forEach(point -> tin.add(new Vertex(point.x, point.y, point.z)));
+ public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax, int smoothing) {
+ final IncrementalTin tin = new IncrementalTin(intervalValueSpacing / 10);
+
+ var vertices = points.stream().map(p -> new Vertex(p.x, p.y, p.z)).collect(Collectors.toList());
+ HilbertSort hs = new HilbertSort();
+ hs.sort(vertices); // prevent degenerate insertion
+ tin.add(vertices, null);
double[] intervals = generateDoubleSequence(isolineMin, isolineMax, intervalValueSpacing);
@@ -591,30 +562,45 @@ public static Map isolines(Collection points, double int
isoline.setStrokeWeight(2);
isoline.setStroke(Colors.PINK);
+ PVector last = new PVector(Float.NaN, Float.NaN);
isoline.beginShape();
for (int i = 0; i < coords.length; i += 2) {
float vx = (float) coords[i];
float vy = (float) coords[i + 1];
- isoline.vertex(vx, vy);
+ PVector curr = new PVector(vx, vy);
+ if (!last.equals(curr)) {
+ isoline.vertex(vx, vy);
+ last.set(curr);
+ }
}
isoline.endShape();
- isolines.put(isoline, (float) contourLine.getZ());
+ if (isoline.getVertexCount() > 1) {
+ // skip pointal "contours"
+ isolines.put(isoline, (float) contourLine.getZ());
+ }
}
return isolines;
}
/**
- * Generates a contour map based on a distance field of a shape.
+ * Generates vector contour lines representing a distance field derived from a
+ * shape.
*
- * A distance field maps each point within the shape to the shortest distance
- * between that point and the shape boundary.
- *
- * @param shape polygonal shape
- * @param spacing distance represented by successive contour lines
- * @return GROUP shape, where each child is a closed contour line or contour
- * line partition
+ * The distance field for a shape assigns each interior point a value equal to
+ * the shortest Euclidean distance from that point to the shape boundary. This
+ * method computes a series of contour lines (isolines), where each line
+ * connects points with the same distance value, effectively visualizing the
+ * "levels" of the distance field like elevation contours on a topographic map.
+ *
+ * @param shape A polygonal shape for which to calculate the distance field
+ * contours.
+ * @param spacing The interval between successive contour lines, i.e., the
+ * distance value difference between each contour.
+ * @return A GROUP PShape. Each child of the group is a closed contour line or a
+ * section (partition) of a contour line, collectively forming the
+ * contour map.
* @since 1.3.0
*/
public static PShape distanceField(PShape shape, double spacing) {
@@ -630,12 +616,129 @@ public static PShape distanceField(PShape shape, double spacing) {
max = Math.max(d.distance, max);
}
- PShape out = PGS_Conversion.flatten(PGS_Contour.isolines(disks, spacing, min, max).keySet());
+ PShape out = PGS_Conversion.flatten(PGS_Contour.isolines(disks, spacing, min, max, 1).keySet());
PShape i = PGS_ShapeBoolean.intersect(shape, out);
PGS_Conversion.disableAllFill(i); // since some shapes may be polygons
+ PGS_Conversion.setAllStrokeColor(i, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
return i;
}
+ /**
+ * Generates vector contour lines representing a "contrast field" of a shape
+ * with respect to a given reference point.
+ *
+ * For each interior point of the shape, this field is defined as the absolute
+ * difference between (a) its shortest Euclidean distance to the shape's
+ * boundary, and (b) its distance to a specified reference point. Contour lines
+ * (isolines) are drawn at regular value intervals, connecting points where this
+ * difference is equal. This effectively visualises "ridges" and balance zones
+ * between the reference point and the shape boundary.
+ *
+ * @param shape A polygonal shape for which to calculate the distance field
+ * contours.
+ * @param intervals The number of successive contour lines.
+ * @param reference The reference point used for distance comparison.
+ * @param seed Random seed for Poisson-disc sampling of interior points.
+ * @return A GROUP PShape where each child is a closed contour line or a contour
+ * segment, together forming the contrast field visualisation as a
+ * vector contour map.
+ * @since 2.1
+ */
+ public static PShape contrastField(PShape shape, int intervals, PVector reference) {
+ final double[] b = new double[4];
+ PGS_Hull.boundingBox(shape, b); // write to bounding box
+ final var g = fromPShape(shape);
+ final var pointLocator = new YStripesPointInAreaLocator((Polygon) g.buffer(10));
+ final IndexedFacetDistance distIndex = new IndexedFacetDistance(g);
+ double adjustedArea = g.getArea() / PGS_ShapePredicates.density(shape);
+
+ var points = PGS_PointSet.poissonN(b[0], b[1], b[2], b[3], (int) Math.max(100, adjustedArea / 100), 1337);
+ List fieldPoints = points.parallelStream().map(p -> {
+ var point = PGS.pointFromPVector(p);
+ var c = point.getCoordinate();
+ if (pointLocator.locate(c) == Location.EXTERIOR) {
+ return null;
+ }
+ var dist = voidDistance(distIndex.distance(point), p, reference);
+ return new PVector((float) c.x, (float) c.y, (float) dist);
+ }).filter(Objects::nonNull).toList();
+
+ var isolines = isolines(fieldPoints, Math.max(1, intervals), 11);
+ var lines = PGS_Conversion.flatten(isolines.keySet());
+
+ PShape contours = PGS_ShapeBoolean.intersect(shape, lines);
+ contours = PGS_Conversion.disableAllFill(contours); // since some shapes may be polygons
+ PGS_Conversion.setAllStrokeColor(contours, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
+
+ return contours;
+ }
+
+ /**
+ * Generates a tree structure representing the shortest paths from a given start
+ * point to all other vertices in the provided mesh. The paths are computed
+ * using the existing connectivity of the mesh edges, ensuring that the
+ * shortest-path tree respects the original mesh structure. The tree is
+ * constructed using a Breadth-First Search (BFS) algorithm.
+ *
+ * The shortest-path tree represents the minimal set of mesh edges required to
+ * connect the start point to all other vertices in the mesh, following the
+ * mesh's inherent connectivity. This ensures that the paths are constrained by
+ * the mesh's topology rather than creating arbitrary connections between
+ * vertices.
+ *
+ * If the provided start point does not exactly match a vertex in the mesh, the
+ * closest vertex in the mesh to the start point is used as the actual starting
+ * point for the shortest-path computation.
+ *
+ * @param mesh A GROUP shape representing a mesh from which the graph is
+ * constructed. The mesh defines the connectivity between
+ * vertices via its edges.
+ * @param source The starting point from which the shortest paths are
+ * calculated. If this point does not exactly match a vertex in
+ * the mesh, the closest vertex in the mesh will be used as the
+ * starting point.
+ * @param flatten Determines the format of the output shortest-path tree.
+ *
+ * If {@code true}, the method returns a flattened representation
+ * of the shortest-path tree as a single set of edges. This
+ * removes duplicate edges and combines all paths into a single
+ * structure.
+ *
+ * If {@code false}, the method returns a GROUP shape of
+ * individual paths, where each path is a separate line from the
+ * start point to each vertex in the mesh. This representation
+ * retains the structure of the shortest-path tree as a
+ * collection of distinct paths.
+ *
+ * @return A PShape object representing the tree of shortest paths from the
+ * start point to all other vertices in the mesh. The paths are
+ * constrained by the mesh's edge connectivity.
+ * @since 2.1
+ */
+ public static PShape distanceTree(PShape mesh, PVector source, boolean flatten) {
+ var g = PGS_Conversion.toGraph(mesh);
+ ShortestPathAlgorithm spa = new BFSShortestPath<>(g);
+
+ final PVector sourceActual = PGS_Optimisation.closestPoint(g.vertexSet(), source);
+ var paths = spa.getPaths(sourceActual);
+
+ PShape out;
+ if (flatten) {
+ var edges = g.vertexSet().stream().filter(v -> !v.equals(sourceActual)) // Exclude the source vertex
+ .flatMap(v -> paths.getPath(v).getEdgeList().stream()) // Flatten the edge lists into a single stream
+ .collect(Collectors.toSet()); // Collect the edges into a Set to remove duplicates
+ out = PGS_SegmentSet.toPShape(edges);
+ } else {
+ var pathLines = g.vertexSet().stream() //
+ .filter(v -> v != sourceActual) // Exclude the source vertex
+ .map(v -> PGS_Conversion.fromPVector(paths.getPath(v).getVertexList())).toList();
+ out = PGS_Conversion.flatten(pathLines);
+ PGS_Conversion.setAllStrokeColor(out, ColorUtils.setAlpha(Colors.PINK, 50), 4);
+ }
+
+ return out;
+ }
+
/**
* Calculates the longest center line passing through a given shape (using
* default straightness weighting and smoothing parameters).
@@ -702,16 +805,13 @@ public static PShape centerLine(PShape shape, double straightnessWeighting, doub
MedialAxis m = new MedialAxis(fromPShape(shape));
List longestPath = new ArrayList<>();
- List subTree1 = m.getDescendants(m.getRoot().children.get(0)).stream().filter(d -> d.degree == 0)
- .collect(Collectors.toList());
- List subTree2 = m.getDescendants(m.getRoot().children.get(1)).stream().filter(d -> d.degree == 0)
- .collect(Collectors.toList());
+ List subTree1 = m.getDescendants(m.getRoot().children.get(0)).stream().filter(d -> d.degree == 0).collect(Collectors.toList());
+ List subTree2 = m.getDescendants(m.getRoot().children.get(1)).stream().filter(d -> d.degree == 0).collect(Collectors.toList());
if (m.getRoot().children.size() == 2) {
// special case of elliptical (etc.) shapes
longestPath = new ArrayList<>(m.getEdges());
} else {
- List subTree3 = m.getDescendants(m.getRoot().children.get(2)).stream().filter(d -> d.degree == 0)
- .collect(Collectors.toList());
+ List subTree3 = m.getDescendants(m.getRoot().children.get(2)).stream().filter(d -> d.degree == 0).collect(Collectors.toList());
MedialDisk longestPathD1 = null;
MedialDisk longestPathD2 = null;
@@ -754,7 +854,10 @@ public static PShape centerLine(PShape shape, double straightnessWeighting, doub
*/
public enum OffsetStyle {
- MITER(BufferParameters.JOIN_MITRE), BEVEL(BufferParameters.JOIN_BEVEL), ROUND(BufferParameters.JOIN_ROUND);
+ MITER(BufferParameters.JOIN_MITRE), //
+ BEVEL(BufferParameters.JOIN_BEVEL), //
+ ROUND(BufferParameters.JOIN_ROUND), //
+ ;
final int style;
@@ -822,8 +925,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci
if (g.getGeometryType().equals(Geometry.TYPENAME_LINESTRING)) {
List strings = new ArrayList<>(curves);
for (int i = 0; i < curves; i++) {
- strings.add(
- OffsetCurve.getCurve(g, spacing * (outwards ? 1 : -1) * i, 8, style.style, BufferParameters.DEFAULT_MITRE_LIMIT));
+ strings.add(OffsetCurve.getCurve(g, spacing * (outwards ? 1 : -1) * i, 8, style.style, BufferParameters.DEFAULT_MITRE_LIMIT));
}
return toPShape(strings);
}
@@ -832,8 +934,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci
g = DouglasPeuckerSimplifier.simplify(g, 0.25);
}
- final BufferParameters bufParams = new BufferParameters(8, BufferParameters.CAP_FLAT, style.style,
- BufferParameters.DEFAULT_MITRE_LIMIT);
+ final BufferParameters bufParams = new BufferParameters(8, BufferParameters.CAP_FLAT, style.style, BufferParameters.DEFAULT_MITRE_LIMIT);
// bufParams.setSimplifyFactor(5); // can produce "poor" yet interesting results
spacing = Math.max(1, Math.abs(spacing)); // ensure positive and >=1
@@ -841,8 +942,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci
final PShape parent = new PShape(PConstants.GROUP);
int currentCurves = 0;
- while ((outwards && currentCurves < curves) || (!outwards && !g.isEmpty() && curves == 0)
- || (!outwards && currentCurves < curves)) {
+ while ((outwards && currentCurves < curves) || (!outwards && !g.isEmpty() && curves == 0) || (!outwards && currentCurves < curves)) {
LinearRing[] rings = new LinearRingIterator(g).getLinearRings();
if (rings.length == 1) {
PShape curve = toPShape(rings[0]);
@@ -899,7 +999,7 @@ private static double[] generateDoubleSequence(double start, double end, double
}
/**
- * Generates a grid of points
+ * Generates a grid of points.
*
* @param minX
* @param minY
@@ -922,6 +1022,12 @@ private static ArrayList generateGrid(double minX, double minY, double
return grid;
}
+ private static float voidDistance(double geomDist, PVector p, PVector x) {
+ float edgeDist = (float) geomDist;
+ float pointDist = p.dist(x);
+ return Math.abs(edgeDist - pointDist); // Highlight where distances intersect
+ }
+
private static void reverse(T[] a) {
// used in straightSkeleton()
int l = a.length;
@@ -932,6 +1038,17 @@ private static void reverse(T[] a) {
}
}
+ private static LineString contourToLineString(Contour contour) {
+ // contours are x1,y1,x2,y2, etc.
+ Coordinate[] coords = new Coordinate[contour.getCoordinates().length / 2];
+ for (int i = 0; i < contour.getCoordinates().length; i += 2) {
+ double vx = contour.getCoordinates()[i];
+ double vy = contour.getCoordinates()[i + 1];
+ coords[i / 2] = new Coordinate(vx, vy);
+ }
+ return GEOM_FACTORY.createLineString(coords);
+ }
+
private static Loop ringToLoop(LinearRing ring, boolean hole, Set edgeCoordsSet, Machine speed) {
Coordinate[] coords = ring.getCoordinates();
if (!hole && !Orientation.isCCW(coords)) {
@@ -958,19 +1075,4 @@ private static Loop ringToLoop(LinearRing ring, boolean hole, Set ringToVec(LinearRing ring, Set edgeCoordsSet) {
- final List points = new ArrayList<>();
- Coordinate[] coords = ring.getCoordinates();
- /*
- * Kendzi polygons are unclosed (cannot start and end with the same point),
- * unlike a LinearRing.
- */
- for (int i = 0; i < coords.length - 1; i++) { // note - 1
- final Vector2dc p = new Vector2d(coords[i].x, coords[i].y);
- points.add(p);
- edgeCoordsSet.add(p);
- }
- return points;
- }
-
}
diff --git a/src/main/java/micycle/pgs/PGS_Conversion.java b/src/main/java/micycle/pgs/PGS_Conversion.java
index 51ba0103..a0c4d055 100644
--- a/src/main/java/micycle/pgs/PGS_Conversion.java
+++ b/src/main/java/micycle/pgs/PGS_Conversion.java
@@ -28,6 +28,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -63,14 +64,16 @@
import org.locationtech.jts.util.GeometricShapeFactory;
import org.scoutant.polyline.PolylineDecoder;
+import com.github.micycle1.betterbeziers.CubicBezier;
+
import it.rambow.master.javautils.PolylineEncoder;
import it.rambow.master.javautils.Track;
import it.rambow.master.javautils.Trackpoint;
import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
-import micycle.betterbeziers.CubicBezier;
import micycle.pgs.color.Colors;
import micycle.pgs.commons.Nullable;
import micycle.pgs.commons.PEdge;
+import net.jafama.FastMath;
import processing.core.PConstants;
import processing.core.PMatrix;
import processing.core.PShape;
@@ -900,24 +903,25 @@ public static List toPVector(PShape shape) {
/**
* Transforms a given PShape into a simple graph representation. In this
* representation, the vertices of the graph correspond to the vertices of the
- * shape, and the edges of the graph correspond to the edges of the shape. This
- * transformation is specifically applicable to polygonal shapes where edges are
- * formed by adjacent vertices.
+ * shape, and the edges of the graph correspond to the edges of the shape.
*
- * The edge weights in the graph are set to the length of the corresponding edge
- * in the shape.
+ * The edge weights in the graph are set to the length (euclidean distance) of
+ * the corresponding geometric edge in the shape.
*
- * @param shape the PShape to convert into a graph
+ * @param shape the PShape to convert into a graph. LINES and polygonal shapes
+ * are accepted (and GROUP shapes thereof).
* @return A SimpleGraph object that represents the structure of the input shape
* @since 1.3.0
* @see #toDualGraph(PShape)
*/
public static SimpleGraph toGraph(PShape shape) {
final SimpleGraph graph = new SimpleWeightedGraph<>(PEdge.class);
- for (PShape face : getChildren(shape)) {
- for (int i = 0; i < face.getVertexCount() - (face.isClosed() ? 0 : 1); i++) {
- final PVector a = face.getVertex(i);
- final PVector b = face.getVertex((i + 1) % face.getVertexCount());
+ for (PShape child : getChildren(shape)) {
+ final int stride = child.getKind() == PShape.LINES ? 2 : 1;
+ // Handle other child shapes (e.g., faces)
+ for (int i = 0; i < child.getVertexCount() - (child.isClosed() ? 0 : 1); i += stride) {
+ final PVector a = child.getVertex(i);
+ final PVector b = child.getVertex((i + 1) % child.getVertexCount());
if (a.equals(b)) {
continue;
}
@@ -943,27 +947,31 @@ public static SimpleGraph toGraph(PShape shape) {
* @since 1.4.0
*/
public static PShape fromGraph(SimpleGraph graph) {
- return PGS.polygonizeEdges(graph.edgeSet());
+ return PGS.polygonizeNodedEdges(graph.edgeSet());
}
/**
- * Takes as input a graph and computes a layout for the graph vertices using a
- * Force-Directed placement algorithm (not vertex coordinates, if any exist).
- * Vertices are joined by their edges.
+ * Computes a layout for the vertices of a graph using a Force-Directed
+ * placement algorithm. The algorithm generates vertex coordinates based on the
+ * graph's topology, preserving its structure (i.e., connectivity and
+ * relationships between vertices and edges). Existing vertex coordinates, if
+ * any, are ignored.
*
- * The output is a rather abstract representation of the input graph, and not a
- * geometric equivalent (unlike most other conversion methods in the class).
+ * The output is an abstract representation of the input graph, not a geometric
+ * equivalent (unlike most other conversion methods in this class). The layout
+ * is bounded by the specified dimensions and anchored at (0, 0).
*
- * @param any vertex type
- * @param any edge type
- * @param graph the graph whose edges and vertices to lay out
- * @param normalizationFactor normalization factor for the optimal distance,
- * between 0 and 1.
- * @param boundsX horizontal vertex bounds
- * @param boundsY vertical vertex bounds
- * @return a GROUP PShape consisting of 2 children; child 0 is the linework
- * (LINES) depicting edges and child 1 is the points (POINTS) depicting
- * vertices. The bounds of the layout are anchored at (0, 0);
+ * @param the type of vertices in the graph
+ * @param the type of edges in the graph
+ * @param graph the graph whose vertices and edges are to be laid
+ * out
+ * @param normalizationFactor the normalization factor for the optimal distance
+ * between vertices, clamped between 0.001 and 1
+ * @param boundsX the horizontal bounds for the layout
+ * @param boundsY the vertical bounds for the layout
+ * @return a GROUP PShape containing two children: child 0 represents the edges
+ * as linework (LINES), and child 1 represents the vertices as points
+ * (POINTS)
* @since 1.3.0
*/
public static PShape fromGraph(SimpleGraph graph, double normalizationFactor, double boundsX, double boundsY) {
@@ -1059,32 +1067,43 @@ public static SimpleGraph toCentroidDualGraph(PShape mesh) {
*/
static SimpleGraph toDualGraph(Collection meshFaces) {
final SimpleGraph graph = new SimpleGraph<>(DefaultEdge.class);
- // map of which edge belong to each face; used to detect half-edges
- final HashMap edgesMap = new HashMap<>(meshFaces.size() * 4);
+ final Map> edgeFacesMap = new HashMap<>();
+ // Phase 1: Collect edges and their associated faces
for (PShape face : meshFaces) {
- graph.addVertex(face); // always add child so disconnected shapes are colored
+ graph.addVertex(face);
for (int i = 0; i < face.getVertexCount(); i++) {
- final PVector a = face.getVertex(i);
- final PVector b = face.getVertex((i + 1) % face.getVertexCount());
+ PVector a = face.getVertex(i);
+ PVector b = face.getVertex((i + 1) % face.getVertexCount());
if (a.equals(b)) {
continue;
}
- final PEdge e = new PEdge(a, b);
- final PShape neighbour = edgesMap.get(e);
- if (neighbour != null) {
- // edge seen before, so faces must be adjacent; create edge between faces
- if (neighbour.equals(face)) { // probably bad input (3 edges the same)
- System.err.println("toDualGraph(): Bad input — saw the same edge 3 times.");
- continue; // continue to prevent self-loop in graph
- }
- graph.addEdge(neighbour, face);
- } else {
- edgesMap.put(e, face); // edge is new
- }
+ PEdge edge = new PEdge(a, b);
+ edgeFacesMap.computeIfAbsent(edge, k -> new ArrayList<>()).add(face);
}
}
+
+ // Phase 2: Process edges in sorted order for graph iteration consistency
+ edgeFacesMap.entrySet().stream().sorted(Comparator.comparing(e -> e.getKey())) // Sort edges to ensure deterministic processing
+ .forEach(entry -> {
+ List faces = entry.getValue();
+ if (faces.size() == 2) {
+ // If exactly two faces share this edge, connect them in the dual graph
+ PShape f1 = faces.get(0);
+ PShape f2 = faces.get(1);
+ if (!f1.equals(f2)) {
+ graph.addEdge(f1, f2); // Avoid self-loops
+ } else {
+ // Handle case where the same face is associated with the edge twice
+ System.err.println("toDualGraph(): Bad input — saw the same edge 3+ times for face: " + f1);
+ }
+ } else if (faces.size() > 2) {
+ // Handle edges shared by more than two faces
+ System.err.println("toDualGraph(): Bad input — edge shared by more than two faces: " + entry.getKey().toString());
+ }
+ });
+
return graph;
}
@@ -1227,7 +1246,7 @@ public static PShape fromHexWKB(String shapeWKB) {
* Google Encoded Polyline format.
*
* @param shape single (holeless) polygon or line
- * @return
+ * @return String with the encoded polyline representing the shape
* @since 1.3.0
*/
public static String toEncodedPolyline(PShape shape) {
@@ -1388,7 +1407,6 @@ public static PShape fromPVector(PVector... vertices) {
*
* @param shell vertices of the shell of the polygon
* @param holes (optional) list of holes
- * @return
* @since 1.4.0
*/
public static PShape fromContours(List shell, @Nullable List> holes) {
@@ -1449,10 +1467,21 @@ public static PShape fromContours(List shell, @Nullable List points = toPVector(shape); // CLOSE
+ List points = toPVector(shape); // unclosed
if (shape.isClosed() && keepClosed) {
points.add(points.get(0).copy()); // since toPVector returns unclosed view
}
+ return toArray(points);
+ }
+
+ /**
+ * Converts a list of PVectors into an array of coordinates.
+ *
+ * @param points
+ * @return coordinate array in the form [[x1, y1], [x2, y2]]
+ * @since 2.1
+ */
+ public static double[][] toArray(List points) {
double[][] out = new double[points.size()][2];
for (int i = 0; i < points.size(); i++) {
PVector point = points.get(i);
@@ -1486,14 +1515,18 @@ public static PShape fromArray(double[][] shape, boolean close) {
/**
* Flattens a collection of PShapes into a single GROUP PShape which has the
- * input shapes as its children.
+ * input shapes as its children. If the collection contains only one shape, it
+ * is returned directly as a non-GROUP shape.
*
* @since 1.2.0
* @see #flatten(PShape...)
*/
public static PShape flatten(Collection shapes) {
PShape group = new PShape(GROUP);
- shapes.forEach(group::addChild);
+ shapes.stream().filter(Objects::nonNull).forEach(group::addChild);
+ if (group.getChildCount() == 1) {
+ return group.getChild(0);
+ }
return group;
}
@@ -1536,11 +1569,11 @@ public static List getChildren(PShape shape) {
while (!parents.isEmpty()) {
final PShape parent = parents.pop(); // will always be a GROUP PShape
if (parent.getChildCount() > 0) { // avoid NPE on .getChildren()
- for (PShape child : parent.getChildren()) {
- if (child.getFamily() == GROUP) {
- parents.add(child);
+ for (PShape candidate : parent.getChildren()) {
+ if (candidate.getFamily() == GROUP) {
+ parents.add(candidate);
} else {
- children.add(child);
+ children.add(candidate);
}
}
}
@@ -1599,6 +1632,7 @@ public static PShape setAllFillColor(PShape shape, int color) {
*
* @param shape
* @return the input object (having now been mutated)
+ * @see #setAllStrokeColor(PShape, int, double, int)
* @see #setAllFillColor(PShape, int)
*/
public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight) {
@@ -1610,6 +1644,27 @@ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWei
return shape;
}
+ /**
+ * Sets the stroke color and cap style for the PShape and all of its children
+ * recursively.
+ *
+ * @param strokeCap either SQUARE, PROJECT, or
+ * ROUND
+ * @return the input object (having now been mutated)
+ * @see #setAllStrokeColor(PShape, int, double)
+ * @see #setAllFillColor(PShape, int)
+ * @since 2.1
+ */
+ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight, int strokeCap) {
+ getChildren(shape).forEach(child -> {
+ child.setStroke(true);
+ child.setStroke(color);
+ child.setStrokeWeight((float) strokeWeight);
+ child.setStrokeCap(strokeCap);
+ });
+ return shape;
+ }
+
/**
* Sets the stroke color equal to the fill color for the PShape and all of its
* descendent shapes individually (that is, each child shape belonging to the
@@ -1707,21 +1762,40 @@ public static PShape disableAllStroke(PShape shape) {
/**
* Rounds the x and y coordinates (to the closest int) of all vertices belonging
- * to the shape, mutating the shape. This can sometimes fix a visual
- * problem in Processing where narrow gaps can appear between otherwise flush
- * shapes.
+ * to the shape. This can sometimes fix a visual problem in Processing where
+ * narrow gaps can appear between otherwise flush shapes. If the shape is a
+ * GROUP, the rounding is applied to all child shapes.
*
- * @return the input object (having now been mutated)
+ * @param shape the PShape to round vertex coordinates for.
+ * @return a rounded copy of the input shape.
+ * @see #roundVertexCoords(PShape, int)
* @since 1.1.3
*/
public static PShape roundVertexCoords(PShape shape) {
- getChildren(shape).forEach(c -> {
+ return roundVertexCoords(shape, 0);
+ }
+
+ /**
+ * Rounds the x and y coordinates (to n decimal places) of all
+ * vertices belonging to the shape. This can sometimes fix a visual problem in
+ * Processing where narrow gaps can appear between otherwise flush shapes. If
+ * the shape is a GROUP, the rounding is applied to all child shapes.
+ *
+ * @param shape the PShape to round vertex coordinates for.
+ * @param n The number of decimal places to which the coordinates should be
+ * rounded.
+ * @return a rounded copy of the input shape.
+ * @since 2.1
+ */
+ public static PShape roundVertexCoords(PShape shape, int n) {
+ return PGS_Processing.transform(shape, s -> {
+ var c = copy(s);
for (int i = 0; i < c.getVertexCount(); i++) {
final PVector v = c.getVertex(i);
- c.setVertex(i, Math.round(v.x), Math.round(v.y));
+ c.setVertex(i, round(v.x, n), round(v.y, n));
}
+ return c;
});
- return shape;
}
/**
@@ -1735,6 +1809,7 @@ public static PShape roundVertexCoords(PShape shape) {
public static PShape copy(PShape shape) {
final PShape copy = new PShape();
copy.setName(shape.getName());
+ final PShapeData style = new PShapeData(shape);
try {
Method method;
@@ -1769,17 +1844,18 @@ public static PShape copy(PShape shape) {
e.printStackTrace();
}
- return copy;
+ return style.applyTo(copy);
}
/**
* Creates a PATH PShape representing a quadratic bezier curve, given by its
* parameters.
- *
- * @param start
- * @param controlPoint
- * @param end
- * @return
+ *
+ * @param start starting point of the bezier curve
+ * @param controlPoint control point of the curve
+ * @param end end point of the bezier curve
+ * @return a PShape representing the quadratic bezier curve as a PATH, sampled
+ * every 2 units along its length
* @since 1.4.0
*/
public static PShape fromQuadraticBezier(PVector start, PVector controlPoint, PVector end) {
@@ -1792,12 +1868,13 @@ public static PShape fromQuadraticBezier(PVector start, PVector controlPoint, PV
/**
* Creates a PATH PShape representing a cubic bezier curve, given by its
* parameters.
- *
- * @param start
- * @param controlPoint1
- * @param controlPoint2
- * @param end
- * @return
+ *
+ * @param start starting point of the bezier curve
+ * @param controlPoint1 first control point of the curve
+ * @param controlPoint2 second control point of the curve
+ * @param end end point of the bezier curve
+ * @return a PShape representing the cubic bezier curve as a PATH, sampled every
+ * 2 units along its length
* @since 1.4.0
*/
public static PShape fromCubicBezier(PVector start, PVector controlPoint1, PVector controlPoint2, PVector end) {
@@ -1943,6 +2020,12 @@ private static T[] reversedCopy(T[] original) {
return reversed;
}
+ private static float round(float x, float n) {
+ float m = (float) FastMath.pow(10, n);
+
+ return FastMath.floor(m * x + 0.5f) / m;
+ }
+
/**
* A utility class for storing and manipulating the visual properties of PShapes
* from the Processing library. It encapsulates the stroke, fill, stroke color,
@@ -1973,8 +2056,10 @@ public static class PShapeData {
public int fillColor, strokeColor;
public float strokeWeight;
public boolean fill, stroke;
+ private final PShape source;
PShapeData(PShape shape) {
+ source = shape;
try {
fillColor = fillColorF.getInt(shape);
fill = fillF.getBoolean(shape);
@@ -1990,9 +2075,16 @@ public static class PShapeData {
* Apply this shapedata to a given PShape.
*
* @param other
+ * @return other (fluent interface)
*/
- public void applyTo(PShape other) {
+ public PShape applyTo(PShape other) {
+ if (source.getFamily() == GROUP && other.getFamily() != GROUP) {
+ // opinionated -- don't apply group styling to non-group
+ return other;
+ }
+
if (other.getFamily() == GROUP) {
+ // ONLY IF child fill/stroke aren't defaults!?
getChildren(other).forEach(c -> applyTo(c));
}
other.setFill(fill);
@@ -2000,6 +2092,8 @@ public void applyTo(PShape other) {
other.setStroke(stroke);
other.setStroke(strokeColor);
other.setStrokeWeight(strokeWeight);
+
+ return other;
}
@Override
diff --git a/src/main/java/micycle/pgs/PGS_Hull.java b/src/main/java/micycle/pgs/PGS_Hull.java
index e57a6abd..61692d30 100644
--- a/src/main/java/micycle/pgs/PGS_Hull.java
+++ b/src/main/java/micycle/pgs/PGS_Hull.java
@@ -1,6 +1,7 @@
package micycle.pgs;
import static micycle.pgs.PGS_Conversion.toPShape;
+import static micycle.pgs.PGS_Conversion.fromPShape;
import java.util.ArrayList;
import java.util.Collection;
@@ -20,7 +21,8 @@
* Generates various types of geomtric hulls (convex, concave, etc.) for
* polygons and point sets.
*
- * A hull is the smallest enclosing shape that contains all points in a set.
+ * A hull is the smallest enclosing shape of some nature that contains all
+ * points in a set.
*
* @author Michael Carleton
* @since 1.3.0
@@ -30,6 +32,51 @@ public class PGS_Hull {
private PGS_Hull() {
}
+ /**
+ * Calculates the bounding box (envelope) for the given {@link PShape} and
+ * returns it as a new {@link PShape}.
+ *
+ * The returned shape represents the axis-aligned bounding rectangle that
+ * contains the input shape.
+ *
+ *
+ * @param shape the input shape for which to compute the bounding box
+ * @return a {@link PShape} representing the bounding box of the input shape
+ * @since 2.1
+ */
+ public static PShape boundingBox(PShape shape) {
+ return boundingBox(shape, new double[4]);
+ }
+
+ /**
+ * Computes the bounding box (envelope) of the given {@link PShape} and writes
+ * its coordinates to the provided array.
+ *
+ * The coordinates are written as: {minX, minY, maxX, maxY}.
+ *
+ *
+ * @param shape the shape for which to calculate the bounding box
+ * @param out an array (length ≥ 4) that will hold the bounding box
+ * coordinates:
+ *
+ * - out[0] = minX
+ * - out[1] = minY
+ * - out[2] = maxX
+ * - out[3] = maxY
+ *
+ * @return a {@link PShape} representing the bounding box of the input shape
+ * @since 2.1
+ */
+ public static PShape boundingBox(PShape shape, double[] out) {
+ var g = fromPShape(shape);
+ var e = g.getEnvelopeInternal();
+ out[0] = e.getMinX();
+ out[1] = e.getMinY();
+ out[2] = e.getMaxX();
+ out[3] = e.getMaxY();
+ return toPShape(g.getEnvelope());
+ }
+
/**
* Computes the convex hull of a point set (the smallest convex polygon that
* contains all the points).
@@ -68,7 +115,7 @@ public static PShape convexHull(PShape shape) {
* @since 1.3.0
*/
public static PShape concaveHull(PShape shapeSet, double concavity, boolean tight) {
- Geometry g = PGS_Conversion.fromPShape(shapeSet);
+ Geometry g = fromPShape(shapeSet);
if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON) || g.getGeometryType().equals(Geometry.TYPENAME_GEOMETRYCOLLECTION)) {
g = g.union();
}
@@ -190,7 +237,7 @@ public static PShape concaveHullBFS2(List points, double threshold) {
*/
public static PShape snapHull(PShape shape, double convexity) {
convexity = Math.max(Math.min(convexity, 1), 0); // constrain 0...1
- ConcaveHullOfPolygons hull = new ConcaveHullOfPolygons(PGS_Conversion.fromPShape(shape)); // union in case multipolygon
+ ConcaveHullOfPolygons hull = new ConcaveHullOfPolygons(fromPShape(shape)); // union in case multipolygon
hull.setMaximumEdgeLengthRatio(convexity);
return toPShape(hull.getHull());
}
diff --git a/src/main/java/micycle/pgs/PGS_Meshing.java b/src/main/java/micycle/pgs/PGS_Meshing.java
index a4d992a1..2297e9a4 100644
--- a/src/main/java/micycle/pgs/PGS_Meshing.java
+++ b/src/main/java/micycle/pgs/PGS_Meshing.java
@@ -13,10 +13,14 @@
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
-
+import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.math3.random.RandomGenerator;
import org.jgrapht.alg.connectivity.ConnectivityInspector;
+import org.jgrapht.alg.interfaces.MatchingAlgorithm;
import org.jgrapht.alg.interfaces.VertexColoringAlgorithm.Coloring;
+import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedMatching;
+import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching;
+import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense;
import org.jgrapht.alg.spanning.GreedyMultiplicativeSpanner;
import org.jgrapht.alg.util.NeighborCache;
import org.jgrapht.graph.AbstractBaseGraph;
@@ -118,7 +122,7 @@ public static PShape urquhartFaces(final IIncrementalTin triangulation, final bo
edges.add(t.getEdgeB().getBaseReference());
edges.add(t.getEdgeC().getBaseReference());
final IQuadEdge longestEdge = findLongestEdge(t).getBaseReference();
- if (!preservePerimeter || (preservePerimeter && !longestEdge.isConstrainedRegionBorder())) {
+ if (!preservePerimeter || (preservePerimeter && !longestEdge.isConstraintRegionBorder())) {
uniqueLongestEdges.add(longestEdge);
}
}
@@ -129,7 +133,7 @@ public static PShape urquhartFaces(final IIncrementalTin triangulation, final bo
final Collection meshEdges = new ArrayList<>(edges.size());
edges.forEach(edge -> meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y)));
- PShape mesh = PGS.polygonizeEdges(meshEdges);
+ PShape mesh = PGS.polygonizeNodedEdges(meshEdges);
return removeHoles(mesh, triangulation);
}
@@ -182,7 +186,7 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo
final double[] midpoint = midpoint(edge);
final Vertex near = tree.query1nn(midpoint).value();
if (near != edge.getA() && near != edge.getB()) {
- if (!preservePerimeter || (preservePerimeter && !edge.isConstrainedRegionBorder())) { // don't remove constraint borders (holes)
+ if (!preservePerimeter || (preservePerimeter && !edge.isConstraintRegionBorder())) { // don't remove constraint borders (holes)
nonGabrielEdges.add(edge); // base reference
}
}
@@ -192,7 +196,7 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo
final Collection meshEdges = new ArrayList<>(edges.size());
edges.forEach(edge -> meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y)));
- PShape mesh = PGS.polygonizeEdges(meshEdges);
+ PShape mesh = PGS.polygonizeNodedEdges(meshEdges);
return removeHoles(mesh, triangulation);
}
@@ -210,7 +214,6 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo
* @param preservePerimeter whether to retain/preserve edges on the perimeter
* even if they should be removed according to the
* relative neighbor condition
- * @return
* @since 1.3.0
*/
public static PShape relativeNeighborFaces(final IIncrementalTin triangulation, final boolean preservePerimeter) {
@@ -227,14 +230,14 @@ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation,
double l = e.getLength();
cache.neighborsOf(e.getA()).forEach(n -> {
if (Math.max(n.getDistance(e.getA()), n.getDistance(e.getB())) < l) {
- if (!preservePerimeter || (preservePerimeter && !e.isConstrainedRegionBorder())) {
+ if (!preservePerimeter || (preservePerimeter && !e.isConstraintRegionBorder())) {
edges.remove(e);
}
}
});
cache.neighborsOf(e.getB()).forEach(n -> {
if (Math.max(n.getDistance(e.getA()), n.getDistance(e.getB())) < l) {
- if (!preservePerimeter || (preservePerimeter && !e.isConstrainedRegionBorder())) {
+ if (!preservePerimeter || (preservePerimeter && !e.isConstraintRegionBorder())) {
edges.remove(e);
}
}
@@ -243,7 +246,7 @@ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation,
List edgesOut = edges.stream().map(PGS_Triangulation::toPEdge).collect(Collectors.toList());
- PShape mesh = PGS.polygonizeEdges(edgesOut);
+ PShape mesh = PGS.polygonizeNodedEdges(edgesOut);
return removeHoles(mesh, triangulation);
}
@@ -274,12 +277,12 @@ public static PShape spannerFaces(final IIncrementalTin triangulation, int k, fi
if (triangulation.getConstraints().isEmpty()) { // does not have constraints
spannerEdges.addAll(triangulation.getPerimeter().stream().map(PGS_Triangulation::toPEdge).collect(Collectors.toList()));
} else { // has constraints
- spannerEdges.addAll(triangulation.getEdges().stream().filter(IQuadEdge::isConstrainedRegionBorder).map(PGS_Triangulation::toPEdge)
+ spannerEdges.addAll(triangulation.getEdges().stream().filter(IQuadEdge::isConstraintRegionBorder).map(PGS_Triangulation::toPEdge)
.collect(Collectors.toList()));
}
}
- PShape mesh = PGS.polygonizeEdges(spannerEdges);
+ PShape mesh = PGS.polygonizeNodedEdges(spannerEdges);
return removeHoles(mesh, triangulation);
}
@@ -366,7 +369,9 @@ public static PShape splitQuadrangulation(final IIncrementalTin triangulation) {
/*-
* Now ideally "regularize" the mesh using techniques explored here:
+ * Towards Fully Regular Quad Mesh Generation -
* https://acdl.mit.edu/ESP/Publications/AIAApaper2019-1988.pdf
+ * A REGULARIZATION APPROACH FOR AUTOMATIC QUAD MESH GENERATION -
* https://acdl.mit.edu/ESP/Publications/IMR28.pdf
*/
@@ -391,6 +396,8 @@ public static PShape splitQuadrangulation(final IIncrementalTin triangulation) {
* triangles being included in the output).
* @return a GROUP PShape, where each child shape is one quadrangle
* @since 1.2.0
+ * @see #matchingQuadrangulation(IIncrementalTin) matchingQuadrangulation() -- a
+ * similar approach, but faster
*/
public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangulation, final boolean preservePerimeter) {
/*-
@@ -433,12 +440,12 @@ public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangula
* triangles". -- ideal, but not implemented here...
*/
// NOTE could now apply Topological optimization, as given in paper.
- if ((color < 2) || (preservePerimeter && (edge.isConstrainedRegionBorder() || perimeter.contains(edge)))) {
+ if ((color < 2) || (preservePerimeter && (edge.isConstraintRegionBorder() || perimeter.contains(edge)))) {
meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y));
}
});
- PShape quads = PGS.polygonizeEdges(meshEdges);
+ PShape quads = PGS.polygonizeNodedEdges(meshEdges);
if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole)
return quads;
} else {
@@ -480,13 +487,13 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation
if (preservePerimeter) {
List perimeter = triangulation.getPerimeter();
triangulation.edges().forEach(edge -> {
- if (edge.isConstrainedRegionBorder() || (unconstrained && perimeter.contains(edge))) {
+ if (edge.isConstraintRegionBorder() || (unconstrained && perimeter.contains(edge))) {
edges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y));
}
});
}
- final PShape quads = PGS.polygonizeEdges(edges);
+ final PShape quads = PGS.polygonizeNodedEdges(edges);
if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole)
return quads;
} else {
@@ -494,6 +501,69 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation
}
}
+ /**
+ * Converts a triangulation into a quadrangulation, by pairing up ("matching")
+ * triangles and merging them into quads. (This is the first step of the
+ * Blossom-Quad algorithm.)
+ *
+ * The method tries to maximise the quality of the quads of the output, meaning
+ * it aims to create quadrilaterals that are as regular and well-shaped
+ * (square-like) as possible.
+ *
+ * Sometimes, it’s not possible to pair all triangles perfectly (such as when
+ * there is not an even number of triangles). In those cases, the result will
+ * include some leftover triangles along with the quads.
+ *
+ * This method follows a similar principle to
+ * {@link #edgeCollapseQuadrangulation(IIncrementalTin, boolean)
+ * edgeCollapseQuadrangulation()}, but instead of using graph coloring to
+ * identify triangle pairs, it uses the Kolmogorov algorithm to find pairings.
+ *
+ * @param triangulation The input mesh made of triangles. This is the starting
+ * point for creating the quadrangulation.
+ * @return A GROUP PShape made of quadrilaterals (and possibly some triangles if
+ * pairing wasn’t perfect).
+ *
+ * @since 2.1
+ */
+ public static PShape matchingQuadrangulation(final IIncrementalTin triangulation) {
+ var g = PGS_Triangulation.toDualGraph(triangulation);
+ MatchingAlgorithm m;
+
+ /*
+ * A perfect matching is not always possible, so fall back to regular matching.
+ * When this happens not all edges can be collapsed, so the output will contain
+ * some triangles alongside the quads.
+ */
+ try {
+ m = new KolmogorovWeightedPerfectMatching<>(g, ObjectiveSense.MAXIMIZE);
+ m.getMatching();
+ } catch (Exception e2) {
+ m = new KolmogorovWeightedMatching<>(g, ObjectiveSense.MAXIMIZE);
+ }
+ var collapsedEdges = m.getMatching().getEdges();
+
+ Set seen = new HashSet<>(g.vertexSet());
+ var quads = collapsedEdges.stream().map(e -> {
+ var t1 = g.getEdgeSource(e);
+ var f1 = toPShape(t1);
+ var t2 = g.getEdgeTarget(e);
+ var f2 = toPShape(t2);
+
+ seen.remove(t1);
+ seen.remove(t2);
+ var quad = PGS_ShapeBoolean.union(f1, f2);
+ return quad;
+ }).collect(Collectors.toList()); // modifiable list
+
+ // include uncollapsed triangles (if any)
+ seen.forEach(t -> {
+ quads.add(toPShape(t));
+ });
+
+ return PGS_Conversion.flatten(quads);
+ }
+
/**
* Removes (what should be) holes from a polygonized quadrangulation.
*
@@ -511,6 +581,9 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation
*/
private static PShape removeHoles(PShape faces, IIncrementalTin triangulation) {
List holes = new ArrayList<>(triangulation.getConstraints()); // copy list
+ if (holes.size() <= 1) {
+ return faces;
+ }
holes = holes.subList(1, holes.size()); // slice off perimeter constraint (not a hole)
STRtree tree = new STRtree();
@@ -571,7 +644,7 @@ private static PShape removeHoles(PShape faces, IIncrementalTin triangulation) {
*/
public static PShape spiralQuadrangulation(List points) {
SpiralQuadrangulation sq = new SpiralQuadrangulation(points);
- return PGS.polygonizeEdges(sq.getQuadrangulationEdges());
+ return PGS.polygonizeNodedEdges(sq.getQuadrangulationEdges());
}
/**
@@ -731,10 +804,12 @@ public static PShape smoothMesh(PShape mesh, double displacementCutoff, boolean
displacementCutoff = Math.max(displacementCutoff, 1e-3);
PMesh m = new PMesh(mesh);
+ int iteration = 0;
+ int maxIterations = 10000;
double displacement;
do {
displacement = m.smoothTaubin(0.25, -0.251, preservePerimeter);
- } while (displacement > displacementCutoff);
+ } while (displacement > displacementCutoff && iteration++ < maxIterations);
return m.getMesh();
}
@@ -773,8 +848,10 @@ public static PShape simplifyMesh(PShape mesh, double tolerance, boolean preserv
*
* This subdivision method is most effective on meshes whose faces are convex
* and have a low vertex count (i.e., less than 6), where edge division points
- * correspond between adjacent faces. This method may fail on meshes with highly
- * concave faces because centroid-vertex visibility is not guaranteed.
+ * correspond between adjacent faces.
+ *
+ * Note: This method may fail on meshes with highly concave faces because
+ * centroid-vertex visibility is not guaranteed.
*
* @param mesh The mesh containing faces to subdivide.
* @param edgeSplitRatio The distance ratio [0...1] along each edge where the
@@ -817,18 +894,21 @@ public static PShape subdivideMesh(PShape mesh, double edgeSplitRatio) {
* edges comprising holes within faces.
*
* @param mesh The conforming mesh shape to extract inner edges from.
- * @return A shape representing the dissolved linework of inner mesh edges.
+ * @return A shape representing the linework of inner mesh edges.
* @since 1.4.0
*/
public static PShape extractInnerEdges(PShape mesh) {
List edges = PGS_SegmentSet.fromPShape(mesh);
- Map bag = new HashMap<>(edges.size());
- edges.forEach(edge -> {
- bag.merge(edge, 1, Integer::sum);
- });
+ Set seenEdges = new HashSet<>(edges.size());
+ Set innerEdges = new HashSet<>();
+
+ for (PEdge edge : edges) {
+ if (!seenEdges.add(edge)) { // add() returns false if edge is already present
+ innerEdges.add(edge);
+ }
+ }
- List innerEdges = bag.entrySet().stream().filter(e -> e.getValue() > 1).map(e -> e.getKey()).collect(Collectors.toList());
- return PGS_SegmentSet.dissolve(innerEdges);
+ return PGS_SegmentSet.toPShape(new ArrayList<>(innerEdges));
}
/**
@@ -841,13 +921,64 @@ public static PShape extractInnerEdges(PShape mesh) {
* @since 2.0
*/
public static List extractInnerVertices(PShape mesh) {
- var allVertices = PGS_Conversion.toPVector(mesh);
- var perimeterVertices = PGS_Conversion.toPVector(PGS_ShapeBoolean.unionMesh(mesh));
- var allVerticesSet = new HashSet<>(allVertices);
- var perimeterVerticesSet = new HashSet<>(perimeterVertices);
+ List allVertices = PGS_Conversion.toPVector(mesh);
+ Set perimeterVertices = new HashSet<>(PGS_Conversion.toPVector(PGS_ShapeBoolean.unionMesh(mesh)));
- allVerticesSet.removeAll(perimeterVerticesSet);
- return new ArrayList<>(allVerticesSet);
+ // Create a new list of only those vertices not in the perimeter.
+ return allVertices.stream().filter(vertex -> !perimeterVertices.contains(vertex)).collect(Collectors.toList());
+ }
+
+ /**
+ * Extracts inner edges and vertices of a mesh. Faster than calling both
+ * extractInnerVertices() and extractInnerEdges() in tandem.
+ */
+ static Pair, List> extractInnerEdgesAndVertices(PShape mesh) {
+ // 1) Get all (undirected) edges
+ List edges = PGS_SegmentSet.fromPShape(mesh);
+ int n = edges.size();
+
+ // 2) Classify edges:
+ // - 'single' holds edges seen exactly once so far
+ // - 'inner' holds edges seen ≥2 times
+ // Using a LinkedHashSet for 'inner' preserves insertion order (if you care)
+ Set single = new HashSet<>((int) (n / 0.75f) + 1);
+ Set inner = new HashSet<>((int) (n / 0.75f) + 1);
+
+ for (PEdge e : edges) {
+ if (single.contains(e)) {
+ // second time we see it → move to inner
+ single.remove(e);
+ inner.add(e);
+ } else if (!inner.contains(e)) {
+ // first time → keep in single
+ single.add(e);
+ }
+ // otherwise: already in 'inner', do nothing
+ }
+
+ // 3) Collect boundary vertices from the edges that remained 'single'
+ Set boundary = new HashSet<>(single.size() * 2);
+ for (PEdge e : single) {
+ boundary.add(e.a);
+ boundary.add(e.b);
+ }
+
+ // 4) Finally, collect inner‐vertices from the 'inner' edges
+ // but only those not in 'boundary' and avoid duplicates
+ List innerVerts = new ArrayList<>();
+ Set seenVertices = new HashSet<>();
+ for (PEdge e : inner) {
+ PVector a = e.a, b = e.b;
+ if (!boundary.contains(a) && seenVertices.add(a)) {
+ innerVerts.add(a);
+ }
+ if (!boundary.contains(b) && seenVertices.add(b)) {
+ innerVerts.add(b);
+ }
+ }
+
+ // 5) Return!
+ return Pair.of(new ArrayList<>(inner), innerVerts);
}
/**
@@ -917,21 +1048,20 @@ public static PShape findContainingFace(PShape mesh, PVector position) {
}
/**
- * Merges the small faces within a mesh into their adjacent faces recursively,
- * ensuring that no faces smaller than a specified area remain. This process is
- * repeated until all faces are at least as large as the minimum area defined by
- * the areaThreshold parameter.
- *
- * @param mesh a PShape object representing the mesh to which the area
- * merge operation will be applied. It must be of type
- * GROUP. Meshes with holes are supported; holes will be
- * preserved.
- * @param areaThreshold the minimum area a face must have to avoid being merged.
- * This is used as a threshold to determine which small
- * faces should be merged into adjacent larger ones.
- * @return PShape object representing the mesh after the merge operation, where
- * all faces have an area greater than or equal to the specified
- * areaThreshold.
+ * Merges all faces in the given mesh that are smaller than a specified area
+ * threshold into their larger neighbors, and repeats this process until no face
+ * remains below the threshold.
+ *
+ * Holes in the original mesh are preserved; only small faces are absorbed into
+ * adjacent larger faces.
+ *
+ * @param mesh a PShape of type GROUP representing the input mesh. May
+ * contain holes, which will be carried through.
+ * @param areaThreshold the minimum allowed area for any face. Any face whose
+ * area is strictly less than this value will be merged
+ * into one of its adjacent faces.
+ * @return a new PShape containing the merged mesh, with original styling
+ * applied, in which every face has area ≥ areaThreshold.
* @since 1.4.0
*/
public static PShape areaMerge(PShape mesh, double areaThreshold) {
@@ -939,6 +1069,29 @@ public static PShape areaMerge(PShape mesh, double areaThreshold) {
return applyOriginalStyling(merged, mesh);
}
+ /**
+ * Merges the smallest faces (by area) in the given mesh into their
+ * adjacent neighbors until the mesh has no more than a specified number of
+ * faces.
+ *
+ * If the input mesh has more faces than {@code remainingFaces}, the smallest
+ * faces are iteratively merged into their larger neighbors until the total face
+ * count is ≤ {@code remainingFaces}. Holes in the mesh are preserved.
+ *
+ * @param mesh a PShape of type GROUP representing the input mesh. May
+ * contain holes, which will be carried through.
+ * @param remainingFaces the target maximum number of faces. The algorithm will
+ * merge the smallest faces until the mesh has at most
+ * this many faces.
+ * @return a new PShape containing the merged mesh, with original styling
+ * applied, in which the total number of faces is ≤ remainingFaces.
+ * @since 2.1
+ */
+ public static PShape areaMerge(PShape mesh, int remainingFaces) {
+ PShape merged = AreaMerge.areaMerge(mesh, remainingFaces);
+ return applyOriginalStyling(merged, mesh);
+ }
+
/**
* Splits each edge of a given mesh shape into a specified number of
* equal-length parts and creates a new shape from the resulting smaller edges.
@@ -968,7 +1121,7 @@ public static PShape splitEdges(PShape split, int parts) {
}
}
- return PGS.polygonizeEdges(splitEdges);
+ return PGS.polygonizeNodedEdges(splitEdges);
}
/**
@@ -1018,4 +1171,11 @@ private static Vertex centroid(final SimpleTriangle t) {
return new Vertex(x, y, 0);
}
+ private static PShape toPShape(SimpleTriangle t) {
+ PVector vertexA = new PVector((float) t.getVertexA().x, (float) t.getVertexA().y);
+ PVector vertexB = new PVector((float) t.getVertexB().x, (float) t.getVertexB().y);
+ PVector vertexC = new PVector((float) t.getVertexC().x, (float) t.getVertexC().y);
+ return PGS_Conversion.fromPVector(Arrays.asList(vertexA, vertexB, vertexC, vertexA));
+ }
+
}
diff --git a/src/main/java/micycle/pgs/PGS_Morphology.java b/src/main/java/micycle/pgs/PGS_Morphology.java
index d933360a..783e2664 100644
--- a/src/main/java/micycle/pgs/PGS_Morphology.java
+++ b/src/main/java/micycle/pgs/PGS_Morphology.java
@@ -2,13 +2,9 @@
import static micycle.pgs.PGS_Conversion.fromPShape;
import static micycle.pgs.PGS_Conversion.toPShape;
-import static processing.core.PConstants.GROUP;
-
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
import java.util.function.BiFunction;
-
import org.locationtech.jts.densify.Densifier;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
@@ -31,16 +27,15 @@
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;
import org.locationtech.jts.simplify.VWSimplifier;
-import micycle.hobbycurves.HobbyCurve;
-import micycle.pgs.PGS.LinearRingIterator;
import micycle.pgs.PGS_Contour.OffsetStyle;
-import micycle.pgs.color.Colors;
import micycle.pgs.commons.ChaikinCut;
import micycle.pgs.commons.CornerRounding;
+import micycle.pgs.commons.CornerRounding.RoundingStyle;
import micycle.pgs.commons.DiscreteCurveEvolution;
import micycle.pgs.commons.DiscreteCurveEvolution.DCETerminationCallback;
import micycle.pgs.commons.EllipticFourierDesc;
import micycle.pgs.commons.GaussianLineSmoothing;
+import micycle.pgs.commons.LaneRiesenfeldSmoothing;
import micycle.pgs.commons.ShapeInterpolation;
import micycle.uniformnoise.UniformNoise;
import processing.core.PConstants;
@@ -64,35 +59,69 @@ private PGS_Morphology() {
}
/**
- * Computes a rounded buffer area around the shape, having the given buffer
- * width.
- *
- * @param shape
- * @param buffer extent/width of the buffer (which may be positive or negative)
- * @return a polygonal shape representing the buffer region (which may be empty)
+ * Returns a rounded buffer region of the given shape at the specified distance.
+ *
+ * The distance is in the same coordinate units as the shape: positive values
+ * expand the shape, negative values contract it. The returned shape is a
+ * polygonal PShape and may be empty. The input shape is not modified.
+ *
+ * @param shape the source shape to buffer
+ * @param buffer distance (extent/width) of the buffer; may be positive or
+ * negative
+ * @return a polygonal PShape representing the buffer region (may be empty)
* @see #buffer(PShape, double, OffsetStyle)
*/
public static PShape buffer(PShape shape, double buffer) {
- final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer));
- return toPShape(fromPShape(shape).buffer(buffer, segments));
+ return buffer(shape, buffer, OffsetStyle.ROUND);
}
/**
- * Computes a buffer area around the shape, having the given buffer width and
- * buffer style (either round, miter, bevel).
- *
- * @param shape
- * @param buffer extent/width of the buffer (which may be positive or negative)
- * @return a polygonal shape representing the buffer region (which may be empty)
+ * Returns a buffer region of the given shape using the specified join style.
+ *
+ * The distance is in the same coordinate units as the shape: positive values
+ * expand the shape, negative values contract it. The bufferStyle controls how
+ * corners are joined (e.g. ROUND, MITER, BEVEL). The returned shape is a
+ * polygonal PShape and may be empty. The input shape is not modified.
+ *
+ * @param shape the source shape to buffer
+ * @param buffer distance (extent/width) of the buffer; may be positive or
+ * negative
+ * @param bufferStyle how to join offset segments (ROUND, MITER, BEVEL)
+ * @return a polygonal PShape representing the buffer region (may be empty)
* @see #buffer(PShape, double)
* @since 1.3.0
*/
public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle) {
+ return buffer(shape, buffer, bufferStyle, CapStyle.ROUND);
+ }
+
+ /**
+ * Returns a buffer region of the given shape using the specified join and cap
+ * styles.
+ *
+ * The distance is in the same coordinate units as the shape: positive values
+ * expand the shape, negative values contract it. bufferStyle controls how
+ * corners are joined; capStyle controls the end-cap style used for open
+ * geometries. The input shape is not modified; the returned PShape preserves
+ * the user data from the original geometry. The result is a polygonal PShape
+ * and may be empty.
+ *
+ * @param shape the source shape to buffer
+ * @param buffer distance (extent/width) of the buffer; may be positive or
+ * negative
+ * @param bufferStyle how to join offset segments (ROUND, MITER, BEVEL)
+ * @param capStyle how to draw end caps for open geometries (e.g. ROUND,
+ * FLAT)
+ * @return a polygonal PShape representing the buffer region (may be empty)
+ * @since 2.1
+ */
+ public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle, CapStyle capStyle) {
Geometry g = fromPShape(shape);
- final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer));
- BufferParameters bufParams = new BufferParameters(segments, BufferParameters.CAP_FLAT, bufferStyle.style, BufferParameters.DEFAULT_MITRE_LIMIT);
+ BufferParameters bufParams = createBufferParams(buffer, 0.5, bufferStyle, capStyle);
BufferOp b = new BufferOp(g, bufParams);
- return toPShape(b.getResultGeometry(buffer));
+ var out = b.getResultGeometry(buffer);
+ out.setUserData(g.getUserData());
+ return toPShape(out);
}
/**
@@ -182,8 +211,10 @@ public static PShape erosionDilation(PShape shape, double buffer) {
buffer = Math.abs(buffer);
final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer));
- Geometry g = BufferOp.bufferOp(fromPShape(shape), -buffer, segments);
+ var in = fromPShape(shape);
+ Geometry g = BufferOp.bufferOp(in, -buffer, segments);
g = BufferOp.bufferOp(g, +buffer, segments);
+ g.setUserData(in.getUserData());
try {
return toPShape(g);
@@ -201,7 +232,6 @@ public static PShape erosionDilation(PShape shape, double buffer) {
*
* @param shape polygonal shape
* @param buffer a positive number
- * @return
* @since 1.3.0
* @see #erosionDilation(PShape, double)
*/
@@ -209,8 +239,11 @@ public static PShape dilationErosion(PShape shape, double buffer) {
buffer = Math.abs(buffer);
final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer));
- Geometry g = BufferOp.bufferOp(fromPShape(shape), buffer, segments);
+ var in = fromPShape(shape);
+ Geometry g = BufferOp.bufferOp(in, buffer, segments);
g = BufferOp.bufferOp(g, -buffer, segments);
+ g.setUserData(in.getUserData());
+
try {
return toPShape(g);
} catch (Exception e) {
@@ -270,12 +303,12 @@ public static PShape simplifyTopology(PShape shape, double distanceTolerance) {
* Simplifies a shape via Discrete Curve Evolution.
*
* This algorithm simplifies a shape by iteratively removing kinks from the
- * shape, starting with those having the least shape-relevance.
+ * shape, starting with those having the least shape-relevance.
*
* The simplification process terminates according to a user-specified
* {@link DCETerminationCallback#shouldTerminate(Coordinate, double, int)
* callback} that decides whether the DCE algorithm should terminate based on
- * the current kink (having a candidate vertex), using its coordinates,
+ * the current kink (having a candidate vertex), using its: coordinate,
* relevance score, and the number of vertices remaining in the simplified
* geometry. Implementations can use this method to provide custom termination
* logic which may depend on various factors, such as a threshold relevance
@@ -296,39 +329,23 @@ public static PShape simplifyTopology(PShape shape, double distanceTolerance) {
* @since 2.0
*/
public static PShape simplifyDCE(PShape shape, DCETerminationCallback terminationCallback) {
- Geometry g = fromPShape(shape);
- switch (g.getGeometryType()) {
- case Geometry.TYPENAME_GEOMETRYCOLLECTION :
- case Geometry.TYPENAME_MULTIPOLYGON :
- case Geometry.TYPENAME_MULTILINESTRING :
- PShape group = new PShape(GROUP);
- for (int i = 0; i < g.getNumGeometries(); i++) {
- group.addChild(simplifyDCE(toPShape(g.getGeometryN(i)), terminationCallback));
- }
- return group;
- case Geometry.TYPENAME_LINEARRING :
- case Geometry.TYPENAME_POLYGON :
- // process each ring individually
- LinearRing[] rings = new LinearRingIterator(g).getLinearRings();
- LinearRing[] dceRings = new LinearRing[rings.length];
- for (int i = 0; i < rings.length; i++) {
- LinearRing ring = rings[i];
- Coordinate[] dce = DiscreteCurveEvolution.process(ring, terminationCallback);
- dceRings[i] = PGS.GEOM_FACTORY.createLinearRing(dce);
- }
- LinearRing[] holes = null;
- if (dceRings.length > 1) {
- holes = Arrays.copyOfRange(dceRings, 1, dceRings.length);
- }
- return toPShape(PGS.GEOM_FACTORY.createPolygon(dceRings[0], holes));
- case Geometry.TYPENAME_LINESTRING :
- LineString l = (LineString) g;
- Coordinate[] dce = DiscreteCurveEvolution.process(l, terminationCallback);
- return toPShape(PGS.GEOM_FACTORY.createLineString(dce));
- default :
- System.err.println(g.getGeometryType() + " are not supported for the simplifyDCE() method."); // pointal geoms
- return new PShape(); // return empty (so element is invisible if not processed)
- }
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ var coords = DiscreteCurveEvolution.process(ring, terminationCallback);
+ return PGS.GEOM_FACTORY.createLineString(coords);
+ });
+ }
+
+ /**
+ * Simplify the shape using DCE, removing vertices with relevance < r.
+ *
+ * @param shape the input shape
+ * @param relevanceThreshold the relevance threshold; only vertices with
+ * relevance >= the threshold will be kept
+ * @return the simplified PShape
+ * @since 2.1
+ */
+ public static PShape simplifyDCE(PShape shape, final double relevanceThreshold) {
+ return simplifyDCE(shape, (currentVertex, relevance, verticesRemaining) -> relevance >= relevanceThreshold);
}
/**
@@ -348,21 +365,20 @@ public static PShape simplifyDCE(PShape shape, DCETerminationCallback terminatio
* @since 1.4.0
*/
public static PShape simplifyHobby(PShape shape, double tension) {
- tension = Math.max(tension, 0.668); // prevent degeneracy
- double[][] vertices = PGS_Conversion.toArray(shape, false);
- HobbyCurve curve = new HobbyCurve(vertices, tension, shape.isClosed(), 0.5, 0.5);
- List points = new ArrayList<>();
- for (double[] b : curve.getBeziers()) {
- int i = 0;
- PVector p1 = new PVector((float) b[i++], (float) b[i++]);
- PVector cp1 = new PVector((float) b[i++], (float) b[i++]);
- PVector cp2 = new PVector((float) b[i++], (float) b[i++]);
- PVector p2 = new PVector((float) b[i++], (float) b[i]);
- PShape bezier = PGS_Conversion.fromCubicBezier(p1, cp1, cp2, p2);
- points.addAll(PGS_Conversion.toPVector(bezier));
- }
-
- return PGS_Conversion.fromPVector(points);
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ var points = PGS_Conversion.toPVector(toPShape(ring));
+ if (ring.isClosed() && !points.get(0).equals(points.get(points.size() - 1))) {
+ points.add(points.get(0).copy());
+ }
+ var g = fromPShape(PGS_Construction.createHobbyCurve(points, tension));
+ if (g instanceof Polygon) {
+ g = ((Polygon) g).getExteriorRing();
+ }
+ if (g instanceof Lineal) {
+ return (LineString) g;
+ }
+ return null;
+ });
}
/**
@@ -437,43 +453,7 @@ public static PShape smooth(PShape shape, double alpha) {
* @see #smooth(PShape, double)
*/
public static PShape smoothGaussian(PShape shape, double sigma) {
- Geometry g = fromPShape(shape);
-
- switch (g.getGeometryType()) {
- case Geometry.TYPENAME_GEOMETRYCOLLECTION :
- case Geometry.TYPENAME_MULTIPOLYGON :
- case Geometry.TYPENAME_MULTILINESTRING :
- PShape group = new PShape(GROUP);
- for (int i = 0; i < g.getNumGeometries(); i++) {
- group.addChild(smoothGaussian(toPShape(g.getGeometryN(i)), sigma));
- }
- return group;
- case Geometry.TYPENAME_POLYGON :
- LinearRingIterator lri = new LinearRingIterator(g);
- LineString[] rings = lri.getLinearRings();
- LinearRing[] ringSmoothed = new LinearRing[rings.length];
- for (int i = 0; i < rings.length; i++) {
- Coordinate[] coords = GaussianLineSmoothing.get(rings[i], Math.max(sigma, 1), 1).getCoordinates();
- if (coords.length > 2) {
- ringSmoothed[i] = PGS.GEOM_FACTORY.createLinearRing(coords);
- } else {
- ringSmoothed[i] = PGS.GEOM_FACTORY.createLinearRing();
- }
- }
-
- LinearRing[] holes = null;
- if (ringSmoothed.length > 1) {
- holes = Arrays.copyOfRange(ringSmoothed, 1, ringSmoothed.length);
- }
- return toPShape(PGS.GEOM_FACTORY.createPolygon(ringSmoothed[0], holes));
- case Geometry.TYPENAME_LINEARRING :
- case Geometry.TYPENAME_LINESTRING :
- LineString l = (LineString) g;
- return toPShape(GaussianLineSmoothing.get(l, Math.max(sigma, 1), 1));
- default :
- System.err.println(g.getGeometryType() + " are not supported for the smoothGaussian() method."); // pointal geoms
- return new PShape(); // return empty (so element is invisible if not processed)
- }
+ return PGS.applyToLinealGeometries(shape, ring -> GaussianLineSmoothing.get(ring, sigma));
}
/**
@@ -503,62 +483,88 @@ public static PShape smoothGaussian(PShape shape, double sigma) {
* @since 1.4.0
*/
public static PShape smoothEllipticFourier(PShape shape, int descriptors) {
- Geometry g = fromPShape(shape);
- switch (g.getGeometryType()) {
- case Geometry.TYPENAME_GEOMETRYCOLLECTION :
- case Geometry.TYPENAME_MULTIPOLYGON :
- case Geometry.TYPENAME_MULTILINESTRING :
- PShape group = new PShape(GROUP);
- for (int i = 0; i < g.getNumGeometries(); i++) {
- group.addChild(smoothEllipticFourier(toPShape(g.getGeometryN(i)), descriptors));
- }
- return group;
- case Geometry.TYPENAME_POLYGON :
- LinearRingIterator lri = new LinearRingIterator(g);
- LinearRing[] rings = lri.getLinearRings();
- LinearRing[] ringProcessed = new LinearRing[rings.length];
- for (int i = 0; i < rings.length; i++) {
- descriptors = Math.min(rings[i].getCoordinates().length / 2, descriptors); // max=#vertices/2
- descriptors = Math.max(2, descriptors); // min=2
- final EllipticFourierDesc efd = new EllipticFourierDesc(rings[i], descriptors);
- Coordinate[] coords = efd.createPolygon();
- ringProcessed[i] = PGS.GEOM_FACTORY.createLinearRing(coords);
- }
-
- LinearRing[] holes = null;
- if (ringProcessed.length > 1) {
- holes = Arrays.copyOfRange(ringProcessed, 1, ringProcessed.length);
- }
- return toPShape(PGS.GEOM_FACTORY.createPolygon(ringProcessed[0], holes));
- case Geometry.TYPENAME_LINEARRING :
- descriptors = Math.min(shape.getVertexCount() / 2, descriptors); // max=#vertices/2
- descriptors = Math.max(2, descriptors); // min=2
- LinearRing l = (LinearRing) g;
- final EllipticFourierDesc efd = new EllipticFourierDesc(l, descriptors);
- return toPShape(PGS.GEOM_FACTORY.createLinearRing(efd.createPolygon()));
- case Geometry.TYPENAME_LINESTRING :
- default :
- System.err.println(g.getGeometryType() + " are not supported for the smoothEllipticFourier() method."); // pointal/string
- // geoms
- return new PShape(); // return empty (so element is invisible if not processed)
- }
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ int descriptorz = Math.min(ring.getCoordinates().length / 2, descriptors); // max=#vertices/2
+ descriptorz = Math.max(2, descriptorz); // min=2
+ if (ring.isClosed()) {
+ final EllipticFourierDesc efd = new EllipticFourierDesc((LinearRing) ring, descriptorz);
+ Coordinate[] coords = efd.createPolygon();
+ return PGS.GEOM_FACTORY.createLinearRing(coords);
+ } else {
+ return null; // open linestrings not supported
+ }
+ });
}
/**
- * Modifies the corners of a specified shape by replacing each angular corner
- * with a smooth, circular arc. The radius of each arc is determined
- * proportionally to the shorter of the two lines forming the corner.
+ * Smooths a shape using Lane-Riesenfeld curve subdivision with 4-point
+ * refinement to reduce contraction.
*
- * @param shape The original PShape object whose corners are to be rounded.
- * @param extent Specifies the degree of corner rounding, with a range from 0 to
- * 1. A value of 0 corresponds to no rounding, whereas a value of
- * 1 yields maximum rounding while still maintaining the validity
- * of the shape. Values above 1 are accepted but may produce
- * unpredictable results.
- * @return A new PShape object with corners rounded to the specified extent.
+ * @param shape A shape having lineal geometries (polygons or
+ * linestrings). Can be a GROUP shape consisting of
+ * these.
+ * @param degree The degree of the LR algorithm. Higher degrees
+ * influence the placement of vertices and the
+ * overall shape of the curve, but only slightly
+ * increase the number of vertices generated.
+ * Increasing the degree also increases the
+ * contraction of the curve toward its control
+ * points. The degree does not directly control the
+ * smoothness of the curve. A value of 3 or 4 is
+ * usually sufficient for most applications.
+ * @param subdivisions The number of times the subdivision process is
+ * applied. More subdivisions result in finer
+ * refinement and visually smoother curves between
+ * vertices. A value of 3 or 4 is usually
+ * sufficient for most applications.
+ * @param antiContractionFactor The weight parameter for the 4-point refinement.
+ * Controls the interpolation strength. A value of
+ * 0 effectively disables the contraction
+ * reduction. Generally suitable values are in
+ * [0...0.1]. Larger values may create
+ * self-intersecting geometry.
+ * @return A Shape having same structure as the input, whose geometries are now
+ * smooth.
+ * @since 2.1
+ */
+ public static PShape smoothLaneRiesenfeld(PShape shape, int degree, int subdivisions, double antiContractionFactor) {
+ return PGS.applyToLinealGeometries(shape, lineal -> LaneRiesenfeldSmoothing.subdivide(lineal, degree, subdivisions, antiContractionFactor));
+ }
+
+ /**
+ * Rounds polygon corners by replacing each corner with a circular arc.
+ *
+ *
+ * This processes only the linear content of the input PShape - the
+ * contour paths (closed contours for polygon exteriors and interior
+ * contours/holes, and open polylines). Non‑linear or unsupported children are
+ * ignored.
+ *
+ *
+ * The radius is nominal; it is clamped so it cannot exceed what
+ * adjacent edges can support (this prevents overlapping or invalid contours).
+ *
+ *
+ * @param shape a polygonal PShape or a GROUP
+ * PShape containing polygonal children; holes
+ * (interior contours) are supported
+ * @param radius nominal radius used to round corners; clamped by adjacent edge
+ * lengths
+ * @return a non-null PShape with rounded corners; possibly an
+ * empty GROUP when nothing remains
*/
- public static PShape round(PShape shape, double extent) {
- return CornerRounding.round(shape, extent);
+ public static PShape round(PShape shape, double radius) {
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ var rounded = CornerRounding.roundCorners(toPShape(ring), radius, RoundingStyle.CIRCLE);
+ var g = fromPShape(rounded);
+ if (g instanceof Polygon) {
+ g = ((Polygon) g).getExteriorRing();
+ }
+ if (g instanceof Lineal) {
+ return (LineString) g;
+ }
+ return null; // pointal or other...
+ });
}
/**
@@ -586,10 +592,19 @@ public static PShape chaikinCut(PShape shape, double ratio, int iterations) {
ratio = Math.max(ratio, 1e-6);
ratio = Math.min(ratio, 1 - 1e-6);
ratio /= 2; // constrain to 0...0.5
- PShape cut = ChaikinCut.chaikin(shape, (float) ratio, iterations);
- PGS_Conversion.setAllFillColor(cut, Colors.WHITE);
- PGS_Conversion.setAllStrokeColor(cut, Colors.PINK, 3);
- return cut;
+ float r = (float) ratio;
+
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ var cut = ChaikinCut.chaikin(toPShape(ring), r, iterations);
+ var g = fromPShape(cut);
+ if (g instanceof Polygon) {
+ g = ((Polygon) g).getExteriorRing();
+ }
+ if (g instanceof Lineal) {
+ return (LineString) g;
+ }
+ return null; // pointal or other...
+ });
}
/**
@@ -743,11 +758,11 @@ public static PShape fieldWarp(PShape shape, double magnitude, double noiseScale
final PShape copy;
if (densify && !pointsShape) {
final Densifier d = new Densifier(fromPShape(shape));
- d.setDistanceTolerance(1);
+ d.setDistanceTolerance(PGS_Conversion.BEZIER_SAMPLE_DISTANCE);
d.setValidate(false);
copy = toPShape(d.getResultGeometry());
} else {
- copy = toPShape(fromPShape(shape));
+ copy = PGS_Conversion.copy(shape);
}
final UniformNoise noise = new UniformNoise((int) (noiseSeed % Integer.MAX_VALUE));
@@ -757,8 +772,14 @@ public static PShape fieldWarp(PShape shape, double magnitude, double noiseScale
copy.addChild(copy);
}
+ /*
+ * TODO preserveEnds arg, that scales the noise offset towards 0 for vertices
+ * near the end (so we don't large jump between end point and warped next
+ * vertex).
+ */
for (PShape child : copy.getChildren()) {
- for (int i = 0; i < child.getVertexCount(); i++) {
+ int offset = 0; // child.isClosed() ? 0 : 1
+ for (int i = offset; i < child.getVertexCount() - offset; i++) {
final PVector coord = child.getVertex(i);
float dx = noise.uniformNoise(coord.x / scale, coord.y / scale + time) - 0.5f;
float dy = noise.uniformNoise(coord.x / scale + (101 + time), coord.y / scale + (101 + time)) - 0.5f;
@@ -805,6 +826,9 @@ public static PShape pinchWarp(PShape shape, PVector pinchPoint, double weight)
vertex.add(direction);
vertices.add(vertex);
}
+ if (shape.isClosed()) {
+ vertices.add(vertices.get(0));
+ }
return PGS_Conversion.fromPVector(vertices);
}
@@ -891,4 +915,55 @@ public static PShape reducePrecision(PShape shape, double precision) {
return toPShape(GeometryPrecisionReducer.reduce(fromPShape(shape), new PrecisionModel(-Math.max(Math.abs(precision), 1e-10))));
}
+ /**
+ * The end cap style to use. Cap style specifies the shape of the ends of
+ * buffered unclosed lines; it has no effect in polygons.
+ */
+ public enum CapStyle {
+
+ /**
+ * The usual round end caps.
+ */
+ ROUND(BufferParameters.CAP_ROUND),
+ /**
+ * End caps are truncated flat at the line ends.
+ */
+ FLAT(BufferParameters.CAP_FLAT),
+ /**
+ * End caps are squared off at the buffer distance beyond the line ends.
+ */
+ SQUARE(BufferParameters.CAP_SQUARE);
+
+ final int style;
+
+ private CapStyle(int style) {
+ this.style = style;
+ }
+ }
+
+ private static BufferParameters createBufferParams(double r, double delta, OffsetStyle bufferStyle, CapStyle capStyle) {
+ r = Math.abs(r);
+
+ // compute the number of points for the full circle
+ double ang = Math.acos(1.0 - delta / r);
+ // if delta/r > 2 or so acos will fail – clamp it
+ if (Double.isNaN(ang) || ang <= 0) {
+ // in this degenerate case just fall back to a small number
+ ang = Math.PI / 8.0;
+ }
+
+ // total points
+ double nPtsDbl = Math.PI / ang;
+ int nPts = (int) Math.ceil(nPtsDbl);
+
+ // segments per quadrant
+ int quadSeg = (int) Math.ceil(nPts / 4.0);
+
+ // enforce a sensible minimum
+ quadSeg = Math.max(quadSeg, BufferParameters.DEFAULT_QUADRANT_SEGMENTS);
+
+ // cap style affects linestrings only
+ return new BufferParameters(quadSeg, capStyle.style, bufferStyle.style, BufferParameters.DEFAULT_MITRE_LIMIT);
+ }
+
}
diff --git a/src/main/java/micycle/pgs/PGS_Optimisation.java b/src/main/java/micycle/pgs/PGS_Optimisation.java
index bd3f229b..d20d4adf 100644
--- a/src/main/java/micycle/pgs/PGS_Optimisation.java
+++ b/src/main/java/micycle/pgs/PGS_Optimisation.java
@@ -7,17 +7,18 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import org.apache.commons.lang3.tuple.Triple;
import org.locationtech.jts.algorithm.MinimumAreaRectangle;
import org.locationtech.jts.algorithm.MinimumBoundingCircle;
import org.locationtech.jts.algorithm.MinimumDiameter;
import org.locationtech.jts.algorithm.construct.LargestEmptyCircle;
import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle;
-import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.Envelope;
@@ -30,18 +31,24 @@
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.util.GeometricShapeFactory;
+import com.github.micycle1.geoblitz.YStripesPointInAreaLocator;
+
import almadina.rectpacking.RBPSolution;
import almadina.rectpacking.Rect;
import almadina.rectpacking.RectPacking.PackingHeuristic;
import micycle.pgs.color.Colors;
import micycle.pgs.commons.ClosestPointPair;
import micycle.pgs.commons.FarthestPointPair;
+import micycle.pgs.commons.FastAtan2;
+import micycle.pgs.commons.FastConvexMaximumInscribedCircle;
import micycle.pgs.commons.LargestEmptyCircles;
import micycle.pgs.commons.MaximumInscribedAARectangle;
import micycle.pgs.commons.MaximumInscribedRectangle;
+import micycle.pgs.commons.MaximumInscribedTriangle;
import micycle.pgs.commons.MinimumBoundingEllipse;
import micycle.pgs.commons.MinimumBoundingTriangle;
import micycle.pgs.commons.Nullable;
+import micycle.pgs.commons.SpiralIterator;
import micycle.pgs.commons.VisibilityPolygon;
import processing.core.PShape;
import processing.core.PVector;
@@ -66,19 +73,61 @@ private PGS_Optimisation() {
*
* @param shape a rectangular shape that covers/bounds the input
* @return polygonal shape having 4 coordinates
+ * @deprecated since 2.1; use {@link micycle.pgs.PGS_Hull#boundingBox(PShape)
+ * boundingBox(PShape)} instead.
*/
+ @Deprecated
public static PShape envelope(PShape shape) {
return toPShape(fromPShape(shape).getEnvelope());
}
/**
- * The Maximum Inscribed Circle is determined by a point in the interior of the
- * area which has the farthest distance from the area boundary, along with a
- * boundary point at that distance.
- *
- * @param shape
- * @param tolerance the distance tolerance for computing the centre point
- * (around 1)
+ * Computes the maximum inscribed circle within a given shape.
+ *
+ * The Maximum Inscribed Circle (MIC) is defined as the largest possible circle
+ * that can be completely contained within the area of the input shape. It is
+ * determined by locating a point inside the shape that has the greatest
+ * distance from the shape's boundary (i.e., the center of the MIC), and
+ * returning a circle centered at this point with a radius equal to that
+ * distance.
+ *
+ *
+ * This method automatically selects a reasonable tolerance value for computing
+ * the center point of the MIC, balancing precision and computational
+ * efficiency.
+ *
+ *
+ * @param shape the {@link PShape} representing the area within which to compute
+ * the MIC
+ * @return a {@link PShape} instance representing the maximum inscribed circle
+ * @since 2.1
+ */
+ public static PShape maximumInscribedCircle(PShape shape) {
+ MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape));
+ final double r = mic.getRadiusLine().getLength();
+ Polygon circle = createCircle(PGS.coordFromPoint(mic.getCenter()), r);
+ return toPShape(circle);
+ }
+
+ /**
+ * Computes the maximum inscribed circle within a given shape, using a specified
+ * tolerance.
+ *
+ * The Maximum Inscribed Circle (MIC) is the largest possible circle that can be
+ * fully contained within the area of the input shape. The center of the MIC is
+ * the point in the interior that is farthest from the shape's boundary, and the
+ * radius is the distance from this center point to the closest boundary point.
+ *
+ *
+ * @param shape the {@link PShape} representing the area within which to
+ * compute the maximum inscribed circle; typically a simple
+ * polygon or multipolygon
+ * @param tolerance the distance tolerance or resolution for approximation; must
+ * be non-negative. Lower values yield a more accurate result
+ * but increase computation time (e.g., 0.5 or 1 is common).
+ * @return a {@link PShape} representing the maximum inscribed circle within the
+ * input shape
+ * @see #maximumInscribedCircle(PShape, double, PVector)
*/
public static PShape maximumInscribedCircle(PShape shape, double tolerance) {
MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape), tolerance);
@@ -87,6 +136,40 @@ public static PShape maximumInscribedCircle(PShape shape, double tolerance) {
return toPShape(circle);
}
+ /**
+ * Computes the maximum inscribed circle (MIC) within a given shape,
+ * using the specified accuracy, and optionally outputs the center and radius.
+ *
+ * The maximum inscribed circle is the largest possible circle completely
+ * contained within the input shape. If a non-null {@code result} vector is
+ * provided, its x and y values will be set to the
+ * coordinates of the circle's center, and z will be set to its
+ * radius.
+ *
+ * The returned {@link PShape} is a polygonal approximation of the inscribed
+ * circle.
+ *
+ * @param shape the {@link PShape} to analyze
+ * @param tolerance the resolution (smaller values increase accuracy but reduce
+ * performance)
+ * @param result if not {@code null}, will receive center (x, y) and radius
+ * (z) of the MIC
+ * @return a {@link PShape} representing the maximum inscribed circle within the
+ * input shape
+ * @see #maximumInscribedCircle(PShape, double)
+ * @since 2.1
+ */
+ public static PShape maximumInscribedCircle(PShape shape, double tolerance, @Nullable PVector result) {
+ MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape), tolerance);
+ final double r = mic.getRadiusLine().getLength();
+ var c = mic.getCenter();
+ Polygon circle = createCircle(PGS.coordFromPoint(c), r);
+ if (result != null) {
+ result.set((float) c.getX(), (float) c.getY(), (float) r);
+ }
+ return toPShape(circle);
+ }
+
/**
* Return the maximum circle (at a given centerpoint inside/outside the circle)
*
@@ -103,11 +186,28 @@ public static PShape maximumInscribedCircle(PShape shape, PVector centerPoint) {
return toPShape(circle);
}
+ /**
+ * Computes the exact largest inscribed circle for a convex polygonal shape.
+ *
+ * This method is preferred and faster when the caller knows the shape is
+ * convex; use it instead of {@link #maximumInscribedCircle(PShape)
+ * maximumInscribedCircle()} for convex inputs.
+ *
+ * @param shape a convex polygonal PShape (non-null)
+ * @return a PVector (x, y, r) where x,y is the circle center and z (r) is the
+ * radius
+ * @since 2.1
+ */
+ public static PVector convexMaximumInscribedCircle(PShape shape) {
+ var c = FastConvexMaximumInscribedCircle.getCircle(fromPShape(shape));
+ return new PVector((float) c.x, (float) c.y, (float) c.z);
+ }
+
/**
* Finds an approximate largest area rectangle (of arbitrary orientation)
* contained within a polygon.
*
- * @param shape
+ * @param shape a polygonal shape
* @return a rectangle shape
* @see #maximumInscribedAARectangle(PShape, boolean)
* maximumInscribedAARectangle() - the largest axis-aligned rectangle
@@ -118,6 +218,20 @@ public static PShape maximumInscribedRectangle(PShape shape) {
return toPShape(mir.computeMIR());
}
+ /**
+ * Finds an approximate largest area triangle (of arbitrary orientation)
+ * contained within a polygon.
+ *
+ * @param shape a polygonal shape
+ * @return a triangular shape
+ * @since 2.1
+ */
+ public static PShape maximumInscribedTriangle(PShape shape) {
+ Polygon polygon = (Polygon) fromPShape(shape);
+ var mit = new MaximumInscribedTriangle(polygon);
+ return toPShape(mit.computeMIT());
+ }
+
/**
* Finds the rectangle with a maximum area whose sides are parallel to the
* x-axis and y-axis ("axis-aligned"), contained/insribed within a convex shape.
@@ -160,64 +274,143 @@ public static PShape maximumInscribedAARectangle(PShape shape, boolean fast) {
*
* The method does not respect holes (for now...).
*
- * @param shape
+ * @param shape a polygonal shape
* @param tolerance a value of 2-5 is usually suitable
* @return shape representing the maximum square
* @since 1.4.0
*/
public static PShape maximumPerimeterSquare(PShape shape, double tolerance) {
- shape = PGS_Morphology.simplify(shape, tolerance / 2);
- final Polygon p = (Polygon) PGS_Conversion.fromPShape(shape);
- Geometry buffer = p.getExteriorRing().buffer(tolerance / 2, 4);
- final Envelope e = buffer.getEnvelopeInternal();
- buffer = DouglasPeuckerSimplifier.simplify(buffer, tolerance / 2);
- final IndexedPointInAreaLocator pia = new IndexedPointInAreaLocator(buffer);
- shape = PGS_Processing.densify(shape, Math.max(0.5, tolerance)); // min of 0.5
- final List points = PGS_Conversion.toPVector(shape);
-
- double maxDiagonal = 0;
- PVector[] maxAreaVertices = new PVector[0];
- for (final PVector a : points) {
- for (final PVector b : points) {
- double dist = PGS.distanceSq(a, b);
-
- if (dist < maxDiagonal) {
+ shape = PGS_Morphology.simplify(shape, tolerance * 0.5);
+ Polygon p = (Polygon) PGS_Conversion.fromPShape(shape);
+ Geometry buffer = p.getExteriorRing().buffer(tolerance * 0.5, 4);
+ Envelope env = buffer.getEnvelopeInternal();
+ buffer = DouglasPeuckerSimplifier.simplify(buffer, tolerance * 0.5);
+ var index = new YStripesPointInAreaLocator((Polygon) buffer);
+
+ shape = PGS_Processing.densify(shape, Math.max(0.5, tolerance));
+ List points = PGS_Conversion.toPVector(shape);
+
+ // copy into primitive arrays & compute point‐set AABB
+ int n = points.size();
+ double[] xs = new double[n], ys = new double[n];
+ double ptsMinX = Double.POSITIVE_INFINITY, ptsMaxX = Double.NEGATIVE_INFINITY;
+ double ptsMinY = Double.POSITIVE_INFINITY, ptsMaxY = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < n; i++) {
+ PVector v = points.get(i);
+ xs[i] = v.x;
+ ys[i] = v.y;
+ if (v.x < ptsMinX) {
+ ptsMinX = v.x;
+ }
+ if (v.x > ptsMaxX) {
+ ptsMaxX = v.x;
+ }
+ if (v.y < ptsMinY) {
+ ptsMinY = v.y;
+ }
+ if (v.y > ptsMaxY) {
+ ptsMaxY = v.y;
+ }
+ }
+
+ // 4) Prepare for the double loop
+ double envMinX = env.getMinX(), envMaxX = env.getMaxX();
+ double envMinY = env.getMinY(), envMaxY = env.getMaxY();
+
+ double maxDiag = 0;
+ int bestI = -1, bestJ = -1;
+ double bestCx = 0, bestCy = 0, bestDx = 0, bestDy = 0;
+ Coordinate cCoord = new Coordinate(), dCoord = new Coordinate();
+
+ // 5) Search for the largest‐diagonal pair i,j
+ for (int i = 0; i < n; i++) {
+ double ax = xs[i], ay = ys[i];
+ // quick global prune: maximum possible sq‐dist from this A to any point
+ double dxA = Math.max(ax - ptsMinX, ptsMaxX - ax);
+ double dyA = Math.max(ay - ptsMinY, ptsMaxY - ay);
+ if ((dxA * dxA + dyA * dyA) <= maxDiag) {
+ continue;
+ }
+
+ for (int j = i + 1; j < n; j++) {
+ double bx = xs[j], by = ys[j];
+ double dx = bx - ax, dy = by - ay;
+ double dist = dx * dx + dy * dy;
+ if (dist <= maxDiag) {
continue;
}
- final PVector m = PVector.add(a, b).div(2);
- final PVector n = new PVector(b.y - a.y, a.x - b.x).div(2);
- final PVector c = PVector.sub(m, n);
-
- final PVector d = PVector.add(m, n);
- // do envelope checks first -- slightly faster
- if (within(c, e) && within(d, e)) {
- if (pia.locate(new Coordinate(c.x, c.y)) != Location.EXTERIOR) {
- if (pia.locate(new Coordinate(d.x, d.y)) != Location.EXTERIOR) {
- maxDiagonal = dist;
- maxAreaVertices = new PVector[] { a, c, b, d, a }; // closed vertices
- }
- }
+ // midpoint
+ double mx = (ax + bx) * 0.5, my = (ay + by) * 0.5;
+ // half‐perp vector
+ double nx = dy * 0.5, ny = -dx * 0.5;
+ // other two corners
+ double cx = mx - nx, cy = my - ny;
+ double dx2 = mx + nx, dy2 = my + ny;
+
+ // envelope quick‐rejection
+ if (cx < envMinX || cx > envMaxX || cy < envMinY || cy > envMaxY) {
+ continue;
+ }
+ if (dx2 < envMinX || dx2 > envMaxX || dy2 < envMinY || dy2 > envMaxY) {
+ continue;
+ }
+
+ // expensive point‐in‐area tests, reusing coords
+ cCoord.x = cx;
+ cCoord.y = cy;
+ if (index.locate(cCoord) == Location.EXTERIOR) {
+ continue;
+ }
+ dCoord.x = dx2;
+ dCoord.y = dy2;
+ if (index.locate(dCoord) == Location.EXTERIOR) {
+ continue;
}
+
+ // success: record as new best
+ maxDiag = dist;
+ bestI = i;
+ bestJ = j;
+ bestCx = cx;
+ bestCy = cy;
+ bestDx = dx2;
+ bestDy = dy2;
}
}
- PShape out = PGS_Conversion.fromPVector(maxAreaVertices);
+ // 6) If we found none, return an empty shape
+ if (bestI < 0) {
+ return PGS_Conversion.fromPVector(new PVector[0]);
+ }
+
+ // 7) Build the closed‐square ring A→C→B→D→A
+ PVector A = new PVector((float) xs[bestI], (float) ys[bestI]);
+ PVector B = new PVector((float) xs[bestJ], (float) ys[bestJ]);
+ PVector C = new PVector((float) bestCx, (float) bestCy);
+ PVector D = new PVector((float) bestDx, (float) bestDy);
+ PVector[] ring = { A, C, B, D, A };
+
+ // 8) Convert back to PShape, style, and return
+ PShape out = PGS_Conversion.fromPVector(ring);
out.setStroke(true);
out.setStroke(micycle.pgs.color.Colors.PINK);
out.setStrokeWeight(4);
return out;
}
- private static boolean within(PVector p, Envelope rect) {
- return p.x >= rect.getMinX() && p.x <= rect.getMaxX() && p.y <= rect.getMaxY() && p.y >= rect.getMinY();
- }
-
/**
- * Computes the Minimum Bounding Circle (MBC) for the points in a Geometry. The
- * MBC is the smallest circle which covers all the vertices of the input shape
- * (this is also known as the Smallest Enclosing Circle). This is equivalent to
- * computing the Maximum Diameter of the input vertex set.
+ * Computes the minimum bounding circle (MBC) that encloses all vertices
+ * of the provided shape.
+ *
+ * The minimum bounding circle is the smallest circle that contains all vertices
+ * of the input shape.
+ *
+ * @param shape the input {@link PShape}; all its vertices will be considered
+ * for the bounding circle computation
+ * @return a {@link PShape} object representing the minimum bounding circle that
+ * encloses all vertices of the input shape
+ * @see #minimumBoundingCircle(PShape, PVector)
*/
public static PShape minimumBoundingCircle(PShape shape) {
MinimumBoundingCircle mbc = new MinimumBoundingCircle(fromPShape(shape));
@@ -226,6 +419,37 @@ public static PShape minimumBoundingCircle(PShape shape) {
return toPShape(circle);
}
+ /**
+ * Computes the minimum bounding circle (MBC) for the given shape, and
+ * optionally returns the center and radius.
+ *
+ * The minimum bounding circle is the smallest circle that contains all vertices
+ * of the input shape. If the provided {@code result} vector is non-null, its
+ * {@code x} and {@code y} fields will be set to the coordinates of the circle's
+ * center, and its {@code z} field will be set to the circle's radius.
+ *
+ * This method returns a new {@link PShape} representing the computed circle.
+ *
+ * @param shape the shape whose vertices will be used to compute the minimum
+ * bounding circle
+ * @param result a {@link PVector}; if non-null, receives the center as (x, y)
+ * and radius as (z)
+ * @return a {@link PShape} representing the minimum bounding circle enclosing
+ * all vertices of the input shape
+ * @see #minimumBoundingCircle(PShape)
+ * @since 2.1
+ */
+ public static PShape minimumBoundingCircle(PShape shape, @Nullable PVector result) {
+ MinimumBoundingCircle mbc = new MinimumBoundingCircle(fromPShape(shape));
+ final double r = mbc.getRadius();
+ var c = mbc.getCentre();
+ Polygon circle = createCircle(c, r);
+ if (result != null) {
+ result.set((float) c.getX(), (float) c.getY(), (float) r);
+ }
+ return toPShape(circle);
+ }
+
/**
* Computes the minimum-width bounding rectangle that encloses a shape. Unlike
* the envelope for a shape, the rectangle returned by this method can have any
@@ -266,7 +490,6 @@ public static PShape minimumAreaRectangle(PShape shape) {
* correspond to a pixel distance). 0.001 to 0.01
* recommended. Higher values are a looser (yet quicker)
* fit.
- * @return
*/
public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance) {
final Geometry hull = fromPShape(shape).convexHull();
@@ -297,7 +520,6 @@ public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance)
* Computes the minimum-area bounding triangle that encloses a shape.
*
* @param shape
- * @return
*/
public static PShape minimumBoundingTriangle(PShape shape) {
MinimumBoundingTriangle mbt = new MinimumBoundingTriangle(fromPShape(shape));
@@ -313,13 +535,91 @@ public static PShape minimumBoundingTriangle(PShape shape) {
* can be moved through, with a single rotation.
*
* @param shape
- * @return
*/
public static PShape minimumDiameter(PShape shape) {
LineString md = (LineString) MinimumDiameter.getMinimumDiameter(fromPShape(shape));
return toPShape(md);
}
+ /**
+ * Computes the minimum-width annulus (the donut-like region between two
+ * concentric circles with minimal width that encloses the vertices of the given
+ * {@link PShape}).
+ *
+ * The annulus is defined as the region between two concentric circles (with
+ * computed center and radii), such that all vertices of the input shape lie
+ * between the inner and outer circle, and the distance between these circles
+ * (the "width" of the annulus) is minimized.
+ *
+ * The algorithm considers only the vertices of the input shape (not the
+ * filled area or edges) as points to be enclosed.
+ *
+ * @param shape the {@link PShape} whose vertices are to be enclosed by
+ * the minimum-width annulus; must be non-null and contain at least
+ * three points
+ * @return a {@link PShape} representing the minimum-width annulus (as a ring
+ * shape)
+ * @since 2.1
+ */
+ public static PShape minimumWidthAnnulus(PShape shape) {
+ var points = PGS_Conversion.toPVector(shape);
+ var d = PGS_ShapePredicates.diameter(shape);
+ var convexHull = PGS_Conversion.toPVector(PGS_Hull.convexHull(points));
+ var tree = PGS.makeKdtree(points);
+
+ var bounds = new double[4];
+ PGS_Hull.boundingBox(shape, bounds); // write to bounds
+ bounds[0] -= d;
+ bounds[1] -= d;
+ bounds[2] += d;
+ bounds[3] += d;
+
+ var vd = PGS_Voronoi.innerVoronoi(points, bounds);
+ var fpvd = PGS_Voronoi.farthestPointVoronoi(points);
+
+ /*
+ * NOTE here we find inner edges/vertices in a generic way without geometric
+ * shortcuts given by VD/FPVD geometry (i.e. we know the circumcircle of each
+ * FPVD vertex -- its circumradius gives us outerR immediately).
+ */
+ var a = PGS_Meshing.extractInnerEdgesAndVertices(vd);
+ var b = PGS_Meshing.extractInnerEdgesAndVertices(fpvd);
+ var vdVertices = a.getRight();
+ var vdEdges = a.getLeft();
+ var fpvdVertices = b.getRight();
+ var fpvdEdges = b.getLeft();
+
+ var overlayVerices = PGS_SegmentSet.intersections(vdEdges, fpvdEdges);
+
+ /*
+ * Candidate centers for the smallest-width annulus have 3 sources. For each
+ * candidate we find the distance both to the closest and farthest vertex. The
+ * difference between these distances is the annulus width; we select the
+ * candidate having the smallest width.
+ */
+ /*-
+ * The 3 centerpoint sources are:
+ * Vertices of the FPVD
+ * Vertices of the VD
+ * Vertices from the intersection between edges of VD and FPVD
+ */
+ var candidates = new ArrayList();
+ candidates.addAll(vdVertices);
+ candidates.addAll(fpvdVertices);
+ candidates.addAll(overlayVerices);
+
+ var result = candidates.parallelStream().map(v -> {
+ // farthest point must lie on convex hull
+ var far = PGS_Optimisation.farthestPoint(convexHull, v);
+ var close = tree.query1nn(new double[] { v.x, v.y });
+ double outerR = far.dist(v);
+ double innerR = close.dist();
+ return Triple.of(v, outerR, innerR);
+ }).min(Comparator.comparingDouble(t -> t.getMiddle() - t.getRight())).get();
+
+ return PGS_Construction.createRing(result.getLeft().x, result.getLeft().y, result.getMiddle(), result.getRight());
+ }
+
/**
* Computes the largest empty circle that does not intersect any obstacles (up
* to a specified tolerance).
@@ -561,13 +861,51 @@ public static PShape binPack(List shapes, double binWidth, double binHei
}
/**
- * Returns the nearest point of the shape to the given point. If the shape is
- * has multiple children/geometries (a GROUP shape), the single closest point is
- * returned.
- *
- * @param shape
- * @param point
- * @return
+ * Returns the closest vertex of a shape to a query point. For GROUP shapes, any
+ * child geometry's vertex may be returned.
+ *
+ * @param shape the PShape to search for the closest vertex
+ * @param queryPoint the query PVector
+ * @return a new PVector at the position of the closest vertex (not a reference
+ * to existing shape data)
+ * @since 2.1
+ */
+ public static PVector closestVertex(PShape shape, PVector queryPoint) {
+ List vertices = PGS_Conversion.toPVector(shape);
+ if (vertices.isEmpty()) {
+ return null;
+ }
+ float minDistSq = Float.POSITIVE_INFINITY;
+ PVector closest = null;
+ for (PVector v : vertices) {
+ float distSq = PVector.dist(v, queryPoint);
+ if (distSq < minDistSq) {
+ minDistSq = distSq;
+ closest = v;
+ }
+ }
+ return closest;
+ }
+
+ /**
+ * Returns the nearest point along the edges of the given shape to the specified
+ * query point.
+ *
+ * This method computes the point on the perimeter (including all edges, not
+ * only the vertices) of the given shape that is closest to the given
+ * {@code point}. For composite shapes (such as GROUP shapes made of multiple
+ * child geometries), the single closest point across all children is returned.
+ *
+ *
+ * Note: The nearest location may be somewhere along an edge of
+ * the shape, not necessarily at one of the original shape's vertices.
+ *
+ *
+ * @param shape the {@code PShape} to search for the closest boundary point.
+ * @param point the {@code PVector} point to which the nearest point is sought.
+ * @return a new {@code PVector} representing the exact coordinates of the
+ * closest point on the shape's boundary or edge (not a reference to the
+ * original coordinate).
* @see #closestPoints(PShape, PVector)
*/
public static PVector closestPoint(PShape shape, PVector point) {
@@ -576,6 +914,35 @@ public static PVector closestPoint(PShape shape, PVector point) {
return new PVector((float) coord.x, (float) coord.y);
}
+ /**
+ * Finds the closest point in the collection to a specified point.
+ *
+ * @param points the collection of points to search within
+ * @param point the point to find the closest neighbor for
+ * @return the closest point from the collection to the specified point
+ * @since 2.1
+ */
+ public static PVector closestPoint(Collection points, PVector point) {
+ if (points == null || points.isEmpty()) {
+ return null; // Handle empty or null collection
+ }
+
+ PVector closest = null;
+ float minDistanceSq = Float.MAX_VALUE;
+
+ for (PVector p : points) {
+ float dx = p.x - point.x;
+ float dy = p.y - point.y;
+ float distanceSq = dx * dx + dy * dy;
+ if (distanceSq < minDistanceSq) {
+ minDistanceSq = distanceSq;
+ closest = p;
+ }
+ }
+
+ return closest;
+ }
+
/**
* Returns the nearest point for each "island" / separate polygon in the GROUP
* input shape.
@@ -612,6 +979,62 @@ public static List closestPointPair(Collection points) {
return closestPointPair.execute();
}
+ /**
+ * Returns the farthest vertex of a shape from a query point. For GROUP shapes,
+ * any child geometry's vertex may be returned.
+ *
+ * @param shape the PShape to search for the farthest vertex
+ * @param queryPoint the query PVector
+ * @return a new PVector at the position of the farthest vertex (not a reference
+ * to existing shape data)
+ * @since 2.1
+ */
+ public static PVector farthestVertex(PShape shape, PVector queryPoint) {
+ List vertices = PGS_Conversion.toPVector(shape);
+ if (vertices.isEmpty()) {
+ return null;
+ }
+ float maxDistSq = Float.NEGATIVE_INFINITY;
+ PVector farthest = null;
+ for (PVector v : vertices) {
+ float distSq = PVector.dist(v, queryPoint);
+ if (distSq > maxDistSq) {
+ maxDistSq = distSq;
+ farthest = v;
+ }
+ }
+ return farthest;
+ }
+
+ /**
+ * Finds the farthest point in the collection from a specified point.
+ *
+ * @param points the collection of points to search within
+ * @param point the point from which the farthest neighbor is sought
+ * @return the farthest point from the collection to the specified point
+ * @since 2.1
+ */
+ public static PVector farthestPoint(Collection points, PVector point) {
+ if (points == null || points.isEmpty()) {
+ return null; // Handle empty or null collection
+ }
+
+ PVector farthest = null;
+ float maxDistanceSq = Float.NEGATIVE_INFINITY;
+
+ for (PVector p : points) {
+ float dx = p.x - point.x;
+ float dy = p.y - point.y;
+ float distanceSq = dx * dx + dy * dy;
+ if (distanceSq > maxDistanceSq) {
+ maxDistanceSq = distanceSq;
+ farthest = p;
+ }
+ }
+
+ return farthest;
+ }
+
/**
* Computes the farthest pair of points (the "diametral pair") in a set of n
* points.
@@ -657,6 +1080,130 @@ public static PShape hilbertSortFaces(PShape mesh) {
return PGS_Conversion.flatten(PGS_PointSet.hilbertSort(points).stream().map(map::get).collect(Collectors.toList()));
}
+ /**
+ * Reorders the faces of a mesh into an anti-clockwise “spiral” (breadth-first
+ * rings) starting from a given face, then returns a new, flattened PShape
+ * containing exactly those faces in spiral order.
+ *
+ * @param mesh mesh-like GROUP PShape
+ * @param startFace One of the child‐faces of {@code mesh}. This face will
+ * appear first in the returned ordering; subsequent faces
+ * follow in concentric breadth‐first “rings” around it, sorted
+ * anti-clockwise.
+ * @return A new, flattened PShape whose set of faces equals the children of
+ * {@code mesh}, but ordered in a spiral starting at
+ * {@code startingFace}.
+ * @since 2.1
+ */
+ public static PShape spiralSortFaces(PShape mesh, PShape startFace) {
+ var faces = SpiralIterator.spiral(startFace, PGS_Conversion.getChildren(mesh));
+ return PGS_Conversion.flatten(faces);
+ }
+
+ /**
+ * Returns a new, flattened PShape containing the child faces of {@code mesh}
+ * sorted by the x and then y coordinates of their centroids.
+ *
+ * This is commonly used for ordering the faces of a mesh spatially in a
+ * grid-like manner, first by increasing x-coordinate of the face centroids, and
+ * breaking ties using y-coordinate.
+ *
+ *
+ * @param mesh a mesh-like GROUP {@link PShape} whose children (faces) will be
+ * sorted by centroid
+ * @return a new, flattened {@code PShape} whose faces are sorted by the
+ * centroids’ x and y coordinates
+ * @since 2.1
+ */
+ public static PShape centroidSortFaces(PShape mesh) {
+ Map map = new HashMap<>(mesh.getChildCount());
+
+ PGS_Conversion.getChildren(mesh).forEach(child -> {
+ PVector centroid = PGS_ShapePredicates.boundsCenter(child);
+ map.put(centroid, child);
+ });
+
+ List centroids = new ArrayList<>(map.keySet());
+
+ centroids.sort((p1, p2) -> {
+ if (p1.x != p2.x) {
+ return Float.compare(p1.x, p2.x);
+ } else {
+ return Float.compare(p1.y, p2.y);
+ }
+ });
+
+ List sortedShapes = centroids.stream().map(map::get).toList();
+ return PGS_Conversion.flatten(sortedShapes);
+ }
+
+ /**
+ * Sorts the faces (children) of a mesh radially around a given centre, then
+ * applies a circular rotation to the order based on the offset parameter.
+ *
+ * @param mesh a PShape with polygonal children (the faces to order)
+ * @param centre the point around which radial sorting is performed (usually the
+ * mesh centroid)
+ * @param offset a fractional value in [0, 1) that specifies where the ordering
+ * starts around the centre, as a proportion of a full 360° turn:
+ *
+ * - offset = 0.0: face whose centroid points directly to the
+ * right (positive X direction) comes first
+ * - offset = 0.25: ordering is rotated 90° counterclockwise;
+ * face pointing “up” (positive Y) comes first
+ * - offset = 0.5: ordering is rotated 180°; face pointing to
+ * the left (-X) comes first
+ * - offset = 0.75: ordering is rotated 270°; face pointing
+ * “down” (-Y) comes first
+ *
+ * Intermediate values smoothly rotate the sequence; outside [0,1)
+ * values wrap around. In other words, this lets you “spin” the
+ * order of faces by a fraction of a circle.
+ * @return a new PShape, with faces flattened into one shape, ordered so that
+ * face[0] begins at offset·360° from the right and continues clockwise
+ */
+ public static PShape radialSortFaces(final PShape mesh, final PVector centre, final double offset) {
+ record FaceInfo(PShape face, double angle, double dist2) {
+ }
+ List faces = PGS_Conversion.getChildren(mesh);
+ List infoList = new ArrayList<>(faces.size());
+
+ double TWO_PI = Math.PI * 2;
+ // force offset into [0,1)
+ double off = ((offset % 1) + 1) % 1;
+ double rot = off * TWO_PI; // convert to radians
+
+ for (PShape f : faces) {
+ // centroid of this face
+ PVector c = PGS_ShapePredicates.centroid(f);
+ double dx = c.x - centre.x;
+ double dy = c.y - centre.y;
+
+ // raw angle in [–π,+π]
+ double a = FastAtan2.atan2(dy, dx);
+ // remap to [0,2π)
+ if (a < 0) {
+ a += TWO_PI;
+ }
+
+ // now apply offset‐rotation and re‐wrap to [0,2π)
+ a = a - rot;
+ if (a < 0) {
+ a += TWO_PI;
+ }
+
+ double d2 = dx * dx + dy * dy;
+ infoList.add(new FaceInfo(f, a, d2));
+ }
+
+ // Sort by our shifted angle, then (optionally) by distance²
+ infoList.sort(Comparator.comparingDouble((FaceInfo fi) -> fi.angle).thenComparingDouble(fi -> fi.dist2));
+
+ List sorted = infoList.stream().map(fi -> fi.face).toList();
+
+ return PGS_Conversion.flatten(sorted);
+ }
+
/**
* Solves the Problem of Apollonius (finding a circle tangent to three other
* circles in the plane). Circles are represented by PVectors, where the z
diff --git a/src/main/java/micycle/pgs/PGS_PointSet.java b/src/main/java/micycle/pgs/PGS_PointSet.java
index f5a4d869..1df51711 100644
--- a/src/main/java/micycle/pgs/PGS_PointSet.java
+++ b/src/main/java/micycle/pgs/PGS_PointSet.java
@@ -18,11 +18,8 @@
import org.apache.commons.math3.random.RandomGenerator;
import org.apache.commons.math3.util.FastMath;
import org.apache.commons.math3.util.Pair;
-import org.jgrapht.alg.interfaces.HamiltonianCycleAlgorithm;
import org.jgrapht.alg.interfaces.SpanningTreeAlgorithm;
import org.jgrapht.alg.spanning.PrimMinimumSpanningTree;
-import org.jgrapht.alg.tour.FarthestInsertionHeuristicTSP;
-import org.jgrapht.alg.tour.TwoOptHeuristicTSP;
import org.jgrapht.graph.SimpleGraph;
import org.tinfour.common.IIncrementalTin;
import org.tinfour.common.Vertex;
@@ -33,6 +30,7 @@
import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator;
import micycle.pgs.commons.GeometricMedian;
+import micycle.pgs.commons.GreedyTSP;
import micycle.pgs.commons.PEdge;
import micycle.pgs.commons.PoissonDistributionJRUS;
import micycle.pgs.commons.ThomasPointProcess;
@@ -43,9 +41,27 @@
* Generation of random sets of 2D points having a variety of different
* distributions and constraints (and associated functions).
*
+ *
+ * Note on Floating-Point Values and Collisions:
+ *
+ *
+ * When generating many random points, collisions in coordinate values are
+ * expected due to the limited precision of floating-point numbers. For example:
+ *
+ * - A {@code float} has ~24 bits of precision (23 explicit mantissa bits + 1
+ * implicit leading bit), which allows for ~16.8 million unique values in the
+ * range [0, 1).
+ * - When generating a large number of points (e.g., 100,000), the probability
+ * of collisions follows the birthday paradox formula:
n² / (2m),
+ * where n is the number of samples and m is the
+ * number of possible unique values.
+ * - For 100,000 points, this results in ~300 expected collisions in the
+ * x-coordinate, even when using a high-quality random number generator.
+ *
+ *
+ *
* @author Michael Carleton
* @since 1.2.0
- *
*/
public final class PGS_PointSet {
@@ -122,6 +138,132 @@ public static List pruneSparsePoints(Collection points, double
.map(entry -> new PVector((float) entry.getKey().getX(), (float) entry.getKey().getY())).toList();
}
+ /**
+ * Remove exactly removeCount points chosen uniformly at random. The returned
+ * list contains the remaining points in the original iteration order.
+ *
+ * @param points collection of PVector points
+ * @param removeCount number of points to remove (must be >= 0)
+ * @return new List containing the remaining points
+ * @since 2.1
+ */
+ public static List pruneRandomRemoveN(Collection points, int removeCount) {
+ return pruneRandomRemoveN(points, removeCount, System.nanoTime());
+ }
+
+ /**
+ * Remove exactly removeCount points chosen uniformly at random, using the
+ * provided seed.
+ *
+ * @param points collection of PVector points
+ * @param removeCount number of points to remove (must be >= 0)
+ * @param seed RNG seed for reproducibility
+ * @return new List containing the remaining points
+ * @since 2.1
+ */
+ public static List pruneRandomRemoveN(Collection points, int removeCount, long seed) {
+ if (removeCount < 0) {
+ throw new IllegalArgumentException("removeCount must be non-negative");
+ }
+ List list = new ArrayList<>(points);
+ final int size = list.size();
+ if (removeCount <= 0) {
+ return new ArrayList<>(list);
+ }
+ if (removeCount >= size) {
+ return new ArrayList<>(); // everything removed
+ }
+
+ // sample removeCount distinct indices using partial Fisher-Yates
+ int[] indices = new int[size];
+ for (int i = 0; i < size; i++) {
+ indices[i] = i;
+ }
+ RandomGenerator r = new XoRoShiRo128PlusRandomGenerator(seed);
+ for (int i = 0; i < removeCount; i++) {
+ int j = i + r.nextInt(size - i);
+ int tmp = indices[i];
+ indices[i] = indices[j];
+ indices[j] = tmp;
+ }
+
+ boolean[] remove = new boolean[size];
+ for (int k = 0; k < removeCount; k++) {
+ remove[indices[k]] = true;
+ }
+
+ List out = new ArrayList<>(size - removeCount);
+ for (int i = 0; i < size; i++) {
+ if (!remove[i]) {
+ out.add(list.get(i));
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Keep exactly keepCount points chosen uniformly at random. The returned list
+ * contains the kept points in the original iteration order.
+ *
+ * @param points collection of PVector points
+ * @param keepCount number of points to keep (must be >= 0)
+ * @return new List containing the kept points
+ * @since 2.1
+ */
+ public static List pruneRandomToN(Collection points, int keepCount) {
+ return pruneRandomToN(points, keepCount, System.nanoTime());
+ }
+
+ /**
+ * Keep exactly keepCount points chosen uniformly at random, using the provided
+ * seed.
+ *
+ * @param points collection of PVector points
+ * @param keepCount number of points to keep (must be >= 0)
+ * @param seed RNG seed for reproducibility
+ * @return new List containing the kept points
+ * @since 2.1
+ */
+ public static List pruneRandomToN(Collection points, int keepCount, long seed) {
+ if (keepCount < 0) {
+ throw new IllegalArgumentException("keepCount must be non-negative");
+ }
+ List list = new ArrayList<>(points);
+ final int size = list.size();
+ if (keepCount <= 0) {
+ return new ArrayList<>();
+ }
+ if (keepCount >= size) {
+ return new ArrayList<>(list);
+ }
+
+ // sample keepCount distinct indices using partial Fisher-Yates
+ int[] indices = new int[size];
+ for (int i = 0; i < size; i++) {
+ indices[i] = i;
+ }
+ RandomGenerator r = new XoRoShiRo128PlusRandomGenerator(seed);
+ for (int i = 0; i < keepCount; i++) {
+ int j = i + r.nextInt(size - i);
+ int tmp = indices[i];
+ indices[i] = indices[j];
+ indices[j] = tmp;
+ }
+
+ boolean[] keep = new boolean[size];
+ for (int k = 0; k < keepCount; k++) {
+ keep[indices[k]] = true;
+ }
+
+ List out = new ArrayList<>(keepCount);
+ for (int i = 0; i < size; i++) {
+ if (keep[i]) {
+ out.add(list.get(i));
+ }
+ }
+ return out;
+ }
+
/**
* Sorts a list of points according to the Hilbert space-filling curve to ensure
* a high-degree of spatial locality in the sequence of points.
@@ -239,8 +381,8 @@ public static List random(double xMin, double yMin, double xMax, double
final SplittableRandom random = new SplittableRandom(seed);
final List points = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
- final float x = (float) (xMin + (xMax - xMin) * random.nextDouble());
- final float y = (float) (yMin + (yMax - yMin) * random.nextDouble());
+ final float x = (float) random.nextDouble(xMin, xMax);
+ final float y = (float) random.nextDouble(yMin, yMax);
points.add(new PVector(x, y));
}
return points;
@@ -297,9 +439,8 @@ public static List gaussian(double centerX, double centerY, double sd,
*
* @param xMin x-coordinate of boundary minimum
* @param yMin y-coordinate of boundary minimum
- * @param xMax x-coordinate of boundary maximum
- * @param yMax y-coordinate of boundary maximum
- * @return
+ * @param xMax x-coordinate of boundary maximum (inclusive)
+ * @param yMax y-coordinate of boundary maximum (inclusive)
*/
public static List squareGrid(final double xMin, final double yMin, final double xMax, final double yMax, final double pointDistance) {
final double width = xMax - xMin;
@@ -307,8 +448,8 @@ public static List squareGrid(final double xMin, final double yMin, fin
final List points = new ArrayList<>();
- for (double x = 0; x < width; x += pointDistance) {
- for (double y = 0; y < height; y += pointDistance) {
+ for (double x = 0; x <= width; x += pointDistance) {
+ for (double y = 0; y <= height; y += pointDistance) {
points.add(new PVector((float) (x + xMin), (float) (y + yMin)));
}
}
@@ -956,16 +1097,12 @@ public static PShape minimumSpanningTree(List points) {
/**
* Computes an approximate Traveling Salesman path for the set of points
- * provided. Utilises a heuristic based TSP solver, starting with the farthest
- * insertion method followed by 2-opt heuristic improvements for tour
- * optimization.
- *
- * Note: The algorithm's runtime grows rapidly as the number of points
- * increases. Large datasets (>1000) may result in long computation times and
- * should be used with caution.
+ * provided. Utilises a heuristic based TSP solver, followed by 2-opt heuristic
+ * improvements for further tour optimisation.
*
* Note {@link PGS_Hull#concaveHullBFS(List, double) concaveHullBFS()} produces
- * a similar result (somewhat longer tours) but is much more performant.
+ * a similar result (somewhat longer tours, i.e. 10%) but is much more
+ * performant.
*
* @param points the list of points for which to compute the approximate
* shortest tour
@@ -975,14 +1112,8 @@ public static PShape minimumSpanningTree(List points) {
* @since 2.0
*/
public static PShape findShortestTour(List points) {
- HamiltonianCycleAlgorithm tsp = new FarthestInsertionHeuristicTSP<>();
- TwoOptHeuristicTSP tspImprover = new TwoOptHeuristicTSP<>();
-
- var graph = PGS.makeCompleteGraph(points);
- var tour = tsp.getTour(graph);
- tour = tspImprover.improveTour(tour);
-
- return PGS_Conversion.fromPVector(tour.getVertexList());
+ var tour = new GreedyTSP<>(points, (a, b) -> a.dist(b));
+ return PGS_Conversion.fromPVector(tour.getTour());
}
/**
diff --git a/src/main/java/micycle/pgs/PGS_Processing.java b/src/main/java/micycle/pgs/PGS_Processing.java
index 23021ab2..0042648c 100644
--- a/src/main/java/micycle/pgs/PGS_Processing.java
+++ b/src/main/java/micycle/pgs/PGS_Processing.java
@@ -15,18 +15,17 @@
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
-import java.util.SplittableRandom;
+import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
+import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
-import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.math3.ml.clustering.CentroidCluster;
import org.apache.commons.math3.ml.clustering.Clusterable;
import org.apache.commons.math3.ml.clustering.KMeansPlusPlusClusterer;
@@ -68,16 +67,13 @@
import org.locationtech.jts.operation.polygonize.Polygonizer;
import org.locationtech.jts.operation.union.UnaryUnionOp;
import org.locationtech.jts.shape.random.RandomPointsInGridBuilder;
-import org.tinfour.common.IConstraint;
-import org.tinfour.common.IIncrementalTin;
-import org.tinfour.common.PolygonConstraint;
-import org.tinfour.common.SimpleTriangle;
import org.tinfour.common.Vertex;
-import org.tinfour.utils.TriangleCollector;
import org.tinfour.voronoi.BoundedVoronoiBuildOptions;
import org.tinfour.voronoi.BoundedVoronoiDiagram;
-import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
+import com.github.micycle1.geoblitz.IndexedLengthIndexedLine;
+import com.github.micycle1.geoblitz.YStripesPointInAreaLocator;
+
import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator;
import micycle.balaban.BalabanSolver;
import micycle.balaban.Point;
@@ -86,6 +82,7 @@
import micycle.pgs.color.Colors;
import micycle.pgs.commons.PolygonDecomposition;
import micycle.pgs.commons.SeededRandomPointsInGridBuilder;
+import micycle.pgs.commons.ShapeRandomPointSampler;
import micycle.trapmap.TrapMap;
import processing.core.PConstants;
import processing.core.PShape;
@@ -141,12 +138,11 @@ public static PShape densify(PShape shape, double distanceTolerance) {
* point away from the shape (outwards); negative
* values offset the point inwards towards its
* interior.
- * @return
* @see #pointsOnExterior(PShape, int, double)
*/
public static PVector pointOnExterior(PShape shape, double perimeterPosition, double offsetDistance) {
perimeterPosition %= 1;
- LengthIndexedLine l = makeIndexedLine(shape);
+ var l = makeIndexedLine(shape);
Coordinate coord = l.extractPoint(perimeterPosition * l.getEndIndex(), offsetDistance);
return new PVector((float) coord.x, (float) coord.y);
@@ -168,11 +164,10 @@ public static PVector pointOnExterior(PShape shape, double perimeterPosition, do
* point away from the shape (outwards); negative
* values offset the point inwards towards its
* interior.
- * @return
* @since 1.4.0
*/
public static PVector pointOnExteriorByDistance(PShape shape, double perimeterDistance, double offsetDistance) {
- LengthIndexedLine l = makeIndexedLine(shape);
+ var l = makeIndexedLine(shape);
Coordinate coord = l.extractPoint(perimeterDistance % l.getEndIndex(), offsetDistance);
return new PVector((float) coord.x, (float) coord.y);
}
@@ -201,145 +196,216 @@ public static PVector pointOnExteriorByDistance(PShape shape, double perimeterDi
* @since 1.3.0
*/
public static List pointsOnExterior(PShape shape, int points, double offsetDistance) {
- // TODO another method that returns concave hull of returned points (when
- // offset)
- List polygons = PGS.extractPolygons(fromPShape(shape));
+ return pointsOnExterior(shape, points, offsetDistance, 0);
+ }
+
+ /**
+ * Extract a fixed number of evenly spaced samples from every linear component.
+ *
+ *
+ * Samples exactly {@code points} positions from each linear component (each
+ * LinearRing or LineString) found in {@code shape}. Sampling is done
+ * independently per component — i.e., {@code points} samples are produced for
+ * each ring or line. {@code startOffset} sets the fractional start position
+ * along each component (0..1). {@code offsetDistance} is applied perpendicular
+ * to the boundary when extracting each sample.
+ *
+ *
+ * Orientation is normalized per component (reversed if necessary) so offsets
+ * are applied consistently. The method only collects points and does not modify
+ * the input geometry.
+ *
+ * @param shape the input PShape containing rings or LineStrings to
+ * sample
+ * @param points number of samples to take from each linear component
+ * @param offsetDistance perpendicular offset applied to each sampled point
+ * @param startOffset fractional start position along each component (0..1)
+ * @return a list of PVector points sampled from every linear component; empty
+ * if none produce samples
+ * @since 1.3.0
+ */
+ public static List pointsOnExterior(PShape shape, int points, double offsetDistance, double startOffset) {
List coords = new ArrayList<>();
- polygons.forEach(polygon -> {
- PGS.extractLinearRings(polygon).forEach(ring -> {
- if (Orientation.isCCW(ring.getCoordinates())) {
- ring = ring.reverse();
- }
- final LengthIndexedLine l = new LengthIndexedLine(ring);
- final double increment = 1d / points;
- for (double distance = 0; distance < 1; distance += increment) {
- final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance);
- coords.add(PGS.toPVector(coord));
- }
- });
+ // normalise startOffset into [0,1)
+ double startNorm = startOffset % 1.0;
+
+ PGS.applyToLinealGeometries(shape, ring -> {
+ // Normalise orientation so sampling/offset direction is consistent
+ if (Orientation.isCCW(ring.getCoordinates())) {
+ ring = ring.reverse();
+ }
+ final var l = new IndexedLengthIndexedLine(ring);
+ if (l.getEndIndex() == 0) {
+ return ring; // skip zero-length components
+ }
+ final double increment = 1.0 / points;
+ double start = startNorm;
+ for (int i = 0; i < points; i++) {
+ double posFrac = (start + i * increment) % 1.0;
+ final Coordinate coord = l.extractPoint(posFrac * l.getEndIndex(), offsetDistance);
+ coords.add(PGS.toPVector(coord));
+ }
+ return ring; // we don't modify geometries here
});
return coords;
}
/**
- * Generates a list of evenly distributed points along the boundary of each ring
- * within a given polygonal shape, which may include its exterior and any
- * interior rings (holes).
+ * Sample points along every linear component of a shape.
+ *
*
- * This method is used to obtain a set of points that approximate the polygonal
- * shape's outline and interior boundaries, with the points spaced at
- * approximate equal intervals determined by the interPointDistance
- * parameter. It supports complex shapes with interior rings (holes) by
- * extracting points from all rings.
- *
- * @param shape The shape from which to generate the points. It
- * should be a polygonal shape.
- * @param interPointDistance The desired distance between consecutive points
- * along each ring's boundary. This controls the
- * spacing of points and the granularity of the
- * representation.
- * @param offsetDistance The offset distance perpendicular to each point on
- * the ring's boundary. Positive values offset points
- * outwards, while negative values bring them towards
- * the interior.
- * @return A list of PVector objects representing the points along the exterior
- * and interior boundaries of the shape.
- * @see #pointOnExterior(PShape, double, double)
- * @see #densify(PShape, double)
+ * Samples points independently for each lineal component (polygon exterior
+ * rings, interior rings/holes, and standalone LineStrings) found in
+ * {@code shape}. Each component is sampled along its length with approximately
+ * {@code interPointDistance} spacing. {@code offsetDistance} is applied
+ * perpendicular to the component when extracting each point.
+ *
+ *
+ * Orientation is normalised per component before sampling so offsets are
+ * applied consistently. Components with zero length (or that yield zero
+ * samples) are skipped. The method collects points only and does not modify the
+ * input geometry.
+ *
+ * @param shape the input PShape containing polygon rings or
+ * LineStrings to sample
+ * @param interPointDistance approximate spacing between consecutive samples on
+ * each linear component
+ * @param offsetDistance perpendicular offset applied to each sampled point
+ * @return a list of PVector points sampled from every linear component; empty
+ * if no samples are produced
+ * @see #applyToLinealGeometries(PShape, java.util.function.UnaryOperator)
* @since 1.3.0
*/
public static List pointsOnExterior(PShape shape, double interPointDistance, double offsetDistance) {
- List polygons = PGS.extractPolygons(fromPShape(shape));
List coords = new ArrayList<>();
- polygons.forEach(polygon -> {
- PGS.extractLinearRings(polygon).forEach(ring -> {
- if (Orientation.isCCW(ring.getCoordinates())) {
- ring = ring.reverse();
- }
- final LengthIndexedLine l = new LengthIndexedLine(ring);
- final int points = (int) Math.round(l.getEndIndex() / interPointDistance);
- final double increment = 1d / points;
- for (double distance = 0; distance < 1; distance += increment) {
- final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance);
- coords.add(PGS.toPVector(coord));
- }
-
- });
+ PGS.applyToLinealGeometries(shape, ring -> {
+ // Normalise orientation so sampling/offset direction is consistent
+ if (Orientation.isCCW(ring.getCoordinates())) {
+ ring = ring.reverse();
+ }
+ final var l = new IndexedLengthIndexedLine(ring);
+ if (l.getEndIndex() == 0) {
+ return ring;
+ }
+ final int points = (int) Math.round(l.getEndIndex() / interPointDistance);
+ final double increment = 1d / points;
+ for (double distance = 0; distance < 1; distance += increment) {
+ final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance);
+ coords.add(PGS.toPVector(coord));
+ }
+ return ring;
});
return coords;
}
/**
- * Extracts evenly spaced dashed line segments along the perimeter of a shape.
- * This method ensures that the segments are distributed uniformly along the
- * shape's boundary, with the possibility of adjusting the start position of the
- * first line based on an offset.
- *
- * @param shape The shape from which to extract the segments.
- * @param lineLength The length of each segment. Must be a positive
- * number.
- * @param interLineDistance The distance between the end of one segment and the
- * start of the next. Must be non-negative.
- * @param offset The starting position offset (around the perimeter
- * [0...1]) for the first line. Values > |1| loop
- * around the shape. Positive values indicate a
- * clockwise (CW) direction, and negative values
- * indicate a counter-clockwise (CCW) direction.
- * @return A GROUP PShape whose children are the extracted segments.
+ * Extracts evenly spaced dashed line segments along every boundary of a shape.
+ *
+ *
+ * For each linear component found in {@code shape} (each polygon perimeter or
+ * path) this method places repeated line segments along that component's
+ * length. Sampling is performed independently per component: exactly the same
+ * spacing/length rules are applied to each component, but counts and positions
+ * are computed from that component's own perimeter.
+ *
+ * @param shape input PShape containing polygons or paths (or a mix
+ * of both)
+ * @param lineLength desired length of each segment
+ * @param interLineDistance gap between consecutive segments along a component
+ * @param offset a fractional offset (a fraction of the component's
+ * perimeter) that sets where the first segment starts.
+ * Any real value is accepted and is normalised by
+ * modulo 1.0 (wrapped into [0,1)). Increasing offset
+ * values move the start point clockwise around the
+ * perimeter, while decreasing or negative values move
+ * it counter-clockwise. Varying {@code offset} over
+ * time will effectively "animate" the segments around
+ * the component.
+ * @return if the input contains only one linear element (i.e. a holeless
+ * polygon), a GROUP shape of segments; otherwise a GROUP PShape whose
+ * children are GROUPs of segment PShapes (one child group per linear
+ * component).
* @since 2.0
*/
public static PShape segmentsOnExterior(PShape shape, double lineLength, double interLineDistance, double offset) {
- LengthIndexedLine l = makeIndexedLine(shape);
- lineLength = Math.max(lineLength, 0.5);
- interLineDistance = Math.max(interLineDistance, 0); // ensure >= 0
-
- final double perimeter = l.getEndIndex();
-
- double totalSegmentLength = lineLength + interLineDistance;
- int numberOfLines = (int) Math.floor(perimeter / totalSegmentLength);
-
- double adjustmentFactor = perimeter / (numberOfLines * totalSegmentLength);
- lineLength *= adjustmentFactor;
- interLineDistance *= adjustmentFactor;
- totalSegmentLength = lineLength + interLineDistance;
-
- offset = -offset; // positive values should wrap CW
- double startingPosition = ((offset % 1.0) + 1.0) % 1.0 * perimeter;
-
- List lines = new ArrayList<>(numberOfLines);
-
- for (int i = 0; i < numberOfLines; i++) {
- double lineStart = (startingPosition + i * totalSegmentLength) % perimeter;
- double lineEnd = (lineStart + lineLength) % perimeter;
-
- Geometry segment;
- PShape line;
- if (lineStart < lineEnd) {
- segment = l.extractLine(lineStart, lineEnd);
- line = PGS_Conversion.toPShape(segment);
- line.setName(String.valueOf(lineStart));
- lines.add(line);
- } else {
- // Handle case where line wraps around the end of the shape (straddles 0)
- // combine 2 segments into a single linestring
- Coordinate[] c1 = l.extractLine(lineStart, perimeter).getCoordinates();
- Coordinate[] c2 = l.extractLine(0, lineEnd).getCoordinates();
- segment = PGS.GEOM_FACTORY.createLineString(ArrayUtils.addAll(c1, c2));
- line = PGS_Conversion.toPShape(segment);
- line.setName(String.valueOf(lineStart));
- lines.add(line);
+ // Normalise parameters
+ double lineLengthFinal = Math.max(lineLength, 0.1);
+ double interLineDistanceFinal = Math.max(interLineDistance, 0.0);
+
+ PShape topGroup = new PShape(PConstants.GROUP);
+
+ // Process every linear component independently
+ PGS.applyToLinealGeometries(shape, ring -> {
+ // Normalise orientation so offsets are consistent
+ if (Orientation.isCCW(ring.getCoordinates())) {
+ ring = ring.reverse();
+ }
+
+ LengthIndexedLine l = new LengthIndexedLine(ring);
+ double perimeter = l.getEndIndex();
+ if (perimeter <= 0) {
+ return ring; // skip empty components
}
+
+ double totalSegmentLength = lineLengthFinal + interLineDistanceFinal;
+ int numberOfLines = (int) Math.floor(perimeter / totalSegmentLength);
+ if (numberOfLines <= 0) {
+ numberOfLines = 1; // ensure at least one placement to avoid division by zero
+ }
+
+ // Adjust lengths so segments + gaps tile the perimeter evenly
+ double adjustmentFactor = perimeter / (numberOfLines * totalSegmentLength);
+ double adjLineLength = lineLengthFinal * adjustmentFactor;
+ double adjInterLineDistance = interLineDistanceFinal * adjustmentFactor;
+ totalSegmentLength = adjLineLength + adjInterLineDistance;
+
+ // offset convention: positive values should wrap CW
+ double startingPosition = (((-offset) % 1.0) + 1.0) % 1.0 * perimeter;
+
+ PShape compGroup = new PShape(PConstants.GROUP);
+ for (int i = 0; i < numberOfLines; i++) {
+ double lineStart = (startingPosition + i * totalSegmentLength) % perimeter;
+ double lineEnd = (lineStart + adjLineLength) % perimeter;
+
+ Geometry segmentGeom;
+ if (lineStart < lineEnd) {
+ segmentGeom = l.extractLine(lineStart, lineEnd);
+ } else {
+ // Wrap-around case, combine two pieces
+ Coordinate[] c1 = l.extractLine(lineStart, perimeter).getCoordinates();
+ Coordinate[] c2 = l.extractLine(0, lineEnd).getCoordinates();
+ Coordinate[] combined = new Coordinate[c1.length + c2.length];
+ System.arraycopy(c1, 0, combined, 0, c1.length);
+ System.arraycopy(c2, 0, combined, c1.length, c2.length);
+ segmentGeom = PGS.GEOM_FACTORY.createLineString(combined);
+ }
+
+ PShape segShape = PGS_Conversion.toPShape(segmentGeom);
+ segShape.setName(String.format("i=%s@%s", i, lineStart));
+ compGroup.addChild(segShape);
+ }
+
+ if (!PGS.isEmptyShape(compGroup)) {
+ topGroup.addChild(compGroup);
+ }
+
+ return ring;
+ });
+
+ if (topGroup.getChildCount() == 1) {
+ return topGroup.getChild(0);
}
- return PGS_Conversion.flatten(lines);
+ return topGroup;
}
/**
- * Creates an CW-oriented length-indexed line from a given PShape.
+ * Creates an CW-oriented length-indexed line from a given PShape. NOTE extracts
+ * first ring from multipolygon
*/
- private static LengthIndexedLine makeIndexedLine(PShape shape) {
+ private static IndexedLengthIndexedLine makeIndexedLine(PShape shape) {
Geometry g = fromPShape(shape);
if (g instanceof Polygonal) {
if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON)) {
@@ -347,11 +413,11 @@ private static LengthIndexedLine makeIndexedLine(PShape shape) {
}
LinearRing e = ((Polygon) g).getExteriorRing();
if (Orientation.isCCW(e.getCoordinates())) {
- e = e.reverse();
+ e = (LinearRing) e.copy().reverse();
}
g = e;
}
- return new LengthIndexedLine(g);
+ return new IndexedLengthIndexedLine(g);
}
/**
@@ -452,8 +518,8 @@ public static double tangentAngle(PShape shape, double perimeterRatio) {
}
/**
- * Computes all points of intersection between the perimeters of
- * two shapes.
+ * Computes all points of intersection between the linework of two
+ * shapes.
*
* NOTE: This method shouldn't be confused with
* {@link micycle.pgs.PGS_ShapeBoolean#intersect(PShape, PShape)
@@ -465,13 +531,30 @@ public static double tangentAngle(PShape shape, double perimeterRatio) {
* @return list of all intersecting points (as PVectors)
*/
public static List shapeIntersection(PShape a, PShape b) {
- final HashSet points = new HashSet<>();
+ final Collection> segmentStringsA = SegmentStringUtil.extractSegmentStrings(fromPShape(a));
+ final Collection> segmentStringsB = SegmentStringUtil.extractSegmentStrings(fromPShape(b));
+
+ return intersections(segmentStringsA, segmentStringsB);
+ }
+
+ static List intersections(Collection> segmentStringsA, Collection> segmentStringsB) {
+ final Collection> larger, smaller;
+ if (segmentStringsA.size() > segmentStringsB.size()) {
+ larger = segmentStringsA;
+ smaller = segmentStringsB;
+ } else {
+ larger = segmentStringsB;
+ smaller = segmentStringsA;
- final Collection> segmentStrings = SegmentStringUtil.extractSegmentStrings(fromPShape(a));
- final MCIndexSegmentSetMutualIntersector mci = new MCIndexSegmentSetMutualIntersector(segmentStrings);
+ }
+
+ final Set points = new HashSet<>();
+ // finds possibly overlapping bounding boxes
+ final MCIndexSegmentSetMutualIntersector mci = new MCIndexSegmentSetMutualIntersector(larger);
+ // checks if two segments actually intersect
final SegmentIntersectionDetector sid = new SegmentIntersectionDetector();
- mci.process(SegmentStringUtil.extractSegmentStrings(fromPShape(b)), new SegmentIntersector() {
+ mci.process(smaller, new SegmentIntersector() {
@Override
public void processIntersections(SegmentString e0, int segIndex0, SegmentString e1, int segIndex1) {
sid.processIntersections(e0, segIndex0, e1, segIndex1);
@@ -535,7 +618,6 @@ public static List lineSegmentsIntersection(List lineSegments)
*
* @param shape defines the region in which random points are generated
* @param points number of points to generate within the shape region
- * @return
* @see #generateRandomPoints(PShape, int, long)
* @see #generateRandomGridPoints(PShape, int, boolean, double)
*/
@@ -559,77 +641,13 @@ public static List generateRandomPoints(PShape shape, int points) {
* @param points number of points to generate within the shape region
* @param seed number used to initialize the underlying pseudorandom number
* generator
- * @return
* @since 1.1.0
* @see #generateRandomPoints(PShape, int)
* @see #generateRandomGridPoints(PShape, int, boolean, double)
*/
public static List generateRandomPoints(PShape shape, int points, long seed) {
- final ArrayList randomPoints = new ArrayList<>(points); // random points out
-
- final IIncrementalTin tin = PGS_Triangulation.delaunayTriangulationMesh(shape);
- final boolean constrained = !tin.getConstraints().isEmpty();
- final double totalArea = StreamSupport.stream(tin.getConstraints().spliterator(), false).mapToDouble(c -> ((PolygonConstraint) c).getArea()).sum();
-
- // use arrays to hold variables (to enable assignment during consumer)
- final SimpleTriangle[] largestTriangle = new SimpleTriangle[1];
- final double[] largestArea = new double[1];
-
- final SplittableRandom r = new SplittableRandom(seed);
- TriangleCollector.visitSimpleTriangles(tin, triangle -> {
- final IConstraint constraint = triangle.getContainingRegion();
- if (!constrained || (constraint != null && constraint.definesConstrainedRegion())) {
- final Vertex a = triangle.getVertexA();
- final Vertex b = triangle.getVertexB();
- final Vertex c = triangle.getVertexC();
-
- // TODO more robust area (dense input produces slivers)
- final double triangleArea = 0.5 * ((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y));
- if (triangleArea > largestArea[0]) {
- largestTriangle[0] = triangle;
- largestArea[0] = triangleArea;
- }
-
- /*
- * Rather than choose a random triangle for each sample, pre-determine the
- * number of samples per triangle and sample this number of points in each
- * triangle successively. I conjecture that this results in a slightly more
- * uniform random distribution, the downside of which is the resulting
- * distribution has less entropy.
- */
- double areaWeight = (triangleArea / totalArea) * points;
- int samples = (int) Math.round(areaWeight);
- if (r.nextDouble() <= (areaWeight - samples)) {
- samples += 1;
- }
- for (int i = 0; i < samples; i++) {
- final double s = r.nextDouble();
- final double t = Math.sqrt(r.nextDouble());
- final double rX = (1 - t) * a.x + t * ((1 - s) * b.x + s * c.x);
- final double rY = (1 - t) * a.y + t * ((1 - s) * b.y + s * c.y);
- randomPoints.add(new PVector((float) rX, (float) rY));
- }
- }
- });
-
- final int remaining = points - randomPoints.size(); // due to rounding, may be a few above/below target number
- if (remaining > 0) {
- final Vertex a = largestTriangle[0].getVertexA();
- final Vertex b = largestTriangle[0].getVertexB();
- final Vertex c = largestTriangle[0].getVertexC();
- for (int i = 0; i < remaining; i++) {
- double s = r.nextDouble();
- double t = Math.sqrt(r.nextDouble());
- double rX = (1 - t) * a.x + t * ((1 - s) * b.x + s * c.x);
- double rY = (1 - t) * a.y + t * ((1 - s) * b.y + s * c.y);
- randomPoints.add(new PVector((float) rX, (float) rY));
- }
- } else if (remaining < 0) {
- Collections.shuffle(randomPoints, new XoRoShiRo128PlusRandom(seed)); // shuffle so that points are removed from regions randomly
- return randomPoints.subList(0, points);
- }
-
- return randomPoints;
+ var sampler = new ShapeRandomPointSampler(shape, seed);
+ return sampler.getRandomPoints(points);
}
/**
@@ -690,7 +708,7 @@ public static List generateRandomGridPoints(PShape shape, int maxPoints
*/
public static List generateRandomGridPoints(PShape shape, int maxPoints, boolean constrainedToCircle, double gutterFraction, long randomSeed) {
Geometry g = fromPShape(shape);
- IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(g);
+ YStripesPointInAreaLocator pointLocator = new YStripesPointInAreaLocator(g);
RandomPointsInGridBuilder r = new SeededRandomPointsInGridBuilder(randomSeed);
r.setConstrainedToCircle(constrainedToCircle);
@@ -728,35 +746,39 @@ public static List generateRandomGridPoints(PShape shape, int maxPoints
* @since 1.4.0
*/
public static PShape nest(PShape shape, int n, double r) {
- final Polygon polygon = (Polygon) fromPShape(shape);
+ final double rActual = r == 1 ? r : r % 1;
- if (r != 1) {
- r %= 1;
- }
- final Polygon[] derivedPolygons = new Polygon[n + 1];
- derivedPolygons[0] = polygon;
- Polygon currentPolygon = polygon;
+ PShape out = new PShape();
+ PGS_Processing.apply(shape, child -> {
+ final Polygon polygon = (Polygon) fromPShape(child);
- for (int i = 0; i < n; i++) {
- Coordinate[] inputCoordinates = currentPolygon.getCoordinates();
- int numVertices = inputCoordinates.length - 1;
+ final Polygon[] derivedPolygons = new Polygon[n + 1];
+ derivedPolygons[0] = polygon;
+ Polygon currentPolygon = polygon;
- Coordinate[] derivedCoordinates = new Coordinate[numVertices + 1];
+ for (int i = 0; i < n; i++) {
+ Coordinate[] inputCoordinates = currentPolygon.getCoordinates();
+ int numVertices = inputCoordinates.length - 1;
- for (int k = 0; k < numVertices; k++) {
- double x = inputCoordinates[k].x * (1 - r) + inputCoordinates[(k + 1) % numVertices].x * r;
- double y = inputCoordinates[k].y * (1 - r) + inputCoordinates[(k + 1) % numVertices].y * r;
- derivedCoordinates[k] = new Coordinate(x, y);
- }
- derivedCoordinates[numVertices] = derivedCoordinates[0]; // close the ring
+ Coordinate[] derivedCoordinates = new Coordinate[numVertices + 1];
- Polygon derivedPolygon = PGS.GEOM_FACTORY.createPolygon(derivedCoordinates);
+ for (int k = 0; k < numVertices; k++) {
+ double x = inputCoordinates[k].x * (1 - r) + inputCoordinates[(k + 1) % numVertices].x * rActual;
+ double y = inputCoordinates[k].y * (1 - r) + inputCoordinates[(k + 1) % numVertices].y * rActual;
+ derivedCoordinates[k] = new Coordinate(x, y);
+ }
+ derivedCoordinates[numVertices] = derivedCoordinates[0]; // close the ring
- derivedPolygons[i + 1] = derivedPolygon;
- currentPolygon = derivedPolygon;
- }
+ Polygon derivedPolygon = PGS.GEOM_FACTORY.createPolygon(derivedCoordinates);
+ derivedPolygon.setUserData(polygon.getUserData()); // copy styling
- return toPShape(GEOM_FACTORY.createMultiPolygon(derivedPolygons));
+ derivedPolygons[i + 1] = derivedPolygon;
+ currentPolygon = derivedPolygon;
+ }
+ out.addChild(toPShape(GEOM_FACTORY.createMultiPolygon(derivedPolygons)));
+ });
+
+ return out.getChildCount() == 1 ? out.getChild(0) : out;
}
/**
@@ -818,7 +840,6 @@ public static PShape removeHiddenLines(PShape shape) {
*
* @param shape a single polygonal shape or GROUP polygonal shape
* @param areaThreshold removes any holes with an area smaller than this value
- * @return
*/
public static PShape removeSmallHoles(PShape shape, double areaThreshold) {
final Geometry g = fromPShape(shape);
@@ -929,7 +950,7 @@ public static PShape split(PShape shape) {
* @see #split(PShape)
*/
public static PShape split(final PShape shape, int splitDepth) {
- // https://stackoverflow.com/questions/64252638/how-to-split-a-jts-polygon
+ // https://stackoverflow.com/questions/64252638/
splitDepth = Math.max(0, splitDepth);
Deque stack = new ArrayDeque<>();
stack.add(fromPShape(shape));
@@ -955,10 +976,10 @@ public static PShape split(final PShape shape, int splitDepth) {
Envelope ulEnv = new Envelope(minX, midX, midY, maxY);
Envelope urEnv = new Envelope(midX, maxX, midY, maxY);
- Geometry UL = OverlayNG.overlay(slice, toGeometry(ulEnv), OverlayNG.INTERSECTION, noder);
- Geometry UR = OverlayNG.overlay(slice, toGeometry(urEnv), OverlayNG.INTERSECTION, noder);
- Geometry LL = OverlayNG.overlay(slice, toGeometry(llEnv), OverlayNG.INTERSECTION, noder);
- Geometry LR = OverlayNG.overlay(slice, toGeometry(lrEnv), OverlayNG.INTERSECTION, noder);
+ Geometry UL = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(ulEnv), OverlayNG.INTERSECTION, noder);
+ Geometry UR = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(urEnv), OverlayNG.INTERSECTION, noder);
+ Geometry LL = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(llEnv), OverlayNG.INTERSECTION, noder);
+ Geometry LR = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(lrEnv), OverlayNG.INTERSECTION, noder);
// Geometries may not be polygonal; in which case, do not include in output.
if (UL instanceof Polygonal && !UL.isEmpty()) {
@@ -985,6 +1006,100 @@ public static PShape split(final PShape shape, int splitDepth) {
return partitions;
}
+ /**
+ * Splits the input shape into multiple wedge-shaped regions by connecting the
+ * centroid to each vertex.
+ *
+ * This method computes the centroid of the given shape, and then draws a line
+ * from the centroid to every vertex on the shape’s exterior. The shape is then
+ * split along these lines, partitioning it into distinct regions—one for each
+ * side of the original shape. For polygons with {@code n} vertices, this
+ * typically creates {@code n} wedge-shaped regions. In the case of concave
+ * polygons, this operation may result in more than {@code n} regions due to the
+ * underlying geometry.
+ *
+ *
+ * The returned shape is a GROUP {@code PShape}, whose children are the
+ * resulting partitioned regions.
+ *
+ *
+ * @param shape The shape to be partitioned. Typically a polygonal
+ * {@code PShape}.
+ * @return A GROUP {@code PShape} which contains one child for each wedge-shaped
+ * partition created by splitting from the centroid to each vertex.
+ * @since 2.1
+ * @see #centroidSplit(PShape, int, double)
+ */
+ public static PShape centroidSplit(PShape shape) {
+ var splits = PGS.prepareLinesPShape(null, null, null);
+ var c = PGS_ShapePredicates.centroid(shape);
+ PGS_Conversion.toPVector(shape).forEach(p -> {
+ splits.vertex(p.x, p.y);
+ splits.vertex(c.x, c.y);
+ });
+ splits.endShape();
+
+ var croppedLines = toPShape(fromPShape(shape).intersection(fromPShape(splits)));
+ var splitPolygons = PGS_ShapeBoolean.unionLines(shape, croppedLines);
+ return PGS_Optimisation.radialSortFaces(splitPolygons, c, 0);
+ }
+
+ /**
+ * Splits the input shape into {@code n} wedge-shaped regions by connecting the
+ * centroid to points along the perimeter.
+ *
+ * This method computes the centroid of the given shape, and then splits the
+ * shape by connecting the centroid to {@code n} points sampled evenly (with
+ * optional offset) around the outer ring (perimeter) of the shape. The split
+ * lines start at these points and end at the centroid. The operation results in
+ * approximately {@code n} regions for convex shapes, but may produce more than
+ * {@code n} partitions for highly concave input.
+ *
+ *
+ * The {@code offset} parameter determines the rotation of the sampling around
+ * the perimeter.
+ *
+ *
+ * The returned shape is a GROUP {@code PShape}, whose children are the
+ * resulting wedge-shaped partitions, radially sorted around the centroid.
+ *
+ *
+ * @param shape The shape to be split; typically a polygonal {@code PShape}.
+ * @param n The number of splits (rays) to create from the centroid;
+ * usually determines number of regions.
+ * @param offset The offset for the split lines, as a fraction of the perimeter,
+ * in [0, 1).
+ * @return A GROUP {@code PShape}, with child shapes being the regions created
+ * by the centroid splits, sorted radially.
+ * @see #centroidSplit(PShape)
+ */
+ public static PShape centroidSplit(PShape shape, int n, double offset) {
+ // 1) build your n radial “spoke” lines from the centroid out to the boundary
+ PVector c = PGS_ShapePredicates.centroid(shape);
+ PShape splits = PGS.prepareLinesPShape(null, null, null);
+ var in = fromPShape(shape);
+ if (in instanceof Polygon) {
+ in = ((Polygon) in).getExteriorRing();
+ }
+ LengthIndexedLine l = new LengthIndexedLine(in);
+
+ for (int i = 0; i < n; i++) {
+ double f = (i / (double) n + offset) % 1.0;
+ Coordinate coord = l.extractPoint(f * l.getEndIndex());
+ PVector p = PGS.toPVector(coord);
+ splits.vertex(p.x, p.y);
+ splits.vertex(c.x, c.y);
+ }
+ splits.endShape();
+
+ // 2) slice the shape by those lines
+ PShape cropLines = toPShape(fromPShape(shape).intersection(fromPShape(splits)));
+ PShape splitPolygons = PGS_ShapeBoolean.unionLines(shape, cropLines);
+
+ // order faces so that face[0] is the wedge starting at offset
+ return PGS_Optimisation.radialSortFaces(splitPolygons, c, offset);
+ }
+
/**
* Partitions shape(s) into convex (simple) polygons.
*
@@ -1210,6 +1325,29 @@ public static PShape eliminateSlivers(PShape shape, double threshold) {
return toPShape(out); // better on smaller thresholds
}
+ /**
+ * Dissolves the linear components of a shape (or group of shapes) into a set of
+ * maximal-length lines in which each unique segment appears once.
+ *
+ * Example uses: avoid double-drawing shared polygon edges; merge contiguous
+ * segments for cleaner rendering/export; or extract non-redundant network edges
+ * for topology or routing.
+ *
+ *
+ * This method does not node the input lines. Crossing segments without a vertex
+ * at the intersection remain crossing in the output.
+ *
+ *
+ * @param shape The {@code PShape} containing linear geometry.
+ * @return A GROUP {@code PShape} whose children are the dissolved
+ * maximal-length lines.
+ * @since 2.1
+ */
+ public static PShape dissolve(PShape shape) {
+ var g = fromPShape(shape);
+ return toPShape(LineDissolver.dissolve(g));
+ }
+
/**
* Attempts to fix shapes with invalid geometry, while preserving its original
* form and location as much as possible. See
@@ -1228,6 +1366,26 @@ public static PShape fix(PShape shape) {
return toPShape(GeometryFixer.fix(fromPShape(shape)));
}
+ /**
+ * Normalises a shape by standardising its vertex ordering and orientation:
+ *
+ * - The outer shell (exterior contour) is oriented clockwise (CW).
+ * - All holes (interior contours) are oriented counterclockwise (CCW).
+ * - Each contour (shell or hole) is rotated so its sequence starts at the
+ * vertex with the minimum coordinate (lexicographically by x, then y).
+ *
+ *
+ * @param shape the {@code PShape} to normalise
+ * @return a new {@code PShape} instance with standardised vertex winding and
+ * canonicalised vertex rotation
+ * @since 2.1
+ */
+ public static PShape normalise(PShape shape) {
+ var g = fromPShape(shape);
+ g.normalize();
+ return toPShape(g);
+ }
+
/**
* Filters out the children of a given PShape object based on a given Predicate
* function. Child shapes are filtered when the predicate is true: "remove
@@ -1256,7 +1414,7 @@ public static PShape fix(PShape shape) {
*/
public static PShape filterChildren(PShape shape, Predicate filterFunction) {
filterFunction = filterFunction.negate();
- List filteredFaces = PGS_Conversion.getChildren(shape).stream().filter(filterFunction::test).collect(Collectors.toList());
+ List filteredFaces = PGS_Conversion.getChildren(shape).stream().filter(filterFunction::test).toList();
return PGS_Conversion.flatten(filteredFaces);
}
@@ -1335,6 +1493,86 @@ public static PShape transformWithIndex(PShape shape, BiFunction function.apply(i, children.get(i))).filter(Objects::nonNull).toList());
}
+ /**
+ * Applies a specified transformation function to each child of the given PShape
+ * and returns a list of results produced by the function.
+ *
+ * This method processes each child of the input shape using the provided
+ * function, which can transform the shape into any desired type T. The function
+ * can return null to exclude a shape from the result list.
+ *
+ * Unlike the {@link #transform(PShape, UnaryOperator)} method, this method does
+ * not flatten the results into a PShape. Instead, it returns a list of
+ * arbitrary objects (type T) produced by the transformation function. This
+ * makes it more flexible for use cases where the transformation does not
+ * necessarily produce PShape objects.
+ *
+ * The transformation function can:
+ *
+ * - Transform the shape into a new object of type T
+ * - Return null to exclude the shape from the result list
+ *
+ *
+ * Note: This method does not modify the original shape or its children. It only
+ * applies the transformation function to each child and collects the results.
+ *
+ * @param The type of the objects produced by the transformation
+ * function.
+ * @param shape The PShape whose children will be transformed.
+ * @param function A Function that takes a PShape as input and returns an object
+ * of type T. If the function returns null for a shape, that
+ * shape will be excluded from the result list.
+ * @return A list of objects of type T produced by applying the transformation
+ * function to each child of the input shape.
+ * @see #transform(PShape, UnaryOperator)
+ * @since 2.1
+ */
+ public static List forEachShape(PShape shape, Function function) {
+ return PGS_Conversion.getChildren(shape).stream().map(function).filter(Objects::nonNull).collect(Collectors.toList());
+ }
+
+ /**
+ * Applies a specified transformation function to each child of the given PShape
+ * along with its index and returns a list of results produced by the function.
+ *
+ * This method processes each child of the input shape using the provided
+ * function, which takes both the index of the child and the child itself as
+ * input. The function can transform the shape into any desired type T or return
+ * null to exclude the shape from the result list.
+ *
+ * Unlike the {@link #transformWithIndex(PShape, BiFunction)} method, this
+ * method does not flatten the results into a PShape. Instead, it returns a list
+ * of arbitrary objects (type T) produced by the transformation function. This
+ * makes it more flexible for use cases where the transformation does not
+ * necessarily produce PShape objects.
+ *
+ * The transformation function can:
+ *
+ * - Transform the shape into a new object of type T
+ * - Return null to exclude the shape from the result list
+ *
+ *
+ * Note: This method does not modify the original shape or its children. It only
+ * applies the transformation function to each child and collects the results.
+ *
+ * @param The type of the objects produced by the transformation
+ * function.
+ * @param shape The PShape whose children will be transformed.
+ * @param function A BiFunction that takes an integer index and a PShape as
+ * input and returns an object of type T. If the function
+ * returns null for a shape, that shape will be excluded from
+ * the result list.
+ * @return A list of objects of type T produced by applying the transformation
+ * function to each child of the input shape along with its index.
+ * @see #transformWithIndex(PShape, BiFunction)
+ * @see #forEachShape(PShape, Function)
+ * @since 2.1
+ */
+ public static List forEachShapeWithIndex(PShape shape, BiFunction function) {
+ List children = PGS_Conversion.getChildren(shape);
+ return IntStream.range(0, children.size()).mapToObj(i -> function.apply(i, children.get(i))).filter(Objects::nonNull).collect(Collectors.toList());
+ }
+
/**
* Applies a specified function to each child of the given PShape.
*
@@ -1404,12 +1642,6 @@ public static PShape applyWithIndex(PShape shape, BiConsumer ap
return shape;
}
- private static Polygon toGeometry(Envelope envelope) {
- return GEOM_FACTORY.createPolygon(GEOM_FACTORY.createLinearRing(new Coordinate[] { new Coordinate(envelope.getMinX(), envelope.getMinY()),
- new Coordinate(envelope.getMaxX(), envelope.getMinY()), new Coordinate(envelope.getMaxX(), envelope.getMaxY()),
- new Coordinate(envelope.getMinX(), envelope.getMaxY()), new Coordinate(envelope.getMinX(), envelope.getMinY()) }));
- }
-
/**
* Used by slice()
*/
diff --git a/src/main/java/micycle/pgs/PGS_SegmentSet.java b/src/main/java/micycle/pgs/PGS_SegmentSet.java
index 51f1f43c..fb3059f1 100644
--- a/src/main/java/micycle/pgs/PGS_SegmentSet.java
+++ b/src/main/java/micycle/pgs/PGS_SegmentSet.java
@@ -11,9 +11,9 @@
import java.util.stream.Collectors;
import org.jgrapht.alg.interfaces.MatchingAlgorithm;
+import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedMatching;
import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching;
import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense;
-import org.jgrapht.graph.SimpleGraph;
import org.locationtech.jts.algorithm.RobustLineIntersector;
import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator;
import org.locationtech.jts.dissolve.LineDissolver;
@@ -163,17 +163,6 @@ public static List graphMatchedSegments(List points) {
return graphMatchedSegments(PGS_Triangulation.delaunayTriangulationMesh(points));
}
- /**
- * Number of segments = #vertices/2
- *
- * Let P be a set of n points, not all on the same line. Let k be the number of
- * points on the boundary of the convex hull of P. Any triangulation of P has 1)
- * 2n − 2 − k triangles, and 2) 3n − 3 − k edges
- *
- * @param triangulation
- * @return
- */
-
/**
* Generates non-intersecting segments via a Perfect matching algorithm
* applied to the given triangulation.
@@ -190,15 +179,15 @@ public static List graphMatchedSegments(List points) {
*/
public static List graphMatchedSegments(IIncrementalTin triangulation) {
// explained here https://stackoverflow.com/a/72565245/
- final SimpleGraph