# 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