diff --git a/CHANGELOG.md b/CHANGELOG.md index dbcdc715..3bac886a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.3** *(2026-xx-xx)* + +### Added +* `fillPack()` to `PGS_CirclePacking`. Fills gaps in an existing circle packing using the Largest Empty Circle (LEC) algorithm, seeded with the existing circles as constraints. + +### Changes +* Reimplemented `PGS_CirclePacking.frontChainPack()` to pack circles directly within the shape boundary, rather than packing the bounding envelope and filtering. +* Reimplemented `PGS_Voronoi.additivelyWeightedVoronoi()`. About 10x faster than before. The `forceConforming` argument has been removed, as it's no longer necessary. + ## **2.2** *(2026-04-06)* ### Added diff --git a/README.md b/README.md index afc21ce3..9a578e65 100644 --- a/README.md +++ b/README.md @@ -750,10 +750,12 @@ Much of the functionality (but by no means all) is demonstrated below: - Obstacle + Obstacle + Fill + diff --git a/pom.xml b/pom.xml index c7d75e4f..6250307b 100644 --- a/pom.xml +++ b/pom.xml @@ -354,7 +354,7 @@ com.github.micycle1 BetterBeziers - 1.0 + 1.1 com.github.micycle1 @@ -385,8 +385,8 @@ com.github.micycle1 - geoblitz - 0.9.6 + GeoBlitz + 0.9.9 com.github.micycle1 diff --git a/resources/circle_packing/fillPack.gif b/resources/circle_packing/fillPack.gif new file mode 100644 index 00000000..9947455e Binary files /dev/null and b/resources/circle_packing/fillPack.gif differ diff --git a/resources/circle_packing/frontChain1.png b/resources/circle_packing/frontChain1.png index 584cb517..c0709e4e 100644 Binary files a/resources/circle_packing/frontChain1.png and b/resources/circle_packing/frontChain1.png differ diff --git a/resources/circle_packing/frontChain2.png b/resources/circle_packing/frontChain2.png index 4338e666..1d189cda 100644 Binary files a/resources/circle_packing/frontChain2.png and b/resources/circle_packing/frontChain2.png differ diff --git a/src/main/java/micycle/pgs/PGS.java b/src/main/java/micycle/pgs/PGS.java index 6e25efa1..3505ab85 100644 --- a/src/main/java/micycle/pgs/PGS.java +++ b/src/main/java/micycle/pgs/PGS.java @@ -167,7 +167,7 @@ static final Coordinate[] toCoords(final Collection points) { } static final PVector toPVector(Coordinate c) { - return new PVector((float) c.x, (float) c.y); + return new PVector((float) c.x, (float) c.y, (float) c.z); } /** diff --git a/src/main/java/micycle/pgs/PGS_CirclePacking.java b/src/main/java/micycle/pgs/PGS_CirclePacking.java index 149dc82c..984b0473 100644 --- a/src/main/java/micycle/pgs/PGS_CirclePacking.java +++ b/src/main/java/micycle/pgs/PGS_CirclePacking.java @@ -14,15 +14,17 @@ import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.Location; +import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.operation.distance.IndexedFacetDistance; import org.tinfour.common.IIncrementalTin; import org.tinfour.common.SimpleTriangle; import org.tinfour.common.Vertex; + +import com.github.micycle1.geoblitz.CircleIndex; import com.github.micycle1.geoblitz.PointDistanceIndex; import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; -import micycle.pgs.commons.CircleCoverTree; -import micycle.pgs.commons.FrontChainPacker; +import micycle.pgs.commons.GeometryFrontChainPacker; import micycle.pgs.commons.LargestEmptyCircles; import micycle.pgs.commons.RepulsionCirclePack; import micycle.pgs.commons.TangencyPack; @@ -191,7 +193,7 @@ public static List stochasticPack(final PShape shape, final int points, * the center point, and .z represents the radius. */ public static List stochasticPack(final PShape shape, final int points, final double minRadius, boolean triangulatePoints, long seed) { - CircleCoverTree tree = new CircleCoverTree<>(); + CircleIndex tree = new CircleIndex<>(); List steinerPoints = PGS_Processing.generateRandomPoints(shape, points, seed); if (triangulatePoints) { @@ -266,32 +268,12 @@ public static List frontChainPack(PShape shape, double radiusMin, doubl * the center point and .z represents radius. */ public static List frontChainPack(PShape shape, double radiusMin, double radiusMax, long seed) { - radiusMin = Math.max(1f, Math.min(radiusMin, radiusMax)); // choose min and constrain - radiusMax = Math.max(1f, Math.max(radiusMin, radiusMax)); // choose max and constrain - final Geometry g = fromPShape(shape); - final Envelope e = g.getEnvelopeInternal(); - 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 YStripesPointInAreaLocator(g.buffer(radiusMax)); - packer.getCircles().removeIf(p -> pointLocator.locate(PGS.coordFromPVector(p)) == Location.EXTERIOR); - } else { - 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; // keep if interior - } - return !distance.isWithinDistance(PGS.pointFromPVector(p), p.z * (2 / 3d)); - }); - } - - return packer.getCircles(); + radiusMin = Math.max(1d, Math.min(radiusMin, radiusMax)); // choose min and constrain + radiusMax = Math.max(1d, Math.max(radiusMin, radiusMax)); // choose max and constrain + + final var packer = new GeometryFrontChainPacker((Polygon) fromPShape(shape), radiusMin, radiusMax, seed); + + return packer.getCircles().stream().map(c -> PGS.toPVector(c)).toList(); } /** @@ -356,6 +338,83 @@ public static List maximumInscribedPack(PShape shape, double minRadius, return out; } + /** + * Fills the gaps in an existing circle packing with new circles, using the + * Largest Empty Circle (LEC) algorithm seeded with the existing packing. + *

+ * The existing circles are treated as constraints: new circles will not overlap + * them (nor each other, nor the shape boundary). Circles are found + * largest-first until the next circle would be smaller than {@code minRadius}. + * + * @param shape The shape within which circles will be packed. + * @param existingCircles An existing circle packing to fill in (perhaps + * produced by another method in this class). Each + * PVector represents one circle: (.x, .y) is the center + * and .z is the radius. + * @param minRadius The minimum allowed radius for the new circles. + * Filling stops once the largest remaining gap is + * smaller than this. + * @param tolerance The tolerance value to control the LEC algorithm's + * accuracy. Higher values yield faster results but lower + * accuracy. A value of 1 is a good starting point. + * @return A list of the new circles only (the input circles are not + * echoed back), each as a PVector: (.x, .y) represent the center point + * and .z represents the radius. + * @since 2.3 + * @see #maximumInscribedPack(PShape, double, double) + */ + public static List fillPack(PShape shape, Collection existingCircles, double minRadius, double tolerance) { + tolerance = Math.max(0.01, tolerance); + minRadius = Math.max(0.01, minRadius); + + final List seeds = existingCircles.stream().map(c -> new Coordinate(c.x, c.y, c.z)).toList(); + final LargestEmptyCircles lec = new LargestEmptyCircles(fromPShape(shape), null, seeds, tolerance); + + final List out = new ArrayList<>(); + double[] c; + while ((c = lec.findNextLEC())[2] >= minRadius) { + out.add(new PVector((float) c[0], (float) c[1], (float) c[2])); + } + return out; + } + + /** + * Fills the gaps in an existing circle packing with exactly {@code n} new + * circles, using the Largest Empty Circle (LEC) algorithm seeded with the + * existing packing. + *

+ * The existing circles are treated as constraints: new circles will not overlap + * them (nor each other, nor the shape boundary). + * + * @param shape The shape within which circles will be packed. + * @param existingCircles An existing circle packing to fill in (perhaps + * produced by another method in this class). Each + * PVector represents one circle: (.x, .y) is the center + * and .z is the radius. + * @param n The number of new circles to find and pack. + * @param tolerance The tolerance value to control the LEC algorithm's + * accuracy. Higher values yield faster results but lower + * accuracy. A value of 1 is a good starting point. + * @return A list of the new circles only (the input circles are not + * echoed back), each as a PVector: (.x, .y) represent the center point + * and .z represents the radius. + * @since 2.3 + * @see #fillPack(PShape, Collection, double, double) + */ + public static List fillPack(PShape shape, Collection existingCircles, int n, double tolerance) { + tolerance = Math.max(0.01, tolerance); + + final List seeds = existingCircles.stream().map(c -> new Coordinate(c.x, c.y, c.z)).toList(); + final LargestEmptyCircles lec = new LargestEmptyCircles(fromPShape(shape), null, seeds, tolerance); + + final List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + double[] c = lec.findNextLEC(); + out.add(new PVector((float) c[0], (float) c[1], (float) c[2])); + } + return out; + } + /** * Generates a circle packing having a pattern of tangencies specified by a * triangulation. diff --git a/src/main/java/micycle/pgs/PGS_Conversion.java b/src/main/java/micycle/pgs/PGS_Conversion.java index b08f82dc..5fcf456e 100644 --- a/src/main/java/micycle/pgs/PGS_Conversion.java +++ b/src/main/java/micycle/pgs/PGS_Conversion.java @@ -2121,7 +2121,7 @@ public static PShape fromQuadraticBezier(PVector start, PVector controlPoint, PV */ public static PShape fromCubicBezier(PVector start, PVector controlPoint1, PVector controlPoint2, PVector end) { CubicBezier bezier = new CubicBezier(start.x, start.y, controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y, end.x, end.y); - double[][] samples = bezier.sampleEquidistantPoints(BEZIER_SAMPLE_DISTANCE); + double[][] samples = bezier.sampleAdaptive(1.0); final List coords = new ArrayList<>(samples.length); for (double[] sample : samples) { coords.add(new PVector((float) sample[0], (float) sample[1])); diff --git a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java index 926dba63..3454f3be 100644 --- a/src/main/java/micycle/pgs/PGS_ShapeBoolean.java +++ b/src/main/java/micycle/pgs/PGS_ShapeBoolean.java @@ -27,7 +27,7 @@ import org.locationtech.jts.operation.union.UnaryUnionOp; import org.locationtech.jts.util.GeometricShapeFactory; -import com.github.micycle1.geoblitz.DiskUnion; +import com.github.micycle1.geoblitz.CircleUnion; import micycle.pgs.commons.FastOverlapRegions; import micycle.pgs.commons.Nullable; @@ -231,7 +231,7 @@ public static PShape union(PShape... shapes) { */ public static PShape unionCircles(Collection circles) { var disks = circles.stream().map(c -> PGS.coordFromPVector(c)).toList(); - var union = DiskUnion.union(disks, PGS_Conversion.BEZIER_SAMPLE_DISTANCE); + var union = CircleUnion.union(disks, PGS_Conversion.BEZIER_SAMPLE_DISTANCE); return toPShape(union); } diff --git a/src/main/java/micycle/pgs/PGS_Voronoi.java b/src/main/java/micycle/pgs/PGS_Voronoi.java index 80f66953..d4c29bd0 100644 --- a/src/main/java/micycle/pgs/PGS_Voronoi.java +++ b/src/main/java/micycle/pgs/PGS_Voronoi.java @@ -483,43 +483,6 @@ public static PShape compoundVoronoi(PShape shape, double[] bounds) { return voronoiCells; } - /** - * Generates an additively weighted Voronoi diagram (AWVD) for a set of - * weighted point sites, clipped to the provided bounding rectangle. - *

- * AWVDs are a generalisation of standard Voronoi diagrams where each site has - * an additive weight. Distances are compared using an adjusted metric of the - * form: - * - *

-	 *   d(p, s) = ||p - s|| - w
-	 * 
- * - * where {@code s} is the site location and {@code w} is its weight. Increasing - * a site's weight tends to expand its cell; decreasing it tends to shrink the - * cell. Unlike standard Voronoi diagrams, AWVD cell boundaries are generally - * curved (hyperbolic arcs), and some sites may end up with empty cells - * depending on weights and configuration. - *

- * Each input {@link PVector} encodes one site where: - *

    - *
  • {@code (.x, .y)} is the site coordinate
  • - *
  • {@code .z} is the site's weight (in the same units as {@code x/y})
  • - *
