The interactive drag section described the original naive implementation (re-solve from scratch each step) and called the caching layer a 'planned future optimization'. In reality _DragCache is fully implemented: pre_drag() builds the system, Jacobian, and compiled evaluator once, and drag_step() reuses them. Update code snippets, add _DragCache field table, and document the single_equation_pass exclusion from the drag path.
8.9 KiB
Assembly Integration
The Kindred solver integrates with FreeCAD's Assembly workbench through the KCSolve pluggable solver framework. This page describes the bridge layer, preference system, and interactive drag protocol.
KindredSolver class
Source: mods/solver/kindred_solver/solver.py
KindredSolver subclasses kcsolve.IKCSolver and implements the solver interface:
class KindredSolver(kcsolve.IKCSolver):
def name(self):
return "Kindred (Newton-Raphson)"
def supported_joints(self):
return list(_SUPPORTED) # 20 of 24 BaseJointKind values
def solve(self, ctx): # Static solve
def diagnose(self, ctx): # Constraint analysis
def pre_drag(self, ctx, drag_parts): # Begin drag session
def drag_step(self, drag_placements): # Mouse move during drag
def post_drag(self): # End drag session
def is_deterministic(self): # Returns True
Registration
The solver is registered at addon load time via Init.py:
import kcsolve
from kindred_solver import KindredSolver
kcsolve.register_solver("kindred", KindredSolver)
The mods/solver/ directory is a FreeCAD addon discovered by the addon loader through its package.xml manifest.
Supported joints
The Kindred solver handles 20 of the 24 BaseJointKind values. The remaining 4 are stubs that produce no residuals:
| Supported | Stub (no residuals) |
|---|---|
| Coincident, PointOnLine, PointInPlane, Concentric, Tangent, Planar, LineInPlane, Parallel, Perpendicular, Angle, Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal, Gear, RackPinion, DistancePointPoint | Cam, Slot, DistanceCylSph, Custom |
Joint limits
Joint travel limits (Constraint.limits) are accepted but not enforced. The solver logs a warning once per instance when limits are encountered. Enforcing inequality constraints requires active-set or barrier-method extensions beyond the current Newton-Raphson formulation.
Solver selection
C++ preference
AssemblyObject::getOrCreateSolver() reads the user preference to select the solver backend:
ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
"User parameter:BaseApp/Preferences/Mod/Assembly");
std::string solverName = hGrp->GetASCII("Solver", "");
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
An empty string ("") returns the registry default (the first solver registered, which is OndselSolver). Setting "kindred" selects the Kindred solver.
resetSolver() clears the cached solver instance so the next solve picks up preference changes.
Preferences UI
The Assembly preferences page (Edit > Preferences > Assembly) includes a "Solver backend" combo box populated from the registry at load time:
- Default -- empty string, uses the registry default (OndselSolver)
- OndselSolver (Lagrangian) --
"ondsel" - Kindred (Newton-Raphson) --
"kindred"(available when the solver addon is loaded)
The preference is stored as Mod/Assembly/Solver in the FreeCAD parameter system.
Programmatic switching
From the Python console:
import FreeCAD
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
# Switch to Kindred
pref.SetString("Solver", "kindred")
# Switch back to default
pref.SetString("Solver", "")
# Force the active assembly to pick up the change
if hasattr(FreeCADGui, "ActiveDocument"):
for obj in FreeCAD.ActiveDocument.Objects:
if hasattr(obj, "resetSolver"):
obj.resetSolver()
Interactive drag protocol
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
pre_drag(ctx, drag_parts)
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a _DragCache for reuse across subsequent drag_step() calls.
def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
system = _build_system(ctx)
half_spaces = compute_half_spaces(...)
weight_vec = build_weight_vector(system.params)
residuals = substitution_pass(system.all_residuals, system.params)
# single_equation_pass is intentionally skipped — it bakes variable
# values as constants that become stale when dragged parts move.
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
# Initial solve (Newton-Raphson + BFGS fallback)
newton_solve(residuals, system.params, ...)
# 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
...
return result
Important: single_equation_pass is not used in the drag path. It analytically solves single-variable equations and bakes the results as Const() nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only substitution_pass (which replaces genuinely grounded parameters) is safe to cache.
drag_step(drag_placements)
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached ParamTable, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
def drag_step(self, drag_placements):
cache = self._drag_cache
params = cache.system.params
# Update only the dragged part's parameters
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
newton_solve(cache.residuals, params, ...,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval)
return result
post_drag()
Called when the drag ends. Clears the cached state.
def post_drag(self):
self._drag_ctx = None
self._drag_parts = None
self._drag_cache = None
_DragCache
The cache holds all artifacts built in pre_drag() that are invariant across drag steps (constraint topology doesn't change during a drag):
| Field | Contents |
|---|---|
system |
_System -- owns ParamTable and Expr trees |
residuals |
list[Expr] -- after substitution pass |
jac_exprs |
list[list[Expr]] -- symbolic Jacobian |
compiled_eval |
Callable or None -- native compiled evaluator |
half_spaces |
list[HalfSpace] -- branch trackers |
weight_vec |
ndarray or None -- minimum-movement weights |
post_step_fn |
Callable or None -- half-space correction callback |
Performance
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each drag_step() only evaluates the cached expressions at updated parameter values:
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- The compiled evaluator (
codegen.py) uses native Pythonexecfor flat evaluation, avoiding the recursive tree-walk overhead - The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
- DOF counting is skipped during drag for speed (
result.dof = -1)
Diagnostics integration
diagnose(ctx) builds the constraint system and runs overconstrained detection, returning a list of kcsolve.ConstraintDiagnostic objects. The Assembly module calls this to populate the constraint diagnostics panel.
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)
Not yet implemented
- Kinematic simulation (
run_kinematic,num_frames,update_for_frame) -- the base class defaults returnFailed. Requires time-stepping integration with motion driver expression evaluation. - Joint limit enforcement -- inequality constraints need active-set or barrier solver extensions.
- Fixed-joint bundling (
supports_bundle_fixed()returnsFalse) -- the solver receives unbundled parts; the Assembly module pre-bundles when needed. - Native export (
export_native()) -- no solver-native debug format defined.