- Replace OndselSolver architecture doc with KCSolve pluggable solver architecture covering IKCSolver interface, SolverRegistry, OndselAdapter, Python bindings, file layout, and testing - Add kcsolve Python API reference with full type documentation, module functions, usage examples, and pybind11 vector-copy caveat - Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2 marked as complete - Update SUMMARY.md with new page links
20 KiB
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
- Stable C++ API — A solver-agnostic interface that the Assembly module calls. Solvers are shared libraries loaded at runtime.
- 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.
- 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.).
- Semi-deterministic solving — Consistent results given consistent input ordering, with configurable tolerance and iteration limits.
- Server-compatible — Solvers run as detached processes claimed by
silorunnerworkers 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
namespace KCSolve {
// Unique identifier for a joint type within a solver
struct JointTypeId {
std::string solver_id; // e.g. "ondsel", "gnn", "relaxation"
std::string joint_name; // e.g. "coincident", "distance"
};
// Base joint categories (SOLIDWORKS-inspired vocabulary)
enum class BaseJointKind {
Coincident,
Concentric,
Tangent,
Distance,
Angle,
Lock,
Parallel,
Perpendicular,
PointOnLine,
SymmetricPlane,
Gear,
Rack,
Cam,
Slot,
Hinge,
Slider,
Cylindrical,
Planar,
Ball,
Screw,
Universal,
Custom // solver-specific extension
};
// A joint definition registered by a solver plugin
struct JointDef {
JointTypeId id;
BaseJointKind base_kind; // which vanilla category it maps to
std::string display_name;
std::string description;
uint32_t dof_removed; // degrees of freedom this joint removes
std::vector<std::string> params; // parameter names (e.g. "distance", "angle")
bool supports_limits = false;
bool supports_friction = false;
};
// A constraint instance in a solve problem
struct Constraint {
JointTypeId joint_type;
std::string part_a; // part label or id
std::string part_b;
// Geometry references (face, edge, vertex indices)
std::vector<std::string> refs_a;
std::vector<std::string> refs_b;
std::map<std::string, double> params; // param_name -> value
bool suppressed = false;
};
// Input to a solve operation
struct SolveContext {
std::vector<Constraint> constraints;
// Part placements as 4x4 transforms (initial guess)
std::map<std::string, std::array<double, 16>> placements;
// Which parts are grounded (fixed)
std::set<std::string> grounded;
// Solver config
double tolerance = 1e-10;
uint32_t max_iterations = 500;
bool deterministic = true; // force consistent ordering
// Optional: previous solution for warm-starting
std::map<std::string, std::array<double, 16>> warm_start;
};
enum class SolveStatus {
Converged,
MaxIterationsReached,
Overconstrained,
Underconstrained,
Redundant,
Failed
};
struct ConstraintDiagnostic {
std::string constraint_id;
double residual;
bool satisfied;
std::string message;
};
struct SolveResult {
SolveStatus status;
uint32_t iterations;
double final_residual;
double solve_time_ms;
std::map<std::string, std::array<double, 16>> placements;
std::vector<ConstraintDiagnostic> diagnostics;
// For semi-deterministic: hash of input ordering
uint64_t input_hash;
};
} // namespace KCSolve
4.2 Solver Interface
namespace KCSolve {
class IKCSolver {
public:
virtual ~IKCSolver() = default;
// Identity
virtual std::string id() const = 0;
virtual std::string name() const = 0;
virtual std::string version() const = 0;
// Joint type registry — called once at load
virtual std::vector<JointDef> supported_joints() const = 0;
// Solve
virtual SolveResult solve(const SolveContext& ctx) = 0;
// Incremental: update a single constraint without full re-solve
// Default impl falls back to full solve
virtual SolveResult update(const SolveContext& ctx,
const std::string& changed_constraint) {
return solve(ctx);
}
// Diagnostic: check if a constraint set is well-posed before solving
virtual SolveStatus diagnose(const SolveContext& ctx) {
return SolveStatus::Converged; // optimistic default
}
// Determinism: given identical input, produce identical output
virtual bool is_deterministic() const { return false; }
};
// Plugin entry point — each .so exports this symbol
using CreateSolverFn = IKCSolver* (*)();
} // namespace KCSolve
4.3 Solver Registry
namespace KCSolve {
class SolverRegistry {
public:
// Scan a directory for solver plugins (*.so / *.dll / *.dylib)
void scan(const std::filesystem::path& plugin_dir);
// Manual registration (for built-in solvers like Ondsel)
void register_solver(std::unique_ptr<IKCSolver> solver);
// Lookup
IKCSolver* get(const std::string& solver_id) const;
std::vector<std::string> available() const;
// Joint type resolution: find which solvers support a given base kind
std::vector<JointTypeId> joints_for(BaseJointKind kind) const;
// Global default solver
void set_default(const std::string& solver_id);
IKCSolver* get_default() const;
};
} // namespace KCSolve
4.4 Plugin Loading
Each solver plugin is a shared library exporting:
extern "C" KCSolve::IKCSolver* kcsolve_create();
extern "C" const char* kcsolve_api_version(); // "1.0"
The registry dlopens each library, checks kcsolve_api_version() compatibility, and calls kcsolve_create(). Plugins are discovered from:
<install_prefix>/lib/kcsolve/— system-installed solvers~/.config/KindredCreate/solvers/— user-installed solversKCSOLVE_PLUGIN_PATHenv 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
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:
# 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.):
class RelaxationSolver(kcsolve.PySolver):
"""A pure-Python iterative relaxation solver for prototyping."""
id = "relaxation"
name = "Iterative Relaxation"
version = "0.1.0"
def supported_joints(self):
return [
kcsolve.JointDef("coincident", kcsolve.BaseJointKind.Coincident, dof_removed=3),
kcsolve.JointDef("distance", kcsolve.BaseJointKind.Distance, dof_removed=2),
# ...
]
def solve(self, ctx: kcsolve.SolveContext) -> kcsolve.SolveResult:
placements = dict(ctx.placements)
for i in range(ctx.max_iterations):
max_residual = 0.0
for c in ctx.constraints:
residual = self._eval_constraint(c, placements)
correction = self._compute_correction(c, residual)
self._apply_correction(placements, c, correction)
max_residual = max(max_residual, abs(residual))
if max_residual < ctx.tolerance:
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.Converged,
iterations=i + 1,
final_residual=max_residual,
placements=placements
)
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.MaxIterationsReached,
iterations=ctx.max_iterations,
final_residual=max_residual,
placements=placements
)
# Register at runtime
kcsolve.register(RelaxationSolver())
Python solvers are discovered from:
<user_macros>/solvers/*.py— user-written solversmods/*/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:
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:
- Claims job from Silo server
- Downloads the assembly
.kcfile - Launches Headless Create (or standalone Python if pure-Python solver)
- Loads the assembly, extracts constraint graph →
SolveContext - Calls
solver.solve(ctx) - Reports
SolveResultback viaPOST /api/runner/jobs/{id}/complete - Optionally writes updated placements as a new revision
7.3 Standalone Solve Process (No GUI)
For server-side batch solving without Headless Create overhead:
#!/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:
-
Canonical input ordering —
SolveContextsorts constraints and parts by a stable key (part label + constraint index) before passing to the solver. The ordering hash is stored inSolveResult.input_hash. -
Solver contract —
IKCSolver::is_deterministic()reports whether the implementation guarantees this. OndselAdapter does (Lagrangian formulation with fixed pivot ordering). A GNN solver might not. -
Tolerance-aware comparison — Two
SolveResults are "equivalent" if all placement deltas are within tolerance, even if iteration counts differ. Used for regression testing. -
Warm-start stability — When
warm_startplacements 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
OndselAdapterwrapping existing solver - Assembly module calls through
SolverRegistryinstead of directly calling OndselSolver - 18 C++ tests, 6 Python integration tests
- PR: #297 (merged)
Phase 2: pybind11 Bindings -- COMPLETE
- Built
kcsolvepybind11 module exposing all enums, structs, and classes PyIKCSolvertrampoline for pure-Python solver subclassesregister_solver()for runtime Python solver registrationPySolverHolderfor 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 callregister_solver()explicitly - PR: #298
- Docs:
docs/src/architecture/ondsel-solver.md,docs/src/reference/kcsolve-python.md
Phase 3: Server Integration
create-solvecommand forsilorunner- YAML job definition for solve jobs
- Standalone solver process (no FreeCAD dependency)
SolveContextJSON 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
-
Location:
src/Lib/KCSolve/(independent library, usable without Assembly module) vssrc/Mod/Assembly/Solver/(tighter coupling, simpler build)? Leaning towardsrc/Lib/since server workers need it without the full Assembly module. -
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.
-
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?
-
API versioning:
kcsolve_api_version()returns a string. Semver with major-only breaking changes? How strict on backward compat for the plugin ABI? -
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 — Current solver documentation
- WORKERS.md — Worker/runner job system
- MULTI_USER_EDITS.md — Async validation pipeline
- DAG.md — Dependency graph for incremental recompute
- ROADMAP.md — Tier 3 compute modules, GNN solver plans