docs: KCSolve architecture and Python API reference
All checks were successful
Build and Test / build (pull_request) Successful in 28m58s

- Replace OndselSolver architecture doc with KCSolve pluggable solver
  architecture covering IKCSolver interface, SolverRegistry, OndselAdapter,
  Python bindings, file layout, and testing
- Add kcsolve Python API reference with full type documentation, module
  functions, usage examples, and pybind11 vector-copy caveat
- Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2
  marked as complete
- Update SUMMARY.md with new page links
This commit is contained in:
forbes
2026-02-19 18:59:05 -06:00
parent 7ea0078ba3
commit 406e120180
4 changed files with 1004 additions and 17 deletions

View File

@@ -19,7 +19,7 @@
- [Python as Source of Truth](./architecture/python-source-of-truth.md)
- [Silo Server](./architecture/silo-server.md)
- [Signal Architecture](./architecture/signal-architecture.md)
- [OndselSolver](./architecture/ondsel-solver.md)
- [KCSolve: Pluggable Solver](./architecture/ondsel-solver.md)
# Development
@@ -64,3 +64,4 @@
- [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md)
- [FileOriginPython Bridge](./reference/cpp-file-origin-python.md)
- [Creating a Custom Origin (C++)](./reference/cpp-custom-origin-guide.md)
- [KCSolve Python API](./reference/kcsolve-python.md)

View File

