Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
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
257 lines
7.0 KiB
Markdown
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
|