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

257 lines
7.0 KiB
Markdown

# 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:
```python
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:
```python
kcsolve.register_solver("my_solver", MySolver)
```
Test it from the FreeCAD console:
```python
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
<?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:**
```python
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
```python
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
```python
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
```python
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:
```python
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:
```python
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):
```python
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:
```python
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](../reference/kcsolve-python.md) -- complete type and function reference
- [KCSolve Architecture](../architecture/ondsel-solver.md) -- C++ framework details
- [Constraints](constraints.md) -- constraint types and residual counts
- [Kindred Solver Overview](overview.md) -- how the built-in Kindred solver works