diff --git a/CHANGELOG.md b/CHANGELOG.md index 203844e2..8a5dde73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to PGS will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Dates are *YYYY-MM-DD*. +## **2.1** *(2025-xx-xx)* + +### Added +* `smoothLaneRiesenfeld` to `PGS_Morphology`. Smooths a shape using Lane-Riesenfeld curve subdivision with 4-point refinement to reduce contraction. +* Additional method signature for `PGS_Conversion.roundVertexCoords()` that accepts a number of decimal places. +* `interiorAngles()` to `PGS_ShapePredicates`. Calculates all interior angles of a polygon. +* `forEachShape()` and `forEachShapeWithIndex()`* to `PGS_Processing`. Applies a specified transformation function of a desired type `T` to each child of the given PShape, returning a list of `T` (*additionally with child's index). +* `maximumInscribedTriangle()` to `PGS_Optimisation`. Finds an approximate largest area triangle (of arbitrary orientation) contained within a polygon. +* `closestPoint()` to `PGS_Optimisation`. Finds the closest point in a collection of points to a specified point. +* `distanceTree()` to `PGS_Contour`. Generates a tree structure representing the shortest paths from a start point to all other vertices in a mesh. + +### Changes +* Optimised `PGS_CirclePacking.tangencyPack()`. It's now around 1.5-2x faster and has higher precision. +* `PGS_Conversion.roundVertexCoords()` now returns a rounded copy of the input (rather than mutating the input). +* Outputs from `PGS_Conversion.toDualGraph()` will now always iterate deterministically on inputs with the same geometry but having a different structure. +* `PGS_Contour.straightSkeleton()` now always uses a more robust approach (which has been sped up considerably too). + +### Fixed +* `PGS_Morphology.rounding()` no longer gives invalid results. +* `PGS_ShapePredicates.elongation()` now correctly measures shape elongation (previously inverted, now returns 1 for highly elongated shapes). +* `PGS_Conversion.toGraph()` now processes `LINES` shapes correctly. +* `PGS_Meshing.urquhartFaces()` no longer errors on triangulation inputs with no constraints. + +### Removed + ## **2.0** *(2025-01-11)* **NOTE: Beginning at v2.0, PGS is built with Java 17.** diff --git a/README.md b/README.md index 46452c03..950b35d0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Library functionality is split over the following classes: * `PGS_Contour` * Methods that produce various contours from shapes: medial axes, straight skeletons, offset curves, etc. * `PGS_Conversion` - * Conversion between *Processing* PShapes and *JTS* Geometries (amongst other formats) + * Conversion between *Processing* PShapes and *JTS* Geometries (amongst other formats). * `PGS_Hull` * Convex and concave hulls of polygons and point sets. * `PGS_Meshing` @@ -172,23 +172,15 @@ Much of the functionality (but by no means all) is demonstrated below: ### Metrics -* Length/perimeter -* Width & Height -* Diameter -* Circularity -* Similarity -* Sphericity -* Elongation -* Density -* Holes -* Maximum interior angle -* Is simple? -* Is convex? -* Equal? (structural and topological equivalence) -* Distance -* Area -* Centroid -* Median + +| | | | +|-------------|-------------|-------------| +| Length/perimeter | Similarity | Is simple? | +| Width & Height | Sphericity | Is convex? | +| Diameter | Elongation | Equal? (structural and topological equivalence) | +| Circularity | Density | Distance | +| Area | Holes | Centroid | +| Interior angles | Maximum interior angle | Median | ## *Contour* diff --git a/pom.xml b/pom.xml index 6a2e6252..b4de4d40 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 micycle PGS - 2.0 + 2.1-SNAPSHOT Processing Geometry Suite Geometric algorithms for Processing @@ -88,7 +88,7 @@ uber-jar - ${fatJarName} + ${fatJarName} org.apache.maven.plugins @@ -215,7 +215,7 @@ com.github.twak campskeleton - 8df5b241d5 + 453a603706 org.jgrapht @@ -274,6 +274,12 @@ com.github.micycle1 space-filling-curves 1.0 + + + quil + processing-core + + com.github.micycle1 @@ -289,6 +295,12 @@ com.github.micycle1 TrapMap 1.0 + + + quil + processing-core + + it.unimi.dsi diff --git a/resources/contour/distanceField.png b/resources/contour/distanceField.png index 4f624a40..c5d09d37 100644 Binary files a/resources/contour/distanceField.png and b/resources/contour/distanceField.png differ diff --git a/resources/contour/isolines.gif b/resources/contour/isolines.gif index 137413e7..d03f16b7 100644 Binary files a/resources/contour/isolines.gif and b/resources/contour/isolines.gif differ diff --git a/resources/morphology/round.gif b/resources/morphology/round.gif index b445e147..3d22b6a1 100644 Binary files a/resources/morphology/round.gif and b/resources/morphology/round.gif differ diff --git a/src/main/java/micycle/pgs/PGS.java b/src/main/java/micycle/pgs/PGS.java index ee5b7a90..1a204b88 100644 --- a/src/main/java/micycle/pgs/PGS.java +++ b/src/main/java/micycle/pgs/PGS.java @@ -1,9 +1,13 @@ package micycle.pgs; +import static micycle.pgs.PGS_Conversion.fromPShape; +import static micycle.pgs.PGS_Conversion.toPShape; +import static processing.core.PConstants.GROUP; import static processing.core.PConstants.LINES; import static processing.core.PConstants.ROUND; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -12,6 +16,7 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Set; +import java.util.function.UnaryOperator; import org.jgrapht.graph.SimpleWeightedGraph; import org.locationtech.jts.geom.Coordinate; @@ -196,31 +201,26 @@ static final float getPShapeStrokeWeight(final PShape sh) { } /** - * Requires a closed hole - * - * @param points - * @return + * For Processing's y-axis down system, a negative area from the shoelace + * formula in the function's logic will actually correspond to what is visually + * counter-clockwise in Processing. */ - static final boolean isClockwise(List points) { - boolean closed = true; - if (points.get(0).equals(points.get(points.size() - 1))) { - closed = false; - points.add(points.get(0)); // mutate list - } - double area = 0; + static boolean isClockwise(final List points) { + if (points == null || points.size() < 3) { + throw new IllegalArgumentException("Polygon must have at least 3 points."); + } - for (int i = 0; i < (points.size()); i++) { - int j = (i + 1) % points.size(); - area += points.get(i).x * points.get(j).y; - area -= points.get(j).x * points.get(i).y; - } + double area = 0; + int n = points.size(); - if (!closed) { - points.remove(points.size() - 1); // revert mutation - } + for (int i = 0; i < n; i++) { + PVector p1 = points.get(i); + PVector p2 = points.get((i + 1) % n); + area += (p1.x * p2.y - p2.x * p1.y); + } - return (area < 0); - } + return area < 0; // negative area means clockwise (in standard y-up system) + } /** * Nodes (optional) then polygonizes a set of line segments. @@ -557,4 +557,63 @@ public void remove() { } } + /** + * Applies a given function to all lineal elements (LineString and LinearRing) + * in the input PShape. The function processes each LineString or LinearRing and + * returns a modified LineString. The method preserves the structure of the + * input geometry, including exterior-hole relationships in polygons and + * multi-geometries. + * + * @param shape The input PShape containing lineal elements to process. + * @param function A UnaryOperator that takes a LineString or LinearRing and + * returns a modified LineString. + * @return A new PShape with the processed lineal elements. Returns an empty + * PShape for unsupported geometry types. + * @since 2.1 + */ + static PShape applyToLinealGeometries(PShape shape, UnaryOperator function) { + Geometry g = fromPShape(shape); + switch (g.getGeometryType()) { + case Geometry.TYPENAME_GEOMETRYCOLLECTION : + case Geometry.TYPENAME_MULTIPOLYGON : + case Geometry.TYPENAME_MULTILINESTRING : + PShape group = new PShape(GROUP); + for (int i = 0; i < g.getNumGeometries(); i++) { + group.addChild(applyToLinealGeometries(toPShape(g.getGeometryN(i)), function)); + } + return group; + case Geometry.TYPENAME_LINEARRING : + case Geometry.TYPENAME_POLYGON : + // Preserve exterior-hole relations + LinearRing[] rings = new LinearRingIterator(g).getLinearRings(); + LinearRing[] processedRings = new LinearRing[rings.length]; + for (int i = 0; i < rings.length; i++) { + LinearRing ring = rings[i]; + LineString out = function.apply(ring); + if (out.isClosed()) { + processedRings[i] = GEOM_FACTORY.createLinearRing(out.getCoordinates()); + } else { + System.err.println("Output LineString is not closed. Closing it automatically."); + Coordinate[] closedCoords = Arrays.copyOf(out.getCoordinates(), out.getNumPoints() + 1); + closedCoords[closedCoords.length - 1] = closedCoords[0]; // Close the ring + processedRings[i] = GEOM_FACTORY.createLinearRing(closedCoords); + } + } + if (processedRings.length == 0) { + return new PShape(); + } + LinearRing[] holes = null; + if (processedRings.length > 1) { + holes = Arrays.copyOfRange(processedRings, 1, processedRings.length); + } + return toPShape(GEOM_FACTORY.createPolygon(processedRings[0], holes)); + case Geometry.TYPENAME_LINESTRING : + LineString l = (LineString) g; + LineString out = function.apply(l); + return toPShape(out); + default : + return new PShape(); // Return empty (so element is invisible if not processed) + } + } + } diff --git a/src/main/java/micycle/pgs/PGS_Coloring.java b/src/main/java/micycle/pgs/PGS_Coloring.java index f7c303a7..4a72b018 100644 --- a/src/main/java/micycle/pgs/PGS_Coloring.java +++ b/src/main/java/micycle/pgs/PGS_Coloring.java @@ -43,6 +43,8 @@ * @since 1.2.0 */ public final class PGS_Coloring { + + public static long SEED = 1337; private PGS_Coloring() { } @@ -256,10 +258,10 @@ private static Coloring findColoring(Collection shapes, Coloring break; case RLF_BRUTE_FORCE_4COLOR : int iterations = 0; - long seed = 1337; + long seed = SEED; // init as default do { coloring = new RLFColoring<>(graph, seed).getColoring(); - seed = ThreadLocalRandom.current().nextLong(); + seed = ThreadLocalRandom.current().nextLong(); // randomise seed iterations++; } while (coloring.getNumberColors() > 4 && iterations < 250); break; diff --git a/src/main/java/micycle/pgs/PGS_Construction.java b/src/main/java/micycle/pgs/PGS_Construction.java index 6b7d5fd3..f520b25c 100644 --- a/src/main/java/micycle/pgs/PGS_Construction.java +++ b/src/main/java/micycle/pgs/PGS_Construction.java @@ -796,7 +796,7 @@ public static PShape createRandomSFCurve(int nColumns, int nRows, double cellWid * @param cellWidth visual/pixel width of each cell * @param cellHeight visual/pixel width of each cell * @param seed random seed - * @return a stroked PATH PShape + * @return a mitered stroked PATH PShape * @see #createRandomSFCurve(int, int, double, double) * @since 1.4.0 */ diff --git a/src/main/java/micycle/pgs/PGS_Contour.java b/src/main/java/micycle/pgs/PGS_Contour.java index 10456f2e..2dfa23ac 100644 --- a/src/main/java/micycle/pgs/PGS_Contour.java +++ b/src/main/java/micycle/pgs/PGS_Contour.java @@ -14,9 +14,12 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.vecmath.Point3d; +import org.jgrapht.alg.interfaces.ShortestPathAlgorithm; +import org.jgrapht.alg.shortestpath.BFSShortestPath; import org.jgrapht.graph.DefaultEdge; import org.jgrapht.graph.SimpleGraph; import org.joml.Vector2d; @@ -232,15 +235,26 @@ public static PShape chordalAxis(PShape shape) { * consisting of straight-line segments only. Roughly, it is the geometric graph * whose edges are the traces of vertices of shrinking mitered offset curves of * the polygon. - * + *

+ * For a single polygon, this method returns a GROUP PShape containing three + * children: + *

    + *
  • Child 0: GROUP PShape consisting of skeleton faces.
  • + *
  • Child 1: LINES PShape representing branches, which are lines connecting + * the skeleton to the polygon's edge.
  • + *
  • Child 2: LINES PShape composed of bones, depicting the pure straight + * skeleton of the polygon.
  • + *
+ *

