diff --git a/docs/INTER_SOLVER.md b/docs/INTER_SOLVER.md new file mode 100644 index 0000000000..2675724ba6 --- /dev/null +++ b/docs/INTER_SOLVER.md @@ -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 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 refs_a; + std::vector refs_b; + std::map params; // param_name -> value + bool suppressed = false; +}; + +// Input to a solve operation +struct SolveContext { + std::vector constraints; + // Part placements as 4x4 transforms (initial guess) + std::map> placements; + // Which parts are grounded (fixed) + std::set 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> 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> placements; + std::vector 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 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 solver); + + // Lookup + IKCSolver* get(const std::string& solver_id) const; + std::vector available() const; + + // Joint type resolution: find which solvers support a given base kind + std::vector 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. `/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: +- `/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 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 66b437e86c..e4a1c93819 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/architecture/ondsel-solver.md b/docs/src/architecture/ondsel-solver.md index 736bf9650c..96e6038c46 100644 --- a/docs/src/architecture/ondsel-solver.md +++ b/docs/src/architecture/ondsel-solver.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`. -## 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(); +}); + +// 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 `/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 diff --git a/docs/src/reference/kcsolve-python.md b/docs/src/reference/kcsolve-python.md new file mode 100644 index 0000000000..2a75739813 --- /dev/null +++ b/docs/src/reference/kcsolve-python.md @@ -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 diff --git a/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py new file mode 100644 index 0000000000..6d8406eae8 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Kindred Systems * +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +"""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) diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index 0505b216fa..b8ceb8a5f5 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -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 ) diff --git a/src/Mod/Assembly/Solver/CMakeLists.txt b/src/Mod/Assembly/Solver/CMakeLists.txt index 206ff7d1f4..2228f79c23 100644 --- a/src/Mod/Assembly/Solver/CMakeLists.txt +++ b/src/Mod/Assembly/Solver/CMakeLists.txt @@ -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() diff --git a/src/Mod/Assembly/Solver/IKCSolver.h b/src/Mod/Assembly/Solver/IKCSolver.h index 47abc5af01..4a404d134b 100644 --- a/src/Mod/Assembly/Solver/IKCSolver.h +++ b/src/Mod/Assembly/Solver/IKCSolver.h @@ -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; diff --git a/src/Mod/Assembly/Solver/bindings/CMakeLists.txt b/src/Mod/Assembly/Solver/bindings/CMakeLists.txt new file mode 100644 index 0000000000..0cde656f9d --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/CMakeLists.txt @@ -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}) diff --git a/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h b/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h new file mode 100644 index 0000000000..7d8151f49f --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/PyIKCSolver.h @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * 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 * + * . * + * * + ***************************************************************************/ + +#ifndef KCSOLVE_PYIKCSOLVER_H +#define KCSOLVE_PYIKCSOLVER_H + +#include +#include + +#include + +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 supported_joints() const override + { + PYBIND11_OVERRIDE_PURE(std::vector, 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& drag_parts) override + { + PYBIND11_OVERRIDE(SolveResult, IKCSolver, pre_drag, ctx, drag_parts); + } + + SolveResult drag_step( + const std::vector& 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 diagnose(const SolveContext& ctx) override + { + PYBIND11_OVERRIDE(std::vector, 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 diff --git a/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp new file mode 100644 index 0000000000..ddc125928b --- /dev/null +++ b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * 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 * + * . * + * * + ***************************************************************************/ + +#include +#include + +#include +#include +#include +#include + +#include "PyIKCSolver.h" + +#include +#include + +namespace py = pybind11; +using namespace KCSolve; + + +// ── PySolverHolder ───────────────────────────────────────────────── +// +// Wraps a Python IKCSolver subclass instance so it can live inside a +// std::unique_ptr 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(); + } + + std::string name() const override + { + py::gil_scoped_acquire gil; + return solver_->name(); + } + + std::vector 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& drag_parts) override + { + py::gil_scoped_acquire gil; + return solver_->pre_drag(ctx, drag_parts); + } + + SolveResult drag_step( + const std::vector& 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 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_(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_(m, "SolveStatus") + .value("Success", SolveStatus::Success) + .value("Failed", SolveStatus::Failed) + .value("InvalidFlip", SolveStatus::InvalidFlip) + .value("NoGroundedParts", SolveStatus::NoGroundedParts); + + py::enum_(m, "DiagnosticKind") + .value("Redundant", ConstraintDiagnostic::Kind::Redundant) + .value("Conflicting", ConstraintDiagnostic::Kind::Conflicting) + .value("PartiallyRedundant", ConstraintDiagnostic::Kind::PartiallyRedundant) + .value("Malformed", ConstraintDiagnostic::Kind::Malformed); + + py::enum_(m, "MotionKind") + .value("Rotational", MotionDef::Kind::Rotational) + .value("Translational", MotionDef::Kind::Translational) + .value("General", MotionDef::Kind::General); + + py::enum_(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_(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 ""; + }); + + py::class_(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_(m, "Constraint"); + + py::class_(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_(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_(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_(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_(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_(m, "SolveResult"); + + py::class_(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_(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_(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(std::move(py_solver_class)); + CreateSolverFn factory = [cls]() -> std::unique_ptr { + py::gil_scoped_acquire gil; + py::object instance = (*cls)(); + return std::make_unique(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."); +} diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index 387f4e4e14..87d83297fc 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -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)