Files
create/docs/INTER_SOLVER.md
forbes 98b0f72352 docs: KCSolve architecture and Python API reference
- 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
2026-02-20 12:02:54 -06:00

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

  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

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:

  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
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 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:

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:

#!/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 orderingSolveContext 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 contractIKCSolver::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 SolveResults 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