+ * For multi-polygons, the method returns a master GROUP PShape. This master + * shape includes multiple skeleton GROUP shapes, each corresponding to a single + * polygon and structured as described above. + * * @param shape a single polygon (that can contain holes), or a multi polygon * (whose polygons can contain holes) - * @return when the input is a single polygon, returns a GROUP PShape containing - * 3 children: child 1 = GROUP PShape of skeleton faces; child 2 = LINES - * PShape of branches (lines that connect skeleton to edge); child 3 = - * LINES PShape of bones (the pure straight skeleton). For - * multi-polygons, a master GROUP shape of skeleton GROUP shapes - * (described above) is returned. + * + * @return PShape based on the input polygon structure, either as a single or + * multi-polygon skeleton representation. */ public static PShape straightSkeleton(PShape shape) { final Geometry g = fromPShape(shape); @@ -255,25 +269,7 @@ public static PShape straightSkeleton(PShape shape) { return shape; } - /** - * - * @param polygon a single polygon that can contain holes - * @return - */ private static PShape straightSkeleton(Polygon polygon) { - /* - * Kenzi implementation (since PGS 1.3.0) is much faster (~50x!) but can fail on - * more complicated inputs. Therefore try Kenzi implementation first, but fall - * back to Twak implementation if it fails. - */ - try { - return straightSkeletonKendzi(polygon); - } catch (Exception e) { - return straightSkeletonTwak(polygon); - } - } - - private static PShape straightSkeletonTwak(Polygon polygon) { if (polygon.getCoordinates().length > 1000) { polygon = (Polygon) DouglasPeuckerSimplifier.simplify(polygon, 2); } @@ -301,7 +297,7 @@ private static PShape straightSkeletonTwak(Polygon polygon) { skeleton.skeleton(); // compute skeleton skeleton.output.faces.values().forEach(f -> { - final List vertices = f.getLoopL().iterator().next().asList(); + List vertices = f.getLoopL().iterator().next().stream().toList(); List faceVertices = new ArrayList<>(); for (int i = 0; i < vertices.size(); i++) { @@ -350,95 +346,20 @@ private static PShape straightSkeletonTwak(Polygon polygon) { return lines; } - private static PShape straightSkeletonKendzi(Polygon polygon) { - final LinearRing[] rings = new LinearRingIterator(polygon).getLinearRings(); - Set edgeCoordsSet = new HashSet<>(); - final List points = ringToVec(rings[0], edgeCoordsSet); - final List> holes = new ArrayList<>(); - for (int i = 1; i < rings.length; i++) { - holes.add(ringToVec(rings[i], edgeCoordsSet)); - } - - final SkeletonOutput so = kendzi.math.geometry.skeleton.Skeleton.skeleton(points, holes, new SkeletonConfiguration()); - final PShape skeleton = new PShape(PConstants.GROUP); - final PShape faces = new PShape(PConstants.GROUP); - /* - * Create PEdges first to prevent lines being duplicated in output shapes since - * faces share branches and bones. - */ - final Set branchEdges = new HashSet<>(); - final Set boneEdges = new HashSet<>(); - so.getFaces().forEach(f -> { - /* - * q stores the index of second vertex of the face that is a shape vertex. This - * is used to rotate f.getPoints() so that the vertices of every face PShape - * begin at the shape edge. - */ - int q = 0; - for (int i = 0; i < f.getPoints().size(); i++) { - final Vector2dc p1 = f.getPoints().get(i); - final Vector2dc p2 = f.getPoints().get((i + 1) % f.getPoints().size()); - final boolean a = edgeCoordsSet.contains(p1); - final boolean b = edgeCoordsSet.contains(p2); - if (a ^ b) { // branch (xor) - branchEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y())); - q = i; - } else { - if (!a) { // bone - boneEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y())); - } else { - q = i; - } - } - } - - List faceVertices = new ArrayList<>(f.getPoints().size()); - Collections.rotate(f.getPoints(), -q + 1); - f.getPoints().forEach(p -> faceVertices.add(new PVector((float) p.x(), (float) p.y()))); - - PShape face = PGS_Conversion.fromPVector(faceVertices); - face.setStroke(true); - face.setStrokeWeight(2); - face.setStroke(ColorUtils.composeColor(147, 112, 219)); - faces.addChild(face); - }); - - final PShape bones = prepareLinesPShape(null, null, 4); - boneEdges.forEach(e -> { - bones.vertex(e.a.x, e.a.y); - bones.vertex(e.b.x, e.b.y); - }); - bones.endShape(); - - final PShape branches = prepareLinesPShape(ColorUtils.composeColor(40, 235, 180), null, null); - branchEdges.forEach(e -> { - branches.vertex(e.a.x, e.a.y); - branches.vertex(e.b.x, e.b.y); - }); - branches.endShape(); - - skeleton.addChild(faces); - skeleton.addChild(branches); - skeleton.addChild(bones); - - return skeleton; - } - /** - * Generates a topographic-like isoline contour map from the shape's vertices. - * The "elevation" (or z value) of points is the euclidean distance between a - * point in the shape and the given "high" point. + * Generates a topographic-like isoline contour map from the shape's vertices + * and a given "high point". Isolines represent the "elevation", or euclidean + * distance, between a location in the shape and the "high point". *

* Assigns each point feature a number equal to the distance between geometry's * centroid and the point. * - * @param shape + * @param shape the bounds in which to draw isolines * @param highPoint position of "high" point within the shape * @param intervalSpacing distance between successive isolines - * @return PShape containing isolines linework + * @return PShape containing isolines linework */ public static PShape isolines(PShape shape, PVector highPoint, double intervalSpacing) { - /* * Also See: * https://github.com/hageldave/JPlotter/blob/master/jplotter/src/main/java/ @@ -636,6 +557,55 @@ public static PShape distanceField(PShape shape, double spacing) { return i; } + /** + * Generates a tree structure representing the shortest paths from a given start + * point to all other vertices in the provided mesh. The paths are computed + * using the existing connectivity of the mesh edges, ensuring that the + * shortest-path tree respects the original mesh structure. The tree is + * constructed using a Breadth-First Search (BFS) algorithm. + *

+ * The shortest-path tree represents the minimal set of mesh edges required to + * connect the start point to all other vertices in the mesh, following the + * mesh's inherent connectivity. This ensures that the paths are constrained by + * the mesh's topology rather than creating arbitrary connections between + * vertices. + *

+ * If the provided start point does not exactly match a vertex in the mesh, the + * closest vertex in the mesh to the start point is used as the actual starting + * point for the shortest-path computation. + * + * @param mesh A GROUP shape representing a mesh from which the graph is + * constructed. The mesh defines the connectivity between + * vertices via its edges. + * @param startPoint The starting point from which the shortest paths are + * calculated. If this point does not exactly match a vertex + * in the mesh, the closest vertex in the mesh will be used as + * the starting point. + * @return A PShape object representing the tree of shortest paths from the + * start point to all other vertices in the mesh. The paths are + * visualized with a semi-transparent pink stroke and are constrained by + * the mesh's edge connectivity. + * @since 2.1 + */ + public static PShape distanceTree(PShape mesh, PVector source) { + var g = PGS_Conversion.toGraph(mesh); + ShortestPathAlgorithm spa = new BFSShortestPath<>(g); + + final PVector sourceActual = PGS_Optimisation.closestPoint(g.vertexSet(), source); + var paths = spa.getPaths(sourceActual); + +// var edges = g.vertexSet().stream().filter(v -> !v.equals(sourceActual)) // Exclude the source vertex +// .flatMap(v -> paths.getPath(v).getEdgeList().stream()) // Flatten the edge lists into a single stream +// .collect(Collectors.toSet()); // Collect the edges into a Set to remove duplicates + + var pathLines = g.vertexSet().stream() // + .filter(v -> v != sourceActual) // Exclude the source vertex + .map(v -> PGS_Conversion.fromPVector(paths.getPath(v).getVertexList())).toList(); + var group = PGS_Conversion.flatten(pathLines); + PGS_Conversion.setAllStrokeColor(group, ColorUtils.setAlpha(Colors.PINK, 50), 2); + return group; + } + /** * Calculates the longest center line passing through a given shape (using * default straightness weighting and smoothing parameters). diff --git a/src/main/java/micycle/pgs/PGS_Conversion.java b/src/main/java/micycle/pgs/PGS_Conversion.java index 51ba0103..109e47e3 100644 --- a/src/main/java/micycle/pgs/PGS_Conversion.java +++ b/src/main/java/micycle/pgs/PGS_Conversion.java @@ -71,6 +71,7 @@ import micycle.pgs.color.Colors; import micycle.pgs.commons.Nullable; import micycle.pgs.commons.PEdge; +import net.jafama.FastMath; import processing.core.PConstants; import processing.core.PMatrix; import processing.core.PShape; @@ -900,24 +901,25 @@ public static List toPVector(PShape shape) { /** * Transforms a given PShape into a simple graph representation. In this * representation, the vertices of the graph correspond to the vertices of the - * shape, and the edges of the graph correspond to the edges of the shape. This - * transformation is specifically applicable to polygonal shapes where edges are - * formed by adjacent vertices. + * shape, and the edges of the graph correspond to the edges of the shape. *

- * The edge weights in the graph are set to the length of the corresponding edge - * in the shape. + * The edge weights in the graph are set to the length (euclidean distance) of + * the corresponding geometric edge in the shape. * - * @param shape the PShape to convert into a graph + * @param shape the PShape to convert into a graph. LINES and polygonal shapes + * are accepted (and GROUP shapes thereof). * @return A SimpleGraph object that represents the structure of the input shape * @since 1.3.0 * @see #toDualGraph(PShape) */ public static SimpleGraph toGraph(PShape shape) { final SimpleGraph graph = new SimpleWeightedGraph<>(PEdge.class); - for (PShape face : getChildren(shape)) { - for (int i = 0; i < face.getVertexCount() - (face.isClosed() ? 0 : 1); i++) { - final PVector a = face.getVertex(i); - final PVector b = face.getVertex((i + 1) % face.getVertexCount()); + for (PShape child : getChildren(shape)) { + final int stride = child.getKind() == PShape.LINES ? 2 : 1; + // Handle other child shapes (e.g., faces) + for (int i = 0; i < child.getVertexCount() - (child.isClosed() ? 0 : 1); i += stride) { + final PVector a = child.getVertex(i); + final PVector b = child.getVertex((i + 1) % child.getVertexCount()); if (a.equals(b)) { continue; } @@ -947,23 +949,27 @@ public static PShape fromGraph(SimpleGraph graph) { } /** - * Takes as input a graph and computes a layout for the graph vertices using a - * Force-Directed placement algorithm (not vertex coordinates, if any exist). - * Vertices are joined by their edges. + * Computes a layout for the vertices of a graph using a Force-Directed + * placement algorithm. The algorithm generates vertex coordinates based on the + * graph's topology, preserving its structure (i.e., connectivity and + * relationships between vertices and edges). Existing vertex coordinates, if + * any, are ignored. *

- * The output is a rather abstract representation of the input graph, and not a - * geometric equivalent (unlike most other conversion methods in the class). + * The output is an abstract representation of the input graph, not a geometric + * equivalent (unlike most other conversion methods in this class). The layout + * is bounded by the specified dimensions and anchored at (0, 0). * - * @param any vertex type - * @param any edge type - * @param graph the graph whose edges and vertices to lay out - * @param normalizationFactor normalization factor for the optimal distance, - * between 0 and 1. - * @param boundsX horizontal vertex bounds - * @param boundsY vertical vertex bounds - * @return a GROUP PShape consisting of 2 children; child 0 is the linework - * (LINES) depicting edges and child 1 is the points (POINTS) depicting - * vertices. The bounds of the layout are anchored at (0, 0); + * @param the type of vertices in the graph + * @param the type of edges in the graph + * @param graph the graph whose vertices and edges are to be laid + * out + * @param normalizationFactor the normalization factor for the optimal distance + * between vertices, clamped between 0.001 and 1 + * @param boundsX the horizontal bounds for the layout + * @param boundsY the vertical bounds for the layout + * @return a GROUP PShape containing two children: child 0 represents the edges + * as linework (LINES), and child 1 represents the vertices as points + * (POINTS) * @since 1.3.0 */ public static PShape fromGraph(SimpleGraph graph, double normalizationFactor, double boundsX, double boundsY) { @@ -1059,32 +1065,43 @@ public static SimpleGraph toCentroidDualGraph(PShape mesh) { */ static SimpleGraph toDualGraph(Collection meshFaces) { final SimpleGraph graph = new SimpleGraph<>(DefaultEdge.class); - // map of which edge belong to each face; used to detect half-edges - final HashMap edgesMap = new HashMap<>(meshFaces.size() * 4); + final Map> edgeFacesMap = new HashMap<>(); + // Phase 1: Collect edges and their associated faces for (PShape face : meshFaces) { - graph.addVertex(face); // always add child so disconnected shapes are colored + graph.addVertex(face); for (int i = 0; i < face.getVertexCount(); i++) { - final PVector a = face.getVertex(i); - final PVector b = face.getVertex((i + 1) % face.getVertexCount()); + PVector a = face.getVertex(i); + PVector b = face.getVertex((i + 1) % face.getVertexCount()); if (a.equals(b)) { continue; } - final PEdge e = new PEdge(a, b); - final PShape neighbour = edgesMap.get(e); - if (neighbour != null) { - // edge seen before, so faces must be adjacent; create edge between faces - if (neighbour.equals(face)) { // probably bad input (3 edges the same) - System.err.println("toDualGraph(): Bad input — saw the same edge 3 times."); - continue; // continue to prevent self-loop in graph - } - graph.addEdge(neighbour, face); - } else { - edgesMap.put(e, face); // edge is new - } + PEdge edge = new PEdge(a, b); + edgeFacesMap.computeIfAbsent(edge, k -> new ArrayList<>()).add(face); } } + + // Phase 2: Process edges in sorted order for graph iteration consistency + edgeFacesMap.entrySet().stream().sorted(Comparator.comparing(e -> e.getKey())) // Sort edges to ensure deterministic processing + .forEach(entry -> { + List faces = entry.getValue(); + if (faces.size() == 2) { + // If exactly two faces share this edge, connect them in the dual graph + PShape f1 = faces.get(0); + PShape f2 = faces.get(1); + if (!f1.equals(f2)) { + graph.addEdge(f1, f2); // Avoid self-loops + } else { + // Handle case where the same face is associated with the edge twice + System.err.println("toDualGraph(): Bad input — saw the same edge 3+ times for face: " + f1); + } + } else if (faces.size() > 2) { + // Handle edges shared by more than two faces + System.err.println("toDualGraph(): Bad input — edge shared by more than two faces: " + entry.getKey().toString()); + } + }); + return graph; } @@ -1449,7 +1466,7 @@ public static PShape fromContours(List shell, @Nullable List points = toPVector(shape); // CLOSE + List points = toPVector(shape); // unclosed if (shape.isClosed() && keepClosed) { points.add(points.get(0).copy()); // since toPVector returns unclosed view } @@ -1486,7 +1503,8 @@ public static PShape fromArray(double[][] shape, boolean close) { /** * Flattens a collection of PShapes into a single GROUP PShape which has the - * input shapes as its children. + * input shapes as its children. If the collection contains only one shape, it + * is directly returned. * * @since 1.2.0 * @see #flatten(PShape...) @@ -1494,6 +1512,9 @@ public static PShape fromArray(double[][] shape, boolean close) { public static PShape flatten(Collection shapes) { PShape group = new PShape(GROUP); shapes.forEach(group::addChild); + if (group.getChildCount() == 1) { + return group.getChild(0); + } return group; } @@ -1599,6 +1620,7 @@ public static PShape setAllFillColor(PShape shape, int color) { * * @param shape * @return the input object (having now been mutated) + * @see #setAllStrokeColor(PShape, int, double, int) * @see #setAllFillColor(PShape, int) */ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight) { @@ -1610,6 +1632,27 @@ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWei return shape; } + /** + * Sets the stroke color and cap style for the PShape and all of its children + * recursively. + * + * @param strokeCap either SQUARE, PROJECT, or + * ROUND + * @return the input object (having now been mutated) + * @see #setAllStrokeColor(PShape, int, double) + * @see #setAllFillColor(PShape, int) + * @since 2.1 + */ + public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight, int strokeCap) { + getChildren(shape).forEach(child -> { + child.setStroke(true); + child.setStroke(color); + child.setStrokeWeight((float) strokeWeight); + child.setStrokeCap(strokeCap); + }); + return shape; + } + /** * Sets the stroke color equal to the fill color for the PShape and all of its * descendent shapes individually (that is, each child shape belonging to the @@ -1707,21 +1750,40 @@ public static PShape disableAllStroke(PShape shape) { /** * Rounds the x and y coordinates (to the closest int) of all vertices belonging - * to the shape, mutating the shape. This can sometimes fix a visual - * problem in Processing where narrow gaps can appear between otherwise flush - * shapes. + * to the shape. This can sometimes fix a visual problem in Processing where + * narrow gaps can appear between otherwise flush shapes. If the shape is a + * GROUP, the rounding is applied to all child shapes. * - * @return the input object (having now been mutated) + * @param shape the PShape to round vertex coordinates for. + * @return a rounded copy of the input shape. + * @see #roundVertexCoords(PShape, int) * @since 1.1.3 */ public static PShape roundVertexCoords(PShape shape) { - getChildren(shape).forEach(c -> { + return roundVertexCoords(shape, 0); + } + + /** + * Rounds the x and y coordinates (to n decimal places) of all + * vertices belonging to the shape. This can sometimes fix a visual problem in + * Processing where narrow gaps can appear between otherwise flush shapes. If + * the shape is a GROUP, the rounding is applied to all child shapes. + * + * @param shape the PShape to round vertex coordinates for. + * @param n The number of decimal places to which the coordinates should be + * rounded. + * @return a rounded copy of the input shape. + * @since 2.1 + */ + public static PShape roundVertexCoords(PShape shape, int n) { + return PGS_Processing.transform(shape, s -> { + var c = copy(s); for (int i = 0; i < c.getVertexCount(); i++) { final PVector v = c.getVertex(i); - c.setVertex(i, Math.round(v.x), Math.round(v.y)); + c.setVertex(i, round(v.x, n), round(v.y, n)); } + return c; }); - return shape; } /** @@ -1943,6 +2005,12 @@ private static T[] reversedCopy(T[] original) { return reversed; } + private static float round(float x, float n) { + float m = (float) FastMath.pow(10, n); + + return FastMath.floor(m * x + 0.5f) / m; + } + /** * A utility class for storing and manipulating the visual properties of PShapes * from the Processing library. It encapsulates the stroke, fill, stroke color, @@ -1990,8 +2058,9 @@ public static class PShapeData { * Apply this shapedata to a given PShape. * * @param other + * @return other (fluent interface) */ - public void applyTo(PShape other) { + public PShape applyTo(PShape other) { if (other.getFamily() == GROUP) { getChildren(other).forEach(c -> applyTo(c)); } @@ -2000,6 +2069,8 @@ public void applyTo(PShape other) { other.setStroke(stroke); other.setStroke(strokeColor); other.setStrokeWeight(strokeWeight); + + return other; } @Override diff --git a/src/main/java/micycle/pgs/PGS_Meshing.java b/src/main/java/micycle/pgs/PGS_Meshing.java index a4d992a1..c963a737 100644 --- a/src/main/java/micycle/pgs/PGS_Meshing.java +++ b/src/main/java/micycle/pgs/PGS_Meshing.java @@ -511,6 +511,9 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation */ private static PShape removeHoles(PShape faces, IIncrementalTin triangulation) { List holes = new ArrayList<>(triangulation.getConstraints()); // copy list + if (holes.size() <= 1) { + return faces; + } holes = holes.subList(1, holes.size()); // slice off perimeter constraint (not a hole) STRtree tree = new STRtree(); diff --git a/src/main/java/micycle/pgs/PGS_Morphology.java b/src/main/java/micycle/pgs/PGS_Morphology.java index d933360a..d29435ea 100644 --- a/src/main/java/micycle/pgs/PGS_Morphology.java +++ b/src/main/java/micycle/pgs/PGS_Morphology.java @@ -37,10 +37,12 @@ import micycle.pgs.color.Colors; import micycle.pgs.commons.ChaikinCut; import micycle.pgs.commons.CornerRounding; +import micycle.pgs.commons.CornerRounding.RoundingStyle; import micycle.pgs.commons.DiscreteCurveEvolution; import micycle.pgs.commons.DiscreteCurveEvolution.DCETerminationCallback; import micycle.pgs.commons.EllipticFourierDesc; import micycle.pgs.commons.GaussianLineSmoothing; +import micycle.pgs.commons.LaneRiesenfeldSmoothing; import micycle.pgs.commons.ShapeInterpolation; import micycle.uniformnoise.UniformNoise; import processing.core.PConstants; @@ -544,21 +546,60 @@ public static PShape smoothEllipticFourier(PShape shape, int descriptors) { } } + /** + * Smooths a shape using Lane-Riesenfeld curve subdivision with 4-point + * refinement to reduce contraction. + * + * @param shape A shape having lineal geometries (polygons or + * linestrings). Can be a GROUP shape consiting of + * these. + * @param degree The degree of the LR algorithm. Higher degrees + * influence the placement of vertices and the + * overall shape of the curve, but only slightly + * increase the number of vertices generated. + * Increasing the degree also increases the + * contraction of the curve toward its control + * points. The degree does not directly control the + * smoothness of the curve. A value of 3 or 4 is + * usually sufficient for most applications. + * @param subdivisions The number of times the subdivision process is + * applied. More subdivisions result in finer + * refinement and visually smoother curves between + * vertices. A value of 3 or 4 is usually + * sufficient for most applications. + * @param antiContractionFactor The weight parameter for the 4-point refinement. + * Controls the interpolation strength. A value of + * 0 effectively disables the contraction + * reduction. Generally suitable values are in + * [0...0.1]. Larger values may create + * self-intersecting geometry. + * @return A Shape having same structure as the input, whose geometries are now + * smooth. + * @since 2.1 + */ + public static PShape smoothLaneRiesenfeld(PShape shape, int degree, int subdivisions, double antiContractionFactor) { + return PGS.applyToLinealGeometries(shape, lineal -> LaneRiesenfeldSmoothing.subdivide(lineal, degree, subdivisions, antiContractionFactor)); + } + /** * Modifies the corners of a specified shape by replacing each angular corner - * with a smooth, circular arc. The radius of each arc is determined - * proportionally to the shorter of the two lines forming the corner. + * with a smooth, circular arc. * - * @param shape The original PShape object whose corners are to be rounded. - * @param extent Specifies the degree of corner rounding, with a range from 0 to - * 1. A value of 0 corresponds to no rounding, whereas a value of - * 1 yields maximum rounding while still maintaining the validity - * of the shape. Values above 1 are accepted but may produce - * unpredictable results. + * @param shape A polygonal PShape, or GROUP shape having polygonal children. + * @param radius The radius of the circular arc used to round each corner. This + * determines how much a circle of the given radius "cuts into" + * the corner. The effective radius is bounded by the lengths of + * the edges forming the corner: If the radius is larger than half + * the length of either edge, it is clamped to the smaller of the + * two half-lengths to prevent overlapping or invalid geometry. * @return A new PShape object with corners rounded to the specified extent. */ - public static PShape round(PShape shape, double extent) { - return CornerRounding.round(shape, extent); + public static PShape round(PShape shape, double radius) { + return PGS_Processing.transform(shape, s -> { + var styling = PGS_Conversion.getShapeStylingData(shape); + var t = CornerRounding.roundCorners(s, radius, RoundingStyle.CIRCLE); + return styling.applyTo(t); + }); } /** diff --git a/src/main/java/micycle/pgs/PGS_Optimisation.java b/src/main/java/micycle/pgs/PGS_Optimisation.java index bd3f229b..8b11aac8 100644 --- a/src/main/java/micycle/pgs/PGS_Optimisation.java +++ b/src/main/java/micycle/pgs/PGS_Optimisation.java @@ -39,6 +39,7 @@ import micycle.pgs.commons.LargestEmptyCircles; import micycle.pgs.commons.MaximumInscribedAARectangle; import micycle.pgs.commons.MaximumInscribedRectangle; +import micycle.pgs.commons.MaximumInscribedTriangle; import micycle.pgs.commons.MinimumBoundingEllipse; import micycle.pgs.commons.MinimumBoundingTriangle; import micycle.pgs.commons.Nullable; @@ -107,7 +108,7 @@ public static PShape maximumInscribedCircle(PShape shape, PVector centerPoint) { * Finds an approximate largest area rectangle (of arbitrary orientation) * contained within a polygon. * - * @param shape + * @param shape a polygonal shape * @return a rectangle shape * @see #maximumInscribedAARectangle(PShape, boolean) * maximumInscribedAARectangle() - the largest axis-aligned rectangle @@ -117,6 +118,20 @@ public static PShape maximumInscribedRectangle(PShape shape) { MaximumInscribedRectangle mir = new MaximumInscribedRectangle(polygon); return toPShape(mir.computeMIR()); } + + /** + * Finds an approximate largest area triangle (of arbitrary orientation) + * contained within a polygon. + * + * @param shape a polygonal shape + * @return a triangular shape + * @since 2.1 + */ + public static PShape maximumInscribedTriangle(PShape shape) { + Polygon polygon = (Polygon) fromPShape(shape); + var mit = new MaximumInscribedTriangle(polygon); + return toPShape(mit.computeMIT()); + } /** * Finds the rectangle with a maximum area whose sides are parallel to the @@ -567,7 +582,8 @@ public static PShape binPack(List shapes, double binWidth, double binHei * * @param shape * @param point - * @return + * @return closest point in the shape to the query (not a reference, but having + * the same coordinates) * @see #closestPoints(PShape, PVector) */ public static PVector closestPoint(PShape shape, PVector point) { @@ -576,6 +592,35 @@ public static PVector closestPoint(PShape shape, PVector point) { return new PVector((float) coord.x, (float) coord.y); } + /** + * Finds the closest point in the collection to a specified point. + * + * @param points the collection of points to search within + * @param point the point to find the closest neighbor for + * @return the closest point from the collection to the specified point + * @since 2.1 + */ + public static PVector closestPoint(Collection points, PVector point) { + if (points == null || points.isEmpty()) { + return null; // Handle empty or null collection + } + + PVector closest = null; + float minDistanceSq = Float.MAX_VALUE; + + for (PVector p : points) { + float dx = p.x - point.x; + float dy = p.y - point.y; + float distanceSq = dx * dx + dy * dy; + if (distanceSq < minDistanceSq) { + minDistanceSq = distanceSq; + closest = p; + } + } + + return closest; + } + /** * Returns the nearest point for each "island" / separate polygon in the GROUP * input shape. diff --git a/src/main/java/micycle/pgs/PGS_PointSet.java b/src/main/java/micycle/pgs/PGS_PointSet.java index f5a4d869..76b48372 100644 --- a/src/main/java/micycle/pgs/PGS_PointSet.java +++ b/src/main/java/micycle/pgs/PGS_PointSet.java @@ -239,8 +239,8 @@ public static List random(double xMin, double yMin, double xMax, double final SplittableRandom random = new SplittableRandom(seed); final List points = new ArrayList<>(n); for (int i = 0; i < n; i++) { - final float x = (float) (xMin + (xMax - xMin) * random.nextDouble()); - final float y = (float) (yMin + (yMax - yMin) * random.nextDouble()); + final float x = (float) random.nextDouble(xMin, xMax); + final float y = (float) random.nextDouble(yMin, yMax); points.add(new PVector(x, y)); } return points; diff --git a/src/main/java/micycle/pgs/PGS_Processing.java b/src/main/java/micycle/pgs/PGS_Processing.java index 23021ab2..638899cd 100644 --- a/src/main/java/micycle/pgs/PGS_Processing.java +++ b/src/main/java/micycle/pgs/PGS_Processing.java @@ -19,6 +19,7 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Predicate; import java.util.function.UnaryOperator; import java.util.stream.Collectors; @@ -1335,6 +1336,86 @@ public static PShape transformWithIndex(PShape shape, BiFunction function.apply(i, children.get(i))).filter(Objects::nonNull).toList()); } + /** + * Applies a specified transformation function to each child of the given PShape + * and returns a list of results produced by the function. + *

+ * This method processes each child of the input shape using the provided + * function, which can transform the shape into any desired type T. The function + * can return null to exclude a shape from the result list. + *

+ * Unlike the {@link #transform(PShape, UnaryOperator)} method, this method does + * not flatten the results into a PShape. Instead, it returns a list of + * arbitrary objects (type T) produced by the transformation function. This + * makes it more flexible for use cases where the transformation does not + * necessarily produce PShape objects. + *

+ * The transformation function can: + *

    + *
  • Transform the shape into a new object of type T
  • + *
  • Return null to exclude the shape from the result list
  • + *
+ *

+ * Note: This method does not modify the original shape or its children. It only + * applies the transformation function to each child and collects the results. + * + * @param The type of the objects produced by the transformation + * function. + * @param shape The PShape whose children will be transformed. + * @param function A Function that takes a PShape as input and returns an object + * of type T. If the function returns null for a shape, that + * shape will be excluded from the result list. + * @return A list of objects of type T produced by applying the transformation + * function to each child of the input shape. + * @see #transform(PShape, UnaryOperator) + * @since 2.1 + */ + public static List forEachShape(PShape shape, Function function) { + return PGS_Conversion.getChildren(shape).stream().map(function).filter(Objects::nonNull).toList(); + } + + /** + * Applies a specified transformation function to each child of the given PShape + * along with its index and returns a list of results produced by the function. + *

+ * This method processes each child of the input shape using the provided + * function, which takes both the index of the child and the child itself as + * input. The function can transform the shape into any desired type T or return + * null to exclude the shape from the result list. + *

+ * Unlike the {@link #transformWithIndex(PShape, BiFunction)} method, this + * method does not flatten the results into a PShape. Instead, it returns a list + * of arbitrary objects (type T) produced by the transformation function. This + * makes it more flexible for use cases where the transformation does not + * necessarily produce PShape objects. + *

+ * The transformation function can: + *

    + *
  • Transform the shape into a new object of type T
  • + *
  • Return null to exclude the shape from the result list
  • + *
+ *

+ * Note: This method does not modify the original shape or its children. It only + * applies the transformation function to each child and collects the results. + * + * @param The type of the objects produced by the transformation + * function. + * @param shape The PShape whose children will be transformed. + * @param function A BiFunction that takes an integer index and a PShape as + * input and returns an object of type T. If the function + * returns null for a shape, that shape will be excluded from + * the result list. + * @return A list of objects of type T produced by applying the transformation + * function to each child of the input shape along with its index. + * @see #transformWithIndex(PShape, BiFunction) + * @see #forEachShape(PShape, Function) + * @since 2.1 + */ + public static List forEachShapeWithIndex(PShape shape, BiFunction function) { + List children = PGS_Conversion.getChildren(shape); + return IntStream.range(0, children.size()).mapToObj(i -> function.apply(i, children.get(i))).filter(Objects::nonNull).toList(); + } + /** * Applies a specified function to each child of the given PShape. *

diff --git a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java index 54a393be..efdbbae0 100644 --- a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java +++ b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.stream.Collectors; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.prep.PreparedGeometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; @@ -85,21 +86,34 @@ public static PShape intersect(final PShape a, final PShape b) { * @since 1.3.0 */ public static PShape intersectMesh(final PShape mesh, final PShape area) { - final Geometry g = fromPShape(area); - final PreparedGeometry cache = PreparedGeometryFactory.prepare(g); + final Geometry areaGeometry = fromPShape(area); + final PreparedGeometry preparedArea = PreparedGeometryFactory.prepare(areaGeometry); + final Envelope areaEnvelope = areaGeometry.getEnvelopeInternal(); + + return PGS_Conversion.toPShape(PGS_Conversion.getChildren(mesh).parallelStream().filter(s -> { + // Quick envelope check before more expensive operations + Geometry face = PGS_Conversion.fromPShape(s); + return areaEnvelope.intersects(face.getEnvelopeInternal()); + }).map(s -> { + final Geometry face = PGS_Conversion.fromPShape(s); + + // First try contains test as it's faster than intersection + if (preparedArea.containsProperly(face)) { + return face; + } - List faces = PGS_Conversion.getChildren(mesh).parallelStream().map(s -> { - final Geometry f = PGS_Conversion.fromPShape(s); - if (cache.containsProperly(f)) { - return f; - } else { - // preserve the fill etc of the PShape during intersection - Geometry boundaryIntersect = OverlayNG.overlay(f, g, OverlayNG.INTERSECTION); - boundaryIntersect.setUserData(f.getUserData()); - return boundaryIntersect; + // Only perform intersection if faces actually overlap + if (preparedArea.intersects(face)) { + Geometry intersection = OverlayNG.overlay(face, areaGeometry, OverlayNG.INTERSECTION); + // Only return non-empty intersections + if (!intersection.isEmpty()) { + intersection.setUserData(face.getUserData()); + return intersection; + } } - }).collect(Collectors.toList()); - return PGS_Conversion.toPShape(faces); + + return null; + }).filter(g -> g != null).collect(Collectors.toList())); } /** @@ -380,7 +394,9 @@ public static PShape complement(PShape shape, double width, double height) { shapeFactory.setNumPoints(4); shapeFactory.setWidth(width); shapeFactory.setHeight(height); - return toPShape(shapeFactory.createRectangle().difference(fromPShape(shape))); + // unioning difference shape helps robustness (when it comprises overlapping + // children) + return toPShape(shapeFactory.createRectangle().difference(fromPShape(shape).union())); } } diff --git a/src/main/java/micycle/pgs/PGS_ShapePredicates.java b/src/main/java/micycle/pgs/PGS_ShapePredicates.java index 21186de8..39383651 100644 --- a/src/main/java/micycle/pgs/PGS_ShapePredicates.java +++ b/src/main/java/micycle/pgs/PGS_ShapePredicates.java @@ -5,7 +5,9 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import javax.vecmath.Point3d; import javax.vecmath.Point4d; @@ -415,11 +417,14 @@ public static double sphericity(final PShape shape) { } /** - * Measures the elongation of a shape; the ratio of a shape's bounding box - * length to its width. + * Measures the elongation of a shape as the ratio of the difference between the + * bounding box's length and width to the maximum dimension. A value of 1 + * indicates a highly elongated shape, while a value of 0 indicates a square or + * nearly square shape. * * @param shape - * @return a value in [0, 1] + * @return a value in the range [0, 1], where 1 represents high elongation and 0 + * represents no elongation */ public static double elongation(final PShape shape) { Geometry obb = MinimumDiameter.getMinimumRectangle(fromPShape(shape)); @@ -429,11 +434,9 @@ public static double elongation(final PShape shape) { Coordinate c2 = rect.getCoordinates()[2]; double l = c0.distance(c1); double w = c1.distance(c2); - if (l >= w) { - return w / l; - } else { - return l / w; - } + double max = Math.max(l, w); + double min = Math.min(l, w); + return 1 - (min / max); } /** @@ -510,6 +513,45 @@ public static double maximumInteriorAngle(PShape shape) { return maxAngle; } + /** + * Calculates the interior angles of a polygon represented by a {@link PShape}. + * The method calculates the interior angle at each vertex. + *

+ * The vertices of the input {@code shape} are assumed to represent a simple + * polygon. + * + * @param shape The {@link PShape} representing the polygon for which to + * calculate interior angles. It's expected to be a polygon shape. + * @return A map where keys are {@link PVector} vertices of the polygon and + * values are their corresponding interior angles in radians as double + * values. + * @since 2.1 + */ + public static Map interiorAngles(PShape shape) { + Map anglesMap = new HashMap<>(); + + var vertices = PGS_Conversion.toPVector(shape); // unclosed + if (!PGS.isClockwise(vertices)) { + Collections.reverse(vertices); + } + + int n = vertices.size(); + for (int i = 0; i < n; i++) { + PVector currentVertex = vertices.get(i); + PVector previousVertex = vertices.get((i - 1 + n) % n); // Get previous vertex, wrapping around + PVector nextVertex = vertices.get((i + 1) % n); // Get next vertex, wrapping around + + Coordinate p0 = new Coordinate(previousVertex.x, previousVertex.y); + Coordinate p1 = new Coordinate(currentVertex.x, currentVertex.y); + Coordinate p2 = new Coordinate(nextVertex.x, nextVertex.y); + double angleRadians = Angle.interiorAngle(p0, p1, p2); // CW + anglesMap.put(currentVertex, angleRadians); + } + + return anglesMap; + + } + /** * Quantifies the similarity between two shapes, by using the pairwise euclidean * distance between each shape's Elliptic Fourier Descriptors (EFD). diff --git a/src/main/java/micycle/pgs/PGS_Voronoi.java b/src/main/java/micycle/pgs/PGS_Voronoi.java index 6415904a..a5464b89 100644 --- a/src/main/java/micycle/pgs/PGS_Voronoi.java +++ b/src/main/java/micycle/pgs/PGS_Voronoi.java @@ -533,7 +533,6 @@ public static PShape multiplicativelyWeightedVoronoi(Collection sites, * @since 2.0 */ public static PShape multiplicativelyWeightedVoronoi(Collection sites, double[] bounds, boolean forceConforming) { - var faces = MultiplicativelyWeightedVoronoi.getMWVFromPVectors(sites.stream().toList(), bounds); Geometry geoms = PGS.GEOM_FACTORY.createGeometryCollection(faces.toArray(new Geometry[] {})); if (forceConforming) { diff --git a/src/main/java/micycle/pgs/commons/CornerRounding.java b/src/main/java/micycle/pgs/commons/CornerRounding.java index f4af4ce5..56e14152 100644 --- a/src/main/java/micycle/pgs/commons/CornerRounding.java +++ b/src/main/java/micycle/pgs/commons/CornerRounding.java @@ -1,157 +1,215 @@ package micycle.pgs.commons; +import static java.lang.Math.PI; + +import java.util.ArrayList; import java.util.List; -import micycle.pgs.PGS_Conversion; import micycle.pgs.color.Colors; -import processing.core.PApplet; +import net.jafama.FastMath; import processing.core.PConstants; import processing.core.PShape; import processing.core.PVector; /** - * Corner rounding for PShape polygons. Replaces corners with arcs. - * - * @author Michael Carleton + * A utility class for rounding the corners of a polygon represented as a + * {@link PShape}. + *

+ * The implementation is based on the algorithm described in the ObservableHQ + * notebook: Rounding + * Polygon Corners. It calculates the necessary arc points for each corner + * and constructs a new {@link PShape} with rounded corners. + *

+ * The class supports different rounding styles, such as strictly geometric + * rounding, natural rounding, and freehand-style rounding, through the + * {@link RoundingStyle} enum. * + * @author Mathieu Jouhet + * @author Michael Carleton */ -public final class CornerRounding { +public class CornerRounding { - // Inspired by https://observablehq.com/@daformat/rounding-polygon-corners - - private CornerRounding() { + /** + * An enum representing the rounding style for the corners of the polygon. + *

    + *
  • {@link #CIRCLE}: Strictly geometric rounding, using perfect circular + * arcs.
  • + *
  • {@link #APPROX}: Natural rounding, approximating circular arcs with + * Bézier curves.
  • + *
  • {@link #HAND}: Freehand-style rounding, providing a more organic + * look.
  • + *
+ */ + public enum RoundingStyle { + CIRCLE, // Strictly geometric rounding + APPROX, // Natural rounding + HAND // Freehand-style rounding } /** - * - * @param shape - * @param extent 0...1 - * @return + * Rounds the corners of a given {@link PShape} using the specified radius and + * rounding style. The method generates circular arcs for each corner and + * constructs a new {@link PShape} with the rounded corners. + * + * @param original The original {@link PShape} whose corners are to be rounded. + * @param radius The radius of the circular arc used to round each corner. + * This determines how much a circle of the given radius "cuts + * into" the corner. The effective radius is bounded by the + * lengths of the edges forming the corner: If the radius is + * larger than half the length of either edge, it is clamped to + * the smaller of the two half-lengths to prevent overlapping or + * invalid geometry. + * @param style The rounding style to apply, as defined in the + * {@link RoundingStyle} enum. + * @return A new {@link PShape} with rounded corners. If the input shape is null + * or the radius is zero, the original shape is returned unchanged. */ - public static PShape round(PShape shape, double extent) { - if (shape.getChildCount() > 1) { - PShape groupRound = new PShape(PConstants.GROUP); - for (PShape child : shape.getChildren()) { - groupRound.addChild(roundPolygon(child, extent)); + public static PShape roundCorners(PShape original, double radius, RoundingStyle style) { + if (original == null || radius == 0) { + return original; + } + + List vertices = extractAndFilterVertices(original); + + int numVertices = vertices.size(); + if (numVertices == 0) { + return original; + } + + List corners = new ArrayList<>(); + + for (int i = 0; i < numVertices; i++) { + PVector c1 = vertices.get(i); + PVector c2 = vertices.get((i + 1) % numVertices); + PVector c3 = vertices.get((i + 2) % numVertices); + + PVector vC1c2 = PVector.sub(c1, c2); + PVector vC3c2 = PVector.sub(c3, c2); + + float dx1 = vC1c2.x; + float dy1 = vC1c2.y; + float mag1 = vC1c2.mag(); + if (mag1 == 0) { + continue; + } + float unitX1 = dx1 / mag1; + float unitY1 = dy1 / mag1; + + float dx3 = vC3c2.x; + float dy3 = vC3c2.y; + float mag3 = vC3c2.mag(); + if (mag3 == 0) { + continue; + } + float unitX3 = dx3 / mag3; + float unitY3 = dy3 / mag3; + + float cross = dx1 * dy3 - dy1 * dx3; + float dot = dx1 * dx3 + dy1 * dy3; + final float angle = abs(atan2(cross, dot)); // == angleBetween(vC1c2, vC3c2) + + float cornerLength = min((float) radius, mag1 / 2, mag3 / 2); + if (cornerLength <= 0) { + continue; } - return groupRound; + + float a = cornerLength * tan(angle / 2); + + float idealCPDistance = 0; + idealCPDistance = switch (style) { + case CIRCLE -> { + double numPointsCircle = (2 * PI) / (PI - angle); + yield (4.0f / 3) * tan(PI / (2 * numPointsCircle)) * a; + } + case APPROX -> { + float multiplier = angle < PI / 2 ? 1 + cos(angle) : 2 - sin(angle); + yield (4.0f / 3) * tan(PI / (2 * ((2 * PI) / angle))) * cornerLength * multiplier; + } + case HAND -> (4.0f / 3) * tan(PI / (2 * ((2 * PI) / angle))) * cornerLength * (2 + sin(angle)); + default -> (4.0f / 3) * cornerLength; + }; + + float cpDistance = cornerLength - idealCPDistance; + + PVector c1CurvePoint = new PVector(c2.x + unitX1 * cornerLength, c2.y + unitY1 * cornerLength); + PVector c1CP = new PVector(c2.x + unitX1 * cpDistance, c2.y + unitY1 * cpDistance); + PVector c3CurvePoint = new PVector(c2.x + unitX3 * cornerLength, c2.y + unitY3 * cornerLength); + PVector c3CP = new PVector(c2.x + unitX3 * cpDistance, c2.y + unitY3 * cpDistance); + + corners.add(new CornerData(c1CurvePoint, c1CP, c3CP, c3CurvePoint)); } - else { - return roundPolygon(shape, extent); + + if (corners.isEmpty()) { + return original; } - } - - private static PShape roundPolygon(PShape shape, double extent) { - PShape rounded = new PShape(PShape.GEOMETRY); - PGS_Conversion.setAllFillColor(rounded, Colors.PINK); + + PVector startPoint = corners.get(corners.size() - 1).c3CurvePoint; + PShape rounded = new PShape(PShape.PATH); + rounded.setStroke(Colors.PINK); + rounded.setStroke(true); + rounded.setFill(true); + rounded.setFill(Colors.WHITE); rounded.beginShape(); + rounded.vertex(startPoint.x, startPoint.y); - final List l = PGS_Conversion.toPVector(shape); - final int size = l.size(); - for (int i = 0; i < l.size(); i++) { - final PVector p1 = l.get(Math.floorMod(i - 1, size)); - final PVector p2 = l.get(i); - final PVector p3 = l.get((i + 1) % size); - roundCorner(p1, p2, p3, extent, rounded); + for (CornerData corner : corners) { + rounded.vertex(corner.c1CurvePoint.x, corner.c1CurvePoint.y); + rounded.bezierVertex(corner.c1CP.x, corner.c1CP.y, corner.c3CP.x, corner.c3CP.y, corner.c3CurvePoint.x, corner.c3CurvePoint.y); } + rounded.endShape(PConstants.CLOSE); return rounded; } - /** - * Round a triplet of points. - * - * @param a - * @param b middle/enclosed point - * @param c - * @param extent - * @param shape - */ - private static void roundCorner(PVector a, PVector b, PVector c, double extent, PShape shape) { - if (clockwise(a, b, c)) { - PVector temp = a; - a = c; - c = temp; - } - - // line vectors - PVector ab = PVector.sub(a, b); - PVector cb = PVector.sub(c, b); - - float theta = PApplet.acos(ab.dot(cb) / (ab.mag() * cb.mag())); // same as a.angleBetween(a, c) - -// final float maxRadius = PApplet.min(ab.div(2).mag(), cb.div(2).mag()); -// extent = extent * maxRadius; - final float extentF = (float) extent; + private static List extractAndFilterVertices(PShape shape) { + List vertices = new ArrayList<>(); + int vertexCount = shape.getVertexCount(); + if (vertexCount > 0) { + PVector firstVertex = new PVector(shape.getVertexX(0), shape.getVertexY(0)); + vertices.add(firstVertex); + PVector prevVertex = firstVertex; + + for (int i = 1; i < vertexCount; i++) { + PVector currentVertex = new PVector(shape.getVertexX(i), shape.getVertexY(i)); + if (currentVertex.x != prevVertex.x || currentVertex.y != prevVertex.y) { + vertices.add(currentVertex); + prevVertex = currentVertex; + } + } - final PVector A = PVector.add(b, ab.mult(extentF / ab.mag())); // where circle touches AB - final PVector C = PVector.add(b, cb.mult(extentF / cb.mag())); // where circle touches CB + // Check if last vertex is the same as the first (for closed shapes) + if (vertexCount > 1 && firstVertex.x == prevVertex.x && firstVertex.y == prevVertex.y) { + vertices.remove(vertices.size() - 1); + } + } + return vertices; + } - PVector vBC = PVector.sub(C, b); + private record CornerData(PVector c1CurvePoint, PVector c1CP, PVector c3CP, PVector c3CurvePoint) { + } - final float lengthBC = vBC.mag(); - final float lengthBD = PApplet.cos(theta / 2); // length - final float lengthFD = PApplet.sin(theta / 2) * lengthBD; // length - final float lengthBF = lengthBD * lengthBD; // length - final float r = lengthFD / (lengthBF / lengthBC); // radius of circle - PVector tangent = ab.normalize().rotate(PConstants.HALF_PI).mult(r); // tangent to ab - tangent.add(A); // find tangent to ab at point A -- this is circle center + private static float min(float a, float b, float c) { + return Math.min(a, Math.min(b, c)); + } - shape.vertex(C.x, C.y); - final float a1 = angleBetween(C, tangent); - final float a2 = angleBetween(A, tangent); - sampleArc(tangent, r, a1, a2, shape); - shape.vertex(A.x, A.y); + private static float abs(float val) { + return Math.abs(val); } - /** - * Sub-sample an arc into invididual vertices. - * - * @param center - * @param r arc radius - * @param startAngle - * @param endAngle - * @param shape - */ - private static void sampleArc(PVector center, float r, float startAngle, float endAngle, PShape shape) { - if (startAngle > endAngle) { - startAngle -= PConstants.TWO_PI; - } - final float n = 4; // every n degrees // TODO magic constant - final float angleInc = (endAngle - startAngle) / (360 / n); - float angle = startAngle; - while (angle < endAngle) { - shape.vertex(r * PApplet.cos(angle) + center.x, center.y + r * PApplet.sin(angle)); - angle += angleInc; - } + private static float atan2(float y, float x) { + return (float) FastMath.atan2(y, x); } - /** - * Are the three given points in clockwise order? - * - * @param p1 - * @param p2 middle point - * @param p3 - * @return true if the points consitute a "right turn" or clockwise orientation - */ - private static boolean clockwise(PVector p1, PVector p2, PVector p3) { - float val = (p2.y - p1.y) * (p3.x - p2.x) - (p2.x - p1.x) * (p3.y - p2.y); - return val < 0; + private static float tan(double angle) { + return (float) FastMath.tan(angle); } - /** - * East = 0; North = -1/2PI; West = -PI; South = -3/2PI | 1/2PI - * - * @param tail PVector Coordinate 1. - * @param head PVector Coordinate 2. - * @return float θ in radians. - */ - private static float angleBetween(PVector tail, PVector head) { - float a = FastAtan2.atan2(tail.y - head.y, tail.x - head.x); - if (a < 0) { - a += PConstants.TWO_PI; - } - return a; + private static float cos(double angle) { + return (float) FastMath.cos(angle); } -} + private static float sin(double angle) { + return (float) FastMath.sin(angle); + } +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/LaneRiesenfeldSmoothing.java b/src/main/java/micycle/pgs/commons/LaneRiesenfeldSmoothing.java new file mode 100644 index 00000000..55998831 --- /dev/null +++ b/src/main/java/micycle/pgs/commons/LaneRiesenfeldSmoothing.java @@ -0,0 +1,219 @@ +package micycle.pgs.commons; + +import org.locationtech.jts.geom.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Geometry smoothing using Lane-Riesenfeld (LR) curve subdivision with 4-point + * refinement to reduce contraction. + *

+ * The LR algorithm is a generalization of the Chaikin subdivision which + * generates splines with variable continuity. The 4-point (Dyn-Levin-Gregory) + * refinement is a variant that interpolates the control points. A combination + * of LR and 4-point refinement can be used to reduce contraction when smoothing + * a geometry. + *

+ * This class provides a utility method to subdivide geometries using the LR + * algorithm with 4-point refinement. The algorithm can be applied to both open + * and closed geometries (e.g., LineString and LinearRing). + * + * @author Michael Carleton + */ +public class LaneRiesenfeldSmoothing { + + // https://observablehq.com/@esperanc/lane-riesenfeld-subdivision + // https://tiborstanko.sk/teaching/geo-num-2017/tp5.html + + /** + * Subdivides the input geometry using the Lane-Riesenfeld algorithm with + * 4-point refinement. + * + * @param geometry The lineal input geometry to subdivide. + * @param degree The degree of the LR algorithm. Higher degrees + * influence the placement of vertices and the + * overall shape of the curve, but only slightly + * increase the number of vertices generated. + * Increasing the degree also increases the + * contraction of the curve toward its control + * points. The degree does not directly control the + * smoothness of the curve. A value of 3 or 4 is + * usually sufficient for most applications. + * @param subdivisions The number of times the subdivision process is + * applied. More subdivisions result in finer + * refinement and visually smoother curves between + * vertices. A value of 3 or 4 is usually + * sufficient for most applications. + * @param antiContractionFactor The weight parameter for the 4-point refinement. + * Controls the interpolation strength. A value of + * 0 effectively disables the contraction + * reduction. Generally suitable values are in + * [0...0.1]. Larger values may create + * self-intersecting geometry. + * @return A new subdivided geometry (LineString or LinearRing). + */ + public static LineString subdivide(LineString geometry, int degree, int subdivisions, double antiContractionFactor) { + Coordinate[] coords = geometry.getCoordinates(); + boolean closed = geometry.isClosed(); + if (closed) { + coords = Arrays.copyOf(coords, coords.length - 1); // Remove the last coordinate if closed + } + Coordinate[] subdivided = lr4(coords, degree, closed, antiContractionFactor, subdivisions); + GeometryFactory factory = geometry.getFactory(); + return createGeometry(factory, subdivided, closed); + } + + private static LineString createGeometry(GeometryFactory factory, Coordinate[] coords, boolean closed) { + if (closed) { + List coordList = new ArrayList<>(Arrays.asList(coords)); + coordList.add(new Coordinate(coordList.get(0))); + return factory.createLinearRing(coordList.toArray(new Coordinate[0])); + } else { + return factory.createLineString(coords); + } + } + + /** + * Applies the Lane-Riesenfeld algorithm with 4-point refinement to an array of + * coordinates. This replaces the vanilla midpoint averaging with four-point + * averaging. + * + * @param points The input array of coordinates. + * @param degree The degree of the Lane-Riesenfeld algorithm. + * @param closed Whether the geometry is closed. + * @param w The weight parameter for the 4-point refinement. + * @param subdivisions The number of times the subdivision process is applied. + * @return A new array of subdivided coordinates. + */ + private static Coordinate[] lr4(Coordinate[] points, int degree, boolean closed, double w, int subdivisions) { + if (degree < 1 || subdivisions < 1) { + return Arrays.copyOf(points, points.length); + } + + Coordinate[] v = points; + + for (int s = 0; s < subdivisions; s++) { + v = fourPoint(v, closed, w); + + for (int d = 1; d < degree; d++) { + int n = v.length; + List u = new ArrayList<>(); + + for (int i = 0; i < n; i++) { + int prevIndex = getPreviousIndex(i, n, closed); + int nextIndex = getNextIndex(i, n, closed); + int nextNextIndex = getNextNextIndex(i, n, closed); + + Coordinate p0 = v[prevIndex]; + Coordinate p1 = v[i]; + Coordinate p2 = v[nextIndex]; + Coordinate p3 = v[nextNextIndex]; + + double qx = computeQ(p0.x, p1.x, p2.x, p3.x, w); + double qy = computeQ(p0.y, p1.y, p2.y, p3.y, w); + u.add(new Coordinate(qx, qy)); + } + + if (closed) { + v = u.toArray(new Coordinate[0]); + } else { + List newV = new ArrayList<>(); + if (v.length > 0) { + newV.add(v[0]); + } + if (!u.isEmpty()) { + newV.addAll(u.subList(0, u.size() - 1)); + } + if (v.length > 0) { + newV.add(v[v.length - 1]); + } + v = newV.toArray(new Coordinate[0]); + } + } + } + + return v; + } + + /** + * Applies the 4-point (Dyn-Levin-Gregory) refinement to an array of + * coordinates. + * + * @param points The input array of coordinates. + * @param closed Whether the geometry is closed. + * @param w The weight parameter for the 4-point refinement. + * @return A new array of refined coordinates. + */ + private static Coordinate[] fourPoint(Coordinate[] points, boolean closed, double w) { + int n = points.length; + if (n == 0) { + return new Coordinate[0]; + } + + List result = new ArrayList<>(points.length); + + for (int i = 0; i < n; i++) { + int prevIndex = getPreviousIndex(i, n, closed); + int nextIndex = getNextIndex(i, n, closed); + int nextNextIndex = getNextNextIndex(i, n, closed); + + Coordinate p0 = points[prevIndex]; + Coordinate p1 = points[i]; + Coordinate p2 = points[nextIndex]; + Coordinate p3 = points[nextNextIndex]; + + double qx = computeQ(p0.x, p1.x, p2.x, p3.x, w); + double qy = computeQ(p0.y, p1.y, p2.y, p3.y, w); + Coordinate q = new Coordinate(qx, qy); + + result.add(p1); + result.add(q); + } + + if (!closed && !result.isEmpty()) { + result.remove(result.size() - 1); + } + + return result.toArray(new Coordinate[0]); + } + + /** + * Computes a new coordinate value using the 4-point refinement formula. + * + * @param p0 The first coordinate value. + * @param p1 The second coordinate value. + * @param p2 The third coordinate value. + * @param p3 The fourth coordinate value. + * @param w The weight parameter. + * @return The computed coordinate value. + */ + private static double computeQ(double p0, double p1, double p2, double p3, double w) { + return -w * p0 + (0.5 + w) * p1 + (0.5 + w) * p2 - w * p3; + } + + private static int getPreviousIndex(int i, int n, boolean closed) { + if (closed) { + return (i - 1 + n) % n; + } else { + return Math.max(0, i - 1); + } + } + + private static int getNextIndex(int i, int n, boolean closed) { + if (closed) { + return (i + 1) % n; + } else { + return Math.min(n - 1, i + 1); + } + } + + private static int getNextNextIndex(int i, int n, boolean closed) { + if (closed) { + return (i + 2) % n; + } else { + return Math.min(n - 1, i + 2); + } + } +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/MaximumInscribedRectangle.java b/src/main/java/micycle/pgs/commons/MaximumInscribedRectangle.java index 952d0556..e330a4fd 100644 --- a/src/main/java/micycle/pgs/commons/MaximumInscribedRectangle.java +++ b/src/main/java/micycle/pgs/commons/MaximumInscribedRectangle.java @@ -4,8 +4,8 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.prep.PreparedGeometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; @@ -13,156 +13,239 @@ import net.metaopt.swarm.FitnessFunction; import net.metaopt.swarm.pso.Particle; import net.metaopt.swarm.pso.Swarm; -import processing.core.PVector; + +import org.apache.commons.math3.analysis.MultivariateFunction; +import org.apache.commons.math3.optim.InitialGuess; +import org.apache.commons.math3.optim.MaxEval; +import org.apache.commons.math3.optim.nonlinear.scalar.GoalType; +import org.apache.commons.math3.optim.nonlinear.scalar.MultivariateOptimizer; +import org.apache.commons.math3.optim.nonlinear.scalar.ObjectiveFunction; +import org.apache.commons.math3.optim.nonlinear.scalar.noderiv.NelderMeadSimplex; +import org.apache.commons.math3.optim.nonlinear.scalar.noderiv.SimplexOptimizer; +import org.apache.commons.math3.optim.PointValuePair; + +import java.util.Random; /** * Finds an approximate largest area rectangle of arbitrary orientation in a - * polygon via particle swarm optimisation. + * concave polygon via particle swarm optimisation. * * @author Michael Carleton * */ public class MaximumInscribedRectangle { - private static final int SWARM_SIZE = 2500; // Reduced from 2500 - private static final int GENERATIONS = 1000; // Reduced from 1000 - private static final double ASPECT_WEIGHT = 0.05; // Small bonus for non-square shapes + private static final int SWARM_SIZE = 2500; // swarm size is tunable + private static final int GENERATIONS = 1000; // maximum PSO generations + private static final double ASPECT_WEIGHT = 0.05; // bonus for non-square shapes private static final GeometryFactory GEOM_FACTORY = new GeometryFactory(); + private static final Random RANDOM = new Random(); + private final Swarm swarm; + private final RectangleFitness fitnessFunction; // used by both PSO and local optimization public MaximumInscribedRectangle(Polygon polygon) { - final MaximumInscribedCircle mic = new MaximumInscribedCircle(polygon, 2); - final double minSquare = mic.getRadiusLine().getLength() / Math.sqrt(2); + // We compute a lower bound based on the maximum inscribed circle. + MaximumInscribedCircle mic = new MaximumInscribedCircle(polygon, 2); + double minSquare = mic.getRadiusLine().getLength() / Math.sqrt(2); - // Create multiple sub-swarms with different initial conditions - final FitnessFunction fitnessFunction = new RectangleFitness(polygon); - swarm = new Swarm(SWARM_SIZE, new RectangleCandidate(), fitnessFunction); + fitnessFunction = new RectangleFitness(polygon); + swarm = new ParallelSwarm(SWARM_SIZE, new RectangleCandidate(), fitnessFunction); - final Envelope e = polygon.getEnvelopeInternal(); - final double[] maxPosition = new double[] { e.getMaxX(), e.getMaxY(), e.getWidth(), e.getHeight(), Math.PI }; - final double[] minPosition = new double[] { e.getMinX(), e.getMinY(), minSquare, minSquare, -Math.PI }; + Envelope e = polygon.getEnvelopeInternal(); + double[] maxPosition = new double[] { e.getMaxX(), e.getMaxY(), e.getWidth(), e.getHeight(), Math.PI }; + double[] minPosition = new double[] { e.getMinX(), e.getMinY(), minSquare, minSquare, -Math.PI }; swarm.setMaxPosition(maxPosition); swarm.setMinPosition(minPosition); - - // Initialize particles in promising regions swarm.init(); initializeSwarm(swarm, e, minSquare); } private void initializeSwarm(Swarm swarm, Envelope e, double minSquare) { Particle[] particles = swarm.getParticles(); - int particlesPerRegion = SWARM_SIZE / 4; + final int particlesPerRegion = SWARM_SIZE / 4; // Four groups + double minX = e.getMinX(); + double minY = e.getMinY(); + double width = e.getWidth(); + double height = e.getHeight(); for (int i = 0; i < SWARM_SIZE; i++) { double[] position = new double[5]; if (i < particlesPerRegion) { - // Group 1: Initialize along envelope edges - position[0] = e.getMinX() + Math.random() * e.getWidth(); - position[1] = Math.random() < 0.5 ? e.getMinY() : e.getMaxY(); - position[2] = e.getWidth() * (0.3 + Math.random() * 0.7); - position[3] = e.getHeight() * (0.3 + Math.random() * 0.7); - position[4] = Math.random() * Math.PI - Math.PI / 2; + // Group 1: Along envelope edges + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = RANDOM.nextDouble() < 0.5 ? e.getMinY() : e.getMaxY(); + position[2] = width * (0.3 + RANDOM.nextDouble() * 0.7); + position[3] = height * (0.3 + RANDOM.nextDouble() * 0.7); + position[4] = RANDOM.nextDouble() * Math.PI - Math.PI / 2; } else if (i < 2 * particlesPerRegion) { - // Group 2: Try vertical rectangles - position[0] = e.getMinX() + Math.random() * e.getWidth(); - position[1] = e.getMinY() + Math.random() * e.getHeight(); - position[2] = e.getWidth() * (0.1 + Math.random() * 0.3); - position[3] = e.getHeight() * (0.7 + Math.random() * 0.3); - position[4] = Math.random() * Math.PI / 6 - Math.PI / 12; + // Group 2: Vertical rectangles + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = minY + RANDOM.nextDouble() * height; + position[2] = width * (0.1 + RANDOM.nextDouble() * 0.3); + position[3] = height * (0.7 + RANDOM.nextDouble() * 0.3); + position[4] = RANDOM.nextDouble() * (Math.PI / 6) - Math.PI / 12; } else if (i < 3 * particlesPerRegion) { - // Group 3: Try horizontal rectangles - position[0] = e.getMinX() + Math.random() * e.getWidth(); - position[1] = e.getMinY() + Math.random() * e.getHeight(); - position[2] = e.getWidth() * (0.7 + Math.random() * 0.3); - position[3] = e.getHeight() * (0.1 + Math.random() * 0.3); - position[4] = Math.PI / 2 + Math.random() * Math.PI / 6 - Math.PI / 12; + // Group 3: Horizontal rectangles + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = minY + RANDOM.nextDouble() * height; + position[2] = width * (0.7 + RANDOM.nextDouble() * 0.3); + position[3] = height * (0.1 + RANDOM.nextDouble() * 0.3); + position[4] = Math.PI / 2 + RANDOM.nextDouble() * (Math.PI / 6) - Math.PI / 12; } else { // Group 4: Random exploration - position[0] = e.getMinX() + Math.random() * e.getWidth(); - position[1] = e.getMinY() + Math.random() * e.getHeight(); - position[2] = minSquare + Math.random() * (e.getWidth() - minSquare); - position[3] = minSquare + Math.random() * (e.getHeight() - minSquare); - position[4] = Math.random() * Math.PI - Math.PI / 2; + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = minY + RANDOM.nextDouble() * height; + position[2] = minSquare + RANDOM.nextDouble() * (width - minSquare); + position[3] = minSquare + RANDOM.nextDouble() * (height - minSquare); + position[4] = RANDOM.nextDouble() * Math.PI - Math.PI / 2; } - particles[i].setPosition(position); particles[i].setVelocity(new double[] { 0, 0, 0, 0, 0 }); particles[i].setBestPosition(position.clone()); } } + /** + * This method runs the swarm evolution and then refines the best candidate + * using Apache Commons Math Nelder–Mead simplex optimization. + */ public Polygon computeMIR() { - int i = 0; - int same = 0; + int gen = 0, stableCount = 0; double lastFitness = Double.MIN_VALUE; - while (i++ < GENERATIONS) { + // Evolve the swarm until convergence criterion or maximum generation reached. + while (gen++ < GENERATIONS) { swarm.evolve(); - - // Periodically reinitialize worst performing particles - if (i % 50 == 0) { + if (gen % 50 == 0) { reinitializeWorstParticles(swarm); } - - if (swarm.getBestFitness() == lastFitness) { - if (same++ > 50) { + double bestFitness = swarm.getBestFitness(); + if (bestFitness == lastFitness) { + if (stableCount++ > 50) { break; } } else { - same = 0; + stableCount = 0; } - lastFitness = swarm.getBestFitness(); + lastFitness = bestFitness; } - return getBestRectangleResult(swarm); - } - - private Polygon getBestRectangleResult(Swarm swarm) { - return rectFromCoords(swarm.getBestPosition()); + // Retrieve the best candidate from the swarm + double[] bestCandidate = swarm.getBestPosition().clone(); + // Refine using Apache Commons Math + bestCandidate = refineCandidate(bestCandidate); + return rectFromCoords(bestCandidate); } private void reinitializeWorstParticles(Swarm swarm) { Particle[] particles = swarm.getParticles(); double[] bestPos = swarm.getBestPosition(); - - // Reinitialize bottom 10% of particles int reinitCount = SWARM_SIZE / 10; + double[] maxPos = swarm.getMaxPosition(); + double[] minPos = swarm.getMinPosition(); for (int i = 0; i < reinitCount; i++) { double[] newPos = new double[5]; - // Explore around current best solution for (int j = 0; j < 5; j++) { - double range = (swarm.getMaxPosition()[j] - swarm.getMinPosition()[j]) * 0.1; - newPos[j] = bestPos[j] + (Math.random() - 0.5) * range; - // Ensure bounds - newPos[j] = Math.max(swarm.getMinPosition()[j], Math.min(swarm.getMaxPosition()[j], newPos[j])); + double range = (maxPos[j] - minPos[j]) * 0.1; + newPos[j] = bestPos[j] + (RANDOM.nextDouble() - 0.5) * range; + // Clamp within bounds. + newPos[j] = Math.max(minPos[j], Math.min(maxPos[j], newPos[j])); } particles[i].setPosition(newPos); particles[i].setVelocity(new double[] { 0, 0, 0, 0, 0 }); } } + /** + * Uses Apache Commons Math optimizer (Nelder–Mead Simplex) to refine the best + * candidate. We define a MultivariateFunction that returns the negative fitness + * for minimization. + */ + private double[] refineCandidate(double[] candidate) { + // We want to maximize the fitness, so we minimize negative fitness. + MultivariateFunction objective = new MultivariateFunction() { + public double value(double[] point) { + // Return the negative fitness + return -fitnessFunction.evaluate(point); + } + }; + + // No additional bounds mapping is used here since our candidate is already + // inside the feasible region. + // However, you can wrap this with a MultivariateFunctionMappingAdapter if + // needed. + MultivariateOptimizer optimizer = new SimplexOptimizer(1e-8, 1e-8); + NelderMeadSimplex simplex = new NelderMeadSimplex(candidate.length); + try { + PointValuePair result = optimizer.optimize(new MaxEval(1000), new ObjectiveFunction(objective), GoalType.MINIMIZE, new InitialGuess(candidate), + simplex); + return result.getPoint(); + } catch (Exception e) { + // If the optimizer fails, return the original candidate. + return candidate; + } + } + + /** + * Builds a rectangle Polygon based on a candidate parameter vector. + * + * The candidate parameters are: [x, y, width, height, angle] and the rectangle + * corners are computed from these values. + */ + private static Polygon rectFromCoords(double[] position) { + double x = position[0]; + double y = position[1]; + double w = position[2]; + double h = position[3]; + double a = position[4]; + + double cosA = FastMath.cos(a); + double sinA = FastMath.sin(a); + double dx = cosA * w; + double dy = sinA * w; + double dx2 = -sinA * h; + double dy2 = cosA * h; + + Coordinate[] coords = new Coordinate[5]; + coords[0] = new Coordinate(x, y); + coords[1] = new Coordinate(x + dx, y + dy); + coords[2] = new Coordinate(x + dx + dx2, y + dy + dy2); + coords[3] = new Coordinate(x + dx2, y + dy2); + coords[4] = coords[0]; + + return GEOM_FACTORY.createPolygon(coords); + } + + /** + * Evaluates each candidate rectangle. A candidate rectangle (constructed from + * position) is given a fitness equal to its area, with a small bonus for + * non-square aspect ratios. If the rectangle is not properly contained in the + * geometry, it returns 0. + */ private class RectangleFitness extends FitnessFunction { - private PreparedGeometry geometry; + private final PreparedGeometry geometry; - RectangleFitness(Geometry geometry) { - this.geometry = PreparedGeometryFactory.prepare(geometry); + RectangleFitness(Geometry geom) { + this.geometry = PreparedGeometryFactory.prepare(geom); } @Override public double evaluate(double[] position) { - final double w = position[2]; - final double h = position[3]; - if (!geometry.containsProperly(rectFromCoords(position))) { + double w = position[2]; + double h = position[3]; + Polygon rect = rectFromCoords(position); + if (!geometry.containsProperly(rect)) { return 0; } - // Add small bonus for non-square shapes to encourage elongated rectangles - double aspectRatio = Math.max(w / h, h / w); - return h * w * (1 + ASPECT_WEIGHT * (aspectRatio - 1)); + double aspectRatio = (w > h) ? w / h : h / w; + return w * h * (1 + ASPECT_WEIGHT * (aspectRatio - 1)); } } + // Particle candidate for the swarm; note the dimension is 5. private class RectangleCandidate extends Particle { public RectangleCandidate() { super(5); @@ -173,31 +256,4 @@ public Object selfFactory() { return new RectangleCandidate(); } } - - private static final Polygon rectFromCoords(double[] position) { - final double x = position[0]; - final double y = position[1]; - final double w = position[2]; - final double h = position[3]; - final double a = position[4]; - - Coordinate[] coords = new Coordinate[5]; - PVector base = new PVector((float) x, (float) y); - PVector dir1 = new PVector((float) FastMath.cos(a), (float) FastMath.sin(a)); - PVector dir2 = dir1.copy().rotate((float) Math.PI * 0.5f); - PVector base2 = base.copy().add(dir1.copy().mult((float) w)); - coords[0] = coordFromPVector(base); - coords[1] = coordFromPVector(base2); - dir2.mult((float) h); - coords[2] = coordFromPVector(base2.add(dir2)); - coords[3] = coordFromPVector(base.add(dir2)); - coords[4] = coords[0]; - - return GEOM_FACTORY.createPolygon(coords); - } - - private static final Coordinate coordFromPVector(PVector p) { - return new Coordinate(p.x, p.y); - } - } diff --git a/src/main/java/micycle/pgs/commons/MaximumInscribedTriangle.java b/src/main/java/micycle/pgs/commons/MaximumInscribedTriangle.java new file mode 100644 index 00000000..21adb40f --- /dev/null +++ b/src/main/java/micycle/pgs/commons/MaximumInscribedTriangle.java @@ -0,0 +1,405 @@ +package micycle.pgs.commons; + +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import org.apache.commons.math3.analysis.MultivariateFunction; +import org.apache.commons.math3.optim.InitialGuess; +import org.apache.commons.math3.optim.MaxEval; +import org.apache.commons.math3.optim.PointValuePair; +import org.apache.commons.math3.optim.nonlinear.scalar.GoalType; +import org.apache.commons.math3.optim.nonlinear.scalar.ObjectiveFunction; +import org.apache.commons.math3.optim.nonlinear.scalar.noderiv.NelderMeadSimplex; +import org.apache.commons.math3.optim.nonlinear.scalar.noderiv.SimplexOptimizer; +import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineSegment; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.prep.PreparedPolygon; +import org.locationtech.jts.index.strtree.STRtree; + +import net.metaopt.swarm.FitnessFunction; +import net.metaopt.swarm.pso.Particle; +import net.metaopt.swarm.pso.Swarm; + +/** + * Finds an approximate largest area triangle of arbitrary orientation in a + * concave polygon via particle swarm optimisation. + * + * @author Michael Carleton + * + */ +public class MaximumInscribedTriangle { + + private static final int SWARM_SIZE = 3000; + private static final int MAX_GENERATIONS = 1000; + private static final GeometryFactory GEOM_FACTORY = new GeometryFactory(); + private static final Random RANDOM = ThreadLocalRandom.current(); + + private final Swarm swarm; + private final TriangleFitness fitnessFunction; + + double minArea = 0; + + public MaximumInscribedTriangle(Polygon polygon) { + fitnessFunction = new TriangleFitness(polygon); + // For triangle, our candidate vector is of dimension 6: [x1,y1,x2,y2,x3,y3]. + swarm = new ParallelSwarm(SWARM_SIZE, new TriangleCandidate(), fitnessFunction); + + // Use the envelope of the polygon as the search bounds. + Envelope e = polygon.getEnvelopeInternal(); + double[] minPosition = new double[] { e.getMinX(), e.getMinY(), e.getMinX(), e.getMinY(), e.getMinX(), e.getMinY() }; + double[] maxPosition = new double[] { e.getMaxX(), e.getMaxY(), e.getMaxX(), e.getMaxY(), e.getMaxX(), e.getMaxY() }; + + var r = MaximumInscribedCircle.getRadiusLine(polygon, 1).getLength(); + /* + * Area of equilateral triangle inscribed in MIC. Sets a minimum bound to the + * MIT area. + */ + minArea = (3 * Math.sqrt(3) / 4) * Math.pow(r, 2); + + swarm.setMinPosition(minPosition); + swarm.setMaxPosition(maxPosition); + swarm.init(); + initializeSwarm(swarm, e); + } + + /** + * Initialize the swarm particles. Several groups use different heuristics: - + * Some choose points on polygon envelope edges. - Others choose random points + * inside the envelope. + */ + private void initializeSwarm(Swarm swarm, Envelope e) { + Particle[] particles = swarm.getParticles(); + int particlesPerGroup = SWARM_SIZE / 4; + double minX = e.getMinX(); + double minY = e.getMinY(); + double width = e.getWidth(); + double height = e.getHeight(); + + for (int i = 0; i < SWARM_SIZE; i++) { + double[] position = new double[6]; + + if (i < particlesPerGroup) { + // Group 1: Pick vertices on the envelope boundary. + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = RANDOM.nextBoolean() ? e.getMinY() : e.getMaxY(); + position[2] = minX + RANDOM.nextDouble() * width; + position[3] = RANDOM.nextBoolean() ? e.getMinY() : e.getMaxY(); + position[4] = minX + RANDOM.nextDouble() * width; + position[5] = RANDOM.nextBoolean() ? e.getMinY() : e.getMaxY(); + } else if (i < 2 * particlesPerGroup) { + // Group 2: Random points in the envelope. + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = minY + RANDOM.nextDouble() * height; + position[2] = minX + RANDOM.nextDouble() * width; + position[3] = minY + RANDOM.nextDouble() * height; + position[4] = minX + RANDOM.nextDouble() * width; + position[5] = minY + RANDOM.nextDouble() * height; + } else if (i < 3 * particlesPerGroup) { + // Group 3: Two points from one edge and one from the opposite edge. + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = e.getMinY(); + position[2] = minX + RANDOM.nextDouble() * width; + position[3] = e.getMinY(); + position[4] = minX + RANDOM.nextDouble() * width; + position[5] = e.getMaxY(); + } else { + // Group 4: Random exploration. + position[0] = minX + RANDOM.nextDouble() * width; + position[1] = minY + RANDOM.nextDouble() * height; + position[2] = minX + RANDOM.nextDouble() * width; + position[3] = minY + RANDOM.nextDouble() * height; + position[4] = minX + RANDOM.nextDouble() * width; + position[5] = minY + RANDOM.nextDouble() * height; + } + + particles[i].setPosition(position); + particles[i].setVelocity(new double[] { 0, 0, 0, 0, 0, 0 }); + particles[i].setBestPosition(position.clone()); + } + } + + /** + * The main entry method which runs the swarm evolution, then refines the best + * candidate using Apache Commons Math's Nelder–Mead optimizer. + */ + public Polygon computeMIT() { + int gen = 0, stableCount = 0; + double lastFitness = Double.MIN_VALUE; + + // Global search via swarm evolution. + while (gen++ < MAX_GENERATIONS) { + swarm.evolve(); + + // Occasionally reinitialize worst performing particles. + if (gen % 100 == 0) { + reinitializeWorstParticles(swarm); + } + double bestFitness = swarm.getBestFitness(); + if (bestFitness == lastFitness) { + if (stableCount++ > 75) { + break; + } + } else { + stableCount = 0; + } + lastFitness = bestFitness; + } + + double[] bestCandidate = swarm.getBestPosition().clone(); + bestCandidate = refineCandidate(bestCandidate); + return triangleFromCoords(bestCandidate); + } + + /** + * Reinitialize the bottom 10% of swarm particles around the best candidate. + */ + private void reinitializeWorstParticles(Swarm swarm) { + Particle[] particles = swarm.getParticles(); + double[] bestPos = swarm.getBestPosition(); + int reinitCount = SWARM_SIZE / 10; + double[] maxPos = swarm.getMaxPosition(); + double[] minPos = swarm.getMinPosition(); + for (int i = 0; i < reinitCount; i++) { + double[] newPos = new double[6]; + for (int j = 0; j < 6; j++) { + double range = (maxPos[j] - minPos[j]) * 0.1; + newPos[j] = bestPos[j] + (RANDOM.nextDouble() - 0.5) * range; + newPos[j] = Math.max(minPos[j], Math.min(maxPos[j], newPos[j])); + } + particles[i].setPosition(newPos); + particles[i].setVelocity(new double[] { 0, 0, 0, 0, 0, 0 }); + } + } + + /** + * Uses Nelder–Mead simplex optimization (via Apache Commons Math) to refine the + * best candidate. The objective function is defined as the negative fitness. + */ + private double[] refineCandidate(double[] candidate) { + MultivariateFunction objective = point -> -fitnessFunction.evaluate(point); + + SimplexOptimizer optimizer = new SimplexOptimizer(1e-8, 1e-8); + NelderMeadSimplex simplex = new NelderMeadSimplex(candidate.length); + try { + PointValuePair result = optimizer.optimize(new MaxEval(1000), new ObjectiveFunction(objective), GoalType.MINIMIZE, new InitialGuess(candidate), + simplex); + return result.getPoint(); + } catch (Exception e) { + return candidate; + } + } + + /** + * Constructs a triangle polygon from a candidate vector. Candidate parameters: + * [x1, y1, x2, y2, x3, y3] + */ + private static Polygon triangleFromCoords(double[] position) { + // Create three coordinates for the triangle. + Coordinate p0 = new Coordinate(position[0], position[1]); + Coordinate p1 = new Coordinate(position[2], position[3]); + Coordinate p2 = new Coordinate(position[4], position[5]); + // Ensure the ring is closed. + Coordinate[] coords = new Coordinate[] { p0, p1, p2, p0 }; + return GEOM_FACTORY.createPolygon(coords); + } + + /** + * The fitness function for candidate triangles in a concave polygon. Instead of + * calling PreparedGeometry.containsProperly(…), we use a custom “fast–path” + * test: 1. All triangle vertices must be inside the polygon (using + * ray–casting). 2. None of the triangle edges may intersect any polygon + * boundary edge. + * + * If both tests pass, return the triangle’s absolute area. + */ + private class TriangleFitness extends FitnessFunction { + // Cached polygon vertices (exterior ring) for fast point testing. + private double[] polyX; + private double[] polyY; + // Spatial index over the polygon’s edges. + private STRtree polygonEdgeIndex; + private final boolean hasHoles; + private PreparedPolygon cache; + + TriangleFitness(Polygon polygon) { + hasHoles = polygon.getNumInteriorRing() > 0; + if (hasHoles) { + cache = new PreparedPolygon(polygon); + } else { // faster approach (but doesn't detect holes) + Coordinate[] coords = polygon.getExteriorRing().getCoordinates(); + int n = coords.length - 1; // last coordinate is a duplicate of the first + polyX = new double[n]; + polyY = new double[n]; + for (int i = 0; i < n; i++) { + polyX[i] = coords[i].x; + polyY[i] = coords[i].y; + } + // Build an STRtree on polygon edges. + polygonEdgeIndex = new STRtree(); + for (int i = 0; i < n; i++) { + Coordinate p0 = coords[i]; + Coordinate p1 = coords[(i + 1) % n]; + LineSegment seg = new LineSegment(p0, p1); + polygonEdgeIndex.insert(new Envelope(seg.p0, seg.p1), seg); + } + polygonEdgeIndex.build(); + } + } + + @Override + public double evaluate(double[] position) { + double x0 = position[0], y0 = position[1]; + double x1 = position[2], y1 = position[3]; + double x2 = position[4], y2 = position[5]; + // Compute absolute area using the cross–product formula. + double area = Math.abs(0.5 * (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1))); + if (area < minArea) { + return 0; + } + + if (hasHoles) { + Polygon tri = triangleFromCoords(position); + if (cache.containsProperly(tri)) { + return area; + } else { + return 0; + } + } else { + // 1. Check that all three vertices are strictly inside the polygon. + if (!pointInPolygon(x0, y0, polyX, polyY) || !pointInPolygon(x1, y1, polyX, polyY) + || !pointInPolygon(x2, y2, polyX, polyY)) { + return 0; + } + + // 2. Check for any intersection between triangle edges and polygon boundary. + // Define the triangle’s three edges. + if (edgeIntersectsPolygon(x0, y0, x1, y1) || edgeIntersectsPolygon(x1, y1, x2, y2) || edgeIntersectsPolygon(x2, y2, x0, y0)) { + return 0; + } + + return area; + } + } + + /** + * Returns true if the triangle edge from (ax,ay) to (bx,by) intersects a + * polygon edge. + */ + private boolean edgeIntersectsPolygon(double ax, double ay, double bx, double by) { + Envelope edgeEnv = new Envelope(ax, bx, ay, by); + List candidates = polygonEdgeIndex.query(edgeEnv); + LineSegment triangleEdge = new LineSegment(new Coordinate(ax, ay), new Coordinate(bx, by)); + for (Object obj : candidates) { + LineSegment polyEdge = (LineSegment) obj; + if (segmentsIntersect(triangleEdge, polyEdge)) { + return true; + } + } + return false; + } + + /** + * Determines whether the two given non-collinear line segments intersect. + * + *

+ * This fast geometric method assumes that the bounding boxes (envelopes) of the + * segments already intersect, so no additional envelope intersection test is + * performed. It also assumes that no three endpoints are collinear, ensuring + * that none of the computed cross products are zero. + *

+ * + *

+ * The algorithm proceeds by computing the cross products to determine the + * relative orientations of the endpoints of each segment with respect to the + * line defined by the other segment. Specifically, let segment 1 be defined by + * points A and B, and segment 2 by points C and D. The method computes: + *

+ * + *
    + *
  • d1 = cross product of (D - C) and (A - C)
  • + *
  • d2 = cross product of (D - C) and (B - C)
  • + *
  • d3 = cross product of (B - A) and (C - A)
  • + *
  • d4 = cross product of (B - A) and (D - A)
  • + *
+ * + *

+ * If d1 and d2 have the same sign, then both A and B lie on the same side of + * the line through C and D, meaning segment 1 does not cross segment 2. + * Similarly, if d3 and d4 have the same sign, segment 2 does not cross segment + * 1. Therefore, the segments intersect if and only if A and B lie on opposite + * sides of the line through C and D, and C and D lie on opposite sides of the + * line through A and B. + *

+ * + * @param seg1 the first line segment + * @param seg2 the second line segment + * @return {@code true} if the segments intersect; {@code false} otherwise + */ + private boolean segmentsIntersect(LineSegment seg1, LineSegment seg2) { + // Cache coordinates + double ax = seg1.p0.x, ay = seg1.p0.y, bx = seg1.p1.x, by = seg1.p1.y; + double cx = seg2.p0.x, cy = seg2.p0.y, dx = seg2.p1.x, dy = seg2.p1.y; + + // Compute differences for segment 2 (t) + double rdx = dx - cx, rdy = dy - cy; + // Compute cross products for seg1 endpoints relative to seg2's line + double d1 = rdx * (ay - cy) - rdy * (ax - cx); + double d2 = rdx * (by - cy) - rdy * (bx - cx); + + // If d1 and d2 are of the same sign, seg1 does not cross seg2. + if ((d1 > 0) == (d2 > 0)) { + return false; + } + + // Compute differences for segment 1 (s) + double sdx = bx - ax, sdy = by - ay; + // Compute cross products for seg2 endpoints relative to seg1's line + double d3 = sdx * (cy - ay) - sdy * (cx - ax); + double d4 = sdx * (dy - ay) - sdy * (dx - ax); + + // The segments intersect if and only if seg2's endpoints lie on opposite + // sides of seg1's line. + return (d3 > 0) != (d4 > 0); + } + + /** + * A standard ray-casting point-in-polygon test. + * + * @param x the test point x coordinate + * @param y the test point y coordinate + * @param polyX the array of polygon vertex x coordinates + * @param polyY the array of polygon vertex y coordinates + * @return true if the point is inside + */ + private boolean pointInPolygon(double x, double y, double[] polyX, double[] polyY) { + boolean inside = false; + int n = polyX.length; + for (int i = 0, j = n - 1; i < n; j = i++) { + // Check if point is between the y-interval of the edge. + if (((polyY[i] > y) != (polyY[j] > y)) && (x < (polyX[j] - polyX[i]) * (y - polyY[i]) / (polyY[j] - polyY[i]) + polyX[i])) { + inside = !inside; + } + } + return inside; + } + } + + /** + * A Particle subclass for triangle candidates. The dimensionality is 6. + */ + private class TriangleCandidate extends Particle { + public TriangleCandidate() { + super(6); + } + + @Override + public Object selfFactory() { + return new TriangleCandidate(); + } + } +} diff --git a/src/main/java/micycle/pgs/commons/MultiplicativelyWeightedVoronoi.java b/src/main/java/micycle/pgs/commons/MultiplicativelyWeightedVoronoi.java index 1928320e..bf372a5e 100644 --- a/src/main/java/micycle/pgs/commons/MultiplicativelyWeightedVoronoi.java +++ b/src/main/java/micycle/pgs/commons/MultiplicativelyWeightedVoronoi.java @@ -14,6 +14,7 @@ import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.operation.overlayng.OverlayNG; +import org.locationtech.jts.operation.overlayng.RingClipper; import org.locationtech.jts.operation.polygonize.Polygonizer; import org.locationtech.jts.operation.union.UnaryUnionOp; import org.tinspin.index.Index.PointEntryKnn; @@ -90,9 +91,9 @@ private static List getMWVDFast(List sites, Envelope exten // intersection(all ap_circles containing s)-union(all ap_circles not containing // s) - sites.sort((s1,s2) -> Double.compare(s1.z, s2.z)); - + sites.sort((s1, s2) -> Double.compare(s1.z, s2.z)); final Geometry extentGeometry = geometryFactory.toGeometry(extent); + final RingClipper rc = new RingClipper(extent); return sites.parallelStream().map(site -> { // NOTE parallel // s2 dominates s1 (hence s1 contained in apollo circle) @@ -124,7 +125,7 @@ private static List getMWVDFast(List sites, Envelope exten if (inCircleData.isEmpty()) { // if no incircles, cell is simply defined by difference between plane and // exCircles - exCircleData.forEach(c -> exCircles.add(createCircle(c[0], c[1], c[2]))); + exCircleData.forEach(c -> exCircles.add(createClippedCircle(c[0], c[1], c[2], rc))); var outerDom = UnaryUnionOp.union(exCircles); return extentGeometry.difference(outerDom); } @@ -149,7 +150,7 @@ private static List getMWVDFast(List sites, Envelope exten }); } - essentialCircles.forEach(c -> inCircles.add(createCircle(c[0], c[1], c[2]))); + essentialCircles.forEach(c -> inCircles.add(createClippedCircle(c[0], c[1], c[2], rc))); // intersect all inCircles to find the dominant region for this site var localDominance = inCircles.stream().reduce((geom1, geom2) -> OverlayNG.overlay(geom1, geom2, OverlayNG.INTERSECTION)).get(); @@ -172,17 +173,17 @@ private static List getMWVDFast(List sites, Envelope exten }).collect(Collectors.toList()); /* - * NOTE optmisation, similar to inCircleData optimisation, but this time, remove - * any SMALLER circles that are contained by another. + * NOTE optimisation, similar to inCircleData optimisation, but this time, + * remove any SMALLER circles that are contained by another. */ exCircleData = filterContainedCircles(exCircleData); - exCircleData.forEach(c -> exCircles.add(createCircle(c[0], c[1], c[2]))); + exCircleData.forEach(c -> exCircles.add(createClippedCircle(c[0], c[1], c[2], rc))); if (exCircles.isEmpty()) { - return localDominance.intersection(extentGeometry); + return localDominance; } else { var outerDom = UnaryUnionOp.union(exCircles); - return localDominance.difference(outerDom).intersection(extentGeometry); + return localDominance.difference(outerDom); } }).toList(); } @@ -310,7 +311,7 @@ private static Geometry apolloniusCircle(Coordinate s1, Coordinate s2, double w1 Coordinate center = new Coordinate(circle[0], circle[1]); // often lies outside bounds double radius = circle[2]; double distance = s1.distance(center); - localDominanceCircle = createCircle(center.x, center.y, radius); + localDominanceCircle = createClippedCircle(center.x, center.y, radius, null); /* * The circle will either enclose site 1 (i.e. it's dominated by site 2), or * will bend away from site 1 (enclosing and dominating site 2). @@ -384,11 +385,11 @@ private static double[] calculateWeightedApollonius(Coordinate s1, Coordinate s2 final double d = Math.sqrt(((s1x - s2x) * (s1x - s2x) + (s1y - s2y) * (s1y - s2y))); // NOTE r can be huge (as circle may tend towards straight line) final double r = Math.abs(w1 * w2 * d * den); - + return new double[] { cx, cy, r }; } - private static Polygon createCircle(double x, double y, double r) { + private static Polygon createClippedCircle(double x, double y, double r, RingClipper rc) { final double maxDeviation = 0.49; // Calculate the number of points based on the radius and maximum deviation. int nPts = (int) Math.ceil(2 * Math.PI / Math.acos(1 - maxDeviation / r)); @@ -408,6 +409,8 @@ private static Polygon createCircle(double x, double y, double r) { } pts[nPts] = new Coordinate(pts[0]); // Close the circle - return geometryFactory.createPolygon(pts); + // NOTE clip the circle to bounds now. slightly speeds up 2d boolean ops later + // on. + return geometryFactory.createPolygon(rc == null ? pts : rc.clip(pts)); } } \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/PEdge.java b/src/main/java/micycle/pgs/commons/PEdge.java index 1853265f..2023a603 100644 --- a/src/main/java/micycle/pgs/commons/PEdge.java +++ b/src/main/java/micycle/pgs/commons/PEdge.java @@ -11,7 +11,7 @@ * @author Michael Carleton * */ -public class PEdge { +public class PEdge implements Comparable { public final PVector a, b; @@ -70,8 +70,7 @@ public PEdge slice(double from, double to) { to = 1; } if (from < 0 || to > 1 || from > to) { - throw new IllegalArgumentException( - "Parameters 'from' and 'to' must be between 0 and 1, and 'from' must be less than or equal to 'to'."); + throw new IllegalArgumentException("Parameters 'from' and 'to' must be between 0 and 1, and 'from' must be less than or equal to 'to'."); } final PVector pointFrom; @@ -123,4 +122,24 @@ public String toString() { private static boolean equals(PVector a, PVector b) { return a.x == b.x && a.y == b.y; } + + @Override + public int compareTo(PEdge other) { + PVector thisMidpoint = midpoint(); + PVector otherMidpoint = other.midpoint(); + return comparePVectors(thisMidpoint, otherMidpoint); + } + + /** + * Helper method to compare two PVectors lexicographically. + */ + private int comparePVectors(PVector v1, PVector v2) { + if (v1.x != v2.x) { + return Float.compare(v1.x, v2.x); + } + if (v1.y != v2.y) { + return Float.compare(v1.y, v2.y); + } + return Float.compare(v1.z, v2.z); + } } \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/PMesh.java b/src/main/java/micycle/pgs/commons/PMesh.java index c0062cb8..375da2cc 100644 --- a/src/main/java/micycle/pgs/commons/PMesh.java +++ b/src/main/java/micycle/pgs/commons/PMesh.java @@ -372,7 +372,7 @@ private static class PMeshVertex { final PVector originalVertex; final PVector smoothedVertex; - boolean onBoundary; + boolean onBoundary = false; List neighbors; public PMeshVertex(PVector v) { diff --git a/src/main/java/micycle/pgs/commons/ParallelSwarm.java b/src/main/java/micycle/pgs/commons/ParallelSwarm.java new file mode 100644 index 00000000..3e41ee56 --- /dev/null +++ b/src/main/java/micycle/pgs/commons/ParallelSwarm.java @@ -0,0 +1,62 @@ +package micycle.pgs.commons; + +import java.util.stream.IntStream; + +import net.metaopt.swarm.FitnessFunction; +import net.metaopt.swarm.pso.Particle; +import net.metaopt.swarm.pso.Swarm; + +/** + * A particle swarm that evaluates particle fitness in parallel for improved + * performance. + * + * @author Michael Carleton + */ +public class ParallelSwarm extends Swarm { + + public ParallelSwarm(int numberOfParticles, Particle sampleParticle, FitnessFunction fitnessFunction) { + super(numberOfParticles, sampleParticle, fitnessFunction); + } + + @Override + public void evaluate() { + // Initialize bestFitness on first run. + if (Double.isNaN(bestFitness)) { + bestFitness = (fitnessFunction.isMaximize() ? Double.NEGATIVE_INFINITY : Double.POSITIVE_INFINITY); + bestParticleIndex = -1; + } + + final int len = particles.length; + final double[] fitnesses = new double[len]; + + // Evaluate each particle's fitness in parallel. + IntStream.range(0, len).parallel().forEach(i -> { + double fit = fitnessFunction.evaluate(particles[i]); + fitnesses[i] = fit; + }); + + // Now process the results sequentially to update the global best and + // neighborhood. + for (int i = 0; i < len; i++) { + numEvaluations++; + double fit = fitnesses[i]; + + // Update 'best global' position if this particle is better. + if (fitnessFunction.isBetterThan(bestFitness, fit)) { + bestFitness = fit; + bestParticleIndex = i; + if (bestPosition == null) { + bestPosition = new double[sampleParticle.getDimension()]; + } + particles[i].copyPosition(bestPosition); + } + + // Update 'best neighborhood' information. + if (neighborhood != null) { + neighborhood.update(this, particles[i]); + } + + } + } + +} diff --git a/src/main/java/micycle/pgs/commons/TangencyPack.java b/src/main/java/micycle/pgs/commons/TangencyPack.java index 1c62d494..c84220b2 100644 --- a/src/main/java/micycle/pgs/commons/TangencyPack.java +++ b/src/main/java/micycle/pgs/commons/TangencyPack.java @@ -6,7 +6,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; import org.apache.commons.math3.complex.Complex; @@ -43,6 +42,7 @@ * * @author Michael Carleton */ + public class TangencyPack { /*- @@ -52,89 +52,45 @@ public class TangencyPack { * https://github.com/kensmath/CirclePack/blob/CP-develop/src/rePack/EuclPacker.java */ - private static final double TOLERANCE = 1 + 1e-8; private static final double TWO_PI = Math.PI * 2; private final IIncrementalTin triangulation; - /** - * Maps a vertex to a list of it neighbouring vertices; the neighbour list is - * ordered radially around the given vertex. - */ - private Map> flowers; - /** - * The radius of each circle (including boundary circles). - */ - private Object2DoubleOpenHashMap radii; - private double[] boundaryRadii; - private Map placements = new HashMap<>(); + + private int[][] flowersIds; // Each row contains neighbor IDs for a flower + private int[] flowerSizes; // Precomputed sizes of each flower + private double[] delArray; // Precomputed sin(π / n) for each flower + private double[] factorDelArray; // Precomputed (1 - del)/del for each flower + + private double[] radiiArray; + private Complex[] placementsArray; private List circles; private Vertex centralVertex; + private double[] boundaryRadii; + + private List allVertices; + private Map vertexToId; + private List interiorVertices; + private Map interiorVertexIndex; + private int[] interiorVertexIds; - /** - * Creates a circle packing using tangancies specified by a triangulation. - * - * @param triangulation Pattern of tangencies; vertices connected by an edge in - * the triangulation represent tangent circles in the - * packing - * @param boundaryRadii Radii of the circles (same for every circle) associated - * with the boundary/perimeter vertices of the - * triangulation - */ public TangencyPack(IIncrementalTin triangulation, double boundaryRadii) { this.triangulation = triangulation; this.boundaryRadii = new double[] { boundaryRadii }; init(); } - /** - * Creates a circle packing using tangancies specified by a triangulation. - * - * @param triangulation Pattern of tangencies; vertices connected by an edge in - * the triangulation represent tangent circles in the - * packing - * @param boundaryRadii List of radii values of the circles associated with the - * boundary/perimeter vertices of the triangulation. The - * list may have fewer radii than the number of boundary - * vertices; in this case, boundary radii will wrap around - * the list - */ public TangencyPack(IIncrementalTin triangulation, List boundaryRadii) { this.triangulation = triangulation; this.boundaryRadii = boundaryRadii.stream().mapToDouble(Double::doubleValue).toArray(); init(); } - /** - * Creates a circle packing using tangancies specified by a triangulation. - * - * @param triangulation Pattern of tangencies; vertices connected by an edge in - * the triangulation represent tangent circles in the - * packing - * @param boundaryRadii Array of radii values of the circles associated with the - * boundary/perimeter vertices of the triangulation. The - * list may have fewer radii than the number of boundary - * vertices; in this case, boundary radii will wrap around - * the list - */ public TangencyPack(IIncrementalTin triangulation, double[] boundaryRadii) { this.triangulation = triangulation; this.boundaryRadii = boundaryRadii; init(); } - /** - * Computes and returns a circle packing for the configuration of tangencies - * given by the triangulation. - * - * @return a list of PVectors, each representing one circle: (.x, .y) represent - * the center point and .z represents radius. - */ - public List pack() { - computeRadii(); - computeCenters(); - return circles; - } - private void init() { Set perimeterVertices = new HashSet<>(); triangulation.getPerimeter().forEach(e -> { @@ -142,328 +98,350 @@ private void init() { perimeterVertices.add(e.getB()); }); - flowers = new HashMap<>(); - radii = new Object2DoubleOpenHashMap<>(triangulation.getVertices().size()); + allVertices = new ArrayList<>(triangulation.getVertices()); + vertexToId = new HashMap<>(); + for (int i = 0; i < allVertices.size(); i++) { + vertexToId.put(allVertices.get(i), i); + } + + radiiArray = new double[allVertices.size()]; + flowersIds = new int[allVertices.size()][]; + interiorVertices = new ArrayList<>(); + interiorVertexIndex = new HashMap<>(); SimpleGraph graph = PGS_Triangulation.toTinfourGraph(triangulation); NeighborCache neighbors = new NeighborCache<>(graph); - final PVector meanVertexPos = new PVector(); - int index = 0; - for (Vertex v : graph.vertexSet()) { + int boundaryIndex = 0; + PVector meanVertexPos = new PVector(); + int vi = 0; + for (Vertex v : allVertices) { if (perimeterVertices.contains(v)) { - radii.put(v, boundaryRadii[index++ % boundaryRadii.length]); + radiiArray[vertexToId.get(v)] = boundaryRadii[boundaryIndex++ % boundaryRadii.length]; } else { List flower = neighbors.neighborListOf(v); - RadialComparator c = new RadialComparator(v); - flower.sort(c); - flowers.put(v, flower); - radii.put(v, boundaryRadii[0] / 10); + flower.sort(new RadialComparator(v)); + int[] flowerIds = new int[flower.size()]; + for (int i = 0; i < flowerIds.length; i++) { + flowerIds[i] = vertexToId.get(flower.get(i)); + } + flowersIds[vi++] = flowerIds; + interiorVertices.add(v); + int vid = vertexToId.get(v); + radiiArray[vid] = boundaryRadii[0] / 10; meanVertexPos.add((float) v.x, (float) v.y); } } - // pick a rather central vertex, so output is same on identical input - meanVertexPos.div(flowers.size()); - double maxDist = Double.MAX_VALUE; - for (Vertex v : flowers.keySet()) { + interiorVertexIds = new int[interiorVertices.size()]; + for (int i = 0; i < interiorVertices.size(); i++) { + Vertex v = interiorVertices.get(i); + interiorVertexIds[i] = vertexToId.get(v); + interiorVertexIndex.put(v, i); + } + + meanVertexPos.div(interiorVertices.size()); + double minDist = Double.MAX_VALUE; + for (Vertex v : interiorVertices) { double dist = v.getDistanceSq(meanVertexPos.x, meanVertexPos.y); - if (dist < maxDist) { - maxDist = dist; + if (dist < minDist) { + minDist = dist; centralVertex = v; } } - } - /** - * Find radii of circles using numerical relaxation. Circle radii converge - * rapidly to a unique fixed point for which all flower angles are are within a - * desired tolerance of 2π, at which point iteration stops and a packing is - * found. - * - * @deprecated in favor of superstep solution - */ - @Deprecated - private void computeRadiiSimple() { - double lastChange = TOLERANCE + 1; - while (lastChange > TOLERANCE) { - lastChange = 1.0; - for (Vertex v : flowers.keySet()) { - double theta = flower(v); - lastChange = Math.max(lastChange, theta); - } + // Precompute flower sizes and trigonometric terms + flowerSizes = new int[interiorVertices.size()]; + delArray = new double[interiorVertices.size()]; + factorDelArray = new double[interiorVertices.size()]; + + for (int i = 0; i < interiorVertices.size(); i++) { + int n = flowersIds[i].length; + flowerSizes[i] = n; + double del = FastMath.sin(Math.PI / n); // del = sin(π / n) + delArray[i] = del; + factorDelArray[i] = (1 - del) / del; // Precompute (1-del)/del } } - /** - * This method implements the super acceleration described in 'A circle packing - * algorithm'. - */ + public List pack() { + computeRadiiSuperStep(); + computeCenters(); + return circles; + } + private void computeRadii() { - final double ttoler = 3 * radii.size() * 1e-11; - int key = 1; // initial superstep type + double ttoler; + // adapt tolerance based on input size. seems sufficient + if (interiorVertices.size() <= 100) { + ttoler = 1e-3; // Base case + } else { + double exponent = 3.5 + (interiorVertices.size()) / 100.0; + ttoler = Math.pow(10, -exponent); + } + int key = 1; double accumErr2 = Double.MAX_VALUE; - int localPasses = 1; - - while ((accumErr2 > ttoler && localPasses < 1000)) { // main loop - Object2DoubleMap R1 = new Object2DoubleOpenHashMap<>(radii); - double c1; + int localPasses = 0; - double factor; - - do { // Make sure factor < 1.0 - c1 = computeAngleSums(); - c1 = Math.sqrt(c1); + while (accumErr2 > ttoler && localPasses < 3 * interiorVertices.size()) { + Object2DoubleMap R1 = new Object2DoubleOpenHashMap<>(interiorVertices.size()); + for (Vertex v : interiorVertices) { + R1.put(v, radiiArray[vertexToId.get(v)]); + } - factor = c1 / accumErr2; - if (factor >= 1.0) { - accumErr2 = c1; - key = 1; - } - } while (factor >= 1.0); + double c1 = computeAngleSums(); + c1 = Math.sqrt(c1); + if (c1 < ttoler) { + break; + } - // ================= superstep calculation ==================== + double factor = c1 / accumErr2; + if (factor >= 1.0) { + accumErr2 = c1; + key = 1; + localPasses++; + continue; + } - Object2DoubleMap R2 = new Object2DoubleOpenHashMap<>(radii); + Object2DoubleMap R2 = new Object2DoubleOpenHashMap<>(interiorVertices.size()); + for (Vertex v : interiorVertices) { + R2.put(v, radiiArray[vertexToId.get(v)]); + } - // find maximum step one can safely take double lmax = 10000; - double fact0; - for (Vertex v : R1.keySet()) { double r1 = R1.getDouble(v); double r2 = R2.getDouble(v); double rat = r2 - r1; - double tr; if (rat < 0) { - lmax = (lmax < (tr = (-r2 / rat))) ? lmax : tr; // to keep R>0 + double tr = -r2 / rat; + lmax = Math.min(lmax, tr); } } - lmax = lmax / 2; + lmax /= 2; - // do super step - double m = 1; - int sct = 1; - int fct = 2; double lambda; - if (key == 1) { // type 1 SS - lambda = m * factor; - double mmax = 0.75 / (1 - factor); // upper limit on m - double mm = 0.0; - m = (mmax < (mm = (1 + 0.8 / (sct + 1)) * m)) ? mmax : mm; - } else { // type 2 SS - fact0 = 0.0; - double ftol = 0.0; - if (sct > fct && Math.abs(factor - fact0) < ftol) { // try SS-2 - lambda = factor / (1 - factor); - sct = -1; - } else { - lambda = factor; // do something - } + if (key == 1) { + lambda = Math.min(0.75 / (1 - factor), factor); + } else { + lambda = factor; } - lambda = (lambda > lmax) ? lmax : lambda; + lambda = Math.min(lambda, lmax); - // interpolate new radii labels - for (Vertex v : R1.keySet()) { + for (Vertex v : interiorVertices) { + int vid = vertexToId.get(v); double r1 = R1.getDouble(v); double r2 = R2.getDouble(v); - double nwr = r2 + lambda * (r2 - r1); - radii.put(v, nwr); + radiiArray[vid] = r2 + lambda * (r2 - r1); } - // end of superstep - - // do step/check superstep - accumErr2 = computeAngleSums(); - accumErr2 = Math.sqrt(accumErr2); - - // check results - double pred = FastMath.exp(lambda * FastMath.log(factor)); // predicted improvement - double act = accumErr2 / c1; // actual improvement - if (act < 1) { // did some good - if (act > pred) { // not as good as expected: reset - if (key == 1) { - key = 2; - } - } // implied else: accept result - } else { // reset to before superstep - for (Vertex v : R1.keySet()) { - double r2 = R2.getDouble(v); - radii.put(v, r2); - } - accumErr2 = c1; - if (key == 2) { + // NOTE probably faster to not call computeAngleSums() again. simply use sum + // from before +// accumErr2 = computeAngleSums(); +// accumErr2 = Math.sqrt(accumErr2); + accumErr2 = c1; + + localPasses++; + } + } + + private void computeRadiiSuperStep() { + // Precompute tolerance based on problem size + final double ttoler = interiorVertices.size() <= 10 ? 1e-2 : Math.pow(10, -(3.5 + interiorVertices.size() / 100.0)); + + // Preallocate working arrays once + final double[] R1 = new double[radiiArray.length]; + final double[] R2 = new double[radiiArray.length]; + + int key = 1; + double accumErr2 = Double.MAX_VALUE; + int localPasses = 1; + final int maxPasses = 3 * interiorVertices.size(); + + while (accumErr2 > ttoler && localPasses < maxPasses) { + // Phase 1: Standard iteration + System.arraycopy(radiiArray, 0, R1, 0, radiiArray.length); + double c1; + double factor; + + // Single-pass factor calculation + do { + c1 = Math.sqrt(computeAngleSums()); + factor = c1 / accumErr2; + if (factor >= 1.0) { + accumErr2 = c1; key = 1; } + } while (factor >= 1.0); + + // Phase 2: Super-step preparation + System.arraycopy(radiiArray, 0, R2, 0, radiiArray.length); + + // Lambda calculation with precomputed values + final double lambda = calculateLambda(R1, R2, factor, key); + + // Vectorized radius update + updateRadii(R1, R2, lambda); + + // Error calculation with early exit check + accumErr2 = Math.sqrt(computeAngleSums()); + + // Convergence monitoring + if (!updateState(R1, R2, c1, accumErr2, factor, key, lambda)) { + key = (key == 1) ? 2 : 1; } localPasses++; } } + + private double calculateLambda(double[] R1, double[] R2, double factor, int key) { + double lmax = Double.MAX_VALUE; + + // Parallel safe iteration (if needed) + for (int vid : interiorVertexIds) { + final double r1 = R1[vid]; + final double r2 = R2[vid]; + final double rat = r2 - r1; + if (rat < 0) { + lmax = Math.min(lmax, -r2 / rat); + } + } + lmax /= 2; - /** - * Determine the centers of the circles using radii of the interior circles. - * - * @return - */ - private void computeCenters() { - if (flowers.size() > 0) { - placements = new HashMap<>(); - - Vertex k1 = centralVertex; // pick one internal circle - placements.put(k1, new Complex(0, 0)); // place it at the origin + if (key == 1) { + return Math.min(0.75 / (1 - factor), factor); + } else { + return Math.min(factor / (1 - factor), lmax); + } + } - Vertex k2 = flowers.get(k1).get(0); // pick one of its neighbors - placements.put(k2, new Complex(radii.getDouble(k1) + radii.getDouble(k2))); // place it on the real axis - place(k1); // recursively place the rest - place(k2); + private void updateRadii(double[] R1, double[] R2, double lambda) { + for (int vid : interiorVertexIds) { + final double delta = R2[vid] - R1[vid]; + radiiArray[vid] = R2[vid] + lambda * delta; } + } + + private boolean updateState(double[] R1, double[] R2, double c1, double accumErr2, double factor, int key, double lambda) { + final double pred = FastMath.exp(lambda * FastMath.log(factor)); + final double act = accumErr2 / c1; - circles = new ArrayList<>(radii.size()); - placements.forEach( - (v, pos) -> circles.add(new PVector((float) pos.getReal(), (float) pos.getImaginary(), (float) radii.getDouble(v)))); + if (act >= 1) { + System.arraycopy(R2, 0, radiiArray, 0, radiiArray.length); + return false; + } + return act <= pred; } - /** - * Compute the angle sum for every flower. - * - * @return sum of angle error (difference between 2PI) across all flowers - */ private double computeAngleSums() { double error = 0; - for (Entry> entry : flowers.entrySet()) { - final Vertex v = entry.getKey(); - final List flower = entry.getValue(); - - final double ra = radii.getDouble(v); - double angleSum = angleSum(ra, flower); - - final int N = 2 * flower.size(); - final double del = FastMath.sin(TWO_PI / N); - final double bet = FastMath.sin(angleSum / N); - final double r2 = ra * bet * (1 - del) / (del * (1 - bet)); + for (int i = 0; i < interiorVertices.size(); i++) { + int vId = interiorVertexIds[i]; + int[] flower = flowersIds[i]; + double ra = radiiArray[vId]; + + // Compute angle sum for the flower + double angleSum = 0; + int n = flowerSizes[i]; + // NOTE inlined angleSum + for (int j = 0; j < n; j++) { + int currentId = flower[j]; + int nextId = (j + 1 < n) ? flower[j + 1] : flower[0]; + double b = radiiArray[currentId]; + double c = radiiArray[nextId]; + double bc = b * c; + double denominator = ra * ra + ra * (b + c) + bc; + angleSum += FastMath.acos(1 - (2 * bc) / denominator); + } - // alternative form -// double hat = ra / (1.0 / FastMath.sin(angleSum / (2 * flower.size())) - 1); -// double r2 = hat * (1.0 / FastMath.sin(Math.PI / flower.size()) - 1); + // Update radius using precomputed values + double factorDel = factorDelArray[i]; + double bet = FastMath.sin(angleSum / (2 * n)); + double r2 = ra * bet * factorDel / (1 - bet); - radii.put(v, r2); - angleSum -= TWO_PI; - error += angleSum * angleSum; // accum abs error + radiiArray[vId] = r2; + error += (angleSum - TWO_PI) * (angleSum - TWO_PI); } return error; } - /** - * - * @param rc radius of center circle - * @param center center circle - * @param flower center circle's petals - * @return - */ - private double angleSum(final double rc, final List flower) { + private double angleSum(final double rc, final List flower) { final int n = flower.size(); double sum = 0.0; - for (int i = 0; i < n; i++) { - int j = i + 1 == n ? 0 : i + 1; - sum += tangentAngle(rc, radii.getDouble(flower.get(i)), radii.getDouble(flower.get(j))); + for (int j = 0; j < n; j++) { + int currentId = flower.get(j); + int nextId = flower.get((j + 1) % n); + sum += tangentAngle(rc, radiiArray[currentId], radiiArray[nextId]); } return sum; } - /** - * Compute the angle sum for the petals surrounding the given vertex and update - * the radius of the vertex such that the angle sum would equal 2π. - * - * @param center target vertex - * @return a measure of the error (difference between target angle sum (2π) and - * the actual angle sum - * @deprecated used by {@link #computeRadiiSimple()} - */ - @Deprecated - private double flower(final Vertex center) { - List flower = flowers.get(center); - final int n = flower.size(); - final double rc = radii.getDouble(center); - double sum = 0.0; - for (int i = 0; i < n; i++) { - int j = i + 1 == n ? 0 : i + 1; - sum += tangentAngleFast(rc, radii.getDouble(flower.get(i)), radii.getDouble(flower.get(j))); + private void computeCenters() { + if (interiorVertices.isEmpty()) { + circles = new ArrayList<>(); + return; } - double hat = rc / (1.0 / FastMath.sin(sum / (2 * n)) - 1); - double newrad = hat * (1.0 / FastMath.sin(Math.PI / n) - 1); - radii.put(center, newrad); + placementsArray = new Complex[allVertices.size()]; + int centralId = vertexToId.get(centralVertex); + placementsArray[centralId] = new Complex(0, 0); - return Math.max(newrad / rc, rc / newrad); - } + int centralIndex = interiorVertexIndex.get(centralVertex); + int[] centralFlower = flowersIds[centralIndex]; + int k2Id = centralFlower[0]; + placementsArray[k2Id] = new Complex(radiiArray[centralId] + radiiArray[k2Id], 0); - /** - * Recursively determine centers of all circles surrounding a given vertex. - */ - private void place(final Vertex centre) { + place(centralVertex); + place(allVertices.get(k2Id)); - if (!flowers.containsKey(centre)) { - return; // boundary vertex + circles = new ArrayList<>(allVertices.size()); + for (Vertex v : allVertices) { + int id = vertexToId.get(v); + Complex pos = placementsArray[id]; + if (pos != null) { + circles.add(new PVector((float) pos.getReal(), (float) pos.getImaginary(), (float) radiiArray[id])); + } } + } - List flower = flowers.get(centre); - final int nc = flower.size(); - final double rcentre = radii.getDouble(centre); - - final Complex minusI = new Complex(0.0, -1); + private void place(Vertex centre) { + int centreId = vertexToId.get(centre); + if (!interiorVertexIndex.containsKey(centre)) { + return; + } - for (int i = -nc; i < nc - 1; i++) { - int ks = i < 0 ? nc + i : i; - Vertex s = flower.get(ks); - double rs = radii.getDouble(s); + int centreIndex = interiorVertexIndex.get(centre); + int[] flower = flowersIds[centreIndex]; + int nc = flower.length; + double rcentre = radiiArray[centreId]; + Complex minusI = new Complex(0, -1); - int kt = ks + 1 < nc ? ks + 1 : 0; - Vertex t = flower.get(kt); - double rt = radii.getDouble(t); + for (int i = 0; i < nc; i++) { + int sId = flower[i]; + int tId = flower[(i + 1) % nc]; - if (placements.containsKey(s) && !placements.containsKey(t)) { + if (placementsArray[sId] != null && placementsArray[tId] == null) { + double rs = radiiArray[sId]; + double rt = radiiArray[tId]; double theta = tangentAngle(rcentre, rs, rt); - Complex offset = (placements.get(s).subtract(placements.get(centre))).divide(new Complex(rs + rcentre)); + + Complex offset = placementsArray[sId].subtract(placementsArray[centreId]).divide(new Complex(rs + rcentre)); offset = offset.multiply(minusI.multiply(theta).exp()); - placements.put(t, placements.get(centre).add(offset.multiply(rt + rcentre))); + placementsArray[tId] = placementsArray[centreId].add(offset.multiply(rt + rcentre)); - place(t); + place(allVertices.get(tId)); } } } private static double tangentAngle(double a, double b, double c) { - /* - * Overall computation time is actually reduced by foregoing trig approximation - * functions (such as tangentAngleFast()), because the slight inaccuracies cause - * solution to converge more slowly, performing more iterations overall. - */ - final double q = b * c; - final double o = 1 - 2 * q / (a * a + a * (b + c) + q); + double q = b * c; + double o = 1 - 2 * q / (a * a + a * (b + c) + q); return FastMath.acos(o); } - /** - * Computes the angle that circles y and z make with circle x (angle yxz). The - * circles are given by their radii and are mutually tangent. - * - * @param rx radius of circle x, the circle of interest - * @param ry radius of circle y, a "petal" circle - * @param rz radius of circle z, a "petal" circle - * @return angle of yxz - * @deprecated - */ - @Deprecated - private static double tangentAngleFast(final double rx, final double ry, final double rz) { - final double x = (ry * rz) / ((rx + ry) * (rx + rz)); - // return 2 * Fixed64.ToDouble(Fixed64.Asin(Fixed64.FromDouble(Math.sqrt(x)))); - // return 2 * Fixed64.ToDouble(Fixed64.Atan(Fixed64.FromDouble(Math.sqrt(x) / (Math.sqrt(1 - x))))); - return 0; - } - private static class RadialComparator implements Comparator { - private Vertex origin; public RadialComparator(Vertex origin) { @@ -475,34 +453,15 @@ public int compare(Vertex o1, Vertex o2) { return polarCompare(origin, o1, o2); } - /** - * Given two points p and q compare them with respect to their radial ordering - * about point o. First checks radial ordering. - * - * @param o the origin - * @param p a point - * @param q another point - * @return -1, 0 or 1 depending on whether angle p is less than, equal to or - * greater than angle q - */ private static int polarCompare(Vertex o, Vertex p, Vertex q) { double dxp = p.x - o.x; double dyp = p.y - o.y; double dxq = q.x - o.x; double dyq = q.y - o.y; - int result = 0; - double alph = FastAtan2.atan2(dxp, dyp); - double beta = FastAtan2.atan2(dxq, dyq); - if (alph < beta) { - result = -1; - } - if (alph > beta) { - result = 1; - } - return result; + double alph = FastMath.atan2(dyp, dxp); + double beta = FastMath.atan2(dyq, dxq); + return Double.compare(alph, beta); } - } - } diff --git a/src/test/java/micycle/pgs/PGSTests.java b/src/test/java/micycle/pgs/PGSTests.java index b08f13c2..19591213 100644 --- a/src/test/java/micycle/pgs/PGSTests.java +++ b/src/test/java/micycle/pgs/PGSTests.java @@ -1,6 +1,7 @@ package micycle.pgs; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.Arrays; @@ -32,7 +33,7 @@ void testFromEdges() { for (int i = 0; i < 15; i++) { edges.add(new PEdge(i, i, i + 1, i + 1)); } - edges.add(new PEdge(15, 15, 0,0)); // close + edges.add(new PEdge(15, 15, 0, 0)); // close Collections.shuffle(edges); @@ -41,4 +42,35 @@ void testFromEdges() { assertEquals(16, orderedVertices.size()); } + @Test + void testOrientation() { + /* + * NOTE the isClockwise method tests for orientation in a y-axis-up coordinate + * system. The lists below are defined in terms of visual orientation in + * Processing (which uses y-axis-down orientation). Hence the results are + * inverted to check the method is geometrically correct. + */ + + // @formatter:off + // (0,0) --------> (1,0) (x-axis increasing to the right) + // | | + // | | + // V V + // (0,1) <--------- (1,1) (y-axis increasing downwards) + // @formatter:on + List clockwisePoints = List.of(new PVector(0, 0), new PVector(1, 0), new PVector(1, 1), new PVector(0, 1)); + assertTrue(!PGS.isClockwise(clockwisePoints)); // NOTE inverted + + List counterClockwisePoints = List.of(new PVector(0, 0), new PVector(0, 1), new PVector(1, 1), new PVector(1, 0)); + assertTrue(PGS.isClockwise(counterClockwisePoints)); // NOTE inverted + + List clockwisePointsClosed = new ArrayList<>( + List.of(new PVector(0, 0), new PVector(1, 0), new PVector(1, 1), new PVector(0, 1), new PVector(0, 0))); + assertTrue(!PGS.isClockwise(clockwisePointsClosed)); // NOTE inverted + + List counterClockwisePointsClosed = new ArrayList<>( + List.of(new PVector(0, 0), new PVector(0, 1), new PVector(1, 1), new PVector(1, 0), new PVector(0, 0))); + assertTrue(PGS.isClockwise(counterClockwisePointsClosed)); // NOTE inverted + } + } diff --git a/src/test/java/micycle/pgs/PGS_ConversionTests.java b/src/test/java/micycle/pgs/PGS_ConversionTests.java index 169fb201..4c2decaa 100644 --- a/src/test/java/micycle/pgs/PGS_ConversionTests.java +++ b/src/test/java/micycle/pgs/PGS_ConversionTests.java @@ -352,14 +352,14 @@ void testMultiContour() { @Test void testVertexRounding() { - final PShape shape = new PShape(PShape.GEOMETRY); + PShape shape = new PShape(PShape.GEOMETRY); shape.beginShape(); shape.vertex(12.4985f, -97.234f); shape.vertex(10, -10); shape.vertex(999.99f, 0.0001f); shape.endShape(PConstants.CLOSE); // close affects rendering only -- does not append another vertex - PGS_Conversion.roundVertexCoords(shape); + shape = PGS_Conversion.roundVertexCoords(shape); assertEquals(12, shape.getVertex(0).x); assertEquals(-97, shape.getVertex(0).y); @@ -368,6 +368,25 @@ void testVertexRounding() { assertEquals(1000, shape.getVertex(2).x); assertEquals(0, shape.getVertex(2).y); } + + @Test + void testVertexRounding1DP() { + PShape shape = new PShape(PShape.GEOMETRY); + shape.beginShape(); + shape.vertex(12.4985f, -97.234f); + shape.vertex(10, -10); + shape.vertex(999.34f, 0.049f); + shape.endShape(PConstants.CLOSE); + + shape = PGS_Conversion.roundVertexCoords(shape, 1); + + assertEquals(12.5, shape.getVertex(0).x, 1e-5); + assertEquals(-97.2, shape.getVertex(0).y, 1e-5); + assertEquals(10, shape.getVertex(1).x, 1e-5); + assertEquals(-10, shape.getVertex(1).y, 1e-5); + assertEquals(999.3, shape.getVertex(2).x, 2e-5); + assertEquals(0, shape.getVertex(2).y, 1e-5); + } @Test void testDuplicateVertices() { diff --git a/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java b/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java index 1278f37a..b7fa57e4 100644 --- a/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java +++ b/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java @@ -18,7 +18,7 @@ class PGS_ShapePredicatesTests { private static final double EPSILON = 1E-4; - static PShape square, triangle; + static PShape square, triangle, rect; @BeforeAll static void initShapes() { @@ -29,6 +29,14 @@ static void initShapes() { square.vertex(10, 10); square.vertex(0, 10); square.endShape(PConstants.CLOSE); // close affects rendering only -- does not append another vertex + + rect = new PShape(PShape.GEOMETRY); // 10x10 rect + rect.beginShape(); + rect.vertex(0, 0); + rect.vertex(10, 0); + rect.vertex(10, 20); + rect.vertex(0, 20); + rect.endShape(PConstants.CLOSE); // close affects rendering only -- does not append another vertex float[] centroid = new float[] { 0, 0 }; float side_length = 10; @@ -63,6 +71,37 @@ void testMaximumInteriorAngle() { assertEquals(Math.PI / 3, PGS_ShapePredicates.maximumInteriorAngle(triangle), EPSILON); } + @Test + void testInteriorAnglesSquare() { + var angles = PGS_ShapePredicates.interiorAngles(square); + assertEquals(4, angles.size(), "Square should have 4 angles"); + + double expectedAngleRadians = Math.PI / 2.0; // 90 degrees in radians + double expectedAngleSumRadians = Math.PI * 2; // 360 degrees for a square + double actualAngleSumRadians = 0; + + for (double angle : angles.values()) { + assertEquals(expectedAngleRadians, angle, 1e-6, "Interior angle should be approximately 90 degrees"); + actualAngleSumRadians += angle; + } + assertEquals(expectedAngleSumRadians, actualAngleSumRadians, 1e-6, "Sum of square interior angles should be approximately 360 degrees"); + + } + + @Test + void testInteriorAnglesTriangle() { + var angles = PGS_ShapePredicates.interiorAngles(triangle); + + assertEquals(3, angles.size(), "Triangle should have 3 angles"); + + double expectedAngleSumRadians = Math.PI; // 180 degrees for a triangle + double actualAngleSumRadians = 0; + for (double angle : angles.values()) { + actualAngleSumRadians += angle; + } + assertEquals(expectedAngleSumRadians, actualAngleSumRadians, 1e-6, "Sum of triangle interior angles should be approximately 180 degrees"); + } + @Test void testHoles() { assertEquals(0, PGS_ShapePredicates.holes(square)); @@ -77,15 +116,21 @@ void testHoles() { coverage.removeChild(0); // remove a mesh face; mesh no longer forms a hole assertEquals(0, PGS_ShapePredicates.holes(coverage)); } - + @Test void testIsClockwise() { assertTrue(PGS_ShapePredicates.isClockwise(square)); - List ccw = PGS_Conversion.toPVector(square);//.reversed(); + List ccw = PGS_Conversion.toPVector(square);// .reversed(); Collections.reverse(ccw); ccw.add(ccw.get(0)); // close assertFalse(PGS_ShapePredicates.isClockwise(PGS_Conversion.fromPVector(ccw))); - + + } + + @Test + void testElongation() { + assertEquals(0, PGS_ShapePredicates.elongation(square)); + assertEquals(0.5, PGS_ShapePredicates.elongation(rect)); } }