@@ -1,27 +1,132 @@
# OndselSolver
# KCSolve: Pluggable Solver Architecture
OndselSolver is the assembly constraint solver used by FreeCAD's Assembly workbench. Kindred Create vendors a fork of the solver as a git submodule.
KCSolve is the pluggable assembly constraint solver framework for Kindred Create. It defines an abstract solver interface (`IKCSolver`) and a runtime registry (`SolverRegistry`) that lets the Assembly module work with any conforming solver backend. The default backend wraps OndselSolver via `OndselAdapter`.
- **Path:** `src/3rdParty/OndselSolver/`
- **Source:** `git.kindred-systems.com/kindred/solver` (Kindred fork)
- **Library:** `src/Mod/Assembly/Solver/` (builds `libKCSolve.so`)
- **Python module:** `src/Mod/Assembly/Solver/bindings/` (builds `kcsolve.so`)
- **Tests:** `tests/src/Mod/Assembly/Solver/` (C++), `src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py` (Python)
## How it works
## Architecture
The solver uses a **Lagrangian constraint formulation** to resolve assembly constraints (mates, joints, fixed positions). Given a set of parts with geometric constraints between them, it computes positions and orientations that satisfy all constraints simultaneously.
```
┌──────────────────────────────────────────────────┐
│ Assembly Module (AssemblyObject.cpp) │
│ Builds SolveContext from FreeCAD document, │
│ calls solver via SolverRegistry │
├──────────────────────────────────────────────────┤
│ SolverRegistry (singleton) │
│ register_solver(), get(), available() │
│ Plugin discovery via scan() / scan_default_paths │
├──────────────┬───────────────────────────────────┤
│ OndselAdapter │ Python solvers │ Future plugins │
│ (C++ built-in)│ (via kcsolve) │ (.so plugins) │
└──────────────┴───────────────────────────────────┘
```
The Assembly workbench (`src/Mod/Assembly/`) calls the solver whenever constraints are added or modified. Kindred Create has patches to `Assembly/` that extend `findPlacement()` for better datum and origin handling.
The Assembly module never references OndselSolver directly. All solver access goes through `SolverRegistry::instance().get()`, which returns a `std::unique_ptr<IKCSolver>`.
## Why a fork
## IKCSolver interface
The solver is forked from the upstream Ondsel project for:
- **Pinned stability** — the submodule is pinned to a known-good commit
- **Potential modifications** — the fork allows Kindred-specific patches if needed
- **Availability** — hosted on Kindred's Gitea instance for reliable access
A solver backend implements `IKCSolver` (defined in `IKCSolver.h`). Only three methods are pure virtual; all others have sensible defaults:
## Future: GNN solver
| Method | Required | Purpose |
|--------|----------|---------|
| `name()` | yes | Human-readable solver name |
| `supported_joints()` | yes | List of `BaseJointKind` values the solver handles |
| `solve(ctx)` | yes | Solve for static equilibrium |
| `update(ctx)` | no | Incremental re-solve after parameter changes |
| `pre_drag(ctx, parts)` | no | Begin interactive drag session |
| `drag_step(placements)` | no | One mouse-move during drag |
| `post_drag()` | no | End drag session |
| `run_kinematic(ctx)` | no | Run kinematic simulation |
| `num_frames()` | no | Frame count after simulation |
| `update_for_frame(i)` | no | Retrieve frame placements |
| `diagnose(ctx)` | no | Detect redundant/conflicting constraints |
| `is_deterministic()` | no | Whether output is reproducible (default: true) |
| `export_native(path)` | no | Write solver-native debug file (e.g. ASMT) |
| `supports_bundle_fixed()` | no | Whether solver handles Fixed-joint bundling internally |
There are plans to explore a Graph Neural Network (GNN) approach to constraint solving that could complement or supplement the Lagrangian solver for specific use cases. This is not yet implemented.
## Core types
## Related: GSL
All types live in `Types.h` with no FreeCAD dependencies, making the header standalone for future server/worker use.
The `src/3rdParty/GSL/` submodule is Microsoft's Guidelines Support Library (`github.com/microsoft/GSL`), providing C++ core guidelines utilities like `gsl::span` and `gsl::not_null`. It is a build dependency, not related to the constraint solver.
**Transform** -- position `[x, y, z]` + unit quaternion `[w, x, y, z]`. Equivalent to `Base::Placement` but independent. Note the quaternion convention differs from `Base::Rotation` which uses `(x, y, z, w)` ordering; the adapter layer handles the swap.
**BaseJointKind** -- 24 primitive constraint types decomposed from FreeCAD's `JointType` and `DistanceType` enums. Covers point constraints (Coincident, PointOnLine, PointInPlane), axis/surface constraints (Concentric, Tangent, Planar), kinematic joints (Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal), mechanical elements (Gear, RackPinion), distance variants, and a `Custom` extension point.
**SolveContext** -- complete solver input: parts (with placements, mass, grounded flag), constraints (with markers, parameters, limits), optional motion definitions and simulation parameters.
**SolveResult** -- solver output: status code, updated part placements, DOF count, constraint diagnostics, and simulation frame count.
## SolverRegistry
Thread-safe singleton managing solver backends:
```cpp
auto& reg = SolverRegistry::instance();
// Registration (at module init)
reg.register_solver("ondsel", []() {
return std::make_unique<OndselAdapter>();
});
// Retrieval
auto solver = reg.get(); // default solver
auto solver = reg.get("ondsel"); // by name
// Queries
reg.available(); // ["ondsel", ...]
reg.joints_for("ondsel"); // [Fixed, Revolute, ...]
reg.set_default("ondsel");
```
Plugin discovery scans directories for shared libraries exporting `kcsolve_api_version()` and `kcsolve_create()`. Default paths: `KCSOLVE_PLUGIN_PATH` env var and `<prefix>/lib/kcsolve/`.
## OndselAdapter
The built-in solver backend wrapping OndselSolver's Lagrangian constraint formulation. Registered as `"ondsel"` at Assembly module initialization.
Supports all 24 joint types. The adapter translates between `SolveContext`/`SolveResult` and OndselSolver's internal `ASMTAssembly` representation, including:
- Part placement conversion (Transform <-> Base::Placement quaternion ordering)
- Constraint parameter mapping (BaseJointKind -> OndselSolver joint classes)
- Interactive drag protocol (pre_drag/drag_step/post_drag)
- Kinematic simulation (run_kinematic/num_frames/update_for_frame)
- Constraint diagnostics (redundancy detection via MbD system)
## Python bindings (kcsolve module)
The `kcsolve` pybind11 module exposes the full C++ API to Python. See [KCSolve Python API](../reference/kcsolve-python.md) for details.
Key capabilities:
- All enums, structs, and classes accessible from Python
- Subclass `IKCSolver` in pure Python to create new solver backends
- Register Python solvers at runtime via `kcsolve.register_solver()`
- Query the registry from the FreeCAD console
## File layout
```
src/Mod/Assembly/Solver/
├── Types.h # Enums and structs (no FreeCAD deps)
├── IKCSolver.h # Abstract solver interface
├── SolverRegistry.h/cpp # Singleton registry + plugin loading
├── OndselAdapter.h/cpp # OndselSolver wrapper
├── KCSolveGlobal.h # DLL export macros
├── CMakeLists.txt # Builds libKCSolve.so
└── bindings/
├── PyIKCSolver.h # pybind11 trampoline for Python subclasses
├── kcsolve_py.cpp # Module definition (enums, structs, classes)
└── CMakeLists.txt # Builds kcsolve.so (pybind11 module)
```
## Testing
- **18 C++ tests** (`KCSolve_tests_run`) covering SolverRegistry (8 tests) and OndselAdapter (10 tests including drag protocol and redundancy diagnosis)
- **16 Python tests** (`TestKCSolvePy`) covering module import, type bindings, registry functions, Python solver subclassing, and full register/load/solve round-trips
- **6 Python integration tests** (`TestSolverIntegration`) testing solver behavior through FreeCAD document objects
## Related
- [KCSolve Python API Reference](../reference/kcsolve-python.md)
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification

