PathFlattener.kt
src/jvmMain/kotlin/afield/geometry/path/PathFlattener.kt · 249 lines
Converts between arc length along an FLine and its node parameter, by flattening its curves to a tolerance.
package afield.geometry.path
import afield.lang.remotejs.JsFun
import afield.math.linalg.Vec3
import afield.geometry.path.java2d.FLinesAndJavaShapes
import java.util.*
import kotlin.math.max
import kotlin.math.sqrt
class PathFlattener(private val c: FLine, tol: Float)
Flattens a line to tolerance tol; lengthToDot turns a distance along it into a node parameter and dotToLength goes back.
class PathFlattener(private val c: FLine, tol: Float) {
private val cursor = FLinesAndJavaShapes.Cursor(c, tol)
inner class Mapping
var dotStart: Float = 0.0F // node.t
var dotEnd: Float = 0.0f // node.t
var start: Vec3? = null
var end: Vec3? = null
var cumulativeDistanceAtEnd: Float = 0.0f
override fun toString(): String
override fun toString(): String {
return "$dotStart->$dotEnd > $cumulativeDistanceAtEnd"
}
}
var mappings: MutableList<Mapping> = ArrayList()
private val tol = tol
var searchDistance: Comparator<Any>
var searchDistance: Comparator<Any> = object : Comparator<Any> {
override fun compare(o1: Any, o2: Any): Int {
val f1 = if (o1 is Number) o1.toFloat() else (o1 as Mapping).cumulativeDistanceAtEnd
val f2 = if (o2 is Number) o2.toFloat() else (o2 as Mapping).cumulativeDistanceAtEnd
return java.lang.Float.compare(f1, f2)
}
}
var searchDot: Comparator<Any>
var searchDot: Comparator<Any> = object : Comparator<Any> {
override fun compare(o1: Any, o2: Any): Int {
val f1 = if (o1 is Number) o1.toFloat() else (o1 as Mapping).dotEnd
val f2 = if (o2 is Number) o2.toFloat() else (o2 as Mapping).dotEnd
return java.lang.Float.compare(f1, f2)
}
}
fun length(): Float
fun length(): Float {
if (mappings.size < 1) return 0.0f
return mappings[mappings.size - 1].cumulativeDistanceAtEnd
}
fun getMappingSublist(length: Float): List<Mapping>
fun getMappingSublist(length: Float): List<Mapping> {
val found = Collections.binarySearch<Any>(mappings, length, searchDistance)
if (found >= 0) return mappings.subList(0, found + 1)
return mappings.subList(0, -found - 1 + 1)
}
fun lengthToDot(length: Float): Float
fun lengthToDot(length: Float): Float {
if (length == 0.0f) return 0.0f
if (mappings.size == 0) return 0.0f
val found = Collections.binarySearch<Any>(mappings, length, searchDistance)
if (found >= 0) return mappings[found].dotEnd
val leftOf = -found - 1
val rightOf = leftOf - 1
if (leftOf > mappings.size - 1) return mappings[mappings.size - 1].dotEnd
val l1 = if (rightOf >= 0) mappings[rightOf].cumulativeDistanceAtEnd else 0.0f
val l2 = mappings[leftOf].cumulativeDistanceAtEnd
if (l2 == l1) return mappings[leftOf].dotEnd
val x = (length - l1) / (l2 - l1)
val de = mappings[leftOf].dotStart * (1 - x) + x * mappings[leftOf].dotEnd
return de
}
fun dotToLength(dot: Float): Float
fun dotToLength(dot: Float): Float {
// `lengthToDot` guards this case and `dotToLength` did not, so a line with no drawable
// segment at all — a bare `moveTo`, or an empty FLine — reached `mappings[-1]` below and
// threw IndexOutOfBoundsException out of `Cursor.d`. `sampleByDistance` reads `Cursor.d` in
// its loop condition, so `FLine().moveTo(x, y).sampleByDistance(5f)` blew up.
if (mappings.isEmpty()) return 0.0f
val found = Collections.binarySearch<Any>(mappings, dot, searchDot)
if (found >= 0) return mappings[found].cumulativeDistanceAtEnd
val leftOf = -found - 1
val rightOf = leftOf - 1
if (leftOf > mappings.size - 1) return mappings[mappings.size - 1].cumulativeDistanceAtEnd
val l1 = mappings[leftOf].dotStart
val l2 = mappings[leftOf].dotEnd
if (l2 == l1) return mappings[rightOf].cumulativeDistanceAtEnd
val x = (dot - l1) / (l2 - l1)
val de =
(if (rightOf >= 0) mappings[rightOf].cumulativeDistanceAtEnd else 0.0f) * (1 - x) + x * mappings[leftOf].cumulativeDistanceAtEnd
return de
}
private fun emitLinearFrame(dotStart: Float, dotEnd: Float, a: Vec3, b: Vec3) {
val m: Mapping = Mapping()
m.start = a
m.end = b
m.dotStart = dotStart
m.dotEnd = dotEnd
if (mappings.size == 0) m.cumulativeDistanceAtEnd = b.distance(a)
else m.cumulativeDistanceAtEnd = b.distance(a) + mappings[mappings.size - 1].cumulativeDistanceAtEnd
mappings.add(m)
}
var tmp: Vec3 = Vec3()
init
body — 66 lines, from line 127
init {
var index = 0
while (cursor.hasNext()) {
val v: Array<Vec3> = cursor.next()
// System.out.println(" cursor :"+ Arrays.asList(v)+" "+index);
if (v.size == 4) {
emitCubicFrame(index.toFloat(), (index + 1).toFloat(), v[0], v[1], v[2], v[3], 0)
} else if (v.size == 2) {
emitLinearFrame(index.toFloat(), (index + 1).toFloat(), v[0], v[1])
}
index++
}
}
private fun emitCubicFrame(
dotStart: Float,
dotEnd: Float,
a: Vec3,
c1: Vec3,
c2: Vec3,
b: Vec3,
sub: Int
) {
var c1: Vec3 = c1
var c2: Vec3 = c2
if (sub > maxSubDiv) {
// Report both terms: reporting only the flatness is actively misleading now, because the
// curves that recurse deepest are the ones whose flatness is 0.
System.err.println(
"warning: maxSubDiv reached :" + dotStart + " " + dotEnd + " " + a + " " + c1 + " " + c2 + " " + b +
" -> flatness " + flatnessFor(a, c1, c2, b) + " skew " + parameterSkewFor(a, c1, c2, b)
)
// Emit the chord anyway. Returning empty-handed dropped this piece of the curve out of
// the table entirely, which silently under-reports `length()` and shifts every distance
// past this point — a much worse failure than a coarse mapping.
emitLinearFrame(dotStart, dotEnd, a, b)
return
}
val f = subdivisionErrorFor(a, c1, c2, b)
if (f > tol || sub < minSubDiv) {
val c12: Vec3 = Vec3()
val c21: Vec3 = Vec3()
val m: Vec3 = Vec3()
FLinesAndJavaShapes.splitCubicFrame(
a,
Vec3(c1).also { c1 = it },
Vec3(c2).also { c2 = it },
b,
0.5f,
c12,
m,
c21,
tmp
)
val mp = dotStart + (dotEnd - dotStart) * 0.5f
emitCubicFrame(dotStart, mp, a, c1, c12, m, sub + 1)
emitCubicFrame(mp, dotEnd, m, c21, c2, b, sub + 1)
} else {
emitLinearFrame(dotStart, dotEnd, a, b)
}
}
How badly a straight line from a to b stands in for this cubic, for the purpose of
building an arc-length table.
This is deliberately not just flatnessFor. A leaf of this recursion becomes a Mapping
whose distance advances linearly in t, so a leaf is only acceptable when the cubic is
both geometrically straight and evenly parameterised, and flatnessFor can only see the
first of those. The classic counter-example is a cubic whose control points sit exactly on
the chord but bunched towards one end: its flatness is exactly 0.0, so no tolerance can
ever force a subdivision, yet t = 0.5 can be four fifths of the way along.
The second term closes that hole: the distance between the curve's mid-parameter point
B(0.5) and the chord's mid-point. It is zero exactly when t is (locally) tracking
distance, and for the collinear example above it is 161 units on a 500 unit chord.
Neither term subsumes the other. A symmetric S-curve has B(0.5) sitting precisely on the
chord's midpoint — the parameterisation term is 0 — while its control points are 200 units
off the chord. So take the larger of the two.
private fun subdivisionErrorFor(a: Vec3, c1: Vec3, c2: Vec3, b: Vec3): Float =
max(flatnessFor(a, c1, c2, b), parameterSkewFor(a, c1, c2, b))
private fun flatnessFor(a: Vec3, c1: Vec3, c2: Vec3, b: Vec3): Float {
// return (Float) CubicCurve2D.getFlatness(a.x, a.y, c1.x, c1.y, c2.x, c2.y, b.x, b.y);
val f1 = FLinesAndJavaShapes.ptSegDistSq3(a.x, a.y, a.z, b.x, b.y, b.z, c1.x, c1.y, c1.z)
val f2 = FLinesAndJavaShapes.ptSegDistSq3(a.x, a.y, a.z, b.x, b.y, b.z, c2.x, c2.y, c2.z)
return sqrt(max(f1, f2))
}
|B(0.5) - (a + b) / 2|. The Bernstein weights at t = 0.5 are 1/8, 3/8, 3/8, 1/8.
private fun parameterSkewFor(a: Vec3, c1: Vec3, c2: Vec3, b: Vec3): Float {
val dx = 0.125f * (a.x + 3 * c1.x + 3 * c2.x + b.x) - 0.5f * (a.x + b.x)
val dy = 0.125f * (a.y + 3 * c1.y + 3 * c2.y + b.y) - 0.5f * (a.y + b.y)
val dz = 0.125f * (a.z + 3 * c1.z + 3 * c2.z + b.z) - 0.5f * (a.z + b.z)
return sqrt(dx * dx + dy * dy + dz * dz)
}
companion object
var maxSubDiv: Int = 10
var minSubDiv: Int = 2
Subdivide every cubic at least this many times whatever the error metric says.
Adaptive subdivision is blind to symmetry: a cubic can be collinear and symmetric
about its chord midpoint, so that both terms of subdivisionErrorFor are exactly zero
while the parameterisation in between is still badly skewed. Two forced levels break
that symmetry — the halves of such a curve are not themselves symmetric — at a floor of
4 leaves per cubic. Anything actually curved already produces hundreds of leaves, so
this only ever binds on the straight-ish segments where it is needed.