diff --git a/orx-noise/src/commonMain/kotlin/PoissonDisk.kt b/orx-noise/src/commonMain/kotlin/PoissonDisk.kt index 305fdceb..f09b9639 100644 --- a/orx-noise/src/commonMain/kotlin/PoissonDisk.kt +++ b/orx-noise/src/commonMain/kotlin/PoissonDisk.kt @@ -27,6 +27,7 @@ internal const val epsilon = 0.0000001 * @param tries number of candidates per point * @param randomOnRing generate random points on a ring with an annulus from r to 2r * @param random a random number generator, default value is [Random.Default] + * @param initialPoints a list of points in sampler space, these points will not be tested against [r] * @param boundsMapper a custom function to check if a point is within bounds * @return a list of points @@ -38,7 +39,7 @@ fun poissonDiskSampling( tries: Int = 30, randomOnRing: Boolean = false, random: Random = Random.Default, - initialPoint: Vector2 = Vector2(width/2.0, height/2.0), + initialPoints: List = listOf(Vector2(width/2.0, height/2.0)), boundsMapper: ((w: Double, h: Double, v: Vector2) -> Boolean)? = null, ): List { val disk = mutableListOf() @@ -51,21 +52,23 @@ fun poissonDiskSampling( val rows = ceil(height / cellSize).toInt() val cols = ceil(width / cellSize).toInt() - val grid = MutableList(rows * cols) { -1 } + val grid = Array(rows * cols) { -1 } fun addPoint(v: Vector2) { val x = (v.x / cellSize).fastFloor() val y = (v.y / cellSize).fastFloor() val index = x + y * cols - disk.add(v) - - grid[index] = disk.lastIndex - - queue.add(disk.lastIndex) + if (x >= 0 && y >= 0 && x < cols && y < rows) { + disk.add(v) + grid[index] = disk.lastIndex + queue.add(disk.lastIndex) + } } - addPoint(initialPoint) + for (initialPoint in initialPoints) { + addPoint(initialPoint) + } val boundsRect = Rectangle(0.0, 0.0, width, height) @@ -81,19 +84,20 @@ fun poissonDiskSampling( } else { active + Polar(random.nextDouble(0.0, 360.0), radius).cartesian } - if (!boundsRect.contains(c)) continue@candidateSearch - // check if it's within bounds - // choose another candidate if it's not - if (boundsMapper != null && !boundsMapper(width, height, c)) continue@candidateSearch - val x = (c.x / cellSize).fastFloor() val y = (c.y / cellSize).fastFloor() + // EJ: early bail-out; + // if grid[y,x] is populated we know that its inhabitant is within the minimum point distance + if (grid[x + y * cols] != -1) { + continue@candidateSearch + } + // Check closest neighbours in a 5x5 grid - for (ix in (-2..2)) { - for (iy in (-2..2)) { + for (iy in (-2..2)) { + for (ix in (-2..2)) { val nx = clamp(x + ix, 0, cols - 1) val ny = clamp(y + iy, 0, rows - 1) @@ -109,10 +113,15 @@ fun poissonDiskSampling( } } + // check if the candidate point is within bounds + // EJ: This is somewhat counter-intuitively moved to the last stage in the process; + // It turns out that the above neighbour search is much more affordable than the bounds check in the + // case of complex bounds (such as described by Shapes or ShapeContours). A simple benchmark shows a + // speed-up of roughly 300% + if (boundsMapper != null && !boundsMapper(width, height, c)) continue@candidateSearch + addPoint(c) - candidateAccepted = true - break } @@ -121,6 +130,6 @@ fun poissonDiskSampling( queue.remove(activeIndex) } } - return disk -} \ No newline at end of file +} + diff --git a/orx-noise/src/commonMain/kotlin/ShapeNoise.kt b/orx-noise/src/commonMain/kotlin/ShapeNoise.kt index b1acd9e6..17597189 100644 --- a/orx-noise/src/commonMain/kotlin/ShapeNoise.kt +++ b/orx-noise/src/commonMain/kotlin/ShapeNoise.kt @@ -4,27 +4,72 @@ import org.openrndr.math.Vector2 import org.openrndr.shape.* import kotlin.random.Random -fun ShapeProvider.uniform(random: Random = Random.Default): Vector2 { +fun ShapeProvider.uniform(distanceToEdge: Double = 0.0, random: Random = Random.Default): Vector2 { val shape = shape + require(!shape.empty) + var attempts = 0 return Vector2.uniformSequence(shape.bounds, random).first { - shape.contains(it) + attempts++ + require(attempts < 100) + if (distanceToEdge == 0.0) { + shape.contains(it) + } else { + shape.contains(it) && shape.contours.minOf { c -> c.nearest(it).position.distanceTo(it) } > distanceToEdge + } } } fun ShapeProvider.poissonDiskSampling( - r: Double, + pointDistance: Double, + distanceToEdge: Double = 0.0, tries: Int = 30, random: Random = Random.Default ): List { val shape = shape + if (shape.empty) { + return emptyList() + } val bounds = shape.bounds val poissonBounds = Rectangle(0.0, 0.0, bounds.width, bounds.height) - val initialPoint = this.uniform(random).map(bounds, poissonBounds) + val initialPoints = shape.splitCompounds().flatMap { compound -> + compound.outline.segments.map { + val t = random.nextDouble() + (it.position(t) - it.normal(t).normalized * distanceToEdge) + }.filter { compound.contains(it) && compound.outline.nearest(it).position.distanceTo(it) >= distanceToEdge-1E-1 }.map { + it.map(bounds, poissonBounds) + } + } - return poissonDiskSampling(bounds.width, bounds.height, r, tries, false, random, initialPoint) { _, _, point -> + val candidatePoints = mutableListOf() + for (point in initialPoints) { + if ((candidatePoints.map { it.distanceTo(point) }.minOrNull() ?: Double.POSITIVE_INFINITY) >= pointDistance) { + candidatePoints.add(point) + } + } + + + if (candidatePoints.isEmpty()) { + return emptyList() + } + + return poissonDiskSampling( + bounds.width, + bounds.height, + pointDistance, + tries, + false, + random, + candidatePoints, + ) { _, _, point -> val contourPoint = point.map(poissonBounds, bounds) - shape.contains(contourPoint) + if (distanceToEdge == 0.0) { + shape.contains(contourPoint) + } else { + shape.contains(contourPoint) && shape.contours.minOf { c -> + c.nearest(contourPoint).position.distanceTo(contourPoint) + } > distanceToEdge + } }.map { it.map(poissonBounds, bounds) }