Files
create/docs/src/solver/writing-a-solver.md
forbes acc255972d
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
feat(assembly): fixed reference planes + solver docs
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
2026-02-21 09:09:16 -06:00

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