diff --git a/CHANGELOG.md b/CHANGELOG.md index 203844e2..dfcdb413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,79 @@ 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. +* New 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. +* `vertexCount()` to `PGS_ShapePredicates`. Returns the total number of vertices that make up a shape. +* `matchingQuadrangulation()` to `PGS_Meshing`. Converts a triangulation into a quadrangulation, by pairing up triangles and merging them into high-quality quads. +* `filterNear()` to `PGS_SegmentSet`. Removes segments that are near others. +* `spiralSortFaces()` to `PGS_Optimisation`. Reorders the faces of a mesh into an anti-clockwise “spiral” (breadth-first rings) starting from a given face. +* `centroidSortFaces()` to `PGS_Optimisation`. Reorders the faces of a mesh by the x and then y coordinates of their centroids. +* `radialSortFaces()` to `PGS_Optimisation`. Sorts the faces of a mesh radially around a given centre. +* `unionLines()` to `PGS_ShapeBoolean`. Unions the linework of two shapes, creating polygonal faces from their intersecting lines. +* `closestVertex()` and `farthestVertex()` to `PGS_Optimisation`. Returns the closest/farthest vertex of a shape to a query point. +* `farthestPoint()` to `PGS_Optimsation`. Finds the farthest point in the collection from a specified point. +* `annularBricks()` to `PGS_Tiling`. Generates a geometric arrangement composed of annular-sector bricks arranged in concentric circular rings. +* `overlapRegions()` to `PGS_ShapeBoolean`. Finds the regions where at least two shapes overlap. +* `normalise()` to `PGS_Processing`. Normalises a shape by standardising its vertex ordering and orientation. +* `boundingBox()` to `PGS_Hull`. Calculates the axis-aligned bounding box (replaces `envelope()` in `PGS_Optimisation`.) +* `farthestPointVoronoi()` to `PGS_Voronoi`. Generates a farthest-point Voronoi diagram for a given set of sites and a bounding box. +* `intersections()` to `PGS_SegmentSet`. Computes all intersection points between two collections of line segments. +* `minimumWidthAnnulus()` to `PGS_Optimisation`. Computes the minimum-width annulus (ring) that encloses the vertices of a shape. +* Overloads of `minimumBoundingCircle()` and `maximumInscribedCircle()` that accept a PVector to output the circle center and radius. +* `createRect()` to `PGS_Construction`. Creates a rectangle with uniformly rounded corners +* New method signature for `PGS_Meshing.areaMerge()` that allows merging faces until the mesh contains exactly a user-specified number of faces. +* `minimumInteriorAngle()` to `PGS_ShapePredicates`. Computes the minimum interior angle of a shape. +* New method signature for `isolines()` having an intervals parameter that specifies the number of contour levels to generate. +* New method signature for `straightSkeleton()` that accepts an integer to control the number of nearest neighboring edges considered during collision detection. +* `contrastField()` to `PGS_Contour`. Generates vector contour lines representing a "contrast field" of a shape with respect to a given reference point. +* `arcDivision` to `PGS_Tiling`. Creates a cellular partition of the plane using arcs formed by circles seeded along its boundary. +* `sliceDivision` to `PGS_Tiling`. Divides the plane into randomly “sliced” polygonal regions. +* New method signature for `pointsOnExterior()` having a starting point offset parameter that specifies the distance along the perimeter to start sampling points. +* `centroidSplit()` to `PGS_Processing`. Splits the input shape into n wedge-shaped regions by connecting the centroid to points along the perimeter. +* `createHobbyCurve()` to `PGS_Construction`. Creates a Hobby curve from a set of control points. +* `pruneRandomRemoveN()` to `PGS_PointSet`. Randomly removes exactly N points from a list of points. +* `pruneRandomToN()` to `PGS_PointSet`. Randomly prunes a list of points to exactly N points. +* `convexMaximumInscribedCircle()` to `PGS_Optimisation`. Computes the largest inscribed circle of a convex polygon (faster and exact). + +### 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). +* Optimised `ColoringAlgorithm.RLF` (the speed increase grows with input size). +* Improved `PGS_PointSet.findShortestTour()` TSP algorithm. It now uses a more effective heuristic that finds shorter tours in less time. +* `PGS_Meshing.extractInnerEdges()` no longer dissolves edges in the output. +* `PGS_Morphology.simplifyHobby()` is much faster, particularly on shapes with many vertices. +* Optimised `PGS_Processing.maximumPerimeterSquare()`. It's about 2x faster. +* Varying `sigma` in `PGS_smoothGaussian()` no longer causes "wobblyness" in the output. +* New method signature for `PGS_Morphology.buffer()` that accepts a cap style parameter. +* `PGS_PointSet.squareGrid()` now populates the grid with points upto and including `xMax` and `yMax` (inclusive). +* Improved `PGS_Transformation.touchScale()` algorithm. It's now more performant and robust. +* `PGS_Tiling.triangleSubdivision()` can now begin from both diagonal axes, not just the top-left to bottom-right diagonal. +* `PGS_Processing.pointsOnExterior()` methods now return points on all elements of a shape, not just the perimeter of the first polygon. +* `PGS_Processing.segmentsOnExterior()` now return segments on all elements of a shape, not just the perimeter of the first polygon. +* These methods in `PGS_Morphology` now process any and all polygon/line elements in a shape: `chaikinCut()`, `smoothGaussian()`, `simplifyDCE()`, `simplifyHobby()`, `smoothEllipticFourier()`, `round()`. +* `dissolve()` to `PGS_Processing`. Dissolves the linear components of a shape into a set of unique maximal-length lines + +### 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. +* `PGS_Tiling.triangleSubdivision()` subdivision is now deterministic across `maxDepth` for a given seed. +* `PGS_Tiling.rectSubdivision()` subdivision is now deterministic across `maxDepth` for a given seed. +* `PGS_Morphology.pinchWarp()` now preserves closed polygon inputs. + +### 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..2750eb4f 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` @@ -74,6 +74,10 @@ PGS is hosted as an artifact for use in Maven or Gradle projects via [Jitpack](h A number of example Processing sketches are provided in [examples](https://github.com/micycle1/PGS/tree/master/examples).

+ + + + @@ -112,6 +116,15 @@ Much of the functionality (but by no means all) is demonstrated below: + + + Union Lines + Overlap Regions + + + + + ## *Transformation* @@ -172,23 +185,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* @@ -228,10 +233,14 @@ Much of the functionality (but by no means all) is demonstrated below: Distance Field Center Line + Distance Tree + Contrast Field + + @@ -266,7 +275,7 @@ Much of the functionality (but by no means all) is demonstrated below: - + @@ -337,10 +346,12 @@ Much of the functionality (but by no means all) is demonstrated below: Convex Hull Snap Hull + Bounding Box + @@ -437,11 +448,12 @@ Much of the functionality (but by no means all) is demonstrated below: Extract Holes Nest - + Centroid Split + @@ -487,10 +499,12 @@ Much of the functionality (but by no means all) is demonstrated below: Centroidal Relaxation Multiplicatively Weighted Voronoi + Farthest-Point Voronoi + @@ -553,10 +567,12 @@ Much of the functionality (but by no means all) is demonstrated below: Extract Inner Vertices Fix Breaks + Matching Quadrangulation + @@ -577,24 +593,25 @@ Much of the functionality (but by no means all) is demonstrated below: - Minimum Bounding Circle + Maximum Inscribed Triangle + Minimum Bounding Circle Minimum Bounding Ellipse + - Minimum Bounding Triangle - Envelope + Minimum-width Annulus Problem of Apollonius - + @@ -613,13 +630,13 @@ Much of the functionality (but by no means all) is demonstrated below: - Closest Vertex Circle Covering + Closest Vertex Visibility Polygon / Isovist - + @@ -637,9 +654,13 @@ Much of the functionality (but by no means all) is demonstrated below: Hilbert Sort Faces + Spiral Sort Faces + Centroid Sort Faces + + @@ -925,9 +946,19 @@ Much of the functionality (but by no means all) is demonstrated below: Penrose Tiling Square-Triangle Tiling + Annular Bricks + Slice Division + + + + + Arc Division + + + diff --git a/examples/README.md b/examples/README.md index aa03b08a..fba19a06 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,10 @@ A collection of example Processing sketches using *Processing Geometry Suite*. T All examples are dynamic and/or interactive. Shown below are merely screenshots. +## blobbies + + + ## blowUp @@ -20,6 +24,10 @@ All examples are dynamic and/or interactive. Shown below are merely screenshots. +## interlock + + + ## intersectionBoids @@ -52,6 +60,10 @@ All examples are dynamic and/or interactive. Shown below are merely screenshots. +## organicSponge + + + ## slice @@ -78,4 +90,8 @@ All examples are dynamic and/or interactive. Shown below are merely screenshots. ## voronoiCutout - \ No newline at end of file + + +## warpedQuilt + + \ No newline at end of file diff --git a/examples/blobbies/blobbies.pde b/examples/blobbies/blobbies.pde new file mode 100644 index 00000000..db117206 --- /dev/null +++ b/examples/blobbies/blobbies.pde @@ -0,0 +1,72 @@ +import processing.javafx.*; +import micycle.pgs.*; +import java.util.List; +import java.util.Collection; +import micycle.pgs.color.Palette; +import micycle.pgs.PGS_Contour.OffsetStyle; + +void setup() { + size(1000, 1000, FX2D); + smooth(); +} + +void draw() { + background(0); + blobbiesArt(3 + mouseX/100d); +} + +void blobbiesArt(double offset) { + var b1 = blobbie(250, 250, 66); + var b2 = blobbie(450, 750, 10); + var b3 = blobbie(700, 225, 1110); + var b4 = blobbie(1000, 1000, 11111); + + var a = PGS_ShapeBoolean.union(b1, b2, b3, b4); // union rather than flatten, in case of overlap + + var outerCurves = PGS_Contour.offsetCurvesOutward(a, OffsetStyle.ROUND, offset, (int) (400 / offset)); + strokeWeight((float) offset); + var palette = Palette.FREAK; + var colOffset = 0; + PGS_Processing.applyWithIndex(outerCurves, (i, c) -> { + stroke(palette.get(i + colOffset)); + var points = PGS_Processing.pointsOnExterior(c, offset, 0); + shape(points); + } + ); + + var innerCurves = PGS_Contour.offsetCurvesInward(a, OffsetStyle.ROUND, offset); + PGS_Processing.applyWithIndex(innerCurves, (i, c) -> { + stroke(Palette.SHEETS.get(i + colOffset)); + var points = PGS_Processing.pointsOnExterior(c, offset, 0); + shape(points); + } + ); +} + +PShape blobbie(double cx, double cy, long seed) { + final double GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + var s = PGS_Construction.createStar(cx, cy, 5, 125, 400, 0); + s = PGS_Morphology.simplify(s, 10); + s = PGS_Morphology.fieldWarp(s, 100, 1, 0, false, seed); + s = PGS_Morphology.simplifyHobby(s, 0.9); + s = PGS_Morphology.fieldWarp(s, 50, 2, 0, false, seed); + s = PGS_Transformation.scale(s, 0.6); + s = PGS_Transformation.rotateAroundCenter(s, seed * GOLDEN_RATIO); + return s; +} + +public void shape(Collection points) { + points.forEach(p -> circle(p)); +} + +public void circle(PVector v) { + if (v.z == 0) { + point(v); + } else { + ellipse(v.x, v.y, v.z * 2, v.z * 2); + } +} + +public void point(PVector v) { + super.point(v.x, v.y); +} diff --git a/examples/interlock/interlock.pde b/examples/interlock/interlock.pde new file mode 100644 index 00000000..7df13341 --- /dev/null +++ b/examples/interlock/interlock.pde @@ -0,0 +1,55 @@ +import processing.javafx.*; +import micycle.pgs.*; +import java.util.List; +import java.util.Collection; +import micycle.pgs.color.Palette; +import micycle.pgs.PGS_Coloring.ColoringAlgorithm; + +void setup() { + size(1000, 1000, FX2D); + smooth(); +} + +void draw() { + shape(interlock(50+mouseX/2, 1337)); +} + +PShape interlock(double r, long seed) { + var palette = Palette.DAILY; + int k = 1; + background(palette.get(k)); + + var plane = PGS_Construction.createRect(0, 0, width, height, 0); + var sites = PGS_Optimisation.circleCoverage(plane, 15, seed); + var circles = PGS_PointSet.applyRandomWeights(sites, r, r + r/2, seed); + var shapes = PGS_Conversion.toCircles(circles); + shapes = PGS_ShapeBoolean.unionLines(shapes, null); // union and polygonise its own linework + + var classes = PGS_Coloring.colorMesh(shapes, ColoringAlgorithm.RLF); // colour classes + + var interlock = classes.entrySet().parallelStream().map(e -> { + var s = e.getKey(); // shape + var i = e.getValue().intValue(); // colour class + + s = PGS_Morphology.buffer(s, -5); + if (PGS_ShapePredicates.area(s) < 200) { + return null; + } + s = PGS_Morphology.simplifyDCE(s, (a, b, c) -> { + return b > 30; + } + ); + + s = PGS_Voronoi.innerVoronoi(s, PGS_Processing.generateRandomPoints(s, 33, seed), 10); + s = PGS_Meshing.areaMerge(s, 4); + s = PGS_Meshing.smoothMesh(s, 1d, true); + s = PGS_Morphology.chaikinCut(s, 0.5, 3); + + PGS_Conversion.setAllFillColor(s, palette.get(k+i * 2)); + PGS_Conversion.setAllStrokeColor(s, palette.get(k+i * 2 + 1), 2); + return s; + } + ).toList(); + + return PGS_Conversion.flatten(interlock); +} diff --git a/examples/organicSponge/organicSponge.pde b/examples/organicSponge/organicSponge.pde new file mode 100644 index 00000000..4f88b106 --- /dev/null +++ b/examples/organicSponge/organicSponge.pde @@ -0,0 +1,67 @@ +import processing.javafx.*; +import micycle.pgs.*; +import java.util.List; +import java.util.Collection; +import micycle.pgs.color.Palette; + +void setup() { + size(1000, 1000, FX2D); + smooth(); +} + +void draw() { + var palette = Palette.FREAK; + + int i = 3; + + background(palette.get(i)); + + long seed = 110; + PShape s = PGS_Construction.createSponge(width, height, 20, 20, 80, 5, seed); + s = PGS_Transformation.scale(s, 1.15); + // sponge can create an arrangement with nested holes; if so, pick the first child + if (s.getChildCount() > 0) { + s = PGS_Conversion.reorderChildren(s, (a, b) -> Double.compare(PGS_ShapePredicates.area(b), PGS_ShapePredicates.area(a))); + s = s.getChild(0); + } + + // circle packing of the structure + var c = PGS_CirclePacking.frontChainPack(s, 3, 15, 0); + fill(palette.get(i + 1)); + strokeWeight(2); + stroke(palette.get(i + 2)); + shape(c); + + boolean dense = mouseX > width/2; + // create a dense packing of structure (SLOW) + if (dense) { + var gaps = PGS_ShapeBoolean.subtract(s, PGS_Conversion.toCircles(c)); + gaps = PGS_Morphology.simplify(gaps, 2); + var c3 = PGS_CirclePacking.maximumInscribedPack(gaps, 3d, 1); + shape(c3); + } + + var holes = PGS_Processing.extractHoles(s); + holes = PGS_Morphology.buffer(holes, -50); + + // circle packing of the structure's holes + var c2 = PGS_CirclePacking.frontChainPack(holes, 6, 20, 1003130); + fill(palette.get(i + 3)); + stroke(palette.get(i + 4)); + shape(c2); +} + +public void shape(Collection points) { + points.forEach(p -> circle(p)); +} + +public void point(PVector v) { + super.point(v.x, v.y); +} +public void circle(PVector v) { + if (v.z == 0) { + point(v); + } else { + ellipse(v.x, v.y, v.z * 2, v.z * 2); + } +} diff --git a/examples/spiralOutline/spiralOutline.pde b/examples/spiralOutline/spiralOutline.pde index 29081202..8081a7c1 100644 --- a/examples/spiralOutline/spiralOutline.pde +++ b/examples/spiralOutline/spiralOutline.pde @@ -2,6 +2,8 @@ import processing.javafx.*; import micycle.pgs.*; import java.util.List; import micycle.uniformnoise.*; +import micycle.pgs.PGS_Contour.OffsetStyle; +import micycle.pgs.PGS_Morphology.CapStyle; PShape polygon; PShape triangles; @@ -22,11 +24,11 @@ void draw() { PShape spiral = PGS_Construction.createLinearSpiral(width/2, height/2, 0.5+mouseX/200f, 250+mouseY/5f); spiral = PGS_Transformation.rotate(spiral, new PVector(width/2, height/2), frameCount/100f); + spiral = PGS_Processing.extractPerimeter(spiral, 0.005, 1); spiral.setFill(false); shape(spiral); - spiral = PGS_Morphology.simplify(spiral, .1); - spiral = PGS_Morphology.buffer(spiral, 20); + spiral = PGS_Morphology.buffer(spiral, 20, OffsetStyle.ROUND, CapStyle.ROUND); int perimeters = 30; // perimeter sections for (double i = 0; i < 1; i += 1f/perimeters) { diff --git a/examples/voronoiCutout/voronoiCutout.pde b/examples/voronoiCutout/voronoiCutout.pde index f8ddcc9f..94e0ecbf 100644 --- a/examples/voronoiCutout/voronoiCutout.pde +++ b/examples/voronoiCutout/voronoiCutout.pde @@ -17,6 +17,8 @@ void setup() { List randomPoints = PGS_PointSet.poisson(30, 30, width - 30, height - 30, 15,1337); polygon = PGS_Hull.concaveHullBFS2(randomPoints, 0); + polygon = PGS_Morphology.erosionDilation(polygon, 1.5); + polygon = PGS_Morphology.simplify(polygon, 0.5); polygon.setFill(false); polygon.setStroke(color(1)); diff --git a/examples/warpedQuilt/warpedQuilt.pde b/examples/warpedQuilt/warpedQuilt.pde new file mode 100644 index 00000000..c1c8b455 --- /dev/null +++ b/examples/warpedQuilt/warpedQuilt.pde @@ -0,0 +1,65 @@ +import processing.javafx.*; +import micycle.pgs.*; +import micycle.pgs.PGS_Coloring.ColoringAlgorithm; +import java.util.List; +import java.util.Collection; +import micycle.pgs.color.Palette; + +void setup() { + size(1000, 1000, FX2D); + smooth(); +} + +void draw() { + quilt(4, 2); +} + +void quilt(int grid, long seed) { // grid is NxN + + // create blob + float buffer = 50; + var points = PGS_PointSet.poisson(buffer, buffer, width-buffer, height-buffer, 35, seed); + var s = PGS_Hull.concaveHullBFS(points, 0); + s = PGS_Morphology.simplifyHobby(s, 1); // make bendy/"organic" + + // create grid + float cX = width/2; + float cY = height/2; + var segz = PGS_SegmentSet.parallelSegments(cX, cY, width * 1.5, width / grid, 0, grid - 1); + segz.addAll(PGS_SegmentSet.parallelSegments(cX, cY, height * 1.5, height / grid, PI / 2, grid - 1)); + var segs = PGS_SegmentSet.toPShape(segz); + + // warp the grid + float time = 0; + segs = PGS_Morphology.fieldWarp(segs, 50, 1, time, true, seed); + + // create quilt patches + var plane = PGS_Construction.createRect(0, 0, width, height, 0); + var patches = PGS_ShapeBoolean.unionLines(segs, plane); + + Palette p = Palette.getPalette(38); + final var hobby = s; + + PGS_Coloring.colorMesh(patches, ColoringAlgorithm.RLF).forEach((patch, col) -> { + int patchBG = col == 0 ? p.get(0) : p.get(2); + int curveColor = col == 0 ? p.get(1) : p.get(3); + + patch.setFill(patchBG); + patch = PGS_Conversion.setAllStrokeToFillColor(patch, 0.5); + PGS_Conversion.disableAllStroke(patch); + shape(patch); + try { + var hobbySlice = PGS_ShapeBoolean.intersect(patch, hobby); + hobbySlice = PGS_Conversion.setAllFillColor(hobbySlice, curveColor); + PGS_Conversion.disableAllStroke(hobbySlice); + shape(hobbySlice); + } + catch (Exception e) { + // just in case + } + } + ); + + PGS_Conversion.setAllStrokeColor(segs, 0, 2); + shape(segs); +} diff --git a/pom.xml b/pom.xml index 6a2e6252..d111c439 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 micycle PGS - 2.0 + 2.1 Processing Geometry Suite Geometric algorithms for Processing @@ -17,6 +17,7 @@ UTF-8 UTF-8 PGS + 3.6.1 @@ -29,7 +30,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.0 + 3.3.1 attach-sources @@ -43,11 +44,11 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.11.2 + 3.12.0 false micycle.pgs - micycle.pgs.color + micycle.pgs.color,micycle.pgs.commons @@ -60,7 +61,7 @@ maven-surefire-plugin - 3.5.1 + 3.5.4 org.jacoco @@ -88,12 +89,12 @@ uber-jar - ${fatJarName} + ${fatJarName} org.apache.maven.plugins maven-shade-plugin - 3.5.0 + ${maven-shade-plugin.version} false true @@ -135,18 +136,20 @@ local-processing + + true + org.apache.maven.plugins maven-shade-plugin - 3.5.0 + ${maven-shade-plugin.version} false true PGS ${processing.library.dir} - true *:* @@ -197,25 +200,30 @@ true + + jogamp + JogAmp + https://jogamp.org/deployment/maven + - com.github.micycle1 - processing-core-4 - 4.3.3 + org.processing + core + 4.4.7 provided true org.locationtech.jts jts-core - 1.20.0 + 1.20.1-SNAPSHOT - com.github.twak + com.github.micycle1 campskeleton - 8df5b241d5 + spatial-index-845e720af6-1 org.jgrapht @@ -223,20 +231,21 @@ 1.5.2 - org.tinfour - TinfourCore - 2.1.7 + com.github.gwlucastrig + Tinfour + 44b8d26e15 com.github.micycle1 JMedialAxis 5207bec2f2 true - - - org.dyn4j - dyn4j - [4.0,) + + + org.locationtech.jts + jts-core + + org.apache.commons @@ -274,11 +283,12 @@ com.github.micycle1 space-filling-curves 1.0 - - - com.github.micycle1 - kendzi-math - 99f9217e84 + + + org.locationtech.jts + jts-core + + com.github.paudan @@ -289,6 +299,12 @@ com.github.micycle1 TrapMap 1.0 + + + quil + processing-core + + it.unimi.dsi @@ -304,6 +320,12 @@ com.github.openjump-gis topology-extension 9c1d788f8d + + + org.locationtech.jts + jts-core + + com.github.scoutant @@ -323,17 +345,23 @@ com.github.micycle1 BetterBeziers - 19567c7be4 + 1.0 com.github.micycle1 Hobby-Curves - 1.0 + 1.1 com.github.whitegreen Dalsoo-Bin-Packing bde2a3ef09 + + + org.locationtech.jts + jts-core + + com.github.micycle1 @@ -341,10 +369,9 @@ 1.0 - com.scrtwpns - mixbox - 2.0.0 - jar + com.github.micycle1 + GeoBlitz + 0.9 diff --git a/resources/boolean/overlapRegions.gif b/resources/boolean/overlapRegions.gif new file mode 100644 index 00000000..3fd181ce Binary files /dev/null and b/resources/boolean/overlapRegions.gif differ diff --git a/resources/boolean/unionLines.gif b/resources/boolean/unionLines.gif new file mode 100644 index 00000000..098c8366 Binary files /dev/null and b/resources/boolean/unionLines.gif differ diff --git a/resources/contour/contrastField.png b/resources/contour/contrastField.png new file mode 100644 index 00000000..291fe291 Binary files /dev/null and b/resources/contour/contrastField.png differ 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/distanceTree.png b/resources/contour/distanceTree.png new file mode 100644 index 00000000..a863e82f Binary files /dev/null and b/resources/contour/distanceTree.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/examples/blobbies.png b/resources/examples/blobbies.png new file mode 100644 index 00000000..ee895224 Binary files /dev/null and b/resources/examples/blobbies.png differ diff --git a/resources/examples/interlock.png b/resources/examples/interlock.png new file mode 100644 index 00000000..a48afff6 Binary files /dev/null and b/resources/examples/interlock.png differ diff --git a/resources/examples/organicSponge.png b/resources/examples/organicSponge.png new file mode 100644 index 00000000..12afdab1 Binary files /dev/null and b/resources/examples/organicSponge.png differ diff --git a/resources/examples/warpedQuilt.png b/resources/examples/warpedQuilt.png new file mode 100644 index 00000000..a50ec346 Binary files /dev/null and b/resources/examples/warpedQuilt.png differ diff --git a/resources/geometry_processing/centroidSplit.gif b/resources/geometry_processing/centroidSplit.gif new file mode 100644 index 00000000..b8be000a Binary files /dev/null and b/resources/geometry_processing/centroidSplit.gif differ diff --git a/resources/meshing/ecQuadrangulation.png b/resources/meshing/ecQuadrangulation.png index 1276837d..e2b2a5c6 100644 Binary files a/resources/meshing/ecQuadrangulation.png and b/resources/meshing/ecQuadrangulation.png differ diff --git a/resources/meshing/matchingQuadrangulation.png b/resources/meshing/matchingQuadrangulation.png new file mode 100644 index 00000000..fb0fb2c5 Binary files /dev/null and b/resources/meshing/matchingQuadrangulation.png differ diff --git a/resources/morphology/gaussianSmooth.gif b/resources/morphology/gaussianSmooth.gif deleted file mode 100644 index b1b21f51..00000000 Binary files a/resources/morphology/gaussianSmooth.gif and /dev/null 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/resources/morphology/smoothGaussian.gif b/resources/morphology/smoothGaussian.gif new file mode 100644 index 00000000..b20ca847 Binary files /dev/null and b/resources/morphology/smoothGaussian.gif differ diff --git a/resources/optimisation/centroidSort.gif b/resources/optimisation/centroidSort.gif new file mode 100644 index 00000000..3cc8cf53 Binary files /dev/null and b/resources/optimisation/centroidSort.gif differ diff --git a/resources/optimisation/minWidthAnnulus.png b/resources/optimisation/minWidthAnnulus.png new file mode 100644 index 00000000..e886ecce Binary files /dev/null and b/resources/optimisation/minWidthAnnulus.png differ diff --git a/resources/optimisation/mit.png b/resources/optimisation/mit.png new file mode 100644 index 00000000..2240b9be Binary files /dev/null and b/resources/optimisation/mit.png differ diff --git a/resources/optimisation/spiralSort.gif b/resources/optimisation/spiralSort.gif new file mode 100644 index 00000000..2781b222 Binary files /dev/null and b/resources/optimisation/spiralSort.gif differ diff --git a/resources/tiling/annularBricks.png b/resources/tiling/annularBricks.png new file mode 100644 index 00000000..5689050e Binary files /dev/null and b/resources/tiling/annularBricks.png differ diff --git a/resources/tiling/arcDivision.png b/resources/tiling/arcDivision.png new file mode 100644 index 00000000..4c3116c4 Binary files /dev/null and b/resources/tiling/arcDivision.png differ diff --git a/resources/tiling/sliceDivision.png b/resources/tiling/sliceDivision.png new file mode 100644 index 00000000..810278b3 Binary files /dev/null and b/resources/tiling/sliceDivision.png differ diff --git a/resources/voronoi/fpvd.gif b/resources/voronoi/fpvd.gif new file mode 100644 index 00000000..79def460 Binary files /dev/null and b/resources/voronoi/fpvd.gif differ diff --git a/src/main/java/micycle/pgs/PGS.java b/src/main/java/micycle/pgs/PGS.java index ee5b7a90..b7e1d6d1 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,8 +16,11 @@ import java.util.List; import java.util.NoSuchElementException; import java.util.Set; +import java.util.function.UnaryOperator; +import org.apache.commons.lang3.ArrayUtils; import org.jgrapht.graph.SimpleWeightedGraph; +import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateList; import org.locationtech.jts.geom.Geometry; @@ -31,6 +38,8 @@ import org.locationtech.jts.noding.snapround.SnapRoundingNoder; import org.locationtech.jts.operation.linemerge.LineMerger; import org.locationtech.jts.operation.polygonize.Polygonizer; +import org.tinspin.index.IndexConfig; +import org.tinspin.index.kdtree.KDTree; import micycle.pgs.color.Colors; import micycle.pgs.commons.Nullable; @@ -196,30 +205,25 @@ 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 + static boolean isClockwise(final List points) { + if (points == null || points.size() < 3) { + throw new IllegalArgumentException("Polygon must have at least 3 points."); } - double area = 0; - 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) } /** @@ -243,18 +247,52 @@ static final PShape polygonizeSegments(Collection segments, boole } }); Collections.shuffle(meshEdges); - return polygonizeEdges(meshEdges); + return polygonizeNodedEdges(meshEdges); + } + + /** + * Given a (possibly non‐noded) set of PEdge’s, optionally nodes all + * intersections, and polygonizes. Does NOT convert back into PEdge; it goes + * straight from noded SegmentStrings → JTS LineStrings → Polygonizer → PShape. + * + * @param edges the input edges + * @param node if true, splits at all interior intersections + * @return a GROUP PShape whose children are each polygon face + */ + static final PShape polygonizeEdges(Collection edges) { + List segStrs = new HashSet<>(edges).stream().map(e -> { + Coordinate c0 = coordFromPVector(e.a); + Coordinate c1 = coordFromPVector(e.b); + return new NodedSegmentString(new Coordinate[] { c0, c1 }, null); + }).toList(); + + Collection noded = nodeSegmentStrings(segStrs); + + Polygonizer polygonizer = new Polygonizer(); + for (SegmentString ss : noded) { + var coords = ss.getCoordinates(); + for (int i = 0; i < coords.length - 1; i++) { + Coordinate p0 = coords[i]; + Coordinate p1 = coords[i + 1]; + LineString ls = GEOM_FACTORY.createLineString(new Coordinate[] { p0, p1 }); + polygonizer.add(ls); + } + } + + @SuppressWarnings("unchecked") + Collection polys = polygonizer.getPolygons(); + return PGS_Conversion.toPShape(polys); } /** - * Polygonizes a set of edges. + * Polygonizes a set of pre-noded edges. * * @param edges a collection of NODED (i.e. non intersecting / must only meet at * their endpoints) edges. The collection can contain duplicates. * @return a GROUP PShape, where each child shape represents a polygon face * formed by the given edges */ - static final PShape polygonizeEdges(Collection edges) { + static final PShape polygonizeNodedEdges(Collection edges) { return polygonizeEdgesRobust(edges); } @@ -271,7 +309,7 @@ static final PShape polygonizeEdges(Collection edges) { private static final PShape polygonizeEdgesRobust(Collection edges) { final Set edgeSet = new HashSet<>(edges); final Polygonizer polygonizer = new Polygonizer(); - polygonizer.setCheckRingsValid(false); +// polygonizer.setCheckRingsValid(false); edgeSet.forEach(ss -> { /* * NOTE: If the same LineString is added more than once to the polygonizer, the @@ -292,7 +330,7 @@ private static final PShape polygonizeEdgesRobust(Collection edges) { * @return */ @SuppressWarnings("unchecked") - static final Collection nodeSegmentStrings(Collection segments) { + static final Collection nodeSegmentStrings(Collection segments) { /* * Other noder implementations do not node correctly (fail to detect * intersections) on many inputs; furthermore, using a very small tolerance @@ -452,6 +490,17 @@ static List extractLinearRings(Polygon polygon) { return rings; } + /** + * Creates a 2D KDTree populated with points. + */ + static KDTree makeKdtree(Collection points) { + KDTree tree = KDTree.create(IndexConfig.create(2).setDefensiveKeyCopy(false)); + points.forEach(p -> { + tree.insert(new double[] { p.x, p.y }, p); + }); + return tree; + } + /** * Provides convenient iteration of the child geometries of a JTS MultiGeometry. * This iterator does not recurse all geometries (as does @@ -557,4 +606,188 @@ public void remove() { } } + /** + * Apply a transformation to every lineal element in a PShape, preserving + * geometry structure and polygon/hole relationships, and return a non-null + * result. + * + *

+ * The geometry encoded by {@code shape} (via {@code fromPShape}) is traversed, + * and {@code function} is applied to each lineal component: {@code LineString} + * and {@code LinearRing}. The function may return a replacement + * {@code LineString}, or {@code null} to drop that element. + * + *

+ * Structure preservation: + *

+ * + *

+ * Additional behavior: + *

    + *
  • Non-closed outputs are closed when possible (if at least two points + * exist).
  • + *
  • Rings must have at least 4 coordinates (including repeated first/last) + * after closing; otherwise they are dropped.
  • + *
  • LineString elements return the transformed line or are dropped if + * {@code function} returns {@code null}.
  • + *
  • Unsupported geometry types yield an empty {@code PShape}.
  • + *
  • No full topology validation is performed; run JTS validators if + * needed.
  • + *
+ * + *

+ * Return contract: + *

    + *
  • This method never returns {@code null}. If no geometry survives, an empty + * {@code PShape} is returned.
  • + *
+ * + * @param shape input PShape encoding geometries to transform (must be + * convertible via {@code fromPShape}) + * @param function a UnaryOperator that receives each {@code LineString} (linear + * rings are passed as {@code LineString}) and returns a + * modified {@code LineString}, or {@code null} to drop the + * element + * @return a non-null {@code PShape} representing the transformed geometry; for + * multi/geometries a GROUP {@code PShape} is returned and may be empty + * when no children survive + * @since 2.1 + */ + static PShape applyToLinealGeometries(PShape shape, UnaryOperator function) { + Geometry g = fromPShape(shape); + final var data = g.getUserData(); // probably styling + switch (g.getGeometryType()) { + case Geometry.TYPENAME_GEOMETRYCOLLECTION : + case Geometry.TYPENAME_MULTIPOLYGON : + case Geometry.TYPENAME_MULTILINESTRING : { + PShape group = new PShape(GROUP); + for (int i = 0; i < g.getNumGeometries(); i++) { + PShape child = applyToLinealGeometries(toPShape(g.getGeometryN(i)), function); + if (!isEmptyShape(child)) { + group.addChild(child); + } + } + // Always return a group, possibly empty + return group; + } + case Geometry.TYPENAME_LINEARRING : + case Geometry.TYPENAME_POLYGON : { + // Preserve exterior-hole relations; allow function to return null (skip) + LinearRing[] rings = new LinearRingIterator(g).getLinearRings(); + List processed = new ArrayList<>(rings.length); + for (int i = 0; i < rings.length; i++) { + LinearRing ring = rings[i]; + LineString out = function.apply(ring); + final boolean isHole = i > 0; + + if (out == null) { + // If the exterior is removed, drop the whole polygon -> empty shape + if (!isHole) { + return new PShape(); + } else { + // skip this hole + continue; + } + } + + Coordinate[] coords = out.getCoordinates(); + + // Ensure closed; if not, close automatically when possible. + if (!out.isClosed()) { + if (coords.length >= 2) { + Coordinate[] closedCoords = Arrays.copyOf(coords, out.getNumPoints() + 1); + closedCoords[closedCoords.length - 1] = closedCoords[0]; // close the ring + coords = closedCoords; + } else { + // Too short to form a ring; skip this ring + if (!isHole) { + return new PShape(); + } else { + continue; + } + } + } + + // Need at least 4 coordinates for a valid closed ring (including repeated + // first) + if (coords.length >= 4) { + // as createPolygon() doesn't check ring orientation + final boolean ccw = Orientation.isCCWArea(coords); + if (isHole && !ccw) { + ArrayUtils.reverse(coords); // make hole CCW + } else if (!isHole && ccw) { + ArrayUtils.reverse(coords); // make exterior CW + } + processed.add(GEOM_FACTORY.createLinearRing(coords)); + } else { + if (!isHole) { + return new PShape(); + } + // skip hole otherwise + } + } + + if (processed.isEmpty()) { + return new PShape(); + } + + LinearRing exterior = processed.get(0); + LinearRing[] holes = (processed.size() > 1) ? processed.subList(1, processed.size()).toArray(new LinearRing[0]) : null; + + var polygon = GEOM_FACTORY.createPolygon(exterior, holes); + polygon.setUserData(data); + return toPShape(polygon); + } + case Geometry.TYPENAME_LINESTRING : { + LineString l = (LineString) g; + LineString out = function.apply(l); + if (out == null) { + return new PShape(); + } + out.setUserData(data); + var line = toPShape(out); + line.setFill(false); + return line; + } + default : + // Return an empty PShape to indicate "ignored / not processed" + return new PShape(); + } + } + + static boolean isEmptyShape(PShape s) { + if (s == null) { + return true; + } + if (s.getChildCount() > 0) { + return false; + } + if (s.getVertexCount() > 0) { + return false; + } + return true; + } + } diff --git a/src/main/java/micycle/pgs/PGS_CirclePacking.java b/src/main/java/micycle/pgs/PGS_CirclePacking.java index a6ab2fb9..b67c7bab 100644 --- a/src/main/java/micycle/pgs/PGS_CirclePacking.java +++ b/src/main/java/micycle/pgs/PGS_CirclePacking.java @@ -10,7 +10,6 @@ import java.util.stream.Collectors; import java.util.stream.StreamSupport; -import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; @@ -24,9 +23,12 @@ import org.tinspin.index.PointMap; import org.tinspin.index.covertree.CoverTree; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + import micycle.pgs.commons.FrontChainPacker; import micycle.pgs.commons.LargestEmptyCircles; import micycle.pgs.commons.RepulsionCirclePack; +import micycle.pgs.commons.ShapeRandomPointSampler; import micycle.pgs.commons.TangencyPack; import processing.core.PShape; import processing.core.PVector; @@ -65,8 +67,7 @@ private PGS_CirclePacking() { * continues to generate circles until the sum of the areas of the circles * exceeds a specified proportion of the area of the given shape. * - * @param shape The shape within which circles will be packed. The - * shape should be in the form of PShape. + * @param shape The shape within which circles will be packed. * @param pointObstacles A collection of PVector points representing obstacles, * around which circles are packed. Only points contained * within the shape are relevant. @@ -80,9 +81,10 @@ private PGS_CirclePacking() { * @since 1.4.0 */ public static List obstaclePack(PShape shape, Collection pointObstacles, double areaCoverRatio) { + areaCoverRatio = Math.min(areaCoverRatio, 1 - (1e-3)); final Geometry geometry = fromPShape(shape); - - LargestEmptyCircles lec = new LargestEmptyCircles(fromPShape(PGS_Conversion.toPointsPShape(pointObstacles)), geometry, areaCoverRatio > 0.95 ? 0.5 : 1); + final Geometry obstacles = fromPShape(PGS_Conversion.toPointsPShape(pointObstacles)); + LargestEmptyCircles lec = new LargestEmptyCircles(obstacles, geometry, areaCoverRatio > 0.95 ? 0.5 : 1); final double shapeArea = geometry.getArea(); double circlesArea = 0; @@ -278,24 +280,24 @@ public static List frontChainPack(PShape shape, double radiusMin, doubl radiusMax = Math.max(1f, Math.max(radiusMin, radiusMax)); // choose max and constrain final Geometry g = fromPShape(shape); final Envelope e = g.getEnvelopeInternal(); - IndexedPointInAreaLocator pointLocator; + YStripesPointInAreaLocator pointLocator; final FrontChainPacker packer = new FrontChainPacker((float) e.getWidth(), (float) e.getHeight(), (float) radiusMin, (float) radiusMax, (float) e.getMinX(), (float) e.getMinY(), seed); if (radiusMin == radiusMax) { // if every circle same radius, use faster contains check - pointLocator = new IndexedPointInAreaLocator(g.buffer(radiusMax)); + pointLocator = new YStripesPointInAreaLocator(g.buffer(radiusMax)); packer.getCircles().removeIf(p -> pointLocator.locate(PGS.coordFromPVector(p)) == Location.EXTERIOR); } else { - pointLocator = new IndexedPointInAreaLocator(g); + pointLocator = new YStripesPointInAreaLocator(g); IndexedFacetDistance distance = new IndexedFacetDistance(g); packer.getCircles().removeIf(p -> { // first test whether shape contains circle center point (somewhat faster) if (pointLocator.locate(PGS.coordFromPVector(p)) != Location.EXTERIOR) { - return false; + return false; // keep if interior } - return !distance.isWithinDistance(PGS.pointFromPVector(p), p.z * 0.666); + return !distance.isWithinDistance(PGS.pointFromPVector(p), p.z * (2 / 3d)); }); } @@ -497,13 +499,13 @@ public static List repulsionPack(PShape shape, List circles) { final List packing = packer.getPacking(); // packing result - IndexedPointInAreaLocator pointLocator; + YStripesPointInAreaLocator pointLocator; if (radiusMin == radiusMax) { // if every circle same radius, use faster contains check - pointLocator = new IndexedPointInAreaLocator(g.buffer(radiusMax)); + pointLocator = new YStripesPointInAreaLocator(g.buffer(radiusMax)); packing.removeIf(p -> pointLocator.locate(PGS.coordFromPVector(p)) == Location.EXTERIOR); } else { - pointLocator = new IndexedPointInAreaLocator(g); + pointLocator = new YStripesPointInAreaLocator(g); IndexedFacetDistance distIndex = new IndexedFacetDistance(g); packing.removeIf(p -> { // first test whether shape contains circle center point (somewhat faster) @@ -537,7 +539,7 @@ public static List squareLatticePack(PShape shape, double diameter) { final Envelope e = g.getEnvelopeInternal(); // buffer the geometry to use InAreaLocator to test circles for overlap (this // works because all circles have the same diameter) - final IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(g.buffer(radius * 0.95)); + final YStripesPointInAreaLocator pointLocator = new YStripesPointInAreaLocator(g.buffer(radius * 0.95)); final double w = e.getWidth() + diameter + e.getMinX(); final double h = e.getHeight() + diameter + e.getMinY(); @@ -575,7 +577,7 @@ public static List hexLatticePack(PShape shape, double diameter) { * Buffer the geometry to use InAreaLocator to test circles for overlap (this * works because all circles have the same diameter). */ - final IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(g.buffer(radius * 0.95)); + final YStripesPointInAreaLocator pointLocator = new YStripesPointInAreaLocator(g.buffer(radius * 0.95)); final double w = e.getWidth() + diameter + e.getMinX(); final double h = e.getHeight() + diameter + e.getMinY(); @@ -664,7 +666,7 @@ private static PVector centroid(SimpleTriangle t) { * A streams filter to remove triangulation triangles that share at least one * edge with the shape edge. */ - private static final Predicate filterBorderTriangles = t -> t.getContainingRegion() != null && !t.getEdgeA().isConstrainedRegionBorder() - && !t.getEdgeB().isConstrainedRegionBorder() && !t.getEdgeC().isConstrainedRegionBorder(); + private static final Predicate filterBorderTriangles = t -> t.getContainingRegion() != null && !t.getEdgeA().isConstraintRegionBorder() + && !t.getEdgeB().isConstraintRegionBorder() && !t.getEdgeC().isConstraintRegionBorder(); } diff --git a/src/main/java/micycle/pgs/PGS_Coloring.java b/src/main/java/micycle/pgs/PGS_Coloring.java index f7c303a7..c44b1312 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,16 +258,16 @@ 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; case RLF : default : - coloring = new RLFColoring<>(graph, 1337).getColoring(); // NOTE fixed seed of 1337 + coloring = new RLFColoring<>(graph, SEED).getColoring(); // NOTE fixed seed of 1337 } return coloring; } diff --git a/src/main/java/micycle/pgs/PGS_Construction.java b/src/main/java/micycle/pgs/PGS_Construction.java index 6b7d5fd3..0299f0a4 100644 --- a/src/main/java/micycle/pgs/PGS_Construction.java +++ b/src/main/java/micycle/pgs/PGS_Construction.java @@ -3,6 +3,7 @@ import static micycle.pgs.PGS_Conversion.toPShape; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Random; @@ -26,6 +27,7 @@ import org.locationtech.jts.util.GeometricShapeFactory; import it.unimi.dsi.util.XoRoShiRo128PlusRandom; +import micycle.hobbycurves.HobbyCurve; import micycle.pgs.PGS_Contour.OffsetStyle; import micycle.pgs.color.Colors; import micycle.pgs.commons.BezierShapeGenerator; @@ -134,20 +136,35 @@ public static PShape createRegularPolyon(int n, double centerX, double centerY, shapeFactory.setCentre(new Coordinate(centerX, centerY)); shapeFactory.setWidth(width * 2); shapeFactory.setHeight(width * 2); + double ia = (Math.PI * (n - 2)) / n; + // flat edge facing down + shapeFactory.setRotation(ia / 2); return toPShape(shapeFactory.createCircle()); } /** - * Creates a supercircle shape. + * Generates a supercircle (also known as a superellipse or Lamé curve) + * shape centered at the specified coordinates. + *

+ * The supercircle is defined by the equation |x/a|n + + * |y/a|n = 1, where the power (n) controls the shape's + * roundness: + *

    + *
  • power < 1: produces star-like (concave) forms
  • + *
  • power = 1: produces a square (with rounded corners due to + * sampling)
  • + *
  • power > 1: increasingly resembles a circle as power + * increases
  • + *
* - * @param centerX centre point X - * @param centerY centre point Y - * @param diameter - * @param power circularity of the super circle. Values less than 1 create - * star-like shapes; power=1 is a square; values>1 are - * increasingly circular. - * @return + * @param centerX the x-coordinate of the supercircle center + * @param centerY the y-coordinate of the supercircle center + * @param diameter the diameter of the supercircle (distance from side to side) + * @param power the exponent controlling the "circularity" (roundness or + * squareness) of the shape; values < 1 yield star-like + * shapes, 1 is a square, values > 1 become more circular + * @return a {@link PShape} representing the generated supercircle */ public static PShape createSupercircle(double centerX, double centerY, double diameter, double power) { GeometricShapeFactory shapeFactory = new GeometricShapeFactory(); @@ -163,31 +180,34 @@ public static PShape createSupercircle(double centerX, double centerY, double di } /** - * Creates a supershape PShape. The parameters feed into the superformula, which - * is a simple 2D analytical expression allowing to draw a wide variety of - * geometric and natural shapes (starfish, petals, snowflakes) by choosing - * suitable values relevant to few parameters. + * Generates a supershape using the superformula, centered at the + * specified coordinates. + *

+ * The superformula is a versatile 2D mathematical equation capable of + * describing an enormous variety of shapes—ranging from rounded polygons and + * starfish to petals and snowflakes—depending on parameter choices. See + * Superformula + * (Wikipedia) or Paul + * Bourke's page for details. + *

+ * Brief parameter effects: *

    - *
  • As the n's are kept equal but reduced the form becomes increasingly - * pinched.
  • - *
  • If n1 is slightly larger than n2 and n3 then bloated forms result.
  • - *
  • Polygonal shapes are achieved with very large values of n1 and large but - * equal values for n2 and n3.
  • - *
  • Asymmetric forms can be created by using different values for the - * n's.
  • - *
  • Smooth starfish shapes result from smaller values of n1 than the n2 and - * n3.
  • + *
  • Equal n-values pinch the form; reducing them increases pinching.
  • + *
  • n₁ larger than n₂/n₃ produces bloated forms.
  • + *
  • Very large n₁, n₂, and n₃ create polygonal shapes.
  • + *
  • Unequal n-values yield asymmetric forms.
  • + *
  • n₁ smaller than n₂/n₃ yields smooth starfish shapes.
  • *
- * - * @param centerX centre point X - * @param centerY centre point Y - * @param radius maximum radius - * @param m specifies the rotational symmetry of the shape (3 = 3 sided; 4 - * = 4 sided) - * @param n1 supershape parameter 1 - * @param n2 supershape parameter 2 - * @param n3 supershape parameter 3 - * @return + * + * @param centerX the x-coordinate for the center of the supershape + * @param centerY the y-coordinate for the center of the supershape + * @param radius maximum radius of the supershape (outermost point) + * @param m symmetry number (e.g. 3 for 3-pointed, 4 for 4-pointed shapes) + * @param n1 superformula parameter controlling general shape (pinching, + * inflation) + * @param n2 superformula parameter affecting shape symmetry + * @param n3 superformula parameter affecting shape symmetry + * @return a {@link PShape} instance representing the generated supershape */ public static PShape createSuperShape(double centerX, double centerY, double radius, double m, double n1, double n2, double n3) { // http://paulbourke.net/geometry/supershape/ @@ -226,17 +246,27 @@ public static PShape createSuperShape(double centerX, double centerY, double rad } /** - * Creates an elliptical arc polygon (a slice of a circle). The polygon is - * formed from the specified arc of an ellipse and the two radii connecting the - * endpoints to the centre of the ellipse. - * - * @param centerX centre point X - * @param centerY centre point Y - * @param width - * @param height - * @param orientation start angle/orientation in radians (where 0 is 12 o'clock) - * @param angle size of the arc angle in radians - * @return + * Creates an elliptical arc polygon—a filled "slice" of an ellipse defined by + * the specified arc and the two radii connecting its endpoints to the ellipse + * center. + *

+ * The arc begins at the given orientation angle (measured from the 12 o'clock + * position, in radians) and extends counterclockwise by the specified angular + * size. If the arc angle is equal to or greater than 2π, a full ellipse is + * generated. + *

+ * The resulting {@link PShape} is suitable for rendering a pie-slice or sector + * from an ellipse or circle. + * + * @param centerX the x-coordinate of the ellipse center + * @param centerY the y-coordinate of the ellipse center + * @param width the full width (diameter on the x-axis) of the ellipse + * @param height the full height (diameter on the y-axis) of the ellipse + * @param orientation the starting angle (in radians), where 0 corresponds to + * "12 o'clock" (upwards) + * @param angle the arc angle (in radians), defining the sweep of the arc; + * if equal or greater than 2π, a full ellipse is produced + * @return a {@link PShape} representing the elliptical arc polygon */ public static PShape createArc(double centerX, double centerY, double width, double height, double orientation, double angle) { if (angle == 0) { @@ -357,18 +387,33 @@ public static PShape createStar(double centerX, double centerY, int numRays, dou } /** - * Creates a "blob"-like shape. + * Generates a "blobbie" shape—a deformable, organic, blobby closed curve + * defined by four parameters. *

- * In order for the shape to not self intersect a + b should be less than 1. - * - * @param centerX The x coordinate of the center - * @param centerY The y coordinate of the center - * @param maxWidth - * @param a blob parameter. a + b should be less than 1 - * @param b blob parameter.a + b should be less than 1 - * @param c blob parameter - * @param d blob parameter - * @return + * The blobbie shape is based on functions involving cosine waves of different + * frequencies, allowing for smooth, natural-looking deformations. Typical + * results are lobed or petal-like closed shapes useful in generative graphics + * or modeling organic phenomena. + *

+ * To avoid self-intersections, the sum of the parameters a and + * b should be less than 1. If self-intersection occurs, the result + * will attempt to be geometrically fixed but may yield unexpected forms. See + * Paul Bourke: Blobbie + * for background. + * + *

{@code
+	 * r(theta) = (maxWidth/2) * [1 + a*cos(2θ + c) + b*cos(3θ + d)]
+	 * }
+ * + * @param centerX the x-coordinate of the blobbie center + * @param centerY the y-coordinate of the blobbie center + * @param maxWidth the maximum width (diameter) of the blobbie + * @param a 2-lobed deformation parameter; together with b, controls the + * main shape undulations (a + b < 1 for simple forms) + * @param b 3-lobed deformation parameter; see note above + * @param c phase offset for the 2-lobed term + * @param d phase offset for the 3-lobed term + * @return a {@link PShape} representing the generated blobbie shape * @since 1.3.0 */ public static PShape createBlobbie(double centerX, double centerY, double maxWidth, double a, double b, double c, double d) { @@ -404,12 +449,22 @@ public static PShape createBlobbie(double centerX, double centerY, double maxWid } /** - * Creates a heart shape. - * - * @param centerX The x coordinate of the center of the heart - * @param centerY The y coordinate of the center of the heart - * @param width Maximum width of the widest part of the heart - * @return + * Creates a classic "heart" shape using a parametric curve, centered and scaled + * as specified. + *

+ * The curve is based on the well-known heart equations, producing a symmetric + * heart that is widest at the center and comes to a point below. + *

+ * The maximum width parameter controls the distance across the heart at its + * widest part. + *

+ * See Heart Curve + * (MathWorld) for the mathematical reference. + * + * @param centerX the x-coordinate for the center of the heart shape + * @param centerY the y-coordinate for the center of the heart shape + * @param width the maximum width (horizontal extent) of the heart + * @return a {@link PShape} representing the generated heart curve * @since 1.1.0 */ public static PShape createHeart(final double centerX, final double centerY, final double width) { @@ -439,13 +494,26 @@ public static PShape createHeart(final double centerX, final double centerY, fin } /** - * Creates a teardrop shape from a parametric curve. - * - * @param centerX The x coordinate of the center of the teardrop - * @param centerY The y coordinate of the center of the teardrop - * @param height height of the teardrop - * @param m order of the curve. Values of [2...5] give good results - * @return + * Creates a teardrop shape using a parametric polar curve, centered and scaled + * as specified. + *

+ * This method generates a classic teardrop or droplet outline, where the + * parameter {@code m} controls the taper and sharpness of the pointed end. + * Lower values for {@code m} (such as 2) create softer drops, while higher + * values (up to 5) yield sharper, pointier ends. + *

+ * The generated {@link PShape} is suitable for use in generative design, + * infographics, and iconography. + *

+ * See Teardrop Curve + * (MathWorld) for mathematical background. + * + * @param centerX the x-coordinate of the center of the teardrop shape + * @param centerY the y-coordinate of the center of the teardrop shape + * @param height the full vertical height of the teardrop, from its base to tip + * @param m the order/tapering factor of the curve; recommended range is + * 2–5 for visually pleasing shapes + * @return a {@link PShape} representing the teardrop outline * @since 1.4.0 */ public static PShape createTeardrop(final double centerX, final double centerY, double height, final double m) { @@ -587,23 +655,23 @@ public static PShape createSponge(double width, double height, int generators, d // A Simple and Effective Geometric Representation for Irregular Porous // Structure Modeling List points = PGS_PointSet.random(thickness, thickness / 2, width - thickness / 2, height - thickness / 2, generators, seed); - if (points.size() < 6) { - return new PShape(); - } - PShape voro = PGS_Voronoi.innerVoronoi(points, 2); + PShape voro = PGS_Voronoi.innerVoronoi(points, new double[] { 0, 0, width, height }, 2); - List blobs = PGS_Conversion.getChildren(PGS_Meshing.stochasticMerge(voro, classes, seed)).stream().map(c -> { - c = PGS_Morphology.buffer(c, -thickness / 2, OffsetStyle.MITER); - c = PGS_Morphology.smoothGaussian(c, smoothing); - return c; - }).collect(Collectors.toList()); + var merged = PGS_Meshing.stochasticMerge(voro, classes, seed); + var blobs = PGS_Processing.transform(merged, blob -> { + blob = PGS_Morphology.buffer(blob, -thickness / 2, OffsetStyle.MITER); + if (smoothing != 0) { + blob = PGS_Morphology.smoothGaussian(blob, smoothing); + } + return blob; + }); /* * Although faster, can't use .simpleSubtract() here because holes (cell * islands) are *sometimes* nested. */ - PShape s = PGS_ShapeBoolean.subtract(PGS.createRect(0, 0, width, height), PGS_Conversion.flatten(blobs)); - s.setStroke(false); + PShape s = PGS_ShapeBoolean.subtract(PGS.createRect(0, 0, width, height), blobs); + PGS_Conversion.disableAllStroke(s); return s; } @@ -719,35 +787,29 @@ public static PShape createFermatSpiral(double centerX, double centerY, double c * @return a stroked PATH PShape with SQUARE stroke cap and MITER joins * @since 1.3.0 */ - public static PShape createRectangularSpiral(float x, float y, float width, float height, float spacing) { - float xx = -width / 2; - float yy = -height / 2; + public static PShape createRectangularSpiral(double x, double y, double width, double height, double spacing) { + double xx = -width / 2.0; + double yy = -height / 2.0; int count = 0; // below is used to dictate spiral orientation & starting point -// if (count == 1) { -// xx *= -1; -// } -// if (count == 2) { -// xx *= -1; -// yy *= -1; -// } -// if (count == 3) { -// yy *= -1; -// } - final float offX = x + width / 2; - final float offY = y + height / 2; + // if (count == 1) { xx *= -1; } + // if (count == 2) { xx *= -1; yy *= -1; } + // if (count == 3) { yy *= -1; } + final double offX = x + width / 2.0; + final double offY = y + height / 2.0; final PShape spiral = new PShape(PShape.PATH); spiral.setFill(false); spiral.setStroke(true); - spiral.setStrokeWeight(5); -// spiral.setStrokeWeight(spacing * 0.333f); + spiral.setStrokeWeight(5f); + // spiral.setStrokeWeight((float)(spacing * 0.333)); spiral.setStroke(Colors.WHITE); spiral.setStrokeJoin(PConstants.MITER); spiral.setStrokeCap(PConstants.SQUARE); spiral.beginShape(); - while (width > 0 && height > 0) { + while (width > 0.0 && height > 0.0) { int dir = count % 4; - spiral.vertex(xx + offX, yy + offY); + // cast to float for Processing's vertex API + spiral.vertex((float) (xx + offX), (float) (yy + offY)); if (dir == 0) { xx += width; width -= spacing; @@ -796,7 +858,7 @@ public static PShape createRandomSFCurve(int nColumns, int nRows, double cellWid * @param cellWidth visual/pixel width of each cell * @param cellHeight visual/pixel width of each cell * @param seed random seed - * @return a stroked PATH PShape + * @return a mitered stroked PATH PShape * @see #createRandomSFCurve(int, int, double, double) * @since 1.4.0 */ @@ -945,6 +1007,74 @@ public static PShape createHilbertCurve(double width, double height, int order) return out; } + /** + * Creates a Hobby Curve from the given list of control points using the + * specified tension. This is a convenience overload that uses a default + * endpoint curl of 0.5. + *

+ * The first and last points may be equal; in that case the curve will be + * treated as closed. + * + * @param points the list of vertices to use as the basis for the Hobby Curve. + * Must be non-null and contain at least two points. + * @param tension a parameter controlling the tightness of the curve. Higher + * values generally produce tighter curves. A suitable domain is + * approximately [0.666..., 3]. Values below 0.1 are clamped to + * 0.1 to avoid degeneracy. + * @return a PShape representing the resulting Hobby Curve composed of cubic + * Bezier segments. + * @since 2.1 + */ + public static PShape createHobbyCurve(List points, double tension) { + return createHobbyCurve(points, tension, 0.5); + } + + /** + * Create a Hobby Curve from the given list of control points using the + * specified tension and endpoint curl parameter. + *

+ * The curve is constructed from cubic Bezier segments. If the first and last + * points are equal the curve will be treated as closed (continuous), and the + * endpoint curl parameter is effectively ignored for continuity. + * + * @param points the list of vertices to use as the basis for the Hobby + * Curve. Must be non-null and contain at least two points. + * @param tension a parameter controlling the tightness of the curve. + * Higher values generally produce tighter curves. A + * suitable domain is approximately [0.666..., 3]. Values + * below 0.1 are clamped to 0.1 to avoid degeneracy. + * @param endPointCurl a tuning parameter that controls the "curl" at the start + * and end of the curve; typical default is 0.5. When the + * curve is closed this value is ignored. + * @return a PShape representing the resulting Hobby Curve composed of cubic + * Bezier segments. + * @since 2.1 + */ + public static PShape createHobbyCurve(List points, double tension, double endPointCurl) { + tension = Math.max(tension, 0.1); // prevent degeneracy + final boolean closed = points.get(0).equals(points.get(points.size() - 1)); + double[][] vertices = PGS_Conversion.toArray(points.subList(0, points.size() - (closed ? 1 : 0))); + // param start/end curve + HobbyCurve curve = new HobbyCurve(vertices, tension, closed, endPointCurl, endPointCurl); + + double[][] beziers = curve.getBeziers(); + + List curveVertices = Arrays.stream(beziers).parallel().flatMap(b -> { + // unpack the array into the 4 PVector points + int i = 0; + PVector p1 = new PVector((float) b[i++], (float) b[i++]); + PVector cp1 = new PVector((float) b[i++], (float) b[i++]); + PVector cp2 = new PVector((float) b[i++], (float) b[i++]); + PVector p2 = new PVector((float) b[i++], (float) b[i]); + + PShape bezierShape = PGS_Conversion.fromCubicBezier(p1, cp1, cp2, p2); + + return PGS_Conversion.toPVector(bezierShape).stream(); // to stream (for flattening) + }).toList(); + + return PGS_Conversion.fromPVector(curveVertices); + } + /** * Creates a Sierpiński Carpet shape, a type of plane fractal. * @@ -1033,13 +1163,166 @@ public static PShape createSierpinskiTriCurve(SierpinskiTriCurveType type, doubl return out; } + /** + * Shortcut for creating a rectangle with uniformly rounded corners, using + * {@link PConstants#CORNER} mode. + *

+ * This convenience method creates a rectangle where all four corners have the + * same radius {@code r}. The rectangle uses Processing's + * {@link PConstants#CORNER} mode coordinates: {@code (a, b)} specify the + * top-left corner, and {@code c} and {@code d} specify width and height, + * respectively. + * + * @param a the x-coordinate of the top-left corner of the rectangle + * @param b the y-coordinate of the top-left corner of the rectangle + * @param c the width of the rectangle + * @param d the height of the rectangle + * @param r the uniform radius to be applied to all four corners (0 + * gives a regular rectangle) + * @return a {@link PShape} representing the rounded rectangle + * @since 2.1 + */ + public static PShape createRect(double a, double b, double c, double d, double r) { + return createRect(PConstants.CORNER, a, b, c, d, r); + } + + /** + * Creates a rectangle with specified corner radii, in any Processing-style + * rectangle mode. + *

+ * The meaning of the {@code a}, {@code b}, {@code c}, and {@code d} parameters + * depends on the {@code rectMode}: + *

    + *
  • {@link PConstants#CORNER}: {@code (a, b)} is the top-left corner; + * {@code c} is width, {@code d} is height
  • + *
  • {@link PConstants#CORNERS}: {@code (a, b)} is the top-left corner; + * {@code (c, d)} is the bottom-right corner
  • + *
  • {@link PConstants#CENTER}: {@code (a, b)} is the rectangle center; + * {@code c} is width, {@code d} is height
  • + *
  • {@link PConstants#RADIUS}: {@code (a, b)} is the center; {@code c} is + * half width, {@code d} is half height
  • + *
+ * Each corner radius parameter refers to a specific corner: + *
    + *
  • {@code tl} – top-left corner radius
  • + *
  • {@code tr} – top-right corner radius
  • + *
  • {@code br} – bottom-right corner radius
  • + *
  • {@code bl} – bottom-left corner radius
  • + *
+ * + * @param rectMode rectangle mode as in Processing; one of + * {@link PConstants#CORNER}, {@link PConstants#CORNERS}, + * {@link PConstants#CENTER}, or {@link PConstants#RADIUS} + * @param a first coordinate: x (or center x, depending on mode) + * @param b second coordinate: y (or center y, depending on mode) + * @param c width, x2, or half-width (see mode above) + * @param d height, y2, or half-height (see mode above) + * @param r the uniform radius to be applied to all four corners + * @return a {@link PShape} representing the rounded rectangle + * @since 2.1 + */ + public static PShape createRect(int rectMode, double a, double b, double c, double d, double r) { + return rect(rectMode, a, b, c, d, r, r, r, r); + } + + static PShape rect(int rectMode, double a, double b, double c, double d, double tl, double tr, double br, double bl) { + double hradius, vradius; + switch (rectMode) { + case PConstants.CORNERS : + break; + case PConstants.CORNER : + c += a; + d += b; + break; + case PConstants.RADIUS : + hradius = c; + vradius = d; + c = a + hradius; + d = b + vradius; + a -= hradius; + b -= vradius; + break; + case PConstants.CENTER : + hradius = c / 2.0; + vradius = d / 2.0; + c = a + hradius; + d = b + vradius; + a -= hradius; + b -= vradius; + break; + } + if (a > c) { + double t = a; + a = c; + c = t; + } + if (b > d) { + double t = b; + b = d; + d = t; + } + double maxRounding = Math.min((c - a) / 2, (d - b) / 2); + tl = Math.min(tl, maxRounding); + tr = Math.min(tr, maxRounding); + br = Math.min(br, maxRounding); + bl = Math.min(bl, maxRounding); + return rectImpl((float) a, (float) b, (float) c, (float) d, (float) tl, (float) tr, (float) br, (float) bl); + } + + private static PShape rectImpl(float x1, float y1, float x2, float y2, float tl, float tr, float br, float bl) { + PShape sh = new PShape(PShape.PATH); + sh.setFill(true); + sh.setFill(Colors.WHITE); + sh.beginShape(); + // Top edge and top-right corner + if (tr != 0) { + sh.vertex(x2 - tr, y1); + sh.quadraticVertex(x2, y1, x2, y1 + tr); + } else { + sh.vertex(x2, y1); + } + // Right edge and bottom-right + if (br != 0) { + sh.vertex(x2, y2 - br); + sh.quadraticVertex(x2, y2, x2 - br, y2); + } else { + sh.vertex(x2, y2); + } + // Bottom edge and bottom-left + if (bl != 0) { + sh.vertex(x1 + bl, y2); + sh.quadraticVertex(x1, y2, x1, y2 - bl); + } else { + sh.vertex(x1, y2); + } + // Left edge and top-left + if (tl != 0) { + sh.vertex(x1, y1 + tl); + sh.quadraticVertex(x1, y1, x1 + tl, y1); + } else { + sh.vertex(x1, y1); + } + sh.endShape(PConstants.CLOSE); + return sh; + } + /** * Creates a polygon finely approximating a circle. * * @since 2.0 */ static Polygon createCircle(Coordinate c, double r) { - return createCircle(c.x, c.y, r, 0.5); // 0.5 still very generous + return createCircle(c.x, c.y, r, 0.5); + } + + /** + * Creates a geometric circle of radius r (.z) centered on (x,y). + * + * @param c the PVector representing a circle + * @since 2.1 + */ + public static PShape createCircle(PVector c) { + return createCircle(c.x, c.y, c.z); } /** @@ -1048,7 +1331,7 @@ static Polygon createCircle(Coordinate c, double r) { * @since 2.0 */ public static PShape createCircle(double x, double y, double r) { - return toPShape(createCirclePoly(x, y, r)); // 0.5 still very generous + return toPShape(createCirclePoly(x, y, r)); } /** @@ -1099,6 +1382,62 @@ static Polygon createCircle(double x, double y, double r, final double maxDeviat return PGS.GEOM_FACTORY.createPolygon(pts); } + /** + * Sample a circular arc from startAngle→endAngle (radians), producing a List of + * PVectors so that no straight‐line chord deviates by more than maxDev pixels + * from the true circle. + * + * @param cx center x + * @param cy center y + * @param r radius (>0) + * @param startAngle start angle in radians + * @param endAngle end angle in radians + * @param maxDev maximum sagitta error in pixels (>0) + * @return List of PVectors, inclusive of both endpoints. + * @since 2.1 + */ + static List arcPoints(double cx, double cy, double r, double startAngle, double endAngle, double maxDev) { + List pts = new ArrayList<>(); + + // 1) Compute raw sweep and direction + double rawRange = endAngle - startAngle; + int dir = rawRange >= 0 ? +1 : -1; + double range = Math.abs(rawRange); + + // 2) Cap at a full circle if the user overshoots + if (range > 2.0 * Math.PI) { + range = 2.0 * Math.PI; + } + + // 3) Solve for maximum step so that sagitta s = R*(1–cos(dθ/2)) ≤ maxDev + // ⇒ dθ ≤ 2 * acos(1 – maxDev / R) + double cosArg = 1.0 - maxDev / r; + // clamp to avoid NaNs + cosArg = Math.max(-1.0, Math.min(1.0, cosArg)); + double maxStep = 2.0 * Math.acos(cosArg); + + // 4) If that step is wider than the entire arc, just use one segment + if (maxStep >= range) { + maxStep = range; + } + + // 5) Determine how many segments are needed (at least 1) + int nSeg = Math.max(1, (int) Math.ceil(range / maxStep)); + + // 6) Compute the actual signed step + double step = dir * (range / nSeg); + + // 7) Sample from i=0…nSeg (inclusive) so endpoints are exact + for (int i = 0; i <= nSeg; i++) { + double a = startAngle + i * step; + double px = cx + r * FastMath.cos(a); + double py = cy + r * FastMath.sin(a); + pts.add(new PVector((float) px, (float) py)); + } + + return pts; + } + /** * Sierpinski curve subdivide. */ diff --git a/src/main/java/micycle/pgs/PGS_Contour.java b/src/main/java/micycle/pgs/PGS_Contour.java index 10456f2e..d7750cc9 100644 --- a/src/main/java/micycle/pgs/PGS_Contour.java +++ b/src/main/java/micycle/pgs/PGS_Contour.java @@ -7,20 +7,21 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.vecmath.Point3d; +import org.jgrapht.alg.interfaces.ShortestPathAlgorithm; +import org.jgrapht.alg.shortestpath.BFSShortestPath; import org.jgrapht.graph.DefaultEdge; import org.jgrapht.graph.SimpleGraph; -import org.joml.Vector2d; -import org.joml.Vector2dc; import org.locationtech.jts.algorithm.Angle; import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.dissolve.LineDissolver; @@ -29,13 +30,14 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Location; import org.locationtech.jts.geom.MultiLineString; import org.locationtech.jts.geom.Polygon; -import org.locationtech.jts.geom.prep.PreparedGeometry; -import org.locationtech.jts.geom.prep.PreparedGeometryFactory; +import org.locationtech.jts.geom.util.GeometryExtracter; import org.locationtech.jts.operation.buffer.BufferOp; import org.locationtech.jts.operation.buffer.BufferParameters; import org.locationtech.jts.operation.buffer.OffsetCurve; +import org.locationtech.jts.operation.distance.IndexedFacetDistance; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.tinfour.common.IIncrementalTin; import org.tinfour.common.IQuadEdge; @@ -44,6 +46,7 @@ import org.tinfour.contour.Contour; import org.tinfour.contour.ContourBuilderForTin; import org.tinfour.standard.IncrementalTin; +import org.tinfour.utils.HilbertSort; import org.tinfour.utils.SmoothingFilter; import org.twak.camp.Corner; import org.twak.camp.Edge; @@ -52,13 +55,11 @@ import org.twak.utils.collections.Loop; import org.twak.utils.collections.LoopL; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; import com.google.common.collect.Lists; -import kendzi.math.geometry.skeleton.SkeletonConfiguration; -import kendzi.math.geometry.skeleton.SkeletonOutput; import micycle.medialAxis.MedialAxis; import micycle.medialAxis.MedialAxis.MedialDisk; -import micycle.pgs.PGS.GeometryIterator; import micycle.pgs.PGS.LinearRingIterator; import micycle.pgs.color.ColorUtils; import micycle.pgs.color.Colors; @@ -69,12 +70,13 @@ import processing.core.PVector; /** - * Methods for producing different kinds of shape contours. + * Methods for producing different kinds of shape contours. * *

- * A 2D contour is a closed sequence (a cycle) of 3 or more connected 2D - * oriented straight line segments called contour edges. The endpoints of the - * contour edges are called vertices. Each contour edge shares its endpoints - * with at least two other contour edges. + * Contours produced by this class are always computed within the interior of + * shapes. Contour lines and features (such as isolines, medial axes, and + * fields) are extracted as vector linework following the topology or scalar + * properties of the enclosed shape area, rather than operations that modify the + * shape boundary. * * @author Michael Carleton * @@ -120,9 +122,16 @@ private PGS_Contour() { public static PShape medialAxis(PShape shape, double axialThreshold, double distanceThreshold, double areaThreshold) { final Geometry g = fromPShape(shape); final MedialAxis m = new MedialAxis(g); - return PGS_SegmentSet.dissolve(m.getPrunedEdges(axialThreshold, distanceThreshold, areaThreshold).stream() - .map(e -> new PEdge(e.head.position.x, e.head.position.y, e.tail.position.x, e.tail.position.y)) - .collect(Collectors.toList())); + var medialEdges = m.getPrunedEdges(axialThreshold, distanceThreshold, areaThreshold); + var medialSegments = medialEdges.stream().map(e -> { + var head = e.head.position; + var tail = e.tail.position; + if (head.equals2D(tail)) { + return null; + } + return new PEdge(head.x, head.y, tail.x, tail.y); + }).filter(Objects::nonNull).toList(); + return PGS_SegmentSet.dissolve(medialSegments); } /** @@ -163,8 +172,8 @@ public static PShape chordalAxis(PShape shape) { switch (graph.outDegreeOf(t)) { case 1 : // Terminal triangle (2 edges in perimeter) final IQuadEdge interiorEdge; // one edge is interior - if (t.getEdgeA().isConstrainedRegionBorder()) { - if (t.getEdgeB().isConstrainedRegionBorder()) { + if (t.getEdgeA().isConstraintRegionBorder()) { + if (t.getEdgeB().isConstraintRegionBorder()) { interiorEdge = t.getEdgeC(); } else { interiorEdge = t.getEdgeB(); @@ -178,10 +187,10 @@ public static PShape chordalAxis(PShape shape) { case 2 : // Sleeve triangle (one edge in perimeter) final IQuadEdge interiorEdgeA; // 2 edges are interior final IQuadEdge interiorEdgeB; - if (t.getEdgeA().isConstrainedRegionBorder()) { + if (t.getEdgeA().isConstraintRegionBorder()) { interiorEdgeA = t.getEdgeB(); interiorEdgeB = t.getEdgeC(); - } else if (t.getEdgeB().isConstrainedRegionBorder()) { + } else if (t.getEdgeB().isConstraintRegionBorder()) { interiorEdgeA = t.getEdgeA(); interiorEdgeB = t.getEdgeC(); } else { @@ -232,52 +241,74 @@ public static PShape chordalAxis(PShape shape) { * consisting of straight-line segments only. Roughly, it is the geometric graph * whose edges are the traces of vertices of shrinking mitered offset curves of * the polygon. - * + *

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

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

+ * For multi-polygons, the method returns a master GROUP PShape. This master + * shape includes multiple skeleton GROUP shapes, each corresponding to a single + * polygon and structured as described above. + * * @param shape a single polygon (that can contain holes), or a multi polygon * (whose polygons can contain holes) - * @return when the input is a single polygon, returns a GROUP PShape containing - * 3 children: child 1 = GROUP PShape of skeleton faces; child 2 = LINES - * PShape of branches (lines that connect skeleton to edge); child 3 = - * LINES PShape of bones (the pure straight skeleton). For - * multi-polygons, a master GROUP shape of skeleton GROUP shapes - * (described above) is returned. + * + * @return PShape based on the input polygon structure, either as a single or + * multi-polygon skeleton representation. */ public static PShape straightSkeleton(PShape shape) { - final Geometry g = fromPShape(shape); - if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON)) { - PShape group = new PShape(PConstants.GROUP); - GeometryIterator gi = new GeometryIterator(g); - gi.forEach(p -> group.addChild(straightSkeleton((Polygon) p))); - return group; - } else if (g.getGeometryType().equals(Geometry.TYPENAME_POLYGON)) { - return straightSkeleton((Polygon) g); - } - return shape; + return straightSkeleton(shape, Integer.MAX_VALUE); } /** + * Computes the straight skeleton for a shape. This method signature accepts an + * integer to control the number of nearest neighboring edges considered during + * collision detection. In practice this can speed up computation considerably. + *

+ * A straight skeleton is a skeletal structure similar to the medial axis, + * consisting of straight-line segments only. Roughly, it is the geometric graph + * whose edges are the traces of vertices of shrinking mitered offset curves of + * the polygon. + *

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

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

+ * For multi-polygons, the method returns a master GROUP PShape. This master + * shape includes multiple skeleton GROUP shapes, each corresponding to a single + * polygon and structured as described above. * - * @param polygon a single polygon that can contain holes - * @return + * @param shape a single polygon (that can contain holes), or a multi polygon + * (whose polygons can contain holes) + * @param k The number of nearest neighboring edges to consider when + * searching for collisions using the spatial index. This parameter + * balances performance and correctness: too few neighbors may miss + * collisions, while too many may reduce the performance benefits + * of the spatial index. + * @return PShape based on the input polygon structure, either as a single or + * multi-polygon skeleton representation. + * @since 2.1 */ - private static PShape straightSkeleton(Polygon polygon) { - /* - * Kenzi implementation (since PGS 1.3.0) is much faster (~50x!) but can fail on - * more complicated inputs. Therefore try Kenzi implementation first, but fall - * back to Twak implementation if it fails. - */ - try { - return straightSkeletonKendzi(polygon); - } catch (Exception e) { - return straightSkeletonTwak(polygon); - } + @SuppressWarnings("unchecked") + public static PShape straightSkeleton(PShape shape, int k) { + final Geometry g = fromPShape(shape); + var skeletons = GeometryExtracter.extract(g, Geometry.TYPENAME_POLYGON).parallelStream().map(p -> straightSkeleton((Polygon) p, k)).toList(); + return PGS_Conversion.flatten(skeletons); } - private static PShape straightSkeletonTwak(Polygon polygon) { - if (polygon.getCoordinates().length > 1000) { - polygon = (Polygon) DouglasPeuckerSimplifier.simplify(polygon, 2); - } - + private static PShape straightSkeleton(Polygon polygon, int k) { final Set edgeCoordsSet = new HashSet<>(); final Skeleton skeleton; final LoopL loops = new LoopL<>(); // list of loops @@ -297,11 +328,11 @@ private static PShape straightSkeletonTwak(Polygon polygon) { final Set branchEdges = new HashSet<>(); final Set boneEdges = new HashSet<>(); try { - skeleton = new Skeleton(loops, true); + skeleton = new Skeleton(loops, k); skeleton.skeleton(); // compute skeleton skeleton.output.faces.values().forEach(f -> { - final List vertices = f.getLoopL().iterator().next().asList(); + List vertices = f.getLoopL().iterator().next().stream().toList(); List faceVertices = new ArrayList<>(); for (int i = 0; i < vertices.size(); i++) { @@ -321,7 +352,7 @@ private static PShape straightSkeletonTwak(Polygon polygon) { PShape face = PGS_Conversion.fromPVector(faceVertices); face.setStroke(true); - face.setStrokeWeight(2); + face.setStrokeWeight(1); face.setStroke(ColorUtils.composeColor(147, 112, 219)); faces.addChild(face); }); @@ -329,7 +360,7 @@ private static PShape straightSkeletonTwak(Polygon polygon) { // hide init or collision errors from console } - final PShape bones = prepareLinesPShape(null, null, 4); + final PShape bones = prepareLinesPShape(null, null, 2); boneEdges.forEach(e -> { bones.vertex(e.a.x, e.a.y); bones.vertex(e.b.x, e.b.y); @@ -350,95 +381,20 @@ private static PShape straightSkeletonTwak(Polygon polygon) { return lines; } - private static PShape straightSkeletonKendzi(Polygon polygon) { - final LinearRing[] rings = new LinearRingIterator(polygon).getLinearRings(); - Set edgeCoordsSet = new HashSet<>(); - final List points = ringToVec(rings[0], edgeCoordsSet); - final List> holes = new ArrayList<>(); - for (int i = 1; i < rings.length; i++) { - holes.add(ringToVec(rings[i], edgeCoordsSet)); - } - - final SkeletonOutput so = kendzi.math.geometry.skeleton.Skeleton.skeleton(points, holes, new SkeletonConfiguration()); - final PShape skeleton = new PShape(PConstants.GROUP); - final PShape faces = new PShape(PConstants.GROUP); - /* - * Create PEdges first to prevent lines being duplicated in output shapes since - * faces share branches and bones. - */ - final Set branchEdges = new HashSet<>(); - final Set boneEdges = new HashSet<>(); - so.getFaces().forEach(f -> { - /* - * q stores the index of second vertex of the face that is a shape vertex. This - * is used to rotate f.getPoints() so that the vertices of every face PShape - * begin at the shape edge. - */ - int q = 0; - for (int i = 0; i < f.getPoints().size(); i++) { - final Vector2dc p1 = f.getPoints().get(i); - final Vector2dc p2 = f.getPoints().get((i + 1) % f.getPoints().size()); - final boolean a = edgeCoordsSet.contains(p1); - final boolean b = edgeCoordsSet.contains(p2); - if (a ^ b) { // branch (xor) - branchEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y())); - q = i; - } else { - if (!a) { // bone - boneEdges.add(new PEdge(p1.x(), p1.y(), p2.x(), p2.y())); - } else { - q = i; - } - } - } - - List faceVertices = new ArrayList<>(f.getPoints().size()); - Collections.rotate(f.getPoints(), -q + 1); - f.getPoints().forEach(p -> faceVertices.add(new PVector((float) p.x(), (float) p.y()))); - - PShape face = PGS_Conversion.fromPVector(faceVertices); - face.setStroke(true); - face.setStrokeWeight(2); - face.setStroke(ColorUtils.composeColor(147, 112, 219)); - faces.addChild(face); - }); - - final PShape bones = prepareLinesPShape(null, null, 4); - boneEdges.forEach(e -> { - bones.vertex(e.a.x, e.a.y); - bones.vertex(e.b.x, e.b.y); - }); - bones.endShape(); - - final PShape branches = prepareLinesPShape(ColorUtils.composeColor(40, 235, 180), null, null); - branchEdges.forEach(e -> { - branches.vertex(e.a.x, e.a.y); - branches.vertex(e.b.x, e.b.y); - }); - branches.endShape(); - - skeleton.addChild(faces); - skeleton.addChild(branches); - skeleton.addChild(bones); - - return skeleton; - } - /** - * Generates a topographic-like isoline contour map from the shape's vertices. - * The "elevation" (or z value) of points is the euclidean distance between a - * point in the shape and the given "high" point. + * Generates a topographic-like isoline contour map from the shape's vertices + * and a given "high point". Isolines represent the "elevation", or euclidean + * distance, between a location in the shape and the "high point". *

* Assigns each point feature a number equal to the distance between geometry's * centroid and the point. * - * @param shape + * @param shape the bounds in which to draw isolines * @param highPoint position of "high" point within the shape * @param intervalSpacing distance between successive isolines - * @return PShape containing isolines linework + * @return PShape containing isolines linework */ public static PShape isolines(PShape shape, PVector highPoint, double intervalSpacing) { - /* * Also See: * https://github.com/hageldave/JPlotter/blob/master/jplotter/src/main/java/ @@ -451,40 +407,28 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp if (g.getCoordinates().length > 2000) { g = DouglasPeuckerSimplifier.simplify(g, 1); } - final int buffer = (int) Math.max(10, Math.round(intervalSpacing) + 1); - PreparedGeometry cache = PreparedGeometryFactory.prepare(g.buffer(10)); + final int buffer = (int) Math.max(10, Math.ceil(intervalSpacing)); + YStripesPointInAreaLocator cache = new YStripesPointInAreaLocator(g.buffer(buffer)); - final List tinVertices = new ArrayList<>(200); - double maxDist = 0; - - /** - * Poisson a little faster, but isolines are more rough - */ -// ArrayList randomPoints = pd.generate(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer, -// e[1].y + buffer, intervalSpacing, 6); -// PoissonDistribution pd = new PoissonDistribution(0); Coordinate[] e = g.getEnvelope().getCoordinates(); // envelope/bounding box of shape - ArrayList randomPoints = generateGrid(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer, e[1].y + buffer, intervalSpacing, - intervalSpacing); + List randomPoints = generateGrid(e[0].x - buffer, e[0].y - buffer, e[3].x + buffer, e[1].y + buffer, intervalSpacing / 3, intervalSpacing / 3); + + final List tinVertices = new ArrayList<>(randomPoints.size()); + double maxDist = 0; for (PVector v : randomPoints) { - /** + /* * Major bottleneck of method is isoline computation so reduce points to only * those needed. */ - if (cache.covers(PGS.pointFromPVector(v))) { - double d = highPoint.dist(v); + if (cache.locate(PGS.coordFromPVector(v)) != Location.EXTERIOR) { + double d = Math.sqrt(PGS.distanceSq(highPoint, v)); maxDist = Math.max(d, maxDist); tinVertices.add(new Vertex(v.x, v.y, d)); } -// if (g.isWithinDistance(PTS.pointFromPVector(v), 10)) { -// double d = highPoint.dist(v); -// maxDist = Math.max(d, maxDist); -// tinVertices.add(new Vertex(v.x, v.y, d, 0)); -// } } - final IncrementalTin tin = new IncrementalTin(intervalSpacing); + final IncrementalTin tin = new IncrementalTin(intervalSpacing / 2); tin.add(tinVertices, null); // insert point set; points are triangulated upon insertion double[] intervals = generateDoubleSequence(0, maxDist, intervalSpacing); @@ -493,38 +437,63 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp * "A null valuator tells the builder to just use the z values from the vertices * rather than applying any adjustments to their values." */ - final ContourBuilderForTin builder = new ContourBuilderForTin(tin, null, intervals, true); + final ContourBuilderForTin builder = new ContourBuilderForTin(tin, null, intervals, false); List contours = builder.getContours(); - PShape parent = new PShape(PConstants.GROUP); - parent.setKind(PConstants.GROUP); + var contourGeom = GEOM_FACTORY.createMultiLineString(contours.stream().map(c -> contourToLineString(c)).toArray(LineString[]::new)); + + PShape out = toPShape(DouglasPeuckerSimplifier.simplify(contourGeom, 0.25).intersection(g)); + PGS_Conversion.disableAllFill(out); + PGS_Conversion.setAllStrokeColor(out, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE); + + return out; + } - LineDissolver ld = new LineDissolver(); - for (Contour contour : contours) { - Coordinate[] coords = new Coordinate[contour.getCoordinates().length / 2]; - for (int i = 0; i < contour.getCoordinates().length; i += 2) { - float vx = (float) contour.getCoordinates()[i]; - float vy = (float) contour.getCoordinates()[i + 1]; - coords[i / 2] = new Coordinate(vx, vy); + /** + * Generates a topographic-like isoline contour map from the given points. This + * method uses the Z value of each PVector point as the "elevation" of that + * location in the map, and uses a specified number of contour intervals. + * + * The function finds the minimum and maximum Z values in the given points, + * divides the Z range into the specified number of intervals, and generates + * isolines or contour curves at corresponding heights. Smoothing can be applied + * to the isolines for better visual results. + * + * @param points Collection of PVectors representing sample locations. The + * z coordinate of each PVector defines the + * elevation at that point. + * @param intervals The number of contour levels (isolines) to generate between + * the minimum and maximum Z values in the input points. Must + * be greater than zero. + * @param smoothing The amount of smoothing to apply to the generated isolines. + * The smoothing algorithm and valid range depend on + * implementation. + * @return A Map where the keys are PShape objects representing the + * isolines, and the values are Float numbers giving the Z + * value (height) for each isoline. + * + * @since 2.1 + * + * @see #isolines(Collection, double, double, double, int) + */ + public static Map isolines(Collection points, int intervals, int smoothing) { + double minZ = Double.MAX_VALUE; + double maxZ = Double.MIN_VALUE; + + for (PVector point : points) { + if (point.z < minZ) { + minZ = point.z; + } + if (point.z > maxZ) { + maxZ = point.z; } - ld.add(GEOM_FACTORY.createLineString(coords)); } - PShape out = new PShape(); - try { - /* - * Need to use intersection() rather than checkling whether vertices are - * contained within the shape (faster) because vertices of longer (straight) - * line segments may lie within the shape when the segment extends outside the - * shape - */ - out = toPShape(DouglasPeuckerSimplifier.simplify(ld.getResult(), 1).intersection(g)); - out.setStrokeCap(PConstants.SQUARE); - } catch (Exception e2) { - // catch non-noded intersection - } - return out; + // Step 2: Compute interval spacing + double diff = maxZ - minZ; + double intervalSpacing = diff / intervals; + return isolines(points, intervalSpacing, minZ, maxZ, smoothing); } /** @@ -542,8 +511,7 @@ public static PShape isolines(PShape shape, PVector highPoint, double intervalSp * @param isolineMax maximum value represented by isolines * @return a map of {isoline -> height of the isoline} */ - public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, - double isolineMax) { + public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax) { return isolines(points, intervalValueSpacing, isolineMin, isolineMax, 0); } @@ -567,10 +535,13 @@ public static Map isolines(Collection points, double int * investigation. * @return a map of {isoline -> height of the isoline} */ - public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax, - int smoothing) { - final IncrementalTin tin = new IncrementalTin(10); - points.forEach(point -> tin.add(new Vertex(point.x, point.y, point.z))); + public static Map isolines(Collection points, double intervalValueSpacing, double isolineMin, double isolineMax, int smoothing) { + final IncrementalTin tin = new IncrementalTin(intervalValueSpacing / 10); + + var vertices = points.stream().map(p -> new Vertex(p.x, p.y, p.z)).collect(Collectors.toList()); + HilbertSort hs = new HilbertSort(); + hs.sort(vertices); // prevent degenerate insertion + tin.add(vertices, null); double[] intervals = generateDoubleSequence(isolineMin, isolineMax, intervalValueSpacing); @@ -591,30 +562,45 @@ public static Map isolines(Collection points, double int isoline.setStrokeWeight(2); isoline.setStroke(Colors.PINK); + PVector last = new PVector(Float.NaN, Float.NaN); isoline.beginShape(); for (int i = 0; i < coords.length; i += 2) { float vx = (float) coords[i]; float vy = (float) coords[i + 1]; - isoline.vertex(vx, vy); + PVector curr = new PVector(vx, vy); + if (!last.equals(curr)) { + isoline.vertex(vx, vy); + last.set(curr); + } } isoline.endShape(); - isolines.put(isoline, (float) contourLine.getZ()); + if (isoline.getVertexCount() > 1) { + // skip pointal "contours" + isolines.put(isoline, (float) contourLine.getZ()); + } } return isolines; } /** - * Generates a contour map based on a distance field of a shape. + * Generates vector contour lines representing a distance field derived from a + * shape. *

- * A distance field maps each point within the shape to the shortest distance - * between that point and the shape boundary. - * - * @param shape polygonal shape - * @param spacing distance represented by successive contour lines - * @return GROUP shape, where each child is a closed contour line or contour - * line partition + * The distance field for a shape assigns each interior point a value equal to + * the shortest Euclidean distance from that point to the shape boundary. This + * method computes a series of contour lines (isolines), where each line + * connects points with the same distance value, effectively visualizing the + * "levels" of the distance field like elevation contours on a topographic map. + * + * @param shape A polygonal shape for which to calculate the distance field + * contours. + * @param spacing The interval between successive contour lines, i.e., the + * distance value difference between each contour. + * @return A GROUP PShape. Each child of the group is a closed contour line or a + * section (partition) of a contour line, collectively forming the + * contour map. * @since 1.3.0 */ public static PShape distanceField(PShape shape, double spacing) { @@ -630,12 +616,129 @@ public static PShape distanceField(PShape shape, double spacing) { max = Math.max(d.distance, max); } - PShape out = PGS_Conversion.flatten(PGS_Contour.isolines(disks, spacing, min, max).keySet()); + PShape out = PGS_Conversion.flatten(PGS_Contour.isolines(disks, spacing, min, max, 1).keySet()); PShape i = PGS_ShapeBoolean.intersect(shape, out); PGS_Conversion.disableAllFill(i); // since some shapes may be polygons + PGS_Conversion.setAllStrokeColor(i, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE); return i; } + /** + * Generates vector contour lines representing a "contrast field" of a shape + * with respect to a given reference point. + *

+ * For each interior point of the shape, this field is defined as the absolute + * difference between (a) its shortest Euclidean distance to the shape's + * boundary, and (b) its distance to a specified reference point. Contour lines + * (isolines) are drawn at regular value intervals, connecting points where this + * difference is equal. This effectively visualises "ridges" and balance zones + * between the reference point and the shape boundary. + * + * @param shape A polygonal shape for which to calculate the distance field + * contours. + * @param intervals The number of successive contour lines. + * @param reference The reference point used for distance comparison. + * @param seed Random seed for Poisson-disc sampling of interior points. + * @return A GROUP PShape where each child is a closed contour line or a contour + * segment, together forming the contrast field visualisation as a + * vector contour map. + * @since 2.1 + */ + public static PShape contrastField(PShape shape, int intervals, PVector reference) { + final double[] b = new double[4]; + PGS_Hull.boundingBox(shape, b); // write to bounding box + final var g = fromPShape(shape); + final var pointLocator = new YStripesPointInAreaLocator((Polygon) g.buffer(10)); + final IndexedFacetDistance distIndex = new IndexedFacetDistance(g); + double adjustedArea = g.getArea() / PGS_ShapePredicates.density(shape); + + var points = PGS_PointSet.poissonN(b[0], b[1], b[2], b[3], (int) Math.max(100, adjustedArea / 100), 1337); + List fieldPoints = points.parallelStream().map(p -> { + var point = PGS.pointFromPVector(p); + var c = point.getCoordinate(); + if (pointLocator.locate(c) == Location.EXTERIOR) { + return null; + } + var dist = voidDistance(distIndex.distance(point), p, reference); + return new PVector((float) c.x, (float) c.y, (float) dist); + }).filter(Objects::nonNull).toList(); + + var isolines = isolines(fieldPoints, Math.max(1, intervals), 11); + var lines = PGS_Conversion.flatten(isolines.keySet()); + + PShape contours = PGS_ShapeBoolean.intersect(shape, lines); + contours = PGS_Conversion.disableAllFill(contours); // since some shapes may be polygons + PGS_Conversion.setAllStrokeColor(contours, micycle.pgs.color.Colors.PINK, 4, PConstants.SQUARE); + + return contours; + } + + /** + * Generates a tree structure representing the shortest paths from a given start + * point to all other vertices in the provided mesh. The paths are computed + * using the existing connectivity of the mesh edges, ensuring that the + * shortest-path tree respects the original mesh structure. The tree is + * constructed using a Breadth-First Search (BFS) algorithm. + *

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

+ * If the provided start point does not exactly match a vertex in the mesh, the + * closest vertex in the mesh to the start point is used as the actual starting + * point for the shortest-path computation. + * + * @param mesh A GROUP shape representing a mesh from which the graph is + * constructed. The mesh defines the connectivity between + * vertices via its edges. + * @param source The starting point from which the shortest paths are + * calculated. If this point does not exactly match a vertex in + * the mesh, the closest vertex in the mesh will be used as the + * starting point. + * @param flatten Determines the format of the output shortest-path tree. + *

+ * If {@code true}, the method returns a flattened representation + * of the shortest-path tree as a single set of edges. This + * removes duplicate edges and combines all paths into a single + * structure. + *

+ * If {@code false}, the method returns a GROUP shape of + * individual paths, where each path is a separate line from the + * start point to each vertex in the mesh. This representation + * retains the structure of the shortest-path tree as a + * collection of distinct paths. + *

+ * @return A PShape object representing the tree of shortest paths from the + * start point to all other vertices in the mesh. The paths are + * constrained by the mesh's edge connectivity. + * @since 2.1 + */ + public static PShape distanceTree(PShape mesh, PVector source, boolean flatten) { + var g = PGS_Conversion.toGraph(mesh); + ShortestPathAlgorithm spa = new BFSShortestPath<>(g); + + final PVector sourceActual = PGS_Optimisation.closestPoint(g.vertexSet(), source); + var paths = spa.getPaths(sourceActual); + + PShape out; + if (flatten) { + var edges = g.vertexSet().stream().filter(v -> !v.equals(sourceActual)) // Exclude the source vertex + .flatMap(v -> paths.getPath(v).getEdgeList().stream()) // Flatten the edge lists into a single stream + .collect(Collectors.toSet()); // Collect the edges into a Set to remove duplicates + out = PGS_SegmentSet.toPShape(edges); + } else { + var pathLines = g.vertexSet().stream() // + .filter(v -> v != sourceActual) // Exclude the source vertex + .map(v -> PGS_Conversion.fromPVector(paths.getPath(v).getVertexList())).toList(); + out = PGS_Conversion.flatten(pathLines); + PGS_Conversion.setAllStrokeColor(out, ColorUtils.setAlpha(Colors.PINK, 50), 4); + } + + return out; + } + /** * Calculates the longest center line passing through a given shape (using * default straightness weighting and smoothing parameters). @@ -702,16 +805,13 @@ public static PShape centerLine(PShape shape, double straightnessWeighting, doub MedialAxis m = new MedialAxis(fromPShape(shape)); List longestPath = new ArrayList<>(); - List subTree1 = m.getDescendants(m.getRoot().children.get(0)).stream().filter(d -> d.degree == 0) - .collect(Collectors.toList()); - List subTree2 = m.getDescendants(m.getRoot().children.get(1)).stream().filter(d -> d.degree == 0) - .collect(Collectors.toList()); + List subTree1 = m.getDescendants(m.getRoot().children.get(0)).stream().filter(d -> d.degree == 0).collect(Collectors.toList()); + List subTree2 = m.getDescendants(m.getRoot().children.get(1)).stream().filter(d -> d.degree == 0).collect(Collectors.toList()); if (m.getRoot().children.size() == 2) { // special case of elliptical (etc.) shapes longestPath = new ArrayList<>(m.getEdges()); } else { - List subTree3 = m.getDescendants(m.getRoot().children.get(2)).stream().filter(d -> d.degree == 0) - .collect(Collectors.toList()); + List subTree3 = m.getDescendants(m.getRoot().children.get(2)).stream().filter(d -> d.degree == 0).collect(Collectors.toList()); MedialDisk longestPathD1 = null; MedialDisk longestPathD2 = null; @@ -754,7 +854,10 @@ public static PShape centerLine(PShape shape, double straightnessWeighting, doub */ public enum OffsetStyle { - MITER(BufferParameters.JOIN_MITRE), BEVEL(BufferParameters.JOIN_BEVEL), ROUND(BufferParameters.JOIN_ROUND); + MITER(BufferParameters.JOIN_MITRE), // + BEVEL(BufferParameters.JOIN_BEVEL), // + ROUND(BufferParameters.JOIN_ROUND), // + ; final int style; @@ -822,8 +925,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci if (g.getGeometryType().equals(Geometry.TYPENAME_LINESTRING)) { List strings = new ArrayList<>(curves); for (int i = 0; i < curves; i++) { - strings.add( - OffsetCurve.getCurve(g, spacing * (outwards ? 1 : -1) * i, 8, style.style, BufferParameters.DEFAULT_MITRE_LIMIT)); + strings.add(OffsetCurve.getCurve(g, spacing * (outwards ? 1 : -1) * i, 8, style.style, BufferParameters.DEFAULT_MITRE_LIMIT)); } return toPShape(strings); } @@ -832,8 +934,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci g = DouglasPeuckerSimplifier.simplify(g, 0.25); } - final BufferParameters bufParams = new BufferParameters(8, BufferParameters.CAP_FLAT, style.style, - BufferParameters.DEFAULT_MITRE_LIMIT); + final BufferParameters bufParams = new BufferParameters(8, BufferParameters.CAP_FLAT, style.style, BufferParameters.DEFAULT_MITRE_LIMIT); // bufParams.setSimplifyFactor(5); // can produce "poor" yet interesting results spacing = Math.max(1, Math.abs(spacing)); // ensure positive and >=1 @@ -841,8 +942,7 @@ private static PShape offsetCurves(PShape shape, OffsetStyle style, double spaci final PShape parent = new PShape(PConstants.GROUP); int currentCurves = 0; - while ((outwards && currentCurves < curves) || (!outwards && !g.isEmpty() && curves == 0) - || (!outwards && currentCurves < curves)) { + while ((outwards && currentCurves < curves) || (!outwards && !g.isEmpty() && curves == 0) || (!outwards && currentCurves < curves)) { LinearRing[] rings = new LinearRingIterator(g).getLinearRings(); if (rings.length == 1) { PShape curve = toPShape(rings[0]); @@ -899,7 +999,7 @@ private static double[] generateDoubleSequence(double start, double end, double } /** - * Generates a grid of points + * Generates a grid of points. * * @param minX * @param minY @@ -922,6 +1022,12 @@ private static ArrayList generateGrid(double minX, double minY, double return grid; } + private static float voidDistance(double geomDist, PVector p, PVector x) { + float edgeDist = (float) geomDist; + float pointDist = p.dist(x); + return Math.abs(edgeDist - pointDist); // Highlight where distances intersect + } + private static void reverse(T[] a) { // used in straightSkeleton() int l = a.length; @@ -932,6 +1038,17 @@ private static void reverse(T[] a) { } } + private static LineString contourToLineString(Contour contour) { + // contours are x1,y1,x2,y2, etc. + Coordinate[] coords = new Coordinate[contour.getCoordinates().length / 2]; + for (int i = 0; i < contour.getCoordinates().length; i += 2) { + double vx = contour.getCoordinates()[i]; + double vy = contour.getCoordinates()[i + 1]; + coords[i / 2] = new Coordinate(vx, vy); + } + return GEOM_FACTORY.createLineString(coords); + } + private static Loop ringToLoop(LinearRing ring, boolean hole, Set edgeCoordsSet, Machine speed) { Coordinate[] coords = ring.getCoordinates(); if (!hole && !Orientation.isCCW(coords)) { @@ -958,19 +1075,4 @@ private static Loop ringToLoop(LinearRing ring, boolean hole, Set ringToVec(LinearRing ring, Set edgeCoordsSet) { - final List points = new ArrayList<>(); - Coordinate[] coords = ring.getCoordinates(); - /* - * Kendzi polygons are unclosed (cannot start and end with the same point), - * unlike a LinearRing. - */ - for (int i = 0; i < coords.length - 1; i++) { // note - 1 - final Vector2dc p = new Vector2d(coords[i].x, coords[i].y); - points.add(p); - edgeCoordsSet.add(p); - } - return points; - } - } diff --git a/src/main/java/micycle/pgs/PGS_Conversion.java b/src/main/java/micycle/pgs/PGS_Conversion.java index 51ba0103..a0c4d055 100644 --- a/src/main/java/micycle/pgs/PGS_Conversion.java +++ b/src/main/java/micycle/pgs/PGS_Conversion.java @@ -28,6 +28,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -63,14 +64,16 @@ import org.locationtech.jts.util.GeometricShapeFactory; import org.scoutant.polyline.PolylineDecoder; +import com.github.micycle1.betterbeziers.CubicBezier; + import it.rambow.master.javautils.PolylineEncoder; import it.rambow.master.javautils.Track; import it.rambow.master.javautils.Trackpoint; import it.unimi.dsi.util.XoRoShiRo128PlusRandom; -import micycle.betterbeziers.CubicBezier; import micycle.pgs.color.Colors; import micycle.pgs.commons.Nullable; import micycle.pgs.commons.PEdge; +import net.jafama.FastMath; import processing.core.PConstants; import processing.core.PMatrix; import processing.core.PShape; @@ -900,24 +903,25 @@ public static List toPVector(PShape shape) { /** * Transforms a given PShape into a simple graph representation. In this * representation, the vertices of the graph correspond to the vertices of the - * shape, and the edges of the graph correspond to the edges of the shape. This - * transformation is specifically applicable to polygonal shapes where edges are - * formed by adjacent vertices. + * shape, and the edges of the graph correspond to the edges of the shape. *

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

- * The output is a rather abstract representation of the input graph, and not a - * geometric equivalent (unlike most other conversion methods in the class). + * The output is an abstract representation of the input graph, not a geometric + * equivalent (unlike most other conversion methods in this class). The layout + * is bounded by the specified dimensions and anchored at (0, 0). * - * @param any vertex type - * @param any edge type - * @param graph the graph whose edges and vertices to lay out - * @param normalizationFactor normalization factor for the optimal distance, - * between 0 and 1. - * @param boundsX horizontal vertex bounds - * @param boundsY vertical vertex bounds - * @return a GROUP PShape consisting of 2 children; child 0 is the linework - * (LINES) depicting edges and child 1 is the points (POINTS) depicting - * vertices. The bounds of the layout are anchored at (0, 0); + * @param the type of vertices in the graph + * @param the type of edges in the graph + * @param graph the graph whose vertices and edges are to be laid + * out + * @param normalizationFactor the normalization factor for the optimal distance + * between vertices, clamped between 0.001 and 1 + * @param boundsX the horizontal bounds for the layout + * @param boundsY the vertical bounds for the layout + * @return a GROUP PShape containing two children: child 0 represents the edges + * as linework (LINES), and child 1 represents the vertices as points + * (POINTS) * @since 1.3.0 */ public static PShape fromGraph(SimpleGraph graph, double normalizationFactor, double boundsX, double boundsY) { @@ -1059,32 +1067,43 @@ public static SimpleGraph toCentroidDualGraph(PShape mesh) { */ static SimpleGraph toDualGraph(Collection meshFaces) { final SimpleGraph graph = new SimpleGraph<>(DefaultEdge.class); - // map of which edge belong to each face; used to detect half-edges - final HashMap edgesMap = new HashMap<>(meshFaces.size() * 4); + final Map> edgeFacesMap = new HashMap<>(); + // Phase 1: Collect edges and their associated faces for (PShape face : meshFaces) { - graph.addVertex(face); // always add child so disconnected shapes are colored + graph.addVertex(face); for (int i = 0; i < face.getVertexCount(); i++) { - final PVector a = face.getVertex(i); - final PVector b = face.getVertex((i + 1) % face.getVertexCount()); + PVector a = face.getVertex(i); + PVector b = face.getVertex((i + 1) % face.getVertexCount()); if (a.equals(b)) { continue; } - final PEdge e = new PEdge(a, b); - final PShape neighbour = edgesMap.get(e); - if (neighbour != null) { - // edge seen before, so faces must be adjacent; create edge between faces - if (neighbour.equals(face)) { // probably bad input (3 edges the same) - System.err.println("toDualGraph(): Bad input — saw the same edge 3 times."); - continue; // continue to prevent self-loop in graph - } - graph.addEdge(neighbour, face); - } else { - edgesMap.put(e, face); // edge is new - } + PEdge edge = new PEdge(a, b); + edgeFacesMap.computeIfAbsent(edge, k -> new ArrayList<>()).add(face); } } + + // Phase 2: Process edges in sorted order for graph iteration consistency + edgeFacesMap.entrySet().stream().sorted(Comparator.comparing(e -> e.getKey())) // Sort edges to ensure deterministic processing + .forEach(entry -> { + List faces = entry.getValue(); + if (faces.size() == 2) { + // If exactly two faces share this edge, connect them in the dual graph + PShape f1 = faces.get(0); + PShape f2 = faces.get(1); + if (!f1.equals(f2)) { + graph.addEdge(f1, f2); // Avoid self-loops + } else { + // Handle case where the same face is associated with the edge twice + System.err.println("toDualGraph(): Bad input — saw the same edge 3+ times for face: " + f1); + } + } else if (faces.size() > 2) { + // Handle edges shared by more than two faces + System.err.println("toDualGraph(): Bad input — edge shared by more than two faces: " + entry.getKey().toString()); + } + }); + return graph; } @@ -1227,7 +1246,7 @@ public static PShape fromHexWKB(String shapeWKB) { * Google Encoded Polyline format. * * @param shape single (holeless) polygon or line - * @return + * @return String with the encoded polyline representing the shape * @since 1.3.0 */ public static String toEncodedPolyline(PShape shape) { @@ -1388,7 +1407,6 @@ public static PShape fromPVector(PVector... vertices) { * * @param shell vertices of the shell of the polygon * @param holes (optional) list of holes - * @return * @since 1.4.0 */ public static PShape fromContours(List shell, @Nullable List> holes) { @@ -1449,10 +1467,21 @@ public static PShape fromContours(List shell, @Nullable List points = toPVector(shape); // CLOSE + List points = toPVector(shape); // unclosed if (shape.isClosed() && keepClosed) { points.add(points.get(0).copy()); // since toPVector returns unclosed view } + return toArray(points); + } + + /** + * Converts a list of PVectors into an array of coordinates. + * + * @param points + * @return coordinate array in the form [[x1, y1], [x2, y2]] + * @since 2.1 + */ + public static double[][] toArray(List points) { double[][] out = new double[points.size()][2]; for (int i = 0; i < points.size(); i++) { PVector point = points.get(i); @@ -1486,14 +1515,18 @@ public static PShape fromArray(double[][] shape, boolean close) { /** * Flattens a collection of PShapes into a single GROUP PShape which has the - * input shapes as its children. + * input shapes as its children. If the collection contains only one shape, it + * is returned directly as a non-GROUP shape. * * @since 1.2.0 * @see #flatten(PShape...) */ public static PShape flatten(Collection shapes) { PShape group = new PShape(GROUP); - shapes.forEach(group::addChild); + shapes.stream().filter(Objects::nonNull).forEach(group::addChild); + if (group.getChildCount() == 1) { + return group.getChild(0); + } return group; } @@ -1536,11 +1569,11 @@ public static List getChildren(PShape shape) { while (!parents.isEmpty()) { final PShape parent = parents.pop(); // will always be a GROUP PShape if (parent.getChildCount() > 0) { // avoid NPE on .getChildren() - for (PShape child : parent.getChildren()) { - if (child.getFamily() == GROUP) { - parents.add(child); + for (PShape candidate : parent.getChildren()) { + if (candidate.getFamily() == GROUP) { + parents.add(candidate); } else { - children.add(child); + children.add(candidate); } } } @@ -1599,6 +1632,7 @@ public static PShape setAllFillColor(PShape shape, int color) { * * @param shape * @return the input object (having now been mutated) + * @see #setAllStrokeColor(PShape, int, double, int) * @see #setAllFillColor(PShape, int) */ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight) { @@ -1610,6 +1644,27 @@ public static PShape setAllStrokeColor(PShape shape, int color, double strokeWei return shape; } + /** + * Sets the stroke color and cap style for the PShape and all of its children + * recursively. + * + * @param strokeCap either SQUARE, PROJECT, or + * ROUND + * @return the input object (having now been mutated) + * @see #setAllStrokeColor(PShape, int, double) + * @see #setAllFillColor(PShape, int) + * @since 2.1 + */ + public static PShape setAllStrokeColor(PShape shape, int color, double strokeWeight, int strokeCap) { + getChildren(shape).forEach(child -> { + child.setStroke(true); + child.setStroke(color); + child.setStrokeWeight((float) strokeWeight); + child.setStrokeCap(strokeCap); + }); + return shape; + } + /** * Sets the stroke color equal to the fill color for the PShape and all of its * descendent shapes individually (that is, each child shape belonging to the @@ -1707,21 +1762,40 @@ public static PShape disableAllStroke(PShape shape) { /** * Rounds the x and y coordinates (to the closest int) of all vertices belonging - * to the shape, mutating the shape. This can sometimes fix a visual - * problem in Processing where narrow gaps can appear between otherwise flush - * shapes. + * to the shape. This can sometimes fix a visual problem in Processing where + * narrow gaps can appear between otherwise flush shapes. If the shape is a + * GROUP, the rounding is applied to all child shapes. * - * @return the input object (having now been mutated) + * @param shape the PShape to round vertex coordinates for. + * @return a rounded copy of the input shape. + * @see #roundVertexCoords(PShape, int) * @since 1.1.3 */ public static PShape roundVertexCoords(PShape shape) { - getChildren(shape).forEach(c -> { + return roundVertexCoords(shape, 0); + } + + /** + * Rounds the x and y coordinates (to n decimal places) of all + * vertices belonging to the shape. This can sometimes fix a visual problem in + * Processing where narrow gaps can appear between otherwise flush shapes. If + * the shape is a GROUP, the rounding is applied to all child shapes. + * + * @param shape the PShape to round vertex coordinates for. + * @param n The number of decimal places to which the coordinates should be + * rounded. + * @return a rounded copy of the input shape. + * @since 2.1 + */ + public static PShape roundVertexCoords(PShape shape, int n) { + return PGS_Processing.transform(shape, s -> { + var c = copy(s); for (int i = 0; i < c.getVertexCount(); i++) { final PVector v = c.getVertex(i); - c.setVertex(i, Math.round(v.x), Math.round(v.y)); + c.setVertex(i, round(v.x, n), round(v.y, n)); } + return c; }); - return shape; } /** @@ -1735,6 +1809,7 @@ public static PShape roundVertexCoords(PShape shape) { public static PShape copy(PShape shape) { final PShape copy = new PShape(); copy.setName(shape.getName()); + final PShapeData style = new PShapeData(shape); try { Method method; @@ -1769,17 +1844,18 @@ public static PShape copy(PShape shape) { e.printStackTrace(); } - return copy; + return style.applyTo(copy); } /** * Creates a PATH PShape representing a quadratic bezier curve, given by its * parameters. - * - * @param start - * @param controlPoint - * @param end - * @return + * + * @param start starting point of the bezier curve + * @param controlPoint control point of the curve + * @param end end point of the bezier curve + * @return a PShape representing the quadratic bezier curve as a PATH, sampled + * every 2 units along its length * @since 1.4.0 */ public static PShape fromQuadraticBezier(PVector start, PVector controlPoint, PVector end) { @@ -1792,12 +1868,13 @@ public static PShape fromQuadraticBezier(PVector start, PVector controlPoint, PV /** * Creates a PATH PShape representing a cubic bezier curve, given by its * parameters. - * - * @param start - * @param controlPoint1 - * @param controlPoint2 - * @param end - * @return + * + * @param start starting point of the bezier curve + * @param controlPoint1 first control point of the curve + * @param controlPoint2 second control point of the curve + * @param end end point of the bezier curve + * @return a PShape representing the cubic bezier curve as a PATH, sampled every + * 2 units along its length * @since 1.4.0 */ public static PShape fromCubicBezier(PVector start, PVector controlPoint1, PVector controlPoint2, PVector end) { @@ -1943,6 +2020,12 @@ private static T[] reversedCopy(T[] original) { return reversed; } + private static float round(float x, float n) { + float m = (float) FastMath.pow(10, n); + + return FastMath.floor(m * x + 0.5f) / m; + } + /** * A utility class for storing and manipulating the visual properties of PShapes * from the Processing library. It encapsulates the stroke, fill, stroke color, @@ -1973,8 +2056,10 @@ public static class PShapeData { public int fillColor, strokeColor; public float strokeWeight; public boolean fill, stroke; + private final PShape source; PShapeData(PShape shape) { + source = shape; try { fillColor = fillColorF.getInt(shape); fill = fillF.getBoolean(shape); @@ -1990,9 +2075,16 @@ public static class PShapeData { * Apply this shapedata to a given PShape. * * @param other + * @return other (fluent interface) */ - public void applyTo(PShape other) { + public PShape applyTo(PShape other) { + if (source.getFamily() == GROUP && other.getFamily() != GROUP) { + // opinionated -- don't apply group styling to non-group + return other; + } + if (other.getFamily() == GROUP) { + // ONLY IF child fill/stroke aren't defaults!? getChildren(other).forEach(c -> applyTo(c)); } other.setFill(fill); @@ -2000,6 +2092,8 @@ public void applyTo(PShape other) { other.setStroke(stroke); other.setStroke(strokeColor); other.setStrokeWeight(strokeWeight); + + return other; } @Override diff --git a/src/main/java/micycle/pgs/PGS_Hull.java b/src/main/java/micycle/pgs/PGS_Hull.java index e57a6abd..61692d30 100644 --- a/src/main/java/micycle/pgs/PGS_Hull.java +++ b/src/main/java/micycle/pgs/PGS_Hull.java @@ -1,6 +1,7 @@ package micycle.pgs; import static micycle.pgs.PGS_Conversion.toPShape; +import static micycle.pgs.PGS_Conversion.fromPShape; import java.util.ArrayList; import java.util.Collection; @@ -20,7 +21,8 @@ * Generates various types of geomtric hulls (convex, concave, etc.) for * polygons and point sets. *

- * A hull is the smallest enclosing shape that contains all points in a set. + * A hull is the smallest enclosing shape of some nature that contains all + * points in a set. * * @author Michael Carleton * @since 1.3.0 @@ -30,6 +32,51 @@ public class PGS_Hull { private PGS_Hull() { } + /** + * Calculates the bounding box (envelope) for the given {@link PShape} and + * returns it as a new {@link PShape}. + *

+ * The returned shape represents the axis-aligned bounding rectangle that + * contains the input shape. + *

+ * + * @param shape the input shape for which to compute the bounding box + * @return a {@link PShape} representing the bounding box of the input shape + * @since 2.1 + */ + public static PShape boundingBox(PShape shape) { + return boundingBox(shape, new double[4]); + } + + /** + * Computes the bounding box (envelope) of the given {@link PShape} and writes + * its coordinates to the provided array. + *

+ * The coordinates are written as: {minX, minY, maxX, maxY}. + *

+ * + * @param shape the shape for which to calculate the bounding box + * @param out an array (length ≥ 4) that will hold the bounding box + * coordinates: + *
    + *
  • out[0] = minX
  • + *
  • out[1] = minY
  • + *
  • out[2] = maxX
  • + *
  • out[3] = maxY
  • + *
+ * @return a {@link PShape} representing the bounding box of the input shape + * @since 2.1 + */ + public static PShape boundingBox(PShape shape, double[] out) { + var g = fromPShape(shape); + var e = g.getEnvelopeInternal(); + out[0] = e.getMinX(); + out[1] = e.getMinY(); + out[2] = e.getMaxX(); + out[3] = e.getMaxY(); + return toPShape(g.getEnvelope()); + } + /** * Computes the convex hull of a point set (the smallest convex polygon that * contains all the points). @@ -68,7 +115,7 @@ public static PShape convexHull(PShape shape) { * @since 1.3.0 */ public static PShape concaveHull(PShape shapeSet, double concavity, boolean tight) { - Geometry g = PGS_Conversion.fromPShape(shapeSet); + Geometry g = fromPShape(shapeSet); if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON) || g.getGeometryType().equals(Geometry.TYPENAME_GEOMETRYCOLLECTION)) { g = g.union(); } @@ -190,7 +237,7 @@ public static PShape concaveHullBFS2(List points, double threshold) { */ public static PShape snapHull(PShape shape, double convexity) { convexity = Math.max(Math.min(convexity, 1), 0); // constrain 0...1 - ConcaveHullOfPolygons hull = new ConcaveHullOfPolygons(PGS_Conversion.fromPShape(shape)); // union in case multipolygon + ConcaveHullOfPolygons hull = new ConcaveHullOfPolygons(fromPShape(shape)); // union in case multipolygon hull.setMaximumEdgeLengthRatio(convexity); return toPShape(hull.getHull()); } diff --git a/src/main/java/micycle/pgs/PGS_Meshing.java b/src/main/java/micycle/pgs/PGS_Meshing.java index a4d992a1..2297e9a4 100644 --- a/src/main/java/micycle/pgs/PGS_Meshing.java +++ b/src/main/java/micycle/pgs/PGS_Meshing.java @@ -13,10 +13,14 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; - +import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.math3.random.RandomGenerator; import org.jgrapht.alg.connectivity.ConnectivityInspector; +import org.jgrapht.alg.interfaces.MatchingAlgorithm; import org.jgrapht.alg.interfaces.VertexColoringAlgorithm.Coloring; +import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedMatching; +import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching; +import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense; import org.jgrapht.alg.spanning.GreedyMultiplicativeSpanner; import org.jgrapht.alg.util.NeighborCache; import org.jgrapht.graph.AbstractBaseGraph; @@ -118,7 +122,7 @@ public static PShape urquhartFaces(final IIncrementalTin triangulation, final bo edges.add(t.getEdgeB().getBaseReference()); edges.add(t.getEdgeC().getBaseReference()); final IQuadEdge longestEdge = findLongestEdge(t).getBaseReference(); - if (!preservePerimeter || (preservePerimeter && !longestEdge.isConstrainedRegionBorder())) { + if (!preservePerimeter || (preservePerimeter && !longestEdge.isConstraintRegionBorder())) { uniqueLongestEdges.add(longestEdge); } } @@ -129,7 +133,7 @@ public static PShape urquhartFaces(final IIncrementalTin triangulation, final bo final Collection meshEdges = new ArrayList<>(edges.size()); edges.forEach(edge -> meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y))); - PShape mesh = PGS.polygonizeEdges(meshEdges); + PShape mesh = PGS.polygonizeNodedEdges(meshEdges); return removeHoles(mesh, triangulation); } @@ -182,7 +186,7 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo final double[] midpoint = midpoint(edge); final Vertex near = tree.query1nn(midpoint).value(); if (near != edge.getA() && near != edge.getB()) { - if (!preservePerimeter || (preservePerimeter && !edge.isConstrainedRegionBorder())) { // don't remove constraint borders (holes) + if (!preservePerimeter || (preservePerimeter && !edge.isConstraintRegionBorder())) { // don't remove constraint borders (holes) nonGabrielEdges.add(edge); // base reference } } @@ -192,7 +196,7 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo final Collection meshEdges = new ArrayList<>(edges.size()); edges.forEach(edge -> meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y))); - PShape mesh = PGS.polygonizeEdges(meshEdges); + PShape mesh = PGS.polygonizeNodedEdges(meshEdges); return removeHoles(mesh, triangulation); } @@ -210,7 +214,6 @@ public static PShape gabrielFaces(final IIncrementalTin triangulation, final boo * @param preservePerimeter whether to retain/preserve edges on the perimeter * even if they should be removed according to the * relative neighbor condition - * @return * @since 1.3.0 */ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation, final boolean preservePerimeter) { @@ -227,14 +230,14 @@ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation, double l = e.getLength(); cache.neighborsOf(e.getA()).forEach(n -> { if (Math.max(n.getDistance(e.getA()), n.getDistance(e.getB())) < l) { - if (!preservePerimeter || (preservePerimeter && !e.isConstrainedRegionBorder())) { + if (!preservePerimeter || (preservePerimeter && !e.isConstraintRegionBorder())) { edges.remove(e); } } }); cache.neighborsOf(e.getB()).forEach(n -> { if (Math.max(n.getDistance(e.getA()), n.getDistance(e.getB())) < l) { - if (!preservePerimeter || (preservePerimeter && !e.isConstrainedRegionBorder())) { + if (!preservePerimeter || (preservePerimeter && !e.isConstraintRegionBorder())) { edges.remove(e); } } @@ -243,7 +246,7 @@ public static PShape relativeNeighborFaces(final IIncrementalTin triangulation, List edgesOut = edges.stream().map(PGS_Triangulation::toPEdge).collect(Collectors.toList()); - PShape mesh = PGS.polygonizeEdges(edgesOut); + PShape mesh = PGS.polygonizeNodedEdges(edgesOut); return removeHoles(mesh, triangulation); } @@ -274,12 +277,12 @@ public static PShape spannerFaces(final IIncrementalTin triangulation, int k, fi if (triangulation.getConstraints().isEmpty()) { // does not have constraints spannerEdges.addAll(triangulation.getPerimeter().stream().map(PGS_Triangulation::toPEdge).collect(Collectors.toList())); } else { // has constraints - spannerEdges.addAll(triangulation.getEdges().stream().filter(IQuadEdge::isConstrainedRegionBorder).map(PGS_Triangulation::toPEdge) + spannerEdges.addAll(triangulation.getEdges().stream().filter(IQuadEdge::isConstraintRegionBorder).map(PGS_Triangulation::toPEdge) .collect(Collectors.toList())); } } - PShape mesh = PGS.polygonizeEdges(spannerEdges); + PShape mesh = PGS.polygonizeNodedEdges(spannerEdges); return removeHoles(mesh, triangulation); } @@ -366,7 +369,9 @@ public static PShape splitQuadrangulation(final IIncrementalTin triangulation) { /*- * Now ideally "regularize" the mesh using techniques explored here: + * Towards Fully Regular Quad Mesh Generation - * https://acdl.mit.edu/ESP/Publications/AIAApaper2019-1988.pdf + * A REGULARIZATION APPROACH FOR AUTOMATIC QUAD MESH GENERATION - * https://acdl.mit.edu/ESP/Publications/IMR28.pdf */ @@ -391,6 +396,8 @@ public static PShape splitQuadrangulation(final IIncrementalTin triangulation) { * triangles being included in the output). * @return a GROUP PShape, where each child shape is one quadrangle * @since 1.2.0 + * @see #matchingQuadrangulation(IIncrementalTin) matchingQuadrangulation() -- a + * similar approach, but faster */ public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangulation, final boolean preservePerimeter) { /*- @@ -433,12 +440,12 @@ public static PShape edgeCollapseQuadrangulation(final IIncrementalTin triangula * triangles". -- ideal, but not implemented here... */ // NOTE could now apply Topological optimization, as given in paper. - if ((color < 2) || (preservePerimeter && (edge.isConstrainedRegionBorder() || perimeter.contains(edge)))) { + if ((color < 2) || (preservePerimeter && (edge.isConstraintRegionBorder() || perimeter.contains(edge)))) { meshEdges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y)); } }); - PShape quads = PGS.polygonizeEdges(meshEdges); + PShape quads = PGS.polygonizeNodedEdges(meshEdges); if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole) return quads; } else { @@ -480,13 +487,13 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation if (preservePerimeter) { List perimeter = triangulation.getPerimeter(); triangulation.edges().forEach(edge -> { - if (edge.isConstrainedRegionBorder() || (unconstrained && perimeter.contains(edge))) { + if (edge.isConstraintRegionBorder() || (unconstrained && perimeter.contains(edge))) { edges.add(new PEdge(edge.getA().x, edge.getA().y, edge.getB().x, edge.getB().y)); } }); } - final PShape quads = PGS.polygonizeEdges(edges); + final PShape quads = PGS.polygonizeNodedEdges(edges); if (triangulation.getConstraints().size() < 2) { // assume constraint 1 is the boundary (not a hole) return quads; } else { @@ -494,6 +501,69 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation } } + /** + * Converts a triangulation into a quadrangulation, by pairing up ("matching") + * triangles and merging them into quads. (This is the first step of the + * Blossom-Quad algorithm.) + *

+ * The method tries to maximise the quality of the quads of the output, meaning + * it aims to create quadrilaterals that are as regular and well-shaped + * (square-like) as possible. + *

+ * Sometimes, it’s not possible to pair all triangles perfectly (such as when + * there is not an even number of triangles). In those cases, the result will + * include some leftover triangles along with the quads. + *

+ * This method follows a similar principle to + * {@link #edgeCollapseQuadrangulation(IIncrementalTin, boolean) + * edgeCollapseQuadrangulation()}, but instead of using graph coloring to + * identify triangle pairs, it uses the Kolmogorov algorithm to find pairings. + * + * @param triangulation The input mesh made of triangles. This is the starting + * point for creating the quadrangulation. + * @return A GROUP PShape made of quadrilaterals (and possibly some triangles if + * pairing wasn’t perfect). + * + * @since 2.1 + */ + public static PShape matchingQuadrangulation(final IIncrementalTin triangulation) { + var g = PGS_Triangulation.toDualGraph(triangulation); + MatchingAlgorithm m; + + /* + * A perfect matching is not always possible, so fall back to regular matching. + * When this happens not all edges can be collapsed, so the output will contain + * some triangles alongside the quads. + */ + try { + m = new KolmogorovWeightedPerfectMatching<>(g, ObjectiveSense.MAXIMIZE); + m.getMatching(); + } catch (Exception e2) { + m = new KolmogorovWeightedMatching<>(g, ObjectiveSense.MAXIMIZE); + } + var collapsedEdges = m.getMatching().getEdges(); + + Set seen = new HashSet<>(g.vertexSet()); + var quads = collapsedEdges.stream().map(e -> { + var t1 = g.getEdgeSource(e); + var f1 = toPShape(t1); + var t2 = g.getEdgeTarget(e); + var f2 = toPShape(t2); + + seen.remove(t1); + seen.remove(t2); + var quad = PGS_ShapeBoolean.union(f1, f2); + return quad; + }).collect(Collectors.toList()); // modifiable list + + // include uncollapsed triangles (if any) + seen.forEach(t -> { + quads.add(toPShape(t)); + }); + + return PGS_Conversion.flatten(quads); + } + /** * Removes (what should be) holes from a polygonized quadrangulation. *

@@ -511,6 +581,9 @@ public static PShape centroidQuadrangulation(final IIncrementalTin triangulation */ private static PShape removeHoles(PShape faces, IIncrementalTin triangulation) { List holes = new ArrayList<>(triangulation.getConstraints()); // copy list + if (holes.size() <= 1) { + return faces; + } holes = holes.subList(1, holes.size()); // slice off perimeter constraint (not a hole) STRtree tree = new STRtree(); @@ -571,7 +644,7 @@ private static PShape removeHoles(PShape faces, IIncrementalTin triangulation) { */ public static PShape spiralQuadrangulation(List points) { SpiralQuadrangulation sq = new SpiralQuadrangulation(points); - return PGS.polygonizeEdges(sq.getQuadrangulationEdges()); + return PGS.polygonizeNodedEdges(sq.getQuadrangulationEdges()); } /** @@ -731,10 +804,12 @@ public static PShape smoothMesh(PShape mesh, double displacementCutoff, boolean displacementCutoff = Math.max(displacementCutoff, 1e-3); PMesh m = new PMesh(mesh); + int iteration = 0; + int maxIterations = 10000; double displacement; do { displacement = m.smoothTaubin(0.25, -0.251, preservePerimeter); - } while (displacement > displacementCutoff); + } while (displacement > displacementCutoff && iteration++ < maxIterations); return m.getMesh(); } @@ -773,8 +848,10 @@ public static PShape simplifyMesh(PShape mesh, double tolerance, boolean preserv *

* This subdivision method is most effective on meshes whose faces are convex * and have a low vertex count (i.e., less than 6), where edge division points - * correspond between adjacent faces. This method may fail on meshes with highly - * concave faces because centroid-vertex visibility is not guaranteed. + * correspond between adjacent faces. + *

+ * Note: This method may fail on meshes with highly concave faces because + * centroid-vertex visibility is not guaranteed. * * @param mesh The mesh containing faces to subdivide. * @param edgeSplitRatio The distance ratio [0...1] along each edge where the @@ -817,18 +894,21 @@ public static PShape subdivideMesh(PShape mesh, double edgeSplitRatio) { * edges comprising holes within faces. * * @param mesh The conforming mesh shape to extract inner edges from. - * @return A shape representing the dissolved linework of inner mesh edges. + * @return A shape representing the linework of inner mesh edges. * @since 1.4.0 */ public static PShape extractInnerEdges(PShape mesh) { List edges = PGS_SegmentSet.fromPShape(mesh); - Map bag = new HashMap<>(edges.size()); - edges.forEach(edge -> { - bag.merge(edge, 1, Integer::sum); - }); + Set seenEdges = new HashSet<>(edges.size()); + Set innerEdges = new HashSet<>(); + + for (PEdge edge : edges) { + if (!seenEdges.add(edge)) { // add() returns false if edge is already present + innerEdges.add(edge); + } + } - List innerEdges = bag.entrySet().stream().filter(e -> e.getValue() > 1).map(e -> e.getKey()).collect(Collectors.toList()); - return PGS_SegmentSet.dissolve(innerEdges); + return PGS_SegmentSet.toPShape(new ArrayList<>(innerEdges)); } /** @@ -841,13 +921,64 @@ public static PShape extractInnerEdges(PShape mesh) { * @since 2.0 */ public static List extractInnerVertices(PShape mesh) { - var allVertices = PGS_Conversion.toPVector(mesh); - var perimeterVertices = PGS_Conversion.toPVector(PGS_ShapeBoolean.unionMesh(mesh)); - var allVerticesSet = new HashSet<>(allVertices); - var perimeterVerticesSet = new HashSet<>(perimeterVertices); + List allVertices = PGS_Conversion.toPVector(mesh); + Set perimeterVertices = new HashSet<>(PGS_Conversion.toPVector(PGS_ShapeBoolean.unionMesh(mesh))); - allVerticesSet.removeAll(perimeterVerticesSet); - return new ArrayList<>(allVerticesSet); + // Create a new list of only those vertices not in the perimeter. + return allVertices.stream().filter(vertex -> !perimeterVertices.contains(vertex)).collect(Collectors.toList()); + } + + /** + * Extracts inner edges and vertices of a mesh. Faster than calling both + * extractInnerVertices() and extractInnerEdges() in tandem. + */ + static Pair, List> extractInnerEdgesAndVertices(PShape mesh) { + // 1) Get all (undirected) edges + List edges = PGS_SegmentSet.fromPShape(mesh); + int n = edges.size(); + + // 2) Classify edges: + // - 'single' holds edges seen exactly once so far + // - 'inner' holds edges seen ≥2 times + // Using a LinkedHashSet for 'inner' preserves insertion order (if you care) + Set single = new HashSet<>((int) (n / 0.75f) + 1); + Set inner = new HashSet<>((int) (n / 0.75f) + 1); + + for (PEdge e : edges) { + if (single.contains(e)) { + // second time we see it → move to inner + single.remove(e); + inner.add(e); + } else if (!inner.contains(e)) { + // first time → keep in single + single.add(e); + } + // otherwise: already in 'inner', do nothing + } + + // 3) Collect boundary vertices from the edges that remained 'single' + Set boundary = new HashSet<>(single.size() * 2); + for (PEdge e : single) { + boundary.add(e.a); + boundary.add(e.b); + } + + // 4) Finally, collect inner‐vertices from the 'inner' edges + // but only those not in 'boundary' and avoid duplicates + List innerVerts = new ArrayList<>(); + Set seenVertices = new HashSet<>(); + for (PEdge e : inner) { + PVector a = e.a, b = e.b; + if (!boundary.contains(a) && seenVertices.add(a)) { + innerVerts.add(a); + } + if (!boundary.contains(b) && seenVertices.add(b)) { + innerVerts.add(b); + } + } + + // 5) Return! + return Pair.of(new ArrayList<>(inner), innerVerts); } /** @@ -917,21 +1048,20 @@ public static PShape findContainingFace(PShape mesh, PVector position) { } /** - * Merges the small faces within a mesh into their adjacent faces recursively, - * ensuring that no faces smaller than a specified area remain. This process is - * repeated until all faces are at least as large as the minimum area defined by - * the areaThreshold parameter. - * - * @param mesh a PShape object representing the mesh to which the area - * merge operation will be applied. It must be of type - * GROUP. Meshes with holes are supported; holes will be - * preserved. - * @param areaThreshold the minimum area a face must have to avoid being merged. - * This is used as a threshold to determine which small - * faces should be merged into adjacent larger ones. - * @return PShape object representing the mesh after the merge operation, where - * all faces have an area greater than or equal to the specified - * areaThreshold. + * Merges all faces in the given mesh that are smaller than a specified area + * threshold into their larger neighbors, and repeats this process until no face + * remains below the threshold. + *

+ * Holes in the original mesh are preserved; only small faces are absorbed into + * adjacent larger faces. + * + * @param mesh a PShape of type GROUP representing the input mesh. May + * contain holes, which will be carried through. + * @param areaThreshold the minimum allowed area for any face. Any face whose + * area is strictly less than this value will be merged + * into one of its adjacent faces. + * @return a new PShape containing the merged mesh, with original styling + * applied, in which every face has area ≥ areaThreshold. * @since 1.4.0 */ public static PShape areaMerge(PShape mesh, double areaThreshold) { @@ -939,6 +1069,29 @@ public static PShape areaMerge(PShape mesh, double areaThreshold) { return applyOriginalStyling(merged, mesh); } + /** + * Merges the smallest faces (by area) in the given mesh into their + * adjacent neighbors until the mesh has no more than a specified number of + * faces. + *

+ * If the input mesh has more faces than {@code remainingFaces}, the smallest + * faces are iteratively merged into their larger neighbors until the total face + * count is ≤ {@code remainingFaces}. Holes in the mesh are preserved. + * + * @param mesh a PShape of type GROUP representing the input mesh. May + * contain holes, which will be carried through. + * @param remainingFaces the target maximum number of faces. The algorithm will + * merge the smallest faces until the mesh has at most + * this many faces. + * @return a new PShape containing the merged mesh, with original styling + * applied, in which the total number of faces is ≤ remainingFaces. + * @since 2.1 + */ + public static PShape areaMerge(PShape mesh, int remainingFaces) { + PShape merged = AreaMerge.areaMerge(mesh, remainingFaces); + return applyOriginalStyling(merged, mesh); + } + /** * Splits each edge of a given mesh shape into a specified number of * equal-length parts and creates a new shape from the resulting smaller edges. @@ -968,7 +1121,7 @@ public static PShape splitEdges(PShape split, int parts) { } } - return PGS.polygonizeEdges(splitEdges); + return PGS.polygonizeNodedEdges(splitEdges); } /** @@ -1018,4 +1171,11 @@ private static Vertex centroid(final SimpleTriangle t) { return new Vertex(x, y, 0); } + private static PShape toPShape(SimpleTriangle t) { + PVector vertexA = new PVector((float) t.getVertexA().x, (float) t.getVertexA().y); + PVector vertexB = new PVector((float) t.getVertexB().x, (float) t.getVertexB().y); + PVector vertexC = new PVector((float) t.getVertexC().x, (float) t.getVertexC().y); + return PGS_Conversion.fromPVector(Arrays.asList(vertexA, vertexB, vertexC, vertexA)); + } + } diff --git a/src/main/java/micycle/pgs/PGS_Morphology.java b/src/main/java/micycle/pgs/PGS_Morphology.java index d933360a..783e2664 100644 --- a/src/main/java/micycle/pgs/PGS_Morphology.java +++ b/src/main/java/micycle/pgs/PGS_Morphology.java @@ -2,13 +2,9 @@ import static micycle.pgs.PGS_Conversion.fromPShape; import static micycle.pgs.PGS_Conversion.toPShape; -import static processing.core.PConstants.GROUP; - import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.function.BiFunction; - import org.locationtech.jts.densify.Densifier; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateList; @@ -31,16 +27,15 @@ import org.locationtech.jts.simplify.TopologyPreservingSimplifier; import org.locationtech.jts.simplify.VWSimplifier; -import micycle.hobbycurves.HobbyCurve; -import micycle.pgs.PGS.LinearRingIterator; import micycle.pgs.PGS_Contour.OffsetStyle; -import micycle.pgs.color.Colors; import micycle.pgs.commons.ChaikinCut; import micycle.pgs.commons.CornerRounding; +import micycle.pgs.commons.CornerRounding.RoundingStyle; import micycle.pgs.commons.DiscreteCurveEvolution; import micycle.pgs.commons.DiscreteCurveEvolution.DCETerminationCallback; import micycle.pgs.commons.EllipticFourierDesc; import micycle.pgs.commons.GaussianLineSmoothing; +import micycle.pgs.commons.LaneRiesenfeldSmoothing; import micycle.pgs.commons.ShapeInterpolation; import micycle.uniformnoise.UniformNoise; import processing.core.PConstants; @@ -64,35 +59,69 @@ private PGS_Morphology() { } /** - * Computes a rounded buffer area around the shape, having the given buffer - * width. - * - * @param shape - * @param buffer extent/width of the buffer (which may be positive or negative) - * @return a polygonal shape representing the buffer region (which may be empty) + * Returns a rounded buffer region of the given shape at the specified distance. + *

+ * The distance is in the same coordinate units as the shape: positive values + * expand the shape, negative values contract it. The returned shape is a + * polygonal PShape and may be empty. The input shape is not modified. + * + * @param shape the source shape to buffer + * @param buffer distance (extent/width) of the buffer; may be positive or + * negative + * @return a polygonal PShape representing the buffer region (may be empty) * @see #buffer(PShape, double, OffsetStyle) */ public static PShape buffer(PShape shape, double buffer) { - final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer)); - return toPShape(fromPShape(shape).buffer(buffer, segments)); + return buffer(shape, buffer, OffsetStyle.ROUND); } /** - * Computes a buffer area around the shape, having the given buffer width and - * buffer style (either round, miter, bevel). - * - * @param shape - * @param buffer extent/width of the buffer (which may be positive or negative) - * @return a polygonal shape representing the buffer region (which may be empty) + * Returns a buffer region of the given shape using the specified join style. + *

+ * The distance is in the same coordinate units as the shape: positive values + * expand the shape, negative values contract it. The bufferStyle controls how + * corners are joined (e.g. ROUND, MITER, BEVEL). The returned shape is a + * polygonal PShape and may be empty. The input shape is not modified. + * + * @param shape the source shape to buffer + * @param buffer distance (extent/width) of the buffer; may be positive or + * negative + * @param bufferStyle how to join offset segments (ROUND, MITER, BEVEL) + * @return a polygonal PShape representing the buffer region (may be empty) * @see #buffer(PShape, double) * @since 1.3.0 */ public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle) { + return buffer(shape, buffer, bufferStyle, CapStyle.ROUND); + } + + /** + * Returns a buffer region of the given shape using the specified join and cap + * styles. + *

+ * The distance is in the same coordinate units as the shape: positive values + * expand the shape, negative values contract it. bufferStyle controls how + * corners are joined; capStyle controls the end-cap style used for open + * geometries. The input shape is not modified; the returned PShape preserves + * the user data from the original geometry. The result is a polygonal PShape + * and may be empty. + * + * @param shape the source shape to buffer + * @param buffer distance (extent/width) of the buffer; may be positive or + * negative + * @param bufferStyle how to join offset segments (ROUND, MITER, BEVEL) + * @param capStyle how to draw end caps for open geometries (e.g. ROUND, + * FLAT) + * @return a polygonal PShape representing the buffer region (may be empty) + * @since 2.1 + */ + public static PShape buffer(PShape shape, double buffer, OffsetStyle bufferStyle, CapStyle capStyle) { Geometry g = fromPShape(shape); - final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer)); - BufferParameters bufParams = new BufferParameters(segments, BufferParameters.CAP_FLAT, bufferStyle.style, BufferParameters.DEFAULT_MITRE_LIMIT); + BufferParameters bufParams = createBufferParams(buffer, 0.5, bufferStyle, capStyle); BufferOp b = new BufferOp(g, bufParams); - return toPShape(b.getResultGeometry(buffer)); + var out = b.getResultGeometry(buffer); + out.setUserData(g.getUserData()); + return toPShape(out); } /** @@ -182,8 +211,10 @@ public static PShape erosionDilation(PShape shape, double buffer) { buffer = Math.abs(buffer); final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer)); - Geometry g = BufferOp.bufferOp(fromPShape(shape), -buffer, segments); + var in = fromPShape(shape); + Geometry g = BufferOp.bufferOp(in, -buffer, segments); g = BufferOp.bufferOp(g, +buffer, segments); + g.setUserData(in.getUserData()); try { return toPShape(g); @@ -201,7 +232,6 @@ public static PShape erosionDilation(PShape shape, double buffer) { * * @param shape polygonal shape * @param buffer a positive number - * @return * @since 1.3.0 * @see #erosionDilation(PShape, double) */ @@ -209,8 +239,11 @@ public static PShape dilationErosion(PShape shape, double buffer) { buffer = Math.abs(buffer); final int segments = (int) Math.ceil(BufferParameters.DEFAULT_QUADRANT_SEGMENTS + Math.sqrt(buffer)); - Geometry g = BufferOp.bufferOp(fromPShape(shape), buffer, segments); + var in = fromPShape(shape); + Geometry g = BufferOp.bufferOp(in, buffer, segments); g = BufferOp.bufferOp(g, -buffer, segments); + g.setUserData(in.getUserData()); + try { return toPShape(g); } catch (Exception e) { @@ -270,12 +303,12 @@ public static PShape simplifyTopology(PShape shape, double distanceTolerance) { * Simplifies a shape via Discrete Curve Evolution. *

* This algorithm simplifies a shape by iteratively removing kinks from the - * shape, starting with those having the least shape-relevance. + * shape, starting with those having the least shape-relevance. *

* The simplification process terminates according to a user-specified * {@link DCETerminationCallback#shouldTerminate(Coordinate, double, int) * callback} that decides whether the DCE algorithm should terminate based on - * the current kink (having a candidate vertex), using its coordinates, + * the current kink (having a candidate vertex), using its: coordinate, * relevance score, and the number of vertices remaining in the simplified * geometry. Implementations can use this method to provide custom termination * logic which may depend on various factors, such as a threshold relevance @@ -296,39 +329,23 @@ public static PShape simplifyTopology(PShape shape, double distanceTolerance) { * @since 2.0 */ public static PShape simplifyDCE(PShape shape, DCETerminationCallback terminationCallback) { - Geometry g = fromPShape(shape); - switch (g.getGeometryType()) { - case Geometry.TYPENAME_GEOMETRYCOLLECTION : - case Geometry.TYPENAME_MULTIPOLYGON : - case Geometry.TYPENAME_MULTILINESTRING : - PShape group = new PShape(GROUP); - for (int i = 0; i < g.getNumGeometries(); i++) { - group.addChild(simplifyDCE(toPShape(g.getGeometryN(i)), terminationCallback)); - } - return group; - case Geometry.TYPENAME_LINEARRING : - case Geometry.TYPENAME_POLYGON : - // process each ring individually - LinearRing[] rings = new LinearRingIterator(g).getLinearRings(); - LinearRing[] dceRings = new LinearRing[rings.length]; - for (int i = 0; i < rings.length; i++) { - LinearRing ring = rings[i]; - Coordinate[] dce = DiscreteCurveEvolution.process(ring, terminationCallback); - dceRings[i] = PGS.GEOM_FACTORY.createLinearRing(dce); - } - LinearRing[] holes = null; - if (dceRings.length > 1) { - holes = Arrays.copyOfRange(dceRings, 1, dceRings.length); - } - return toPShape(PGS.GEOM_FACTORY.createPolygon(dceRings[0], holes)); - case Geometry.TYPENAME_LINESTRING : - LineString l = (LineString) g; - Coordinate[] dce = DiscreteCurveEvolution.process(l, terminationCallback); - return toPShape(PGS.GEOM_FACTORY.createLineString(dce)); - default : - System.err.println(g.getGeometryType() + " are not supported for the simplifyDCE() method."); // pointal geoms - return new PShape(); // return empty (so element is invisible if not processed) - } + return PGS.applyToLinealGeometries(shape, ring -> { + var coords = DiscreteCurveEvolution.process(ring, terminationCallback); + return PGS.GEOM_FACTORY.createLineString(coords); + }); + } + + /** + * Simplify the shape using DCE, removing vertices with relevance < r. + * + * @param shape the input shape + * @param relevanceThreshold the relevance threshold; only vertices with + * relevance >= the threshold will be kept + * @return the simplified PShape + * @since 2.1 + */ + public static PShape simplifyDCE(PShape shape, final double relevanceThreshold) { + return simplifyDCE(shape, (currentVertex, relevance, verticesRemaining) -> relevance >= relevanceThreshold); } /** @@ -348,21 +365,20 @@ public static PShape simplifyDCE(PShape shape, DCETerminationCallback terminatio * @since 1.4.0 */ public static PShape simplifyHobby(PShape shape, double tension) { - tension = Math.max(tension, 0.668); // prevent degeneracy - double[][] vertices = PGS_Conversion.toArray(shape, false); - HobbyCurve curve = new HobbyCurve(vertices, tension, shape.isClosed(), 0.5, 0.5); - List points = new ArrayList<>(); - for (double[] b : curve.getBeziers()) { - int i = 0; - PVector p1 = new PVector((float) b[i++], (float) b[i++]); - PVector cp1 = new PVector((float) b[i++], (float) b[i++]); - PVector cp2 = new PVector((float) b[i++], (float) b[i++]); - PVector p2 = new PVector((float) b[i++], (float) b[i]); - PShape bezier = PGS_Conversion.fromCubicBezier(p1, cp1, cp2, p2); - points.addAll(PGS_Conversion.toPVector(bezier)); - } - - return PGS_Conversion.fromPVector(points); + return PGS.applyToLinealGeometries(shape, ring -> { + var points = PGS_Conversion.toPVector(toPShape(ring)); + if (ring.isClosed() && !points.get(0).equals(points.get(points.size() - 1))) { + points.add(points.get(0).copy()); + } + var g = fromPShape(PGS_Construction.createHobbyCurve(points, tension)); + if (g instanceof Polygon) { + g = ((Polygon) g).getExteriorRing(); + } + if (g instanceof Lineal) { + return (LineString) g; + } + return null; + }); } /** @@ -437,43 +453,7 @@ public static PShape smooth(PShape shape, double alpha) { * @see #smooth(PShape, double) */ public static PShape smoothGaussian(PShape shape, double sigma) { - Geometry g = fromPShape(shape); - - switch (g.getGeometryType()) { - case Geometry.TYPENAME_GEOMETRYCOLLECTION : - case Geometry.TYPENAME_MULTIPOLYGON : - case Geometry.TYPENAME_MULTILINESTRING : - PShape group = new PShape(GROUP); - for (int i = 0; i < g.getNumGeometries(); i++) { - group.addChild(smoothGaussian(toPShape(g.getGeometryN(i)), sigma)); - } - return group; - case Geometry.TYPENAME_POLYGON : - LinearRingIterator lri = new LinearRingIterator(g); - LineString[] rings = lri.getLinearRings(); - LinearRing[] ringSmoothed = new LinearRing[rings.length]; - for (int i = 0; i < rings.length; i++) { - Coordinate[] coords = GaussianLineSmoothing.get(rings[i], Math.max(sigma, 1), 1).getCoordinates(); - if (coords.length > 2) { - ringSmoothed[i] = PGS.GEOM_FACTORY.createLinearRing(coords); - } else { - ringSmoothed[i] = PGS.GEOM_FACTORY.createLinearRing(); - } - } - - LinearRing[] holes = null; - if (ringSmoothed.length > 1) { - holes = Arrays.copyOfRange(ringSmoothed, 1, ringSmoothed.length); - } - return toPShape(PGS.GEOM_FACTORY.createPolygon(ringSmoothed[0], holes)); - case Geometry.TYPENAME_LINEARRING : - case Geometry.TYPENAME_LINESTRING : - LineString l = (LineString) g; - return toPShape(GaussianLineSmoothing.get(l, Math.max(sigma, 1), 1)); - default : - System.err.println(g.getGeometryType() + " are not supported for the smoothGaussian() method."); // pointal geoms - return new PShape(); // return empty (so element is invisible if not processed) - } + return PGS.applyToLinealGeometries(shape, ring -> GaussianLineSmoothing.get(ring, sigma)); } /** @@ -503,62 +483,88 @@ public static PShape smoothGaussian(PShape shape, double sigma) { * @since 1.4.0 */ public static PShape smoothEllipticFourier(PShape shape, int descriptors) { - Geometry g = fromPShape(shape); - switch (g.getGeometryType()) { - case Geometry.TYPENAME_GEOMETRYCOLLECTION : - case Geometry.TYPENAME_MULTIPOLYGON : - case Geometry.TYPENAME_MULTILINESTRING : - PShape group = new PShape(GROUP); - for (int i = 0; i < g.getNumGeometries(); i++) { - group.addChild(smoothEllipticFourier(toPShape(g.getGeometryN(i)), descriptors)); - } - return group; - case Geometry.TYPENAME_POLYGON : - LinearRingIterator lri = new LinearRingIterator(g); - LinearRing[] rings = lri.getLinearRings(); - LinearRing[] ringProcessed = new LinearRing[rings.length]; - for (int i = 0; i < rings.length; i++) { - descriptors = Math.min(rings[i].getCoordinates().length / 2, descriptors); // max=#vertices/2 - descriptors = Math.max(2, descriptors); // min=2 - final EllipticFourierDesc efd = new EllipticFourierDesc(rings[i], descriptors); - Coordinate[] coords = efd.createPolygon(); - ringProcessed[i] = PGS.GEOM_FACTORY.createLinearRing(coords); - } - - LinearRing[] holes = null; - if (ringProcessed.length > 1) { - holes = Arrays.copyOfRange(ringProcessed, 1, ringProcessed.length); - } - return toPShape(PGS.GEOM_FACTORY.createPolygon(ringProcessed[0], holes)); - case Geometry.TYPENAME_LINEARRING : - descriptors = Math.min(shape.getVertexCount() / 2, descriptors); // max=#vertices/2 - descriptors = Math.max(2, descriptors); // min=2 - LinearRing l = (LinearRing) g; - final EllipticFourierDesc efd = new EllipticFourierDesc(l, descriptors); - return toPShape(PGS.GEOM_FACTORY.createLinearRing(efd.createPolygon())); - case Geometry.TYPENAME_LINESTRING : - default : - System.err.println(g.getGeometryType() + " are not supported for the smoothEllipticFourier() method."); // pointal/string - // geoms - return new PShape(); // return empty (so element is invisible if not processed) - } + return PGS.applyToLinealGeometries(shape, ring -> { + int descriptorz = Math.min(ring.getCoordinates().length / 2, descriptors); // max=#vertices/2 + descriptorz = Math.max(2, descriptorz); // min=2 + if (ring.isClosed()) { + final EllipticFourierDesc efd = new EllipticFourierDesc((LinearRing) ring, descriptorz); + Coordinate[] coords = efd.createPolygon(); + return PGS.GEOM_FACTORY.createLinearRing(coords); + } else { + return null; // open linestrings not supported + } + }); } /** - * Modifies the corners of a specified shape by replacing each angular corner - * with a smooth, circular arc. The radius of each arc is determined - * proportionally to the shorter of the two lines forming the corner. + * Smooths a shape using Lane-Riesenfeld curve subdivision with 4-point + * refinement to reduce contraction. * - * @param shape The original PShape object whose corners are to be rounded. - * @param extent Specifies the degree of corner rounding, with a range from 0 to - * 1. A value of 0 corresponds to no rounding, whereas a value of - * 1 yields maximum rounding while still maintaining the validity - * of the shape. Values above 1 are accepted but may produce - * unpredictable results. - * @return A new PShape object with corners rounded to the specified extent. + * @param shape A shape having lineal geometries (polygons or + * linestrings). Can be a GROUP shape consisting of + * these. + * @param degree The degree of the LR algorithm. Higher degrees + * influence the placement of vertices and the + * overall shape of the curve, but only slightly + * increase the number of vertices generated. + * Increasing the degree also increases the + * contraction of the curve toward its control + * points. The degree does not directly control the + * smoothness of the curve. A value of 3 or 4 is + * usually sufficient for most applications. + * @param subdivisions The number of times the subdivision process is + * applied. More subdivisions result in finer + * refinement and visually smoother curves between + * vertices. A value of 3 or 4 is usually + * sufficient for most applications. + * @param antiContractionFactor The weight parameter for the 4-point refinement. + * Controls the interpolation strength. A value of + * 0 effectively disables the contraction + * reduction. Generally suitable values are in + * [0...0.1]. Larger values may create + * self-intersecting geometry. + * @return A Shape having same structure as the input, whose geometries are now + * smooth. + * @since 2.1 + */ + public static PShape smoothLaneRiesenfeld(PShape shape, int degree, int subdivisions, double antiContractionFactor) { + return PGS.applyToLinealGeometries(shape, lineal -> LaneRiesenfeldSmoothing.subdivide(lineal, degree, subdivisions, antiContractionFactor)); + } + + /** + * Rounds polygon corners by replacing each corner with a circular arc. + * + *

+ * This processes only the linear content of the input PShape - the + * contour paths (closed contours for polygon exteriors and interior + * contours/holes, and open polylines). Non‑linear or unsupported children are + * ignored. + *

+ *

+ * The radius is nominal; it is clamped so it cannot exceed what + * adjacent edges can support (this prevents overlapping or invalid contours). + *

+ * + * @param shape a polygonal PShape or a GROUP + * PShape containing polygonal children; holes + * (interior contours) are supported + * @param radius nominal radius used to round corners; clamped by adjacent edge + * lengths + * @return a non-null PShape with rounded corners; possibly an + * empty GROUP when nothing remains */ - public static PShape round(PShape shape, double extent) { - return CornerRounding.round(shape, extent); + public static PShape round(PShape shape, double radius) { + return PGS.applyToLinealGeometries(shape, ring -> { + var rounded = CornerRounding.roundCorners(toPShape(ring), radius, RoundingStyle.CIRCLE); + var g = fromPShape(rounded); + if (g instanceof Polygon) { + g = ((Polygon) g).getExteriorRing(); + } + if (g instanceof Lineal) { + return (LineString) g; + } + return null; // pointal or other... + }); } /** @@ -586,10 +592,19 @@ public static PShape chaikinCut(PShape shape, double ratio, int iterations) { ratio = Math.max(ratio, 1e-6); ratio = Math.min(ratio, 1 - 1e-6); ratio /= 2; // constrain to 0...0.5 - PShape cut = ChaikinCut.chaikin(shape, (float) ratio, iterations); - PGS_Conversion.setAllFillColor(cut, Colors.WHITE); - PGS_Conversion.setAllStrokeColor(cut, Colors.PINK, 3); - return cut; + float r = (float) ratio; + + return PGS.applyToLinealGeometries(shape, ring -> { + var cut = ChaikinCut.chaikin(toPShape(ring), r, iterations); + var g = fromPShape(cut); + if (g instanceof Polygon) { + g = ((Polygon) g).getExteriorRing(); + } + if (g instanceof Lineal) { + return (LineString) g; + } + return null; // pointal or other... + }); } /** @@ -743,11 +758,11 @@ public static PShape fieldWarp(PShape shape, double magnitude, double noiseScale final PShape copy; if (densify && !pointsShape) { final Densifier d = new Densifier(fromPShape(shape)); - d.setDistanceTolerance(1); + d.setDistanceTolerance(PGS_Conversion.BEZIER_SAMPLE_DISTANCE); d.setValidate(false); copy = toPShape(d.getResultGeometry()); } else { - copy = toPShape(fromPShape(shape)); + copy = PGS_Conversion.copy(shape); } final UniformNoise noise = new UniformNoise((int) (noiseSeed % Integer.MAX_VALUE)); @@ -757,8 +772,14 @@ public static PShape fieldWarp(PShape shape, double magnitude, double noiseScale copy.addChild(copy); } + /* + * TODO preserveEnds arg, that scales the noise offset towards 0 for vertices + * near the end (so we don't large jump between end point and warped next + * vertex). + */ for (PShape child : copy.getChildren()) { - for (int i = 0; i < child.getVertexCount(); i++) { + int offset = 0; // child.isClosed() ? 0 : 1 + for (int i = offset; i < child.getVertexCount() - offset; i++) { final PVector coord = child.getVertex(i); float dx = noise.uniformNoise(coord.x / scale, coord.y / scale + time) - 0.5f; float dy = noise.uniformNoise(coord.x / scale + (101 + time), coord.y / scale + (101 + time)) - 0.5f; @@ -805,6 +826,9 @@ public static PShape pinchWarp(PShape shape, PVector pinchPoint, double weight) vertex.add(direction); vertices.add(vertex); } + if (shape.isClosed()) { + vertices.add(vertices.get(0)); + } return PGS_Conversion.fromPVector(vertices); } @@ -891,4 +915,55 @@ public static PShape reducePrecision(PShape shape, double precision) { return toPShape(GeometryPrecisionReducer.reduce(fromPShape(shape), new PrecisionModel(-Math.max(Math.abs(precision), 1e-10)))); } + /** + * The end cap style to use. Cap style specifies the shape of the ends of + * buffered unclosed lines; it has no effect in polygons. + */ + public enum CapStyle { + + /** + * The usual round end caps. + */ + ROUND(BufferParameters.CAP_ROUND), + /** + * End caps are truncated flat at the line ends. + */ + FLAT(BufferParameters.CAP_FLAT), + /** + * End caps are squared off at the buffer distance beyond the line ends. + */ + SQUARE(BufferParameters.CAP_SQUARE); + + final int style; + + private CapStyle(int style) { + this.style = style; + } + } + + private static BufferParameters createBufferParams(double r, double delta, OffsetStyle bufferStyle, CapStyle capStyle) { + r = Math.abs(r); + + // compute the number of points for the full circle + double ang = Math.acos(1.0 - delta / r); + // if delta/r > 2 or so acos will fail – clamp it + if (Double.isNaN(ang) || ang <= 0) { + // in this degenerate case just fall back to a small number + ang = Math.PI / 8.0; + } + + // total points + double nPtsDbl = Math.PI / ang; + int nPts = (int) Math.ceil(nPtsDbl); + + // segments per quadrant + int quadSeg = (int) Math.ceil(nPts / 4.0); + + // enforce a sensible minimum + quadSeg = Math.max(quadSeg, BufferParameters.DEFAULT_QUADRANT_SEGMENTS); + + // cap style affects linestrings only + return new BufferParameters(quadSeg, capStyle.style, bufferStyle.style, BufferParameters.DEFAULT_MITRE_LIMIT); + } + } diff --git a/src/main/java/micycle/pgs/PGS_Optimisation.java b/src/main/java/micycle/pgs/PGS_Optimisation.java index bd3f229b..d20d4adf 100644 --- a/src/main/java/micycle/pgs/PGS_Optimisation.java +++ b/src/main/java/micycle/pgs/PGS_Optimisation.java @@ -7,17 +7,18 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.lang3.tuple.Triple; import org.locationtech.jts.algorithm.MinimumAreaRectangle; import org.locationtech.jts.algorithm.MinimumBoundingCircle; import org.locationtech.jts.algorithm.MinimumDiameter; import org.locationtech.jts.algorithm.construct.LargestEmptyCircle; import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle; -import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateList; import org.locationtech.jts.geom.Envelope; @@ -30,18 +31,24 @@ import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; import org.locationtech.jts.util.GeometricShapeFactory; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + import almadina.rectpacking.RBPSolution; import almadina.rectpacking.Rect; import almadina.rectpacking.RectPacking.PackingHeuristic; import micycle.pgs.color.Colors; import micycle.pgs.commons.ClosestPointPair; import micycle.pgs.commons.FarthestPointPair; +import micycle.pgs.commons.FastAtan2; +import micycle.pgs.commons.FastConvexMaximumInscribedCircle; import micycle.pgs.commons.LargestEmptyCircles; import micycle.pgs.commons.MaximumInscribedAARectangle; import micycle.pgs.commons.MaximumInscribedRectangle; +import micycle.pgs.commons.MaximumInscribedTriangle; import micycle.pgs.commons.MinimumBoundingEllipse; import micycle.pgs.commons.MinimumBoundingTriangle; import micycle.pgs.commons.Nullable; +import micycle.pgs.commons.SpiralIterator; import micycle.pgs.commons.VisibilityPolygon; import processing.core.PShape; import processing.core.PVector; @@ -66,19 +73,61 @@ private PGS_Optimisation() { * * @param shape a rectangular shape that covers/bounds the input * @return polygonal shape having 4 coordinates + * @deprecated since 2.1; use {@link micycle.pgs.PGS_Hull#boundingBox(PShape) + * boundingBox(PShape)} instead. */ + @Deprecated public static PShape envelope(PShape shape) { return toPShape(fromPShape(shape).getEnvelope()); } /** - * The Maximum Inscribed Circle is determined by a point in the interior of the - * area which has the farthest distance from the area boundary, along with a - * boundary point at that distance. - * - * @param shape - * @param tolerance the distance tolerance for computing the centre point - * (around 1) + * Computes the maximum inscribed circle within a given shape. + *

+ * The Maximum Inscribed Circle (MIC) is defined as the largest possible circle + * that can be completely contained within the area of the input shape. It is + * determined by locating a point inside the shape that has the greatest + * distance from the shape's boundary (i.e., the center of the MIC), and + * returning a circle centered at this point with a radius equal to that + * distance. + *

+ *

+ * This method automatically selects a reasonable tolerance value for computing + * the center point of the MIC, balancing precision and computational + * efficiency. + *

+ * + * @param shape the {@link PShape} representing the area within which to compute + * the MIC + * @return a {@link PShape} instance representing the maximum inscribed circle + * @since 2.1 + */ + public static PShape maximumInscribedCircle(PShape shape) { + MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape)); + final double r = mic.getRadiusLine().getLength(); + Polygon circle = createCircle(PGS.coordFromPoint(mic.getCenter()), r); + return toPShape(circle); + } + + /** + * Computes the maximum inscribed circle within a given shape, using a specified + * tolerance. + *

+ * The Maximum Inscribed Circle (MIC) is the largest possible circle that can be + * fully contained within the area of the input shape. The center of the MIC is + * the point in the interior that is farthest from the shape's boundary, and the + * radius is the distance from this center point to the closest boundary point. + *

+ * + * @param shape the {@link PShape} representing the area within which to + * compute the maximum inscribed circle; typically a simple + * polygon or multipolygon + * @param tolerance the distance tolerance or resolution for approximation; must + * be non-negative. Lower values yield a more accurate result + * but increase computation time (e.g., 0.5 or 1 is common). + * @return a {@link PShape} representing the maximum inscribed circle within the + * input shape + * @see #maximumInscribedCircle(PShape, double, PVector) */ public static PShape maximumInscribedCircle(PShape shape, double tolerance) { MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape), tolerance); @@ -87,6 +136,40 @@ public static PShape maximumInscribedCircle(PShape shape, double tolerance) { return toPShape(circle); } + /** + * Computes the maximum inscribed circle (MIC) within a given shape, + * using the specified accuracy, and optionally outputs the center and radius. + *

+ * The maximum inscribed circle is the largest possible circle completely + * contained within the input shape. If a non-null {@code result} vector is + * provided, its x and y values will be set to the + * coordinates of the circle's center, and z will be set to its + * radius. + *

+ * The returned {@link PShape} is a polygonal approximation of the inscribed + * circle. + * + * @param shape the {@link PShape} to analyze + * @param tolerance the resolution (smaller values increase accuracy but reduce + * performance) + * @param result if not {@code null}, will receive center (x, y) and radius + * (z) of the MIC + * @return a {@link PShape} representing the maximum inscribed circle within the + * input shape + * @see #maximumInscribedCircle(PShape, double) + * @since 2.1 + */ + public static PShape maximumInscribedCircle(PShape shape, double tolerance, @Nullable PVector result) { + MaximumInscribedCircle mic = new MaximumInscribedCircle(fromPShape(shape), tolerance); + final double r = mic.getRadiusLine().getLength(); + var c = mic.getCenter(); + Polygon circle = createCircle(PGS.coordFromPoint(c), r); + if (result != null) { + result.set((float) c.getX(), (float) c.getY(), (float) r); + } + return toPShape(circle); + } + /** * Return the maximum circle (at a given centerpoint inside/outside the circle) * @@ -103,11 +186,28 @@ public static PShape maximumInscribedCircle(PShape shape, PVector centerPoint) { return toPShape(circle); } + /** + * Computes the exact largest inscribed circle for a convex polygonal shape. + *

+ * This method is preferred and faster when the caller knows the shape is + * convex; use it instead of {@link #maximumInscribedCircle(PShape) + * maximumInscribedCircle()} for convex inputs. + * + * @param shape a convex polygonal PShape (non-null) + * @return a PVector (x, y, r) where x,y is the circle center and z (r) is the + * radius + * @since 2.1 + */ + public static PVector convexMaximumInscribedCircle(PShape shape) { + var c = FastConvexMaximumInscribedCircle.getCircle(fromPShape(shape)); + return new PVector((float) c.x, (float) c.y, (float) c.z); + } + /** * Finds an approximate largest area rectangle (of arbitrary orientation) * contained within a polygon. * - * @param shape + * @param shape a polygonal shape * @return a rectangle shape * @see #maximumInscribedAARectangle(PShape, boolean) * maximumInscribedAARectangle() - the largest axis-aligned rectangle @@ -118,6 +218,20 @@ public static PShape maximumInscribedRectangle(PShape shape) { return toPShape(mir.computeMIR()); } + /** + * Finds an approximate largest area triangle (of arbitrary orientation) + * contained within a polygon. + * + * @param shape a polygonal shape + * @return a triangular shape + * @since 2.1 + */ + public static PShape maximumInscribedTriangle(PShape shape) { + Polygon polygon = (Polygon) fromPShape(shape); + var mit = new MaximumInscribedTriangle(polygon); + return toPShape(mit.computeMIT()); + } + /** * Finds the rectangle with a maximum area whose sides are parallel to the * x-axis and y-axis ("axis-aligned"), contained/insribed within a convex shape. @@ -160,64 +274,143 @@ public static PShape maximumInscribedAARectangle(PShape shape, boolean fast) { *

* The method does not respect holes (for now...). * - * @param shape + * @param shape a polygonal shape * @param tolerance a value of 2-5 is usually suitable * @return shape representing the maximum square * @since 1.4.0 */ public static PShape maximumPerimeterSquare(PShape shape, double tolerance) { - shape = PGS_Morphology.simplify(shape, tolerance / 2); - final Polygon p = (Polygon) PGS_Conversion.fromPShape(shape); - Geometry buffer = p.getExteriorRing().buffer(tolerance / 2, 4); - final Envelope e = buffer.getEnvelopeInternal(); - buffer = DouglasPeuckerSimplifier.simplify(buffer, tolerance / 2); - final IndexedPointInAreaLocator pia = new IndexedPointInAreaLocator(buffer); - shape = PGS_Processing.densify(shape, Math.max(0.5, tolerance)); // min of 0.5 - final List points = PGS_Conversion.toPVector(shape); - - double maxDiagonal = 0; - PVector[] maxAreaVertices = new PVector[0]; - for (final PVector a : points) { - for (final PVector b : points) { - double dist = PGS.distanceSq(a, b); - - if (dist < maxDiagonal) { + shape = PGS_Morphology.simplify(shape, tolerance * 0.5); + Polygon p = (Polygon) PGS_Conversion.fromPShape(shape); + Geometry buffer = p.getExteriorRing().buffer(tolerance * 0.5, 4); + Envelope env = buffer.getEnvelopeInternal(); + buffer = DouglasPeuckerSimplifier.simplify(buffer, tolerance * 0.5); + var index = new YStripesPointInAreaLocator((Polygon) buffer); + + shape = PGS_Processing.densify(shape, Math.max(0.5, tolerance)); + List points = PGS_Conversion.toPVector(shape); + + // copy into primitive arrays & compute point‐set AABB + int n = points.size(); + double[] xs = new double[n], ys = new double[n]; + double ptsMinX = Double.POSITIVE_INFINITY, ptsMaxX = Double.NEGATIVE_INFINITY; + double ptsMinY = Double.POSITIVE_INFINITY, ptsMaxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + PVector v = points.get(i); + xs[i] = v.x; + ys[i] = v.y; + if (v.x < ptsMinX) { + ptsMinX = v.x; + } + if (v.x > ptsMaxX) { + ptsMaxX = v.x; + } + if (v.y < ptsMinY) { + ptsMinY = v.y; + } + if (v.y > ptsMaxY) { + ptsMaxY = v.y; + } + } + + // 4) Prepare for the double loop + double envMinX = env.getMinX(), envMaxX = env.getMaxX(); + double envMinY = env.getMinY(), envMaxY = env.getMaxY(); + + double maxDiag = 0; + int bestI = -1, bestJ = -1; + double bestCx = 0, bestCy = 0, bestDx = 0, bestDy = 0; + Coordinate cCoord = new Coordinate(), dCoord = new Coordinate(); + + // 5) Search for the largest‐diagonal pair i,j + for (int i = 0; i < n; i++) { + double ax = xs[i], ay = ys[i]; + // quick global prune: maximum possible sq‐dist from this A to any point + double dxA = Math.max(ax - ptsMinX, ptsMaxX - ax); + double dyA = Math.max(ay - ptsMinY, ptsMaxY - ay); + if ((dxA * dxA + dyA * dyA) <= maxDiag) { + continue; + } + + for (int j = i + 1; j < n; j++) { + double bx = xs[j], by = ys[j]; + double dx = bx - ax, dy = by - ay; + double dist = dx * dx + dy * dy; + if (dist <= maxDiag) { continue; } - final PVector m = PVector.add(a, b).div(2); - final PVector n = new PVector(b.y - a.y, a.x - b.x).div(2); - final PVector c = PVector.sub(m, n); - - final PVector d = PVector.add(m, n); - // do envelope checks first -- slightly faster - if (within(c, e) && within(d, e)) { - if (pia.locate(new Coordinate(c.x, c.y)) != Location.EXTERIOR) { - if (pia.locate(new Coordinate(d.x, d.y)) != Location.EXTERIOR) { - maxDiagonal = dist; - maxAreaVertices = new PVector[] { a, c, b, d, a }; // closed vertices - } - } + // midpoint + double mx = (ax + bx) * 0.5, my = (ay + by) * 0.5; + // half‐perp vector + double nx = dy * 0.5, ny = -dx * 0.5; + // other two corners + double cx = mx - nx, cy = my - ny; + double dx2 = mx + nx, dy2 = my + ny; + + // envelope quick‐rejection + if (cx < envMinX || cx > envMaxX || cy < envMinY || cy > envMaxY) { + continue; + } + if (dx2 < envMinX || dx2 > envMaxX || dy2 < envMinY || dy2 > envMaxY) { + continue; + } + + // expensive point‐in‐area tests, reusing coords + cCoord.x = cx; + cCoord.y = cy; + if (index.locate(cCoord) == Location.EXTERIOR) { + continue; + } + dCoord.x = dx2; + dCoord.y = dy2; + if (index.locate(dCoord) == Location.EXTERIOR) { + continue; } + + // success: record as new best + maxDiag = dist; + bestI = i; + bestJ = j; + bestCx = cx; + bestCy = cy; + bestDx = dx2; + bestDy = dy2; } } - PShape out = PGS_Conversion.fromPVector(maxAreaVertices); + // 6) If we found none, return an empty shape + if (bestI < 0) { + return PGS_Conversion.fromPVector(new PVector[0]); + } + + // 7) Build the closed‐square ring A→C→B→D→A + PVector A = new PVector((float) xs[bestI], (float) ys[bestI]); + PVector B = new PVector((float) xs[bestJ], (float) ys[bestJ]); + PVector C = new PVector((float) bestCx, (float) bestCy); + PVector D = new PVector((float) bestDx, (float) bestDy); + PVector[] ring = { A, C, B, D, A }; + + // 8) Convert back to PShape, style, and return + PShape out = PGS_Conversion.fromPVector(ring); out.setStroke(true); out.setStroke(micycle.pgs.color.Colors.PINK); out.setStrokeWeight(4); return out; } - private static boolean within(PVector p, Envelope rect) { - return p.x >= rect.getMinX() && p.x <= rect.getMaxX() && p.y <= rect.getMaxY() && p.y >= rect.getMinY(); - } - /** - * Computes the Minimum Bounding Circle (MBC) for the points in a Geometry. The - * MBC is the smallest circle which covers all the vertices of the input shape - * (this is also known as the Smallest Enclosing Circle). This is equivalent to - * computing the Maximum Diameter of the input vertex set. + * Computes the minimum bounding circle (MBC) that encloses all vertices + * of the provided shape. + *

+ * The minimum bounding circle is the smallest circle that contains all vertices + * of the input shape. + * + * @param shape the input {@link PShape}; all its vertices will be considered + * for the bounding circle computation + * @return a {@link PShape} object representing the minimum bounding circle that + * encloses all vertices of the input shape + * @see #minimumBoundingCircle(PShape, PVector) */ public static PShape minimumBoundingCircle(PShape shape) { MinimumBoundingCircle mbc = new MinimumBoundingCircle(fromPShape(shape)); @@ -226,6 +419,37 @@ public static PShape minimumBoundingCircle(PShape shape) { return toPShape(circle); } + /** + * Computes the minimum bounding circle (MBC) for the given shape, and + * optionally returns the center and radius. + *

+ * The minimum bounding circle is the smallest circle that contains all vertices + * of the input shape. If the provided {@code result} vector is non-null, its + * {@code x} and {@code y} fields will be set to the coordinates of the circle's + * center, and its {@code z} field will be set to the circle's radius. + *

+ * This method returns a new {@link PShape} representing the computed circle. + * + * @param shape the shape whose vertices will be used to compute the minimum + * bounding circle + * @param result a {@link PVector}; if non-null, receives the center as (x, y) + * and radius as (z) + * @return a {@link PShape} representing the minimum bounding circle enclosing + * all vertices of the input shape + * @see #minimumBoundingCircle(PShape) + * @since 2.1 + */ + public static PShape minimumBoundingCircle(PShape shape, @Nullable PVector result) { + MinimumBoundingCircle mbc = new MinimumBoundingCircle(fromPShape(shape)); + final double r = mbc.getRadius(); + var c = mbc.getCentre(); + Polygon circle = createCircle(c, r); + if (result != null) { + result.set((float) c.getX(), (float) c.getY(), (float) r); + } + return toPShape(circle); + } + /** * Computes the minimum-width bounding rectangle that encloses a shape. Unlike * the envelope for a shape, the rectangle returned by this method can have any @@ -266,7 +490,6 @@ public static PShape minimumAreaRectangle(PShape shape) { * correspond to a pixel distance). 0.001 to 0.01 * recommended. Higher values are a looser (yet quicker) * fit. - * @return */ public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance) { final Geometry hull = fromPShape(shape).convexHull(); @@ -297,7 +520,6 @@ public static PShape minimumBoundingEllipse(PShape shape, double errorTolerance) * Computes the minimum-area bounding triangle that encloses a shape. * * @param shape - * @return */ public static PShape minimumBoundingTriangle(PShape shape) { MinimumBoundingTriangle mbt = new MinimumBoundingTriangle(fromPShape(shape)); @@ -313,13 +535,91 @@ public static PShape minimumBoundingTriangle(PShape shape) { * can be moved through, with a single rotation. * * @param shape - * @return */ public static PShape minimumDiameter(PShape shape) { LineString md = (LineString) MinimumDiameter.getMinimumDiameter(fromPShape(shape)); return toPShape(md); } + /** + * Computes the minimum-width annulus (the donut-like region between two + * concentric circles with minimal width that encloses the vertices of the given + * {@link PShape}). + *

+ * The annulus is defined as the region between two concentric circles (with + * computed center and radii), such that all vertices of the input shape lie + * between the inner and outer circle, and the distance between these circles + * (the "width" of the annulus) is minimized. + *

+ * The algorithm considers only the vertices of the input shape (not the + * filled area or edges) as points to be enclosed. + * + * @param shape the {@link PShape} whose vertices are to be enclosed by + * the minimum-width annulus; must be non-null and contain at least + * three points + * @return a {@link PShape} representing the minimum-width annulus (as a ring + * shape) + * @since 2.1 + */ + public static PShape minimumWidthAnnulus(PShape shape) { + var points = PGS_Conversion.toPVector(shape); + var d = PGS_ShapePredicates.diameter(shape); + var convexHull = PGS_Conversion.toPVector(PGS_Hull.convexHull(points)); + var tree = PGS.makeKdtree(points); + + var bounds = new double[4]; + PGS_Hull.boundingBox(shape, bounds); // write to bounds + bounds[0] -= d; + bounds[1] -= d; + bounds[2] += d; + bounds[3] += d; + + var vd = PGS_Voronoi.innerVoronoi(points, bounds); + var fpvd = PGS_Voronoi.farthestPointVoronoi(points); + + /* + * NOTE here we find inner edges/vertices in a generic way without geometric + * shortcuts given by VD/FPVD geometry (i.e. we know the circumcircle of each + * FPVD vertex -- its circumradius gives us outerR immediately). + */ + var a = PGS_Meshing.extractInnerEdgesAndVertices(vd); + var b = PGS_Meshing.extractInnerEdgesAndVertices(fpvd); + var vdVertices = a.getRight(); + var vdEdges = a.getLeft(); + var fpvdVertices = b.getRight(); + var fpvdEdges = b.getLeft(); + + var overlayVerices = PGS_SegmentSet.intersections(vdEdges, fpvdEdges); + + /* + * Candidate centers for the smallest-width annulus have 3 sources. For each + * candidate we find the distance both to the closest and farthest vertex. The + * difference between these distances is the annulus width; we select the + * candidate having the smallest width. + */ + /*- + * The 3 centerpoint sources are: + * Vertices of the FPVD + * Vertices of the VD + * Vertices from the intersection between edges of VD and FPVD + */ + var candidates = new ArrayList(); + candidates.addAll(vdVertices); + candidates.addAll(fpvdVertices); + candidates.addAll(overlayVerices); + + var result = candidates.parallelStream().map(v -> { + // farthest point must lie on convex hull + var far = PGS_Optimisation.farthestPoint(convexHull, v); + var close = tree.query1nn(new double[] { v.x, v.y }); + double outerR = far.dist(v); + double innerR = close.dist(); + return Triple.of(v, outerR, innerR); + }).min(Comparator.comparingDouble(t -> t.getMiddle() - t.getRight())).get(); + + return PGS_Construction.createRing(result.getLeft().x, result.getLeft().y, result.getMiddle(), result.getRight()); + } + /** * Computes the largest empty circle that does not intersect any obstacles (up * to a specified tolerance). @@ -561,13 +861,51 @@ public static PShape binPack(List shapes, double binWidth, double binHei } /** - * Returns the nearest point of the shape to the given point. If the shape is - * has multiple children/geometries (a GROUP shape), the single closest point is - * returned. - * - * @param shape - * @param point - * @return + * Returns the closest vertex of a shape to a query point. For GROUP shapes, any + * child geometry's vertex may be returned. + * + * @param shape the PShape to search for the closest vertex + * @param queryPoint the query PVector + * @return a new PVector at the position of the closest vertex (not a reference + * to existing shape data) + * @since 2.1 + */ + public static PVector closestVertex(PShape shape, PVector queryPoint) { + List vertices = PGS_Conversion.toPVector(shape); + if (vertices.isEmpty()) { + return null; + } + float minDistSq = Float.POSITIVE_INFINITY; + PVector closest = null; + for (PVector v : vertices) { + float distSq = PVector.dist(v, queryPoint); + if (distSq < minDistSq) { + minDistSq = distSq; + closest = v; + } + } + return closest; + } + + /** + * Returns the nearest point along the edges of the given shape to the specified + * query point. + *

+ * This method computes the point on the perimeter (including all edges, not + * only the vertices) of the given shape that is closest to the given + * {@code point}. For composite shapes (such as GROUP shapes made of multiple + * child geometries), the single closest point across all children is returned. + *

+ *

+ * Note: The nearest location may be somewhere along an edge of + * the shape, not necessarily at one of the original shape's vertices. + *

+ * + * @param shape the {@code PShape} to search for the closest boundary point. + * @param point the {@code PVector} point to which the nearest point is sought. + * @return a new {@code PVector} representing the exact coordinates of the + * closest point on the shape's boundary or edge (not a reference to the + * original coordinate). * @see #closestPoints(PShape, PVector) */ public static PVector closestPoint(PShape shape, PVector point) { @@ -576,6 +914,35 @@ public static PVector closestPoint(PShape shape, PVector point) { return new PVector((float) coord.x, (float) coord.y); } + /** + * Finds the closest point in the collection to a specified point. + * + * @param points the collection of points to search within + * @param point the point to find the closest neighbor for + * @return the closest point from the collection to the specified point + * @since 2.1 + */ + public static PVector closestPoint(Collection points, PVector point) { + if (points == null || points.isEmpty()) { + return null; // Handle empty or null collection + } + + PVector closest = null; + float minDistanceSq = Float.MAX_VALUE; + + for (PVector p : points) { + float dx = p.x - point.x; + float dy = p.y - point.y; + float distanceSq = dx * dx + dy * dy; + if (distanceSq < minDistanceSq) { + minDistanceSq = distanceSq; + closest = p; + } + } + + return closest; + } + /** * Returns the nearest point for each "island" / separate polygon in the GROUP * input shape. @@ -612,6 +979,62 @@ public static List closestPointPair(Collection points) { return closestPointPair.execute(); } + /** + * Returns the farthest vertex of a shape from a query point. For GROUP shapes, + * any child geometry's vertex may be returned. + * + * @param shape the PShape to search for the farthest vertex + * @param queryPoint the query PVector + * @return a new PVector at the position of the farthest vertex (not a reference + * to existing shape data) + * @since 2.1 + */ + public static PVector farthestVertex(PShape shape, PVector queryPoint) { + List vertices = PGS_Conversion.toPVector(shape); + if (vertices.isEmpty()) { + return null; + } + float maxDistSq = Float.NEGATIVE_INFINITY; + PVector farthest = null; + for (PVector v : vertices) { + float distSq = PVector.dist(v, queryPoint); + if (distSq > maxDistSq) { + maxDistSq = distSq; + farthest = v; + } + } + return farthest; + } + + /** + * Finds the farthest point in the collection from a specified point. + * + * @param points the collection of points to search within + * @param point the point from which the farthest neighbor is sought + * @return the farthest point from the collection to the specified point + * @since 2.1 + */ + public static PVector farthestPoint(Collection points, PVector point) { + if (points == null || points.isEmpty()) { + return null; // Handle empty or null collection + } + + PVector farthest = null; + float maxDistanceSq = Float.NEGATIVE_INFINITY; + + for (PVector p : points) { + float dx = p.x - point.x; + float dy = p.y - point.y; + float distanceSq = dx * dx + dy * dy; + if (distanceSq > maxDistanceSq) { + maxDistanceSq = distanceSq; + farthest = p; + } + } + + return farthest; + } + /** * Computes the farthest pair of points (the "diametral pair") in a set of n * points. @@ -657,6 +1080,130 @@ public static PShape hilbertSortFaces(PShape mesh) { return PGS_Conversion.flatten(PGS_PointSet.hilbertSort(points).stream().map(map::get).collect(Collectors.toList())); } + /** + * Reorders the faces of a mesh into an anti-clockwise “spiral” (breadth-first + * rings) starting from a given face, then returns a new, flattened PShape + * containing exactly those faces in spiral order. + * + * @param mesh mesh-like GROUP PShape + * @param startFace One of the child‐faces of {@code mesh}. This face will + * appear first in the returned ordering; subsequent faces + * follow in concentric breadth‐first “rings” around it, sorted + * anti-clockwise. + * @return A new, flattened PShape whose set of faces equals the children of + * {@code mesh}, but ordered in a spiral starting at + * {@code startingFace}. + * @since 2.1 + */ + public static PShape spiralSortFaces(PShape mesh, PShape startFace) { + var faces = SpiralIterator.spiral(startFace, PGS_Conversion.getChildren(mesh)); + return PGS_Conversion.flatten(faces); + } + + /** + * Returns a new, flattened PShape containing the child faces of {@code mesh} + * sorted by the x and then y coordinates of their centroids. + *

+ * This is commonly used for ordering the faces of a mesh spatially in a + * grid-like manner, first by increasing x-coordinate of the face centroids, and + * breaking ties using y-coordinate. + *

+ * + * @param mesh a mesh-like GROUP {@link PShape} whose children (faces) will be + * sorted by centroid + * @return a new, flattened {@code PShape} whose faces are sorted by the + * centroids’ x and y coordinates + * @since 2.1 + */ + public static PShape centroidSortFaces(PShape mesh) { + Map map = new HashMap<>(mesh.getChildCount()); + + PGS_Conversion.getChildren(mesh).forEach(child -> { + PVector centroid = PGS_ShapePredicates.boundsCenter(child); + map.put(centroid, child); + }); + + List centroids = new ArrayList<>(map.keySet()); + + centroids.sort((p1, p2) -> { + if (p1.x != p2.x) { + return Float.compare(p1.x, p2.x); + } else { + return Float.compare(p1.y, p2.y); + } + }); + + List sortedShapes = centroids.stream().map(map::get).toList(); + return PGS_Conversion.flatten(sortedShapes); + } + + /** + * Sorts the faces (children) of a mesh radially around a given centre, then + * applies a circular rotation to the order based on the offset parameter. + * + * @param mesh a PShape with polygonal children (the faces to order) + * @param centre the point around which radial sorting is performed (usually the + * mesh centroid) + * @param offset a fractional value in [0, 1) that specifies where the ordering + * starts around the centre, as a proportion of a full 360° turn: + *
    + *
  • offset = 0.0: face whose centroid points directly to the + * right (positive X direction) comes first
  • + *
  • offset = 0.25: ordering is rotated 90° counterclockwise; + * face pointing “up” (positive Y) comes first
  • + *
  • offset = 0.5: ordering is rotated 180°; face pointing to + * the left (-X) comes first
  • + *
  • offset = 0.75: ordering is rotated 270°; face pointing + * “down” (-Y) comes first
  • + *
+ * Intermediate values smoothly rotate the sequence; outside [0,1) + * values wrap around. In other words, this lets you “spin” the + * order of faces by a fraction of a circle. + * @return a new PShape, with faces flattened into one shape, ordered so that + * face[0] begins at offset·360° from the right and continues clockwise + */ + public static PShape radialSortFaces(final PShape mesh, final PVector centre, final double offset) { + record FaceInfo(PShape face, double angle, double dist2) { + } + List faces = PGS_Conversion.getChildren(mesh); + List infoList = new ArrayList<>(faces.size()); + + double TWO_PI = Math.PI * 2; + // force offset into [0,1) + double off = ((offset % 1) + 1) % 1; + double rot = off * TWO_PI; // convert to radians + + for (PShape f : faces) { + // centroid of this face + PVector c = PGS_ShapePredicates.centroid(f); + double dx = c.x - centre.x; + double dy = c.y - centre.y; + + // raw angle in [–π,+π] + double a = FastAtan2.atan2(dy, dx); + // remap to [0,2π) + if (a < 0) { + a += TWO_PI; + } + + // now apply offset‐rotation and re‐wrap to [0,2π) + a = a - rot; + if (a < 0) { + a += TWO_PI; + } + + double d2 = dx * dx + dy * dy; + infoList.add(new FaceInfo(f, a, d2)); + } + + // Sort by our shifted angle, then (optionally) by distance² + infoList.sort(Comparator.comparingDouble((FaceInfo fi) -> fi.angle).thenComparingDouble(fi -> fi.dist2)); + + List sorted = infoList.stream().map(fi -> fi.face).toList(); + + return PGS_Conversion.flatten(sorted); + } + /** * Solves the Problem of Apollonius (finding a circle tangent to three other * circles in the plane). Circles are represented by PVectors, where the z diff --git a/src/main/java/micycle/pgs/PGS_PointSet.java b/src/main/java/micycle/pgs/PGS_PointSet.java index f5a4d869..1df51711 100644 --- a/src/main/java/micycle/pgs/PGS_PointSet.java +++ b/src/main/java/micycle/pgs/PGS_PointSet.java @@ -18,11 +18,8 @@ import org.apache.commons.math3.random.RandomGenerator; import org.apache.commons.math3.util.FastMath; import org.apache.commons.math3.util.Pair; -import org.jgrapht.alg.interfaces.HamiltonianCycleAlgorithm; import org.jgrapht.alg.interfaces.SpanningTreeAlgorithm; import org.jgrapht.alg.spanning.PrimMinimumSpanningTree; -import org.jgrapht.alg.tour.FarthestInsertionHeuristicTSP; -import org.jgrapht.alg.tour.TwoOptHeuristicTSP; import org.jgrapht.graph.SimpleGraph; import org.tinfour.common.IIncrementalTin; import org.tinfour.common.Vertex; @@ -33,6 +30,7 @@ import it.unimi.dsi.util.XoRoShiRo128PlusRandom; import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator; import micycle.pgs.commons.GeometricMedian; +import micycle.pgs.commons.GreedyTSP; import micycle.pgs.commons.PEdge; import micycle.pgs.commons.PoissonDistributionJRUS; import micycle.pgs.commons.ThomasPointProcess; @@ -43,9 +41,27 @@ * Generation of random sets of 2D points having a variety of different * distributions and constraints (and associated functions). * + *

+ * Note on Floating-Point Values and Collisions: + *

+ *

+ * When generating many random points, collisions in coordinate values are + * expected due to the limited precision of floating-point numbers. For example: + *

    + *
  • A {@code float} has ~24 bits of precision (23 explicit mantissa bits + 1 + * implicit leading bit), which allows for ~16.8 million unique values in the + * range [0, 1).
  • + *
  • When generating a large number of points (e.g., 100,000), the probability + * of collisions follows the birthday paradox formula: n² / (2m), + * where n is the number of samples and m is the + * number of possible unique values.
  • + *
  • For 100,000 points, this results in ~300 expected collisions in the + * x-coordinate, even when using a high-quality random number generator.
  • + *
+ *

+ * * @author Michael Carleton * @since 1.2.0 - * */ public final class PGS_PointSet { @@ -122,6 +138,132 @@ public static List pruneSparsePoints(Collection points, double .map(entry -> new PVector((float) entry.getKey().getX(), (float) entry.getKey().getY())).toList(); } + /** + * Remove exactly removeCount points chosen uniformly at random. The returned + * list contains the remaining points in the original iteration order. + * + * @param points collection of PVector points + * @param removeCount number of points to remove (must be >= 0) + * @return new List containing the remaining points + * @since 2.1 + */ + public static List pruneRandomRemoveN(Collection points, int removeCount) { + return pruneRandomRemoveN(points, removeCount, System.nanoTime()); + } + + /** + * Remove exactly removeCount points chosen uniformly at random, using the + * provided seed. + * + * @param points collection of PVector points + * @param removeCount number of points to remove (must be >= 0) + * @param seed RNG seed for reproducibility + * @return new List containing the remaining points + * @since 2.1 + */ + public static List pruneRandomRemoveN(Collection points, int removeCount, long seed) { + if (removeCount < 0) { + throw new IllegalArgumentException("removeCount must be non-negative"); + } + List list = new ArrayList<>(points); + final int size = list.size(); + if (removeCount <= 0) { + return new ArrayList<>(list); + } + if (removeCount >= size) { + return new ArrayList<>(); // everything removed + } + + // sample removeCount distinct indices using partial Fisher-Yates + int[] indices = new int[size]; + for (int i = 0; i < size; i++) { + indices[i] = i; + } + RandomGenerator r = new XoRoShiRo128PlusRandomGenerator(seed); + for (int i = 0; i < removeCount; i++) { + int j = i + r.nextInt(size - i); + int tmp = indices[i]; + indices[i] = indices[j]; + indices[j] = tmp; + } + + boolean[] remove = new boolean[size]; + for (int k = 0; k < removeCount; k++) { + remove[indices[k]] = true; + } + + List out = new ArrayList<>(size - removeCount); + for (int i = 0; i < size; i++) { + if (!remove[i]) { + out.add(list.get(i)); + } + } + return out; + } + + /** + * Keep exactly keepCount points chosen uniformly at random. The returned list + * contains the kept points in the original iteration order. + * + * @param points collection of PVector points + * @param keepCount number of points to keep (must be >= 0) + * @return new List containing the kept points + * @since 2.1 + */ + public static List pruneRandomToN(Collection points, int keepCount) { + return pruneRandomToN(points, keepCount, System.nanoTime()); + } + + /** + * Keep exactly keepCount points chosen uniformly at random, using the provided + * seed. + * + * @param points collection of PVector points + * @param keepCount number of points to keep (must be >= 0) + * @param seed RNG seed for reproducibility + * @return new List containing the kept points + * @since 2.1 + */ + public static List pruneRandomToN(Collection points, int keepCount, long seed) { + if (keepCount < 0) { + throw new IllegalArgumentException("keepCount must be non-negative"); + } + List list = new ArrayList<>(points); + final int size = list.size(); + if (keepCount <= 0) { + return new ArrayList<>(); + } + if (keepCount >= size) { + return new ArrayList<>(list); + } + + // sample keepCount distinct indices using partial Fisher-Yates + int[] indices = new int[size]; + for (int i = 0; i < size; i++) { + indices[i] = i; + } + RandomGenerator r = new XoRoShiRo128PlusRandomGenerator(seed); + for (int i = 0; i < keepCount; i++) { + int j = i + r.nextInt(size - i); + int tmp = indices[i]; + indices[i] = indices[j]; + indices[j] = tmp; + } + + boolean[] keep = new boolean[size]; + for (int k = 0; k < keepCount; k++) { + keep[indices[k]] = true; + } + + List out = new ArrayList<>(keepCount); + for (int i = 0; i < size; i++) { + if (keep[i]) { + out.add(list.get(i)); + } + } + return out; + } + /** * Sorts a list of points according to the Hilbert space-filling curve to ensure * a high-degree of spatial locality in the sequence of points. @@ -239,8 +381,8 @@ public static List random(double xMin, double yMin, double xMax, double final SplittableRandom random = new SplittableRandom(seed); final List points = new ArrayList<>(n); for (int i = 0; i < n; i++) { - final float x = (float) (xMin + (xMax - xMin) * random.nextDouble()); - final float y = (float) (yMin + (yMax - yMin) * random.nextDouble()); + final float x = (float) random.nextDouble(xMin, xMax); + final float y = (float) random.nextDouble(yMin, yMax); points.add(new PVector(x, y)); } return points; @@ -297,9 +439,8 @@ public static List gaussian(double centerX, double centerY, double sd, * * @param xMin x-coordinate of boundary minimum * @param yMin y-coordinate of boundary minimum - * @param xMax x-coordinate of boundary maximum - * @param yMax y-coordinate of boundary maximum - * @return + * @param xMax x-coordinate of boundary maximum (inclusive) + * @param yMax y-coordinate of boundary maximum (inclusive) */ public static List squareGrid(final double xMin, final double yMin, final double xMax, final double yMax, final double pointDistance) { final double width = xMax - xMin; @@ -307,8 +448,8 @@ public static List squareGrid(final double xMin, final double yMin, fin final List points = new ArrayList<>(); - for (double x = 0; x < width; x += pointDistance) { - for (double y = 0; y < height; y += pointDistance) { + for (double x = 0; x <= width; x += pointDistance) { + for (double y = 0; y <= height; y += pointDistance) { points.add(new PVector((float) (x + xMin), (float) (y + yMin))); } } @@ -956,16 +1097,12 @@ public static PShape minimumSpanningTree(List points) { /** * Computes an approximate Traveling Salesman path for the set of points - * provided. Utilises a heuristic based TSP solver, starting with the farthest - * insertion method followed by 2-opt heuristic improvements for tour - * optimization. - *

- * Note: The algorithm's runtime grows rapidly as the number of points - * increases. Large datasets (>1000) may result in long computation times and - * should be used with caution. + * provided. Utilises a heuristic based TSP solver, followed by 2-opt heuristic + * improvements for further tour optimisation. *

* Note {@link PGS_Hull#concaveHullBFS(List, double) concaveHullBFS()} produces - * a similar result (somewhat longer tours) but is much more performant. + * a similar result (somewhat longer tours, i.e. 10%) but is much more + * performant. * * @param points the list of points for which to compute the approximate * shortest tour @@ -975,14 +1112,8 @@ public static PShape minimumSpanningTree(List points) { * @since 2.0 */ public static PShape findShortestTour(List points) { - HamiltonianCycleAlgorithm tsp = new FarthestInsertionHeuristicTSP<>(); - TwoOptHeuristicTSP tspImprover = new TwoOptHeuristicTSP<>(); - - var graph = PGS.makeCompleteGraph(points); - var tour = tsp.getTour(graph); - tour = tspImprover.improveTour(tour); - - return PGS_Conversion.fromPVector(tour.getVertexList()); + var tour = new GreedyTSP<>(points, (a, b) -> a.dist(b)); + return PGS_Conversion.fromPVector(tour.getTour()); } /** diff --git a/src/main/java/micycle/pgs/PGS_Processing.java b/src/main/java/micycle/pgs/PGS_Processing.java index 23021ab2..0042648c 100644 --- a/src/main/java/micycle/pgs/PGS_Processing.java +++ b/src/main/java/micycle/pgs/PGS_Processing.java @@ -15,18 +15,17 @@ import java.util.Iterator; import java.util.List; import java.util.Objects; -import java.util.SplittableRandom; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Predicate; import java.util.function.UnaryOperator; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; -import java.util.stream.StreamSupport; -import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.math3.ml.clustering.CentroidCluster; import org.apache.commons.math3.ml.clustering.Clusterable; import org.apache.commons.math3.ml.clustering.KMeansPlusPlusClusterer; @@ -68,16 +67,13 @@ import org.locationtech.jts.operation.polygonize.Polygonizer; import org.locationtech.jts.operation.union.UnaryUnionOp; import org.locationtech.jts.shape.random.RandomPointsInGridBuilder; -import org.tinfour.common.IConstraint; -import org.tinfour.common.IIncrementalTin; -import org.tinfour.common.PolygonConstraint; -import org.tinfour.common.SimpleTriangle; import org.tinfour.common.Vertex; -import org.tinfour.utils.TriangleCollector; import org.tinfour.voronoi.BoundedVoronoiBuildOptions; import org.tinfour.voronoi.BoundedVoronoiDiagram; -import it.unimi.dsi.util.XoRoShiRo128PlusRandom; +import com.github.micycle1.geoblitz.IndexedLengthIndexedLine; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator; import micycle.balaban.BalabanSolver; import micycle.balaban.Point; @@ -86,6 +82,7 @@ import micycle.pgs.color.Colors; import micycle.pgs.commons.PolygonDecomposition; import micycle.pgs.commons.SeededRandomPointsInGridBuilder; +import micycle.pgs.commons.ShapeRandomPointSampler; import micycle.trapmap.TrapMap; import processing.core.PConstants; import processing.core.PShape; @@ -141,12 +138,11 @@ public static PShape densify(PShape shape, double distanceTolerance) { * point away from the shape (outwards); negative * values offset the point inwards towards its * interior. - * @return * @see #pointsOnExterior(PShape, int, double) */ public static PVector pointOnExterior(PShape shape, double perimeterPosition, double offsetDistance) { perimeterPosition %= 1; - LengthIndexedLine l = makeIndexedLine(shape); + var l = makeIndexedLine(shape); Coordinate coord = l.extractPoint(perimeterPosition * l.getEndIndex(), offsetDistance); return new PVector((float) coord.x, (float) coord.y); @@ -168,11 +164,10 @@ public static PVector pointOnExterior(PShape shape, double perimeterPosition, do * point away from the shape (outwards); negative * values offset the point inwards towards its * interior. - * @return * @since 1.4.0 */ public static PVector pointOnExteriorByDistance(PShape shape, double perimeterDistance, double offsetDistance) { - LengthIndexedLine l = makeIndexedLine(shape); + var l = makeIndexedLine(shape); Coordinate coord = l.extractPoint(perimeterDistance % l.getEndIndex(), offsetDistance); return new PVector((float) coord.x, (float) coord.y); } @@ -201,145 +196,216 @@ public static PVector pointOnExteriorByDistance(PShape shape, double perimeterDi * @since 1.3.0 */ public static List pointsOnExterior(PShape shape, int points, double offsetDistance) { - // TODO another method that returns concave hull of returned points (when - // offset) - List polygons = PGS.extractPolygons(fromPShape(shape)); + return pointsOnExterior(shape, points, offsetDistance, 0); + } + + /** + * Extract a fixed number of evenly spaced samples from every linear component. + * + *

+ * Samples exactly {@code points} positions from each linear component (each + * LinearRing or LineString) found in {@code shape}. Sampling is done + * independently per component — i.e., {@code points} samples are produced for + * each ring or line. {@code startOffset} sets the fractional start position + * along each component (0..1). {@code offsetDistance} is applied perpendicular + * to the boundary when extracting each sample. + * + *

+ * Orientation is normalized per component (reversed if necessary) so offsets + * are applied consistently. The method only collects points and does not modify + * the input geometry. + * + * @param shape the input PShape containing rings or LineStrings to + * sample + * @param points number of samples to take from each linear component + * @param offsetDistance perpendicular offset applied to each sampled point + * @param startOffset fractional start position along each component (0..1) + * @return a list of PVector points sampled from every linear component; empty + * if none produce samples + * @since 1.3.0 + */ + public static List pointsOnExterior(PShape shape, int points, double offsetDistance, double startOffset) { List coords = new ArrayList<>(); - polygons.forEach(polygon -> { - PGS.extractLinearRings(polygon).forEach(ring -> { - if (Orientation.isCCW(ring.getCoordinates())) { - ring = ring.reverse(); - } - final LengthIndexedLine l = new LengthIndexedLine(ring); - final double increment = 1d / points; - for (double distance = 0; distance < 1; distance += increment) { - final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance); - coords.add(PGS.toPVector(coord)); - } - }); + // normalise startOffset into [0,1) + double startNorm = startOffset % 1.0; + + PGS.applyToLinealGeometries(shape, ring -> { + // Normalise orientation so sampling/offset direction is consistent + if (Orientation.isCCW(ring.getCoordinates())) { + ring = ring.reverse(); + } + final var l = new IndexedLengthIndexedLine(ring); + if (l.getEndIndex() == 0) { + return ring; // skip zero-length components + } + final double increment = 1.0 / points; + double start = startNorm; + for (int i = 0; i < points; i++) { + double posFrac = (start + i * increment) % 1.0; + final Coordinate coord = l.extractPoint(posFrac * l.getEndIndex(), offsetDistance); + coords.add(PGS.toPVector(coord)); + } + return ring; // we don't modify geometries here }); return coords; } /** - * Generates a list of evenly distributed points along the boundary of each ring - * within a given polygonal shape, which may include its exterior and any - * interior rings (holes). + * Sample points along every linear component of a shape. + * *

- * This method is used to obtain a set of points that approximate the polygonal - * shape's outline and interior boundaries, with the points spaced at - * approximate equal intervals determined by the interPointDistance - * parameter. It supports complex shapes with interior rings (holes) by - * extracting points from all rings. - * - * @param shape The shape from which to generate the points. It - * should be a polygonal shape. - * @param interPointDistance The desired distance between consecutive points - * along each ring's boundary. This controls the - * spacing of points and the granularity of the - * representation. - * @param offsetDistance The offset distance perpendicular to each point on - * the ring's boundary. Positive values offset points - * outwards, while negative values bring them towards - * the interior. - * @return A list of PVector objects representing the points along the exterior - * and interior boundaries of the shape. - * @see #pointOnExterior(PShape, double, double) - * @see #densify(PShape, double) + * Samples points independently for each lineal component (polygon exterior + * rings, interior rings/holes, and standalone LineStrings) found in + * {@code shape}. Each component is sampled along its length with approximately + * {@code interPointDistance} spacing. {@code offsetDistance} is applied + * perpendicular to the component when extracting each point. + * + *

+ * Orientation is normalised per component before sampling so offsets are + * applied consistently. Components with zero length (or that yield zero + * samples) are skipped. The method collects points only and does not modify the + * input geometry. + * + * @param shape the input PShape containing polygon rings or + * LineStrings to sample + * @param interPointDistance approximate spacing between consecutive samples on + * each linear component + * @param offsetDistance perpendicular offset applied to each sampled point + * @return a list of PVector points sampled from every linear component; empty + * if no samples are produced + * @see #applyToLinealGeometries(PShape, java.util.function.UnaryOperator) * @since 1.3.0 */ public static List pointsOnExterior(PShape shape, double interPointDistance, double offsetDistance) { - List polygons = PGS.extractPolygons(fromPShape(shape)); List coords = new ArrayList<>(); - polygons.forEach(polygon -> { - PGS.extractLinearRings(polygon).forEach(ring -> { - if (Orientation.isCCW(ring.getCoordinates())) { - ring = ring.reverse(); - } - final LengthIndexedLine l = new LengthIndexedLine(ring); - final int points = (int) Math.round(l.getEndIndex() / interPointDistance); - final double increment = 1d / points; - for (double distance = 0; distance < 1; distance += increment) { - final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance); - coords.add(PGS.toPVector(coord)); - } - - }); + PGS.applyToLinealGeometries(shape, ring -> { + // Normalise orientation so sampling/offset direction is consistent + if (Orientation.isCCW(ring.getCoordinates())) { + ring = ring.reverse(); + } + final var l = new IndexedLengthIndexedLine(ring); + if (l.getEndIndex() == 0) { + return ring; + } + final int points = (int) Math.round(l.getEndIndex() / interPointDistance); + final double increment = 1d / points; + for (double distance = 0; distance < 1; distance += increment) { + final Coordinate coord = l.extractPoint(distance * l.getEndIndex(), offsetDistance); + coords.add(PGS.toPVector(coord)); + } + return ring; }); return coords; } /** - * Extracts evenly spaced dashed line segments along the perimeter of a shape. - * This method ensures that the segments are distributed uniformly along the - * shape's boundary, with the possibility of adjusting the start position of the - * first line based on an offset. - * - * @param shape The shape from which to extract the segments. - * @param lineLength The length of each segment. Must be a positive - * number. - * @param interLineDistance The distance between the end of one segment and the - * start of the next. Must be non-negative. - * @param offset The starting position offset (around the perimeter - * [0...1]) for the first line. Values > |1| loop - * around the shape. Positive values indicate a - * clockwise (CW) direction, and negative values - * indicate a counter-clockwise (CCW) direction. - * @return A GROUP PShape whose children are the extracted segments. + * Extracts evenly spaced dashed line segments along every boundary of a shape. + * + *

+ * For each linear component found in {@code shape} (each polygon perimeter or + * path) this method places repeated line segments along that component's + * length. Sampling is performed independently per component: exactly the same + * spacing/length rules are applied to each component, but counts and positions + * are computed from that component's own perimeter. + * + * @param shape input PShape containing polygons or paths (or a mix + * of both) + * @param lineLength desired length of each segment + * @param interLineDistance gap between consecutive segments along a component + * @param offset a fractional offset (a fraction of the component's + * perimeter) that sets where the first segment starts. + * Any real value is accepted and is normalised by + * modulo 1.0 (wrapped into [0,1)). Increasing offset + * values move the start point clockwise around the + * perimeter, while decreasing or negative values move + * it counter-clockwise. Varying {@code offset} over + * time will effectively "animate" the segments around + * the component. + * @return if the input contains only one linear element (i.e. a holeless + * polygon), a GROUP shape of segments; otherwise a GROUP PShape whose + * children are GROUPs of segment PShapes (one child group per linear + * component). * @since 2.0 */ public static PShape segmentsOnExterior(PShape shape, double lineLength, double interLineDistance, double offset) { - LengthIndexedLine l = makeIndexedLine(shape); - lineLength = Math.max(lineLength, 0.5); - interLineDistance = Math.max(interLineDistance, 0); // ensure >= 0 - - final double perimeter = l.getEndIndex(); - - double totalSegmentLength = lineLength + interLineDistance; - int numberOfLines = (int) Math.floor(perimeter / totalSegmentLength); - - double adjustmentFactor = perimeter / (numberOfLines * totalSegmentLength); - lineLength *= adjustmentFactor; - interLineDistance *= adjustmentFactor; - totalSegmentLength = lineLength + interLineDistance; - - offset = -offset; // positive values should wrap CW - double startingPosition = ((offset % 1.0) + 1.0) % 1.0 * perimeter; - - List lines = new ArrayList<>(numberOfLines); - - for (int i = 0; i < numberOfLines; i++) { - double lineStart = (startingPosition + i * totalSegmentLength) % perimeter; - double lineEnd = (lineStart + lineLength) % perimeter; - - Geometry segment; - PShape line; - if (lineStart < lineEnd) { - segment = l.extractLine(lineStart, lineEnd); - line = PGS_Conversion.toPShape(segment); - line.setName(String.valueOf(lineStart)); - lines.add(line); - } else { - // Handle case where line wraps around the end of the shape (straddles 0) - // combine 2 segments into a single linestring - Coordinate[] c1 = l.extractLine(lineStart, perimeter).getCoordinates(); - Coordinate[] c2 = l.extractLine(0, lineEnd).getCoordinates(); - segment = PGS.GEOM_FACTORY.createLineString(ArrayUtils.addAll(c1, c2)); - line = PGS_Conversion.toPShape(segment); - line.setName(String.valueOf(lineStart)); - lines.add(line); + // Normalise parameters + double lineLengthFinal = Math.max(lineLength, 0.1); + double interLineDistanceFinal = Math.max(interLineDistance, 0.0); + + PShape topGroup = new PShape(PConstants.GROUP); + + // Process every linear component independently + PGS.applyToLinealGeometries(shape, ring -> { + // Normalise orientation so offsets are consistent + if (Orientation.isCCW(ring.getCoordinates())) { + ring = ring.reverse(); + } + + LengthIndexedLine l = new LengthIndexedLine(ring); + double perimeter = l.getEndIndex(); + if (perimeter <= 0) { + return ring; // skip empty components } + + double totalSegmentLength = lineLengthFinal + interLineDistanceFinal; + int numberOfLines = (int) Math.floor(perimeter / totalSegmentLength); + if (numberOfLines <= 0) { + numberOfLines = 1; // ensure at least one placement to avoid division by zero + } + + // Adjust lengths so segments + gaps tile the perimeter evenly + double adjustmentFactor = perimeter / (numberOfLines * totalSegmentLength); + double adjLineLength = lineLengthFinal * adjustmentFactor; + double adjInterLineDistance = interLineDistanceFinal * adjustmentFactor; + totalSegmentLength = adjLineLength + adjInterLineDistance; + + // offset convention: positive values should wrap CW + double startingPosition = (((-offset) % 1.0) + 1.0) % 1.0 * perimeter; + + PShape compGroup = new PShape(PConstants.GROUP); + for (int i = 0; i < numberOfLines; i++) { + double lineStart = (startingPosition + i * totalSegmentLength) % perimeter; + double lineEnd = (lineStart + adjLineLength) % perimeter; + + Geometry segmentGeom; + if (lineStart < lineEnd) { + segmentGeom = l.extractLine(lineStart, lineEnd); + } else { + // Wrap-around case, combine two pieces + Coordinate[] c1 = l.extractLine(lineStart, perimeter).getCoordinates(); + Coordinate[] c2 = l.extractLine(0, lineEnd).getCoordinates(); + Coordinate[] combined = new Coordinate[c1.length + c2.length]; + System.arraycopy(c1, 0, combined, 0, c1.length); + System.arraycopy(c2, 0, combined, c1.length, c2.length); + segmentGeom = PGS.GEOM_FACTORY.createLineString(combined); + } + + PShape segShape = PGS_Conversion.toPShape(segmentGeom); + segShape.setName(String.format("i=%s@%s", i, lineStart)); + compGroup.addChild(segShape); + } + + if (!PGS.isEmptyShape(compGroup)) { + topGroup.addChild(compGroup); + } + + return ring; + }); + + if (topGroup.getChildCount() == 1) { + return topGroup.getChild(0); } - return PGS_Conversion.flatten(lines); + return topGroup; } /** - * Creates an CW-oriented length-indexed line from a given PShape. + * Creates an CW-oriented length-indexed line from a given PShape. NOTE extracts + * first ring from multipolygon */ - private static LengthIndexedLine makeIndexedLine(PShape shape) { + private static IndexedLengthIndexedLine makeIndexedLine(PShape shape) { Geometry g = fromPShape(shape); if (g instanceof Polygonal) { if (g.getGeometryType().equals(Geometry.TYPENAME_MULTIPOLYGON)) { @@ -347,11 +413,11 @@ private static LengthIndexedLine makeIndexedLine(PShape shape) { } LinearRing e = ((Polygon) g).getExteriorRing(); if (Orientation.isCCW(e.getCoordinates())) { - e = e.reverse(); + e = (LinearRing) e.copy().reverse(); } g = e; } - return new LengthIndexedLine(g); + return new IndexedLengthIndexedLine(g); } /** @@ -452,8 +518,8 @@ public static double tangentAngle(PShape shape, double perimeterRatio) { } /** - * Computes all points of intersection between the perimeters of - * two shapes. + * Computes all points of intersection between the linework of two + * shapes. *

* NOTE: This method shouldn't be confused with * {@link micycle.pgs.PGS_ShapeBoolean#intersect(PShape, PShape) @@ -465,13 +531,30 @@ public static double tangentAngle(PShape shape, double perimeterRatio) { * @return list of all intersecting points (as PVectors) */ public static List shapeIntersection(PShape a, PShape b) { - final HashSet points = new HashSet<>(); + final Collection segmentStringsA = SegmentStringUtil.extractSegmentStrings(fromPShape(a)); + final Collection segmentStringsB = SegmentStringUtil.extractSegmentStrings(fromPShape(b)); + + return intersections(segmentStringsA, segmentStringsB); + } + + static List intersections(Collection segmentStringsA, Collection segmentStringsB) { + final Collection larger, smaller; + if (segmentStringsA.size() > segmentStringsB.size()) { + larger = segmentStringsA; + smaller = segmentStringsB; + } else { + larger = segmentStringsB; + smaller = segmentStringsA; - final Collection segmentStrings = SegmentStringUtil.extractSegmentStrings(fromPShape(a)); - final MCIndexSegmentSetMutualIntersector mci = new MCIndexSegmentSetMutualIntersector(segmentStrings); + } + + final Set points = new HashSet<>(); + // finds possibly overlapping bounding boxes + final MCIndexSegmentSetMutualIntersector mci = new MCIndexSegmentSetMutualIntersector(larger); + // checks if two segments actually intersect final SegmentIntersectionDetector sid = new SegmentIntersectionDetector(); - mci.process(SegmentStringUtil.extractSegmentStrings(fromPShape(b)), new SegmentIntersector() { + mci.process(smaller, new SegmentIntersector() { @Override public void processIntersections(SegmentString e0, int segIndex0, SegmentString e1, int segIndex1) { sid.processIntersections(e0, segIndex0, e1, segIndex1); @@ -535,7 +618,6 @@ public static List lineSegmentsIntersection(List lineSegments) * * @param shape defines the region in which random points are generated * @param points number of points to generate within the shape region - * @return * @see #generateRandomPoints(PShape, int, long) * @see #generateRandomGridPoints(PShape, int, boolean, double) */ @@ -559,77 +641,13 @@ public static List generateRandomPoints(PShape shape, int points) { * @param points number of points to generate within the shape region * @param seed number used to initialize the underlying pseudorandom number * generator - * @return * @since 1.1.0 * @see #generateRandomPoints(PShape, int) * @see #generateRandomGridPoints(PShape, int, boolean, double) */ public static List generateRandomPoints(PShape shape, int points, long seed) { - final ArrayList randomPoints = new ArrayList<>(points); // random points out - - final IIncrementalTin tin = PGS_Triangulation.delaunayTriangulationMesh(shape); - final boolean constrained = !tin.getConstraints().isEmpty(); - final double totalArea = StreamSupport.stream(tin.getConstraints().spliterator(), false).mapToDouble(c -> ((PolygonConstraint) c).getArea()).sum(); - - // use arrays to hold variables (to enable assignment during consumer) - final SimpleTriangle[] largestTriangle = new SimpleTriangle[1]; - final double[] largestArea = new double[1]; - - final SplittableRandom r = new SplittableRandom(seed); - TriangleCollector.visitSimpleTriangles(tin, triangle -> { - final IConstraint constraint = triangle.getContainingRegion(); - if (!constrained || (constraint != null && constraint.definesConstrainedRegion())) { - final Vertex a = triangle.getVertexA(); - final Vertex b = triangle.getVertexB(); - final Vertex c = triangle.getVertexC(); - - // TODO more robust area (dense input produces slivers) - final double triangleArea = 0.5 * ((b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y)); - if (triangleArea > largestArea[0]) { - largestTriangle[0] = triangle; - largestArea[0] = triangleArea; - } - - /* - * Rather than choose a random triangle for each sample, pre-determine the - * number of samples per triangle and sample this number of points in each - * triangle successively. I conjecture that this results in a slightly more - * uniform random distribution, the downside of which is the resulting - * distribution has less entropy. - */ - double areaWeight = (triangleArea / totalArea) * points; - int samples = (int) Math.round(areaWeight); - if (r.nextDouble() <= (areaWeight - samples)) { - samples += 1; - } - for (int i = 0; i < samples; i++) { - final double s = r.nextDouble(); - final double t = Math.sqrt(r.nextDouble()); - final double rX = (1 - t) * a.x + t * ((1 - s) * b.x + s * c.x); - final double rY = (1 - t) * a.y + t * ((1 - s) * b.y + s * c.y); - randomPoints.add(new PVector((float) rX, (float) rY)); - } - } - }); - - final int remaining = points - randomPoints.size(); // due to rounding, may be a few above/below target number - if (remaining > 0) { - final Vertex a = largestTriangle[0].getVertexA(); - final Vertex b = largestTriangle[0].getVertexB(); - final Vertex c = largestTriangle[0].getVertexC(); - for (int i = 0; i < remaining; i++) { - double s = r.nextDouble(); - double t = Math.sqrt(r.nextDouble()); - double rX = (1 - t) * a.x + t * ((1 - s) * b.x + s * c.x); - double rY = (1 - t) * a.y + t * ((1 - s) * b.y + s * c.y); - randomPoints.add(new PVector((float) rX, (float) rY)); - } - } else if (remaining < 0) { - Collections.shuffle(randomPoints, new XoRoShiRo128PlusRandom(seed)); // shuffle so that points are removed from regions randomly - return randomPoints.subList(0, points); - } - - return randomPoints; + var sampler = new ShapeRandomPointSampler(shape, seed); + return sampler.getRandomPoints(points); } /** @@ -690,7 +708,7 @@ public static List generateRandomGridPoints(PShape shape, int maxPoints */ public static List generateRandomGridPoints(PShape shape, int maxPoints, boolean constrainedToCircle, double gutterFraction, long randomSeed) { Geometry g = fromPShape(shape); - IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(g); + YStripesPointInAreaLocator pointLocator = new YStripesPointInAreaLocator(g); RandomPointsInGridBuilder r = new SeededRandomPointsInGridBuilder(randomSeed); r.setConstrainedToCircle(constrainedToCircle); @@ -728,35 +746,39 @@ public static List generateRandomGridPoints(PShape shape, int maxPoints * @since 1.4.0 */ public static PShape nest(PShape shape, int n, double r) { - final Polygon polygon = (Polygon) fromPShape(shape); + final double rActual = r == 1 ? r : r % 1; - if (r != 1) { - r %= 1; - } - final Polygon[] derivedPolygons = new Polygon[n + 1]; - derivedPolygons[0] = polygon; - Polygon currentPolygon = polygon; + PShape out = new PShape(); + PGS_Processing.apply(shape, child -> { + final Polygon polygon = (Polygon) fromPShape(child); - for (int i = 0; i < n; i++) { - Coordinate[] inputCoordinates = currentPolygon.getCoordinates(); - int numVertices = inputCoordinates.length - 1; + final Polygon[] derivedPolygons = new Polygon[n + 1]; + derivedPolygons[0] = polygon; + Polygon currentPolygon = polygon; - Coordinate[] derivedCoordinates = new Coordinate[numVertices + 1]; + for (int i = 0; i < n; i++) { + Coordinate[] inputCoordinates = currentPolygon.getCoordinates(); + int numVertices = inputCoordinates.length - 1; - for (int k = 0; k < numVertices; k++) { - double x = inputCoordinates[k].x * (1 - r) + inputCoordinates[(k + 1) % numVertices].x * r; - double y = inputCoordinates[k].y * (1 - r) + inputCoordinates[(k + 1) % numVertices].y * r; - derivedCoordinates[k] = new Coordinate(x, y); - } - derivedCoordinates[numVertices] = derivedCoordinates[0]; // close the ring + Coordinate[] derivedCoordinates = new Coordinate[numVertices + 1]; - Polygon derivedPolygon = PGS.GEOM_FACTORY.createPolygon(derivedCoordinates); + for (int k = 0; k < numVertices; k++) { + double x = inputCoordinates[k].x * (1 - r) + inputCoordinates[(k + 1) % numVertices].x * rActual; + double y = inputCoordinates[k].y * (1 - r) + inputCoordinates[(k + 1) % numVertices].y * rActual; + derivedCoordinates[k] = new Coordinate(x, y); + } + derivedCoordinates[numVertices] = derivedCoordinates[0]; // close the ring - derivedPolygons[i + 1] = derivedPolygon; - currentPolygon = derivedPolygon; - } + Polygon derivedPolygon = PGS.GEOM_FACTORY.createPolygon(derivedCoordinates); + derivedPolygon.setUserData(polygon.getUserData()); // copy styling - return toPShape(GEOM_FACTORY.createMultiPolygon(derivedPolygons)); + derivedPolygons[i + 1] = derivedPolygon; + currentPolygon = derivedPolygon; + } + out.addChild(toPShape(GEOM_FACTORY.createMultiPolygon(derivedPolygons))); + }); + + return out.getChildCount() == 1 ? out.getChild(0) : out; } /** @@ -818,7 +840,6 @@ public static PShape removeHiddenLines(PShape shape) { * * @param shape a single polygonal shape or GROUP polygonal shape * @param areaThreshold removes any holes with an area smaller than this value - * @return */ public static PShape removeSmallHoles(PShape shape, double areaThreshold) { final Geometry g = fromPShape(shape); @@ -929,7 +950,7 @@ public static PShape split(PShape shape) { * @see #split(PShape) */ public static PShape split(final PShape shape, int splitDepth) { - // https://stackoverflow.com/questions/64252638/how-to-split-a-jts-polygon + // https://stackoverflow.com/questions/64252638/ splitDepth = Math.max(0, splitDepth); Deque stack = new ArrayDeque<>(); stack.add(fromPShape(shape)); @@ -955,10 +976,10 @@ public static PShape split(final PShape shape, int splitDepth) { Envelope ulEnv = new Envelope(minX, midX, midY, maxY); Envelope urEnv = new Envelope(midX, maxX, midY, maxY); - Geometry UL = OverlayNG.overlay(slice, toGeometry(ulEnv), OverlayNG.INTERSECTION, noder); - Geometry UR = OverlayNG.overlay(slice, toGeometry(urEnv), OverlayNG.INTERSECTION, noder); - Geometry LL = OverlayNG.overlay(slice, toGeometry(llEnv), OverlayNG.INTERSECTION, noder); - Geometry LR = OverlayNG.overlay(slice, toGeometry(lrEnv), OverlayNG.INTERSECTION, noder); + Geometry UL = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(ulEnv), OverlayNG.INTERSECTION, noder); + Geometry UR = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(urEnv), OverlayNG.INTERSECTION, noder); + Geometry LL = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(llEnv), OverlayNG.INTERSECTION, noder); + Geometry LR = OverlayNG.overlay(slice, GEOM_FACTORY.toGeometry(lrEnv), OverlayNG.INTERSECTION, noder); // Geometries may not be polygonal; in which case, do not include in output. if (UL instanceof Polygonal && !UL.isEmpty()) { @@ -985,6 +1006,100 @@ public static PShape split(final PShape shape, int splitDepth) { return partitions; } + /** + * Splits the input shape into multiple wedge-shaped regions by connecting the + * centroid to each vertex. + *

+ * This method computes the centroid of the given shape, and then draws a line + * from the centroid to every vertex on the shape’s exterior. The shape is then + * split along these lines, partitioning it into distinct regions—one for each + * side of the original shape. For polygons with {@code n} vertices, this + * typically creates {@code n} wedge-shaped regions. In the case of concave + * polygons, this operation may result in more than {@code n} regions due to the + * underlying geometry. + *

+ *

+ * The returned shape is a GROUP {@code PShape}, whose children are the + * resulting partitioned regions. + *

+ * + * @param shape The shape to be partitioned. Typically a polygonal + * {@code PShape}. + * @return A GROUP {@code PShape} which contains one child for each wedge-shaped + * partition created by splitting from the centroid to each vertex. + * @since 2.1 + * @see #centroidSplit(PShape, int, double) + */ + public static PShape centroidSplit(PShape shape) { + var splits = PGS.prepareLinesPShape(null, null, null); + var c = PGS_ShapePredicates.centroid(shape); + PGS_Conversion.toPVector(shape).forEach(p -> { + splits.vertex(p.x, p.y); + splits.vertex(c.x, c.y); + }); + splits.endShape(); + + var croppedLines = toPShape(fromPShape(shape).intersection(fromPShape(splits))); + var splitPolygons = PGS_ShapeBoolean.unionLines(shape, croppedLines); + return PGS_Optimisation.radialSortFaces(splitPolygons, c, 0); + } + + /** + * Splits the input shape into {@code n} wedge-shaped regions by connecting the + * centroid to points along the perimeter. + *

+ * This method computes the centroid of the given shape, and then splits the + * shape by connecting the centroid to {@code n} points sampled evenly (with + * optional offset) around the outer ring (perimeter) of the shape. The split + * lines start at these points and end at the centroid. The operation results in + * approximately {@code n} regions for convex shapes, but may produce more than + * {@code n} partitions for highly concave input. + *

+ *

+ * The {@code offset} parameter determines the rotation of the sampling around + * the perimeter. + *

+ *

+ * The returned shape is a GROUP {@code PShape}, whose children are the + * resulting wedge-shaped partitions, radially sorted around the centroid. + *

+ * + * @param shape The shape to be split; typically a polygonal {@code PShape}. + * @param n The number of splits (rays) to create from the centroid; + * usually determines number of regions. + * @param offset The offset for the split lines, as a fraction of the perimeter, + * in [0, 1). + * @return A GROUP {@code PShape}, with child shapes being the regions created + * by the centroid splits, sorted radially. + * @see #centroidSplit(PShape) + */ + public static PShape centroidSplit(PShape shape, int n, double offset) { + // 1) build your n radial “spoke” lines from the centroid out to the boundary + PVector c = PGS_ShapePredicates.centroid(shape); + PShape splits = PGS.prepareLinesPShape(null, null, null); + var in = fromPShape(shape); + if (in instanceof Polygon) { + in = ((Polygon) in).getExteriorRing(); + } + LengthIndexedLine l = new LengthIndexedLine(in); + + for (int i = 0; i < n; i++) { + double f = (i / (double) n + offset) % 1.0; + Coordinate coord = l.extractPoint(f * l.getEndIndex()); + PVector p = PGS.toPVector(coord); + splits.vertex(p.x, p.y); + splits.vertex(c.x, c.y); + } + splits.endShape(); + + // 2) slice the shape by those lines + PShape cropLines = toPShape(fromPShape(shape).intersection(fromPShape(splits))); + PShape splitPolygons = PGS_ShapeBoolean.unionLines(shape, cropLines); + + // order faces so that face[0] is the wedge starting at offset + return PGS_Optimisation.radialSortFaces(splitPolygons, c, offset); + } + /** * Partitions shape(s) into convex (simple) polygons. * @@ -1210,6 +1325,29 @@ public static PShape eliminateSlivers(PShape shape, double threshold) { return toPShape(out); // better on smaller thresholds } + /** + * Dissolves the linear components of a shape (or group of shapes) into a set of + * maximal-length lines in which each unique segment appears once. + *

+ * Example uses: avoid double-drawing shared polygon edges; merge contiguous + * segments for cleaner rendering/export; or extract non-redundant network edges + * for topology or routing. + *

+ *

+ * This method does not node the input lines. Crossing segments without a vertex + * at the intersection remain crossing in the output. + *

+ * + * @param shape The {@code PShape} containing linear geometry. + * @return A GROUP {@code PShape} whose children are the dissolved + * maximal-length lines. + * @since 2.1 + */ + public static PShape dissolve(PShape shape) { + var g = fromPShape(shape); + return toPShape(LineDissolver.dissolve(g)); + } + /** * Attempts to fix shapes with invalid geometry, while preserving its original * form and location as much as possible. See @@ -1228,6 +1366,26 @@ public static PShape fix(PShape shape) { return toPShape(GeometryFixer.fix(fromPShape(shape))); } + /** + * Normalises a shape by standardising its vertex ordering and orientation: + *
    + *
  • The outer shell (exterior contour) is oriented clockwise (CW).
  • + *
  • All holes (interior contours) are oriented counterclockwise (CCW).
  • + *
  • Each contour (shell or hole) is rotated so its sequence starts at the + * vertex with the minimum coordinate (lexicographically by x, then y).
  • + *
+ * + * @param shape the {@code PShape} to normalise + * @return a new {@code PShape} instance with standardised vertex winding and + * canonicalised vertex rotation + * @since 2.1 + */ + public static PShape normalise(PShape shape) { + var g = fromPShape(shape); + g.normalize(); + return toPShape(g); + } + /** * Filters out the children of a given PShape object based on a given Predicate * function. Child shapes are filtered when the predicate is true: "remove @@ -1256,7 +1414,7 @@ public static PShape fix(PShape shape) { */ public static PShape filterChildren(PShape shape, Predicate filterFunction) { filterFunction = filterFunction.negate(); - List filteredFaces = PGS_Conversion.getChildren(shape).stream().filter(filterFunction::test).collect(Collectors.toList()); + List filteredFaces = PGS_Conversion.getChildren(shape).stream().filter(filterFunction::test).toList(); return PGS_Conversion.flatten(filteredFaces); } @@ -1335,6 +1493,86 @@ public static PShape transformWithIndex(PShape shape, BiFunction function.apply(i, children.get(i))).filter(Objects::nonNull).toList()); } + /** + * Applies a specified transformation function to each child of the given PShape + * and returns a list of results produced by the function. + *

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

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

+ * The transformation function can: + *

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

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

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

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

+ * The transformation function can: + *

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

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

@@ -1404,12 +1642,6 @@ public static PShape applyWithIndex(PShape shape, BiConsumer ap return shape; } - private static Polygon toGeometry(Envelope envelope) { - return GEOM_FACTORY.createPolygon(GEOM_FACTORY.createLinearRing(new Coordinate[] { new Coordinate(envelope.getMinX(), envelope.getMinY()), - new Coordinate(envelope.getMaxX(), envelope.getMinY()), new Coordinate(envelope.getMaxX(), envelope.getMaxY()), - new Coordinate(envelope.getMinX(), envelope.getMaxY()), new Coordinate(envelope.getMinX(), envelope.getMinY()) })); - } - /** * Used by slice() */ diff --git a/src/main/java/micycle/pgs/PGS_SegmentSet.java b/src/main/java/micycle/pgs/PGS_SegmentSet.java index 51f1f43c..fb3059f1 100644 --- a/src/main/java/micycle/pgs/PGS_SegmentSet.java +++ b/src/main/java/micycle/pgs/PGS_SegmentSet.java @@ -11,9 +11,9 @@ import java.util.stream.Collectors; import org.jgrapht.alg.interfaces.MatchingAlgorithm; +import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedMatching; import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching; import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense; -import org.jgrapht.graph.SimpleGraph; import org.locationtech.jts.algorithm.RobustLineIntersector; import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; import org.locationtech.jts.dissolve.LineDissolver; @@ -163,17 +163,6 @@ public static List graphMatchedSegments(List points) { return graphMatchedSegments(PGS_Triangulation.delaunayTriangulationMesh(points)); } - /** - * Number of segments = #vertices/2 - * - * Let P be a set of n points, not all on the same line. Let k be the number of - * points on the boundary of the convex hull of P. Any triangulation of P has 1) - * 2n − 2 − k triangles, and 2) 3n − 3 − k edges - * - * @param triangulation - * @return - */ - /** * Generates non-intersecting segments via a Perfect matching algorithm * applied to the given triangulation. @@ -190,15 +179,15 @@ public static List graphMatchedSegments(List points) { */ public static List graphMatchedSegments(IIncrementalTin triangulation) { // explained here https://stackoverflow.com/a/72565245/ - final SimpleGraph g = PGS_Triangulation.toGraph(triangulation); + final var g = PGS_Triangulation.toGraph(triangulation); MatchingAlgorithm m; try { - m = new KolmogorovWeightedPerfectMatching<>(g, KolmogorovWeightedPerfectMatching.DEFAULT_OPTIONS, ObjectiveSense.MAXIMIZE); - return new ArrayList<>(m.getMatching().getEdges()); - } catch (IllegalArgumentException e) { - // catch exception if no perfect matching is possible - return new ArrayList<>(); + m = new KolmogorovWeightedPerfectMatching<>(g, ObjectiveSense.MAXIMIZE); + m.getMatching(); + } catch (Exception e2) { + m = new KolmogorovWeightedMatching<>(g, ObjectiveSense.MAXIMIZE); } + return new ArrayList<>(m.getMatching().getEdges()); } /** @@ -393,6 +382,14 @@ public static PShape toPShape(Collection segments, @Nullable Integer stro return lines; } + public static Map toBag(List edges) { + Map edgeBag = new HashMap<>(edges.size()); + for (PEdge edge : edges) { + edgeBag.put(edge, edgeBag.getOrDefault(edge, 0) + 1); + } + return edgeBag; + } + /** * Dissolves the edges from a collection of {@link micycle.pgs.commons.PEdge * PEdges} into a set of maximal-length LineStrings in which each unique segment @@ -416,6 +413,26 @@ public static PShape dissolve(Collection segments) { return PGS_Conversion.toPShape(dissolved); } + /** + * Computes all intersection points between two collections of line segments. + *

+ * Given two collections of edges, this method finds and returns all points + * where a segment from the first collection intersects with a segment from the + * second collection, including intersection endpoints if applicable. + *

+ * + * @param edgesA the first collection of line segments to compare + * @param edgesB the second collection of line segments to compare + * @return a list of {@link PVector} representing all intersection points found + * between the two collections of segments + * @since 2.1 + */ + public static List intersections(Collection edgesA, Collection edgesB) { + var segsA = fromPEdges(edgesA); + var segsB = fromPEdges(edgesB); + return PGS_Processing.intersections(segsA, segsB); + } + /** * Extracts a list of unique PEdge segments representing the given shape. *

@@ -519,6 +536,41 @@ public static List filterAxisAligned(List segments, double angleDe return filtered; } + /** + * Removes segments that are near others. More specifically, removes any segment + * that lies closer than a given threshold to a previously retained segment. + *

+ * This method uses a simple greedy, O(n²) algorithm: it iterates through the + * input {@code segments} in order, keeps the first segment unconditionally, and + * for each subsequent segment rejects it if its distance to any segment already + * in the output list is less than {@code distance}. + *

+ * + * @param segments the list of segments to filter. + * @param distance the minimum allowed distance between any two segments in the + * returned list; must be non-negative. + * @return a new {@link List} containing those segments from the input (in + * original order) such that each pair of segments in the returned list + * is at least {@code distance} apart. + * @since 2.1 + */ + public static List filterNear(List segments, double distance) { + List filtered = new ArrayList<>(); + for (PEdge seg : segments) { + boolean tooClose = false; + for (PEdge kept : filtered) { + if (seg.distance(kept) < distance) { + tooClose = true; + break; + } + } + if (!tooClose) { + filtered.add(seg); + } + } + return filtered; + } + /** * Retains line segments from a set of line segments that are wholly contained * within a given shape. diff --git a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java index 54a393be..f0119799 100644 --- a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java +++ b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java @@ -8,17 +8,31 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; - +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.prep.PreparedGeometry; import org.locationtech.jts.geom.prep.PreparedGeometryFactory; import org.locationtech.jts.geom.util.GeometryFixer; +import org.locationtech.jts.geom.util.LinearComponentExtracter; +import org.locationtech.jts.noding.NodedSegmentString; +import org.locationtech.jts.noding.Noder; +import org.locationtech.jts.noding.SegmentString; +import org.locationtech.jts.noding.SegmentStringDissolver; +import org.locationtech.jts.noding.SegmentStringUtil; +import org.locationtech.jts.noding.snapround.SnapRoundingNoder; +import org.locationtech.jts.operation.overlay.OverlayOp; import org.locationtech.jts.operation.overlayng.CoverageUnion; import org.locationtech.jts.operation.overlayng.OverlayNG; +import org.locationtech.jts.operation.polygonize.Polygonizer; import org.locationtech.jts.operation.union.UnaryUnionOp; import org.locationtech.jts.util.GeometricShapeFactory; +import micycle.pgs.commons.FastOverlapRegions; +import micycle.pgs.commons.Nullable; import micycle.pgs.commons.PEdge; import processing.core.PConstants; import processing.core.PShape; @@ -26,7 +40,7 @@ /** * Boolean set-operations for 2D shapes. - * + * * @author Michael Carleton * */ @@ -46,7 +60,7 @@ private PGS_ShapeBoolean() { * intersecting parts of individual faces will be collapsed into a single area. * To preserve individual faces during intersection, use * {@link #intersectMesh(PShape, PShape) intersectMesh()}. - * + * * @param a The first shape to be intersected. * @param b The second shape to intersect with the first. * @return A new shape representing the area of intersection between the two @@ -76,7 +90,7 @@ public static PShape intersect(final PShape a, final PShape b) { * {@link #intersect(PShape, PShape) intersect(a, b)} method repeatedly for * every face of a mesh-like shape a against an area * b. - * + * * @param mesh A mesh-like GROUP shape that will be intersected with the * polygonal area. * @param area A polygonal shape with which the mesh will be intersected. @@ -85,21 +99,33 @@ 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); - - 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; + final Geometry areaGeometry = fromPShape(area); + final Envelope areaEnvelope = areaGeometry.getEnvelopeInternal(); + final PreparedGeometry preparedArea = PreparedGeometryFactory.prepare(areaGeometry); + + List intersections = PGS_Conversion.getChildren(mesh).parallelStream().map(child -> { + Geometry face = PGS_Conversion.fromPShape(child); + // Quick check: if the envelopes don’t even intersect, skip this face. + if (!areaEnvelope.intersects(face.getEnvelopeInternal())) { + return null; } - }).collect(Collectors.toList()); - return PGS_Conversion.toPShape(faces); + // Fast test: if the area completely contains the face then no need for overlay. + if (preparedArea.containsProperly(face)) { + return face; + } + // Otherwise, if the face intersects the area, compute the actual intersection. + if (preparedArea.intersects(face)) { + Geometry intersection = OverlayNG.overlay(face, areaGeometry, OverlayNG.INTERSECTION); + if (!intersection.isEmpty()) { + // Propagate any user data + intersection.setUserData(face.getUserData()); + return intersection; + } + } + return null; + }).filter(Objects::nonNull).collect(Collectors.toList()); + + return PGS_Conversion.toPShape(intersections); } /** @@ -148,17 +174,103 @@ public static PShape union(final Collection shapes) { * them into a new shape that encompasses the total area of all input shapes. * Overlapping areas among the shapes are included only once in the resulting * shape. - * + * * @param shapes A variable number of PShape instances to be unified. * @return A new PShape object representing the union of the input shapes. * @see #union(PShape, PShape) For a union operation on two shapes. * @see #union(PShape...) For a union operation on a list of shapes. */ - public static PShape union(PShape... shapes) { return union(Arrays.asList(shapes)); } + /** + * Unions the linework of two shapes, creating polygonal faces from their + * intersecting lines. This method focuses on the linework (linear components) + * of the input geometries rather than their areas. It differs from a standard + * polygon union operation, as it processes the lines to find intersections and + * generates new polygonal faces based on the resulting linework. + *

+ * If {@code b} is {@code null}, only the linework from {@code a} is used. + *

+ * + * @param a The first input geometry as a {@link PShape}. + * @param b b The second input geometry as a {@link PShape}, or {@code null} to + * use only {@code a}'s linework. + * @return A new {@link PShape} representing the polygonal faces created by the + * union of the input geometries' linework. Returns {@code null} if the + * input geometries do not produce any valid polygonal faces. + * @since 2.1 + */ + public static PShape unionLines(PShape a, @Nullable PShape b) { + var aG = fromPShape(a); + var bG = b == null ? PGS.GEOM_FACTORY.createEmpty(2) : fromPShape(b); + var lA = LinearComponentExtracter.getGeometry(aG); + var lB = LinearComponentExtracter.getGeometry(bG); + + Polygonizer polygonizer = new Polygonizer(false); + polygonizer.add(OverlayNG.overlay(lA, lB, OverlayOp.UNION, new PrecisionModel(-1e-3))); + + return toPShape(polygonizer.getGeometry()); + } + + /** + * Unions the linework of the given shapes (varargs form). + *

+ * This is a convenience overload that forwards to + * {@link #unionLines(Collection)}. Null entries in {@code shapes} are ignored + * by the collection overload. + *

+ * + * @param shapes Zero or more {@link PShape} instances whose linework will be + * unioned. May be empty. + * @return A new {@link PShape} representing polygonal faces created by the + * union of the provided shapes' linework, or {@code null} if no valid + * polygonal faces resulted. + * @since 2.1 + */ + public static PShape unionLines(PShape... shapes) { + return unionLines(Arrays.asList(shapes)); + } + + /** + * Unions the linework of a collection of shapes, creating polygonal faces from + * their intersecting lines. + *

+ * This method focuses on the linework (linear components) of the input + * geometries rather than their areas. It differs from a standard polygon union + * operation, as it processes the lines to find intersections and generates new + * polygonal faces based on the resulting linework. + * + * @param shapes A collection of {@link PShape} instances to union. May contain + * {@code null} elements which will be ignored. The collection + * itself should not be {@code null}. + * @return A new {@link PShape} representing the polygonal faces created by the + * union of the provided shapes' linework, or {@code null} if no valid + * polygonal faces were produced. + * @since 2.1 + */ + @SuppressWarnings("unchecked") + public static PShape unionLines(Collection shapes) { + var totalSegs = shapes.stream().map(s -> fromPShape(s)).filter(Objects::nonNull) + .flatMap(g -> ((List) SegmentStringUtil.extractSegmentStrings(g)).stream()).toList(); + + SegmentStringDissolver d = new SegmentStringDissolver(); + d.dissolve(totalSegs); + var dissolvedSegs = d.getDissolved(); + + Noder noder = new SnapRoundingNoder(new PrecisionModel(-5e-3)); + noder.computeNodes(dissolvedSegs); + var nodedSegs = noder.getNodedSubstrings(); + + var segmentGeometry = SegmentStringUtil.toGeometry(nodedSegs, PGS.GEOM_FACTORY); + + Polygonizer polygonizer = new Polygonizer(); + polygonizer.add(segmentGeometry); + var polys = (List) polygonizer.getPolygons(); + return toPShape(polys); + } + /** * @see #unionMesh(PShape) * @param faces collection of faces comprising a mesh @@ -254,12 +366,42 @@ public static PShape unionMeshWithoutHoles(final Collection mesh) { return PGS_Conversion.fromPVector(orderedVertices); } + /** + * Finds all regions covered by at least two input shapes. + *

    + *
  • If {@code merged} is true, each child in the output is a disjoint + * component representing the union of all overlapping regions. No returned + * children overlap each other (multi-way overlaps are merged).
  • + *
  • If {@code merged} is false, each child is an individual pairwise overlap + * region. These children may themselves overlap (e.g., areas with three or more + * input overlaps will appear in multiple children).
  • + *
+ * Only regions covered by two or more inputs are included. + * + * Use {@code merged = true} for a clean, non-overlapping set of overlap regions + * (as merged maximal patches). Use {@code merged = false} to examine or style + * every individual pairwise overlap, noting that children may overlap. + * + * @param shapes input collection of {@code PShape} area shapes (e.g., polygons) + * @param merged if true, merges all overlapping regions into a minimal set of + * disjoint (non-overlapping) children; if false, each child is a + * pairwise overlap region, and children may mutually overlap in + * areas covered by three or more inputs + * @return a group {@code PShape} with each child representing a + * multiply-covered region + * @since 2.1 + */ + public static PShape overlapRegions(Collection shapes, boolean merged) { + var worker = new FastOverlapRegions(fromPShape(PGS_Conversion.flatten(shapes))); + return toPShape(worker.get(merged)); + } + /** * Subtracts one shape (b) from another shape (a) and returns the resulting * shape. This procedure is also known as "difference". *

* Subtract is the opposite of {@link #union(PShape, PShape) union()}. - * + * * @param a The PShape from which the other PShape will be subtracted. * @param b The PShape that will be subtracted from the first PShape. * @return A new PShape representing the difference between the two input @@ -282,7 +424,7 @@ public static PShape subtract(final PShape a, final PShape b) { * {@link #subtract(PShape, PShape) subtract()} but this method produces valid * results only if all holes lie inside the shell and holes are not * nested. - * + * * @param shell polygonal shape * @param holes single polygon, or GROUP shape, whose children are holes that * lie within the shell @@ -348,7 +490,7 @@ public static PShape subtractMesh(PShape mesh, PShape area) { * Calculates the symmetric difference between two shapes. The symmetric * difference is the set of regions that exist in either of the two shapes but * not in their intersection. - * + * * @param a The first shape. * @param b The second shape. * @return A new shape representing the symmetric difference between the two @@ -368,7 +510,7 @@ public static PShape symDifference(PShape a, PShape b) { * The resulting shape corresponds to the portion of the rectangle not covered * by the input shape. The operation is essentially a subtraction of the input * shape from the rectangle. - * + * * @param shape The input shape for which the complement is to be determined. * @param width The width of the rectangular boundary. * @param height The height of the rectangular boundary. @@ -380,7 +522,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..12cd5529 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; @@ -15,7 +17,7 @@ import org.locationtech.jts.algorithm.MinimumDiameter; import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle; -import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; +import org.locationtech.jts.algorithm.locate.PointOnGeometryLocator; import org.locationtech.jts.algorithm.match.HausdorffSimilarityMeasure; import org.locationtech.jts.coverage.CoverageUnion; import org.locationtech.jts.coverage.CoverageValidator; @@ -29,6 +31,8 @@ import org.locationtech.jts.geom.util.PolygonExtracter; import org.locationtech.jts.operation.valid.IsValidOp; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + import micycle.pgs.commons.EllipticFourierDesc; import micycle.pgs.commons.GeometricMedian; import micycle.trapmap.TrapMap; @@ -85,7 +89,7 @@ public static boolean containsPoint(PShape shape, PVector point) { * @return true if every point is contained within the shape */ public static boolean containsAllPoints(PShape shape, Collection points) { - final IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(fromPShape(shape)); + final PointOnGeometryLocator pointLocator = new YStripesPointInAreaLocator(fromPShape(shape)); for (PVector p : points) { if (pointLocator.locate(new Coordinate(p.x, p.y)) == Location.EXTERIOR) { return false; @@ -107,7 +111,7 @@ public static boolean containsAllPoints(PShape shape, Collection points * point at same index */ public static List containsPoints(PShape shape, Collection points) { - final IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(fromPShape(shape)); + final PointOnGeometryLocator pointLocator = new YStripesPointInAreaLocator(fromPShape(shape)); ArrayList bools = new ArrayList<>(points.size()); for (PVector p : points) { bools.add(pointLocator.locate(new Coordinate(p.x, p.y)) != Location.EXTERIOR); @@ -129,7 +133,7 @@ public static List containsPoints(PShape shape, Collection poi * @return a filtered view of the input points */ public static List findContainedPoints(PShape shape, Collection points) { - final IndexedPointInAreaLocator pointLocator = new IndexedPointInAreaLocator(fromPShape(shape)); + final PointOnGeometryLocator pointLocator = new YStripesPointInAreaLocator(fromPShape(shape)); List contained = new ArrayList<>(); for (PVector p : points) { if (pointLocator.locate(new Coordinate(p.x, p.y)) != Location.EXTERIOR) { @@ -415,11 +419,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 +436,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); } /** @@ -450,6 +455,18 @@ public static double convexity(PShape shape) { return g.getArea() / g.convexHull().getArea(); } + /** + * Returns the total number of vertices that make up a shape. + *

+ * Unlike PShape.getVertexCount() this method properly returns the + * number of vertices in GROUP and primitive shapes. + * + * @since 2.1 + */ + public static int vertexCount(PShape shape) { + return PGS_Conversion.getChildren(shape).stream().mapToInt(s -> s.getVertexCount()).sum(); + } + /** * Counts the number of holes in a shape. *

@@ -510,6 +527,79 @@ public static double maximumInteriorAngle(PShape shape) { return maxAngle; } + /** + * Computes the minimum/interior angle of a polygon. + * + * @param shape simple polygonal shape + * @return the smallest interior angle in the range [0, 2*PI] + * @since 2.1 + */ + public static double minimumInteriorAngle(PShape shape) { + // Extract coordinates from PShape + final Coordinate[] coordz = fromPShape(shape).getCoordinates(); + final CoordinateList coords = new CoordinateList(coordz); + // Remove the closing duplicate (last == first) + coords.remove(coords.size() - 1); + + // Ensure consistent winding (we want CW ordering for interior‐angle convention) + if (Orientation.isCCW(coordz)) { + Collections.reverse(coords); + } + + // Initialize to the largest possible angle + double minAngle = 2 * Math.PI; + + // Walk triples of consecutive vertices to compute interior angles + for (int i = 0; i < coords.size(); i++) { + Coordinate p0 = coords.get(i); + Coordinate p1 = coords.get((i + 1) % coords.size()); + Coordinate p2 = coords.get((i + 2) % coords.size()); + double angle = Angle.interiorAngle(p0, p1, p2); + minAngle = Math.min(minAngle, angle); + } + + return minAngle; + } + + /** + * Calculates all 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_Tiling.java b/src/main/java/micycle/pgs/PGS_Tiling.java index 90495e8d..ea8e3807 100644 --- a/src/main/java/micycle/pgs/PGS_Tiling.java +++ b/src/main/java/micycle/pgs/PGS_Tiling.java @@ -1,12 +1,24 @@ package micycle.pgs; +import static micycle.pgs.PGS.GEOM_FACTORY; + import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.SplittableRandom; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.operation.overlayng.RingClipper; +import org.locationtech.jts.operation.polygonize.Polygonizer; +import org.locationtech.jts.operation.union.UnaryUnionOp; + import micycle.pgs.color.Colors; import micycle.pgs.commons.DoyleSpiral; import micycle.pgs.commons.HatchTiling; +import micycle.pgs.commons.PEdge; import micycle.pgs.commons.PenroseTiling; import micycle.pgs.commons.RectangularSubdivision; import micycle.pgs.commons.SquareTriangleTiling; @@ -21,33 +33,42 @@ *

* A tiling is created when a collection of tiles fills a plane such that no * gaps occur between the tiles and no two tiles overlap each other. - * + *

+ *

+ * Naming convention in this class:
+ * Methods ending with "subdivision" recursively break the plane down into + * smaller and smaller shapes, usually of the same geometric type at each step + * (for example, rectangles are split into smaller rectangles).
+ * Methods ending with "division" split the plane all at once, in a single step, + * often by making several cuts or slices, with no recursion. + *

+ * * @author Michael Carleton * @since 1.2.0 */ public final class PGS_Tiling { - private static final double ROOT3 = Math.sqrt(3); + private static final double ROOT3 = Math.sqrt(3); // for hex private PGS_Tiling() { } /** * Recursively and randomly subdivides the given/bounded plane into rectangles. - * + * * @param width width of the quad subdivision plane * @param height height of the quad subdivision plane * @param maxDepth maximum number of subdivisions (recursion depth) * @return a GROUP PShape, where each child shape is a face of the subdivision * @see #rectSubdivision(double, double, int, long) seeded rectSubdivsion() */ - public static PShape rectSubdivision(double width, double height, int maxDepth) { + public static PShape rectSubdivision(final double width, final double height, final int maxDepth) { return rectSubdivision(width, height, maxDepth, System.nanoTime()); } /** * Recursively and randomly subdivides the given/bounded plane into rectangles. - * + * * @param width width of the quad subdivision plane * @param height height of the quad subdivision plane * @param maxDepth maximum number of subdivisions (recursion depth) @@ -55,15 +76,18 @@ public static PShape rectSubdivision(double width, double height, int maxDepth) * @return a GROUP PShape, where each child shape is a face of the subdivision * @see #rectSubdivision(double, double, int) non-seeded rectSubdivsion() */ - public static PShape rectSubdivision(double width, double height, int maxDepth, long seed) { + public static PShape rectSubdivision(final double width, final double height, int maxDepth, final long seed) { maxDepth++; // so that given depth==0 returns non-divided square final RectangularSubdivision rectangularSubdivision = new RectangularSubdivision(width, height, maxDepth, seed); - return rectangularSubdivision.divide(); + var division = rectangularSubdivision.divide(); + division = PGS_Conversion.setAllFillColor(division, Colors.WHITE); + division = PGS_Conversion.setAllStrokeColor(division, Colors.PINK, 2); + return division; } /** * Recursively and randomly subdivides the given/bounded plane into triangles. - * + * * @param width width of the subdivision plane * @param height height of the subdivision plane * @param maxDepth maximum number of subdivisions (recursion depth) @@ -71,13 +95,13 @@ public static PShape rectSubdivision(double width, double height, int maxDepth, * @see #triangleSubdivision(double, double, int, long) seeded * triangleSubdivsion() */ - public static PShape triangleSubdivision(double width, double height, int maxDepth) { + public static PShape triangleSubdivision(final double width, final double height, final int maxDepth) { return triangleSubdivision(width, height, maxDepth, System.nanoTime()); } /** * Recursively and randomly subdivides the given/bounded plane into triangles. - * + * * @param width width of the subdivision plane * @param height height of the subdivision plane * @param maxDepth maximum number of subdivisions (recursion depth) @@ -86,30 +110,33 @@ public static PShape triangleSubdivision(double width, double height, int maxDep * @see PGS_Tiling#triangleSubdivision(double, double, int) non-seeded * triangleSubdivision() */ - public static PShape triangleSubdivision(double width, double height, int maxDepth, long seed) { + public static PShape triangleSubdivision(final double width, final double height, int maxDepth, final long seed) { maxDepth++; // so that given depth==0 returns non-divided triangle final TriangleSubdivision subdivision = new TriangleSubdivision(width, height, maxDepth, seed); - return subdivision.divide(); + var division = subdivision.divide(); + division = PGS_Conversion.setAllFillColor(division, Colors.WHITE); + division = PGS_Conversion.setAllStrokeColor(division, Colors.PINK, 2); + return division; } /** * Recursively and randomly subdivides the given/bounded plane into convex quad * polygons. - * + * * @param width width of the plane that is subdivided * @param height height of the plane that is subdivided * @param depth number of subdivisions (recursion depth) * @return a GROUP PShape, where each child shape is a face of the subdivision * @see #quadSubdivision(double, double, int, long) seeded quadSubdivision() */ - public static PShape quadSubdivision(double width, double height, int depth) { + public static PShape quadSubdivision(final double width, final double height, final int depth) { return quadSubdivision(width, height, depth, System.nanoTime()); } /** * Recursively and randomly subdivides the given/bounded plane into convex quad * polygons. - * + * * @param width width of the quad subdivision plane * @param height height of the quad subdivision plane * @param depth number of subdivisions (recursion depth) @@ -117,11 +144,11 @@ public static PShape quadSubdivision(double width, double height, int depth) { * @return a GROUP PShape, where each child shape is a face of the subdivision * @see #quadSubdivision(double, double, int) non-seeded quadSubdivision() */ - public static PShape quadSubdivision(double width, double height, int depth, long seed) { + public static PShape quadSubdivision(final double width, final double height, final int depth, final long seed) { // https://openprocessing.org/sketch/1045334 final float w = (float) width; final float h = (float) height; - final float off = 20; + final float off = 0; final SplittableRandom r = new SplittableRandom(seed); final PVector p1 = new PVector(off, off); @@ -138,7 +165,7 @@ public static PShape quadSubdivision(double width, double height, int depth, lon /** * Randomly subdivides the plane into equal-width strips having varying lengths. - * + * * @param width width of the subdivision plane * @param height height of the subdivision plane * @param gridCountX horizontal grid count @@ -147,19 +174,156 @@ public static PShape quadSubdivision(double width, double height, int depth, lon * @return a GROUP PShape, where each child shape is a face of the subdivision * @since 1.3.0 */ - public static PShape hatchSubdivision(double width, double height, int gridCountX, int gridCountY, long seed) { + public static PShape hatchSubdivision(final double width, final double height, final int gridCountX, final int gridCountY, final long seed) { final HatchTiling ht = new HatchTiling((int) width, (int) height, gridCountX, gridCountY); - PShape tiling = ht.getTiling(seed); + final PShape tiling = ht.getTiling(seed); PGS_Conversion.setAllStrokeColor(tiling, Colors.PINK, 4); return tiling; } + /** + * Divides the plane into randomly “sliced” polygonal regions. + *

+ * {@code slices} random cuts are generated across the plane (dimensions w×h, at + * (0,0)). Each cut connects a random point on one side of the plane to a random + * point on another side. If {@code forceOpposite} is true, each cut always + * connects opposite sides; otherwise the two sides are chosen at random (but + * never the same side). + *

+ *

+ * In practice: + *

    + *
  • forceOpposite == true → mostly long, quadrilateral strips + * that span the full width or height of the rectangle.
  • + *
  • forceOpposite == false → a richer variety of cell shapes + * (triangles, trapezoids, L-shapes, etc.) that may not stretch all the way + * across.
  • + *
+ *

+ * + * @param width the width of the plane + * @param height the height of the plane + * @param slices the number of random interior cuts to perform + * @param forceOpposite if true, each cut connects opposite sides of the + * rectangle; if false, cuts connect any two distinct sides + * @param seed the random seed for reproducible slice placement + * @return a GROUP PShape containing the subdivided polygonal regions + * @since 2.1 + */ + public static PShape sliceDivision(final double width, final double height, final int slices, final boolean forceOpposite, final long seed) { + final List cuts = new ArrayList<>(slices + 4); + final double x = 0, y = 0; + final PVector A = new PVector((float) x, (float) y); + final PVector B = new PVector((float) (x + width), (float) y); + final PVector C = new PVector((float) (x + width), (float) (y + height)); + final PVector D = new PVector((float) x, (float) (y + height)); + + cuts.add(new PEdge(A, B)); + cuts.add(new PEdge(B, C)); + cuts.add(new PEdge(C, D)); + cuts.add(new PEdge(D, A)); + + final SplittableRandom r = new SplittableRandom(seed); + for (int i = 0; i < slices; i++) { + final int s1 = r.nextInt(4); + int s2; + if (forceOpposite) { + // always pick the side directly opposite s1 + s2 = (s1 + 2) % 4; + } else { + // pick any side except s1 + do { + s2 = r.nextInt(4); + } while (s2 == s1); + } + + final var p1 = PGS.toPVector(pointOnSide(s1, r, x, y, width, height)); + final var p2 = PGS.toPVector(pointOnSide(s2, r, x, y, width, height)); + final PEdge cut = new PEdge(p1, p2); + cuts.add(cut); + } + + return PGS.polygonizeEdges(cuts); + } + + /** + * Creates a cellular partition of the plane using arcs formed by circles seeded + * along its boundary. Each circle’s radius is chosen large enough to guarantee + * it intersects at least two distinct sides of the plane, forming an arc cut. + *

+ * In practice: + *

    + *
  • You get a “cellular” subdivision where each circle carves out roughly + * circular holes or bulges against the rectangle boundary.
  • + *
  • Because every radius ≥ the minimum distance to a second side, each circle + * always spans at least two sides (forming an arc).
  • + *
+ *

+ * + * @param width the width of the plane + * @param height the height of the plane + * @param arcs the number of circles to seed along the boundary + * @param circlePoints the number of linear segments used to approximate each + * circle + * @param seed the random seed for reproducible circle placement & radii + * @return a GROUP PShape containing the polygonal faces formed by the plane + * plus the seeded circles + * @since 2.1 + */ + public static PShape arcDivision(final double width, final double height, final int arcs, final long seed) { + final SplittableRandom rnd = new SplittableRandom(seed); + final double maxDim = Math.max(width, height); + final double x = 0, y = 0; + final var e = new Envelope(x, x + width, y, y + height); + final RingClipper clipper = new RingClipper(e); + + final List polys = new ArrayList<>(arcs); + polys.add(((Polygon) GEOM_FACTORY.toGeometry(e)).getExteriorRing()); + + // 2) Seed 'seeds' circles along random sides + for (int i = 0; i < arcs; i++) { + // pick a side 0=top,1=right,2=bottom,3=left + final int side = rnd.nextInt(4); + // pick a random point along that side + final Coordinate center = pointOnSide(side, rnd, x, y, width, height); + + // compute min distance from center to any *other* side + double minReq = Double.POSITIVE_INFINITY; + // top edge y=y, bottom y=y+h, left x=x, right x=x+w + final double cx = center.x, cy = center.y; + if (side != 0) { + minReq = Math.min(minReq, Math.abs(cy - y)); + } + if (side != 2) { + minReq = Math.min(minReq, Math.abs(cy - (y + height))); + } + if (side != 3) { + minReq = Math.min(minReq, Math.abs(cx - x)); + } + if (side != 1) { + minReq = Math.min(minReq, Math.abs(cx - (x + width))); + } + + final double radius = minReq + rnd.nextDouble() * (maxDim - minReq); + + final var circle = PGS_Construction.createCirclePoly(cx, cy, radius); + final var clip = clipper.clip(circle.getCoordinates()); + polys.add(GEOM_FACTORY.createLineString(clip)); + + } + + final Polygonizer polygonizer = new Polygonizer(); + polygonizer.setCheckRingsValid(false); + polygonizer.add(UnaryUnionOp.union(polys)); + return PGS_Conversion.toPShape(polygonizer.getPolygons()); + } + /** * Generates a Doyle spiral. A Doyle spiral fills the plane with closely packed * circles, where the radius of each circle in a packing is proportional to the * distance of its centre from a central point. Each circle is tangent to six * others that surround it by a ring of tangent circles - * + * * @param centerX x coordinate of the center of the spiral * @param centerY y coordinate of the center of the spiral * @param p at least 2 @@ -170,17 +334,7 @@ public static PShape hatchSubdivision(double width, double height, int gridCount * @return A list of PVectors, each representing one circle in the spiral: (.x, * .y) represent the center point and .z represents radius. */ - public static List doyleSpiral(double centerX, double centerY, int p, int q, double maxRadius) { - // A closed-form solution for a single p, q (now deprecated). - /* - * double start = 0; // starting circle n double sr, ang, cr; - * - * for (int i = 0; i < nCircles; i++) { sr = Math.exp((start + i) * 0.06101); // - * spiral radius ang = (start + i) * 0.656; // spiral angle cr = 0.3215 * - * Math.exp((start + i) * 0.06101); // circle radius circles.add(new - * PVector((float) (sr * Math.cos(ang) + centerX), (float) (sr * Math.sin(ang) + - * centerY), (float) cr)); } - */ + public static List doyleSpiral(final double centerX, final double centerY, final int p, final int q, final double maxRadius) { final DoyleSpiral doyleSpiral = new DoyleSpiral(p, q, maxRadius); doyleSpiral.getCircles().forEach(c -> c.add((float) centerX, (float) centerY)); return new ArrayList<>(doyleSpiral.getCircles()); @@ -188,7 +342,7 @@ public static List doyleSpiral(double centerX, double centerY, int p, i /** * Generates a hexagonal tiling of the plane. - * + * * @param width width of the tiling plane * @param height height of the tiling plane * @param sideLength side length of each hexagon @@ -196,7 +350,7 @@ public static List doyleSpiral(double centerX, double centerY, int p, i * top is flat, or pointy * @return a GROUP PShape, where each child shape is a hexagon of the tiling */ - public static PShape hexTiling(double width, double height, double sideLength, boolean flat) { + public static PShape hexTiling(final double width, final double height, final double sideLength, final boolean flat) { final double span = sideLength * ROOT3; final PShape tiling = new PShape(PConstants.GROUP); double x = 0; @@ -232,24 +386,22 @@ public static PShape hexTiling(double width, double height, double sideLength, b /** * Generates an "islamic-style" (Girih) tiling of the plane. - * + * * @param width width of the tiling plane * @param height height of the tiling plane * @param w * @param h * @return a GROUP PShape, where each child shape is a tile of the tiling */ - public static PShape islamicTiling(double width, double height, double w, double h) { + public static PShape islamicTiling(final double width, final double height, final double w, final double h) { // adapted from https://openprocessing.org/sketch/320133 final double[] vector = { -w, 0, w, -h, w, 0, -w, h }; final ArrayList segments = new ArrayList<>(); for (int x = 0; x < width; x += w * 2) { for (int y = 0; y < height; y += h * 2) { for (int i = 0; i <= vector.length; i++) { - segments.add( - new PVector((float) (vector[i % vector.length] + x + w), (float) (vector[(i + 6) % vector.length] + y + h))); - segments.add(new PVector((float) (vector[(i + 1) % vector.length] + x + w), - (float) (vector[(i + 1 + 6) % vector.length] + y + h))); + segments.add(new PVector((float) (vector[i % vector.length] + x + w), (float) (vector[(i + 6) % vector.length] + y + h))); + segments.add(new PVector((float) (vector[(i + 1) % vector.length] + x + w), (float) (vector[(i + 1 + 6) % vector.length] + y + h))); } } } @@ -258,36 +410,36 @@ public static PShape islamicTiling(double width, double height, double w, double /** * Generates a Penrose Tiling (consisting of rhombi). - * + * * @param centerX x coordinate of the center/origin of the tiling * @param centerY y coordinate of the center/origin of the tiling * @param radius maximum radius of the tiling (measured from the center) * @param steps number of tiling subdivisions * @return a GROUP PShape, where each child shape is a face of the tiling */ - public static PShape penroseTiling(double centerX, double centerY, final double radius, final int steps) { + public static PShape penroseTiling(final double centerX, final double centerY, final double radius, final int steps) { final PenroseTiling pr = new PenroseTiling(centerX, centerY, radius, steps); - return PGS.polygonizeEdges(pr.getEdges()); + return PGS.polygonizeNodedEdges(pr.getEdges()); } /** * Generates a non-periodic tiling, comprising squares and equilateral * triangles. - * + * * @param width width of the tiling plane * @param height height of the tiling plane * @param tileSize diameter of each tile * @return a GROUP PShape, where each child shape is a tile of the tiling * @since 1.3.0 */ - public static PShape squareTriangleTiling(double width, double height, double tileSize) { + public static PShape squareTriangleTiling(final double width, final double height, final double tileSize) { return squareTriangleTiling(width, height, tileSize, System.nanoTime()); } /** * Generates a non-periodic tiling, comprising squares and equilateral * triangles, having a given seed. - * + * * @param width width of the tiling plane * @param height height of the tiling plane * @param tileSize diameter of each tile @@ -295,14 +447,78 @@ public static PShape squareTriangleTiling(double width, double height, double ti * @return a GROUP PShape, where each child shape is a tile of the tiling * @since 1.3.0 */ - public static PShape squareTriangleTiling(double width, double height, double tileSize, long seed) { + public static PShape squareTriangleTiling(final double width, final double height, final double tileSize, final long seed) { final SquareTriangleTiling stt = new SquareTriangleTiling(width, height, tileSize); return stt.getTiling(seed); } + /** + * Generates a geometric arrangement composed of annular-sector bricks arranged + * in concentric circular rings. Rings progressively expand from the inside out + * based on the growth rates provided. Brick sizes (arc length) adapt radially + * according to growth factors. + * + * @param nRings Number of annular rings to generate. + * @param cx The x-coordinate of the center of the generated pattern. + * @param cy The y-coordinate of the center of the generated pattern. + * @param innerRadius The radius of the innermost ring in pixels. + * @param ringGrowth The growth factor of each successive ring radius; values + * greater than 1.0 cause rings to expand radially outward. + * @param segGrowth The growth factor controlling segment (brick) arc length + * adjustment along each ring; values greater than 1.0 + * increase brick length progressively outward. + * @return A single flattened {@code PShape} consisting of annular-sector bricks + * forming concentric rings around the specified center point + * ({@code cx}, {@code cy}). + * @since 2.1 + */ + public static PShape annularBricks(final int nRings, final double cx, final double cy, final double innerRadius, double ringGrowth, double segGrowth) { + segGrowth = Math.max(segGrowth, 1e-6); + ringGrowth = Math.max(ringGrowth, 1e-6); + final List bricks = new ArrayList<>(); + final double GOLDEN_ANGLE = 2.0 * Math.PI * 0.38196601125; + double segSize = innerRadius; + final double maxDev = 0.25; // max chord sagitta in pixels + + for (int i = 0; i < nRings; i++) { + // ring geometry in double + final double r = innerRadius * Math.pow(ringGrowth, i); + final double dr = (r * (ringGrowth - 1.0)) * 0.90; + final double perim = 2.0 * Math.PI * r; + final int count = (int) Math.max(1.0, Math.floor(perim / segSize)); + final double dang = 2.0 * Math.PI / count; + final double gapAng = (dr / 6.0) / perim * 2.0 * Math.PI; + final double offset = (i * GOLDEN_ANGLE) % (2.0 * Math.PI); + + // tile this ring + for (int k = 0; k < count; k++) { + final double a0 = offset + k * dang + 0.5 * gapAng; + final double a1 = offset + (k + 1) * dang - 0.5 * gapAng; + + final List outer = PGS_Construction.arcPoints(cx, cy, r + dr, a0, a1, maxDev); + + // generate inner in the same direction and reverse it + final List inner = PGS_Construction.arcPoints(cx, cy, r, a0, a1, maxDev); + Collections.reverse(inner); + + outer.addAll(inner); + outer.add(outer.get(0)); // close the loop + + final PShape brick = PGS_Conversion.fromPVector(outer); + brick.setStroke(false); + bricks.add(brick); + } + + segSize *= segGrowth; + segSize = Math.max(segSize, 1); + } + + return PGS_Conversion.flatten(bricks); + } + /** * Generates a hexagon shape. - * + * * @param x x-position of hexagon envelope's top left corner * @param y y-position of hexagon envelope's top left corner * @param sideLength hexagon side length @@ -310,7 +526,7 @@ public static PShape squareTriangleTiling(double width, double height, double ti * pointy) * @return a PATH PShape */ - private static PShape hexagon(double x, double y, double sideLength, boolean flat) { + private static PShape hexagon(final double x, final double y, final double sideLength, final boolean flat) { final double span = sideLength * ROOT3; final PShape hexagon = new PShape(PShape.PATH); hexagon.setStroke(true); @@ -340,10 +556,11 @@ private static PShape hexagon(double x, double y, double sideLength, boolean fla return hexagon; } - private static void divideRect(PVector p1, PVector p2, PVector p3, PVector p4, int n, PShape parent, SplittableRandom r) { + private static void divideRect(final PVector p1, final PVector p2, final PVector p3, final PVector p4, int n, final PShape parent, + final SplittableRandom r) { n--; if (n == 0) { - PShape division = new PShape(PShape.PATH); + final PShape division = new PShape(PShape.PATH); division.setStrokeJoin(PConstants.MITER); division.beginShape(); division.vertex(p1.x, p1.y); @@ -353,23 +570,50 @@ private static void divideRect(PVector p1, PVector p2, PVector p3, PVector p4, i division.endShape(PConstants.CLOSE); parent.addChild(division); } else if (n > 0) { - float w = PVector.dist(p1, p2) + PVector.dist(p3, p4); - float h = PVector.dist(p1, p4) + PVector.dist(p2, p3); - int t = 3; - float r1 = (1f / t) * r.nextInt(1, t); - float r2 = (1f / t) * r.nextInt(1, t); + final float w = PVector.dist(p1, p2) + PVector.dist(p3, p4); + final float h = PVector.dist(p1, p4) + PVector.dist(p2, p3); + final int t = 3; + final float r1 = (1f / t) * r.nextInt(1, t); + final float r2 = (1f / t) * r.nextInt(1, t); if (w < h) { - PVector v1 = PVector.lerp(p1, p4, r1); - PVector v2 = PVector.lerp(p2, p3, r2); + final PVector v1 = PVector.lerp(p1, p4, r1); + final PVector v2 = PVector.lerp(p2, p3, r2); divideRect(p1, p2, v2, v1, n, parent, r); divideRect(v1, v2, p3, p4, n, parent, r); } else { - PVector v1 = PVector.lerp(p1, p2, r1); - PVector v2 = PVector.lerp(p3, p4, r2); + final PVector v1 = PVector.lerp(p1, p2, r1); + final PVector v2 = PVector.lerp(p3, p4, r2); divideRect(p1, v1, v2, p4, n, parent, r); divideRect(v1, p2, p3, v2, n, parent, r); } } } + /** + * Returns a random point on one of the four sides of the rectangle. side = + * 0→top, 1→right, 2→bottom, 3→left. + */ + private static Coordinate pointOnSide(final int side, final SplittableRandom r, final double x, final double y, final double w, final double h) { + double px, py; + switch (side) { + case 0 : // top + px = x + r.nextDouble() * w; + py = y; + break; + case 1 : // right + px = x + w; + py = y + r.nextDouble() * h; + break; + case 2 : // bottom + px = x + r.nextDouble() * w; + py = y + h; + break; + default : // left + px = x; + py = y + r.nextDouble() * h; + break; + } + return new Coordinate(px, py); + } + } diff --git a/src/main/java/micycle/pgs/PGS_Transformation.java b/src/main/java/micycle/pgs/PGS_Transformation.java index 95c6a1f3..60ac37e2 100644 --- a/src/main/java/micycle/pgs/PGS_Transformation.java +++ b/src/main/java/micycle/pgs/PGS_Transformation.java @@ -10,9 +10,8 @@ import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.util.AffineTransformation; -import org.locationtech.jts.operation.distance.IndexedFacetDistance; - import micycle.pgs.commons.ProcrustesAlignment; +import micycle.pgs.commons.TouchScale; import processing.core.PShape; import processing.core.PVector; @@ -221,59 +220,24 @@ public static PShape resizeByMajorAxis(PShape shape, double targetSize) { } /** - * Scales a shape (based on its centroid) so that it touches the boundary of - * another shape. The scaling shape's centroid must lie outside the other shape. * - * @param shape the shape to scale. its centroid should be outside container - * @param boundary - * @param tolerance >=0 + * Scales shape about its centroid until it first contacts the + * linear boundary defined by boundary. + *

+ * The centroid of shape is used as the scale center and is + * expected to lie outside boundary for the "touch" semantics. The + * boundary PShape is interpreted as linear elements + * (exterior rings and holes are supported). + *

+ * + * @param shape the PShape to scale; its centroid should lie + * outside boundary + * @param boundary the PShape whose linear elements form the target + * boundary (supports holes) + * @return a new PShape representing the scaled shape */ - public static PShape touchScale(PShape shape, PShape boundary, double tolerance) { - tolerance = Math.max(tolerance, 0.01); - final IndexedFacetDistance index = new IndexedFacetDistance(fromPShape(boundary)); - Geometry scaleShape = fromPShape(shape); - - final Coordinate centroid = scaleShape.getCentroid().getCoordinate(); - - double dist = Double.MAX_VALUE; - final int maxIter = 75; - int iter = 0; - - while (dist > tolerance && iter < maxIter) { - Coordinate[] coords = index.nearestPoints(scaleShape); - dist = coords[0].distance(coords[1]); - - /* - * When dist == 0, the shape is either fully contained within the boundary or - * partially covers it. We attempt to first shrink the shape so that no longer - * covers the boundary. If dist remains zero after repeated shrinking we - * conclude the shape is properly inside the boundary. - */ - if (dist == 0) { - int z = 0; - while (z++ < 7) { - AffineTransformation t = AffineTransformation.scaleInstance(0.5, 0.5, centroid.x, centroid.y); - scaleShape = t.transform(scaleShape); - dist = index.distance(scaleShape); - if (dist > 0) { - break; - } - } - if (dist == 0) { - /* - * If dist still == 0, then shape's centroid lies on the perimeter of the - * boundary. - */ - return toPShape(scaleShape); - } - } - double d1 = centroid.distance(coords[1]); - double d2 = centroid.distance(coords[0]); - AffineTransformation t = AffineTransformation.scaleInstance(d2 / d1, d2 / d1, centroid.x, centroid.y); - scaleShape = t.transform(scaleShape); - iter++; - } - return toPShape(scaleShape); + public static PShape touchScale(PShape shape, PShape boundary) { + return toPShape(TouchScale.scale(fromPShape(shape), fromPShape(boundary))); } /** @@ -573,10 +537,8 @@ public static PShape rotateAroundCenter(PShape shape, double angle) { } /** - * Flips the shape horizontally based on its centre point. - * - * @param shape - * @return + * Flips the shape horizontally based on its centre point (mirror over the + * x-axis passing through its centroid). */ public static PShape flipHorizontal(PShape shape) { Geometry g = fromPShape(shape); @@ -598,10 +560,8 @@ public static PShape flipHorizontal(PShape shape, double y) { } /** - * Flips the shape vertically based on its centre point. - * - * @param shape - * @return + * Flips the shape vertically based on its centre point (mirror over the y-axis + * passing through its centroid). */ public static PShape flipVertical(PShape shape) { Geometry g = fromPShape(shape); diff --git a/src/main/java/micycle/pgs/PGS_Triangulation.java b/src/main/java/micycle/pgs/PGS_Triangulation.java index 2333e3c7..2c4213f2 100644 --- a/src/main/java/micycle/pgs/PGS_Triangulation.java +++ b/src/main/java/micycle/pgs/PGS_Triangulation.java @@ -3,6 +3,7 @@ import static micycle.pgs.PGS_Conversion.fromPShape; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -28,6 +29,7 @@ import org.tinfour.common.PolygonConstraint; import org.tinfour.common.SimpleTriangle; import org.tinfour.common.Vertex; +import org.tinfour.edge.QuadEdge; import org.tinfour.standard.IncrementalTin; import org.tinfour.utils.HilbertSort; import org.tinfour.utils.TriangleCollector; @@ -93,8 +95,7 @@ public static PShape delaunayTriangulation(PShape shape) { * @see #delaunayTriangulationPoints(PShape, Collection, boolean, int, boolean) * @see #delaunayTriangulationMesh(PShape, Collection, boolean, int, boolean) */ - public static PShape delaunayTriangulation(PShape shape, @Nullable Collection steinerPoints, boolean constrain, - int refinements, boolean pretty) { + public static PShape delaunayTriangulation(PShape shape, @Nullable Collection steinerPoints, boolean constrain, int refinements, boolean pretty) { final IIncrementalTin tin = delaunayTriangulationMesh(shape, steinerPoints, constrain, refinements, pretty); return toPShape(tin); } @@ -175,8 +176,8 @@ public static List delaunayTriangulationPoints(PShape shape) { * @see #delaunayTriangulation(PShape, Collection, boolean, int, boolean) * @see #delaunayTriangulationMesh(PShape, Collection, boolean, int, boolean) */ - public static List delaunayTriangulationPoints(PShape shape, @Nullable Collection steinerPoints, boolean constrain, - int refinements, boolean pretty) { + public static List delaunayTriangulationPoints(PShape shape, @Nullable Collection steinerPoints, boolean constrain, int refinements, + boolean pretty) { final IIncrementalTin tin = delaunayTriangulationMesh(shape, steinerPoints, constrain, refinements, pretty); final ArrayList triangles = new ArrayList<>(); @@ -254,8 +255,8 @@ public static IIncrementalTin delaunayTriangulationMesh(PShape shape) { * @see #delaunayTriangulation(PShape, Collection, boolean, int, boolean) * @see #delaunayTriangulationPoints(PShape, Collection, boolean, int, boolean) */ - public static IIncrementalTin delaunayTriangulationMesh(@Nullable PShape shape, @Nullable Collection steinerPoints, - boolean constrain, int refinements, boolean pretty) { + public static IIncrementalTin delaunayTriangulationMesh(@Nullable PShape shape, @Nullable Collection steinerPoints, boolean constrain, + int refinements, boolean pretty) { return delaunayTriangulationMesh(shape, steinerPoints, constrain, refinements, pretty, true); } @@ -290,18 +291,38 @@ public static IIncrementalTin delaunayTriangulationMesh(Collection poin * @since 2.0 */ public static IIncrementalTin delaunayTriangulationMesh(Collection points, PShape shapeConstraint) { - return delaunayTriangulationMesh(shapeConstraint, points, false, 0, false, false); + return delaunayTriangulationMesh(shapeConstraint, points, true, 0, false, false); } /** - * @param insertShapeVertices Determines input shape vertices are treated: as - * part of the triangulation (=true), or as a + * @param shape the shape to generate a triangulation from. Can + * be null. + * @param steinerPoints A list of additional points to insert into the + * triangulation in addition to the vertices of the + * input shape. Can be null. + * @param constrain whether to constrain the triangulation to the + * shape's boundary. If using a shape, it is + * recommended to set this to true. + * @param refinements The number of times to subdivide the triangulation + * by inserting the centroid of each triangle. Should + * be 0 or greater, typically no more than 5. + * @param pretty Whether to maintain Delaunay nature when + * constraining the triangulation and check that + * centroid locations are within the shape during + * refinement. This can result in more regular + * triangle shapes and sizes, but with a performance + * overhead that increases with higher refinement + * levels. Has no effect if + * constrain=false and + * refinements=0. + * @param insertShapeVertices Determines how input shape vertices are treated: + * as part of the triangulation (=true), or as a * boundary constraint only (=false). */ - private static IIncrementalTin delaunayTriangulationMesh(@Nullable PShape shape, @Nullable Collection steinerPoints, - boolean constrain, int refinements, boolean pretty, boolean insertShapeVertices) { + private static IIncrementalTin delaunayTriangulationMesh(@Nullable PShape shape, @Nullable Collection steinerPoints, boolean constrain, + int refinements, boolean pretty, boolean insertShapeVertices) { Geometry g = shape == null ? PGS.GEOM_FACTORY.createEmpty(2) : fromPShape(shape); - final IncrementalTin tin = new IncrementalTin(10); + final IncrementalTin tin = new IncrementalTin(1); final List vertices = new ArrayList<>(); final Coordinate[] coords = g.getCoordinates(); @@ -339,7 +360,7 @@ private static IIncrementalTin delaunayTriangulationMesh(@Nullable PShape shape, */ refinementVertices.clear(); TriangleCollector.visitSimpleTriangles(tin, t -> { - if (t.getArea() > 99) { // don't refine small triangles + if (t.getArea() > 99) { // don't refine small triangles (NOTE magic constant) final Coordinate center = centroid(t); // use centroid rather than circumcircle center if (pretty || pointLocator.locate(center) != Location.EXTERIOR) { refinementVertices.add(new Vertex(center.x, center.y, Double.NaN)); @@ -460,8 +481,7 @@ public static List poissonTriangulationPoints(PShape shape, double spac public static IIncrementalTin poissonTriangulationMesh(PShape shape, double spacing) { final Envelope e = fromPShape(shape).getEnvelopeInternal(); - final List poissonPoints = PGS_PointSet.poisson(e.getMinX(), e.getMinY(), e.getMinX() + e.getWidth(), - e.getMinY() + e.getHeight(), spacing, 0); + final List poissonPoints = PGS_PointSet.poisson(e.getMinX(), e.getMinY(), e.getMinX() + e.getWidth(), e.getMinY() + e.getHeight(), spacing, 0); final IIncrementalTin tin = delaunayTriangulationMesh(shape, poissonPoints, true, 0, false); return tin; @@ -534,7 +554,7 @@ public static SimpleGraph toGraph(IIncrementalTin triangulation) // if (isEdgeOnPerimeter(e)) { // return; // skip to next triangle // } - if (notConstrained || e.isConstrainedRegionMember()) { + if (notConstrained || e.isConstraintRegionMember()) { final IQuadEdge base = e.getBaseReference(); PVector a = toPVector(base.getA()); PVector b = toPVector(base.getB()); @@ -568,7 +588,7 @@ public static SimpleGraph toTinfourGraph(IIncrementalTin tria // if (isEdgeOnPerimeter(e)) { // return; // skip to next triangle // } - if ((notConstrained || e.isConstrainedRegionMember())) { + if ((notConstrained || e.isConstraintRegionMember())) { final IQuadEdge base = e.getBaseReference(); graph.addVertex(base.getA()); graph.addVertex(base.getB()); @@ -584,14 +604,21 @@ public static SimpleGraph toTinfourGraph(IIncrementalTin tria *

* A dual graph of a triangulation has a vertex for each constrained triangle of * the input, and an edge connecting each pair of triangles that are adjacent. + *

+ * Edges are weighted, where weights represent a quality measure ([0...1], where + * 1 is a perfect square) for the quadrilateral formed by collapsing the shared + * edge between the two triangles. * * @param triangulation triangulation mesh - * @return * @since 1.3.0 + * @return a simple weighted dual graph * @see #toTinfourGraph(IIncrementalTin) */ public static SimpleGraph toDualGraph(IIncrementalTin triangulation) { - final SimpleGraph graph = new SimpleGraph<>(DefaultEdge.class); + final SimpleGraph graph = new SimpleWeightedGraph<>(DefaultEdge.class); + // vertex supplier required in some graph algorithms + QuadEdge dummy = new QuadEdge(0); + graph.setVertexSupplier(() -> new SimpleTriangle(null, dummy, dummy, dummy)); final boolean notConstrained = triangulation.getConstraints().isEmpty(); final Map edgeMap = new HashMap<>(triangulation.countTriangles().getCount() * 3); @@ -602,22 +629,39 @@ public static SimpleGraph toDualGraph(IIncrementalT edgeMap.put(t.getEdgeA(), t); edgeMap.put(t.getEdgeB(), t); edgeMap.put(t.getEdgeC(), t); - graph.addVertex(t); + graph.addVertex(t); // triangle } }); + /* + * Set the edge weight. This provides a quality measure for the quadrilateral + * element formed by collapsing the shared edge between two triangles. This + * weight is used in Blossom Quad algorithm. + */ graph.vertexSet().forEach(t -> { - final SimpleTriangle n1 = edgeMap.get(t.getEdgeA().getDual()); - final SimpleTriangle n2 = edgeMap.get(t.getEdgeB().getDual()); - final SimpleTriangle n3 = edgeMap.get(t.getEdgeC().getDual()); - if (n1 != null) { - graph.addEdge(t, n1); + final SimpleTriangle t1 = edgeMap.get(t.getEdgeA().getDual()); + final SimpleTriangle t2 = edgeMap.get(t.getEdgeB().getDual()); + final SimpleTriangle t3 = edgeMap.get(t.getEdgeC().getDual()); + if (t1 != null) { + var edge = graph.addEdge(t, t1); + if (edge != null) { + double weight = computeQuadQuality(t, t1, t.getEdgeA()); + graph.setEdgeWeight(edge, weight); + } } - if (n2 != null) { - graph.addEdge(t, n2); + if (t2 != null) { + var edge = graph.addEdge(t, t2); + if (edge != null) { + double weight = computeQuadQuality(t, t2, t.getEdgeB()); + graph.setEdgeWeight(edge, weight); + } } - if (n3 != null) { - graph.addEdge(t, n3); + if (t3 != null) { + var edge = graph.addEdge(t, t3); + if (edge != null) { + double weight = computeQuadQuality(t, t3, t.getEdgeC()); + graph.setEdgeWeight(edge, weight); + } } }); @@ -665,4 +709,51 @@ private static Coordinate centroid(final SimpleTriangle t) { y /= 3; return new Coordinate(x, y); } + + /** + * Computes the quality measure of a quadrilateral formed by merging two + * adjacent triangles. The quality measure is based on the maximum deviation of + * the interior angles of the quadrilateral from 90 degrees (π/2 radians). A + * perfect rectangle or square will have a quality measure of 1, while a + * quadrilateral with an angle of 0 or π radians will have a quality measure of + * 0. + * + * @param t1 The first triangle sharing the edge with the second + * triangle. + * @param t2 The second triangle sharing the edge with the first + * triangle. + * @param sharedEdge The edge shared by t1 and t2, belonging to t1. The dual of + * this edge (sharedEdge.getDual()) represents the same edge + * but belonging to t2. + * @return A quality measure between 0 and 1, where 1 indicates a perfect + * quadrilateral (all interior angles are 90 degrees) and 0 indicates a + * degenerate quadrilateral (at least one interior angle is 0 or π + * radians). + */ + private static double computeQuadQuality(SimpleTriangle t1, SimpleTriangle t2, IQuadEdge sharedEdge) { + PVector a = toPVector(sharedEdge.getReverse().getA()); + PVector b = toPVector(sharedEdge.getA()); + PVector c = toPVector(sharedEdge.getReverseFromDual().getA()); + PVector d = toPVector(sharedEdge.getB()); + + List vertices = Arrays.asList(a, b, c, d); + + PShape quad = PGS_Conversion.fromPVector(vertices); + + Map angles = PGS_ShapePredicates.interiorAngles(quad); + + // Calculate maximum deviation from 90 degrees + double maxDeviation = 0.0; + for (double angle : angles.values()) { + double deviation = Math.abs(Math.PI / 2 - angle); + if (deviation > maxDeviation) { + maxDeviation = deviation; + } + } + + double cost = 1 - (2 / Math.PI) * maxDeviation; + cost = Math.max(cost, 0); // Ensure cost is non-negative + return cost; + } + } diff --git a/src/main/java/micycle/pgs/PGS_Voronoi.java b/src/main/java/micycle/pgs/PGS_Voronoi.java index 6415904a..ba4ffd1a 100644 --- a/src/main/java/micycle/pgs/PGS_Voronoi.java +++ b/src/main/java/micycle/pgs/PGS_Voronoi.java @@ -1,6 +1,7 @@ package micycle.pgs; import static micycle.pgs.PGS_Conversion.fromPShape; +import static micycle.pgs.PGS_Conversion.toPShape; import java.awt.geom.Rectangle2D; import java.util.ArrayList; @@ -17,6 +18,7 @@ import org.locationtech.jts.geom.Polygonal; import org.locationtech.jts.operation.overlay.snap.GeometrySnapper; import org.locationtech.jts.operation.overlayng.OverlayNG; +import org.locationtech.jts.operation.relateng.RelateNG; import org.tinfour.common.IQuadEdge; import org.tinfour.common.Vertex; import org.tinfour.standard.IncrementalTin; @@ -26,8 +28,10 @@ import org.tinfour.voronoi.ThiessenPolygon; import micycle.pgs.color.Colors; +import micycle.pgs.commons.FarthestPointVoronoi; import micycle.pgs.commons.MultiplicativelyWeightedVoronoi; import micycle.pgs.commons.Nullable; +import micycle.pgs.commons.PEdge; import processing.core.PConstants; import processing.core.PShape; import processing.core.PVector; @@ -141,15 +145,26 @@ public static PShape innerVoronoi(final PShape shape, Collection additi */ public static PShape innerVoronoi(final PShape shape, final boolean constrain, @Nullable final double[] bounds, @Nullable final Collection steinerPoints, final int relaxations) { - final Geometry g = fromPShape(shape); - BoundedVoronoiDiagram v = innerVoronoiRaw(shape, constrain, bounds, steinerPoints, relaxations); + BoundedVoronoiDiagram v = innerVoronoiRaw(shape, bounds, steinerPoints, relaxations); List faces = new ArrayList<>(); if (v != null && v.getPolygons() != null) { faces = v.getPolygons().stream().filter(p -> p.getEdges().size() > 1).map(PGS_Voronoi::toPolygon).collect(Collectors.toList()); } - if (constrain && g instanceof Polygonal) { - faces = faces.parallelStream().map(f -> OverlayNG.overlay(f, g, OverlayNG.INTERSECTION)).collect(Collectors.toList()); - faces.removeIf(f -> f.getNumPoints() == 0); // (odd, artifacts of intersection?) + if (constrain) { + final Geometry g = fromPShape(shape); + if (g instanceof Polygonal) { + final var index = RelateNG.prepare(g); + faces = faces.parallelStream().map(f -> { + final var relation = index.evaluate(f); + if (relation.isContains()) { + return f; + } else if (relation.isDisjoint()) { + return g.getFactory().createEmpty(2); + } + return OverlayNG.overlay(f, g, OverlayNG.INTERSECTION); + }).collect(Collectors.toList()); + faces.removeIf(f -> f.isEmpty() || f.getNumPoints() == 0); + } } PShape facesShape = PGS_Conversion.toPShape(faces); @@ -183,9 +198,6 @@ public static PShape innerVoronoi(final PShape shape, final boolean constrain, @ * * @param shape The shape to generate the inner Voronoi diagram for * (using its vertices for Voronoi sites). - * @param constrain A flag indicating whether or not to constrain the - * resulting diagram to the original shape (if it is - * polygonal). * @param bounds an optional array of the form [minX, minY, maxX, maxY] * representing the bounds of the diagram. The boundary * must fully contain the shape (but needn't contain all @@ -194,14 +206,13 @@ public static PShape innerVoronoi(final PShape shape, final boolean constrain, @ * Steiner points to be used as additional sites in the * diagram. * @param relaxations the number of times to relax the diagram. 0 or greater. - * * @return a GROUP PShape, where each child shape is a Voronoi cell. The * .name value of each cell is set to the integer index of * its vertex site. * @see #innerVoronoi(Collection) */ - public static BoundedVoronoiDiagram innerVoronoiRaw(final PShape shape, final boolean constrain, @Nullable final double[] bounds, - @Nullable final Collection steinerPoints, final int relaxations) { + public static BoundedVoronoiDiagram innerVoronoiRaw(final PShape shape, @Nullable final double[] bounds, @Nullable final Collection steinerPoints, + final int relaxations) { final Geometry g = fromPShape(shape); final List vertices = new ArrayList<>(); final Coordinate[] coords = g.getCoordinates(); @@ -257,7 +268,7 @@ public static BoundedVoronoiDiagram innerVoronoiRaw(final PShape shape, final bo newSites.add(newSite); } } - if (maxDistDelta < 0.05) { + if (maxDistDelta < 1e-6) { break; // sufficiently converged, exit relaxation early } v = new BoundedVoronoiDiagram(newSites, options); @@ -347,8 +358,8 @@ public static PShape innerVoronoi(Collection points, double[] bounds, i * diagram generation process. */ - public static BoundedVoronoiDiagram innerVoronoiRaw(Collection points, double[] bounds, int relaxations) { - return innerVoronoiRaw(PGS_Conversion.toPointsPShape(points), false, bounds, null, relaxations); + public static BoundedVoronoiDiagram innerVoronoiRaw(Collection points, @Nullable double[] bounds, int relaxations) { + return innerVoronoiRaw(PGS_Conversion.toPointsPShape(points), bounds, null, relaxations); } /** @@ -533,7 +544,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) { @@ -544,6 +554,80 @@ public static PShape multiplicativelyWeightedVoronoi(Collection sites, return s; } + /** + * Generates the farthest-point Voronoi diagram (FPVD) for a set of + * sites. + *

+ * The farthest-point Voronoi diagram partitions the plane into regions such + * that each region consists of all points for which a particular site is the + * farthest among all provided sites (not the nearest). Only sites that + * are convex hull vertices have regions in the FPVD. + *

+ * The resulting diagram is not clipped to a bounding box and may extend well + * beyond the convex hull of the input sites, but it is still represented by a + * finite set of edges. + * + * @param sites a collection of {@link PVector} representing the sites; only + * convex hull vertices have regions + * @return a {@link PShape} representing the farthest-point Voronoi diagram as a + * set of edges + * @see #farthestPointVoronoi(Collection, double[]) + * @since 2.1 + */ + public static PShape farthestPointVoronoi(Collection sites) { + FarthestPointVoronoi fpvd = new FarthestPointVoronoi(); + fpvd.setSites(sites.stream().map(s -> PGS.coordFromPVector(s)).toList()); + + var edges = fpvd.getDCEL().getEdges().stream().map(e -> { + var a = PGS.toPVector(e.origVertex); + var b = PGS.toPVector(e.destVertex); + return new PEdge(a, b); + }).toList(); + + return PGS_SegmentSet.toPShape(edges); + } + + /** + * Generates a farthest-point Voronoi diagram (FPVD) for a given set of + * sites and a bounding box. + *

+ * The farthest-point Voronoi diagram is a variant of the Voronoi diagram + * in which the region for each site p consists of all points in + * the plane for which p is the farthest site among all + * sites. In contrast to a regular (nearest-point) Voronoi diagram, where each + * region surrounds and contains its generating site, in the FPVD, regions do + * not hug their site; instead, the generator of a region is {typically + * distant and not even contained within} its region. + *

+ * Properties: + *

    + *
  • Only sites that are vertices of the convex hull have non-empty regions in + * the FPVD, since only those can be farthest from some location in the + * plane.
  • + *
  • A useful interpretation: all points in a given FPVD region share the same + * farthest generator site. However, the generator site is not visually apparent + * from the region itself, as it is not located within or even near the region. + *
+ * + * @param sites A collection of {@link PVector}s representing sites; only the + * convex hull vertices will have corresponding regions in the + * output diagram. + * @param bounds A double array of form [minX, minY, maxX, maxY] + * representing the axis-aligned bounding box for clipping the + * diagram. + * @return A {@link PShape} representing the farthest-point Voronoi diagram. + * Each cell corresponds to the region for one convex hull vertex site. + * @since 2.1 + */ + public static PShape farthestPointVoronoi(Collection sites, double[] bounds) { + FarthestPointVoronoi fpvd = new FarthestPointVoronoi(); + Envelope e = new Envelope(bounds[0], bounds[2], bounds[1], bounds[3]); // x,x,y,y + fpvd.setClipEnvelope(e); + fpvd.setSites(sites.stream().map(s -> PGS.coordFromPVector(s)).toList()); + + return toPShape(fpvd.getDiagram()); + } + static Polygon toPolygon(ThiessenPolygon polygon) { Coordinate[] coords = new Coordinate[polygon.getEdges().size() + 1]; int i = 0; diff --git a/src/main/java/micycle/pgs/color/ColorUtils.java b/src/main/java/micycle/pgs/color/ColorUtils.java index b9a0790a..50ea27a2 100644 --- a/src/main/java/micycle/pgs/color/ColorUtils.java +++ b/src/main/java/micycle/pgs/color/ColorUtils.java @@ -1,6 +1,6 @@ package micycle.pgs.color; -import com.scrtwpns.Mixbox; +//import com.scrtwpns.Mixbox; import net.jafama.FastMath; @@ -122,9 +122,9 @@ public static int[] hexToColor(String[] colors) { * * @return the new mixed color */ - public static int pigmentMix(int colorA, int colorB, float t) { - return Mixbox.lerp(colorA, colorB, t); - } +// public static int pigmentMix(int colorA, int colorB, float t) { +// return Mixbox.lerp(colorA, colorB, t); +// } /** * Produces a smooth hue-cycling rainbow. diff --git a/src/main/java/micycle/pgs/color/Palette.java b/src/main/java/micycle/pgs/color/Palette.java new file mode 100644 index 00000000..4ce9fd9c --- /dev/null +++ b/src/main/java/micycle/pgs/color/Palette.java @@ -0,0 +1,159 @@ +package micycle.pgs.color; + +/** + * A selection of color palettes. + *

+ * All palettes were taken from (and are visible at) + * Roni Kaufman Color + * Pals. + * + * @author Michael Carleton + * + */ +public enum Palette { + + // @formatter:off + _1115(new String[] { "#ffe03d", "#fe4830", "#d33033", "#6d358a", "#1c509e", "#00953c" }), + ARCADE(new String[] { "#021d34", "#228fca", "#dcedf0" }), + ARCS(new String[] { "#ff3232", "#ff9932", "#ffff32", "#32ff32", "#32ffff", "#3232ff", "#ff32ff" }), + AVANTGARDE(new String[] { "#f398c3", "#cf3895", "#a0d28d", "#06b4b0", "#fef45f", "#fed000", "#0c163f" }), + BANANABERRY(new String[] { "#ffffbf", "#f2f218", "#660535", "#f260e3", "#ffbff8" }), + BASE(new String[] { "#f0f0f0", "#8c8c8c", "#0f0f0f", "#0863d3", "#f5d216", "#f43809", "#08b233", "#9913bf" }), + BRAIN(new String[] { "#ee726b", "#ffc5c7", "#fef9c6" }), + CHEDDAR(new String[] { "#ff7b00", "#ff8800", "#ff9500", "#ffa200", "#ffaa00", "#ffb700", "#ffc300" }), + CLOUDY(new String[] { "#044e9e", "#6190d3", "#fcf7ed", "#fcd494", "#f4b804" }), + COMMIT(new String[] { "#563d7c", "#0096d8", "#f4e361", "#f24679" }), + CONNECTION(new String[] { "#f24358", "#f2a643", "#f2e343", "#43f278", "#43a0f2", "#c343f2" }), + CUBE(new String[] { "#fde200", "#ef2626", "#5600ae", "#713df5" }), + CUBE_DARK(new String[] { "#d6d1b4", "#bab397", "#44473f", "#292e2a" }), + DATA(new String[] { "#f9f9f9", "#ff0000", "#0000ff" }), + DAILY(new String[] { "#131314", "#272829", "#ffe18e", "#fff0c6" }), + EDIT(new String[] { "#1c1c1c", "#f47a9d", "#f4ea7a", "#f2f2f2" }), + ESCAPE(new String[] { "#f3e17e", "#dd483c", "#4b8a5f", "#0d150b", "#faf8e2" }), + EVENING(new String[] { "#ff4f19", "#15084d", "#5ce6e6" }), + FANS(new String[] { "#000000", "#83a6bc", "#faece1", "#bab1a8" }), + FLAT(new String[] { "#203051", "#4464a1", "#62b6de", "#b3dce0", "#e2f0f3" }), + FREAK(new String[] { "#07224f", "#ed361a", "#fc8405", "#f7c72a" }), + HER(new String[] { "#cd1440", "#fafafa" }), + HOMAGE(new String[] { "#fef9c6", "#ffcc4d", "#f5b800", "#56a1c4", "#4464a1", "#ee726b", "#df5f50", "#5a3034" }), + IRIS(new String[] { "#ebedec", "#71859d", "#f6d448", "#fafafa" }), + JELLY(new String[] { "#102340", "#fe01ec", "#8a07da" }), + LAB(new String[] { "#061922", "#e5d62d", "#e52dbd", "#982de5", "#92f0e5" }), + LEFT(new String[] { "#070213", "#1c0541", "#300560", "#3e137f", "#412287", "#3b3d8c", "#e6f41c" }), + LESSON(new String[] { "#ed225d", "#3caf65", "#0d40bf", "#f5b800", "#2a2a2a" }), + LIGHT(new String[] { "#00b2b4", "#fdd35b", "#3b4696", "#f4737d", "#333333" }), + LIZARD(new String[] { "#fff247", "#47e9ff", "#8447ff", "#ff47ce" }), + MARBLE(new String[] { "#218ad4", "#76df55", "#0b1435", "#ffffff" }), + META(new String[] { "#226699", "#dd2e44", "#ffcc4d" }), + MONDRIAN(new String[] { "#0a0a0a", "#f7f3f2", "#0077e1", "#f5d216", "#fc3503" }), + MORNING(new String[] { "#ffd919", "#262104", "#fffbe6" }), + NORTH(new String[] { "#dc060e", "#ffd400", "#0064b0", "#001a5b", "#ffffff" }), + OPTICAL(new String[] { "#f2eb8a", "#fed000", "#fc8405", "#ed361a", "#e2f0f3", "#b3dce0", "#4464a1", "#203051", "#ffc5c7", "#f398c3", "#cf3895", "#6d358a", "#06b4b0", "#4b8a5f" }), + PAINT(new String[] { "#b30000", "#e6cf00", "#1283b3", "#fafafa", "#0a0a0a" }), + PIE(new String[] { "#fdcc01", "#f60001", "#001ea6", "#007d45", "#040a07", "#f1ebf7" }), + PLAY(new String[] { "#e20404", "#fcd202", "#ffffff", "#000000" }), + PODCAST(new String[] { "#584594", "#e488b7", "#d74c41", "#f0d235", "#36ad63", "#69bcea", "#fdfdfd" }), + POET(new String[] { "#f4f3ed", "#efc807", "#ed5d53", "#e2dbb5", "#45291c", "#080b0f" }), + RAINBOW(new String[] { "#BF616A", "#D08770", "#EBCB8B", "#A3BE8C", "#B48EAD" }), + REPETITION(new String[] { "#2c2060", "#4bd3e5", "#fffbe6", "#ffd919", "#ff4f19" }), + RIGHT(new String[] { "#4464a1", "#62b6de", "#b3dce0", "#fafafa", "#ffc5c7", "#ee726b", "#cd1440"}), + SCIENCE(new String[] { "#ffe819", "#000000" }), + SHEETS(new String[] { "#025760", "#7fd0d3", "#b3ead7", "#eff7F5", "#fae9c1", "#f8ca75", "#e88d44" }), + SLUSSEN(new String[] {"#fafafa", "#f398c3", "#f44e24", "#f4d730", "#23b247", "#2a76d3", "#0a0a0a"}), + SPLASH(new String[] { "#32312e", "#795330", "#c7ae82", "#f5f2e3" }), + STARS(new String[] { "#0a1966", "#ffef0d", "#fafafa" }), + TEK(new String[] { "#fcf7ed", "#c6c3ba", "#a5a29b", "#fcde97", "#fccf64" }), + TEN(new String[] { "#fffbe6", "#050505", "#abcd5e", "#29ac9f", "#14976b", "#b3dce0", "#62b6de", "#2b67af", "#f589a3", "#ef562f", "#fc8405", "#f9d531" }), + TROLL(new String[] { "#294984", "#6ca0a7", "#ffc789", "#df5f50", "#5a3034", "#fff1dd" }), + TROPICAL(new String[] { "#3f7373", "#4d8c8c", "#5ba6a6", "#69bfbf", "#77d9d9", "#f0de84", "#c5d419" }), + WAVE(new String[] { "#008cff", "#0099ff", "#00a5ff", "#00b2ff", "#00bfff", "#00cbff", "#00d8ff", "#00e5ff", "#00f2ff", "#00ffff" }), + WHEEL(new String[] { "#ffe140", "#ffa922", "#1bc0c6", "#2484ae", "#134e6e" }), + ; + // @formatter:on + + private final String[] value; + private final int[] intValue; + private final int length; + + private Palette(String[] value) { + this.value = value; + this.intValue = ColorUtils.hexToColor(value); + length = value.length; + } + + public String[] stringValue() { + return value; + } + + public String[] stringValue(int rotation) { + return rotate(stringValue(), rotation); + } + + public int[] intValue() { + return intValue; + } + + public int length() { + return length; + } + + public int[] intValue(int rotation) { + return rotate(intValue, rotation); + } + + public int getLastColor() { + return intValue[intValue.length - 1]; + } + + /** + * Gets the color closest to the given fraction along the palette. + */ + public int get(double fraction) { + return intValue[(int) Math.round(fraction * (intValue.length - 1))]; + } + + /** + * Gets the color at the given index, modulo-ready. + * + * @param index + */ + public int get(int index) { + int length = intValue.length; + int positiveIndex = (index >= 0) ? index : length + (index % length); + return intValue[positiveIndex % length]; + } + + public static Palette getPalette(int id) { + return Palette.values()[id % Palette.values().length]; + } + + public static Palette getRandomPalette() { + return Palette.values()[(int) (Math.random() * Palette.values().length)]; + } + + public static Palette getRandomPalette(int minColors) { + Palette palette = null; + while (palette == null || palette.intValue.length < minColors) { + palette = getRandomPalette(); + } + return palette; + } + + private static int[] rotate(int[] data, int rotation) { + rotation %= data.length; + int[] result = new int[data.length]; + for (int i = 0; i < data.length; i++) { + result[(i + (data.length - rotation)) % data.length] = data[i]; + } + return result; + } + + private static String[] rotate(String[] data, int rotation) { + String[] result = new String[data.length]; + for (int i = 0; i < data.length; i++) { + result[(i + (data.length - rotation)) % data.length] = data[i]; + } + return result; + } + +} diff --git a/src/main/java/micycle/pgs/commons/AreaMerge.java b/src/main/java/micycle/pgs/commons/AreaMerge.java index 0a461efa..9593ddae 100644 --- a/src/main/java/micycle/pgs/commons/AreaMerge.java +++ b/src/main/java/micycle/pgs/commons/AreaMerge.java @@ -1,10 +1,12 @@ package micycle.pgs.commons; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.PriorityQueue; import java.util.TreeSet; import java.util.stream.Collectors; @@ -31,6 +33,79 @@ public class AreaMerge { private AreaMerge() { } + /** + * Merges faces until the mesh contains exactly {@code targetFaceCount} faces. + * Strategy: 1. put every face (wrapped in a FaceGroup) into a priority queue + * ordered by area (smallest first); 2. repeatedly remove the smallest group, + * merge it into its smallest neighbouring group, push the updated neighbour + * back into the queue; 3. stop after the required number of merges has been + * executed. + * + * @param mesh input mesh + * @param targetFaceCount exact number of faces desired (must be ≥ 1) + * @return a new PShape containing exactly {@code targetFaceCount} faces + */ + public static PShape areaMerge(PShape mesh, int targetFaceCount) { + if (targetFaceCount < 1) { + throw new IllegalArgumentException("targetFaceCount must be ≥ 1"); + } + + List faces = PGS_Conversion.getChildren(mesh); + int currentFaceCount = faces.size(); + if (targetFaceCount >= currentFaceCount) { // nothing to do + return mesh; + } + + // build face-groups and dual graph + SimpleGraph dual = PGS_Conversion.toDualGraph(mesh); + + Map face2Group = new HashMap<>(dual.vertexSet().size()); + SimpleGraph groupsGraph = new SimpleGraph<>(DefaultEdge.class); + + for (PShape f : dual.vertexSet()) { + FaceGroup g = new FaceGroup(f, PGS_ShapePredicates.area(f)); + face2Group.put(f, g); + groupsGraph.addVertex(g); + } + dual.edgeSet().forEach(e -> { + groupsGraph.addEdge(face2Group.get(dual.getEdgeSource(e)), face2Group.get(dual.getEdgeTarget(e))); + }); + + // priority queue by area + PriorityQueue pq = new PriorityQueue<>(groupsGraph.vertexSet()); + + int facesLeft = currentFaceCount; + while (facesLeft > targetFaceCount && !pq.isEmpty()) { + + // 1. smallest face/group + FaceGroup smallest = pq.poll(); + if (!groupsGraph.containsVertex(smallest)) { // outdated entry + continue; + } + + // 2. smallest neighbour + List neighbours = Graphs.neighborListOf(groupsGraph, smallest); + if (neighbours.isEmpty()) { // isolated – abort + break; + } + FaceGroup bestNeighbour = neighbours.stream().min(Comparator.comparingDouble(g -> g.area)).get(); + + // 3. update queue: remove neighbour (its key is about to change) + pq.remove(bestNeighbour); + + // 4. merge topo + attributes + bestNeighbour.mergeWith(smallest); + mergeVertices(groupsGraph, bestNeighbour, smallest); + + // 5. re-insert updated neighbour + pq.offer(bestNeighbour); + + facesLeft--; // one face removed + } + + return PGS_Conversion.flatten(groupsGraph.vertexSet().stream().map(g -> PGS_ShapeBoolean.unionMesh(g.faces.keySet())).collect(Collectors.toList())); + } + /** * Recursively merges smaller faces of a mesh into their adjacent faces. The * procedure continues until there are no resulting faces with an area smaller @@ -38,7 +113,6 @@ private AreaMerge() { * * @param mesh * @param areaThreshold - * @return */ public static PShape areaMerge(PShape mesh, double areaThreshold) { SimpleGraph graph = PGS_Conversion.toDualGraph(mesh); @@ -83,18 +157,14 @@ public static PShape areaMerge(PShape mesh, double areaThreshold) { smallestNeighbor.mergeWith(toMerge); // merge face groups mergeVertices(groupsGraph, smallestNeighbor, toMerge); // update topology + // remove outdated entry if it exists if (smallestNeighbor.area > areaThreshold) { smallGroups.remove(smallestNeighbor); } } - final boolean hasHoles = PGS_ShapePredicates.holes(mesh) > 0; return PGS_Conversion.flatten(groupsGraph.vertexSet().stream().map(g -> { - if (hasHoles) { - return PGS_ShapeBoolean.unionMesh(g.faces.keySet()); - } else { - return PGS_ShapeBoolean.unionMeshWithoutHoles(g.faces.keySet()); - } + return PGS_ShapeBoolean.unionMesh(g.faces.keySet()); }).collect(Collectors.toList())); } diff --git a/src/main/java/micycle/pgs/commons/BezierShapeGenerator.java b/src/main/java/micycle/pgs/commons/BezierShapeGenerator.java index 04d2fc72..9a7e40e5 100644 --- a/src/main/java/micycle/pgs/commons/BezierShapeGenerator.java +++ b/src/main/java/micycle/pgs/commons/BezierShapeGenerator.java @@ -8,7 +8,7 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateList; -import micycle.betterbeziers.CubicBezier; +import com.github.micycle1.betterbeziers.CubicBezier; import net.jafama.FastMath; /** diff --git a/src/main/java/micycle/pgs/commons/ChaikinCut.java b/src/main/java/micycle/pgs/commons/ChaikinCut.java index 94fe5b09..514e0b2a 100644 --- a/src/main/java/micycle/pgs/commons/ChaikinCut.java +++ b/src/main/java/micycle/pgs/commons/ChaikinCut.java @@ -154,14 +154,14 @@ private static ArrayList chaikinCut(PVector a, PVector b, float ratio) /** * Determines whether to cut an edge. Returns false for edges with a euclidean - * distance less than 1.0. + * distance less than 0.5. */ private static boolean cut(PVector a, PVector b) { // TODO expand to exclude almost coincident edge pairs final float dx = b.x - a.x; final float dy = b.y - a.y; final float d2 = dx * dx + dy * dy; - return d2 > 1; + return d2 > 0.5; } } 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/DiscreteCurveEvolution.java b/src/main/java/micycle/pgs/commons/DiscreteCurveEvolution.java index 9bc72c7f..e3620bf3 100644 --- a/src/main/java/micycle/pgs/commons/DiscreteCurveEvolution.java +++ b/src/main/java/micycle/pgs/commons/DiscreteCurveEvolution.java @@ -1,14 +1,11 @@ package micycle.pgs.commons; +import java.util.ArrayList; import java.util.List; import java.util.TreeSet; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateList; import org.locationtech.jts.geom.LineString; - import net.jafama.FastMath; /** @@ -42,6 +39,9 @@ public class DiscreteCurveEvolution { * The callback interface for determining the termination condition of the * Discrete Curve Evolution (DCE) process. *

+ * Vertices are supplied in order of significance starting with the least + * significant. + *

* This functional interface defines a single method that decides whether the * DCE algorithm should terminate based on the current kink (having a candidate * vertex), using its coordinates, relevance score, and the number of vertices @@ -50,6 +50,7 @@ public class DiscreteCurveEvolution { * a threshold relevance score, a specific number of vertices to preserve, or * other criteria. * + * * @see #process(LineString, DCETerminationCallback) */ @FunctionalInterface @@ -98,6 +99,9 @@ public interface DCETerminationCallback { public static Coordinate[] process(LineString lineString, DCETerminationCallback terminationCallback) { final boolean closed = lineString.isClosed(); Coordinate[] coords = lineString.getCoordinates(); + if (coords.length == 0) { + return coords; + } if (closed && coords.length > 2) { Coordinate[] newCoords = new Coordinate[coords.length - 1]; System.arraycopy(coords, 0, newCoords, 0, coords.length - 1); @@ -131,8 +135,8 @@ public static Coordinate[] process(LineString lineString, DCETerminationCallback final TreeSet kinkRelevanceTree = new TreeSet<>(kinks); if (kinks.size() != kinkRelevanceTree.size()) { int lostKinks = kinks.size() - kinkRelevanceTree.size(); - throw new IllegalStateException(String.format( - "%d Kink objects were lost during the conversion from the kinks list to the kinkRelevanceTree set.", lostKinks)); + throw new IllegalStateException( + String.format("%d Kink objects were lost during the conversion from the kinks list to the kinkRelevanceTree set.", lostKinks)); } while (kinkRelevanceTree.size() > 2) { Kink candidate = kinkRelevanceTree.pollFirst(); @@ -164,7 +168,15 @@ public static Coordinate[] process(LineString lineString, DCETerminationCallback } private static List createKinksWithIds(Coordinate[] coords) { - return IntStream.range(0, coords.length).mapToObj(i -> new Kink(coords[i], i)).collect(Collectors.toList()); + List result = new ArrayList<>(); + + for (int i = 0; i < coords.length; i++) { + if (i == 0 || !coords[i].equals2D(coords[i - 1])) { + result.add(new Kink(coords[i], result.size())); + } + } + + return result; } /** diff --git a/src/main/java/micycle/pgs/commons/FarthestPointVoronoi.java b/src/main/java/micycle/pgs/commons/FarthestPointVoronoi.java new file mode 100644 index 00000000..89ef7b2d --- /dev/null +++ b/src/main/java/micycle/pgs/commons/FarthestPointVoronoi.java @@ -0,0 +1,372 @@ +package micycle.pgs.commons; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.locationtech.jts.algorithm.ConvexHull; +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiPoint; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.Triangle; +import org.locationtech.jts.operation.polygonize.Polygonizer; + +import micycle.pgs.commons.FarthestPointVoronoi.DCELVertex; + +/** + * Farthest-Point Voronoi Diagram + *

+ * The region for a site ( p ) in a farthest-point Voronoi diagram (FPVD) is the + * set of all points in the plane for which ( p ) is the farthest site among all + * sites. In contrast, in a regular Voronoi diagram, regions hug their site—they + * surround and contain the generating point, making it visually obvious which + * region belongs to which site. + *

+ * In the FPVD, regions do not hug their site; the generator site is far away + * and not contained in its region. Only vertices of the convex hull have + * regions in the FPVD, as only they can be farthest from some location in the + * plane. + *

+ * A useful interpretation is that all points within a given FPVD region + * mutually share the same farthest site—the region’s generator. However, the + * site for a region is not visually apparent, since it is not located within or + * necessarily even near its region. + * + * @author Michael Carleton + */ +public class FarthestPointVoronoi { + + private Envelope clipEnv = null; // user‐supplied envelope (optional) + private List sites = null; + private GeometryFactory gf = new GeometryFactory(); + private DCEL dcel; + private Envelope env; + + /** + * If set, every edge in the final diagram will be clipped to the larger of + * (this envelope) and (an envelope surrounding the input sites). + */ + public void setClipEnvelope(Envelope clipEnv) { + this.clipEnv = new Envelope(clipEnv); + } + + /** + * Supply your sites as JTS Coordinates. + */ + public void setSites(Collection coords) { + this.sites = new ArrayList<>(coords); + } + + /** + * Supply your sites as a JTS Geometry (e.g. a MultiPoint, or a + * GeometryCollection of Points). + */ + public void setSites(Geometry geom) { + this.sites = new ArrayList<>(); + for (int i = 0; i < geom.getNumGeometries(); i++) { + Geometry g = geom.getGeometryN(i); + if (g instanceof Point) { + sites.add(((Point) g).getCoordinate()); + } + } + } + + /** + * Compute the farthest‐site Voronoi diagram of the sites, as regions clipped + * according to the user-defined envelope (if present). + */ + public Geometry getDiagram() { + getDCEL(); + + var clipPoly = ((Polygon) gf.toGeometry(env)).getExteriorRing(); + var segs = dcel.getEdges().stream().map(e -> gf.createLineString(new Coordinate[] { e.origVertex, e.destVertex })) + .collect(Collectors.toList()); + var segsGeom = gf.createMultiLineString(segs.toArray(new LineString[0])); + var geom = segsGeom.union(clipPoly); // node + + var polygonizer = new Polygonizer(false); + polygonizer.add(geom); + return gf.createMultiPolygon(GeometryFactory.toPolygonArray(polygonizer.getPolygons())); + } + + public DCEL getDCEL() { + if (dcel == null) { // lazily + // 1) compute the sites' bounding‐box + env = new Envelope(); + for (Coordinate c : sites) { + env.expandToInclude(c); + } + if (clipEnv != null) { + env.expandToInclude(clipEnv); + } + + double far = env.getDiameter() * 2; + + // 3) compute convex hull of the sites + MultiPoint mp = gf.createMultiPointFromCoords(sites.toArray(new Coordinate[0])); + Geometry ch = new ConvexHull(mp).getConvexHull(); + List hullCoords = Arrays.asList(ch.getCoordinates()); + + // 4) build the FPVD + dcel = computeFPVD(hullCoords, far); + } + return dcel; + } + + private static DCEL computeFPVD(List hull, final double far) { + // routine from + // https://dccg.upc.edu/people/vera/Applet/smallest_enclosing_circle.html + int n = hull.size(); + DCEL dcel = new DCEL(n); + + // build a closed ring array + Coordinate[] ring = new Coordinate[n + 1]; + for (int i = 0; i < n; i++) { + ring[i] = hull.get(i); + } + ring[n] = hull.get(0); + + // ensure CW ordering for correct bisector direction + if (Orientation.isCCW(ring)) { + Collections.reverse(hull); + } + + // build initial site list S and the “infinite” bisector starts + n -= 1; + List S = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + Coordinate c0 = hull.get(i); + Coordinate c1 = hull.get((i + 1) % n); + S.add(new Coordinate(c0.x, c0.y, i)); + + double dx = c1.x - c0.x, dy = c1.y - c0.y; + double len = Math.hypot(dx, dy); + // outward normal for a CCW hull + double nx = dy / len; + double ny = -dx / len; + + double mx = 0.5 * (c0.x + c1.x) + far * nx; + double my = 0.5 * (c0.y + c1.y) + far * ny; + Coordinate infinitePt = new Coordinate(mx, my); + + dcel.vertices.add(new DCELVertex(infinitePt)); + dcel.points[i] = i; + } + + // iteratively remove sites with the largest circumcircle + // simple n^2 loop (fine since convex hull usually has very few points) + while (S.size() > 2) { + int p = maximize(S); + int q = (p - 1 + S.size()) % S.size(); + int r = (p + 1) % S.size(); + + Coordinate Pq = S.get(q), Pp = S.get(p), Pr = S.get(r); + Coordinate center = Triangle.circumcentre(Pq, Pp, Pr); + + int newVid = dcel.vertices.size(); + int vidPp = dcel.points[(int) Pp.z]; + int vidPq = dcel.points[(int) Pq.z]; + + dcel.edges.add(new DCELEdge(center, dcel.vertices.get(vidPp).point, newVid, vidPp)); + dcel.edges.add(new DCELEdge(center, dcel.vertices.get(vidPq).point, newVid, vidPq)); + + int e2 = dcel.edges.size() - 1; + int e1 = e2 - 1; + + // create new real vertex + DCELVertex v = new DCELVertex(center, e2, e1, true); + v.setCircumcircle(Pq, Pp, Pr); + dcel.vertices.add(v); + dcel.points[(int) Pp.z] = newVid; + + // update Pq‐vertex to point to these half‐edges + int vq = dcel.points[(int) Pq.z]; + dcel.vertices.set(vq, v); + dcel.points[(int) Pq.z] = vq; + + S.remove(p); + } + + // if two sites remain, close them + if (S.size() == 2) { + Coordinate A = S.get(0), B = S.get(1); + int va = dcel.points[(int) A.z]; + int vb = dcel.points[(int) B.z]; + dcel.edges.add(new DCELEdge(dcel.vertices.get(va).point, dcel.vertices.get(vb).point, va, vb)); + } + + return dcel; + } + + /** + * Finds index i of the vertex triplet (i-1,i,i+1) that form the circumcircle + * having the possible largest radius. + */ + private static int maximize(List S) { + double bestR = -1; + int bestI = -1, N = S.size(); + for (int i = 0; i < N; i++) { + Coordinate prev = S.get((i - 1 + N) % N); + Coordinate cur = S.get(i); + Coordinate next = S.get((i + 1) % N); + double r = circumradiusMetric(prev, cur, next); + if (r > bestR) { + bestR = r; + bestI = i; + } + } + return bestI; + } + + /** + * Computes a value proportional to the square of the circumradius of the + * triangle defined by three coordinates. The actual circumradius is not + * computed; instead, the returned value can be used for circumradius + * comparisons between triangles (lower values correspond to smaller + * circumradii). + *

+ * This method is optimized for speed and does not compute any square roots or + * trigonometric functions. + */ + public static double circumradiusMetric(Coordinate a, Coordinate b, Coordinate c) { + // Edge vectors + double abx = b.x - a.x, aby = b.y - a.y; + double acx = c.x - a.x, acy = c.y - a.y; + double bcx = c.x - b.x, bcy = c.y - b.y; + + // Squared edge lengths + double ab2 = abx * abx + aby * aby; + double bc2 = bcx * bcx + bcy * bcy; + double ca2 = acx * acx + acy * acy; // |CA|² = |AC|² + + // Squared twice-area (safer than uu*vv - uv*uv) + double cross = abx * acy - aby * acx; + double cross2 = cross * cross; + if (cross2 == 0.0) // collinear or degenerate + return Double.POSITIVE_INFINITY; + + // metric ∝ R² (the constant 4 is dropped) + return ab2 * bc2 * ca2 / cross2; + } + + /** + * A directed DCEL edge from origVertex → destVertex. + *

+ * However, {@link #hashCode()} and {@link #equals(Object)} are implemented such + * that this edge is considered equal (and hashes equally) to the edge from + * destVertex → origVertex for undirected comparisons. + */ + public static class DCELEdge { + public final Coordinate origVertex; + public final Coordinate destVertex; + public final int origIndex; + public final int destIndex; + + DCELEdge(Coordinate origVertex, Coordinate destVertex, int origIndex, int destIndex) { + this.origVertex = origVertex; + this.destVertex = destVertex; + this.origIndex = origIndex; + this.destIndex = destIndex; + } + + @Override + public int hashCode() { + // For vertices, order agnostic + int verticesHash = origVertex.hashCode() ^ destVertex.hashCode(); + // For indices, order agnostic + int indicesHash = Integer.hashCode(origIndex) ^ Integer.hashCode(destIndex); + // Combine + return 31 * verticesHash + indicesHash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + DCELEdge other = (DCELEdge) obj; + + // Direction-agnostic comparison + boolean case1 = origVertex.equals(other.origVertex) && destVertex.equals(other.destVertex) && origIndex == other.origIndex + && destIndex == other.destIndex; + boolean case2 = origVertex.equals(other.destVertex) && destVertex.equals(other.origVertex) && origIndex == other.destIndex + && destIndex == other.origIndex; + + return case1 || case2; + } + } + + /** + * A DCEL vertex, storing a geometric point, the indices of the outgoing (next) + * and incoming (prev) half‐edges, and a flag real. + */ + public static class DCELVertex { + public final Coordinate point; + public Coordinate[] circumcircle; + /** whether this is a “real” Voronoi vertex vs. the dummy ray‐start */ + public final boolean real; + + DCELVertex(Coordinate p, int next, int prev, boolean real) { + this.point = p; + this.real = real; + } + + DCELVertex(Coordinate p) { + this(p, -1, -1, false); + } + + void setCircumcircle(Coordinate a, Coordinate b, Coordinate c) { + this.circumcircle = new Coordinate[3]; + circumcircle[0] = a; + circumcircle[1] = b; + circumcircle[2] = c; + } + } + + /** + * A simple container for the DCEL. points[i] = index in vertices of the + * DCEL‐vertex corresponding to original hull‐vertex i. + */ + public static class DCEL { + public final List edges; + /** + * Non-infinite vertices comprising the FPVD. + */ + private final List vertices; + private final int[] points; + + DCEL(int nHullVertices) { + this.edges = new ArrayList<>(); + this.vertices = new ArrayList<>(); + this.points = new int[nHullVertices]; + } + + /** + * Returns all (inner/non-infinite) vertices comprising the FPVD edges. + */ + public List getVertices() { + return new ArrayList<>(vertices.stream().filter(v -> v.real).collect(Collectors.toSet())); + } + + /** + * Returns all unique edges comprising the FPVD. + */ + public List getEdges() { + return new ArrayList<>(new HashSet<>(edges)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/FastConvexMaximumInscribedCircle.java b/src/main/java/micycle/pgs/commons/FastConvexMaximumInscribedCircle.java new file mode 100644 index 00000000..1fa0e812 --- /dev/null +++ b/src/main/java/micycle/pgs/commons/FastConvexMaximumInscribedCircle.java @@ -0,0 +1,160 @@ +package micycle.pgs.commons; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.math3.optim.MaxIter; +import org.apache.commons.math3.optim.PointValuePair; +import org.apache.commons.math3.optim.linear.LinearConstraint; +import org.apache.commons.math3.optim.linear.LinearConstraintSet; +import org.apache.commons.math3.optim.linear.LinearObjectiveFunction; +import org.apache.commons.math3.optim.linear.NonNegativeConstraint; +import org.apache.commons.math3.optim.linear.Relationship; +import org.apache.commons.math3.optim.linear.SimplexSolver; +import org.apache.commons.math3.optim.nonlinear.scalar.GoalType; +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.Polygonal; + +/** + * Computes the exact maximum inscribed circle inside a convex polygon by + * solving a linear program. + * + * @author Michael Carleton + */ +public final class FastConvexMaximumInscribedCircle { + + /* + * From 'An Efficient Algorithm to Calculate the Center of the Biggest Inscribed + * Circle in an Irregular Polygon' by OSCAR MARTINEZ. + */ + + /** + * Solve for the maximum inscribed circle from a Geometry. + * + * This method requires a single Polygon. It throws IllegalArgumentException if + * the input is null, not polygonal, or not a single Polygon (e.g. a + * MultiPolygon is rejected). For an empty polygon the delegated solve(Polygon) + * may return null. + * + * @param geom input geometry (must be a single Polygon) + * @return Coordinate (x,y,r) with r in z, or null on failure / empty polygon + * @throws IllegalArgumentException if geom is null or not a single polygonal + * Polygon + */ + public static Coordinate getCircle(Geometry geom) { + if (geom == null) { + throw new IllegalArgumentException("geometry must not be null"); + } + if (!(geom instanceof Polygonal)) { + throw new IllegalArgumentException("geometry must be polygonal"); + } + if (!(geom instanceof Polygon)) { + throw new IllegalArgumentException("expected a single Polygon (not a MultiPolygon)"); + } + return getCircle((Polygon) geom); + } + + /** + * Solve for the maximum inscribed circle inside the given polygon. + *

+ * The method formulates a linear program that maximizes r subject to half-plane + * constraints derived from the polygon edges. For each edge the constraint is + * n.x * x + n.y * y - r >= n · p0 where n is the inward unit normal and p0 is + * an edge endpoint. + *

+ * Important: - The polygon should be convex for the solution to be valid. - If + * a solution cannot be found or an error occurs, null is returned. + *

+ * The returned Coordinate encodes (x,y,r) with r in the z component. + * + * @param polygon polygon to inscribe the maximum inscribed circle into (may + * contain holes) + * @return Coordinate (x,y,r) with r in z, or null on failure or invalid input + */ + public static Coordinate getCircle(Polygon polygon) { + if (polygon == null || polygon.isEmpty()) { + return null; + } + + // maximise r + final LinearObjectiveFunction obj = new LinearObjectiveFunction(new double[] { 0, 0, 1 }, 0); + + final List constraints = new ArrayList<>(); + +// Envelope bounds to help solver stability (keeps x,y near polygon) +// final Envelope env = polygon.getEnvelopeInternal(); +// constraints.add(new LinearConstraint(new double[] { 1, 0, 0 }, Relationship.GEQ, env.getMinX())); +// constraints.add(new LinearConstraint(new double[] { 1, 0, 0 }, Relationship.LEQ, env.getMaxX())); +// constraints.add(new LinearConstraint(new double[] { 0, 1, 0 }, Relationship.GEQ, env.getMinY())); +// constraints.add(new LinearConstraint(new double[] { 0, 1, 0 }, Relationship.LEQ, env.getMaxY())); +// // r >= 0 +// constraints.add(new LinearConstraint(new double[] { 0, 0, 1 }, Relationship.GEQ, 0.0)); + + // Add constraints for outer ring and any holes + addRingConstraints(polygon.getExteriorRing(), polygon, constraints); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) { + addRingConstraints(polygon.getInteriorRingN(i), polygon, constraints); + } + + try { + SimplexSolver solver = new SimplexSolver(1e-3); + PointValuePair sol = solver.optimize(obj, new LinearConstraintSet(constraints), GoalType.MAXIMIZE, new NonNegativeConstraint(false), + new MaxIter(1000)); + if (sol == null) { + return null; + } + + double[] p = sol.getPoint(); + double r = p[2]; + Coordinate c = new Coordinate(p[0], p[1], r); + return c; + } catch (Exception e) { + return null; + } + } + + /** + * Convert a ring (edge list) into half-plane linear constraints and append them + * to the provided list. + * + * @param ring edge ring (exterior or interior) + * @param parent parent polygon (used to detect exterior ring) + * @param out list to append LinearConstraint objects to + */ + private static void addRingConstraints(LineString ring, Polygon parent, List out) { + final Coordinate[] coords = ring.getCoordinates(); + if (coords.length < 2) { + return; + } + + final boolean isShell = ring == parent.getExteriorRing(); + final boolean ccw = Orientation.isCCW(coords); + // If isShell == ccw, left normal points inward; otherwise flip it + final double inwardSign = (isShell == ccw) ? +1.0 : -1.0; + + for (int i = 0; i < coords.length - 1; i++) { + final Coordinate p0 = coords[i]; + final Coordinate p1 = coords[i + 1]; + + final double len = p0.distance(p1); + if (len == 0) { + continue; + } + + final double dx = p1.x - p0.x; + final double dy = p1.y - p0.y; + // Left unit normal + double nx = (-dy / len) * inwardSign; + double ny = (dx / len) * inwardSign; + + // Half-plane: nx*x + ny*y - r >= nx*p0.x + ny*p0.y + final double rhs = nx * p0.x + ny * p0.y; + out.add(new LinearConstraint(new double[] { nx, ny, -1.0 }, Relationship.GEQ, rhs)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/FastOverlapRegions.java b/src/main/java/micycle/pgs/commons/FastOverlapRegions.java new file mode 100644 index 00000000..653bb41c --- /dev/null +++ b/src/main/java/micycle/pgs/commons/FastOverlapRegions.java @@ -0,0 +1,292 @@ +package micycle.pgs.commons; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +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.Polygonal; +import org.locationtech.jts.geom.prep.PreparedGeometry; +import org.locationtech.jts.geom.prep.PreparedPolygon; +import org.locationtech.jts.geom.util.PolygonExtracter; +import org.locationtech.jts.index.SpatialIndex; +import org.locationtech.jts.index.hprtree.HilbertEncoder; +import org.locationtech.jts.index.strtree.STRtree; +import org.locationtech.jts.operation.overlayng.OverlayNG; +import org.locationtech.jts.shape.fractal.HilbertCode; + +/** + * Computes the combined area where two or more input shapes overlap. Optimised + * for efficient calculation for many input shapes/intersections. + * + * @author Michael Carleton + */ +public class FastOverlapRegions { + + private final SpatialIndex spatialIndex; + private final List indexedGeoms; + private final GeometryFactory factory; + + @SuppressWarnings("unchecked") + public FastOverlapRegions(Geometry geometry) { + this(PolygonExtracter.getPolygons(geometry), geometry.getFactory()); + } + + public FastOverlapRegions(List geometries, GeometryFactory factory) { + // build STRtree of valid input geometries + spatialIndex = new STRtree(); + indexedGeoms = new ArrayList<>(geometries.size()); + this.factory = factory; + + int k = 0; + for (int i = 0; i < geometries.size(); i++) { + Geometry g = geometries.get(i); + if (g == null || g.isEmpty() || g.getDimension() < 2 || !(g instanceof Polygonal)) { + continue; + } + IndexedGeom ig = new IndexedGeom(g, k++); + indexedGeoms.add(ig); + spatialIndex.insert(g.getEnvelopeInternal(), ig); + } + +// spatialIndex.build(); + } + + public Geometry get(boolean union) { + // find overlapping regions + var patches = findPairwiseOverlaps(); + + if (!union) { + return factory.buildGeometry(patches.stream().map(p -> p.geom).toList()); + } + + /* + * Before we would find and union connected component groups (groups of patches + * that are known to form an intersecting "blob") to reduce unneccessary union + * operations. However, this optimisation is negligible given the hilbert-sorted + * parallelStream approach. + */ +// var groups = findConnectedComponents(patches); + + var geoms = patches.stream().map(p -> p.geom).collect(Collectors.toList()); + return fastUnion(geoms); + } + + /** + * Computes intersections between all geometries, with parallelism and efficient + * short-circuiting. + * + * @return + */ + private List findPairwiseOverlaps() { + AtomicInteger patchId = new AtomicInteger(0); + var allPairwiseIntersections = indexedGeoms.parallelStream().flatMap(igA -> { + Stream.Builder builder = Stream.builder(); + Geometry a = igA.geom; + + @SuppressWarnings("unchecked") + List candidates = (List) spatialIndex.query(a.getEnvelopeInternal()); + + for (IndexedGeom igB : candidates) { + // skip self‐pair and duplicates + if (igB.idx <= igA.idx) { + continue; + } + + // fast reject if they don’t actually meet + Geometry b = igB.geom; + if (!igA.preparedGeometry.intersects(b)) { + continue; + } + + // compute the real intersection + Geometry inter = OverlayNG.overlay(a, b, OverlayNG.INTERSECTION); // polygonal + if (!inter.isEmpty()) { + builder.add(new IntersectPatch(patchId.getAndIncrement(), inter)); + } + + } + return builder.build(); + }).toList(); + + return allPairwiseIntersections; + } + + /** + * Much faster CascadedPolygonUnion (similar idea, but faster sorting and + * parallel union). + */ + private Geometry fastUnion(List geoms) { + int n = geoms.size(); + sort(geoms, HilbertCode.level(n)); // sort according to center point of MBR + + return geoms.parallelStream().reduce((g1, g2) -> { + var result = g1.union(g2); + if (!(result instanceof Polygonal)) { + var polygons = PolygonExtracter.getPolygons(result); + if (polygons.size() == 1) { + result = (Polygon) polygons.get(0); + } else { + result = factory.buildGeometry(polygons); + } + } + return result; + }).orElse(factory.createEmpty(2)); + } + + /** + * Finds groups of connected (intersecting) geometries from the input list. + * Returns a list of lists, where each inner list contains geometries of one + * connected component. + */ + private List> findConnectedComponents(List patches) { + STRtree patchIndex = new STRtree(); + for (IntersectPatch ip : patches) { + patchIndex.insert(ip.env, ip); + } + patchIndex.build(); + + UnionFind uf2 = new UnionFind(patches.size()); + + // For every patch, look for neighbouring patches whose polygons actually + // touch/overlap + List unionPairs = patches.parallelStream().flatMap(ip -> { + @SuppressWarnings("unchecked") + List neigh = patchIndex.query(ip.env); + return neigh.stream().filter(jp -> jp.idx > ip.idx).filter(jp -> ip.preparedGeometry.intersects(jp.geom)).map(jp -> new int[] { ip.idx, jp.idx }); + }).toList(); + + // Do union in serial, to avoid concurrency issues + for (int[] pair : unionPairs) { + uf2.union(pair[0], pair[1]); + } + + Map> islands = new HashMap<>(); + for (IntersectPatch ip : patches) { + int root = uf2.find(ip.idx); + islands.computeIfAbsent(root, k -> new ArrayList<>()).add(ip.geom); + } + return new ArrayList<>(islands.values()); + } + + /** + * Sorts a list of {@link Geometry} objects in-place by their spatial order + * using Hilbert curve encoding of their envelopes. + * + * @param geoms the list of geometries to sort + * @param level the resolution level for Hilbert curve encoding + */ + private static void sort(List geoms, int level) { + int n = geoms.size(); + if (n < 2) + return; + + Envelope globalExtent = new Envelope(); + for (Geometry g : geoms) { + globalExtent.expandToInclude(g.getEnvelopeInternal()); + } + + HilbertEncoder encoder = new HilbertEncoder(level, globalExtent); + int[] keys = new int[n]; + for (int i = 0; i < n; i++) { + Envelope e = geoms.get(i).getEnvelopeInternal(); + keys[i] = encoder.encode(e); + } + sortInPlaceByKeys(keys, geoms); + } + + /** + * Used by sort(). + */ + private static void sortInPlaceByKeys(int[] keys, List values) { + final int n = keys.length; + + Integer[] idx = IntStream.range(0, n).boxed().toArray(Integer[]::new); + Arrays.sort(idx, Comparator.comparingInt(i -> keys[i])); + + // rearrange keys and values in-place by following permutation cycles, + // so that both arrays are sorted according to hilbert order key. + boolean[] seen = new boolean[n]; + for (int i = 0; i < n; i++) { + if (seen[i] || idx[i] == i) + continue; + + int cycleStart = i; + int j = i; + int savedKey = keys[j]; + T savedVal = values.get(j); + + do { + seen[j] = true; + int next = idx[j]; + keys[j] = keys[next]; + values.set(j, values.get(next)); + + j = next; + } while (j != cycleStart); + + keys[j] = savedKey; + values.set(j, savedVal); + seen[j] = true; + } + } + + private static record IndexedGeom(Geometry geom, int idx, PreparedGeometry preparedGeometry) { + private IndexedGeom(Geometry geom, int idx) { + this(geom, idx, new PreparedPolygon((Polygonal) geom)); + } + } + + private static record IntersectPatch(int idx, // a dense 0..M−1 index + Geometry geom, Envelope env, PreparedGeometry preparedGeometry) { + public IntersectPatch(int idx, Geometry g) { + this(idx, g, g.getEnvelopeInternal(), new PreparedPolygon((Polygonal) g)); + } + } + + private static class UnionFind { + private final int[] parent, size; // change rank to size + + public UnionFind(int n) { + parent = new int[n]; + size = new int[n]; + for (int i = 0; i < n; i++) { + parent[i] = i; + size[i] = 1; // initialize to 1 + } + } + + public int find(int x) { + // iterative path‐halving + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + public void union(int a, int b) { + int ra = find(a), rb = find(b); + if (ra == rb) + return; + if (size[ra] < size[rb]) { + parent[ra] = rb; + size[rb] += size[ra]; + } else { + parent[rb] = ra; + size[ra] += size[rb]; + } + } + } + +} diff --git a/src/main/java/micycle/pgs/commons/FrontChainPacker.java b/src/main/java/micycle/pgs/commons/FrontChainPacker.java index e90d050c..909ed659 100644 --- a/src/main/java/micycle/pgs/commons/FrontChainPacker.java +++ b/src/main/java/micycle/pgs/commons/FrontChainPacker.java @@ -21,7 +21,7 @@ public class FrontChainPacker { // https://observablehq.com/@mbostock/packing-circles-inside-a-rectangle - // https://sci-hub.do/http://dx.doi.org/10.1145/1124772.1124851 + // 'Visualization of large hierarchical data by circle packing' private final float width, height; private final float offsetX, offsetY; @@ -213,7 +213,6 @@ private boolean place(PVector b, PVector a, PVector c) { * Determines whether the candidate circle (represented by PVector) cannot cover * the packing region. * - * @param v * @return true if circle can cover region */ private boolean withinBounds(PVector v) { diff --git a/src/main/java/micycle/pgs/commons/GaussianLineSmoothing.java b/src/main/java/micycle/pgs/commons/GaussianLineSmoothing.java index f66f4029..5b292fb1 100644 --- a/src/main/java/micycle/pgs/commons/GaussianLineSmoothing.java +++ b/src/main/java/micycle/pgs/commons/GaussianLineSmoothing.java @@ -1,9 +1,15 @@ package micycle.pgs.commons; -import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.CoordinateSequences; +import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; import net.jafama.FastMath; @@ -11,203 +17,296 @@ * Line gaussian smoothing. * * @author Julien Gaffuri + * @author Michael Carleton * */ public class GaussianLineSmoothing { - // from https://github.com/locationtech/jts/pull/478 + // based on https://github.com/locationtech/jts/pull/478 - private GaussianLineSmoothing() { - } + // Tuning knobs + private static final int SAMPLES_PER_SIGMA = 6; // 6..8 recommended + private static final double CUTOFF_SIGMAS = 5.0; // truncate kernel at 5σ + private static final int MAX_SAMPLES = 8192; // upper cap per geometry + private static final int COUNT_QUANTUM = 32; // quantize grid size (stability) - /** - * @param line - * @param sigmaM - */ - public static LineString get(LineString line, double sigmaM) { - return get(line, sigmaM, -1); + private GaussianLineSmoothing() { } /** * Line gaussian smoothing. The position of each point is the average position * of its neighbors, weighted by a gaussian kernel. For non-closed lines, the * initial and final points are preserved. - * - * @param line The input line - * @param sigmaM The standard deviation of the gaussian kernel. The larger, - * the more smoothed. - * @param resolution The target resolution of the geometry. This parameter is - * used to filter/simplify the final geometry. + * + * @param line The input line + * @param sigmaM The standard deviation of the gaussian kernel. The larger, the + * more smoothed. */ - public static LineString get(LineString line, double sigmaM, double resolution) { - if (line.getCoordinates().length <= 2) { + public static LineString get(LineString line, double sigmaM) { + if (line == null) { + return null; + } + final int np = line.getNumPoints(); + if (np <= 2 || sigmaM <= 0) { return (LineString) line.copy(); } - // output is sensitive to vertex order, so normalise the line (note doesn't - // normalise orientation) boolean isClosed = line.isClosed(); - if (isClosed) { - line = normalise(line); - } double length = line.getLength(); - double densifiedResolution = sigmaM / 3; + if (length == 0) { + return (LineString) line.copy(); + } - // handle extreme cases: too large sigma resulting in too large densified - // resolution. - if (densifiedResolution > 0.25 * length) { + // Extreme sigma fallback (same as your original idea) + if (sigmaM > 0.333 * length) { + GeometryFactory gf = line.getFactory(); if (isClosed) { - // return tiny triangle nearby center point - Coordinate c = line.getCentroid().getCoordinate(); - length *= 0.01; - return line.getFactory() - .createLineString(new Coordinate[] { new Coordinate(c.x - length, c.y - length), new Coordinate(c.x, c.y + length), - new Coordinate(c.x + length, c.y - length), new Coordinate(c.x - length, c.y - length) }); + return gf.createLineString(); } else { - // return segment - return line.getFactory() - .createLineString(new Coordinate[] { line.getCoordinateN(0), line.getCoordinateN(line.getNumPoints() - 1) }); + return line.getFactory().createLineString(new Coordinate[] { line.getCoordinateN(0), line.getCoordinateN(np - 1) }); } } - // compute densified line - Coordinate[] densifiedCoords = LittleThumblingDensifier.densify(line, densifiedResolution).getCoordinates(); + LineString src = line; + if (isClosed) { + /* + * LineString.norm() does not reorder coordinates so we norm with method below. + * Output of Gaussian smoothing is sensitive to vertex order! + */ + normalize((LinearRing) line, true); + } - // build output line structure - int nb = (int) (length / densifiedResolution); - Coordinate[] out = new Coordinate[nb + 1]; + // Build a STABLE uniform arc-length grid: + // - density ~ SAMPLES_PER_SIGMA per sigma + // - sample count quantized to COUNT_QUANTUM + // - capped by MAX_SAMPLES + Resampled rs = resampleUniformStable(src, sigmaM, isClosed); + Coordinate[] samples = rs.coords; + int n = samples.length; + int M = isClosed ? (n - 1) : n; // number of unique samples to convolve + double step = rs.step; - // prepare gaussian coefficients - int n = 7 * 3; // it should be: E(7*sigma/densifiedResolution), which is 7*3; - double[] gcs = new double[n + 1]; - final double a = sigmaM * Math.sqrt(2 * Math.PI); - final double b = sigmaM * sigmaM * 2; - final double d = densifiedResolution * densifiedResolution; - for (int i = 0; i < n + 1; i++) { - gcs[i] = FastMath.exp(-i * i * d / b) / a; + // Pack into arrays + double[] xs = new double[M]; + double[] ys = new double[M]; + for (int i = 0; i < M; i++) { + xs[i] = samples[i].x; + ys[i] = samples[i].y; } - final Coordinate c0 = densifiedCoords[0]; - final Coordinate cN = densifiedCoords[nb]; - for (int i = 0; i < nb; i++) { - if (!isClosed && i == 0) { - continue; - } + // Precompute Gaussian weights (truncated) using exact distances + int halfWidth = Math.max(1, (int) Math.ceil(CUTOFF_SIGMAS * sigmaM / step)); + if (isClosed) { + halfWidth = Math.min(halfWidth, (M - 1) / 2); + } + double[] w = new double[halfWidth + 1]; + final double twoSigma2 = 2.0 * sigmaM * sigmaM; + w[0] = 1.0; + for (int j = 1; j <= halfWidth; j++) { + double d = j * step; + w[j] = FastMath.exp(-(d * d) / twoSigma2); + } + + // Convolve with per-point renormalization (exact discrete Gaussian on this + // grid) + double[] ox = new double[M]; + double[] oy = new double[M]; - // compute coordinates of point i of the smoothed line (gauss mean) - double x = 0.0, y = 0.0; - for (int j = -n; j <= n; j++) { - // index of the point to consider on the original densified line - int q = i + j; - // find coordinates (xq,yq) of point q - double xq, yq; - if (q < 0) { - if (isClosed) { - // make loop to get the right point - q = q % nb; - if (q < 0) { - q += nb; - } - Coordinate c = densifiedCoords[q]; - xq = c.x; - yq = c.y; - } else { - // get symetric point - q = (-q) % nb; - if (q == 0) { - q = nb; - } - Coordinate c = densifiedCoords[q]; - xq = 2 * c0.x - c.x; - yq = 2 * c0.y - c.y; - } - } else if (q > nb) { - if (isClosed) { - // make loop to get the right point - q = q % nb; - if (q == 0) { - q = nb; - } - Coordinate c = densifiedCoords[q]; - xq = c.x; - yq = c.y; - } else { - // get symetric point - q = nb - q % nb; - if (q == nb) { - q = 0; - } - Coordinate c = densifiedCoords[q]; - xq = 2 * cN.x - c.x; - yq = 2 * cN.y - c.y; - } - } else { - // general case (most frequent) - Coordinate c = densifiedCoords[q]; - xq = c.x; - yq = c.y; + if (isClosed) { + for (int i = 0; i < M; i++) { + double sx = xs[i] * w[0]; + double sy = ys[i] * w[0]; + double sw = w[0]; + for (int j = 1; j <= halfWidth; j++) { + int il = wrap(i - j, M); + int ir = wrap(i + j, M); + double ww = w[j]; + sx += ww * (xs[il] + xs[ir]); + sy += ww * (ys[il] + ys[ir]); + sw += 2.0 * ww; + } + ox[i] = sx / sw; + oy[i] = sy / sw; + } + } else { + // Reflect at the ends; also preserve endpoints exactly + for (int i = 0; i < M; i++) { + if (i == 0 || i == M - 1) { + ox[i] = xs[i]; + oy[i] = ys[i]; + continue; } - // get gaussian coefficient - double gc = gcs[j >= 0 ? j : -j]; - // add contribution of point q to new position of point i - x += xq * gc; - y += yq * gc; + double sx = xs[i] * w[0]; + double sy = ys[i] * w[0]; + double sw = w[0]; + for (int j = 1; j <= halfWidth; j++) { + int il = reflect(i - j, M); + int ir = reflect(i + j, M); + double ww = w[j]; + sx += ww * (xs[il] + xs[ir]); + sy += ww * (ys[il] + ys[ir]); + sw += 2.0 * ww; + } + ox[i] = sx / sw; + oy[i] = sy / sw; } - // assign smoothed position of point i - out[i] = new Coordinate(x * densifiedResolution, y * densifiedResolution); } - // handle start and end points + // Rebuild geometry + Coordinate[] out; if (isClosed) { - // ensure start and end locations are the same - out[nb] = out[0]; + out = new Coordinate[M + 1]; + for (int i = 0; i < M; i++) { + out[i] = new Coordinate(ox[i], oy[i]); + } + out[M] = new Coordinate(out[0]); } else { - // ensure start and end points are at the same position as the initial geometry - out[0] = densifiedCoords[0]; - out[nb] = densifiedCoords[densifiedCoords.length - 1]; + out = new Coordinate[M]; + for (int i = 0; i < M; i++) { + out[i] = new Coordinate(ox[i], oy[i]); + } + out[0] = new Coordinate(samples[0]); // exact endpoints + out[M - 1] = new Coordinate(samples[n - 1]); } - - LineString lsOut = line.getFactory().createLineString(out); - return lsOut; + return line.getFactory().createLineString(out); } - /** - * Normalises the LineString so that it starts from the coordinate with the - * smallest x value. In case of a tie on the x value, the smallest y value is - * used. - * - * @param line The open or closed LineString to be normalised. - * @return A new LineString with coordinates ordered starting from the smallest - * x (and y, if tied). - */ - private static LineString normalise(LineString line) { - boolean isClosed = line.isClosed(); + // Stable resampling: + // - samples-per-sigma fixed + // - count quantized (COUNT_QUANTUM) + // - independent of input vertex placement + private static Resampled resampleUniformStable(LineString line, double sigmaM, boolean isClosed) { + Coordinate[] in = line.getCoordinates(); + int n = in.length; + if (n < 2) { + return new Resampled(in, 1.0); + } - Coordinate[] originalCoords = line.getCoordinates(); - if (isClosed && originalCoords[0].equals2D(originalCoords[originalCoords.length - 1])) { - // Remove last vertex if it is a duplicate of the first for closed lines - originalCoords = Arrays.copyOf(originalCoords, originalCoords.length - 1); + double total = 0.0; + for (int i = 1; i < n; i++) { + total += dist(in[i - 1], in[i]); + } + if (isClosed && !in[0].equals2D(in[n - 1])) { + total += dist(in[n - 1], in[0]); + } + if (total == 0.0) { + return new Resampled(in, 1.0); } - // Find index of coordinate with smallest x value, tie by y value - int minIndex = 0; - for (int i = 1; i < originalCoords.length; i++) { - if (originalCoords[i].x < originalCoords[minIndex].x - || (originalCoords[i].x == originalCoords[minIndex].x && originalCoords[i].y < originalCoords[minIndex].y)) { - minIndex = i; - } + // Target count based on samples-per-sigma, with caps and quantization for + // stability + int target = Math.max(8, (int) Math.ceil((SAMPLES_PER_SIGMA * total) / Math.max(sigmaM, 1e-12))); + target = Math.min(target, MAX_SAMPLES); + if (isClosed) { + // ensure at least 4 samples + target = Math.max(4, target); + } else { + target = Math.max(2, target); + } + // Quantize to a multiple of COUNT_QUANTUM to avoid flicker from tiny length + // changes + if (target > COUNT_QUANTUM) { + int q = (target + COUNT_QUANTUM / 2) / COUNT_QUANTUM; + target = Math.max(COUNT_QUANTUM, q * COUNT_QUANTUM); + target = Math.min(target, MAX_SAMPLES); } - // Rotate array to start from vertex with smallest x (and y, if tied) - Coordinate[] rotatedCoords = new Coordinate[originalCoords.length + (isClosed ? 1 : 0)]; - System.arraycopy(originalCoords, minIndex, rotatedCoords, 0, originalCoords.length - minIndex); - System.arraycopy(originalCoords, 0, rotatedCoords, originalCoords.length - minIndex, minIndex); + double step = total / target; if (isClosed) { - rotatedCoords[rotatedCoords.length - 1] = rotatedCoords[0]; // Close the loop + List out = new ArrayList<>(target + 1); + double targetS = 0.0; + int segIdx = 0; + double acc = 0.0; + Coordinate a = in[0]; + Coordinate b = (n > 1 ? in[1] : in[0]); + double segLen = dist(a, b); + + for (int k = 0; k < target; k++) { + while (acc + segLen < targetS) { + acc += segLen; + segIdx++; + int next = (segIdx + 1 < n) ? segIdx + 1 : 0; + a = in[segIdx % n]; + b = in[next]; + segLen = dist(a, b); + } + double t = segLen > 0 ? (targetS - acc) / segLen : 0.0; + out.add(new Coordinate(a.x + t * (b.x - a.x), a.y + t * (b.y - a.y))); + targetS += step; + } + out.add(new Coordinate(out.get(0))); + return new Resampled(out.toArray(new Coordinate[0]), step); + } else { + List out = new ArrayList<>(target + 1); + out.add(new Coordinate(in[0])); + double targetS = step; + int segIdx = 0; + double acc = 0.0; + Coordinate a = in[0]; + Coordinate b = in[1]; + double segLen = dist(a, b); + + while (targetS < total && segIdx < n - 1) { + while (acc + segLen < targetS && segIdx < n - 1) { + acc += segLen; + segIdx++; + a = in[segIdx]; + b = (segIdx + 1 < n) ? in[segIdx + 1] : in[segIdx]; + segLen = dist(a, b); + } + double t = segLen > 0 ? (targetS - acc) / segLen : 0.0; + out.add(new Coordinate(a.x + t * (b.x - a.x), a.y + t * (b.y - a.y))); + targetS += step; + } + out.add(new Coordinate(in[n - 1])); + return new Resampled(out.toArray(new Coordinate[0]), step); } + } - return line.getFactory().createLineString(rotatedCoords); + private static int wrap(int i, int n) { + int r = i % n; + return r < 0 ? r + n : r; } + private static int reflect(int i, int n) { + if (n == 1) { + return 0; + } + while (i < 0 || i >= n) { + if (i < 0) { + i = -i - 1; + } else { + i = 2 * n - i - 1; + } + } + return i; + } + + private static double dist(Coordinate a, Coordinate b) { + return a.distance(b); + } + + private static void normalize(LinearRing ring, boolean clockwise) { + if (ring.isEmpty()) { + return; + } + + CoordinateSequence seq = ring.getCoordinateSequence(); + int minCoordinateIndex = CoordinateSequences.minCoordinateIndex(seq, 0, seq.size() - 2); + CoordinateSequences.scroll(seq, minCoordinateIndex, true); + if (Orientation.isCCW(seq) == clockwise) { + CoordinateSequences.reverse(seq); + } + } + + private static final class Resampled { + final Coordinate[] coords; + final double step; + + Resampled(Coordinate[] coords, double step) { + this.coords = coords; + this.step = step; + } + } } \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/GreedyTSP.java b/src/main/java/micycle/pgs/commons/GreedyTSP.java new file mode 100644 index 00000000..be0303bd --- /dev/null +++ b/src/main/java/micycle/pgs/commons/GreedyTSP.java @@ -0,0 +1,302 @@ +package micycle.pgs.commons; + +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; +import java.util.function.ToDoubleBiFunction; +import java.util.stream.Collectors; + +/** + * A high-performance implementation of the Traveling Salesman Problem (TSP) + * using a greedy construction heuristic followed by 2-opt local search + * improvement. + * + *

Algorithm Overview

+ *

+ * This implementation uses a two-phase approach: + *

+ *
    + *
  1. Greedy Construction: Builds an initial tour by + * repeatedly selecting the shortest available edge that doesn't violate TSP + * constraints (no cycles except the final one, maximum degree 2 per + * vertex)
  2. + *
  3. 2-opt Improvement: Iteratively improves the tour by + * swapping edges until no further improvement is possible
  4. + *
+ * + * @param the type of vertices in the graph. Can be any type for which + * distances can be computed. + * + * @author Michael Carleton + */ +public class GreedyTSP { + + private final List vertices; + private final ToDoubleBiFunction distFunc; + private final double[][] allDist; + + public GreedyTSP(List vertices, ToDoubleBiFunction distFunc) { + if (vertices == null || vertices.isEmpty()) { + throw new IllegalArgumentException("Vertex list must not be null or empty"); + } + this.vertices = List.copyOf(vertices); + this.distFunc = distFunc; + this.allDist = initDistanceTable(); + } + + /** + * Build the full symmetric distance matrix. + */ + private double[][] initDistanceTable() { + int n = vertices.size(); + double[][] d = new double[n][n]; + for (int i = 0; i < n; i++) { + d[i][i] = 0; + for (int j = i + 1; j < n; j++) { + double dij = distFunc.applyAsDouble(vertices.get(i), vertices.get(j)); + d[i][j] = dij; + d[j][i] = dij; + } + } + return d; + } + + /** + * Runs greedy construction heuristic, then improves with 2-opt, and returns a + * CLOSED tour (first vertex repeated at end). + */ + public List getTour() { + int n = vertices.size(); + if (n == 1) { + return List.of(vertices.get(0), vertices.get(0)); + } + if (n == 2) { + V a = vertices.get(0), b = vertices.get(1); + return List.of(a, b, a); + } + + // 1) Build tour using greedy edge selection + int[] tour = buildGreedyTour(); + + // 2) Improve with 2-opt + improve(tour); + + // 3) Map back to V + return Arrays.stream(tour).mapToObj(vertices::get).collect(Collectors.toList()); + } + + /** + * Edge record for efficient immutable edge representation. + */ + private record Edge(int u, int v, double weight) implements Comparable { + @Override + public int compareTo(Edge other) { + return Double.compare(this.weight, other.weight); + } + } + + /** + * Build tour using greedy edge selection with optimizations. + */ + private int[] buildGreedyTour() { + int n = vertices.size(); + + // Pre-allocate exact capacity + int edgeCount = n * (n - 1) / 2; + Edge[] edges = new Edge[edgeCount]; + int idx = 0; + + // Create edges array directly (avoid List overhead) + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + edges[idx++] = new Edge(i, j, allDist[i][j]); + } + } + Arrays.sort(edges); + + // Use byte array for degrees (max degree is 2) + byte[] degree = new byte[n]; + UnionFind uf = new UnionFind(n); + + // Pre-allocate adjacency lists with exact capacity (2) + int[][] adj = new int[n][2]; + for (int i = 0; i < n; i++) { + adj[i][0] = adj[i][1] = -1; + } + + int edgesAdded = 0; + for (Edge e : edges) { + // Fast degree check + // Fast cycle check (skip for last edge) + if (degree[e.u] == 2 || degree[e.v] == 2 || (edgesAdded < n - 1 && uf.connected(e.u, e.v))) { + continue; + } + + // Add edge to adjacency (no list needed, max 2 neighbors) + adj[e.u][degree[e.u]] = e.v; + adj[e.v][degree[e.v]] = e.u; + degree[e.u]++; + degree[e.v]++; + uf.union(e.u, e.v); + + if (++edgesAdded == n) { + break; + } + } + + // Convert adjacency representation to tour array + return buildTourFromAdjacency(adj); + } + + /** + * Convert adjacency array representation to tour array. Optimized to avoid list + * operations. + */ + private int[] buildTourFromAdjacency(int[][] adj) { + int n = vertices.size(); + int[] tour = new int[n + 1]; + + // Use bitset for visited tracking (more cache-friendly) + BitSet visited = new BitSet(n); + + tour[0] = 0; + visited.set(0); + int current = 0; + int prev = -1; + + // Follow the path (each vertex has exactly 2 neighbors) + for (int i = 1; i < n; i++) { + int next = adj[current][0]; + if (next == prev || visited.get(next)) { + next = adj[current][1]; + } + tour[i] = next; + visited.set(next); + prev = current; + current = next; + } + + tour[n] = 0; // close the tour + return tour; + } + + /** + * Optimized Union-Find with path compression and union by rank. + */ + private static class UnionFind { + private final int[] parent; + private final byte[] rank; // rank never exceeds log(n) + + UnionFind(int n) { + parent = new int[n]; + rank = new byte[n]; + for (int i = 0; i < n; i++) { + parent[i] = i; + } + } + + int find(int x) { + int root = x; + // Find root + while (parent[root] != root) { + root = parent[root]; + } + // Path compression + while (x != root) { + int next = parent[x]; + parent[x] = root; + x = next; + } + return root; + } + + boolean connected(int x, int y) { + return find(x) == find(y); + } + + void union(int x, int y) { + int px = find(x); + int py = find(y); + if (px == py) { + return; + } + + // Union by rank + if (rank[px] < rank[py]) { + parent[px] = py; + } else if (rank[px] > rank[py]) { + parent[py] = px; + } else { + parent[py] = px; + rank[px]++; + } + } + } + + /** + * Improve tour with 2-opt. Optimized with early termination and better cache + * patterns. + */ + private void improve(int[] tour) { + int N = tour.length - 1; + double minImprovement = 1e-9; + int stallCount = 0; + int maxStalls = 3; // stop after 3 rounds with tiny improvements + + while (true) { + double bestDelta = 0; + int bestI = -1, bestJ = -1; + + // Cache-friendly iteration pattern + for (int i = 0; i < N - 2; i++) { + int ci = tour[i], ci1 = tour[i + 1]; + double currentEdge = allDist[ci][ci1]; + + // Start j from i+2 to avoid adjacent edges + for (int j = i + 2; j < N; j++) { + int cj = tour[j], cj1 = tour[j + 1]; + + // Quick calculation with early exit + double newEdges = allDist[ci][cj] + allDist[ci1][cj1]; + double oldEdges = currentEdge + allDist[cj][cj1]; + double delta = newEdges - oldEdges; + + if (delta < bestDelta) { + bestDelta = delta; + bestI = i; + bestJ = j; + } + } + } + + if (bestDelta < -minImprovement) { + // Apply the improvement + reverse(tour, bestI + 1, bestJ); + stallCount = 0; + } else if (bestDelta < 0) { + // Very small improvement + reverse(tour, bestI + 1, bestJ); + if (++stallCount >= maxStalls) { + break; + } + } else { + // No improvement found + break; + } + } + } + + /** + * Optimized in-place reverse using XOR swap for primitives. + */ + private void reverse(int[] tour, int from, int to) { + while (from < to) { + // XOR swap (avoids temp variable) + tour[from] ^= tour[to]; + tour[to] ^= tour[from]; + tour[from] ^= tour[to]; + from++; + to--; + } + } +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/IncrementalTinDual.java b/src/main/java/micycle/pgs/commons/IncrementalTinDual.java index 8eb7e3ed..dde3ef2e 100644 --- a/src/main/java/micycle/pgs/commons/IncrementalTinDual.java +++ b/src/main/java/micycle/pgs/commons/IncrementalTinDual.java @@ -65,7 +65,7 @@ public IncrementalTinDual(IIncrementalTin tin) { */ private void createDualEdges() { tin.getEdgeIterator().forEachRemaining(e -> { // iterates base edges only - if (hasConstraints && !e.isConstrainedRegionInterior()) { + if (hasConstraints && !e.isConstraintRegionInterior()) { // TODO keep unconstrained edges, but mark as unconstrained? return; } @@ -101,7 +101,7 @@ public PShape getMesh() { final HashSet seenHubs = new HashSet<>(); edgeDuals.keySet().forEach(edge -> { - if (hasConstraints && !edge.isConstrainedRegionInterior()) { + if (hasConstraints && !edge.isConstraintRegionInterior()) { return; } 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..58c53a41 --- /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.length > 0) { + 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 && coords.length > 0) { + 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/LargestEmptyCircles.java b/src/main/java/micycle/pgs/commons/LargestEmptyCircles.java index 447a1b70..8a713e59 100644 --- a/src/main/java/micycle/pgs/commons/LargestEmptyCircles.java +++ b/src/main/java/micycle/pgs/commons/LargestEmptyCircles.java @@ -1,11 +1,12 @@ package micycle.pgs.commons; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; -import java.util.LinkedList; import java.util.List; -import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator; +import org.locationtech.jts.algorithm.locate.PointOnGeometryLocator; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; @@ -15,6 +16,8 @@ import org.locationtech.jts.geom.Polygonal; import org.locationtech.jts.operation.distance.IndexedFacetDistance; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + /** * Adapts {@link org.locationtech.jts.algorithm.construct.LargestEmptyCircle * LargestEmptyCircle}, allowing for repeated calls to find the N largest empty @@ -35,15 +38,14 @@ public class LargestEmptyCircles { private final double tolerance; private final GeometryFactory factory; - private IndexedPointInAreaLocator obstaclesPointLocator; // when obstacles are polygonal - private IndexedPointInAreaLocator boundsPointLocator; + private PointOnGeometryLocator obstaclesPointLocator; // when obstacles are polygonal + private PointOnGeometryLocator boundsPointLocator; private IndexedFacetDistance obstacleDistance; private IndexedFacetDistance boundaryDistance; private Envelope gridEnv; private Cell farthestCell; - // Priority queue of cells, ordered by decreasing distance from constraints - private LinkedList cellQueue = new LinkedList<>(); + private ArrayDeque cellStack = new ArrayDeque<>(); private List nextIterCells = new ArrayList<>(); private List circles = new ArrayList<>(); @@ -79,16 +81,15 @@ public LargestEmptyCircles(Geometry obstacles, Geometry boundary, double toleran this.tolerance = tolerance; if (obstacles instanceof Polygonal && boundary != null) { - // used to exclude circle from lying within the obstacles - obstaclesPointLocator = new IndexedPointInAreaLocator(obstacles); + obstaclesPointLocator = new YStripesPointInAreaLocator(obstacles); } - /* - * If no boundary given, use convex hull of obstacles as boundary. - */ if (boundary == null || boundary.isEmpty()) { if (obstacles instanceof Polygonal) { boundary = obstacles; } else { + /* + * If no boundary given, use convex hull of obstacles as boundary. + */ boundary = obstacles.convexHull(); } } @@ -98,23 +99,22 @@ public LargestEmptyCircles(Geometry obstacles, Geometry boundary, double toleran this.factory = obstacles.getFactory(); /* - * Circle *radii* will always be bounded by the boundary (in the JTS - * implementation, only circle center points must lie within the boundary). + * Combine, in case the nearest obstacle is farther away than the nearest + * boundary. (it's faster to make one call on a larger index than 2 separate + * calls to each index). */ final Geometry distGeom = obstacles.getFactory().createGeometryCollection(new Geometry[] { obstacles, boundary }); obstacleDistance = new IndexedFacetDistance(distGeom); - +// obstacleDistance = new IndexedFacetDistance(obstacles); } private void initBoundary() { gridEnv = boundary.getEnvelopeInternal(); - // if bounds does not enclose an area cannot create a boundsPointLocator if (boundary.getDimension() >= 2) { - boundsPointLocator = new IndexedPointInAreaLocator(boundary); + boundsPointLocator = new YStripesPointInAreaLocator(boundary); boundaryDistance = new IndexedFacetDistance(boundary); } - - createInitialGrid(gridEnv, cellQueue); + createInitialGrid(gridEnv, cellStack); } /** @@ -134,12 +134,11 @@ private double distanceToConstraints(Point p) { double boundaryDist = boundaryDistance.distance(p); return -boundaryDist; } - double dist = obstacleDistance.distance(p); - /* * If obstacles are polygonal, ensure circles do not lie within their interior. * Only applies when the given boundary is not null. */ + double dist = obstacleDistance.distance(p); if (obstaclesPointLocator != null && (obstaclesPointLocator.locate(c) == Location.INTERIOR)) { dist = -dist; } @@ -152,12 +151,6 @@ private double distanceToConstraints(double x, double y) { return distanceToConstraints(pt); } - /** - * Computes the (next) N largest empty circles. - * - * @param n number of circles - * @return array of circles; each circle is represented as: [x,y,r] - */ public double[][] findLECs(int n) { double[][] lecs = new double[n][3]; for (int i = 0; i < n; i++) { @@ -166,39 +159,45 @@ public double[][] findLECs(int n) { return lecs; } - /** - * Computes the next largest empty circle. - * - * @return an array representing the circle: [x,y,r] - */ public double[] findNextLEC() { double farthestD; if (gridEnv == null) { // first iteration initBoundary(); - // use the area centroid as the initial candidate center point + // pick best seed from initial grid instead of only centroid farthestCell = createCentroidCell(obstacles); farthestD = farthestCell.getDistance(); + for (Cell c : cellStack) { + if (c.getDistance() > farthestD) { + farthestCell = c; + farthestD = c.getDistance(); + } + } } else { + // Update remaining candidates with the newly-placed circle nextIterCells.forEach(c -> c.updateDistance(circles.get(circles.size() - 1))); - cellQueue = new LinkedList<>(nextIterCells); + cellStack = new ArrayDeque<>(nextIterCells); nextIterCells.clear(); - farthestD = Double.MIN_VALUE; + + // Seed best for this iteration from what we already have + farthestD = Double.NEGATIVE_INFINITY; + for (Cell c : cellStack) { + if (c.getDistance() > farthestD) { + farthestCell = c; + farthestD = c.getDistance(); + } + } } - /* - * Carry out the branch-and-bound search of the cell space. - */ - while (!cellQueue.isEmpty()) { - // pick the cell with greatest distance from the queue - Cell cell = cellQueue.removeLast(); + // Branch-and-bound + while (!cellStack.isEmpty()) { + // LIFO pop for DFS-like behavior + Cell cell = cellStack.removeLast(); - // update the center cell if the candidate is further from the constraints if (cell.getDistance() > farthestD) { farthestCell = cell; farthestD = farthestCell.getDistance(); } - /* * If this cell may contain a better approximation to the center of the empty * circle, then refine it (partition into subcells which are added into the @@ -222,7 +221,7 @@ public double[] findNextLEC() { * Cell is inside the boundary. It may contain the center if the maximum * possible distance is greater than the current distance (up to tolerance). */ - double potentialIncrease = cell.getMaxDistance() - farthestCell.getDistance(); + double potentialIncrease = cell.getMaxDistance() - farthestD; if (potentialIncrease > tolerance) { enqueueChildren(cell); } else { @@ -231,7 +230,7 @@ public double[] findNextLEC() { } } } - // the farthest cell is the best approximation to the LEC center + final Cell lecCell = farthestCell; final double r = lecCell.distance; final double[] circle = new double[] { lecCell.getX(), lecCell.getY(), r }; @@ -240,23 +239,67 @@ public double[] findNextLEC() { return circle; } - // split the cell into four sub-cells private void enqueueChildren(final Cell cell) { final double h2 = cell.getHSide() / 2; + final double parentDist = cell.getDistance(); + final double farthestD = (farthestCell != null) ? farthestCell.getDistance() : Double.NEGATIVE_INFINITY; + + // The max potential increase from parent's center to any point in a child cell + // is dist(parent_center, child_corner) = sqrt((h2)^2 + (h2)^2) = h2*sqrt(2) + // The max distance in a child cell is at its corner, which is h2*sqrt(2) from + // its center. + // So, an upper bound on a child's maxDist is parentDist + 2 * h2 * SQRT2 + double maxChildPotential = parentDist + 2 * h2 * Math.sqrt(2); + + if (maxChildPotential <= farthestD + tolerance) { + // Even the most optimistic estimate for any child of this cell + // won't beat the current best. So we don't need to subdivide. + // We might still need to keep this cell for the next iteration. + nextIterCells.add(cell); + return; + } - cellQueue.add(createCell(cell.x - h2, cell.y - h2, h2)); - cellQueue.add(createCell(cell.x + h2, cell.y - h2, h2)); - cellQueue.add(createCell(cell.x - h2, cell.y + h2, h2)); - cellQueue.add(createCell(cell.x + h2, cell.y + h2, h2)); + Cell c1 = createCellIfPromising(cell.x - h2, cell.y - h2, h2, farthestD); + Cell c2 = createCellIfPromising(cell.x + h2, cell.y - h2, h2, farthestD); + Cell c3 = createCellIfPromising(cell.x - h2, cell.y + h2, h2, farthestD); + Cell c4 = createCellIfPromising(cell.x + h2, cell.y + h2, h2, farthestD); + + Cell[] kids = new Cell[] { c1, c2, c3, c4 }; + Arrays.sort(kids, (a, b) -> { + if (a == null && b == null) + return 0; + if (a == null) + return -1; // nulls go first + if (b == null) + return 1; + return Double.compare(a.getMaxDistance(), b.getMaxDistance()); + }); + + for (Cell k : kids) { + if (k != null) { + cellStack.addLast(k); + } + } } - /** - * Initializes the queue with a grid of cells covering the extent of the area. - * - * @param env the area extent to cover - * @param cellQueue the queue to initialize - */ - private void createInitialGrid(Envelope env, Collection cellQueue) { + // Helper method to create a cell only if it's worth investigating + private Cell createCellIfPromising(final double x, final double y, final double h, double farthestD) { + // We can't use the Lipschitz bound here because we don't know the parent's + // distance, + // but we can check the cell after creation before adding it. + // The main pruning is the check in enqueueChildren. This is a secondary check. + Cell c = createCell(x, y, h); + if (c.getMaxDistance() > farthestD + tolerance) { + return c; + } + // If not promising, but might be useful for the next LEC search, add it there. + if (!c.isFullyOutside()) { + nextIterCells.add(c); + } + return null; + } + + private void createInitialGrid(Envelope env, Collection target) { double minX = env.getMinX(); double maxX = env.getMaxX(); double minY = env.getMinY(); @@ -266,10 +309,9 @@ private void createInitialGrid(Envelope env, Collection cellQueue) { double cellSize = Math.min(width, height); double hSize = cellSize / 2.0; - // compute initial grid of cells to cover area for (double x = minX; x < maxX; x += cellSize) { for (double y = minY; y < maxY; y += cellSize) { - cellQueue.add(createCell(x + hSize, y + hSize, hSize)); + target.add(createCell(x + hSize, y + hSize, hSize)); } } } @@ -280,19 +322,11 @@ private Cell createCell(final double x, final double y, final double h) { return c; } - // create a cell centered on area centroid private Cell createCentroidCell(Geometry geom) { Point p = geom.getCentroid(); return new Cell(p.getX(), p.getY(), 0, distanceToConstraints(p)); } - /** - * A square grid cell centered on a given point with a given side half-length, - * and having a given distance from the center point to the constraints. The - * maximum possible distance from any point in the cell to the constraints can - * be computed. This is used as the ordering and upper-bound function in the - * branch-and-bound algorithm. - */ private static class Cell implements Comparable { private static final double SQRT2 = 1.4142135623730951; @@ -304,64 +338,59 @@ private static class Cell implements Comparable { private double maxDist; Cell(double x, double y, double hSide, double distanceToConstraints) { - this.x = x; // cell center x - this.y = y; // cell center y - this.hSide = hSide; // half the cell size - - // the distance from cell center to constraints + this.x = x; + this.y = y; + this.hSide = hSide; distance = distanceToConstraints; - - /* - * The maximum possible distance to the constraints for points in this cell is - * the center distance plus the radius (half the diagonal length). - */ this.maxDist = distance + hSide * SQRT2; } + // CHANGED: sqrt-free early-rejection for circle; only take sqrt if it might + // improve public void updateDistance(double[] c) { - double deltaX = x - c[0]; - double deltaY = y - c[1]; - // for now d is circle-cell-center dist - double d = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - double r = c[2]; - - if (d < r) { - d = -(r - d); // negative (inside the circle) - } else { - // d is distance from cell center to circle boundary - d -= r; - } - - if (d < distance) { - distance = d; - maxDist = distance + hSide * SQRT2; + final double dx = x - c[0]; + final double dy = y - c[1]; + final double dsq = dx * dx + dy * dy; + final double r = c[2]; + + double D = distance; + double t = D + r; // improvement only possible if sqrt(dsq) < D + r + if (t > 0) { + double tsq = t * t; + if (dsq < tsq) { + double d = Math.sqrt(dsq) - r; // signed (negative when inside) + if (d < D) { + distance = d; + maxDist = distance + hSide * SQRT2; + } + } } } - /** - * Updates the distance of this cell based on circle constraints. Distance - * between circle boundary is used. - */ + // CHANGED: sqrt-free early-rejection loop over all circles public void updateDistance(List circles) { - double minCircleDist = Double.MAX_VALUE; + double D = distance; for (double[] c : circles) { - double deltaX = x - c[0]; - double deltaY = y - c[1]; - // for now d is circle-cell-center dist - double d = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - double r = c[2]; - - if (d < r) { - d = -(r - d); // negative (inside the circle) - } else { - // d is distance from cell center to circle boundary - d -= r; + final double r = c[2]; + double t = D + r; + if (t <= 0) { + // No circle can improve when D <= -r for this circle + continue; + } + final double dx = x - c[0]; + final double dy = y - c[1]; + final double dsq = dx * dx + dy * dy; + final double tsq = t * t; + + if (dsq < tsq) { + double d = Math.sqrt(dsq) - r; // signed (negative when inside) + if (d < D) { + D = d; + } } - minCircleDist = Math.min(minCircleDist, d); } - - if (minCircleDist < distance) { - distance = minCircleDist; + if (D < distance) { + distance = D; maxDist = distance + hSide * SQRT2; } } @@ -394,9 +423,6 @@ public double getY() { return y; } - /** - * A cell is greater if its maximum distance is larger. - */ @Override public int compareTo(Cell o) { return (int) (o.maxDist - this.maxDist); 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..505ddcd6 100644 --- a/src/main/java/micycle/pgs/commons/PEdge.java +++ b/src/main/java/micycle/pgs/commons/PEdge.java @@ -1,32 +1,52 @@ package micycle.pgs.commons; +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.Distance; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.LineSegment; + import processing.core.PVector; /** * An undirected edge / line segment joining 2 PVectors. *

* Note: PEdges PEdge(a, b) and PEdge(b, a) are - * considered equal. - * + * considered equal (though the ordering for .a and .b is preserved). + * * @author Michael Carleton * */ -public class PEdge { +public class PEdge implements Comparable { public final PVector a, b; + private final Coordinate aCoord, bCoord; + + /** + * A null PEdge. + */ + public PEdge() { + this.a = null; + this.b = null; + aCoord = null; + bCoord = null; + } - public PEdge(PVector a, PVector b) { + public PEdge(final PVector a, final PVector b) { this.a = a; this.b = b; + aCoord = coordFromPVector(a); + bCoord = coordFromPVector(b); } - public PEdge(double x1, double y1, double x2, double y2) { + public PEdge(final double x1, final double y1, final double x2, final double y2) { this(new PVector((float) x1, (float) y1), new PVector((float) x2, (float) y2)); } /** * Rounds (mutates) the vertex coordinates of this PEdge to their closest ints. - * + * * @return this PEdge */ public PEdge round() { @@ -41,18 +61,50 @@ public PVector midpoint() { return PVector.add(a, b).div(2); } + /** + * Returns the point on the segment [a, b] at parameter t, where t=0 gives a, + * t=1 gives b, and values in between give the corresponding point on the line. + * If t is outside [0,1] it will be clamped. + */ + public PVector pointAt(double t) { + if (t < 0) { + t = 0; + } else if (t > 1) { + t = 1; + } + final float ft = (float) t; + return new PVector(a.x + (b.x - a.x) * ft, a.y + (b.y - a.y) * ft); + } + /** * Calculates the Euclidean distance of this PEdge. - * - * @return */ public float length() { return a.dist(b); } + /** + * Computes the minimum distance between this and another edge. + */ + public double distance(final PEdge other) { + return Distance.segmentToSegment(aCoord, bCoord, other.aCoord, other.bCoord); + } + + /** + * Computes the distance from a point p to this edge. + */ + public double distance(final PVector point) { + return Distance.pointToSegment(coordFromPVector(point), aCoord, bCoord); + } + + public PVector closestPoint(final PVector point) { + final LineSegment l = new LineSegment(aCoord, bCoord); + return coordToPVector(l.closestPoint(coordFromPVector(point))); + } + /** * Calculates the subsection of this PEdge as a new PEdge. - * + * * @param from the start of the subsection as a normalized value along the * length of this PEdge. 'from' should be less than or equal to * 'to'. @@ -70,8 +122,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; @@ -91,6 +142,39 @@ public PEdge slice(double from, double to) { return new PEdge(pointFrom, pointTo); } + public List sample(double d) { + if (d <= 0) { + throw new IllegalArgumentException("d must be > 0"); + } + + final double len = length(); // length() returns float, assign to double + final List samples = new ArrayList<>(); + + // degenerate segment + if (len == 0.0) { + samples.add(new PVector(a.x, a.y)); + return samples; + } + + // always include the first point + samples.add(new PVector(a.x, a.y)); + + final double step = d / len; // step in parametric [0..1] space + // add intermediate samples at t = step, 2*step, ... while t < 1.0 + for (double t = step; t < 1.0; t += step) { + samples.add(pointAt(t)); + } + + // always include the last point (avoid duplicate if last intermediate ~= b) + final PVector last = samples.get(samples.size() - 1); + final double eps = 1e-6; + if (last.dist(b) > eps) { + samples.add(new PVector(b.x, b.y)); + } + + return samples; + } + @Override /** * Direction-agnostic hash. @@ -100,9 +184,8 @@ public int hashCode() { } @Override - public boolean equals(Object obj) { - if (obj instanceof PEdge) { - PEdge other = (PEdge) obj; + public boolean equals(final Object obj) { + if (obj instanceof final PEdge other) { return (equals(a, other.a) && equals(b, other.b)) || (equals(b, other.a) && equals(a, other.b)); } return false; @@ -120,7 +203,35 @@ public String toString() { return a.toString() + " <-> " + b.toString(); } - private static boolean equals(PVector a, PVector b) { + private static boolean equals(final PVector a, final PVector b) { return a.x == b.x && a.y == b.y; } + + @Override + public int compareTo(final PEdge other) { + final PVector thisMidpoint = midpoint(); + final PVector otherMidpoint = other.midpoint(); + return comparePVectors(thisMidpoint, otherMidpoint); + } + + /** + * Helper method to compare two PVectors lexicographically. + */ + private int comparePVectors(final PVector v1, final 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); + } + + private static final Coordinate coordFromPVector(final PVector p) { + return new Coordinate(p.x, p.y); + } + + private static final PVector coordToPVector(final Coordinate c) { + return new PVector((float) c.x, (float) c.y); + } } \ 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/PolygonDecomposition.java b/src/main/java/micycle/pgs/commons/PolygonDecomposition.java index 5d8a81cf..8be9b243 100644 --- a/src/main/java/micycle/pgs/commons/PolygonDecomposition.java +++ b/src/main/java/micycle/pgs/commons/PolygonDecomposition.java @@ -1,39 +1,13 @@ -/* - * Copyright (c) 2010-2020 William Bittle http://www.dyn4j.org/ - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are permitted - * provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, this list of conditions - * and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions - * and the following disclaimer in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or - * promote products derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND - * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER - * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT - * OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ package micycle.pgs.commons; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import org.dyn4j.Epsilon; -import org.dyn4j.geometry.Geometry; -import org.dyn4j.geometry.Segment; -import org.dyn4j.geometry.Vector2; +import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineSegment; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.PrecisionModel; @@ -46,8 +20,8 @@ * achieve optimal decompositions, however this is not guaranteed. * * @author William Bittle - * @version 3.1.10 - * @see Bayazit + * @author Refactored for JTS by Michael Carleton + * @see Mark Bayazits Algorithm */ public class PolygonDecomposition { @@ -57,67 +31,39 @@ private PolygonDecomposition() { } public static List decompose(Polygon polygon) { - Vector2[] points = new Vector2[polygon.getCoordinates().length]; - for (int i = 0; i < points.length; i++) { - points[i] = new Vector2(polygon.getCoordinates()[i].x, polygon.getCoordinates()[i].y); + // Use only the exterior ring; algorithm expects a simple polygon boundary. + Coordinate[] closed = polygon.getExteriorRing().getCoordinates(); // closed ring + boolean ccw = Orientation.isCCW(closed); + + Coordinate[] open = openRing(closed); // remove duplicate closing coord + if (!ccw) { + reverse(open); } - return decompose(points); + return decompose(open); } - /** - * Dyn4j entrypoint - */ - private static List decompose(Vector2... points) { - // check for null array + private static List decompose(Coordinate... points) { if (points == null) { throw new NullPointerException("Points are null."); } - // get the number of points int size = points.length; - // check the size - if (size < 4) { - throw new IllegalArgumentException("Points have invalid size (<4)."); + if (size < 3) { + throw new IllegalArgumentException("Points have invalid size (<3 open vertices)."); } - // get the winding order - double winding = Geometry.getWinding(points); - - // reverse the array if the points are in clockwise order - if (winding < 0.0) { - Geometry.reverseWinding(points); - } - - // create a list for the points to go in - List polygon = new ArrayList<>(); - - // copy the points to the list + List polygon = new ArrayList<>(); Collections.addAll(polygon, points); - // create a list for the polygons to live List polygons = new ArrayList<>(); - - // decompose the polygon decomposePolygon(polygon, polygons); - - // return the result return polygons; } - /** - * Internal recursive method to decompose the given polygon into convex - * sub-polygons. - * - * @param polygon the polygon to decompose - * @param polygons the list to store the convex polygons resulting from the - * decomposition - */ - private static void decomposePolygon(List polygon, List polygons) { - // get the size of the given polygon - int size = polygon.size(); - - // initialize - Vector2 upperIntersection = new Vector2(); - Vector2 lowerIntersection = new Vector2(); + private static void decomposePolygon(final List polygon, final List polygons) { + final int size = polygon.size(); + + Coordinate upperIntersection = null; + Coordinate lowerIntersection = null; double upperDistance = Double.MAX_VALUE; double lowerDistance = Double.MAX_VALUE; double closestDistance = Double.MAX_VALUE; @@ -125,84 +71,56 @@ private static void decomposePolygon(List polygon, List polygo int lowerIndex = 0; int closestIndex = 0; - List lower = new ArrayList<>(); - List upper = new ArrayList<>(); + final List lower = new ArrayList<>(); + final List upper = new ArrayList<>(); - // loop over all the vertices for (int i = 0; i < size; i++) { - // get the current vertex - Vector2 p = polygon.get(i); - - // get the adjacent vertices - Vector2 p0 = polygon.get(i - 1 < 0 ? size - 1 : i - 1); - Vector2 p1 = polygon.get(i + 1 == size ? 0 : i + 1); + final Coordinate p = polygon.get(i); + final Coordinate p0 = polygon.get(i - 1 < 0 ? size - 1 : i - 1); + final Coordinate p1 = polygon.get(i + 1 == size ? 0 : i + 1); - // check if the vertex is a reflex vertex if (isReflex(p0, p, p1)) { - - // loop over the vertices to determine if both extended - // adjacent edges intersect one edge (in which case a - // steiner point will be added) for (int j = 0; j < size; j++) { - Vector2 q = polygon.get(j); - - // get the adjacent vertices - Vector2 q0 = polygon.get(j - 1 < 0 ? size - 1 : j - 1); - Vector2 q1 = polygon.get(j + 1 == size ? 0 : j + 1); + final Coordinate q = polygon.get(j); + final Coordinate q0 = polygon.get(j - 1 < 0 ? size - 1 : j - 1); + final Coordinate q1 = polygon.get(j + 1 == size ? 0 : j + 1); - // create a storage location for the intersection point - Vector2 s = new Vector2(); - - // extend the previous edge - // does the line p0->p go between the vertices q and q0 + // extend the previous edge: infinite lines p0-p with q-q0 if (left(p0, p, q) && rightOn(p0, p, q0)) { - // get the intersection point - if (getIntersection(p0, p, q, q0, s)) { - // make sure the intersection point is to the right of - // the edge p1->p (this makes sure its inside the polygon) - if (right(p1, p, s)) { - // get the distance from p to the intersection point s - double dist = p.distanceSquared(s); - // only save the smallest - if (dist < lowerDistance) { - lowerDistance = dist; - lowerIntersection.set(s); - lowerIndex = j; - } + final Coordinate s = lineLineIntersection(p0, p, q, q0); + if (s != null && right(p1, p, s)) { + final double dist = p.distanceSq(s); + if (dist < lowerDistance) { + lowerDistance = dist; + lowerIntersection = s; + lowerIndex = j; } } } - // extend the next edge - // does the line p1->p go between q and q1 + // extend the next edge: infinite lines p1-p with q-q1 if (left(p1, p, q1) && rightOn(p1, p, q)) { - // get the intersection point - if (getIntersection(p1, p, q, q1, s)) { - // make sure the intersection point is to the left of - // the edge p0->p (this makes sure its inside the polygon) - if (left(p0, p, s)) { - // get the distance from p to the intersection point s - double dist = p.distanceSquared(s); - // only save the smallest - if (dist < upperDistance) { - upperDistance = dist; - upperIntersection.set(s); - upperIndex = j; - } + final Coordinate s = lineLineIntersection(p1, p, q, q1); + if (s != null && left(p0, p, s)) { + final double dist = p.distanceSq(s); + if (dist < upperDistance) { + upperDistance = dist; + upperIntersection = s; + upperIndex = j; } } } } - // if the lower index and upper index are equal then this means - // that the range of p only included an edge (both extended previous - // and next edges of p only intersected the same edge, therefore no - // point exists within that range to connect to) if (lowerIndex == (upperIndex + 1) % size) { - // create a steiner point in the middle - Vector2 s = upperIntersection.sum(lowerIntersection).multiply(0.5); + // create a Steiner point + final Coordinate s = midpoint(upperIntersection, lowerIntersection); + // guard in case intersections weren’t found due to degeneracy + if (s == null) { + // fall back to skipping this reflex (should be rare) + return; + } - // partition the polygon if (i < upperIndex) { lower.addAll(polygon.subList(i, upperIndex + 1)); lower.add(s); @@ -221,25 +139,18 @@ private static void decomposePolygon(List polygon, List polygo upper.addAll(polygon.subList(lowerIndex, i + 1)); } } else { - // otherwise we need to find the closest "visible" point to p - if (lowerIndex > upperIndex) { upperIndex += size; } closestIndex = lowerIndex; - // find the closest visible point for (int j = lowerIndex; j <= upperIndex; j++) { - int jmod = j % size; - Vector2 q = polygon.get(jmod); - - if (q == p || q == p0 || q == p1) { + final int jmod = j % size; + final Coordinate q = polygon.get(jmod); + if (coordsEqual(q, p) || coordsEqual(q, p0) || coordsEqual(q, p1)) { continue; } - - // check the distance first, since this is generally - // a much faster operation than checking if its visible - double dist = p.distanceSquared(q); + final double dist = p.distanceSq(q); if (dist < closestDistance) { if (isVisible(polygon, i, jmod)) { closestDistance = dist; @@ -248,7 +159,6 @@ private static void decomposePolygon(List polygon, List polygo } } - // once we find the closest partition the polygon if (i < closestIndex) { lower.addAll(polygon.subList(i, closestIndex + 1)); if (closestIndex != 0) { @@ -264,7 +174,6 @@ private static void decomposePolygon(List polygon, List polygo } } - // decompose the smaller first if (lower.size() < upper.size()) { decomposePolygon(lower, polygons); decomposePolygon(upper, polygons); @@ -272,159 +181,61 @@ private static void decomposePolygon(List polygon, List polygo decomposePolygon(upper, polygons); decomposePolygon(lower, polygons); } - - // if the given polygon contains a reflex vertex, then return return; } } - // if we get here, we know the given polygon has 0 reflex vertices - // and is therefore convex, add it to the list of convex polygons + // No reflex vertices => polygon is convex if (polygon.size() < 3) { return; } - Vector2[] vertices = new Vector2[polygon.size()]; - polygon.toArray(vertices); - Coordinate[] jtsCoords = new Coordinate[vertices.length + 1]; + final Coordinate[] vertices = polygon.toArray(new Coordinate[0]); + final Coordinate[] jtsCoords = new Coordinate[vertices.length + 1]; for (int j = 0; j < vertices.length; j++) { - Coordinate coordinate = new Coordinate(vertices[j].x, vertices[j].y); - jtsCoords[j] = coordinate; + jtsCoords[j] = new Coordinate(vertices[j].x, vertices[j].y); } jtsCoords[vertices.length] = jtsCoords[0]; polygons.add(GEOM_FACTORY.createPolygon(jtsCoords)); } - /** - * Returns true if the given vertex, b, is a reflex vertex. - *

- * A reflex vertex is a vertex who's interior angle is greater than 180 degrees. - * - * @param p0 the vertex to test - * @param p the previous vertex - * @param p1 the next vertex - * @return boolean - */ - private static boolean isReflex(Vector2 p0, Vector2 p, Vector2 p1) { - // if the point p is to the right of the line p0-p1 then - // the point is a reflex vertex + private static boolean isReflex(final Coordinate p0, final Coordinate p, final Coordinate p1) { return right(p1, p0, p); } - /** - * Returns true if the given point p is to the left of the line created by a-b. - * - * @param a the first point of the line - * @param b the second point of the line - * @param p the point to test - * @return boolean - */ - private static boolean left(Vector2 a, Vector2 b, Vector2 p) { - return Segment.getLocation(p, a, b) > 0; + private static boolean left(final Coordinate a, final Coordinate b, final Coordinate p) { + return Orientation.index(a, b, p) > 0; } - /** - * Returns true if the given point p is to the left or on the line created by - * a-b. - * - * @param a the first point of the line - * @param b the second point of the line - * @param p the point to test - * @return boolean - */ - private static boolean leftOn(Vector2 a, Vector2 b, Vector2 p) { - return Segment.getLocation(p, a, b) >= 0; + private static boolean leftOn(final Coordinate a, final Coordinate b, final Coordinate p) { + return Orientation.index(a, b, p) >= 0; } - /** - * Returns true if the given point p is to the right of the line created by a-b. - * - * @param a the first point of the line - * @param b the second point of the line - * @param p the point to test - * @return boolean - */ - private static boolean right(Vector2 a, Vector2 b, Vector2 p) { - return Segment.getLocation(p, a, b) < 0; + private static boolean right(final Coordinate a, final Coordinate b, final Coordinate p) { + return Orientation.index(a, b, p) < 0; } - /** - * Returns true if the given point p is to the right or on the line created by - * a-b. - * - * @param a the first point of the line - * @param b the second point of the line - * @param p the point to test - * @return boolean - */ - private static boolean rightOn(Vector2 a, Vector2 b, Vector2 p) { - return Segment.getLocation(p, a, b) <= 0; + private static boolean rightOn(final Coordinate a, final Coordinate b, final Coordinate p) { + return Orientation.index(a, b, p) <= 0; } - /** - * Returns true if the given lines intersect and returns the intersection point - * in the p parameter. - * - * @param a1 the first point of the first line - * @param a2 the second point of the first line - * @param b1 the first point of the second line - * @param b2 the second point of the second line - * @param p the destination object for the intersection point - * @return boolean - */ - private static boolean getIntersection(Vector2 a1, Vector2 a2, Vector2 b1, Vector2 b2, Vector2 p) { - - // compute S1 and S2 - Vector2 s1 = a1.difference(a2); - Vector2 s2 = b1.difference(b2); - - // compute the cross product (the determinant if we used matrix solving - // techniques) - double det = s1.cross(s2); - - // make sure the matrix isn't singular (the lines could be parallel) - if (Math.abs(det) <= Epsilon.E) { - // return false since there is no way that the segments could be intersecting - return false; - } else { - // pre-divide the determinant - det = 1.0 / det; - - // compute t2 - double t2 = det * (a1.cross(s1) - b1.cross(s1)); + private static Coordinate lineLineIntersection(final Coordinate a1, final Coordinate a2, final Coordinate b1, final Coordinate b2) { + final LineSegment la = new LineSegment(a1, a2); + final LineSegment lb = new LineSegment(b1, b2); + // lineIntersection returns the intersection of the supporting lines (not + // segments), or null if parallel/collinear + return la.lineIntersection(lb); + } - // compute the intersection point - // P = B1(1.0 - t2) + B2(t2) - p.x = b1.x * (1.0 - t2) + b2.x * t2; - p.y = b1.y * (1.0 - t2) + b2.y * t2; + private static boolean isVisible(final List polygon, final int i, final int j) { + final int s = polygon.size(); + final Coordinate iv0 = polygon.get(i == 0 ? s - 1 : i - 1); + final Coordinate iv = polygon.get(i); + final Coordinate iv1 = polygon.get(i + 1 == s ? 0 : i + 1); - // return that they intersect - return true; - } - } + final Coordinate jv0 = polygon.get(j == 0 ? s - 1 : j - 1); + final Coordinate jv = polygon.get(j); + final Coordinate jv1 = polygon.get(j + 1 == s ? 0 : j + 1); - /** - * Returns true if the vertex at index i can see the vertex at index j. - * - * @param polygon the current polygon - * @param i the ith vertex - * @param j the jth vertex - * @return boolean - * @since 3.1.10 - */ - private static boolean isVisible(List polygon, int i, int j) { - int s = polygon.size(); - Vector2 iv0, iv, iv1; - Vector2 jv0, jv, jv1; - - iv0 = polygon.get(i == 0 ? s - 1 : i - 1); - iv = polygon.get(i); - iv1 = polygon.get(i + 1 == s ? 0 : i + 1); - - jv0 = polygon.get(j == 0 ? s - 1 : j - 1); - jv = polygon.get(j); - jv1 = polygon.get(j + 1 == s ? 0 : j + 1); - - // can i see j if (isReflex(iv0, iv, iv1)) { if (leftOn(iv, iv0, jv) && rightOn(iv, iv1, jv)) { return false; @@ -434,7 +245,6 @@ private static boolean isVisible(List polygon, int i, int j) { return false; } } - // can j see i if (isReflex(jv0, jv, jv1)) { if (leftOn(jv, jv0, iv) && rightOn(jv, jv1, iv)) { return false; @@ -444,16 +254,18 @@ private static boolean isVisible(List polygon, int i, int j) { return false; } } - // make sure the segment from i to j doesn't intersect any edges + + final LineSegment segA = new LineSegment(iv, jv); for (int k = 0; k < s; k++) { - int ki1 = k + 1 == s ? 0 : k + 1; + final int ki1 = k + 1 == s ? 0 : k + 1; if (k == i || k == j || ki1 == i || ki1 == j) { continue; } - Vector2 k1 = polygon.get(k); - Vector2 k2 = polygon.get(ki1); + final Coordinate k1 = polygon.get(k); + final Coordinate k2 = polygon.get(ki1); - Vector2 in = Segment.getSegmentIntersection(iv, jv, k1, k2); + final LineSegment segB = new LineSegment(k1, k2); + final Coordinate in = segA.intersection(segB); if (in != null) { return false; } @@ -461,4 +273,32 @@ private static boolean isVisible(List polygon, int i, int j) { return true; } + + private static void reverse(final Coordinate[] pts) { + for (int i = 0, j = pts.length - 1; i < j; i++, j--) { + final Coordinate tmp = pts[i]; + pts[i] = pts[j]; + pts[j] = tmp; + } + } + + private static Coordinate[] openRing(final Coordinate[] coords) { + if (coords.length >= 2 && coords[0].equals2D(coords[coords.length - 1])) { + final Coordinate[] open = new Coordinate[coords.length - 1]; + System.arraycopy(coords, 0, open, 0, coords.length - 1); + return open; + } + return coords; + } + + private static Coordinate midpoint(final Coordinate a, final Coordinate b) { + if (a == null || b == null) { + return null; + } + return new Coordinate((a.x + b.x) * 0.5, (a.y + b.y) * 0.5); + } + + private static boolean coordsEqual(final Coordinate a, final Coordinate b) { + return a.equals2D(b); + } } diff --git a/src/main/java/micycle/pgs/commons/RLFColoring.java b/src/main/java/micycle/pgs/commons/RLFColoring.java index b2f65af3..e62aa008 100644 --- a/src/main/java/micycle/pgs/commons/RLFColoring.java +++ b/src/main/java/micycle/pgs/commons/RLFColoring.java @@ -1,20 +1,18 @@ package micycle.pgs.commons; import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashSet; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Random; import java.util.Set; -import java.util.concurrent.ThreadLocalRandom; - import org.jgrapht.Graph; import org.jgrapht.alg.interfaces.VertexColoringAlgorithm; import org.jgrapht.alg.util.NeighborCache; -import org.jgrapht.util.CollectionUtil; /** * The Recursive Largest First (RLF) algorithm for graph coloring. @@ -49,85 +47,91 @@ */ public class RLFColoring implements VertexColoringAlgorithm { - /* - * TODO see 'Efficiency issues in the RLF heuristic for graph coloring' for data - * structure and better time complexity. - */ - private final Graph graph; - /** let U denote the set of uncolored vertices */ - private final Set U; + private final List vertexList; + private final Map vertexIndex; + private final int n; + + private final BitSet U; // uncolored vertices + private final BitSet W; // uncolored vertices with neighbor in C + private final Map C; // colored vertices + /** - * let W be the set (initially empty) of uncolored vertices with at least one - * neighbor in C + * An array storing the number of uncolored neighbors for each vertex (AU(x) in + * the RLF algorithm). */ - private final Set W; - /** the colored vertices, and their corresponding color */ - final Map C; - private final NeighborCache neighborCache; - + private final int[] AU; /** - * These maps store the numbers AU(x) and AW(x). Each time a vertex is removed - * from U (and its uncolored neighbours added to W) they are updated (provided - * an efficient computing the value each call). + * An array storing the number of neighbors in the set W for each vertex (AW(x) + * in the RLF algorithm). */ - final Map AU; - final Map AW; + private final int[] AW; - private int activeColor; + // Pre-computed adjacency lists as indices + private final int[][] adjacency; + private final NeighborCache neighborCache; - public RLFColoring(Graph graph) { - this(graph, ThreadLocalRandom.current().nextLong()); - } + private int activeColor; public RLFColoring(Graph graph, long seed) { - this.graph = Objects.requireNonNull(graph, "Graph cannot be null"); - - List vertices = new ArrayList<>(graph.vertexSet()); - Collections.shuffle(vertices, new Random(seed)); - U = new LinkedHashSet<>(vertices); + this.graph = Objects.requireNonNull(graph); + this.n = graph.vertexSet().size(); + this.neighborCache = new NeighborCache<>(graph); + + // Create vertex indexing + vertexList = new ArrayList<>(graph.vertexSet()); + Collections.shuffle(vertexList, new Random(seed)); + vertexIndex = new HashMap<>(n); + for (int i = 0; i < n; i++) { + vertexIndex.put(vertexList.get(i), i); + } - W = new HashSet<>(graph.vertexSet().size()); - C = CollectionUtil.newHashMapWithExpectedSize(graph.vertexSet().size()); - neighborCache = new NeighborCache<>(graph); - AU = CollectionUtil.newHashMapWithExpectedSize(graph.vertexSet().size()); - AW = CollectionUtil.newHashMapWithExpectedSize(graph.vertexSet().size()); + U = new BitSet(n); + U.set(0, n); // all initially uncolored + W = new BitSet(n); + + AU = new int[n]; + AW = new int[n]; + C = new HashMap<>(n); + + // Pre-compute adjacency as indices + adjacency = new int[n][]; + for (int i = 0; i < n; i++) { + V v = vertexList.get(i); + Set neighbors = neighborCache.neighborsOf(v); + adjacency[i] = new int[neighbors.size()]; + int j = 0; + for (V neighbor : neighbors) { + adjacency[i][j++] = vertexIndex.get(neighbor); + } + AU[i] = adjacency[i].length; + } } @Override public Coloring getColoring() { - final int n = graph.vertexSet().size(); - - V nextClassInitialVertex = null; - int maxDegree = 0; - activeColor = -1; // current color class (RLF builds color classes sequentially) - - for (V v : U) { - AU.put(v, neighborCache.neighborsOf(v).size()); - AW.put(v, 0); - } + activeColor = -1; - while (C.size() < n) { // while G contains uncolored vertices - /* - * Recompute U (set of uncolored vertices). - */ - U.addAll(graph.vertexSet()); - U.removeAll(C.keySet()); + while (C.size() < n) { + // Recompute U + U.set(0, n); + for (V v : C.keySet()) { + U.clear(vertexIndex.get(v)); + } activeColor++; - // Choose a vertex v ∈ U with largest value AU(v). - // Select the uncolored vertex which has the largest degree for coloring. - maxDegree = Integer.MIN_VALUE; - for (V v : U) { - int d = AU.get(v); - if (d > maxDegree) { - maxDegree = d; // may be negative (which is fine) - nextClassInitialVertex = v; + // Find vertex with largest AU value + int maxAU = Integer.MIN_VALUE; + V nextVertex = null; + for (int i = U.nextSetBit(0); i >= 0; i = U.nextSetBit(i + 1)) { + if (AU[i] > maxAU) { + maxAU = AU[i]; + nextVertex = vertexList.get(i); } } - createColorClass(nextClassInitialVertex); + createColorClass(nextVertex); } return new ColoringImpl<>(C, activeColor + 1); @@ -139,88 +143,81 @@ public Coloring getColoring() { * * @param vertex inital vertex of color class */ - void createColorClass(V vertex) { - // Initialize W as the set of vertices in U adjacent to v + private void createColorClass(V initialVertex) { W.clear(); + Arrays.fill(AW, 0); // Reset AW for this color class - color(vertex); + color(initialVertex); - while (!U.isEmpty()) { - V candidate = findNextCandidate(); // select a vertex u ∈ U with largest value AW(u) + while (U.cardinality() > 0) { + V candidate = findNextCandidate(); + if (candidate == null) + break; color(candidate); } } - /** - * Colors the given vertex with the current color class. - */ - private void color(V vertex) { - // Move all neighbors w ∈ U of u to W - final Set uncoloredNeighbours = findUncoloredNeighbours(vertex); - W.addAll(uncoloredNeighbours); - U.removeAll(uncoloredNeighbours); - - // Move u from U to C - U.remove(vertex); - C.put(vertex, activeColor); - - uncoloredNeighbours.forEach(n -> { - final Set neighbours = neighborCache.neighborsOf(n); // NOTE use graph adjacency (not uncolored neighbours) - /* - * Each time a vertex w is moved from U to W, AW(x) is incremented by one unit - * and AU(x) is decreased by one unit for all neighbors x ∈ U of w. - */ - neighbours.forEach(n2 -> { - AW.merge(n2, 1, Integer::sum); - AU.merge(n2, -1, Integer::sum); - }); - /* - * When a vertex u ∈ U is moved from U to Cv, AU(x) is decreased by one unit for - * all neighbors x ∈ U of u. - */ - AU.merge(n, -1, Integer::sum); - }); - } - /** * Find next vertex that should belong to the current color class. The next * vertex to be moved from U to C is one having the largest number of neighbors * in W (the set of uncolored vertices with at least one neighbor in C). - * - * @return */ private V findNextCandidate() { V candidate = null; + int maxAW = -1; - int maxDegree = -1; - // TODO optimise this O(n) loop - // look at https://imada.sdu.dk/~marco/Publications/Files/MIC2011-ChiGalGua.pdf - for (V v : U) { - int d = AW.get(v); - if (d > maxDegree) { - maxDegree = d; - candidate = v; + // Find vertex in U with maximum AW value + for (int i = U.nextSetBit(0); i >= 0; i = U.nextSetBit(i + 1)) { + if (AW[i] > maxAW) { + maxAW = AW[i]; + candidate = vertexList.get(i); } } - /* - * NOTE Leighton specifies tie-breaking at this stage: "Ties are, if possible, - * broken by choosing a vertex with the smallest number of neighbors in U", but - * I've found that such tie-breaking consistently leads to worse colorings - * (higher chromatic number), so tie-breaking has not been included. - */ return candidate; } /** - * Finds the neighbours of u in U (the set of uncolored vertices). - * - * @return set of uncolored neighbouring vertices of u + * Colors the given vertex with the current color class. */ - private Set findUncoloredNeighbours(V u) { - // neighborsOf(v) does not include v (which is desirable) - final Set aU = new HashSet<>(neighborCache.neighborsOf(u)); - aU.retainAll(U); - return aU; + private void color(V vertex) { + int vIdx = vertexIndex.get(vertex); + + // Get uncolored neighbors efficiently + BitSet uncoloredNeighbors = new BitSet(n); + for (int nIdx : adjacency[vIdx]) { + if (U.get(nIdx)) { + uncoloredNeighbors.set(nIdx); + } + } + + // Move uncolored neighbors to W + for (int nIdx = uncoloredNeighbors.nextSetBit(0); nIdx >= 0; nIdx = uncoloredNeighbors.nextSetBit(nIdx + 1)) { + + W.set(nIdx); + U.clear(nIdx); + + // Update AW and AU for neighbors of this moved vertex + for (int nnIdx : adjacency[nIdx]) { + if (U.get(nnIdx)) { + AW[nnIdx]++; + AU[nnIdx]--; + } + } + + // Update AU for the moved vertex itself + AU[nIdx]--; + } + + // Move vertex from U to C + U.clear(vIdx); + C.put(vertex, activeColor); + + // Update AU for remaining neighbors + for (int nIdx : adjacency[vIdx]) { + if (U.get(nIdx)) { + AU[nIdx]--; + } + } } -} +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/RectangularSubdivision.java b/src/main/java/micycle/pgs/commons/RectangularSubdivision.java index 27edc6d0..736577c3 100644 --- a/src/main/java/micycle/pgs/commons/RectangularSubdivision.java +++ b/src/main/java/micycle/pgs/commons/RectangularSubdivision.java @@ -1,7 +1,5 @@ package micycle.pgs.commons; -import java.util.SplittableRandom; - import micycle.pgs.color.Colors; import processing.core.PConstants; import processing.core.PShape; @@ -27,30 +25,38 @@ public class RectangularSubdivision { private final double cornerDist; private final double width, height; - private final SplittableRandom r; + // Base seed for deterministic per-rectangle randomness + private final long baseSeed; private PShape division; + // Salts to separate different purposes + private static final long SALT_SPLIT_H = 0xA0761D6478BD642FL; + private static final long SALT_SPLIT_V = 0xE7037ED1A0B428DBL; + private static final long SALT_ORIENT_GATE = 0x8EBC6AF09C88C6E3L; + private static final long SALT_ORIENT_BOOL = 0x589965CC75374CC3L; + private static final long SALT_DIVIDE = 0x1D8E4E27C47D124FL; + public RectangularSubdivision(double width, double height, long seed) { this.width = width; this.height = height; + this.baseSeed = seed; divPt = new double[] { width / 2, height / 2 }; - cornerDist = Math.sqrt(Math.pow(divPt[0], 2) + Math.pow(divPt[1], 2)); - r = new SplittableRandom(seed); + cornerDist = Math.sqrt(divPt[0] * divPt[0] + divPt[1] * divPt[1]); } public RectangularSubdivision(double width, double height, int maxDepth, long seed) { this.width = width; this.height = height; this.maxDepth = maxDepth; + this.baseSeed = seed; divPt = new double[] { width / 2, height / 2 }; - cornerDist = Math.sqrt(Math.pow(divPt[0], 2) + Math.pow(divPt[1], 2)); - r = new SplittableRandom(seed); + cornerDist = Math.sqrt(divPt[0] * divPt[0] + divPt[1] * divPt[1]); } /** * Produces a new rectangular subdivision using the configured parameters. - * + * * @return a GROUP PShape where each child shape is one rectangle */ public PShape divide() { @@ -59,76 +65,135 @@ public PShape divide() { return division; } - private void sliceDivide(double x1, double y1, double x2, double y2, double x3, double y3, double x4, double y4, int depth, int base, - boolean horizontal) { + private void sliceDivide(double x1, double y1, double x2, double y2, double x3, double y3, double x4, double y4, int depth, int base, boolean horizontal) { + if (depth == base) { rect(x1, y1, x2, y2, x3, y3, x4, y4); + return; + } + + // Current rectangle bounds (order-independent) + double minX = Math.min(Math.min(x1, x2), Math.min(x3, x4)); + double maxX = Math.max(Math.max(x1, x2), Math.max(x3, x4)); + double minY = Math.min(Math.min(y1, y2), Math.min(y3, y4)); + double maxY = Math.max(Math.max(y1, y2), Math.max(y3, y4)); + + // Deterministic split positions for this rectangle + double uH = rectUniform(minX, minY, maxX, maxY, SALT_SPLIT_H); + double uV = rectUniform(minX, minY, maxX, maxY, SALT_SPLIT_V); + double randValHor = y1 + (y2 - y1) * uH * (1 - splitUniformity) + (y2 - y1) / 2.0 * splitUniformity; + double randValVer = x1 + (x4 - x1) * uV * (1 - splitUniformity) + (x4 - x1) / 2.0 * splitUniformity; + + // horizontal split points + double nx12 = (x1 + x2) / 2.0; + double ny12 = randValHor; + double nx34 = (x3 + x4) / 2.0; + double ny34 = randValHor; + // vertical split points + double nx14 = randValVer; + double ny14 = (y1 + y4) / 2.0; + double nx23 = randValVer; + double ny23 = (y2 + y3) / 2.0; + + // Ternary assignments (current split direction) + double a1 = x1; + double a2 = y1; + double a3 = (horizontal ? nx12 : x2); + double a4 = (horizontal ? ny12 : y2); + double a5 = (horizontal ? nx34 : nx23); + double a6 = (horizontal ? ny34 : ny23); + double a7 = (horizontal ? x4 : nx14); + double a8 = (horizontal ? y4 : ny14); + + double b1 = (horizontal ? x2 : nx14); + double b2 = (horizontal ? y2 : ny14); + double b3 = (horizontal ? nx12 : nx23); + double b4 = (horizontal ? ny12 : ny23); + double b5 = (horizontal ? nx34 : x3); + double b6 = (horizontal ? ny34 : y3); + double b7 = (horizontal ? x3 : x4); + double b8 = (horizontal ? y3 : y4); + + // Centers and distances from division point + double center_aX = (a1 + a3) / 2.0; + double center_aY = (a2 + a4) / 2.0; + double center_bX = (b1 + b3) / 2.0; + double center_bY = (b2 + b4) / 2.0; + double distA = Math.hypot(center_aX - divPt[0], center_aY - divPt[1]); + double distB = Math.hypot(center_bX - divPt[0], center_bY - divPt[1]); + + // Child bounds (order-independent), used for stable randomness per child + double[] boundsA = rectBounds(a1, a2, a3, a4, a5, a6, a7, a8); + double[] boundsB = rectBounds(b1, b2, b3, b4, b5, b6, b7, b8); + + // Decide each child's next split orientation deterministically + boolean r1horwider = (Math.abs(a5 - a1) <= Math.abs(a6 - a2)); // is wider horizontally + boolean r2horwider = (Math.abs(b5 - b1) <= Math.abs(b6 - b2)); + boolean r1hor = chooseOrientation(boundsA, r1horwider); + boolean r2hor = chooseOrientation(boundsB, r2horwider); + + // Recursive calls or make rectangles + double probA = towardsCenter * distA / cornerDist + (1 - towardsCenter) * (1 - divProb); + double probB = towardsCenter * distB / cornerDist + (1 - towardsCenter) * (1 - divProb); + + double toDivide1 = (depth < maxDepth - minDepth ? rectUniform(boundsA[0], boundsA[1], boundsA[2], boundsA[3], SALT_DIVIDE) : 1.0); + if (toDivide1 > probA) { + sliceDivide(a1, a2, a3, a4, a5, a6, a7, a8, depth - 1, base, r1hor); + } else { + rect(a1, a2, a3, a4, a5, a6, a7, a8); + } + + double toDivide2 = (depth < maxDepth - minDepth ? rectUniform(boundsB[0], boundsB[1], boundsB[2], boundsB[3], SALT_DIVIDE) : 1.0); + if (toDivide2 > probB) { + sliceDivide(b1, b2, b3, b4, b5, b6, b7, b8, depth - 1, base, r2hor); } else { - double randValHor = y1 + (y2 - y1) * r.nextDouble() * (1 - splitUniformity) + (y2 - y1) / 2 * splitUniformity; - double randValVer = x1 + (x4 - x1) * r.nextDouble() * (1 - splitUniformity) + (x4 - x1) / 2 * splitUniformity; - // horizontal split points - double nx12 = (x1 + x2) / 2; - double ny12 = randValHor; - double nx34 = (x3 + x4) / 2; - double ny34 = randValHor; - // vertical split points - double nx14 = randValVer; - double ny14 = (y1 + y4) / 2; - double nx23 = randValVer; - double ny23 = (y2 + y3) / 2; - // ternary assignments - double a1 = x1; - double a2 = y1; - double a3 = (horizontal ? nx12 : x2); - double a4 = (horizontal ? ny12 : y2); - double a5 = (horizontal ? nx34 : nx23); - double a6 = (horizontal ? ny34 : ny23); - double a7 = (horizontal ? x4 : nx14); - double a8 = (horizontal ? y4 : ny14); - double b1 = (horizontal ? x2 : nx14); - double b2 = (horizontal ? y2 : ny14); - double b3 = (horizontal ? nx12 : nx23); - double b4 = (horizontal ? ny12 : ny23); - double b5 = (horizontal ? nx34 : x3); - double b6 = (horizontal ? ny34 : y3); - double b7 = (horizontal ? x3 : x4); - double b8 = (horizontal ? y3 : y4); - - // center pt - double center_aX = (a1 + a3) / 2; - double center_aY = (a2 + a4) / 2; - double center_bX = (b1 + b3) / 2; - double center_bY = (b2 + b4) / 2; - double distA = Math.sqrt(square(center_aX - divPt[0]) + square(center_aY - divPt[1])); - double distB = Math.sqrt(square(center_bX - divPt[0]) + square(center_bY - divPt[1])); - - // Determine which way to split - boolean r1horwider = (Math.abs(a5 - a1) <= Math.abs(a6 - a2)); // boolean for "is wider horizontally" - boolean r2horwider = (Math.abs(b5 - b1) <= Math.abs(b6 - b2)); - boolean r1hor = (r.nextDouble() > 1 - balance ? r1horwider : r.nextDouble() > 0.5); // divide by wider or random - boolean r2hor = (r.nextDouble() > 1 - balance ? r2horwider : r.nextDouble() > 0.5); - - // Recursive calls or make and display rectangles - double probA = towardsCenter * distA / cornerDist + (1 - towardsCenter) * (1 - divProb); - double probB = towardsCenter * distB / cornerDist + (1 - towardsCenter) * (1 - divProb); - double toDivide1 = (depth < maxDepth - minDepth ? r.nextDouble() : 1.0); // 1 if less than min depth - if (toDivide1 > probA) { // 0.2 - sliceDivide(a1, a2, a3, a4, a5, a6, a7, a8, depth - 1, base, r1hor); - } else { - rect(a1, a2, a3, a4, a5, a6, a7, a8); - } - - double toDivide2 = (depth < maxDepth - minDepth ? r.nextDouble() : 1.0); - if (toDivide2 > probB) { - sliceDivide(b1, b2, b3, b4, b5, b6, b7, b8, depth - 1, base, r2hor); - } else { - rect(b1, b2, b3, b4, b5, b6, b7, b8); - } + rect(b1, b2, b3, b4, b5, b6, b7, b8); + } + } + + private boolean chooseOrientation(double[] bounds, boolean widerHorizontal) { + double minX = bounds[0], minY = bounds[1], maxX = bounds[2], maxY = bounds[3]; + double gate = rectUniform(minX, minY, maxX, maxY, SALT_ORIENT_GATE); + if (gate > 1 - balance) { + return widerHorizontal; } + double coin = rectUniform(minX, minY, maxX, maxY, SALT_ORIENT_BOOL); + return coin > 0.5; + } + + private static double[] rectBounds(double x1, double y1, double x2, double y2, double x3, double y3, double x4, double y4) { + double minX = Math.min(Math.min(x1, x2), Math.min(x3, x4)); + double maxX = Math.max(Math.max(x1, x2), Math.max(x3, x4)); + double minY = Math.min(Math.min(y1, y2), Math.min(y3, y4)); + double maxY = Math.max(Math.max(y1, y2), Math.max(y3, y4)); + return new double[] { minX, minY, maxX, maxY }; + } + + private double rectUniform(double minX, double minY, double maxX, double maxY, long salt) { + long h = hashRect(minX, minY, maxX, maxY, salt); + return toUnit(mix64(h)); + } + + private long hashRect(double minX, double minY, double maxX, double maxY, long salt) { + long h = mix64(baseSeed ^ salt); + h = mix64(h ^ Double.doubleToLongBits(minX)); + h = mix64(h ^ Double.doubleToLongBits(minY)); + h = mix64(h ^ Double.doubleToLongBits(maxX)); + h = mix64(h ^ Double.doubleToLongBits(maxY)); + return h; + } + + private static long mix64(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); + } + + private static double toUnit(long bits) { + return (bits >>> 11) * 0x1.0p-53; // [0,1) } private void rect(double x1, double y1, double x2, double y2, double x3, double y3, double x4, double y4) { - // rect(x1, y1, x3 - x1, y3 - y1); final PShape rect = new PShape(PShape.PATH); rect.setFill(true); rect.setFill(Colors.PINK); @@ -142,9 +207,4 @@ private void rect(double x1, double y1, double x2, double y2, double x3, double rect.endShape(PConstants.CLOSE); division.addChild(rect); } - - private static double square(double x) { - return x * x; - } - } diff --git a/src/main/java/micycle/pgs/commons/ShapeInterpolation.java b/src/main/java/micycle/pgs/commons/ShapeInterpolation.java index 9408cbcd..a57a8e7f 100644 --- a/src/main/java/micycle/pgs/commons/ShapeInterpolation.java +++ b/src/main/java/micycle/pgs/commons/ShapeInterpolation.java @@ -142,50 +142,13 @@ private static int findBestRotation(CoordinateList a, CoordinateList b) { private static double calculateSumOfSquares(CoordinateList a, CoordinateList b, int offset, int n) { double sumOfSquares = 0; for (int i = 0; i < n; i++) { - sumOfSquares += distSq(a.get((offset + i) % n), b.get(i)); + sumOfSquares += a.get((offset + i) % n).distanceSq(b.get(i)); } return sumOfSquares; } - @Deprecated - private static int findBestRotationSimple(CoordinateList a, CoordinateList b) { - final int n = a.size(); - final int inc = (int) Math.max(1, Math.ceil(n / 1000d)); - double min = Double.MAX_VALUE; - int bestOffset = 0; - - for (int offset = 0; offset < n; offset += inc) { - double sumOfSquares = 0; - - for (int i = 0; i < n; i++) { - sumOfSquares += distSq(a.get((offset + i) % n), b.get(i)); - if (sumOfSquares > min) { - break; - } - } - - if (sumOfSquares < min) { - min = sumOfSquares; - bestOffset = offset; - if (sumOfSquares == 0) { - return bestOffset; // return early when shapes are identical - } - } - } - - return bestOffset; - } - private static Coordinate lerp(Coordinate from, Coordinate to, double t) { return new Coordinate(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t); } - private static final double distSq(final Coordinate a, final Coordinate b) { - // don't use Coordinate.distance() because it uses Math.hypot() (slower!) - // don't sqrt -- not needed - final double dx = a.x - b.x; - final double dy = a.y - b.y; - return (dx * dx + dy * dy); - } - } diff --git a/src/main/java/micycle/pgs/commons/ShapeRandomPointSampler.java b/src/main/java/micycle/pgs/commons/ShapeRandomPointSampler.java new file mode 100644 index 00000000..b49f7f5a --- /dev/null +++ b/src/main/java/micycle/pgs/commons/ShapeRandomPointSampler.java @@ -0,0 +1,155 @@ +package micycle.pgs.commons; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.SplittableRandom; +import java.util.random.RandomGenerator; + +import org.tinfour.common.IConstraint; +import org.tinfour.common.IIncrementalTin; +import org.tinfour.common.SimpleTriangle; +import org.tinfour.common.Vertex; +import org.tinfour.utils.TriangleCollector; + +import micycle.pgs.PGS_Triangulation; +import processing.core.PShape; +import processing.core.PVector; + +/** + * @author Michael Carleton + */ +public final class ShapeRandomPointSampler { + + private final double[] ax, ay, bx, by, cx, cy; // triangle vertices + private final double[] prob; // alias table probs + private final int[] alias; // alias table indices + private final int n; // number of triangles + private RandomGenerator rng; + + public ShapeRandomPointSampler(final PShape shape, final long seed) { + reseed(seed); + + // Build constrained Delaunay TIN + final IIncrementalTin tin = PGS_Triangulation.delaunayTriangulationMesh(shape); + final boolean constrained = !tin.getConstraints().isEmpty(); + + // Collect valid triangles and their areas + final List tris = new ArrayList<>(); + final List areas = new ArrayList<>(); + final double eps = 1e-15; + + TriangleCollector.visitSimpleTriangles(tin, (SimpleTriangle tri) -> { + final IConstraint region = tri.getContainingRegion(); + final boolean inside = !constrained || (region != null && region.definesConstrainedRegion()); + if (!inside) + return; + + final Vertex A = tri.getVertexA(); + final Vertex B = tri.getVertexB(); + final Vertex C = tri.getVertexC(); + + // Robust, unsigned area = 0.5 * |(B-A) x (C-A)| + final double area2 = (B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y); + final double area = 0.5 * Math.abs(area2); + if (area > eps && Double.isFinite(area)) { + tris.add(new double[] { A.x, A.y, B.x, B.y, C.x, C.y }); + areas.add(area); + } + }); + + this.n = tris.size(); + if (n == 0) { + throw new IllegalStateException("No valid triangles found for shape; cannot sample."); + } + + this.ax = new double[n]; + this.ay = new double[n]; + this.bx = new double[n]; + this.by = new double[n]; + this.cx = new double[n]; + this.cy = new double[n]; + for (int i = 0; i < n; i++) { + double[] t = tris.get(i); + ax[i] = t[0]; + ay[i] = t[1]; + bx[i] = t[2]; + by[i] = t[3]; + cx[i] = t[4]; + cy[i] = t[5]; + } + + // Build alias table for area weights (Walker’s method) + this.prob = new double[n]; + this.alias = new int[n]; + + final double sumArea = areas.stream().mapToDouble(Double::doubleValue).sum(); + final double[] scaled = new double[n]; // weights scaled by n: w_i * n + final Deque small = new ArrayDeque<>(); + final Deque large = new ArrayDeque<>(); + + for (int i = 0; i < n; i++) { + double w = areas.get(i) / sumArea; + double p = w * n; + scaled[i] = p; + if (p < 1.0) + small.add(i); + else + large.add(i); + } + + while (!small.isEmpty() && !large.isEmpty()) { + int s = small.removeLast(); + int l = large.removeLast(); + prob[s] = scaled[s]; + alias[s] = l; + scaled[l] = (scaled[l] + scaled[s]) - 1.0; + if (scaled[l] < 1.0) + small.add(l); + else + large.add(l); + } + + // Any leftover get prob=1 + while (!large.isEmpty()) + prob[large.removeLast()] = 1.0; + while (!small.isEmpty()) + prob[small.removeLast()] = 1.0; + } + + public void reseed(long seed) { + this.rng = new SplittableRandom(seed); + } + + public PVector getRandomPoint() { + // Alias sampling: pick triangle + final int i = rng.nextInt(n); + final double u = rng.nextDouble(); + final int triIndex = (u < prob[i]) ? i : alias[i]; + + // Uniform inside triangle using barycentric sqrt-trick + final double r1 = rng.nextDouble(); + final double r2 = rng.nextDouble(); + final double sqrtR1 = Math.sqrt(r1); + final double wA = 1.0 - sqrtR1; + final double wB = sqrtR1 * (1.0 - r2); + final double wC = sqrtR1 * r2; + + final float x = (float) (wA * ax[triIndex] + wB * bx[triIndex] + wC * cx[triIndex]); + final float y = (float) (wA * ay[triIndex] + wB * by[triIndex] + wC * cy[triIndex]); + return new PVector(x, y); + } + + public List getRandomPoints(int count) { + List pts = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + pts.add(getRandomPoint()); + } + return pts; + } + + public int triangleCount() { + return n; + } +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/SpiralIterator.java b/src/main/java/micycle/pgs/commons/SpiralIterator.java new file mode 100644 index 00000000..30289e7c --- /dev/null +++ b/src/main/java/micycle/pgs/commons/SpiralIterator.java @@ -0,0 +1,363 @@ +package micycle.pgs.commons; + +import java.util.*; + +import net.jafama.FastMath; +import processing.core.PShape; +import processing.core.PVector; + +/** + * An iterator that walks the faces of a polygonal mesh in a concentric “spiral” + * order from a given face. + * + * @author Michael Carleton + */ + +public class SpiralIterator implements Iterator { + + /*- + * Build the next BFS‐ring in an “edge‐aware CW‐spiral” by: + * + * 1) Gathering all unvisited faces that touch any vertex of the current ring. + * 2) Sorting them globally by descending polar‐angle around the start‐face centroid + * (i.e. a single CW sort). + * 3) Splitting that sorted list into connected components under edge‐adjacency + * (so faces sharing an edge stay together). + * 4) Locating the component which contains a face sharing an edge with the + * last‐emitted face, and rotating the component‐list so it comes first. + * 5) Rotating that first component internally so its very first face + * actually shares an edge with the last‐emitted face. + * 6) Finally, flattening all components in order—each component still in CW order. + * + * This guarantees: + * - maximal edge‐continuity (no artificial “gaps” inside a component), + * - strictly CW progression (no backwards jumps), + * - and a single global angle sort per ring for efficiency. + */ + + // ─── immutable data ────────────────────────────────────────────────────────── + private final List faces; // idx → PShape + private final int F; // number of faces + private final int[][] faceVerts; // face → its vertex‐IDs + private final List[] vertToFaces; // vertex → touching faces + private final int[][] edgeNbrs; // face → edge‐adjacent face‐IDs + private final double[] angle; // face → global CW‐polar angle + + // ─── mutable iteration state ──────────────────────────────────────────────── + private final boolean[] visited; // face visited? + private List currentRing; // face‐IDs in current ring + private Iterator ringIter; + private int lastEmitted; // last face‐ID we handed out + + // ─────────────────────────────────────────────────────────────────────────────── + /** + * Compute the spiral ordering of {@code allFaces}, beginning at + * {@code startFace}. The returned list contains every face exactly once: first + * the start face, then all faces 1 hop away (in a continuous edge‐first walk, + * with angle‐based tie‐breaking), then all faces 2 hops away, etc. + * + * @param startFace the face from which to begin the spiral; must be an element + * of {@code allFaces} + * @param allFaces the complete list of mesh faces (triangles or polygons) + * @return a new List of faces in spiral order + */ + public static List spiral(PShape startFace, List allFaces) { + SpiralIterator it = new SpiralIterator(startFace, allFaces); + List out = new ArrayList<>(allFaces.size()); + while (it.hasNext()) { + out.add(it.next()); + } + return out; + } + + /** + * Construct a new spiral iterator over the mesh faces. Performs all + * preprocessing (indexing vertices, building adjacency, computing angles) so + * that subsequent {@link #hasNext()} and {@link #next()} calls run in amortized + * O(1) per face plus one sort/reorder per BFS ring. + * + * @param startFace one of the faces in {@code allFaces}; this will be returned + * first by the iterator + * @param allFaces the list of all mesh faces to visit + * @throws IllegalArgumentException if {@code startFace} is not in + * {@code allFaces} + */ + @SuppressWarnings("unchecked") + public SpiralIterator(PShape startFace, List allFaces) { + this.faces = allFaces; + this.F = allFaces.size(); + Map fidx = new HashMap<>(F); + for (int i = 0; i < F; i++) + fidx.put(allFaces.get(i), i); + Integer startIdx = fidx.get(startFace); + if (startIdx == null) + throw new IllegalArgumentException("startFace not in allFaces"); + + // 1) build per‐face vertex‐lists + global vertex‐ID map + Map vmap = new HashMap<>(); + int nextVid = 0; + faceVerts = new int[F][]; + for (int f = 0; f < F; f++) { + PShape shp = allFaces.get(f); + int vc = shp.getVertexCount(); + int[] vids = new int[vc]; + for (int j = 0; j < vc; j++) { + PVector v = shp.getVertex(j); + VertexKey k = new VertexKey(v); + Integer vid = vmap.get(k); + if (vid == null) { + vid = nextVid++; + vmap.put(k, vid); + } + vids[j] = vid; + } + faceVerts[f] = vids; + } + + // 2) build vertex→faces incidence + vertToFaces = new List[nextVid]; + for (int v = 0; v < nextVid; v++) { + vertToFaces[v] = new ArrayList<>(); + } + for (int f = 0; f < F; f++) { + for (int v : faceVerts[f]) { + vertToFaces[v].add(f); + } + } + + // 3) build face→edge‐neighbors via an undirected‐edge map + Map> e2f = new HashMap<>(); + for (int f = 0; f < F; f++) { + int[] vids = faceVerts[f]; + int vc = vids.length; + for (int i = 0; i < vc; i++) { + EdgeKey ek = new EdgeKey(vids[i], vids[(i + 1) % vc]); + e2f.computeIfAbsent(ek, __ -> new ArrayList<>()).add(f); + } + } + edgeNbrs = new int[F][]; + for (int f = 0; f < F; f++) { + Set nb = new HashSet<>(); + int[] vids = faceVerts[f]; + for (int i = 0; i < vids.length; i++) { + EdgeKey ek = new EdgeKey(vids[i], vids[(i + 1) % vids.length]); + for (int g : e2f.get(ek)) { + if (g != f) + nb.add(g); + } + } + edgeNbrs[f] = nb.stream().mapToInt(x -> x).toArray(); + } + + // 4) precompute global CW‐angle around startFace + angle = new double[F]; + PVector c0 = computeCentroid(startFace); + for (int f = 0; f < F; f++) { + PVector cf = computeCentroid(allFaces.get(f)); + angle[f] = FastMath.atan2(cf.y - c0.y, cf.x - c0.x); + } + + // 5) init BFS rings + visited = new boolean[F]; + visited[startIdx] = true; + lastEmitted = startIdx; + currentRing = List.of(startIdx); + ringIter = currentRing.iterator(); + } + + @Override + public boolean hasNext() { + if (ringIter.hasNext()) { + return true; + } + // build the next vertex‐adjacent ring + Set Rset = new LinkedHashSet<>(); + for (int f : currentRing) { + for (int v : faceVerts[f]) { + for (int g : vertToFaces[v]) { + if (!visited[g]) { + visited[g] = true; + Rset.add(g); + } + } + } + } + if (Rset.isEmpty()) { + return false; + } + + // reorder so that edge‐connected components remain together, + // the component touching lastEmitted comes first, and each + // component is globally CW‐sorted & rotated to hug the edge. + currentRing = reorderRing(Rset, lastEmitted); + ringIter = currentRing.iterator(); + return ringIter.hasNext(); + } + + @Override + public PShape next() { + if (!hasNext()) + throw new NoSuchElementException(); + int f = ringIter.next(); + lastEmitted = f; + return faces.get(f); + } + + // ───────────────────────────────────────────────────────────────────────────── + /** + * Given the new BFS‐ring Rset and the face‐ID lastEmitted, partition Rset into + * edge‐connected components, sort each component CW by global angle, rotate the + * “seed” component to start at its member sharing an edge with lastEmitted, + * then concatenate. + */ + private List reorderRing(Set Rset, int seedFace) { + // 1) globally CW‐sort all ring‐members by angle[] descending + List ringSorted = new ArrayList<>(Rset); + ringSorted.sort((a, b) -> Double.compare(angle[b], angle[a])); + + // 2) extract edge‐connected components in ringSorted order + Set seen = new HashSet<>(); + List> comps = new ArrayList<>(); + for (int f : ringSorted) + if (!seen.contains(f)) { + // flood‐fill + List comp = new ArrayList<>(); + Deque stack = new ArrayDeque<>(); + stack.push(f); + seen.add(f); + while (!stack.isEmpty()) { + int u = stack.pop(); + comp.add(u); + for (int g : edgeNbrs[u]) { + if (Rset.contains(g) && !seen.contains(g)) { + seen.add(g); + stack.push(g); + } + } + } + comps.add(comp); + } + + // 3) find which component touches seedFace by an edge + Set seedNbrs = new HashSet<>(); + for (int g : edgeNbrs[seedFace]) { + if (Rset.contains(g)) + seedNbrs.add(g); + } + int firstIdx = 0; + for (int i = 0; i < comps.size(); i++) { + for (int f : comps.get(i)) { + if (seedNbrs.contains(f)) { + firstIdx = i; + break; + } + } + } + // rotate so that comps[firstIdx] is first + Collections.rotate(comps, -firstIdx); + + // 4) for each component, CW‐sort by global angle + for (List comp : comps) { + comp.sort((a, b) -> Double.compare(angle[b], angle[a])); + } + + // 5) build the output list + List out = new ArrayList<>(Rset.size()); + for (int ci = 0; ci < comps.size(); ci++) { + List comp = comps.get(ci); + + // for the first (seeded) comp, rotate to start at the best seed‐nbr + if (ci == 0 && !seedNbrs.isEmpty()) { + // pick the comp‐member connected to seedFace + int best = comp.get(0); + double bestD = Double.POSITIVE_INFINITY; + for (int f : comp) { + if (seedNbrs.contains(f)) { + double d = cwDist(angle[seedFace], angle[f]); + if (d < bestD) { + bestD = d; + best = f; + } + } + } + // rotate comp so best is at index 0 + int k = comp.indexOf(best); + Collections.rotate(comp, -k); + } + + out.addAll(comp); + } + return out; + } + + /** CW‐distance from angle a1 to a2, in [0,2π). */ + private double cwDist(double a1, double a2) { + double d = a1 - a2; + if (d < 0) + d += Math.PI * 2; + return d; + } + + /** centroid of a face */ + private PVector computeCentroid(PShape f) { + float cx = 0, cy = 0; + int vc = f.getVertexCount(); + for (int i = 0; i < vc; i++) { + PVector v = f.getVertex(i); + cx += v.x; + cy += v.y; + } + return new PVector(cx / vc, cy / vc); + } + + // ── small helpers for hashing PVector/Edges ───────────────────────────────── + private static class VertexKey { + final int xh, yh, zh; + + VertexKey(PVector v) { + xh = Float.floatToIntBits(v.x); + yh = Float.floatToIntBits(v.y); + zh = Float.floatToIntBits(v.z); + } + + @Override + public int hashCode() { + return Objects.hash(xh, yh, zh); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof VertexKey)) + return false; + VertexKey k = (VertexKey) o; + return xh == k.xh && yh == k.yh && zh == k.zh; + } + } + + private static class EdgeKey { + final int a, b; + + EdgeKey(int x, int y) { + if (x < y) { + a = x; + b = y; + } else { + a = y; + b = x; + } + } + + @Override + public int hashCode() { + return Objects.hash(a, b); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof EdgeKey)) + return false; + EdgeKey e = (EdgeKey) o; + return a == e.a && b == e.b; + } + } +} \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/TangencyPack.java b/src/main/java/micycle/pgs/commons/TangencyPack.java index 1c62d494..4c23165e 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,352 @@ 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++; } } - /** - * Determine the centers of the circles using radii of the interior circles. - * - * @return - */ - private void computeCenters() { - if (flowers.size() > 0) { - placements = new HashMap<>(); + private double calculateLambda(double[] R1, double[] R2, double factor, int key) { + double lmax = Double.MAX_VALUE; - Vertex k1 = centralVertex; // pick one internal circle - placements.put(k1, new Complex(0, 0)); // place it at the origin + // 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; - 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); + if (key == 1) { + return Math.min(0.75 / (1 - factor), factor); + } else { + return Math.min(factor / (1 - factor), lmax); } + } - circles = new ArrayList<>(radii.size()); - placements.forEach( - (v, pos) -> circles.add(new PVector((float) pos.getReal(), (float) pos.getImaginary(), (float) radii.getDouble(v)))); + 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; + } } - /** - * 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(); + 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; - final double ra = radii.getDouble(v); - double angleSum = angleSum(ra, flower); + if (act >= 1) { + System.arraycopy(R2, 0, radiiArray, 0, radiiArray.length); + return false; + } + return act <= pred; + } - 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)); + private double computeAngleSums() { + double error = 0; + 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; + double x = 1 - (2 * bc) / denominator; + x = Math.max(-1.0, Math.min(1.0, x)); + angleSum += FastMath.acos(x); + } - // 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 +455,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/main/java/micycle/pgs/commons/TouchScale.java b/src/main/java/micycle/pgs/commons/TouchScale.java new file mode 100644 index 00000000..5762c396 --- /dev/null +++ b/src/main/java/micycle/pgs/commons/TouchScale.java @@ -0,0 +1,205 @@ +package micycle.pgs.commons; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineSegment; +import org.locationtech.jts.geom.util.AffineTransformation; +import org.locationtech.jts.noding.SegmentString; +import org.locationtech.jts.noding.SegmentStringUtil; +import org.locationtech.jts.operation.distance.DistanceOp; + +/** + * Compute a uniform scale about the shape centroid so the shape first touches a + * boundary geometry (shrink or grow to first contact). + *

+ * Uses analytic vertex–edge events (shape-vertex → boundary-edge and + * boundary-vertex → shape-edge) to compute the minimal positive scale factor. + *

+ * + * @author Michael Carleton + */ +public class TouchScale { + + /** + *

+ * Scales a shape about its centroid so it first contacts boundary. + *

+ * + *
    + *
  • Inputs: shape and boundary (may be polygons, lines, + * multiparts).
  • + *
  • Behavior: returns a new Geometry = scaled shape. The centroid of + * gShape is used as the scale center
  • + *
+ * + * @param shape the geometry to scale + * @param boundary the boundary geometry to touch + * @return scaled geometry (new Geometry instance) + */ + @SuppressWarnings("unchecked") + public static Geometry scale(Geometry shape, Geometry boundary) { + /* + * Uniform scaling about the centroid c sends every point x to c + s·(x − c). + * First contact happens at the smallest s > 0 where either: a shape-vertex + * (scaled along its ray from c) hits a boundary edge, or a boundary-vertex hits + * a shape edge (scaled). Solve these two cases in closed form for all + * vertex–edge pairs; take the minimal positive s. + */ + if (shape.isEmpty() || boundary.isEmpty()) + return shape; + + final Coordinate c = shape.getCentroid().getCoordinate(); + final double EPS = 1e-12; + + // Vertices (duplicates are fine) + final Coordinate[] shapeVerts = shape.getCoordinates(); + final Coordinate[] boundVerts = boundary.getCoordinates(); + + // Edges from boundaries (exterior + holes) + final List ssB = SegmentStringUtil.extractSegmentStrings(boundary.getBoundary()); + final List edgesB = toLineSegments(ssB); + + final List ssS = SegmentStringUtil.extractSegmentStrings(shape.getBoundary()); + final List edgesS = toLineSegments(ssS); + + double sBest = Double.POSITIVE_INFINITY; + + // 1) shape-vertex -> boundary-edge + for (Coordinate v : shapeVerts) { + double dvx = v.x - c.x, dvy = v.y - c.y; + if (Math.abs(dvx) + Math.abs(dvy) < EPS) + continue; + + for (LineSegment seg : edgesB) { + double ex = seg.p1.x - seg.p0.x, ey = seg.p1.y - seg.p0.y; + double denom = cross(dvx, dvy, ex, ey); + if (Math.abs(denom) < EPS) + continue; + + double s = -cross(c.x - seg.p0.x, c.y - seg.p0.y, ex, ey) / denom; + if (!(s > 0)) + continue; + + double yx = c.x + s * dvx, yy = c.y + s * dvy; + double txNum = ((yx - seg.p0.x) * ex + (yy - seg.p0.y) * ey); + double txDen = ex * ex + ey * ey; + if (txDen <= EPS) + continue; + double t = txNum / txDen; + if (t >= -1e-9 && t <= 1 + 1e-9) { + if (s < sBest) + sBest = s; + } + } + } + + // 2) boundary-vertex -> shape-edge + for (Coordinate w : boundVerts) { + double wcx = w.x - c.x, wcy = w.y - c.y; + + for (LineSegment seg : edgesS) { + double rax = seg.p0.x - c.x, ray = seg.p0.y - c.y; + double rbx = seg.p1.x - c.x, rby = seg.p1.y - c.y; + double mx = rbx - rax, my = rby - ray; + + double denom = cross(rax, ray, mx, my); + if (Math.abs(denom) < EPS) + continue; + + double s1 = cross(wcx, wcy, mx, my) / denom; + if (!(s1 > 0)) + continue; + + double s2 = cross(rax, ray, wcx, wcy) / denom; + double u = s2 / s1; + if (u >= -1e-9 && u <= 1 + 1e-9) { + if (s1 < sBest) + sBest = s1; + } + } + } + + // Fallback if degenerate + if (!Double.isFinite(sBest)) { + Coordinate nb = nearestPointOnBoundary(boundary, c); + double ux = nb.x - c.x, uy = nb.y - c.y; + double ulen = Math.hypot(ux, uy); + if (ulen < EPS) + return shape; + ux /= ulen; + uy /= ulen; + + double rB = firstRayHitDistance(c.x, c.y, ux, uy, edgesB); + double rS = maxDotAlong(shapeVerts, c, ux, uy); + if (rB > 0 && rS > EPS) + sBest = rB / rS; + else + return shape; + } + + // Tiny relative backoff + double sApply = Math.max(0.0, sBest * (1 - 1e-9)); + + AffineTransformation T = AffineTransformation.scaleInstance(sApply, sApply, c.x, c.y); + Geometry out = T.transform(shape); + return out; + } + + /* -------- helpers -------- */ + + private static List toLineSegments(List ssList) { + ArrayList out = new ArrayList<>(); + for (SegmentString ss : ssList) { + Coordinate[] cs = ss.getCoordinates(); + for (int i = 0; i < cs.length - 1; i++) { + if (!cs[i].equals2D(cs[i + 1])) { + out.add(new LineSegment(cs[i], cs[i + 1])); + } + } + } + return out; + } + + private static double cross(double ax, double ay, double bx, double by) { + return ax * by - ay * bx; + } + + private static double firstRayHitDistance(double cx, double cy, double rx, double ry, List edges) { + final double EPS = 1e-12; + double best = Double.POSITIVE_INFINITY; + for (LineSegment seg : edges) { + double ex = seg.p1.x - seg.p0.x, ey = seg.p1.y - seg.p0.y; + double denom = cross(rx, ry, ex, ey); + if (Math.abs(denom) < EPS) + continue; + double dx = seg.p0.x - cx, dy = seg.p0.y - cy; + double t = cross(dx, dy, ex, ey) / denom; // along ray + double u = cross(dx, dy, rx, ry) / denom; // along segment + if (t > 0 && u >= -1e-9 && u <= 1 + 1e-9) { + if (t < best) + best = t; + } + } + return best; + } + + private static double maxDotAlong(Coordinate[] verts, Coordinate c, double ux, double uy) { + double best = -Double.MAX_VALUE; + for (Coordinate v : verts) { + double dx = v.x - c.x, dy = v.y - c.y; + double d = dx * ux + dy * uy; + if (d > best) + best = d; + } + return best; + } + + private static Coordinate nearestPointOnBoundary(Geometry boundary, Coordinate c) { + Coordinate[] pair = DistanceOp.nearestPoints(boundary, boundary.getFactory().createPoint(c)); + return pair[0]; + } + +} diff --git a/src/main/java/micycle/pgs/commons/TriangleSubdivision.java b/src/main/java/micycle/pgs/commons/TriangleSubdivision.java index b552e34e..df575379 100644 --- a/src/main/java/micycle/pgs/commons/TriangleSubdivision.java +++ b/src/main/java/micycle/pgs/commons/TriangleSubdivision.java @@ -1,8 +1,5 @@ package micycle.pgs.commons; -import org.apache.commons.math3.random.RandomGenerator; - -import it.unimi.dsi.util.XoRoShiRo128PlusRandomGenerator; import micycle.pgs.color.ColorUtils; import processing.core.PApplet; import processing.core.PConstants; @@ -25,22 +22,42 @@ public class TriangleSubdivision { /** Probability that a triangle will subdivide */ private final double divProb = 0.85; - final RandomGenerator random; + // Base seed we’ll derive deterministic values from + private final long baseSeed; private PShape division; private final double width, height; + // salts to separate different random purposes + private static final long SALT_DIVIDE = 0xA0761D6478BD642FL; + private static final long SALT_LERP = 0xE7037ED1A0B428DBL; + public TriangleSubdivision(double width, double height, int maxDepth, long seed) { this.width = width; this.height = height; maxDiv = maxDepth; - random = new XoRoShiRo128PlusRandomGenerator(seed); + this.baseSeed = seed; } public PShape divide() { + return divide(mix64(baseSeed) % 2 == 0); + } + + public PShape divide(boolean flip) { + // flip==false -> diagonal TL->BR (original) + // flip==true -> diagonal TR->BL (the other diagonal) division = new PShape(PConstants.GROUP); - divideTriangle(0, 0, width, 0, width, height, maxDiv, 1); // top right half - divideTriangle(0, 0, 0, height, width, height, maxDiv, 1); // bottom left half + + if (!flip) { + // diagonal from top-left (0,0) to bottom-right (width,height) + divideTriangle(0, 0, width, 0, width, height, maxDiv, 1); // top-right triangle + divideTriangle(0, 0, 0, height, width, height, maxDiv, 1); // bottom-left triangle + } else { + // diagonal from top-right (width,0) to bottom-left (0,height) + divideTriangle(width, 0, width, height, 0, height, maxDiv, 1); // right-bottom triangle + divideTriangle(0, 0, width, 0, 0, height, maxDiv, 1); // left-top triangle + } + return division; } @@ -49,7 +66,8 @@ private void divideTriangle(double x1, double y1, double x2, double y2, double x if (depth == base) { division.addChild(tri.getShape()); } else { - final double toDivide = randomGaussian(0.5, 0.25); + // Deterministic per-triangle Gaussian value for "should we divide?" + final double toDivide = triGaussian(0.5, 0.25, tri.p1, tri.p2, tri.p3, SALT_DIVIDE); if (toDivide < divProb) { tri.computeOppositePoint(); divideTriangle(tri.d.x, tri.d.y, tri.l.x, tri.l.y, tri.n1.x, tri.n1.y, depth - 1, base); @@ -60,8 +78,44 @@ private void divideTriangle(double x1, double y1, double x2, double y2, double x } } - private double randomGaussian(double mean, double sd) { - return random.nextGaussian() * sd + mean; + // Deterministic Gaussian based on triangle vertices and a salt + private double triGaussian(double mean, double sd, PVector a, PVector b, PVector c, long salt) { + long x = hashTriangle(baseSeed, a, b, c, salt); + // Two uniforms via splitmix-like stepping for Box-Muller + long r1 = mix64(x + 0x9E3779B97F4A7C15L); + long r2 = mix64(x + 2L * 0x9E3779B97F4A7C15L); + double u1 = toUnit(r1); + double u2 = toUnit(r2); + // Guard against log(0) + u1 = (u1 <= 1e-15) ? 1e-15 : (u1 >= 1.0) ? 1.0 - 1e-15 : u1; + double z = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + return z * sd + mean; + } + + // Convert 64-bit random bits to double in [0,1) + private static double toUnit(long bits) { + // 53 significant bits for double fraction + return (bits >>> 11) * 0x1.0p-53; + } + + // Hash the triangle in a stable way (order-sensitive, which is fine because + // construction is deterministic) + private static long hashTriangle(long seed, PVector p1, PVector p2, PVector p3, long salt) { + long h = seed ^ salt; + h = mix64(h ^ Float.floatToIntBits(p1.x)); + h = mix64(h ^ Float.floatToIntBits(p1.y)); + h = mix64(h ^ Float.floatToIntBits(p2.x)); + h = mix64(h ^ Float.floatToIntBits(p2.y)); + h = mix64(h ^ Float.floatToIntBits(p3.x)); + h = mix64(h ^ Float.floatToIntBits(p3.y)); + return h; + } + + // Strong 64-bit mixer (SplitMix64 finalizer) + private static long mix64(long z) { + z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; + z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; + return z ^ (z >>> 31); } private class Triangle { @@ -82,7 +136,9 @@ private void computeOppositePoint() { double d13 = p3.dist(p1); double maxLength = Math.max(Math.max(d12, d23), d13); - float randVal = PApplet.constrain((float) randomGaussian(0.5, VARIANCE), 0, 1); + // Deterministic per-triangle Gaussian for lerp t + float randVal = PApplet.constrain((float) triGaussian(0.5, VARIANCE, p1, p2, p3, SALT_LERP), 0, 1); + if (maxLength == d12) { d = p3.copy(); n1 = p1.copy(); @@ -104,7 +160,7 @@ private void computeOppositePoint() { private PShape getShape() { final PShape triangle = new PShape(PShape.PATH); triangle.setFill(true); - triangle.setFill(ColorUtils.composeColor(255, 0, 255, 80)); + triangle.setFill(ColorUtils.composeColor(237, 50, 162)); triangle.setStroke(true); triangle.setStroke(255); triangle.beginShape(); diff --git a/src/main/java/org/jgrapht/alg/tour/FarthestInsertionHeuristicTSP.java b/src/main/java/org/jgrapht/alg/tour/FarthestInsertionHeuristicTSP.java deleted file mode 100644 index d80d7dc0..00000000 --- a/src/main/java/org/jgrapht/alg/tour/FarthestInsertionHeuristicTSP.java +++ /dev/null @@ -1,337 +0,0 @@ -package org.jgrapht.alg.tour; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import org.jgrapht.Graph; -import org.jgrapht.GraphPath; -import org.jgrapht.Graphs; -import org.jgrapht.graph.GraphWalk; -import org.jgrapht.util.VertexToIntegerMapping; - -/** - * The farthest insertion heuristic algorithm for the TSP problem. - * - *

- * The travelling salesman problem (TSP) asks the following question: "Given a - * list of cities and the distances between each pair of cities, what is the - * shortest possible route that visits each city exactly once and returns to the - * origin city?". - *

- * - *

- * Insertion heuristics are quite straightforward, and there are many variants - * to choose from. The basics of insertion heuristics is to start with a partial - * tour of a subset of all cities, and then iteratively an unvisited vertex (a - * vertex whichis not in the tour) is chosen given a criterion and inserted in - * the best position of the partial tour. Per each iteration, the farthest - * insertion heuristic selects the farthest unvisited vertex from the partial - * tour. This algorithm provides a guarantee to compute tours no more than 0(log - * N) times optimum (assuming the triangle inequality). However, regarding - * practical results, some references refer to this heuristic as one of the best - * among the category of insertion heuristics. This implementation uses the - * longest edge by default as the initial sub-tour if one is not provided. - *

- * - *

- * The description of this algorithm can be consulted on:
- * Johnson, D. S., & McGeoch, L. A. (2007). Experimental Analysis of Heuristics - * for the STSP. In G. Gutin & A. P. Punnen (Eds.), The Traveling Salesman - * Problem and Its Variations (pp. 369–443). Springer US. - * https://doi.org/10.1007/0-306-48213-4_9 - *

- * - *

- * This implementation can also be used in order to augment an existing partial - * tour. See constructor {@link #FarthestInsertionHeuristicTSP(GraphPath)}. - *

- * - *

- * The runtime complexity of this class is $O(V^2)$. - *

- * - *

- * This algorithm requires that the graph is complete. - *

- * - * @param the graph vertex type - * @param the graph edge type - * @author José Alejandro Cornejo Acosta - */ -public class FarthestInsertionHeuristicTSP extends HamiltonianCycleAlgorithmBase { - - /** - * Initial vertices in the tour - */ - private GraphPath initialSubtour; - - /** - * Distances from unvisited vertices to the partially constructed tour - */ - private double[] distances = null; - - /** - * Matrix of distances between all vertices - */ - private double[][] allDist; - - /** - * Mapping of vertices to integers to work on. - */ - private VertexToIntegerMapping mapping; - - /** - * Constructor. By default a sub-tour is chosen based on the longest edge - */ - public FarthestInsertionHeuristicTSP() { - this(null); - } - - /** - * Constructor - * - * Specifies an existing sub-tour that will be augmented to form a complete tour - * when {@link #getTour(org.jgrapht.Graph) } is called - * - * @param subtour Initial sub-tour, or null to start with longest edge - */ - public FarthestInsertionHeuristicTSP(GraphPath subtour) { - this.initialSubtour = subtour; - } - - /** - * Computes a tour using the farthest insertion heuristic. - * - * @param graph the input graph - * @return a tour - * @throws IllegalArgumentException if the graph is not undirected - * @throws IllegalArgumentException if the graph is not complete - * @throws IllegalArgumentException if the graph contains no vertices - */ - @Override - public GraphPath getTour(Graph graph) { -// checkGraph(graph); // NOTE don't check -- PGS will prepare valid complete graph - if (graph.vertexSet().size() == 1) { - return getSingletonTour(graph); - } - - mapping = Graphs.getVertexToIntegerMapping(graph); - - // Computes matrix of distances - E longestEdge = computeDistanceMatrix(graph); - if (initialSubtour == null || initialSubtour.getVertexList().isEmpty()) { - // If no initial subtour was provided, create one based on the longest edge - V v = graph.getEdgeSource(longestEdge); - V u = graph.getEdgeTarget(longestEdge); - - // at this point weight does not matter - initialSubtour = new GraphWalk<>(graph, List.of(v, u), -1); - } - - int n = mapping.getIndexList().size(); - - // initialize tour - int[] tour = initPartialTour(); - - // init distances from unvisited vertices to the partially constructed tour - initDistances(tour); - - // construct tour - for (int i = initialSubtour.getVertexList().size(); i < n; i++) { - - // Find the index of the farthest unvisited vertex. - int idxFarthest = getFarthest(i); - int k = tour[idxFarthest]; - - // Search for the best position of vertex k in the tour - double saving = Double.POSITIVE_INFINITY; - int bestIndex = -1; - for (int j = 0; j <= i; j++) { - - int x = (j == 0 ? tour[i - 1] : tour[j - 1]); - int y = (j == i ? tour[0] : tour[j]); - - double dxk = allDist[x][k]; - double dky = allDist[k][y]; - double dxy = (x == y ? 0 : allDist[x][y]); - - double savingTmp = dxk + dky - dxy; - if (savingTmp < saving) { - saving = savingTmp; - bestIndex = j; - } - } - swap(tour, i, idxFarthest); - swap(distances, i, idxFarthest); - - // perform insertion of vertex k - for (int j = i; j > bestIndex; j--) { - tour[j] = tour[j - 1]; - } - tour[bestIndex] = k; - - // Update distances from vertices to the partial tour - updateDistances(k, i + 1); - } - - tour[n] = tour[0]; // close tour manually. Arrays.asList does not support add - - // Map the tour from integer values to V values - List tourList = Arrays.stream(tour).mapToObj(i -> mapping.getIndexList().get(i)).collect(Collectors.toList()); - return closedVertexListToTour(tourList, graph); - } - - /** - * Initialize the partial tour with the vertices of {@code initialSubtour} at - * the beginning of the tour. - * - * @return a dummy tour with the vertices of {@code initialSubtour} at the - * beginning. - */ - private int[] initPartialTour() { - int n = mapping.getVertexMap().size(); - int[] tour = new int[n + 1]; - Set visited = new HashSet<>(); - int i = 0; - for (var v : initialSubtour.getVertexList()) { - int iv = mapping.getVertexMap().get(v); - visited.add(iv); - tour[i++] = iv; - } - for (int v = 0; v < n; v++) { - if (!visited.contains(v)) { - tour[i++] = v; - } - } - - return tour; - } - - @Override - protected GraphPath closedVertexListToTour(List tour, Graph graph) { - assert tour.get(0) == tour.get(tour.size() - 1); - - List edges = new ArrayList<>(tour.size() - 1); - double tourWeight = 0d; - V u = tour.get(0); - for (V v : tour.subList(1, tour.size())) { - E e = graph.getEdge(u, v); - edges.add(e); - tourWeight += graph.getEdgeWeight(e); - u = v; - } - return new GraphWalk<>(graph, tour.get(0), tour.get(0), tour, edges, tourWeight); - } - - /** - * Computes the matrix of distances by using the already computed - * {@code mapping} of vertices to integers - * - * @param graph the input graph - * @return the longest edge to initialize the partial tour if necessary - */ - private E computeDistanceMatrix(Graph graph) { - E longestEdge = null; - double longestEdgeWeight = -1; - int n = graph.vertexSet().size(); - allDist = new double[n][n]; - for (var edge : graph.edgeSet()) { - V source = graph.getEdgeSource(edge); - V target = graph.getEdgeTarget(edge); - if (!source.equals(target)) { - int i = mapping.getVertexMap().get(source); - int j = mapping.getVertexMap().get(target); - if (allDist[i][j] == 0) { - allDist[i][j] = allDist[j][i] = graph.getEdgeWeight(edge); - if (longestEdgeWeight < allDist[i][j]) { - longestEdgeWeight = allDist[i][j]; - longestEdge = edge; - } - } - } - } - return longestEdge; - } - - /** - * Find the index of the unvisited vertex which is farthest from the partially - * constructed tour. - * - * @param start The unvisited vertices start at index {@code start} - * @return the index of the unvisited vertex which is farthest from the - * partially constructed tour. - */ - private int getFarthest(int start) { - int n = distances.length; - int farthest = -1; - double maxDist = -1; - for (int i = start; i < n; i++) { - double dist = distances[i]; - if (dist > maxDist) { - farthest = i; - maxDist = dist; - } - } - return farthest; - } - - /** - * Initialize distances from the unvisited vertices to the initial subtour - * - * @param tour a partial tour with {@code initialSubtour} at the beginning - */ - private void initDistances(int[] tour) { - int n = mapping.getVertexMap().size(); - int start = initialSubtour.getVertexList().size(); - distances = new double[n]; - Arrays.fill(distances, start, n, Double.POSITIVE_INFINITY); - for (int i = start; i < n; i++) { - for (int j = 0; j < start; j++) { - distances[i] = Math.min(distances[i], allDist[tour[i]][tour[j]]); - } - } - } - - /** - * Update the distances from the unvisited vertices to the partially constructed - * tour - * - * @param v the last vertex added to the tour - * @param start the unvisited vertices start at index {@code start} - */ - private void updateDistances(int v, int start) { - for (int i = start; i < distances.length; i++) { - distances[i] = Math.min(allDist[v][i], distances[i]); - } - } - - /** - * Swaps the two elements at the specified indices in the given double array. - * - * @param arr the array - * @param i the index of the first element - * @param j the index of the second element - */ - public static void swap(double[] arr, int i, int j) { - double tmp = arr[j]; - arr[j] = arr[i]; - arr[i] = tmp; - } - - /** - * Swaps the two elements at the specified indices in the given int array. - * - * @param arr the array - * @param i the index of the first element - * @param j the index of the second element - */ - public static void swap(int[] arr, int i, int j) { - int tmp = arr[j]; - arr[j] = arr[i]; - arr[i] = tmp; - } -} \ No newline at end of file diff --git a/src/test/java/micycle/pgs/PGSTests.java b/src/test/java/micycle/pgs/PGSTests.java index b08f13c2..0bfa1e2a 100644 --- a/src/test/java/micycle/pgs/PGSTests.java +++ b/src/test/java/micycle/pgs/PGSTests.java @@ -1,15 +1,29 @@ package micycle.pgs; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.UnaryOperator; import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; import micycle.pgs.commons.PEdge; +import processing.core.PConstants; +import processing.core.PShape; import processing.core.PVector; class PGSTests { @@ -32,7 +46,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 +55,141 @@ 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 + } + + @Test + void testApplyToLinealGeometries() { + GeometryFactory gf = new GeometryFactory(); + + // 1) Single LineString -> keep + LineString ls = gf.createLineString(new Coordinate[] { new Coordinate(0, 0), new Coordinate(1, 1) }); + PShape lineShape = PGS_Conversion.toPShape(ls); + UnaryOperator keepAll = (LineString in) -> in; // identity + PShape outLineShape = PGS.applyToLinealGeometries(lineShape, keepAll); + assertNotNull(outLineShape, "LineString should be kept when function returns non-null"); + Geometry outGeom = PGS_Conversion.fromPShape(outLineShape); + assertTrue(outGeom instanceof LineString, "Result should be a LineString"); + assertArrayEquals(ls.getCoordinates(), ((LineString) outGeom).getCoordinates(), "Coordinates should be unchanged"); + + // 2) Single LineString -> drop (function returns null) + UnaryOperator dropAll = (lsIn) -> null; + PShape dropped = PGS.applyToLinealGeometries(lineShape, dropAll); + assertEquals(0, dropped.getChildCount()); + assertEquals(0, dropped.getVertexCount()); + + // 3) Polygon with exterior + one hole -> drop hole only + // exterior: square (0,0)-(4,0)-(4,4)-(0,4)-(0,0) + LinearRing exterior = gf.createLinearRing( + new Coordinate[] { new Coordinate(0, 0), new Coordinate(4, 0), new Coordinate(4, 4), new Coordinate(0, 4), new Coordinate(0, 0) }); + // hole: square (1,1)-(3,1)-(3,3)-(1,3)-(1,1) + LinearRing hole = gf.createLinearRing( + new Coordinate[] { new Coordinate(1, 1), new Coordinate(3, 1), new Coordinate(3, 3), new Coordinate(1, 3), new Coordinate(1, 1) }); + Polygon polyWithHole = gf.createPolygon(exterior, new LinearRing[] { hole }); + PShape polyShape = PGS_Conversion.toPShape(polyWithHole); + + // function that drops any ring whose first coordinate x == 1 (i.e., the hole) + UnaryOperator dropHoleIfStartsAt1 = (LineString in) -> { + Coordinate c0 = in.getCoordinateN(0); + if (Double.compare(c0.x, 1.0) == 0) { + return null; // drop this ring (hole) + } + return in; + }; + + PShape polyProcessed = PGS.applyToLinealGeometries(polyShape, dropHoleIfStartsAt1); + assertNotNull(polyProcessed, "Polygon with hole should remain when only hole is dropped"); + Geometry polyProcessedGeom = PGS_Conversion.fromPShape(polyProcessed); + assertTrue(polyProcessedGeom instanceof Polygon, "Result should be a Polygon"); + Polygon pRes = (Polygon) polyProcessedGeom; + assertEquals(0, pRes.getNumInteriorRing(), "Hole should have been removed"); + + // 4) Polygon -> drop exterior => entire polygon is dropped + UnaryOperator dropExteriorIfStartsAt0 = (LineString in) -> { + Coordinate c0 = in.getCoordinateN(0); + if (Double.compare(c0.x, 0.0) == 0 && Double.compare(c0.y, 0.0) == 0) { + return null; // drop exterior -> polygon should be dropped entirely + } + return in; + }; + PShape polyDropped = PGS.applyToLinealGeometries(polyShape, dropExteriorIfStartsAt0); + assertTrue(polyDropped.getChildCount() == 0 && polyDropped.getVertexCount() == 0, + "If exterior ring is dropped, the whole polygon should be dropped (null returned)"); + + // 5) MultiPolygon where one child is dropped and one kept + // Polygon A (kept): square at origin without hole + Polygon polyA = gf.createPolygon( + gf.createLinearRing( + new Coordinate[] { new Coordinate(0, 0), new Coordinate(2, 0), new Coordinate(2, 2), new Coordinate(0, 2), new Coordinate(0, 0) }), + null); + + // Polygon B (to be dropped): square starting at x==10 + Polygon polyB = gf.createPolygon(gf.createLinearRing( + new Coordinate[] { new Coordinate(10, 10), new Coordinate(12, 10), new Coordinate(12, 12), new Coordinate(10, 12), new Coordinate(10, 10) }), + null); + + MultiPolygon multi = gf.createMultiPolygon(new Polygon[] { polyA, polyB }); + PShape multiShape = PGS_Conversion.toPShape(multi); + + // function that drops any ring starting at x >= 10 (so polygon B dropped) + UnaryOperator dropXge10 = (LineString in) -> { + Coordinate c0 = in.getCoordinateN(0); + if (c0.x >= 10.0) { + return null; + } + return in; + }; + + PShape multiProcessed = PGS.applyToLinealGeometries(multiShape, dropXge10); + assertNotNull(multiProcessed, "MultiPolygon with one surviving child should not be null"); + assertEquals(PConstants.GROUP, multiProcessed.getKind(), "Resulting PShape should be a GROUP"); + assertEquals(1, multiProcessed.getChildCount(), "GROUP should have exactly one child after dropping one polygon"); + + Geometry multiProcGeom = PGS_Conversion.fromPShape(multiProcessed); + // After transformation, should be a MultiPolygon or a Polygon depending on + // builder; accept either but verify one child/polygon remains + if (multiProcGeom instanceof MultiPolygon) { + MultiPolygon mp = (MultiPolygon) multiProcGeom; + assertEquals(1, mp.getNumGeometries(), "One polygon should remain in the MultiPolygon"); + assertTrue(mp.getGeometryN(0) instanceof Polygon, "Remaining geometry should be a Polygon"); + } else if (multiProcGeom instanceof Polygon) { + // Possible that transformer collapses to single Polygon; check it's polyA + // coordinates + Polygon p = (Polygon) multiProcGeom; + assertEquals(0, p.getNumInteriorRing(), "Remaining polygon should have no holes"); + + assertTrue(polyA.getExteriorRing().equalsTopo(p.getExteriorRing()), "Remaining polygon should match polyA"); + } else { + fail("Unexpected geometry type after processing MultiPolygon: " + multiProcGeom.getGeometryType()); + } + } + } diff --git a/src/test/java/micycle/pgs/PGS_ConversionTests.java b/src/test/java/micycle/pgs/PGS_ConversionTests.java index 169fb201..aa4d6ee2 100644 --- a/src/test/java/micycle/pgs/PGS_ConversionTests.java +++ b/src/test/java/micycle/pgs/PGS_ConversionTests.java @@ -17,6 +17,7 @@ import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.PrecisionModel; +import micycle.pgs.PGS_Conversion.PShapeData; import micycle.pgs.color.ColorUtils; import processing.core.PConstants; import processing.core.PShape; @@ -352,14 +353,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 +369,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() { @@ -398,13 +418,22 @@ void testCopy() { PShape a = PGS_Construction.createSierpinskiCurve(0, 0, 10, 3); PShape b = PGS_Construction.createHeart(0, 0, 10); PShape group = PGS_Conversion.flatten(a, b); + PGS_Conversion.setAllFillColor(group, 1337); + PShapeData d = new PShapeData(group.getChild(0)); + assertEquals(1337, d.fillColor); PShape copy = PGS_Conversion.copy(group); + // test geom structure preserved assertTrue(PGS_ShapePredicates.equalsNorm(group, copy)); copy.getChild(0).setVertex(0, -999,-999); // shouldn't change group assertFalse(PGS_ShapePredicates.equalsNorm(group, copy)); + + // test styling preserved + d = new PShapeData(copy.getChild(0)); + + assertEquals(1337, d.fillColor); } @Test diff --git a/src/test/java/micycle/pgs/PGS_MeshingTests.java b/src/test/java/micycle/pgs/PGS_MeshingTests.java index 93bfa058..20fecda3 100644 --- a/src/test/java/micycle/pgs/PGS_MeshingTests.java +++ b/src/test/java/micycle/pgs/PGS_MeshingTests.java @@ -22,6 +22,11 @@ void testAreaMerge() { assertTrue(PGS_Conversion.getChildren(mergedMesh).stream().allMatch(f -> PGS_ShapePredicates.area(f) >= areaThreshold)); assertTrue(faces.size() >= mergedMesh.getChildCount()); assertEquals(PGS_ShapePredicates.area(mesh), PGS_ShapePredicates.area(mergedMesh), 1e-6); + + System.out.println(mesh.getChildCount()); + mergedMesh = PGS_Meshing.areaMerge(mesh, 20); // test remaining faces constructor + assertEquals(20, mergedMesh.getChildCount()); + assertEquals(PGS_ShapePredicates.area(mesh), PGS_ShapePredicates.area(mergedMesh), 1e-6); } } diff --git a/src/test/java/micycle/pgs/PGS_MorphologyGroupShapeTests.java b/src/test/java/micycle/pgs/PGS_MorphologyGroupShapeTests.java index 460f618a..d7f530e7 100644 --- a/src/test/java/micycle/pgs/PGS_MorphologyGroupShapeTests.java +++ b/src/test/java/micycle/pgs/PGS_MorphologyGroupShapeTests.java @@ -48,35 +48,35 @@ void prepareGroupShape() { } @Test - void test_PGS_Morphology_buffer() { + void testBuffer() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.buffer(GROUP_SHAPE, -1); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_chaikinCut() { + void testChaikinCut() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.chaikinCut(GROUP_SHAPE, 0.5, 2); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_erosionDilation() { + void testErosionDilation() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.erosionDilation(GROUP_SHAPE, 0); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_fieldWarp() { + void testFieldWarp() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.fieldWarp(GROUP_SHAPE, 10, 1, false); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_minkDifference() { + void testMinkDifference() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); final PShape mink = new PShape(PShape.PATH); mink.beginShape(); @@ -91,7 +91,7 @@ void test_PGS_Morphology_minkDifference() { } @Test - void test_PGS_Morphology_minkSum() { + void testMinkSum() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); final PShape mink = new PShape(PShape.PATH); mink.beginShape(); @@ -106,49 +106,49 @@ void test_PGS_Morphology_minkSum() { } @Test - void test_PGS_Morphology_radialWarp() { + void testRadialWarp() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.radialWarp(GROUP_SHAPE, 10, 1, false); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_round() { + void testRound() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.round(GROUP_SHAPE, 0.5); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_simplify() { + void testSimplify() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.simplify(GROUP_SHAPE, 1); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_simplifyTopology() { + void testSimplifyTopology() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.simplifyTopology(GROUP_SHAPE, 1); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_simplifyVW() { + void testSimplifyVW() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.simplifyVW(GROUP_SHAPE, 1); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_smooth() { + void testSmooth() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.smooth(GROUP_SHAPE, 0.5); assertEquals(2, out.getChildCount()); } @Test - void test_PGS_Morphology_smoothGaussian() { + void testSmoothGaussian() { assumeTrue(GROUP_SHAPE.getChildCount() == 2); PShape out = PGS_Morphology.smoothGaussian(GROUP_SHAPE, 10); assertEquals(2, out.getChildCount()); diff --git a/src/test/java/micycle/pgs/PGS_MorphologyTests.java b/src/test/java/micycle/pgs/PGS_MorphologyTests.java new file mode 100644 index 00000000..16ec9578 --- /dev/null +++ b/src/test/java/micycle/pgs/PGS_MorphologyTests.java @@ -0,0 +1,202 @@ +package micycle.pgs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +import micycle.pgs.commons.DiscreteCurveEvolution.DCETerminationCallback; +import processing.core.PShape; + +public class PGS_MorphologyTests { + + private static final GeometryFactory GF = new GeometryFactory(); + + private PShape inShape; + private Geometry inGeom; + private double[] originalAreas; + private int[] originalHoleCounts; + + @BeforeEach + public void setUpPolygons() { + // Build two polygons with holes and outward/inward spikes (same as in your + // smoothGaussian test) + Polygon p1 = buildPolygonWithHoles(spikyRectRing(0, 0, 10, 8, 0.8, true), new LinearRing[] { spikyRectRing(2, 2, 4.5, 4.5, 0.6, false) }); + + Polygon p2 = buildPolygonWithHoles(spikyRectRing(20, 0, 34, 12, 1.0, true), + new LinearRing[] { spikyRectRing(22.5, 2.5, 27.5, 5.5, 0.7, false), spikyRectRing(28.0, 7.0, 32.0, 10.0, 0.5, false) }); + + MultiPolygon mp = GF.createMultiPolygon(new Polygon[] { p1, p2 }); + inShape = PGS_Conversion.toPShape(mp); + + // Basic sanity + assertNotNull(inShape); + assertTrue(inShape.getChildCount() >= 2); + + inGeom = PGS_Conversion.fromPShape(inShape); + assertEquals(2, inGeom.getNumGeometries()); + + originalAreas = new double[2]; + originalHoleCounts = new int[2]; + for (int i = 0; i < 2; i++) { + Geometry gi = inGeom.getGeometryN(i); + assertTrue(gi instanceof Polygon); + originalHoleCounts[i] = ((Polygon) gi).getNumInteriorRing(); + originalAreas[i] = PGS_ShapePredicates.area(inShape.getChild(i)); + } + } + + @Test + public void testHobbySimplify() { + PShape outShape = PGS_Morphology.simplifyHobby(inShape, 1); + Geometry outGeom = getOutputGeom(outShape); + assertPolygonsAndHoleCounts(outGeom); + assertAreasDecreased(outShape, "after elliptic Fourier smoothing"); + } + + @Test + public void testRounding() { + PShape outShape = PGS_Morphology.round(inShape, 20); + Geometry outGeom = getOutputGeom(outShape); + assertPolygonsAndHoleCounts(outGeom); + assertAreasDecreased(outShape, "after rounding"); + } + + @Test + public void testChaikinCut() { + PShape outShape = PGS_Morphology.chaikinCut(inShape, 0.5, 3); + Geometry outGeom = getOutputGeom(outShape); + assertPolygonsAndHoleCounts(outGeom); + assertAreasDecreased(outShape, "after Chaikin cutting"); + } + + @Test + public void testSmoothEllipticFourier() { + // choose descriptors moderately large to preserve smoothing effect + int descriptors = 10; + + PShape outShape = PGS_Morphology.smoothEllipticFourier(inShape, descriptors); + Geometry outGeom = getOutputGeom(outShape); + assertPolygonsAndHoleCounts(outGeom); + assertAreasDecreased(outShape, "after elliptic Fourier smoothing"); + } + + @Test + public void testSimplifyDCE() { + // Create a simple termination callback: stop when remaining vertices <= + // threshold + final int targetVertices = 8; + DCETerminationCallback callback = new DCETerminationCallback() { + @Override + public boolean shouldTerminate(Coordinate currentVertex, double relevance, int verticesRemaining) { + return verticesRemaining <= targetVertices; + } + }; + + // Apply DCE simplification + PShape outShape = PGS_Morphology.simplifyDCE(inShape, callback); + + // Output should be a non-null group with two polygonal children + Geometry outGeom = getOutputGeom(outShape); + // additional DCE-specific assertions (ensure output geometries are polygons) + for (int i = 0; i < 2; i++) { + assertTrue(outGeom.getGeometryN(i) instanceof Polygon); + } + + // Structure: preserve polygon count and hole counts + assertPolygonsAndHoleCounts(outGeom); + + // Areas: each polygon's area should be reduced after simplification + assertAreasDecreased(outShape, "after DCE simplification"); + } + + @Test + public void testSmoothGaussian() { + // Apply smoothing + double sigma = 1.5; + PShape outShape = PGS_Morphology.smoothGaussian(inShape, sigma); + + Geometry outGeom = getOutputGeom(outShape); + assertPolygonsAndHoleCounts(outGeom); + assertAreasDecreased(outShape, "after smoothing"); + } + + /* Helper factories and geometry builders */ + + private static Polygon buildPolygonWithHoles(LinearRing exterior, LinearRing[] holes) { + return GF.createPolygon(exterior, holes); + } + +// Build a rectangular ring with two spike points per edge. +// For spikesOutward = true: spikes point outside the rectangle bounds. +// For spikesOutward = false (holes): spikes point toward the rectangle center. + private static LinearRing spikyRectRing(double minX, double minY, double maxX, double maxY, double amplitude, boolean spikesOutward) { + + List coords = new ArrayList<>(); + + // Bottom edge: (minX,minY) -> (maxX,minY) + coords.add(new Coordinate(minX, minY)); + coords.add(new Coordinate(lerp(minX, maxX, 1.0 / 3.0), minY + (spikesOutward ? -amplitude : +amplitude))); + coords.add(new Coordinate(lerp(minX, maxX, 2.0 / 3.0), minY + (spikesOutward ? -amplitude : +amplitude))); + coords.add(new Coordinate(maxX, minY)); + + // Right edge: (maxX,minY) -> (maxX,maxY) + coords.add(new Coordinate(maxX + (spikesOutward ? +amplitude : -amplitude), lerp(minY, maxY, 1.0 / 3.0))); + coords.add(new Coordinate(maxX + (spikesOutward ? +amplitude : -amplitude), lerp(minY, maxY, 2.0 / 3.0))); + coords.add(new Coordinate(maxX, maxY)); + + // Top edge: (maxX,maxY) -> (minX,maxY) + coords.add(new Coordinate(lerp(maxX, minX, 2.0 / 3.0), maxY + (spikesOutward ? +amplitude : -amplitude))); + coords.add(new Coordinate(lerp(maxX, minX, 1.0 / 3.0), maxY + (spikesOutward ? +amplitude : -amplitude))); + coords.add(new Coordinate(minX, maxY)); + + // Left edge: (minX,maxY) -> (minX,minY) + coords.add(new Coordinate(minX + (spikesOutward ? -amplitude : +amplitude), lerp(maxY, minY, 2.0 / 3.0))); + coords.add(new Coordinate(minX + (spikesOutward ? -amplitude : +amplitude), lerp(maxY, minY, 1.0 / 3.0))); + + // Close ring + coords.add(new Coordinate(minX, minY)); + + return GF.createLinearRing(coords.toArray(new Coordinate[0])); + } + + private static double lerp(double a, double b, double t) { + return a + (b - a) * t; + } + + /* New helper assertion methods to remove duplication */ + + private Geometry getOutputGeom(PShape outShape) { + assertNotNull(outShape, "Output shape must not be null"); + Geometry outGeom = PGS_Conversion.fromPShape(outShape); + assertEquals(2, outGeom.getNumGeometries(), "Output geometry must contain two geometries"); + assertEquals(2, outShape.getChildCount(), "Output PShape must have two children"); + return outGeom; + } + + private void assertPolygonsAndHoleCounts(Geometry outGeom) { + for (int i = 0; i < 2; i++) { + assertTrue(outGeom.getGeometryN(i) instanceof Polygon, "Geometry " + i + " must be a Polygon"); + Polygon op = (Polygon) outGeom.getGeometryN(i); + assertEquals(originalHoleCounts[i], op.getNumInteriorRing(), "Hole count should be preserved for polygon " + i); + } + } + + private void assertAreasDecreased(PShape outShape, String messagePrefix) { + double outArea0 = PGS_ShapePredicates.area(outShape.getChild(0)); + double outArea1 = PGS_ShapePredicates.area(outShape.getChild(1)); + assertTrue(outArea0 < originalAreas[0], "Polygon 0 area should decrease " + messagePrefix); + assertTrue(outArea1 < originalAreas[1], "Polygon 1 area should decrease " + messagePrefix); + } +} \ No newline at end of file diff --git a/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java b/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java index 1278f37a..bbb459d4 100644 --- a/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java +++ b/src/test/java/micycle/pgs/PGS_ShapePredicatesTests.java @@ -16,9 +16,9 @@ class PGS_ShapePredicatesTests { - private static final double EPSILON = 1E-4; + private static final double EPSILON = 1E-5; - static PShape square, triangle; + static PShape square, triangle, rect; @BeforeAll static void initShapes() { @@ -30,6 +30,14 @@ static void initShapes() { 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; triangle = new PShape(PShape.GEOMETRY); // equilateral triangle @@ -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,27 @@ 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)); + } + + @Test + void testMinimumInteriorAngle() { + assertEquals(Math.PI / 2, PGS_ShapePredicates.minimumInteriorAngle(rect), EPSILON); + assertEquals(Math.PI / 3, PGS_ShapePredicates.minimumInteriorAngle(triangle), EPSILON); } }