feat(assembly): fixed reference planes + solver docs
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
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
This commit is contained in:
256
docs/src/solver/writing-a-solver.md
Normal file
256
docs/src/solver/writing-a-solver.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user