From c0ee4ecccfd6bbde0469b8cc9f27e79624e9cf06 Mon Sep 17 00:00:00 2001 From: Kindred Bot Date: Thu, 19 Feb 2026 20:58:03 +0000 Subject: [PATCH 1/4] docs: sync Silo server documentation Auto-synced from kindred/silo main branch. --- docs/src/silo-server/MODULES.md | 22 +++++++++------------- docs/src/silo-server/frontend-spec.md | 14 +++++++------- docs/src/silo-server/overview.md | 6 +++--- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/src/silo-server/MODULES.md b/docs/src/silo-server/MODULES.md index d7480b5172..589e0c4db8 100644 --- a/docs/src/silo-server/MODULES.md +++ b/docs/src/silo-server/MODULES.md @@ -23,7 +23,7 @@ These cannot be disabled. They define what Silo *is*. |-----------|------|-------------| | `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation | | `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors | -| `storage` | Storage | MinIO/S3 file storage, presigned uploads, versioning | +| `storage` | Storage | Filesystem storage | ### 2.2 Optional Modules @@ -470,12 +470,10 @@ Returns full config grouped by module with secrets redacted: "default": "kindred-rd" }, "storage": { - "endpoint": "minio:9000", - "bucket": "silo-files", - "access_key": "****", - "secret_key": "****", - "use_ssl": false, - "region": "us-east-1", + "backend": "filesystem", + "filesystem": { + "root_dir": "/var/lib/silo/data" + }, "status": "connected" }, "database": { @@ -566,7 +564,7 @@ Available for modules with external connections: | Module | Test Action | |--------|------------| -| `storage` | Ping MinIO, verify bucket exists | +| `storage` | Verify filesystem storage directory is accessible | | `auth` (ldap) | Attempt LDAP bind with configured credentials | | `auth` (oidc) | Fetch OIDC discovery document from issuer URL | | `odoo` | Attempt XML-RPC connection to Odoo | @@ -602,11 +600,9 @@ database: sslmode: disable storage: - endpoint: minio:9000 - bucket: silo-files - access_key: silominio - secret_key: silominiosecret - use_ssl: false + backend: filesystem + filesystem: + root_dir: /var/lib/silo/data schemas: directory: /etc/silo/schemas diff --git a/docs/src/silo-server/frontend-spec.md b/docs/src/silo-server/frontend-spec.md index 6eb6ab3908..2214508db7 100644 --- a/docs/src/silo-server/frontend-spec.md +++ b/docs/src/silo-server/frontend-spec.md @@ -337,7 +337,7 @@ Supporting files: | File | Purpose | |------|---------| | `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector | -| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs | +| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload | | `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects | | `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` | | `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow | @@ -421,7 +421,7 @@ Below the picker, the selected category is shown as a breadcrumb: `Fasteners › ### FileDropZone -Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow. +Handles drag-and-drop and click-to-browse file uploads. **Props**: @@ -435,7 +435,7 @@ interface FileDropZoneProps { interface PendingAttachment { file: File; - objectKey: string; // MinIO key after upload + objectKey: string; // storage key after upload uploadProgress: number; // 0-100 uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; error?: string; @@ -462,7 +462,7 @@ Clicking the zone opens a hidden ``. 1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`. 2. Backend returns `{ object_key, upload_url, expires_at }`. -3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking). +3. `PUT` the file directly to the presigned URL using `XMLHttpRequest` (for progress tracking). 4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`. 5. The `object_key` is later sent to the item creation endpoint to associate the file. @@ -589,10 +589,10 @@ Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by ``` POST /api/uploads/presign Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 } -Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." } +Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://...", "expires_at": "2026-02-06T..." } ``` -The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. +The Go handler generates a presigned PUT URL for direct upload. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. ### 2. File Association -- IMPLEMENTED @@ -612,7 +612,7 @@ Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" } Response: 204 ``` -Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column. +Stores the thumbnail at `items/{item_id}/thumbnail.png` in storage. Updates `item.thumbnail_key` column. ### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor) diff --git a/docs/src/silo-server/overview.md b/docs/src/silo-server/overview.md index 3262f0649f..eb6fcd0264 100644 --- a/docs/src/silo-server/overview.md +++ b/docs/src/silo-server/overview.md @@ -34,7 +34,7 @@ silo/ │ ├── ods/ # ODS spreadsheet library │ ├── partnum/ # Part number generation │ ├── schema/ # YAML schema parsing -│ ├── storage/ # MinIO file storage +│ ├── storage/ # Filesystem storage │ └── testutil/ # Test helpers ├── web/ # React SPA (Vite + TypeScript) │ └── src/ @@ -55,7 +55,7 @@ silo/ See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions. -**Docker Compose (quickest — includes PostgreSQL, MinIO, OpenLDAP, and Silo):** +**Docker Compose (quickest — includes PostgreSQL, OpenLDAP, and Silo):** ```bash ./scripts/setup-docker.sh @@ -65,7 +65,7 @@ docker compose -f deployments/docker-compose.allinone.yaml up -d **Development (local Go + Docker services):** ```bash -make docker-up # Start PostgreSQL + MinIO in Docker +make docker-up # Start PostgreSQL in Docker make run # Run silo locally with Go ``` -- 2.49.1 From 98b0f723528e2ca1ee1c1cbdf09666ac0cac84df Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 18:59:05 -0600 Subject: [PATCH 2/4] 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 --- docs/INTER_SOLVER.md | 568 +++++++++++++++++++++++++ docs/src/SUMMARY.md | 3 +- docs/src/architecture/ondsel-solver.md | 137 +++++- docs/src/reference/kcsolve-python.md | 313 ++++++++++++++ 4 files changed, 1004 insertions(+), 17 deletions(-) create mode 100644 docs/INTER_SOLVER.md create mode 100644 docs/src/reference/kcsolve-python.md 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 -- 2.49.1 From 315ac2a25dbe7ace525859fbcfe0f0a2758df4b9 Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 19:06:08 -0600 Subject: [PATCH 3/4] docs(kcsolve): expand Python API reference with full method docs Expand SolveContext field descriptions (motions, simulation, bundle_fixed), Constraint params table, marker explanations, Constraint.Limit descriptions, MotionDef field descriptions, SimulationParams field descriptions, and all optional IKCSolver methods with signatures, parameter docs, and usage examples (interactive drag protocol, kinematic simulation, diagnostics, export_native, capability queries). --- docs/src/reference/kcsolve-python.md | 222 ++++++++++++++++++++------- 1 file changed, 169 insertions(+), 53 deletions(-) diff --git a/docs/src/reference/kcsolve-python.md b/docs/src/reference/kcsolve-python.md index f99ac7a1cd..2a75739813 100644 --- a/docs/src/reference/kcsolve-python.md +++ b/docs/src/reference/kcsolve-python.md @@ -70,57 +70,92 @@ Note: quaternion convention is `(w, x, y, z)`, which differs from FreeCAD's `Bas ### 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` | +A constraint between two parts, built from a FreeCAD JointObject by the adapter layer. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `id` | `str` | `""` | FreeCAD document object name (e.g. `"Joint001"`) | +| `part_i` | `str` | `""` | Solver-side part ID for first reference | +| `marker_i` | `Transform` | identity | Coordinate system on `part_i` (attachment point/orientation) | +| `part_j` | `str` | `""` | Solver-side part ID for second reference | +| `marker_j` | `Transform` | identity | Coordinate system on `part_j` (attachment point/orientation) | +| `type` | `BaseJointKind` | `Coincident` | Constraint type | +| `params` | `list[float]` | `[]` | Scalar parameters (interpretation depends on `type`) | +| `limits` | `list[Constraint.Limit]` | `[]` | Joint travel limits | +| `activated` | `bool` | `True` | Whether this constraint is active | + +**`marker_i` / `marker_j`** -- Define the local coordinate frames on each part where the joint acts. For example, a Revolute joint's markers define the hinge axis direction and attachment points on each part. + +**`params`** -- Interpretation depends on `type`: + +| Type | params[0] | params[1] | +|------|-----------|-----------| +| `Angle` | angle (radians) | | +| `RackPinion` | pitch radius | | +| `Screw` | pitch | | +| `Gear` | radius I | radius J (negative for belt) | +| `DistancePointPoint` | distance | | +| `DistanceCylSph` | distance | | +| `Planar` | offset | | +| `Concentric` | distance | | +| `PointInPlane` | offset | | +| `LineInPlane` | offset | | ### Constraint.Limit -| Field | Type | Default | -|-------|------|---------| -| `kind` | `LimitKind` | `TranslationMin` | -| `value` | `float` | `0.0` | -| `tolerance` | `float` | `1e-9` | +Joint travel limits (translation or rotation bounds). + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | `LimitKind` | `TranslationMin` | Which degree of freedom to limit | +| `value` | `float` | `0.0` | Limit value (meters for translation, radians for rotation) | +| `tolerance` | `float` | `1e-9` | Solver tolerance for limit enforcement | ### MotionDef -| Field | Type | Default | -|-------|------|---------| -| `kind` | `MotionKind` | `Rotational` | -| `joint_id` | `str` | `""` | -| `marker_i` | `str` | `""` | -| `marker_j` | `str` | `""` | -| `rotation_expr` | `str` | `""` | -| `translation_expr` | `str` | `""` | +A motion driver for kinematic simulation. Defines time-dependent actuation of a constraint. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `kind` | `MotionKind` | `Rotational` | Type of motion: `Rotational`, `Translational`, or `General` (both) | +| `joint_id` | `str` | `""` | ID of the constraint this motion drives | +| `marker_i` | `str` | `""` | Reference marker on first part | +| `marker_j` | `str` | `""` | Reference marker on second part | +| `rotation_expr` | `str` | `""` | Rotation law as a function of time `t` (e.g. `"2*pi*t"`) | +| `translation_expr` | `str` | `""` | Translation law as a function of time `t` (e.g. `"10*t"`) | + +For `Rotational` kind, only `rotation_expr` is used. For `Translational`, only `translation_expr`. For `General`, both are set. ### 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` | +Time-stepping parameters for kinematic simulation via `run_kinematic()`. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `t_start` | `float` | `0.0` | Simulation start time (seconds) | +| `t_end` | `float` | `1.0` | Simulation end time (seconds) | +| `h_out` | `float` | `0.01` | Output time step -- controls frame rate (e.g. `0.04` = 25 fps) | +| `h_min` | `float` | `1e-9` | Minimum internal integration step | +| `h_max` | `float` | `1.0` | Maximum internal integration step | +| `error_tol` | `float` | `1e-6` | Error tolerance for adaptive time stepping | ### SolveContext -| Field | Type | Default | -|-------|------|---------| -| `parts` | `list[Part]` | `[]` | -| `constraints` | `list[Constraint]` | `[]` | -| `motions` | `list[MotionDef]` | `[]` | -| `simulation` | `SimulationParams` or `None` | `None` | -| `bundle_fixed` | `bool` | `False` | +Complete input to a solve operation. Built by the adapter layer from FreeCAD document objects, or constructed manually for scripted solving. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `parts` | `list[Part]` | `[]` | All parts in the assembly | +| `constraints` | `list[Constraint]` | `[]` | Constraints between parts | +| `motions` | `list[MotionDef]` | `[]` | Motion drivers for kinematic simulation | +| `simulation` | `SimulationParams` or `None` | `None` | Time-stepping parameters for `run_kinematic()` | +| `bundle_fixed` | `bool` | `False` | Hint to merge Fixed-joint-connected parts into rigid bodies | + +**`motions`** -- Motion drivers define time-dependent joint actuation for kinematic simulation. Each `MotionDef` references a constraint by `joint_id` and provides expressions (functions of time `t`) for rotation and/or translation. Only used when calling `run_kinematic()`. + +**`simulation`** -- When set, provides time-stepping parameters (`t_start`, `t_end`, step sizes, error tolerance) for kinematic simulation via `run_kinematic()`. When `None`, kinematic simulation is not requested. + +**`bundle_fixed`** -- When `True`, parts connected by `Fixed` joints should be merged into single rigid bodies before solving, reducing the problem size. If the solver reports `supports_bundle_fixed() == True`, it handles this internally. Otherwise, the caller (adapter layer) pre-bundles before building the context. **Important:** pybind11 returns copies of `list` fields, not references. Use whole-list assignment: @@ -179,21 +214,102 @@ class MySolver(kcsolve.IKCSolver): return result ``` -Optional overrides (all have default implementations): +All other methods are optional and have default implementations. Override them to add capabilities beyond basic solving. -| 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` | +#### update(ctx) -> SolveResult + +Incrementally re-solve after parameter changes (e.g. joint angle adjusted during creation). Solvers can optimize this path since only parameters changed, not topology. Default: delegates to `solve()`. + +```python +def update(self, ctx): + # Only re-evaluate changed constraints, reuse cached factorization + return self._incremental_solve(ctx) +``` + +#### Interactive drag protocol + +Three-phase protocol for interactive part dragging in the viewport. Solvers can maintain internal state across the drag session for better performance. + +**pre_drag(ctx, drag_parts) -> SolveResult** -- Prepare for a drag session. `drag_parts` is a `list[str]` of part IDs being dragged. Solve the initial state and cache internal data. Default: delegates to `solve()`. + +**drag_step(drag_placements) -> SolveResult** -- Called on each mouse move. `drag_placements` is a `list[SolveResult.PartResult]` with the current positions of dragged parts. Returns updated placements for all affected parts. Default: returns Success with no placements. + +**post_drag()** -- End the drag session and release internal state. Default: no-op. + +```python +def pre_drag(self, ctx, drag_parts): + self._cached_system = self._build_system(ctx) + return self.solve(ctx) + +def drag_step(self, drag_placements): + # Use cached system for fast incremental solve + for dp in drag_placements: + self._cached_system.set_placement(dp.id, dp.placement) + return self._cached_system.solve_incremental() + +def post_drag(self): + self._cached_system = None +``` + +#### Kinematic simulation + +**run_kinematic(ctx) -> SolveResult** -- Run a kinematic simulation over the time range in `ctx.simulation`. After this call, `num_frames()` returns the frame count and `update_for_frame(i)` retrieves individual frames. Requires `ctx.simulation` to be set and `ctx.motions` to contain at least one motion driver. Default: returns Failed. + +**num_frames() -> int** -- Number of simulation frames available after `run_kinematic()`. Default: returns 0. + +**update_for_frame(index) -> SolveResult** -- Retrieve part placements for simulation frame at `index` (0-based, must be < `num_frames()`). Default: returns Failed. + +```python +# Run a kinematic simulation +ctx.simulation = kcsolve.SimulationParams() +ctx.simulation.t_start = 0.0 +ctx.simulation.t_end = 2.0 +ctx.simulation.h_out = 0.04 # 25 fps + +motion = kcsolve.MotionDef() +motion.kind = kcsolve.MotionKind.Rotational +motion.joint_id = "Joint001" +motion.rotation_expr = "2*pi*t" # one revolution per second +ctx.motions = [motion] + +solver = kcsolve.load("ondsel") +result = solver.run_kinematic(ctx) + +for i in range(solver.num_frames()): + frame = solver.update_for_frame(i) + for pr in frame.placements: + print(f"frame {i}: {pr.id} at {list(pr.placement.position)}") +``` + +#### diagnose(ctx) -> list[ConstraintDiagnostic] + +Analyze the assembly for redundant, conflicting, or malformed constraints. May require a prior `solve()` call for some solvers. Returns a list of `ConstraintDiagnostic` objects. Default: returns empty list. + +```python +diags = solver.diagnose(ctx) +for d in diags: + if d.kind == kcsolve.DiagnosticKind.Redundant: + print(f"Redundant: {d.constraint_id} - {d.detail}") + elif d.kind == kcsolve.DiagnosticKind.Conflicting: + print(f"Conflict: {d.constraint_id} - {d.detail}") +``` + +#### is_deterministic() -> bool + +Whether this solver produces identical results given identical input. Used for regression testing and result caching. Default: returns `True`. + +#### export_native(path) + +Write a solver-native debug/diagnostic file (e.g. ASMT format for OndselSolver). Requires a prior `solve()` or `run_kinematic()` call. Default: no-op. + +```python +solver.solve(ctx) +solver.export_native("/tmp/debug.asmt") +``` + +#### supports_bundle_fixed() -> bool + +Whether this solver handles Fixed-joint part bundling internally. When `False`, the caller merges Fixed-joint-connected parts into single rigid bodies before building the `SolveContext`, reducing problem size. When `True`, the solver receives unbundled parts and optimizes internally. Default: returns `False`. ### OndselAdapter -- 2.49.1 From 64fbc167f71b9d709245a22c50587f72581681bd Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 19:22:51 -0600 Subject: [PATCH 4/4] docs(solver): server specification for KCSolve solver service Comprehensive specification covering: - Architecture: solver module integrated into Silo's job queue system - Data model: JSON schemas for SolveContext and SolveResult transport - REST API: submit, status, list, cancel endpoints under /api/solver/ - SSE events: solver.created, solver.progress, solver.completed, solver.failed - Runner integration: standalone kcsolve execution, capability reporting - Job definitions: manual solve, commit-time validation, kinematic simulation - SolveContext extraction: headless Create and .kc archive packing - Database schema: solver_results table with per-revision result caching - Configuration: server and runner config patterns - Security: input validation, runner isolation, authentication - Client SDK: Python client and Create workbench integration sketches - Implementation plan: Phase 3a-3e breakdown --- docs/src/SUMMARY.md | 1 + docs/src/silo-server/SOLVER.md | 899 +++++++++++++++++++++++++++++++++ 2 files changed, 900 insertions(+) create mode 100644 docs/src/silo-server/SOLVER.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index e4a1c93819..d70fff7760 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -46,6 +46,7 @@ - [Gap Analysis](./silo-server/GAP_ANALYSIS.md) - [Frontend Spec](./silo-server/frontend-spec.md) - [Installation](./silo-server/INSTALL.md) +- [Solver Service](./silo-server/SOLVER.md) - [Roadmap](./silo-server/ROADMAP.md) # Reference diff --git a/docs/src/silo-server/SOLVER.md b/docs/src/silo-server/SOLVER.md new file mode 100644 index 0000000000..5b3f894a40 --- /dev/null +++ b/docs/src/silo-server/SOLVER.md @@ -0,0 +1,899 @@ +# Solver Service Specification + +**Status:** Draft +**Last Updated:** 2026-02-19 +**Depends on:** KCSolve Phase 1 (PR #297), Phase 2 (PR #298) + +--- + +## 1. Overview + +The solver service extends Silo's job queue system with assembly constraint solving capabilities. It enables server-side solving of assemblies stored in Silo, with results streamed back to clients in real time via SSE. + +This specification describes how the existing KCSolve client-side API (C++ library + pybind11 `kcsolve` module) integrates with Silo's worker infrastructure to provide headless, asynchronous constraint solving. + +### 1.1 Goals + +1. **Offload solving** -- Move heavy solve operations off the user's machine to server workers. +2. **Batch validation** -- Automatically validate assemblies on commit (e.g. check for over-constrained systems). +3. **Solver selection** -- Allow the server to run different solvers than the client (e.g. a more thorough solver for validation, a fast one for interactive editing). +4. **Standalone execution** -- Solver workers can run without a full FreeCAD installation, using just the `kcsolve` Python module and the `.kc` file. + +### 1.2 Non-Goals + +- **Interactive drag** -- Real-time drag solving stays client-side (latency-sensitive). +- **Geometry processing** -- Workers don't compute geometry; they receive pre-extracted constraint graphs. +- **Solver development** -- Writing new solver backends is out of scope; this spec covers the transport and execution layer. + +--- + +## 2. Architecture + +``` + ┌─────────────────────┐ + │ Kindred Create │ + │ (FreeCAD client) │ + └───────┬──────────────┘ + │ 1. POST /api/solver/jobs + │ (SolveContext JSON) + │ + │ 4. GET /api/events (SSE) + │ solver.progress, solver.completed + ▼ + ┌─────────────────────┐ + │ Silo Server │ + │ (silod) │ + │ │ + │ solver module │ + │ REST + SSE + queue │ + └───────┬──────────────┘ + │ 2. POST /api/runner/claim + │ 3. POST /api/runner/jobs/{id}/complete + ▼ + ┌─────────────────────┐ + │ Solver Runner │ + │ (silorunner) │ + │ │ + │ kcsolve module │ + │ OndselAdapter │ + │ Python solvers │ + └─────────────────────┘ +``` + +### 2.1 Components + +| Component | Role | Deployment | +|-----------|------|------------| +| **Silo server** | Job queue management, REST API, SSE broadcast, result storage | Existing `silod` binary | +| **Solver runner** | Claims solver jobs, executes `kcsolve`, reports results | New runner tag `solver` on existing `silorunner` | +| **kcsolve module** | Python/C++ solver library (Phase 1+2) | Installed on runner nodes | +| **Create client** | Submits jobs, receives results via SSE | Existing FreeCAD client | + +### 2.2 Module Registration + +The solver service is a Silo module with ID `solver`, gated behind the existing module system: + +```yaml +# config.yaml +modules: + solver: + enabled: true +``` + +It depends on the `jobs` module being enabled. All solver endpoints return `404` with `{"error": "module not enabled"}` when disabled. + +--- + +## 3. Data Model + +### 3.1 SolveContext JSON Schema + +The `SolveContext` is the input to a solve operation. Currently it exists only as a C++ struct and pybind11 binding with no serialization. Phase 3 adds JSON serialization to enable server transport. + +```json +{ + "api_version": 1, + "parts": [ + { + "id": "Part001", + "placement": { + "position": [0.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "mass": 1.0, + "grounded": true + }, + { + "id": "Part002", + "placement": { + "position": [100.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "mass": 1.0, + "grounded": false + } + ], + "constraints": [ + { + "id": "Joint001", + "part_i": "Part001", + "marker_i": { + "position": [50.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "part_j": "Part002", + "marker_j": { + "position": [0.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "type": "Revolute", + "params": [], + "limits": [], + "activated": true + } + ], + "motions": [], + "simulation": null, + "bundle_fixed": false +} +``` + +**Field reference:** See [KCSolve Python API](../reference/kcsolve-python.md) for full field documentation. The JSON schema maps 1:1 to the Python/C++ types. + +**Enum serialization:** Enums serialize as strings matching their Python names (e.g. `"Revolute"`, `"Success"`, `"Redundant"`). + +**Transform shorthand:** The `placement` and `marker_*` fields use the `Transform` struct: `position` is `[x, y, z]`, `quaternion` is `[w, x, y, z]`. + +**Constraint.Limit:** +```json +{ + "kind": "RotationMin", + "value": -1.5708, + "tolerance": 1e-9 +} +``` + +**MotionDef:** +```json +{ + "kind": "Rotational", + "joint_id": "Joint001", + "marker_i": "", + "marker_j": "", + "rotation_expr": "2*pi*t", + "translation_expr": "" +} +``` + +**SimulationParams:** +```json +{ + "t_start": 0.0, + "t_end": 2.0, + "h_out": 0.04, + "h_min": 1e-9, + "h_max": 1.0, + "error_tol": 1e-6 +} +``` + +### 3.2 SolveResult JSON Schema + +```json +{ + "status": "Success", + "placements": [ + { + "id": "Part002", + "placement": { + "position": [50.0, 0.0, 0.0], + "quaternion": [0.707, 0.0, 0.707, 0.0] + } + } + ], + "dof": 1, + "diagnostics": [ + { + "constraint_id": "Joint003", + "kind": "Redundant", + "detail": "6 DOF removed by Joint003 are already constrained" + } + ], + "num_frames": 0 +} +``` + +### 3.3 Solver Job Record + +Solver jobs are stored in the existing `jobs` table. The solver-specific data is in the `args` and `result` JSONB columns. + +**Job args (input):** +```json +{ + "solver": "ondsel", + "operation": "solve", + "context": { /* SolveContext JSON */ }, + "item_part_number": "ASM-001", + "revision_number": 3 +} +``` + +**Operation types:** +| Operation | Description | Requires simulation? | +|-----------|-------------|---------------------| +| `solve` | Static equilibrium solve | No | +| `diagnose` | Constraint analysis only (no placement update) | No | +| `kinematic` | Time-domain kinematic simulation | Yes | + +**Job result (output):** +```json +{ + "result": { /* SolveResult JSON */ }, + "solver_name": "OndselSolver (Lagrangian)", + "solver_version": "1.0", + "solve_time_ms": 127.4 +} +``` + +--- + +## 4. REST API + +All endpoints are prefixed with `/api/solver/` and gated behind `RequireModule("solver")`. + +### 4.1 Submit Solve Job + +``` +POST /api/solver/jobs +Authorization: Bearer silo_... +Content-Type: application/json + +{ + "solver": "ondsel", + "operation": "solve", + "context": { /* SolveContext */ }, + "priority": 50 +} +``` + +**Optional fields:** +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `solver` | string | `""` (default solver) | Solver name from registry | +| `operation` | string | `"solve"` | `solve`, `diagnose`, or `kinematic` | +| `context` | object | required | SolveContext JSON | +| `priority` | int | `50` | Lower = higher priority | +| `item_part_number` | string | `null` | Silo item reference (for result association) | +| `revision_number` | int | `null` | Revision that generated this context | +| `callback_url` | string | `null` | Webhook URL for completion notification | + +**Response `201 Created`:** +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending", + "created_at": "2026-02-19T18:30:00Z" +} +``` + +**Error responses:** +| Code | Condition | +|------|-----------| +| `400` | Invalid SolveContext (missing required fields, unknown enum values) | +| `401` | Not authenticated | +| `404` | Module not enabled | +| `422` | Unknown solver name, invalid operation | + +### 4.2 Get Job Status + +``` +GET /api/solver/jobs/{jobID} +``` + +**Response `200 OK`:** +```json +{ + "job_id": "550e8400-...", + "status": "completed", + "operation": "solve", + "solver": "ondsel", + "priority": 50, + "item_part_number": "ASM-001", + "revision_number": 3, + "runner_id": "runner-01", + "runner_name": "solver-worker-01", + "created_at": "2026-02-19T18:30:00Z", + "claimed_at": "2026-02-19T18:30:01Z", + "completed_at": "2026-02-19T18:30:02Z", + "result": { + "result": { /* SolveResult */ }, + "solver_name": "OndselSolver (Lagrangian)", + "solve_time_ms": 127.4 + } +} +``` + +### 4.3 List Solver Jobs + +``` +GET /api/solver/jobs?status=completed&item=ASM-001&limit=20&offset=0 +``` + +**Query parameters:** +| Param | Type | Description | +|-------|------|-------------| +| `status` | string | Filter by status: `pending`, `claimed`, `running`, `completed`, `failed` | +| `item` | string | Filter by item part number | +| `operation` | string | Filter by operation type | +| `solver` | string | Filter by solver name | +| `limit` | int | Page size (default 20, max 100) | +| `offset` | int | Pagination offset | + +**Response `200 OK`:** +```json +{ + "jobs": [ /* array of job objects */ ], + "total": 42, + "limit": 20, + "offset": 0 +} +``` + +### 4.4 Cancel Job + +``` +POST /api/solver/jobs/{jobID}/cancel +``` + +Only `pending` and `claimed` jobs can be cancelled. Running jobs must complete or time out. + +**Response `200 OK`:** +```json +{ + "job_id": "550e8400-...", + "status": "cancelled" +} +``` + +### 4.5 Get Solver Registry + +``` +GET /api/solver/solvers +``` + +Returns available solvers on registered runners. Runners report their solver capabilities during heartbeat. + +**Response `200 OK`:** +```json +{ + "solvers": [ + { + "name": "ondsel", + "display_name": "OndselSolver (Lagrangian)", + "deterministic": true, + "supported_joints": [ + "Coincident", "Fixed", "Revolute", "Cylindrical", + "Slider", "Ball", "Screw", "Gear", "RackPinion", + "Parallel", "Perpendicular", "Angle", "Planar", + "Concentric", "PointOnLine", "PointInPlane", + "LineInPlane", "Tangent", "DistancePointPoint", + "DistanceCylSph", "Universal" + ], + "runner_count": 2 + } + ], + "default_solver": "ondsel" +} +``` + +--- + +## 5. Server-Sent Events + +Solver jobs emit events on the existing `/api/events` SSE stream. + +### 5.1 Event Types + +| Event | Payload | When | +|-------|---------|------| +| `solver.created` | `{job_id, operation, solver, item_part_number}` | Job submitted | +| `solver.claimed` | `{job_id, runner_id, runner_name}` | Runner starts work | +| `solver.progress` | `{job_id, progress, message}` | Progress update (0-100) | +| `solver.completed` | `{job_id, status, dof, diagnostics_count, solve_time_ms}` | Job succeeded | +| `solver.failed` | `{job_id, error_message}` | Job failed | + +### 5.2 Example Stream + +``` +event: solver.created +data: {"job_id":"abc-123","operation":"solve","solver":"ondsel","item_part_number":"ASM-001"} + +event: solver.claimed +data: {"job_id":"abc-123","runner_id":"r1","runner_name":"solver-worker-01"} + +event: solver.progress +data: {"job_id":"abc-123","progress":50,"message":"Building constraint system..."} + +event: solver.completed +data: {"job_id":"abc-123","status":"Success","dof":3,"diagnostics_count":1,"solve_time_ms":127.4} +``` + +### 5.3 Client Integration + +The Create client subscribes to the SSE stream and updates the Assembly workbench UI: + +1. **Silo viewport widget** shows job status indicator (pending/running/done/failed) +2. On `solver.completed`, the client can fetch the full result via `GET /api/solver/jobs/{id}` and apply placements +3. On `solver.failed`, the client shows the error in the report panel +4. Diagnostic results (redundant/conflicting constraints) surface in the constraint tree + +--- + +## 6. Runner Integration + +### 6.1 Runner Requirements + +Solver runners are standard `silorunner` instances with the `solver` tag. They require: + +- Python 3.11+ with `kcsolve` module installed +- `libKCSolve.so` and solver backend libraries (e.g. `libOndselSolver.so`) +- Network access to the Silo server + +No FreeCAD installation is required. The runner operates on pre-extracted `SolveContext` JSON. + +### 6.2 Runner Registration + +```bash +# Register a solver runner (admin) +curl -X POST https://silo.example.com/api/runners \ + -H "Authorization: Bearer admin_token" \ + -d '{"name":"solver-01","tags":["solver"]}' + +# Response includes one-time token +{"id":"uuid","token":"silo_runner_xyz..."} +``` + +### 6.3 Runner Heartbeat + +Runners report solver capabilities during heartbeat: + +```json +POST /api/runner/heartbeat +{ + "capabilities": { + "solvers": ["ondsel"], + "api_version": 1, + "python_version": "3.11.11" + } +} +``` + +### 6.4 Runner Execution Flow + +```python +#!/usr/bin/env python3 +"""Solver runner entry point.""" + +import json +import kcsolve + + +def execute_solve_job(args: dict) -> dict: + """Execute a solver job from parsed args.""" + solver_name = args.get("solver", "") + operation = args.get("operation", "solve") + ctx_dict = args["context"] + + # Deserialize SolveContext from JSON + ctx = kcsolve.SolveContext.from_dict(ctx_dict) + + # Load solver + solver = kcsolve.load(solver_name) + if solver is None: + raise ValueError(f"Unknown solver: {solver_name!r}") + + # Execute operation + if operation == "solve": + result = solver.solve(ctx) + elif operation == "diagnose": + diags = solver.diagnose(ctx) + result = kcsolve.SolveResult() + result.diagnostics = diags + elif operation == "kinematic": + result = solver.run_kinematic(ctx) + else: + raise ValueError(f"Unknown operation: {operation!r}") + + # Serialize result + return { + "result": result.to_dict(), + "solver_name": solver.name(), + "solver_version": "1.0", + } +``` + +### 6.5 Standalone Process Mode + +For minimal deployments, the runner can invoke a standalone solver process: + +```bash +echo '{"solver":"ondsel","operation":"solve","context":{...}}' | \ + python3 -m kcsolve.runner +``` + +The `kcsolve.runner` module reads JSON from stdin, executes the solve, and writes the result JSON to stdout. Exit code 0 = success, non-zero = failure with error JSON on stderr. + +--- + +## 7. Job Definitions + +### 7.1 Manual Solve Job + +Triggered by the client when the user requests a server-side solve: + +```yaml +job: + name: assembly-solve + version: 1 + description: "Solve assembly constraints on server" + + trigger: + type: manual + + scope: + type: assembly + + compute: + type: solver + command: solver-run + + runner: + tags: [solver] + + timeout: 300 + max_retries: 1 + priority: 50 +``` + +### 7.2 Commit-Time Validation + +Automatically validates assembly constraints when a new revision is committed: + +```yaml +job: + name: assembly-validate + version: 1 + description: "Validate assembly constraints on commit" + + trigger: + type: revision_created + filter: + item_type: assembly + + scope: + type: assembly + + compute: + type: solver + command: solver-diagnose + args: + operation: diagnose + + runner: + tags: [solver] + + timeout: 120 + max_retries: 2 + priority: 75 +``` + +### 7.3 Kinematic Simulation + +Server-side kinematic simulation for assemblies with motion definitions: + +```yaml +job: + name: assembly-kinematic + version: 1 + description: "Run kinematic simulation" + + trigger: + type: manual + + scope: + type: assembly + + compute: + type: solver + command: solver-kinematic + args: + operation: kinematic + + runner: + tags: [solver] + + timeout: 1800 + max_retries: 0 + priority: 100 +``` + +--- + +## 8. SolveContext Extraction + +When a solver job is triggered by a revision commit (rather than a direct context submission), the server or runner must extract a `SolveContext` from the `.kc` file. + +### 8.1 Extraction via Headless Create + +For full-fidelity extraction that handles geometry classification: + +```bash +create --console -e " +import kcsolve_extract +kcsolve_extract.extract_and_solve('input.kc', 'output.json', solver='ondsel') +" +``` + +This requires a full Create installation on the runner and uses the Assembly module's existing adapter layer to build `SolveContext` from document objects. + +### 8.2 Extraction from .kc Silo Directory + +For lightweight extraction without FreeCAD, the constraint graph can be stored in the `.kc` archive's `silo/` directory during commit: + +``` +silo/solver/context.json # Pre-extracted SolveContext +silo/solver/result.json # Last solve result (if any) +``` + +The client extracts the `SolveContext` locally before committing the `.kc` file. The server reads it from the archive, avoiding the need for geometry processing on the runner. + +**Commit-time packing** (client side): +```python +# In the Assembly workbench commit hook: +ctx = assembly_object.build_solve_context() +kc_archive.write("silo/solver/context.json", ctx.to_json()) +``` + +**Runner-side extraction:** +```python +import zipfile, json + +with zipfile.ZipFile("assembly.kc") as zf: + ctx_json = json.loads(zf.read("silo/solver/context.json")) +``` + +--- + +## 9. Database Schema + +### 9.1 Migration + +The solver module uses the existing `jobs` table. One new table is added for result caching: + +```sql +-- Migration: 020_solver_results.sql + +CREATE TABLE solver_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, + operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic' + solver_name TEXT NOT NULL, + status TEXT NOT NULL, -- SolveStatus string + dof INTEGER, + diagnostics JSONB DEFAULT '[]', + placements JSONB DEFAULT '[]', + num_frames INTEGER DEFAULT 0, + solve_time_ms DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(item_id, revision_number, operation) +); + +CREATE INDEX idx_solver_results_item ON solver_results(item_id); +CREATE INDEX idx_solver_results_status ON solver_results(status); +``` + +The `UNIQUE(item_id, revision_number, operation)` constraint means each revision has at most one result per operation type. Re-running overwrites the previous result. + +### 9.2 Result Association + +When a solver job completes, the server: +1. Stores the full result in the `jobs.result` JSONB column (standard job result) +2. Upserts a row in `solver_results` for quick lookup by item/revision +3. Broadcasts `solver.completed` SSE event + +--- + +## 10. Configuration + +### 10.1 Server Config + +```yaml +# config.yaml +modules: + solver: + enabled: true + default_solver: "ondsel" + max_context_size_mb: 10 # Reject oversized SolveContext payloads + default_timeout: 300 # Default job timeout (seconds) + auto_diagnose_on_commit: true # Auto-submit diagnose job on revision commit +``` + +### 10.2 Environment Variables + +| Variable | Description | +|----------|-------------| +| `SILO_SOLVER_ENABLED` | Override module enabled state | +| `SILO_SOLVER_DEFAULT` | Default solver name | + +### 10.3 Runner Config + +```yaml +# runner.yaml +server_url: https://silo.example.com +token: silo_runner_xyz... +tags: [solver] + +solver: + kcsolve_path: /opt/create/lib # LD_LIBRARY_PATH for kcsolve.so + python: /opt/create/bin/python3 + max_concurrent: 2 # Parallel job slots per runner +``` + +--- + +## 11. Security + +### 11.1 Authentication + +All solver endpoints use the existing Silo authentication: +- **User endpoints** (`/api/solver/jobs`): Session or API token, requires `viewer` role to read, `editor` role to submit +- **Runner endpoints** (`/api/runner/...`): Runner token authentication (existing) + +### 11.2 Input Validation + +The server validates SolveContext JSON before queuing: +- Maximum payload size (configurable, default 10 MB) +- Required fields present (`parts`, `constraints`) +- Enum values are valid strings +- Transform arrays have correct length (position: 3, quaternion: 4) +- No duplicate part or constraint IDs + +### 11.3 Runner Isolation + +Solver runners execute untrusted constraint data. Mitigations: +- Runners should run in containers or sandboxed environments +- Python solver registration (`kcsolve.register_solver()`) is disabled in runner mode +- Solver execution has a configurable timeout (killed on expiry) +- Result size is bounded (large kinematic simulations are truncated) + +--- + +## 12. Client SDK + +### 12.1 Python Client + +The existing `silo-client` package is extended with solver methods: + +```python +from silo_client import SiloClient + +client = SiloClient("https://silo.example.com", token="silo_...") + +# Submit a solve job +import kcsolve +ctx = kcsolve.SolveContext() +# ... build context ... + +job = client.solver.submit(ctx.to_dict(), solver="ondsel") +print(job.id, job.status) # "pending" + +# Poll for completion +result = client.solver.wait(job.id, timeout=60) +print(result.status) # "Success" + +# Or use SSE for real-time updates +for event in client.solver.stream(job.id): + print(event.type, event.data) + +# Query results for an item +results = client.solver.results("ASM-001") +``` + +### 12.2 Create Workbench Integration + +The Assembly workbench adds a "Solve on Server" command: + +```python +# CommandSolveOnServer.py (sketch) +def activated(self): + assembly = get_active_assembly() + ctx = assembly.build_solve_context() + + # Submit to Silo + from silo_client import get_client + client = get_client() + job = client.solver.submit(ctx.to_dict()) + + # Subscribe to SSE for updates + self.watch_job(job.id) + +def on_solver_completed(self, job_id, result): + # Apply placements back to assembly + assembly = get_active_assembly() + for pr in result["placements"]: + assembly.set_part_placement(pr["id"], pr["placement"]) + assembly.recompute() +``` + +--- + +## 13. Implementation Plan + +### Phase 3a: JSON Serialization + +Add `to_dict()` / `from_dict()` methods to all KCSolve types in the pybind11 module. + +**Files to modify:** +- `src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp` -- add dict conversion methods + +**Verification:** `ctx.to_dict()` round-trips through `SolveContext.from_dict()`. + +### Phase 3b: Server Endpoints + +Add the solver module to the Silo server. + +**Files to create (in silo repository):** +- `internal/modules/solver/solver.go` -- Module registration and config +- `internal/modules/solver/handlers.go` -- REST endpoint handlers +- `internal/modules/solver/events.go` -- SSE event definitions +- `migrations/020_solver_results.sql` -- Database migration + +### Phase 3c: Runner Support + +Add solver job execution to `silorunner`. + +**Files to create:** +- `src/Mod/Assembly/Solver/bindings/runner.py` -- `kcsolve.runner` entry point +- Runner capability reporting during heartbeat + +### Phase 3d: .kc Context Packing + +Pack `SolveContext` into `.kc` archives on commit. + +**Files to modify:** +- `mods/silo/freecad/silo_origin.py` -- Hook into commit to pack solver context + +### Phase 3e: Client Integration + +Add "Solve on Server" command to the Assembly workbench. + +**Files to modify:** +- `mods/silo/freecad/` -- Solver client methods +- `src/Mod/Assembly/` -- Server solve command + +--- + +## 14. Open Questions + +1. **Context size limits** -- Large assemblies may produce multi-MB SolveContext JSON. Should we compress (gzip) or use a binary format (msgpack)? + +2. **Result persistence** -- How long should solver results be retained? Per-revision (overwritten on next commit) or historical (keep all)? + +3. **Kinematic frame storage** -- Kinematic simulations can produce thousands of frames. Store all frames in JSONB, or write to a separate file and reference it? + +4. **Multi-solver comparison** -- Should the API support running the same context through multiple solvers and comparing results? Useful for Phase 4 (second solver validation). + +5. **Webhook notifications** -- The `callback_url` field allows external integrations (e.g. CI). What authentication should the webhook use? + +--- + +## 15. References + +- [KCSolve Architecture](../architecture/ondsel-solver.md) +- [KCSolve Python API Reference](../reference/kcsolve-python.md) +- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- Full pluggable solver spec +- [WORKERS.md](WORKERS.md) -- Worker/runner job system +- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server specification +- [MODULES.md](MODULES.md) -- Module system -- 2.49.1