View File

@@ -0,0 +1,313 @@
# KCSolve Python API Reference
The `kcsolve` module provides Python access to the KCSolve pluggable solver framework. It is built with pybind11 and installed alongside the Assembly module.
```python
import kcsolve
```
## Module constants
| Name | Value | Description |
|------|-------|-------------|
| `API_VERSION_MAJOR` | `1` | KCSolve API major version |
## Enums
### BaseJointKind
Primitive constraint types. 24 values:
`Coincident`, `PointOnLine`, `PointInPlane`, `Concentric`, `Tangent`, `Planar`, `LineInPlane`, `Parallel`, `Perpendicular`, `Angle`, `Fixed`, `Revolute`, `Cylindrical`, `Slider`, `Ball`, `Screw`, `Universal`, `Gear`, `RackPinion`, `Cam`, `Slot`, `DistancePointPoint`, `DistanceCylSph`, `Custom`
### SolveStatus
| Value | Meaning |
|-------|---------|
| `Success` | Solve converged |
| `Failed` | Solve did not converge |
| `InvalidFlip` | Orientation flipped past threshold |
| `NoGroundedParts` | No grounded parts in assembly |
### DiagnosticKind
`Redundant`, `Conflicting`, `PartiallyRedundant`, `Malformed`
### MotionKind
`Rotational`, `Translational`, `General`
### LimitKind
`TranslationMin`, `TranslationMax`, `RotationMin`, `RotationMax`
## Structs
### Transform
Rigid-body transform: position + unit quaternion.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `position` | `list[float]` (3) | `[0, 0, 0]` | Translation (x, y, z) |
| `quaternion` | `list[float]` (4) | `[1, 0, 0, 0]` | Unit quaternion (w, x, y, z) |
```python
t = kcsolve.Transform()
t = kcsolve.Transform.identity() # same as default
```
Note: quaternion convention is `(w, x, y, z)`, which differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles conversion.
### Part
| Field | Type | Default |
|-------|------|---------|
| `id` | `str` | `""` |
| `placement` | `Transform` | identity |
| `mass` | `float` | `1.0` |
| `grounded` | `bool` | `False` |
### Constraint
| Field | Type | Default |
|-------|------|---------|
| `id` | `str` | `""` |
| `part_i` | `str` | `""` |
| `marker_i` | `Transform` | identity |
| `part_j` | `str` | `""` |
| `marker_j` | `Transform` | identity |
| `type` | `BaseJointKind` | `Coincident` |
| `params` | `list[float]` | `[]` |
| `limits` | `list[Constraint.Limit]` | `[]` |
| `activated` | `bool` | `True` |
### Constraint.Limit
| Field | Type | Default |
|-------|------|---------|
| `kind` | `LimitKind` | `TranslationMin` |
| `value` | `float` | `0.0` |
| `tolerance` | `float` | `1e-9` |
### MotionDef
| Field | Type | Default |
|-------|------|---------|
| `kind` | `MotionKind` | `Rotational` |
| `joint_id` | `str` | `""` |
| `marker_i` | `str` | `""` |
| `marker_j` | `str` | `""` |
| `rotation_expr` | `str` | `""` |
| `translation_expr` | `str` | `""` |
### SimulationParams
| Field | Type | Default |
|-------|------|---------|
| `t_start` | `float` | `0.0` |
| `t_end` | `float` | `1.0` |
| `h_out` | `float` | `0.01` |
| `h_min` | `float` | `1e-9` |
| `h_max` | `float` | `1.0` |
| `error_tol` | `float` | `1e-6` |
### SolveContext
| Field | Type | Default |
|-------|------|---------|
| `parts` | `list[Part]` | `[]` |
| `constraints` | `list[Constraint]` | `[]` |
| `motions` | `list[MotionDef]` | `[]` |
| `simulation` | `SimulationParams` or `None` | `None` |
| `bundle_fixed` | `bool` | `False` |
**Important:** pybind11 returns copies of `list` fields, not references. Use whole-list assignment:
```python
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box1"
ctx.parts = [p] # correct
# ctx.parts.append(p) # does NOT modify ctx
```
### ConstraintDiagnostic
| Field | Type | Default |
|-------|------|---------|
| `constraint_id` | `str` | `""` |
| `kind` | `DiagnosticKind` | `Redundant` |
| `detail` | `str` | `""` |
### SolveResult
| Field | Type | Default |
|-------|------|---------|
| `status` | `SolveStatus` | `Success` |
| `placements` | `list[SolveResult.PartResult]` | `[]` |
| `dof` | `int` | `-1` |
| `diagnostics` | `list[ConstraintDiagnostic]` | `[]` |
| `num_frames` | `int` | `0` |
### SolveResult.PartResult
| Field | Type | Default |
|-------|------|---------|
| `id` | `str` | `""` |
| `placement` | `Transform` | identity |
## Classes
### IKCSolver
Abstract base class for solver backends. Subclass in Python to create custom solvers.
Three methods must be implemented:
```python
class MySolver(kcsolve.IKCSolver):
def name(self):
return "My Solver"
def supported_joints(self):
return [kcsolve.BaseJointKind.Fixed, kcsolve.BaseJointKind.Revolute]
def solve(self, ctx):
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success
return result
```
Optional overrides (all have default implementations):
| Method | Default behavior |
|--------|-----------------|
| `update(ctx)` | Delegates to `solve()` |
| `pre_drag(ctx, drag_parts)` | Delegates to `solve()` |
| `drag_step(drag_placements)` | Returns Success with no placements |
| `post_drag()` | No-op |
| `run_kinematic(ctx)` | Returns Failed |
| `num_frames()` | Returns 0 |
| `update_for_frame(index)` | Returns Failed |
| `diagnose(ctx)` | Returns empty list |
| `is_deterministic()` | Returns `True` |
| `export_native(path)` | No-op |
| `supports_bundle_fixed()` | Returns `False` |
### OndselAdapter
Built-in solver wrapping OndselSolver's Lagrangian constraint formulation. Inherits `IKCSolver`.
```python
solver = kcsolve.OndselAdapter()
solver.name() # "OndselSolver (Lagrangian)"
```
In practice, use `kcsolve.load("ondsel")` rather than constructing directly, as this goes through the registry.
## Module functions
### available()
Return names of all registered solvers.
```python
kcsolve.available() # ["ondsel"]
```
### load(name="")
Create an instance of the named solver. If `name` is empty, uses the default. Returns `None` if the solver is not found.
```python
solver = kcsolve.load("ondsel")
solver = kcsolve.load() # default solver
```
### joints_for(name)
Query supported joint types for a registered solver.
```python
joints = kcsolve.joints_for("ondsel")
# [BaseJointKind.Coincident, BaseJointKind.Fixed, ...]
```
### set_default(name)
Set the default solver name. Returns `True` if the name is registered.
```python
kcsolve.set_default("ondsel") # True
kcsolve.set_default("unknown") # False
```
### get_default()
Get the current default solver name.
```python
kcsolve.get_default() # "ondsel"
```
### register_solver(name, solver_class)
Register a Python solver class with the SolverRegistry. `solver_class` must be a callable that returns an `IKCSolver` subclass instance.
```python
class MySolver(kcsolve.IKCSolver):
def name(self): return "MySolver"
def supported_joints(self): return [kcsolve.BaseJointKind.Fixed]
def solve(self, ctx):
r = kcsolve.SolveResult()
r.status = kcsolve.SolveStatus.Success
return r
kcsolve.register_solver("my_solver", MySolver)
solver = kcsolve.load("my_solver")
```
## Complete example
```python
import kcsolve
# Build a two-part assembly with a 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]
# Solve
solver = kcsolve.load("ondsel")
result = solver.solve(ctx)
print(result.status) # SolveStatus.Success
for pr in result.placements:
print(f"{pr.id}: pos={list(pr.placement.position)}")
# Diagnostics
diags = solver.diagnose(ctx)
for d in diags:
print(f"{d.constraint_id}: {d.kind} - {d.detail}")
```
## Related
- [KCSolve Architecture](../architecture/ondsel-solver.md)
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification