makeHashSet(int expectedSize) {
return new HashSet<>((int) ((expectedSize) / 0.75 + 1));
}
- /**
- * Computes an ordered list of vertices that make up the boundary
- * of a polygon from an unordered collection of edges. The
- * underlying approach is around ~10x faster than JTS .buffer(0) and ~3x faster
- * than {@link LineMerger}.
- *
- * For now, this method does not properly support multi-shapes, nor unclosed
- * edge collections (that form unclosed linestrings).
- *
- * Notably, unlike {@link LineMerger} this approach does not merge successive
- * boundary segments that together form a straight line into a single longer
- * segment.
- *
- * @param edges unordered/random collection of edges (containing no duplicates),
- * that together constitute the boundary of a single polygon / a
- * closed ring
- * @return list of sequential vertices belonging to the polygon that follow some
- * constant winding (may wind clockwise or anti-clockwise). Note: this
- * vertex list is not closed (having same start and end vertex) by
- * default!
- */
- static List fromEdges(Collection edges) {
- // NOTE same as org.locationtech.jts.operation.linemerge.LineSequencer ?
- // map of vertex to the 2 edges that share it
- final HashMap> vertexEdges = new HashMap<>((int) ((edges.size()) / 0.75 + 1));
-
- /*
- * Build up map of vertex->edge to later find edges sharing a given vertex in
- * O(1). When the input is valid (edges form a closed loop) every vertex is
- * shared by 2 edges.
- */
- for (PEdge e : edges) {
- if (vertexEdges.containsKey(e.a)) {
- vertexEdges.get(e.a).add(e);
- } else {
- HashSet h = new HashSet<>();
- h.add(e);
- vertexEdges.put(e.a, h);
- }
- if (vertexEdges.containsKey(e.b)) {
- vertexEdges.get(e.b).add(e);
- } else {
- HashSet h = new HashSet<>();
- h.add(e);
- vertexEdges.put(e.b, h);
- }
- }
-
- List vertices = new ArrayList<>(edges.size() + 1); // boundary vertices
-
- // begin by choosing a random edge
- final PEdge startingEdge = edges.iterator().next();
- vertices.add(startingEdge.a);
- vertices.add(startingEdge.b);
- vertexEdges.get(startingEdge.a).remove(startingEdge);
- vertexEdges.get(startingEdge.b).remove(startingEdge);
-
- while (vertices.size() < edges.size()) {
- final PVector lastVertex = vertices.get(vertices.size() - 1);
- Set connectedEdges = vertexEdges.get(lastVertex);
-
- if (connectedEdges.isEmpty()) {
- /*
- * This will be hit if the input is malformed (contains multiple disjoint shapes
- * for example), and break when the first loop is closed. On valid inputs the
- * while loop will break before this statement can be hit.
- */
- break;
- }
-
- final PEdge nextEdge = connectedEdges.iterator().next();
- if (nextEdge.a.equals(lastVertex)) {
- vertices.add(nextEdge.b);
- vertexEdges.get(nextEdge.b).remove(nextEdge);
- } else {
- vertices.add(nextEdge.a);
- vertexEdges.get(nextEdge.a).remove(nextEdge);
- }
- connectedEdges.remove(nextEdge); // remove this edge from vertex mapping
- if (connectedEdges.isEmpty()) {
- vertexEdges.remove(lastVertex); // have used both edges connected to this vertex -- now remove!
- }
- }
-
- return vertices;
- }
-
static SimpleWeightedGraph makeCompleteGraph(List points) {
SimpleWeightedGraph graph = new SimpleWeightedGraph<>(PEdge.class);
@@ -463,14 +463,10 @@ static SimpleWeightedGraph makeCompleteGraph(List point
* list. Other geometry types contained within the input geometry are ignored.
*/
static List extractPolygons(Geometry g) {
- List polygons = new ArrayList<>(g.getNumGeometries());
+ List polygons = new ArrayList<>();
g.apply((GeometryFilter) geom -> {
if (geom instanceof Polygon) {
polygons.add((Polygon) geom);
- } else if (geom instanceof MultiPolygon) {
- for (int i = 0; i < geom.getNumGeometries(); i++) {
- polygons.add((Polygon) geom.getGeometryN(i));
- }
}
});
return polygons;
@@ -564,9 +560,9 @@ public LinearRingIterator(Geometry g) {
ArrayList rings = new ArrayList<>(g.getNumGeometries());
for (int i = 0; i < g.getNumGeometries(); i++) {
Polygon poly = (Polygon) g.getGeometryN(i);
-// if (poly.getNumPoints() == 0) {
-// continue;
-// }
+ // if (poly.getNumPoints() == 0) {
+ // continue;
+ // }
rings.add(poly.getExteriorRing());
for (int j = 0; j < poly.getNumInteriorRing(); j++) {
rings.add(poly.getInteriorRingN(j));
@@ -607,174 +603,113 @@ public void remove() {
}
/**
- * Apply a transformation to every lineal element in a PShape, preserving
- * geometry structure and polygon/hole relationships, and return a non-null
- * result.
+ * Apply a transformation to every lineal element in a {@code PShape},
+ * preserving geometry structure and polygon/hole relationships, and return a
+ * non-null result.
*
*
- * The geometry encoded by {@code shape} (via {@code fromPShape}) is traversed,
+ * The geometry encoded by {@code shape} (via {@code fromPShape}) is traversed
* and {@code function} is applied to each lineal component: {@code LineString}
- * and {@code LinearRing}. The function may return a replacement
+ * and polygon rings ({@code LinearRing}, passed to the function as a
+ * {@code LineString}). The function may return a replacement
* {@code LineString}, or {@code null} to drop that element.
*
- *
- * Structure preservation:
+ *
Structure preservation
*
- * - GeometryCollection / MultiPolygon / MultiLineString:
+ *
- GeometryCollection / MultiPolygon / MultiLineString
*
- * - Children are processed recursively; original grouping and order are
- * preserved.
- * - Children for which the function yields {@code null} (or become empty) are
- * filtered out before assembling the result.
- * - A GROUP {@code PShape} is always returned (it may be empty if nothing
- * survives).
+ * - Children are processed in index order; the relative order of surviving
+ * children is preserved.
+ * - Children for which the function yields {@code null} (or that become
+ * empty) are omitted from the result.
+ * - If the input encodes a multi/collection geometry, the returned
+ * {@code PShape} is always of kind {@code GROUP} (it may be empty if nothing
+ * survives), even if only a single child remains after filtering.
*
*
- * - Polygon / LinearRing:
+ *
+ *
- Polygon
*
- * - Rings are visited shell-first (exterior, then holes), preserving the
- * exterior–hole relations.
- * - If the exterior becomes {@code null} or invalid, the entire polygon is
+ *
- Rings are visited shell-first (exterior, then holes in interior-ring
+ * index order), preserving exterior–hole relationships.
+ * - If the exterior ring is dropped or becomes invalid, the entire polygon is
* dropped.
- * - Holes that become {@code null} or invalid are omitted; remaining holes
- * retain order.
- * - Ring orientation is enforced: exterior is CW; holes are CCW.
+ * - Holes that are dropped or become invalid are omitted; remaining holes
+ * retain their original order.
+ * - Ring orientation is enforced: exterior is clockwise (CW); holes are
+ * counter-clockwise (CCW).
+ *
+ *
+ *
+ * - LinearRing
+ *
+ * - If a {@code LinearRing} is encountered outside a polygon, it is treated
+ * as an exterior ring for closure/orientation rules.
*
*
*
*
- *
- * Additional behavior:
+ *
Additional behavior
*
- * - Non-closed outputs are closed when possible (if at least two points
+ *
- Non-closed ring outputs are closed when possible (if at least two points
* exist).
* - Rings must have at least 4 coordinates (including repeated first/last)
* after closing; otherwise they are dropped.
- * - LineString elements return the transformed line or are dropped if
- * {@code function} returns {@code null}.
- * - Unsupported geometry types yield an empty {@code PShape}.
+ * - {@code LineString} elements return the transformed line, or are dropped
+ * if {@code function} returns {@code null}.
+ * - Unsupported geometry types are ignored (dropped). If the root geometry is
+ * unsupported, an empty {@code PShape} is returned.
* - No full topology validation is performed; run JTS validators if
* needed.
*
*
- *
- * Return contract:
+ *
Return contract
*
* - This method never returns {@code null}. If no geometry survives, an empty
- * {@code PShape} is returned.
+ * {@code PShape} is returned (for multi/collection inputs, an empty
+ * {@code GROUP} {@code PShape}).
*
*
- * @param shape input PShape encoding geometries to transform (must be
- * convertible via {@code fromPShape})
- * @param function a UnaryOperator that receives each {@code LineString} (linear
- * rings are passed as {@code LineString}) and returns a
- * modified {@code LineString}, or {@code null} to drop the
- * element
- * @return a non-null {@code PShape} representing the transformed geometry; for
- * multi/geometries a GROUP {@code PShape} is returned and may be empty
- * when no children survive
+ * @param shape input {@code PShape} encoding geometries to transform (must
+ * be convertible via {@code fromPShape})
+ * @param function operator applied to each {@code LineString}; polygon rings
+ * are passed as {@code LineString}. Returning {@code null}
+ * drops that element.
+ * @return a non-null {@code PShape} representing the transformed geometry
* @since 2.1
*/
- static PShape applyToLinealGeometries(PShape shape, UnaryOperator function) {
- Geometry g = fromPShape(shape);
- final var data = g.getUserData(); // probably styling
- 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++) {
- PShape child = applyToLinealGeometries(toPShape(g.getGeometryN(i)), function);
- if (!isEmptyShape(child)) {
- group.addChild(child);
- }
- }
- // Always return a group, possibly empty
- return group;
- }
- case Geometry.TYPENAME_LINEARRING :
- case Geometry.TYPENAME_POLYGON : {
- // Preserve exterior-hole relations; allow function to return null (skip)
- LinearRing[] rings = new LinearRingIterator(g).getLinearRings();
- List processed = new ArrayList<>(rings.length);
- for (int i = 0; i < rings.length; i++) {
- LinearRing ring = rings[i];
- LineString out = function.apply(ring);
- final boolean isHole = i > 0;
-
- if (out == null) {
- // If the exterior is removed, drop the whole polygon -> empty shape
- if (!isHole) {
- return new PShape();
- } else {
- // skip this hole
- continue;
- }
- }
+ static PShape applyToLinealGeometries(PShape shape, UnaryOperator fn) {
+ final Geometry in = fromPShape(shape);
- Coordinate[] coords = out.getCoordinates();
-
- // Ensure closed; if not, close automatically when possible.
- if (!out.isClosed()) {
- if (coords.length >= 2) {
- Coordinate[] closedCoords = Arrays.copyOf(coords, out.getNumPoints() + 1);
- closedCoords[closedCoords.length - 1] = closedCoords[0]; // close the ring
- coords = closedCoords;
- } else {
- // Too short to form a ring; skip this ring
- if (!isHole) {
- return new PShape();
- } else {
- continue;
- }
- }
- }
+ if (in instanceof Point || in instanceof MultiPoint) {
+ return new PShape();
+ }
- // Need at least 4 coordinates for a valid closed ring (including repeated
- // first)
- if (coords.length >= 4) {
- // as createPolygon() doesn't check ring orientation
- final boolean ccw = Orientation.isCCWArea(coords);
- if (isHole && !ccw) {
- ArrayUtils.reverse(coords); // make hole CCW
- } else if (!isHole && ccw) {
- ArrayUtils.reverse(coords); // make exterior CW
- }
- processed.add(GEOM_FACTORY.createLinearRing(coords));
- } else {
- if (!isHole) {
- return new PShape();
- }
- // skip hole otherwise
- }
- }
+ final boolean rootIsMultiPolygon = in instanceof MultiPolygon;
+ final boolean rootIsMultiLineString = in instanceof MultiLineString;
+ final boolean rootIsGeomCollection = (in instanceof GeometryCollection) && !rootIsMultiPolygon && !rootIsMultiLineString && !(in instanceof MultiPoint);
- if (processed.isEmpty()) {
- return new PShape();
- }
+ final Object rootUserData = in.getUserData();
- LinearRing exterior = processed.get(0);
- LinearRing[] holes = (processed.size() > 1) ? processed.subList(1, processed.size()).toArray(new LinearRing[0]) : null;
+ Geometry out = new PGS_Transformer(fn).transform(in);
- var polygon = GEOM_FACTORY.createPolygon(exterior, holes);
- polygon.setUserData(data);
- return toPShape(polygon);
- }
- case Geometry.TYPENAME_LINESTRING : {
- LineString l = (LineString) g;
- LineString out = function.apply(l);
- if (out == null) {
- return new PShape();
- }
- out.setUserData(data);
- var line = toPShape(out);
- line.setFill(false);
- return line;
- }
- default :
- // Return an empty PShape to indicate "ignored / not processed"
- return new PShape();
+ // Never return null; match empty policies
+ if (out == null || out.isEmpty()) {
+ return new PShape(PConstants.GROUP);
+ }
+
+ // Preserve "GROUP-ness" for multi/collection roots even if only one child
+ // survives
+ if (rootIsMultiPolygon && out instanceof Polygon p) {
+ out = GEOM_FACTORY.createMultiPolygon(new Polygon[] { p });
+ } else if (rootIsMultiLineString && out instanceof LineString ls && !(out instanceof MultiLineString)) {
+ out = GEOM_FACTORY.createMultiLineString(new LineString[] { ls });
+ } else if (rootIsGeomCollection && !(out instanceof GeometryCollection)) {
+ out = GEOM_FACTORY.createGeometryCollection(new Geometry[] { out });
}
+
+ out.setUserData(rootUserData);
+ return toPShape(out);
}
static boolean isEmptyShape(PShape s) {
@@ -790,4 +725,128 @@ static boolean isEmptyShape(PShape s) {
return true;
}
+ private static class PGS_Transformer extends GeometryTransformer {
+
+ private final UnaryOperator fn;
+
+ PGS_Transformer(UnaryOperator fn) {
+ this.fn = fn;
+ }
+
+ @Override
+ protected Geometry transformPolygon(Polygon p, Geometry parent) {
+ // Own the polygon traversal order: shell first, then holes by index.
+ LinearRing shell = processRing(p.getExteriorRing(), false);
+ if (shell == null) {
+ return null; // drop whole polygon
+ }
+
+ List holes = new ArrayList<>(p.getNumInteriorRing());
+ for (int i = 0; i < p.getNumInteriorRing(); i++) {
+ LinearRing h = processRing(p.getInteriorRingN(i), true);
+ if (h != null) {
+ holes.add(h);
+ }
+ }
+
+ Polygon out = GEOM_FACTORY.createPolygon(shell, holes.toArray(LinearRing[]::new));
+ out.setUserData(p.getUserData());
+ return out;
+ }
+
+ @Override
+ protected Geometry transformLinearRing(LinearRing ring, Geometry parent) {
+ // Standalone rings: treat as exterior policy (CW)
+ LinearRing out = processRing(ring, false);
+ if (out != null) {
+ out.setUserData(ring.getUserData());
+ }
+ return out;
+ }
+
+ @Override
+ protected Geometry transformLineString(LineString ls, Geometry parent) {
+ // Note: GeometryTransformer may route rings here too; ensure we handle them as
+ // rings.
+ if (ls instanceof LinearRing r) {
+ return transformLinearRing(r, parent);
+ }
+
+ LineString res = fn.apply(ls);
+ if (res == null || res.isEmpty()) {
+ return null;
+ }
+
+ LineString out = GEOM_FACTORY.createLineString(res.getCoordinateSequence());
+ out.setUserData(ls.getUserData());
+ return out;
+ }
+
+ @Override
+ protected Geometry transformGeometryCollection(GeometryCollection gc, Geometry parent) {
+ // Preserve order; filter null/empty; preserve container type
+ List kept = new ArrayList<>(gc.getNumGeometries());
+ for (int i = 0; i < gc.getNumGeometries(); i++) {
+ Geometry t = transform(gc.getGeometryN(i));
+ if (t != null && !t.isEmpty()) {
+ kept.add(t);
+ }
+ }
+
+ if (gc instanceof MultiPolygon) {
+ List polys = new ArrayList<>();
+ for (Geometry g : kept) {
+ if (g instanceof Polygon p) {
+ polys.add(p);
+ }
+ }
+ return GEOM_FACTORY.createMultiPolygon(polys.toArray(Polygon[]::new));
+ }
+
+ if (gc instanceof MultiLineString) {
+ List lines = new ArrayList<>();
+ for (Geometry g : kept) {
+ if (g instanceof LineString ls) {
+ lines.add(ls);
+ }
+ }
+ return GEOM_FACTORY.createMultiLineString(lines.toArray(LineString[]::new));
+ }
+
+ return GEOM_FACTORY.createGeometryCollection(kept.toArray(Geometry[]::new));
+ }
+
+ private LinearRing processRing(LinearRing ring, boolean isHole) {
+ // Apply fn to ring (passed as LineString)
+ LineString res = fn.apply(ring);
+ if (res == null || res.isEmpty()) {
+ return null;
+ }
+
+ Coordinate[] coords = res.getCoordinates();
+
+ // Ensure closed when possible
+ if (coords.length >= 2 && !coords[0].equals2D(coords[coords.length - 1])) {
+ coords = Arrays.copyOf(coords, coords.length + 1);
+ coords[coords.length - 1] = coords[0];
+ }
+
+ // Need at least 4 coordinates for a valid ring
+ if (coords.length < 4) {
+ return null;
+ }
+
+ // Enforce orientation: exterior CW, holes CCW
+ boolean ccw = Orientation.isCCWArea(coords);
+ if (isHole && !ccw) {
+ ArrayUtils.reverse(coords);
+ }
+ if (!isHole && ccw) {
+ ArrayUtils.reverse(coords);
+ }
+
+ return GEOM_FACTORY.createLinearRing(coords);
+ }
+ }
+
}
diff --git a/src/main/java/micycle/pgs/PGS_CirclePacking.java b/src/main/java/micycle/pgs/PGS_CirclePacking.java
index b67c7bab..d0912725 100644
--- a/src/main/java/micycle/pgs/PGS_CirclePacking.java
+++ b/src/main/java/micycle/pgs/PGS_CirclePacking.java
@@ -28,7 +28,6 @@
import micycle.pgs.commons.FrontChainPacker;
import micycle.pgs.commons.LargestEmptyCircles;
import micycle.pgs.commons.RepulsionCirclePack;
-import micycle.pgs.commons.ShapeRandomPointSampler;
import micycle.pgs.commons.TangencyPack;
import processing.core.PShape;
import processing.core.PVector;
@@ -84,7 +83,7 @@ public static List obstaclePack(PShape shape, Collection point
areaCoverRatio = Math.min(areaCoverRatio, 1 - (1e-3));
final Geometry geometry = fromPShape(shape);
final Geometry obstacles = fromPShape(PGS_Conversion.toPointsPShape(pointObstacles));
- LargestEmptyCircles lec = new LargestEmptyCircles(obstacles, geometry, areaCoverRatio > 0.95 ? 0.5 : 1);
+ var lec = new LargestEmptyCircles(geometry, obstacles, areaCoverRatio > 0.95 ? 0.5 : 1);
final double shapeArea = geometry.getArea();
double circlesArea = 0;
@@ -322,7 +321,7 @@ public static List frontChainPack(PShape shape, double radiusMin, doubl
*/
public static List maximumInscribedPack(PShape shape, int n, double tolerance) {
tolerance = Math.max(0.01, tolerance);
- LargestEmptyCircles mics = new LargestEmptyCircles(fromPShape(shape), null, tolerance);
+ LargestEmptyCircles mics = new LargestEmptyCircles(fromPShape(shape), tolerance);
final List out = new ArrayList<>();
for (int i = 0; i < n; i++) {
@@ -352,7 +351,7 @@ public static List maximumInscribedPack(PShape shape, int n, double tol
public static List maximumInscribedPack(PShape shape, double minRadius, double tolerance) {
tolerance = Math.max(0.01, tolerance);
minRadius = Math.max(0.01, minRadius);
- LargestEmptyCircles mics = new LargestEmptyCircles(fromPShape(shape), null, tolerance);
+ LargestEmptyCircles mics = new LargestEmptyCircles(fromPShape(shape), tolerance);
final List out = new ArrayList<>();
double[] currentLEC;
diff --git a/src/main/java/micycle/pgs/PGS_Coloring.java b/src/main/java/micycle/pgs/PGS_Coloring.java
index c44b1312..6860de42 100644
--- a/src/main/java/micycle/pgs/PGS_Coloring.java
+++ b/src/main/java/micycle/pgs/PGS_Coloring.java
@@ -4,7 +4,6 @@
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
-import org.jgrapht.alg.color.ColorRefinementAlgorithm;
import org.jgrapht.alg.color.LargestDegreeFirstColoring;
import org.jgrapht.alg.color.RandomGreedyColoring;
import org.jgrapht.alg.color.SaturationDegreeColoring;
@@ -16,6 +15,7 @@
import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
import micycle.pgs.color.ColorUtils;
import micycle.pgs.color.Colors;
+import micycle.pgs.commons.DBLACColoring;
import micycle.pgs.commons.GeneticColoring;
import micycle.pgs.commons.RLFColoring;
import processing.core.PShape;
@@ -43,7 +43,7 @@
* @since 1.2.0
*/
public final class PGS_Coloring {
-
+
public static long SEED = 1337;
private PGS_Coloring() {
@@ -82,11 +82,7 @@ public enum ColoringAlgorithm {
*/
DSATUR,
/**
- * Finds the coarsest coloring of a graph.
- */
- COARSE,
- /**
- * Recursive largest-first coloring (recommended).
+ * Recursive largest-first coloring.
*/
RLF,
/**
@@ -101,7 +97,22 @@ public enum ColoringAlgorithm {
* specifically targets a chromaticity of 4 (falls back to 5 if no solution is
* found).
*/
- GENETIC
+ GENETIC,
+ /**
+ * Degree-Based Largest Adjacency Count coloring.
+ *
+ *
+ * Fast with good chromaticity (recommended).
+ *
+ *
+ * Repeatedly selects an uncolored vertex that maximizes LAC(v) =
+ * number of already-colored neighbors. Ties are broken by larger static degree,
+ * then by the shuffled index. Each selected vertex is colored using first-fit
+ * (smallest feasible color).
+ *
+ * @since 2.2
+ */
+ DBLAC,
}
/**
@@ -133,6 +144,25 @@ public static Map colorMesh(Collection shapes, Coloring
return coloring.getColors();
}
+ /**
+ * Computes a coloring of the given mesh shape using the default coloring
+ * algorithm ({@link ColoringAlgorithm#DBLAC DBLAC}) and applies the provided
+ * palette to its faces.
+ *
+ * This method mutates the fill colour of the input {@code meshShape} by setting
+ * the fill of each child face {@link PShape}. If the computed number of
+ * required colors exceeds the palette length.
+ *
+ * @param meshShape a GROUP {@link PShape} whose children constitute the
+ * faces of a conforming mesh
+ * @param colorPalette the colors with which to color the mesh
+ * @return the input {@code meshShape} (whose faces have now been colored)
+ * @see #colorMesh(PShape, ColoringAlgorithm, int[])
+ */
+ public static PShape colorMesh(PShape meshShape, int[] colorPalette) {
+ return colorMesh(meshShape, ColoringAlgorithm.DBLAC, colorPalette);
+ }
+
/**
* Computes a coloring of the given mesh shape and colors its faces using the
* colors provided. This method mutates the fill colour of the input shape.
@@ -146,8 +176,8 @@ public static Map colorMesh(Collection shapes, Coloring
public static PShape colorMesh(PShape shape, ColoringAlgorithm coloringAlgorithm, int[] colorPalette) {
final Coloring coloring = findColoring(shape, coloringAlgorithm);
if (coloring.getNumberColors() > colorPalette.length) {
- System.err.format("WARNING: Number of mesh colors (%s) exceeds those provided in palette (%s)%s", coloring.getNumberColors(),
- colorPalette.length, System.lineSeparator());
+ System.err.format("WARNING: Number of mesh colors (%s) exceeds those provided in palette (%s)%s", coloring.getNumberColors(), colorPalette.length,
+ System.lineSeparator());
}
coloring.getColors().forEach((face, color) -> {
int c = colorPalette[color % colorPalette.length]; // NOTE use modulo to avoid OOB exception
@@ -250,11 +280,11 @@ private static Coloring findColoring(Collection shapes, Coloring
case DSATUR :
coloring = new SaturationDegreeColoring<>(graph).getColoring();
break;
- case COARSE :
- coloring = new ColorRefinementAlgorithm<>(graph).getColoring();
- break;
case GENETIC :
- coloring = new GeneticColoring<>(graph).getColoring();
+ coloring = new GeneticColoring<>(graph, SEED).getColoring();
+ break;
+ case DBLAC :
+ coloring = new DBLACColoring<>(graph, SEED).getColoring();
break;
case RLF_BRUTE_FORCE_4COLOR :
int iterations = 0;
diff --git a/src/main/java/micycle/pgs/PGS_Construction.java b/src/main/java/micycle/pgs/PGS_Construction.java
index 0299f0a4..5dc82311 100644
--- a/src/main/java/micycle/pgs/PGS_Construction.java
+++ b/src/main/java/micycle/pgs/PGS_Construction.java
@@ -40,7 +40,7 @@
import micycle.spacefillingcurves.SierpinskiTenSteps;
import micycle.spacefillingcurves.SierpinskiThreeSteps;
import micycle.spacefillingcurves.SpaceFillingCurve;
-import micycle.srpg.SRPolygonGenerator;
+import com.github.micycle1.srpg.SRPolygonGenerator;
import net.jafama.FastMath;
import processing.core.PConstants;
import processing.core.PShape;
@@ -128,9 +128,10 @@ public static PShape createRandomPolygonExact(int n, double width, double height
* @param centerX centre point X
* @param centerY centre point Y
* @param width polygon width
+ * @return a PShape representing a regular polygon
* @since 2.0
*/
- public static PShape createRegularPolyon(int n, double centerX, double centerY, double width) {
+ public static PShape createRegularPolygon(int n, double centerX, double centerY, double width) {
final GeometricShapeFactory shapeFactory = new GeometricShapeFactory();
shapeFactory.setNumPoints(n);
shapeFactory.setCentre(new Coordinate(centerX, centerY));
@@ -234,7 +235,7 @@ public static PShape createSuperShape(double centerX, double centerY, double rad
r = Math.pow(t1 + t2, 1 / n1);
if (Math.abs(r) != 0) {
r *= radius; // multiply r (0...1) by (max) radius
-// r = radius/r;
+ // r = radius/r;
shape.vertex((float) (centerX + r * FastMath.cos(angle)), (float) (centerY + r * FastMath.sin(angle)));
}
@@ -376,7 +377,7 @@ public static PShape createArbelos(double centerX, double centerY, double radius
* @param outerRadius The outer radius of the star
* @param roundness A roundness value between 0.0 and 1.0, for the inner and
* outer corners of the star.
- * @return The star shape
+ * @return The star shape as a PShape
*/
public static PShape createStar(double centerX, double centerY, int numRays, double innerRadius, double outerRadius, double roundness) {
roundness = Math.max(Math.min(1, roundness), 0);
@@ -418,8 +419,8 @@ public static PShape createStar(double centerX, double centerY, int numRays, dou
*/
public static PShape createBlobbie(double centerX, double centerY, double maxWidth, double a, double b, double c, double d) {
// http://paulbourke.net/geometry/blobbie/
- final double cirumference = 2 * Math.PI * maxWidth / 2;
- final int samples = (int) (cirumference / 2); // 1 point every 2 distance
+ final double circumference = 2 * Math.PI * maxWidth / 2;
+ final int samples = (int) (circumference / 2); // 1 point every 2 distance
double dt = Math.PI * 2 / samples;
final CoordinateList blobbieCoords = new CoordinateList();
@@ -472,7 +473,7 @@ public static PShape createHeart(final double centerX, final double centerY, fin
PShape heart = new PShape(PShape.PATH);
heart.setFill(true);
heart.setFill(Colors.WHITE);
- heart.beginShape();
+ heart.beginShape(PConstants.POLYGON);
final double length = 6.3855 * width; // Arc length of parametric curve from wolfram alpha
final int points = (int) length / 2; // sample every 2 units along curve (roughly)
@@ -554,8 +555,8 @@ public static PShape createGear(final double centerX, final double centerY, fina
curve.setFill(Colors.WHITE);
curve.beginShape();
- final double cirumference = 2 * Math.PI * radius;
- final int samples = (int) (cirumference / 5); // 1 point every 5 distance
+ final double circumference = 2 * Math.PI * radius;
+ final int samples = (int) (circumference / 5); // 1 point every 5 distance
final double angleInc = Math.PI * 2 / samples;
double angle = 0;
@@ -642,8 +643,7 @@ public static PShape createRing(double centerX, double centerY, double outerRadi
* @param generators the number of generator points for the underlying Voronoi
* tessellation. Should be >5.
* @param thickness thickness of sponge structure walls
- * @param smoothing the cell smoothing factor which determines how rounded the
- * cells are. a value of 6 is a good starting point.
+ * @param smoothing level of gaussian smoothing to apply to the structure
* @param classes the number of classes to use for the cell merging process,
* where lower results in more merging (or larger "blob-like"
* shapes).
@@ -980,7 +980,7 @@ public static PShape createSierpinskiCurve(double centerX, double centerY, doubl
final PShape curve = new PShape(PShape.PATH);
curve.setFill(true);
curve.setFill(Colors.WHITE);
- curve.beginShape();
+ curve.beginShape(PConstants.POLYGON);
half1.forEach(p -> curve.vertex((float) p[0], (float) p[1]));
curve.endShape(PConstants.CLOSE);
@@ -1271,6 +1271,7 @@ static PShape rect(int rectMode, double a, double b, double c, double d, double
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.setKind(PConstants.POLYGON);
sh.setFill(true);
sh.setFill(Colors.WHITE);
sh.beginShape();
@@ -1365,7 +1366,7 @@ static Polygon createCircle(double x, double y, double r, final double maxDeviat
int nPts = (int) Math.ceil(2 * Math.PI / Math.acos(1 - maxDeviation / r));
nPts = Math.max(nPts, 21); // min of 21 points for tiny circles
final int circumference = (int) (Math.PI * r * 2);
- if (nPts > circumference * 2) {
+ if (nPts > circumference * 2 && circumference > 0) {
// AT MOST 1 point every half pixel
nPts = circumference * 2;
}
diff --git a/src/main/java/micycle/pgs/PGS_Contour.java b/src/main/java/micycle/pgs/PGS_Contour.java
index d7750cc9..44ca8042 100644
--- a/src/main/java/micycle/pgs/PGS_Contour.java
+++ b/src/main/java/micycle/pgs/PGS_Contour.java
@@ -13,8 +13,8 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.function.DoubleBinaryOperator;
import java.util.stream.Collectors;
-import java.util.stream.Stream;
import javax.vecmath.Point3d;
@@ -38,6 +38,7 @@
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.operation.overlayng.OverlayNG;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.tinfour.common.IIncrementalTin;
import org.tinfour.common.IQuadEdge;
@@ -55,6 +56,7 @@
import org.twak.utils.collections.Loop;
import org.twak.utils.collections.LoopL;
+import com.github.micycle1.geoblitz.SegmentVoronoiIndex;
import com.github.micycle1.geoblitz.YStripesPointInAreaLocator;
import com.google.common.collect.Lists;
@@ -63,6 +65,7 @@
import micycle.pgs.PGS.LinearRingIterator;
import micycle.pgs.color.ColorUtils;
import micycle.pgs.color.Colors;
+import micycle.pgs.commons.MarchingSquares;
import micycle.pgs.commons.PEdge;
import net.jafama.FastMath;
import processing.core.PConstants;
@@ -70,16 +73,22 @@
import processing.core.PVector;
/**
- * Methods for producing different kinds of shape contours. *
+ * Methods for producing interior contour structures from shapes.
+ *
*
- * 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.
+ * The algorithms in this class extract derived linework—such as
+ * medial/chordal axes, straight skeletons, isolines, and field-derived
+ * curves—computed from within the interior of a polygonal {@link PShape}. These
+ * results describe the shape’s internal topology or scalar fields (e.g.,
+ * distance-to-boundary), rather than directly editing the original boundary.
*
- * @author Michael Carleton
+ *
+ * Note: Outputs are typically vector linework (polylines) and may be
+ * returned as GROUP {@code PShape}s. Depending on geometry complexity, some
+ * methods may produce branching networks, multiple disjoint components, or
+ * degenerate segments.
*
+ * @author Michael Carleton
*/
public final class PGS_Contour {
@@ -140,7 +149,7 @@ public static PShape medialAxis(PShape shape, double axialThreshold, double dist
*
* In its primitive form, the chordal axis is constructed by joining the
* midpoints of the chords and the centroids of junction and terminal triangles
- * of the delaunay trianglution of a shape.
+ * of the delaunay triangulation of a shape.
*
* It can be considered a more useful alternative to the medial axis for
* obtaining skeletons of discrete shapes.
@@ -150,7 +159,6 @@ public static PShape medialAxis(PShape shape, double axialThreshold, double dist
* segment (possibly >2 vertices)
* @since 1.3.0
*/
- @SuppressWarnings("unchecked")
public static PShape chordalAxis(PShape shape) {
/*-
* See 'Rectification of the Chordal Axis Transform and a New Criterion for
@@ -533,7 +541,7 @@ public static Map isolines(Collection points, double int
* requirements of the application. Values in the
* range 5 to 40 are good candidates for
* investigation.
- * @return a map of {isoline -> height of the isoline}
+ * @return a map of {isoline (path) -> height of the isoline}
*/
public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax, int smoothing) {
final IncrementalTin tin = new IncrementalTin(intervalValueSpacing / 10);
@@ -563,7 +571,7 @@ public static Map isolines(Collection points, double int
isoline.setStroke(Colors.PINK);
PVector last = new PVector(Float.NaN, Float.NaN);
- isoline.beginShape();
+ isoline.beginShape(PConstants.PATH);
for (int i = 0; i < coords.length; i += 2) {
float vx = (float) coords[i];
float vy = (float) coords[i + 1];
@@ -585,42 +593,176 @@ public static Map isolines(Collection points, double int
}
/**
- * Generates vector contour lines representing a distance field derived from a
- * shape.
+ * Extracts contour lines (isolines) from a user-defined 2D “height map” over a
+ * rectangular region.
+ *
+ * You provide a function {@code f(x,y)} that returns a numeric value for every
+ * point. This method samples that function on a regular grid over
+ * {@code bounds}, then traces contour lines that connect points with the same
+ * value (like elevation contours on a map) using the Marching Squares
+ * algorithm.
+ *
+ * This is a very versatile way to turn simple math functions into computational
+ * patterns—ripples, bands, interference fields, cellular textures, etc.—without
+ * manually constructing geometry. The contour value range is determined
+ * automatically from the sampled minimum/maximum values.
+ *
+ * @param bounds Sampling bounds as {@code [xmin, ymin, xmax, ymax]}.
+ * @param sampleSpacing Grid spacing in coordinate units (smaller yields finer
+ * detail but is slower). 5 is sufficient for very high
+ * quality.
+ * @param contourInterval The value step between successive contour lines.
+ * @param valueFunction Function that returns the value at {@code (x,y)}.
+ * @return A map of isoline shapes to their corresponding contour (height)
+ * value.
+ * @since 2.2
+ */
+ public static PShape isolinesFromFunction(double[] bounds, double sampleSpacing, double contourInterval, DoubleBinaryOperator valueFunction) {
+ return isolinesFromFunction(bounds, sampleSpacing, contourInterval, valueFunction, Double.NaN, Double.NaN);
+ }
+
+ /**
+ * Extracts contour lines (isolines) from a user-defined 2D “height map” over a
+ * rectangular region, within a specified value range.
+ *
+ * You provide a function {@code f(x,y)} that returns a numeric value for every
+ * point. This method samples that function on a regular grid over
+ * {@code bounds}, then traces contour lines that connect points with the same
+ * value (like elevation contours on a map) using the Marching Squares
+ * algorithm.
+ *
+ * This is a very versatile way to turn simple math functions into computational
+ * patterns. Only contour lines with values in {@code [isolineMin, isolineMax]}
+ * are produced.
+ *
+ * @param bounds Sampling bounds as {@code [xmin, ymin, xmax, ymax]}.
+ * @param sampleSpacing Grid spacing in coordinate units (smaller yields finer
+ * detail but is slower). 5 is sufficient for very high
+ * quality.
+ * @param contourInterval The value step between successive contour lines.
+ * @param valueFunction Function that returns the value at {@code (x,y)}.
+ * @param isolineMin Minimum contour value (inclusive).
+ * @param isolineMax Maximum contour value (inclusive).
+ * @return A map of isoline shapes to their corresponding contour (height)
+ * value.
+ * @since 2.2
+ */
+ public static PShape isolinesFromFunction(double[] bounds, double sampleSpacing, double contourInterval, DoubleBinaryOperator valueFunction,
+ double isolineMin, double isolineMax) {
+ var isolines = MarchingSquares.isolines(bounds, sampleSpacing, contourInterval, isolineMin, isolineMax, valueFunction).keySet();
+
+ var out = PGS_Conversion.flatten(isolines);
+ PGS_Conversion.setAllStrokeColor(out, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
+
+ return out;
+ }
+
+ /**
+ * Extracts the zero contour (the 0-level set) from a user-defined 2D
+ * “height map” over a rectangular region.
+ *
+ * You provide a function {@code f(x,y)} that returns a numeric value for every
+ * point. This method samples that function on a regular grid over
+ * {@code bounds}, then traces the isoline where {@code f(x,y) = 0} using the
+ * Marching Squares algorithm.
*
- * 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.
+ * The resulting contour follows the boundary between positive and negative
+ * values of {@code f} (i.e., where the function crosses zero). This is useful
+ * for extracting implicit curves such as circles, signed-distance fields, and
+ * other zero-crossing patterns.
*
- * @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.
+ * @param bounds Sampling bounds as {@code [xmin, ymin, xmax, ymax]}.
+ * @param sampleSpacing Grid spacing in coordinate units (smaller yields finer
+ * detail but is slower). 5 is sufficient for very high
+ * quality.
+ * @param valueFunction Function that returns the value at {@code (x,y)}.
+ * @return A {@link PShape} containing all extracted zero-value isoline
+ * polylines within {@code bounds}.
+ * @since 2.2
+ */
+ public static PShape isolineZeroFromFunction(double[] bounds, double sampleSpacing, DoubleBinaryOperator valueFunction) {
+ var isolines = MarchingSquares.isolineZero(bounds, sampleSpacing, valueFunction).keySet();
+
+ var out = PGS_Conversion.flatten(isolines);
+ PGS_Conversion.setAllStrokeColor(out, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
+
+ return out;
+ }
+
+ /**
+ * Generates interior contour lines (isolines) that radiate from a shape
+ * “center”.
+ *
+ * The result resembles offset curves (inward parallels), but the underlying
+ * metric is not a pure boundary offset. Instead, contours are derived from a
+ * distance-like field that balances distance to the boundary with distance to
+ * an interior pole (chosen automatically), producing characteristic
+ * rings/levels emanating from the shape’s interior.
+ *
+ * @param shape A polygonal {@link PShape} to generate contours for.
+ * @param spacing The contour interval between successive lines.
+ * @return A {@code GROUP} {@link PShape} whose children form the contour set
+ * inside {@code shape}.
* @since 1.3.0
+ * @see #distanceField(PShape, double, PVector)
*/
public static PShape distanceField(PShape shape, double spacing) {
- Geometry g = fromPShape(shape);
- MedialAxis m = new MedialAxis(g);
-
- List disks = new ArrayList<>();
- double min = Double.POSITIVE_INFINITY;
- double max = Double.NEGATIVE_INFINITY;
- for (MedialDisk d : m.getDisks()) {
- disks.add(new PVector((float) d.position.x, (float) d.position.y, (float) d.distance));
- min = Math.min(d.distance, min);
- max = Math.max(d.distance, max);
- }
+ PVector mic = new PVector();
+ PGS_Optimisation.maximumInscribedCircle(shape, 1, mic);
+ return distanceField(shape, spacing, mic);
+ }
- 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 interior contour lines (isolines) that radiate from a specified
+ * pole point within a polygon.
+ *
+ * The result is similar in spirit to inward offset curves, but governed by a
+ * distance-like field that blends proximity to the boundary with proximity to
+ * the given {@code pole}. This tends to produce characteristic “rings”/levels
+ * centred on {@code pole}, clipped to the shape interior.
+ *
+ * @param shape A polygonal {@link PShape} to generate contours for.
+ * @param spacing The contour interval between successive lines.
+ * @param pole The point that the contours are oriented around (need not lie
+ * inside {@code shape}).
+ * @return A {@code GROUP} {@link PShape} whose children form the contour set
+ * inside {@code shape}.
+ * @since 2.2
+ */
+ public static PShape distanceField(PShape shape, double spacing, PVector pole) {
+ final Geometry g = fromPShape(shape);
+ final var svi = new SegmentVoronoiIndex((Polygon) g, Math.max(spacing / 5.0, 4));
+
+ DoubleBinaryOperator fn = (x, y) -> {
+ Coordinate c = new Coordinate(x, y);
+ double dGeo = svi.distanceToNearestSegment(c);
+ double dPoint = Math.sqrt((x - pole.x) * (x - pole.x) + (y - pole.y) * (y - pole.y));
+ return dGeo - dPoint; // no abs() as abs produces cusp where dGeo==dPoint
+ };
+
+ var env = g.getEnvelopeInternal();
+ env.expandBy(1);
+ double[] bounds = { env.getMinX(), env.getMinY(), env.getMaxX(), env.getMaxY() };
+
+ double sampleSpacing = Math.max(spacing / 10.0, 4); // heuristic
+ var contourMap = isolinesFromFunction(bounds, sampleSpacing, spacing, fn);
+
+ /*
+ * Experienced 'Overlay input is mixed-dimension' issue when intersecting
+ * geometry collection of isolines with g - so force to MultiLineString.
+ */
+
+ var contours = PGS_Conversion.getChildren(contourMap).stream().map(c -> {
+ var cg = fromPShape(c);
+ return cg.getGeometryType().equals(Geometry.TYPENAME_POLYGON) ? cg.getBoundary() : cg;
+ }).toArray(LineString[]::new);
+
+ var contourStrings = PGS.GEOM_FACTORY.createMultiLineString(contours);
+ var out = toPShape(OverlayNG.overlay(contourStrings, g, OverlayNG.INTERSECTION));
+
+ PGS_Conversion.setAllStrokeColor(out, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE);
+
+ return out;
}
/**
@@ -935,7 +1077,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci
}
final BufferParameters bufParams = new BufferParameters(8, BufferParameters.CAP_FLAT, style.style, BufferParameters.DEFAULT_MITRE_LIMIT);
-// bufParams.setSimplifyFactor(5); // can produce "poor" yet interesting results
+ // bufParams.setSimplifyFactor(5); // can produce "poor" yet interesting results
spacing = Math.max(1, Math.abs(spacing)); // ensure positive and >=1
spacing = outwards ? spacing : -spacing;
@@ -1009,8 +1151,8 @@ private static double[] generateDoubleSequence(double start, double end, double
* @param spacingY
* @return
*/
- private static ArrayList generateGrid(double minX, double minY, double maxX, double maxY, double spacingX, double spacingY) {
- ArrayList grid = new ArrayList<>();
+ private static List generateGrid(double minX, double minY, double maxX, double maxY, double spacingX, double spacingY) {
+ List grid = new ArrayList<>();
double[] y = generateDoubleSequence(minY, maxY, spacingY);
double[] x = generateDoubleSequence(minX, maxX, spacingX);
diff --git a/src/main/java/micycle/pgs/PGS_Conversion.java b/src/main/java/micycle/pgs/PGS_Conversion.java
index a0c4d055..94299b8e 100644
--- a/src/main/java/micycle/pgs/PGS_Conversion.java
+++ b/src/main/java/micycle/pgs/PGS_Conversion.java
@@ -59,8 +59,8 @@
import org.locationtech.jts.io.WKBWriter;
import org.locationtech.jts.io.WKTReader;
import org.locationtech.jts.io.WKTWriter;
-import org.locationtech.jts.io.geojson.GeoJsonReader;
-import org.locationtech.jts.io.geojson.GeoJsonWriter;
+import org.locationtech.jts.operation.polygonize.Polygonizer;
+import org.locationtech.jts.precision.GeometryPrecisionReducer;
import org.locationtech.jts.util.GeometricShapeFactory;
import org.scoutant.polyline.PolylineDecoder;
@@ -87,6 +87,25 @@
* Though certain conversion methods are utilised internally by the library,
* they have been kept public to cater to more complex user requirements.
*
+ * Closed-path semantics: Processing {@code PShape}s can be closed
+ * without unambiguously indicating whether they represent linework (a
+ * closed {@code LineString}) or an areal region (a {@code Polygon}). PGS
+ * resolves this ambiguity using the shape's {@code kind}:
+ *
+ * - A closed {@code PShape} with {@code kind == PConstants.POLYGON} is
+ * treated as polygonal and converts to a JTS {@code Polygon} (holes via
+ * contours are supported).
+ * - A {@code PShape} with {@code kind == PConstants.PATH} is treated as
+ * lineal and converts to a JTS {@code LineString}, even if closed.
+ * - If a shape is not closed, it is always treated as lineal and
+ * converts to a JTS {@code LineString}, regardless of {@code kind} (an unclosed
+ * {@code POLYGON} kind cannot form a valid JTS {@code Polygon}).
+ *
+ * When converting from JTS to {@code PShape}, {@link #toPShape(Geometry)}
+ * encodes these semantics by setting the output {@code PShape}'s {@code kind}
+ * to {@code POLYGON} for polygonal JTS geometries and {@code PATH} for lineal
+ * JTS geometries, ensuring round-trip stability.
+ *
* Note: JTS {@code Geometries} do not provide support for bezier curves. As
* such, bezier curves are linearised/divided into straight line segments during
* the conversion process from {@code PShape} to JTS {@code Geometry}.
@@ -96,14 +115,13 @@
* {@link #PRESERVE_STYLE} (set to true by default), and
* {@link #HANDLE_MULTICONTOUR} (set to false by default). Users are encouraged
* to review these flags as part of more complicated workflows with this class.
- *
+ *
* @author Michael Carleton
*/
public final class PGS_Conversion {
/** Approximate distance between successive sample points on bezier curves */
static final float BEZIER_SAMPLE_DISTANCE = 2;
- private static Field MATRIX_FIELD, PSHAPE_FILL_FIELD;
/**
* A boolean flag that affects whether a PShape's style (fillColor, strokeColor,
* strokeWidth) is preserved during PShape->Geometry->PShape
@@ -129,7 +147,41 @@ public final class PGS_Conversion {
* GitHub.
*/
public static boolean HANDLE_MULTICONTOUR = false;
+ /**
+ * When converting JTS {@link org.locationtech.jts.geom.Geometry Geometry} to a
+ * Processing {@link processing.core.PShape PShape} (inside
+ * {@link #toPShape(Geometry) toPShape()}), this flag controls whether PGS
+ * performs an explicit precision reduction step before writing
+ * vertices as floats.
+ *
+ * Processing {@code PShape} vertices are stored as 32-bit floats, so a
+ * double->float cast always introduces rounding. If this flag
+ * is false (default), PGS relies on that implicit rounding only.
+ *
+ * If this flag is true, PGS first snap-rounds each component
+ * geometry to a fixed grid of 1/1024 and then casts to float. This
+ * can reduce the (very rare) chance that float rounding breaks tight
+ * topological relationships (e.g. a conforming mesh where vertices/edges must
+ * match exactly), at the cost of intentionally coarsening coordinates even in
+ * cases where the float cast alone might have preserved more detail.
+ *
+ * Scope: This is currently applied only when converting
+ * multi-geometries / collections (i.e. {@code GeometryCollection},
+ * {@code MultiPolygon}, {@code MultiLineString}) containing more than one
+ * component geometry. These cases are more susceptible to float rounding
+ * causing adjacent components to no longer share identical boundary vertices
+ * after conversion.
+ *
+ * See {@link org.locationtech.jts.precision.GeometryPrecisionReducer
+ * GeometryPrecisionReducer} for more information.
+ *
+ * Default = false.
+ */
+ public static boolean FLOAT_SAFE_MESH_CONVERSION = false;
+
+ private static GeometryPrecisionReducer reducer = new GeometryPrecisionReducer(PGS.PM);
+ private static Field MATRIX_FIELD, PSHAPE_FILL_FIELD;
static {
try {
MATRIX_FIELD = PShape.class.getDeclaredField("matrix");
@@ -218,40 +270,68 @@ public static PShape toPShape(final Geometry g) {
} else {
shape.setFamily(GROUP);
for (int i = 0; i < g.getNumGeometries(); i++) {
- shape.addChild(toPShape(g.getGeometryN(i)));
+ Geometry child = g.getGeometryN(i);
+ if (FLOAT_SAFE_MESH_CONVERSION) {
+ Geometry reducedChild = reducer.reduce(child);
+ reducedChild.setUserData(child.getUserData());
+ child = reducedChild;
+ }
+ shape.addChild(toPShape(child));
}
}
break;
- // TODO treat closed linestrings as unfilled & unclosed paths?
- case Geometry.TYPENAME_LINEARRING : // LinearRings are closed by definition
- case Geometry.TYPENAME_LINESTRING : // LineStrings may be open
+ case Geometry.TYPENAME_LINEARRING : {
+ // LinearRings are closed by definition
+ // treat as lineal
+ final LineString ring = (LineString) g;
+ shape.setFamily(PShape.PATH);
+ shape.setFill(false);
+
+ shape.beginShape(PConstants.PATH); // encode polygonness
+ Coordinate[] coords = ring.getCoordinates();
+ // Skip the closing coordinate (same as first)
+ for (int i = 0; i < coords.length - 1; i++) {
+ shape.vertex((float) coords[i].x, (float) coords[i].y);
+ }
+ shape.endShape(PConstants.CLOSE);
+ break;
+ }
+
+ case Geometry.TYPENAME_LINESTRING : {
+ // LineStrings may be open or closed.
+ // always treat as linear path (no fill)
final LineString l = (LineString) g;
final boolean closed = l.isClosed();
+
shape.setFamily(PShape.PATH);
- shape.beginShape();
- Coordinate[] coords = l.getCoordinates();
- for (int i = 0; i < coords.length - (closed ? 1 : 0); i++) {
+ shape.setFill(false); // never fill LineStrings (even if closed)
+
+ shape.beginShape(PConstants.PATH); // encode lineal
+ final Coordinate[] coords = l.getCoordinates();
+
+ // If closed, skip the duplicated closing vertex
+ final int n = coords.length - (closed ? 1 : 0);
+ for (int i = 0; i < n; i++) {
shape.vertex((float) coords[i].x, (float) coords[i].y);
}
- if (closed) { // closed vertex was skipped, so close the path
- shape.endShape(PConstants.CLOSE);
+
+ if (closed) {
+ shape.endShape(PConstants.CLOSE); // close the path, still unfilled
} else {
- // shape is more akin to an unconnected line: keep as PATH shape, but don't fill
- // visually
shape.endShape();
- shape.setFill(false);
}
break;
+ }
case Geometry.TYPENAME_POLYGON :
final Polygon polygon = (Polygon) g;
shape.setFamily(PShape.PATH);
- shape.beginShape();
+ shape.beginShape(PConstants.POLYGON);
/*
* Outer and inner loops are iterated up to length-1 to skip the point that
* closes the JTS shape (same as the first point).
*/
- coords = polygon.getExteriorRing().getCoordinates();
+ Coordinate[] coords = polygon.getExteriorRing().getCoordinates();
for (int i = 0; i < coords.length - 1; i++) {
final Coordinate coord = coords[i];
shape.vertex((float) coord.x, (float) coord.y);
@@ -367,7 +447,7 @@ public static PShape toPShape(Collection extends Geometry> geometries) {
* unsupported.
*/
public static Geometry fromPShape(PShape shape) {
- Geometry g = GEOM_FACTORY.createEmpty(2);
+ Geometry g;
switch (shape.getFamily()) {
case PConstants.GROUP :
@@ -386,6 +466,8 @@ public static Geometry fromPShape(PShape shape) {
case PShape.PRIMITIVE :
g = fromPrimitive(shape); // (no holes)
break;
+ default :
+ throw new IllegalArgumentException("Unrecognised (invalid) PShape family type: " + shape.getFamily());
}
if (PRESERVE_STYLE && g != null) {
@@ -469,9 +551,10 @@ private static Geometry fromCreateShape(PShape shape) {
/**
* Extracts the contours from a POLYGON or PATH
- * PShape, represented as lists of PVector points. It extracts both the exterior
- * contour (perimeter) and interior contours (holes). For such PShape types, all
- * contours after the first are guaranteed to be holes.
+ * PShape, represented as lists of PVector points (having closing vertex). It
+ * extracts both the exterior contour (perimeter) and interior contours (holes).
+ * For such PShape types, all contours after the first are guaranteed to be
+ * holes.
*
* Background: The PShape data structure stores all vertices in a single array,
* with contour breaks designated in a separate array of vertex codes. This
@@ -528,7 +611,7 @@ public static List> toContours(PShape shape) {
continue;
default : // VERTEX
PVector v = shape.getVertex(i).copy();
- // skip consecutive duplicate vertices
+ // NOTE skip consecutive duplicate vertices
if (lastVertex == null || !(v.x == lastVertex.x && v.y == lastVertex.y)) {
rings.get(currentGroup).add(v);
lastVertex = v;
@@ -568,20 +651,28 @@ private static Geometry fromVertices(PShape shape) {
return GEOM_FACTORY.createPoint(outerRing[0]);
} else if (outerRing.length == 2) {
return GEOM_FACTORY.createLineString(outerRing);
- } else if (shape.isClosed()) { // closed geometry or path
- if (HANDLE_MULTICONTOUR) { // handle single shapes that *may* represent multiple shapes over many contours
- return fromMultiContourShape(rings, false, false);
- } else { // assume all contours beyond the first represent holes
- LinearRing outer = GEOM_FACTORY.createLinearRing(outerRing); // should always be valid
- LinearRing[] holes = new LinearRing[rings.size() - 1]; // Create linear ring for each hole in the shape
- for (int j = 1; j < rings.size(); j++) {
- final Coordinate[] innerCoords = rings.get(j);
- holes[j - 1] = GEOM_FACTORY.createLinearRing(innerCoords);
+ } else {
+ final boolean closed = shape.isClosed();
+ final int kind = shape.getKind();
+ final boolean hasHoles = contours.size() > 1;
+
+ // POLYGON kind only matters when closed (or when holes exist)
+ final boolean polygonal = hasHoles || (closed && kind == PConstants.POLYGON);
+
+ if (polygonal) {
+ if (HANDLE_MULTICONTOUR) {
+ return fromMultiContourShape(rings, false, false);
+ } else {
+ final LinearRing outer = GEOM_FACTORY.createLinearRing(outerRing);
+ final LinearRing[] holes = new LinearRing[rings.size() - 1];
+ for (int j = 1; j < rings.size(); j++) {
+ holes[j - 1] = GEOM_FACTORY.createLinearRing(rings.get(j));
+ }
+ return GEOM_FACTORY.createPolygon(outer, holes);
}
- return GEOM_FACTORY.createPolygon(outer, holes);
+ } else {
+ return GEOM_FACTORY.createLineString(outerRing);
}
- } else { // not closed
- return GEOM_FACTORY.createLineString(outerRing);
}
}
@@ -703,7 +794,7 @@ private static Geometry fromMultiContourShape(List contours, boole
"PGS_Conversion Error: Shape contour #%s was identified as a hole but no existing exterior rings contained it.", j));
}
}
- } else { // this ring is new polygon (or explictly contour #1)
+ } else { // this ring is new polygon (or explicitly contour #1)
ring = GEOM_FACTORY.createLinearRing(contourCoords);
if (previousRingIsHole) {
previousRingIsHole = false;
@@ -835,7 +926,7 @@ public static final PShape toPointsPShape(PVector... vertices) {
*/
public static final PShape toPointsPShape(Collection points) {
PShape shape = new PShape();
- shape.setFamily(PShape.GEOMETRY);
+ shape.setFamily(PShape.PATH);
shape.setStrokeCap(PConstants.ROUND);
shape.setStroke(true);
shape.setStroke(micycle.pgs.color.Colors.PINK);
@@ -916,17 +1007,52 @@ public static List toPVector(PShape shape) {
*/
public static SimpleGraph toGraph(PShape shape) {
final SimpleGraph graph = new SimpleWeightedGraph<>(PEdge.class);
+
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 int kind = child.getKind();
+
+ // Contour-aware handling (preserves holes as separate rings)
+ if (kind == PConstants.POLYGON || kind == PShape.PATH) {
+ final List> rings = toContours(child);
+
+ for (List ring : rings) {
+ final int n = ring.size();
+ if (n < 2) {
+ continue;
+ }
+
+ for (int i = 0; i < n - 1; i++) {
+ final PVector a = ring.get(i);
+ final PVector b = ring.get((i + 1));
+ if (a.equals(b)) {
+ continue;
+ }
+
+ final PEdge e = new PEdge(a, b);
+ graph.addVertex(a);
+ graph.addVertex(b);
+ graph.addEdge(a, b, e);
+ graph.setEdgeWeight(e, e.length());
+ }
+ }
+
+ continue;
+ }
+
+ // Original behavior for LINES and other non-contour shapes
+ final int stride = (kind == PConstants.LINES) ? 2 : 1;
+ final int vc = child.getVertexCount();
+ final int end = vc - (child.isClosed() ? 0 : 1);
+
+ for (int i = 0; i < end; i += stride) {
final PVector a = child.getVertex(i);
- final PVector b = child.getVertex((i + 1) % child.getVertexCount());
+ final PVector b = child.getVertex((i + 1) % vc);
if (a.equals(b)) {
continue;
}
- final PEdge e = new PEdge(a, b);
+ final PEdge e = new PEdge(a, b);
graph.addVertex(a);
graph.addVertex(b);
graph.addEdge(a, b, e);
@@ -939,15 +1065,20 @@ public static SimpleGraph toGraph(PShape shape) {
/**
* Converts a given SimpleGraph consisting of PVectors and PEdges into a PShape
- * by polygonizing its edges. If the graph represented a shape with holes, these
- * will not be preserved during the conversion.
+ * by polygonizing its edges. Nested rings are inferred as holes of the
+ * enclosing polygon, rather than returned as separate overlapping polygons.
*
* @param graph the graph to be converted into a PShape.
* @return a PShape representing the polygonized edges of the graph.
* @since 1.4.0
*/
public static PShape fromGraph(SimpleGraph graph) {
- return PGS.polygonizeNodedEdges(graph.edgeSet());
+ final Polygonizer polygonizer = new Polygonizer();
+ var edges = graph.edgeSet().stream().map(e -> PGS.createLineString(e.a, e.b)).toList();
+ polygonizer.add(edges);
+ @SuppressWarnings("unchecked")
+ List polys = (List) polygonizer.getPolygons();
+ return toPShape(PGS.dropHolePolygons(polys, false));
}
/**
@@ -1111,6 +1242,8 @@ static SimpleGraph toDualGraph(Collection meshFaces
* Writes the Well-Known Text representation of a shape. The
* Well-Known Text format is defined in the OGC Simple Features
* Specification for SQL.
+ *
+ * This variant uses single-precision floating point for output coordinates.
*
* @param shape shape to process
* @return a Geometry Tagged Text string
@@ -1119,8 +1252,29 @@ static SimpleGraph toDualGraph(Collection meshFaces
*/
public static String toWKT(PShape shape) {
WKTWriter writer = new WKTWriter(2);
- writer.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED)); // 1 d.p.
-// writer.setMaxCoordinatesPerLine(1);
+ writer.setPrecisionModel(new PrecisionModel(PrecisionModel.FLOATING_SINGLE));
+ // writer.setMaxCoordinatesPerLine(1);
+ return writer.writeFormatted(fromPShape(shape));
+ }
+
+ /**
+ * Writes the Well-Known Text representation of a shape. The
+ * Well-Known Text format is defined in the OGC Simple Features
+ * Specification for SQL.
+ *
+ * The precision of output coordinates is controlled via the
+ * decimalPlaces parameter. A larger value will preserve more
+ * digits after the decimal point.
+ *
+ * @param shape shape to process
+ * @return a Geometry Tagged Text string
+ * @since 2.2
+ * @see #fromWKT(String)
+ */
+ public static String toWKT(PShape shape, int decimalPlaces) {
+ decimalPlaces = Math.min(decimalPlaces, 10); // 10 max
+ WKTWriter writer = new WKTWriter(2);
+ writer.setPrecisionModel(new PrecisionModel(Math.pow(10, decimalPlaces - 1)));
return writer.writeFormatted(fromPShape(shape));
}
@@ -1282,36 +1436,17 @@ public static PShape fromEncodedPolyline(String encodedPolyline) {
coords.add(new Coordinate(x, y));
});
- return toPShape(GEOM_FACTORY.createLineString(coords.toCoordinateArray()));
- }
+ Coordinate[] coordArray = coords.toCoordinateArray();
+ boolean isClosed = coordArray.length > 1 && coordArray[0].equals2D(coordArray[coordArray.length - 1]);
- /**
- * Writes a shape into the string representation of its GeoJSON format.
- *
- * @param shape
- * @return json JSON string
- * @since 1.3.0
- */
- public static String toGeoJSON(PShape shape) {
- final GeoJsonWriter writer = new GeoJsonWriter(1);
- writer.setForceCCW(true);
- return writer.write(fromPShape(shape));
- }
-
- /**
- * Converts a GeoJSON representation of a shape into its PShape counterpart.
- *
- * @param json GeoJSON string
- * @return PShape represented by the GeoJSON
- * @since 1.3.0
- */
- public static PShape fromGeoJSON(String json) {
- final GeoJsonReader reader = new GeoJsonReader(GEOM_FACTORY);
- try {
- return toPShape(reader.read(json));
- } catch (ParseException e) {
- System.err.println("Error occurred when converting json to shape.");
- return new PShape();
+ /*
+ * NOTE Inherently ambiguous (did the closed polyline represent an areal or
+ * lineal geometry?). Treat closed polyline as polygon.
+ */
+ if (isClosed && coordArray.length >= 4) {
+ return toPShape(GEOM_FACTORY.createPolygon(coordArray));
+ } else {
+ return toPShape(GEOM_FACTORY.createLineString(coordArray));
}
}
@@ -1373,7 +1508,7 @@ public static PShape fromPVector(Collection vertices) {
shape.setStroke(closed ? Colors.PINK : Colors.WHITE);
shape.setStrokeWeight(2);
- shape.beginShape();
+ shape.beginShape(closed ? PConstants.POLYGON : PConstants.PATH);
for (int i = 0; i < verticesList.size() - (closed ? 1 : 0); i++) {
PVector v = verticesList.get(i);
shape.vertex(v.x, v.y);
@@ -1423,7 +1558,7 @@ public static PShape fromContours(List shell, @Nullable List
* A hull is the smallest enclosing shape of some nature that contains all
@@ -132,7 +132,7 @@ public static PShape concaveHull(PShape shapeSet, double concavity, boolean tigh
*
* @param points
* @param concavity a factor value between 0 and 1, specifying how concave the
- * output is (where 1 is maximal concavity)
+ * output is (where 0 is maximal concavity)
* @return
* @since 1.1.0
* @see #concaveHullBFS(List, double)
diff --git a/src/main/java/micycle/pgs/PGS_Meshing.java b/src/main/java/micycle/pgs/PGS_Meshing.java
index 2297e9a4..a3f9e82b 100644
--- a/src/main/java/micycle/pgs/PGS_Meshing.java
+++ b/src/main/java/micycle/pgs/PGS_Meshing.java
@@ -2,78 +2,83 @@
import static micycle.pgs.PGS_Conversion.fromPShape;
import static micycle.pgs.PGS_Conversion.getChildren;
+import static micycle.pgs.PGS_Conversion.toPShape;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
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;
import org.jgrapht.graph.DefaultEdge;
import org.jgrapht.graph.SimpleGraph;
-import org.locationtech.jts.algorithm.Orientation;
+import org.locationtech.jts.coverage.CoverageCleaner;
import org.locationtech.jts.coverage.CoverageSimplifier;
import org.locationtech.jts.coverage.CoverageValidator;
import org.locationtech.jts.geom.Coordinate;
-import org.locationtech.jts.geom.CoordinateList;
import org.locationtech.jts.geom.Geometry;
-import org.locationtech.jts.geom.Polygon;
-import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.noding.SegmentString;
-import org.locationtech.jts.operation.overlayng.OverlayNG;
+import org.locationtech.jts.operation.polygonize.Polygonizer;
import org.tinfour.common.IConstraint;
import org.tinfour.common.IIncrementalTin;
import org.tinfour.common.IQuadEdge;
import org.tinfour.common.SimpleTriangle;
import org.tinfour.common.Vertex;
import org.tinfour.utils.TriangleCollector;
-import org.tinspin.index.PointMap;
-import org.tinspin.index.kdtree.KDTree;
-import com.vividsolutions.jcs.conflate.coverage.CoverageCleaner;
-import com.vividsolutions.jcs.conflate.coverage.CoverageCleaner.Parameters;
-import com.vividsolutions.jump.feature.FeatureCollection;
-import com.vividsolutions.jump.feature.FeatureDatasetFactory;
-import com.vividsolutions.jump.feature.FeatureUtil;
-import com.vividsolutions.jump.task.DummyTaskMonitor;
+import com.github.micycle1.geoblitz.EndpointSnapper;
import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator;
import micycle.pgs.PGS_Conversion.PShapeData;
import micycle.pgs.color.Colors;
import micycle.pgs.commons.AreaMerge;
+import micycle.pgs.commons.EdgePrunedFaces;
import micycle.pgs.commons.IncrementalTinDual;
import micycle.pgs.commons.PEdge;
import micycle.pgs.commons.PMesh;
-import micycle.pgs.commons.RLFColoring;
import micycle.pgs.commons.SpiralQuadrangulation;
import processing.core.PConstants;
import processing.core.PShape;
import processing.core.PVector;
/**
- * Mesh generation (excluding triangulation) and processing.
+ * Mesh generation and mesh processing utilities (excluding triangulation).
+ *
+ *
+ * This class contains algorithms that operate on mesh-like representations
+ * derived from shapes (most commonly a Delaunay triangulation), producing
+ * alternative adjacency structures (graphs/faces), quad meshes, and cleaned or
+ * simplified meshes. In contrast to polygon-focused operations, these methods
+ * work with connectivity (vertices/edges/faces) as a first-class
+ * concern.
+ *
*
- * Many of the methods within this class process an existing Delaunay
- * triangulation; you may first generate such a triangulation from a shape using
- * the
- * {@link PGS_Triangulation#delaunayTriangulationMesh(PShape, Collection, boolean, int, boolean)
- * delaunayTriangulationMesh()} method.
- *
+ * Many methods expect an {@link IIncrementalTin} (a Delaunay TIN) or a
+ * {@link PShape} that encodes a mesh (often a GROUP of faces/edges). A typical
+ * workflow is:
+ *
+ * - Generate a triangulation via {@link PGS_Triangulation}.
+ * - Derive or transform connectivity (Gabriel / RNG / Urquhart / spanners /
+ * duals).
+ * - Optionally quadrangulate, smooth, simplify, subdivide, or repair the
+ * mesh.
+ *
+ *
* @author Michael Carleton
* @since 1.2.0
*/
@@ -110,32 +115,7 @@ private PGS_Meshing() {
* @see #gabrielFaces(IIncrementalTin, boolean)
*/
public static PShape urquhartFaces(final IIncrementalTin triangulation, final boolean preservePerimeter) {
- final HashSet edges = PGS.makeHashSet(triangulation.getMaximumEdgeAllocationIndex());
- final HashSet uniqueLongestEdges = PGS.makeHashSet(triangulation.getMaximumEdgeAllocationIndex());
-
- final boolean notConstrained = triangulation.getConstraints().isEmpty();
-
- TriangleCollector.visitSimpleTriangles(triangulation, t -> {
- final IConstraint constraint = t.getContainingRegion();
- if (notConstrained || (constraint != null && constraint.definesConstrainedRegion())) {
- edges.add(t.getEdgeA().getBaseReference());
- edges.add(t.getEdgeB().getBaseReference());
- edges.add(t.getEdgeC().getBaseReference());
- final IQuadEdge longestEdge = findLongestEdge(t).getBaseReference();
- if (!preservePerimeter || (preservePerimeter && !longestEdge.isConstraintRegionBorder())) {
- uniqueLongestEdges.add(longestEdge);
- }
- }
- });
-
- edges.removeAll(uniqueLongestEdges);
-
- 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.polygonizeNodedEdges(meshEdges);
-
- return removeHoles(mesh, triangulation);
+ return EdgePrunedFaces.urquhartFaces(triangulation, preservePerimeter);
}
/**
@@ -162,42 +142,7 @@ public static PShape urquhartFaces(final IIncrementalTin triangulation, final bo
* @see #urquhartFaces(IIncrementalTin, boolean)
*/
public static PShape gabrielFaces(final IIncrementalTin triangulation, final boolean preservePerimeter) {
- final HashSet edges = new HashSet<>();
- final HashSet vertices = new HashSet<>();
-
- final boolean notConstrained = triangulation.getConstraints().isEmpty();
- TriangleCollector.visitSimpleTriangles(triangulation, t -> {
- final IConstraint constraint = t.getContainingRegion();
- if (notConstrained || (constraint != null && constraint.definesConstrainedRegion())) {
- edges.add(t.getEdgeA().getBaseReference()); // add edge to set
- edges.add(t.getEdgeB().getBaseReference()); // add edge to set
- edges.add(t.getEdgeC().getBaseReference()); // add edge to set
- vertices.add(t.getVertexA());
- vertices.add(t.getVertexB());
- vertices.add(t.getVertexC());
- }
- });
-
- final PointMap tree = KDTree.create(2);
- vertices.forEach(v -> tree.insert(new double[] { v.x, v.y }, v));
-
- final HashSet nonGabrielEdges = new HashSet<>(); // base references to edges that should be removed
- edges.forEach(edge -> {
- final double[] midpoint = midpoint(edge);
- final Vertex near = tree.query1nn(midpoint).value();
- if (near != edge.getA() && near != edge.getB()) {
- if (!preservePerimeter || (preservePerimeter && !edge.isConstraintRegionBorder())) { // don't remove constraint borders (holes)
- nonGabrielEdges.add(edge); // base reference
- }
- }
- });
- edges.removeAll(nonGabrielEdges);
-
- 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.polygonizeNodedEdges(meshEdges);
- return removeHoles(mesh, triangulation);
+ return EdgePrunedFaces.gabrielFaces(triangulation, preservePerimeter);
}
/**
@@ -217,37 +162,7 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo
* @since 1.3.0
*/
public static PShape relativeNeighborFaces(final IIncrementalTin triangulation, final boolean preservePerimeter) {
- SimpleGraph graph = PGS_Triangulation.toTinfourGraph(triangulation);
- NeighborCache cache = new NeighborCache<>(graph);
-
- Set edges = new HashSet<>(graph.edgeSet());
-
- /*
- * If any vertex is nearer to both vertices of an edge, than the length of the
- * edge, this edge does not belong in the RNG.
- */
- graph.edgeSet().forEach(e -> {
- 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.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.isConstraintRegionBorder())) {
- edges.remove(e);
- }
- }
- });
- });
-
- List edgesOut = edges.stream().map(PGS_Triangulation::toPEdge).collect(Collectors.toList());
-
- PShape mesh = PGS.polygonizeNodedEdges(edgesOut);
- return removeHoles(mesh, triangulation);
+ return EdgePrunedFaces.relativeNeighborFaces(triangulation, preservePerimeter);
}
/**
@@ -265,26 +180,7 @@ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation,
* @since 1.3.0
*/
public static PShape spannerFaces(final IIncrementalTin triangulation, int k, final boolean preservePerimeter) {
- SimpleGraph graph = PGS_Triangulation.toGraph(triangulation);
- if (graph.edgeSet().isEmpty()) {
- return new PShape();
- }
-
- k = Math.max(2, k); // min(2) since k=1 returns triangulation
- GreedyMultiplicativeSpanner spanner = new GreedyMultiplicativeSpanner<>(graph, k);
- List spannerEdges = spanner.getSpanner().stream().collect(Collectors.toList());
- if (preservePerimeter) {
- 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::isConstraintRegionBorder).map(PGS_Triangulation::toPEdge)
- .collect(Collectors.toList()));
- }
- }
-
- PShape mesh = PGS.polygonizeNodedEdges(spannerEdges);
-
- return removeHoles(mesh, triangulation);
+ return EdgePrunedFaces.spannerFaces(triangulation, k, preservePerimeter);
}
/**
@@ -400,57 +296,7 @@ public static PShape splitQuadrangulation(final IIncrementalTin triangulation) {
* similar approach, but faster
*/
public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangulation, final boolean preservePerimeter) {
- /*-
- * From 'Fast unstructured quadrilateral mesh generation'.
- * A better coloring approach is given in 'Face coloring in unstructured CFD codes'.
- *
- * First partition the edges of the triangular mesh into three groups such that
- * no triangle has two edges of the same color (find groups by reducing to a
- * graph-coloring).
- * Then obtain an all-quadrilateral mesh by removing all edges of *one*
- * particular color.
- */
- final boolean unconstrained = triangulation.getConstraints().isEmpty();
- final AbstractBaseGraph graph = new SimpleGraph<>(DefaultEdge.class);
- TriangleCollector.visitSimpleTriangles(triangulation, t -> {
- final IConstraint constraint = t.getContainingRegion();
- if (unconstrained || (constraint != null && constraint.definesConstrainedRegion())) {
- graph.addVertex(t.getEdgeA().getBaseReference());
- graph.addVertex(t.getEdgeB().getBaseReference());
- graph.addVertex(t.getEdgeC().getBaseReference());
-
- graph.addEdge(t.getEdgeA().getBaseReference(), t.getEdgeB().getBaseReference());
- graph.addEdge(t.getEdgeA().getBaseReference(), t.getEdgeC().getBaseReference());
- graph.addEdge(t.getEdgeB().getBaseReference(), t.getEdgeC().getBaseReference());
- }
- });
-
- Coloring coloring = new RLFColoring<>(graph, 1337).getColoring();
-
- final HashSet perimeter = new HashSet<>(triangulation.getPerimeter());
- if (!unconstrained) {
- perimeter.clear(); // clear, the perimeter of constrained tin is unaffected by the constraint
- }
-
- final Collection meshEdges = new ArrayList<>();
- coloring.getColors().forEach((edge, color) -> {
- /*
- * "We can remove the edges of any one of the colors, however a convenient
- * choice is the one that leaves the fewest number of unmerged boundary
- * triangles". -- ideal, but not implemented here...
- */
- // NOTE could now apply Topological optimization, as given in paper.
- 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.polygonizeNodedEdges(meshEdges);
- if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole)
- return quads;
- } else {
- return removeHoles(quads, triangulation);
- }
+ return EdgePrunedFaces.edgeCollapseQuadrangulation(triangulation, preservePerimeter);
}
/**
@@ -471,33 +317,70 @@ public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangula
* @return a GROUP PShape, where each child shape is one quadrangle
* @since 1.2.0
*/
- public static PShape centroidQuadrangulation(final IIncrementalTin triangulation, final boolean preservePerimeter) {
- final boolean unconstrained = triangulation.getConstraints().isEmpty();
- final HashSet edges = new HashSet<>();
- TriangleCollector.visitSimpleTriangles(triangulation, t -> {
- final IConstraint constraint = t.getContainingRegion();
- if (unconstrained || (constraint != null && constraint.definesConstrainedRegion())) {
- Vertex centroid = centroid(t);
- edges.add(new PEdge(centroid.getX(), centroid.getY(), t.getVertexA().x, t.getVertexA().y));
- edges.add(new PEdge(centroid.getX(), centroid.getY(), t.getVertexB().x, t.getVertexB().y));
- edges.add(new PEdge(centroid.getX(), centroid.getY(), t.getVertexC().x, t.getVertexC().y));
+ public static PShape centroidQuadrangulation(final IIncrementalTin tin, final boolean preservePerimeter) {
+ final boolean unconstrained = tin.getConstraints().isEmpty();
+ final var gf = PGS.GEOM_FACTORY;
+
+ // Collect accepted triangles: centroids + base-edge adjacency
+ final List centroids = new ArrayList<>();
+ final Map edgeAdj = new IdentityHashMap<>();
+
+ TriangleCollector.visitSimpleTriangles(tin, t -> {
+ final IConstraint c = t.getContainingRegion();
+ if (!(unconstrained || (c != null && c.definesConstrainedRegion()))) {
+ return;
}
+
+ final int tid = centroids.size();
+ centroids.add(new double[] { (t.getVertexA().x + t.getVertexB().x + t.getVertexC().x) / 3.0,
+ (t.getVertexA().y + t.getVertexB().y + t.getVertexC().y) / 3.0 });
+
+ addAdj(edgeAdj, t.getEdgeA().getBaseReference(), tid);
+ addAdj(edgeAdj, t.getEdgeB().getBaseReference(), tid);
+ addAdj(edgeAdj, t.getEdgeC().getBaseReference(), tid);
});
- if (preservePerimeter) {
- List perimeter = triangulation.getPerimeter();
- triangulation.edges().forEach(edge -> {
- if (edge.isConstraintRegionBorder() || (unconstrained && perimeter.contains(edge))) {
- edges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y));
- }
- });
+ if (centroids.isEmpty()) {
+ return new PShape();
+ }
+
+ final List out = new ArrayList<>();
+ for (Map.Entry en : edgeAdj.entrySet()) {
+ final IQuadEdge e = en.getKey();
+ final int[] inc = en.getValue();
+ final Vertex A = e.getA(), B = e.getB();
+
+ if (inc[0] >= 0 && inc[1] >= 0) {
+ // Interior edge -> quad [A, c0, B, c1, A]
+ final double[] c0 = centroids.get(inc[0]);
+ final double[] c1 = centroids.get(inc[1]);
+ final Coordinate[] ring = new Coordinate[] { new Coordinate(A.x, A.y), new Coordinate(c0[0], c0[1]), new Coordinate(B.x, B.y),
+ new Coordinate(c1[0], c1[1]), new Coordinate(A.x, A.y) };
+ out.add(PGS_Conversion.toPShape(gf.createPolygon(ring)));
+ } else if (preservePerimeter) {
+ // Boundary edge -> triangle [A, c, B, A]
+ final int tid = inc[0] >= 0 ? inc[0] : inc[1];
+ final double[] c = centroids.get(tid);
+ final Coordinate[] tri = new Coordinate[] { new Coordinate(A.x, A.y), new Coordinate(c[0], c[1]), new Coordinate(B.x, B.y),
+ new Coordinate(A.x, A.y) };
+ out.add(PGS_Conversion.toPShape(gf.createPolygon(tri)));
+ }
}
- final PShape quads = PGS.polygonizeNodedEdges(edges);
- if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole)
- return quads;
+ final PShape quads = PGS_Conversion.flatten(out);
+ return quads;
+ }
+
+ private static void addAdj(Map adj, IQuadEdge base, int tid) {
+ int[] a = adj.get(base);
+ if (a == null) {
+ a = new int[] { -1, -1 };
+ adj.put(base, a);
+ }
+ if (a[0] < 0) {
+ a[0] = tid;
} else {
- return removeHoles(quads, triangulation);
+ a[1] = tid;
}
}
@@ -546,9 +429,9 @@ public static PShape matchingQuadrangulation(final IIncrementalTin triangulation
Set seen = new HashSet<>(g.vertexSet());
var quads = collapsedEdges.stream().map(e -> {
var t1 = g.getEdgeSource(e);
- var f1 = toPShape(t1);
+ var f1 = triToPShape(t1);
var t2 = g.getEdgeTarget(e);
- var f2 = toPShape(t2);
+ var f2 = triToPShape(t2);
seen.remove(t1);
seen.remove(t2);
@@ -558,80 +441,11 @@ public static PShape matchingQuadrangulation(final IIncrementalTin triangulation
// include uncollapsed triangles (if any)
seen.forEach(t -> {
- quads.add(toPShape(t));
+ quads.add(triToPShape(t));
});
- return PGS_Conversion.flatten(quads);
- }
-
- /**
- * Removes (what should be) holes from a polygonized quadrangulation.
- *
- * When the polygonizer is applied to the collapsed triangles of a
- * triangulation, it cannot determine which collapsed regions represent holes in
- * the quadrangulation and will consequently fill them in. The subroutine below
- * restores holes/topology, detecting which polygonized face(s) are original
- * holes. Note the geometry of the original hole/constraint and its associated
- * polygonized face are different, since quads are polygonized, not triangles
- * (hence an overlap metric is used to match candidates).
- *
- * @param faces faces of the quadrangulation
- * @param triangulation
- * @return
- */
- 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();
- holes.stream().map(constraint -> constraint.getVertices()).iterator().forEachRemaining(vertices -> {
- CoordinateList coords = new CoordinateList(); // coords of constraint
- vertices.forEach(v -> coords.add(new Coordinate(v.x, v.y)));
- coords.closeRing();
-
- if (!Orientation.isCCWArea(coords.toCoordinateArray())) { // triangulation holes are CW
- Polygon polygon = PGS.GEOM_FACTORY.createPolygon(coords.toCoordinateArray());
- tree.insert(polygon.getEnvelopeInternal(), polygon);
- }
- });
-
- List nonHoles = PGS_Conversion.getChildren(faces).parallelStream().filter(quad -> {
- /*
- * If quad overlaps with a hole detect whether it *is* that hole via Hausdorff
- * Similarity.
- */
- final Geometry g = PGS_Conversion.fromPShape(quad);
-
- @SuppressWarnings("unchecked")
- List matches = tree.query(g.getEnvelopeInternal());
-
- for (Polygon m : matches) {
- try {
- // PGS_ShapePredicates.overlap() inlined here
- Geometry overlap = OverlayNG.overlay(m, g, OverlayNG.INTERSECTION);
- double a1 = g.getArea();
- double a2 = m.getArea();
- double total = a1 + a2;
- double aOverlap = overlap.getArea();
- double w1 = a1 / total;
- double w2 = a2 / total;
-
- double similarity = w1 * (aOverlap / a1) + w2 * (aOverlap / a2);
- if (similarity > 0.2) { // magic constant, unsure what the best value is
- return false; // is hole; keep=false
- }
- } catch (Exception e) { // catch occasional noded error
- continue;
- }
-
- }
- return true; // is not hole; keep=true
- }).collect(Collectors.toList());
-
- return PGS_Conversion.flatten(nonHoles);
+ // sort faces so that output is structurally deterministic
+ return PGS_Optimisation.centroidSortFaces(PGS_Conversion.flatten(quads));
}
/**
@@ -764,9 +578,12 @@ public static PShape stochasticMerge(PShape mesh, int nClasses, long seed) {
* @since 1.4.0
*/
public static PShape smoothMesh(PShape mesh, int iterations, boolean preservePerimeter) {
+ // TODO smooth with enum for smoothing?
PMesh m = new PMesh(mesh);
for (int i = 0; i < iterations; i++) {
m.smoothTaubin(0.25, -0.251, preservePerimeter);
+ // m.smoothHC(0.33, 0.33, 0.33, preservePerimeter);
+ // m.smoothCotanWeighted(preservePerimeter);
}
return m.getMesh();
}
@@ -825,7 +642,7 @@ public static PShape smoothMesh(PShape mesh, double displacementCutoff, boolean
* the original.
* @param preservePerimeter whether to only simplify inner-boundaries and
* leaving outer boundary edges unchanged.
- * @return GROUP shape comprising the simplfied mesh faces
+ * @return GROUP shape comprising the simplified mesh faces
* @since 1.4.0
*/
public static PShape simplifyMesh(PShape mesh, double tolerance, boolean preservePerimeter) {
@@ -856,7 +673,7 @@ public static PShape simplifyMesh(PShape mesh, double tolerance, boolean preserv
* @param mesh The mesh containing faces to subdivide.
* @param edgeSplitRatio The distance ratio [0...1] along each edge where the
* faces are subdivided. A value of 0.5 is mid-edge
- * division (recommended value for a simple subvision).
+ * division (recommended value for a simple subdivision).
* @return A new GROUP PShape representing the subdivided mesh.
* @since 1.4.0
*/
@@ -981,40 +798,133 @@ static Pair, List> extractInnerEdgesAndVertices(PShape mesh
return Pair.of(new ArrayList<>(inner), innerVerts);
}
+ /**
+ * Convenience overload of {@link #fixBrokenFaces(PShape, double, boolean)} that
+ * performs endpoint-only snapping within {@code tolerance} and then polygonises
+ * the result ({@code polygonise = true}).
+ *
+ * @param coverage input coverage as a {@link PShape}; may include polygons and
+ * broken boundary lines
+ * @param tolerance maximum distance within which endpoints may be
+ * clustered/snapped
+ * @return a flattened {@link PShape} containing polygonal faces, and any
+ * remaining linework
+ * @see #fixBrokenFaces(PShape, double, boolean)
+ * @since 2.2
+ */
+ public static PShape fixBrokenFaces(PShape coverage, double tolerance) {
+ return fixBrokenFaces(coverage, tolerance, true);
+ }
+
+ /**
+ * Repairs broken faces in near-coverage linework using endpoint-only snapping,
+ * then polygonises the result.
+ *
+ * Targets face-level defects in a line arrangement that is intended to form a
+ * valid coverage but doesn’t quite join exactly (e.g., near-misses, tiny
+ * endpoint gaps, unclosed rings). It performs endpoint-only snapping within
+ * {@code tolerance} and then polygonises the result.
+ *
+ *
+ * - Performs Endpoint-only snapping (not general vertex snapping):
+ *
+ * - Only line endpoints move; interior vertices are not adjusted, and polygon
+ * vertices are never moved.
+ * - Endpoints (and polygon vertices) within {@code tolerance} form transitive
+ * clusters.
+ * - If a cluster contains any polygon vertex, endpoints snap to that vertex
+ * (polygons act as fixed anchors).
+ * - If a cluster contains only endpoints, they snap mutually to the cluster
+ * mean, closing gaps where no valid nodes exist yet.
+ * - Closed LineStrings are treated as polygons and ignored for snapping.
+ *
+ *
+ * - Polygonisation:
+ *
+ * - Builds faces from the snapped linework and returns a flattened shape
+ * containing faces, cut edges, and dangles.
+ *
+ *
+ *
+ *
+ * Outcome: This focuses on repairing faces from near-coverage linework. As a
+ * side effect, it often yields a valid or more valid coverage, but it is not a
+ * general cleaner for inter-face gaps/overlaps.
+ *
+ *
+ * For cleaning breaks between faces (gaps, overlaps, slivers, misaligned shared
+ * edges) in an existing coverage, use {@link #fixBreaks(PShape, double, double)
+ * fixBreaks()}.
+ *
+ *
+ * @param coverage input coverage as a {@link PShape}; may include polygons and
+ * broken boundary lines
+ * @param tolerance maximum distance within which endpoints may be
+ * clustered/snapped
+ * @return a flattened {@link PShape} containing polygonal faces, and any
+ * remaining linework
+ * @see #fixBreaks(PShape, double, double)
+ * @since 2.2
+ */
+ @SuppressWarnings("unchecked")
+ public static PShape fixBrokenFaces(PShape coverage, double tolerance, boolean polygonise) {
+ var g = fromPShape(coverage);
+ EndpointSnapper snapper = new EndpointSnapper(tolerance);
+ var fixed = snapper.snapEndpoints(g, true);
+
+ if (!polygonise) {
+ return toPShape(fixed);
+ }
+
+ Polygonizer p = new Polygonizer(false);
+ p.add(fixed);
+ var polys = toPShape(p.getPolygons());
+ var cuts = toPShape(p.getCutEdges());
+ var dangles = toPShape(p.getDangles());
+
+ return PGS_Conversion.flatten(polys, cuts, dangles);
+ }
+
/**
* Removes gaps and overlaps from meshes/polygon collections that are intended
* to satisfy the following conditions:
*
- * - Vector-clean - edges between neighbouring polygons must either be
+ *
- Vector-clean — edges between neighbouring polygons must either be
* identical or intersect only at endpoints.
- * - Non-overlapping - No two polygons may overlap. Equivalently, polygons
- * must be interior-disjoint.
+ * - Non-overlapping — no two polygons may overlap (polygons are
+ * interior-disjoint).
*
*
- * It may not always be possible to perfectly clean the input.
+ * Note: This operates on breaks between faces (inter-polygon gaps,
+ * overlaps, slivers, and misaligned shared edges), not on “broken” faces / line
+ * arrangements with unclosed lines or endpoint gaps. For repairing broken faces
+ * via endpoint-only snapping, see {@link #fixBrokenFaces(PShape, double)
+ * fixBrokenFaces()}.
+ *
*
- * While this method is intended to be used to fix malformed coverages, it also
- * can be used to snap collections of disparate polygons together.
- *
- * @param coverage a GROUP shape, consisting of the polygonal faces to
- * clean
- * @param distanceTolerance the distance below which segments and vertices are
- * considered to match
- * @param angleTolerance the maximum angle difference between matching
- * segments, in degrees
+ * It may not always be possible to perfectly clean the input. While this method
+ * is intended for malformed coverages, it can also snap collections of
+ * disparate polygons together.
+ *
+ *
+ * @param coverage a GROUP shape consisting of the polygonal faces to clean
+ * @param maxGapWidth the maximum width of the gaps that will be filled and
+ * merged
* @return GROUP shape whose child polygons satisfy a (hopefully) valid coverage
* @since 1.3.0
* @see #findBreaks(PShape)
+ * @see #fixBrokenFaces(PShape, double)
*/
- public static PShape fixBreaks(PShape coverage, double distanceTolerance, double angleTolerance) {
- final List geometries = PGS_Conversion.getChildren(coverage).stream().map(PGS_Conversion::fromPShape).collect(Collectors.toList());
- final FeatureCollection features = FeatureDatasetFactory.createFromGeometry(geometries);
+ public static PShape fixBreaks(PShape coverage, double maxGapWidth) {
+ Geometry[] geomsIn = PGS_Conversion.getChildren(coverage).stream().map(f -> fromPShape(f)).filter(q -> q != null).toArray(Geometry[]::new);
+
+ CoverageCleaner cleaner = new CoverageCleaner(geomsIn);
+ cleaner.setGapMaximumWidth(maxGapWidth);
+ cleaner.clean();
- final CoverageCleaner cc = new CoverageCleaner(features, new DummyTaskMonitor());
- cc.process(new Parameters(distanceTolerance, angleTolerance));
+ var geomsOut = PGS.GEOM_FACTORY.createGeometryCollection(cleaner.getResult());
- final List cleanedGeometries = FeatureUtil.toGeometries(cc.getUpdatedFeatures().getFeatures());
- final PShape out = PGS_Conversion.toPShape(cleanedGeometries);
+ final PShape out = PGS_Conversion.toPShape(geomsOut);
PGS_Conversion.setAllStrokeColor(out, Colors.PINK, 2);
return out;
}
@@ -1047,6 +957,33 @@ public static PShape findContainingFace(PShape mesh, PVector position) {
.orElse(null);
}
+ /**
+ * Identifies disconnected groups of faces (islands) within a mesh by analysing
+ * face adjacency relationships.
+ *
+ * The returned islands are sorted by the number of faces they contain in
+ * descending order, with the island containing the most faces appearing first.
+ *
+ * @param mesh a PShape of type GROUP representing the input mesh
+ * @return a list of PShape GROUP objects, each containing one connected island
+ * of faces, sorted from largest to smallest by face count. Returns an
+ * empty list if the mesh is empty or cannot be converted to a dual
+ * graph.
+ * @since 2.2
+ */
+ public static List findIslands(PShape mesh) {
+ var dual = PGS_Conversion.toDualGraph(mesh);
+
+ if (dual == null || dual.vertexSet().isEmpty()) {
+ return List.of();
+ }
+
+ var inspector = new ConnectivityInspector<>(dual);
+ var components = inspector.connectedSets();
+
+ return components.stream().map(PGS_Conversion::flatten).sorted(Comparator.comparingInt(PShape::getChildCount).reversed()).toList();
+ }
+
/**
* 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
@@ -1135,43 +1072,7 @@ private static PShape applyOriginalStyling(final PShape newMesh, final PShape ol
return newMesh;
}
- /**
- * Calculate the longest edge of a given triangle.
- */
- private static IQuadEdge findLongestEdge(final SimpleTriangle t) {
- if (t.getEdgeA().getLength() > t.getEdgeB().getLength()) {
- if (t.getEdgeC().getLength() > t.getEdgeA().getLength()) {
- return t.getEdgeC();
- } else {
- return t.getEdgeA();
- }
- } else {
- if (t.getEdgeC().getLength() > t.getEdgeB().getLength()) {
- return t.getEdgeC();
- } else {
- return t.getEdgeB();
- }
- }
- }
-
- private static double[] midpoint(final IQuadEdge edge) {
- final Vertex a = edge.getA();
- final Vertex b = edge.getB();
- return new double[] { (a.x + b.x) / 2d, (a.y + b.y) / 2d };
- }
-
- private static Vertex centroid(final SimpleTriangle t) {
- final Vertex a = t.getVertexA();
- final Vertex b = t.getVertexB();
- final Vertex c = t.getVertexC();
- double x = a.x + b.x + c.x;
- x /= 3;
- double y = a.y + b.y + c.y;
- y /= 3;
- return new Vertex(x, y, 0);
- }
-
- private static PShape toPShape(SimpleTriangle t) {
+ private static PShape triToPShape(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);
diff --git a/src/main/java/micycle/pgs/PGS_Morphology.java b/src/main/java/micycle/pgs/PGS_Morphology.java
index 783e2664..74b53c33 100644
--- a/src/main/java/micycle/pgs/PGS_Morphology.java
+++ b/src/main/java/micycle/pgs/PGS_Morphology.java
@@ -2,9 +2,12 @@
import static micycle.pgs.PGS_Conversion.fromPShape;
import static micycle.pgs.PGS_Conversion.toPShape;
-import java.util.ArrayList;
+import static micycle.pgs.PGS.GEOM_FACTORY;
+
+import java.util.Arrays;
import java.util.List;
import java.util.function.BiFunction;
+import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle;
import org.locationtech.jts.densify.Densifier;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.CoordinateList;
@@ -20,39 +23,53 @@
import org.locationtech.jts.linearref.LengthIndexedLine;
import org.locationtech.jts.operation.buffer.BufferOp;
import org.locationtech.jts.operation.buffer.BufferParameters;
-import org.locationtech.jts.operation.buffer.VariableBuffer;
import org.locationtech.jts.precision.GeometryPrecisionReducer;
import org.locationtech.jts.shape.CubicBezierCurve;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.simplify.TopologyPreservingSimplifier;
import org.locationtech.jts.simplify.VWSimplifier;
+import com.gihub.micycle1.malleo.Malleo;
+import com.github.micycle1.geoblitz.FastVariableBuffer;
+
import micycle.pgs.PGS_Contour.OffsetStyle;
import micycle.pgs.commons.ChaikinCut;
+import micycle.pgs.commons.ContourRegularization;
+import micycle.pgs.commons.ContourRegularization.Parameters;
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.FastAtan2;
import micycle.pgs.commons.GaussianLineSmoothing;
+import micycle.pgs.commons.HausdorffInterpolator;
import micycle.pgs.commons.LaneRiesenfeldSmoothing;
-import micycle.pgs.commons.ShapeInterpolation;
+import micycle.pgs.commons.NewtonThieleRingMorpher;
+import micycle.pgs.commons.SchneiderBezierFitter;
+import micycle.pgs.commons.VoronoiInterpolator;
import micycle.uniformnoise.UniformNoise;
+import net.jafama.FastMath;
import processing.core.PConstants;
import processing.core.PShape;
import processing.core.PVector;
import uk.osgb.algorithm.minkowski_sum.MinkowskiSum;
/**
- * Methods that affect the geometry or topology of shapes.
- *
- * @author Michael Carleton
+ * Morphological editing operations for {@link PShape} polygons.
*
+ *
+ * This class hosts algorithms that reshape geometry, typically by
+ * offsetting, simplifying, smoothing, warping, or deforming outlines; often
+ * changing vertex count and sometimes changing topology (splitting/merging
+ * parts, creating/removing holes).
+ *
+ * @author Michael Carleton
*/
public final class PGS_Morphology {
static {
- MinkowskiSum.setGeometryFactory(PGS.GEOM_FACTORY);
+ MinkowskiSum.setGeometryFactory(GEOM_FACTORY);
}
private PGS_Morphology() {
@@ -128,7 +145,7 @@ public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle
* Buffers a shape with a varying buffer distance (interpolated between a start
* distance and an end distance) along the shape's perimeter.
*
- * @param shape a single polygon or lineal shape
+ * @param shape a polygon, lineal shape, or GROUP containing such shapes
* @param startDistance the starting buffer amount
* @param endDistance the terminating buffer amount
* @return a polygonal shape representing the variable buffer region (which may
@@ -136,11 +153,10 @@ public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle
* @since 1.3.0
*/
public static PShape variableBuffer(PShape shape, double startDistance, double endDistance) {
- Geometry g = fromPShape(shape);
- if (!g.getGeometryType().equals(Geometry.TYPENAME_LINEARRING) && !g.getGeometryType().equals(Geometry.TYPENAME_LINESTRING)) {
- g = ((Polygon) g).getExteriorRing(); // variable buffer applies to linestrings only
- }
- return toPShape(VariableBuffer.buffer(g, startDistance, endDistance));
+ return PGS.applyToLinealGeometries(shape, line -> {
+ var buffer = (Polygon) FastVariableBuffer.buffer(line, startDistance, endDistance);
+ return buffer.getExteriorRing();
+ });
}
/**
@@ -160,9 +176,10 @@ public static PShape variableBuffer(PShape shape, double startDistance, double e
* }
*
*
- * @param shape A single polygon or lineal shape
+ * @param shape A single polygon, lineal shape, or GROUP containing
+ * such shapes
* @param bufferCallback A callback function that receives the vertex coordinate
- * and a double representing tractional distance (0...1)
+ * and a double representing fractional distance (0...1)
* of the vertex along the shape's boundary. The function
* may use properties of the vertex, or its position, to
* determine the buffer width at that point.
@@ -172,27 +189,71 @@ public static PShape variableBuffer(PShape shape, double startDistance, double e
* @since 2.0
*/
public static PShape variableBuffer(PShape shape, BiFunction bufferCallback) {
- final Geometry inputGeometry = fromPShape(shape);
- if (!(inputGeometry instanceof Lineal || inputGeometry instanceof Polygon)) {
- throw new IllegalArgumentException("The geometry must be linear or a non-multi polygonal shape.");
- }
- var coords = inputGeometry.getCoordinates();
- double[] bufferDistances = new double[coords.length];
- double totalLength = inputGeometry.getLength();
- double running_length = 0;
- Coordinate previousCoordinate = coords[0];
-
- for (int i = 1; i < coords.length; i++) {
- running_length += previousCoordinate.distance(coords[i]);
- double fractionalDistance = running_length / totalLength; // 0...1
- bufferDistances[i] = bufferCallback.apply(coords[i], fractionalDistance);
- previousCoordinate = coords[i];
- }
+ return PGS.applyToLinealGeometries(shape, line -> {
+ final Coordinate[] coords = line.getCoordinates();
+ if (coords.length == 0) {
+ // return an "empty buffer" geometry consistent with VariableBuffer expectations
+ return null;
+ }
+
+ final double totalLength = line.getLength();
+ final double[] bufferDistances = new double[coords.length];
+
+ // Guard against degenerate/zero-length lines (all points same).
+ if (totalLength == 0) {
+ final double d0 = bufferCallback.apply(coords[0], 0.0);
+ for (int i = 0; i < bufferDistances.length; i++) {
+ bufferDistances[i] = d0;
+ }
+ } else {
+ bufferDistances[0] = bufferCallback.apply(coords[0], 0.0);
+
+ double runningLength = 0;
+ Coordinate prev = coords[0];
- bufferDistances[0] = bufferCallback.apply(coords[0], 0.0);
+ for (int i = 1; i < coords.length; i++) {
+ runningLength += prev.distance(coords[i]);
+ final double fractionalDistance = runningLength / totalLength; // 0..1
+ bufferDistances[i] = bufferCallback.apply(coords[i], fractionalDistance);
+ prev = coords[i];
+ }
+ }
+
+ final var vb = new FastVariableBuffer(line, bufferDistances);
+ var buffer = (Polygon) vb.getResult();
+ return buffer.getExteriorRing();
+ });
+ }
+
+ /**
+ * Erodes (a negative buffer) a shape by a normalised amount (scaled to shape
+ * size).
+ *
+ * {@code amount} is dimensionless: {@code amount == 1} corresponds to a full
+ * erosion (approximately to the maximum inscribed radius), often collapsing
+ * polygons to empty. {@code shape} may be a {@code GROUP}; each polygonal
+ * element is processed independently. The sign of {@code amount} is ignored
+ * (always erodes).
+ *
+ * @param shape the source shape (polygonal or {@code GROUP})
+ * @param amount normalised erosion amount (dimensionless)
+ * @return a polygonal {@code PShape} of the eroded geometry (may be empty)
+ * @since 2.2
+ */
+ public static PShape normalisedErosion(PShape shape, double amount) {
+ double amt = -Math.abs(amount); // force erosion
+ var polys = PGS.extractPolygons(fromPShape(shape));
+ var buffered = polys.parallelStream().map(p -> {
+ var mic = new MaximumInscribedCircle(p, 0.5);
+ var r = mic.getRadiusLine().getLength() * (1 + 1e-3);
+ var buffer = amt * r;
+ var bufParams = createBufferParams(buffer, 0.5, OffsetStyle.ROUND, CapStyle.ROUND);
+ BufferOp b = new BufferOp(p, bufParams);
+ var out = b.getResultGeometry(buffer);
+ return out;
+ }).toList();
- VariableBuffer variableBuffer = new VariableBuffer(inputGeometry, bufferDistances);
- return toPShape(variableBuffer.getResult());
+ return toPShape(buffered);
}
/**
@@ -204,7 +265,7 @@ public static PShape variableBuffer(PShape shape, BiFunction {
- var coords = DiscreteCurveEvolution.process(ring, terminationCallback);
- return PGS.GEOM_FACTORY.createLineString(coords);
+ return DiscreteCurveEvolution.process(ring, terminationCallback);
});
}
@@ -340,7 +400,9 @@ public static PShape simplifyDCE(PShape shape, DCETerminationCallback terminatio
*
* @param shape the input shape
* @param relevanceThreshold the relevance threshold; only vertices with
- * relevance >= the threshold will be kept
+ * relevance >= the threshold will be kept. 20 is a
+ * good starting value for generally imperceptible
+ * simplification.
* @return the simplified PShape
* @since 2.1
*/
@@ -429,6 +491,13 @@ public static PShape minkDifference(PShape source, PShape subtract) {
* Smoothes a shape. The smoothing algorithm inserts new vertices which are
* positioned using Bezier splines. The output shape tends to be a little larger
* than the input.
+ *
+ * Note: this method effectively constructs a Bezier curve through the existing
+ * vertices. As a result, if the input geometry already has very dense / closely
+ * spaced vertices, the smoothing may have little or no perceptual effect. This
+ * differs from other smoothing approaches (e.g. Gaussian) that operate at a
+ * spatial scale and are therefore largely invariant to vertex density.
+ *
*
* @param shape shape to smooth
* @param alpha curvedness parameter (0 is linear, 1 is round, >1 is
@@ -441,6 +510,47 @@ public static PShape smooth(PShape shape, double alpha) {
return toPShape(curve);
}
+ /**
+ * Smoothes a shape by fitting one or more cubic Bezier curve segments
+ * to each lineal component (polylines and polygon rings), then
+ * resampling the fitted Beziers to produce a new vertex sequence.
+ *
+ * This method uses Philip J. Schneider’s curve fitting algorithm. Unlike
+ * {@link #smooth(PShape, double) smooth()}, which constructs a Bezier curve
+ * through the existing vertices, this method approximates the input
+ * within a user-specified tolerance and can substantially simplify noisy or
+ * densely-vertexed input while producing a visually smoother result.
+ *
+ *
+ * The {@code maxDeviation} parameter controls how closely the fitted Bezier(s)
+ * must follow the original polyline/ring: smaller values preserve the original
+ * shape more strictly (often producing more Bezier segments and/or more output
+ * vertices), while larger values allow a smoother, more generalised result.
+ *
+ *
+ * Implementation note: the fitted Bezier segments are sampled at a fixed
+ * spacing (currently 2 units in the coordinate system of the input geometry) to
+ * create the returned JTS geometry, which is then converted back to a
+ * {@link PShape}.
+ *
+ *
+ * @param shape shape whose lineal geometry (LineStrings and polygon
+ * rings) will be Bezier-fit and resampled
+ * @param maxDeviation maximum allowed deviation (error tolerance) between the
+ * input vertices and the fitted Bezier curve(s); must be
+ * {@code > 0}
+ * @return a smoothed copy of {@code shape} produced by piecewise cubic Bezier
+ * fitting and resampling
+ *
+ * @since 2.2
+ * @see SchneiderBezierFitter
+ */
+ public static PShape smoothBezierFit(PShape shape, double maxDeviation) {
+ return PGS.applyToLinealGeometries(shape, ring -> {
+ return SchneiderBezierFitter.fitAndSample(ring, maxDeviation, PGS_Conversion.BEZIER_SAMPLE_DISTANCE);
+ });
+ }
+
/**
* Smoothes a shape by applying a gaussian filter to vertex coordinates. At
* larger values, this morphs the input shape much more visually than
@@ -450,12 +560,29 @@ public static PShape smooth(PShape shape, double alpha) {
* @param sigma The standard deviation of the gaussian kernel. Larger values
* provide more smoothing.
* @return smoothed copy of the shape
+ * @see #smoothGaussianNormalised(PShape, double)
* @see #smooth(PShape, double)
*/
public static PShape smoothGaussian(PShape shape, double sigma) {
return PGS.applyToLinealGeometries(shape, ring -> GaussianLineSmoothing.get(ring, sigma));
}
+ /**
+ * Applies Gaussian smoothing to each lineal geometry in a {@link PShape} using
+ * a normalised amount in [0..1], intended to be scale-invariant across children
+ * of different sizes. {@code amount=0} leaves geometry unchanged;
+ * {@code amount=1} collapses (per geometry) using the extreme-sigma fallback.
+ *
+ * @param shape input shape
+ * @param amount normalised smoothing amount in [0..1]
+ * @return new shape with smoothed lineal components
+ * @see #smoothGaussian(PShape, double)
+ * @since 2.2
+ */
+ public static PShape smoothGaussianNormalised(PShape shape, double amount) {
+ return PGS.applyToLinealGeometries(shape, ring -> GaussianLineSmoothing.getNormalised(ring, amount));
+ }
+
/**
* Calculates the Elliptic Fourier Descriptors (EFD) of a specified shape,
* yielding a simplified/smoothed shape representation based on the specified
@@ -489,7 +616,7 @@ public static PShape smoothEllipticFourier(PShape shape, int descriptors) {
if (ring.isClosed()) {
final EllipticFourierDesc efd = new EllipticFourierDesc((LinearRing) ring, descriptorz);
Coordinate[] coords = efd.createPolygon();
- return PGS.GEOM_FACTORY.createLinearRing(coords);
+ return GEOM_FACTORY.createLinearRing(coords);
} else {
return null; // open linestrings not supported
}
@@ -608,19 +735,30 @@ public static PShape chaikinCut(PShape shape, double ratio, int iterations) {
}
/**
- * Distorts a polygonal shape by radially displacing its vertices along the line
- * connecting each vertex with the shape's centroid, creating a warping or
+ * Radially warps a polygon by moving each boundary vertex inward/outward along
+ * the ray from the polygon centroid to that vertex, creating a warping or
* perturbing effect.
*
- * The shape's input vertices can optionally be densified prior to the warping
- * operation.
+ * Optionally, the input boundary can be densified before warping by inserting
+ * additional vertices at a spacing of ~1 unit. This causes long edges to warp
+ * smoothly along their full length rather than only at the original corner
+ * vertices.
*
- * @param shape A polygonal PShape object to be distorted.
- * @param magnitude The degree of the displacement, which determines the
- * maximum Euclidean distance a vertex will be moved in
- * relation to the shape's centroid.
- * @param warpOffset An offset angle, which establishes the starting angle for
- * the displacement process.
+ * @param shape A polygonal {@link PShape} (or GROUP of polygons) to be
+ * distorted. The warp is applied to each polygon ring
+ * independently.
+ * @param magnitude Controls the strength of the warp. Larger values produce
+ * larger radial displacements from the original boundary
+ * (i.e., larger inward/outward movement). A value of
+ * {@code 0} produces an unchanged shape.
+ * @param warpOffset An angular phase offset (in radians) added to each vertex's
+ * polar angle before sampling the noise field. Changing
+ * {@code warpOffset} does not change the warp magnitude; it
+ * rotates the noise pattern around the centroid (i.e., shifts
+ * where bulges/indentations occur along the boundary). This
+ * is useful for animation by incrementing {@code warpOffset}
+ * over time. The warp has a period of 2π. A typical/useful
+ * domain is {@code [0, 2*Math.PI)}.
* @param densify A boolean parameter determining whether the shape should be
* densified (by inserting additional vertices at a distance
* of 1) before warping. If true, shapes with long edges will
@@ -630,39 +768,69 @@ public static PShape chaikinCut(PShape shape, double ratio, int iterations) {
* specified parameters.
*/
public static PShape radialWarp(PShape shape, double magnitude, double warpOffset, boolean densify) {
- Geometry g = fromPShape(shape);
- if (!g.getGeometryType().equals(Geometry.TYPENAME_POLYGON)) {
- System.err.println("radialWarp() expects (single) polygon input. The geometry resolved to a " + g.getGeometryType());
- return shape;
- }
+ final UniformNoise noise = new UniformNoise(1337);
+
+ return PGS.applyToLinealGeometries(shape, line -> {
- final Point point = g.getCentroid();
- final PVector c = new PVector((float) point.getX(), (float) point.getY());
+ // radialWarp is defined for polygon rings; if we get an open line, just return
+ // it unchanged
+ if (!line.isClosed()) {
+ return line;
+ }
+ final Point centroid = line.getCentroid();
+ final PVector c = new PVector((float) centroid.getX(), (float) centroid.getY());
+
+ Geometry working = line;
+ if (densify) {
+ final Densifier d = new Densifier(line);
+ d.setDistanceTolerance(1);
+ d.setValidate(false);
+ working = d.getResultGeometry();
+ }
- final List coords;
+ final Coordinate[] coords = working.getCoordinates();
+ if (coords.length == 0) {
+ return line;
+ }
- if (densify) {
- final Densifier d = new Densifier(fromPShape(shape));
- d.setDistanceTolerance(1);
- d.setValidate(false);
- coords = PGS_Conversion.toPVector(toPShape(d.getResultGeometry()));
- } else {
- coords = PGS_Conversion.toPVector(shape);
- }
+ // Warp all unique vertices; then explicitly re-close
+ final int n = coords.length;
+ for (int i = 0; i < n - 1; i++) { // ignore last coordinate (closure); we re-close after warping
+ final double x = coords[i].x;
+ final double y = coords[i].y;
- final UniformNoise noise = new UniformNoise(1337);
- coords.forEach(coord -> {
- PVector heading = PVector.sub(coord, c); // vector from center to each vertex
- final double angle = heading.heading() + warpOffset;
- float perturbation = noise.uniformNoise(Math.cos(angle), Math.sin(angle));
- perturbation -= 0.5f; // [0...1] -> [-0.5...0.5]
- perturbation *= magnitude * 2;
- coord.add(heading.normalize().mult(perturbation)); // add perturbation to vertex
+ double dx = x - c.x;
+ double dy = y - c.y;
+
+ final double len = Math.sqrt(dx * dx + dy * dy);
+ if (len == 0) {
+ continue; // vertex at centroid
+ }
+
+ final double angle = FastAtan2.atan2(dy, dx) + warpOffset;
+
+ float perturbation = noise.uniformNoise(FastMath.cos(angle), FastMath.sin(angle));
+ perturbation -= 0.5f; // [0..1] -> [-0.5..0.5]
+ perturbation *= (float) (magnitude * 2.0);
+
+ // normalize heading and displace
+ dx /= len;
+ dy /= len;
+
+ coords[i].x = x + dx * perturbation;
+ coords[i].y = y + dy * perturbation;
+ }
+
+ // ensure exact closure
+ coords[n - 1].x = coords[0].x;
+ coords[n - 1].y = coords[0].y;
+
+ // preserve ring-ness if possible
+ if (line instanceof LinearRing) {
+ return GEOM_FACTORY.createLinearRing(coords);
+ }
+ return GEOM_FACTORY.createLineString(coords);
});
- if (!coords.get(0).equals(coords.get(coords.size() - 1))) {
- coords.add(coords.get(0));
- }
- return PGS_Conversion.fromPVector(coords);
}
/**
@@ -697,7 +865,7 @@ public static PShape sineWarp(PShape shape, double magnitude, double frequency,
}
coords.closeRing();
- Geometry out = GeometryFixer.fix(PGS.GEOM_FACTORY.createPolygon(coords.toCoordinateArray()));
+ Geometry out = GeometryFixer.fix(GEOM_FACTORY.createPolygon(coords.toCoordinateArray()));
return PGS_Conversion.toPShape(out);
}
@@ -772,29 +940,42 @@ 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()) {
- int offset = 0; // child.isClosed() ? 0 : 1
- for (int i = offset; i < child.getVertexCount() - offset; i++) {
+ int vCount = child.getVertexCount();
+ if (vCount == 0)
+ continue;
+
+ // Determine if the shape is closed.
+ boolean isClosed = child.isClosed() || (vCount > 1 && child.getVertex(0).equals(child.getVertex(vCount - 1)));
+
+ // If closed, we iterate up to N-1 and handle the last vertex separately to
+ // ensure closure.
+ int limit = isClosed ? vCount - 1 : vCount;
+
+ for (int i = 0; i < limit; 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;
child.setVertex(i, coord.x + (dx * (float) magnitude * 2), coord.y + (dy * (float) magnitude * 2));
}
+
+ // If the shape was closed, sync the last vertex with the newly warped first
+ // vertex.
+ if (isClosed && vCount > 1) {
+ PVector firstV = child.getVertex(0);
+ child.setVertex(vCount - 1, firstV.x, firstV.y);
+ }
}
if (pointsShape) {
return copy;
} else {
if (copy.getChildCount() == 1) {
+ // Fix self-intersections or invalid geometries caused by warping
return toPShape(GeometryFixer.fix(fromPShape(copy.getChild(0))));
} else {
- // don't apply geometryFixer to GROUP shape, since fixing a multigeometry
- // appears to merge shapes. TODO apply .fix() to shapes individually
+ // Return group as-is (fixing individual children would be safer but requires a
+ // loop)
return copy;
}
}
@@ -816,32 +997,51 @@ public static PShape fieldWarp(PShape shape, double magnitude, double noiseScale
* @since 2.0
*/
public static PShape pinchWarp(PShape shape, PVector pinchPoint, double weight) {
- List vertices = new ArrayList<>(shape.getVertexCount());
- for (int i = 0; i < shape.getVertexCount(); i++) {
- PVector vertex = shape.getVertex(i).copy();
- float distance = PVector.dist(vertex, pinchPoint);
- float w = (float) (weight / (distance + 1));
- PVector direction = PVector.sub(pinchPoint, vertex);
- direction.mult(w);
- vertex.add(direction);
- vertices.add(vertex);
- }
- if (shape.isClosed()) {
- vertices.add(vertices.get(0));
- }
- return PGS_Conversion.fromPVector(vertices);
+ return PGS.applyToLinealGeometries(shape, line -> {
+ final var gf = line.getFactory();
+ final var coords = line.getCoordinates();
+
+ if (coords.length == 0) {
+ return line;
+ }
+
+ final boolean closed = line.isClosed();
+
+ for (int i = 0; i < coords.length; i++) {
+ // if closed, we'll re-close explicitly after warping to avoid drift
+ if (closed && i == coords.length - 1) {
+ break;
+ }
+
+ final double x = coords[i].x;
+ final double y = coords[i].y;
+
+ final double dx = pinchPoint.x - x;
+ final double dy = pinchPoint.y - y;
+
+ final double distance = Math.sqrt(dx * dx + dy * dy);
+ final double w = weight / (distance + 1.0);
+
+ coords[i].x = x + dx * w;
+ coords[i].y = y + dy * w;
+ }
+
+ if (closed) {
+ coords[coords.length - 1].x = coords[0].x;
+ coords[coords.length - 1].y = coords[0].y;
+ }
+
+ return gf.createLineString(coords);
+ });
}
/**
* Generates an intermediate shape between two shapes by interpolating between
- * them. This process has many names: shape morphing / blending / averaging /
- * tweening / interpolation.
- *
- * The underlying technique rotates one of the shapes to minimise the total
- * distance between each shape's vertices, then performs linear interpolation
- * between vertices. This performs well in practice but the outcome worsens as
- * shapes become more concave; more sophisticated techniques would employ some
- * level of rigidity preservation.
+ * their exterior rings. This process has many names: shape morphing / blending
+ * / averaging / tweening / interpolation.
+ *
+ * Note the interpolated shape may self-intersect (this implementation is not
+ * "rigid").
*
* @param from a single polygon; the shape we want to morph from
* @param to a single polygon; the shape we want to morph
@@ -850,53 +1050,215 @@ public static PShape pinchWarp(PShape shape, PVector pinchPoint, double weight)
* @return a polygonal PShape
* @since 1.2.0
* @see #interpolate(PShape, PShape, int)
+ * @implNote Uses {@link NewtonThieleRingMorpher} for higher-quality
+ * interpolation.
*/
public static PShape interpolate(PShape from, PShape to, double interpolationFactor) {
- final Geometry fromGeom = fromPShape(from);
- final Geometry toGeom = fromPShape(to);
- if (toGeom.getGeometryType().equals(Geometry.TYPENAME_POLYGON) && fromGeom.getGeometryType().equals(Geometry.TYPENAME_POLYGON)) {
- final ShapeInterpolation tween = new ShapeInterpolation(fromGeom, toGeom);
- return toPShape(PGS.GEOM_FACTORY.createPolygon(tween.tween(interpolationFactor)));
- } else {
- System.err.println("interpolate() accepts holeless single polygons only (for now).");
- return from;
- }
+ return interpolate(List.of(from, to), interpolationFactor);
+ }
+
+ /**
+ * Generates an intermediate shape from a sequence of input shapes by
+ * interpolating (morphing) between their exterior rings.
+ *
+ * This is a generalisation of {@link #interpolate(PShape, PShape, double)} to
+ * more than two shapes. The interpolation follows the order of {@code shapes}.
+ *
+ * Note the interpolated shape may self-intersect (this implementation is not
+ * "rigid").
+ *
+ * @param shapes a list of single-polygon {@link PShape}s; only the
+ * exterior ring is used.
+ * @param interpolationFactor interpolation parameter in the range
+ * {@code [0..1]}
+ * @return a polygonal {@link PShape} representing the interpolated shape
+ * @since 2.2.0
+ * @see #interpolate(PShape, PShape, double)
+ * @implNote Uses {@link NewtonThieleRingMorpher} for higher-quality
+ * interpolation.
+ */
+ public static PShape interpolate(List shapes, double interpolationFactor) {
+ var rings = shapes.stream().map(s -> ((Polygon) fromPShape(s)).getExteriorRing()).toArray(LinearRing[]::new);
+ NewtonThieleRingMorpher m = new NewtonThieleRingMorpher(rings);
+ var tween = m.interpolate(interpolationFactor);
+ return toPShape(tween);
}
/**
- * Generates intermediate shapes (frames) between two shapes by interpolating
- * between them. This process has many names: shape morphing / blending /
+ * Generates intermediate shapes (frames) by interpolating (morphing) through a
+ * sequence of shapes. This process has many names: shape morphing / blending /
* averaging / tweening / interpolation.
*
- * This method is faster than calling
- * {@link #interpolate(PShape, PShape, double) interpolate()} repeatedly for
- * different interpolation factors.
- *
- * @param from a single polygon; the shape we want to morph from
- * @param to a single polygon; the shape we want to morph from
- * into
- * @param frames the number of frames (including first and last) to generate. >=
- * 2
- * @return a GROUP PShape, where each child shape is a frame from the
- * interpolation
- * @since 1.3.0
+ * The returned frames include both endpoints: the first frame corresponds to
+ * {@code t = 0} (the first shape in {@code shapes}) and the last frame
+ * corresponds to {@code t = 1} (the last shape in {@code shapes}). Intermediate
+ * frames are evenly spaced in {@code [0..1]} using {@code t = i/(frames-1)}.
+ *
+ * This method is faster than calling {@link #interpolate(List, double)} (or
+ * {@link #interpolate(PShape, PShape, double)}) repeatedly for different
+ * interpolation factors.
+ *
+ * @param shapes a list of single-polygon {@link PShape}s, in the order they
+ * should be morphed through; only the exterior ring is used.
+ * @param frames the number of frames (including first and last) to generate;
+ * must be {@code >= 2}
+ * @return a GROUP {@link PShape} whose children are the generated frames
+ * @since 2.2.0
+ * @see #interpolate(List, double)
* @see #interpolate(PShape, PShape, double)
*/
- public static PShape interpolate(PShape from, PShape to, int frames) {
- final Geometry fromGeom = fromPShape(from);
- final Geometry toGeom = fromPShape(to);
- if (toGeom.getGeometryType().equals(Geometry.TYPENAME_POLYGON) && fromGeom.getGeometryType().equals(Geometry.TYPENAME_POLYGON)) {
- final ShapeInterpolation tween = new ShapeInterpolation(fromGeom, toGeom);
- final float fraction = 1f / (frames - 1);
- PShape out = new PShape();
- for (int i = 0; i < frames; i++) {
- out.addChild(toPShape(PGS.GEOM_FACTORY.createPolygon(tween.tween(fraction * i))));
- }
- return out;
- } else {
- System.err.println("interpolate() accepts holeless single polygons only (for now).");
- return from;
+ public static PShape interpolate(List shapes, int frames) {
+ var rings = shapes.stream().map(s -> ((Polygon) fromPShape(s)).getExteriorRing()).toArray(LinearRing[]::new);
+ NewtonThieleRingMorpher m = new NewtonThieleRingMorpher(rings);
+
+ final double fraction = 1d / (frames - 1);
+ PShape out = new PShape();
+ for (int i = 0; i < frames; i++) {
+ out.addChild(toPShape(GEOM_FACTORY.createPolygon(m.interpolate(fraction * i))));
}
+
+ return out;
+ }
+
+ /**
+ * Interpolates ("morphs") between two shapes using a Hausdorff-distance based
+ * dilation approach.
+ *
+ * The intermediate shape is computed by buffering each input by a complementary
+ * amount (based on the estimated Hausdorff distance between the shapes) and
+ * intersecting the two buffers. This provides a correspondence-free morph that
+ * works even when the inputs have different vertex counts, components, or
+ * holes.
+ *
+ * @param from the starting shape (α = 0)
+ * @param to the ending shape (α = 1)
+ * @param morphFactor the interpolation parameter α (in {@code [0,1]})
+ * @return a new {@code PShape} representing the Hausdorff morph between
+ * {@code from} and {@code to}
+ * @since 2.2
+ */
+ public static PShape dilationMorph(PShape from, PShape to, double morphFactor) {
+ var gFrom = fromPShape(from);
+ var gTo = fromPShape(to);
+ var i = HausdorffInterpolator.interpolateUsingEstimatedHausdorff(gFrom, gTo, morphFactor, 1, 15);
+ return toPShape(i);
+ }
+
+ /**
+ * Computes the Voronoi-based Hausdorff morph between two shapes.
+ *
+ * Convenience overload for
+ * {@link #voronoiMorph(PShape, PShape, double, double, boolean)} using default
+ * parameters.
+ *
+ * Uses {@code maxSegmentLength = 0} (no boundary densification) and
+ * {@code unionResult = true} (returns a cleaned area geometry).
+ *
+ * @param from the starting shape (α = 0)
+ * @param to the ending shape (α = 1)
+ * @param morphFactor the morph parameter α, in {@code [0,1]}
+ * @return a new {@code PShape} representing the Voronoi Hausdorff morph between
+ * {@code from} and {@code to}
+ * @see #voronoiMorph(PShape, PShape, double, double, boolean)
+ * @since 2.2
+ */
+ public static PShape voronoiMorph(PShape from, PShape to, double morphFactor) {
+ return voronoiMorph(from, to, morphFactor, 0, true);
+ }
+
+ /**
+ * Interpolates ("morphs") between two shapes using a Voronoi partition
+ * approach.
+ *
+ * The non-overlapping parts of each input are partitioned by Voronoi cells
+ * induced by sampled boundary sites of the other shape; each partition piece is
+ * then moved toward its closest site:
+ *
+ * - closest vertex: uniform scaling toward that vertex,
+ * - closest edge: scaling perpendicular to the edge’s supporting
+ * line.
+ *
+ * The result is the union of transformed pieces from {@code from} using
+ * fraction {@code α} and transformed pieces from {@code to} using fraction
+ * {@code 1-α}, plus their overlap.
+ *
+ * This method supports polygons with holes and groups with disconnected
+ * components, and does not require any explicit correspondence between the
+ * inputs.
+ *
+ * @param from the starting shape (α = 0)
+ * @param to the ending shape (α = 1)
+ * @param morphFactor the morph parameter α, in {@code [0,1]}
+ * @param maxSegmentLength maximum segment length used to densify boundaries
+ * when sampling Voronoi sites; {@code <= 0} disables
+ * densification
+ * @param unionResult if {@code true}, unions the result into a clean area
+ * geometry (slower); if {@code false}, returns a
+ * combined multi/collection geometry (faster) that may
+ * retain overlaps/seams
+ * @return a new {@code PShape} representing the Voronoi-partition morph between
+ * {@code from} and {@code to}
+ * @see #voronoiMorph(PShape, PShape, double)
+ * @since 2.2
+ */
+ public static PShape voronoiMorph(PShape from, PShape to, double morphFactor, double maxSegmentLength, boolean unionResult) {
+ var gFrom = fromPShape(from);
+ var gTo = fromPShape(to);
+ var pvp = VoronoiInterpolator.prepareVoronoiPartition(gFrom, gTo, maxSegmentLength, 0);
+ var g = VoronoiInterpolator.interpolateVoronoi(pvp, morphFactor, unionResult);
+ return toPShape(g);
+ }
+
+ /**
+ * As-rigid-as-possible (ARAP) 2D deformation of a polygon {@link PShape} using
+ * point handles.
+ *
Handle semantics
+ *
+ * - {@code handles} are points in the rest (original) shape's
+ * coordinate space.
+ * - {@code handleTargets} are the desired positions for those same handles in
+ * the deformed shape.
+ * - Both lists must have the same size and matching order (i.e., index
+ * {@code i} in {@code handles} maps to index {@code i} in
+ * {@code handleTargets}).
+ * - ARAP typically requires at least 2 handles for a stable solve.
+ *
+ *
+ * Performance notes
+ *
+ * This method rebuilds and refines a triangulation on every call. For
+ * interactive dragging (re-solving every frame), prefer using {@link Malleo}
+ * directly: build the triangulation and call
+ * {@link Malleo#prepareHandles(List)} once, then repeatedly call
+ * {@link Malleo#solve(Malleo.CompiledHandles, List)} with updated targets.
+ *
+ *
Output
+ *
+ * Returns the deformed polygon boundary. The result may self-intersect
+ * depending on handle motion and mesh quality.
+ *
+ * @param shape the rest shape to deform (expected to be a single
+ * polygon {@code PShape})
+ * @param handles handle locations in rest-space
+ * @param handleTargets target locations for each handle, in the same order as
+ * {@code handles}
+ * @return a new {@code PShape} representing the deformed shape
+ * @since 2.2
+ */
+ public static PShape arapDeform(PShape shape, List handles, List handleTargets) {
+ var t = PGS_Triangulation.delaunayTriangulationMesh(shape);
+ PGS_Triangulation.refine(t, 15, 50); // refine
+ var g = PGS_Triangulation.toGeometry(t);
+
+ Malleo m = new Malleo(g);
+ var mHandles = Arrays.asList(PGS.toCoords(handles));
+ var mTargets = Arrays.asList(PGS.toCoords(handleTargets));
+
+ var compiledHandles = m.prepareHandles(mHandles);
+
+ var deformed = m.solve(compiledHandles, mTargets);
+
+ return toPShape(deformed);
}
/**
@@ -912,7 +1274,63 @@ public static PShape interpolate(PShape from, PShape to, int frames) {
* @since 1.3.0
*/
public static PShape reducePrecision(PShape shape, double precision) {
- return toPShape(GeometryPrecisionReducer.reduce(fromPShape(shape), new PrecisionModel(-Math.max(Math.abs(precision), 1e-10))));
+ var pm = new PrecisionModel(-Math.max(Math.abs(precision), 1e-10));
+ if (shape.getFamily() == PShape.GROUP) {
+ // pointwise preserves polygon faces (doesn't merge)
+ return toPShape(GeometryPrecisionReducer.reducePointwise(fromPShape(shape), pm));
+ } else {
+ return toPShape(GeometryPrecisionReducer.reduce(fromPShape(shape), pm));
+ }
+ }
+
+ /**
+ * Regularises (straightens) the contour of a lineal {@link PShape} by snapping
+ * edges toward a small set of principal directions and simplifying the result.
+ * The prinicipal direction is derived from the shape's longest edge.
+ *
+ * @param shape a lineal {@code PShape} to regularise (or a group containing
+ * lineal children)
+ * @param maxOffset maximum allowed offset. Used to constrain how far the
+ * regularised contour may deviate from the input; must be
+ * >= 0
+ * @return a new {@code PShape} whose linework has been regularised
+ * @see #regularise(PShape, double, double)
+ * @since 2.2
+ */
+ public static PShape regularise(PShape shape, double maxOffset) {
+ var params = Parameters.builder().maximumOffset(maxOffset);
+ return PGS.applyToLinealGeometries(shape, l -> {
+ return ContourRegularization.regularize(l, params.build());
+ });
+ }
+
+ /**
+ * Regularises (straightens) the contour of a lineal {@link PShape} by snapping
+ * edges toward principal directions and simplifying the result.
+ *
+ * This overload lets you provide an explicit principal axis
+ * orientation (in degrees). Edges are snapped to be parallel to that axis
+ * or to its orthogonal (axis + 90°), subject to the {@code maxOffset}
+ * constraint.
+ *
+ * @param shape a lineal {@code PShape} to regularize (or a group
+ * containing lineal children)
+ * @param maxOffset maximum allowed offset used to constrain how far the
+ * regularised contour may deviate from the input; must
+ * be >= 0
+ * @param axisOrientation principal axis direction, in degrees, expected in the
+ * range {@code [0,180)} (values outside this range are
+ * normalised)
+ * @return a new {@code PShape} whose linework has been regularised
+ * @see #regularise(PShape, double)
+ * @since 2.2
+ */
+ public static PShape regularise(PShape shape, double maxOffset, double axisOrientation) {
+ var d = new ContourRegularization.UserDefinedDirections(5, axisOrientation);
+ var params = Parameters.builder().maximumOffset(maxOffset).directions(d);
+ return PGS.applyToLinealGeometries(shape, l -> {
+ return ContourRegularization.regularize(l, params.build());
+ });
}
/**
diff --git a/src/main/java/micycle/pgs/PGS_Optimisation.java b/src/main/java/micycle/pgs/PGS_Optimisation.java
index d20d4adf..e4e2b0b8 100644
--- a/src/main/java/micycle/pgs/PGS_Optimisation.java
+++ b/src/main/java/micycle/pgs/PGS_Optimisation.java
@@ -16,6 +16,7 @@
import org.apache.commons.lang3.tuple.Triple;
import org.locationtech.jts.algorithm.MinimumAreaRectangle;
import org.locationtech.jts.algorithm.MinimumBoundingCircle;
+import org.locationtech.jts.algorithm.MinimumBoundingTriangle;
import org.locationtech.jts.algorithm.MinimumDiameter;
import org.locationtech.jts.algorithm.construct.LargestEmptyCircle;
import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle;
@@ -27,6 +28,7 @@
import org.locationtech.jts.geom.Location;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
+import org.locationtech.jts.geom.Polygonal;
import org.locationtech.jts.operation.distance.DistanceOp;
import org.locationtech.jts.simplify.DouglasPeuckerSimplifier;
import org.locationtech.jts.util.GeometricShapeFactory;
@@ -46,10 +48,10 @@
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.PConstants;
import processing.core.PShape;
import processing.core.PVector;
import whitegreen.dalsoo.DalsooPack;
@@ -234,7 +236,8 @@ public static PShape maximumInscribedTriangle(PShape shape) {
/**
* 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.
+ * x-axis and y-axis ("axis-aligned"), contained/inscribed within a convex
+ * shape.
*
* This method computes the MIR for convex shapes only; if a concave shape is
* passed in, the resulting rectangle will be computed based on its convex hull.
@@ -507,7 +510,7 @@ public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance)
final PShape ellipse = new PShape(PShape.PATH);
ellipse.setFill(true);
ellipse.setFill(Colors.WHITE);
- ellipse.beginShape();
+ ellipse.beginShape(PConstants.POLYGON);
for (double[] eEoord : eEoords) {
ellipse.vertex((float) eEoord[0], (float) eEoord[1]);
}
@@ -522,7 +525,7 @@ public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance)
* @param shape
*/
public static PShape minimumBoundingTriangle(PShape shape) {
- MinimumBoundingTriangle mbt = new MinimumBoundingTriangle(fromPShape(shape));
+ var mbt = new MinimumBoundingTriangle(fromPShape(shape));
return toPShape(mbt.getTriangle());
}
@@ -686,8 +689,9 @@ public static PShape largestEmptyCircle(PShape obstacles, @Nullable PShape bound
*/
public static List largestEmptyCircles(PShape obstacles, @Nullable PShape boundary, int n, double tolerance) {
tolerance = Math.max(0.01, tolerance);
- LargestEmptyCircles lecs = new LargestEmptyCircles(obstacles == null ? null : fromPShape(obstacles), boundary == null ? null : fromPShape(boundary),
- tolerance);
+ var boundaryG = boundary == null ? null : fromPShape(boundary);
+ var obstaclesG = obstacles == null ? null : fromPShape(obstacles);
+ var lecs = new LargestEmptyCircles(boundaryG, obstaclesG, tolerance);
final List out = new ArrayList<>();
for (int i = 0; i < n; i++) {
@@ -875,10 +879,10 @@ public static PVector closestVertex(PShape shape, PVector queryPoint) {
if (vertices.isEmpty()) {
return null;
}
- float minDistSq = Float.POSITIVE_INFINITY;
+ double minDistSq = Double.POSITIVE_INFINITY;
PVector closest = null;
for (PVector v : vertices) {
- float distSq = PVector.dist(v, queryPoint);
+ double distSq = PGS.distanceSq(v, queryPoint);
if (distSq < minDistSq) {
minDistSq = distSq;
closest = v;
@@ -910,6 +914,9 @@ public static PVector closestVertex(PShape shape, PVector queryPoint) {
*/
public static PVector closestPoint(PShape shape, PVector point) {
Geometry g = fromPShape(shape);
+ if (g instanceof Polygonal) {
+ g = g.getBoundary();
+ }
Coordinate coord = DistanceOp.nearestPoints(g, PGS.pointFromPVector(point))[0];
return new PVector((float) coord.x, (float) coord.y);
}
@@ -956,9 +963,9 @@ public static PVector closestPoint(Collection points, PVector point) {
*/
public static List closestPoints(PShape shape, PVector point) {
Geometry g = fromPShape(shape);
- ArrayList points = new ArrayList<>();
+ List points = new ArrayList<>();
for (int i = 0; i < g.getNumGeometries(); i++) {
- final Coordinate coord = DistanceOp.nearestPoints(g.getGeometryN(i), PGS.pointFromPVector(point))[0];
+ final Coordinate coord = DistanceOp.nearestPoints(g.getGeometryN(i).getBoundary(), PGS.pointFromPVector(point))[0];
points.add(PGS.toPVector(coord));
}
return points;
@@ -1272,22 +1279,30 @@ public static PVector solveApollonius(PVector c1, PVector c2, PVector c3, int s1
}
/**
- * Computes a visibility polygon / isovist, the area visible from a given point
- * in a space, considering occlusions caused by obstacles. In this case,
- * obstacles comprise the line segments of input shape.
+ * Computes the visibility polygon (isovist): the region visible from a given
+ * viewpoint, with occlusions caused by the edges of the supplied shape.
*
- * @param obstacles shape representing obstacles, which may have any manner of
- * polygon and line geometries.
- * @param viewPoint view point from which to compute visibility. If the input if
- * polygonal, the viewpoint may lie outside the polygon.
- * @return a polygonal shape representing the visibility polygon.
+ * @param obstacles a PShape whose edges serve as occluding obstacles; may
+ * contain polygons and/or lines.
+ * @param viewPoint the viewpoint from which visibility is computed. If the
+ * input if polygonal, the viewpoint may lie outside the
+ * polygon.
+ * @return a polygon representing the visible region from {@code viewPoint}
* @since 1.4.0
* @see #visibilityPolygon(PShape, Collection)
*/
public static PShape visibilityPolygon(PShape obstacles, PVector viewPoint) {
+ var g = fromPShape(obstacles);
+ var p = PGS.pointFromPVector(viewPoint);
+
VisibilityPolygon vp = new VisibilityPolygon();
- vp.addGeometry(fromPShape(obstacles));
- return toPShape(vp.getIsovist(PGS.coordFromPVector(viewPoint), true));
+ vp.addGeometry(g);
+
+ /*
+ * Skip adding envelope only when viewpoint is in a polygon.
+ */
+ var isovist = vp.getIsovist(p.getCoordinate(), (g instanceof Polygonal) ? !g.contains(p) : true);
+ return toPShape(isovist);
}
/**
diff --git a/src/main/java/micycle/pgs/PGS_PointSet.java b/src/main/java/micycle/pgs/PGS_PointSet.java
index 1df51711..5f91a07c 100644
--- a/src/main/java/micycle/pgs/PGS_PointSet.java
+++ b/src/main/java/micycle/pgs/PGS_PointSet.java
@@ -4,6 +4,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Random;
import java.util.SplittableRandom;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -30,6 +31,7 @@
import it.unimi.dsi.util.XoRoShiRo128PlusRandom;
import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator;
import micycle.pgs.commons.GeometricMedian;
+import micycle.pgs.commons.GonHeuristic;
import micycle.pgs.commons.GreedyTSP;
import micycle.pgs.commons.PEdge;
import micycle.pgs.commons.PoissonDistributionJRUS;
@@ -393,7 +395,7 @@ public static List random(double xMin, double yMin, double xMax, double
* point set is centered around the given center, given by mean coordinates.
*
* @param centerX x coordinate of the center/mean of the point set
- * @param centerY x coordinate of the center/mean of the point set
+ * @param centerY y coordinate of the center/mean of the point set
* @param sd standard deviation, which specifies how much the values can
* vary from the mean. 68% of point samples have a value within
* one standard deviation of the mean; three standard deviations
@@ -411,7 +413,7 @@ public static List gaussian(double centerX, double centerY, double sd,
* by mean coordinates.
*
* @param centerX x coordinate of the center/mean of the point set
- * @param centerY x coordinate of the center/mean of the point set
+ * @param centerY y coordinate of the center/mean of the point set
* @param sd standard deviation, which specifies how much the values can
* vary from the mean. 68% of point samples have a value within
* one standard deviation of the mean; three standard deviations
@@ -547,7 +549,7 @@ public static List hexagon(double centerX, double centerY, int length,
* (annulus).
*
* @param centerX x coordinate of the center/mean of the ring
- * @param centerY x coordinate of the center/mean of the ring
+ * @param centerY y coordinate of the center/mean of the ring
* @param innerRadius radius of the ring's hole
* @param outerRadius outer radius of the ring
* @param maxAngle sweep angle of the ring (in radians). Can be negative
@@ -1084,7 +1086,7 @@ public static List sobolLDS(double xMin, double yMin, double xMax, doub
* @return a LINES PShape
* @since 1.3.0
*/
- public static PShape minimumSpanningTree(List points) {
+ public static PShape minimumSpanningTree(Collection points) {
/*
* The Euclidean minimum spanning tree in a plane is a subgraph of the Delaunay
* triangulation.
@@ -1099,10 +1101,6 @@ public static PShape minimumSpanningTree(List points) {
* Computes an approximate Traveling Salesman path for the set of points
* 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, i.e. 10%) but is much more
- * performant.
*
* @param points the list of points for which to compute the approximate
* shortest tour
@@ -1111,11 +1109,50 @@ public static PShape minimumSpanningTree(List points) {
* starting point).
* @since 2.0
*/
- public static PShape findShortestTour(List points) {
+ public static PShape findShortestTour(Collection points) {
var tour = new GreedyTSP<>(points, (a, b) -> a.dist(b));
return PGS_Conversion.fromPVector(tour.getTour());
}
+ /**
+ * Selects {@code k} points from {@code points} to act as centers that are
+ * typically well distributed over the input set (i.e., each new center tends to
+ * be chosen from the currently “largest uncovered” / most distant region
+ * relative to the centers selected so far).
+ *
+ * @param points the input points; must be non-empty and contain at least
+ * {@code k} points
+ * @param k the number of centers to return; must be {@code >= 1}
+ * @return a list containing {@code k} points chosen as centers (subset of
+ * {@code points})
+ * @throws IllegalArgumentException if {@code k <= 0}, {@code points} is empty,
+ * or {@code points.size() < k}
+ * @since 2.2
+ */
+ public static List kCenters(Collection points, int k) {
+ return kCenters(points, k, System.nanoTime());
+ }
+
+ /**
+ * Selects {@code k} points from {@code points} to act as centers that are
+ * typically well distributed over the input set (i.e., each new center tends to
+ * be chosen from the currently “largest uncovered” / most distant region
+ * relative to the centers selected so far).
+ *
+ * @param points the input points; must be non-empty and contain at least
+ * {@code k} points
+ * @param k the number of centers to return; must be {@code >= 1}
+ * @param seed random seed used for deterministic center selection
+ * @return a list containing {@code k} points chosen as centers (subset of
+ * {@code points})
+ * @since 2.2
+ */
+ public static List kCenters(Collection points, int k, long seed) {
+ GonHeuristic gh = new GonHeuristic<>(new Random(seed));
+ var centers = gh.getCenters(points, k, (a, b) -> PGS.distanceSq(a, b));
+ return centers;
+ }
+
/**
* Applies random weights within a specified range to a list of points. The
* weights are assigned to the z-coordinate of each point using a random number
@@ -1153,11 +1190,12 @@ public static List applyRandomWeights(List points, double minW
* with a random weight assigned to its z-coordinate
* @since 2.0
*/
- public static List applyRandomWeights(List points, double minWeight, double maxWeight, long seed) {
+ public static List applyRandomWeights(List points, final double minWeight, final double maxWeight, final long seed) {
final SplittableRandom random = new SplittableRandom(seed);
return points.stream().map(p -> {
p = p.copy();
- p.z = (float) random.nextDouble(minWeight, maxWeight);
+ var w = minWeight == maxWeight ? minWeight : random.nextDouble(minWeight, maxWeight);
+ p.z = (float) w;
return p;
}).collect(Collectors.toList());
}
diff --git a/src/main/java/micycle/pgs/PGS_Polygonisation.java b/src/main/java/micycle/pgs/PGS_Polygonisation.java
new file mode 100644
index 00000000..60b3b4c9
--- /dev/null
+++ b/src/main/java/micycle/pgs/PGS_Polygonisation.java
@@ -0,0 +1,569 @@
+package micycle.pgs;
+
+import static micycle.pgs.PGS_Conversion.toPShape;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+
+import micycle.pgs.commons.AreaOptimalPolygonizer;
+import micycle.pgs.commons.AreaOptimalPolygonizer.AreaObjective;
+import micycle.pgs.commons.Uncrossing2Opt;
+import net.jafama.FastMath;
+import processing.core.PShape;
+import processing.core.PVector;
+
+/**
+ * Generates simple polygonisations of point sets.
+ *
+ * A polygonisation is a simple polygon whose vertex set is exactly the given
+ * point set, i.e. a non-self-intersecting Hamiltonian cycle through all points.
+ * Different algorithms may produce different polygonisations of the same point
+ * set.
+ *
+ * Polygonisations are distinct from geometric hulls: hulls may select a
+ * subset of extreme points to form an enclosing boundary, whereas
+ * polygonisations must use all points as vertices.
+ *
+ * @author Michael Carleton
+ * @since 2.2
+ */
+public class PGS_Polygonisation {
+
+ /**
+ * Produces a simple polygonisation that attempts to minimise the polygon area
+ * while using every point in the supplied set as a vertex.
+ *
+ * @param points the input point set (must not be {@code null}) containing >2
+ * points.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon that polygonises the input points and (attempts to) minimise
+ * area.
+ * @see {@link #maxArea(Collection)}
+ * @since 2.2
+ */
+ public static PShape minArea(Collection points) {
+ var coords = points.stream().map(p -> PGS.coordFromPVector(p)).toList();
+ var g = AreaOptimalPolygonizer.polygonize(coords, AreaObjective.MINIMIZE);
+ return toPShape(g);
+ }
+
+ /**
+ * Produces a simple polygonisation that attempts to maximise the polygon area
+ * while using every point in the supplied set as a vertex.
+ *
+ * @param points the input point set (must not be {@code null}) containing >2
+ * points.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon that polygonises the input points and (attempts to) maximise
+ * area.
+ * @see #minArea(Collection)
+ * @since 2.2
+ */
+ public static PShape maxArea(Collection points) {
+ var coords = points.stream().map(p -> PGS.coordFromPVector(p)).toList();
+ var g = AreaOptimalPolygonizer.polygonize(coords, AreaObjective.MAXIMIZE);
+ return toPShape(g);
+ }
+
+ /**
+ * Computes a polygonisation that approximates a shortest closed tour visiting
+ * every point exactly once (a Hamiltonian cycle with small perimeter).
+ *
+ * This method is effectively a TSP-style polygonisation: it returns a simple
+ * polygon whose total edge length is minimised (or approximated by the
+ * underlying shortest-tour routine).
+ *
+ * @param points the input point set (must not be {@code null}). If the set
+ * contains fewer than three distinct points an appropriate
+ * degenerate {@link processing.core.PShape PShape} containing the
+ * input points will be returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon that attempts to minimise perimeter.
+ * @since 2.2
+ */
+ public static PShape minPerimeter(Collection points) {
+ return PGS_PointSet.findShortestTour(points);
+ }
+
+ /**
+ * Builds a polygonisation by scanning points horizontally (primary sort by Y,
+ * secondary by X) and then removing edge crossings via a 2-opt (segment
+ * reversal) uncrossing routine.
+ *
+ * The produced polygon has "horizontal scanline" characteristics.
+ *
+ * @param points the input point set (may be {@code null}). If {@code null} an
+ * empty {@link processing.core.PShape PShape} is returned. If the
+ * set contains fewer than three distinct points a degenerate
+ * {@link processing.core.PShape PShape} containing the input
+ * points is returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon constructed by horizontal scan and uncrossing.
+ * @since 2.2
+ */
+ public static PShape horizontal(Collection points) {
+ return scanAndResolve(points, true);
+ }
+
+ /**
+ * Builds a polygonisation by scanning points vertically (primary sort by X,
+ * secondary by Y) and then removing edge crossings via a 2-opt (segment
+ * reversal) uncrossing routine.
+ *
+ * The produced polygon has "vertical scanline" characteristics.
+ *
+ * @param points the input point set (may be {@code null}). If {@code null} an
+ * empty {@link processing.core.PShape PShape} is returned. If the
+ * set contains fewer than three distinct points a degenerate
+ * {@link processing.core.PShape PShape} containing the input
+ * points is returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon constructed by vertical scan and uncrossing.
+ * @since 2.2
+ */
+ public static PShape vertical(Collection points) {
+ return scanAndResolve(points, false);
+ }
+
+ /**
+ * Produces a polygonisation by ordering points according to a Hilbert curve
+ * (space-filling curve) ordering, then applying a local uncrossing (2-opt) pass
+ * to remove any segment intersections.
+ *
+ * Hilbert ordering tends to preserve locality and thus often produces visually
+ * compact, low-crossing initial orderings which the uncrossing step refines
+ * into a simple polygon.
+ *
+ * @param points the input point set (must not be {@code null}). If the set
+ * contains fewer than three distinct points an appropriate
+ * degenerate {@link processing.core.PShape PShape} containing the
+ * input points will be returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon obtained from Hilbert ordering + uncrossing.
+ * @since 2.2
+ */
+ public static PShape hilbert(Collection points) {
+ var seq = PGS_PointSet.hilbertSort(new ArrayList(points));
+ Uncrossing2Opt.uncross(seq);
+ return toPolygon(seq);
+ }
+
+ /**
+ * Builds a polygonisation by grouping points into concentric "rings" around the
+ * centroid, ordering points within each ring by polar angle, and stitching the
+ * rings together into a single sequence.
+ *
+ * This is a heuristic polygonisation: it favors "circular" or banded structures
+ * (concentric/clustered layouts) and often produces visually compact,
+ * low-crossing initial orders that the uncrossing step refines into a simple
+ * polygon.
+ *
+ * @param points the input point set (may be {@code null}). If {@code null} an
+ * empty {@link processing.core.PShape PShape} is returned. If the
+ * set contains fewer than three distinct points a degenerate
+ * {@link processing.core.PShape PShape} containing the input
+ * points is returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon constructed by concentric ring (circular) ordering and
+ * subsequent uncrossing.
+ * @since 2.2
+ */
+ public static PShape circular(Collection points) {
+ if (points == null) {
+ return new PShape();
+ }
+ final int n = points.size();
+ if (n < 3)
+ return PGS_Conversion.fromPVector(new ArrayList<>(points));
+
+ // center = centroid
+ double cx = 0, cy = 0;
+ for (PVector p : points) {
+ cx += p.x;
+ cy += p.y;
+ }
+ cx /= n;
+ cy /= n;
+
+ List info = new ArrayList<>(n);
+ for (PVector p : points) {
+ double dx = p.x - cx, dy = p.y - cy;
+ info.add(new Info(p, Math.sqrt(dx * dx + dy * dy), FastMath.atan2(dy, dx)));
+ }
+
+ // sort by radius and split into rings (equal-size quantiles)
+ Collections.sort(info, (a, b) -> Double.compare(a.r, b.r));
+ int numRings = Math.max(1, (int) Math.round(Math.sqrt(n))); // heuristic
+ List> rings = new ArrayList<>(numRings);
+ for (int i = 0; i < numRings; i++)
+ rings.add(new ArrayList<>());
+
+ for (int i = 0; i < n; i++) {
+ int bucket = (int) ((long) i * numRings / n); // maps 0..n-1 into 0..numRings-1
+ rings.get(bucket).add(info.get(i));
+ }
+
+ // sort each ring by angle
+ for (List ring : rings) {
+ Collections.sort(ring, (a, b) -> Double.compare(a.theta, b.theta));
+ }
+
+ // concatenate rings, aligning each ring to the nearest start point and
+ // alternating direction
+ List seq = new ArrayList<>(n);
+ boolean forward = true;
+ for (List ring : rings) {
+ if (ring.isEmpty())
+ continue;
+ if (seq.isEmpty()) {
+ // first ring: optionally start at smallest theta, and maybe reverse for parity
+ if (!forward)
+ Collections.reverse(ring);
+ for (Info it : ring)
+ seq.add(it.p);
+ } else {
+ // find index in ring nearest to last appended point
+ PVector last = seq.get(seq.size() - 1);
+ int start = 0;
+ double best = Double.POSITIVE_INFINITY;
+ for (int k = 0; k < ring.size(); k++) {
+ double dx = last.x - ring.get(k).p.x;
+ double dy = last.y - ring.get(k).p.y;
+ double d2 = dx * dx + dy * dy;
+ if (d2 < best) {
+ best = d2;
+ start = k;
+ }
+ }
+ // append ring starting at start, in forward or reverse direction
+ if (forward) {
+ for (int k = 0; k < ring.size(); k++) {
+ seq.add(ring.get((start + k) % ring.size()).p);
+ }
+ } else {
+ for (int k = 0; k < ring.size(); k++) {
+ int idx = (start - k) % ring.size();
+ if (idx < 0)
+ idx += ring.size();
+ seq.add(ring.get(idx).p);
+ }
+ }
+ }
+ forward = !forward;
+ }
+
+ Uncrossing2Opt.uncross(seq);
+
+ return toPolygon(seq);
+ }
+
+ /**
+ * Generates a polygonisation by angular (radial) sorting: points are sorted by
+ * angle around the centroid, tie-broken by distance from the centroid, and then
+ * a 2-opt uncrossing pass is applied.
+ *
+ * The angular sort usually gives a star-shaped output.
+ *
+ * @param points the input point set (may be {@code null}). If {@code null} an
+ * empty {@link processing.core.PShape PShape} is returned. If the
+ * set contains fewer than three distinct points a degenerate
+ * {@link processing.core.PShape PShape} containing the input
+ * points is returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon constructed by radial sorting and uncrossing.
+ * @since 2.2
+ */
+ public static PShape angular(Collection points) {
+ if (points == null) {
+ return new PShape();
+ }
+ final int n = points.size();
+ if (n == 0) {
+ return PGS_Conversion.fromPVector(points);
+ }
+ if (n < 3) {
+ return PGS_Conversion.fromPVector(new ArrayList<>(points));
+ }
+
+ // compute centroid as center for angular sort
+ double cx = 0.0, cy = 0.0;
+ for (PVector p : points) {
+ cx += p.x;
+ cy += p.y;
+ }
+ cx /= n;
+ cy /= n;
+
+ List info = new ArrayList<>(n);
+ for (PVector p : points) {
+ double dx = p.x - cx;
+ double dy = p.y - cy;
+ double theta = FastMath.atan2(dy, dx);
+ double r = Math.sqrt(dx * dx + dy * dy);
+ info.add(new Info(p, r, theta));
+ }
+
+ // sort by angle, tie-break by radius (closer first)
+ Collections.sort(info, (a, b) -> {
+ int c = Double.compare(a.theta, b.theta);
+ if (c != 0)
+ return c;
+ return Double.compare(a.r, b.r);
+ });
+
+ List seq = new ArrayList<>(n);
+ for (Info it : info) {
+ seq.add(it.p);
+ }
+
+ Uncrossing2Opt.uncross(seq);
+
+ return toPolygon(seq);
+ }
+
+ /**
+ * Constructs a polygonisation using the "onion" (convex-layers) strategy:
+ * repeatedly peel convex hull layers (outermost first), stitch hull layers into
+ * a single cyclic order (alternating directions for continuity), insert any
+ * leftover points with a cheapest-insertion heuristic, and finally apply a
+ * 2-opt uncrossing pass.
+ *
+ * This approach tends to respect global convex structure and produces
+ * spiral-like polygonisations that use all points as vertices.
+ *
+ * @param points the input point set (may be {@code null}). If {@code null} an
+ * empty {@link processing.core.PShape PShape} is returned. If the
+ * set contains fewer than three distinct points a degenerate
+ * {@link processing.core.PShape PShape} containing the input
+ * points is returned.
+ * @return a new {@link processing.core.PShape PShape} representing a simple
+ * polygon constructed by convex-layer peeling, stitching and
+ * uncrossing.
+ * @since 2.2
+ */
+ public static PShape onion(Collection points) {
+ if (points == null)
+ return new PShape();
+ int n0 = points.size();
+ if (n0 < 3)
+ return PGS_Conversion.fromPVector(new ArrayList<>(points));
+
+ // mutable working set
+ List remaining = new ArrayList<>(points);
+
+ // peel convex hull layers
+ List> layers = new ArrayList<>();
+ while (remaining.size() >= 3) {
+ List hull = convexHullMonotoneChain(remaining);
+ if (hull.size() < 3)
+ break; // degenerate (collinear etc.)
+ layers.add(hull);
+
+ // remove hull points (identity-based)
+ var onHull = Collections.newSetFromMap(new IdentityHashMap());
+ onHull.addAll(hull);
+ remaining.removeIf(onHull::contains);
+ }
+
+ // stitch layers into one cyclic order (spiral-ish)
+ List seq = new ArrayList<>(points.size());
+ boolean forward = true;
+
+ for (List layer : layers) {
+ if (layer.isEmpty())
+ continue;
+
+ if (seq.isEmpty()) {
+ if (!forward)
+ java.util.Collections.reverse(layer);
+ seq.addAll(layer);
+ } else {
+ PVector last = seq.get(seq.size() - 1);
+ List rotated = rotateToNearest(layer, last);
+ if (!forward)
+ Collections.reverse(rotated);
+ seq.addAll(rotated);
+ }
+ forward = !forward;
+ }
+
+ // if anything left (0,1,2 points or collinear residue), insert cheaply
+ for (PVector p : remaining) {
+ insertCheapest(seq, p);
+ }
+
+ Uncrossing2Opt.uncross(seq);
+
+ return toPolygon(seq);
+ }
+
+ /**
+ * Generic scan-based polygonisation. If primaryIsY is true, points are sorted
+ * primarily by Y then X (horizontal scanlines). Otherwise sorted primarily by X
+ * then Y (vertical scanlines). After sorting, a 2-opt style crossing removal is
+ * applied by iteratively reversing segments that cause segment intersections.
+ *
+ * @param points the input point set
+ * @param primaryIsY whether to sort primarily by Y (true) or X (false)
+ * @return a simple polygon PShape
+ */
+ private static PShape scanAndResolve(Collection points, boolean primaryIsY) {
+ // defensive handling
+ if (points == null) {
+ return new PShape();
+ }
+
+ final int n = points.size();
+ if (n == 0) {
+ return PGS_Conversion.fromPVector(points);
+ }
+ if (n < 3) {
+ // trivial: nothing to polygonise
+ return PGS_Conversion.fromPVector(new ArrayList<>(points));
+ }
+
+ // make a mutable copy
+ List seq = new ArrayList<>(points);
+
+ // comparator depending on primary axis
+ Comparator cmp = primaryIsY ? Comparator.comparingDouble((PVector p) -> p.y).thenComparingDouble(p -> p.x)
+ : Comparator.comparingDouble((PVector p) -> p.x).thenComparingDouble(p -> p.y);
+
+ Collections.sort(seq, cmp);
+ Uncrossing2Opt.uncross(seq);
+
+ return toPolygon(seq);
+ }
+
+ /**
+ * Computes the convex hull of a point set using the Monotone Chain algorithm.
+ *
+ * @param pts list of points
+ * @return a list of points representing the convex hull in CCW order
+ */
+ private static List convexHullMonotoneChain(List pts) {
+ // returns CCW hull without repeating the first point
+ int n = pts.size();
+ if (n < 3)
+ return new ArrayList<>();
+
+ // sort by x then y
+ List p = new ArrayList<>(pts);
+ p.sort((a, b) -> {
+ int cx = Float.compare(a.x, b.x);
+ if (cx != 0)
+ return cx;
+ return Float.compare(a.y, b.y);
+ });
+
+ List lower = new ArrayList<>();
+ for (PVector v : p) {
+ while (lower.size() >= 2 && cross(lower.get(lower.size() - 2), lower.get(lower.size() - 1), v) <= 0) {
+ lower.remove(lower.size() - 1);
+ }
+ lower.add(v);
+ }
+
+ List upper = new ArrayList<>();
+ for (int i = p.size() - 1; i >= 0; i--) {
+ PVector v = p.get(i);
+ while (upper.size() >= 2 && cross(upper.get(upper.size() - 2), upper.get(upper.size() - 1), v) <= 0) {
+ upper.remove(upper.size() - 1);
+ }
+ upper.add(v);
+ }
+
+ // remove last of each (it's the start of the other list)
+ lower.remove(lower.size() - 1);
+ upper.remove(upper.size() - 1);
+
+ List hull = new ArrayList<>(lower.size() + upper.size());
+ hull.addAll(lower);
+ hull.addAll(upper);
+ return hull;
+ }
+
+ private static float cross(PVector o, PVector a, PVector b) {
+ return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
+ }
+
+ private static List rotateToNearest(List ring, PVector target) {
+ int m = ring.size();
+ if (m == 0)
+ return new ArrayList<>();
+
+ int best = 0;
+ double bestD2 = Double.POSITIVE_INFINITY;
+ for (int i = 0; i < m; i++) {
+ PVector p = ring.get(i);
+ double dx = p.x - target.x;
+ double dy = p.y - target.y;
+ double d2 = dx * dx + dy * dy;
+ if (d2 < bestD2) {
+ bestD2 = d2;
+ best = i;
+ }
+ }
+
+ List out = new ArrayList<>(m);
+ for (int k = 0; k < m; k++)
+ out.add(ring.get((best + k) % m));
+ return out;
+ }
+
+ /**
+ * Inserts a point into a cycle at the position that minimizes the increase in
+ * total length (cheapest insertion heuristic).
+ *
+ * @param cycle the current polygon cycle (modified in place)
+ * @param p the point to insert
+ */
+ private static void insertCheapest(List cycle, PVector p) {
+ int n = cycle.size();
+ if (n == 0) {
+ cycle.add(p);
+ return;
+ }
+ if (n == 1) {
+ cycle.add(p);
+ return;
+ }
+
+ int bestIdx = 0;
+ double bestDelta = Double.POSITIVE_INFINITY;
+
+ for (int i = 0; i < n; i++) {
+ PVector a = cycle.get(i);
+ PVector b = cycle.get((i + 1) % n);
+ double delta = dist(a, p) + dist(p, b) - dist(a, b);
+ if (delta < bestDelta) {
+ bestDelta = delta;
+ bestIdx = i + 1;
+ }
+ }
+ cycle.add(bestIdx, p);
+ }
+
+ private static double dist(PVector a, PVector b) {
+ double dx = a.x - b.x, dy = a.y - b.y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ private static record Info(PVector p, double r, double theta) {
+ }
+
+ /**
+ * Converts a list of vertices into a closed polygon PShape.
+ */
+ private static PShape toPolygon(List points) {
+ if (!points.get(0).equals(points.get(points.size() - 1))) {
+ points.add(points.get(0)); // close
+ }
+ return PGS_Conversion.fromPVector(points);
+ }
+
+}
diff --git a/src/main/java/micycle/pgs/PGS_Processing.java b/src/main/java/micycle/pgs/PGS_Processing.java
index 0042648c..563e0c8d 100644
--- a/src/main/java/micycle/pgs/PGS_Processing.java
+++ b/src/main/java/micycle/pgs/PGS_Processing.java
@@ -32,7 +32,9 @@
import org.apache.commons.math3.ml.distance.EuclideanDistance;
import org.locationtech.jts.algorithm.Angle;
import org.locationtech.jts.algorithm.Area;
+import org.locationtech.jts.algorithm.LineIntersector;
import org.locationtech.jts.algorithm.Orientation;
+import org.locationtech.jts.algorithm.RobustLineIntersector;
import org.locationtech.jts.algorithm.hull.ConcaveHullOfPolygons;
import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator;
import org.locationtech.jts.densify.Densifier;
@@ -51,10 +53,12 @@
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.geom.util.GeometryFixer;
import org.locationtech.jts.geom.util.LineStringExtracter;
+import org.locationtech.jts.geom.util.LinearComponentExtracter;
import org.locationtech.jts.geom.util.PolygonExtracter;
import org.locationtech.jts.linearref.LengthIndexedLine;
+import org.locationtech.jts.noding.BasicSegmentString;
+import org.locationtech.jts.noding.MCIndexNoder;
import org.locationtech.jts.noding.MCIndexSegmentSetMutualIntersector;
-import org.locationtech.jts.noding.NodedSegmentString;
import org.locationtech.jts.noding.Noder;
import org.locationtech.jts.noding.SegmentIntersectionDetector;
import org.locationtech.jts.noding.SegmentIntersector;
@@ -75,12 +79,9 @@
import com.github.micycle1.geoblitz.YStripesPointInAreaLocator;
import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator;
-import micycle.balaban.BalabanSolver;
-import micycle.balaban.Point;
-import micycle.balaban.Segment;
import micycle.pgs.color.ColorUtils;
import micycle.pgs.color.Colors;
-import micycle.pgs.commons.PolygonDecomposition;
+import micycle.pgs.commons.KeilSnoeyinkConvexPartitioner;
import micycle.pgs.commons.SeededRandomPointsInGridBuilder;
import micycle.pgs.commons.ShapeRandomPointSampler;
import micycle.trapmap.TrapMap;
@@ -89,10 +90,17 @@
import processing.core.PVector;
/**
- * Methods that process shape geometry: partitioning, slicing, cleaning, etc.
+ * Shape-processing utilities for {@link PShape} geometry.
+ *
+ *
+ * This class groups “workflow” operations that operate on shapes
+ * rather than primarily reshaping them: sampling and traversal,
+ * validation/repair, cleaning and filtering, intersection helpers, and
+ * partitioning/slicing/splitting into multiple parts. Methods often return
+ * derived shapes (or shape collections) suitable for downstream steps such as
+ * meshing, tiling, coloring, or boolean operations.
*
* @author Michael Carleton
- *
*/
public final class PGS_Processing {
@@ -138,6 +146,8 @@ public static PShape densify(PShape shape, double distanceTolerance) {
* point away from the shape (outwards); negative
* values offset the point inwards towards its
* interior.
+ * @return A {@link PVector} located on the exterior of {@code shape} at the
+ * requested perimeter position and offset.
* @see #pointsOnExterior(PShape, int, double)
*/
public static PVector pointOnExterior(PShape shape, double perimeterPosition, double offsetDistance) {
@@ -164,6 +174,8 @@ 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 A {@link PVector} located at the specified distance along the
+ * exterior perimeter, offset by {@code offsetDistance}.
* @since 1.4.0
*/
public static PVector pointOnExteriorByDistance(PShape shape, double perimeterDistance, double offsetDistance) {
@@ -433,9 +445,11 @@ private static IndexedLengthIndexedLine makeIndexedLine(PShape shape) {
* @since 1.2.0
*/
public static PShape extractPerimeter(PShape shape, double from, double to) {
- from = floatMod(from, 1);
- if (to != 1) { // so that value of 1 is not moduloed to equal 0
- to = floatMod(to, 1);
+ if (!isWhole(from)) {
+ from = floatMod(from, 1.0);
+ }
+ if (!isWhole(to)) {
+ to = floatMod(to, 1.0);
}
Geometry g = fromPShape(shape);
if (!g.getGeometryType().equals(Geometry.TYPENAME_LINEARRING) && !g.getGeometryType().equals(Geometry.TYPENAME_LINESTRING)) {
@@ -451,26 +465,8 @@ public static PShape extractPerimeter(PShape shape, double from, double to) {
.createLineString(Stream.concat(Arrays.stream(l1.getCoordinates()), Arrays.stream(l2.getCoordinates())).toArray(Coordinate[]::new)));
}
- /*
- * The PGS toPShape() method treats a closed linestring as polygonal (having a
- * fill), which occurs when from==0 and to==1. We don't want the output to be
- * filled in, so build the PATH shape here without closing it.
- */
LineString string = (LineString) l.extractLine(length * from, length * to);
- PShape perimeter = new PShape();
- perimeter.setFamily(PShape.PATH);
- perimeter.setStroke(true);
- perimeter.setStroke(micycle.pgs.color.Colors.PINK);
- perimeter.setStrokeWeight(4);
-
- perimeter.beginShape();
- Coordinate[] coords = string.getCoordinates();
- for (Coordinate coord : coords) {
- perimeter.vertex((float) coord.x, (float) coord.y);
- }
- perimeter.endShape();
-
- return perimeter;
+ return toPShape(string);
}
/**
@@ -518,25 +514,146 @@ public static double tangentAngle(PShape shape, double perimeterRatio) {
}
/**
- * Computes all points of intersection between the linework of two
- * shapes.
+ * Computes all self-intersection points of the linework contained within a
+ * single shape.
+ *
*
- * NOTE: This method shouldn't be confused with
+ * This is equivalent to finding all intersection points formed by pairwise
+ * intersections of the shape's lineal components (exterior rings, interior
+ * rings/holes and standalone LineStrings).
+ *
+ * Note endpoint-endpoint intersections ("touches") are not included.
+ *
+ * @param a the input shape whose linework will be tested for self-intersections
+ * @return a List containing self-intersection points; empty if none
+ * are found
+ * @since 2.2
+ */
+ public static List intersectionPoints(PShape a) {
+ Geometry g = fromPShape(a);
+
+ @SuppressWarnings("unchecked")
+ List strings = LinearComponentExtracter.getLines(g);
+
+ final Collection segmentStringsA = new ArrayList<>(strings.size());
+
+ for (LineString ls : strings) {
+ Coordinate[] c = ls.getCoordinates();
+ if (c.length < 2) {
+ continue;
+ }
+
+ // ignore closed
+ int n = c.length;
+ if (n >= 2 && c[0].equals2D(c[n - 1])) {
+ n--; // drop duplicated closing coord
+ }
+
+ // emit one SegmentString per segment
+ for (int i = 0; i < n - 1; i++) {
+ Coordinate p0 = new Coordinate(c[i]);
+ Coordinate p1 = new Coordinate(c[i + 1]);
+ if (!p0.equals2D(p1)) {
+ segmentStringsA.add(new BasicSegmentString(new Coordinate[] { p0, p1 }, null));
+ }
+ }
+ }
+
+ return intersections(segmentStringsA, false);
+ }
+
+ /**
+ * Computes all intersection points between the linework (edges/boundaries) of
+ * two shapes.
+ *
+ * This method operates on the extracted linework of the provided PShapes: that
+ * includes polygon exteriors, polygon holes, and standalone paths (and any
+ * lineal children of GROUP shapes). It does not compute the geometric
+ * intersection area of filled polygons — see
* {@link micycle.pgs.PGS_ShapeBoolean#intersect(PShape, PShape)
- * PGS_ShapeBoolean.intersect()}, which finds the shape made by the intersecting
- * shape areas.
+ * PGS_ShapeBoolean.intersect()} for area-based intersection results.
*
- * @param a one shape
- * @param b another shape
- * @return list of all intersecting points (as PVectors)
+ * @param a one input shape (polygons, lines or groups containing them)
+ * @param b the other input shape (polygons, lines or groups containing them)
+ * @return a List containing the intersection points between the
+ * linework of {@code a} and {@code b}. Returns an empty list if no
+ * intersections are found.
*/
- public static List shapeIntersection(PShape a, PShape b) {
+ public static List intersectionPoints(PShape a, PShape b) {
final Collection> segmentStringsA = SegmentStringUtil.extractSegmentStrings(fromPShape(a));
final Collection> segmentStringsB = SegmentStringUtil.extractSegmentStrings(fromPShape(b));
return intersections(segmentStringsA, segmentStringsB);
}
+ static List intersections(Collection> segments, boolean countEndpointTouches) {
+ @SuppressWarnings("unchecked")
+ final Collection segStrings = (Collection) segments;
+
+ final Set hits = new HashSet<>();
+ final RobustLineIntersector li = new RobustLineIntersector();
+
+ final SegmentIntersector intersector = (e0, i0, e1, i1) -> {
+ // Skip identical segment
+ if (e0 == e1 && i0 == i1)
+ return;
+
+ // For self-comparisons, avoid double-reporting, and (optionally) skip adjacent
+ // segments
+ if (e0 == e1) {
+ if (i1 <= i0)
+ return; // process each pair once
+
+ if (!countEndpointTouches) {
+ final int nSegs = e0.size() - 1; // number of segments in this SegmentString
+
+ // Adjacent by index, including wrap-around (last segment adjacent to first)
+ final boolean adjacent = Math.abs(i0 - i1) == 1 || (i0 == 0 && i1 == nSegs - 1) || (i1 == 0 && i0 == nSegs - 1);
+
+ if (adjacent)
+ return;
+ }
+ }
+
+ final Coordinate p0 = e0.getCoordinate(i0);
+ final Coordinate p1 = e0.getCoordinate(i0 + 1);
+ final Coordinate q0 = e1.getCoordinate(i1);
+ final Coordinate q1 = e1.getCoordinate(i1 + 1);
+
+ li.computeIntersection(p0, p1, q0, q1);
+ if (!li.hasIntersection())
+ return;
+
+ final boolean collinear = li.getIntersectionNum() == LineIntersector.COLLINEAR;
+
+ for (int k = 0; k < li.getIntersectionNum(); k++) {
+ final Coordinate ip = li.getIntersection(k);
+
+ if (!countEndpointTouches) {
+ // Keep "proper" crossings (interior-interior) and collinear overlaps.
+ // Otherwise drop intersections that occur at any segment endpoint (touches).
+ if (!li.isProper() && !collinear) {
+ if (ip.equals2D(p0) || ip.equals2D(p1) || ip.equals2D(q0) || ip.equals2D(q1)) {
+ continue;
+ }
+ }
+ }
+
+ hits.add(new Coordinate(ip));
+ }
+ };
+
+ MCIndexNoder noder = new MCIndexNoder();
+ noder.setSegmentIntersector(intersector);
+ noder.computeNodes(segStrings);
+
+ final List out = new ArrayList<>(hits.size());
+ for (Coordinate c : hits) {
+ out.add(new PVector((float) c.x, (float) c.y));
+ }
+ return out;
+ }
+
static List intersections(Collection> segmentStringsA, Collection> segmentStringsB) {
final Collection> larger, smaller;
if (segmentStringsA.size() > segmentStringsB.size()) {
@@ -554,58 +671,15 @@ static List intersections(Collection> segmentStringsA, Collection>
// checks if two segments actually intersect
final SegmentIntersectionDetector sid = new SegmentIntersectionDetector();
- mci.process(smaller, new SegmentIntersector() {
- @Override
- public void processIntersections(SegmentString e0, int segIndex0, SegmentString e1, int segIndex1) {
- sid.processIntersections(e0, segIndex0, e1, segIndex1);
- if (sid.hasIntersection()) {
- points.add(new PVector((float) sid.getIntersection().x, (float) sid.getIntersection().y));
- }
- }
-
- @Override
- public boolean isDone() {
- return false;
+ mci.process(smaller, (e0, segIndex0, e1, segIndex1) -> {
+ sid.processIntersections(e0, segIndex0, e1, segIndex1);
+ if (sid.hasIntersection()) {
+ points.add(new PVector((float) sid.getIntersection().x, (float) sid.getIntersection().y));
}
});
return new ArrayList<>(points);
}
- /**
- * Computes all points of intersection between segments in a set of line
- * segments. The input set is first processed to remove degenerate segments
- * (does not mutate the input).
- *
- * @param lineSegments a list of PVectors where each pair (couplet) of PVectors
- * represent the start and end point of one line segment
- * @return A list of PVectors each representing the intersection point of a
- * segment pair
- */
- public static List lineSegmentsIntersection(List lineSegments) {
- final List intersections = new ArrayList<>();
- if (lineSegments.size() % 2 != 0) {
- System.err.println(
- "The input to lineSegmentsIntersection() contained an odd number of line segment vertices. The method expects successive pairs of vertices");
- return intersections;
- }
-
- Collection segments = new ArrayList<>();
- for (int i = 0; i < lineSegments.size(); i += 2) { // iterate pairwise
- final PVector p1 = lineSegments.get(i);
- final PVector p2 = lineSegments.get(i + 1);
- segments.add(new Segment(p1.x, p1.y, p2.x, p2.y));
- }
-
- final BalabanSolver balabanSolver = new BalabanSolver((a, b) -> {
- final Point pX = a.getIntersection(b);
- intersections.add(new PVector((float) pX.x, (float) pX.y));
- });
- segments.removeAll(balabanSolver.findDegenerateSegments(segments));
- balabanSolver.computeIntersections(segments);
-
- return intersections;
- }
-
/**
* Generates N random points that lie within the shape region.
*
@@ -618,6 +692,8 @@ 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 a list of {@link PVector} points randomly sampled inside
+ * {@code shape}
* @see #generateRandomPoints(PShape, int, long)
* @see #generateRandomGridPoints(PShape, int, boolean, double)
*/
@@ -888,32 +964,63 @@ public static PShape extractHoles(PShape shape) {
}
/**
- * Finds the polygonal faces formed by a set of intersecting line segments.
- *
- * @param lineSegmentVertices a list of PVectors where each pair (couplet) of
- * PVectors represent the start and end point of one
- * line segment
- * @return a GROUP PShape where each child shape is a face / enclosed area
- * formed between intersecting lines
- * @since 1.1.2
+ * Extracts the topological boundary of the given shape.
+ *
+ *
+ * For a polygonal (area) {@code PShape}, the boundary is its perimeter: the
+ * outer outline plus the outlines of any holes. The returned shape encodes this
+ * as one or more unfilled {@link PShape#PATH PATH} shapes (closed where
+ * appropriate).
+ *
+ *
+ * For non-area shapes, the boundary may be empty or may reduce to point-like
+ * elements (for example, the boundary of an open path consists of its end
+ * vertices).
+ *
+ *
+ * This is useful because some operations have different semantics depending on
+ * whether the input is encoded as an area ({@code kind == POLYGON}) or as a
+ * stroke/path ({@code kind == PATH}). For example, buffering a {@code POLYGON}
+ * expands/contracts an area, whereas buffering a {@code PATH} produces a
+ * stroked “tube” around the linework. Extracting the boundary provides a
+ * consistent way to convert an area into its outline representation prior to
+ * such operations.
+ *
+ *
+ * Note: the returned {@code PShape} may be a {@link PConstants#GROUP} if the
+ * boundary contains multiple disjoint components.
+ *
+ * @param shape the input shape whose boundary is to be returned
+ * @return a {@code PShape} representing the boundary of {@code shape}
+ * @since 2.2
*/
- public static PShape polygonizeLines(List lineSegmentVertices) {
- // TODO constructor for LINES PShape
- if (lineSegmentVertices.size() % 2 != 0) {
- System.err.println("The input to polygonizeLines() contained an odd number of vertices. The method expects successive pairs of vertices.");
- return new PShape();
- }
-
- final List segmentStrings = new ArrayList<>(lineSegmentVertices.size() / 2);
- for (int i = 0; i < lineSegmentVertices.size(); i += 2) {
- final PVector v1 = lineSegmentVertices.get(i);
- final PVector v2 = lineSegmentVertices.get(i + 1);
- if (!v1.equals(v2)) {
- segmentStrings.add(new NodedSegmentString(new Coordinate[] { PGS.coordFromPVector(v1), PGS.coordFromPVector(v2) }, null));
- }
- }
+ public static PShape extractBoundary(PShape shape) {
+ return toPShape(fromPShape(shape).getBoundary());
+ }
- return PGS.polygonizeSegments(segmentStrings, true);
+ /**
+ * Finds polygonal faces from the given shape's linework.
+ *
+ * This method extracts linework from the supplied PShape (including existing
+ * polygon edges and standalone line primitives), nodes intersections, and
+ * polygonizes the resulting segment network. Only closed polygonal faces
+ * (enclosed areas) are returned. Open edges, dangling line segments
+ * ("dangles"), and isolated lines that do not form a closed ring are ignored
+ * and dropped — the result contains faces only.
+ *
+ * The returned PShape is a GROUP whose children are PShapes representing each
+ * detected face.
+ *
+ * @param shape a PShape whose linework (edges) will be used to find polygonal
+ * faces; can include existing polygons or line primitives
+ * @return a GROUP PShape containing only the polygonal faces discovered from
+ * the input linework; dangles and non-enclosed edges are not included
+ * @since 2.2
+ */
+ public static PShape polygonize(PShape shape) {
+ var g = fromPShape(shape);
+ var segs = SegmentStringUtil.extractNodedSegmentStrings(g);
+ return PGS.polygonizeSegments(segs, true);
}
/**
@@ -1101,21 +1208,35 @@ public static PShape centroidSplit(PShape shape, int n, double offset) {
}
/**
- * Partitions shape(s) into convex (simple) polygons.
+ * Partitions the provided shape into convex, simple polygonal pieces.
+ *
+ * This implementation uses the optimal Keil & Snoeyink dynamic-programming
+ * approach, which minimises the number of added diagonals and thus the number
+ * of convex pieces.
+ *
+ * The input may be a single polygon PShape or a GROUP PShape containing
+ * multiple polygon children. Each polygon child is partitioned independently;
+ * the method returns a GROUP PShape whose children are the convex pieces. If
+ * the partition produces exactly one child, that single child PShape is
+ * returned (rather than a GROUP).
+ *
+ * Polygons with interior holes are supported — holes are bridged to produce
+ * simple polygons prior to partitioning.
*
- * @param shape the shape to partition. can be a single polygon or a GROUP of
- * polygons
- * @return a GROUP PShape, where each child shape is some convex partition of
- * the original shape
+ * @param shape a non-null PShape representing a polygon or a GROUP of polygons
+ * @return a GROUP PShape whose children are convex, simple polygon partitions
+ * of the input; if only one partition piece results, that child PShape
+ * is returned directly
+ * @implNote Implementation changed in v2.2 from Bayazit algorithm to Keil &
+ * Snoeyink (optimal).
*/
public static PShape convexPartition(PShape shape) {
- // algorithm described in https://mpen.ca/406/bayazit
final Geometry g = fromPShape(shape);
final PShape polyPartitions = new PShape(PConstants.GROUP);
@SuppressWarnings("unchecked")
final List polygons = PolygonExtracter.getPolygons(g);
- polygons.forEach(p -> polyPartitions.addChild(toPShape(PolygonDecomposition.decompose(p))));
+ polygons.forEach(p -> polyPartitions.addChild(toPShape(KeilSnoeyinkConvexPartitioner.convexPartition(p))));
if (polyPartitions.getChildCount() == 1) {
return polyPartitions.getChild(0);
@@ -1128,7 +1249,7 @@ public static PShape convexPartition(PShape shape) {
* Randomly partitions a shape into N approximately equal-area polygonal cells.
*
* @param shape a polygonal (non-group, no holes) shape to partition
- * @param parts number of roughly equal area partitons to create
+ * @param parts number of roughly equal area partitions to create
* @return a GROUP PShape, whose child shapes are partitions of the original
* @since 1.3.0
*/
@@ -1141,7 +1262,7 @@ public static PShape equalPartition(final PShape shape, final int parts) {
* equal-area polygonal cells.
*
* @param shape a polygonal (non-group, no holes) shape to partition
- * @param parts number of roughly equal area partitons to create
+ * @param parts number of roughly equal area partitions to create
* @param seed number used to initialize the underlying pseudorandom number
* generator
* @return a GROUP PShape, whose child shapes are partitions of the original
@@ -1660,4 +1781,8 @@ private static double floatMod(double x, double y) {
return (x - Math.floor(x / y) * y);
}
+ private static boolean isWhole(double v) {
+ return Double.isFinite(v) && Math.abs(v - Math.rint(v)) < 1e-12;
+ }
+
}
diff --git a/src/main/java/micycle/pgs/PGS_SegmentSet.java b/src/main/java/micycle/pgs/PGS_SegmentSet.java
index fb3059f1..821e9dcb 100644
--- a/src/main/java/micycle/pgs/PGS_SegmentSet.java
+++ b/src/main/java/micycle/pgs/PGS_SegmentSet.java
@@ -14,6 +14,7 @@
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.locationtech.jts.algorithm.Orientation;
import org.locationtech.jts.algorithm.RobustLineIntersector;
import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator;
import org.locationtech.jts.dissolve.LineDissolver;
@@ -31,6 +32,8 @@
import org.locationtech.jts.noding.SegmentStringUtil;
import org.tinfour.common.IIncrementalTin;
+import com.github.micycle1.geoblitz.IndexedLengthIndexedLine;
+
import micycle.pgs.color.Colors;
import micycle.pgs.commons.FastAtan2;
import micycle.pgs.commons.Nullable;
@@ -350,6 +353,406 @@ public static List parallelSegments(double centerX, double centerY, doubl
return edges;
}
+ /**
+ * Function that supplies the perpendicular segment length at each sampled
+ * position along a shape component (optionally varying by location, phase, or
+ * normal angle).
+ */
+ @FunctionalInterface
+ public interface SegmentLengthFn {
+ /**
+ * @param x sampled boundary point x
+ * @param y sampled boundary point y
+ * @param posFrac fractional position along the current component in [0,1)
+ * (includes {@code startOffset} phase)
+ * @param angleRad angle of the (outward) unit normal in radians at the sample.
+ * (Tangent angle is {@code angleRad - PI/2}).
+ * @return desired segment length L. If {@code <= 0}, the segment is skipped.
+ */
+ double length(double x, double y, double posFrac, double angleRad);
+ }
+
+ /**
+ * Extracts perpendicular segments along each linear component of {@code shape},
+ * with each segment centered on the path/outline.
+ *
+ *
+ * This method samples positions along each linear component (each path or
+ * polygon boundary) at approximately {@code interSegmentDistance} spacing. At
+ * every sampled position it builds a length {@code L} segment that is
+ * perpendicular to the local direction and centered on the sampled point (i.e.
+ * it extends {@code L/2} to each side).
+ *
+ *
+ * {@code startOffset} is wrapped into {@code [0,1)} and acts like a phase along
+ * each component. For closed boundaries, increasing {@code startOffset}
+ * advances sampling counterclockwise and decreasing it advances clockwise. For
+ * open paths, it advances forward/backward along the path direction.
+ *
+ * @param shape the input {@link PShape} containing rings or line
+ * strings
+ * @param interSegmentDistance spacing between successive segments along each
+ * component (arc-length units)
+ * @param L length of each perpendicular segment (must be
+ * > 0)
+ * @param startOffset fractional phase along each component (0..1);
+ * values outside this range are wrapped
+ * @return a list of {@link PEdge} segments from every linear component; empty
+ * if none produce segments
+ * @since 2.2
+ */
+ public static List