lp_polytope.kt
src/jvmMain/kotlin/afield/geometry/spatial/lp_polytope.kt · 959 lines
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.geometry.spatial
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.convexHullMesh
import afield.geometry.mesh.solidCentroid
import afield.geometry.mesh.volume
import afield.geometry.path.FLine
import afield.graphics.render.color
import afield.graphics.render.thickness
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.vec
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
The three-dimensional companion of lp_trajectories.kt: the same linear-programming methods, as
curve generators, on a convex polytope { x : nᵢ·x ≤ bᵢ } in space. The methods are the 2-D ones
with 3×3 linear algebra in place of 2×2 (the ellipsoid method's constants change with the
dimension: centre step 1/(n+1) = 1/4, expansion n²/(n²−1) = 9/8). The simplex walk follows the
polytope's edges, which the constructor finds by enumerating vertices.
Pure math, independent of Box; doubles throughout, Vec3 only for drawing.
class LpPolytope(normals: List<Vec3>, bounds: List<Double>)
A bounded convex polytope with an interior, held as its half-spaces nᵢ·x ≤ bᵢ (unit outward
normals) and as the vertices and edges those half-spaces make, ready for the trajectory methods
(centralPath, centralFan, primalDual, pdhg, ellipsoid, simplexWalk, cuttingPlanes).
The constructor takes the half-spaces as given — normals of any nonzero length, which are normalised — and keeps every one, including any that are redundant: a redundant half-space does not change the polytope but does push the barrier methods, as in 2-D. It rejects, with a message, a set that is unbounded (the normals do not surround the origin) or has no interior. Use LpPolytope.hull for the convex hull of a point set, LpPolytope.of for a mesh's hull, and LpPolytope.box for an axis-aligned box.
vertices are found by intersecting every triple of planes, so construction costs O(m⁴) in
the number of half-spaces; it is meant for the tens of faces a drawn figure has, not thousands.
class LpPolytope(normals: List<Vec3>, bounds: List<Double>) {
val normalX: DoubleArray
x component of each half-space's unit outward normal.
val normalY: DoubleArray
y component of each half-space's unit outward normal.
val normalZ: DoubleArray
z component of each half-space's unit outward normal.
val bound: DoubleArray
Each half-space's bound: the polytope is normal[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240)·x ≤ bound[i](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) for every i.
val vertices: List<Vec3>
The vertices, each where three or more of the planes meet.
val vertexPlanes: List<IntArray>
For each vertex, the indices of the half-spaces tight at it.
val edges: List<IntArray>
The edges, as pairs of indices into vertices.
val center: Vec3
The mean of the vertices, strictly inside; the default start.
init
body — 46 lines, from line 68
init {
require(normals.size == bounds.size) { "LpPolytope needs one bound per normal: ${normals.size} normals, ${bounds.size} bounds" }
require(normals.size >= 4) { "LpPolytope needs at least 4 half-spaces to be bounded, got ${normals.size}" }
val m = normals.size
normalX = DoubleArray(m)
normalY = DoubleArray(m)
normalZ = DoubleArray(m)
bound = DoubleArray(m)
for (i in 0 until m) {
val n = normals[i]
val len = sqrt(n.x.toDouble() * n.x + n.y.toDouble() * n.y + n.z.toDouble() * n.z)
require(len > 0.0 && len.isFinite() && bounds[i].isFinite()) { "LpPolytope: half-space $i (${normals[i]} · x ≤ ${bounds[i]}) is not a finite nonzero normal and bound" }
normalX[i] = n.x / len
normalY[i] = n.y / len
normalZ[i] = n.z / len
bound[i] = bounds[i] / len
}
require(normalsSurroundOrigin(normalX, normalY, normalZ)) { "LpPolytope: the half-spaces leave a direction unbounded (their normals do not surround the origin)" }
val enumerated = enumerateVertices(normalX, normalY, normalZ, bound)
require(enumerated.first.size >= 4) { "LpPolytope: the half-spaces have no interior (only ${enumerated.first.size} vertices)" }
vertices = enumerated.first.map { vec(it[0], it[1], it[2]) }
vertexPlanes = enumerated.second
val pts = enumerated.first
val cx = pts.sumOf { it[0] } / pts.size
val cy = pts.sumOf { it[1] } / pts.size
val cz = pts.sumOf { it[2] } / pts.size
require((0 until m).all { bound[it] - normalX[it] * cx - normalY[it] * cy - normalZ[it] * cz > 1e-9 * (1.0 + abs(bound[it])) }) {
"LpPolytope: the half-spaces have no interior (the vertices are coplanar)"
}
center = vec(cx, cy, cz)
val e = ArrayList<IntArray>()
for (a in pts.indices) for (b in a + 1 until pts.size) {
val shared = vertexPlanes[a].filter { it in vertexPlanes[b] }
// Two vertices on two non-parallel common planes lie on that planes' line, and that line
// meets the polytope in exactly one edge.
if (shared.size >= 2 && shared.any { i -> shared.any { j -> crossLength(i, j) > 1e-9 } }) e.add(intArrayOf(a, b))
}
edges = e
}
private fun crossLength(i: Int, j: Int): Double {
val x = normalY[i] * normalZ[j] - normalZ[i] * normalY[j]
val y = normalZ[i] * normalX[j] - normalX[i] * normalZ[j]
val z = normalX[i] * normalY[j] - normalY[i] * normalX[j]
return sqrt(x * x + y * y + z * z)
}
val constraintCount: Int get() = bound.size
The number of half-spaces, which is the number of constraints.
fun slack(i: Int, x: Double, y: Double, z: Double): Double
How far (x, y, z) is inside half-space i: positive inside, zero on its plane.
fun slack(i: Int, x: Double, y: Double, z: Double): Double = bound[i] - normalX[i] * x - normalY[i] * y - normalZ[i] * z
fun violation(x: Double, y: Double, z: Double): Double
The largest constraint violation at (x, y, z), zero inside or on the polytope.
fun violation(x: Double, y: Double, z: Double): Double {
var v = 0.0
for (i in 0 until constraintCount) v = max(v, -slack(i, x, y, z))
return v
}
fun containsStrictly(x: Double, y: Double, z: Double): Boolean
True when (x, y, z) is strictly inside every half-space.
fun containsStrictly(x: Double, y: Double, z: Double): Boolean = (0 until constraintCount).all { slack(it, x, y, z) > 0.0 }
fun optimalVertex(direction: Vec3): Vec3
The vertex that maximises direction · x.
fun optimalVertex(direction: Vec3): Vec3 = vertices.maxBy { it.x.toDouble() * direction.x + it.y.toDouble() * direction.y + it.z.toDouble() * direction.z }
fun neighbours(i: Int): List<Int>
The vertices next to vertex i along an edge.
fun neighbours(i: Int): List<Int> = edges.mapNotNull { if (it[0] == i) it[1] else if (it[1] == i) it[0] else null }
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine
The edges as one FLine of separate segments: the polytope's wireframe.
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine = FLine().also { f ->
for (e in edges) {
f.moveTo(vertices[e[0]])
f.lineTo(vertices[e[1]])
}
f.color = Vec4(color)
f.thickness = thickness
}
fun toMesh(): TriMesh = convexHullMesh(vertices)
The solid, as a triangulated TriMesh (the hull of vertices).
val volume: Double get() = toMesh().volume()
The enclosed volume.
companion object
companion object {
fun hull(points: List<Vec3>): LpPolytope
The convex hull of points as a polytope: any point cloud, with repeats and interior
points. The hull's triangles become half-spaces, and coplanar triangles (a cube's face
is two) merge into one, so a cube has six constraints, not twelve. Fails, with a message,
when the points have no volume (fewer than four, or all coplanar).
fun hull(points: List<Vec3>): LpPolytope {
val mesh = convexHullMesh(points)
require(!mesh.isEmpty) { "LpPolytope.hull: the ${points.size} points have no volume (fewer than 4, or all coplanar)" }
return fromHullMesh(mesh)
}
fun of(mesh: TriMesh): LpPolytope = hull(mesh.vertices())
The convex hull of mesh's vertices, as a polytope; see hull.
fun box(x0: Double, y0: Double, z0: Double, x1: Double, y1: Double, z1: Double): LpPolytope
The axis-aligned box from (x0, y0, z0) to (x1, y1, z1).
fun box(x0: Double, y0: Double, z0: Double, x1: Double, y1: Double, z1: Double): LpPolytope {
require(x1 > x0 && y1 > y0 && z1 > z0) { "LpPolytope.box needs x1 > x0, y1 > y0, z1 > z0" }
return LpPolytope(
listOf(vec(1.0, 0.0, 0.0), vec(-1.0, 0.0, 0.0), vec(0.0, 1.0, 0.0), vec(0.0, -1.0, 0.0), vec(0.0, 0.0, 1.0), vec(0.0, 0.0, -1.0)),
listOf(x1, -x0, y1, -y0, z1, -z0),
)
}
fun cube(half: Double = 1.0): LpPolytope = box(-half, -half, -half, half, half, half)
The cube of half-width half centred on the origin.
private fun fromHullMesh(mesh: TriMesh): LpPolytope {
val n = mesh.vertexCount
var cx = 0.0
var cy = 0.0
var cz = 0.0
for (i in 0 until n) {
cx += mesh.x(i); cy += mesh.y(i); cz += mesh.z(i)
}
cx /= n; cy /= n; cz /= n
var scale = 1.0
for (i in 0 until n) scale = max(scale, max(abs(mesh.x(i).toDouble()), max(abs(mesh.y(i).toDouble()), abs(mesh.z(i).toDouble()))))
val planes = ArrayList<DoubleArray>()
for (t in 0 until mesh.triangleCount) {
val a = mesh.indices[3 * t]
val b = mesh.indices[3 * t + 1]
val c = mesh.indices[3 * t + 2]
val ux = mesh.x(b).toDouble() - mesh.x(a); val uy = mesh.y(b).toDouble() - mesh.y(a); val uz = mesh.z(b).toDouble() - mesh.z(a)
val vx = mesh.x(c).toDouble() - mesh.x(a); val vy = mesh.y(c).toDouble() - mesh.y(a); val vz = mesh.z(c).toDouble() - mesh.z(a)
var nx = uy * vz - uz * vy
var ny = uz * vx - ux * vz
var nz = ux * vy - uy * vx
val len = sqrt(nx * nx + ny * ny + nz * nz)
if (len < 1e-12 * scale * scale) continue
nx /= len; ny /= len; nz /= len
var d = nx * mesh.x(a) + ny * mesh.y(a) + nz * mesh.z(a)
// Orient away from the vertex mean, which is inside, whatever the mesh's winding.
if (nx * cx + ny * cy + nz * cz > d) { nx = -nx; ny = -ny; nz = -nz; d = -d }
// Merge coplanar triangles: same normal, same offset. The hull's points are floats.
if (planes.none { p -> p[0] * nx + p[1] * ny + p[2] * nz > 1 - 1e-6 && abs(p[3] - d) < 1e-5 * scale }) planes.add(doubleArrayOf(nx, ny, nz, d))
}
// The hull's points are floats, so its surface can be very slightly non-convex. Move
// each plane out to its furthest hull vertex, so every vertex is inside every half-space
// and no sliver vertex appears where a plane would have clipped a corner.
for (p in planes) for (i in 0 until n) p[3] = max(p[3], p[0] * mesh.x(i) + p[1] * mesh.y(i) + p[2] * mesh.z(i))
return LpPolytope(planes.map { vec(it[0], it[1], it[2]) }, planes.map { it[3] })
}
}
}
Whether the origin is strictly inside the convex hull of the normals, i.e. no direction d has nᵢ·d ≤ 0 for every i (which would be an unbounded ray). Equivalently the normals' hull is a solid whose every face plane has the origin strictly on its inner side.
private fun normalsSurroundOrigin(nx: DoubleArray, ny: DoubleArray, nz: DoubleArray): Boolean {
val hull = convexHullMesh(List(nx.size) { vec(nx[it], ny[it], nz[it]) })
if (hull.isEmpty) return false
val c = hull.vertices()
val mx = c.sumOf { it.x.toDouble() } / c.size
val my = c.sumOf { it.y.toDouble() } / c.size
val mz = c.sumOf { it.z.toDouble() } / c.size
for (t in 0 until hull.triangleCount) {
val a = hull.indices[3 * t]
val b = hull.indices[3 * t + 1]
val cc = hull.indices[3 * t + 2]
val ux = hull.x(b).toDouble() - hull.x(a); val uy = hull.y(b).toDouble() - hull.y(a); val uz = hull.z(b).toDouble() - hull.z(a)
val vx = hull.x(cc).toDouble() - hull.x(a); val vy = hull.y(cc).toDouble() - hull.y(a); val vz = hull.z(cc).toDouble() - hull.z(a)
val px = uy * vz - uz * vy
val py = uz * vx - ux * vz
val pz = ux * vy - uy * vx
val len = sqrt(px * px + py * py + pz * pz)
if (len < 1e-14) continue
val d = (px * hull.x(a) + py * hull.y(a) + pz * hull.z(a)) / len
val side = (px * mx + py * my + pz * mz) / len - d
val origin = -d
// The origin must be on the same side as the hull's own interior, by a margin.
if (side * origin <= 0.0 || abs(origin) < 1e-7) return false
}
return true
}
Every point where three planes meet and every constraint holds, merged when two are the same point, each with the set of constraints tight there.
private fun enumerateVertices(nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray): Pair<List<DoubleArray>, List<IntArray>> {
val m = b.size
val scale = 1.0 + b.maxOf { abs(it) }
val tolerance = 1e-9 * scale
// A vertex where many nearly coplanar planes meet (a hull of float points) is found by many
// triples, the ill-conditioned ones a little off. Take the best-conditioned triples first; a
// later triple whose three planes are all tight at a vertex already found is that vertex.
val tightTolerance = 1e3 * tolerance
class Candidate(val p: DoubleArray, val planes: IntArray, val det: Double)
val candidates = ArrayList<Candidate>()
for (i in 0 until m) for (j in i + 1 until m) for (k in j + 1 until m) {
val det = abs(det3(nx[i], ny[i], nz[i], nx[j], ny[j], nz[j], nx[k], ny[k], nz[k]))
if (det < 1e-9) continue
val p = solve3(nx[i], ny[i], nz[i], nx[j], ny[j], nz[j], nx[k], ny[k], nz[k], b[i], b[j], b[k]) ?: continue
if ((0 until m).any { nx[it] * p[0] + ny[it] * p[1] + nz[it] * p[2] > b[it] + tightTolerance }) continue
candidates.add(Candidate(p, intArrayOf(i, j, k), det))
}
candidates.sortByDescending { it.det }
val points = ArrayList<DoubleArray>()
for (cand in candidates) {
val known = points.any { p -> cand.planes.all { abs(b[it] - nx[it] * p[0] - ny[it] * p[1] - nz[it] * p[2]) <= tightTolerance } }
if (!known) points.add(cand.p)
}
val tight = points.map { p ->
(0 until m).filter { abs(b[it] - nx[it] * p[0] - ny[it] * p[1] - nz[it] * p[2]) <= tightTolerance }.toIntArray()
}
return points to tight
}
private fun det3(a0: Double, a1: Double, a2: Double, b0: Double, b1: Double, b2: Double, c0: Double, c1: Double, c2: Double) =
a0 * (b1 * c2 - b2 * c1) - a1 * (b0 * c2 - b2 * c0) + a2 * (b0 * c1 - b1 * c0)
Cramer's rule for the 3×3 system with rows a, b, c and right-hand side r; null when singular.
private fun solve3(
a0: Double, a1: Double, a2: Double, b0: Double, b1: Double, b2: Double, c0: Double, c1: Double, c2: Double,
r0: Double, r1: Double, r2: Double,
): DoubleArray? {
val det = a0 * (b1 * c2 - b2 * c1) - a1 * (b0 * c2 - b2 * c0) + a2 * (b0 * c1 - b1 * c0)
if (abs(det) < 1e-12) return null
val x = r0 * (b1 * c2 - b2 * c1) - a1 * (r1 * c2 - b2 * r2) + a2 * (r1 * c1 - b1 * r2)
val y = a0 * (r1 * c2 - b2 * r2) - r0 * (b0 * c2 - b2 * c0) + a2 * (b0 * r2 - r1 * c0)
val z = a0 * (b1 * r2 - r1 * c1) - a1 * (b0 * r2 - r1 * c0) + r0 * (b0 * c1 - b1 * c0)
return doubleArrayOf(x / det, y / det, z / det)
}
Solves the symmetric 3×3 system H d = g, H = [[a, b, c], [b, d, e], [c, e, f]]; null when singular.
private fun solveSym3(h: DoubleArray, g0: Double, g1: Double, g2: Double): DoubleArray? {
val r = solve3(h[0], h[1], h[2], h[1], h[3], h[4], h[2], h[4], h[5], g0, g1, g2) ?: return null
return if (r.all { it.isFinite() }) r else null
}
data class LpSample3
One step of a 3-D trajectory: the 3-D LpSample.
data class LpSample3(
/** The iterate's x. */
val x: Double,
/** The iterate's y. */
val y: Double,
/** The iterate's z. */
val z: Double,
/** The step number, starting at 0. */
val iteration: Int,
/** `c·x` with `c` the normalised objective direction. */
val objective: Double,
/** The largest constraint violation, zero for a feasible iterate. */
val violation: Double,
/**
* The method's own measure, as for [LpSample]: μ for [centralPath] and [primalDual], the dual
* norm for [pdhg], `√det P` for [ellipsoid], the remaining volume for [cuttingPlanes], `NaN`
* for [simplexWalk].
*/
val scale: Double,
/**
* What kind of step this is: the method's name, or for [ellipsoid] which cut was taken —
* `feasibility-cut`, `objective-cut`, or `final` at the last step.
*/
val tag: String,
) {
val position: Vec3 get() = vec(x, y, z)
The iterate as a Vec3, for drawing.
class LpTrajectory3(val method: String, val samples: List<LpSample3>, val solverSteps: Int = 0)
A 3-D method's whole walk, in order; see LpTrajectory.
class LpTrajectory3(val method: String, val samples: List<LpSample3>, val solverSteps: Int = 0) {
val last: LpSample3 get() = samples.last()
The final iterate.
val positions: List<Vec3> get() = samples.map { it.position }
The iterates as Vec3s.
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine
The walk as an open 3-D polyline.
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine =
FLine().also { f ->
samples.forEachIndexed { i, s -> if (i == 0) f.moveTo(s.x, s.y, s.z) else f.lineTo(s.x, s.y, s.z) }
f.color = Vec4(color)
f.thickness = thickness
}
}
data class LpEllipsoid
The ellipsoid { z : (z − c)ᵀ P⁻¹ (z − c) ≤ 1 } with centre (cx, cy, cz) and symmetric
positive definite P = [[pxx, pxy, pxz], [pxy, pyy, pyz], [pxz, pyz, pzz]], as kept by
LpPolytope.ellipsoid.
data class LpEllipsoid(
val cx: Double, val cy: Double, val cz: Double,
val pxx: Double, val pxy: Double, val pxz: Double, val pyy: Double, val pyz: Double, val pzz: Double,
) {
val determinant: Double
det P; the volume is 4/3 π √det.
val determinant: Double
get() = pxx * (pyy * pzz - pyz * pyz) - pxy * (pxy * pzz - pyz * pxz) + pxz * (pxy * pyz - pyy * pxz)
val isPositiveDefinite: Boolean get()
True when P is symmetric positive definite (all leading minors positive).
val isPositiveDefinite: Boolean get() = pxx > 0.0 && pxx * pyy - pxy * pxy > 0.0 && determinant > 0.0
fun quadraticForm(x: Double, y: Double, z: Double): Double
(z − c)ᵀ P⁻¹ (z − c) for z = (x, y, z): at most 1 inside the ellipsoid.
fun quadraticForm(x: Double, y: Double, z: Double): Double {
val dx = x - cx
val dy = y - cy
val dz = z - cz
val s = solveSym3(doubleArrayOf(pxx, pxy, pxz, pyy, pyz, pzz), dx, dy, dz) ?: return Double.POSITIVE_INFINITY
return dx * s[0] + dy * s[1] + dz * s[2]
}
Cholesky factor L (P = L Lᵀ), lower triangular, row-major.
private fun cholesky(): DoubleArray {
val l11 = sqrt(max(pxx, 0.0))
val l21 = if (l11 > 0.0) pxy / l11 else 0.0
val l31 = if (l11 > 0.0) pxz / l11 else 0.0
val l22 = sqrt(max(pyy - l21 * l21, 0.0))
val l32 = if (l22 > 0.0) (pyz - l31 * l21) / l22 else 0.0
val l33 = sqrt(max(pzz - l31 * l31 - l32 * l32, 0.0))
return doubleArrayOf(l11, l21, l22, l31, l32, l33)
}
fun toFLine(rings: Int = 6, segments: Int = 48, color: Vec4 = vec(0.55, 0.80, 1.0, 0.4), thickness: Double = 0.8): FLine
A wireframe: the unit sphere's rings circles of latitude and rings meridians, mapped onto
the ellipsoid through the Cholesky factor of P, as one FLine.
fun toFLine(rings: Int = 6, segments: Int = 48, color: Vec4 = vec(0.55, 0.80, 1.0, 0.4), thickness: Double = 0.8): FLine {
val l = cholesky()
fun map(ux: Double, uy: Double, uz: Double) = vec(
cx + l[0] * ux,
cy + l[1] * ux + l[2] * uy,
cz + l[3] * ux + l[4] * uy + l[5] * uz,
)
return FLine().also { f ->
for (r in 1 until rings) {
val phi = PI * r / rings - PI / 2
for (k in 0..segments) {
val a = 2.0 * PI * k / segments
val p = map(cos(phi) * cos(a), cos(phi) * sin(a), sin(phi))
if (k == 0) f.moveTo(p) else f.lineTo(p)
}
}
for (r in 0 until rings) {
val a = PI * r / rings
for (k in 0..segments) {
val phi = 2.0 * PI * k / segments
val p = map(cos(phi) * cos(a), cos(phi) * sin(a), sin(phi))
if (k == 0) f.moveTo(p) else f.lineTo(p)
}
}
f.color = Vec4(color)
f.thickness = thickness
}
}
}
class LpEllipsoidTrace3(val trajectory: LpTrajectory3, val ellipsoids: List<LpEllipsoid>)
LpPolytope.ellipsoid's result: the centres, and the ellipsoid kept at every step.
class LpPdhgTrace3(val trajectory: LpTrajectory3, val duals: List<DoubleArray>)
LpPolytope.pdhg's result: the primal walk, and the dual vector at every step.
class LpCuttingPlaneTrace3(val trajectory: LpTrajectory3, val regions: List<LpPolytope>)
LpPolytope.cuttingPlanes's result: the centres, and the region still in play at every step.
private fun unit3(direction: Vec3): DoubleArray {
val len = sqrt(direction.x.toDouble() * direction.x + direction.y.toDouble() * direction.y + direction.z.toDouble() * direction.z)
require(len > 0.0 && len.isFinite()) { "the objective direction must be a nonzero finite vector, got $direction" }
return doubleArrayOf(direction.x / len, direction.y / len, direction.z / len)
}
private fun LpPolytope.sample(c: DoubleArray, x: Double, y: Double, z: Double, k: Int, scale: Double, tag: String) =
LpSample3(x, y, z, k, c[0] * x + c[1] * y + c[2] * z, violation(x, y, z), scale, tag)
private fun LpPolytope.checkedWeights(weights: DoubleArray?): DoubleArray {
val w = weights ?: DoubleArray(constraintCount) { 1.0 }
require(w.size == constraintCount) { "need one weight per half-space: ${w.size} weights for $constraintCount half-spaces" }
require(w.all { it.isFinite() && it > 0.0 }) { "half-space weights must be positive and finite: ${w.toList()}" }
return w
}
Gradient and Hessian (negated, so positive definite) of c·x + μ Σ wᵢ ln sᵢ; null outside.
private fun barrierParts(
nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray, w: DoubleArray,
c: DoubleArray, mu: Double, x: Double, y: Double, z: Double,
): Pair<DoubleArray, DoubleArray>? {
var g0 = c[0]
var g1 = c[1]
var g2 = c[2]
val h = DoubleArray(6)
for (i in nx.indices) {
val s = b[i] - nx[i] * x - ny[i] * y - nz[i] * z
if (s <= 0.0) return null
val t = mu * w[i] / s
g0 -= t * nx[i]; g1 -= t * ny[i]; g2 -= t * nz[i]
val q = t / s
h[0] += q * nx[i] * nx[i]; h[1] += q * nx[i] * ny[i]; h[2] += q * nx[i] * nz[i]
h[3] += q * ny[i] * ny[i]; h[4] += q * ny[i] * nz[i]; h[5] += q * nz[i] * nz[i]
}
return doubleArrayOf(g0, g1, g2) to h
}
Damped Newton for the barrier maximiser in 3-D. Returns (x, y, z, steps).
private fun barrierNewton3(
nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray, w: DoubleArray,
c: DoubleArray, mu: Double, x0: Double, y0: Double, z0: Double, maxIterations: Int = 100,
): DoubleArray {
var x = x0
var y = y0
var z = z0
var steps = 0
fun value(px: Double, py: Double, pz: Double): Double {
var v = c[0] * px + c[1] * py + c[2] * pz
for (i in nx.indices) {
val s = b[i] - nx[i] * px - ny[i] * py - nz[i] * pz
if (s <= 0.0) return Double.NEGATIVE_INFINITY
v += mu * w[i] * ln(s)
}
return v
}
repeat(maxIterations) {
val (g, h) = barrierParts(nx, ny, nz, b, w, c, mu, x, y, z) ?: return doubleArrayOf(x, y, z, steps.toDouble())
val d = solveSym3(h, g[0], g[1], g[2]) ?: error("degenerate barrier Hessian")
val decrement = g[0] * d[0] + g[1] * d[1] + g[2] * d[2]
if (decrement < 1e-18) return doubleArrayOf(x, y, z, steps.toDouble())
steps++
val before = value(x, y, z)
var alpha = 1.0
var accepted = false
for (tries in 0 until 80) {
val after = value(x + alpha * d[0], y + alpha * d[1], z + alpha * d[2])
if (after.isFinite() && after >= before + 1e-4 * alpha * decrement) {
x += alpha * d[0]; y += alpha * d[1]; z += alpha * d[2]
accepted = true
break
}
alpha *= 0.5
}
if (!accepted) return doubleArrayOf(x, y, z, steps.toDouble())
}
return doubleArrayOf(x, y, z, steps.toDouble())
}
fun LpPolytope.centralPath
The log-barrier central path in 3-D: for μ falling geometrically from muHigh to muLow, the
maximiser of c·x + μ Σ wᵢ ln(slackᵢ), by damped Newton (a 3×3 solve per step) warm-started
from the previous sample. It leaves the weighted analytic centre and ends at the optimal vertex
for direction. Every sample is strictly feasible; LpSample3.scale is μ. See the 2-D
LpPolygon.centralPath for what the weights do.
fun LpPolytope.centralPath(
direction: Vec3,
weights: DoubleArray? = null,
samples: Int = 160,
muHigh: Double = 1000.0,
muLow: Double = 1e-5,
start: Vec3 = center,
): LpTrajectory3 {
require(samples >= 2) { "centralPath needs at least 2 samples, got $samples" }
require(muHigh > muLow && muLow > 0.0) { "centralPath needs muHigh > muLow > 0, got $muHigh and $muLow" }
require(containsStrictly(start.x.toDouble(), start.y.toDouble(), start.z.toDouble())) { "centralPath must start strictly inside the polytope; $start is not" }
val c = unit3(direction)
val w = checkedWeights(weights)
var p = doubleArrayOf(start.x.toDouble(), start.y.toDouble(), start.z.toDouble())
var steps = 0
val out = List(samples) { k ->
val mu = exp(ln(muHigh) + (ln(muLow) - ln(muHigh)) * k / (samples - 1))
p = barrierNewton3(normalX, normalY, normalZ, bound, w, c, mu, p[0], p[1], p[2])
steps += p[3].toInt()
sample(c, p[0], p[1], p[2], k, mu, "central-path")
}
return LpTrajectory3("central-path", out, steps)
}
fun sphereDirections(count: Int): List<Vec3>
count directions spread evenly over the unit sphere (a Fibonacci spiral), the directions
LpPolytope.centralFan uses.
fun sphereDirections(count: Int): List<Vec3> {
require(count > 0) { "sphereDirections needs at least one direction, got $count" }
val golden = PI * (3.0 - sqrt(5.0))
return List(count) { i ->
val z = 1.0 - 2.0 * (i + 0.5) / count
val r = sqrt(max(0.0, 1.0 - z * z))
vec(r * cos(golden * i), r * sin(golden * i), z)
}
}
fun LpPolytope.centralFan
A fan of central paths over directions objective directions spread over the sphere
(sphereDirections): every path leaves the same weighted analytic centre and ends at the vertex
that wins for its direction.
fun LpPolytope.centralFan(
directions: Int = 64,
samples: Int = 100,
weights: DoubleArray? = null,
muHigh: Double = 1000.0,
muLow: Double = 1e-5,
): List<LpTrajectory3> = sphereDirections(directions).map { centralPath(it, weights, samples, muHigh, muLow) }
fun LpPolytope.primalDual
Mehrotra's predictor–corrector primal-dual interior-point method in 3-D, every Newton iterate
recorded; the 3×3 port of LpPolygon.primalDual. LpSample3.scale is the duality measure μ.
body — 77 lines, from line 575
fun LpPolytope.primalDual(
direction: Vec3,
maxIterations: Int = 60,
tolerance: Double = 1e-9,
stepFraction: Double = 0.95,
start: Vec3 = center,
): LpTrajectory3 {
require(maxIterations >= 0) { "maxIterations must be non-negative, got $maxIterations" }
require(stepFraction > 0.0 && stepFraction < 1.0) { "stepFraction must be in (0, 1), got $stepFraction" }
require(containsStrictly(start.x.toDouble(), start.y.toDouble(), start.z.toDouble())) { "primalDual must start strictly inside the polytope; $start is not" }
val c = unit3(direction)
val m = constraintCount
var x = start.x.toDouble()
var y = start.y.toDouble()
var z = start.z.toDouble()
val s = DoubleArray(m) { slack(it, x, y, z) }
val u = DoubleArray(m) { 1.0 }
val out = ArrayList<LpSample3>()
fun mu() = (0 until m).sumOf { s[it] * u[it] } / m
out.add(sample(c, x, y, z, 0, mu(), "primal-dual"))
val ds = DoubleArray(m)
val du = DoubleArray(m)
val rc = DoubleArray(m)
fun direction(rd: DoubleArray): DoubleArray {
val h = DoubleArray(6)
var r0 = -rd[0]
var r1 = -rd[1]
var r2 = -rd[2]
for (i in 0 until m) {
val q = u[i] / s[i]
h[0] += q * normalX[i] * normalX[i]; h[1] += q * normalX[i] * normalY[i]; h[2] += q * normalX[i] * normalZ[i]
h[3] += q * normalY[i] * normalY[i]; h[4] += q * normalY[i] * normalZ[i]; h[5] += q * normalZ[i] * normalZ[i]
r0 -= normalX[i] * rc[i] / s[i]
r1 -= normalY[i] * rc[i] / s[i]
r2 -= normalZ[i] * rc[i] / s[i]
}
val d = solveSym3(h, r0, r1, r2) ?: error("degenerate primal-dual normal matrix")
for (i in 0 until m) {
ds[i] = -(normalX[i] * d[0] + normalY[i] * d[1] + normalZ[i] * d[2])
du[i] = (rc[i] - u[i] * ds[i]) / s[i]
}
return d
}
fun maxStep(v: DoubleArray, dv: DoubleArray): Double {
var a = 1.0
for (i in v.indices) if (dv[i] < 0.0) a = min(a, -v[i] / dv[i])
return a
}
for (k in 1..maxIterations) {
val rd = doubleArrayOf(
(0 until m).sumOf { normalX[it] * u[it] } - c[0],
(0 until m).sumOf { normalY[it] * u[it] } - c[1],
(0 until m).sumOf { normalZ[it] * u[it] } - c[2],
)
val muNow = mu()
if (muNow < tolerance && sqrt(rd[0] * rd[0] + rd[1] * rd[1] + rd[2] * rd[2]) < tolerance) break
for (i in 0 until m) rc[i] = -s[i] * u[i]
direction(rd)
val ap = maxStep(s, ds)
val ad = maxStep(u, du)
val muAff = (0 until m).sumOf { (s[it] + ap * ds[it]) * (u[it] + ad * du[it]) } / m
val sigma = (muAff / muNow).let { it * it * it }
for (i in 0 until m) rc[i] = -s[i] * u[i] - ds[i] * du[i] + sigma * muNow
val d = direction(rd)
val alphaP = min(1.0, stepFraction * maxStep(s, ds))
val alphaD = min(1.0, stepFraction * maxStep(u, du))
x += alphaP * d[0]
y += alphaP * d[1]
z += alphaP * d[2]
for (i in 0 until m) {
s[i] = slack(i, x, y, z)
u[i] += alphaD * du[i]
}
out.add(sample(c, x, y, z, k, mu(), "primal-dual"))
}
return LpTrajectory3("primal-dual", out)
}
fun LpPolytope.pdhg
Primal-dual hybrid gradient (Chambolle–Pock) in 3-D, every primal and dual state kept,
infeasible ones included; the port of LpPolygon.pdhg. The primal walk overshoots the polytope
and spirals into the optimal vertex. LpSample3.scale is the dual vector's norm.
body — 41 lines, from line 658
fun LpPolytope.pdhg(
direction: Vec3,
iterations: Int = 1000,
tau: Double = 0.15,
sigma: Double = 0.15,
theta: Double = 1.0,
start: Vec3 = center,
): LpPdhgTrace3 {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
require(tau > 0.0 && sigma > 0.0 && theta >= 0.0) { "pdhg needs tau > 0, sigma > 0, theta >= 0; got $tau, $sigma, $theta" }
val c = unit3(direction)
val m = constraintCount
val dual = DoubleArray(m)
var x = start.x.toDouble()
var y = start.y.toDouble()
var z = start.z.toDouble()
var bx = x
var by = y
var bz = z
fun dualNorm() = sqrt(dual.sumOf { it * it })
val samples = arrayListOf(sample(c, x, y, z, 0, 0.0, "pdhg"))
val duals = arrayListOf(dual.copyOf())
for (k in 1..iterations) {
for (i in 0 until m) dual[i] = max(0.0, dual[i] - sigma * slack(i, bx, by, bz))
var gx = c[0]
var gy = c[1]
var gz = c[2]
for (i in 0 until m) {
gx -= normalX[i] * dual[i]; gy -= normalY[i] * dual[i]; gz -= normalZ[i] * dual[i]
}
val nx = x + tau * gx
val ny = y + tau * gy
val nz = z + tau * gz
if (!nx.isFinite() || !ny.isFinite() || !nz.isFinite()) break
bx = nx + theta * (nx - x); by = ny + theta * (ny - y); bz = nz + theta * (nz - z)
x = nx; y = ny; z = nz
samples.add(sample(c, x, y, z, k, dualNorm(), "pdhg"))
duals.add(dual.copyOf())
}
return LpPdhgTrace3(LpTrajectory3("pdhg", samples), duals)
}
fun LpPolytope.ellipsoid
The central-cut ellipsoid method in 3-D, every centre and ellipsoid kept. It starts from a ball
of initialRadius (default: the polytope's diameter) around start, cuts on the most violated
half-space at an infeasible centre and on the objective at a feasible one. In n = 3 dimensions
each cut moves the centre by −Pg / (4√(gᵀPg)) and replaces P by
9/8 · (P − ½ (Pg)(Pg)ᵀ / gᵀPg) (n²/(n²−1) and 2/(n+1)), which multiplies the volume by
27/32 ≈ 0.84 every step: slower than the 2-D method's 0.77, as the method is in higher
dimensions. LpSample3.tag and LpSample3.scale (√det P) are as in LpPolygon.ellipsoid.
body — 54 lines, from line 709
fun LpPolytope.ellipsoid(
direction: Vec3,
iterations: Int = 120,
initialRadius: Double? = null,
start: Vec3 = center,
): LpEllipsoidTrace3 {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
val c = unit3(direction)
val radius = initialRadius ?: vertices.maxOf { a -> vertices.maxOf { b -> a.distance(b).toDouble() } }
require(radius > 0.0 && radius.isFinite()) { "initialRadius must be positive, got $radius" }
var x = start.x.toDouble()
var y = start.y.toDouble()
var z = start.z.toDouble()
val p = doubleArrayOf(radius * radius, 0.0, 0.0, radius * radius, 0.0, radius * radius)
val samples = ArrayList<LpSample3>()
val ellipsoids = ArrayList<LpEllipsoid>()
for (k in 0..iterations) {
val e = LpEllipsoid(x, y, z, p[0], p[1], p[2], p[3], p[4], p[5])
ellipsoids.add(e)
var worst = -1
var worstViolation = 0.0
for (i in 0 until constraintCount) {
val v = -slack(i, x, y, z)
if (v > worstViolation) {
worstViolation = v
worst = i
}
}
val tag = if (k == iterations) "final" else if (worst >= 0) "feasibility-cut" else "objective-cut"
samples.add(sample(c, x, y, z, k, sqrt(max(e.determinant, 0.0)), tag))
if (k == iterations) break
val gx = if (worst >= 0) normalX[worst] else -c[0]
val gy = if (worst >= 0) normalY[worst] else -c[1]
val gz = if (worst >= 0) normalZ[worst] else -c[2]
val ux = p[0] * gx + p[1] * gy + p[2] * gz
val uy = p[1] * gx + p[3] * gy + p[4] * gz
val uz = p[2] * gx + p[4] * gy + p[5] * gz
val gpg = gx * ux + gy * uy + gz * uz
if (gpg <= 1e-280 || !gpg.isFinite()) break
val norm = sqrt(gpg)
x -= ux / (4.0 * norm)
y -= uy / (4.0 * norm)
z -= uz / (4.0 * norm)
val factor = 9.0 / 8.0
val shrink = 0.5 / gpg
p[0] = factor * (p[0] - shrink * ux * ux)
p[1] = factor * (p[1] - shrink * ux * uy)
p[2] = factor * (p[2] - shrink * ux * uz)
p[3] = factor * (p[3] - shrink * uy * uy)
p[4] = factor * (p[4] - shrink * uy * uz)
p[5] = factor * (p[5] - shrink * uz * uz)
}
return LpEllipsoidTrace3(LpTrajectory3("ellipsoid", samples), ellipsoids)
}
fun LpPolytope.simplexWalk(direction: Vec3, startVertex: Int = 0): LpTrajectory3
The simplex method in 3-D: a walk along the polytope's LpPolytope.edges from startVertex,
moving each time to the neighbouring vertex that improves c·x most, until none does. A convex
polytope has no local optimum that is not global, so it ends at the optimal vertex.
LpSample3.scale is NaN.
fun LpPolytope.simplexWalk(direction: Vec3, startVertex: Int = 0): LpTrajectory3 {
require(startVertex in vertices.indices) { "startVertex must be in 0 until ${vertices.size}, got $startVertex" }
val c = unit3(direction)
fun value(i: Int) = c[0] * vertices[i].x + c[1] * vertices[i].y + c[2] * vertices[i].z
val out = ArrayList<LpSample3>()
var i = startVertex
for (step in 0..vertices.size) {
val v = vertices[i]
out.add(sample(c, v.x.toDouble(), v.y.toDouble(), v.z.toDouble(), step, Double.NaN, "simplex"))
val next = neighbours(i).maxByOrNull { value(it) } ?: break
if (value(next) > value(i) + 1e-9) i = next else break
}
return LpTrajectory3("simplex", out)
}
internal fun chebyshevCenter3(nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray): DoubleArray
The Chebyshev centre in 3-D: maximise r subject to nᵢ·x + r ≤ bᵢ, a four-variable LP whose optimum has four tight constraints. Solves each quadruple's 4×4 system and keeps the feasible solution with the largest r. Returns (x, y, z, r).
internal fun chebyshevCenter3(nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray): DoubleArray {
val m = b.size
var best: DoubleArray? = null
val a = Array(4) { DoubleArray(5) }
for (i in 0 until m) for (j in i + 1 until m) for (k in j + 1 until m) for (l in k + 1 until m) {
val rows = intArrayOf(i, j, k, l)
for (r in 0 until 4) {
val q = rows[r]
a[r][0] = nx[q]; a[r][1] = ny[q]; a[r][2] = nz[q]; a[r][3] = 1.0; a[r][4] = b[q]
}
val sol = gauss4(a) ?: continue
val r = sol[3]
if (r <= 0.0 || (best != null && r <= best[3])) continue
val tolerance = 1e-9 * (1.0 + abs(r))
if ((0 until m).all { nx[it] * sol[0] + ny[it] * sol[1] + nz[it] * sol[2] + r <= b[it] + tolerance }) best = sol
}
return checkNotNull(best) { "chebyshevCenter3: the region has no interior" }
}
Gaussian elimination with partial pivoting on a 4×5 augmented matrix (overwritten).
private fun gauss4(a: Array<DoubleArray>): DoubleArray? {
for (col in 0 until 4) {
var piv = col
for (r in col + 1 until 4) if (abs(a[r][col]) > abs(a[piv][col])) piv = r
if (abs(a[piv][col]) < 1e-12) return null
if (piv != col) { val t = a[piv]; a[piv] = a[col]; a[col] = t }
for (r in col + 1 until 4) {
val f = a[r][col] / a[col][col]
for (cc in col until 5) a[r][cc] -= f * a[col][cc]
}
}
val x = DoubleArray(4)
for (r in 3 downTo 0) {
var s = a[r][4]
for (cc in r + 1 until 4) s -= a[r][cc] * x[cc]
x[r] = s / a[r][r]
}
return x
}
internal fun volumetricCenter3
The 3-D form of volumetricCenter in lp_trajectories.kt: minimise ½ ln det H, H = Σ aᵢaᵢᵀ / sᵢ²,
with Vaidya's Hessian approximation Q = Σ σᵢ aᵢaᵢᵀ / sᵢ² and backtracking, from a strictly
feasible start.
body — 66 lines, from line 835
internal fun volumetricCenter3(
nx: DoubleArray, ny: DoubleArray, nz: DoubleArray, b: DoubleArray, start: DoubleArray, maxIterations: Int = 100,
): DoubleArray {
val x = start.copyOf()
// symmetric 3x3 as (xx, xy, xz, yy, yz, zz)
fun sum(px: Double, py: Double, pz: Double, weight: (Int, Double) -> Double): DoubleArray? {
val m = DoubleArray(6)
for (i in nx.indices) {
val s = b[i] - nx[i] * px - ny[i] * py - nz[i] * pz
if (s <= 0.0) return null
val q = weight(i, s)
m[0] += q * nx[i] * nx[i]; m[1] += q * nx[i] * ny[i]; m[2] += q * nx[i] * nz[i]
m[3] += q * ny[i] * ny[i]; m[4] += q * ny[i] * nz[i]; m[5] += q * nz[i] * nz[i]
}
return m
}
fun det(m: DoubleArray) = m[0] * (m[3] * m[5] - m[4] * m[4]) - m[1] * (m[1] * m[5] - m[4] * m[2]) + m[2] * (m[1] * m[4] - m[3] * m[2])
// adjugate, so inverse = adj / det
fun adj(m: DoubleArray) = doubleArrayOf(
m[3] * m[5] - m[4] * m[4], m[2] * m[4] - m[1] * m[5], m[1] * m[4] - m[2] * m[3],
m[0] * m[5] - m[2] * m[2], m[1] * m[2] - m[0] * m[4], m[0] * m[3] - m[1] * m[1],
)
fun quad(a: DoubleArray, u: Double, v: Double, w: Double) =
a[0] * u * u + a[3] * v * v + a[5] * w * w + 2 * (a[1] * u * v + a[2] * u * w + a[4] * v * w)
fun value(px: Double, py: Double, pz: Double): Double {
val h = sum(px, py, pz) { _, s -> 1.0 / (s * s) } ?: return Double.POSITIVE_INFINITY
val d = det(h)
return if (d > 0.0) 0.5 * ln(d) else Double.POSITIVE_INFINITY
}
repeat(maxIterations) {
val h = sum(x[0], x[1], x[2]) { _, s -> 1.0 / (s * s) } ?: return x
val hd = det(h)
if (!(hd > 0.0)) return x
val hInv = adj(h)
val g = DoubleArray(3)
val q = sum(x[0], x[1], x[2]) { i, s ->
val leverage = quad(hInv, nx[i], ny[i], nz[i]) / hd / (s * s)
g[0] += leverage * nx[i] / s; g[1] += leverage * ny[i] / s; g[2] += leverage * nz[i] / s
leverage / (s * s)
} ?: return x
val qd = det(q)
if (!(qd > 0.0) || !qd.isFinite()) return x
val qa = adj(q)
val d = doubleArrayOf(
-(qa[0] * g[0] + qa[1] * g[1] + qa[2] * g[2]) / qd,
-(qa[1] * g[0] + qa[3] * g[1] + qa[4] * g[2]) / qd,
-(qa[2] * g[0] + qa[4] * g[1] + qa[5] * g[2]) / qd,
)
val decrement = -(g[0] * d[0] + g[1] * d[1] + g[2] * d[2])
if (decrement < 1e-18) return x
val before = value(x[0], x[1], x[2])
var alpha = 1.0
var accepted = false
for (tries in 0 until 80) {
val after = value(x[0] + alpha * d[0], x[1] + alpha * d[1], x[2] + alpha * d[2])
if (after.isFinite() && after <= before - 1e-4 * alpha * decrement) {
for (k in 0..2) x[k] += alpha * d[k]
accepted = true
break
}
alpha *= 0.5
}
if (!accepted) return x
}
return x
}
fun LpPolytope.cuttingPlanes
A cutting-plane walk in 3-D: find a centre of the region still in play, cut it with the objective
(c·x ≥ c·centre), repeat. center is LpCenter.ANALYTIC (the analytic centre of every
constraint so far, cuts included), LpCenter.CENTROID (the centre of gravity, which removes at
least 1 − (3/4)³ ≈ 58% of the volume each step) or LpCenter.CHEBYSHEV (the centre of the
largest inscribed ball) or LpCenter.VOLUMETRIC (Vaidya's volumetric centre of every constraint
so far, started from the analytic centre).
Stops when the remaining volume falls below minVolume or after iterations.
LpSample3.scale is the remaining volume, and LpCuttingPlaneTrace3.regions holds each region.
fun LpPolytope.cuttingPlanes(
direction: Vec3,
iterations: Int = 20,
center: LpCenter = LpCenter.ANALYTIC,
minVolume: Double = 1e-9,
): LpCuttingPlaneTrace3 {
require(iterations >= 0) { "iterations must be non-negative, got $iterations" }
val c = unit3(direction)
val normals = (0 until constraintCount).map { vec(normalX[it], normalY[it], normalZ[it]) }.toMutableList()
val bounds = bound.toMutableList()
var region = this
val samples = ArrayList<LpSample3>()
val regions = ArrayList<LpPolytope>()
for (k in 0..iterations) {
val mesh = region.toMesh()
val volume = mesh.volume()
val p = when (center) {
LpCenter.CENTROID -> mesh.solidCentroid().let { doubleArrayOf(it.x.toDouble(), it.y.toDouble(), it.z.toDouble()) }
LpCenter.CHEBYSHEV -> chebyshevCenter3(region.normalX, region.normalY, region.normalZ, region.bound)
LpCenter.ANALYTIC, LpCenter.VOLUMETRIC -> {
val s = region.center
val analytic = barrierNewton3(
region.normalX, region.normalY, region.normalZ, region.bound, DoubleArray(region.constraintCount) { 1.0 },
doubleArrayOf(0.0, 0.0, 0.0), 1.0, s.x.toDouble(), s.y.toDouble(), s.z.toDouble(),
)
if (center == LpCenter.ANALYTIC) analytic
else volumetricCenter3(region.normalX, region.normalY, region.normalZ, region.bound, analytic)
}
}
samples.add(sample(c, p[0], p[1], p[2], k, volume, "cutting-plane"))
regions.add(region)
if (k == iterations || volume < minVolume) break
val cut = -(c[0] * p[0] + c[1] * p[1] + c[2] * p[2])
normals.add(vec(-c[0], -c[1], -c[2]))
bounds.add(cut)
region = runCatching { LpPolytope(normals, bounds) }.getOrNull() ?: break
}
return LpCuttingPlaneTrace3(LpTrajectory3("cutting-plane-${center.name.lowercase()}", samples), regions)
}
fun List<LpTrajectory3>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine>
Every path of a 3-D fan as an FLine, coloured round a hue circle.
@JvmName("toFLines3")
fun List<LpTrajectory3>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine> =
mapIndexed { i, t ->
val h = 2.0 * PI * i / size
t.toFLine(vec(0.6 + 0.4 * cos(h), 0.6 + 0.4 * cos(h - 2.1), 0.6 + 0.4 * cos(h + 2.1), alpha), thickness)
}