LowSegmentFLineFitting.kt
src/jvmMain/kotlin/afield/geometry/path/LowSegmentFLineFitting.kt · 578 lines
Fits a point sequence with as few line and arc segments as its error budget allows.
fitLowSegmentFLine returns the fitted FLine, and fitLowSegmentFLineResult also reports the
error in a LowSegmentFLineFitResult; LowSegmentFLineFitConfig sets the segment limit and the
improvement a split must earn. Tests: LowSegmentFLineFittingTest.
package afield.geometry.path
import afield.lang.remotejs.JsFun
import afield.math.linalg.Vec3
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan
data class LowSegmentFLineFitConfig
Controls the conservative low-segment fitter used by fitLowSegmentFLine.
Error is measured geometrically against each primitive, not by assuming that input point indices are uniformly spaced along that primitive.
data class LowSegmentFLineFitConfig(
/** Most primitives the fit may use; also capped at one fewer than the number of input points. */
val maxSegments: Int = 3,
/** An extra segment must cut the RMS error by at least this fraction of the current error. */
val minSplitRelativeImprovement: Double = 0.30,
/**
* And by at least this much outright — as a fraction of the points' bounding-box diagonal
* (floored at 1), not as a distance in the input's own units.
*/
val minSplitAbsoluteImprovement: Double = 0.02,
/**
* Charged per primitive beyond the first when comparing two fits, in the same bounding-box
* diagonal units: the cheaper fit wins even when both pass the improvement tests.
*/
val complexityPenalty: Double = 0.015,
/** An arc must beat the straight line's RMS error by at least this fraction of it. */
val minArcRelativeImprovement: Double = 0.15,
/** And by at least this much outright, in bounding-box diagonal units as above. */
val minArcAbsoluteImprovement: Double = 0.005,
/** Largest sweep a single arc primitive may cover, radians. Clamped to just under a full turn. */
val maxArcSweep: Double = PI * 1.75,
/**
* Largest sweep carried by one cubic when the arc is written out; longer arcs are split into
* that many cubics. Clamped to at most π.
*/
val maxArcSweepPerCubic: Double = PI / 2.0,
/**
* Rejects the near-straight arcs: drops any candidate whose circle centre would sit farther
* from the chord than this multiple of the chord's length.
*/
val maxArcRadiusToChordRatio: Double = 1_000.0,
/** How many candidate arc heights to score per chord; at least 3. */
val arcCandidateCount: Int = 17
)
data class LowSegmentFLineFitResult
data class LowSegmentFLineFitResult(
val line: FLine,
val rmsError: Double,
val maxError: Double,
val segmentCount: Int,
val primitives: List<LowSegmentFLineFitPrimitive>
)
sealed class LowSegmentFLineFitPrimitive
abstract val startIndex: Int
abstract val endIndex: Int
abstract val start: Vec3
abstract val end: Vec3
abstract val rmsError: Double
abstract val maxError: Double
data class Line
data class Line(
override val startIndex: Int,
override val endIndex: Int,
override val start: Vec3,
override val end: Vec3,
override val rmsError: Double,
override val maxError: Double
) : LowSegmentFLineFitPrimitive()
data class Arc
data class Arc(
override val startIndex: Int,
override val endIndex: Int,
override val start: Vec3,
override val end: Vec3,
override val rmsError: Double,
override val maxError: Double,
val center: Vec3,
val radius: Double,
val sweep: Double
) : LowSegmentFLineFitPrimitive()
}
fun lowSegmentFLine
fun lowSegmentFLine(
points: Iterable<Vec3>,
config: LowSegmentFLineFitConfig = LowSegmentFLineFitConfig()
): FLine = points.fitLowSegmentFLine(config)
fun Iterable<Vec3>.fitLowSegmentFLine
fun Iterable<Vec3>.fitLowSegmentFLine(
config: LowSegmentFLineFitConfig = LowSegmentFLineFitConfig()
): FLine = fitLowSegmentFLineResult(config).line
fun Iterable<Vec3>.fitLowSegmentFLineResult
body — 464 lines, from line 113
fun Iterable<Vec3>.fitLowSegmentFLineResult(
config: LowSegmentFLineFitConfig = LowSegmentFLineFitConfig()
): LowSegmentFLineFitResult {
val source = dedupeConsecutive(map { Vec3(it) })
require(source.all { it.isFinite() }) { "fitLowSegmentFLine: all points must be finite" }
if (source.isEmpty()) {
return LowSegmentFLineFitResult(FLine(), 0.0, 0.0, 0, emptyList())
}
if (source.size == 1) {
val line = FLine().moveTo(source.first())
return LowSegmentFLineFitResult(line, 0.0, 0.0, 0, emptyList())
}
val scale = pointScale(source)
val tuned = config.copy(
maxSegments = config.maxSegments.coerceAtLeast(1),
maxArcSweep = config.maxArcSweep.coerceIn(1e-6, PI * 2.0 - 1e-6),
maxArcSweepPerCubic = config.maxArcSweepPerCubic.coerceIn(1e-6, PI),
arcCandidateCount = config.arcCandidateCount.coerceAtLeast(3)
)
val maxSegments = min(tuned.maxSegments, source.size - 1)
val primitiveCache = Array(source.size) { arrayOfNulls<PrimitiveFit>(source.size) }
for (start in 0 until source.lastIndex) {
for (end in start + 1 until source.size) {
primitiveCache[start][end] = fitPrimitive(source, start, end, tuned, scale)
}
}
val models = fitSegmentModels(source.size, primitiveCache, maxSegments)
val chosen = chooseConservativeModel(models, tuned, scale)
?: models.first()
val line = chosen.toFLine(tuned)
return LowSegmentFLineFitResult(
line = line,
rmsError = chosen.rms,
maxError = chosen.maxError,
segmentCount = chosen.primitives.size,
primitives = chosen.primitives.map { it.toPublicPrimitive() }
)
}
private enum class PrimitiveKind { Line, Arc }
private data class PrimitiveFit(
val kind: PrimitiveKind,
val startIndex: Int,
val endIndex: Int,
val start: Vec3,
val end: Vec3,
val sumSq: Double,
val count: Int,
val maxError: Double,
val arc: ArcGeometry? = null
) {
val rms: Double get() = if (count <= 0) 0.0 else sqrt(sumSq / count)
}
private data class ArcGeometry(
val origin: Vec3,
val xAxis: Vec3,
val yAxis: Vec3,
val centerX: Double,
val centerY: Double,
val radius: Double,
val startAngle: Double,
val sweep: Double
) {
fun localPoint(angle: Double): Vec3 {
val x = centerX + radius * cos(angle)
val y = centerY + radius * sin(angle)
return Vec3(origin)
.add(Vec3(xAxis).mul(x.toFloat()))
.add(Vec3(yAxis).mul(y.toFloat()))
}
fun centerPoint(): Vec3 = Vec3(origin)
.add(Vec3(xAxis).mul(centerX.toFloat()))
.add(Vec3(yAxis).mul(centerY.toFloat()))
}
private data class SegmentModel(
val primitives: List<PrimitiveFit>,
val sumSq: Double,
val count: Int,
val maxError: Double
) {
val rms: Double get() = if (count <= 0) 0.0 else sqrt(sumSq / count)
fun plus(next: PrimitiveFit): SegmentModel = SegmentModel(
primitives = primitives + next,
sumSq = sumSq + next.sumSq,
count = count + next.count - 1,
maxError = max(maxError, next.maxError)
)
}
private fun fitSegmentModels(
pointCount: Int,
primitiveCache: Array<Array<PrimitiveFit?>>,
maxSegments: Int
): List<SegmentModel> {
val n = pointCount
val dp = Array(maxSegments + 1) { arrayOfNulls<SegmentModel>(n) }
for (end in 1 until n) {
val primitive = primitiveCache[0][end] ?: continue
dp[1][end] = SegmentModel(listOf(primitive), primitive.sumSq, primitive.count, primitive.maxError)
}
for (segments in 2..maxSegments) {
for (end in segments until n) {
var best: SegmentModel? = null
for (split in segments - 1 until end) {
val previous = dp[segments - 1][split] ?: continue
val primitive = primitiveCache[split][end] ?: continue
val candidate = previous.plus(primitive)
if (best == null || candidate.rms < best.rms) best = candidate
}
dp[segments][end] = best
}
}
return (1..maxSegments).mapNotNull { dp[it][n - 1] }
}
private fun chooseConservativeModel(
models: List<SegmentModel>,
config: LowSegmentFLineFitConfig,
scale: Double
): SegmentModel? {
if (models.isEmpty()) return null
var accepted = models.first()
for (candidate in models.drop(1)) {
val improvement = accepted.rms - candidate.rms
val relative = improvement / max(accepted.rms, scale * 1e-9)
val absoluteOk = improvement >= config.minSplitAbsoluteImprovement.coerceAtLeast(0.0) * scale
val relativeOk = relative >= config.minSplitRelativeImprovement.coerceAtLeast(0.0)
val acceptedScore = accepted.rms + config.complexityPenalty.coerceAtLeast(0.0) * scale * (accepted.primitives.size - 1)
val candidateScore = candidate.rms + config.complexityPenalty.coerceAtLeast(0.0) * scale * (candidate.primitives.size - 1)
if (absoluteOk && relativeOk && candidateScore < acceptedScore) {
accepted = candidate
}
}
return accepted
}
private fun fitPrimitive(
points: List<Vec3>,
start: Int,
end: Int,
config: LowSegmentFLineFitConfig,
scale: Double
): PrimitiveFit {
val line = fitLinePrimitive(points, start, end)
val arc = fitArcPrimitive(points, start, end, config, scale) ?: return line
val improvement = line.rms - arc.rms
val relative = improvement / max(line.rms, scale * 1e-9)
val absoluteOk = improvement >= config.minArcAbsoluteImprovement.coerceAtLeast(0.0) * scale
val relativeOk = relative >= config.minArcRelativeImprovement.coerceAtLeast(0.0)
return if (absoluteOk && relativeOk && arc.maxError <= line.maxError) arc else line
}
private fun fitLinePrimitive(points: List<Vec3>, start: Int, end: Int): PrimitiveFit {
val a = points[start]
val b = points[end]
var sumSq = 0.0
var maxError = 0.0
for (i in start..end) {
val d = pointToSegmentDistance(points[i], a, b)
sumSq += d * d
maxError = max(maxError, d)
}
return PrimitiveFit(
kind = PrimitiveKind.Line,
startIndex = start,
endIndex = end,
start = Vec3(a),
end = Vec3(b),
sumSq = sumSq,
count = end - start + 1,
maxError = maxError
)
}
private fun fitArcPrimitive(
points: List<Vec3>,
start: Int,
end: Int,
config: LowSegmentFLineFitConfig,
scale: Double
): PrimitiveFit? {
if (end - start < 2) return null
val a = points[start]
val b = points[end]
val chord = Vec3(b).sub(a)
val chordLength = chord.length().toDouble()
if (chordLength <= scale * 1e-7) return null
val xAxis = Vec3(chord).normalize()
var bestPerp = Vec3()
var bestPerpLength = 0.0
for (i in start + 1 until end) {
val ap = Vec3(points[i]).sub(a)
val along = xAxis.dot(ap)
val perp = ap.sub(Vec3(xAxis).mul(along))
val length = perp.length().toDouble()
if (length > bestPerpLength) {
bestPerpLength = length
bestPerp = perp
}
}
if (bestPerpLength <= scale * 1e-5) return null
val yAxis = bestPerp.normalize()
val normal = Vec3(xAxis).cross(yAxis).normalize()
if (normal.lengthSquared() <= 1e-12f) return null
val projected = ArrayList<ProjectedPoint>(end - start + 1)
for (i in start..end) {
val ap = Vec3(points[i]).sub(a)
projected += ProjectedPoint(
x = xAxis.dot(ap).toDouble(),
y = yAxis.dot(ap).toDouble(),
z = normal.dot(ap).toDouble()
)
}
val hCandidates = arcHeightCandidates(projected, chordLength, config)
var best: PrimitiveFit? = null
for (h in hCandidates) {
val radius = sqrt(chordLength * chordLength * 0.25 + h * h)
if (!radius.isFinite() || radius <= scale * 1e-7) continue
val centerX = chordLength * 0.5
val startAngle = atan2(-h, -centerX)
val endAngle = atan2(-h, centerX)
val positiveSweep = positiveAngle(endAngle - startAngle)
val negativeSweep = -positiveAngle(startAngle - endAngle)
val sweeps = doubleArrayOf(positiveSweep, negativeSweep)
for (sweep in sweeps) {
if (abs(sweep) > config.maxArcSweep) continue
val scored = scoreArc(projected, centerX, h, radius, startAngle, sweep)
val arc = ArcGeometry(
origin = Vec3(a),
xAxis = Vec3(xAxis),
yAxis = Vec3(yAxis),
centerX = centerX,
centerY = h,
radius = radius,
startAngle = startAngle,
sweep = sweep
)
val fit = PrimitiveFit(
kind = PrimitiveKind.Arc,
startIndex = start,
endIndex = end,
start = Vec3(a),
end = Vec3(b),
sumSq = scored.first,
count = projected.size,
maxError = scored.second,
arc = arc
)
if (best == null || fit.rms < best.rms) best = fit
}
}
return best
}
private data class ProjectedPoint(val x: Double, val y: Double, val z: Double)
private fun arcHeightCandidates(
projected: List<ProjectedPoint>,
chordLength: Double,
config: LowSegmentFLineFitConfig
): List<Double> {
val raw = ArrayList<Double>()
val maxAbsH = chordLength * config.maxArcRadiusToChordRatio.coerceAtLeast(1.0)
for (i in 1 until projected.lastIndex) {
val p = projected[i]
if (abs(p.y) <= chordLength * 1e-8) continue
val h = (p.x * p.x - chordLength * p.x + p.y * p.y) / (2.0 * p.y)
if (h.isFinite() && abs(h) <= maxAbsH) raw += h
}
if (raw.isEmpty()) return emptyList()
raw.sort()
if (raw.size <= config.arcCandidateCount) return raw.distinctBy { quantize(it, chordLength) }
val out = ArrayList<Double>(config.arcCandidateCount)
for (i in 0 until config.arcCandidateCount) {
val t = if (config.arcCandidateCount == 1) 0.0 else i.toDouble() / (config.arcCandidateCount - 1).toDouble()
val idx = (t * (raw.size - 1)).toInt().coerceIn(0, raw.lastIndex)
out += raw[idx]
}
out += raw[raw.size / 2]
return out.distinctBy { quantize(it, chordLength) }
}
private fun scoreArc(
points: List<ProjectedPoint>,
centerX: Double,
centerY: Double,
radius: Double,
startAngle: Double,
sweep: Double
): Pair<Double, Double> {
var sumSq = 0.0
var maxError = 0.0
for (p in points) {
val dx = p.x - centerX
val dy = p.y - centerY
val angle = atan2(dy, dx)
val inArc = angleInSweep(startAngle, sweep, angle)
val planarDistance = if (inArc) {
abs(sqrt(dx * dx + dy * dy) - radius)
} else {
val dStart = sqrt(p.x * p.x + p.y * p.y)
val dEnd = sqrt((p.x - points.last().x) * (p.x - points.last().x) + p.y * p.y)
min(dStart, dEnd)
}
val d = sqrt(planarDistance * planarDistance + p.z * p.z)
sumSq += d * d
maxError = max(maxError, d)
}
return sumSq to maxError
}
private fun SegmentModel.toFLine(config: LowSegmentFLineFitConfig): FLine {
val out = FLine()
if (primitives.isEmpty()) return out
out.moveTo(primitives.first().start)
for (primitive in primitives) {
when (primitive.kind) {
PrimitiveKind.Line -> out.lineTo(primitive.end)
PrimitiveKind.Arc -> appendArc(out, primitive, config)
}
}
return out
}
private fun appendArc(out: FLine, primitive: PrimitiveFit, config: LowSegmentFLineFitConfig) {
val arc = primitive.arc ?: run {
out.lineTo(primitive.end)
return
}
val chunks = ceil(abs(arc.sweep) / config.maxArcSweepPerCubic).toInt().coerceAtLeast(1)
for (i in 0 until chunks) {
val a0 = arc.startAngle + arc.sweep * i.toDouble() / chunks.toDouble()
val a1 = arc.startAngle + arc.sweep * (i + 1).toDouble() / chunks.toDouble()
val delta = a1 - a0
val k = 4.0 / 3.0 * tan(delta / 4.0)
val p0 = arc.localPoint(a0)
val p3 = if (i == chunks - 1) primitive.end else arc.localPoint(a1)
val t0 = localTangent(arc, a0)
val t1 = localTangent(arc, a1)
val c1 = Vec3(p0).add(t0.mul((k * arc.radius).toFloat()))
val c2 = Vec3(p3).sub(t1.mul((k * arc.radius).toFloat()))
out.cubicTo(c1, c2, p3)
}
}
private fun localTangent(arc: ArcGeometry, angle: Double): Vec3 {
return Vec3(arc.xAxis).mul((-sin(angle)).toFloat())
.add(Vec3(arc.yAxis).mul(cos(angle).toFloat()))
}
private fun PrimitiveFit.toPublicPrimitive(): LowSegmentFLineFitPrimitive {
return when (kind) {
PrimitiveKind.Line -> LowSegmentFLineFitPrimitive.Line(
startIndex = startIndex,
endIndex = endIndex,
start = Vec3(start),
end = Vec3(end),
rmsError = rms,
maxError = maxError
)
PrimitiveKind.Arc -> LowSegmentFLineFitPrimitive.Arc(
startIndex = startIndex,
endIndex = endIndex,
start = Vec3(start),
end = Vec3(end),
rmsError = rms,
maxError = maxError,
center = arc?.centerPoint() ?: Vec3(start),
radius = arc?.radius ?: 0.0,
sweep = arc?.sweep ?: 0.0
)
}
}
private fun pointToSegmentDistance(point: Vec3, start: Vec3, end: Vec3): Double {
val dx = (end.x - start.x).toDouble()
val dy = (end.y - start.y).toDouble()
val dz = (end.z - start.z).toDouble()
val lenSq = dx * dx + dy * dy + dz * dz
if (lenSq <= 1e-18) return distance(point, start)
val ax = (point.x - start.x).toDouble()
val ay = (point.y - start.y).toDouble()
val az = (point.z - start.z).toDouble()
val t = ((ax * dx + ay * dy + az * dz) / lenSq)
.coerceIn(0.0, 1.0)
val px = start.x.toDouble() + t * dx
val py = start.y.toDouble() + t * dy
val pz = start.z.toDouble() + t * dz
val ex = point.x.toDouble() - px
val ey = point.y.toDouble() - py
val ez = point.z.toDouble() - pz
return sqrt(ex * ex + ey * ey + ez * ez)
}
private fun distance(a: Vec3, b: Vec3): Double {
val dx = (a.x - b.x).toDouble()
val dy = (a.y - b.y).toDouble()
val dz = (a.z - b.z).toDouble()
return sqrt(dx * dx + dy * dy + dz * dz)
}
private fun pointScale(points: List<Vec3>): Double {
var minX = Double.POSITIVE_INFINITY
var minY = Double.POSITIVE_INFINITY
var minZ = Double.POSITIVE_INFINITY
var maxX = Double.NEGATIVE_INFINITY
var maxY = Double.NEGATIVE_INFINITY
var maxZ = Double.NEGATIVE_INFINITY
for (p in points) {
minX = min(minX, p.x.toDouble())
minY = min(minY, p.y.toDouble())
minZ = min(minZ, p.z.toDouble())
maxX = max(maxX, p.x.toDouble())
maxY = max(maxY, p.y.toDouble())
maxZ = max(maxZ, p.z.toDouble())
}
val dx = maxX - minX
val dy = maxY - minY
val dz = maxZ - minZ
return sqrt(dx * dx + dy * dy + dz * dz).coerceAtLeast(1.0)
}
private fun positiveAngle(angle: Double): Double {
var a = angle % (PI * 2.0)
if (a < 0.0) a += PI * 2.0
return a
}
private fun angleInSweep(start: Double, sweep: Double, angle: Double): Boolean {
val tolerance = 1e-8
return if (sweep >= 0.0) {
positiveAngle(angle - start) <= sweep + tolerance
} else {
positiveAngle(start - angle) <= -sweep + tolerance
}
}
private fun quantize(value: Double, scale: Double): Long {
val s = scale.coerceAtLeast(1e-9)
return (value / (s * 1e-9)).toLong()
}
private fun dedupeConsecutive(points: List<Vec3>): List<Vec3> {
if (points.size < 2) return points
val out = ArrayList<Vec3>(points.size)
for (point in points) {
if (out.isEmpty() || distance(out.last(), point) > 1e-7) out += point
}
return out
}