Compare commits

..

21 Commits

Author SHA1 Message Date
forbes
a8fc1388ba 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
2026-02-19 19:22:51 -06:00
forbes
bd43e62822 docs(kcsolve): expand Python API reference with full method docs
All checks were successful
Build and Test / build (pull_request) Successful in 29m49s
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).
2026-02-19 19:06:08 -06:00
forbes
406e120180 docs: KCSolve architecture and Python API reference
All checks were successful
Build and Test / build (pull_request) Successful in 28m58s
- Replace OndselSolver architecture doc with KCSolve pluggable solver
  architecture covering IKCSolver interface, SolverRegistry, OndselAdapter,
  Python bindings, file layout, and testing
- Add kcsolve Python API reference with full type documentation, module
  functions, usage examples, and pybind11 vector-copy caveat
- Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2
  marked as complete
- Update SUMMARY.md with new page links
2026-02-19 18:59:05 -06:00
forbes
7ea0078ba3 feat(kcsolve): pybind11 bindings and Python solver support
All checks were successful
Build and Test / build (pull_request) Successful in 29m19s
Add the kcsolve pybind11 module exposing the KCSolve C++ API to Python:

- PyIKCSolver trampoline enabling Python IKCSolver subclasses
- Bindings for all 5 enums, 10 structs, IKCSolver, and OndselAdapter
- Module functions wrapping SolverRegistry (available, load, joints_for,
  set_default, get_default, register_solver)
- PySolverHolder class forwarding virtual calls with GIL acquisition
- register_solver() for runtime Python solver registration

IKCSolver constructor moved from protected to public for pybind11
trampoline access (class remains abstract via 3 pure virtuals).

Includes 16 Python tests covering module import, type bindings, enum
values, registry functions, Python solver subclassing, and full
register/load/solve round-trip.