- * - * @param weightedSites a collection of weighted sites encoded as PVectors: - * {@code (.x, .y)} position and {@code .z} weight - * @param bounds an array of the form {@code [minX, minY, maxX, maxY]} - * defining the clipping bounds of the diagram; must fully - * contain all sites - * @return a GROUP {@link PShape} where each child shape is a (possibly curved) - * AWVD cell polygon clipped to {@code bounds} - * @since 2.2 - */ - public static PShape additivelyWeightedVoronoi(Collection weightedSites, double[] bounds) { - return additivelyWeightedVoronoi(weightedSites, bounds, false); - } - /** * Generates an additively weighted Voronoi diagram (AWVD) for a set of * weighted point sites, clipped to the provided bounding rectangle. @@ -551,38 +514,28 @@ public static PShape additivelyWeightedVoronoi(Collection weightedSites * simplify the interior boundaries. * * - * @param weightedSites a collection of weighted sites encoded as PVectors: - * {@code (.x, .y)} position and {@code .z} weight - * @param bounds an array of the form {@code [minX, minY, maxX, maxY]} - * defining the clipping bounds of the diagram; must - * fully contain all sites - * @param forceConforming whether to apply additional processing to try to - * ensure adjacent cells form a conforming coverage - * (i.e., no tiny gaps between cells) + * @param weightedSites a collection of weighted sites encoded as PVectors: + * {@code (.x, .y)} position and {@code .z} weight + * @param bounds an array of the form {@code [minX, minY, maxX, maxY]} + * defining the clipping bounds of the diagram; must fully + * contain all sites * @return a GROUP {@link PShape} where each child shape is a (possibly curved) * AWVD cell polygon clipped to {@code bounds} * @since 2.2 */ - public static PShape additivelyWeightedVoronoi(Collection weightedSites, double[] bounds, boolean forceConforming) { + public static PShape additivelyWeightedVoronoi(Collection weightedSites, double[] bounds) { var sites = weightedSites.stream().map(s -> PGS.coordFromPVector(s)).toList(); var e = new Envelope(bounds[0], bounds[2], bounds[1], bounds[3]); // x,x,y,y - AdditivelyWeightedVoronoi vd = new AdditivelyWeightedVoronoi(GEOM_FACTORY, 0.2); + AdditivelyWeightedVoronoi vd = new AdditivelyWeightedVoronoi(GEOM_FACTORY, 0.25); List cells = vd.computeCells(sites, e); - if (forceConforming) { - cells = PGS_Meshing.fixBreaks(cells, 1); // gapWidth==1 suits errTol==0.2 - var simple = CoverageSimplifier.simplifyInner(cells.toArray(Geometry[]::new), 1); - cells = Arrays.asList(simple); - } else { - // Produces rather dense output, so simplify using conservative DCE relevance. - final DCETerminationCallback dceCallback = (currentVertex, relevance, verticesRemaining) -> relevance >= 15; - - cells = cells.stream().map(cell -> { - var ring = DiscreteCurveEvolution.process((LineString) cell.getBoundary(), dceCallback); - return ring; - }).toList(); - } + // Produces rather dense output, so simplify using conservative DCE relevance + final DCETerminationCallback dceCallback = (currentVertex, relevance, verticesRemaining) -> relevance >= 10; + cells = cells.stream().map(cell -> { + var ring = DiscreteCurveEvolution.process((LineString) cell.getBoundary(), dceCallback); + return ring; + }).toList(); var awvd = toPShape(cells); PGS_Conversion.setAllFillColor(awvd, Colors.WHITE); diff --git a/src/main/java/micycle/pgs/commons/AdditivelyWeightedVoronoi.java b/src/main/java/micycle/pgs/commons/AdditivelyWeightedVoronoi.java index da396d13..8082607d 100644 --- a/src/main/java/micycle/pgs/commons/AdditivelyWeightedVoronoi.java +++ b/src/main/java/micycle/pgs/commons/AdditivelyWeightedVoronoi.java @@ -2,494 +2,1120 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; -import java.util.Objects; +import java.util.Map; import java.util.stream.IntStream; 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.index.ItemVisitor; +import org.locationtech.jts.index.hprtree.HPRtree; import net.jafama.FastMath; /** - * Approximates 2D additively-weighted Voronoi cells (Apollonius diagram) for - * input sites encoded as {@link Coordinate}s: (x,y) is the site center and z is - * the additive weight (radius). + * Computes 2D additively-weighted Voronoi cells (the Apollonius diagram) + * for input sites encoded as {@link Coordinate}s: (x,y) is the site centre and + * z is the additive weight (radius). The distance to site i is + * {@code d_i(p) = |p - p_i| - w_i}; cell i is the locus where no other + * site is closer. * - *

- * Each cell boundary is represented in polar form around the site center as a - * "lifted radius" function t(θ). For each direction θ, competitors induce a - * candidate radius; the boundary is the lower envelope (minimum) over all - * competitors. The envelope is approximated by angular sampling with adaptive - * refinement. This idea is based on: Hans-Martin Will, “Practical and - * efficient computation of additively weighted Voronoi cells for applications - * in molecular biology”, 1998. + *

Formulation

* - *

- * Cells are bounded to a rectangular clip envelope. For each sampled direction, - * the radius is upper-bounded by the ray's exit distance from the rectangle. - * This produces a polygon that is already confined to the clip rectangle. + * Around site i, a competitor j bounds the cell in direction + * {@code u(t) = (cos t, sin t)} at radius * - *

- * Dominated (empty) cells are omitted from the returned list. + *

+ * t_j(t) = N / (2 (dw + <u, c>)), c = p_j - p_i, dw = w_j - w_i, N = |c|² - dw²
+ * 
+ * + * and the cell boundary is the lower envelope {@code min_j t_j}. Rather than + * sampling that envelope, this implementation works with the reciprocal radius: + * + *
+ * rho_j(t) = 1 / t_j(t) = (dw + c_x cos t + c_y sin t) / (N/2)
+ * 
+ * + * which is affine in (cos t, sin t), hence a sinusoid. The lower envelope of + * conics becomes an upper envelope of sinusoids, + * {@code t(t) = 1 / max_j rho_j(t)}. * - *

- * Notes: *

    - *
  • For best results, the clip envelope should contain all sites.
  • - *
  • This implementation returns polygon shells only (no holes).
  • + *
  • Two sinusoids cross at most twice, so every envelope breakpoint is + * available in closed form and nothing is discovered by sampling.
  • + *
  • A clip half-plane contributes the same kind of reciprocal-radius + * constraint.
  • + *
  • For a non-dominated pair N is positive, and non-positive reciprocal + * radius is naturally ignored by the upper envelope.
  • *
- * + * + *

Structure

+ * + *
    + *
  1. Coincident sites are deduplicated.
  2. + *
  3. An R-tree over disk bounding boxes supplies a certified superset of the + * competitors that can bind each cell.
  4. + *
  5. The exact sinusoidal envelope determines the symbolic boundary.
  6. + *
  7. Vertices are keyed by cyclically ordered triples of constraints.
  8. + *
  9. Edges are keyed by their curve and symbolic endpoints, globally interned, + * and flattened once.
  10. + *
  11. Incident cells consume the same flattened edge in opposite directions, + * making shared polygon boundaries watertight by construction.
  12. + *
+ * + *

+ * JTS stores the resulting polygons. If sites lie outside a supplied clipping + * envelope, cells are first built in an enclosing polar box and JTS performs + * the final exceptional trim against the requested envelope. + * * @author Michael Carleton */ public class AdditivelyWeightedVoronoi { - private static class AV2DOptions { - - /** - * Adaptive sampling error tolerance (squared). Smaller values yield smoother - * curves at the cost of more vertices. - */ - public double errorToleranceSq = 0.01 * 0.01; - - /** Maximum recursion depth for adaptive sampling. */ - public int maxRecursionDepth = 10; - - /** Scale factor for the default clip envelope when none is provided. */ - public double boundsScale = 1.5; - - /** - * Small epsilon for denominator/degeneracy checks. This is an absolute epsilon; - * for very large coordinate magnitudes, consider increasing it. - */ - public double eps = 1e-12; - } + private static final int CLIP_XMAX = -1, CLIP_XMIN = -2, CLIP_YMAX = -3, CLIP_YMIN = -4; + private static final double TWO_PI = 2 * Math.PI; + private static final double ANG_EPS = 1e-12; + private static final double MAX_PATCH_SPAN = Math.PI / 3; + private static final double BOUNDS_SCALE = 1.5; private final GeometryFactory gf; - private final AV2DOptions opt; + private final double tol; /** - * Creates an instance using the given geometry factory and an adaptive sampling - * tolerance. - * * @param gf geometry factory used to create output polygons - * @param errorTolerance maximum allowed deviation (in coordinate units) used by - * the adaptive sampler; smaller values produce smoother - * cell boundaries at the cost of more vertices + * @param errorTolerance maximum sagitta of each emitted conic chord */ public AdditivelyWeightedVoronoi(GeometryFactory gf, double errorTolerance) { + if (gf == null) { + throw new IllegalArgumentException("GeometryFactory must not be null"); + } this.gf = gf; - this.opt = new AV2DOptions(); - this.opt.errorToleranceSq = errorTolerance * errorTolerance; + tol = Double.isFinite(errorTolerance) && errorTolerance > 0 ? errorTolerance : 1e-3; } /** - * Computes clipped polygonal approximations of additively-weighted Voronoi - * cells. - * - *

- * Input sites are provided as {@link Coordinate}s where (x,y) is the site - * location and z is its additive weight. Cells are bounded to the supplied - * {@code bounds}, or to a default envelope derived from the data if - * {@code bounds} is {@code null}. - * - *

- * Dominated (empty) cells are omitted from the returned list. Each returned - * polygon has its {@linkplain Polygon#getUserData() userData} set to the - * corresponding input site index. + * Computes clipped polygonal approximations of the weighted Voronoi cells. * - * @param sites input sites; {@code (x,y)} is the site center and {@code z} is - * the additive weight (non-finite z is treated as 0) - * @param bounds optional clip envelope; if {@code null}, a default is computed - * from the input extent and weight magnitudes - * @return list of polygons for non-empty cells; order is unspecified due to - * parallel execution + * @param sites input sites; (x,y) is the centre and z the additive weight + * @param bounds optional clipping envelope + * @return non-empty cells in ascending site order, carrying the site index as + * user data */ public List computeCells(List sites, Envelope bounds) { if (sites == null || sites.isEmpty()) { return List.of(); } + return new Ctx(sites, bounds).compute(); + } + + // ------------------------------------------------------------------ + // per-invocation state + // ------------------------------------------------------------------ + + private final class Ctx { + + final int n; + final double[] x, y, w; + final boolean[] dead; + final Envelope clip; + final double bMinX, bMaxX, bMinY, bMaxY; + final boolean trim; + final double distEps, seedRadius, boxSpan; + final DiskIndex index; + + Ctx(List sites, Envelope bounds) { + n = sites.size(); + x = new double[n]; + y = new double[n]; + w = new double[n]; + + Envelope dataEnv = new Envelope(); + double maxAbsW = 0, maxAbsC = 0; + + for (int i = 0; i < n; i++) { + Coordinate c = sites.get(i); + if (c == null || !Double.isFinite(c.x) || !Double.isFinite(c.y)) { + throw new IllegalArgumentException("Site " + i + " has non-finite coordinates"); + } + x[i] = c.x; + y[i] = c.y; + w[i] = Double.isFinite(c.getZ()) ? c.getZ() : 0; + maxAbsW = Math.max(maxAbsW, Math.abs(w[i])); + maxAbsC = Math.max(maxAbsC, Math.max(Math.abs(x[i]), Math.abs(y[i]))); + dataEnv.expandToInclude(x[i], y[i]); + } + + clip = bounds == null ? defaultClip(dataEnv, maxAbsW) : new Envelope(bounds); + if (clip.isNull() || !(clip.getWidth() > 0) || !(clip.getHeight() > 0) || !Double.isFinite(clip.getMinX()) || !Double.isFinite(clip.getMaxX()) + || !Double.isFinite(clip.getMinY()) || !Double.isFinite(clip.getMaxY())) { + throw new IllegalArgumentException("Bounds must be finite and non-empty"); + } + + double scale = Math.max(Math.max(clip.getWidth(), clip.getHeight()), maxAbsC); + if (!(scale > 0) || !Double.isFinite(scale)) { + scale = 1; + } + distEps = 1e-12 * scale; + + /* + * The polar form requires every site strictly inside its box. When the + * requested clip does not provide that, use an enclosing box and trim the + * finished cells afterward. + */ + double margin = 1e-6 * scale + tol; + boolean outside = false; + for (int i = 0; i < n && !outside; i++) { + outside = x[i] <= clip.getMinX() + margin || x[i] >= clip.getMaxX() - margin || y[i] <= clip.getMinY() + margin + || y[i] >= clip.getMaxY() - margin; + } + + Envelope box = new Envelope(clip); + if (outside) { + box.expandToInclude(dataEnv); + box.expandBy(margin); + } + + trim = outside; + bMinX = box.getMinX(); + bMaxX = box.getMaxX(); + bMinY = box.getMinY(); + bMaxY = box.getMaxY(); + boxSpan = box.getWidth() + box.getHeight(); + seedRadius = 1.5 * Math.sqrt(box.getWidth() * box.getHeight() / Math.max(1, n)); + index = new DiskIndex(x, y, w); + + /* + * Keep the heaviest coincident site, using the lowest index on ties. + */ + dead = new boolean[n]; + IntStream.range(0, n).parallel().forEach(i -> { + IntList near = new IntList(8); + index.query(x[i], y[i], 0, near); + for (int t = 0; t < near.size; t++) { + int j = near.get(t); + if (j != i && x[j] == x[i] && y[j] == y[i] && (w[j] > w[i] || w[j] == w[i] && j < i)) { + dead[i] = true; + return; + } + } + }); + } + + /** + * Builds symbolic cells in parallel, interns their shared geometry, flattens + * each distinct edge once, then assembles the polygons. + */ + List compute() { + SCell[] cells = IntStream.range(0, n).parallel().mapToObj(this::symbolicCell).toArray(SCell[]::new); + Graph graph = new Graph(); + for (SCell cell : cells) { + if (cell != null) { + graph.add(cell); + } + } + graph.validate(); + graph.flatten(); + + return IntStream.range(0, n).parallel().mapToObj(i -> polygons(cells[i], graph)).flatMap(List::stream).toList(); + } + + private final class Graph { + + final Map vertices = new HashMap<>(); + final Map edges = new HashMap<>(); + + void add(SCell cell) { + for (Use use : cell.boundary) { + EKey key = use.key; + double[] p0 = vertices.computeIfAbsent(key.v0, Ctx.this::vertex); + double[] p1 = vertices.computeIfAbsent(key.v1, Ctx.this::vertex); + Edge edge = edges.computeIfAbsent(key, k -> new Edge(k, p0, p1)); + edge.uses++; + if (use.forward) { + edge.forwardUses++; + } + } + } + + void validate() { + for (Edge edge : edges.values()) { + if (edge.key.isClip()) { + if (edge.uses != 1) { + throw failure("Clip edge has " + edge.uses + " uses: " + edge.key); + } + } else if (edge.uses != 2 || edge.forwardUses != 1) { + throw failure("Inconsistent shared edge: " + edge.key); + } + } + } + + void flatten() { + edges.values().parallelStream().forEach(e -> e.line = flattenEdge(e)); + } + } + + // -------------------------------------------------------------- + // certified candidates and sinusoidal envelope + // -------------------------------------------------------------- + + private Cell buildCell(int i) { + IntList ids = new IntList(16); + seed(i, ids); + + for (int round = 0; round < 64; round++) { + Constraint[] constraints = constraints(i, ids); + if (constraints == null) { + return null; + } + + Arrays.sort(constraints); + Cell cell = envelope(constraints); + double radius = cell.maxRadius(); + + IntList query = new IntList(8); + query(i, radius, query); + boolean grew = false; + + for (int t = 0; t < query.size; t++) { + int j = query.get(t); + if (!ids.contains(j)) { + ids.add(j); + grew = true; + } + } + if (!grew) { + return cell; + } + } + throw failure("Candidate certification did not converge for site " + i); + } - final int n = sites.size(); - final double[] x = new double[n]; - final double[] y = new double[n]; - final double[] w = new double[n]; + private Constraint[] constraints(int i, IntList ids) { + List out = new ArrayList<>(ids.size + 4); + out.add(clipConstraint(i, CLIP_XMAX)); + out.add(clipConstraint(i, CLIP_XMIN)); + out.add(clipConstraint(i, CLIP_YMAX)); + out.add(clipConstraint(i, CLIP_YMIN)); + + for (int t = 0; t < ids.size; t++) { + int j = ids.get(t); + if (j == i || dead[j]) { + continue; + } - final Envelope dataEnv = new Envelope(); - double maxAbsW = 0.0; + double dx = x[j] - x[i], dy = y[j] - y[i], dw = w[j] - w[i]; + double d = FastMath.hypot(dx, dy); + if (dw >= d - distEps) { + return null; + } - for (int i = 0; i < n; i++) { - Coordinate c = sites.get(i); - x[i] = c.getX(); - y[i] = c.getY(); - double zi = c.getZ(); - w[i] = Double.isFinite(zi) ? zi : 0.0; - maxAbsW = Math.max(maxAbsW, Math.abs(w[i])); - dataEnv.expandToInclude(x[i], y[i]); + double den = 0.5 * (d * d - dw * dw); + if (den > 0) { + out.add(new Constraint(j, dw, dx, dy, den)); + } + } + return out.toArray(Constraint[]::new); } - final Envelope clipEnv = bounds != null ? new Envelope(bounds) : defaultClipEnvelope(dataEnv, maxAbsW); + /** + * Opens the certified loop with a cheap radius guess. Nothing here needs to be + * complete; buildCell certifies the final candidate set. + */ + private void seed(int i, IntList out) { + for (double radius = seedRadius;; radius *= 2) { + out.clear(); + index.query(x[i], y[i], radius, out); + + int usable = 0; + for (int t = 0; t < out.size; t++) { + int j = out.get(t); + if (j != i && !dead[j]) { + usable++; + } + } + if (usable >= 4) { + return; + } + if (!Double.isFinite(radius) || radius >= boxSpan) { + out.clear(); + index.all(out); + return; + } + } + } - final double minX = clipEnv.getMinX(), maxX = clipEnv.getMaxX(); - final double minY = clipEnv.getMinY(), maxY = clipEnv.getMaxY(); + /** + * All j that can bind within radius r: {@code (d - w_j + w_i)/2 < r}. + */ + private void query(int i, double radius, IntList out) { + IntList raw = new IntList(16); + double expand = Double.isFinite(radius) ? Math.max(0, 2 * radius - w[i]) : Double.POSITIVE_INFINITY; + + if (Double.isFinite(expand)) { + index.query(x[i], y[i], expand, raw); + } else { + index.all(raw); + } - final Precompute pc = precomputePairs(x, y, w); + for (int t = 0; t < raw.size; t++) { + int j = raw.get(t); + if (j == i || dead[j]) { + continue; + } + double d = FastMath.hypot(x[j] - x[i], y[j] - y[i]); + if (0.5 * (d - w[j] + w[i]) < radius) { + out.add(j); + } + } + } - List out = IntStream.range(0, n).parallel().mapToObj(i -> { - if (pc.empty[i]) { + // -------------------------------------------------------------- + // symbolic topology + // -------------------------------------------------------------- + + private SCell symbolicCell(int i) { + if (dead[i]) { return null; } - // per-cell prune bound: farthest corner distance from site to clip rectangle - final double Rsite = radiusToCoverEnvelopeFromSite(clipEnv, x[i], y[i]); + Cell cell = buildCell(i); + if (cell == null) { + return null; + } + if (cell.k < 2) { + throw failure("Degenerate envelope for site " + i); + } - Polygon approx = approximateCellPolygon(i, x, y, w, pc.candidates[i], Rsite, minX, maxX, minY, maxY); - approx.setUserData(i); - return approx; - }).filter(Objects::nonNull).toList(); + List boundary = new ArrayList<>(cell.k); + for (int s = 0; s < cell.k; s++) { + int prev = cell.cs[cell.owner[(s - 1 + cell.k) % cell.k]].id; + int owner = cell.cs[cell.owner[s]].id; + int next = cell.cs[cell.owner[(s + 1) % cell.k]].id; - return out; - } + VKey from = VKey.of(i, prev, owner); + VKey to = VKey.of(i, owner, next); + if (from.equals(to)) { + continue; + } - private static double radiusToCoverEnvelopeFromSite(Envelope env, double xi, double yi) { - double minX = env.getMinX(), maxX = env.getMaxX(); - double minY = env.getMinY(), maxY = env.getMaxY(); + EKey key = EKey.of(i, owner, from, to); + boundary.add(new Use(key, key.v0.equals(from))); + } - double d1 = FastMath.hypot(minX - xi, minY - yi); - double d2 = FastMath.hypot(minX - xi, maxY - yi); - double d3 = FastMath.hypot(maxX - xi, minY - yi); - double d4 = FastMath.hypot(maxX - xi, maxY - yi); + if (boundary.size() < 2) { + throw failure("Degenerate symbolic boundary for site " + i); + } + return new SCell(i, List.copyOf(boundary)); + } - double R = Math.max(Math.max(d1, d2), Math.max(d3, d4)); - return R * 1.0000001 + 1e-9; - } + /** + * Solves one cyclically identified vertex in the frame of its lowest numbered + * incident site. + */ + private double[] vertex(VKey key) { + int site = key.a, previous = key.b, current = key.c; + Constraint p = constraintOf(site, previous), q = constraintOf(site, current); + if (p == null || q == null) { + throw failure("Invalid vertex " + key); + } + + double dA = q.a * p.den - p.a * q.den; + double dB = q.b * p.den - p.b * q.den; + double dC = q.c * p.den - p.c * q.den; + double magnitude = FastMath.hypot(dB, dC); - private Polygon approximateCellPolygon(int i, double[] x, double[] y, double[] w, int[] candidateIndices, double Rsite, double minX, double maxX, - double minY, double maxY) { - final double xi = x[i], yi = y[i], wi = w[i]; + if (!(magnitude > 0) || !Double.isFinite(magnitude)) { + throw failure("Unresolved vertex " + key); + } + + double ratio = Math.max(-1, Math.min(1, -dA / magnitude)); + double theta = FastMath.atan2(dC, dB) - FastMath.acos(ratio); + double cos = FastMath.cos(theta), sin = FastMath.sin(theta); + double num = p.num(cos, sin); - // no competitors => within the clip region the cell is the whole rectangle - if (candidateIndices == null || candidateIndices.length == 0) { - return createClipRectanglePolygon(minX, maxX, minY, maxY); + if (!(num > 0)) { + throw failure("Non-positive vertex radius at " + key); + } + + double radius = p.den / num; + double px = x[site] + radius * cos, py = y[site] + radius * sin; + if (!(radius > 0) || !Double.isFinite(px) || !Double.isFinite(py)) { + throw failure("Non-finite vertex " + key); + } + return new double[] { px, py }; } - // sort candidates by a metric monotone with the lower bound on bisector - // distance - Candidate[] sortedCands = new Candidate[candidateIndices.length]; - for (int k = 0; k < candidateIndices.length; k++) { - int j = candidateIndices[k]; - double d = FastMath.hypot(x[j] - xi, y[j] - yi); - sortedCands[k] = new Candidate(j, d, d - w[j]); + /** + * Flattens one globally interned edge in canonical endpoint order. + */ + private List flattenEdge(Edge edge) { + if (edge.key.isClip() || w[edge.key.a] == w[edge.key.b]) { + return List.of(coord(edge.p0), coord(edge.p1)); + } + + List arc = canonicalArc(edge.key.a, edge.key.b, edge.p0, edge.p1); + if (arc == null || arc.size() < 2) { + throw failure("Could not construct edge " + edge.key); + } + + List line = new ArrayList<>(arc.size()); + for (double[] p : arc) { + if (!finite(p)) { + throw failure("Non-finite point on edge " + edge.key); + } + line.add(coord(p)); + } + + /* + * Force the globally interned endpoint values, even if an equivalent + * parameterisation path differs in its last bit. + */ + line.set(0, coord(edge.p0)); + line.set(line.size() - 1, coord(edge.p1)); + return List.copyOf(line); } - Arrays.sort(sortedCands); - // build compact primitive arrays and prune competitors that cannot matter - // inside the clip - int cN = sortedCands.length; - double[] cxTmp = new double[cN]; - double[] cyTmp = new double[cN]; - double[] rTmp = new double[cN]; - double[] numTmp = new double[cN]; // |c|^2 - r^2 - double[] minBDTmp = new double[cN]; // lower bound on distance to bisector + // -------------------------------------------------------------- + // polygon assembly + // -------------------------------------------------------------- - int m = 0; - for (int t = 0; t < cN; t++) { - int j = sortedCands[t].index; + private List polygons(SCell cell, Graph graph) { + if (cell == null) { + return List.of(); + } - double dx = x[j] - xi; - double dy = y[j] - yi; - double d = sortedCands[t].dist; - double rr = w[j] - wi; + List ring = new ArrayList<>(4 * cell.boundary.size() + 1); + for (Use use : cell.boundary) { + Edge edge = graph.edges.get(use.key); + if (edge == null || edge.line == null) { + throw failure("Missing flattened edge " + use.key); + } + append(ring, edge.line, use.forward); + } - double minBisectorDist = (d - rr) * 0.5; + if (ring.size() < 4 || !ring.get(0).equals2D(ring.get(ring.size() - 1))) { + throw failure("Open or degenerate ring for site " + cell.site); + } + + Polygon polygon = gf.createPolygon(ring.toArray(Coordinate[]::new)); + if (!trim) { + polygon.setUserData(cell.site); + return List.of(polygon); + } - if (minBisectorDist >= Rsite) { - continue; + /* + * Compatibility path for sites outside the requested clip. Shared diagram edges + * were still flattened only once; JTS performs only this final exceptional + * overlay. + */ + Geometry clipped = polygon.intersection(gf.toGeometry(clip)); + List out = new ArrayList<>(); + for (int i = 0; i < clipped.getNumGeometries(); i++) { + Geometry geometry = clipped.getGeometryN(i); + if (geometry instanceof Polygon p && !p.isEmpty()) { + p.setUserData(cell.site); + out.add(p); + } } + return out; + } - cxTmp[m] = dx; - cyTmp[m] = dy; - rTmp[m] = rr; - numTmp[m] = (dx * dx + dy * dy) - (rr * rr); - minBDTmp[m] = minBisectorDist; - m++; + private void append(List ring, List edge, boolean forward) { + if (forward) { + int from = ring.isEmpty() ? 0 : 1; + if (!ring.isEmpty() && !ring.get(ring.size() - 1).equals2D(edge.get(0))) { + throw failure("Disconnected edge"); + } + for (int i = from; i < edge.size(); i++) { + ring.add(new Coordinate(edge.get(i))); + } + } else { + int from = ring.isEmpty() ? edge.size() - 1 : edge.size() - 2; + if (!ring.isEmpty() && !ring.get(ring.size() - 1).equals2D(edge.get(edge.size() - 1))) { + throw failure("Disconnected reversed edge"); + } + for (int i = from; i >= 0; i--) { + ring.add(new Coordinate(edge.get(i))); + } + } } - if (m == 0) { - return createClipRectanglePolygon(minX, maxX, minY, maxY); + // -------------------------------------------------------------- + // conic construction + // -------------------------------------------------------------- + + /** + * Flattens the hyperbolic a|b arc from p0 to p2 as exact rational quadratic + * patches. + */ + private List canonicalArc(int a, int b, double[] p0, double[] p2) { + Constraint constraint = siteConstraint(a, b); + if (constraint == null) { + return null; + } + + double xa = x[a], ya = y[a]; + double t0 = FastMath.atan2(p0[1] - ya, p0[0] - xa); + double t2 = FastMath.atan2(p2[1] - ya, p2[0] - xa); + + /* + * rho is least at gap and non-positive there, so the correct branch is the + * sweep that excludes it. + */ + double gap = FastMath.atan2(constraint.c, constraint.b) + Math.PI; + double ccw = wrap(t2 - t0); + boolean fromP0 = !(wrap(gap - t0) < ccw); + double start = fromP0 ? t0 : t2; + double span = fromP0 ? ccw : TWO_PI - ccw; + + if (!(span > 1e-13) || !Double.isFinite(span)) { + return null; + } + + int parts = Math.max(1, (int) Math.ceil(span / MAX_PATCH_SPAN)); + double[][] node = new double[parts + 1][]; + node[0] = fromP0 ? p0 : p2; + node[parts] = fromP0 ? p2 : p0; + + for (int i = 1; i < parts; i++) { + node[i] = onCurve(a, constraint, start + span * i / parts); + if (node[i] == null) { + return null; + } + } + + List out = new ArrayList<>(4 * parts + 4); + out.add(node[0]); + + for (int i = 0; i < parts; i++) { + double[] shoulder = onCurve(a, constraint, start + span * (i + 0.5) / parts); + if (shoulder == null) { + return null; + } + patch(a, b, node[i], node[i + 1], shoulder, out); + } + + if (!fromP0) { + Collections.reverse(out); + } + return out; } - double[] cx = Arrays.copyOf(cxTmp, m); - double[] cy = Arrays.copyOf(cyTmp, m); - double[] r = Arrays.copyOf(rTmp, m); - double[] num = Arrays.copyOf(numTmp, m); - double[] minBisectorDist = Arrays.copyOf(minBDTmp, m); + /** + * Constructs one rational quadratic from its endpoints, end tangents and one + * interior point, then flattens it. + */ + private void patch(int a, int b, double[] q0, double[] q2, double[] s, List out) { + double[] t0 = tangent(a, b, q0), t2 = tangent(a, b, q2); + double[] p1 = t0 == null || t2 == null ? null : meet(q0, t0, q2, t2); + if (p1 == null) { + throw failure("Could not construct conic control point for " + a + "|" + b); + } + + double alpha = cross(p1[0] - s[0], p1[1] - s[1], q2[0] - s[0], q2[1] - s[1]); + double beta = cross(q2[0] - s[0], q2[1] - s[1], q0[0] - s[0], q0[1] - s[1]); + double gamma = cross(q0[0] - s[0], q0[1] - s[1], p1[0] - s[0], p1[1] - s[1]); - // adaptive sampling with carry-forward sector endpoints - List pts = new ArrayList<>(); + boolean ag = alpha > 0 && gamma > 0 || alpha < 0 && gamma < 0; + boolean ab = alpha > 0 && beta > 0 || alpha < 0 && beta < 0; + double scale = Math.max(Math.abs(alpha), Math.max(Math.abs(beta), Math.abs(gamma))); - int initialSectors = 8; - double dt = 2.0 * Math.PI / initialSectors; + if (!ag || !ab || !(scale > 0) || !Double.isFinite(scale)) { + throw failure("Invalid conic barycentrics for " + a + "|" + b); + } - double tPrev = 0.0; - double rPrev = computeRadius(cx, cy, r, num, minBisectorDist, xi, yi, minX, maxX, minY, maxY, tPrev); + alpha /= scale; + beta /= scale; + gamma /= scale; + double weight = Math.abs(beta) / (2 * Math.sqrt(alpha * gamma)); - { - double x1 = rPrev * FastMath.cos(tPrev); - double y1 = rPrev * FastMath.sin(tPrev); - pts.add(new Coordinate(xi + x1, yi + y1)); + if (!(weight > 0) || !Double.isFinite(weight)) { + throw failure("Invalid conic weight for " + a + "|" + b); + } + flatten(q0, p1, q2, weight, tol, out, 0); } - for (int k = 0; k < initialSectors; k++) { - double tNext = (k + 1) * dt; - double rNext = computeRadius(cx, cy, r, num, minBisectorDist, xi, yi, minX, maxX, minY, maxY, tNext); + private double[] tangent(int a, int b, double[] p) { + double ax = p[0] - x[a], ay = p[1] - y[a]; + double bx = p[0] - x[b], by = p[1] - y[b]; + double la = FastMath.hypot(ax, ay), lb = FastMath.hypot(bx, by); - double x1 = rPrev * FastMath.cos(tPrev); - double y1 = rPrev * FastMath.sin(tPrev); - double x2 = rNext * FastMath.cos(tNext); - double y2 = rNext * FastMath.sin(tNext); + if (!(la > 0) || !(lb > 0)) { + return null; + } - sampleRecursively(xi, yi, cx, cy, r, num, minBisectorDist, minX, maxX, minY, maxY, tPrev, rPrev, x1, y1, tNext, rNext, x2, y2, pts, 0); + double tx = ax / la + bx / lb, ty = ay / la + by / lb; + return tx * tx + ty * ty > 0 && Double.isFinite(tx) && Double.isFinite(ty) ? new double[] { tx, ty } : null; + } - pts.add(new Coordinate(xi + x2, yi + y2)); + private double[] onCurve(int site, Constraint constraint, double theta) { + double cos = FastMath.cos(theta), sin = FastMath.sin(theta); + double num = constraint.num(cos, sin); + if (!(num > 0)) { + return null; + } - tPrev = tNext; - rPrev = rNext; + double radius = constraint.den / num; + double px = x[site] + radius * cos, py = y[site] + radius * sin; + return radius > 0 && Double.isFinite(px) && Double.isFinite(py) ? new double[] { px, py } : null; } - pts = removeNearDuplicates(pts, 1e-6); - if (pts.size() < 3) { - return createClipRectanglePolygon(minX, maxX, minY, maxY); + private Constraint constraintOf(int frame, int id) { + return id >= 0 ? siteConstraint(frame, id) : clipConstraint(frame, id); } - if (!pts.get(0).equals2D(pts.get(pts.size() - 1))) { - pts.add(new Coordinate(pts.get(0))); + private Constraint siteConstraint(int i, int j) { + double dx = x[j] - x[i], dy = y[j] - y[i], dw = w[j] - w[i]; + double den = 0.5 * (dx * dx + dy * dy - dw * dw); + return den > 0 ? new Constraint(j, dw, dx, dy, den) : null; } - return gf.createPolygon(pts.toArray(new Coordinate[0])); + private Constraint clipConstraint(int i, int id) { + return switch (id) { + case CLIP_XMAX -> new Constraint(id, 0, 1, 0, bMaxX - x[i]); + case CLIP_XMIN -> new Constraint(id, 0, -1, 0, x[i] - bMinX); + case CLIP_YMAX -> new Constraint(id, 0, 0, 1, bMaxY - y[i]); + case CLIP_YMIN -> new Constraint(id, 0, 0, -1, y[i] - bMinY); + default -> throw new IllegalArgumentException("Unknown clip id " + id); + }; + } } - private Polygon createClipRectanglePolygon(double minX, double maxX, double minY, double maxY) { - Coordinate[] pts = new Coordinate[] { new Coordinate(minX, minY), new Coordinate(maxX, minY), new Coordinate(maxX, maxY), new Coordinate(minX, maxY), - new Coordinate(minX, minY) }; - return gf.createPolygon(gf.createLinearRing(pts)); - } + // ------------------------------------------------------------------ + // symbolic keys and shared edges + // ------------------------------------------------------------------ - private void sampleRecursively(double xi, double yi, double[] cx, double[] cy, double[] r, double[] num, double[] minBisectorDist, double minX, double maxX, - double minY, double maxY, double t1, double r1, double x1, double y1, double t2, double r2, double x2, double y2, List resultPts, - int depth) { + /** + * Cyclically ordered constraints meeting at a vertex. Rotation is + * canonicalised; reversal is not, since it distinguishes the two possible + * Apollonius vertices of the same triple. + */ + private record VKey(int a, int b, int c) implements Comparable { + + static VKey of(int a, int b, int c) { + int[] v = { a, b, c }; + int first = 0; + for (int i = 1; i < 3; i++) { + if (v[i] >= 0 && (v[first] < 0 || v[i] < v[first])) { + first = i; + } + } + return new VKey(v[first], v[(first + 1) % 3], v[(first + 2) % 3]); + } - if (depth >= opt.maxRecursionDepth) { - return; + @Override + public int compareTo(VKey o) { + int c0 = Integer.compare(a, o.a); + if (c0 == 0) { + c0 = Integer.compare(b, o.b); + } + return c0 == 0 ? Integer.compare(c, o.c) : c0; } + } - final double tMid = 0.5 * (t1 + t2); - final double rMidActual = computeRadius(cx, cy, r, num, minBisectorDist, xi, yi, minX, maxX, minY, maxY, tMid); + /** + * A curve and its symbolically ordered endpoints. For a site bisector a and b + * are the ordered site ids; for a clip edge both equal the negative clip id. + */ + private record EKey(int a, int b, VKey v0, VKey v1) { - final double chordMx = 0.5 * (x1 + x2); - final double chordMy = 0.5 * (y1 + y2); + static EKey of(int site, int owner, VKey from, VKey to) { + int a = owner < 0 ? owner : Math.min(site, owner); + int b = owner < 0 ? owner : Math.max(site, owner); + return from.compareTo(to) <= 0 ? new EKey(a, b, from, to) : new EKey(a, b, to, from); + } - final double cosMid = FastMath.cos(tMid); - final double sinMid = FastMath.sin(tMid); - final double surfMx = rMidActual * cosMid; - final double surfMy = rMidActual * sinMid; + boolean isClip() { + return a < 0; + } + } + + private record Use(EKey key, boolean forward) { + } - final double dx = chordMx - surfMx; - final double dy = chordMy - surfMy; - final double distSq = dx * dx + dy * dy; + private record SCell(int site, List boundary) { + } - if (distSq > opt.errorToleranceSq) { - sampleRecursively(xi, yi, cx, cy, r, num, minBisectorDist, minX, maxX, minY, maxY, t1, r1, x1, y1, tMid, rMidActual, surfMx, surfMy, resultPts, - depth + 1); + private static final class Edge { - resultPts.add(new Coordinate(xi + surfMx, yi + surfMy)); + final EKey key; + final double[] p0, p1; + int uses, forwardUses; + List line; - sampleRecursively(xi, yi, cx, cy, r, num, minBisectorDist, minX, maxX, minY, maxY, tMid, rMidActual, surfMx, surfMy, t2, r2, x2, y2, resultPts, - depth + 1); + Edge(EKey key, double[] p0, double[] p1) { + this.key = key; + this.p0 = p0; + this.p1 = p1; } } - private double computeRadius(double[] cx, double[] cy, double[] r, double[] num, double[] minBisectorDist, double xi, double yi, double minX, double maxX, - double minY, double maxY, double theta) { + // ------------------------------------------------------------------ + // upper envelope of sinusoids + // ------------------------------------------------------------------ - final double px = FastMath.cos(theta); - final double py = FastMath.sin(theta); + private static Cell envelope(Constraint[] constraints) { + Cell envelope = new Cell(constraints); + envelope.k = 1; + envelope.owner[0] = 0; + envelope.start[0] = 0; + double radius = envelope.maxRadius(); - // initialize with the ray's exit distance from the clip rectangle (tight upper - // bound) - double tMin = rayBoxExitDistance(xi, yi, px, py, minX, maxX, minY, maxY); - if (!(tMin > 0.0) || !Double.isFinite(tMin)) { - tMin = Double.POSITIVE_INFINITY; - } + IntList owners = new IntList(16); + DoubleList starts = new DoubleList(16); + double[] roots = new double[2], split = new double[2]; - for (int t = 0; t < cx.length; t++) { - // candidates are sorted by a lower bound; stop once the bound exceeds the - // current best - if (minBisectorDist[t] > tMin) { + for (int f = 1; f < constraints.length; f++) { + if (constraints[f].minT >= radius) { break; } + envelope.insert(f, owners, starts, roots, split); + radius = envelope.maxRadius(); + } + return envelope; + } - final double denomInner = r[t] + (px * cx[t] + py * cy[t]); - if (denomInner <= opt.eps) { - continue; - } + private static int crossings(Constraint f, Constraint g, double[] out) { + double dA = f.a * g.den - g.a * f.den; + double dB = f.b * g.den - g.b * f.den; + double dC = f.c * g.den - g.c * f.den; + double radius = FastMath.hypot(dB, dC); - final double tt = num[t] / (2.0 * denomInner); - if (tt > 0.0 && tt < tMin) { - tMin = tt; - } + if (!(radius > Math.abs(dA))) { + return 0; } - return tMin; + double phi = FastMath.atan2(dC, dB); + double halfWidth = FastMath.acos(-dA / radius); + out[0] = phi - halfWidth; + out[1] = phi + halfWidth; + return 2; } - private static double rayBoxExitDistance(double ox, double oy, double dx, double dy, double minX, double maxX, double minY, double maxY) { - double tEnter = 0.0; - double tExit = Double.POSITIVE_INFINITY; + private static final class Cell { - if (dx == 0.0) { - if (ox < minX || ox > maxX) { - return Double.NaN; - } - } else { - double tx1 = (minX - ox) / dx; - double tx2 = (maxX - ox) / dx; - if (tx1 > tx2) { - double tmp = tx1; - tx1 = tx2; - tx2 = tmp; + final Constraint[] cs; + int[] owner; + double[] start; + int k; + + Cell(Constraint[] constraints) { + cs = constraints; + owner = new int[4 * constraints.length + 8]; + start = new double[owner.length]; + } + + double end(int s) { + return s + 1 < k ? start[s + 1] : start[0] + TWO_PI; + } + + void insert(int f, IntList newOwners, DoubleList newStarts, double[] roots, double[] split) { + newOwners.clear(); + newStarts.clear(); + Constraint candidate = cs[f]; + + for (int s = 0; s < k; s++) { + int incumbentId = owner[s]; + Constraint incumbent = cs[incumbentId]; + double a0 = start[s], a1 = end(s); + int rootCount = crossings(candidate, incumbent, roots); + int splitCount = 0; + + for (int t = 0; t < rootCount; t++) { + double root = a0 + wrap(roots[t] - a0); + if (root > a0 + ANG_EPS && root < a1 - ANG_EPS) { + split[splitCount++] = root; + } + } + if (splitCount == 2 && split[0] > split[1]) { + double tmp = split[0]; + split[0] = split[1]; + split[1] = tmp; + } + + double low = a0; + for (int t = 0; t <= splitCount; t++) { + double high = t < splitCount ? split[t] : a1; + double middle = 0.5 * (low + high); + double cos = FastMath.cos(middle), sin = FastMath.sin(middle); + int winner = candidate.num(cos, sin) * incumbent.den > incumbent.num(cos, sin) * candidate.den ? f : incumbentId; + + if (newOwners.size == 0 || newOwners.last() != winner) { + newOwners.add(winner); + newStarts.add(low); + } + low = high; + } } - tEnter = Math.max(tEnter, tx1); - tExit = Math.min(tExit, tx2); - if (tExit < tEnter) { - return Double.NaN; + + int size = newOwners.size; + if (size == 0) { + return; } - } - if (dy == 0.0) { - if (oy < minY || oy > maxY) { - return Double.NaN; + int from = size > 1 && newOwners.get(0) == newOwners.get(size - 1) ? 1 : 0; + k = size - from; + + if (k > owner.length) { + owner = Arrays.copyOf(owner, 2 * k); + start = Arrays.copyOf(start, 2 * k); } - } else { - double ty1 = (minY - oy) / dy; - double ty2 = (maxY - oy) / dy; - if (ty1 > ty2) { - double tmp = ty1; - ty1 = ty2; - ty2 = tmp; + for (int i = 0; i < k; i++) { + owner[i] = newOwners.get(i + from); + start[i] = newStarts.get(i + from); } - tEnter = Math.max(tEnter, ty1); - tExit = Math.min(tExit, ty2); - if (tExit < tEnter) { - return Double.NaN; + if (k == 1) { + start[0] = 0; } } - return tExit; + double maxRadius() { + double minimum = Double.POSITIVE_INFINITY; + + for (int s = 0; s < k; s++) { + Constraint constraint = cs[owner[s]]; + double a0 = start[s], a1 = end(s); + + minimum = Math.min(minimum, constraint.rho(FastMath.cos(a0), FastMath.sin(a0))); + minimum = Math.min(minimum, constraint.rho(FastMath.cos(a1), FastMath.sin(a1))); + + double low = FastMath.atan2(constraint.c, constraint.b) + Math.PI; + if (wrap(low - a0) < a1 - a0) { + minimum = Math.min(minimum, constraint.rho(FastMath.cos(low), FastMath.sin(low))); + } + } + return minimum > 0 ? 1 / minimum : Double.POSITIVE_INFINITY; + } } - private static class Candidate implements Comparable { - int index; - double dist; - double sortMetric; + private static final class Constraint implements Comparable { - Candidate(int index, double dist, double sortMetric) { - this.index = index; - this.dist = dist; - this.sortMetric = sortMetric; + final int id; + final double a, b, c, den, minT; + + Constraint(int id, double a, double b, double c, double den) { + this.id = id; + this.a = a; + this.b = b; + this.c = c; + this.den = den; + minT = den / (a + FastMath.hypot(b, c)); + } + + double num(double cos, double sin) { + return a + b * cos + c * sin; + } + + double rho(double cos, double sin) { + return num(cos, sin) / den; } @Override - public int compareTo(Candidate o) { - return Double.compare(this.sortMetric, o.sortMetric); + public int compareTo(Constraint other) { + return Double.compare(minT, other.minT); } } - private Envelope defaultClipEnvelope(Envelope dataEnv, double maxAbsW) { - double diag = dataEnv.getDiameter(); - if (diag == 0) { - diag = 1.0; + // ------------------------------------------------------------------ + // rational quadratic flattening + // ------------------------------------------------------------------ + + /** + * Flattens a rational quadratic by shoulder subdivision. A segment is accepted + * only when its analytic sagitta does not exceed tolerance. + */ + private static void flatten(double[] p0, double[] p1, double[] p2, double weight, double tolerance, List out, int depth) { + + double ex = p2[0] - p0[0], ey = p2[1] - p0[1]; + double length = FastMath.hypot(ex, ey); + if (!(length > 0) || !(weight > 0) || !Double.isFinite(weight)) { + throw failure("Degenerate rational quadratic"); + } + + double controlDistance = Math.abs(ex / length * (p1[1] - p0[1]) - ey / length * (p1[0] - p0[0])); + double sagitta = controlDistance * weight / (1 + weight); + + if (Double.isFinite(sagitta) && sagitta <= tolerance) { + out.add(p2); + return; + } + if (depth >= 64) { + throw failure("Rational quadratic did not converge"); + } + + double alpha = weight / (1 + weight); + double[] q1 = { p0[0] + alpha * (p1[0] - p0[0]), p0[1] + alpha * (p1[1] - p0[1]) }; + double[] q2 = { p2[0] + alpha * (p1[0] - p2[0]), p2[1] + alpha * (p1[1] - p2[1]) }; + double[] shoulder = { q1[0] + 0.5 * (q2[0] - q1[0]), q1[1] + 0.5 * (q2[1] - q1[1]) }; + double childWeight = Math.sqrt(0.5 * (1 + weight)); + + if (!finite(q1) || !finite(q2) || !finite(shoulder) || !Double.isFinite(childWeight)) { + throw failure("Non-finite rational quadratic"); } - double half = opt.boundsScale * (diag + 2.0 * maxAbsW + 1.0); - double cx = dataEnv.centre().x; - double cy = dataEnv.centre().y; - return new Envelope(cx - half, cx + half, cy - half, cy + half); + + flatten(p0, q1, shoulder, childWeight, tolerance, out, depth + 1); + flatten(shoulder, q2, p2, childWeight, tolerance, out, depth + 1); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static Envelope defaultClip(Envelope dataEnv, double maxAbsWeight) { + double diagonal = dataEnv.getDiameter(); + if (!(diagonal > 0)) { + diagonal = 1; + } + double half = BOUNDS_SCALE * (diagonal + 2 * maxAbsWeight + 1); + Coordinate centre = dataEnv.centre(); + return new Envelope(centre.x - half, centre.x + half, centre.y - half, centre.y + half); + } + + private static double[] meet(double[] p, double[] u, double[] q, double[] v) { + double denominator = cross(u[0], u[1], v[0], v[1]); + if (denominator == 0 || !Double.isFinite(denominator)) { + return null; + } + double t = cross(q[0] - p[0], q[1] - p[1], v[0], v[1]) / denominator; + double px = p[0] + t * u[0], py = p[1] + t * u[1]; + return Double.isFinite(px) && Double.isFinite(py) ? new double[] { px, py } : null; + } + + private static double cross(double ax, double ay, double bx, double by) { + return ax * by - ay * bx; + } + + private static double wrap(double angle) { + double value = angle % TWO_PI; + return value < 0 ? value + TWO_PI : value; + } + + private static boolean finite(double[] point) { + return Double.isFinite(point[0]) && Double.isFinite(point[1]); + } + + private static Coordinate coord(double[] point) { + return new Coordinate(point[0], point[1]); } - private static List removeNearDuplicates(List pts, double tol) { - if (pts.isEmpty()) { - return pts; + private static IllegalStateException failure(String message) { + return new IllegalStateException(message); + } + + // ------------------------------------------------------------------ + // disk R-tree + // ------------------------------------------------------------------ + + private static final class DiskIndex { + + private final HPRtree tree = new HPRtree(); + private final Integer[] boxed; + + DiskIndex(double[] x, double[] y, double[] w) { + boxed = new Integer[x.length]; + for (int i = 0; i < x.length; i++) { + boxed[i] = i; + double radius = Math.max(w[i], 0); + tree.insert(new Envelope(x[i] - radius, x[i] + radius, y[i] - radius, y[i] + radius), boxed[i]); + } + tree.build(); } - double tolSq = tol * tol; - List out = new ArrayList<>(pts.size()); - Coordinate prev = null; - for (Coordinate c : pts) { - if (prev == null || prev.distanceSq(c) > tolSq) { - out.add(c); - prev = c; + + void query(double x, double y, double expand, IntList out) { + if (!Double.isFinite(expand)) { + all(out); + return; } + Envelope envelope = new Envelope(x, x, y, y); + if (expand > 0) { + envelope.expandBy(expand); + } + tree.query(envelope, new Collector(out)); } - if (out.size() >= 2 && out.get(0).distanceSq(out.get(out.size() - 1)) <= tolSq) { - out.remove(out.size() - 1); + + void all(IntList out) { + for (int i = 0; i < boxed.length; i++) { + out.add(i); + } + } + + private record Collector(IntList out) implements ItemVisitor { + @Override + public void visitItem(Object item) { + out.add((Integer) item); + } } - return out; } - @SuppressWarnings("unchecked") - private Precompute precomputePairs(double[] x, double[] y, double[] w) { - int n = x.length; - boolean[] empty = new boolean[n]; - List[] cand = new ArrayList[n]; - for (int i = 0; i < n; i++) { - cand[i] = new ArrayList<>(); + // ------------------------------------------------------------------ + // primitive lists + // ------------------------------------------------------------------ + + private static final class IntList { + + int[] values; + int size; + + IntList(int capacity) { + values = new int[Math.max(4, capacity)]; } - double domTol = 10 * opt.eps; + void add(int value) { + if (size == values.length) { + values = Arrays.copyOf(values, size * 2); + } + values[size++] = value; + } - for (int i = 0; i < n; i++) { - for (int j = i + 1; j < n; j++) { - double dx = x[j] - x[i]; - double dy = y[j] - y[i]; - double d = FastMath.hypot(dx, dy); + int get(int index) { + return values[index]; + } - double rij = w[j] - w[i]; - double rji = -rij; + int last() { + return values[size - 1]; + } - // dominance test: if wj - wi > dij then i is dominated everywhere - if (rij > d + domTol) { - empty[i] = true; - } - if (rji > d + domTol) { - empty[j] = true; - } + void clear() { + size = 0; + } - // necessary condition for an in-front intersection along some ray: r + max(p·c) - // > 0 => r + d > 0 - if (rij + d > opt.eps) { - cand[i].add(j); - } - if (rji + d > opt.eps) { - cand[j].add(i); + boolean contains(int value) { + for (int i = 0; i < size; i++) { + if (values[i] == value) { + return true; } } + return false; } + } + + private static final class DoubleList { - int[][] candidates = new int[n][]; - for (int i = 0; i < n; i++) { - candidates[i] = cand[i].stream().mapToInt(Integer::intValue).toArray(); + double[] values; + int size; + + DoubleList(int capacity) { + values = new double[Math.max(4, capacity)]; } - return new Precompute(empty, candidates); - } - static final class Precompute { - final boolean[] empty; - final int[][] candidates; + void add(double value) { + if (size == values.length) { + values = Arrays.copyOf(values, size * 2); + } + values[size++] = value; + } + + double get(int index) { + return values[index]; + } - Precompute(boolean[] empty, int[][] candidates) { - this.empty = empty; - this.candidates = candidates; + void clear() { + size = 0; } } } \ No newline at end of file diff --git a/src/main/java/micycle/pgs/commons/CircleCoverTree.java b/src/main/java/micycle/pgs/commons/CircleCoverTree.java deleted file mode 100644 index 05302c7a..00000000 --- a/src/main/java/micycle/pgs/commons/CircleCoverTree.java +++ /dev/null @@ -1,514 +0,0 @@ -package micycle.pgs.commons; - -import java.util.Arrays; - -/** - * A spatial index for circles that supports fast incremental insertion and - * nearest-circle queries. - *

- * Each entry is stored as a circle {@code (x, y, r)} plus an associated value. - * The structure is designed for workloads where circles are added over time and - * queried frequently, such as stochastic circle packing or geometric sampling. - *

- * Internally this uses a cover tree with the metric - * {@code d(c1, c2) = hypot(dx, dy) + |dr|}. Point queries are evaluated using - * an embedding based on the current {@linkplain #maxRadius() maximum inserted - * radius}, which makes nearest-neighbor search correspond to minimizing circle - * clearance {@code hypot(q - center) - r}. - *

- * This implementation is a fusion of the circle-distance metric described in - * this Stack Overflow - * answer and the TinSpin utilities {@code CoverTree} class. The metric is - * hard-wired into a fast, specialized reimplementation of TinSpin's cover tree, - * rather than being supplied as a generic distance function over - * {@code (x, y, r)} tuples. - * - * @author Michael Carleton - */ -public final class CircleCoverTree { - - // tree state - private Node root; - private Node anyLeaf; // for quick detach during rare root-growth - private int size; - private double maxR; - - // scratch (reused, no allocations during queries) - private Node[] stack = null; - private int[] order = null; - private double[] keys = null; - - /** - * Returns the number of circles currently stored in the tree. - * - * @return the number of indexed circles - */ - public int size() { - return size; - } - - /** - * Returns the largest radius inserted so far. - * - * @return the maximum stored circle radius - */ - public double maxRadius() { - return maxR; - } - - // circle-circle metric for the cover tree structure - private static double dCircleCircle(Node a, Node b) { - double dx = a.x - b.x; - double dy = a.y - b.y; - double dr = a.r - b.r; - return Math.sqrt(dx * dx + dy * dy) + Math.abs(dr); - } - - // Query embedding distance with R>=r: sqrt(d2) + (R - r) - // We try to avoid sqrt most of the time by using squared pruning. - private static double queryOffset(double R, Node n) { - return (R - n.r); // >=0 - } - - /** - * Inserts a circle and its associated value into the index. - * - * @param x circle center x-coordinate - * @param y circle center y-coordinate - * @param r circle radius - * @param value value associated with the circle - */ - public void insert(double x, double y, double r, T value) { - maxR = Math.max(maxR, r); - - if (root == null) { - root = new Node<>(x, y, r, value, 0); - anyLeaf = root; - size = 1; - return; - } - - Node xn = new Node<>(x, y, r, value, -1); - anyLeaf = xn; // new node is a leaf at insertion time - - if (!root.hasChildren()) { - double dist = dCircleCircle(root, xn); - int level = levelFromDist(dist); - root.level = level + 1; - xn.level = level; - root.addChild(xn, dist); - root.adjustMaxDist(dist); - size++; - return; - } - - insert(root, xn); - size++; - } - - private void insert(Node p, Node x) { - double distPX = dCircleCircle(p, x); - double covP = covDist(p.level); - - if (distPX > covP) { - // Grow tree upward until x fits. - while (distPX > covP) { // BASE=2 => (BASE-1)*covP == covP - Node q = detachAnyLeaf(); // O(1) typical - q.level = p.level + 1; - double dqp = dCircleCircle(q, p); - q.addChild(p, dqp); - q.adjustMaxDist(dqp + p.maxDist); - p = q; - distPX = dCircleCircle(p, x); - covP = covDist(p.level); - } - - x.level = p.level + 1; - Node newRoot = x; - newRoot.addChild(p, distPX); - newRoot.adjustMaxDist(distPX + p.maxDist); - root = newRoot; - return; - } - - insertNearestAncestor(p, x, distPX); - } - - private void insertNearestAncestor(Node p, Node x, double distPX) { - double covChild = covDist(p.level - 1); - - Node best = null; - double bestDist = Double.POSITIVE_INFINITY; - - // pick closest child that can cover x - for (int i = 0; i < p.nChildren; i++) { - Node q = p.children[i]; - double dqx = dCircleCircle(q, x); - if (dqx <= covChild && dqx < bestDist) { - bestDist = dqx; - best = q; - } - } - - if (best != null) { - insertNearestAncestor(best, x, bestDist); - p.adjustMaxDist(distPX); - return; - } - - x.level = p.level - 1; - p.addChild(x, distPX); - p.adjustMaxDist(distPX); - } - - private Node detachAnyLeaf() { - Node leaf = anyLeaf; - - // If leaf is root (tree size 1), root-growth case won't happen anyway. - if (leaf == null || leaf.parent == null) { - // fallback (rare) - leaf = root; - while (leaf.hasChildren()) { - leaf = leaf.children[0]; - } - } - - Node parent = leaf.parent; - parent.removeChildAt(leaf.parentIndex); - leaf.parent = null; - leaf.parentIndex = -1; - - // pick a new "anyLeaf" cheaply - Node cur = parent; - while (cur != null && cur.hasChildren()) { - cur = cur.children[0]; - } - anyLeaf = (cur != null) ? cur : root; - - // NOTE: We are not tightening maxDist after removal (conservative). Correct, - // slightly less pruning. - return leaf; - } - - /** - * Tests whether the query point lies inside any indexed circle. - * - * @param qx query point x-coordinate - * @param qy query point y-coordinate - * @return {@code true} if the point is contained in at least one stored circle; - * {@code false} otherwise - */ - public boolean existsInside(double qx, double qy) { - if (root == null) { - return false; - } - final double R = maxR; - return existsWithinMetric(qx, qy, R, R); - } - - /** - * Returns whether any stored circle is within a given metric distance of the - * query. - *

- * This is the lower-level query primitive used by the more user-friendly - * helpers. For example, with {@code R = maxRadius}, {@code limitMetric = R} - * tests whether the point lies inside any circle, and - * {@code limitMetric = R + t} tests whether some circle has clearance at most - * {@code t}. - * - * @param qx query point x-coordinate - * @param qy query point y-coordinate - * @param R query embedding radius, typically {@link #maxRadius()} - * @param limitMetric inclusive metric-distance threshold - * @return {@code true} if any indexed circle satisfies the threshold; - * {@code false} otherwise - */ - public boolean existsWithinMetric(double qx, double qy, double R, double limitMetric) { - if (root == null) { - return false; - } - - ensureStack(); - - int sp = 0; - stack[sp++] = root; - - while (sp != 0) { - Node p = stack[--sp]; - - // Check p itself within limit without sqrt: - // D = sqrt(d2) + (R - r) <= limit <=> sqrt(d2) <= limit - (R - r) - double rhs = limitMetric - queryOffset(R, p); - if (rhs >= 0) { - double dx = qx - p.x, dy = qy - p.y; - double d2 = dx * dx + dy * dy; - if (d2 <= rhs * rhs) { - return true; - } - } - - if (!p.hasChildren()) { - continue; - } - - // Push children that cannot be pruned (also sqrt-free): - // Need: (sqrt(d2) + (R-rChild)) - maxDistChild <= limit - // <=> sqrt(d2) <= limit + maxDistChild - (R - rChild) - for (int i = 0; i < p.nChildren; i++) { - Node c = p.children[i]; - double bound = limitMetric + c.maxDist - queryOffset(R, c); - if (bound <= 0) { - continue; // impossible - } - double dx = qx - c.x, dy = qy - c.y; - double d2 = dx * dx + dy * dy; - if (d2 <= bound * bound) { - stack[sp++] = c; - if (sp == stack.length) { - growStack(); - } - } - } - } - return false; - } - - /** - * Finds the stored circle with minimum clearance to the query point. - *

- * Clearance is {@code hypot(q - center) - radius}, so negative values mean the - * point lies inside the returned circle. - * - * @param qx query point x-coordinate - * @param qy query point y-coordinate - * @return the nearest-circle result, or {@code null} if the tree is empty - */ - public Nearest nearest(double qx, double qy) { - if (root == null) { - return null; - } - final double R = maxR; - - ensureStack(); - - Nearest best = new Nearest<>(); - // compute exact root distance once - double dx0 = qx - root.x, dy0 = qy - root.y; - double dRoot = Math.sqrt(dx0 * dx0 + dy0 * dy0) + queryOffset(R, root); - best.set(root, dRoot, R); - - int sp = 0; - stack[sp++] = root; - - while (sp != 0) { - Node p = stack[--sp]; - - if (!p.hasChildren()) { - continue; - } - - // Optional near-first: gather candidates and visit smaller d2 first (no alloc). - int nCand = 0; - ensureOrderAndKeys(p.nChildren); - - for (int i = 0; i < p.nChildren; i++) { - Node c = p.children[i]; - - // sqrt-free prune test derived from: - // explore if (dist(query,c) - c.maxDist) < best.metricDist - // - // dist(query,c) = sqrt(d2) + (R - c.r) - // => sqrt(d2) < best.metricDist + c.maxDist - (R - c.r) - double t = best.metricDist + c.maxDist - queryOffset(R, c); - if (t <= 0) { - continue; - } - - double dx = qx - c.x, dy = qy - c.y; - double d2 = dx * dx + dy * dy; - if (d2 >= t * t) { - continue; - } - - // Keep candidate; key by d2 (approx near-first) - order[nCand] = i; - keys[nCand] = d2; - nCand++; - } - - // insertion sort (fast for small degrees; cover trees typically have small - // branching) - for (int i = 1; i < nCand; i++) { - int oi = order[i]; - double ki = keys[i]; - int j = i - 1; - while (j >= 0 && keys[j] > ki) { - keys[j + 1] = keys[j]; - order[j + 1] = order[j]; - j--; - } - keys[j + 1] = ki; - order[j + 1] = oi; - } - - // push in reverse so nearest visited first (stack LIFO) - for (int idx = nCand - 1; idx >= 0; idx--) { - Node c = p.children[order[idx]]; - - // Now compute exact dist (sqrt) only for survivors - double dx = qx - c.x, dy = qy - c.y; - double d2 = dx * dx + dy * dy; - double dist = Math.sqrt(d2) + queryOffset(R, c); - - if (dist < best.metricDist) { - best.set(c, dist, R); - } - - // We already know it's worth exploring based on squared prune, - // but best may have improved; optional re-check: - if (dist - c.maxDist < best.metricDist) { - stack[sp++] = c; - if (sp == stack.length) { - growStack(); - } - } - } - } - - return best; - } - - @SuppressWarnings("unchecked") - private void ensureStack() { - if (stack == null) { - stack = new Node[64]; - } - } - - private void growStack() { - stack = Arrays.copyOf(stack, stack.length * 2); - } - - private void ensureOrderAndKeys(int needed) { - if (order == null || order.length < needed) { - int cap = 1; - while (cap < needed) { - cap <<= 1; - } - order = new int[cap]; - keys = new double[cap]; - } - } - - private static double covDist(int level) { - return Math.scalb(1.0, level); // 2^level - } - - private static int levelFromDist(double dist) { - return Math.getExponent(dist); // floor(log2(dist)) for dist>0 - } - - private static final class Node { - double x, y, r; - int level; - - // metric bookkeeping - double distToParent; // circle-circle metric distance to parent - double maxDist; // max metric distance from this node to any descendant - - // parent pointers (speeds leaf detaches) - Node parent; - int parentIndex = -1; - - T value; - - Node[] children; - int nChildren; - - @SuppressWarnings("unchecked") - Node(double x, double y, double r, T value, int level) { - this.x = x; - this.y = y; - this.r = r; - this.value = value; - this.level = level; - this.children = new Node[0]; - } - - boolean hasChildren() { - return nChildren != 0; - } - - void ensureChildCap(int cap) { - if (children.length >= cap) { - return; - } - int newCap = Math.max(4, children.length * 2); - if (newCap < cap) { - newCap = cap; - } - children = Arrays.copyOf(children, newCap); - } - - void addChild(Node c, double distThisToChild) { - ensureChildCap(nChildren + 1); - int idx = nChildren++; - children[idx] = c; - - c.parent = this; - c.parentIndex = idx; - c.distToParent = distThisToChild; - } - - void removeChildAt(int idx) { - int last = --nChildren; - if (idx != last) { - Node moved = children[last]; - children[idx] = moved; - moved.parentIndex = idx; - } - children[last] = null; - } - - void adjustMaxDist(double maybe) { - if (maybe > maxDist) { - maxDist = maybe; - } - } - } - - public static final class Nearest { - /** Value associated with the nearest circle. */ - public T value; - - /** X-coordinate of the nearest circle center. */ - public double cx; - - /** Y-coordinate of the nearest circle center. */ - public double cy; - - /** Radius of the nearest circle. */ - public double cr; - - /** Metric distance {@code D((qx, qy, R=maxR), (cx, cy, r))}. */ - public double metricDist; - - /** - * Signed Euclidean distance from the query point to the nearest circle - * boundary: {@code hypot(q - c) - r = metricDist - R}. - *

- * Positive values mean the query lies outside the circle, zero means it lies on - * the boundary, and negative values mean it lies inside the circle. - */ - public double clearance; - - void set(Node n, double metricDist, double R) { - this.value = n.value; - this.cx = n.x; - this.cy = n.y; - this.cr = n.r; - this.metricDist = metricDist; - this.clearance = metricDist - R; - } - } -} diff --git a/src/main/java/micycle/pgs/commons/FrontChainPacker.java b/src/main/java/micycle/pgs/commons/FrontChainPacker.java deleted file mode 100644 index 909ed659..00000000 --- a/src/main/java/micycle/pgs/commons/FrontChainPacker.java +++ /dev/null @@ -1,254 +0,0 @@ -package micycle.pgs.commons; - -import java.util.ArrayList; -import java.util.List; - -import it.unimi.dsi.util.XoRoShiRo128PlusRandom; -import processing.core.PVector; - -/** - * Circle packing of rectangle boundaries using the front-chain packing - * algorithm from 'Visualization of Large Hierarchical Data by Circle Packing'. - *

- * The algorithm initialises circles in the boundary center and builds up the - * packing in a spiral pattern from this center until it reaches the rectangle - * boundary. - * - * @author Mike Bostock - * @author Java port and modifications by Michael Carleton - * - */ -public class FrontChainPacker { - - // https://observablehq.com/@mbostock/packing-circles-inside-a-rectangle - // 'Visualization of large hierarchical data by circle packing' - - private final float width, height; - private final float offsetX, offsetY; - private final float radiusMin, radiusMax; - private final XoRoShiRo128PlusRandom rand; - - /** - * The square of the max euclidean distance between a circle center (of - * radiusMax) and the center of the boundary. The packing terminates when the - * center point of the next circle to place falls outside this distance. - */ - private final float maxDistSq; - - private final List circles; - - /** - * Creates a FrontChainPacker instance. Circles are packed upon initialisation. - *

- * Each circle in the output packing is prescribed a random radius between the - * range given. - * - * @param width width of rectangle boundary to pack - * @param height height of rectangle boundary to pack - * @param radiusMin minimum radius of circles in the packing - * @param radiusMax maximum radius of circles in the packing# - * @see #FrontChainPacker(float, float, float, float, float, float) - */ - public FrontChainPacker(float width, float height, float radiusMin, float radiusMax) { - this(width, height, radiusMin, radiusMax, 0, 0, System.nanoTime()); - } - - /** - * Creates a FrontChainPacker instance. Circles are packed upon initialisation. - * Each circle in the output packing is prescribed a random radius between a - * radius range given by its minimum and maximum values. - * - * @param width width of rectangle boundary to pack - * @param height height of rectangle boundary to pack - * @param radiusMin minimum radius of circles in the packing - * @param radiusMax maximum radius of circles in the packing - * @see #FrontChainPacker(float, float, float, float) - */ - public FrontChainPacker(float width, float height, float radiusMin, float radiusMax, float offsetX, float offsetY, long seed) { - this.width = width; - this.height = height; - this.radiusMin = Math.max(1f, Math.min(radiusMin, radiusMax)); // choose min and constrain - this.radiusMax = Math.max(1f, Math.max(radiusMin, radiusMax)); // choose max and constrain - this.offsetX = offsetX; - this.offsetY = offsetY; - this.maxDistSq = this.width * this.height / 2 + this.radiusMax * this.radiusMax; - rand = new XoRoShiRo128PlusRandom(seed); - - this.circles = pack(new ArrayList<>()); - } - - public List getCircles() { - return circles; - } - - private List pack(List circles) { - // init first chain of 3 - circles.add(new PVector(0, 0, randomRadius())); - circles.add(new PVector(0, 0, randomRadius())); - circles.add(new PVector(0, 0, randomRadius())); - - PVector A, B, C; - A = circles.get(0); // Place the first circle. - B = circles.get(1); // Place the second circle. - A.x = -B.z; - B.x = A.z; - B.y = 0; - - place(B, A, C = circles.get(2)); - - Node a, b, c; - - // Initialize the front-chain using the first three circles a, b and c. - a = new Node(A); - b = new Node(B); - c = new Node(C); - a.next = c.previous = b; - b.next = a.previous = c; - c.next = b.previous = a; - - circles.add(new PVector(0, 0, randomRadius())); - - int iter = 0; - pack: while (place(a.c, b.c, C = circles.get(circles.size() - 1))) { - c = new Node(C); - - // Find the closest intersecting circle on the front-chain, if any. - // “Closeness” is determined by linear distance along the front-chain. - // “Ahead” or “behind” is likewise determined by linear distance. - Node j = b.next; - Node k = a.previous; - float sj = b.c.z; - float sk = a.c.z; - do { - if (++iter > 10) { - break; // prevent infinite loop when large difference between the given min & max radii - } - if (sj <= sk) { - if (intersects(j.c, c.c)) { - b = j; - a.next = b; - b.previous = a; - continue pack; - } - sj += j.c.z; - j = j.next; - } else { - if (intersects(k.c, c.c)) { - a = k; - a.next = b; - b.previous = a; - continue pack; - } - sk += k.c.z; - k = k.previous; - } - } while (j != k.next); - iter = 0; - - // Success! Insert the new circle c between a and b. - c.previous = a; - c.next = b; - a.next = b.previous = b = c; - - // Compute the new closest circle pair to the centroid. - float aa = score(a); - float ca; - while ((c = c.next) != b) { - if ((ca = score(c)) < aa) { - a = c; - aa = ca; - } - } - b = a.next; - circles.add(new PVector(0, 0, randomRadius())); // last was within bounds; add new circle - } - - circles.forEach(cl -> { // translate to corner, then offset (if applicable) - cl.x += (width / 2f) + offsetX; - cl.y += (height / 2f) + offsetY; - }); - - return circles; - } - - /** - * Compute the position of a new PVector c, given two other PVectors in the - * chain same chain, a, b. - * - * @return true if - */ - private boolean place(PVector b, PVector a, PVector c) { - final float dx = b.x - a.x; - final float dy = b.y - a.y; - final float d2 = dx * dx + dy * dy; - final float cx, cy; // coordinates to be computed for c - if (d2 != 0) { - final float a2 = (a.z + c.z) * (a.z + c.z); - final float b2 = (b.z + c.z) * (b.z + c.z); - if (a2 > b2) { - final float x = (d2 + b2 - a2) / (2 * d2); - final float y = (float) Math.sqrt(Math.max(0f, b2 / d2 - x * x)); - cx = b.x - x * dx - y * dy; - cy = b.y - x * dy + y * dx; - } else { - final float x = (d2 + a2 - b2) / (2 * d2); - final float y = (float) Math.sqrt(Math.max(0f, a2 / d2 - x * x)); - cx = a.x + x * dx - y * dy; - cy = a.y + x * dy + y * dx; - } - } else { - cx = a.x + c.z; - cy = a.y; - } - - if (withinBounds(c)) { - c.x = cx; - c.y = cy; - return true; - } - return false; - } - - /** - * Determines whether the candidate circle (represented by PVector) cannot cover - * the packing region. - * - * @return true if circle can cover region - */ - private boolean withinBounds(PVector v) { - return v.x * v.x + v.y * v.y < maxDistSq; - } - - private static boolean intersects(PVector a, PVector b) { - final float dr = a.z + b.z - 1e-6f; - final float dx = b.x - a.x; - final float dy = b.y - a.y; - return dr > 0 && dr * dr > dx * dx + dy * dy; - } - - private float score(Node node) { - final PVector a = node.c; - final PVector b = node.next.c; - final float ab = a.z + b.z; - final float cx = (a.x * b.z + b.x * a.z) / ab; - final float cy = (a.y * b.z + b.y * a.z) / ab; - return Math.max(Math.abs(cx * height), Math.abs(cy * width)); - } - - private float randomRadius() { - return radiusMin == radiusMax ? radiusMin : rand.nextFloat(radiusMin, radiusMax); - } - - private static class Node { - - final PVector c; - Node next, previous; - - Node(PVector circle) { - this.c = circle; - this.next = null; - this.previous = null; - } - } - -} diff --git a/src/main/java/micycle/pgs/commons/GeometryFrontChainPacker.java b/src/main/java/micycle/pgs/commons/GeometryFrontChainPacker.java new file mode 100644 index 00000000..77f9d678 --- /dev/null +++ b/src/main/java/micycle/pgs/commons/GeometryFrontChainPacker.java @@ -0,0 +1,406 @@ +package micycle.pgs.commons; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.SplittableRandom; + +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.Location; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.index.quadtree.Quadtree; +import org.locationtech.jts.operation.distance.IndexedFacetDistance; + +import com.github.micycle1.geoblitz.CircleIndex; +import com.github.micycle1.geoblitz.PointDistanceIndex; +import com.github.micycle1.geoblitz.YStripesPointInAreaLocator; + +/** + * Circle packing of an arbitrary polygonal boundary using the front-chain + * packing algorithm from 'Visualization of Large Hierarchical Data by Circle + * Packing' (adapted from Mike Bostock's rectangle implementation). + *

+ * Circles are seeded at the centre of the polygon's envelope and grown outwards + * in a spiral. Unlike the rectangle variant, this version respects the true + * shape of the input {@link Polygon} (including holes and concave regions): + *

    + *
  • A candidate circle whose disc would cross the polygon boundary is + * iteratively shrunk (down to, at most, {@code radiusMin}) and + * re-seated tangentially against its two parent circles so that it fits inside + * the boundary.
  • + *
  • Candidates that still cannot fit (centre outside the polygon, or the + * available clearance is smaller than {@code radiusMin}) are kept in the + * front-chain — so the spiral can continue past concavities and holes — but are + * excluded from the output.
  • + *
+ * Boundary queries are accelerated with {@link IndexedFacetDistance} (STR-tree + * over the boundary linework) and {@link IndexedPointInAreaLocator}; overlap + * guarding of boundary-shrunk circles uses an incremental {@link Quadtree}. + * + * @author Mike Bostock (original front-chain algorithm) + * @author JTS/arbitrary-geometry adaptation + */ +public class GeometryFrontChainPacker { + + private static final double EPSILON = 1e-9; + /** Slack used for tangency/overlap tests (matches intersects()). */ + private static final double OVERLAP_EPSILON = 1e-6; + /** Max tangent re-seating iterations when shrinking a circle to fit. */ + private static final int MAX_SHRINK_ITERATIONS = 16; + + private final double radiusMin, radiusMax; + private final SplittableRandom rand; + + private final double centerX, centerY; // spiral origin (envelope centre) + private final double envWidth, envHeight; + + /** + * Square of the max distance between a circle centre (of radiusMax) and the + * spiral origin. The packing terminates when the next candidate centre falls + * outside this distance (i.e. the spiral has covered the whole envelope). + */ + private final double maxDistSq; + + private final PointOnGeometryLocator interiorLocator; + private final PointDistanceIndex boundaryDistance; + + /** + * Spatial index over every *accepted* circle. Used to guard boundary-shrunk + * circles against overlapping already-placed circles: shrinking pulls a circle + * back towards its two parents, where it may collide with interior (non-front) + * circles that the front-chain intersection scan cannot see. + */ + private final CircleIndex circleIndex = new CircleIndex<>(); + + private final List circles; + + /** + * Creates a packer and computes the packing immediately. + * + * @param polygon polygonal region to pack (may be concave / contain holes) + * @param radiusMin minimum circle radius (must be > 0) + * @param radiusMax maximum circle radius + * @param seed RNG seed (packings are deterministic per seed) + */ + public GeometryFrontChainPacker(Polygon polygon, double radiusMin, double radiusMax, long seed) { + this.radiusMin = Math.max(EPSILON, Math.min(radiusMin, radiusMax)); + this.radiusMax = Math.max(EPSILON, Math.max(radiusMin, radiusMax)); + this.rand = new SplittableRandom(seed); + + final Envelope e = polygon.getEnvelopeInternal(); + this.centerX = (e.getMinX() + e.getMaxX()) / 2d; + this.centerY = (e.getMinY() + e.getMaxY()) / 2d; + this.envWidth = e.getWidth(); + this.envHeight = e.getHeight(); + this.maxDistSq = envWidth * envHeight / 2 + this.radiusMax * this.radiusMax; + + this.interiorLocator = new YStripesPointInAreaLocator(polygon); + this.boundaryDistance = new PointDistanceIndex(polygon.getBoundary()); + + this.circles = pack(); + } + + /** + * @return packed circles; x/y are the circle centre, z is its radius + */ + public List getCircles() { + return circles; + } + + private List pack() { + final List chain = new ArrayList<>(); + final Set discarded = Collections.newSetFromMap(new IdentityHashMap<>()); + + // init first chain of 3, seeded at the envelope centre + chain.add(new Coordinate(0, 0, randomRadius())); + chain.add(new Coordinate(0, 0, randomRadius())); + chain.add(new Coordinate(0, 0, randomRadius())); + + final Coordinate A = chain.get(0); + final Coordinate B = chain.get(1); + Coordinate C = chain.get(2); + A.x = centerX - B.z; + A.y = centerY; + B.x = centerX + A.z; + B.y = centerY; + tangentPosition(B, A, C); + + // the first three circles may themselves straddle the boundary + accept(A, fitInPlace(A), discarded); + accept(B, fitInPlace(B), discarded); + accept(C, fit(B, A, C, false), discarded); + + Node a = new Node(A); + Node b = new Node(B); + Node c = new Node(C); + a.next = c.previous = b; + b.next = a.previous = c; + c.next = b.previous = a; + + chain.add(new Coordinate(0, 0, randomRadius())); + + boolean fitted; + int iter = 0; + double sampledRadius = chain.get(chain.size() - 1).z; + pack: while (true) { + C = chain.get(chain.size() - 1); + // restore the sampled radius: a retry after a front splice gets new + // parents, which may allow the full radius again + C.z = sampledRadius; + tangentPosition(a.c, b.c, C); + if (!withinBounds(C)) { + break; // spiral has covered the whole envelope + } + fitted = fit(a.c, b.c, C, true); + c = new Node(C); + + // Find the closest intersecting circle on the front-chain, if any. + // "Closeness" is determined by linear distance along the front-chain. + Node j = b.next; + Node k = a.previous; + double sj = b.c.z; + double sk = a.c.z; + do { + // The scan may legitimately walk a large part of the front, which + // grows with the packing; the bound below only trips on a corrupted + // chain. Crucially, when it trips the candidate is *discarded* from + // the output rather than inserted as a visibly overlapping circle. + if (++iter > chain.size() + 16) { + fitted = false; + break; + } + if (sj <= sk) { + if (intersects(j.c, c.c)) { + b = j; + a.next = b; + b.previous = a; + continue pack; + } + sj += j.c.z; + j = j.next; + } else { + if (intersects(k.c, c.c)) { + a = k; + a.next = b; + b.previous = a; + continue pack; + } + sk += k.c.z; + k = k.previous; + } + } while (j != k.next); + iter = 0; + + // Success! Insert the new circle c between a and b. + accept(C, fitted, discarded); + c.previous = a; + c.next = b; + a.next = b.previous = b = c; + + // Compute the new closest circle pair to the spiral origin. + double aa = score(a); + double ca; + while ((c = c.next) != b) { + if ((ca = score(c)) < aa) { + a = c; + aa = ca; + } + } + b = a.next; + chain.add(new Coordinate(0, 0, randomRadius())); // last circle fit within bounds; add another + sampledRadius = chain.get(chain.size() - 1).z; + } + + chain.remove(chain.size() - 1); // trailing candidate that fell outside the spiral bounds + + final List out = new ArrayList<>(chain.size()); + for (Coordinate circle : chain) { + if (!discarded.contains(circle)) { + out.add(circle); + } + } + return out; + } + + /** + * Records a circle's fate: accepted circles enter the spatial index (so later + * boundary-shrunk circles can avoid them); rejected circles are excluded from + * the output but remain on the front-chain. + */ + private void accept(Coordinate c, boolean fitted, Set discarded) { + if (fitted) { + circleIndex.insert(c.x, c.y, c.z, c); + } else { + discarded.add(c); + } + } + + /** + * Positions circle c tangentially against circles a and b (using c's current + * radius), writing the result into c. + */ + private static void tangentPosition(Coordinate b, Coordinate a, Coordinate c) { + final double dx = b.x - a.x; + final double dy = b.y - a.y; + final double d2 = dx * dx + dy * dy; + if (d2 > EPSILON) { + final double a2 = (a.z + c.z) * (a.z + c.z); + final double b2 = (b.z + c.z) * (b.z + c.z); + if (a2 > b2) { + final double x = (d2 + b2 - a2) / (2 * d2); + final double y = Math.sqrt(Math.max(0d, b2 / d2 - x * x)); + c.x = b.x - x * dx - y * dy; + c.y = b.y - x * dy + y * dx; + } else { + final double x = (d2 + a2 - b2) / (2 * d2); + final double y = Math.sqrt(Math.max(0d, a2 / d2 - x * x)); + c.x = a.x + x * dx - y * dy; + c.y = a.y + x * dy + y * dx; + } + } else { + c.x = a.x + c.z; + c.y = a.y; + } + } + + /** + * Makes circle c respect the polygon boundary. If c's disc would cross the + * boundary, its radius is shrunk (bounded below by radiusMin) and c is + * re-seated tangentially against its parents a and b, since a smaller radius + * yields a different tangent position. + *

+ * Because shrinking pulls the centre back towards the parents — into space that + * may already be occupied by interior circles the front-chain scan cannot + * detect — any circle that has been shrunk is additionally checked against the + * spatial index of accepted circles, and shrunk further if it overlaps one. + * This is what prevents "double layer" artefacts near the boundary. Virgin + * (unshrunk) placements are left untouched so the normal front-chain + * intersection/splicing logic keeps driving the packing. + * + * @param guardOverlaps whether to apply the accepted-circle overlap guard once + * shrinking has occurred + * @return true if c (possibly shrunk) lies fully inside the polygon and + * overlaps no accepted circle + */ + private boolean fit(Coordinate b, Coordinate a, Coordinate c, boolean guardOverlaps) { + boolean shrunk = false; + for (int i = 0; i < MAX_SHRINK_ITERATIONS; i++) { + if (isExterior(c)) { + // A large radius can push the tangent position outside the polygon. + // A smaller radius pulls the centre back towards the parents, so + // shrink and re-seat instead of failing outright. Use the distance + // to the boundary as a hint for how much to cut. + if (c.z <= radiusMin + EPSILON) { + return false; // already minimal and still outside + } + final double overshoot = distanceToBoundary(c); + c.z = Math.max(radiusMin, Math.min(c.z * 0.5, c.z - overshoot)); + shrunk = true; + tangentPosition(b, a, c); + continue; + } + final double allowed = allowedRadius(c, guardOverlaps && shrunk); + if (allowed >= c.z - OVERLAP_EPSILON) { + return true; // fits + } + final double target = Math.max(radiusMin, allowed); + if (target >= c.z - EPSILON) { + return false; // at radiusMin and still overlapping + } + c.z = target; + shrunk = true; + tangentPosition(b, a, c); + } + return !isExterior(c) && allowedRadius(c, guardOverlaps && shrunk) >= c.z - OVERLAP_EPSILON; + } + + /** + * Largest radius circle c could have at its current centre: the clearance to + * the polygon boundary, optionally further constrained by the distance to + * already-accepted circles. + */ + private double allowedRadius(Coordinate c, boolean guardOverlaps) { + double allowed = distanceToBoundary(c); + if (guardOverlaps && allowed > 0 && circleIndex.size() > 0) { + final var n = circleIndex.nearest(c.x, c.y); + if (n != null && n.clearance < allowed) { + allowed = n.clearance; + } + } + return allowed; + } + + /** + * Boundary-fits a circle whose position is fixed (used for the seed circles): + * shrinks the radius only, without tangential re-seating. + */ + private boolean fitInPlace(Coordinate c) { + if (isExterior(c)) { + return false; + } + final double clearance = distanceToBoundary(c); + if (clearance >= c.z - EPSILON) { + return true; + } + if (clearance < radiusMin) { + return false; + } + c.z = clearance; + return true; + } + + private boolean isExterior(Coordinate c) { + return interiorLocator.locate(c) == Location.EXTERIOR; + } + + private double distanceToBoundary(Coordinate c) { + return boundaryDistance.distance(c); + } + + /** + * Determines whether the candidate circle centre is still within the spiral's + * working region (the polygon's envelope, roughly). Used as the packing + * termination condition. + */ + private boolean withinBounds(Coordinate v) { + final double dx = v.x - centerX; + final double dy = v.y - centerY; + return dx * dx + dy * dy < maxDistSq; + } + + private static boolean intersects(Coordinate a, Coordinate b) { + final double dr = a.z + b.z - OVERLAP_EPSILON; + final double dx = b.x - a.x; + final double dy = b.y - a.y; + return dr > 0 && dr * dr > dx * dx + dy * dy; + } + + private double score(Node node) { + final Coordinate a = node.c; + final Coordinate b = node.next.c; + final double ab = a.z + b.z; + final double cx = (a.x * b.z + b.x * a.z) / ab - centerX; + final double cy = (a.y * b.z + b.y * a.z) / ab - centerY; + return Math.max(Math.abs(cx * envHeight), Math.abs(cy * envWidth)); + } + + private double randomRadius() { + return radiusMin == radiusMax ? radiusMin : rand.nextDouble(radiusMin, radiusMax); + } + + private static class Node { + + final Coordinate c; + Node next, previous; + + Node(Coordinate circle) { + this.c = circle; + } + } + +} \ 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 51cf7fa2..5d875d16 100644 --- a/src/main/java/micycle/pgs/commons/LargestEmptyCircles.java +++ b/src/main/java/micycle/pgs/commons/LargestEmptyCircles.java @@ -1,10 +1,6 @@ package micycle.pgs.commons; -import java.util.ArrayDeque; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; -import java.util.Deque; import java.util.List; import org.locationtech.jts.geom.Coordinate; @@ -37,23 +33,56 @@ * (circles must not cover them). * * - *

Iteration and reuse

Repeated calls to {@link #findNextLEC()} reuse - * and refine a cached set of candidate cells, making successive extractions - * faster than recomputing from scratch. + *

Algorithm

Best-first branch-and-bound over a quadtree of cells, + * ordered by a lazy max-heap keyed on each cell's optimistic upper + * bound ({@code dist + halfSide·√2}): + *
    + *
  • Best-first: the cell with the greatest upper bound is always + * subdivided next, so the search only ever expands cells whose bound exceeds + * the final answer — the provably minimal set for this bound function — and + * terminates the moment {@code top.maxDist − incumbent ≤ tolerance}, with no + * queue draining.
  • + *
  • Persistent: the heap survives across {@link #findNextLEC()} + * calls. Cells left in the heap (bounds below the previous answer) are the + * candidate set for the next extraction.
  • + *
  • Lazy: each cell carries a stamp — the number of circles + * that had been found when its distance was last computed. Since new circles + * only ever decrease a cell's distance (keys only shrink), a stale + * cell's stored key is an upper bound on its true key, so the heap top always + * dominates every true key. Cells are refreshed against newer circles only + * when they surface at the top; the (typical) majority that never surface are + * never touched.
  • + *
+ * Cells are stored in flat parallel primitive arrays (no per-cell objects, no + * GC pressure, cache-friendly sifting). */ public class LargestEmptyCircles { + private static final double SQRT2 = Math.sqrt(2); + private final Geometry boundary; // polygonal private final Geometry obstacles; // nullable; may be any Geometry private final double tolerance; private PointDistanceIndex boundaryDistance; + private boolean initialized = false; - private Envelope gridEnv; - private Cell farthestCell; + /* + * Lazy max-heap of cells, struct-of-arrays. Keyed on hmax (optimistic upper + * bound = hd + hh·√2). hstamp[i] is the circle count when hd[i] was last + * computed; hd only decreases as circles are added, so stale keys + * over-estimate — safe for a max-heap with refresh-at-top. + */ + private double[] hx = new double[4096]; + private double[] hy = new double[4096]; + private double[] hh = new double[4096]; // half side length + private double[] hd = new double[4096]; // signed distance at center + private double[] hmax = new double[4096]; // hd + hh·√2 (heap key) + private int[] hstamp = new int[4096]; + private int heapSize = 0; - private final Deque cellStack = new ArrayDeque<>(); - private final List nextIterCells = new ArrayList<>(4096); + // Incumbent (best evaluated center) of the current extraction. + private double bestX, bestY, bestD; // Primitive circle store (x,y,r) private double[] cx = new double[64]; @@ -94,6 +123,38 @@ public LargestEmptyCircles(Geometry boundary, double tolerance) { * non-polygonal, or if {@code tolerance <= 0} */ public LargestEmptyCircles(Geometry boundary, Geometry obstacles, double tolerance) { + this(boundary, obstacles, null, tolerance); + } + + /** + * Creates an instance seeded with an existing circle packing, so the search + * "fills in" the gaps of that packing rather than starting from an empty + * region. + *

+ * Each seed circle behaves exactly like a circle previously returned by + * {@link #findNextLEC()}: subsequent circles will not overlap it (they treat + * its rim as a distance constraint). Seed circles are not returned + * by {@link #findNextLEC()} / {@link #findLECs(int)}; only newly-found + * circles are. + * + * @param boundary polygonal constraint region (shell and holes are + * respected) + * @param obstacles optional constraints geometry (see + * {@link #LargestEmptyCircles(Geometry, Geometry, double)}); + * may be {@code null} or empty + * @param existingCircles optional list of pre-existing circles, one per + * {@link Coordinate}, where {@code x,y} is the circle + * center and {@code z} is its radius. A {@code NaN} + * radius is treated as {@code 0} (a point constraint). + * May be {@code null} or empty. + * @param tolerance accuracy tolerance (> 0). Smaller values increase + * work and accuracy. + * @throws IllegalArgumentException if {@code boundary} is null/empty, + * non-polygonal, if {@code tolerance <= 0}, + * or if a seed circle has a non-finite + * center or negative radius + */ + public LargestEmptyCircles(Geometry boundary, Geometry obstacles, List existingCircles, double tolerance) { if (boundary == null || boundary.isEmpty()) { throw new IllegalArgumentException("Boundary geometry is null or empty."); } @@ -106,39 +167,19 @@ public LargestEmptyCircles(Geometry boundary, Geometry obstacles, double toleran this.boundary = boundary; this.obstacles = obstacles; this.tolerance = tolerance; - } - private void initBoundary() { - gridEnv = boundary.getEnvelopeInternal(); - - // Index distance to boundary rings AND obstacle linework. - // Sign is determined by boundary (modified by polygonal obstacles). - boundaryDistance = new PointDistanceIndex(boundary, obstacles); - - createInitialGrid(gridEnv, cellStack); - } - - /** - * Computes signed distance to the constraint set at the given coordinate. - *

- * The returned value is: - *

    - *
  • positive if the point lies inside the feasible region (inside - * {@code boundary} and outside any polygonal obstacles),
  • - *
  • negative if the point lies outside the feasible region,
  • - *
- * and the magnitude is the distance to the nearest constraining feature, which - * includes: boundary rings, obstacle lineal components, and obstacle puntal - * components. - * - * @param x x-ordinate - * @param y y-ordinate - * @return signed distance to constraints - */ - private double distanceToConstraints(double x, double y) { - tmp.x = x; - tmp.y = y; - return boundaryDistance.distance(tmp); // signed distance + if (existingCircles != null) { + for (Coordinate c : existingCircles) { + if (c == null) { + continue; + } + final double r = Double.isNaN(c.getZ()) ? 0 : c.getZ(); + if (!Double.isFinite(c.x) || !Double.isFinite(c.y) || !Double.isFinite(r) || r < 0) { + throw new IllegalArgumentException("Invalid seed circle: " + c); + } + addCircle(c.x, c.y, r); + } + } } /** @@ -160,256 +201,277 @@ public double[][] findLECs(int n) { * Finds the next largest empty circle and caches it internally. *

* On the first call, initialises the search structure. On subsequent calls, - * reuses candidate cells and updates them against the most recently found - * circle to avoid recomputing from scratch. + * the persistent heap of candidate cells is reused; cells are lazily updated + * against circles found since they were last evaluated, only when they reach + * the top of the heap. * * @return the next circle as {@code [x, y, r]} where {@code (x,y)} is the * center and {@code r} is the radius (signed distance at the selected * center; typically {@code r > 0}) */ public double[] findNextLEC() { - double farthestD; - - if (gridEnv == null) { // first iteration - initBoundary(); - - farthestCell = createCentroidCell(boundary); - farthestD = farthestCell.getDistance(); - for (Cell c : cellStack) { - double d = c.getDistance(); - if (d > farthestD) { - farthestD = d; - farthestCell = c; - } - } + if (!initialized) { + init(); } else { - // update remaining candidates with newest circle only - final double lastX = cx[circleCount - 1]; - final double lastY = cy[circleCount - 1]; - final double lastR = cr[circleCount - 1]; - - for (Cell nextIterCell : nextIterCells) { - nextIterCell.updateDistance(lastX, lastY, lastR); - } - - cellStack.clear(); - cellStack.addAll(nextIterCells); - nextIterCells.clear(); - - farthestD = Double.NEGATIVE_INFINITY; - for (Cell c : cellStack) { - double d = c.getDistance(); - if (d > farthestD) { - farthestD = d; - farthestCell = c; - } - } + bestD = Double.NEGATIVE_INFINITY; } - // Branch-and-bound - while (!cellStack.isEmpty()) { - Cell cell = cellStack.removeLast(); // DFS-like + final double tol = tolerance; - double d = cell.getDistance(); - if (d > farthestD) { - farthestD = d; - farthestCell = cell; + while (true) { + refreshTop(); // ensure heap top (if any) is current w.r.t. all circles + if (heapSize == 0) { + break; } - if (cell.isFullyOutside()) { - continue; + // top is fresh: its key is the global maximum of all true upper bounds + final double d = hd[0]; + if (d > bestD) { + bestD = d; + bestX = hx[0]; + bestY = hy[0]; } - if (cell.isOutside()) { - if (cell.getMaxDistance() > tolerance) { - enqueueChildren(cell, farthestD); - } - } else { - if (cell.getMaxDistance() - farthestD > tolerance) { - enqueueChildren(cell, farthestD); - } else { - nextIterCells.add(cell); - } + // Optimality gap: no cell anywhere can beat the incumbent by > tol. + if (hmax[0] - bestD <= tol) { + break; } - } - double x = farthestCell.getX(); - double y = farthestCell.getY(); - double r = farthestCell.getDistance(); + // Pop the top and subdivide it into 4 children. + final double x = hx[0]; + final double y = hy[0]; + final double h2 = hh[0] * 0.5; + popTop(); + + final double reach = h2 * SQRT2; + evalChild(x - h2, y - h2, h2, reach); + evalChild(x + h2, y - h2, h2, reach); + evalChild(x - h2, y + h2, h2, reach); + evalChild(x + h2, y + h2, h2, reach); + } + final double x = bestX, y = bestY, r = bestD; addCircle(x, y, r); return new double[] { x, y, r }; } - private void addCircle(double x, double y, double r) { - if (circleCount == cx.length) { - int n = cx.length << 1; - cx = Arrays.copyOf(cx, n); - cy = Arrays.copyOf(cy, n); - cr = Arrays.copyOf(cr, n); - } - cx[circleCount] = x; - cy[circleCount] = y; - cr[circleCount] = r; - circleCount++; - } - - private void enqueueChildren(final Cell cell, final double farthestD) { - final double h2 = cell.getHSide() / 2.0; - - // optimistic bound for any child of this cell - final double maxChildPotential = cell.getDistance() + 2.0 * h2 * Cell.SQRT2; - if (maxChildPotential <= farthestD + tolerance) { - nextIterCells.add(cell); - return; - } + private void init() { + final Envelope env = boundary.getEnvelopeInternal(); - // Create 4 kids, push all (no sorting) - Cell c1 = createCellIfUseful(cell.x - h2, cell.y - h2, h2, farthestD); - Cell c2 = createCellIfUseful(cell.x + h2, cell.y - h2, h2, farthestD); - Cell c3 = createCellIfUseful(cell.x - h2, cell.y + h2, h2, farthestD); - Cell c4 = createCellIfUseful(cell.x + h2, cell.y + h2, h2, farthestD); + // Index distance to boundary rings AND obstacle linework. + // Sign is determined by boundary (modified by polygonal obstacles). + boundaryDistance = new PointDistanceIndex(boundary, obstacles); - if (c1 != null) { - cellStack.addLast(c1); - } - if (c2 != null) { - cellStack.addLast(c2); - } - if (c3 != null) { - cellStack.addLast(c3); - } - if (c4 != null) { - cellStack.addLast(c4); + // Seed the incumbent with the centroid (clamped by any seed circles). + final Point p = boundary.getCentroid(); + bestX = p.getX(); + bestY = p.getY(); + bestD = clampedDistance(bestX, bestY); + + // Initial square grid over the envelope. + final double minX = env.getMinX(), maxX = env.getMaxX(); + final double minY = env.getMinY(), maxY = env.getMaxY(); + final double cellSize = Math.min(env.getWidth(), env.getHeight()); + final double hSize = cellSize / 2.0; + final double reach = hSize * SQRT2; + + for (double gx = minX; gx < maxX; gx += cellSize) { + for (double gy = minY; gy < maxY; gy += cellSize) { + evalChild(gx + hSize, gy + hSize, hSize, reach); + } } + initialized = true; } - private Cell createCellIfUseful(final double x, final double y, final double h, final double farthestD) { - Cell c = createCell(x, y, h); - - if (c.getMaxDistance() > farthestD + tolerance) { - return c; - } - - if (!c.isFullyOutside()) { - nextIterCells.add(c); + /** + * Evaluates a child cell against all constraints and circles, updates the + * incumbent, and pushes it onto the heap unless it is entirely outside the + * feasible region ({@code maxDist < 0}). + */ + private void evalChild(final double px, final double py, final double h, final double reach) { + final double d = clampedDistance(px, py); + if (d > bestD) { + bestD = d; + bestX = px; + bestY = py; } - return null; - } - - private void createInitialGrid(Envelope env, Collection target) { - double minX = env.getMinX(), maxX = env.getMaxX(); - double minY = env.getMinY(), maxY = env.getMaxY(); - double cellSize = Math.min(env.getWidth(), env.getHeight()); - double hSize = cellSize / 2.0; - - for (double x = minX; x < maxX; x += cellSize) { - for (double y = minY; y < maxY; y += cellSize) { - target.add(createCell(x + hSize, y + hSize, hSize)); - } + final double max = d + reach; + if (max >= 0) { + push(px, py, h, d, max, circleCount); } } - private Cell createCell(final double x, final double y, final double h) { - Cell c = new Cell(x, y, h, distanceToConstraints(x, y)); - c.updateDistanceAll(cx, cy, cr, circleCount); - return c; - } - - private Cell createCentroidCell(Geometry geom) { - Point p = geom.getCentroid(); - Cell c = new Cell(p.getX(), p.getY(), 0, distanceToConstraints(p.getX(), p.getY())); - c.updateDistanceAll(cx, cy, cr, circleCount); - return c; + /** Raw signed distance to boundary/obstacle constraints at {@code (x,y)}. */ + private double signedDistance(final double x, final double y) { + tmp.x = x; + tmp.y = y; + return boundaryDistance.distance(tmp); } - private static final class Cell { - static final double SQRT2 = Math.sqrt(2); - - private final double x, y, hSide; - private double distance; // signed - private double maxDist; - - Cell(double x, double y, double hSide, double dist) { - this.x = x; - this.y = y; - this.hSide = hSide; - this.distance = dist; - this.maxDist = dist + hSide * SQRT2; - } - - void updateDistance(double cX, double cY, double cR) { - final double dx = x - cX; - final double dy = y - cY; + /** + * Signed distance to the constraint set at {@code (x,y)}, clamped by all + * circles found so far (distance to a previous circle's rim caps the value so + * new circles remain empty of old ones). + */ + private double clampedDistance(final double x, final double y) { + double D = signedDistance(x, y); + final double[] pcx = cx, pcy = cy, pcr = cr; + final int n = circleCount; + for (int i = 0; i < n; i++) { + final double r = pcr[i]; + final double t = D + r; + if (t <= 0) { + continue; // circle i cannot reduce D + } + final double dx = x - pcx[i]; + final double dy = y - pcy[i]; final double dsq = dx * dx + dy * dy; - - double D = distance; - double t = D + cR; - if (t > 0) { - double tsq = t * t; - if (dsq < tsq) { - double d = Math.sqrt(dsq) - cR; - if (d < D) { - distance = d; - maxDist = d + hSide * SQRT2; - } + if (dsq < t * t) { // sqrt(dsq) - r < D, so it improves + final double d = Math.sqrt(dsq) - r; + if (d < D) { + D = d; } } } + return D; + } - void updateDistanceAll(double[] cx, double[] cy, double[] cr, int n) { - double D = distance; - for (int i = 0; i < n; i++) { + // ------------------------------------------------------------------ + // Lazy max-heap (keyed on hmax) + // ------------------------------------------------------------------ + + /** + * Ensures the heap top is current with respect to all circles found so far, + * discarding cells that become fully infeasible. Loops because a sift-down + * after refreshing may surface another stale cell. + *

+ * Correctness of laziness: distances only decrease as circles are added, so a + * stale key over-estimates its true value; the heap top's stored key + * therefore dominates every true key in the heap, and once the top is fresh + * its key is the true global maximum. + */ + private void refreshTop() { + final int cc = circleCount; + while (heapSize > 0 && hstamp[0] != cc) { + double D = hd[0]; + final double x = hx[0], y = hy[0]; + // apply only the circles added since this cell was last touched + for (int i = hstamp[0]; i < cc; i++) { final double r = cr[i]; - double t = D + r; + final double t = D + r; if (t <= 0) { continue; } - final double dx = x - cx[i]; final double dy = y - cy[i]; final double dsq = dx * dx + dy * dy; - - final double tsq = t * t; - if (dsq < tsq) { - double d = Math.sqrt(dsq) - r; + if (dsq < t * t) { + final double d = Math.sqrt(dsq) - r; if (d < D) { D = d; } } } - if (D < distance) { - distance = D; - maxDist = D + hSide * SQRT2; + hstamp[0] = cc; + if (D < hd[0]) { + hd[0] = D; + final double max = D + hh[0] * SQRT2; + if (max < 0) { // now fully outside; discard + popTop(); + continue; + } + hmax[0] = max; + siftDown(0); // key decreased } + // if key unchanged, top is fresh and still the max → loop exits } + } - boolean isFullyOutside() { - return maxDist < 0; + private void push(final double x, final double y, final double h, final double d, final double max, + final int stamp) { + if (heapSize == hx.length) { + grow(); } - - boolean isOutside() { - return distance < 0; + int i = heapSize++; + // sift up with a hole (no swaps) + while (i > 0) { + final int parent = (i - 1) >>> 1; + if (hmax[parent] >= max) { + break; + } + copyCell(parent, i); + i = parent; } + hx[i] = x; + hy[i] = y; + hh[i] = h; + hd[i] = d; + hmax[i] = max; + hstamp[i] = stamp; + } - double getMaxDistance() { - return maxDist; + private void popTop() { + final int last = --heapSize; + if (last > 0) { + copyCell(last, 0); + siftDown(0); } + } - double getDistance() { - return distance; + private void siftDown(int i) { + final int n = heapSize; + final double x = hx[i], y = hy[i], h = hh[i], d = hd[i], max = hmax[i]; + final int stamp = hstamp[i]; + final int half = n >>> 1; // nodes >= half are leaves + while (i < half) { + int child = (i << 1) + 1; + final int right = child + 1; + if (right < n && hmax[right] > hmax[child]) { + child = right; + } + if (hmax[child] <= max) { + break; + } + copyCell(child, i); + i = child; } + hx[i] = x; + hy[i] = y; + hh[i] = h; + hd[i] = d; + hmax[i] = max; + hstamp[i] = stamp; + } - double getHSide() { - return hSide; - } + private void copyCell(final int from, final int to) { + hx[to] = hx[from]; + hy[to] = hy[from]; + hh[to] = hh[from]; + hd[to] = hd[from]; + hmax[to] = hmax[from]; + hstamp[to] = hstamp[from]; + } - double getX() { - return x; - } + private void grow() { + final int n = hx.length << 1; + hx = Arrays.copyOf(hx, n); + hy = Arrays.copyOf(hy, n); + hh = Arrays.copyOf(hh, n); + hd = Arrays.copyOf(hd, n); + hmax = Arrays.copyOf(hmax, n); + hstamp = Arrays.copyOf(hstamp, n); + } - double getY() { - return y; + private void addCircle(final double x, final double y, final double r) { + if (circleCount == cx.length) { + final int n = cx.length << 1; + cx = Arrays.copyOf(cx, n); + cy = Arrays.copyOf(cy, n); + cr = Arrays.copyOf(cr, n); } + cx[circleCount] = x; + cy[circleCount] = y; + cr[circleCount] = r; + circleCount++; } } \ No newline at end of file diff --git a/src/test/java/micycle/pgs/PGS_VoronoiTests.java b/src/test/java/micycle/pgs/PGS_VoronoiTests.java index a7c7b84b..2fdb8e6c 100644 --- a/src/test/java/micycle/pgs/PGS_VoronoiTests.java +++ b/src/test/java/micycle/pgs/PGS_VoronoiTests.java @@ -43,7 +43,7 @@ void testPowerDiagram() { @Test void testAdditivelyWeightedVoronoi() { - assertValidVoronoi(PGS_Voronoi::additivelyWeightedVoronoi, true); + assertValidVoronoi(PGS_Voronoi::additivelyWeightedVoronoi); } @Test