ArchitecturalGeometry.kt
src/jvmMain/kotlin/afield/math/optimization/ceres/ArchitecturalGeometry.kt · 1649 lines
@file:afield.runtime.completion.CompletionCategory("Solvers")
package afield.math.optimization.ceres
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector3fc
import java.util.*
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sqrt
A named, typed DSL over the raw constraint vocabulary in CeresGeometry.kt, plus the interactive
machinery for solving it while a user drags. geometryModel opens a GeometryModelScope where
you declare PointRefs, LineRefs, PlaneRefs and PanelRefs (points can be created from
Vector3fc values or bound to live Vector3fs via GeometryModelScope.bindPoint) and then
state relations inside constraints { } — ConstraintScope supplies length, parallelTo,
angleRadiansTo, planar, on, inside, lock, weak, mirror, dihedral and the ceq
infix equality.
GeometryModel.openSession (or session) compiles the program once and returns a
GeometrySolveSession holding a mutable point cloud, per-point prior weights and a reusable
solve report; GeometrySolveSession.drag / DragHandle / pin / release are the interaction
verbs, weighted by InteractionPolicy.
liveGeometry wraps a model block rather than a model: each LiveGeometryProgram.step re-runs the block, reuses the existing session when point names, point count and program hash are all unchanged, reads every bound point back in, solves, and writes the results out through the bindings. LiveGeometryProgram.polishLocked does a separate hard-locked settle pass.
Point arithmetic (PointExpr, a + b, 0.5 * c) creates synthetic "trace" points that are
recomputed from their expression before every solve. The default backend is FfmCeresBackend, so
these entry points need the native Ceres library present.
class PointRef internal constructor(val id: PointId, val name: String?)
A handle on one point of a model — what the DSL passes around instead of an index.
Equality is by id alone: the name is ignored, and so is which model the reference came from. Two models that both
declared a third point produce references that compare equal, so a PointRef used against the wrong session silently
addresses whatever point happens to sit at that index. They are only ever created by a GeometryModelScope.
class PointRef internal constructor(val id: PointId, val name: String?) {
val index: Int get() = id.value
The raw index, for reaching the underlying PointCloud or MutablePointCloud directly.
override fun equals(other: Any?): Boolean
By id alone, so a reference from another model compares equal at the same index.
override fun equals(other: Any?): Boolean =
other is PointRef && id == other.id
override fun hashCode(): Int = id.hashCode()
id's hash, to match equals.
override fun toString(): String = name ?: "p$index"
The declared name, or p and the index — p12 — where the scope was given none.
data class LineRef(val a: PointRef, val b: PointRef, val name: String? = null)
Two points named as a segment. Carries no constraint of its own — it is the receiver the length, angle and direction verbs hang off, and the two endpoints stay ordinary free points.
data class PlaneRef(val a: PointRef, val b: PointRef, val c: PointRef, val name: String? = null)
Three points named as a plane, which therefore moves with them.
The order matters: the normal is unit(cross(b - a, c - a)), and it decides the sign of every offset and
every half-space taken against this plane.
data class PanelRef(val a: PointRef, val b: PointRef, val c: PointRef, val d: PointRef, val name: String? = null)
Four points named as a quadrilateral, in perimeter order — area triangulates on the a-c diagonal, so a transposed
pair describes a bow tie rather than a panel.
data class PanelRef(val a: PointRef, val b: PointRef, val c: PointRef, val d: PointRef, val name: String? = null) {
val points: List<PointRef> get() = listOf(a, b, c, d)
The four corners in perimeter order, for a caller that wants to iterate rather than name them.
class PointExpr internal constructor
An affine combination of points — a + b, 0.5 * (a + b), a - b — as an expression you can constrain with
ConstraintScope.ceq.
Terms collect by point and zero coefficients are dropped, so the same point mentioned twice becomes one term with the
weights summed. Note that nothing normalises the coefficients: a + b is the sum of two positions, not their
midpoint; write 0.5 * (a + b) for that.
How it is enforced is the important part. Constraining an expression materialises a synthetic point, which is recomputed from the expression before each solve rather than tied to its inputs by a constraint. So the relation holds between solves, not within one, and force applied to the derived point does not travel back to its terms.
class PointExpr internal constructor(
internal val terms: Map<PointRef, Double>,
internal val constant: Vec3 = Vec3(0.0, 0.0, 0.0),
) {
operator fun plus(other: PointExpr): PointExpr
Add two expressions, summing the coefficient of any point they share. Not a midpoint.
operator fun plus(other: PointExpr): PointExpr {
val next = terms.toMutableMap()
other.terms.forEach { (point, weight) ->
next[point] = (next[point] ?: 0.0) + weight
}
return PointExpr(next.cleanTerms(), constant + other.constant)
}
operator fun plus(other: PointRef): PointExpr = this + other.expr
Add a bare point into this expression, as a term with coefficient one.
operator fun minus(other: PointExpr): PointExpr = this + (-other)
Subtract an expression, which for two points is the edge vector between them.
operator fun minus(other: PointRef): PointExpr = this - other.expr
Subtract a bare point from this expression.
operator fun unaryMinus(): PointExpr = this * -1.0
Negate every coefficient and the constant.
operator fun times(scale: Number): PointExpr
Scale every coefficient and the constant. The scale must be finite.
operator fun times(scale: Number): PointExpr {
val s = scale.toDouble()
require(s.isFinite()) { "expression scale must be finite" }
return PointExpr(terms.mapValues { it.value * s }.cleanTerms(), constant * s)
}
internal fun directPointOrNull(): PointRef?
internal fun directPointOrNull(): PointRef? =
terms.entries.singleOrNull()?.takeIf { it.value == 1.0 && constant.isZero() }?.key
override fun toString(): String
The combination written out, as in west + east*0.5, so a trace point can be named.
override fun toString(): String =
buildString {
if (terms.isEmpty() && constant.isZero()) append("0")
terms.entries.forEachIndexed { index, (point, weight) ->
if (index > 0) append(" + ")
append(point)
if (weight != 1.0) append("*").append(weight)
}
if (!constant.isZero()) {
if (isNotEmpty()) append(" + ")
append("Vec3(${constant.x}, ${constant.y}, ${constant.z})")
}
}
}
class DotExpr internal constructor
A pending dot-product statement, waiting for ConstraintScope.ceq to give it a target. Normalised by default, so the
target is a cosine; ConstraintScope.raw makes it a raw dot product in length squared.
class DotExpr internal constructor(
/** The first segment. Its direction is `b - a` of that line. */
val a: LineRef,
/** The second segment, dotted against [a]. */
val b: LineRef,
/** Whether the target is a cosine. False makes it a raw dot product, in length squared. */
val normalized: Boolean = true,
)
class GeometryTrace internal constructor
A synthetic point and the PointExpr it is recomputed from before every solve.
Created when an expression is constrained. It is a real free parameter during the solve, re-derived at the start of the next one — which is why an expression relation is one-way within a single solve.
class GeometryTrace internal constructor(
/** The synthetic point the expression is written into, which is a free parameter like any other. */
val target: PointRef,
/** What [target] is recomputed from at the start of each solve. */
val expression: PointExpr,
)
class PointBinding internal constructor
The link between a model point and a value that lives outside the model: a reader, a writer, and the tolerance that decides when a change in the read value counts as a drag.
Created by GeometryModelScope.bindPoint. dragTolerance is the whole trick — a bound value that moves further than
this between steps is treated as user input and weighted heavily, so external edits become drags without anyone
saying so.
class PointBinding internal constructor(
/** The model point this value stands for. */
val point: PointRef,
/** Called before each step to see where the outside world has put the point. */
val reader: () -> Vec3,
/** Called after each solve with the answer, which is how a bound `Vector3f` is updated in place. */
val writer: (Vec3) -> Unit,
/** How far a read value may move between steps before the change counts as user input. */
val dragTolerance: Double,
/** The prior weight such a change is given, or null for the policy's own drag weight. */
val dragWeight: Double?,
internal val sourceIdentity: Int? = null,
) {
init
init {
require(dragTolerance.isFinite() && dragTolerance >= 0.0) { "drag tolerance must be finite and non-negative" }
dragWeight?.let {
require(it.isFinite() && it >= 0.0) { "drag weight must be finite and non-negative" }
}
}
}
class GeometryModel internal constructor
A finished, immutable model: the named points, their seed positions, the compiled-ready CeresProgram, and the trace expressions and external bindings that go with them.
Describes a solve without being one. openSession is where it becomes a live problem; initialPointCloud and mutablePoints hand out copies of the seed geometry, so a model can be solved many times over from the same start.
The seed positions are not merely a starting guess — they are what ConstraintScope.lock locks to and what Planar fits its plane variables from, so two models with the same constraints and different seeds are genuinely different problems.
class GeometryModel internal constructor(
/** One entry per point, in index order, null where the scope declared the point unnamed. */
val pointNames: List<String?>,
private val initialValues: DoubleArray,
/** The constraints as the backend wants them, ready to compile but not yet compiled. */
val program: CeresProgram,
internal val traces: List<GeometryTrace>,
internal val bindings: List<PointBinding>,
) {
val pointCount: Int = pointNames.size
How many points the model declares, trace points included.
fun initialPointCloud(): PointCloud = PointCloud(initialValues.copyOf())
A copy of the declared positions. Copies, so the model's own seed cannot be edited through it.
fun mutablePoints(): MutablePointCloud = MutablePointCloud(initialValues.copyOf(), pointNames)
A fresh mutable cloud at the declared positions, carrying the point names. What a session solves into.
fun openSession
Compile the program and open a live GeometrySolveSession over it — the transition from description to solving.
Compiling is the expensive step and happens once here, so hold the session for as long as the model is unchanged. It owns native memory and must be closed.
Note the parameter shape: baselinePrior and metricCapacity are only read while constructing the default
policy, so passing an explicit policy silently ignores both. Set them on the policy instead.
fun openSession(
backend: CeresSolverBackend = FfmCeresBackend(),
baselinePrior: Double = 1e-6,
metricCapacity: Int = 128,
policy: InteractionPolicy = InteractionPolicy(
preserveWeight = baselinePrior,
releaseWeight = baselinePrior,
metricCapacity = metricCapacity,
),
): GeometrySolveSession = GeometrySolveSession(this, backend, policy)
internal fun withHardLocks(points: Iterable<PointRef>, weight: Double): GeometryModel
internal fun withHardLocks(points: Iterable<PointRef>, weight: Double): GeometryModel {
require(weight.isFinite() && weight >= 0.0) { "hard lock weight must be finite and non-negative" }
return withHardLocks(points.associateWith { initialValue(it) }, weight)
}
internal fun withHardLocks(targets: Map<PointRef, Vec3>, weight: Double): GeometryModel
internal fun withHardLocks(targets: Map<PointRef, Vec3>, weight: Double): GeometryModel {
require(weight.isFinite() && weight >= 0.0) { "hard lock weight must be finite and non-negative" }
val ids = targets.keys.map { it.id }.distinct()
if (ids.isEmpty()) return this
val lockedInitialValues = initialValues.copyOf()
targets.forEach { (point, target) ->
require(target.x.isFinite() && target.y.isFinite() && target.z.isFinite()) { "hard lock target must be finite" }
val i = point.id.value * 3
lockedInitialValues[i] = target.x
lockedInitialValues[i + 1] = target.y
lockedInitialValues[i + 2] = target.z
}
val locks = listOf(
LockPosition(ids, AxisSet.X, weight),
LockPosition(ids, AxisSet.Y, weight),
LockPosition(ids, AxisSet.Z, weight),
)
return GeometryModel(
pointNames = pointNames,
initialValues = lockedInitialValues,
// copy(), not a fresh CeresProgram: the loss and any free-variable declarations belong to
// the program being locked, and rebuilding it from its constraints alone drops them.
program = program.copy(constraints = program.constraints + locks),
traces = traces,
bindings = bindings,
)
}
internal fun initialValue(point: PointRef): Vec3
internal fun initialValue(point: PointRef): Vec3 {
val i = point.id.value * 3
require(i + 2 in initialValues.indices) { "point is not in this model: $point" }
return Vec3(initialValues[i], initialValues[i + 1], initialValues[i + 2])
}
}
class GeometryModelScope
The declaration half of the DSL: everything you can say inside a geometryModel block before, and around, the
constraints { } that states the relations.
Points come three ways and the difference matters. point with literal coordinates is a plain free variable; point
with a Vector3fc reads its value once and is memoised by object identity, so passing the same vector twice gives
you the same point and two equal-but-distinct vectors give you two; bindPoint takes a reader and a writer, and
those are called on every LiveGeometryProgram step, which is how a model tracks values that live outside it.
line, plane and panel are not constraints — they are named tuples of points, so the same three points can be a
plane here and a triangle there at no cost. Constraint order within constraints { } does not matter; point
declaration order does, because it fixes the ids.
class GeometryModelScope {
private val pointNames = mutableListOf<String?>()
private val pointValues = mutableListOf<Double>()
private val constraints = mutableListOf<GeometryConstraint>()
private val traces = mutableListOf<GeometryTrace>()
private val bindings = mutableListOf<PointBinding>()
private val vectorPointRefs = IdentityHashMap<Vector3fc, PointRef>()
private val mutableVectorPointRefs = mutableMapOf<Int, PointRef>()
private var syntheticPointCounter = 0
private var defaultInteractivePriors = true
private var defaultInteractivePriorWeight = 1e-6
private var loss = RobustLoss.DEFAULT
fun robustLoss(loss: RobustLoss)
Choose the loss every residual is wrapped in. Read this before tuning a single weight.
The default is RobustLoss.DEFAULT — Huber(1e-3) — inherited from the native wrapper, and it
is not plain least squares. Beyond a scaled residual of 1e-3, which on a model measured in
metres is essentially everywhere, Huber's cost is linear rather than quadratic. So two statements
pulling one unknown in different directions settle at the weighted median of their targets,
not the weighted mean: at weights 1 and 1000 the answer is not (1·a + 1000·b)/1001, it is
b to within a rounding error. The heavier statement simply wins.
That is a reasonable default for interactive editing — one badly-placed point should not drag the
model — and it is the wrong one whenever the weights are meant to trade off, which includes
every case where somebody wrote two weights expecting a compromise. For those, say
robustLoss(RobustLoss.NONE). Until this existed the DSL had no way to say it at all.
fun robustLoss(loss: RobustLoss) {
this.loss = loss
}
fun point(name: String, x: Double, y: Double, z: Double): PointRef = point(x, y, z, name)
A named point at explicit coordinates: the name-first spelling of point.
fun point(name: String, value: Vector3fc): PointRef = point(value, name)
A named point seeded from a Vector3fc, memoised by object identity like the unnamed form.
fun point(value: Vector3fc, name: String? = null): PointRef
A point seeded from a Vector3fc, memoised by object identity: the same vector always yields the same
PointRef, two vectors that merely hold equal coordinates yield two points.
The value is read once, at declaration. The vector is not watched and not written back — for that, declare it with bindPoint or the Vector3f.point property.
fun point(value: Vector3fc, name: String? = null): PointRef =
vectorPointRefs.getOrPut(value) {
point(value.x().toDouble(), value.y().toDouble(), value.z().toDouble(), name)
}
val Vector3fc.c: PointRef get() = point(this)
v.c — the terse form of point for a read-only vector, memoised by identity in the same way.
val Vector3f.point: PointRef get() = bindPoint(value = this)
v.point — a point bound to a mutable Vector3f, read and written back on every solve.
Memoised by identity, so the same vector is always the same point. The generated name embeds the vector's
identity hash, which means a LiveGeometryProgram whose block allocates fresh Vector3fs every step sees a
different model each time and rebuilds its session; hoist the vectors out of the block.
fun point
A named bound point: as bindPoint, for when the name is not optional.
fun point(
name: String,
reader: () -> Vec3,
writer: (Vec3) -> Unit = {},
dragTolerance: Double = 1e-5,
dragWeight: Double? = null,
): PointRef = bindPoint(name, reader, writer, dragTolerance, dragWeight)
fun bindPoint
A point whose value lives outside the model: reader supplies it, writer receives the solved result.
The bridge between a constraint model and whatever else holds the geometry. LiveGeometryProgram calls reader
at the start of every step and writer at the end, so an external edit becomes an input and the solve becomes an
output. reader is also called here, once, for the seed — and must return finite coordinates every time or the
step throws.
A point whose read value has moved by more than dragTolerance since the last step is treated as dragged, and
gets dragWeight (or the policy's) rather than the resting prior. That is how "the user moved this one" is
inferred without being told; set dragTolerance high to opt a point out. The default writer discards, which
makes a read-only input.
fun bindPoint(
name: String? = null,
reader: () -> Vec3,
writer: (Vec3) -> Unit = {},
dragTolerance: Double = 1e-5,
dragWeight: Double? = null,
sourceIdentity: Int? = null,
): PointRef {
val seed = reader()
val ref = point(seed.x, seed.y, seed.z, name)
bindings += PointBinding(ref, reader, writer, dragTolerance, dragWeight, sourceIdentity)
return ref
}
fun bindPoint
Bind a mutable Vector3f directly: the solver reads it and writes the answer back into it, in place.
The common case, and the reason Vector3f.point exists. Memoised by identity hash, so the same vector is one
point however often it is mentioned. Coordinates round-trip through Float, so a solved position is stored to
about seven significant digits — enough for geometry, not enough to compare a bound point for exact equality with
the solved cloud.
fun bindPoint(
name: String? = null,
value: Vector3f,
dragTolerance: Double = 1e-5,
dragWeight: Double? = null,
): PointRef {
val identity = System.identityHashCode(value)
mutableVectorPointRefs[identity]?.let { return it }
val ref = bindPoint(
name = name ?: "vector3f:$identity",
reader = { value.vec3() },
writer = { value.set(it.x.toFloat(), it.y.toFloat(), it.z.toFloat()) },
dragTolerance = dragTolerance,
dragWeight = dragWeight,
sourceIdentity = identity,
)
mutableVectorPointRefs[identity] = ref
vectorPointRefs[value] = ref
return ref
}
fun point(x: Double, y: Double, z: Double, name: String? = null): PointRef
A free point at a literal position. The seed matters: it is where the solve starts from, where ConstraintScope.lock will lock to, and — for a non-convex problem — which of several answers you get.
name is for diagnostics and for LiveGeometryProgram's reuse check; two points may share one.
fun point(x: Double, y: Double, z: Double, name: String? = null): PointRef {
require(x.isFinite() && y.isFinite() && z.isFinite()) { "point coordinates must be finite" }
val ref = PointRef(PointId(pointNames.size), name)
pointNames += name
pointValues += x
pointValues += y
pointValues += z
return ref
}
fun line(a: PointRef, b: PointRef, name: String? = null): LineRef
Name a segment. Declares nothing about it — a LineRef is a handle you then hang ConstraintScope.length,
ConstraintScope.parallelTo and the rest on.
fun line(a: PointRef, b: PointRef, name: String? = null): LineRef {
require(a != b) { "line endpoints must be distinct" }
return LineRef(a, b, name)
}
fun line(a: Vector3fc, b: Vector3fc, name: String? = null): LineRef
As line, declaring a point for each Vector3fc end.
fun line(a: Vector3fc, b: Vector3fc, name: String? = null): LineRef =
line(point(a), point(b), name)
fun line(a: PointRef, b: Vector3fc, name: String? = null): LineRef
As line, declaring a point for the Vector3fc end.
fun line(a: PointRef, b: Vector3fc, name: String? = null): LineRef =
line(a, point(b), name)
fun line(a: Vector3fc, b: PointRef, name: String? = null): LineRef
As line, declaring a point for the Vector3fc end.
fun line(a: Vector3fc, b: PointRef, name: String? = null): LineRef =
line(point(a), b, name)
infix fun PointRef.lineTo(other: PointRef): LineRef = line(this, other)
a lineTo b — line as an infix, for reading a declaration left to right.
infix fun PointRef.lineTo(other: Vector3fc): LineRef = line(this, other)
As above, declaring a point for the Vector3fc end.
infix fun Vector3fc.lineTo(other: PointRef): LineRef = line(this, other)
As above, declaring a point for the Vector3fc end.
infix fun Vector3fc.lineTo(other: Vector3fc): LineRef = line(this, other)
As above, declaring a point for both ends.
fun plane(a: PointRef, b: PointRef, c: PointRef, name: String? = null): PlaneRef
Name a plane by three of the model's own points, so it moves with them.
The order fixes the normal, unit(cross(b - a, c - a)), and therefore the sign of every offset and half-space
taken against it. Points must be distinct; nothing checks that they are not collinear, which would leave the
normal arbitrary.
fun plane(a: PointRef, b: PointRef, c: PointRef, name: String? = null): PlaneRef {
requireDistinctRefs(a, b, c)
return PlaneRef(a, b, c, name)
}
fun plane(a: Vector3fc, b: Vector3fc, c: Vector3fc, name: String? = null): PlaneRef
As plane, declaring a point for each Vector3fc corner. The order still fixes the normal.
fun plane(a: Vector3fc, b: Vector3fc, c: Vector3fc, name: String? = null): PlaneRef =
plane(point(a), point(b), point(c), name)
fun plane(a: PointRef, b: Vector3fc, c: Vector3fc, name: String? = null): PlaneRef
As plane, declaring points for the Vector3fc corners.
fun plane(a: PointRef, b: Vector3fc, c: Vector3fc, name: String? = null): PlaneRef =
plane(a, point(b), point(c), name)
fun plane(a: Vector3fc, b: PointRef, c: Vector3fc, name: String? = null): PlaneRef
As plane, declaring points for the Vector3fc corners.
fun plane(a: Vector3fc, b: PointRef, c: Vector3fc, name: String? = null): PlaneRef =
plane(point(a), b, point(c), name)
fun plane(a: Vector3fc, b: Vector3fc, c: PointRef, name: String? = null): PlaneRef
As plane, declaring points for the Vector3fc corners.
fun plane(a: Vector3fc, b: Vector3fc, c: PointRef, name: String? = null): PlaneRef =
plane(point(a), point(b), c, name)
fun panel(a: PointRef, b: PointRef, c: PointRef, d: PointRef, name: String? = null): PanelRef
Name a quadrilateral. Pass the corners around the perimeter — ConstraintScope.area triangulates on the
a-c diagonal, so a transposed pair silently constrains a bow tie.
fun panel(a: PointRef, b: PointRef, c: PointRef, d: PointRef, name: String? = null): PanelRef {
requireDistinctRefs(a, b, c, d)
return PanelRef(a, b, c, d, name)
}
fun panel(a: Vector3fc, b: Vector3fc, c: Vector3fc, d: Vector3fc, name: String? = null): PanelRef
As panel, declaring a point for each corner. Perimeter order still matters.
fun panel(a: Vector3fc, b: Vector3fc, c: Vector3fc, d: Vector3fc, name: String? = null): PanelRef =
panel(point(a), point(b), point(c), point(d), name)
fun constraints(block: ConstraintScope.() -> Unit) {
State the relations. Runs immediately, appending to this model; may be called more than once, and constraints accumulate across calls.
ConstraintScope can also declare points, so a relation may introduce the geometry it needs inline.
fun constraints(block: ConstraintScope.() -> Unit) {
ConstraintScope(this).block()
}
fun defaultInteractionPriors(weight: Double = 1e-6)
Set the weight of the whole-cloud prior that build appends. Raise it to make the model stiffer against being
dragged; the default 1e-6 is meant to be invisible except where the geometry leaves a point free.
fun defaultInteractionPriors(weight: Double = 1e-6) {
require(weight.isFinite() && weight >= 0.0) { "default prior weight must be finite and non-negative" }
defaultInteractivePriors = true
defaultInteractivePriorWeight = weight
}
fun noDefaultInteractionPriors()
Suppress the automatic whole-cloud prior, leaving the model constrained by nothing but what you wrote.
For a fully determined problem, or one that supplies its own regularisation. Without it, any direction the constraints do not pin is a direction the minimiser is free to wander in, and the answer becomes dependent on the path taken to it.
fun noDefaultInteractionPriors() {
defaultInteractivePriors = false
}
internal fun add(constraint: GeometryConstraint)
internal fun add(constraint: GeometryConstraint) {
constraints += constraint
}
internal fun positionOf(point: PointRef): Vec3
internal fun positionOf(point: PointRef): Vec3 {
val i = point.index * 3
return Vec3(pointValues[i], pointValues[i + 1], pointValues[i + 2])
}
internal fun materialize(expr: PointExpr, name: String? = null): PointRef
internal fun materialize(expr: PointExpr, name: String? = null): PointRef {
expr.directPointOrNull()?.let { return it }
val at = expr.terms.entries.fold(expr.constant) { acc, (point, weight) ->
acc + positionOf(point) * weight
}
val ref = point(at.x, at.y, at.z, name ?: "_trace${syntheticPointCounter++}")
traces += GeometryTrace(ref, expr)
add(WeakPosition(listOf(ref.id), AxisSet.XYZ, 1e-6))
return ref
}
fun build(): GeometryModel
Freeze the declarations into a GeometryModel, appending the whole-cloud weak prior unless one was written by hand or noDefaultInteractionPriors was called.
geometryModel calls this for you. Calling it twice gives two independent models over the same declarations.
fun build(): GeometryModel {
val finalConstraints = constraints.toMutableList()
val alreadyHasAllPointPrior = constraints.any {
it is WeakPosition && it.axes == AxisSet.XYZ && PointId.ALL_POINTS in it.points
}
if (defaultInteractivePriors && !alreadyHasAllPointPrior) {
finalConstraints += WeakPosition(listOf(PointId.ALL_POINTS), AxisSet.XYZ, defaultInteractivePriorWeight)
}
return GeometryModel(
pointNames = pointNames.toList(),
initialValues = pointValues.toDoubleArray(),
program = CeresProgram(finalConstraints, loss),
traces = traces.toList(),
bindings = bindings.toList(),
)
}
}
class ConstraintScope internal constructor(private val model: GeometryModelScope)
The verbs: what you can say about the points, lines, planes and panels a GeometryModelScope has declared.
Every method here is a named front end for one of the constraints in CeresGeometry.kt — length is
DistanceEquals, on is PointOnPlane, dihedral is DihedralAngle — so the units, the sign conventions and the
documented traps of the underlying constraint all still apply. What this layer adds is names, receiver syntax, and a
few conveniences the raw vocabulary cannot express: lock with AxisSet.XYZ expands to three locks, sameXYZ to
three same-coordinate constraints, and angleRadiansTo takes the cosine for you.
Weights default to 1.0 and are only comparable between constraints whose residuals share units. Nothing here checks
for redundancy or contradiction: two conflicting statements are not an error, they are a compromise weighted by
whatever you gave them.
What kind of compromise depends on robustLoss, and the default is not the one this sentence used to promise.
Under the inherited default — Huber(1e-3), see RobustLoss.DEFAULT and the discussion at
CeresGeometry's RobustLoss — the compromise is nearer a weighted median than a weighted mean, so the heavier
statement effectively wins outright. Call robustLoss(RobustLoss.NONE) when the weights are meant to trade off.
class ConstraintScope internal constructor(private val model: GeometryModelScope) {
fun add(constraint: GeometryConstraint)
Add a raw GeometryConstraint — the escape hatch to anything this scope has no verb for.
fun add(constraint: GeometryConstraint) {
model.add(constraint)
}
fun expression
A residual written here in Kotlin and differentiated exactly by Ceres (CeresExpressionResidual) — the way past this scope's vocabulary without a native rebuild.
build receives one CeresExprVec per entry of points, in order, and returns one expression
per residual component. So "this point is a third of the way from u to v", which no opcode
encodes, is three residuals:
expression(listOf(p, u, v), weight = 10.0) { (pe, ue, ve) ->
val want = ue * (1.0 - f) + ve * f
listOf(pe.x - want.x, pe.y - want.y, pe.z - want.z)
}
Why this exists next to ceq. An affine relation stated with a PointExpr goes through
GeometryModelScope.materialize, which introduces a trace point recomputed between solves and
held only by a 1e-6 prior — so within a solve the minimiser can satisfy the relation by moving
the trace point rather than the geometry, and the weight is fixed at 1.0 besides. An expression
residual is two-sided, carries the weight you give it, and adds no parameters. Prefer it
wherever the relation is meant to push back.
The costs are CeresExpressionResidual's: the VM interprets rather than specialising the way a fixed-arity functor does, and Ceres forbids one residual block naming the same point twice, so points must be distinct.
fun expression(
points: List<PointRef>,
weight: Double = 1.0,
build: (List<CeresExprVec>) -> List<CeresExpr>,
) {
val bound = points.indices.map { CeresExprVec(ExprParam(it, 0), ExprParam(it, 1), ExprParam(it, 2)) }
add(CeresExpressionResidual(points.map { it.id }, build(bound), weight))
}
val Vector3fc.c: PointRef get() = model.point(this)
v.c — declare a point at this vector's value, inside constraints { }. Read once, not watched.
val Vector3f.point: PointRef get() = model.bindPoint(value = this)
v.point — declare a point bound to this mutable vector, so the solve writes back into it.
fun point(value: Vector3fc, name: String? = null): PointRef = model.point(value, name)
Declare a point from inside constraints { }, for geometry the model block did not name.
fun line(a: PointRef, b: PointRef, name: String? = null): LineRef = model.line(a, b, name)
Name a segment from inside constraints { }. The endpoints must be distinct.
fun line(a: Vector3fc, b: Vector3fc, name: String? = null): LineRef = model.line(a, b, name)
As above, declaring a point for each Vector3fc end.
fun line(a: PointRef, b: Vector3fc, name: String? = null): LineRef = model.line(a, b, name)
As above, declaring a point for the Vector3fc end.
fun line(a: Vector3fc, b: PointRef, name: String? = null): LineRef = model.line(a, b, name)
As above, declaring a point for the Vector3fc end.
infix fun PointRef.lineTo(other: PointRef): LineRef = line(this, other)
a lineTo b — line as an infix.
infix fun PointRef.lineTo(other: Vector3fc): LineRef = line(this, other)
As above, declaring a point for the Vector3fc end.
infix fun Vector3fc.lineTo(other: PointRef): LineRef = line(this, other)
As above, declaring a point for the Vector3fc end.
infix fun Vector3fc.lineTo(other: Vector3fc): LineRef = line(this, other)
As above, declaring a point for both ends.
infix fun LineRef.dot(other: LineRef): DotExpr = DotExpr(this, other)
Begin a dot-product statement: (a lineTo b) dot (c lineTo d) ceq 0.5. Normalised, so the target is a cosine.
fun DotExpr.raw(): DotExpr = DotExpr(a, b, normalized = false)
Make a dot statement unnormalised, so the target is a raw dot product in length squared and the lengths are
constrained too. Rarely what you want; angleCosTo is the angle-only form.
operator fun PointRef.plus(other: PointRef): PointExpr = expr + other
Start a PointExpr from two points. A sum of positions, not a midpoint; scale it yourself.
operator fun PointRef.plus(other: PointExpr): PointExpr = expr + other
Add a point into an existing expression.
operator fun PointRef.minus(other: PointRef): PointExpr = expr - other
The difference of two points, which is the edge vector between them.
operator fun PointRef.minus(other: PointExpr): PointExpr = expr - other
Subtract an expression from a point.
operator fun PointRef.times(scale: Number): PointExpr = expr * scale
Scale a point's position, as in 0.5 * (a + b).
operator fun Number.times(point: PointRef): PointExpr = point * this
The same scaling with the number on the left, so 0.5 * a reads as it would on paper.
operator fun Number.times(expr: PointExpr): PointExpr = expr * this
The same, scaling a whole expression, as in 0.5 * (a + b).
infix fun DotExpr.ceq(target: Number)
Drive a dot statement to a value. Weight is fixed at 1.0; use angleCosTo if you need to set it.
infix fun DotExpr.ceq(target: Number) {
val t = target.toDouble()
require(t.isFinite()) { "dot target must be finite" }
add(VectorDot(a.a.id, a.b.id, b.a.id, b.b.id, t, normalized, 1.0))
}
infix fun PointRef.ceq(other: PointRef)
Two points coincident, as three same-coordinate constraints at weight 1.0.
A soft weld, not an identity: the two remain separate parameters and a heavier constraint elsewhere can pull them apart. If you want one point, declare one point.
infix fun PointRef.ceq(other: PointRef) {
sameXYZ(this, other)
}
infix fun PointRef.ceq(other: Vector3fc)
As PointRef.ceq, against a position; the vector is declared as a point first.
infix fun PointRef.ceq(other: Vector3fc) {
this ceq other.c
}
infix fun Vector3fc.ceq(other: PointRef)
As PointRef.ceq, with the vector on the left; it is declared as a point first.
infix fun Vector3fc.ceq(other: PointRef) {
this.c ceq other
}
infix fun PointExpr.ceq(other: PointRef)
Tie a point to an affine combination of others — (a + b) * 0.5 ceq mid for a midpoint.
The combination is materialised as a synthetic point, recomputed from its expression immediately before every
solve and then constrained to other. The consequence is that the coupling is one-way within a solve: moving
mid pulls on the synthetic point, but not back through it onto a and b, which only catch up at the start of
the next solve. Good for derived geometry, wrong for a relation you expect to push both ways.
infix fun PointExpr.ceq(other: PointRef) {
sameXYZ(model.materialize(this), other)
}
infix fun PointExpr.ceq(other: Vector3fc)
As PointExpr.ceq, against a position; the vector is declared as a point first.
infix fun PointExpr.ceq(other: Vector3fc) {
this ceq other.c
}
infix fun PointRef.ceq(other: PointExpr)
PointExpr.ceq with the operands the other way round; the same one-way coupling applies.
infix fun PointRef.ceq(other: PointExpr) {
other ceq this
}
fun weakAll(weight: Double = 1e-6)
A weak "stay near where you are" prior on every point, at a weight meant to be invisible except where the geometry leaves a point free.
WeakPosition's target is refreshed from the input cloud on each solve, so this regularises against the current positions rather than pulling back towards the seed.
fun weakAll(weight: Double = 1e-6) {
add(WeakPosition(listOf(PointId.ALL_POINTS), AxisSet.XYZ, weight))
}
fun noNegativeZAll(weight: Double = 1.0)
Penalise negative z on every point: a ground plane written as a residual, not as a bound.
fun noNegativeZAll(weight: Double = 1.0) {
add(NoNegativeZ(listOf(PointId.ALL_POINTS), weight))
}
fun Iterable<PointRef>.planar(weight: Double = 1.0)
All these points coplanar, on a plane the solver introduces as free variables (Planar).
Needs at least three points, and is only meaningful past three. Adds auxiliary plane variables to the problem, so the reported cost includes residuals you never wrote; on a model with many faces that formulation has a known null space — see Planar.
fun Iterable<PointRef>.planar(weight: Double = 1.0) {
add(Planar(map { it.id }, weight))
}
fun PanelRef.planar(weight: Double = 1.0)
A flat panel: planar over its four corners.
fun PanelRef.planar(weight: Double = 1.0) {
points.planar(weight)
}
fun PanelRef.area(target: Double, weight: Double = 1.0)
A panel of a prescribed area, triangulated on the a-c diagonal (PanelArea).
The residual is an area, so its weight is on a different scale from every length constraint in the model by a factor of length squared.
fun PanelRef.area(target: Double, weight: Double = 1.0) {
add(PanelArea(a.id, b.id, c.id, d.id, target, weight))
}
fun PointRef.on(plane: PlaneRef, offset: Double = 0.0, weight: Double = 1.0)
This point on that plane, or offset away from it along the normal (PointOnPlane).
The sign of offset follows the plane's point order. The plane is made of model points and moves too, so this is
a relation, not a fixture.
fun PointRef.on(plane: PlaneRef, offset: Double = 0.0, weight: Double = 1.0) {
add(PointOnPlane(id, plane.a.id, plane.b.id, plane.c.id, offset, weight))
}
fun PointRef.inside(halfspace: PlaneRef, minSignedDistance: Double = 0.0, weight: Double = 1.0)
This point on the normal side of that plane, with minSignedDistance of clearance (PointInHalfspace).
A barrier: costs exactly nothing once satisfied, so it can be added to a tuned model without moving it. Which side counts as inside follows the plane's point order.
fun PointRef.inside(halfspace: PlaneRef, minSignedDistance: Double = 0.0, weight: Double = 1.0) {
add(PointInHalfspace(id, halfspace.a.id, halfspace.b.id, halfspace.c.id, minSignedDistance, weight))
}
fun PointRef.lock(axes: AxisSet = AxisSet.XYZ, weight: Double = 1e6)
Hold this point where it was declared. See lock for what "hold" and "where" actually mean.
fun PointRef.lock(axes: AxisSet = AxisSet.XYZ, weight: Double = 1e6) {
lock(listOf(this), axes, weight)
}
fun lock(points: Iterable<PointRef>, axes: AxisSet = AxisSet.XYZ, weight: Double = 1e6)
Hold points at the coordinates they were declared with, on the given axes, at a heavy default weight.
AxisSet.XYZ is expanded into three separate LockPosition constraints, because the bytecode has no all-axes
lock. Still soft: heavy enough to win in practice, not a hard constraint, and the target is the seed position
rather than wherever the point has since been dragged to. For "keep it where it is now", pin it on the session
instead.
fun lock(points: Iterable<PointRef>, axes: AxisSet = AxisSet.XYZ, weight: Double = 1e6) {
val ids = points.map { it.id }
if (axes == AxisSet.XYZ) {
add(LockPosition(ids, AxisSet.X, weight))
add(LockPosition(ids, AxisSet.Y, weight))
add(LockPosition(ids, AxisSet.Z, weight))
} else {
add(LockPosition(ids, axes, weight))
}
}
fun PointRef.weak(axes: AxisSet = AxisSet.XYZ, weight: Double = 1e-6)
A light preference for this point staying put, refreshed from its current position on every solve (WeakPosition).
Unlike lock, the target follows the point, so this resists motion rather than remembering a place. The model already has a whole-cloud version of this; use it to make one point stiffer than the rest.
fun PointRef.weak(axes: AxisSet = AxisSet.XYZ, weight: Double = 1e-6) {
add(WeakPosition(listOf(id), axes, weight))
}
fun LineRef.length(distance: Double, weight: Double = 1.0)
A fixed length (DistanceEquals) — the constraint everything else's weight is usually scaled against.
fun LineRef.length(distance: Double, weight: Double = 1.0) {
add(DistanceEquals(a.id, b.id, distance, weight))
}
fun LineRef.minLength(distance: Double, weight: Double = 1.0)
A minimum length (DistanceAtLeast): a barrier, costing exactly nothing while it is respected.
fun LineRef.minLength(distance: Double, weight: Double = 1.0) {
add(DistanceAtLeast(a.id, b.id, distance, weight))
}
fun LineRef.maxLength(distance: Double, weight: Double = 1.0)
A maximum length (DistanceAtMost): the mirror barrier, free while short enough.
fun LineRef.maxLength(distance: Double, weight: Double = 1.0) {
add(DistanceAtMost(a.id, b.id, distance, weight))
}
fun LineRef.sameLengthAs(other: LineRef, weight: Double = 1.0)
Two segments the same length, without saying what (EqualDistance). Use sameVectorAs if you also mean parallel.
Segments that share a point — two walls meeting at a corner — are the common case for "make this room square", and
Ceres aborts the whole process on a residual that names one point twice. So a shared point re-encodes as one
expression residual over the distinct points: the same |ab| - |cd|, scaled by the same sqrt(weight).
fun LineRef.sameLengthAs(other: LineRef, weight: Double = 1.0) {
val ends = listOf(a, b, other.a, other.b)
if (ends.map { it.id }.distinct().size == 4) {
add(EqualDistance(a.id, b.id, other.a.id, other.b.id, weight))
return
}
val distinct = ends.distinctBy { it.id }
fun slot(p: PointRef) = distinct.indexOfFirst { it.id == p.id }
expression(distinct, weight) { v -> listOf(v[slot(a)].distanceTo(v[slot(b)]) - v[slot(other.a)].distanceTo(v[slot(other.b)])) }
}
fun LineRef.sameVectorAs(other: LineRef, weight: Double = 1.0)
One segment a translate of another — same length, same direction, same sense (EqualEdgeVector). Strictly
stronger than sameLengthAs plus parallelTo; the shape a repeated bay or module usually wants.
fun LineRef.sameVectorAs(other: LineRef, weight: Double = 1.0) {
add(EqualEdgeVector(a.id, b.id, other.a.id, other.b.id, weight))
}
fun LineRef.parallelTo(other: LineRef, weight: Double = 1.0)
Parallel, in either sense — antiparallel satisfies it too (LineParallel). Endpoints must all be distinct.
fun LineRef.parallelTo(other: LineRef, weight: Double = 1.0) {
add(LineParallel(a.id, b.id, other.a.id, other.b.id, weight))
}
fun LineRef.angleCosTo(other: LineRef, targetCos: Double, weight: Double = 1.0)
A prescribed angle given as its cosine (LineAngle). Unsigned: it cannot tell +t from -t.
fun LineRef.angleCosTo(other: LineRef, targetCos: Double, weight: Double = 1.0) {
add(LineAngle(a.id, b.id, other.a.id, other.b.id, targetCos, weight))
}
fun LineRef.angleRadiansTo(other: LineRef, radians: Double, weight: Double = 1.0)
A prescribed angle in radians, converted to a cosine here. The readable form of angleCosTo — and, being a
cosine underneath, equally unsigned and equally blind to a full turn.
fun LineRef.angleRadiansTo(other: LineRef, radians: Double, weight: Double = 1.0) {
require(radians.isFinite()) { "angle must be finite" }
angleCosTo(other, cos(radians), weight)
}
fun LineRef.perpendicularTo(other: LineRef, weight: Double = 1.0)
A right angle: angleCosTo at zero. Says nothing about either length.
fun LineRef.perpendicularTo(other: LineRef, weight: Double = 1.0) {
angleCosTo(other, 0.0, weight)
}
fun LineRef.direction(direction: Vec3, weight: Double = 1.0)
Align a segment with a world direction — vertical, level, on a grid axis (LineDirection).
One of the few statements that reaches outside the model. The vector's length is irrelevant, it must not be zero, and the reversed direction satisfies it equally.
fun LineRef.direction(direction: Vec3, weight: Double = 1.0) {
add(LineDirection(a.id, b.id, direction, weight))
}
fun LineRef.toward(point: PointRef, weight: Double = 1.0)
Point this segment's line at another point (LineTowardPoint) — radial members, a set of lines converging.
There is no ray: a target behind the segment satisfies it exactly as well as one in front.
fun LineRef.toward(point: PointRef, weight: Double = 1.0) {
add(LineTowardPoint(a.id, b.id, point.id, weight))
}
fun LineRef.vanishesAt(point: PointRef, weight: Double = 1.0) = toward(point, weight)
toward, named for the perspective case — a family of segments sharing one vanishing point.
fun PlaneRef.normal(direction: Vec3, targetCos: Double = 1.0, weight: Double = 1.0)
Turn a plane to face a given direction (PlaneNormalDirection).
Signed, unlike direction: targetCos = 1.0 means the plane's own normal — fixed by its point order — points
the same way, -1.0 means it faces the other way.
fun PlaneRef.normal(direction: Vec3, targetCos: Double = 1.0, weight: Double = 1.0) {
add(PlaneNormalDirection(a.id, b.id, c.id, direction, targetCos, weight))
}
fun dihedral(edge: LineRef, sideA: PointRef, sideB: PointRef, targetCos: Double, weight: Double = 1.0)
A fold angle across an edge, given as its cosine (DihedralAngle).
-1.0 is flat and +1.0 is folded shut, which is the opposite of most people's first guess — a coplanar panel
joint is -1.0. All four points must be distinct.
fun dihedral(edge: LineRef, sideA: PointRef, sideB: PointRef, targetCos: Double, weight: Double = 1.0) {
add(DihedralAngle(edge.a.id, edge.b.id, sideA.id, sideB.id, targetCos, weight))
}
fun mirror(point: PointRef, mirror: PointRef, across: PlaneRef, weight: Double = 1.0)
Two points reflections of each other across a plane (MirrorAcrossPlane) — the symmetry statement of the DSL.
Symmetric and side-agnostic: a pair that has collapsed onto the plane satisfies it. All five points must be distinct, so a point cannot be its own mirror.
fun mirror(point: PointRef, mirror: PointRef, across: PlaneRef, weight: Double = 1.0) {
add(MirrorAcrossPlane(point.id, mirror.id, across.a.id, across.b.id, across.c.id, weight))
}
fun same(points: Iterable<PointRef>, axes: AxisSet, weight: Double = 1.0)
Points sharing one or two coordinates — a level row, a shared height (SameCoordinate).
Every point is tied to the first in the list, not to its neighbour, and AxisSet.XYZ is rejected here: use
sameXYZ or ceq.
fun same(points: Iterable<PointRef>, axes: AxisSet, weight: Double = 1.0) {
add(SameCoordinate(points.map { it.id }, axes, weight))
}
fun sameXYZ(a: PointRef, b: PointRef, weight: Double = 1.0)
Two points coincident, as three single-axis SameCoordinate constraints. What ceq is written in terms of.
fun sameXYZ(a: PointRef, b: PointRef, weight: Double = 1.0) {
add(SameCoordinate(listOf(a.id, b.id), AxisSet.X, weight))
add(SameCoordinate(listOf(a.id, b.id), AxisSet.Y, weight))
add(SameCoordinate(listOf(a.id, b.id), AxisSet.Z, weight))
}
}
class GeometrySolveSession internal constructor
A compiled model plus the mutable state a gesture needs: the live points, the per-point priors, the reusable report, and the pins.
Solve as often as you like — the native problem is built once and every solve reuses it, allocating nothing on the fast path. The cost is that points is the state: each solve overwrites it, so snapshot before anything you might want to undo.
Two things happen before every solve, whether you asked or not. Trace points (PointExpr) are recomputed from their expressions, and pinned points are moved back to their pinned targets — unless that point is the one currently being dragged. So a set on a pinned point does not survive a solve.
Owns native memory; close it, or open it with use.
class GeometrySolveSession internal constructor(
model: GeometryModel,
backend: CeresSolverBackend,
/** The weights and iteration counts this session's drag, release and settle passes use. */
val policy: InteractionPolicy,
) : AutoCloseable {
var model: GeometryModel
The model compiled into the native problem. A live program swaps it for one with the same point count and program hash, which is how a step reuses the session instead of recompiling.
var model: GeometryModel = model
private set
val points: MutablePointCloud = model.mutablePoints()
The live geometry, overwritten by every solve. Read it for results; snapshot it before you need an undo.
val priors: MutablePriorWeights
The live prior weights — this is where a drag, a pin and a release are expressed.
val priors: MutablePriorWeights = MutablePriorWeights.uniform(model.pointCount, policy.preserveWeight)
val report: MutableSolveReport = MutableSolveReport(policy.metricCapacity)
Filled by every solve that was asked for metrics; reused, so read it before the next one.
val quickOptions: SolveOptions = policy.dragOptions
The cheap per-frame options from the policy. The default for solve.
val metricOptions: SolveOptions = policy.releaseOptions
The longer settle-with-diagnostics options from the policy. The default for release.
private val compiled: CompiledCeresSolver = backend.compile(model.initialPointCloud(), model.program)
private val pinned = BooleanArray(model.pointCount)
private val pinnedTargets = DoubleArray(model.pointCount * 3)
private val pinnedWeights = DoubleArray(model.pointCount) { policy.pinnedWeight }
fun solve(options: SolveOptions = quickOptions): Boolean
Re-solve from the current state, in place. Returns whether the answer is usable — a weak claim, not convergence.
Recomputes trace points and re-imposes pins first. With the default quickOptions this is a few iterations, meant to be called every frame rather than to finish.
fun solve(options: SolveOptions = quickOptions): Boolean =
solvePrepared(options, activeDrag = null)
internal fun solvePrepared(options: SolveOptions, activeDrag: PointId?): Boolean
internal fun solvePrepared(options: SolveOptions, activeDrag: PointId?): Boolean =
solvePrepared(options, activeDrag?.let(::setOf) ?: emptySet())
internal fun solvePrepared(options: SolveOptions, activeDrags: Set<PointId>): Boolean
internal fun solvePrepared(options: SolveOptions, activeDrags: Set<PointId>): Boolean {
applyTraces()
applyPins(activeDrags)
return solveCompiled(options)
}
private fun applyTraces() {
model.traces.forEach { trace ->
points.set(trace.target.id, trace.expression.evaluate(points))
}
}
private fun solveCompiled(options: SolveOptions): Boolean =
if (compiled is ReusableCompiledCeresSolver) {
compiled.solveInPlace(points, priors, options, report)
} else {
val result = compiled.solve(points.snapshot(), priors.asImmutable(), options)
points.copyFrom(result.points)
report.ok = result.ok
result.metrics?.let { metrics ->
report.totalCost = metrics.totalCost ?: Double.NaN
report.clearGroups()
metrics.rawByOpcode.forEach { (opcode, cost) ->
if (opcode in report.groupCosts.indices) report.groupCosts[opcode] = cost
}
report.groupCount = metrics.rawByOpcode.size
} ?: run {
report.totalCost = Double.NaN
report.clearGroups()
}
result.ok
}
fun solve(maxIterations: Int, metrics: Boolean = false): Boolean
Solve for a given number of iterations, sizing the metric request to this session's report. The convenient form when you want more effort than the policy's default and nothing else changed.
fun solve(maxIterations: Int, metrics: Boolean = false): Boolean =
solve(SolveOptions(maxIterations = maxIterations, metrics = metrics, metricGroupCapacity = report.groupCosts.size))
fun set(point: PointRef, value: Vec3)
Move a point directly, without touching its prior.
A starting position, not a constraint: the next solve is free to move it straight back unless its prior says otherwise — and if the point is pinned, the pin is re-imposed and this write is lost. drag is the verb that means "and hold it there".
fun set(point: PointRef, value: Vec3) {
points.set(point.id, value.copy())
}
fun set(point: PointRef, x: Double, y: Double, z: Double)
set from loose coordinates. A starting position, not a constraint; see the overload above.
fun set(point: PointRef, x: Double, y: Double, z: Double) {
points.set(point.id, x, y, z)
}
fun get(point: PointRef): Vec3 = points.get(point.id)
The point's current solved position.
fun drag(point: PointRef, policy: InteractionPolicy = this.policy): DragHandle
Begin a gesture: a DragHandle that holds this point heavily until released.
The stateful form, for a mouse-down to mouse-up sequence. Close it — or use it — and the point is released and
the model settles; forget to, and it stays weighted for every subsequent solve.
fun drag(point: PointRef, policy: InteractionPolicy = this.policy): DragHandle =
DragHandle(this, point, policy)
fun drag(point: PointRef, target: Vec3, strength: Double = policy.dragWeight, options: SolveOptions = quickOptions): Boolean
Move a point to target with a heavy prior and re-solve in one call — the stateless form of a drag frame.
Leaves the prior heavy afterwards, so a gesture built from these must end with release. A pinned point is not fought over here: the drag counts as active, so the pin is not re-imposed for this solve.
fun drag(point: PointRef, target: Vec3, strength: Double = policy.dragWeight, options: SolveOptions = quickOptions): Boolean {
points.set(point.id, target)
priors.set(point.id, strength)
return solvePrepared(options, activeDrag = point.id)
}
fun release(point: PointRef, weight: Double = policy.releaseWeight, options: SolveOptions = metricOptions): Boolean
End a drag: drop the point's prior back to resting and run the longer settle.
The point keeps wherever the solve leaves it — releasing is not undoing. Note that this does not clear a pin; the next solve will re-impose one if there is one.
fun release(point: PointRef, weight: Double = policy.releaseWeight, options: SolveOptions = metricOptions): Boolean {
priors.set(point.id, weight)
return solve(options)
}
fun pin(point: PointRef, weight: Double = policy.pinnedWeight)
Fix a point where it is now, and keep putting it back there before every solve.
Stronger than a heavy prior, because the position is re-asserted rather than merely preferred: the current position is captured as the pin target, and every subsequent solve restores it first. The exception is a point being actively dragged, which wins for that solve. set on a pinned point does not stick; unpin first.
fun pin(point: PointRef, weight: Double = policy.pinnedWeight) {
require(weight.isFinite() && weight >= 0.0) { "pin weight must be finite and non-negative" }
pinned[point.index] = true
pinnedWeights[point.index] = weight
points.copyPoint(point.id, pinnedTargets, point.index * 3)
priors.set(point.id, weight)
}
fun unpin(point: PointRef, weight: Double = policy.preserveWeight)
Release a pin and return the point to a resting prior. Leaves it where it is; it is simply free again.
fun unpin(point: PointRef, weight: Double = policy.preserveWeight) {
pinned[point.index] = false
priors.set(point.id, weight)
}
fun isPinned(point: PointRef): Boolean = pinned[point.index]
Whether pin is currently holding this point.
fun resetPriors(weight: Double = policy.preserveWeight)
Return every prior to weight, then restore the pinned points' own weights — the "forget the last gesture" call.
Pins survive; drags do not. LiveGeometryProgram calls this at the start of every step, which is why an implicit drag has to be re-detected each time rather than persisting.
fun resetPriors(weight: Double = policy.preserveWeight) {
priors.fill(weight)
for (i in pinned.indices) {
if (pinned[i]) priors.set(PointId(i), pinnedWeights[i])
}
}
internal fun retargetCompatible(model: GeometryModel)
internal fun retargetCompatible(model: GeometryModel) {
require(model.pointCount == this.model.pointCount) { "point count changed from ${this.model.pointCount} to ${model.pointCount}" }
require(model.program.legacyHash() == this.model.program.legacyHash()) { "constraint program changed" }
this.model = model
}
private fun applyPins(activeDrags: Set<PointId>) {
for (i in pinned.indices) {
if (!pinned[i]) continue
val point = PointId(i)
if (point in activeDrags) continue
points.setPointFrom(point, pinnedTargets, i * 3)
priors.set(point, pinnedWeights[i])
}
}
override fun close()
Release the native problem. The session, its points and its priors are unusable afterwards.
override fun close() {
compiled.close()
}
}
class DragHandle internal constructor
One gesture on one point: to while it moves, release when it stops.
A handle exists so the heavy prior has an owner. It is AutoCloseable and closing releases, so use is the shape
that cannot leave a point weighted; call to or settle after releasing and it throws rather than silently
reweighting.
Nothing stops two handles existing on the same point at once, and they would fight over its prior.
class DragHandle internal constructor(
private val session: GeometrySolveSession,
/** The point this gesture holds. */
val point: PointRef,
/** The weights and iteration counts [to], [settle] and [release] draw their defaults from. */
val policy: InteractionPolicy,
) : AutoCloseable {
private var active = true
fun to(target: Vec3, solve: Boolean = true): Boolean
Move the dragged point and, unless solve is false, re-solve at drag effort. The per-frame call of a gesture.
Returns the solve's usability flag — or true when solve is false, since nothing was attempted.
fun to(target: Vec3, solve: Boolean = true): Boolean {
check(active) { "drag handle is already released" }
session.points.set(point.id, target)
session.priors.set(point.id, policy.dragWeight)
return !solve || session.solvePrepared(policy.dragOptions, activeDrag = point.id)
}
fun to(x: Double, y: Double, z: Double, solve: Boolean = true): Boolean
to from loose coordinates.
fun to(x: Double, y: Double, z: Double, solve: Boolean = true): Boolean =
to(Vec3(x, y, z), solve)
fun settle(options: SolveOptions = policy.dragOptions): Boolean
Re-solve without moving the point — more iterations at the same target, while still holding the drag.
Defaults to drag effort, so pass InteractionPolicy.settleOptions if you actually want the long pass. The point
stays held afterwards; this is not a release.
fun settle(options: SolveOptions = policy.dragOptions): Boolean {
check(active) { "drag handle is already released" }
session.priors.set(point.id, policy.dragWeight)
return session.solvePrepared(options, activeDrag = point.id)
}
fun release(options: SolveOptions = policy.releaseOptions): Boolean
End the gesture: drop the prior to resting and run the settle. Idempotent — a second call reports the last result without solving again.
fun release(options: SolveOptions = policy.releaseOptions): Boolean {
if (!active) return session.report.ok
active = false
session.priors.set(point.id, policy.releaseWeight)
return session.solve(options)
}
override fun close()
Releases, which means closing a handle solves. Intentional: use should not leave a point weighted.
override fun close() {
release()
}
}
fun geometryModel(block: GeometryModelScope.() -> Unit): GeometryModel =
The way in: declare points and the relations between them, get back an immutable GeometryModel that can be solved and re-solved.
val model = geometryModel {
val a = point("a", 0.0, 0.0, 0.0)
val b = point("b", 1.0, 0.2, 0.0)
constraints {
(a lineTo b).length(1.0)
a.lock()
}
}
The block runs once, immediately. Point ids are assigned in declaration order, so the model's identity — and the session cache in LiveGeometryProgram — depends on the order you declare things in, not just on what you declare.
Unless the block says GeometryModelScope.noDefaultInteractionPriors, a weak whole-cloud WeakPosition is appended, which is what stops an underdetermined model from wandering. Solving needs the native Ceres library, since GeometryModel.openSession defaults to the FFM backend.
fun geometryModel(block: GeometryModelScope.() -> Unit): GeometryModel =
GeometryModelScope().apply(block).build()
fun GeometryModel.session
GeometryModel.openSession as an extension, for the reading order model.session { }. Same parameters and the same
caveat: baselinePrior and metricCapacity are ignored if you pass an explicit policy.
fun GeometryModel.session(
backend: CeresSolverBackend = FfmCeresBackend(),
baselinePrior: Double = 1e-6,
metricCapacity: Int = 128,
policy: InteractionPolicy = InteractionPolicy(
preserveWeight = baselinePrior,
releaseWeight = baselinePrior,
metricCapacity = metricCapacity,
),
): GeometrySolveSession = openSession(backend, baselinePrior, metricCapacity, policy)
class LivePointMove internal constructor
One bound point that changed between steps: where it was, what its reader now says, and whether the caller said so
(explicit) or the move was inferred from exceeding the binding's drag tolerance.
class LivePointMove internal constructor(
/** The binding that noticed, which names the point and carries the tolerance that was exceeded. */
val binding: PointBinding,
/** Where the point stood after the previous step. */
val previous: Vec3,
/** What the binding's reader returns now. */
val read: Vec3,
/** How far it moved: the distance compared against the binding's drag tolerance. */
val distance: Double,
/** True when the caller named this point as dragged, false when the move was inferred. */
val explicit: Boolean,
)
class LiveGeometryStep internal constructor
What one LiveGeometryProgram.step did: whether the solve was usable, whether the session had to be rebuilt, which bound points moved, and the model identity that was solved.
report is the session's live report, not a copy — read it before the next step overwrites it. rebuilt is the field to watch when a live-coded sketch feels like it is forgetting things: a rebuild throws away pins and session state.
class LiveGeometryStep internal constructor(
/** Whether this step's solve was usable. */
val ok: Boolean,
/** Whether the session had to be recompiled, which drops pins and the state they stood on. */
val rebuilt: Boolean,
/** The bound points that changed since the last step, explicitly or by exceeding a tolerance. */
val moved: List<LivePointMove>,
/** The point count of the model that was solved; one of the three things a reuse depends on. */
val pointCount: Int,
/** The program hash of the model that was solved; another, alongside the point names. */
val programHash: UInt,
/** The session's own report, not a copy. Read it before the next step overwrites it. */
val report: MutableSolveReport,
)
class LiveGeometryProgram
A model block held across frames: re-declares, reuses the compiled session where it can, reads the bound values in, solves, writes them back.
The unit of use is step. Everything a live-coded constraint sketch needs is there — the model may change between steps, the geometry may be moved from outside, and the solve happens in place — at the price of two things worth knowing. The block runs on every step, so keep it cheap and free of allocation that changes the model's identity; and the session is reused only when point names, point count and program hash all match, with a rebuild dropping pins and the session's accumulated state.
polishLocked is the separate, non-interactive pass: a throwaway session with hard locks on the points you name, iterated until the cost stops improving. Owns native memory through its session; close it.
class LiveGeometryProgram(
private val backend: CeresSolverBackend = FfmCeresBackend(),
private val policy: InteractionPolicy = InteractionPolicy(),
private val block: GeometryModelScope.() -> Unit,
) : AutoCloseable {
private var session: GeometrySolveSession? = null
private var signature: LiveGeometrySignature? = null
private val lastExplicitDragTargets = mutableMapOf<PointId, Vec3>()
private val lastExplicitVectorTargets = mutableMapOf<Int, Vec3>()
val currentSession: GeometrySolveSession?
The live session, or null before the first step. Replaced whenever the model changes shape.
val currentSession: GeometrySolveSession?
get() = session
fun step
One frame: re-run the block, reuse or rebuild the session, read every bound point, solve, and write the results back through the bindings.
A point may be declared dragged — by PointRef in dragged, by object in draggedVectors — or be inferred
dragged, because its bound value moved by more than its tolerance since the last step. Either way it gets the
drag weight for this step only; priors are reset at the top of every step, so a drag has to be re-asserted each
frame.
With options null, effort is chosen from whether anything moved: drag options (with metrics forced on) if so,
the longer release options if not — so a still frame settles by itself. The returned LiveGeometryStep says
whether the session was rebuilt and what moved, and carries the session's live report.
fun step(
options: SolveOptions? = null,
dragged: Iterable<PointRef> = emptyList(),
draggedVectors: Iterable<Vector3f> = emptyList(),
): LiveGeometryStep {
val model = geometryModel(block)
val nextSignature = LiveGeometrySignature(model)
val existing = session
val rebuilt: Boolean
val active = if (existing != null && signature == nextSignature) {
existing.retargetCompatible(model)
rebuilt = false
existing
} else {
existing?.close()
rebuilt = true
model.openSession(backend = backend, policy = policy)
}
session = active
signature = nextSignature
active.resetPriors(policy.preserveWeight)
val explicitPointIds = dragged.mapTo(linkedSetOf()) { it.id }
val explicitVectorIdentities = draggedVectors.mapTo(linkedSetOf()) { System.identityHashCode(it) }
explicitPointIds.forEach { active.priors.set(it, policy.dragWeight) }
val moved = readBindings(model, active, explicitPointIds, explicitVectorIdentities)
rememberExplicitDragTargets(moved)
val solveOptions = options ?: if (moved.isEmpty()) policy.releaseOptions else policy.dragOptions.withMetrics()
val activeDrags = moved.mapTo(explicitPointIds) { it.binding.point.id }
val ok = active.solvePrepared(solveOptions, activeDrags)
writeBindings(model, active)
return LiveGeometryStep(
ok = ok,
rebuilt = rebuilt,
moved = moved,
pointCount = model.pointCount,
programHash = model.program.legacyHash(),
report = active.report,
)
}
fun step(dragged: PointRef, vararg moreDragged: PointRef, options: SolveOptions? = null): LiveGeometryStep
step naming the dragged points as varargs, so a caller need not build a collection.
fun step(dragged: PointRef, vararg moreDragged: PointRef, options: SolveOptions? = null): LiveGeometryStep =
step(options = options, dragged = sequenceOf(dragged, *moreDragged).asIterable())
fun step(dragged: Vector3f, vararg moreDragged: Vector3f, options: SolveOptions? = null): LiveGeometryStep
step naming the dragged bound vectors as varargs, matched by object identity.
fun step(dragged: Vector3f, vararg moreDragged: Vector3f, options: SolveOptions? = null): LiveGeometryStep =
step(options = options, draggedVectors = sequenceOf(dragged, *moreDragged).asIterable())
fun polishLocked
A settle pass with some points nailed down: build a throwaway model with hard locks on locked, solve it to
convergence, and write the result out through the bindings.
What "nailed down" means differs by argument. A PointRef is locked to wherever the user last explicitly dragged
it, or to its declared position if never dragged; a Vector3f is locked to whatever it currently reads. The pass
runs single-iteration solves up to SolveOptions.maxIterations and stops early once the cost stops improving —
but only when SolveOptions.metrics is on, since that is what the improvement is read from. With metrics off it
always runs the full count.
The locked session is separate and closed on the way out, so the live session and its pins are untouched. Nothing here is a genuinely hard constraint: "locked" is a very heavy prior.
body — 55 lines, from line 1450
fun polishLocked(
locked: Iterable<PointRef> = emptyList(),
lockedVectors: Iterable<Vector3f> = emptyList(),
weight: Double = policy.pinnedWeight,
options: SolveOptions = policy.settleOptions,
): LiveGeometryStep {
val model = geometryModel(block)
val lockedVectorIdentities = lockedVectors.mapTo(linkedSetOf()) { System.identityHashCode(it) }
val lockTargets = linkedMapOf<PointRef, Vec3>()
locked.forEach { point ->
lockTargets[point] = lastExplicitDragTargets[point.id] ?: model.initialValue(point)
}
model.bindings.forEach { binding ->
val identity = binding.sourceIdentity
if (identity in lockedVectorIdentities) {
lockTargets[binding.point] = binding.reader()
}
}
val lockedModel = model.withHardLocks(lockTargets, weight)
lockedModel.openSession(backend = backend, policy = policy).use { polish ->
polish.resetPriors(policy.preserveWeight)
val moved = readBindings(lockedModel, polish, emptySet(), emptySet(), detectImplicitMoves = false)
val ok = polishRepeated(polish, options)
writeBindings(lockedModel, polish)
return LiveGeometryStep(
ok = ok,
rebuilt = true,
moved = moved,
pointCount = lockedModel.pointCount,
programHash = lockedModel.program.legacyHash(),
report = polish.report,
)
}
}
private fun polishRepeated(session: GeometrySolveSession, options: SolveOptions): Boolean {
val passOptions = options.copy(maxIterations = 1)
var ok = false
var previousCost = Double.POSITIVE_INFINITY
repeat(options.maxIterations) {
ok = session.solve(passOptions)
if (options.metrics) {
val cost = session.report.totalCost
if (hasConverged(previousCost, cost)) return ok
previousCost = cost
}
}
return ok
}
private fun hasConverged(previous: Double, current: Double): Boolean {
if (!previous.isFinite() || !current.isFinite()) return false
val improvement = previous - current
return improvement >= 0.0 && improvement <= max(1e-12, abs(previous) * 1e-9)
}
fun polishLocked(locked: PointRef, vararg moreLocked: PointRef, weight: Double = policy.pinnedWeight, options: SolveOptions = policy.settleOptions): LiveGeometr
polishLocked naming the locked points as varargs.
fun polishLocked(locked: PointRef, vararg moreLocked: PointRef, weight: Double = policy.pinnedWeight, options: SolveOptions = policy.settleOptions): LiveGeometryStep =
polishLocked(locked = sequenceOf(locked, *moreLocked).asIterable(), weight = weight, options = options)
fun polishLocked(locked: Vector3f, vararg moreLocked: Vector3f, weight: Double = policy.pinnedWeight, options: SolveOptions = policy.settleOptions): LiveGeometr
polishLocked naming the locked bound vectors as varargs, matched by object identity.
fun polishLocked(locked: Vector3f, vararg moreLocked: Vector3f, weight: Double = policy.pinnedWeight, options: SolveOptions = policy.settleOptions): LiveGeometryStep =
polishLocked(lockedVectors = sequenceOf(locked, *moreLocked).asIterable(), weight = weight, options = options)
private fun readBindings(
model: GeometryModel,
session: GeometrySolveSession,
explicitPointIds: Set<PointId>,
explicitVectorIdentities: Set<Int>,
detectImplicitMoves: Boolean = true,
): List<LivePointMove> {
val moved = mutableListOf<LivePointMove>()
model.bindings.forEach { binding ->
val read = binding.reader()
require(read.x.isFinite() && read.y.isFinite() && read.z.isFinite()) { "point reader returned non-finite coordinates" }
val previous = session.get(binding.point)
val distance = distance(previous, read)
session.set(binding.point, read.copy())
val explicit = binding.point.id in explicitPointIds || binding.sourceIdentity in explicitVectorIdentities
if (explicit || (detectImplicitMoves && distance > binding.dragTolerance)) {
session.priors.set(binding.point.id, binding.dragWeight ?: policy.dragWeight)
moved += LivePointMove(binding, previous, read, distance, explicit)
}
}
return moved
}
private fun writeBindings(model: GeometryModel, session: GeometrySolveSession) {
model.bindings.forEach { binding ->
binding.writer(session.get(binding.point))
}
}
private fun rememberExplicitDragTargets(moved: List<LivePointMove>) {
moved.forEach { move ->
if (!move.explicit) return@forEach
lastExplicitDragTargets[move.binding.point.id] = move.read
move.binding.sourceIdentity?.let { lastExplicitVectorTargets[it] = move.read }
}
}
override fun close()
Release the compiled session. A later step compiles a fresh one rather than failing.
override fun close() {
session?.close()
session = null
signature = null
}
private data class LiveGeometrySignature(
val pointNames: List<String?>,
val pointCount: Int,
val programHash: UInt,
) {
constructor(model: GeometryModel) : this(
pointNames = model.pointNames,
pointCount = model.pointCount,
programHash = model.program.legacyHash(),
)
}
}
fun liveGeometry
Wrap a model block rather than a model, so the declarations can be re-run — the live-coding entry point.
The block is evaluated on every LiveGeometryProgram.step, which is what lets an editor change the constraints while the thing is being dragged. Hold the returned program across frames: it keeps the compiled session and reuses it whenever the point names, point count and program hash all match, and rebuilds silently when they do not.
Written for a block that closes over external Vector3fs declared with GeometryModelScope.bindPoint, which the
solve reads from and writes back to. Allocate those outside the block — a fresh one each step changes the model's
identity and throws the session away, along with its pins.
fun liveGeometry(
backend: CeresSolverBackend = FfmCeresBackend(),
policy: InteractionPolicy = InteractionPolicy(),
block: GeometryModelScope.() -> Unit,
): LiveGeometryProgram =
LiveGeometryProgram(backend, policy, block)
val PointRef.expr: PointExpr get() = PointExpr(mapOf(this to 1.0))
A point as a one-term PointExpr, so arithmetic can start from it. a + b and 0.5 * a go through this.
operator fun PointRef.plus(other: PointRef): PointExpr = expr + other
The point arithmetic, outside constraints { }. These are the operators ConstraintScope carries, for
building an expression before there is a scope to state it in.
operator fun PointRef.plus(other: PointExpr): PointExpr = expr + other
Add a point into an existing expression.
operator fun PointRef.minus(other: PointRef): PointExpr = expr - other
The difference of two points, which is the edge vector between them.
operator fun PointRef.minus(other: PointExpr): PointExpr = expr - other
Subtract an expression from a point.
operator fun PointRef.times(scale: Number): PointExpr = expr * scale
Scale a point's position. Nothing normalises, so 0.5 * (a + b) is the midpoint and a + b is not.
operator fun Number.times(point: PointRef): PointExpr = point * this
The same scaling with the number on the left.
operator fun Number.times(expr: PointExpr): PointExpr = expr * this
The same, scaling a whole expression.
private fun requireDistinctRefs(vararg refs: PointRef) {
require(refs.map { it.id }.toSet().size == refs.size) { "point references must be distinct" }
}
private fun Map<PointRef, Double>.cleanTerms(): Map<PointRef, Double> =
filterValues { it != 0.0 }
private operator fun Vec3.plus(other: Vec3): Vec3 =
Vec3(x + other.x, y + other.y, z + other.z)
private operator fun Vec3.times(scale: Double): Vec3 =
Vec3(x * scale, y * scale, z * scale)
private fun Vec3.isZero(): Boolean =
x == 0.0 && y == 0.0 && z == 0.0
private fun PointExpr.evaluate(points: MutablePointCloud): Vec3 =
terms.entries.fold(constant) { acc, (point, weight) ->
acc + points.get(point.id) * weight
}
private fun Vector3fc.vec3(): Vec3 =
Vec3(x().toDouble(), y().toDouble(), z().toDouble())
private fun SolveOptions.withMetrics(): SolveOptions =
if (metrics) this else copy(metrics = true)
private fun distance(a: Vec3, b: Vec3): Double {
val dx = a.x - b.x
val dy = a.y - b.y
val dz = a.z - b.z
return sqrt(dx * dx + dy * dy + dz * dz)
}