Closes #288
2026-02-19 18:04:49 -06:00
f20ae3a667 Merge pull request 'feat(assembly): pluggable solver system — Phase 1 (#287)' (#297) from feat/solver-api-types into main
All checks were successful
Build and Test / build (push) Successful in 29m44s
Reviewed-on: #297
2026-02-19 22:58:33 +00:00
forbes
934cdf5767 test(assembly): regression tests for KCSolve solver refactor (#296)
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
Phase 1e: Add C++ gtest and Python unittest coverage for the pluggable
solver system introduced in Phases 1a-1d.

C++ tests (KCSolve_tests_run):
- SolverRegistryTest (8 tests): register/get, duplicates, defaults,
  available list, joints_for capability queries
- OndselAdapterTest (10 tests): identity checks, supported/unsupported
  joints, Fixed/Revolute solve round-trips, no-grounded-parts handling,
  exception safety, drag protocol (pre_drag/drag_step/post_drag),
  redundant constraint diagnostics

Python tests (TestSolverIntegration):
- Full-stack solve via AssemblyObject → IKCSolver → OndselAdapter
- Fixed joint placement matching, revolute joint success
- No-ground error code (-6), redundancy detection (-2)
- ASMT export produces non-empty file
- Deterministic solve stability (solve twice → same result)
2026-02-19 16:56:11 -06:00
forbes
5c33aacecb feat(solver): refactor AssemblyObject to use IKCSolver interface (#295)
Rewire AssemblyObject to call through KCSolve::IKCSolver instead of
directly manipulating OndselSolver ASMT types.

Key changes:
- Remove all 30+ #include <OndselSolver/*> from AssemblyObject.cpp
- Remove MbDPartData, objectPartMap, mbdAssembly members
- Add solver_ (IKCSolver), lastResult_ (SolveResult), partIdToObjs_ maps
- New buildSolveContext() builds SolveContext from FreeCAD document objects
  with JointType/DistanceType -> BaseJointKind decomposition
- New computeMarkerTransform() replaces handleOneSideOfJoint()
- New computeRackPinionMarkers() replaces getRackPinionMarkers()
- Rewrite solve/preDrag/doDragStep/postDrag/generateSimulation to call
  through IKCSolver interface
- Rewrite setNewPlacements/validateNewPlacements to use SolveResult
- Rewrite updateSolveStatus to use ConstraintDiagnostic
- Add export_native() to IKCSolver for ASMT export support
- Register OndselAdapter at Assembly module init in AppAssembly.cpp
- Remove OndselSolver from Assembly_LIBS (linked transitively via KCSolve)

Assembly module now has zero OndselSolver includes. All solver coupling
is confined to KCSolve/OndselAdapter.cpp.
2026-02-19 16:43:52 -06:00
forbes
32dbe20ce0 feat(solver): implement OndselAdapter wrapping OndselSolver behind IKCSolver (#294)
Phase 1c of the pluggable solver system. Creates OndselAdapter — the
concrete IKCSolver implementation that wraps OndselSolver's Lagrangian
MBD engine behind the KCSolve abstraction layer.

The adapter translates KCSolve types (SolveContext, BaseJointKind,
Transform) to OndselSolver's ASMT hierarchy (ASMTAssembly, ASMTPart,
ASMTJoint, ASMTMarker) and extracts results back into SolveResult.
All 30+ OndselSolver #includes are confined to OndselAdapter.cpp.

Key implementation details:
- build_assembly(): creates ASMTAssembly from SolveContext
- create_joint(): maps 20 BaseJointKind values to ASMT joint types
  (eliminates the 35-case DistanceType switch — decomposition done upstream)
- Quaternion-to-rotation-matrix conversion for OndselSolver input
- Direct quaternion extraction for output (both use w,x,y,z convention)
- Drag lifecycle: pre_drag/drag_step/post_drag with stateful assembly
- Simulation lifecycle: run_kinematic/num_frames/update_for_frame
- Diagnostic extraction: iterates MBD system constraints for redundancy
- Static register_solver() for SolverRegistry integration
- ExternalSystem back-pointer NOT set — breaks bidirectional coupling

New files:
- Solver/OndselAdapter.h — class declaration with KCSolveExport
- Solver/OndselAdapter.cpp — full implementation (~530 lines)

Modified:
- Solver/CMakeLists.txt — add sources, link OndselSolver (PRIVATE)
2026-02-19 16:19:44 -06:00
forbes
76b91c6597 feat(solver): implement SolverRegistry with plugin loading (#293)
Phase 1b of the pluggable solver system. Converts KCSolve from a
header-only INTERFACE target to a SHARED library and implements
the SolverRegistry with dynamic plugin discovery.

Changes:
- Add KCSolveGlobal.h export macro header (KCSolveExport)
- Move SolverRegistry method bodies from header to SolverRegistry.cpp
- Implement scan() with dlopen/LoadLibrary plugin loading
- Add scan_default_paths() for KCSOLVE_PLUGIN_PATH + system paths
- Plugin entry points: kcsolve_api_version() + kcsolve_create()
- API version checking (major version compatibility)
- Convert CMakeLists.txt from INTERFACE to SHARED library
- Link FreeCADBase (PRIVATE) for Console logging
- Link dl on POSIX for dynamic loading
- Fix -Wmissing-field-initializers warnings in IKCSolver.h defaults

The registry discovers plugins by scanning directories for shared
libraries that export the kcsolve C entry points. Plugins are
validated for API version compatibility before registration.
Manual registration via register_solver() remains available for
built-in solvers (e.g. OndselAdapter in Phase 1c).
2026-02-19 16:07:37 -06:00
forbes
47e6c14461 feat(solver): define IKCSolver C++ API types and interface (#292)
Add the pluggable solver API as header-only files under
src/Mod/Assembly/Solver/. This is Phase 1a of the pluggable solver
system (INTER_SOLVER.md).

New files:
- Types.h: BaseJointKind enum (24 decomposed constraint types),
  Transform, Part, Constraint, SolveContext, SolveResult, and
  supporting types. Uses standalone types (no FreeCAD dependency)
  for future server worker compatibility.
- IKCSolver.h: Abstract solver interface with solve(), drag protocol
  (pre_drag/drag_step/post_drag), kinematic simulation
  (run_kinematic/num_frames/update_for_frame), and diagnostics.
  Only solve(), name(), and supported_joints() are pure virtual;
  all other methods have default implementations.
- SolverRegistry.h: Thread-safe singleton registry for solver
  backends with factory-based registration and default solver
  selection.
- CMakeLists.txt: INTERFACE library target (header-only for now).

Modified:
- Assembly/CMakeLists.txt: add_subdirectory(Solver)
- Assembly/App/CMakeLists.txt: link KCSolve INTERFACE target
2026-02-19 15:55:57 -06:00
551979b441 Merge pull request 'chore: configure Kindred submodules to track main branch' (#286) from chore/submodule-branch-tracking into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #286
2026-02-19 20:58:27 +00:00
Kindred Bot
8897399a10 docs: sync Silo server documentation
Some checks failed
Build and Test / build (push) Has been cancelled
Deploy Docs / build-and-deploy (push) Successful in 51s
Auto-synced from kindred/silo main branch.
2026-02-19 20:58:03 +00:00
forbes
6dc4341a5f chore: configure Kindred submodules to track main branch
All checks were successful
Build and Test / build (pull_request) Successful in 29m19s
Add branch = main to mods/silo and mods/ztools in .gitmodules.
Enables 'git submodule update --remote' to auto-advance to latest main.
Third-party deps (GSL, googletest, AddonManager) remain pinned.
2026-02-19 14:57:31 -06:00
ab6d09c138 Merge pull request 'chore: update submodule pointers to latest main' (#285) from fix/submodule-pointers into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #285
2026-02-19 20:52:46 +00:00
forbes
133af52f11 chore: update submodule pointers to latest main
All checks were successful
Build and Test / build (pull_request) Successful in 29m12s
- mods/silo: f67d9a0 (feature branch) -> 43e905c (main, includes merged #49)
- mods/ztools: 296a94e (pre-rebase) -> 08e439b (rebased on main, includes theme removal)
2026-02-19 14:52:22 -06:00
0bc2cf3b6a Merge pull request 'feat(ztools): migrate to kindred_sdk palette system (#278)' (#283) from feat/ztools-sdk-migration into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #283
2026-02-19 20:48:37 +00:00
forbes
0330396843 refactor: extract theme from ztools into base distribution (#278)
All checks were successful
Build and Test / build (pull_request) Successful in 29m25s
- Add Spreadsheet color preferences to KindredCreate.cfg using FCText
  entries (TextColor, AliasedCellBackgroundColor, PositiveNumberColor,
  NegativeNumberColor) matching the C++ GetASCII() reader in SheetModel.cpp
- Remove CatppuccinMocha install directive from CMakeLists.txt
- Update ztools submodule: theme.py deleted, CatppuccinMocha preference
  pack removed, package.xml cleaned up

The previous apply_spreadsheet_colors() in ztools was a no-op: it called
SetUnsigned() but the Spreadsheet C++ reads GetASCII() — different param
types in FreeCAD's parameter system. Now properly fixed via preference pack.

Closes #278
2026-02-19 14:46:59 -06:00
7fe046379b Merge pull request 'feat: BOM auto-extraction from Assembly links + manifest field population' (#282) from feat/bom-sync-and-manifest into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 37s
Build and Test / build (push) Has been cancelled
Reviewed-on: #282
2026-02-19 20:23:22 +00:00
forbes
0bc03ea421 feat: BOM auto-extraction and manifest field population (#276, #277)
All checks were successful
Build and Test / build (pull_request) Successful in 29m22s
Documentation updates:
- KNOWN_ISSUES.md: correct #6 (datum handling), resolve #10
  (delete_bom_entry) and #11 (silo icons), fix stale formatDate
  reference, mark completed next steps, add new next steps.
- INTEGRATION_PLAN.md: correct ztools SDK migration claim.

kc_format.py (#277):
- New _manifest_enrich_hook: populates part_uuid from SiloItemId and
  silo_instance from Silo API URL on every .kc save.
- New update_manifest_fields(): public API to update manifest fields
  in an already-saved .kc ZIP (used for post-upload revision_hash).

mods/silo submodule (#276):
- New bom_sync.py extraction engine.
- Post-commit hooks for BOM sync and manifest revision update.
- SSE bom_merged signal + Activity pane handler.
- merge_bom_json client method (forward-looking).

Refs: #276, #277
2026-02-19 12:37:24 -06:00
0c43957e9b Merge pull request 'chore: update mods/silo submodule pointer to main' (#275) from fix/silo-submodule-pointer into main
All checks were successful
Build and Test / build (push) Successful in 30m4s
Sync Silo Server Docs / sync (push) Successful in 41s
Reviewed-on: #275
2026-02-19 01:58:49 +00:00
forbes
2ce00a527a chore: update mods/silo pointer to main
All checks were successful
Build and Test / build (pull_request) Successful in 28m57s
Points to silo-mod main (af98994) which includes the correct
silo-client submodule pointer at main (285bd1f).
2026-02-18 19:58:30 -06:00
37 changed files with 6533 additions and 1168 deletions

2
.gitmodules vendored
View File

@@ -13,6 +13,8 @@
[submodule "mods/ztools"]
path = mods/ztools
url = https://git.kindred-systems.com/forbes/ztools.git
branch = main
[submodule "mods/silo"]
path = mods/silo
url = https://git.kindred-systems.com/kindred/silo-mod.git
branch = main

View File

@@ -111,7 +111,7 @@ Pure Python addons with `package.xml` manifests. Self-contained with `Init.py`,
**Implementation:** `mods/sdk/kindred_sdk/` package with wrappers for editing contexts, theme tokens, FileOrigin registration, and dock panel creation. YAML-driven palette system (`palettes/catppuccin-mocha.yaml`) replaces hardcoded color dicts that were duplicated across 5+ locations.
**Migration:** Both ztools and Silo have been migrated to use the SDK (#250). No addon code calls `FreeCADGui.registerEditingOverlay()`, `FreeCADGui.addOrigin()`, or defines hardcoded MOCHA dicts directly.
**Migration:** Silo has been migrated to use the SDK (#250) — it declares `<dependency>sdk</dependency>` and uses `kindred_sdk` for overlay registration and theme tokens. ztools has **not yet been migrated**: its `package.xml` lacks `<kindred>` metadata and its `InitGui.py` does not import from `kindred_sdk`. See KNOWN_ISSUES.md next step #9.
### Phase 1.75: C++ module scaffold -- DONE

568
docs/INTER_SOLVER.md Normal file
View File

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

View File

@@ -16,7 +16,7 @@
5. **No unit tests.** Zero test coverage for ztools and Silo FreeCAD commands. Silo Go backend also lacks tests.
6. **Assembly solver datum handling is minimal.** The `findPlacement()` fix in `src/Mod/Assembly/UtilsAssembly.py` extracts placement from `obj.Shape.Faces[0]` for `PartDesign::Plane` and from shape vertex for `PartDesign::Point`. Does not handle empty shapes or non-planar datum objects.
6. **Assembly solver datum handling is minimal.** `UtilsAssembly.findPlacement()` handles standard shapes (faces, edges, vertices) and `App::Line` origin objects. It does not extract placement from `PartDesign::Plane` or `PartDesign::Point` datum objects — when no element is selected, it returns a default `App.Placement()`. This means assembly joints referencing datum planes/points may produce incorrect placement.
### Medium
@@ -26,9 +26,9 @@
9. **tangent_to_cylinder falls back to manual placement.** TangentPlane MapMode requires a vertex reference not collected by the current UI.
10. **`delete_bom_entry()` bypasses error normalization.** Uses raw `urllib.request` instead of `SiloClient._request()`.
10. ~~**`delete_bom_entry()` bypasses error normalization.**~~ Resolved. `delete_bom_entry()` uses `self._request("DELETE", ...)` which routes through `SiloClient._request()` with proper error handling.
11. **Missing Silo icons.** Three commands reference icons that don't exist: `silo-tag.svg` (`Silo_TagProjects`), `silo-rollback.svg` (`Silo_Rollback`), `silo-status.svg` (`Silo_SetStatus`). The `_icon()` function returns an empty string, so these commands render without toolbar icons.
11. ~~**Missing Silo icons.**~~ Resolved. All three icons now exist: `silo-tag.svg`, `silo-rollback.svg`, `silo-status.svg` in `mods/silo/freecad/resources/icons/`.
### Fixed (retain for reference)
@@ -50,7 +50,7 @@
| CSRF protection | Implemented | `nosurf` library on web form routes |
| File locking | Not implemented | Needed to prevent concurrent edits |
| Odoo ERP integration | Stub only | Returns "not yet implemented" |
| Part number date segments | Broken | `formatDate()` returns error |
| Part number date segments | Unknown | `formatDate()` reference is stale — function not found in codebase |
| Location/inventory APIs | Tables exist, no handlers | |
| CSV import rollback | Not implemented | `bom_handlers.go` |
| SSE event streaming | Implemented | Reconnect logic with exponential backoff |
@@ -71,14 +71,20 @@
1. **Authentication hardening** -- Deploy FreeIPA and Keycloak infrastructure. End-to-end test LDAP and OIDC flows. Harden token rotation and session expiry.
2. **BOM-Assembly bridge** -- Auto-populate Silo BOM from Assembly component links on save.
2. **BOM-Assembly bridge** -- Auto-populate Silo BOM from Assembly component links on save. See `docs/BOM_MERGE.md` for specification.
3. **File locking** -- Pessimistic locks on `Silo_Open` to prevent concurrent edits. Requires server-side lock table and client-side lock display.
4. **Build system** -- CMake install rules for `mods/` submodules so packages include ztools and Silo without manual steps.
4. ~~**Build system**~~ Done. CMake install rules in `src/Mod/Create/CMakeLists.txt` handle all `mods/` submodules.
5. **Test coverage** -- Unit tests for ztools datum creation, Silo FreeCAD commands, and Go API endpoints.
6. **QSS consolidation** -- Eliminate the 3-copy QSS duplication via build-time copy or symlinks. The canonical source is `resources/preferences/KindredCreate/KindredCreate.qss`.
6. ~~**QSS consolidation**~~ Done. Canonical QSS is `src/Gui/Stylesheets/KindredCreate.qss`; PreferencePacks copy generated at build time via `configure_file()`.
7. **Update notification UI** -- Display in-app notification when a new release is available (issue #30). The update checker backend is already implemented.
7. **Update notification UI** -- Display in-app notification when a new release is available (issue #30). The update checker backend (`update_checker.py`) runs at startup; notification UI still needed.
8. **KC file format completion** -- Populate `silo_instance` and `revision_hash` in manifest.json. Implement write-back for history.json, approvals.json, dependencies.json. See `docs/KC_SPECIFICATION.md`.
9. **ztools SDK migration** -- Add `<kindred>` metadata to `mods/ztools/package.xml` (load priority, version bounds, SDK dependency). Migrate `InitGui.py` to use `kindred_sdk` APIs for context/overlay registration.
10. **DAG cross-item edges** -- Assembly constraints referencing geometry in child parts should populate `dag_cross_edges`. Deferred until assembly constraint model is finalized.

View File

@@ -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
@@ -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
@@ -64,3 +65,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)

View File

@@ -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<IKCSolver>`.
## 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<OndselAdapter>();
});
// 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 `<prefix>/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

View File

@@ -0,0 +1,429 @@
# 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
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
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
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
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
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:
```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
```
All other methods are optional and have default implementations. Override them to add capabilities beyond basic solving.
#### 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
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

View File

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

View File

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

View File

@@ -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 `<input type="file" multiple>`.
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)

View File

@@ -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
```

View File

@@ -26,6 +26,8 @@
#include <Base/Interpreter.h>
#include <Base/PyObjectBase.h>
#include <Mod/Assembly/Solver/OndselAdapter.h>
#include "AssemblyObject.h"
#include "AssemblyLink.h"
#include "BomObject.h"
@@ -54,6 +56,10 @@ PyMOD_INIT_FUNC(AssemblyApp)
}
PyObject* mod = Assembly::initModule();
// Register the built-in OndselSolver adapter with the solver registry.
KCSolve::OndselAdapter::register_solver();
Base::Console().log("Loading Assembly module... done\n");

File diff suppressed because it is too large Load Diff

View File

@@ -25,24 +25,21 @@
#ifndef ASSEMBLY_AssemblyObject_H
#define ASSEMBLY_AssemblyObject_H
#include <memory>
#include <boost/signals2.hpp>
#include <Mod/Assembly/AssemblyGlobal.h>
#include <Mod/Assembly/Solver/Types.h>
#include <App/FeaturePython.h>
#include <App/Part.h>
#include <App/PropertyLinks.h>
#include <OndselSolver/enum.h>
namespace MbD
namespace KCSolve
{
class ASMTPart;
class ASMTAssembly;
class ASMTJoint;
class ASMTMarker;
class ASMTPart;
} // namespace MbD
class IKCSolver;
} // namespace KCSolve
namespace App
{
@@ -105,7 +102,6 @@ public:
void exportAsASMT(std::string fileName);
Base::Placement getMbdPlacement(std::shared_ptr<MbD::ASMTPart> mbdPart);
bool validateNewPlacements();
void setNewPlacements();
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
@@ -114,42 +110,8 @@ public:
// This makes sure that LinkGroups or sub-assemblies have identity placements.
void ensureIdentityPlacements();
// Ondsel Solver interface
std::shared_ptr<MbD::ASMTAssembly> makeMbdAssembly();
void create_mbdSimulationParameters(App::DocumentObject* sim);
std::shared_ptr<MbD::ASMTPart> makeMbdPart(
std::string& name,
Base::Placement plc = Base::Placement(),
double mass = 1.0
);
std::shared_ptr<MbD::ASMTPart> getMbDPart(App::DocumentObject* obj);
// To help the solver, during dragging, we are bundling parts connected by a fixed joint.
// So several assembly components are bundled in a single ASMTPart.
// So we need to store the plc of each bundled object relative to the bundle origin (first obj
// of objectPartMap).
struct MbDPartData
{
std::shared_ptr<MbD::ASMTPart> part;
Base::Placement offsetPlc; // This is the offset within the bundled parts
};
MbDPartData getMbDData(App::DocumentObject* part);
std::shared_ptr<MbD::ASMTMarker> makeMbdMarker(std::string& name, Base::Placement& plc);
std::vector<std::shared_ptr<MbD::ASMTJoint>> makeMbdJoint(App::DocumentObject* joint);
std::shared_ptr<MbD::ASMTJoint> makeMbdJointOfType(App::DocumentObject* joint, JointType jointType);
std::shared_ptr<MbD::ASMTJoint> makeMbdJointDistance(App::DocumentObject* joint);
std::string handleOneSideOfJoint(
App::DocumentObject* joint,
const char* propRefName,
const char* propPlcName
);
void getRackPinionMarkers(
App::DocumentObject* joint,
std::string& markerNameI,
std::string& markerNameJ
);
int slidingPartIndex(App::DocumentObject* joint);
void jointParts(std::vector<App::DocumentObject*> joints);
JointGroup* getJointGroup() const;
ViewGroup* getExplodedViewGroup() const;
template<typename T>
@@ -169,8 +131,6 @@ public:
const std::vector<App::DocumentObject*>& excludeJoints = {}
);
std::unordered_set<App::DocumentObject*> getGroundedParts();
std::unordered_set<App::DocumentObject*> fixGroundedParts();
void fixGroundedPart(App::DocumentObject* obj, Base::Placement& plc, std::string& jointName);
bool isJointConnectingPartToGround(App::DocumentObject* joint, const char* partPropName);
bool isJointTypeConnecting(App::DocumentObject* joint);
@@ -210,7 +170,7 @@ public:
std::vector<App::DocumentObject*> getMotionsFromSimulation(App::DocumentObject* sim);
bool isMbDJointValid(App::DocumentObject* joint);
bool isJointValid(App::DocumentObject* joint);
bool isEmpty() const;
int numberOfComponents() const;
@@ -259,12 +219,56 @@ public:
fastsignals::signal<void()> signalSolverUpdate;
private:
std::shared_ptr<MbD::ASMTAssembly> mbdAssembly;
// ── Solver integration ─────────────────────────────────────────
KCSolve::IKCSolver* getOrCreateSolver();
KCSolve::SolveContext buildSolveContext(
const std::vector<App::DocumentObject*>& joints,
bool forSimulation = false,
App::DocumentObject* sim = nullptr
);
KCSolve::Transform computeMarkerTransform(
App::DocumentObject* joint,
const char* propRefName,
const char* propPlcName
);
struct RackPinionResult
{
std::string partIdI;
KCSolve::Transform markerI;
std::string partIdJ;
KCSolve::Transform markerJ;
};
RackPinionResult computeRackPinionMarkers(App::DocumentObject* joint);
// ── Part ↔ solver ID mapping ───────────────────────────────────
// Maps a solver part ID to the FreeCAD objects it represents.
// Multiple objects map to one ID when parts are bundled by Fixed joints.
struct PartMapping
{
App::DocumentObject* obj;
Base::Placement offset; // identity for primary, non-identity for bundled
};
std::unordered_map<std::string, std::vector<PartMapping>> partIdToObjs_;
std::unordered_map<App::DocumentObject*, std::string> objToPartId_;
// Register a part (and recursively its fixed-joint bundle when bundleFixed is set).
// Returns the solver part ID.
std::string registerPart(App::DocumentObject* obj);
// ── Solver state ───────────────────────────────────────────────
std::unique_ptr<KCSolve::IKCSolver> solver_;
KCSolve::SolveResult lastResult_;
// ── Existing state (unchanged) ─────────────────────────────────
std::unordered_map<App::DocumentObject*, MbDPartData> objectPartMap;
std::vector<std::pair<App::DocumentObject*, double>> objMasses;
std::vector<App::DocumentObject*> draggedParts;
std::vector<App::DocumentObject*> motions;
std::vector<std::pair<App::DocumentObject*, Base::Placement>> previousPositions;

View File

@@ -5,7 +5,7 @@ set(Assembly_LIBS
PartDesign
Spreadsheet
FreeCADApp
OndselSolver
KCSolve
)
generate_from_py(AssemblyObject)

View File

@@ -0,0 +1,237 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""Unit tests for the kcsolve pybind11 module."""
import unittest
class TestKCSolveImport(unittest.TestCase):
"""Verify that the kcsolve module loads and exposes expected symbols."""
def test_import(self):
import kcsolve
for sym in (
"IKCSolver",
"OndselAdapter",
"Transform",
"Part",
"Constraint",
"SolveContext",
"SolveResult",
"BaseJointKind",
"SolveStatus",
"available",
"load",
"register_solver",
):
self.assertTrue(hasattr(kcsolve, sym), f"missing symbol: {sym}")
def test_api_version(self):
import kcsolve
self.assertEqual(kcsolve.API_VERSION_MAJOR, 1)
class TestKCSolveTypes(unittest.TestCase):
"""Verify struct/enum bindings behave correctly."""
def test_transform_identity(self):
import kcsolve
t = kcsolve.Transform.identity()
self.assertEqual(list(t.position), [0.0, 0.0, 0.0])
self.assertEqual(list(t.quaternion), [1.0, 0.0, 0.0, 0.0]) # w,x,y,z
def test_part_defaults(self):
import kcsolve
p = kcsolve.Part()
self.assertEqual(p.id, "")
self.assertAlmostEqual(p.mass, 1.0)
self.assertFalse(p.grounded)
def test_solve_context_construction(self):
import kcsolve
ctx = kcsolve.SolveContext()
self.assertEqual(len(ctx.parts), 0)
self.assertEqual(len(ctx.constraints), 0)
p = kcsolve.Part()
p.id = "part1"
# pybind11 def_readwrite on std::vector returns a copy,
# so we must assign the whole list back.
ctx.parts = [p]
self.assertEqual(len(ctx.parts), 1)
self.assertEqual(ctx.parts[0].id, "part1")
def test_enum_values(self):
import kcsolve
self.assertEqual(int(kcsolve.SolveStatus.Success), 0)
# BaseJointKind.Fixed should exist
self.assertIsNotNone(kcsolve.BaseJointKind.Fixed)
# DiagnosticKind should exist
self.assertIsNotNone(kcsolve.DiagnosticKind.Redundant)
def test_constraint_fields(self):
import kcsolve
c = kcsolve.Constraint()
c.id = "Joint001"
c.part_i = "part1"
c.part_j = "part2"
c.type = kcsolve.BaseJointKind.Fixed
self.assertEqual(c.id, "Joint001")
self.assertEqual(c.type, kcsolve.BaseJointKind.Fixed)
def test_solve_result_fields(self):
import kcsolve
r = kcsolve.SolveResult()
self.assertEqual(r.status, kcsolve.SolveStatus.Success)
self.assertEqual(r.dof, -1)
self.assertEqual(len(r.placements), 0)
class TestKCSolveRegistry(unittest.TestCase):
"""Verify SolverRegistry wrapper functions."""
def test_available_returns_list(self):
import kcsolve
result = kcsolve.available()
self.assertIsInstance(result, list)
def test_load_ondsel(self):
import kcsolve
solver = kcsolve.load("ondsel")
# Ondsel should be registered by FreeCAD init
if solver is not None:
self.assertIn("Ondsel", solver.name())
def test_load_unknown_returns_none(self):
import kcsolve
solver = kcsolve.load("nonexistent_solver_xyz")
self.assertIsNone(solver)
def test_get_set_default(self):
import kcsolve
original = kcsolve.get_default()
# Setting unknown solver should return False
self.assertFalse(kcsolve.set_default("nonexistent_solver_xyz"))
# Default should be unchanged
self.assertEqual(kcsolve.get_default(), original)
class TestPySolver(unittest.TestCase):
"""Verify Python IKCSolver subclassing and registration."""
def _make_solver_class(self):
import kcsolve
class _DummySolver(kcsolve.IKCSolver):
def name(self):
return "DummyPySolver"
def supported_joints(self):
return [
kcsolve.BaseJointKind.Fixed,
kcsolve.BaseJointKind.Revolute,
]
def solve(self, ctx):
r = kcsolve.SolveResult()
r.status = kcsolve.SolveStatus.Success
parts = ctx.parts # copy from C++ vector
r.dof = len(parts) * 6
placements = []
for p in parts:
pr = kcsolve.SolveResult.PartResult()
pr.id = p.id
pr.placement = p.placement
placements.append(pr)
r.placements = placements
return r
return _DummySolver
def test_instantiate_python_solver(self):
cls = self._make_solver_class()
solver = cls()
self.assertEqual(solver.name(), "DummyPySolver")
self.assertEqual(len(solver.supported_joints()), 2)
def test_python_solver_solve(self):
import kcsolve
cls = self._make_solver_class()
solver = cls()
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box1"
p.grounded = True
ctx.parts = [p]
result = solver.solve(ctx)
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
self.assertEqual(result.dof, 6)
self.assertEqual(len(result.placements), 1)
self.assertEqual(result.placements[0].id, "box1")
def test_register_and_roundtrip(self):
import kcsolve
cls = self._make_solver_class()
# Use a unique name to avoid collision across test runs
name = "test_dummy_roundtrip"
kcsolve.register_solver(name, cls)
self.assertIn(name, kcsolve.available())
loaded = kcsolve.load(name)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.name(), "DummyPySolver")
ctx = kcsolve.SolveContext()
result = loaded.solve(ctx)
self.assertEqual(result.status, kcsolve.SolveStatus.Success)
def test_default_virtuals(self):
"""Default implementations of optional virtuals should not crash."""
import kcsolve
cls = self._make_solver_class()
solver = cls()
self.assertTrue(solver.is_deterministic())
self.assertFalse(solver.supports_bundle_fixed())
ctx = kcsolve.SolveContext()
diags = solver.diagnose(ctx)
self.assertEqual(len(diags), 0)

View File

@@ -0,0 +1,216 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# /****************************************************************************
# *
# Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
# *
# This file is part of FreeCAD. *
# *
# FreeCAD is free software: you can redistribute it and/or modify it *
# under the terms of the GNU Lesser General Public License as *
# published by the Free Software Foundation, either version 2.1 of the *
# License, or (at your option) any later version. *
# *
# FreeCAD is distributed in the hope that it will be useful, but *
# WITHOUT ANY WARRANTY; without even the implied warranty of *
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# Lesser General Public License for more details. *
# *
# You should have received a copy of the GNU Lesser General Public *
# License along with FreeCAD. If not, see *
# <https://www.gnu.org/licenses/>. *
# *
# ***************************************************************************/
"""
Solver integration tests for Phase 1e (KCSolve refactor).
These tests verify that the AssemblyObject → IKCSolver → OndselAdapter pipeline
produces correct results via the full FreeCAD stack. They complement the C++
unit tests in tests/src/Mod/Assembly/Solver/.
"""
import os
import tempfile
import unittest
import FreeCAD as App
import JointObject
class TestSolverIntegration(unittest.TestCase):
"""Full-stack solver regression tests exercising AssemblyObject.solve()."""
def setUp(self):
doc_name = self.__class__.__name__
if App.ActiveDocument:
if App.ActiveDocument.Name != doc_name:
App.newDocument(doc_name)
else:
App.newDocument(doc_name)
App.setActiveDocument(doc_name)
self.doc = App.ActiveDocument
self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly")
self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints")
def tearDown(self):
App.closeDocument(self.doc.Name)
# ── Helpers ─────────────────────────────────────────────────────
def _make_box(self, x=0, y=0, z=0, size=10):
"""Create a Part::Box inside the assembly with a given offset."""
box = self.assembly.newObject("Part::Box", "Box")
box.Length = size
box.Width = size
box.Height = size
box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation())
return box
def _ground(self, obj):
"""Create a grounded joint for the given object."""
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, obj)
return gnd
def _make_joint(self, joint_type, ref1, ref2):
"""Create a joint of the given type connecting two (obj, subelements) pairs.
joint_type: integer JointType enum value (0=Fixed, 1=Revolute, etc.)
ref1, ref2: tuples of (obj, [sub_element, sub_element])
"""
joint = self.jointgroup.newObject("App::FeaturePython", "Joint")
JointObject.Joint(joint, joint_type)
refs = [
[ref1[0], ref1[1]],
[ref2[0], ref2[1]],
]
joint.Proxy.setJointConnectors(joint, refs)
return joint
# ── Tests ───────────────────────────────────────────────────────
def test_solve_fixed_joint(self):
"""Two boxes + grounded + fixed joint → placements match."""
box1 = self._make_box(10, 20, 30)
box2 = self._make_box(40, 50, 60)
self._ground(box2)
# Fixed joint (type 0) connecting Face6+Vertex7 on each box.
self._make_joint(
0,
[box2, ["Face6", "Vertex7"]],
[box1, ["Face6", "Vertex7"]],
)
# After setJointConnectors, solve() was already called internally.
# Verify that box1 moved to match box2.
self.assertTrue(
box1.Placement.isSame(box2.Placement, 1e-6),
"Fixed joint: box1 should match box2 placement",
)
def test_solve_revolute_joint(self):
"""Two boxes + grounded + revolute joint → solve succeeds (return 0)."""
box1 = self._make_box(0, 0, 0)
box2 = self._make_box(100, 0, 0)
self._ground(box1)
# Revolute joint (type 1) connecting Face6+Vertex7.
self._make_joint(
1,
[box1, ["Face6", "Vertex7"]],
[box2, ["Face6", "Vertex7"]],
)
result = self.assembly.solve()
self.assertEqual(result, 0, "Revolute joint solve should succeed")
def test_solve_returns_code_for_no_ground(self):
"""Assembly with no grounded parts → solve returns -6."""
box1 = self._make_box(0, 0, 0)
box2 = self._make_box(50, 0, 0)
# Fixed joint but no ground.
joint = self.jointgroup.newObject("App::FeaturePython", "Joint")
JointObject.Joint(joint, 0)
refs = [
[box1, ["Face6", "Vertex7"]],
[box2, ["Face6", "Vertex7"]],
]
joint.Proxy.setJointConnectors(joint, refs)
result = self.assembly.solve()
self.assertEqual(result, -6, "No grounded parts should return -6")
def test_solve_returns_redundancy(self):
"""Over-constrained assembly → solve returns -2 (redundant)."""
box1 = self._make_box(0, 0, 0)
box2 = self._make_box(50, 0, 0)
self._ground(box1)
# Two fixed joints between the same faces → redundant.
self._make_joint(
0,
[box1, ["Face6", "Vertex7"]],
[box2, ["Face6", "Vertex7"]],
)
self._make_joint(
0,
[box1, ["Face5", "Vertex5"]],
[box2, ["Face5", "Vertex5"]],
)
result = self.assembly.solve()
self.assertEqual(result, -2, "Redundant constraints should return -2")
def test_export_asmt(self):
"""exportAsASMT() produces a non-empty file."""
box1 = self._make_box(0, 0, 0)
box2 = self._make_box(50, 0, 0)
self._ground(box1)
self._make_joint(
0,
[box1, ["Face6", "Vertex7"]],
[box2, ["Face6", "Vertex7"]],
)
self.assembly.solve()
with tempfile.NamedTemporaryFile(suffix=".asmt", delete=False) as f:
path = f.name
try:
self.assembly.exportAsASMT(path)
self.assertTrue(os.path.exists(path), "ASMT file should exist")
self.assertGreater(
os.path.getsize(path), 0, "ASMT file should be non-empty"
)
finally:
if os.path.exists(path):
os.unlink(path)
def test_solve_multiple_times_stable(self):
"""Solving the same assembly twice produces identical placements."""
box1 = self._make_box(10, 20, 30)
box2 = self._make_box(40, 50, 60)
self._ground(box2)
self._make_joint(
0,
[box2, ["Face6", "Vertex7"]],
[box1, ["Face6", "Vertex7"]],
)
self.assembly.solve()
plc_first = App.Placement(box1.Placement)
self.assembly.solve()
plc_second = box1.Placement
self.assertTrue(
plc_first.isSame(plc_second, 1e-6),
"Deterministic solver should produce identical results",
)

View File

@@ -11,6 +11,7 @@ else ()
endif ()
endif ()
add_subdirectory(Solver)
add_subdirectory(App)
if(BUILD_GUI)
@@ -56,6 +57,8 @@ SET(AssemblyTests_SRCS
AssemblyTests/__init__.py
AssemblyTests/TestCore.py
AssemblyTests/TestCommandInsertLink.py
AssemblyTests/TestSolverIntegration.py
AssemblyTests/TestKCSolvePy.py
AssemblyTests/mocks/__init__.py
AssemblyTests/mocks/MockGui.py
)

View File

@@ -0,0 +1,46 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
set(KCSolve_SRCS
KCSolveGlobal.h
Types.h
IKCSolver.h
SolverRegistry.h
SolverRegistry.cpp
OndselAdapter.h
OndselAdapter.cpp
)
add_library(KCSolve SHARED ${KCSolve_SRCS})
target_include_directories(KCSolve
PUBLIC
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/src
)
target_compile_definitions(KCSolve
PRIVATE
CMAKE_INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}"
)
target_link_libraries(KCSolve
PRIVATE
FreeCADBase
OndselSolver
)
# Platform-specific dynamic loading library
if(NOT WIN32)
target_link_libraries(KCSolve PRIVATE ${CMAKE_DL_LIBS})
endif()
if(FREECAD_WARN_ERROR)
target_compile_warn_error(KCSolve)
endif()
SET_BIN_DIR(KCSolve KCSolve /Mod/Assembly)
INSTALL(TARGETS KCSolve DESTINATION ${CMAKE_INSTALL_LIBDIR})
if(FREECAD_USE_PYBIND11)
add_subdirectory(bindings)
endif()

View File

@@ -0,0 +1,189 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_IKCSOLVER_H
#define KCSOLVE_IKCSOLVER_H
#include <cstddef>
#include <string>
#include <vector>
#include "Types.h"
namespace KCSolve
{
/// Abstract interface for a pluggable assembly constraint solver.
///
/// Solver backends implement this interface. The Assembly module calls
/// through it via the SolverRegistry. A minimal solver only needs to
/// implement solve(), name(), and supported_joints() — all other methods
/// have default implementations that either delegate to solve() or
/// return sensible defaults.
///
/// Method mapping to current AssemblyObject operations:
///
/// solve() <-> AssemblyObject::solve()
/// pre_drag() <-> AssemblyObject::preDrag()
/// drag_step() <-> AssemblyObject::doDragStep()
/// post_drag() <-> AssemblyObject::postDrag()
/// run_kinematic() <-> AssemblyObject::generateSimulation()
/// num_frames() <-> AssemblyObject::numberOfFrames()
/// update_for_frame() <-> AssemblyObject::updateForFrame()
/// diagnose() <-> AssemblyObject::updateSolveStatus()
class IKCSolver
{
public:
virtual ~IKCSolver() = default;
/// Human-readable solver name (e.g. "OndselSolver (Lagrangian)").
virtual std::string name() const = 0;
/// Return the set of BaseJointKind values this solver supports.
/// The registry uses this for capability-based solver selection.
virtual std::vector<BaseJointKind> supported_joints() const = 0;
// ── Static solve ───────────────────────────────────────────────
/// Solve the assembly for static equilibrium.
/// @param ctx Complete description of parts, constraints, and options.
/// @return Result with updated placements and diagnostics.
virtual SolveResult solve(const SolveContext& ctx) = 0;
/// Incrementally update an already-solved assembly after parameter
/// changes (e.g. joint angle/distance changed during joint creation).
/// Default: delegates to solve().
virtual SolveResult update(const SolveContext& ctx)
{
return solve(ctx);
}
// ── Interactive drag ───────────────────────────────────────────
//
// Three-phase protocol for interactive part dragging:
// 1. pre_drag() — solve initial state, prepare for dragging
// 2. drag_step() — called on each mouse move with updated positions
// 3. post_drag() — finalize and release internal solver state
//
// Solvers can maintain internal state across the drag session for
// better interactive performance. This addresses a known weakness
// in the current direct-OndselSolver integration.
/// Prepare for an interactive drag session.
/// @param ctx Assembly state before dragging begins.
/// @param drag_parts IDs of parts being dragged.
/// @return Initial solve result.
virtual SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& /*drag_parts*/)
{
return solve(ctx);
}
/// Perform one incremental drag step.
/// @param drag_placements Current placements of the dragged parts
/// (part ID + new transform).
/// @return Updated placements for all affected parts.
virtual SolveResult drag_step(
const std::vector<SolveResult::PartResult>& /*drag_placements*/)
{
return SolveResult {SolveStatus::Success, {}, -1, {}, 0};
}
/// End an interactive drag session and finalize state.
virtual void post_drag()
{
}
// ── Kinematic simulation ───────────────────────────────────────
/// 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 frame placements.
/// Default: delegates to solve() (ignoring simulation params).
virtual SolveResult run_kinematic(const SolveContext& /*ctx*/)
{
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
/// Number of simulation frames available after run_kinematic().
virtual std::size_t num_frames() const
{
return 0;
}
/// Retrieve part placements for simulation frame at index.
/// @pre index < num_frames()
virtual SolveResult update_for_frame(std::size_t /*index*/)
{
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
// ── Diagnostics ────────────────────────────────────────────────
/// Analyze the assembly for redundant, conflicting, or malformed
/// constraints. May require a prior solve() call for some solvers.
virtual std::vector<ConstraintDiagnostic> diagnose(const SolveContext& /*ctx*/)
{
return {};
}
// ── Capability queries ─────────────────────────────────────────
/// Whether this solver produces deterministic results given
/// identical input.
virtual bool is_deterministic() const
{
return true;
}
/// Export solver-native debug/diagnostic file (e.g. ASMT for OndselSolver).
/// Default: no-op. Requires a prior solve() or run_kinematic() call.
virtual void export_native(const std::string& /*path*/)
{
}
/// Whether this solver handles fixed-joint part bundling internally.
/// When false, the caller bundles parts connected by Fixed joints
/// before building the SolveContext. When true, the solver receives
/// unbundled parts and optimizes internally.
virtual bool supports_bundle_fixed() const
{
return false;
}
// Public default constructor for pybind11 trampoline support.
// The class remains abstract (3 pure virtuals prevent direct instantiation).
IKCSolver() = default;
private:
// Non-copyable, non-movable (polymorphic base class)
IKCSolver(const IKCSolver&) = delete;
IKCSolver& operator=(const IKCSolver&) = delete;
IKCSolver(IKCSolver&&) = delete;
IKCSolver& operator=(IKCSolver&&) = delete;
};
} // namespace KCSolve
#endif // KCSOLVE_IKCSOLVER_H

View File

@@ -0,0 +1,37 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include <FCGlobal.h>
#ifndef KCSOLVE_GLOBAL_H
#define KCSOLVE_GLOBAL_H
#ifndef KCSolveExport
# ifdef KCSolve_EXPORTS
# define KCSolveExport FREECAD_DECL_EXPORT
# else
# define KCSolveExport FREECAD_DECL_IMPORT
# endif
#endif
#endif // KCSOLVE_GLOBAL_H

View File

@@ -0,0 +1,796 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include "OndselAdapter.h"
#include "SolverRegistry.h"
#include <Base/Console.h>
#include <OndselSolver/CREATE.h>
#include <OndselSolver/ASMTAssembly.h>
#include <OndselSolver/ASMTAngleJoint.h>
#include <OndselSolver/ASMTConstantGravity.h>
#include <OndselSolver/ASMTCylSphJoint.h>
#include <OndselSolver/ASMTCylindricalJoint.h>
#include <OndselSolver/ASMTFixedJoint.h>
#include <OndselSolver/ASMTGearJoint.h>
#include <OndselSolver/ASMTGeneralMotion.h>
#include <OndselSolver/ASMTLineInPlaneJoint.h>
#include <OndselSolver/ASMTMarker.h>
#include <OndselSolver/ASMTParallelAxesJoint.h>
#include <OndselSolver/ASMTPart.h>
#include <OndselSolver/ASMTPerpendicularJoint.h>
#include <OndselSolver/ASMTPlanarJoint.h>
#include <OndselSolver/ASMTPointInPlaneJoint.h>
#include <OndselSolver/ASMTRackPinionJoint.h>
#include <OndselSolver/ASMTRevCylJoint.h>
#include <OndselSolver/ASMTRevoluteJoint.h>
#include <OndselSolver/ASMTRotationLimit.h>
#include <OndselSolver/ASMTRotationalMotion.h>
#include <OndselSolver/ASMTScrewJoint.h>
#include <OndselSolver/ASMTSimulationParameters.h>
#include <OndselSolver/ASMTSphSphJoint.h>
#include <OndselSolver/ASMTSphericalJoint.h>
#include <OndselSolver/ASMTTranslationLimit.h>
#include <OndselSolver/ASMTTranslationalJoint.h>
#include <OndselSolver/ASMTTranslationalMotion.h>
#include <OndselSolver/ExternalSystem.h>
// For System::jointsMotionsDo and diagnostic iteration
#include <OndselSolver/Constraint.h>
#include <OndselSolver/Joint.h>
#include <OndselSolver/System.h>
using namespace MbD;
namespace KCSolve
{
// ── Static registration ────────────────────────────────────────────
void OndselAdapter::register_solver()
{
SolverRegistry::instance().register_solver(
"ondsel",
[]() { return std::make_unique<OndselAdapter>(); });
}
// ── IKCSolver identity ─────────────────────────────────────────────
std::string OndselAdapter::name() const
{
return "OndselSolver (Lagrangian)";
}
bool OndselAdapter::is_deterministic() const
{
return true;
}
bool OndselAdapter::supports_bundle_fixed() const
{
return false;
}
std::vector<BaseJointKind> OndselAdapter::supported_joints() const
{
return {
BaseJointKind::Coincident,
BaseJointKind::PointOnLine,
BaseJointKind::PointInPlane,
BaseJointKind::Concentric,
BaseJointKind::Tangent,
BaseJointKind::Planar,
BaseJointKind::LineInPlane,
BaseJointKind::Parallel,
BaseJointKind::Perpendicular,
BaseJointKind::Angle,
BaseJointKind::Fixed,
BaseJointKind::Revolute,
BaseJointKind::Cylindrical,
BaseJointKind::Slider,
BaseJointKind::Ball,
BaseJointKind::Screw,
BaseJointKind::Gear,
BaseJointKind::RackPinion,
BaseJointKind::DistancePointPoint,
BaseJointKind::DistanceCylSph,
};
}
// ── Quaternion → rotation matrix ───────────────────────────────────
void OndselAdapter::quat_to_matrix(const std::array<double, 4>& q,
double (&mat)[3][3])
{
double w = q[0], x = q[1], y = q[2], z = q[3];
double xx = x * x, yy = y * y, zz = z * z;
double xy = x * y, xz = x * z, yz = y * z;
double wx = w * x, wy = w * y, wz = w * z;
mat[0][0] = 1.0 - 2.0 * (yy + zz);
mat[0][1] = 2.0 * (xy - wz);
mat[0][2] = 2.0 * (xz + wy);
mat[1][0] = 2.0 * (xy + wz);
mat[1][1] = 1.0 - 2.0 * (xx + zz);
mat[1][2] = 2.0 * (yz - wx);
mat[2][0] = 2.0 * (xz - wy);
mat[2][1] = 2.0 * (yz + wx);
mat[2][2] = 1.0 - 2.0 * (xx + yy);
}
// ── Assembly building ──────────────────────────────────────────────
std::shared_ptr<ASMTPart> OndselAdapter::make_part(const Part& part)
{
auto mbdPart = CREATE<ASMTPart>::With();
mbdPart->setName(part.id);
auto massMarker = CREATE<ASMTPrincipalMassMarker>::With();
massMarker->setMass(part.mass);
massMarker->setDensity(1.0);
massMarker->setMomentOfInertias(1.0, 1.0, 1.0);
mbdPart->setPrincipalMassMarker(massMarker);
const auto& pos = part.placement.position;
mbdPart->setPosition3D(pos[0], pos[1], pos[2]);
double mat[3][3];
quat_to_matrix(part.placement.quaternion, mat);
mbdPart->setRotationMatrix(
mat[0][0], mat[0][1], mat[0][2],
mat[1][0], mat[1][1], mat[1][2],
mat[2][0], mat[2][1], mat[2][2]);
return mbdPart;
}
std::shared_ptr<ASMTMarker> OndselAdapter::make_marker(const std::string& markerName,
const Transform& tf)
{
auto mbdMarker = CREATE<ASMTMarker>::With();
mbdMarker->setName(markerName);
const auto& pos = tf.position;
mbdMarker->setPosition3D(pos[0], pos[1], pos[2]);
double mat[3][3];
quat_to_matrix(tf.quaternion, mat);
mbdMarker->setRotationMatrix(
mat[0][0], mat[0][1], mat[0][2],
mat[1][0], mat[1][1], mat[1][2],
mat[2][0], mat[2][1], mat[2][2]);
return mbdMarker;
}
std::shared_ptr<ASMTJoint> OndselAdapter::create_joint(const Constraint& c)
{
auto param = [&](std::size_t i, double fallback = 0.0) -> double {
return i < c.params.size() ? c.params[i] : fallback;
};
switch (c.type) {
case BaseJointKind::Coincident:
return CREATE<ASMTSphericalJoint>::With();
case BaseJointKind::PointOnLine: {
auto j = CREATE<ASMTCylSphJoint>::With();
j->distanceIJ = param(0);
return j;
}
case BaseJointKind::PointInPlane: {
auto j = CREATE<ASMTPointInPlaneJoint>::With();
j->offset = param(0);
return j;
}
case BaseJointKind::Concentric: {
auto j = CREATE<ASMTRevCylJoint>::With();
j->distanceIJ = param(0);
return j;
}
case BaseJointKind::Tangent: {
auto j = CREATE<ASMTPlanarJoint>::With();
j->offset = param(0);
return j;
}
case BaseJointKind::Planar: {
auto j = CREATE<ASMTPlanarJoint>::With();
j->offset = param(0);
return j;
}
case BaseJointKind::LineInPlane: {
auto j = CREATE<ASMTLineInPlaneJoint>::With();
j->offset = param(0);
return j;
}
case BaseJointKind::Parallel:
return CREATE<ASMTParallelAxesJoint>::With();
case BaseJointKind::Perpendicular:
return CREATE<ASMTPerpendicularJoint>::With();
case BaseJointKind::Angle: {
auto j = CREATE<ASMTAngleJoint>::With();
j->theIzJz = param(0);
return j;
}
case BaseJointKind::Fixed:
return CREATE<ASMTFixedJoint>::With();
case BaseJointKind::Revolute:
return CREATE<ASMTRevoluteJoint>::With();
case BaseJointKind::Cylindrical:
return CREATE<ASMTCylindricalJoint>::With();
case BaseJointKind::Slider:
return CREATE<ASMTTranslationalJoint>::With();
case BaseJointKind::Ball:
return CREATE<ASMTSphericalJoint>::With();
case BaseJointKind::Screw: {
auto j = CREATE<ASMTScrewJoint>::With();
j->pitch = param(0);
return j;
}
case BaseJointKind::Gear: {
auto j = CREATE<ASMTGearJoint>::With();
j->radiusI = param(0);
j->radiusJ = param(1);
return j;
}
case BaseJointKind::RackPinion: {
auto j = CREATE<ASMTRackPinionJoint>::With();
j->pitchRadius = param(0);
return j;
}
case BaseJointKind::DistancePointPoint: {
auto j = CREATE<ASMTSphSphJoint>::With();
j->distanceIJ = param(0);
return j;
}
case BaseJointKind::DistanceCylSph: {
auto j = CREATE<ASMTCylSphJoint>::With();
j->distanceIJ = param(0);
return j;
}
// Unsupported types
case BaseJointKind::Universal:
case BaseJointKind::Cam:
case BaseJointKind::Slot:
case BaseJointKind::Custom:
Base::Console().warning(
"KCSolve: OndselAdapter does not support joint kind %d for constraint '%s'\n",
static_cast<int>(c.type), c.id.c_str());
return nullptr;
}
return nullptr; // unreachable, but silences compiler warnings
}
void OndselAdapter::add_limits(const Constraint& c,
const std::string& marker_i,
const std::string& marker_j)
{
for (const auto& lim : c.limits) {
switch (lim.kind) {
case Constraint::Limit::Kind::TranslationMin: {
auto limit = CREATE<ASMTTranslationLimit>::With();
limit->setName(c.id + "-LimitLenMin");
limit->setMarkerI(marker_i);
limit->setMarkerJ(marker_j);
limit->settype("=>");
limit->setlimit(std::to_string(lim.value));
limit->settol(std::to_string(lim.tolerance));
assembly_->addLimit(limit);
break;
}
case Constraint::Limit::Kind::TranslationMax: {
auto limit = CREATE<ASMTTranslationLimit>::With();
limit->setName(c.id + "-LimitLenMax");
limit->setMarkerI(marker_i);
limit->setMarkerJ(marker_j);
limit->settype("=<");
limit->setlimit(std::to_string(lim.value));
limit->settol(std::to_string(lim.tolerance));
assembly_->addLimit(limit);
break;
}
case Constraint::Limit::Kind::RotationMin: {
auto limit = CREATE<ASMTRotationLimit>::With();
limit->setName(c.id + "-LimitRotMin");
limit->setMarkerI(marker_i);
limit->setMarkerJ(marker_j);
limit->settype("=>");
limit->setlimit(std::to_string(lim.value) + "*pi/180.0");
limit->settol(std::to_string(lim.tolerance));
assembly_->addLimit(limit);
break;
}
case Constraint::Limit::Kind::RotationMax: {
auto limit = CREATE<ASMTRotationLimit>::With();
limit->setName(c.id + "-LimitRotMax");
limit->setMarkerI(marker_i);
limit->setMarkerJ(marker_j);
limit->settype("=<");
limit->setlimit(std::to_string(lim.value) + "*pi/180.0");
limit->settol(std::to_string(lim.tolerance));
assembly_->addLimit(limit);
break;
}
}
}
}
void OndselAdapter::add_motions(const SolveContext& ctx,
const std::string& marker_i,
const std::string& marker_j,
const std::string& joint_id)
{
// Collect motions that target this joint.
std::vector<const MotionDef*> joint_motions;
for (const auto& m : ctx.motions) {
if (m.joint_id == joint_id) {
joint_motions.push_back(&m);
}
}
if (joint_motions.empty()) {
return;
}
// If there are two motions of different kinds on the same joint,
// combine them into a GeneralMotion (cylindrical joint case).
if (joint_motions.size() == 2
&& joint_motions[0]->kind != joint_motions[1]->kind) {
auto motion = CREATE<ASMTGeneralMotion>::With();
motion->setName(joint_id + "-GeneralMotion");
motion->setMarkerI(marker_i);
motion->setMarkerJ(marker_j);
for (const auto* m : joint_motions) {
if (m->kind == MotionDef::Kind::Rotational) {
motion->angIJJ->atiput(2, m->rotation_expr);
}
else {
motion->rIJI->atiput(2, m->translation_expr);
}
}
assembly_->addMotion(motion);
return;
}
// Single motion or multiple of the same kind.
for (const auto* m : joint_motions) {
switch (m->kind) {
case MotionDef::Kind::Rotational: {
auto motion = CREATE<ASMTRotationalMotion>::With();
motion->setName(joint_id + "-AngularMotion");
motion->setMarkerI(marker_i);
motion->setMarkerJ(marker_j);
motion->setRotationZ(m->rotation_expr);
assembly_->addMotion(motion);
break;
}
case MotionDef::Kind::Translational: {
auto motion = CREATE<ASMTTranslationalMotion>::With();
motion->setName(joint_id + "-LinearMotion");
motion->setMarkerI(marker_i);
motion->setMarkerJ(marker_j);
motion->setTranslationZ(m->translation_expr);
assembly_->addMotion(motion);
break;
}
case MotionDef::Kind::General: {
auto motion = CREATE<ASMTGeneralMotion>::With();
motion->setName(joint_id + "-GeneralMotion");
motion->setMarkerI(marker_i);
motion->setMarkerJ(marker_j);
if (!m->rotation_expr.empty()) {
motion->angIJJ->atiput(2, m->rotation_expr);
}
if (!m->translation_expr.empty()) {
motion->rIJI->atiput(2, m->translation_expr);
}
assembly_->addMotion(motion);
break;
}
}
}
}
void OndselAdapter::fix_grounded_parts(const SolveContext& ctx)
{
for (const auto& part : ctx.parts) {
if (!part.grounded) {
continue;
}
auto it = part_map_.find(part.id);
if (it == part_map_.end()) {
continue;
}
// Assembly-level marker at the part's placement.
std::string asmMarkerName = "ground-" + part.id;
auto asmMarker = make_marker(asmMarkerName, part.placement);
assembly_->addMarker(asmMarker);
// Part-level marker at identity.
std::string partMarkerName = "FixingMarker";
auto partMarker = make_marker(partMarkerName, Transform::identity());
it->second->addMarker(partMarker);
// Fixed joint connecting them.
auto fixedJoint = CREATE<ASMTFixedJoint>::With();
fixedJoint->setName("ground-fix-" + part.id);
fixedJoint->setMarkerI("/OndselAssembly/" + asmMarkerName);
fixedJoint->setMarkerJ("/OndselAssembly/" + part.id + "/" + partMarkerName);
assembly_->addJoint(fixedJoint);
}
}
void OndselAdapter::set_simulation_params(const SimulationParams& params)
{
auto mbdSim = assembly_->simulationParameters;
mbdSim->settstart(params.t_start);
mbdSim->settend(params.t_end);
mbdSim->sethout(params.h_out);
mbdSim->sethmin(params.h_min);
mbdSim->sethmax(params.h_max);
mbdSim->seterrorTol(params.error_tol);
}
void OndselAdapter::build_assembly(const SolveContext& ctx)
{
assembly_ = CREATE<ASMTAssembly>::With();
assembly_->setName("OndselAssembly");
part_map_.clear();
// Do NOT set externalSystem->freecadAssemblyObject — breaking the coupling.
// Add parts.
for (const auto& part : ctx.parts) {
auto mbdPart = make_part(part);
assembly_->addPart(mbdPart);
part_map_[part.id] = mbdPart;
}
// Fix grounded parts.
fix_grounded_parts(ctx);
// Add constraints (joints + limits + motions).
for (const auto& c : ctx.constraints) {
if (!c.activated) {
continue;
}
auto mbdJoint = create_joint(c);
if (!mbdJoint) {
continue;
}
// Create markers on the respective parts.
auto it_i = part_map_.find(c.part_i);
auto it_j = part_map_.find(c.part_j);
if (it_i == part_map_.end() || it_j == part_map_.end()) {
Base::Console().warning(
"KCSolve: constraint '%s' references unknown part(s)\n",
c.id.c_str());
continue;
}
std::string markerNameI = c.id + "-mkrI";
std::string markerNameJ = c.id + "-mkrJ";
auto mkrI = make_marker(markerNameI, c.marker_i);
it_i->second->addMarker(mkrI);
auto mkrJ = make_marker(markerNameJ, c.marker_j);
it_j->second->addMarker(mkrJ);
std::string fullMarkerI = "/OndselAssembly/" + c.part_i + "/" + markerNameI;
std::string fullMarkerJ = "/OndselAssembly/" + c.part_j + "/" + markerNameJ;
mbdJoint->setName(c.id);
mbdJoint->setMarkerI(fullMarkerI);
mbdJoint->setMarkerJ(fullMarkerJ);
assembly_->addJoint(mbdJoint);
// Add limits (only when not in simulation mode).
if (!ctx.simulation.has_value() && !c.limits.empty()) {
add_limits(c, fullMarkerI, fullMarkerJ);
}
// Add motions.
if (!ctx.motions.empty()) {
add_motions(ctx, fullMarkerI, fullMarkerJ, c.id);
}
}
// Set simulation parameters if present.
if (ctx.simulation.has_value()) {
set_simulation_params(*ctx.simulation);
}
}
// ── Result extraction ──────────────────────────────────────────────
Transform OndselAdapter::extract_part_transform(
const std::shared_ptr<ASMTPart>& part) const
{
Transform tf;
double x, y, z;
part->getPosition3D(x, y, z);
tf.position = {x, y, z};
double q0, q1, q2, q3;
part->getQuarternions(q0, q1, q2, q3);
// OndselSolver returns (w, x, y, z) — matches our convention.
tf.quaternion = {q0, q1, q2, q3};
return tf;
}
SolveResult OndselAdapter::extract_result() const
{
SolveResult result;
result.status = SolveStatus::Success;
for (const auto& [id, mbdPart] : part_map_) {
SolveResult::PartResult pr;
pr.id = id;
pr.placement = extract_part_transform(mbdPart);
result.placements.push_back(std::move(pr));
}
return result;
}
std::vector<ConstraintDiagnostic> OndselAdapter::extract_diagnostics() const
{
std::vector<ConstraintDiagnostic> diags;
if (!assembly_ || !assembly_->mbdSystem) {
return diags;
}
assembly_->mbdSystem->jointsMotionsDo([&](std::shared_ptr<Joint> jm) {
if (!jm) {
return;
}
bool isRedundant = false;
jm->constraintsDo([&](std::shared_ptr<MbD::Constraint> con) {
if (!con) {
return;
}
std::string spec = con->constraintSpec();
if (spec.rfind("Redundant", 0) == 0) {
isRedundant = true;
}
});
if (isRedundant) {
// Extract the constraint name from the solver's joint name.
// Format: "/OndselAssembly/ground_moves#Joint001" → "Joint001"
std::string fullName = jm->name;
std::size_t hashPos = fullName.find_last_of('#');
std::string cleanName = (hashPos != std::string::npos)
? fullName.substr(hashPos + 1)
: fullName;
ConstraintDiagnostic diag;
diag.constraint_id = cleanName;
diag.kind = ConstraintDiagnostic::Kind::Redundant;
diag.detail = "Constraint is redundant";
diags.push_back(std::move(diag));
}
});
return diags;
}
// ── Solve operations ───────────────────────────────────────────────
SolveResult OndselAdapter::solve(const SolveContext& ctx)
{
try {
build_assembly(ctx);
assembly_->runPreDrag();
return extract_result();
}
catch (const std::exception& e) {
Base::Console().warning("KCSolve: OndselAdapter solve failed: %s\n", e.what());
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
catch (...) {
Base::Console().warning("KCSolve: OndselAdapter solve failed: unknown exception\n");
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
}
SolveResult OndselAdapter::update(const SolveContext& ctx)
{
return solve(ctx);
}
// ── Drag protocol ──────────────────────────────────────────────────
SolveResult OndselAdapter::pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts)
{
drag_part_ids_ = drag_parts;
try {
build_assembly(ctx);
assembly_->runPreDrag();
return extract_result();
}
catch (const std::exception& e) {
Base::Console().warning("KCSolve: OndselAdapter pre_drag failed: %s\n", e.what());
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
catch (...) {
Base::Console().warning("KCSolve: OndselAdapter pre_drag failed: unknown exception\n");
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
}
SolveResult OndselAdapter::drag_step(
const std::vector<SolveResult::PartResult>& drag_placements)
{
if (!assembly_) {
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
try {
auto dragParts = std::make_shared<std::vector<std::shared_ptr<ASMTPart>>>();
for (const auto& dp : drag_placements) {
auto it = part_map_.find(dp.id);
if (it == part_map_.end()) {
continue;
}
auto& mbdPart = it->second;
// Update position.
const auto& pos = dp.placement.position;
mbdPart->updateMbDFromPosition3D(pos[0], pos[1], pos[2]);
// Update rotation.
double mat[3][3];
quat_to_matrix(dp.placement.quaternion, mat);
mbdPart->updateMbDFromRotationMatrix(
mat[0][0], mat[0][1], mat[0][2],
mat[1][0], mat[1][1], mat[1][2],
mat[2][0], mat[2][1], mat[2][2]);
dragParts->push_back(mbdPart);
}
assembly_->runDragStep(dragParts);
return extract_result();
}
catch (...) {
// Drag step failures are non-fatal — caller will skip this frame.
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
}
void OndselAdapter::post_drag()
{
if (assembly_) {
assembly_->runPostDrag();
}
drag_part_ids_.clear();
}
// ── Kinematic simulation ───────────────────────────────────────────
SolveResult OndselAdapter::run_kinematic(const SolveContext& ctx)
{
try {
build_assembly(ctx);
assembly_->runKINEMATIC();
auto result = extract_result();
result.num_frames = assembly_->numberOfFrames();
return result;
}
catch (const std::exception& e) {
Base::Console().warning("KCSolve: OndselAdapter run_kinematic failed: %s\n", e.what());
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
catch (...) {
Base::Console().warning(
"KCSolve: OndselAdapter run_kinematic failed: unknown exception\n");
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
}
std::size_t OndselAdapter::num_frames() const
{
if (!assembly_) {
return 0;
}
return assembly_->numberOfFrames();
}
SolveResult OndselAdapter::update_for_frame(std::size_t index)
{
if (!assembly_) {
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
if (index >= assembly_->numberOfFrames()) {
return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
}
assembly_->updateForFrame(index);
return extract_result();
}
// ── Diagnostics ────────────────────────────────────────────────────
std::vector<ConstraintDiagnostic> OndselAdapter::diagnose(const SolveContext& ctx)
{
// Ensure we have a solved assembly to inspect.
if (!assembly_ || !assembly_->mbdSystem) {
solve(ctx);
}
return extract_diagnostics();
}
// ── Native export ──────────────────────────────────────────────────
void OndselAdapter::export_native(const std::string& path)
{
if (assembly_) {
assembly_->outputFile(path);
}
}
} // namespace KCSolve

View File

@@ -0,0 +1,129 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_ONDSELADAPTER_H
#define KCSOLVE_ONDSELADAPTER_H
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "IKCSolver.h"
#include "KCSolveGlobal.h"
namespace MbD
{
class ASMTAssembly;
class ASMTJoint;
class ASMTMarker;
class ASMTPart;
} // namespace MbD
namespace KCSolve
{
/// IKCSolver implementation wrapping OndselSolver's Lagrangian MBD engine.
///
/// Translates KCSolve types (SolveContext, BaseJointKind, Transform) to
/// OndselSolver's ASMT hierarchy (ASMTAssembly, ASMTPart, ASMTJoint, etc.)
/// and extracts results back into SolveResult.
///
/// All OndselSolver #includes are confined to OndselAdapter.cpp.
class KCSolveExport OndselAdapter : public IKCSolver
{
public:
OndselAdapter() = default;
// ── IKCSolver pure virtuals ────────────────────────────────────
std::string name() const override;
std::vector<BaseJointKind> supported_joints() const override;
SolveResult solve(const SolveContext& ctx) override;
// ── IKCSolver overrides ────────────────────────────────────────
SolveResult update(const SolveContext& ctx) override;
SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts) override;
SolveResult drag_step(
const std::vector<SolveResult::PartResult>& drag_placements) override;
void post_drag() override;
SolveResult run_kinematic(const SolveContext& ctx) override;
std::size_t num_frames() const override;
SolveResult update_for_frame(std::size_t index) override;
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override;
bool is_deterministic() const override;
bool supports_bundle_fixed() const override;
void export_native(const std::string& path) override;
/// Register OndselAdapter as "ondsel" in the SolverRegistry.
/// Call once at module init time.
static void register_solver();
private:
// ── Assembly building ──────────────────────────────────────────
void build_assembly(const SolveContext& ctx);
std::shared_ptr<MbD::ASMTPart> make_part(const Part& part);
std::shared_ptr<MbD::ASMTMarker> make_marker(const std::string& name,
const Transform& tf);
std::shared_ptr<MbD::ASMTJoint> create_joint(const Constraint& c);
void add_limits(const Constraint& c,
const std::string& marker_i,
const std::string& marker_j);
void add_motions(const SolveContext& ctx,
const std::string& marker_i,
const std::string& marker_j,
const std::string& joint_id);
void fix_grounded_parts(const SolveContext& ctx);
void set_simulation_params(const SimulationParams& params);
// ── Result extraction ──────────────────────────────────────────
SolveResult extract_result() const;
std::vector<ConstraintDiagnostic> extract_diagnostics() const;
Transform extract_part_transform(
const std::shared_ptr<MbD::ASMTPart>& part) const;
// ── Quaternion ↔ rotation matrix conversion ────────────────────
/// Convert unit quaternion (w,x,y,z) to 3×3 rotation matrix (row-major).
static void quat_to_matrix(const std::array<double, 4>& q,
double (&mat)[3][3]);
// ── Internal state ─────────────────────────────────────────────
std::shared_ptr<MbD::ASMTAssembly> assembly_;
std::unordered_map<std::string, std::shared_ptr<MbD::ASMTPart>> part_map_;
std::vector<std::string> drag_part_ids_;
};
} // namespace KCSolve
#endif // KCSOLVE_ONDSELADAPTER_H

View File

@@ -0,0 +1,346 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include "SolverRegistry.h"
#include <Base/Console.h>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <sstream>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
#else
# include <dlfcn.h>
#endif
namespace fs = std::filesystem;
namespace
{
// Platform extension for shared libraries.
#ifdef _WIN32
constexpr const char* PLUGIN_EXT = ".dll";
constexpr char PATH_SEP = ';';
#elif defined(__APPLE__)
constexpr const char* PLUGIN_EXT = ".dylib";
constexpr char PATH_SEP = ':';
#else
constexpr const char* PLUGIN_EXT = ".so";
constexpr char PATH_SEP = ':';
#endif
// Dynamic library loading wrappers.
void* open_library(const char* path)
{
#ifdef _WIN32
return static_cast<void*>(LoadLibraryA(path));
#else
return dlopen(path, RTLD_LAZY);
#endif
}
void* get_symbol(void* handle, const char* symbol)
{
#ifdef _WIN32
return reinterpret_cast<void*>(
GetProcAddress(static_cast<HMODULE>(handle), symbol));
#else
return dlsym(handle, symbol);
#endif
}
std::string load_error()
{
#ifdef _WIN32
DWORD err = GetLastError();
char* msg = nullptr;
FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM,
nullptr, err, 0, reinterpret_cast<char*>(&msg), 0, nullptr);
std::string result = msg ? msg : "unknown error";
LocalFree(msg);
return result;
#else
const char* err = dlerror();
return err ? err : "unknown error";
#endif
}
/// Parse major version from a version string like "1.0" or "2.1.3".
/// Returns -1 on failure.
int parse_major_version(const char* version_str)
{
if (!version_str) {
return -1;
}
char* end = nullptr;
long major = std::strtol(version_str, &end, 10);
if (end == version_str || major < 0) {
return -1;
}
return static_cast<int>(major);
}
} // anonymous namespace
namespace KCSolve
{
// Plugin C entry point types.
using ApiVersionFn = const char* (*)();
using CreateFn = IKCSolver* (*)();
// ── Singleton ──────────────────────────────────────────────────────
SolverRegistry& SolverRegistry::instance()
{
static SolverRegistry reg;
return reg;
}
SolverRegistry::SolverRegistry() = default;
SolverRegistry::~SolverRegistry()
{
for (void* handle : handles_) {
close_handle(handle);
}
}
void SolverRegistry::close_handle(void* handle)
{
if (!handle) {
return;
}
#ifdef _WIN32
FreeLibrary(static_cast<HMODULE>(handle));
#else
dlclose(handle);
#endif
}
// ── Registration ───────────────────────────────────────────────────
bool SolverRegistry::register_solver(const std::string& name, CreateSolverFn factory)
{
std::lock_guard<std::mutex> lock(mutex_);
auto [it, inserted] = factories_.emplace(name, std::move(factory));
if (!inserted) {
Base::Console().warning("KCSolve: solver '%s' already registered, skipping\n",
name.c_str());
return false;
}
if (default_name_.empty()) {
default_name_ = name;
}
Base::Console().log("KCSolve: registered solver '%s'\n", name.c_str());
return true;
}
// ── Lookup ─────────────────────────────────────────────────────────
std::unique_ptr<IKCSolver> SolverRegistry::get(const std::string& name) const
{
std::lock_guard<std::mutex> lock(mutex_);
const std::string& key = name.empty() ? default_name_ : name;
if (key.empty()) {
return nullptr;
}
auto it = factories_.find(key);
if (it == factories_.end()) {
return nullptr;
}
return it->second();
}
std::vector<std::string> SolverRegistry::available() const
{
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> names;
names.reserve(factories_.size());
for (const auto& [name, _] : factories_) {
names.push_back(name);
}
return names;
}
std::vector<BaseJointKind> SolverRegistry::joints_for(const std::string& name) const
{
auto solver = get(name);
if (!solver) {
return {};
}
return solver->supported_joints();
}
bool SolverRegistry::set_default(const std::string& name)
{
std::lock_guard<std::mutex> lock(mutex_);
if (factories_.find(name) == factories_.end()) {
return false;
}
default_name_ = name;
return true;
}
std::string SolverRegistry::get_default() const
{
std::lock_guard<std::mutex> lock(mutex_);
return default_name_;
}
// ── Plugin scanning ────────────────────────────────────────────────
void SolverRegistry::scan(const std::string& directory)
{
std::error_code ec;
if (!fs::is_directory(directory, ec)) {
// Non-existent directories are not an error — just skip.
return;
}
Base::Console().log("KCSolve: scanning '%s' for plugins\n", directory.c_str());
for (const auto& entry : fs::directory_iterator(directory, ec)) {
if (ec) {
Base::Console().warning("KCSolve: error iterating '%s': %s\n",
directory.c_str(), ec.message().c_str());
break;
}
if (!entry.is_regular_file(ec)) {
continue;
}
const auto& path = entry.path();
if (path.extension() != PLUGIN_EXT) {
continue;
}
const std::string path_str = path.string();
// Load the shared library.
void* handle = open_library(path_str.c_str());
if (!handle) {
Base::Console().warning("KCSolve: failed to load '%s': %s\n",
path_str.c_str(), load_error().c_str());
continue;
}
// Check API version.
auto version_fn = reinterpret_cast<ApiVersionFn>(
get_symbol(handle, "kcsolve_api_version"));
if (!version_fn) {
// Not a KCSolve plugin — silently skip.
close_handle(handle);
continue;
}
const char* version_str = version_fn();
int major = parse_major_version(version_str);
if (major != API_VERSION_MAJOR) {
Base::Console().warning(
"KCSolve: plugin '%s' has incompatible API version '%s' "
"(expected major %d)\n",
path_str.c_str(),
version_str ? version_str : "(null)",
API_VERSION_MAJOR);
close_handle(handle);
continue;
}
// Get the factory symbol.
auto create_fn = reinterpret_cast<CreateFn>(
get_symbol(handle, "kcsolve_create"));
if (!create_fn) {
Base::Console().warning(
"KCSolve: plugin '%s' missing kcsolve_create() symbol\n",
path_str.c_str());
close_handle(handle);
continue;
}
// Create a temporary instance to get the solver name.
std::unique_ptr<IKCSolver> probe(create_fn());
if (!probe) {
Base::Console().warning(
"KCSolve: plugin '%s' kcsolve_create() returned null\n",
path_str.c_str());
close_handle(handle);
continue;
}
std::string solver_name = probe->name();
probe.reset();
// Wrap the C function pointer in a factory lambda.
CreateSolverFn factory = [create_fn]() -> std::unique_ptr<IKCSolver> {
return std::unique_ptr<IKCSolver>(create_fn());
};
if (register_solver(solver_name, std::move(factory))) {
handles_.push_back(handle);
Base::Console().log("KCSolve: loaded plugin '%s' from '%s'\n",
solver_name.c_str(), path_str.c_str());
}
else {
// Duplicate name — close the handle.
close_handle(handle);
}
}
}
void SolverRegistry::scan_default_paths()
{
// 1. KCSOLVE_PLUGIN_PATH environment variable.
const char* env_path = std::getenv("KCSOLVE_PLUGIN_PATH");
if (env_path && env_path[0] != '\0') {
std::istringstream stream(env_path);
std::string dir;
while (std::getline(stream, dir, PATH_SEP)) {
if (!dir.empty()) {
scan(dir);
}
}
}
// 2. System install path: <install_prefix>/lib/kcsolve/
// Derive from the executable location or use a compile-time path.
// For now, use a path relative to the FreeCAD lib directory.
std::error_code ec;
fs::path system_dir = fs::path(CMAKE_INSTALL_PREFIX) / "lib" / "kcsolve";
if (fs::is_directory(system_dir, ec)) {
scan(system_dir.string());
}
}
} // namespace KCSolve

View File

@@ -0,0 +1,124 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_SOLVERREGISTRY_H
#define KCSOLVE_SOLVERREGISTRY_H
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include "IKCSolver.h"
#include "KCSolveGlobal.h"
namespace KCSolve
{
/// Factory function that creates a solver instance.
using CreateSolverFn = std::function<std::unique_ptr<IKCSolver>()>;
/// Current KCSolve API major version. Plugins must match this to load.
constexpr int API_VERSION_MAJOR = 1;
/// Singleton registry for pluggable solver backends.
///
/// Solver plugins register themselves at module load time via
/// register_solver(). The Assembly module retrieves solvers via get().
///
/// Thread safety: all public methods are internally synchronized.
///
/// Usage:
/// // Registration (at module init):
/// KCSolve::SolverRegistry::instance().register_solver(
/// "ondsel", []() { return std::make_unique<OndselAdapter>(); });
///
/// // Retrieval:
/// auto solver = KCSolve::SolverRegistry::instance().get(); // default
/// auto solver = KCSolve::SolverRegistry::instance().get("ondsel");
class KCSolveExport SolverRegistry
{
public:
/// Access the singleton instance.
static SolverRegistry& instance();
~SolverRegistry();
/// Register a solver backend.
/// @param name Unique solver name (e.g. "ondsel").
/// @param factory Factory function that creates solver instances.
/// @return true if registration succeeded, false if name taken.
bool register_solver(const std::string& name, CreateSolverFn factory);
/// Create an instance of the named solver.
/// @param name Solver name. If empty, uses the default solver.
/// @return Solver instance, or nullptr if not found.
std::unique_ptr<IKCSolver> get(const std::string& name = {}) const;
/// Return the names of all registered solvers.
std::vector<std::string> available() const;
/// Query which BaseJointKind values a named solver supports.
/// Creates a temporary instance to call supported_joints().
std::vector<BaseJointKind> joints_for(const std::string& name) const;
/// Set the default solver name.
/// @return true if the name is registered, false otherwise.
bool set_default(const std::string& name);
/// Get the default solver name.
std::string get_default() const;
/// Scan a directory for solver plugin shared libraries.
/// Each plugin must export kcsolve_api_version() and kcsolve_create().
/// Non-existent or empty directories are handled gracefully.
void scan(const std::string& directory);
/// Scan all default plugin discovery paths:
/// 1. KCSOLVE_PLUGIN_PATH env var (colon-separated, semicolon on Windows)
/// 2. <install_prefix>/lib/kcsolve/
void scan_default_paths();
private:
SolverRegistry();
SolverRegistry(const SolverRegistry&) = delete;
SolverRegistry& operator=(const SolverRegistry&) = delete;
SolverRegistry(SolverRegistry&&) = delete;
SolverRegistry& operator=(SolverRegistry&&) = delete;
/// Close a single plugin handle (platform-specific).
static void close_handle(void* handle);
mutable std::mutex mutex_;
std::unordered_map<std::string, CreateSolverFn> factories_;
std::string default_name_;
std::vector<void*> handles_; // loaded plugin library handles
};
} // namespace KCSolve
#endif // KCSOLVE_SOLVERREGISTRY_H

View File

@@ -0,0 +1,286 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_TYPES_H
#define KCSOLVE_TYPES_H
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
namespace KCSolve
{
// ── Transform ──────────────────────────────────────────────────────
//
// Rigid-body transform: position (x, y, z) + unit quaternion (w, x, y, z).
// Semantically equivalent to Base::Placement but free of FreeCAD dependencies
// so that KCSolve headers remain standalone (for future server worker use).
//
// Quaternion convention: (w, x, y, z) — mathematical standard.
// Note: Base::Rotation(q0,q1,q2,q3) uses (x, y, z, w) ordering.
// The adapter layer handles this swap.
struct Transform
{
std::array<double, 3> position {0.0, 0.0, 0.0};
std::array<double, 4> quaternion {1.0, 0.0, 0.0, 0.0}; // w, x, y, z
static Transform identity()
{
return {};
}
};
// ── BaseJointKind ──────────────────────────────────────────────────
//
// Decomposed primitive constraint types. Uses SOLIDWORKS-inspired vocabulary
// from the INTER_SOLVER.md spec rather than OndselSolver internal names.
//
// The existing Assembly::JointType (13 values) and Assembly::DistanceType
// (35+ values) map to these via the adapter layer. In particular, the
// "Distance" JointType is decomposed based on geometry classification
// (see makeMbdJointDistance in AssemblyObject.cpp).
enum class BaseJointKind : std::uint8_t
{
// Point constraints (decomposed from JointType::Distance)
Coincident, // PointOnPoint, d=0 — 3 DOF removed
PointOnLine, // Point constrained to a line — 2 DOF removed
PointInPlane, // Point constrained to a plane — 1 DOF removed
// Axis/surface constraints (decomposed from JointType::Distance)
Concentric, // Coaxial (line-line, circle-circle, cyl-cyl) — 4 DOF removed
Tangent, // Face-on-face tangency — 1 DOF removed
Planar, // Coplanar faces — 3 DOF removed
LineInPlane, // Line constrained to a plane — 2 DOF removed
// Axis orientation constraints (direct from JointType)
Parallel, // Parallel axes — 2 DOF removed
Perpendicular, // 90-degree axes — 1 DOF removed
Angle, // Arbitrary axis angle — 1 DOF removed
// Standard kinematic joints (direct 1:1 from JointType)
Fixed, // Rigid weld — 6 DOF removed
Revolute, // Hinge — 5 DOF removed
Cylindrical, // Rotation + sliding on axis — 4 DOF removed
Slider, // Linear translation — 5 DOF removed
Ball, // Spherical — 3 DOF removed
Screw, // Helical (rotation + coupled translation) — 5 DOF removed
Universal, // U-joint / Cardan — 4 DOF removed (future)
// Mechanical element constraints
Gear, // Gear pair or belt (sign determines direction)
RackPinion, // Rack-and-pinion
Cam, // Cam-follower (future)
Slot, // Slot constraint (future)
// Distance variants with non-zero offset
DistancePointPoint, // Point-to-point with offset — 2 DOF removed
DistanceCylSph, // Cylinder-sphere distance — varies
Custom, // Solver-specific extension point
};
// ── Part ───────────────────────────────────────────────────────────
struct Part
{
std::string id;
Transform placement;
double mass {1.0};
bool grounded {false};
};
// ── Constraint ─────────────────────────────────────────────────────
//
// A constraint between two parts. Built from a FreeCAD JointObject by
// the adapter layer (classifying geometry into the specific BaseJointKind).
struct Constraint
{
std::string id; // FreeCAD document object name (e.g. "Joint001")
std::string part_i; // solver-side part ID for first reference
Transform marker_i; // coordinate system on part_i
std::string part_j; // solver-side part ID for second reference
Transform marker_j; // coordinate system on part_j
BaseJointKind type {};
// Scalar parameters (interpretation depends on type):
// Angle: params[0] = angle in radians
// RackPinion: params[0] = pitch radius
// Screw: params[0] = pitch
// Gear: params[0] = radiusI, params[1] = radiusJ (negative for belt)
// DistancePointPoint: params[0] = distance
// DistanceCylSph: params[0] = distance
// Planar: params[0] = offset
// Concentric: params[0] = distance
// PointInPlane: params[0] = offset
// LineInPlane: params[0] = offset
std::vector<double> params;
// Joint limits (length or angle bounds)
struct Limit
{
enum class Kind : std::uint8_t
{
TranslationMin,
TranslationMax,
RotationMin,
RotationMax,
};
Kind kind {};
double value {0.0};
double tolerance {1.0e-9};
};
std::vector<Limit> limits;
bool activated {true};
};
// ── MotionDef ──────────────────────────────────────────────────────
//
// A motion driver for kinematic simulation.
struct MotionDef
{
enum class Kind : std::uint8_t
{
Rotational,
Translational,
General,
};
Kind kind {};
std::string joint_id; // which constraint this drives
std::string marker_i;
std::string marker_j;
// Motion law expressions (function of time 't').
// For General: both are set. Otherwise only the relevant one.
std::string rotation_expr;
std::string translation_expr;
};
// ── SimulationParams ───────────────────────────────────────────────
//
// Parameters for kinematic simulation (run_kinematic).
// Maps to create_mbdSimulationParameters() in AssemblyObject.cpp.
struct SimulationParams
{
double t_start {0.0};
double t_end {1.0};
double h_out {0.01}; // output time step
double h_min {1.0e-9};
double h_max {1.0};
double error_tol {1.0e-6};
};
// ── SolveContext ───────────────────────────────────────────────────
//
// Complete input to a solve operation. Built by the adapter layer
// from FreeCAD document objects.
struct SolveContext
{
std::vector<Part> parts;
std::vector<Constraint> constraints;
std::vector<MotionDef> motions;
// Present when running kinematic simulation via run_kinematic().
std::optional<SimulationParams> simulation;
// Hint: bundle parts connected by Fixed joints into single rigid bodies.
// When true and the solver does not support_bundle_fixed(), the adapter
// layer pre-bundles before passing to the solver.
bool bundle_fixed {false};
};
// ── SolveStatus ────────────────────────────────────────────────────
//
// Matches the return codes from AssemblyObject::solve().
enum class SolveStatus : std::int8_t
{
Success = 0,
Failed = -1,
InvalidFlip = -2, // orientation flipped past threshold
NoGroundedParts = -6, // no grounded parts in assembly
};
// ── ConstraintDiagnostic ───────────────────────────────────────────
//
// Per-constraint diagnostic information from updateSolveStatus().
struct ConstraintDiagnostic
{
enum class Kind : std::uint8_t
{
Redundant,
Conflicting,
PartiallyRedundant,
Malformed,
};
std::string constraint_id; // FreeCAD object name
Kind kind {};
std::string detail; // human-readable description
};
// ── SolveResult ────────────────────────────────────────────────────
//
// Output of a solve operation.
struct SolveResult
{
SolveStatus status {SolveStatus::Success};
// Updated placements for each part (only parts that moved).
struct PartResult
{
std::string id;
Transform placement;
};
std::vector<PartResult> placements;
// Degrees of freedom remaining (-1 = unknown).
int dof {-1};
// Constraint diagnostics (redundant, conflicting, etc.).
std::vector<ConstraintDiagnostic> diagnostics;
// For kinematic simulation: number of computed frames.
std::size_t num_frames {0};
};
} // namespace KCSolve
#endif // KCSOLVE_TYPES_H

View File

@@ -0,0 +1,31 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
set(KCSolvePy_SRCS
PyIKCSolver.h
kcsolve_py.cpp
)
add_library(kcsolve_py SHARED ${KCSolvePy_SRCS})
target_include_directories(kcsolve_py
PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/src
${pybind11_INCLUDE_DIR}
)
target_link_libraries(kcsolve_py
PRIVATE
pybind11::module
Python3::Python
KCSolve
)
if(FREECAD_WARN_ERROR)
target_compile_warn_error(kcsolve_py)
endif()
SET_BIN_DIR(kcsolve_py kcsolve /Mod/Assembly)
SET_PYTHON_PREFIX_SUFFIX(kcsolve_py)
INSTALL(TARGETS kcsolve_py DESTINATION ${CMAKE_INSTALL_LIBDIR})

View File

@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#ifndef KCSOLVE_PYIKCSOLVER_H
#define KCSOLVE_PYIKCSOLVER_H
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
namespace KCSolve
{
/// pybind11 trampoline class for IKCSolver.
/// Enables Python subclasses that override virtual methods.
class PyIKCSolver : public IKCSolver
{
public:
using IKCSolver::IKCSolver;
// ── Pure virtuals ──────────────────────────────────────────────
std::string name() const override
{
PYBIND11_OVERRIDE_PURE(std::string, IKCSolver, name);
}
std::vector<BaseJointKind> supported_joints() const override
{
PYBIND11_OVERRIDE_PURE(std::vector<BaseJointKind>, IKCSolver, supported_joints);
}
SolveResult solve(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE_PURE(SolveResult, IKCSolver, solve, ctx);
}
// ── Virtuals with defaults ─────────────────────────────────────
SolveResult update(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update, ctx);
}
SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, pre_drag, ctx, drag_parts);
}
SolveResult drag_step(
const std::vector<SolveResult::PartResult>& drag_placements) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, drag_step, drag_placements);
}
void post_drag() override
{
PYBIND11_OVERRIDE(void, IKCSolver, post_drag);
}
SolveResult run_kinematic(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, run_kinematic, ctx);
}
std::size_t num_frames() const override
{
PYBIND11_OVERRIDE(std::size_t, IKCSolver, num_frames);
}
SolveResult update_for_frame(std::size_t index) override
{
PYBIND11_OVERRIDE(SolveResult, IKCSolver, update_for_frame, index);
}
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
{
PYBIND11_OVERRIDE(std::vector<ConstraintDiagnostic>, IKCSolver, diagnose, ctx);
}
bool is_deterministic() const override
{
PYBIND11_OVERRIDE(bool, IKCSolver, is_deterministic);
}
void export_native(const std::string& path) override
{
PYBIND11_OVERRIDE(void, IKCSolver, export_native, path);
}
bool supports_bundle_fixed() const override
{
PYBIND11_OVERRIDE(bool, IKCSolver, supports_bundle_fixed);
}
};
} // namespace KCSolve
#endif // KCSOLVE_PYIKCSOLVER_H

View File

@@ -0,0 +1,359 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* This file is part of FreeCAD. *
* *
* FreeCAD is free software: you can redistribute it and/or modify it *
* under the terms of the GNU Lesser General Public License as *
* published by the Free Software Foundation, either version 2.1 of the *
* License, or (at your option) any later version. *
* *
* FreeCAD is distributed in the hope that it will be useful, but *
* WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
* Lesser General Public License for more details. *
* *
* You should have received a copy of the GNU Lesser General Public *
* License along with FreeCAD. If not, see *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
#include <Mod/Assembly/Solver/OndselAdapter.h>
#include <Mod/Assembly/Solver/SolverRegistry.h>
#include <Mod/Assembly/Solver/Types.h>
#include "PyIKCSolver.h"
#include <memory>
#include <string>
namespace py = pybind11;
using namespace KCSolve;
// ── PySolverHolder ─────────────────────────────────────────────────
//
// Wraps a Python IKCSolver subclass instance so it can live inside a
// std::unique_ptr<IKCSolver> returned by SolverRegistry::get().
// Prevents Python GC by holding a py::object reference and acquires
// the GIL before every forwarded call.
class PySolverHolder : public IKCSolver
{
public:
explicit PySolverHolder(py::object obj)
: obj_(std::move(obj))
{
solver_ = obj_.cast<IKCSolver*>();
}
std::string name() const override
{
py::gil_scoped_acquire gil;
return solver_->name();
}
std::vector<BaseJointKind> supported_joints() const override
{
py::gil_scoped_acquire gil;
return solver_->supported_joints();
}
SolveResult solve(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->solve(ctx);
}
SolveResult update(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->update(ctx);
}
SolveResult pre_drag(const SolveContext& ctx,
const std::vector<std::string>& drag_parts) override
{
py::gil_scoped_acquire gil;
return solver_->pre_drag(ctx, drag_parts);
}
SolveResult drag_step(
const std::vector<SolveResult::PartResult>& drag_placements) override
{
py::gil_scoped_acquire gil;
return solver_->drag_step(drag_placements);
}
void post_drag() override
{
py::gil_scoped_acquire gil;
solver_->post_drag();
}
SolveResult run_kinematic(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->run_kinematic(ctx);
}
std::size_t num_frames() const override
{
py::gil_scoped_acquire gil;
return solver_->num_frames();
}
SolveResult update_for_frame(std::size_t index) override
{
py::gil_scoped_acquire gil;
return solver_->update_for_frame(index);
}
std::vector<ConstraintDiagnostic> diagnose(const SolveContext& ctx) override
{
py::gil_scoped_acquire gil;
return solver_->diagnose(ctx);
}
bool is_deterministic() const override
{
py::gil_scoped_acquire gil;
return solver_->is_deterministic();
}
void export_native(const std::string& path) override
{
py::gil_scoped_acquire gil;
solver_->export_native(path);
}
bool supports_bundle_fixed() const override
{
py::gil_scoped_acquire gil;
return solver_->supports_bundle_fixed();
}
private:
py::object obj_; // prevents Python GC
IKCSolver* solver_; // raw pointer into the trampoline inside obj_
};
// ── Module definition ──────────────────────────────────────────────
PYBIND11_MODULE(kcsolve, m)
{
m.doc() = "KCSolve — pluggable assembly constraint solver API";
m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR;
// ── Enums ──────────────────────────────────────────────────────
py::enum_<BaseJointKind>(m, "BaseJointKind")
.value("Coincident", BaseJointKind::Coincident)
.value("PointOnLine", BaseJointKind::PointOnLine)
.value("PointInPlane", BaseJointKind::PointInPlane)
.value("Concentric", BaseJointKind::Concentric)
.value("Tangent", BaseJointKind::Tangent)
.value("Planar", BaseJointKind::Planar)
.value("LineInPlane", BaseJointKind::LineInPlane)
.value("Parallel", BaseJointKind::Parallel)
.value("Perpendicular", BaseJointKind::Perpendicular)
.value("Angle", BaseJointKind::Angle)
.value("Fixed", BaseJointKind::Fixed)
.value("Revolute", BaseJointKind::Revolute)
.value("Cylindrical", BaseJointKind::Cylindrical)
.value("Slider", BaseJointKind::Slider)
.value("Ball", BaseJointKind::Ball)
.value("Screw", BaseJointKind::Screw)
.value("Universal", BaseJointKind::Universal)
.value("Gear", BaseJointKind::Gear)
.value("RackPinion", BaseJointKind::RackPinion)
.value("Cam", BaseJointKind::Cam)
.value("Slot", BaseJointKind::Slot)
.value("DistancePointPoint", BaseJointKind::DistancePointPoint)
.value("DistanceCylSph", BaseJointKind::DistanceCylSph)
.value("Custom", BaseJointKind::Custom);
py::enum_<SolveStatus>(m, "SolveStatus")
.value("Success", SolveStatus::Success)
.value("Failed", SolveStatus::Failed)
.value("InvalidFlip", SolveStatus::InvalidFlip)
.value("NoGroundedParts", SolveStatus::NoGroundedParts);
py::enum_<ConstraintDiagnostic::Kind>(m, "DiagnosticKind")
.value("Redundant", ConstraintDiagnostic::Kind::Redundant)
.value("Conflicting", ConstraintDiagnostic::Kind::Conflicting)
.value("PartiallyRedundant", ConstraintDiagnostic::Kind::PartiallyRedundant)
.value("Malformed", ConstraintDiagnostic::Kind::Malformed);
py::enum_<MotionDef::Kind>(m, "MotionKind")
.value("Rotational", MotionDef::Kind::Rotational)
.value("Translational", MotionDef::Kind::Translational)
.value("General", MotionDef::Kind::General);
py::enum_<Constraint::Limit::Kind>(m, "LimitKind")
.value("TranslationMin", Constraint::Limit::Kind::TranslationMin)
.value("TranslationMax", Constraint::Limit::Kind::TranslationMax)
.value("RotationMin", Constraint::Limit::Kind::RotationMin)
.value("RotationMax", Constraint::Limit::Kind::RotationMax);
// ── Struct bindings ────────────────────────────────────────────
py::class_<Transform>(m, "Transform")
.def(py::init<>())
.def_readwrite("position", &Transform::position)
.def_readwrite("quaternion", &Transform::quaternion)
.def_static("identity", &Transform::identity)
.def("__repr__", [](const Transform& t) {
return "<kcsolve.Transform pos=["
+ std::to_string(t.position[0]) + ", "
+ std::to_string(t.position[1]) + ", "
+ std::to_string(t.position[2]) + "]>";
});
py::class_<Part>(m, "Part")
.def(py::init<>())
.def_readwrite("id", &Part::id)
.def_readwrite("placement", &Part::placement)
.def_readwrite("mass", &Part::mass)
.def_readwrite("grounded", &Part::grounded);
auto constraint_class = py::class_<Constraint>(m, "Constraint");
py::class_<Constraint::Limit>(constraint_class, "Limit")
.def(py::init<>())
.def_readwrite("kind", &Constraint::Limit::kind)
.def_readwrite("value", &Constraint::Limit::value)
.def_readwrite("tolerance", &Constraint::Limit::tolerance);
constraint_class
.def(py::init<>())
.def_readwrite("id", &Constraint::id)
.def_readwrite("part_i", &Constraint::part_i)
.def_readwrite("marker_i", &Constraint::marker_i)
.def_readwrite("part_j", &Constraint::part_j)
.def_readwrite("marker_j", &Constraint::marker_j)
.def_readwrite("type", &Constraint::type)
.def_readwrite("params", &Constraint::params)
.def_readwrite("limits", &Constraint::limits)
.def_readwrite("activated", &Constraint::activated);
py::class_<MotionDef>(m, "MotionDef")
.def(py::init<>())
.def_readwrite("kind", &MotionDef::kind)
.def_readwrite("joint_id", &MotionDef::joint_id)
.def_readwrite("marker_i", &MotionDef::marker_i)
.def_readwrite("marker_j", &MotionDef::marker_j)
.def_readwrite("rotation_expr", &MotionDef::rotation_expr)
.def_readwrite("translation_expr", &MotionDef::translation_expr);
py::class_<SimulationParams>(m, "SimulationParams")
.def(py::init<>())
.def_readwrite("t_start", &SimulationParams::t_start)
.def_readwrite("t_end", &SimulationParams::t_end)
.def_readwrite("h_out", &SimulationParams::h_out)
.def_readwrite("h_min", &SimulationParams::h_min)
.def_readwrite("h_max", &SimulationParams::h_max)
.def_readwrite("error_tol", &SimulationParams::error_tol);
py::class_<SolveContext>(m, "SolveContext")
.def(py::init<>())
.def_readwrite("parts", &SolveContext::parts)
.def_readwrite("constraints", &SolveContext::constraints)
.def_readwrite("motions", &SolveContext::motions)
.def_readwrite("simulation", &SolveContext::simulation)
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed);
py::class_<ConstraintDiagnostic>(m, "ConstraintDiagnostic")
.def(py::init<>())
.def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id)
.def_readwrite("kind", &ConstraintDiagnostic::kind)
.def_readwrite("detail", &ConstraintDiagnostic::detail);
auto result_class = py::class_<SolveResult>(m, "SolveResult");
py::class_<SolveResult::PartResult>(result_class, "PartResult")
.def(py::init<>())
.def_readwrite("id", &SolveResult::PartResult::id)
.def_readwrite("placement", &SolveResult::PartResult::placement);
result_class
.def(py::init<>())
.def_readwrite("status", &SolveResult::status)
.def_readwrite("placements", &SolveResult::placements)
.def_readwrite("dof", &SolveResult::dof)
.def_readwrite("diagnostics", &SolveResult::diagnostics)
.def_readwrite("num_frames", &SolveResult::num_frames);
// ── IKCSolver (with trampoline for Python subclassing) ─────────
py::class_<IKCSolver, PyIKCSolver>(m, "IKCSolver")
.def(py::init<>())
.def("name", &IKCSolver::name)
.def("supported_joints", &IKCSolver::supported_joints)
.def("solve", &IKCSolver::solve, py::arg("ctx"))
.def("update", &IKCSolver::update, py::arg("ctx"))
.def("pre_drag", &IKCSolver::pre_drag,
py::arg("ctx"), py::arg("drag_parts"))
.def("drag_step", &IKCSolver::drag_step,
py::arg("drag_placements"))
.def("post_drag", &IKCSolver::post_drag)
.def("run_kinematic", &IKCSolver::run_kinematic, py::arg("ctx"))
.def("num_frames", &IKCSolver::num_frames)
.def("update_for_frame", &IKCSolver::update_for_frame,
py::arg("index"))
.def("diagnose", &IKCSolver::diagnose, py::arg("ctx"))
.def("is_deterministic", &IKCSolver::is_deterministic)
.def("export_native", &IKCSolver::export_native, py::arg("path"))
.def("supports_bundle_fixed", &IKCSolver::supports_bundle_fixed);
// ── OndselAdapter ──────────────────────────────────────────────
py::class_<OndselAdapter, IKCSolver>(m, "OndselAdapter")
.def(py::init<>());
// ── Module-level functions (SolverRegistry wrapper) ────────────
m.def("available", []() {
return SolverRegistry::instance().available();
}, "Return names of all registered solvers.");
m.def("load", [](const std::string& name) {
return SolverRegistry::instance().get(name);
}, py::arg("name") = "",
"Create an instance of the named solver (default if empty).\n"
"Returns None if the solver is not found.");
m.def("joints_for", [](const std::string& name) {
return SolverRegistry::instance().joints_for(name);
}, py::arg("name"),
"Query supported joint types for the named solver.");
m.def("set_default", [](const std::string& name) {
return SolverRegistry::instance().set_default(name);
}, py::arg("name"),
"Set the default solver name. Returns True if the name is registered.");
m.def("get_default", []() {
return SolverRegistry::instance().get_default();
}, "Get the current default solver name.");
m.def("register_solver", [](const std::string& name, py::object py_solver_class) {
auto cls = std::make_shared<py::object>(std::move(py_solver_class));
CreateSolverFn factory = [cls]() -> std::unique_ptr<IKCSolver> {
py::gil_scoped_acquire gil;
py::object instance = (*cls)();
return std::make_unique<PySolverHolder>(std::move(instance));
};
return SolverRegistry::instance().register_solver(name, std::move(factory));
}, py::arg("name"), py::arg("solver_class"),
"Register a Python solver class with the SolverRegistry.\n"
"solver_class must be a callable that returns an IKCSolver subclass.");
}

View File

@@ -22,11 +22,17 @@
# **************************************************************************/
import TestApp
from AssemblyTests.TestCore import TestCore
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
from AssemblyTests.TestCore import TestCore
from AssemblyTests.TestKCSolvePy import (
TestKCSolveImport, # noqa: F401
TestKCSolveRegistry, # noqa: F401
TestKCSolveTypes, # noqa: F401
TestPySolver, # noqa: F401
)
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
# Use the modules so that code checkers don't complain (flake8)
True if TestCore else False
True if TestCommandInsertLink else False
True if TestSolverIntegration else False

View File

@@ -45,6 +45,51 @@ def _metadata_save_hook(doc, filename, entries):
register_pre_reinject(_metadata_save_hook)
def _manifest_enrich_hook(doc, filename, entries):
"""Populate silo_instance and part_uuid from the tracked Silo object."""
raw = entries.get("silo/manifest.json")
if raw is None:
return
try:
manifest = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return
changed = False
# Populate part_uuid from SiloItemId if available.
for obj in doc.Objects:
if hasattr(obj, "SiloItemId") and obj.SiloItemId:
if manifest.get("part_uuid") != obj.SiloItemId:
manifest["part_uuid"] = obj.SiloItemId
changed = True
break
# Populate silo_instance from Silo settings.
if not manifest.get("silo_instance"):
try:
import silo_commands
api_url = silo_commands._get_api_url()
if api_url:
# Strip /api suffix to get base instance URL.
instance = api_url.rstrip("/")
if instance.endswith("/api"):
instance = instance[:-4]
manifest["silo_instance"] = instance
changed = True
except Exception:
pass
if changed:
entries["silo/manifest.json"] = (json.dumps(manifest, indent=2) + "\n").encode(
"utf-8"
)
register_pre_reinject(_manifest_enrich_hook)
KC_VERSION = "1.0"
@@ -133,6 +178,34 @@ class _KcFormatObserver:
)
def update_manifest_fields(filename, updates):
"""Update fields in an existing .kc manifest after save.
*filename*: path to the .kc file.
*updates*: dict of field_name -> value to merge into the manifest.
Used by silo_commands to write ``revision_hash`` after a successful
upload (which happens after the ZIP has already been written by save).
"""
if not filename or not filename.lower().endswith(".kc"):
return
if not os.path.isfile(filename):
return
try:
with zipfile.ZipFile(filename, "a") as zf:
if "silo/manifest.json" not in zf.namelist():
return
raw = zf.read("silo/manifest.json")
manifest = json.loads(raw)
manifest.update(updates)
zf.writestr(
"silo/manifest.json",
json.dumps(manifest, indent=2) + "\n",
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kc_format: failed to update manifest: {e}\n")
def register():
"""Connect to application-level save signals."""
FreeCAD.addDocumentObserver(_KcFormatObserver())

View File

@@ -95,6 +95,7 @@ if(BUILD_GUI)
endif()
if(BUILD_ASSEMBLY)
list (APPEND TestExecutables Assembly_tests_run)
list (APPEND TestExecutables KCSolve_tests_run)
endif(BUILD_ASSEMBLY)
if(BUILD_MATERIAL)
list (APPEND TestExecutables Material_tests_run)

View File

@@ -1,6 +1,7 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
add_subdirectory(App)
add_subdirectory(Solver)
if (NOT FREECAD_USE_EXTERNAL_ONDSELSOLVER)
target_include_directories(Assembly_tests_run PUBLIC

View File

@@ -0,0 +1,13 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
add_executable(KCSolve_tests_run
SolverRegistry.cpp
OndselAdapter.cpp
)
target_link_libraries(KCSolve_tests_run
gtest_main
${Google_Tests_LIBS}
KCSolve
FreeCADApp
)

View File

@@ -0,0 +1,251 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <gtest/gtest.h>
#include <FCConfig.h>
#include <App/Application.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
#include <Mod/Assembly/Solver/OndselAdapter.h>
#include <Mod/Assembly/Solver/Types.h>
#include <src/App/InitApplication.h>
#include <algorithm>
#include <cmath>
using namespace KCSolve;
// ── Fixture ────────────────────────────────────────────────────────
class OndselAdapterTest : public ::testing::Test
{
protected:
static void SetUpTestSuite()
{
tests::initApplication();
}
void SetUp() override
{
adapter_ = std::make_unique<OndselAdapter>();
}
/// Build a minimal two-part context with a single constraint.
static SolveContext twoPartContext(BaseJointKind jointKind,
bool groundFirst = true)
{
SolveContext ctx;
Part p1;
p1.id = "Part1";
p1.placement = Transform::identity();
p1.grounded = groundFirst;
ctx.parts.push_back(p1);
Part p2;
p2.id = "Part2";
p2.placement = Transform::identity();
p2.placement.position = {100.0, 0.0, 0.0};
p2.grounded = false;
ctx.parts.push_back(p2);
Constraint c;
c.id = "Joint1";
c.part_i = "Part1";
c.marker_i = Transform::identity();
c.part_j = "Part2";
c.marker_j = Transform::identity();
c.type = jointKind;
ctx.constraints.push_back(c);
return ctx;
}
std::unique_ptr<OndselAdapter> adapter_;
};
// ── Identity / capability tests ────────────────────────────────────
TEST_F(OndselAdapterTest, Name) // NOLINT
{
auto n = adapter_->name();
EXPECT_FALSE(n.empty());
EXPECT_NE(n.find("Ondsel"), std::string::npos);
}
TEST_F(OndselAdapterTest, SupportedJoints) // NOLINT
{
auto joints = adapter_->supported_joints();
EXPECT_FALSE(joints.empty());
// Must include core kinematic joints.
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Fixed), joints.end());
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Revolute), joints.end());
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Cylindrical), joints.end());
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Ball), joints.end());
// Must exclude unsupported types.
EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Universal), joints.end());
EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Cam), joints.end());
EXPECT_EQ(std::find(joints.begin(), joints.end(), BaseJointKind::Slot), joints.end());
}
TEST_F(OndselAdapterTest, IsDeterministic) // NOLINT
{
EXPECT_TRUE(adapter_->is_deterministic());
}
TEST_F(OndselAdapterTest, SupportsBundleFixed) // NOLINT
{
EXPECT_FALSE(adapter_->supports_bundle_fixed());
}
// ── Solve round-trips ──────────────────────────────────────────────
TEST_F(OndselAdapterTest, SolveFixedJoint) // NOLINT
{
auto ctx = twoPartContext(BaseJointKind::Fixed);
auto result = adapter_->solve(ctx);
EXPECT_EQ(result.status, SolveStatus::Success);
EXPECT_FALSE(result.placements.empty());
// Both parts should end up at the same position (fixed joint).
const auto* pr1 = &result.placements[0];
const auto* pr2 = &result.placements[1];
if (pr1->id == "Part2") {
std::swap(pr1, pr2);
}
// Part1 is grounded — should remain at origin.
EXPECT_NEAR(pr1->placement.position[0], 0.0, 1e-3);
EXPECT_NEAR(pr1->placement.position[1], 0.0, 1e-3);
EXPECT_NEAR(pr1->placement.position[2], 0.0, 1e-3);
// Part2 should be pulled to Part1's position by the fixed joint
// (markers are both identity, so the parts are welded at the same point).
EXPECT_NEAR(pr2->placement.position[0], 0.0, 1e-3);
EXPECT_NEAR(pr2->placement.position[1], 0.0, 1e-3);
EXPECT_NEAR(pr2->placement.position[2], 0.0, 1e-3);
}
TEST_F(OndselAdapterTest, SolveRevoluteJoint) // NOLINT
{
auto ctx = twoPartContext(BaseJointKind::Revolute);
auto result = adapter_->solve(ctx);
EXPECT_EQ(result.status, SolveStatus::Success);
EXPECT_FALSE(result.placements.empty());
}
TEST_F(OndselAdapterTest, SolveNoGroundedParts) // NOLINT
{
// OndselAdapter itself doesn't require grounded parts — that check
// lives in AssemblyObject. The solver should still attempt to solve.
auto ctx = twoPartContext(BaseJointKind::Fixed, /*groundFirst=*/false);
auto result = adapter_->solve(ctx);
// May succeed or fail depending on OndselSolver's behavior, but must not crash.
EXPECT_TRUE(result.status == SolveStatus::Success
|| result.status == SolveStatus::Failed);
}
TEST_F(OndselAdapterTest, SolveCatchesException) // NOLINT
{
// Malformed context: constraint references non-existent parts.
SolveContext ctx;
Part p;
p.id = "LonePart";
p.placement = Transform::identity();
p.grounded = true;
ctx.parts.push_back(p);
Constraint c;
c.id = "BadJoint";
c.part_i = "DoesNotExist";
c.marker_i = Transform::identity();
c.part_j = "AlsoDoesNotExist";
c.marker_j = Transform::identity();
c.type = BaseJointKind::Fixed;
ctx.constraints.push_back(c);
// Should not crash — returns Failed or succeeds with warnings.
auto result = adapter_->solve(ctx);
SUCCEED(); // If we get here without crashing, the test passes.
}
// ── Drag protocol ──────────────────────────────────────────────────
TEST_F(OndselAdapterTest, DragProtocol) // NOLINT
{
auto ctx = twoPartContext(BaseJointKind::Revolute);
auto preResult = adapter_->pre_drag(ctx, {"Part2"});
EXPECT_EQ(preResult.status, SolveStatus::Success);
// Move Part2 slightly.
SolveResult::PartResult dragPlc;
dragPlc.id = "Part2";
dragPlc.placement = Transform::identity();
dragPlc.placement.position = {10.0, 5.0, 0.0};
auto stepResult = adapter_->drag_step({dragPlc});
// drag_step may fail if the solver can't converge — that's OK.
EXPECT_TRUE(stepResult.status == SolveStatus::Success
|| stepResult.status == SolveStatus::Failed);
// post_drag must not crash.
adapter_->post_drag();
SUCCEED();
}
// ── Diagnostics ────────────────────────────────────────────────────
TEST_F(OndselAdapterTest, DiagnoseRedundant) // NOLINT
{
// Over-constrained: two fixed joints between the same two parts.
SolveContext ctx;
Part p1;
p1.id = "PartA";
p1.placement = Transform::identity();
p1.grounded = true;
ctx.parts.push_back(p1);
Part p2;
p2.id = "PartB";
p2.placement = Transform::identity();
p2.placement.position = {50.0, 0.0, 0.0};
p2.grounded = false;
ctx.parts.push_back(p2);
Constraint c1;
c1.id = "FixedJoint1";
c1.part_i = "PartA";
c1.marker_i = Transform::identity();
c1.part_j = "PartB";
c1.marker_j = Transform::identity();
c1.type = BaseJointKind::Fixed;
ctx.constraints.push_back(c1);
Constraint c2;
c2.id = "FixedJoint2";
c2.part_i = "PartA";
c2.marker_i = Transform::identity();
c2.part_j = "PartB";
c2.marker_j = Transform::identity();
c2.type = BaseJointKind::Fixed;
ctx.constraints.push_back(c2);
auto diags = adapter_->diagnose(ctx);
// With two identical fixed joints, one must be redundant.
bool hasRedundant = std::any_of(diags.begin(), diags.end(), [](const auto& d) {
return d.kind == ConstraintDiagnostic::Kind::Redundant;
});
EXPECT_TRUE(hasRedundant);
}

View File

@@ -0,0 +1,131 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <gtest/gtest.h>
#include <Mod/Assembly/Solver/IKCSolver.h>
#include <Mod/Assembly/Solver/SolverRegistry.h>
#include <Mod/Assembly/Solver/Types.h>
#include <algorithm>
using namespace KCSolve;
// ── Minimal mock solver for registry tests ─────────────────────────
namespace
{
class MockSolver : public IKCSolver
{
public:
std::string name() const override
{
return "MockSolver";
}
std::vector<BaseJointKind> supported_joints() const override
{
return {BaseJointKind::Fixed, BaseJointKind::Revolute};
}
SolveResult solve(const SolveContext& /*ctx*/) override
{
return SolveResult {SolveStatus::Success, {}, 0, {}, 0};
}
};
} // namespace
// ── Tests ──────────────────────────────────────────────────────────
//
// SolverRegistry is a singleton — tests use unique names to avoid
// interference across test cases.
TEST(SolverRegistryTest, GetUnknownReturnsNull) // NOLINT
{
auto solver = SolverRegistry::instance().get("nonexistent_solver_xyz");
EXPECT_EQ(solver, nullptr);
}
TEST(SolverRegistryTest, RegisterAndGet) // NOLINT
{
auto& reg = SolverRegistry::instance();
bool ok = reg.register_solver("test_reg_get",
[]() { return std::make_unique<MockSolver>(); });
EXPECT_TRUE(ok);
auto solver = reg.get("test_reg_get");
ASSERT_NE(solver, nullptr);
EXPECT_EQ(solver->name(), "MockSolver");
}
TEST(SolverRegistryTest, DuplicateRegistrationFails) // NOLINT
{
auto& reg = SolverRegistry::instance();
bool first = reg.register_solver("test_dup",
[]() { return std::make_unique<MockSolver>(); });
EXPECT_TRUE(first);
bool second = reg.register_solver("test_dup",
[]() { return std::make_unique<MockSolver>(); });
EXPECT_FALSE(second);
}
TEST(SolverRegistryTest, AvailableListsSolvers) // NOLINT
{
auto& reg = SolverRegistry::instance();
reg.register_solver("test_avail_1",
[]() { return std::make_unique<MockSolver>(); });
reg.register_solver("test_avail_2",
[]() { return std::make_unique<MockSolver>(); });
auto names = reg.available();
EXPECT_NE(std::find(names.begin(), names.end(), "test_avail_1"), names.end());
EXPECT_NE(std::find(names.begin(), names.end(), "test_avail_2"), names.end());
}
TEST(SolverRegistryTest, SetDefaultAndGet) // NOLINT
{
auto& reg = SolverRegistry::instance();
reg.register_solver("test_default",
[]() { return std::make_unique<MockSolver>(); });
bool ok = reg.set_default("test_default");
EXPECT_TRUE(ok);
// get() with no arg should return the default.
auto solver = reg.get();
ASSERT_NE(solver, nullptr);
EXPECT_EQ(solver->name(), "MockSolver");
}
TEST(SolverRegistryTest, SetDefaultUnknownFails) // NOLINT
{
auto& reg = SolverRegistry::instance();
bool ok = reg.set_default("totally_unknown_solver");
EXPECT_FALSE(ok);
}
TEST(SolverRegistryTest, JointsForReturnsCapabilities) // NOLINT
{
auto& reg = SolverRegistry::instance();
reg.register_solver("test_joints",
[]() { return std::make_unique<MockSolver>(); });
auto joints = reg.joints_for("test_joints");
EXPECT_EQ(joints.size(), 2u);
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Fixed), joints.end());
EXPECT_NE(std::find(joints.begin(), joints.end(), BaseJointKind::Revolute), joints.end());
}
TEST(SolverRegistryTest, JointsForUnknownReturnsEmpty) // NOLINT
{
auto joints = SolverRegistry::instance().joints_for("totally_unknown_solver_2");
EXPECT_TRUE(joints.empty());
}