Assembly Origin Planes: - AssemblyObject::setupObject() relabels origin planes to Top (XY), Front (XZ), Right (YZ) on assembly creation - CommandCreateAssembly.py makes origin planes visible by default - AssemblyUtils.cpp getObjFromRef() resolves LocalCoordinateSystem to child datum elements for joint references to origin planes - TestAssemblyOriginPlanes.py: 9 integration tests covering structure, labels, grounding, reference resolution, solver, and save/load round-trip Solver Documentation: - docs/src/solver/: 7 new pages covering architecture overview, expression DAG, constraints, solving algorithms, diagnostics, assembly integration, and writing custom solvers - docs/src/SUMMARY.md: added Kindred Solver section
7.0 KiB
Writing a Custom Solver
The KCSolve framework lets you implement a solver backend in pure Python, register it at runtime, and select it through the Assembly preferences. This tutorial walks through building a minimal solver and then extending it.
Minimal solver
A solver must subclass kcsolve.IKCSolver and implement three methods:
import kcsolve
class MySolver(kcsolve.IKCSolver):
def __init__(self):
super().__init__() # required for pybind11 trampoline
def name(self):
return "My Custom Solver"
def supported_joints(self):
return [
kcsolve.BaseJointKind.Fixed,
kcsolve.BaseJointKind.Revolute,
]
def solve(self, ctx):
result = kcsolve.SolveResult()
# Find grounded parts
grounded = {p.id for p in ctx.parts if p.grounded}
if not grounded:
result.status = kcsolve.SolveStatus.NoGroundedParts
return result
# Your solving logic here...
# For each non-grounded part, compute its solved placement
for part in ctx.parts:
if part.grounded:
continue
pr = kcsolve.SolveResult.PartResult()
pr.id = part.id
pr.placement = part.placement # use current placement as placeholder
result.placements = result.placements + [pr]
result.status = kcsolve.SolveStatus.Success
result.dof = 0
return result
Register it:
kcsolve.register_solver("my_solver", MySolver)
Test it from the FreeCAD console:
solver = kcsolve.load("my_solver")
print(solver.name()) # "My Custom Solver"
ctx = kcsolve.SolveContext()
# ... build context ...
result = solver.solve(ctx)
print(result.status) # SolveStatus.Success
Addon packaging
To make your solver load automatically, create a FreeCAD addon:
my_solver_addon/
package.xml # Addon manifest
Init.py # Registration entry point
my_solver/
__init__.py
solver.py # MySolver class
package.xml:
<?xml version="1.0" encoding="UTF-8"?>
<package format="1">
<name>MyCustomSolver</name>
<description>Custom assembly constraint solver</description>
<version>0.1.0</version>
<content>
<preferencepack>
<name>MySolver</name>
</preferencepack>
</content>
</package>
Init.py:
import kcsolve
from my_solver.solver import MySolver
kcsolve.register_solver("my_solver", MySolver)
Place the addon in the FreeCAD Mod directory or as a git submodule in mods/.
Working with SolveContext
The SolveContext contains everything the solver needs:
Parts
for part in ctx.parts:
print(f"{part.id}: grounded={part.grounded}")
print(f" position: {list(part.placement.position)}")
print(f" quaternion: {list(part.placement.quaternion)}")
print(f" mass: {part.mass}")
Each part has 7 degrees of freedom: 3 translation (x, y, z) and 4 quaternion components (w, x, y, z) with a unit-norm constraint reducing the rotational DOF to 3.
Quaternion convention: (w, x, y, z) where w is the scalar part. This differs from FreeCAD's Base.Rotation(x, y, z, w). The adapter layer handles the swap.
Constraints
for c in ctx.constraints:
if not c.activated:
continue
print(f"{c.id}: {c.type} between {c.part_i} and {c.part_j}")
print(f" marker_i: pos={list(c.marker_i.position)}, "
f"quat={list(c.marker_i.quaternion)}")
print(f" params: {list(c.params)}")
print(f" limits: {len(c.limits)}")
The marker transforms define local coordinate frames on each part. The constraint type determines what geometric relationship is enforced between these frames.
Returning results
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success
result.dof = computed_dof
placements = []
for part_id, pos, quat in solved_parts:
pr = kcsolve.SolveResult.PartResult()
pr.id = part_id
pr.placement = kcsolve.Transform()
pr.placement.position = list(pos)
pr.placement.quaternion = list(quat)
placements.append(pr)
result.placements = placements
return result
Important: pybind11 list fields return copies. Use result.placements = [...] (whole-list assignment), not result.placements.append(...).
Adding optional capabilities
Diagnostics
Override diagnose() to detect overconstrained or malformed assemblies:
def diagnose(self, ctx):
diagnostics = []
# ... analyze constraints ...
d = kcsolve.ConstraintDiagnostic()
d.constraint_id = "Joint001"
d.kind = kcsolve.DiagnosticKind.Redundant
d.detail = "This joint duplicates Joint002"
diagnostics.append(d)
return diagnostics
Interactive drag
Override the three drag methods for real-time viewport dragging:
def pre_drag(self, ctx, drag_parts):
self._ctx = ctx
self._dragging = set(drag_parts)
return self.solve(ctx)
def drag_step(self, drag_placements):
# Update dragged parts in stored context
for pr in drag_placements:
for part in self._ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
return self.solve(self._ctx)
def post_drag(self):
self._ctx = None
self._dragging = None
For responsive dragging, the solver should converge quickly from a nearby initial guess. Use warm-starting (current placements as initial guess) and consider caching internal state across drag steps.
Incremental update
Override update() for the case where only constraint parameters changed (not topology):
def update(self, ctx):
# Reuse cached factorization, only re-evaluate changed residuals
return self.solve(ctx) # default: just re-solve
Testing
Unit tests (without FreeCAD)
Test your solver logic with hand-built SolveContext objects:
import kcsolve
def test_fixed_joint():
ctx = kcsolve.SolveContext()
base = kcsolve.Part()
base.id = "base"
base.grounded = True
arm = kcsolve.Part()
arm.id = "arm"
arm.placement.position = [100.0, 0.0, 0.0]
joint = kcsolve.Constraint()
joint.id = "Joint001"
joint.part_i = "base"
joint.part_j = "arm"
joint.type = kcsolve.BaseJointKind.Fixed
ctx.parts = [base, arm]
ctx.constraints = [joint]
solver = MySolver()
result = solver.solve(ctx)
assert result.status == kcsolve.SolveStatus.Success
Integration tests (with FreeCAD)
For integration testing within FreeCAD, follow the pattern in TestKindredSolverIntegration.py: set the solver preference in setUp(), create document objects, and verify solve results.
Reference
- KCSolve Python API -- complete type and function reference
- KCSolve Architecture -- C++ framework details
- Constraints -- constraint types and residual counts
- Kindred Solver Overview -- how the built-in Kindred solver works