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