Merge pull request 'feat(kcsolve): pybind11 bindings and Python solver support' (#298) from feat/solver-api-types into main
Reviewed-on: #298
This commit was merged in pull request #298.
This commit is contained in:
568
docs/INTER_SOLVER.md
Normal file
568
docs/INTER_SOLVER.md
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
429
docs/src/reference/kcsolve-python.md
Normal file
429
docs/src/reference/kcsolve-python.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# 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
|
||||
|
||||
A constraint between two parts, built from a FreeCAD JointObject by the adapter layer.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `id` | `str` | `""` | FreeCAD document object name (e.g. `"Joint001"`) |
|
||||
| `part_i` | `str` | `""` | Solver-side part ID for first reference |
|
||||
| `marker_i` | `Transform` | identity | Coordinate system on `part_i` (attachment point/orientation) |
|
||||
| `part_j` | `str` | `""` | Solver-side part ID for second reference |
|
||||
| `marker_j` | `Transform` | identity | Coordinate system on `part_j` (attachment point/orientation) |
|
||||
| `type` | `BaseJointKind` | `Coincident` | Constraint type |
|
||||
| `params` | `list[float]` | `[]` | Scalar parameters (interpretation depends on `type`) |
|
||||
| `limits` | `list[Constraint.Limit]` | `[]` | Joint travel limits |
|
||||
| `activated` | `bool` | `True` | Whether this constraint is active |
|
||||
|
||||
**`marker_i` / `marker_j`** -- Define the local coordinate frames on each part where the joint acts. For example, a Revolute joint's markers define the hinge axis direction and attachment points on each part.
|
||||
|
||||
**`params`** -- Interpretation depends on `type`:
|
||||
|
||||
| Type | params[0] | params[1] |
|
||||
|------|-----------|-----------|
|
||||
| `Angle` | angle (radians) | |
|
||||
| `RackPinion` | pitch radius | |
|
||||
| `Screw` | pitch | |
|
||||
| `Gear` | radius I | radius J (negative for belt) |
|
||||
| `DistancePointPoint` | distance | |
|
||||
| `DistanceCylSph` | distance | |
|
||||
| `Planar` | offset | |
|
||||
| `Concentric` | distance | |
|
||||
| `PointInPlane` | offset | |
|
||||
| `LineInPlane` | offset | |
|
||||
|
||||
### Constraint.Limit
|
||||
|
||||
Joint travel limits (translation or rotation bounds).
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `kind` | `LimitKind` | `TranslationMin` | Which degree of freedom to limit |
|
||||
| `value` | `float` | `0.0` | Limit value (meters for translation, radians for rotation) |
|
||||
| `tolerance` | `float` | `1e-9` | Solver tolerance for limit enforcement |
|
||||
|
||||
### MotionDef
|
||||
|
||||
A motion driver for kinematic simulation. Defines time-dependent actuation of a constraint.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `kind` | `MotionKind` | `Rotational` | Type of motion: `Rotational`, `Translational`, or `General` (both) |
|
||||
| `joint_id` | `str` | `""` | ID of the constraint this motion drives |
|
||||
| `marker_i` | `str` | `""` | Reference marker on first part |
|
||||
| `marker_j` | `str` | `""` | Reference marker on second part |
|
||||
| `rotation_expr` | `str` | `""` | Rotation law as a function of time `t` (e.g. `"2*pi*t"`) |
|
||||
| `translation_expr` | `str` | `""` | Translation law as a function of time `t` (e.g. `"10*t"`) |
|
||||
|
||||
For `Rotational` kind, only `rotation_expr` is used. For `Translational`, only `translation_expr`. For `General`, both are set.
|
||||
|
||||
### SimulationParams
|
||||
|
||||
Time-stepping parameters for kinematic simulation via `run_kinematic()`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `t_start` | `float` | `0.0` | Simulation start time (seconds) |
|
||||
| `t_end` | `float` | `1.0` | Simulation end time (seconds) |
|
||||
| `h_out` | `float` | `0.01` | Output time step -- controls frame rate (e.g. `0.04` = 25 fps) |
|
||||
| `h_min` | `float` | `1e-9` | Minimum internal integration step |
|
||||
| `h_max` | `float` | `1.0` | Maximum internal integration step |
|
||||
| `error_tol` | `float` | `1e-6` | Error tolerance for adaptive time stepping |
|
||||
|
||||
### SolveContext
|
||||
|
||||
Complete input to a solve operation. Built by the adapter layer from FreeCAD document objects, or constructed manually for scripted solving.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `parts` | `list[Part]` | `[]` | All parts in the assembly |
|
||||
| `constraints` | `list[Constraint]` | `[]` | Constraints between parts |
|
||||
| `motions` | `list[MotionDef]` | `[]` | Motion drivers for kinematic simulation |
|
||||
| `simulation` | `SimulationParams` or `None` | `None` | Time-stepping parameters for `run_kinematic()` |
|
||||
| `bundle_fixed` | `bool` | `False` | Hint to merge Fixed-joint-connected parts into rigid bodies |
|
||||
|
||||
**`motions`** -- Motion drivers define time-dependent joint actuation for kinematic simulation. Each `MotionDef` references a constraint by `joint_id` and provides expressions (functions of time `t`) for rotation and/or translation. Only used when calling `run_kinematic()`.
|
||||
|
||||
**`simulation`** -- When set, provides time-stepping parameters (`t_start`, `t_end`, step sizes, error tolerance) for kinematic simulation via `run_kinematic()`. When `None`, kinematic simulation is not requested.
|
||||
|
||||
**`bundle_fixed`** -- When `True`, parts connected by `Fixed` joints should be merged into single rigid bodies before solving, reducing the problem size. If the solver reports `supports_bundle_fixed() == True`, it handles this internally. Otherwise, the caller (adapter layer) pre-bundles before building the context.
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
All other methods are optional and have default implementations. Override them to add capabilities beyond basic solving.
|
||||
|
||||
#### update(ctx) -> SolveResult
|
||||
|
||||
Incrementally re-solve after parameter changes (e.g. joint angle adjusted during creation). Solvers can optimize this path since only parameters changed, not topology. Default: delegates to `solve()`.
|
||||
|
||||
```python
|
||||
def update(self, ctx):
|
||||
# Only re-evaluate changed constraints, reuse cached factorization
|
||||
return self._incremental_solve(ctx)
|
||||
```
|
||||
|
||||
#### Interactive drag protocol
|
||||
|
||||
Three-phase protocol for interactive part dragging in the viewport. Solvers can maintain internal state across the drag session for better performance.
|
||||
|
||||
**pre_drag(ctx, drag_parts) -> SolveResult** -- Prepare for a drag session. `drag_parts` is a `list[str]` of part IDs being dragged. Solve the initial state and cache internal data. Default: delegates to `solve()`.
|
||||
|
||||
**drag_step(drag_placements) -> SolveResult** -- Called on each mouse move. `drag_placements` is a `list[SolveResult.PartResult]` with the current positions of dragged parts. Returns updated placements for all affected parts. Default: returns Success with no placements.
|
||||
|
||||
**post_drag()** -- End the drag session and release internal state. Default: no-op.
|
||||
|
||||
```python
|
||||
def pre_drag(self, ctx, drag_parts):
|
||||
self._cached_system = self._build_system(ctx)
|
||||
return self.solve(ctx)
|
||||
|
||||
def drag_step(self, drag_placements):
|
||||
# Use cached system for fast incremental solve
|
||||
for dp in drag_placements:
|
||||
self._cached_system.set_placement(dp.id, dp.placement)
|
||||
return self._cached_system.solve_incremental()
|
||||
|
||||
def post_drag(self):
|
||||
self._cached_system = None
|
||||
```
|
||||
|
||||
#### Kinematic simulation
|
||||
|
||||
**run_kinematic(ctx) -> SolveResult** -- Run a kinematic simulation over the time range in `ctx.simulation`. After this call, `num_frames()` returns the frame count and `update_for_frame(i)` retrieves individual frames. Requires `ctx.simulation` to be set and `ctx.motions` to contain at least one motion driver. Default: returns Failed.
|
||||
|
||||
**num_frames() -> int** -- Number of simulation frames available after `run_kinematic()`. Default: returns 0.
|
||||
|
||||
**update_for_frame(index) -> SolveResult** -- Retrieve part placements for simulation frame at `index` (0-based, must be < `num_frames()`). Default: returns Failed.
|
||||
|
||||
```python
|
||||
# Run a kinematic simulation
|
||||
ctx.simulation = kcsolve.SimulationParams()
|
||||
ctx.simulation.t_start = 0.0
|
||||
ctx.simulation.t_end = 2.0
|
||||
ctx.simulation.h_out = 0.04 # 25 fps
|
||||
|
||||
motion = kcsolve.MotionDef()
|
||||
motion.kind = kcsolve.MotionKind.Rotational
|
||||
motion.joint_id = "Joint001"
|
||||
motion.rotation_expr = "2*pi*t" # one revolution per second
|
||||
ctx.motions = [motion]
|
||||
|
||||
solver = kcsolve.load("ondsel")
|
||||
result = solver.run_kinematic(ctx)
|
||||
|
||||
for i in range(solver.num_frames()):
|
||||
frame = solver.update_for_frame(i)
|
||||
for pr in frame.placements:
|
||||
print(f"frame {i}: {pr.id} at {list(pr.placement.position)}")
|
||||
```
|
||||
|
||||
#### diagnose(ctx) -> list[ConstraintDiagnostic]
|
||||
|
||||
Analyze the assembly for redundant, conflicting, or malformed constraints. May require a prior `solve()` call for some solvers. Returns a list of `ConstraintDiagnostic` objects. Default: returns empty list.
|
||||
|
||||
```python
|
||||
diags = solver.diagnose(ctx)
|
||||
for d in diags:
|
||||
if d.kind == kcsolve.DiagnosticKind.Redundant:
|
||||
print(f"Redundant: {d.constraint_id} - {d.detail}")
|
||||
elif d.kind == kcsolve.DiagnosticKind.Conflicting:
|
||||
print(f"Conflict: {d.constraint_id} - {d.detail}")
|
||||
```
|
||||
|
||||
#### is_deterministic() -> bool
|
||||
|
||||
Whether this solver produces identical results given identical input. Used for regression testing and result caching. Default: returns `True`.
|
||||
|
||||
#### export_native(path)
|
||||
|
||||
Write a solver-native debug/diagnostic file (e.g. ASMT format for OndselSolver). Requires a prior `solve()` or `run_kinematic()` call. Default: no-op.
|
||||
|
||||
```python
|
||||
solver.solve(ctx)
|
||||
solver.export_native("/tmp/debug.asmt")
|
||||
```
|
||||
|
||||
#### supports_bundle_fixed() -> bool
|
||||
|
||||
Whether this solver handles Fixed-joint part bundling internally. When `False`, the caller merges Fixed-joint-connected parts into single rigid bodies before building the `SolveContext`, reducing problem size. When `True`, the solver receives unbundled parts and optimizes internally. Default: 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
|
||||
237
src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
Normal file
237
src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
# * FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
# * under the terms of the GNU Lesser General Public License as *
|
||||
# * published by the Free Software Foundation, either version 2.1 of the *
|
||||
# * License, or (at your option) any later version. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, but *
|
||||
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
# * Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Lesser General Public *
|
||||
# * License along with FreeCAD. If not, see *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""Unit tests for the kcsolve pybind11 module."""
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestKCSolveImport(unittest.TestCase):
|
||||
"""Verify that the kcsolve module loads and exposes expected symbols."""
|
||||
|
||||
def test_import(self):
|
||||
import kcsolve
|
||||
|
||||
for sym in (
|
||||
"IKCSolver",
|
||||
"OndselAdapter",
|
||||
"Transform",
|
||||
"Part",
|
||||
"Constraint",
|
||||
"SolveContext",
|
||||
"SolveResult",
|
||||
"BaseJointKind",
|
||||
"SolveStatus",
|
||||
"available",
|
||||
"load",
|
||||
"register_solver",
|
||||
):
|
||||
self.assertTrue(hasattr(kcsolve, sym), f"missing symbol: {sym}")
|
||||
|
||||
def test_api_version(self):
|
||||
import kcsolve
|
||||
|
||||
self.assertEqual(kcsolve.API_VERSION_MAJOR, 1)
|
||||
|
||||
|
||||
class TestKCSolveTypes(unittest.TestCase):
|
||||
"""Verify struct/enum bindings behave correctly."""
|
||||
|
||||
def test_transform_identity(self):
|
||||
import kcsolve
|
||||
|
||||
t = kcsolve.Transform.identity()
|
||||
self.assertEqual(list(t.position), [0.0, 0.0, 0.0])
|
||||
self.assertEqual(list(t.quaternion), [1.0, 0.0, 0.0, 0.0]) # w,x,y,z
|
||||
|
||||
def test_part_defaults(self):
|
||||
import kcsolve
|
||||
|
||||
p = kcsolve.Part()
|
||||
self.assertEqual(p.id, "")
|
||||
self.assertAlmostEqual(p.mass, 1.0)
|
||||
self.assertFalse(p.grounded)
|
||||
|
||||
def test_solve_context_construction(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
self.assertEqual(len(ctx.parts), 0)
|
||||
self.assertEqual(len(ctx.constraints), 0)
|
||||
|
||||
p = kcsolve.Part()
|
||||
p.id = "part1"
|
||||
# pybind11 def_readwrite on std::vector returns a copy,
|
||||
# so we must assign the whole list back.
|
||||
ctx.parts = [p]
|
||||
self.assertEqual(len(ctx.parts), 1)
|
||||
self.assertEqual(ctx.parts[0].id, "part1")
|
||||
|
||||
def test_enum_values(self):
|
||||
import kcsolve
|
||||
|
||||
self.assertEqual(int(kcsolve.SolveStatus.Success), 0)
|
||||
# BaseJointKind.Fixed should exist
|
||||
self.assertIsNotNone(kcsolve.BaseJointKind.Fixed)
|
||||
# DiagnosticKind should exist
|
||||
self.assertIsNotNone(kcsolve.DiagnosticKind.Redundant)
|
||||
|
||||
def test_constraint_fields(self):
|
||||
import kcsolve
|
||||
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "Joint001"
|
||||
c.part_i = "part1"
|
||||
c.part_j = "part2"
|
||||
c.type = kcsolve.BaseJointKind.Fixed
|
||||
self.assertEqual(c.id, "Joint001")
|
||||
self.assertEqual(c.type, kcsolve.BaseJointKind.Fixed)
|
||||
|
||||
def test_solve_result_fields(self):
|
||||
import kcsolve
|
||||
|
||||
r = kcsolve.SolveResult()
|
||||
self.assertEqual(r.status, kcsolve.SolveStatus.Success)
|
||||
self.assertEqual(r.dof, -1)
|
||||
self.assertEqual(len(r.placements), 0)
|
||||
|
||||
|
||||
class TestKCSolveRegistry(unittest.TestCase):
|
||||
"""Verify SolverRegistry wrapper functions."""
|
||||
|
||||
def test_available_returns_list(self):
|
||||
import kcsolve
|
||||
|
||||
result = kcsolve.available()
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_load_ondsel(self):
|
||||
import kcsolve
|
||||
|
||||
solver = kcsolve.load("ondsel")
|
||||
# Ondsel should be registered by FreeCAD init
|
||||
if solver is not None:
|
||||
self.assertIn("Ondsel", solver.name())
|
||||
|
||||
def test_load_unknown_returns_none(self):
|
||||
import kcsolve
|
||||
|
||||
solver = kcsolve.load("nonexistent_solver_xyz")
|
||||
self.assertIsNone(solver)
|
||||
|
||||
def test_get_set_default(self):
|
||||
import kcsolve
|
||||
|
||||
original = kcsolve.get_default()
|
||||
# Setting unknown solver should return False
|
||||
self.assertFalse(kcsolve.set_default("nonexistent_solver_xyz"))
|
||||
# Default should be unchanged
|
||||
self.assertEqual(kcsolve.get_default(), original)
|
||||
|
||||
|
||||
class TestPySolver(unittest.TestCase):
|
||||
"""Verify Python IKCSolver subclassing and registration."""
|
||||
|
||||
def _make_solver_class(self):
|
||||
import kcsolve
|
||||
|
||||
class _DummySolver(kcsolve.IKCSolver):
|
||||
def name(self):
|
||||
return "DummyPySolver"
|
||||
|
||||
def supported_joints(self):
|
||||
return [
|
||||
kcsolve.BaseJointKind.Fixed,
|
||||
kcsolve.BaseJointKind.Revolute,
|
||||
]
|
||||
|
||||
def solve(self, ctx):
|
||||
r = kcsolve.SolveResult()
|
||||
r.status = kcsolve.SolveStatus.Success
|
||||
parts = ctx.parts # copy from C++ vector
|
||||
r.dof = len(parts) * 6
|
||||
placements = []
|
||||
for p in parts:
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = p.id
|
||||
pr.placement = p.placement
|
||||
placements.append(pr)
|
||||
r.placements = placements
|
||||
return r
|
||||
|
||||
return _DummySolver
|
||||
|
||||
def test_instantiate_python_solver(self):
|
||||
cls = self._make_solver_class()
|
||||
solver = cls()
|
||||
self.assertEqual(solver.name(), "DummyPySolver")
|
||||
self.assertEqual(len(solver.supported_joints()), 2)
|
||||
|
||||
def test_python_solver_solve(self):
|
||||
import kcsolve
|
||||
|
||||
cls = self._make_solver_class()
|
||||
solver = cls()
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
p = kcsolve.Part()
|
||||
p.id = "box1"
|
||||
p.grounded = True
|
||||
ctx.parts = [p]
|
||||
|
||||
result = solver.solve(ctx)
|
||||
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
|
||||
self.assertEqual(result.dof, 6)
|
||||
self.assertEqual(len(result.placements), 1)
|
||||
self.assertEqual(result.placements[0].id, "box1")
|
||||
|
||||
def test_register_and_roundtrip(self):
|
||||
import kcsolve
|
||||
|
||||
cls = self._make_solver_class()
|
||||
# Use a unique name to avoid collision across test runs
|
||||
name = "test_dummy_roundtrip"
|
||||
kcsolve.register_solver(name, cls)
|
||||
|
||||
self.assertIn(name, kcsolve.available())
|
||||
|
||||
loaded = kcsolve.load(name)
|
||||
self.assertIsNotNone(loaded)
|
||||
self.assertEqual(loaded.name(), "DummyPySolver")
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
result = loaded.solve(ctx)
|
||||
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
|
||||
|
||||
def test_default_virtuals(self):
|
||||
"""Default implementations of optional virtuals should not crash."""
|
||||
import kcsolve
|
||||
|
||||
cls = self._make_solver_class()
|
||||
solver = cls()
|
||||
self.assertTrue(solver.is_deterministic())
|
||||
self.assertFalse(solver.supports_bundle_fixed())
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
diags = solver.diagnose(ctx)
|
||||
self.assertEqual(len(diags), 0)
|
||||
@@ -58,6 +58,7 @@ SET(AssemblyTests_SRCS
|
||||
AssemblyTests/TestCore.py
|
||||
AssemblyTests/TestCommandInsertLink.py
|
||||
AssemblyTests/TestSolverIntegration.py
|
||||
AssemblyTests/TestKCSolvePy.py
|
||||
AssemblyTests/mocks/__init__.py
|
||||
AssemblyTests/mocks/MockGui.py
|
||||
)
|
||||
|
||||
@@ -40,3 +40,7 @@ endif()
|
||||
|
||||
SET_BIN_DIR(KCSolve KCSolve /Mod/Assembly)
|
||||
INSTALL(TARGETS KCSolve DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
|
||||
if(FREECAD_USE_PYBIND11)
|
||||
add_subdirectory(bindings)
|
||||
endif()
|
||||
|
||||
@@ -172,9 +172,11 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
// Public default constructor for pybind11 trampoline support.
|
||||
// The class remains abstract (3 pure virtuals prevent direct instantiation).
|
||||
IKCSolver() = default;
|
||||
|
||||
private:
|
||||
// Non-copyable, non-movable (polymorphic base class)
|
||||
IKCSolver(const IKCSolver&) = delete;
|
||||
IKCSolver& operator=(const IKCSolver&) = delete;
|
||||
|
||||
31
src/Mod/Assembly/Solver/bindings/CMakeLists.txt
Normal file
31
src/Mod/Assembly/Solver/bindings/CMakeLists.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
set(KCSolvePy_SRCS
|
||||
PyIKCSolver.h
|
||||
kcsolve_py.cpp
|
||||
)
|
||||
|
||||
add_library(kcsolve_py SHARED ${KCSolvePy_SRCS})
|
||||
|
||||
target_include_directories(kcsolve_py
|
||||
PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_BINARY_DIR}/src
|
||||
${pybind11_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(kcsolve_py
|
||||
PRIVATE
|
||||
pybind11::module
|
||||
Python3::Python
|
||||
KCSolve
|
||||
)
|
||||
|
||||
if(FREECAD_WARN_ERROR)
|
||||
target_compile_warn_error(kcsolve_py)
|
||||
endif()
|
||||
|
||||
SET_BIN_DIR(kcsolve_py kcsolve /Mod/Assembly)
|
||||
SET_PYTHON_PREFIX_SUFFIX(kcsolve_py)
|
||||
|
||||
INSTALL(TARGETS kcsolve_py DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
121
src/Mod/Assembly/Solver/bindings/PyIKCSolver.h
Normal file
121
src/Mod/Assembly/Solver/bindings/PyIKCSolver.h
Normal file
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
/****************************************************************************
|
||||
* *
|
||||
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
|
||||
* *
|
||||
* This file is part of FreeCAD. *
|
||||
* *
|
||||
* FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
* under the terms of the GNU Lesser General Public License as *
|
||||
* published by the Free Software Foundation, either version 2.1 of the *
|
||||
* License, or (at your option) any later version. *
|
||||
* *
|
||||
* FreeCAD is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with FreeCAD. If not, see *
|
||||
* <https://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef KCSOLVE_PYIKCSOLVER_H
|
||||
#define KCSOLVE_PYIKCSOLVER_H
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <Mod/Assembly/Solver/IKCSolver.h>
|
||||
|
||||
namespace KCSolve
|
||||
{
|
||||
|
||||
/// pybind11 trampoline class for IKCSolver.
|
||||
/// Enables Python subclasses that override virtual methods.
|
||||
class PyIKCSolver : public IKCSolver
|
||||
{
|
||||
public:
|
||||
using IKCSolver::IKCSolver;
|
||||
|
||||
// ── Pure virtuals ──────────────────────────────────────────────
|
||||
|
||||
std::string name() const override
|
||||
{
|
||||
PYBIND11_OVERRIDE_PURE(std::string, IKCSolver, name);
|
||||
}
|
||||
|
||||
std::vector<BaseJointKind> supported_joints() const override
|
||||
{
|
||||
PYBIND11_OVERRIDE_PURE(std::vector<BaseJointKind>, IKCSolver, supported_joints);
|
||||
}
|
||||
|
||||
SolveResult solve(const SolveContext& ctx) override
|
||||
{
|
||||
PYBIND11_OVERRIDE_PURE(SolveResult, IKCSolver, solve, ctx);
|
||||
}
|
||||
|
||||
// ── Virtuals with defaults ─────────────────────────────────────
|
||||
|
||||
SolveResult update(const SolveContext& ctx) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update, ctx);
|
||||
}
|
||||
|
||||
SolveResult pre_drag(const SolveContext& ctx,
|
||||
const std::vector<std::string>& drag_parts) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(SolveResult, IKCSolver, pre_drag, ctx, drag_parts);
|
||||
}
|
||||
|
||||
SolveResult drag_step(
|
||||
const std::vector<SolveResult::PartResult>& drag_placements) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(SolveResult, IKCSolver, drag_step, drag_placements);
|
||||
}
|
||||
|
||||
void post_drag() override
|
||||
{
|
||||
PYBIND11_OVERRIDE(void, IKCSolver, post_drag);
|
||||
}
|
||||
|
||||
SolveResult run_kinematic(const SolveContext& ctx) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(SolveResult, IKCSolver, run_kinematic, ctx);
|
||||
}
|
||||
|
||||
std::size_t num_frames() const override
|
||||
{
|
||||
PYBIND11_OVERRIDE(std::size_t, IKCSolver, num_frames);
|
||||
}
|
||||
|
||||
SolveResult update_for_frame(std::size_t index) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update_for_frame, index);
|
||||
}
|
||||
|
||||
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(std::vector<ConstraintDiagnostic>, IKCSolver, diagnose, ctx);
|
||||
}
|
||||
|
||||
bool is_deterministic() const override
|
||||
{
|
||||
PYBIND11_OVERRIDE(bool, IKCSolver, is_deterministic);
|
||||
}
|
||||
|
||||
void export_native(const std::string& path) override
|
||||
{
|
||||
PYBIND11_OVERRIDE(void, IKCSolver, export_native, path);
|
||||
}
|
||||
|
||||
bool supports_bundle_fixed() const override
|
||||
{
|
||||
PYBIND11_OVERRIDE(bool, IKCSolver, supports_bundle_fixed);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace KCSolve
|
||||
|
||||
#endif // KCSOLVE_PYIKCSOLVER_H
|
||||
359
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp
Normal file
359
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp
Normal file
@@ -0,0 +1,359 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
/****************************************************************************
|
||||
* *
|
||||
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
|
||||
* *
|
||||
* This file is part of FreeCAD. *
|
||||
* *
|
||||
* FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
* under the terms of the GNU Lesser General Public License as *
|
||||
* published by the Free Software Foundation, either version 2.1 of the *
|
||||
* License, or (at your option) any later version. *
|
||||
* *
|
||||
* FreeCAD is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with FreeCAD. If not, see *
|
||||
* <https://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
***************************************************************************/
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <Mod/Assembly/Solver/IKCSolver.h>
|
||||
#include <Mod/Assembly/Solver/OndselAdapter.h>
|
||||
#include <Mod/Assembly/Solver/SolverRegistry.h>
|
||||
#include <Mod/Assembly/Solver/Types.h>
|
||||
|
||||
#include "PyIKCSolver.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace KCSolve;
|
||||
|
||||
|
||||
// ── PySolverHolder ─────────────────────────────────────────────────
|
||||
//
|
||||
// Wraps a Python IKCSolver subclass instance so it can live inside a
|
||||
// std::unique_ptr<IKCSolver> returned by SolverRegistry::get().
|
||||
// Prevents Python GC by holding a py::object reference and acquires
|
||||
// the GIL before every forwarded call.
|
||||
|
||||
class PySolverHolder : public IKCSolver
|
||||
{
|
||||
public:
|
||||
explicit PySolverHolder(py::object obj)
|
||||
: obj_(std::move(obj))
|
||||
{
|
||||
solver_ = obj_.cast<IKCSolver*>();
|
||||
}
|
||||
|
||||
std::string name() const override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->name();
|
||||
}
|
||||
|
||||
std::vector<BaseJointKind> supported_joints() const override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->supported_joints();
|
||||
}
|
||||
|
||||
SolveResult solve(const SolveContext& ctx) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->solve(ctx);
|
||||
}
|
||||
|
||||
SolveResult update(const SolveContext& ctx) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->update(ctx);
|
||||
}
|
||||
|
||||
SolveResult pre_drag(const SolveContext& ctx,
|
||||
const std::vector<std::string>& drag_parts) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->pre_drag(ctx, drag_parts);
|
||||
}
|
||||
|
||||
SolveResult drag_step(
|
||||
const std::vector<SolveResult::PartResult>& drag_placements) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->drag_step(drag_placements);
|
||||
}
|
||||
|
||||
void post_drag() override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
solver_->post_drag();
|
||||
}
|
||||
|
||||
SolveResult run_kinematic(const SolveContext& ctx) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->run_kinematic(ctx);
|
||||
}
|
||||
|
||||
std::size_t num_frames() const override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->num_frames();
|
||||
}
|
||||
|
||||
SolveResult update_for_frame(std::size_t index) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->update_for_frame(index);
|
||||
}
|
||||
|
||||
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->diagnose(ctx);
|
||||
}
|
||||
|
||||
bool is_deterministic() const override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->is_deterministic();
|
||||
}
|
||||
|
||||
void export_native(const std::string& path) override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
solver_->export_native(path);
|
||||
}
|
||||
|
||||
bool supports_bundle_fixed() const override
|
||||
{
|
||||
py::gil_scoped_acquire gil;
|
||||
return solver_->supports_bundle_fixed();
|
||||
}
|
||||
|
||||
private:
|
||||
py::object obj_; // prevents Python GC
|
||||
IKCSolver* solver_; // raw pointer into the trampoline inside obj_
|
||||
};
|
||||
|
||||
|
||||
// ── Module definition ──────────────────────────────────────────────
|
||||
|
||||
PYBIND11_MODULE(kcsolve, m)
|
||||
{
|
||||
m.doc() = "KCSolve — pluggable assembly constraint solver API";
|
||||
m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR;
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────
|
||||
|
||||
py::enum_<BaseJointKind>(m, "BaseJointKind")
|
||||
.value("Coincident", BaseJointKind::Coincident)
|
||||
.value("PointOnLine", BaseJointKind::PointOnLine)
|
||||
.value("PointInPlane", BaseJointKind::PointInPlane)
|
||||
.value("Concentric", BaseJointKind::Concentric)
|
||||
.value("Tangent", BaseJointKind::Tangent)
|
||||
.value("Planar", BaseJointKind::Planar)
|
||||
.value("LineInPlane", BaseJointKind::LineInPlane)
|
||||
.value("Parallel", BaseJointKind::Parallel)
|
||||
.value("Perpendicular", BaseJointKind::Perpendicular)
|
||||
.value("Angle", BaseJointKind::Angle)
|
||||
.value("Fixed", BaseJointKind::Fixed)
|
||||
.value("Revolute", BaseJointKind::Revolute)
|
||||
.value("Cylindrical", BaseJointKind::Cylindrical)
|
||||
.value("Slider", BaseJointKind::Slider)
|
||||
.value("Ball", BaseJointKind::Ball)
|
||||
.value("Screw", BaseJointKind::Screw)
|
||||
.value("Universal", BaseJointKind::Universal)
|
||||
.value("Gear", BaseJointKind::Gear)
|
||||
.value("RackPinion", BaseJointKind::RackPinion)
|
||||
.value("Cam", BaseJointKind::Cam)
|
||||
.value("Slot", BaseJointKind::Slot)
|
||||
.value("DistancePointPoint", BaseJointKind::DistancePointPoint)
|
||||
.value("DistanceCylSph", BaseJointKind::DistanceCylSph)
|
||||
.value("Custom", BaseJointKind::Custom);
|
||||
|
||||
py::enum_<SolveStatus>(m, "SolveStatus")
|
||||
.value("Success", SolveStatus::Success)
|
||||
.value("Failed", SolveStatus::Failed)
|
||||
.value("InvalidFlip", SolveStatus::InvalidFlip)
|
||||
.value("NoGroundedParts", SolveStatus::NoGroundedParts);
|
||||
|
||||
py::enum_<ConstraintDiagnostic::Kind>(m, "DiagnosticKind")
|
||||
.value("Redundant", ConstraintDiagnostic::Kind::Redundant)
|
||||
.value("Conflicting", ConstraintDiagnostic::Kind::Conflicting)
|
||||
.value("PartiallyRedundant", ConstraintDiagnostic::Kind::PartiallyRedundant)
|
||||
.value("Malformed", ConstraintDiagnostic::Kind::Malformed);
|
||||
|
||||
py::enum_<MotionDef::Kind>(m, "MotionKind")
|
||||
.value("Rotational", MotionDef::Kind::Rotational)
|
||||
.value("Translational", MotionDef::Kind::Translational)
|
||||
.value("General", MotionDef::Kind::General);
|
||||
|
||||
py::enum_<Constraint::Limit::Kind>(m, "LimitKind")
|
||||
.value("TranslationMin", Constraint::Limit::Kind::TranslationMin)
|
||||
.value("TranslationMax", Constraint::Limit::Kind::TranslationMax)
|
||||
.value("RotationMin", Constraint::Limit::Kind::RotationMin)
|
||||
.value("RotationMax", Constraint::Limit::Kind::RotationMax);
|
||||
|
||||
// ── Struct bindings ────────────────────────────────────────────
|
||||
|
||||
py::class_<Transform>(m, "Transform")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("position", &Transform::position)
|
||||
.def_readwrite("quaternion", &Transform::quaternion)
|
||||
.def_static("identity", &Transform::identity)
|
||||
.def("__repr__", [](const Transform& t) {
|
||||
return "<kcsolve.Transform pos=["
|
||||
+ std::to_string(t.position[0]) + ", "
|
||||
+ std::to_string(t.position[1]) + ", "
|
||||
+ std::to_string(t.position[2]) + "]>";
|
||||
});
|
||||
|
||||
py::class_<Part>(m, "Part")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("id", &Part::id)
|
||||
.def_readwrite("placement", &Part::placement)
|
||||
.def_readwrite("mass", &Part::mass)
|
||||
.def_readwrite("grounded", &Part::grounded);
|
||||
|
||||
auto constraint_class = py::class_<Constraint>(m, "Constraint");
|
||||
|
||||
py::class_<Constraint::Limit>(constraint_class, "Limit")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("kind", &Constraint::Limit::kind)
|
||||
.def_readwrite("value", &Constraint::Limit::value)
|
||||
.def_readwrite("tolerance", &Constraint::Limit::tolerance);
|
||||
|
||||
constraint_class
|
||||
.def(py::init<>())
|
||||
.def_readwrite("id", &Constraint::id)
|
||||
.def_readwrite("part_i", &Constraint::part_i)
|
||||
.def_readwrite("marker_i", &Constraint::marker_i)
|
||||
.def_readwrite("part_j", &Constraint::part_j)
|
||||
.def_readwrite("marker_j", &Constraint::marker_j)
|
||||
.def_readwrite("type", &Constraint::type)
|
||||
.def_readwrite("params", &Constraint::params)
|
||||
.def_readwrite("limits", &Constraint::limits)
|
||||
.def_readwrite("activated", &Constraint::activated);
|
||||
|
||||
py::class_<MotionDef>(m, "MotionDef")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("kind", &MotionDef::kind)
|
||||
.def_readwrite("joint_id", &MotionDef::joint_id)
|
||||
.def_readwrite("marker_i", &MotionDef::marker_i)
|
||||
.def_readwrite("marker_j", &MotionDef::marker_j)
|
||||
.def_readwrite("rotation_expr", &MotionDef::rotation_expr)
|
||||
.def_readwrite("translation_expr", &MotionDef::translation_expr);
|
||||
|
||||
py::class_<SimulationParams>(m, "SimulationParams")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("t_start", &SimulationParams::t_start)
|
||||
.def_readwrite("t_end", &SimulationParams::t_end)
|
||||
.def_readwrite("h_out", &SimulationParams::h_out)
|
||||
.def_readwrite("h_min", &SimulationParams::h_min)
|
||||
.def_readwrite("h_max", &SimulationParams::h_max)
|
||||
.def_readwrite("error_tol", &SimulationParams::error_tol);
|
||||
|
||||
py::class_<SolveContext>(m, "SolveContext")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("parts", &SolveContext::parts)
|
||||
.def_readwrite("constraints", &SolveContext::constraints)
|
||||
.def_readwrite("motions", &SolveContext::motions)
|
||||
.def_readwrite("simulation", &SolveContext::simulation)
|
||||
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed);
|
||||
|
||||
py::class_<ConstraintDiagnostic>(m, "ConstraintDiagnostic")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id)
|
||||
.def_readwrite("kind", &ConstraintDiagnostic::kind)
|
||||
.def_readwrite("detail", &ConstraintDiagnostic::detail);
|
||||
|
||||
auto result_class = py::class_<SolveResult>(m, "SolveResult");
|
||||
|
||||
py::class_<SolveResult::PartResult>(result_class, "PartResult")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("id", &SolveResult::PartResult::id)
|
||||
.def_readwrite("placement", &SolveResult::PartResult::placement);
|
||||
|
||||
result_class
|
||||
.def(py::init<>())
|
||||
.def_readwrite("status", &SolveResult::status)
|
||||
.def_readwrite("placements", &SolveResult::placements)
|
||||
.def_readwrite("dof", &SolveResult::dof)
|
||||
.def_readwrite("diagnostics", &SolveResult::diagnostics)
|
||||
.def_readwrite("num_frames", &SolveResult::num_frames);
|
||||
|
||||
// ── IKCSolver (with trampoline for Python subclassing) ─────────
|
||||
|
||||
py::class_<IKCSolver, PyIKCSolver>(m, "IKCSolver")
|
||||
.def(py::init<>())
|
||||
.def("name", &IKCSolver::name)
|
||||
.def("supported_joints", &IKCSolver::supported_joints)
|
||||
.def("solve", &IKCSolver::solve, py::arg("ctx"))
|
||||
.def("update", &IKCSolver::update, py::arg("ctx"))
|
||||
.def("pre_drag", &IKCSolver::pre_drag,
|
||||
py::arg("ctx"), py::arg("drag_parts"))
|
||||
.def("drag_step", &IKCSolver::drag_step,
|
||||
py::arg("drag_placements"))
|
||||
.def("post_drag", &IKCSolver::post_drag)
|
||||
.def("run_kinematic", &IKCSolver::run_kinematic, py::arg("ctx"))
|
||||
.def("num_frames", &IKCSolver::num_frames)
|
||||
.def("update_for_frame", &IKCSolver::update_for_frame,
|
||||
py::arg("index"))
|
||||
.def("diagnose", &IKCSolver::diagnose, py::arg("ctx"))
|
||||
.def("is_deterministic", &IKCSolver::is_deterministic)
|
||||
.def("export_native", &IKCSolver::export_native, py::arg("path"))
|
||||
.def("supports_bundle_fixed", &IKCSolver::supports_bundle_fixed);
|
||||
|
||||
// ── OndselAdapter ──────────────────────────────────────────────
|
||||
|
||||
py::class_<OndselAdapter, IKCSolver>(m, "OndselAdapter")
|
||||
.def(py::init<>());
|
||||
|
||||
// ── Module-level functions (SolverRegistry wrapper) ────────────
|
||||
|
||||
m.def("available", []() {
|
||||
return SolverRegistry::instance().available();
|
||||
}, "Return names of all registered solvers.");
|
||||
|
||||
m.def("load", [](const std::string& name) {
|
||||
return SolverRegistry::instance().get(name);
|
||||
}, py::arg("name") = "",
|
||||
"Create an instance of the named solver (default if empty).\n"
|
||||
"Returns None if the solver is not found.");
|
||||
|
||||
m.def("joints_for", [](const std::string& name) {
|
||||
return SolverRegistry::instance().joints_for(name);
|
||||
}, py::arg("name"),
|
||||
"Query supported joint types for the named solver.");
|
||||
|
||||
m.def("set_default", [](const std::string& name) {
|
||||
return SolverRegistry::instance().set_default(name);
|
||||
}, py::arg("name"),
|
||||
"Set the default solver name. Returns True if the name is registered.");
|
||||
|
||||
m.def("get_default", []() {
|
||||
return SolverRegistry::instance().get_default();
|
||||
}, "Get the current default solver name.");
|
||||
|
||||
m.def("register_solver", [](const std::string& name, py::object py_solver_class) {
|
||||
auto cls = std::make_shared<py::object>(std::move(py_solver_class));
|
||||
CreateSolverFn factory = [cls]() -> std::unique_ptr<IKCSolver> {
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object instance = (*cls)();
|
||||
return std::make_unique<PySolverHolder>(std::move(instance));
|
||||
};
|
||||
return SolverRegistry::instance().register_solver(name, std::move(factory));
|
||||
}, py::arg("name"), py::arg("solver_class"),
|
||||
"Register a Python solver class with the SolverRegistry.\n"
|
||||
"solver_class must be a callable that returns an IKCSolver subclass.");
|
||||
}
|
||||
@@ -24,6 +24,12 @@
|
||||
import TestApp
|
||||
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
|
||||
from AssemblyTests.TestCore import TestCore
|
||||
from AssemblyTests.TestKCSolvePy import (
|
||||
TestKCSolveImport, # noqa: F401
|
||||
TestKCSolveRegistry, # noqa: F401
|
||||
TestKCSolveTypes, # noqa: F401
|
||||
TestPySolver, # noqa: F401
|
||||
)
|
||||
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
|
||||
|
||||
# Use the modules so that code checkers don't complain (flake8)
|
||||
|
||||
Reference in New Issue
Block a user