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

568
docs/INTER_SOLVER.md Normal file
View File

@@ -0,0 +1,568 @@
# Pluggable Assembly Solver Architecture
**Status:** Phase 2 complete
**Last Updated:** 2026-02-19
---
## 1. Problem
Kindred Create currently vendors OndselSolver as a monolithic assembly constraint solver. Different engineering domains benefit from different solver strategies — Lagrangian methods work well for rigid body assemblies but poorly for over-constrained or soft-constraint systems. A pluggable architecture lets us ship multiple solvers (including experimental ones) without touching core assembly logic, and lets the server farm out solve jobs to headless worker processes.
---
## 2. Design Goals
1. **Stable C++ API** — A solver-agnostic interface that the Assembly module calls. Solvers are shared libraries loaded at runtime.
2. **Python binding layer** — Every C++ solver is exposed to Python via pybind11, enabling rapid prototyping, debugging, and server-side execution without a full GUI build.
3. **Solver-defined joint types** — Each solver declares its own joint/mate vocabulary, mapped from a common base set (inspired by SOLIDWORKS mates: coincident, concentric, tangent, distance, angle, lock, etc.).
4. **Semi-deterministic solving** — Consistent results given consistent input ordering, with configurable tolerance and iteration limits.
5. **Server-compatible** — Solvers run as detached processes claimed by `silorunner` workers via the existing job queue.
---
## 3. Architecture Layers
```
┌──────────────────────────────────────────────────────┐
│ Layer 4: Server / Worker │
│ silorunner claims solve jobs, executes via Python │
│ Headless Create or standalone solver process │
├──────────────────────────────────────────────────────┤
│ Layer 3: Python Debug & Scripting │
│ pybind11 bindings for all solvers │
│ Introspection, step-through, constraint viz │
│ import kcsolve; s = kcsolve.load("ondsel") │
├──────────────────────────────────────────────────────┤
│ Layer 2: Solver Plugins (.so / .dll / .dylib) │
│ Each implements IKCSolver interface │
│ Registers joint types via manifest │
│ Loaded by SolverRegistry at runtime │
├──────────────────────────────────────────────────────┤
│ Layer 1: C++ Solver API (libkcsolve) │
│ IKCSolver, JointDef, SolveContext, SolveResult │
│ SolverRegistry (discovery, loading, selection) │
│ Ships as a shared library linked by Assembly module │
└──────────────────────────────────────────────────────┘
```
---
## 4. Layer 1: C++ Solver API
Located at `src/Mod/Assembly/Solver/` (or `src/Lib/KCSolve/` if we want it independent of Assembly).
### 4.1 Core Types
```cpp
namespace KCSolve {
// Unique identifier for a joint type within a solver
struct JointTypeId {
std::string solver_id; // e.g. "ondsel", "gnn", "relaxation"
std::string joint_name; // e.g. "coincident", "distance"
};
// Base joint categories (SOLIDWORKS-inspired vocabulary)
enum class BaseJointKind {
Coincident,
Concentric,
Tangent,
Distance,
Angle,
Lock,
Parallel,
Perpendicular,
PointOnLine,
SymmetricPlane,
Gear,
Rack,
Cam,
Slot,
Hinge,
Slider,
Cylindrical,
Planar,
Ball,
Screw,
Universal,
Custom // solver-specific extension
};
// A joint definition registered by a solver plugin
struct JointDef {
JointTypeId id;
BaseJointKind base_kind; // which vanilla category it maps to
std::string display_name;
std::string description;
uint32_t dof_removed; // degrees of freedom this joint removes
std::vector<std::string> params; // parameter names (e.g. "distance", "angle")
bool supports_limits = false;
bool supports_friction = false;
};
// A constraint instance in a solve problem
struct Constraint {
JointTypeId joint_type;
std::string part_a; // part label or id
std::string part_b;
// Geometry references (face, edge, vertex indices)
std::vector<std::string> refs_a;
std::vector<std::string> refs_b;
std::map<std::string, double> params; // param_name -> value
bool suppressed = false;
};
// Input to a solve operation
struct SolveContext {
std::vector<Constraint> constraints;
// Part placements as 4x4 transforms (initial guess)
std::map<std::string, std::array<double, 16>> placements;
// Which parts are grounded (fixed)
std::set<std::string> grounded;
// Solver config
double tolerance = 1e-10;
uint32_t max_iterations = 500;
bool deterministic = true; // force consistent ordering
// Optional: previous solution for warm-starting
std::map<std::string, std::array<double, 16>> warm_start;
};
enum class SolveStatus {
Converged,
MaxIterationsReached,
Overconstrained,
Underconstrained,
Redundant,
Failed
};
struct ConstraintDiagnostic {
std::string constraint_id;
double residual;
bool satisfied;
std::string message;
};
struct SolveResult {
SolveStatus status;
uint32_t iterations;
double final_residual;
double solve_time_ms;
std::map<std::string, std::array<double, 16>> placements;
std::vector<ConstraintDiagnostic> diagnostics;
// For semi-deterministic: hash of input ordering
uint64_t input_hash;
};
} // namespace KCSolve
```
### 4.2 Solver Interface
```cpp
namespace KCSolve {
class IKCSolver {
public:
virtual ~IKCSolver() = default;
// Identity
virtual std::string id() const = 0;
virtual std::string name() const = 0;
virtual std::string version() const = 0;
// Joint type registry — called once at load
virtual std::vector<JointDef> supported_joints() const = 0;
// Solve
virtual SolveResult solve(const SolveContext& ctx) = 0;
// Incremental: update a single constraint without full re-solve
// Default impl falls back to full solve
virtual SolveResult update(const SolveContext& ctx,
const std::string& changed_constraint) {
return solve(ctx);
}
// Diagnostic: check if a constraint set is well-posed before solving
virtual SolveStatus diagnose(const SolveContext& ctx) {
return SolveStatus::Converged; // optimistic default
}
// Determinism: given identical input, produce identical output
virtual bool is_deterministic() const { return false; }
};
// Plugin entry point — each .so exports this symbol
using CreateSolverFn = IKCSolver* (*)();
} // namespace KCSolve
```
### 4.3 Solver Registry
```cpp
namespace KCSolve {
class SolverRegistry {
public:
// Scan a directory for solver plugins (*.so / *.dll / *.dylib)
void scan(const std::filesystem::path& plugin_dir);
// Manual registration (for built-in solvers like Ondsel)
void register_solver(std::unique_ptr<IKCSolver> solver);
// Lookup
IKCSolver* get(const std::string& solver_id) const;
std::vector<std::string> available() const;
// Joint type resolution: find which solvers support a given base kind
std::vector<JointTypeId> joints_for(BaseJointKind kind) const;
// Global default solver
void set_default(const std::string& solver_id);
IKCSolver* get_default() const;
};
} // namespace KCSolve
```
### 4.4 Plugin Loading
Each solver plugin is a shared library exporting:
```cpp
extern "C" KCSolve::IKCSolver* kcsolve_create();
extern "C" const char* kcsolve_api_version(); // "1.0"
```
The registry `dlopen`s each library, checks `kcsolve_api_version()` compatibility, and calls `kcsolve_create()`. Plugins are discovered from:
1. `<install_prefix>/lib/kcsolve/` — system-installed solvers
2. `~/.config/KindredCreate/solvers/` — user-installed solvers
3. `KCSOLVE_PLUGIN_PATH` env var — development overrides
---
## 5. Layer 2: OndselSolver Adapter
The first plugin wraps the existing OndselSolver, mapping its internal constraint types to the `IKCSolver` interface.
```
src/Mod/Assembly/Solver/
├── IKCSolver.h # Interface + types from §4
├── SolverRegistry.cpp # Plugin discovery and loading
├── OndselAdapter.cpp # Wraps OndselSolver as IKCSolver plugin
└── CMakeLists.txt
```
`OndselAdapter` translates between `SolveContext` ↔ OndselSolver's Lagrangian formulation. This is the reference implementation and proves the API works before any new solvers are written.
Joint mapping for OndselAdapter:
| BaseJointKind | Ondsel Constraint | DOF Removed |
|---------------|-------------------|-------------|
| Coincident | PointOnPoint | 3 |
| Concentric | CylindricalOnCylindrical | 4 |
| Tangent | FaceOnFace (tangent mode) | 1 |
| Distance | PointOnPoint + offset | 2 |
| Angle | AxisAngle | 1 |
| Lock | FullLock | 6 |
| Hinge | RevoluteJoint | 5 |
| Slider | PrismaticJoint | 5 |
| Cylindrical | CylindricalJoint | 4 |
| Ball | SphericalJoint | 3 |
---
## 6. Layer 3: Python Bindings
### 6.1 pybind11 Module
```
src/Mod/Assembly/Solver/bindings/
├── kcsolve_py.cpp # pybind11 module definition
└── CMakeLists.txt
```
```python
import kcsolve
# List available solvers
print(kcsolve.available()) # ["ondsel", ...]
# Load a solver
solver = kcsolve.load("ondsel")
print(solver.name, solver.version)
print(solver.supported_joints())
# Build a problem
ctx = kcsolve.SolveContext()
ctx.add_part("base", placement=..., grounded=True)
ctx.add_part("arm", placement=...)
ctx.add_constraint("coincident", "base", "arm",
refs_a=["Face6"], refs_b=["Face1"])
# Solve
result = solver.solve(ctx)
print(result.status) # SolveStatus.Converged
print(result.iterations) # 12
print(result.solve_time_ms) # 3.4
print(result.placements["arm"])
# Diagnostics per constraint
for d in result.diagnostics:
print(f"{d.constraint_id}: residual={d.residual:.2e} ok={d.satisfied}")
```
### 6.2 Debug / Introspection API
The Python layer adds capabilities the C++ interface intentionally omits for performance:
```python
# Step-through solving (debug mode)
debugger = kcsolve.Debugger(solver, ctx)
for step in debugger.iterate():
print(f"iter {step.iteration}: residual={step.residual:.6e}")
print(f" moved: {step.parts_moved}")
print(f" worst constraint: {step.worst_constraint}")
if step.residual < 1e-8:
break
# Constraint dependency graph
graph = kcsolve.dependency_graph(ctx)
# Returns dict: constraint_id -> [dependent_constraint_ids]
# DOF analysis
analysis = kcsolve.dof_analysis(ctx)
print(f"Total DOF: {analysis.total_dof}")
print(f"Removed: {analysis.constrained_dof}")
print(f"Remaining: {analysis.free_dof}")
for part, dofs in analysis.per_part.items():
print(f" {part}: {dofs} free")
```
### 6.3 Pure-Python Solver Support
The Python layer also supports solvers written entirely in Python (no C++ required). This is the fast path for prototyping new approaches (GNN, relaxation, etc.):
```python
class RelaxationSolver(kcsolve.PySolver):
"""A pure-Python iterative relaxation solver for prototyping."""
id = "relaxation"
name = "Iterative Relaxation"
version = "0.1.0"
def supported_joints(self):
return [
kcsolve.JointDef("coincident", kcsolve.BaseJointKind.Coincident, dof_removed=3),
kcsolve.JointDef("distance", kcsolve.BaseJointKind.Distance, dof_removed=2),
# ...
]
def solve(self, ctx: kcsolve.SolveContext) -> kcsolve.SolveResult:
placements = dict(ctx.placements)
for i in range(ctx.max_iterations):
max_residual = 0.0
for c in ctx.constraints:
residual = self._eval_constraint(c, placements)
correction = self._compute_correction(c, residual)
self._apply_correction(placements, c, correction)
max_residual = max(max_residual, abs(residual))
if max_residual < ctx.tolerance:
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.Converged,
iterations=i + 1,
final_residual=max_residual,
placements=placements
)
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.MaxIterationsReached,
iterations=ctx.max_iterations,
final_residual=max_residual,
placements=placements
)
# Register at runtime
kcsolve.register(RelaxationSolver())
```
Python solvers are discovered from:
- `<user_macros>/solvers/*.py` — user-written solvers
- `mods/*/solvers/*.py` — addon-provided solvers
---
## 7. Layer 4: Server Integration
### 7.1 Solve Job Definition
Extends the existing worker system (WORKERS.md) with a new job type:
```yaml
job:
name: assembly-solve
version: 1
description: "Solve assembly constraints using specified solver"
trigger:
type: revision_created
filter:
item_type: assembly
scope:
type: assembly
compute:
type: solve
command: create-solve
args:
solver: ondsel # or "auto" for registry default
tolerance: 1e-10
max_iterations: 500
deterministic: true
output_placements: true # write solved placements back to revision
output_diagnostics: true # store constraint diagnostics in job result
runner:
tags: [create, solver]
timeout: 300
max_retries: 1
priority: 75
```
### 7.2 Headless Solve via Runner
The `create-solve` command in `silorunner`:
1. Claims job from Silo server
2. Downloads the assembly `.kc` file
3. Launches Headless Create (or standalone Python if pure-Python solver)
4. Loads the assembly, extracts constraint graph → `SolveContext`
5. Calls `solver.solve(ctx)`
6. Reports `SolveResult` back via `POST /api/runner/jobs/{id}/complete`
7. Optionally writes updated placements as a new revision
### 7.3 Standalone Solve Process (No GUI)
For server-side batch solving without Headless Create overhead:
```python
#!/usr/bin/env python3
"""Standalone solver worker — no FreeCAD dependency."""
import kcsolve
import json, sys
problem = json.load(sys.stdin)
ctx = kcsolve.SolveContext.from_dict(problem)
solver = kcsolve.load(problem.get("solver", "ondsel"))
result = solver.solve(ctx)
json.dump(result.to_dict(), sys.stdout)
```
This enables lightweight solver containers that don't need the full Create installation — useful for CI validation, quick constraint checks, and scaling solver capacity independently of geometry workers.
---
## 8. Semi-Deterministic Behavior
"Semi-deterministic" means: given the same constraint set and initial placements, the solver produces the same result. This is achieved by:
1. **Canonical input ordering**`SolveContext` sorts constraints and parts by a stable key (part label + constraint index) before passing to the solver. The ordering hash is stored in `SolveResult.input_hash`.
2. **Solver contract**`IKCSolver::is_deterministic()` reports whether the implementation guarantees this. OndselAdapter does (Lagrangian formulation with fixed pivot ordering). A GNN solver might not.
3. **Tolerance-aware comparison** — Two `SolveResult`s are "equivalent" if all placement deltas are within tolerance, even if iteration counts differ. Used for regression testing.
4. **Warm-start stability** — When `warm_start` placements are provided, the solver should converge to the same solution as a cold start (within tolerance), just faster. This is validated in the test suite.
---
## 9. Implementation Phases
### Phase 1: API + OndselAdapter (foundation) -- COMPLETE
- Defined `IKCSolver.h`, core types (`Types.h`), `SolverRegistry`
- Implemented `OndselAdapter` wrapping existing solver
- Assembly module calls through `SolverRegistry` instead of directly calling OndselSolver
- 18 C++ tests, 6 Python integration tests
- **PR:** #297 (merged)
### Phase 2: pybind11 Bindings -- COMPLETE
- Built `kcsolve` pybind11 module exposing all enums, structs, and classes
- `PyIKCSolver` trampoline for pure-Python solver subclasses
- `register_solver()` for runtime Python solver registration
- `PySolverHolder` for GIL-safe forwarding of virtual calls
- 16 Python tests covering types, registry, and Python solver round-trips
- Debug/introspection API (Debugger, `dependency_graph()`, `dof_analysis()`) deferred to Phase 4+
- Automatic Python solver discovery (`mods/*/solvers/`) deferred -- users call `register_solver()` explicitly
- **PR:** #298
- **Docs:** `docs/src/architecture/ondsel-solver.md`, `docs/src/reference/kcsolve-python.md`
### Phase 3: Server Integration
- `create-solve` command for `silorunner`
- YAML job definition for solve jobs
- Standalone solver process (no FreeCAD dependency)
- `SolveContext` JSON serialization for inter-process communication
- **Deliverable:** Solve jobs run async through the worker system
### Phase 4: Second Solver (validation)
- Implement a simple relaxation or gradient-descent solver as a Python plugin
- Validates that the API actually supports different solving strategies
- Benchmark against OndselAdapter for correctness and performance
- **Deliverable:** Two interchangeable solvers, selectable per-assembly
### Phase 5: GNN Solver (future)
- Graph Neural Network approach from existing roadmap
- Likely a Python solver wrapping a trained model
- Focus on fast approximate solutions for interactive editing
- Falls back to OndselAdapter for final precision solve
- **Deliverable:** Hybrid solve pipeline (GNN fast-guess → Lagrangian refinement)
---
## 10. File Locations
```
src/Lib/KCSolve/ # or src/Mod/Assembly/Solver/
├── include/
│ └── KCSolve/
│ ├── IKCSolver.h # Interface + all types
│ ├── SolverRegistry.h # Plugin loading and lookup
│ └── Types.h # Enums, structs
├── src/
│ ├── SolverRegistry.cpp
│ └── OndselAdapter.cpp
├── bindings/
│ └── kcsolve_py.cpp # pybind11
├── plugins/ # Additional compiled solver plugins
└── CMakeLists.txt
```
---
## 11. Open Questions
1. **Location**: `src/Lib/KCSolve/` (independent library, usable without Assembly module) vs `src/Mod/Assembly/Solver/` (tighter coupling, simpler build)? Leaning toward `src/Lib/` since server workers need it without the full Assembly module.
2. **Geometry abstraction**: The C++ API uses string references for faces/edges/vertices. Should we pass actual OCC geometry (TopoDS_Shape) through the interface, or keep it abstract and let each solver adapter resolve references? Abstract is more portable but adds a translation step.
3. **Constraint persistence**: Currently constraints live in the FCStd XML. Should the pluggable layer introduce its own serialization, or always read/write through FreeCAD's property system?
4. **API versioning**: `kcsolve_api_version()` returns a string. Semver with major-only breaking changes? How strict on backward compat for the plugin ABI?
5. **License implications**: OndselSolver is LGPL. New solver plugins could be any license since they're loaded at runtime via a stable C API boundary. Confirm this interpretation.
---
## 12. References
- [ondsel-solver.md](ondsel-solver.md) — Current solver documentation
- [WORKERS.md](WORKERS.md) — Worker/runner job system
- [MULTI_USER_EDITS.md](MULTI_USER_EDITS.md) — Async validation pipeline
- [DAG.md](DAG.md) — Dependency graph for incremental recompute
- [ROADMAP.md](ROADMAP.md) — Tier 3 compute modules, GNN solver plans

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