All checks were successful
Build and Test / build (pull_request) Successful in 28m58s
- 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
569 lines
20 KiB
Markdown
569 lines
20 KiB
Markdown
# Pluggable Assembly Solver Architecture
|
|
|
|
**Status:** Phase 2 complete
|
|
**Last Updated:** 2026-02-19
|
|
|
|
---
|
|
|
|
## 1. Problem
|
|
|
|
Kindred Create currently vendors OndselSolver as a monolithic assembly constraint solver. Different engineering domains benefit from different solver strategies — Lagrangian methods work well for rigid body assemblies but poorly for over-constrained or soft-constraint systems. A pluggable architecture lets us ship multiple solvers (including experimental ones) without touching core assembly logic, and lets the server farm out solve jobs to headless worker processes.
|
|
|
|
---
|
|
|
|
## 2. Design Goals
|
|
|
|
1. **Stable C++ API** — A solver-agnostic interface that the Assembly module calls. Solvers are shared libraries loaded at runtime.
|
|
2. **Python binding layer** — Every C++ solver is exposed to Python via pybind11, enabling rapid prototyping, debugging, and server-side execution without a full GUI build.
|
|
3. **Solver-defined joint types** — Each solver declares its own joint/mate vocabulary, mapped from a common base set (inspired by SOLIDWORKS mates: coincident, concentric, tangent, distance, angle, lock, etc.).
|
|
4. **Semi-deterministic solving** — Consistent results given consistent input ordering, with configurable tolerance and iteration limits.
|
|
5. **Server-compatible** — Solvers run as detached processes claimed by `silorunner` workers via the existing job queue.
|
|
|
|
---
|
|
|
|
## 3. Architecture Layers
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────┐
|
|
│ Layer 4: Server / Worker │
|
|
│ silorunner claims solve jobs, executes via Python │
|
|
│ Headless Create or standalone solver process │
|
|
├──────────────────────────────────────────────────────┤
|
|
│ Layer 3: Python Debug & Scripting │
|
|
│ pybind11 bindings for all solvers │
|
|
│ Introspection, step-through, constraint viz │
|
|
│ import kcsolve; s = kcsolve.load("ondsel") │
|
|
├──────────────────────────────────────────────────────┤
|
|
│ Layer 2: Solver Plugins (.so / .dll / .dylib) │
|
|
│ Each implements IKCSolver interface │
|
|
│ Registers joint types via manifest │
|
|
│ Loaded by SolverRegistry at runtime │
|
|
├──────────────────────────────────────────────────────┤
|
|
│ Layer 1: C++ Solver API (libkcsolve) │
|
|
│ IKCSolver, JointDef, SolveContext, SolveResult │
|
|
│ SolverRegistry (discovery, loading, selection) │
|
|
│ Ships as a shared library linked by Assembly module │
|
|
└──────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Layer 1: C++ Solver API
|
|
|
|
Located at `src/Mod/Assembly/Solver/` (or `src/Lib/KCSolve/` if we want it independent of Assembly).
|
|
|
|
### 4.1 Core Types
|
|
|
|
```cpp
|
|
namespace KCSolve {
|
|
|
|
// Unique identifier for a joint type within a solver
|
|
struct JointTypeId {
|
|
std::string solver_id; // e.g. "ondsel", "gnn", "relaxation"
|
|
std::string joint_name; // e.g. "coincident", "distance"
|
|
};
|
|
|
|
// Base joint categories (SOLIDWORKS-inspired vocabulary)
|
|
enum class BaseJointKind {
|
|
Coincident,
|
|
Concentric,
|
|
Tangent,
|
|
Distance,
|
|
Angle,
|
|
Lock,
|
|
Parallel,
|
|
Perpendicular,
|
|
PointOnLine,
|
|
SymmetricPlane,
|
|
Gear,
|
|
Rack,
|
|
Cam,
|
|
Slot,
|
|
Hinge,
|
|
Slider,
|
|
Cylindrical,
|
|
Planar,
|
|
Ball,
|
|
Screw,
|
|
Universal,
|
|
Custom // solver-specific extension
|
|
};
|
|
|
|
// A joint definition registered by a solver plugin
|
|
struct JointDef {
|
|
JointTypeId id;
|
|
BaseJointKind base_kind; // which vanilla category it maps to
|
|
std::string display_name;
|
|
std::string description;
|
|
uint32_t dof_removed; // degrees of freedom this joint removes
|
|
std::vector<std::string> params; // parameter names (e.g. "distance", "angle")
|
|
bool supports_limits = false;
|
|
bool supports_friction = false;
|
|
};
|
|
|
|
// A constraint instance in a solve problem
|
|
struct Constraint {
|
|
JointTypeId joint_type;
|
|
std::string part_a; // part label or id
|
|
std::string part_b;
|
|
// Geometry references (face, edge, vertex indices)
|
|
std::vector<std::string> refs_a;
|
|
std::vector<std::string> refs_b;
|
|
std::map<std::string, double> params; // param_name -> value
|
|
bool suppressed = false;
|
|
};
|
|
|
|
// Input to a solve operation
|
|
struct SolveContext {
|
|
std::vector<Constraint> constraints;
|
|
// Part placements as 4x4 transforms (initial guess)
|
|
std::map<std::string, std::array<double, 16>> placements;
|
|
// Which parts are grounded (fixed)
|
|
std::set<std::string> grounded;
|
|
// Solver config
|
|
double tolerance = 1e-10;
|
|
uint32_t max_iterations = 500;
|
|
bool deterministic = true; // force consistent ordering
|
|
// Optional: previous solution for warm-starting
|
|
std::map<std::string, std::array<double, 16>> warm_start;
|
|
};
|
|
|
|
enum class SolveStatus {
|
|
Converged,
|
|
MaxIterationsReached,
|
|
Overconstrained,
|
|
Underconstrained,
|
|
Redundant,
|
|
Failed
|
|
};
|
|
|
|
struct ConstraintDiagnostic {
|
|
std::string constraint_id;
|
|
double residual;
|
|
bool satisfied;
|
|
std::string message;
|
|
};
|
|
|
|
struct SolveResult {
|
|
SolveStatus status;
|
|
uint32_t iterations;
|
|
double final_residual;
|
|
double solve_time_ms;
|
|
std::map<std::string, std::array<double, 16>> placements;
|
|
std::vector<ConstraintDiagnostic> diagnostics;
|
|
// For semi-deterministic: hash of input ordering
|
|
uint64_t input_hash;
|
|
};
|
|
|
|
} // namespace KCSolve
|
|
```
|
|
|
|
### 4.2 Solver Interface
|
|
|
|
```cpp
|
|
namespace KCSolve {
|
|
|
|
class IKCSolver {
|
|
public:
|
|
virtual ~IKCSolver() = default;
|
|
|
|
// Identity
|
|
virtual std::string id() const = 0;
|
|
virtual std::string name() const = 0;
|
|
virtual std::string version() const = 0;
|
|
|
|
// Joint type registry — called once at load
|
|
virtual std::vector<JointDef> supported_joints() const = 0;
|
|
|
|
// Solve
|
|
virtual SolveResult solve(const SolveContext& ctx) = 0;
|
|
|
|
// Incremental: update a single constraint without full re-solve
|
|
// Default impl falls back to full solve
|
|
virtual SolveResult update(const SolveContext& ctx,
|
|
const std::string& changed_constraint) {
|
|
return solve(ctx);
|
|
}
|
|
|
|
// Diagnostic: check if a constraint set is well-posed before solving
|
|
virtual SolveStatus diagnose(const SolveContext& ctx) {
|
|
return SolveStatus::Converged; // optimistic default
|
|
}
|
|
|
|
// Determinism: given identical input, produce identical output
|
|
virtual bool is_deterministic() const { return false; }
|
|
};
|
|
|
|
// Plugin entry point — each .so exports this symbol
|
|
using CreateSolverFn = IKCSolver* (*)();
|
|
|
|
} // namespace KCSolve
|
|
```
|
|
|
|
### 4.3 Solver Registry
|
|
|
|
```cpp
|
|
namespace KCSolve {
|
|
|
|
class SolverRegistry {
|
|
public:
|
|
// Scan a directory for solver plugins (*.so / *.dll / *.dylib)
|
|
void scan(const std::filesystem::path& plugin_dir);
|
|
|
|
// Manual registration (for built-in solvers like Ondsel)
|
|
void register_solver(std::unique_ptr<IKCSolver> solver);
|
|
|
|
// Lookup
|
|
IKCSolver* get(const std::string& solver_id) const;
|
|
std::vector<std::string> available() const;
|
|
|
|
// Joint type resolution: find which solvers support a given base kind
|
|
std::vector<JointTypeId> joints_for(BaseJointKind kind) const;
|
|
|
|
// Global default solver
|
|
void set_default(const std::string& solver_id);
|
|
IKCSolver* get_default() const;
|
|
};
|
|
|
|
} // namespace KCSolve
|
|
```
|
|
|
|
### 4.4 Plugin Loading
|
|
|
|
Each solver plugin is a shared library exporting:
|
|
|
|
```cpp
|
|
extern "C" KCSolve::IKCSolver* kcsolve_create();
|
|
extern "C" const char* kcsolve_api_version(); // "1.0"
|
|
```
|
|
|
|
The registry `dlopen`s each library, checks `kcsolve_api_version()` compatibility, and calls `kcsolve_create()`. Plugins are discovered from:
|
|
|
|
1. `<install_prefix>/lib/kcsolve/` — system-installed solvers
|
|
2. `~/.config/KindredCreate/solvers/` — user-installed solvers
|
|
3. `KCSOLVE_PLUGIN_PATH` env var — development overrides
|
|
|
|
---
|
|
|
|
## 5. Layer 2: OndselSolver Adapter
|
|
|
|
The first plugin wraps the existing OndselSolver, mapping its internal constraint types to the `IKCSolver` interface.
|
|
|
|
```
|
|
src/Mod/Assembly/Solver/
|
|
├── IKCSolver.h # Interface + types from §4
|
|
├── SolverRegistry.cpp # Plugin discovery and loading
|
|
├── OndselAdapter.cpp # Wraps OndselSolver as IKCSolver plugin
|
|
└── CMakeLists.txt
|
|
```
|
|
|
|
`OndselAdapter` translates between `SolveContext` ↔ OndselSolver's Lagrangian formulation. This is the reference implementation and proves the API works before any new solvers are written.
|
|
|
|
Joint mapping for OndselAdapter:
|
|
|
|
| BaseJointKind | Ondsel Constraint | DOF Removed |
|
|
|---------------|-------------------|-------------|
|
|
| Coincident | PointOnPoint | 3 |
|
|
| Concentric | CylindricalOnCylindrical | 4 |
|
|
| Tangent | FaceOnFace (tangent mode) | 1 |
|
|
| Distance | PointOnPoint + offset | 2 |
|
|
| Angle | AxisAngle | 1 |
|
|
| Lock | FullLock | 6 |
|
|
| Hinge | RevoluteJoint | 5 |
|
|
| Slider | PrismaticJoint | 5 |
|
|
| Cylindrical | CylindricalJoint | 4 |
|
|
| Ball | SphericalJoint | 3 |
|
|
|
|
---
|
|
|
|
## 6. Layer 3: Python Bindings
|
|
|
|
### 6.1 pybind11 Module
|
|
|
|
```
|
|
src/Mod/Assembly/Solver/bindings/
|
|
├── kcsolve_py.cpp # pybind11 module definition
|
|
└── CMakeLists.txt
|
|
```
|
|
|
|
```python
|
|
import kcsolve
|
|
|
|
# List available solvers
|
|
print(kcsolve.available()) # ["ondsel", ...]
|
|
|
|
# Load a solver
|
|
solver = kcsolve.load("ondsel")
|
|
print(solver.name, solver.version)
|
|
print(solver.supported_joints())
|
|
|
|
# Build a problem
|
|
ctx = kcsolve.SolveContext()
|
|
ctx.add_part("base", placement=..., grounded=True)
|
|
ctx.add_part("arm", placement=...)
|
|
ctx.add_constraint("coincident", "base", "arm",
|
|
refs_a=["Face6"], refs_b=["Face1"])
|
|
|
|
# Solve
|
|
result = solver.solve(ctx)
|
|
print(result.status) # SolveStatus.Converged
|
|
print(result.iterations) # 12
|
|
print(result.solve_time_ms) # 3.4
|
|
print(result.placements["arm"])
|
|
|
|
# Diagnostics per constraint
|
|
for d in result.diagnostics:
|
|
print(f"{d.constraint_id}: residual={d.residual:.2e} ok={d.satisfied}")
|
|
```
|
|
|
|
### 6.2 Debug / Introspection API
|
|
|
|
The Python layer adds capabilities the C++ interface intentionally omits for performance:
|
|
|
|
```python
|
|
# Step-through solving (debug mode)
|
|
debugger = kcsolve.Debugger(solver, ctx)
|
|
for step in debugger.iterate():
|
|
print(f"iter {step.iteration}: residual={step.residual:.6e}")
|
|
print(f" moved: {step.parts_moved}")
|
|
print(f" worst constraint: {step.worst_constraint}")
|
|
if step.residual < 1e-8:
|
|
break
|
|
|
|
# Constraint dependency graph
|
|
graph = kcsolve.dependency_graph(ctx)
|
|
# Returns dict: constraint_id -> [dependent_constraint_ids]
|
|
|
|
# DOF analysis
|
|
analysis = kcsolve.dof_analysis(ctx)
|
|
print(f"Total DOF: {analysis.total_dof}")
|
|
print(f"Removed: {analysis.constrained_dof}")
|
|
print(f"Remaining: {analysis.free_dof}")
|
|
for part, dofs in analysis.per_part.items():
|
|
print(f" {part}: {dofs} free")
|
|
```
|
|
|
|
### 6.3 Pure-Python Solver Support
|
|
|
|
The Python layer also supports solvers written entirely in Python (no C++ required). This is the fast path for prototyping new approaches (GNN, relaxation, etc.):
|
|
|
|
```python
|
|
class RelaxationSolver(kcsolve.PySolver):
|
|
"""A pure-Python iterative relaxation solver for prototyping."""
|
|
|
|
id = "relaxation"
|
|
name = "Iterative Relaxation"
|
|
version = "0.1.0"
|
|
|
|
def supported_joints(self):
|
|
return [
|
|
kcsolve.JointDef("coincident", kcsolve.BaseJointKind.Coincident, dof_removed=3),
|
|
kcsolve.JointDef("distance", kcsolve.BaseJointKind.Distance, dof_removed=2),
|
|
# ...
|
|
]
|
|
|
|
def solve(self, ctx: kcsolve.SolveContext) -> kcsolve.SolveResult:
|
|
placements = dict(ctx.placements)
|
|
for i in range(ctx.max_iterations):
|
|
max_residual = 0.0
|
|
for c in ctx.constraints:
|
|
residual = self._eval_constraint(c, placements)
|
|
correction = self._compute_correction(c, residual)
|
|
self._apply_correction(placements, c, correction)
|
|
max_residual = max(max_residual, abs(residual))
|
|
if max_residual < ctx.tolerance:
|
|
return kcsolve.SolveResult(
|
|
status=kcsolve.SolveStatus.Converged,
|
|
iterations=i + 1,
|
|
final_residual=max_residual,
|
|
placements=placements
|
|
)
|
|
return kcsolve.SolveResult(
|
|
status=kcsolve.SolveStatus.MaxIterationsReached,
|
|
iterations=ctx.max_iterations,
|
|
final_residual=max_residual,
|
|
placements=placements
|
|
)
|
|
|
|
# Register at runtime
|
|
kcsolve.register(RelaxationSolver())
|
|
```
|
|
|
|
Python solvers are discovered from:
|
|
- `<user_macros>/solvers/*.py` — user-written solvers
|
|
- `mods/*/solvers/*.py` — addon-provided solvers
|
|
|
|
---
|
|
|
|
## 7. Layer 4: Server Integration
|
|
|
|
### 7.1 Solve Job Definition
|
|
|
|
Extends the existing worker system (WORKERS.md) with a new job type:
|
|
|
|
```yaml
|
|
job:
|
|
name: assembly-solve
|
|
version: 1
|
|
description: "Solve assembly constraints using specified solver"
|
|
trigger:
|
|
type: revision_created
|
|
filter:
|
|
item_type: assembly
|
|
scope:
|
|
type: assembly
|
|
compute:
|
|
type: solve
|
|
command: create-solve
|
|
args:
|
|
solver: ondsel # or "auto" for registry default
|
|
tolerance: 1e-10
|
|
max_iterations: 500
|
|
deterministic: true
|
|
output_placements: true # write solved placements back to revision
|
|
output_diagnostics: true # store constraint diagnostics in job result
|
|
runner:
|
|
tags: [create, solver]
|
|
timeout: 300
|
|
max_retries: 1
|
|
priority: 75
|
|
```
|
|
|
|
### 7.2 Headless Solve via Runner
|
|
|
|
The `create-solve` command in `silorunner`:
|
|
|
|
1. Claims job from Silo server
|
|
2. Downloads the assembly `.kc` file
|
|
3. Launches Headless Create (or standalone Python if pure-Python solver)
|
|
4. Loads the assembly, extracts constraint graph → `SolveContext`
|
|
5. Calls `solver.solve(ctx)`
|
|
6. Reports `SolveResult` back via `POST /api/runner/jobs/{id}/complete`
|
|
7. Optionally writes updated placements as a new revision
|
|
|
|
### 7.3 Standalone Solve Process (No GUI)
|
|
|
|
For server-side batch solving without Headless Create overhead:
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Standalone solver worker — no FreeCAD dependency."""
|
|
import kcsolve
|
|
import json, sys
|
|
|
|
problem = json.load(sys.stdin)
|
|
ctx = kcsolve.SolveContext.from_dict(problem)
|
|
|
|
solver = kcsolve.load(problem.get("solver", "ondsel"))
|
|
result = solver.solve(ctx)
|
|
|
|
json.dump(result.to_dict(), sys.stdout)
|
|
```
|
|
|
|
This enables lightweight solver containers that don't need the full Create installation — useful for CI validation, quick constraint checks, and scaling solver capacity independently of geometry workers.
|
|
|
|
---
|
|
|
|
## 8. Semi-Deterministic Behavior
|
|
|
|
"Semi-deterministic" means: given the same constraint set and initial placements, the solver produces the same result. This is achieved by:
|
|
|
|
1. **Canonical input ordering** — `SolveContext` sorts constraints and parts by a stable key (part label + constraint index) before passing to the solver. The ordering hash is stored in `SolveResult.input_hash`.
|
|
|
|
2. **Solver contract** — `IKCSolver::is_deterministic()` reports whether the implementation guarantees this. OndselAdapter does (Lagrangian formulation with fixed pivot ordering). A GNN solver might not.
|
|
|
|
3. **Tolerance-aware comparison** — Two `SolveResult`s are "equivalent" if all placement deltas are within tolerance, even if iteration counts differ. Used for regression testing.
|
|
|
|
4. **Warm-start stability** — When `warm_start` placements are provided, the solver should converge to the same solution as a cold start (within tolerance), just faster. This is validated in the test suite.
|
|
|
|
---
|
|
|
|
## 9. Implementation Phases
|
|
|
|
### Phase 1: API + OndselAdapter (foundation) -- COMPLETE
|
|
|
|
- Defined `IKCSolver.h`, core types (`Types.h`), `SolverRegistry`
|
|
- Implemented `OndselAdapter` wrapping existing solver
|
|
- Assembly module calls through `SolverRegistry` instead of directly calling OndselSolver
|
|
- 18 C++ tests, 6 Python integration tests
|
|
- **PR:** #297 (merged)
|
|
|
|
### Phase 2: pybind11 Bindings -- COMPLETE
|
|
|
|
- Built `kcsolve` pybind11 module exposing all enums, structs, and classes
|
|
- `PyIKCSolver` trampoline for pure-Python solver subclasses
|
|
- `register_solver()` for runtime Python solver registration
|
|
- `PySolverHolder` for GIL-safe forwarding of virtual calls
|
|
- 16 Python tests covering types, registry, and Python solver round-trips
|
|
- Debug/introspection API (Debugger, `dependency_graph()`, `dof_analysis()`) deferred to Phase 4+
|
|
- Automatic Python solver discovery (`mods/*/solvers/`) deferred -- users call `register_solver()` explicitly
|
|
- **PR:** #298
|
|
- **Docs:** `docs/src/architecture/ondsel-solver.md`, `docs/src/reference/kcsolve-python.md`
|
|
|
|
### Phase 3: Server Integration
|
|
|
|
- `create-solve` command for `silorunner`
|
|
- YAML job definition for solve jobs
|
|
- Standalone solver process (no FreeCAD dependency)
|
|
- `SolveContext` JSON serialization for inter-process communication
|
|
- **Deliverable:** Solve jobs run async through the worker system
|
|
|
|
### Phase 4: Second Solver (validation)
|
|
|
|
- Implement a simple relaxation or gradient-descent solver as a Python plugin
|
|
- Validates that the API actually supports different solving strategies
|
|
- Benchmark against OndselAdapter for correctness and performance
|
|
- **Deliverable:** Two interchangeable solvers, selectable per-assembly
|
|
|
|
### Phase 5: GNN Solver (future)
|
|
|
|
- Graph Neural Network approach from existing roadmap
|
|
- Likely a Python solver wrapping a trained model
|
|
- Focus on fast approximate solutions for interactive editing
|
|
- Falls back to OndselAdapter for final precision solve
|
|
- **Deliverable:** Hybrid solve pipeline (GNN fast-guess → Lagrangian refinement)
|
|
|
|
---
|
|
|
|
## 10. File Locations
|
|
|
|
```
|
|
src/Lib/KCSolve/ # or src/Mod/Assembly/Solver/
|
|
├── include/
|
|
│ └── KCSolve/
|
|
│ ├── IKCSolver.h # Interface + all types
|
|
│ ├── SolverRegistry.h # Plugin loading and lookup
|
|
│ └── Types.h # Enums, structs
|
|
├── src/
|
|
│ ├── SolverRegistry.cpp
|
|
│ └── OndselAdapter.cpp
|
|
├── bindings/
|
|
│ └── kcsolve_py.cpp # pybind11
|
|
├── plugins/ # Additional compiled solver plugins
|
|
└── CMakeLists.txt
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Open Questions
|
|
|
|
1. **Location**: `src/Lib/KCSolve/` (independent library, usable without Assembly module) vs `src/Mod/Assembly/Solver/` (tighter coupling, simpler build)? Leaning toward `src/Lib/` since server workers need it without the full Assembly module.
|
|
|
|
2. **Geometry abstraction**: The C++ API uses string references for faces/edges/vertices. Should we pass actual OCC geometry (TopoDS_Shape) through the interface, or keep it abstract and let each solver adapter resolve references? Abstract is more portable but adds a translation step.
|
|
|
|
3. **Constraint persistence**: Currently constraints live in the FCStd XML. Should the pluggable layer introduce its own serialization, or always read/write through FreeCAD's property system?
|
|
|
|
4. **API versioning**: `kcsolve_api_version()` returns a string. Semver with major-only breaking changes? How strict on backward compat for the plugin ABI?
|
|
|
|
5. **License implications**: OndselSolver is LGPL. New solver plugins could be any license since they're loaded at runtime via a stable C API boundary. Confirm this interpretation.
|
|
|
|
---
|
|
|
|
## 12. References
|
|
|
|
- [ondsel-solver.md](ondsel-solver.md) — Current solver documentation
|
|
- [WORKERS.md](WORKERS.md) — Worker/runner job system
|
|
- [MULTI_USER_EDITS.md](MULTI_USER_EDITS.md) — Async validation pipeline
|
|
- [DAG.md](DAG.md) — Dependency graph for incremental recompute
|
|
- [ROADMAP.md](ROADMAP.md) — Tier 3 compute modules, GNN solver plans
|