Files
solver/kindred_solver/solver.py
forbes-0023 5802d45a7f fix(solver): skip single_equation_pass during drag to prevent stale constraints
single_equation_pass analytically solves variables and bakes their values
as Const() nodes into downstream residual expressions. During drag, the
cached residuals use these stale constants even though part positions have
changed, causing constraints like Planar distance=0 to silently stop
being enforced.

Skip single_equation_pass in the pre_drag() path. Only substitution_pass
(which replaces genuinely grounded parameters) is safe to cache across
drag steps. Newton-Raphson converges in 1-2 iterations from a nearby
initial guess anyway, so the prepass optimization is unnecessary for drag.

Add regression tests covering the bug scenario and the fix.
2026-02-25 12:57:43 -06:00

893 lines
28 KiB
Python

"""KindredSolver — IKCSolver implementation bridging KCSolve to the
expression-based Newton-Raphson solver."""
from __future__ import annotations
import logging
import time
import kcsolve
log = logging.getLogger(__name__)
from .bfgs import bfgs_solve
from .constraints import (
AngleConstraint,
BallConstraint,
CamConstraint,
CoincidentConstraint,
ConcentricConstraint,
ConstraintBase,
CylindricalConstraint,
DistanceCylSphConstraint,
DistancePointPointConstraint,
FixedConstraint,
GearConstraint,
LineInPlaneConstraint,
ParallelConstraint,
PerpendicularConstraint,
PlanarConstraint,
PointInPlaneConstraint,
PointOnLineConstraint,
RackPinionConstraint,
RevoluteConstraint,
ScrewConstraint,
SliderConstraint,
SlotConstraint,
TangentConstraint,
UniversalConstraint,
)
from .decompose import decompose, solve_decomposed
from .diagnostics import find_overconstrained
from .dof import count_dof
from .entities import RigidBody
from .newton import newton_solve
from .params import ParamTable
from .preference import (
apply_half_space_correction,
build_weight_vector,
compute_half_spaces,
)
from .prepass import single_equation_pass, substitution_pass
# Assemblies with fewer free bodies than this use the monolithic path.
_DECOMPOSE_THRESHOLD = 8
# All BaseJointKind values this solver can handle
_SUPPORTED = {
# Phase 1
kcsolve.BaseJointKind.Coincident,
kcsolve.BaseJointKind.DistancePointPoint,
kcsolve.BaseJointKind.Fixed,
# Phase 2: point constraints
kcsolve.BaseJointKind.PointOnLine,
kcsolve.BaseJointKind.PointInPlane,
# Phase 2: orientation
kcsolve.BaseJointKind.Parallel,
kcsolve.BaseJointKind.Perpendicular,
kcsolve.BaseJointKind.Angle,
# Phase 2: axis/surface
kcsolve.BaseJointKind.Concentric,
kcsolve.BaseJointKind.Tangent,
kcsolve.BaseJointKind.Planar,
kcsolve.BaseJointKind.LineInPlane,
# Phase 2: kinematic joints
kcsolve.BaseJointKind.Ball,
kcsolve.BaseJointKind.Revolute,
kcsolve.BaseJointKind.Cylindrical,
kcsolve.BaseJointKind.Slider,
kcsolve.BaseJointKind.Screw,
kcsolve.BaseJointKind.Universal,
# Phase 2: mechanical
kcsolve.BaseJointKind.Gear,
kcsolve.BaseJointKind.RackPinion,
}
class KindredSolver(kcsolve.IKCSolver):
"""Expression-based Newton-Raphson constraint solver."""
def __init__(self):
super().__init__()
self._drag_ctx = None
self._drag_parts = None
self._drag_cache = None
self._limits_warned = False
def name(self):
return "Kindred (Newton-Raphson)"
def supported_joints(self):
return list(_SUPPORTED)
# ── Static solve ────────────────────────────────────────────────
def solve(self, ctx):
t0 = time.perf_counter()
n_parts = len(ctx.parts)
n_constraints = len(ctx.constraints)
n_grounded = sum(1 for p in ctx.parts if p.grounded)
log.info(
"solve: %d parts (%d grounded), %d constraints",
n_parts,
n_grounded,
n_constraints,
)
system = _build_system(ctx)
n_free_bodies = sum(1 for b in system.bodies.values() if not b.grounded)
n_residuals = len(system.all_residuals)
n_free_params = len(system.params.free_names())
log.info(
"solve: system built — %d free bodies, %d residuals, %d free params",
n_free_bodies,
n_residuals,
n_free_params,
)
# Warn once per solver instance if any constraints have limits
if not self._limits_warned:
for c in ctx.constraints:
if c.limits:
log.warning(
"Joint limits on '%s' ignored (not yet supported by Kindred solver)",
c.id,
)
self._limits_warned = True
break
# Solution preference: half-spaces, weight vector
half_spaces = compute_half_spaces(
system.constraint_objs,
system.constraint_indices,
system.params,
)
weight_vec = build_weight_vector(system.params)
if half_spaces:
post_step_fn = lambda p: apply_half_space_correction(p, half_spaces)
else:
post_step_fn = None
# Pre-passes on full system
residuals = substitution_pass(system.all_residuals, system.params)
residuals = single_equation_pass(residuals, system.params)
# Solve (decomposed for large assemblies, monolithic for small)
jac_exprs = None # may be populated by _monolithic_solve
if n_free_bodies >= _DECOMPOSE_THRESHOLD:
grounded_ids = {pid for pid, b in system.bodies.items() if b.grounded}
clusters = decompose(ctx.constraints, grounded_ids)
log.info(
"solve: decomposed into %d cluster(s) (%d free bodies >= threshold %d)",
len(clusters),
n_free_bodies,
_DECOMPOSE_THRESHOLD,
)
if len(clusters) > 1:
converged = solve_decomposed(
clusters,
system.bodies,
system.constraint_objs,
system.constraint_indices,
system.params,
)
else:
converged, jac_exprs = _monolithic_solve(
residuals,
system.params,
system.quat_groups,
post_step=post_step_fn,
weight_vector=weight_vec,
)
else:
log.debug(
"solve: monolithic path (%d free bodies < threshold %d)",
n_free_bodies,
_DECOMPOSE_THRESHOLD,
)
converged, jac_exprs = _monolithic_solve(
residuals,
system.params,
system.quat_groups,
post_step=post_step_fn,
weight_vector=weight_vec,
)
# DOF
dof = count_dof(residuals, system.params, jac_exprs=jac_exprs)
# Build result
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
result.dof = dof
# Diagnostics on failure
if not converged:
result.diagnostics = _run_diagnostics(
residuals,
system.params,
system.residual_ranges,
ctx,
jac_exprs=jac_exprs,
)
result.placements = _extract_placements(system.params, system.bodies)
elapsed = (time.perf_counter() - t0) * 1000
log.info(
"solve: %s in %.1f ms — dof=%d, %d placements",
"converged" if converged else "FAILED",
elapsed,
dof,
len(result.placements),
)
if not converged and result.diagnostics:
for d in result.diagnostics:
log.warning(" diagnostic: [%s] %s%s", d.kind, d.constraint_id, d.detail)
return result
# ── Incremental update ──────────────────────────────────────────
# The base class default (delegates to solve()) is correct here:
# solve() uses current placements as initial guess, so small
# parameter changes converge quickly without special handling.
# ── Interactive drag ────────────────────────────────────────────
def pre_drag(self, ctx, drag_parts):
log.info("pre_drag: drag_parts=%s", drag_parts)
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
self._drag_step_count = 0
# Build the system once and cache everything for drag_step() reuse.
t0 = time.perf_counter()
system = _build_system(ctx)
half_spaces = compute_half_spaces(
system.constraint_objs,
system.constraint_indices,
system.params,
)
weight_vec = build_weight_vector(system.params)
if half_spaces:
post_step_fn = lambda p: apply_half_space_correction(p, half_spaces)
else:
post_step_fn = None
residuals = substitution_pass(system.all_residuals, system.params)
# NOTE: single_equation_pass is intentionally skipped for drag.
# It permanently fixes variables and removes residuals from the
# list. During drag the dragged part's parameters change each
# frame, which can invalidate those analytic solutions and cause
# constraints (e.g. Planar distance=0) to stop being enforced.
# The substitution pass alone is safe because it only replaces
# genuinely grounded parameters with constants.
# Build symbolic Jacobian + compile once
from .codegen import try_compile_system
free = system.params.free_names()
n_res = len(residuals)
n_free = len(free)
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
compiled_eval = try_compile_system(residuals, jac_exprs, n_res, n_free)
# Initial solve
converged = newton_solve(
residuals,
system.params,
quat_groups=system.quat_groups,
max_iter=100,
tol=1e-10,
post_step=post_step_fn,
weight_vector=weight_vec,
jac_exprs=jac_exprs,
compiled_eval=compiled_eval,
)
if not converged:
converged = bfgs_solve(
residuals,
system.params,
quat_groups=system.quat_groups,
max_iter=200,
tol=1e-10,
weight_vector=weight_vec,
jac_exprs=jac_exprs,
compiled_eval=compiled_eval,
)
# Cache for drag_step() reuse
cache = _DragCache()
cache.system = system
cache.residuals = residuals
cache.jac_exprs = jac_exprs
cache.compiled_eval = compiled_eval
cache.half_spaces = half_spaces
cache.weight_vec = weight_vec
cache.post_step_fn = post_step_fn
self._drag_cache = cache
# Build result
dof = count_dof(residuals, system.params, jac_exprs=jac_exprs)
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
result.dof = dof
result.placements = _extract_placements(system.params, system.bodies)
elapsed = (time.perf_counter() - t0) * 1000
log.info(
"pre_drag: initial solve %s in %.1f ms — dof=%d",
"converged" if converged else "FAILED",
elapsed,
dof,
)
return result
def drag_step(self, drag_placements):
ctx = self._drag_ctx
if ctx is None:
log.warning("drag_step: no drag context (pre_drag not called?)")
return kcsolve.SolveResult()
self._drag_step_count = getattr(self, "_drag_step_count", 0) + 1
# Update dragged part placements in ctx (for caller consistency)
for pr in drag_placements:
for part in ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
cache = getattr(self, "_drag_cache", None)
if cache is None:
# Fallback: no cache, do a full solve
log.debug(
"drag_step #%d: no cache, falling back to full solve",
self._drag_step_count,
)
return self.solve(ctx)
t0 = time.perf_counter()
params = cache.system.params
# Update only the dragged part's 7 parameter values
for pr in drag_placements:
pfx = pr.id + "/"
params.set_value(pfx + "tx", pr.placement.position[0])
params.set_value(pfx + "ty", pr.placement.position[1])
params.set_value(pfx + "tz", pr.placement.position[2])
params.set_value(pfx + "qw", pr.placement.quaternion[0])
params.set_value(pfx + "qx", pr.placement.quaternion[1])
params.set_value(pfx + "qy", pr.placement.quaternion[2])
params.set_value(pfx + "qz", pr.placement.quaternion[3])
# Solve with cached artifacts — no rebuild
converged = newton_solve(
cache.residuals,
params,
quat_groups=cache.system.quat_groups,
max_iter=100,
tol=1e-10,
post_step=cache.post_step_fn,
weight_vector=cache.weight_vec,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval,
)
if not converged:
converged = bfgs_solve(
cache.residuals,
params,
quat_groups=cache.system.quat_groups,
max_iter=200,
tol=1e-10,
weight_vector=cache.weight_vec,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval,
)
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success if converged else kcsolve.SolveStatus.Failed
result.dof = -1 # skip DOF counting during drag for speed
result.placements = _extract_placements(params, cache.system.bodies)
elapsed = (time.perf_counter() - t0) * 1000
if not converged:
log.warning(
"drag_step #%d: solve FAILED in %.1f ms",
self._drag_step_count,
elapsed,
)
else:
log.debug(
"drag_step #%d: ok in %.1f ms",
self._drag_step_count,
elapsed,
)
return result
def post_drag(self):
steps = getattr(self, "_drag_step_count", 0)
log.info("post_drag: completed after %d drag steps", steps)
self._drag_ctx = None
self._drag_parts = None
self._drag_step_count = 0
self._drag_cache = None
# ── Diagnostics ─────────────────────────────────────────────────
def diagnose(self, ctx):
system = _build_system(ctx)
residuals = substitution_pass(system.all_residuals, system.params)
return _run_diagnostics(
residuals,
system.params,
system.residual_ranges,
ctx,
)
def is_deterministic(self):
return True
class _DragCache:
"""Cached artifacts from pre_drag() reused across drag_step() calls.
During interactive drag the constraint topology is invariant — only
the dragged part's parameter values change. Caching the built
system, symbolic Jacobian, and compiled evaluator eliminates the
expensive rebuild overhead (~150 ms) on every frame.
"""
__slots__ = (
"system", # _System — owns ParamTable + Expr trees
"residuals", # list[Expr] — after substitution + single-equation pass
"jac_exprs", # list[list[Expr]] — symbolic Jacobian
"compiled_eval", # Callable or None
"half_spaces", # list[HalfSpace]
"weight_vec", # ndarray or None
"post_step_fn", # Callable or None
)
class _System:
"""Intermediate representation of a built constraint system."""
__slots__ = (
"params",
"bodies",
"constraint_objs",
"constraint_indices",
"all_residuals",
"residual_ranges",
"quat_groups",
)
def _build_system(ctx):
"""Build the solver's internal representation from a SolveContext.
Returns a _System with params, bodies, constraint objects,
residuals, residual-to-constraint mapping, and quaternion groups.
"""
system = _System()
params = ParamTable()
bodies = {} # part_id -> RigidBody
# 1. Build entities from parts
for part in ctx.parts:
pos = tuple(part.placement.position)
quat = tuple(part.placement.quaternion) # (w, x, y, z)
body = RigidBody(
part.id,
params,
position=pos,
quaternion=quat,
grounded=part.grounded,
)
bodies[part.id] = body
# 2. Build constraint residuals (track index mapping for decomposition)
all_residuals = []
constraint_objs = []
constraint_indices = [] # parallel to constraint_objs: index in ctx.constraints
skipped_inactive = 0
skipped_missing_body = 0
skipped_unsupported = 0
for idx, c in enumerate(ctx.constraints):
if not c.activated:
skipped_inactive += 1
continue
body_i = bodies.get(c.part_i)
body_j = bodies.get(c.part_j)
if body_i is None or body_j is None:
skipped_missing_body += 1
log.debug(
"_build_system: constraint[%d] %s skipped — missing body (%s or %s)",
idx,
c.id,
c.part_i,
c.part_j,
)
continue
marker_i_pos = tuple(c.marker_i.position)
marker_j_pos = tuple(c.marker_j.position)
obj = _build_constraint(
c.type,
body_i,
marker_i_pos,
body_j,
marker_j_pos,
c.marker_i,
c.marker_j,
c.params,
)
if obj is None:
skipped_unsupported += 1
log.debug(
"_build_system: constraint[%d] %s type=%s — unsupported, skipped",
idx,
c.id,
c.type,
)
continue
constraint_objs.append(obj)
constraint_indices.append(idx)
all_residuals.extend(obj.residuals())
if skipped_inactive or skipped_missing_body or skipped_unsupported:
log.debug(
"_build_system: skipped constraints — %d inactive, %d missing body, %d unsupported",
skipped_inactive,
skipped_missing_body,
skipped_unsupported,
)
# 3. Build residual-to-constraint mapping
residual_ranges = [] # (start_row, end_row, constraint_idx)
row = 0
for i, obj in enumerate(constraint_objs):
n = len(obj.residuals())
residual_ranges.append((row, row + n, constraint_indices[i]))
row += n
# 4. Add quaternion normalization residuals for non-grounded bodies
quat_groups = []
for body in bodies.values():
if not body.grounded:
all_residuals.append(body.quat_norm_residual())
quat_groups.append(body.quat_param_names())
system.params = params
system.bodies = bodies
system.constraint_objs = constraint_objs
system.constraint_indices = constraint_indices
system.all_residuals = all_residuals
system.residual_ranges = residual_ranges
system.quat_groups = quat_groups
return system
def _run_diagnostics(residuals, params, residual_ranges, ctx, jac_exprs=None):
"""Run overconstrained detection and return kcsolve diagnostics."""
diagnostics = []
if not hasattr(kcsolve, "ConstraintDiagnostic"):
return diagnostics
diags = find_overconstrained(residuals, params, residual_ranges, jac_exprs=jac_exprs)
for d in diags:
cd = kcsolve.ConstraintDiagnostic()
cd.constraint_id = ctx.constraints[d.constraint_index].id
cd.kind = (
kcsolve.DiagnosticKind.Redundant
if d.kind == "redundant"
else kcsolve.DiagnosticKind.Conflicting
)
cd.detail = d.detail
diagnostics.append(cd)
return diagnostics
def _extract_placements(params, bodies):
"""Extract solved placements from the parameter table."""
env = params.get_env()
placements = []
for body in bodies.values():
if body.grounded:
continue
pr = kcsolve.SolveResult.PartResult()
pr.id = body.part_id
pr.placement = kcsolve.Transform()
pr.placement.position = list(body.extract_position(env))
pr.placement.quaternion = list(body.extract_quaternion(env))
placements.append(pr)
return placements
def _monolithic_solve(all_residuals, params, quat_groups, post_step=None, weight_vector=None):
"""Newton-Raphson solve with BFGS fallback on the full system.
Returns ``(converged, jac_exprs)`` so the caller can reuse the
symbolic Jacobian for DOF counting / diagnostics.
"""
from .codegen import try_compile_system
free = params.free_names()
n_res = len(all_residuals)
n_free = len(free)
# Build symbolic Jacobian once
jac_exprs = [[r.diff(name).simplify() for name in free] for r in all_residuals]
# Compile once
compiled_eval = try_compile_system(all_residuals, jac_exprs, n_res, n_free)
t0 = time.perf_counter()
converged = newton_solve(
all_residuals,
params,
quat_groups=quat_groups,
max_iter=100,
tol=1e-10,
post_step=post_step,
weight_vector=weight_vector,
jac_exprs=jac_exprs,
compiled_eval=compiled_eval,
)
nr_ms = (time.perf_counter() - t0) * 1000
if not converged:
log.info("_monolithic_solve: Newton-Raphson failed (%.1f ms), trying BFGS", nr_ms)
t1 = time.perf_counter()
converged = bfgs_solve(
all_residuals,
params,
quat_groups=quat_groups,
max_iter=200,
tol=1e-10,
weight_vector=weight_vector,
jac_exprs=jac_exprs,
compiled_eval=compiled_eval,
)
bfgs_ms = (time.perf_counter() - t1) * 1000
if converged:
log.info("_monolithic_solve: BFGS converged (%.1f ms)", bfgs_ms)
else:
log.warning("_monolithic_solve: BFGS also failed (%.1f ms)", bfgs_ms)
else:
log.debug("_monolithic_solve: Newton-Raphson converged (%.1f ms)", nr_ms)
return converged, jac_exprs
def _build_constraint(
kind,
body_i,
marker_i_pos,
body_j,
marker_j_pos,
marker_i,
marker_j,
c_params,
) -> ConstraintBase | None:
"""Create the appropriate constraint object from a BaseJointKind."""
marker_i_quat = tuple(marker_i.quaternion)
marker_j_quat = tuple(marker_j.quaternion)
# -- Phase 1 constraints --------------------------------------------------
if kind == kcsolve.BaseJointKind.Coincident:
return CoincidentConstraint(body_i, marker_i_pos, body_j, marker_j_pos)
if kind == kcsolve.BaseJointKind.DistancePointPoint:
distance = c_params[0] if c_params else 0.0
if distance == 0.0:
# Distance=0 is point coincidence. Use CoincidentConstraint
# (3 linear residuals) instead of the squared form which has
# a degenerate Jacobian when the constraint is satisfied.
return CoincidentConstraint(
body_i,
marker_i_pos,
body_j,
marker_j_pos,
)
return DistancePointPointConstraint(
body_i,
marker_i_pos,
body_j,
marker_j_pos,
distance,
)
if kind == kcsolve.BaseJointKind.Fixed:
return FixedConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
# -- Phase 2: point constraints -------------------------------------------
if kind == kcsolve.BaseJointKind.PointOnLine:
return PointOnLineConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
if kind == kcsolve.BaseJointKind.PointInPlane:
offset = c_params[0] if c_params else 0.0
return PointInPlaneConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
offset=offset,
)
# -- Phase 2: orientation constraints -------------------------------------
if kind == kcsolve.BaseJointKind.Parallel:
return ParallelConstraint(body_i, marker_i_quat, body_j, marker_j_quat)
if kind == kcsolve.BaseJointKind.Perpendicular:
return PerpendicularConstraint(body_i, marker_i_quat, body_j, marker_j_quat)
if kind == kcsolve.BaseJointKind.Angle:
angle = c_params[0] if c_params else 0.0
return AngleConstraint(body_i, marker_i_quat, body_j, marker_j_quat, angle)
# -- Phase 2: axis/surface constraints ------------------------------------
if kind == kcsolve.BaseJointKind.Concentric:
distance = c_params[0] if c_params else 0.0
return ConcentricConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
distance=distance,
)
if kind == kcsolve.BaseJointKind.Tangent:
return TangentConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
if kind == kcsolve.BaseJointKind.Planar:
offset = c_params[0] if c_params else 0.0
return PlanarConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
offset=offset,
)
if kind == kcsolve.BaseJointKind.LineInPlane:
offset = c_params[0] if c_params else 0.0
return LineInPlaneConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
offset=offset,
)
# -- Phase 2: kinematic joints --------------------------------------------
if kind == kcsolve.BaseJointKind.Ball:
return BallConstraint(body_i, marker_i_pos, body_j, marker_j_pos)
if kind == kcsolve.BaseJointKind.Revolute:
return RevoluteConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
if kind == kcsolve.BaseJointKind.Cylindrical:
return CylindricalConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
if kind == kcsolve.BaseJointKind.Slider:
return SliderConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
if kind == kcsolve.BaseJointKind.Screw:
pitch = c_params[0] if c_params else 1.0
return ScrewConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
pitch=pitch,
)
if kind == kcsolve.BaseJointKind.Universal:
return UniversalConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
)
# -- Phase 2: mechanical constraints --------------------------------------
if kind == kcsolve.BaseJointKind.Gear:
radius_i = c_params[0] if len(c_params) > 0 else 1.0
radius_j = c_params[1] if len(c_params) > 1 else 1.0
return GearConstraint(
body_i,
marker_i_quat,
body_j,
marker_j_quat,
radius_i,
radius_j,
)
if kind == kcsolve.BaseJointKind.RackPinion:
pitch_radius = c_params[0] if c_params else 1.0
return RackPinionConstraint(
body_i,
marker_i_pos,
marker_i_quat,
body_j,
marker_j_pos,
marker_j_quat,
pitch_radius=pitch_radius,
)
# -- Stubs (accepted but produce no residuals) ----------------------------
if kind == kcsolve.BaseJointKind.Cam:
return CamConstraint()
if kind == kcsolve.BaseJointKind.Slot:
return SlotConstraint()
if kind == kcsolve.BaseJointKind.DistanceCylSph:
return DistanceCylSphConstraint()
return None