Compare commits
70 Commits
d7b532255b
...
feat/solve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8fc1388ba | ||
|
|
bd43e62822 | ||
|
|
406e120180 | ||
|
|
7ea0078ba3 | ||
| f20ae3a667 | |||
|
|
934cdf5767 | ||
|
|
5c33aacecb | ||
|
|
32dbe20ce0 | ||
|
|
76b91c6597 | ||
|
|
47e6c14461 | ||
| 551979b441 | |||
|
|
8897399a10 | ||
|
|
6dc4341a5f | ||
| ab6d09c138 | |||
|
|
133af52f11 | ||
| 0bc2cf3b6a | |||
|
|
0330396843 | ||
|
|
6690d0355a | ||
| 7fe046379b | |||
|
|
0bc03ea421 | ||
| 0c43957e9b | |||
|
|
2ce00a527a | ||
| 967e434607 | |||
|
|
264e82179d | ||
|
|
40fac46862 | ||
| ed71a0c8b9 | |||
| 0ea2622a73 | |||
|
|
9748384e7d | ||
|
|
bb14d7b0ef | ||
| 099d2a025a | |||
| 3e93f4a756 | |||
| 8b6205a340 | |||
| 6fe5cc1d4d | |||
|
|
29f4a7b110 | ||
|
|
e947822c7a | ||
| 92183ef697 | |||
| b721e67c8d | |||
|
|
90728414a9 | ||
| d87b79698f | |||
|
|
65f24b23eb | ||
|
|
deb425db44 | ||
|
|
e70348508e | ||
| 41669eea8b | |||
|
|
ea49736549 | ||
|
|
99f2a92df4 | ||
| 4510ace7b9 | |||
| a1105c9d80 | |||
| 06c698d425 | |||
| 252e2c3b3e | |||
| 68380357fb | |||
| 076a1e90b0 | |||
| ab2fde4755 | |||
| 5d8a253956 | |||
| 6c11d64c5f | |||
| 0ffa171c52 | |||
| acfb627d67 | |||
| 64edae4c04 | |||
| 0ea1b1fde5 | |||
| e667aceead | |||
| 34964066a0 | |||
| 33026b6ff9 | |||
|
|
9dd43a7cc3 | ||
| 98d1877472 | |||
| 587a95dd66 | |||
| 73f6641199 | |||
| 60ceb47e4f | |||
| a8df078eb3 | |||
| f4d91db094 | |||
| b083970a4d | |||
| 19a91cb221 |
2
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -5,42 +5,116 @@
|
||||
```
|
||||
FreeCAD startup
|
||||
└─ src/Mod/Create/Init.py
|
||||
└─ setup_kindred_addons()
|
||||
├─ exec(mods/ztools/ztools/Init.py)
|
||||
└─ exec(mods/silo/freecad/Init.py)
|
||||
└─ addon_loader.load_addons(gui=False)
|
||||
├─ scan_addons("mods/") — find package.xml manifests
|
||||
├─ parse_manifest() — extract <kindred> extensions
|
||||
├─ validate_manifest() — check min/max_create_version
|
||||
├─ resolve_load_order() — topological sort by <dependency>
|
||||
└─ for each addon in order:
|
||||
├─ add addon dir to sys.path
|
||||
├─ exec(Init.py)
|
||||
└─ register in AddonRegistry (FreeCAD.KindredAddons)
|
||||
|
||||
└─ src/Mod/Create/InitGui.py
|
||||
├─ setup_kindred_workbenches()
|
||||
│ ├─ exec(mods/ztools/ztools/InitGui.py)
|
||||
│ │ └─ schedules deferred _register() (2000ms)
|
||||
│ │ ├─ imports ZTools commands
|
||||
│ │ ├─ installs _ZToolsManipulator (global)
|
||||
│ │ └─ injects commands into editing contexts
|
||||
│ └─ exec(mods/silo/freecad/InitGui.py)
|
||||
│ ├─ registers SiloWorkbench
|
||||
│ └─ schedules deferred Silo overlay registration (2500ms)
|
||||
├─ addon_loader.load_addons(gui=True)
|
||||
│ └─ for each addon in order:
|
||||
│ └─ exec(InitGui.py)
|
||||
│ ├─ sdk (priority 0): logs "SDK loaded"
|
||||
│ ├─ ztools (priority 50): schedules deferred _register() (2000ms)
|
||||
│ │ ├─ imports ZTools commands
|
||||
│ │ ├─ installs _ZToolsManipulator (global)
|
||||
│ │ └─ injects commands into editing contexts
|
||||
│ └─ silo (priority 60): registers SiloWorkbench
|
||||
│ └─ schedules deferred Silo overlay registration (2500ms)
|
||||
├─ EditingContextResolver singleton created (MainWindow constructor)
|
||||
│ ├─ registers built-in contexts (PartDesign, Sketcher, Assembly, Spreadsheet)
|
||||
│ ├─ connects to signalInEdit/signalResetEdit/signalActiveDocument/signalActivateView
|
||||
│ └─ BreadcrumbToolBar connected to contextChanged signal
|
||||
└─ Deferred setup (QTimer):
|
||||
├─ 500ms: _register_kc_format() → .kc file format
|
||||
├─ 1500ms: _register_silo_origin() → registers Silo FileOrigin
|
||||
├─ 2000ms: _setup_silo_auth_panel() → "Database Auth" dock
|
||||
├─ 2000ms: ZTools _register() → commands + manipulator
|
||||
├─ 2500ms: Silo overlay registration → "Silo Origin" toolbar overlay
|
||||
├─ 3000ms: _check_silo_first_start() → settings prompt
|
||||
├─ 4000ms: _setup_silo_activity_panel() → "Database Activity" dock (SSE)
|
||||
├─ 4000ms: _setup_silo_activity_panel() → "Database Activity" dock
|
||||
└─ 10000ms: _check_for_updates() → update checker (Gitea API)
|
||||
```
|
||||
|
||||
### Addon lifecycle
|
||||
|
||||
Each addon in `mods/` provides a `package.xml` manifest with a `<kindred>` extension block:
|
||||
|
||||
```xml
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>50</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
```
|
||||
|
||||
The loader (`addon_loader.py`) processes addons in this order:
|
||||
|
||||
1. **Scan** — find all `mods/*/package.xml` files
|
||||
2. **Parse** — extract `<kindred>` metadata (version bounds, priority, dependencies)
|
||||
3. **Validate** — reject addons incompatible with the current Create version
|
||||
4. **Resolve** — topological sort by `<dependency>` declarations, breaking ties by `<load_priority>`
|
||||
5. **Load** — execute `Init.py` (console) then `InitGui.py` (GUI) for each addon
|
||||
6. **Register** — populate `FreeCAD.KindredAddons` registry with addon state
|
||||
|
||||
Current load order: **sdk** (0) → **ztools** (50) → **silo** (60)
|
||||
|
||||
## Key source layout
|
||||
|
||||
```
|
||||
src/Mod/Create/ Kindred bootstrap module (Python)
|
||||
├── Init.py Adds mods/ addon paths, loads Init.py files
|
||||
├── InitGui.py Loads workbenches, installs Silo manipulators
|
||||
src/Mod/Create/ Kindred Create module
|
||||
├── Init.py Console bootstrap — loads addons via manifest-driven loader
|
||||
├── InitGui.py GUI bootstrap — loads addons, Silo integration, update checker
|
||||
├── addon_loader.py Manifest-driven addon loader with dependency resolution
|
||||
├── kc_format.py .kc file format round-trip preservation
|
||||
├── version.py.in CMake template → version.py (build-time)
|
||||
└── update_checker.py Checks Gitea releases API for updates
|
||||
├── update_checker.py Checks Gitea releases API for updates
|
||||
├── CreateGlobal.h C++ export macros (CreateExport, CreateGuiExport)
|
||||
├── App/ C++ App library (CreateApp.so)
|
||||
│ ├── AppCreate.cpp Module entry point — PyMOD_INIT_FUNC(CreateApp)
|
||||
│ └── AppCreatePy.cpp Python module object (Py::ExtensionModule)
|
||||
└── Gui/ C++ Gui library (CreateGui.so)
|
||||
├── AppCreateGui.cpp Module entry point — PyMOD_INIT_FUNC(CreateGui)
|
||||
└── AppCreateGuiPy.cpp Python module object (Py::ExtensionModule)
|
||||
|
||||
mods/sdk/ [dir] Kindred addon SDK — stable API contract
|
||||
├── package.xml Manifest (priority 0, no dependencies)
|
||||
├── kindred_sdk/
|
||||
│ ├── __init__.py Public API re-exports
|
||||
│ ├── context.py Editing context wrappers (register_context, register_overlay, ...)
|
||||
│ ├── theme.py YAML-driven palette system (get_theme_tokens, load_palette, Palette)
|
||||
│ ├── origin.py FileOrigin registration (register_origin, unregister_origin)
|
||||
│ ├── dock.py Deferred dock panel helper (register_dock_panel)
|
||||
│ ├── compat.py Version detection (create_version, freecad_version)
|
||||
│ └── palettes/
|
||||
│ └── catppuccin-mocha.yaml 26 colors + 14 semantic roles
|
||||
└── Init.py / InitGui.py Minimal log messages
|
||||
|
||||
mods/ztools/ [submodule] command provider (not a workbench)
|
||||
├── package.xml Manifest (priority 50, depends on sdk)
|
||||
├── ztools/InitGui.py Deferred command registration + _ZToolsManipulator
|
||||
├── ztools/ztools/
|
||||
│ ├── commands/ Datum, pattern, pocket, assembly, spreadsheet
|
||||
│ ├── datums/core.py Datum creation via Part::AttachExtension
|
||||
│ └── resources/ Icons (SDK theme tokens), theme utilities
|
||||
└── CatppuccinMocha/ Theme preference pack (QSS)
|
||||
|
||||
mods/silo/ [submodule → silo-mod.git] FreeCAD workbench
|
||||
├── freecad/package.xml Manifest (priority 60, depends on sdk)
|
||||
├── silo-client/ [submodule → silo-client.git] shared API client
|
||||
│ └── silo_client/ SiloClient, SiloSettings, CATEGORY_NAMES
|
||||
└── freecad/ FreeCAD workbench (Python)
|
||||
├── InitGui.py SiloWorkbench + overlay registration (via SDK)
|
||||
├── silo_commands.py Commands + FreeCADSiloSettings adapter
|
||||
└── silo_origin.py FileOrigin backend for Silo (via SDK)
|
||||
|
||||
src/Gui/EditingContext.h/.cpp EditingContextResolver singleton + context registry
|
||||
src/Gui/BreadcrumbToolBar.h/.cpp Color-coded breadcrumb toolbar (Catppuccin Mocha)
|
||||
@@ -49,22 +123,6 @@ src/Gui/CommandOrigin.cpp Origin_Commit/Pull/Push/Info/BOM commands
|
||||
src/Gui/OriginManager.h/.cpp Origin lifecycle management
|
||||
src/Gui/OriginSelectorWidget.h/.cpp UI for origin selection
|
||||
|
||||
mods/ztools/ [submodule] command provider (not a workbench)
|
||||
├── ztools/InitGui.py Deferred command registration + _ZToolsManipulator
|
||||
├── ztools/ztools/
|
||||
│ ├── commands/ Datum, pattern, pocket, assembly, spreadsheet
|
||||
│ ├── datums/core.py Datum creation via Part::AttachExtension
|
||||
│ └── resources/ Icons, theme utilities
|
||||
└── CatppuccinMocha/ Theme preference pack (QSS)
|
||||
|
||||
mods/silo/ [submodule -> silo-mod.git] FreeCAD workbench
|
||||
├── silo-client/ [submodule -> silo-client.git] shared API client
|
||||
│ └── silo_client/ SiloClient, SiloSettings, CATEGORY_NAMES
|
||||
└── freecad/ FreeCAD workbench (Python)
|
||||
├── InitGui.py SiloWorkbench + Silo overlay context registration
|
||||
├── silo_commands.py Commands + FreeCADSiloSettings adapter
|
||||
└── silo_origin.py FileOrigin backend for Silo
|
||||
|
||||
src/Gui/Stylesheets/ QSS themes and SVG assets
|
||||
src/Gui/PreferencePacks/ KindredCreate preference pack (cfg + build-time QSS)
|
||||
```
|
||||
|
||||
568
docs/INTER_SOLVER.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
115
docs/UPSTREAM.md
@@ -74,46 +74,71 @@ These files/directories exist only in Kindred Create and can be copied directly
|
||||
|
||||
36 Kindred commits touch core FreeCAD C++ files. Of those, **38 files** also changed on `upstream/main`, creating potential conflicts. Listed in chronological order (oldest first) for cherry-pick sequence.
|
||||
|
||||
### Category legend
|
||||
|
||||
| Category | Meaning | Long-term plan |
|
||||
|----------|---------|----------------|
|
||||
| **1 — Extension** | Platform extension points for Python addons. Core differentiator. | KEEP — maintain and isolate |
|
||||
| **2 — Branding** | Branding and theming. Unavoidable in a fork. | KEEP — minimize surface area |
|
||||
| **3 — Bug fix** | Bug fixes and polish applicable to upstream FreeCAD. | UPSTREAM — contribute and eliminate |
|
||||
|
||||
### Commit sequence
|
||||
|
||||
| # | Hash | Summary | Conflict Risk | Files |
|
||||
|---|------|---------|---------------|-------|
|
||||
| 1 | `316d4f4b524` | Initial branding (CMakeLists, splash, about, theme, icons, QRC) | **HIGH** — CMakeLists.txt, DlgAbout.cpp, SplashScreen.cpp, resource.qrc all changed upstream | 14 files |
|
||||
| 2 | `8c6837cc152` | Theme padding/clipping fixes | LOW — KindredCreate.qss is new file | 1 file |
|
||||
| 3 | `bb3f3ac6d6c` | Dock task panel right, remove non-Kindred themes | **MEDIUM** — MainWindow.cpp, PreferencePacks CMakeLists changed upstream | 8 files |
|
||||
| 4 | `e85162947b7` | Startup theme selector fix | **MEDIUM** — StartupProcess.cpp changed upstream | 4 files |
|
||||
| 5 | `eb80c07f57a` | Theme QSS refinements | LOW — Kindred-only files | 4 files |
|
||||
| 6 | `8639b6fd8ab` | Resolve unknown command/style token errors | **MEDIUM** — StartupProcess.cpp | 5 files |
|
||||
| 7 | `fea1280fa90` | Theme alternate-background-color | LOW | 2 files |
|
||||
| 8 | `b3fedfb19fb` | Theme tree item padding | LOW | 2 files |
|
||||
| 9 | `0d4545b7d67` | Tree branch line SVGs | LOW — new files | 8 files |
|
||||
| 10 | `434ae797a43` | Theme selector cleanup | **MEDIUM** — StartupProcess.cpp | 4 files |
|
||||
| 11 | `224feda4ad6` | Catppuccin icon override infrastructure | **MEDIUM** — BitmapFactory.cpp, CMakeLists.txt | 2 files |
|
||||
| 12 | `7535a48ec4c` | Origin abstraction layer | **HIGH** — Application.cpp, ApplicationPy.cpp/.h, CMakeLists.txt | 6 files (4 new) |
|
||||
| 13 | `38358e431d2` | LocalFileOrigin and Std_* delegation | **HIGH** — CommandDoc.cpp, FileOrigin.cpp/.h | 3 files |
|
||||
| 14 | `79c85ed2e5d` | FileOriginPython interactive methods | LOW — Kindred-only files | 3 files |
|
||||
| 15 | `deeb6376f71` | OriginSelectorWidget | **HIGH** — Action.cpp/.h, CommandStd.cpp, Workbench.cpp, CMakeLists.txt | 8 files |
|
||||
| 16 | `679aaec6d49` | Origin commands (Commit/Pull/Push) | **MEDIUM** — Application.cpp, Command.h, Workbench.cpp | 5 files |
|
||||
| 17 | `db85277f262` | OriginManagerDialog | LOW — new files + OriginSelectorWidget.cpp | 3 files |
|
||||
| 18 | `015df38328c` | Document-origin tracking in window title | **MEDIUM** — MDIView.cpp | 3 files |
|
||||
| 19 | `a6e84552da5` | Cross-origin detection in SaveAs | **MEDIUM** — CommandDoc.cpp | 1 file |
|
||||
| 20 | `84b69b935b2` | Build fix: remove SelectModule.h include | LOW | 1 file |
|
||||
| 21 | `2f594dac0a5` | Use Python API for viewDefaultOrientation | **MEDIUM** — CommandDoc.cpp | 1 file |
|
||||
| 22 | `724440dcb75` | Build fixes for OriginSelectorWidget/Manager | LOW — Kindred files | 2 files |
|
||||
| 23 | `c858706d480` | Add silo icons to QRC | LOW — resource.qrc (additive) | 6 files |
|
||||
| 24 | `d95c850b7b1` | Widen origin selector widget | LOW | 1 file |
|
||||
| 25 | `10b5c9d584f` | Wire OriginManagerDialog to Silo_Settings | LOW | 1 file |
|
||||
| 26 | `cc5ba638d1f` | UI polish — Wayland scaling, menu icon size | **HIGH** — DlgSettingsGeneral.cpp/.h/.ui changed upstream | 6 files |
|
||||
| 27 | `4bf74cf3391` | Build fix for DlgSettingsGeneral | **MEDIUM** — DlgSettingsGeneral.h, StartupProcess.cpp | 2 files |
|
||||
| 28 | `6773ca0dfd8` | Eliminate QSS/CFG duplication | LOW — Kindred files | 3 files |
|
||||
| 29 | `977fa3c9347` | QGroupBox indicator, hyperlink color | LOW — KindredCreate.qss | 1 file |
|
||||
| 30 | `8b2ce4b73a4` | Native Qt start panel + kindred:// URL | **MEDIUM** — MainWindow.cpp | 1 file |
|
||||
| 31 | `bf637af4e45` | Window flickering and icon clipping fix | **MEDIUM** — MainWindow.cpp/.h | 3 files |
|
||||
| 32 | `70118201b02` | MDI pre-document tab for Silo new item | **HIGH** — ApplicationPy, BreadcrumbToolBar, EditingContext, MainWindow, Workbench | 11 files |
|
||||
| 33 | `1f49e3fa6da` | Build fix: ToolBarItem incomplete type, reportException | **LOW** — likely already fixed upstream | 2 files |
|
||||
| 34 | `f71decca089` | Splash screen: skip runtime draw, mantle bg | **MEDIUM** — SplashScreen.cpp | 3 files |
|
||||
| 35 | `2b0c6774c07` | Dev build defaults, skip version migration | **MEDIUM** — CMakeLists.txt, DlgVersionMigrator.cpp | 2 files |
|
||||
| 36 | `ab71902a4c2` | Forward visibility arg in appendToolbar() | LOW — FreeCADGuiInit.py | 1 file |
|
||||
| # | Hash | Summary | Category | Conflict Risk | Files |
|
||||
|---|------|---------|----------|---------------|-------|
|
||||
| 1 | `316d4f4b524` | Initial branding (CMakeLists, splash, about, theme, icons, QRC) | 2 — Branding | **HIGH** — CMakeLists.txt, DlgAbout.cpp, SplashScreen.cpp, resource.qrc all changed upstream | 14 files |
|
||||
| 2 | `8c6837cc152` | Theme padding/clipping fixes | 2 — Branding | LOW — KindredCreate.qss is new file | 1 file |
|
||||
| 3 | `bb3f3ac6d6c` | Dock task panel right, remove non-Kindred themes | 2 — Branding | **MEDIUM** — MainWindow.cpp, PreferencePacks CMakeLists changed upstream | 8 files |
|
||||
| 4 | `e85162947b7` | Startup theme selector fix | 2 — Branding | **MEDIUM** — StartupProcess.cpp changed upstream | 4 files |
|
||||
| 5 | `eb80c07f57a` | Theme QSS refinements | 2 — Branding | LOW — Kindred-only files | 4 files |
|
||||
| 6 | `8639b6fd8ab` | Resolve unknown command/style token errors | 2 — Branding | **MEDIUM** — StartupProcess.cpp | 5 files |
|
||||
| 7 | `fea1280fa90` | Theme alternate-background-color | 2 — Branding | LOW | 2 files |
|
||||
| 8 | `b3fedfb19fb` | Theme tree item padding | 2 — Branding | LOW | 2 files |
|
||||
| 9 | `0d4545b7d67` | Tree branch line SVGs | 2 — Branding | LOW — new files | 8 files |
|
||||
| 10 | `434ae797a43` | Theme selector cleanup | 2 — Branding | **MEDIUM** — StartupProcess.cpp | 4 files |
|
||||
| 11 | `224feda4ad6` | Catppuccin icon override infrastructure | 2 — Branding | **MEDIUM** — BitmapFactory.cpp, CMakeLists.txt | 2 files |
|
||||
| 12 | `7535a48ec4c` | Origin abstraction layer | 1 — Extension | **HIGH** — Application.cpp, ApplicationPy.cpp/.h, CMakeLists.txt | 6 files (4 new) |
|
||||
| 13 | `38358e431d2` | LocalFileOrigin and Std_* delegation | 1 — Extension | **HIGH** — CommandDoc.cpp, FileOrigin.cpp/.h | 3 files |
|
||||
| 14 | `79c85ed2e5d` | FileOriginPython interactive methods | 1 — Extension | LOW — Kindred-only files | 3 files |
|
||||
| 15 | `deeb6376f71` | OriginSelectorWidget | 1 — Extension | **HIGH** — Action.cpp/.h, CommandStd.cpp, Workbench.cpp, CMakeLists.txt | 8 files |
|
||||
| 16 | `679aaec6d49` | Origin commands (Commit/Pull/Push) | 1 — Extension | **MEDIUM** — Application.cpp, Command.h, Workbench.cpp | 5 files |
|
||||
| 17 | `db85277f262` | OriginManagerDialog | 1 — Extension | LOW — new files + OriginSelectorWidget.cpp | 3 files |
|
||||
| 18 | `015df38328c` | Document-origin tracking in window title | 1 — Extension | **MEDIUM** — MDIView.cpp | 3 files |
|
||||
| 19 | `a6e84552da5` | Cross-origin detection in SaveAs | 1 — Extension | **MEDIUM** — CommandDoc.cpp | 1 file |
|
||||
| 20 | `84b69b935b2` | Build fix: remove SelectModule.h include | 1 — Extension | LOW | 1 file |
|
||||
| 21 | `2f594dac0a5` | Use Python API for viewDefaultOrientation | 1 — Extension | **MEDIUM** — CommandDoc.cpp | 1 file |
|
||||
| 22 | `724440dcb75` | Build fixes for OriginSelectorWidget/Manager | 1 — Extension | LOW — Kindred files | 2 files |
|
||||
| 23 | `c858706d480` | Add silo icons to QRC | 2 — Branding | LOW — resource.qrc (additive) | 6 files |
|
||||
| 24 | `d95c850b7b1` | Widen origin selector widget | 1 — Extension | LOW | 1 file |
|
||||
| 25 | `10b5c9d584f` | Wire OriginManagerDialog to Silo_Settings | 1 — Extension | LOW | 1 file |
|
||||
| 26 | `cc5ba638d1f` | UI polish — Wayland scaling, menu icon size | 3 — Bug fix | **HIGH** — DlgSettingsGeneral.cpp/.h/.ui changed upstream | 6 files |
|
||||
| 27 | `4bf74cf3391` | Build fix for DlgSettingsGeneral | 3 — Bug fix | **MEDIUM** — DlgSettingsGeneral.h, StartupProcess.cpp | 2 files |
|
||||
| 28 | `6773ca0dfd8` | Eliminate QSS/CFG duplication | 2 — Branding | LOW — Kindred files | 3 files |
|
||||
| 29 | `977fa3c9347` | QGroupBox indicator, hyperlink color | 2 — Branding | LOW — KindredCreate.qss | 1 file |
|
||||
| 30 | `8b2ce4b73a4` | Native Qt start panel + kindred:// URL | 1 — Extension | **MEDIUM** — MainWindow.cpp | 1 file |
|
||||
| 31 | `bf637af4e45` | Window flickering and icon clipping fix | 3 — Bug fix | **MEDIUM** — MainWindow.cpp/.h | 3 files |
|
||||
| 32 | `70118201b02` | MDI pre-document tab for Silo new item | 1 — Extension | **HIGH** — ApplicationPy, BreadcrumbToolBar, EditingContext, MainWindow, Workbench | 11 files |
|
||||
| 33 | `1f49e3fa6da` | Build fix: ToolBarItem incomplete type, reportException | 3 — Bug fix | **LOW** — Kindred-specific build fix | 2 files |
|
||||
| 34 | `f71decca089` | Splash screen: skip runtime draw, mantle bg | 2 — Branding | **MEDIUM** — SplashScreen.cpp | 3 files |
|
||||
| 35 | `2b0c6774c07` | Dev build defaults, skip version migration | 2 — Branding | **MEDIUM** — CMakeLists.txt, DlgVersionMigrator.cpp | 2 files |
|
||||
| 36 | `ab71902a4c2` | Forward visibility arg in appendToolbar() | 1 — Extension | LOW — FreeCADGuiInit.py | 1 file |
|
||||
|
||||
### Category summary
|
||||
|
||||
| Category | Count | Commits |
|
||||
|----------|-------|---------|
|
||||
| 1 — Extension | 16 | #12–22, #24–25, #30, #32, #36 |
|
||||
| 2 — Branding | 16 | #1–11, #23, #28–29, #34–35 |
|
||||
| 3 — Bug fix | 4 | #26–27, #31, #33 |
|
||||
|
||||
### Category 3 — Upstream status (verified Feb 2026)
|
||||
|
||||
| # | Summary | Upstream Fixed? | FreeCAD Issues | Notes |
|
||||
|---|---------|-----------------|----------------|-------|
|
||||
| 26 | Wayland scaling, menu icon size pref | PARTIAL | [#25448](https://github.com/FreeCAD/FreeCAD/issues/25448), [#23830](https://github.com/FreeCAD/FreeCAD/issues/23830), [#23396](https://github.com/FreeCAD/FreeCAD/issues/23396) | Upstream has some HiDPI C++ fixes but Wayland fractional scaling still open. Menu icon size pref is a Kindred-added feature, not yet contributed. |
|
||||
| 27 | Build fix for DlgSettingsGeneral | NO | — | Companion to #26. `setMenuIconSize()` in StartupProcess is Kindred-added. |
|
||||
| 31 | Window flickering and icon clipping | NO | [#12917](https://github.com/FreeCAD/FreeCAD/issues/12917), [#18481](https://github.com/FreeCAD/FreeCAD/issues/18481) | Upstream still uses `setStyleSheet()` on statusBar causing repaint cascade. Good upstream PR candidate — replace with `setPalette()`. |
|
||||
| 33 | ToolBarItem incomplete type, reportException | N/A | — | Kindred-internal build fix. The `ToolBarItem` forward-decl issue only manifests with Kindred modifications to Workbench.h. The `reportException` typo is in Kindred-only editing context code. |
|
||||
|
||||
### HIGH conflict files (need manual attention)
|
||||
|
||||
@@ -137,14 +162,14 @@ These files are modified by both Kindred and upstream. They will almost certainl
|
||||
|
||||
## Phase 3: Module-Level Changes
|
||||
|
||||
### Assembly fixes (check if still needed)
|
||||
### Assembly fixes (verified Feb 2026)
|
||||
|
||||
| Hash | Summary | Status |
|
||||
|------|---------|--------|
|
||||
| `316d4f4b524` | Joint flip overconstrain fix | May be fixed upstream — verify |
|
||||
| `9dc50cef727` | SIGSEGV during document restore | May be fixed upstream — verify |
|
||||
| `ddefb236521` | Solver ignoring datum plane refs | May be fixed upstream — verify |
|
||||
| `b7374d7b1fc` | findPlacement() datum/origin handling | May be fixed upstream — verify |
|
||||
| Hash | Summary | Upstream Status | Action |
|
||||
|------|---------|-----------------|--------|
|
||||
| `316d4f4b524` | Joint flip overconstrain fix (91° rotation guard) | **NOT fixed** — upstream has basic grounded-object validation only. [FreeCAD#20377](https://github.com/FreeCAD/FreeCAD/issues/20377) open. | KEEP — re-apply |
|
||||
| `9dc50cef727` | SIGSEGV during document restore (`isRestoring()` guard) | **NOT fixed** — upstream `onChanged()` has no restore guard. [FreeCAD#18225](https://github.com/FreeCAD/FreeCAD/issues/18225) closed via different path. | KEEP — re-apply |
|
||||
| `ddefb236521` | Solver ignoring datum plane refs (`PartDesign::Plane`/`Point`) | **NOT fixed** — upstream `findPlacement()` lacks these types. | KEEP — re-apply, upstream candidate |
|
||||
| `b7374d7b1fc` | findPlacement() datum/origin handling (`App::Plane`/`Point`) | **PARTIAL** — `App::Line` fixed upstream via [PR#20026](https://github.com/FreeCAD/FreeCAD/pull/20026). `App::Plane`/`App::Point` still missing. | KEEP — re-apply the `Plane`/`Point` parts only |
|
||||
|
||||
Files touched: `src/Mod/Assembly/App/AssemblyObject.cpp`, `src/Mod/Assembly/UtilsAssembly.py`, `src/Mod/Assembly/InitGui.py`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
- [Repository Structure](./development/repo-structure.md)
|
||||
- [Build System](./development/build-system.md)
|
||||
- [Gui Module Build](./development/gui-build-integration.md)
|
||||
- [Package.xml Schema Extensions](./development/package-xml-schema.md)
|
||||
|
||||
# Silo Server
|
||||
|
||||
@@ -45,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
|
||||
@@ -63,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
109
docs/src/development/package-xml-schema.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Package.xml Schema Extensions
|
||||
|
||||
Kindred Create extends FreeCAD's standard `package.xml` addon manifest with a `<kindred>` element. This element provides metadata for the manifest-driven addon loader: version compatibility, load ordering, dependency declarations, and editing context registration.
|
||||
|
||||
The `<kindred>` element is ignored by FreeCAD's AddonManager and stock module loader. Addons with this element remain compatible with upstream FreeCAD.
|
||||
|
||||
## Field reference
|
||||
|
||||
| Field | Parsed by loader | Required | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `min_create_version` | Yes | No | *(none)* | Minimum Kindred Create version. Addon is skipped if the running version is lower. |
|
||||
| `max_create_version` | Yes | No | *(none)* | Maximum Kindred Create version. Addon is skipped if the running version is higher. |
|
||||
| `load_priority` | Yes | No | `100` | Integer. Lower values load first. Used as a secondary sort after dependency resolution. |
|
||||
| `dependencies` | Yes | No | *(none)* | List of addon names (by `<name>` in their `package.xml`) that must load before this one. |
|
||||
| `sdk_version` | No | No | *(none)* | Required kindred-addon-sdk version. Reserved for future use when the SDK is available. |
|
||||
| `pure_python` | No | No | `true` | If `false`, the addon requires compiled C++ components. Informational. |
|
||||
| `contexts` | No | No | *(none)* | Editing contexts this addon registers or injects into. Informational. |
|
||||
|
||||
Fields marked "Parsed by loader" are read by `addon_loader.py` and affect load behavior. Other fields are informational metadata for tooling and documentation.
|
||||
|
||||
## Schema
|
||||
|
||||
```xml
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<max_create_version>1.0.0</max_create_version>
|
||||
<sdk_version>0.1.0</sdk_version>
|
||||
<load_priority>100</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
<dependency>other-addon</dependency>
|
||||
</dependencies>
|
||||
<contexts>
|
||||
<context id="partdesign.body" action="inject"/>
|
||||
<context id="sketcher.edit" action="register"/>
|
||||
<context id="*" action="overlay"/>
|
||||
</contexts>
|
||||
</kindred>
|
||||
```
|
||||
|
||||
All child elements are optional. An empty `<kindred/>` element is valid and signals that the addon is Kindred-aware with all defaults.
|
||||
|
||||
### Version fields
|
||||
|
||||
`min_create_version` and `max_create_version` are compared against the running Kindred Create version using semantic versioning (major.minor.patch). If the running version falls outside the declared range, the addon is skipped with a warning in the report view.
|
||||
|
||||
### Load priority
|
||||
|
||||
When multiple addons have no dependency relationship, `load_priority` determines their relative order. Lower values load first. Suggested ranges:
|
||||
|
||||
| Range | Use |
|
||||
|---|---|
|
||||
| 0-9 | SDK and core infrastructure |
|
||||
| 10-49 | Foundation addons that others may depend on |
|
||||
| 50-99 | Standard addons |
|
||||
| 100+ | Optional or late-loading addons |
|
||||
|
||||
### Dependencies
|
||||
|
||||
Each `<dependency>` names another addon by its `<name>` element in `package.xml`. The loader resolves load order using topological sort. If a dependency is not found among discovered addons, the dependent addon is skipped.
|
||||
|
||||
### Contexts
|
||||
|
||||
The `<contexts>` element documents which editing contexts the addon interacts with. The `action` attribute describes the type of interaction:
|
||||
|
||||
| Action | Meaning |
|
||||
|---|---|
|
||||
| `inject` | Addon injects commands into this context's toolbars |
|
||||
| `register` | Addon registers this as a new context |
|
||||
| `overlay` | Addon registers an overlay that may apply across contexts |
|
||||
|
||||
A wildcard `id="*"` indicates the addon's overlay applies universally (matched by a condition function rather than a specific context ID).
|
||||
|
||||
## Example: complete package.xml
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
<name>MyAddon</name>
|
||||
<description>Example Kindred Create addon</description>
|
||||
<version>0.2.0</version>
|
||||
<maintainer email="dev@example.com">Developer</maintainer>
|
||||
<license>LGPL-2.1-or-later</license>
|
||||
<url type="repository">https://git.example.com/myaddon</url>
|
||||
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>MyAddonWorkbench</classname>
|
||||
<subdirectory>./</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>80</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<contexts>
|
||||
<context id="partdesign.body" action="inject"/>
|
||||
</contexts>
|
||||
</kindred>
|
||||
</package>
|
||||
```
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- The `<kindred>` element sits outside `<content>` and is not part of FreeCAD's package.xml specification. FreeCAD's `App.Metadata` C++ parser and the AddonManager's Python `MetadataReader` both ignore unknown elements.
|
||||
- An addon installed in stock FreeCAD will work normally; the `<kindred>` extensions are simply unused.
|
||||
- The Kindred Create loader works with or without the `<kindred>` element. Addons that omit it load with no version constraints and default priority (100).
|
||||
429
docs/src/reference/kcsolve-python.md
Normal 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
|
||||
@@ -73,25 +73,27 @@ database:
|
||||
|
||||
---
|
||||
|
||||
## Storage (MinIO/S3)
|
||||
## Storage (Filesystem)
|
||||
|
||||
| Key | Type | Default | Env Override | Description |
|
||||
|-----|------|---------|-------------|-------------|
|
||||
| `storage.endpoint` | string | — | `SILO_MINIO_ENDPOINT` | MinIO/S3 endpoint (`host:port`) |
|
||||
| `storage.access_key` | string | — | `SILO_MINIO_ACCESS_KEY` | Access key |
|
||||
| `storage.secret_key` | string | — | `SILO_MINIO_SECRET_KEY` | Secret key |
|
||||
| `storage.bucket` | string | — | — | S3 bucket name (created automatically if missing) |
|
||||
| `storage.use_ssl` | bool | `false` | — | Use HTTPS for MinIO connections |
|
||||
| `storage.region` | string | `"us-east-1"` | — | S3 region |
|
||||
Files are stored on the local filesystem under a configurable root directory.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `storage.backend` | string | `"filesystem"` | Storage backend (`filesystem`) |
|
||||
| `storage.filesystem.root_dir` | string | — | Root directory for file storage (required) |
|
||||
|
||||
```yaml
|
||||
storage:
|
||||
endpoint: "localhost:9000"
|
||||
access_key: "" # use SILO_MINIO_ACCESS_KEY env var
|
||||
secret_key: "" # use SILO_MINIO_SECRET_KEY env var
|
||||
bucket: "silo-files"
|
||||
use_ssl: false
|
||||
region: "us-east-1"
|
||||
backend: "filesystem"
|
||||
filesystem:
|
||||
root_dir: "/opt/silo/data"
|
||||
```
|
||||
|
||||
Ensure the directory exists and is writable by the `silo` user:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/silo/data
|
||||
sudo chown silo:silo /opt/silo/data
|
||||
```
|
||||
|
||||
---
|
||||
@@ -264,9 +266,6 @@ All environment variable overrides. These take precedence over values in `config
|
||||
| `SILO_DB_NAME` | `database.name` | PostgreSQL database name |
|
||||
| `SILO_DB_USER` | `database.user` | PostgreSQL user |
|
||||
| `SILO_DB_PASSWORD` | `database.password` | PostgreSQL password |
|
||||
| `SILO_MINIO_ENDPOINT` | `storage.endpoint` | MinIO endpoint |
|
||||
| `SILO_MINIO_ACCESS_KEY` | `storage.access_key` | MinIO access key |
|
||||
| `SILO_MINIO_SECRET_KEY` | `storage.secret_key` | MinIO secret key |
|
||||
| `SILO_SESSION_SECRET` | `auth.session_secret` | Session cookie signing secret |
|
||||
| `SILO_ADMIN_USERNAME` | `auth.local.default_admin_username` | Default admin username |
|
||||
| `SILO_ADMIN_PASSWORD` | `auth.local.default_admin_password` | Default admin password |
|
||||
@@ -296,11 +295,9 @@ database:
|
||||
sslmode: "disable"
|
||||
|
||||
storage:
|
||||
endpoint: "localhost:9000"
|
||||
access_key: "minioadmin"
|
||||
secret_key: "minioadmin"
|
||||
bucket: "silo-files"
|
||||
use_ssl: false
|
||||
backend: "filesystem"
|
||||
filesystem:
|
||||
root_dir: "./data"
|
||||
|
||||
schemas:
|
||||
directory: "./schemas"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> instructions. This document covers ongoing maintenance and operations for an
|
||||
> existing deployment.
|
||||
|
||||
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and MinIO services.
|
||||
This guide covers deploying Silo to a dedicated VM using external PostgreSQL and local filesystem storage.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -26,28 +26,25 @@ This guide covers deploying Silo to a dedicated VM using external PostgreSQL and
|
||||
│ │ silod │ │
|
||||
│ │ (Silo API Server) │ │
|
||||
│ │ :8080 │ │
|
||||
│ │ Files: /opt/silo/data │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────────────┐
|
||||
│ psql.example.internal │ │ minio.example.internal │
|
||||
│ PostgreSQL 16 │ │ MinIO S3 │
|
||||
│ :5432 │ │ :9000 (API) │
|
||||
│ │ │ :9001 (Console) │
|
||||
└─────────────────────────┘ └─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ psql.example.internal │
|
||||
│ PostgreSQL 16 │
|
||||
│ :5432 │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## External Services
|
||||
|
||||
The following external services are already configured:
|
||||
|
||||
| Service | Host | Database/Bucket | User |
|
||||
|---------|------|-----------------|------|
|
||||
| Service | Host | Database | User |
|
||||
|---------|------|----------|------|
|
||||
| PostgreSQL | psql.example.internal:5432 | silo | silo |
|
||||
| MinIO | minio.example.internal:9000 | silo-files | silouser |
|
||||
|
||||
Migrations have been applied to the database.
|
||||
Files are stored on the local filesystem at `/opt/silo/data`. Migrations have been applied to the database.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,21 +104,15 @@ Fill in the values:
|
||||
# Database credentials (psql.example.internal)
|
||||
SILO_DB_PASSWORD=your-database-password
|
||||
|
||||
# MinIO credentials (minio.example.internal)
|
||||
SILO_MINIO_ACCESS_KEY=silouser
|
||||
SILO_MINIO_SECRET_KEY=your-minio-secret-key
|
||||
|
||||
```
|
||||
|
||||
### Verify External Services
|
||||
|
||||
Before deploying, verify connectivity to external services:
|
||||
Before deploying, verify connectivity to PostgreSQL:
|
||||
|
||||
```bash
|
||||
# Test PostgreSQL
|
||||
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
|
||||
|
||||
# Test MinIO
|
||||
curl -I http://minio.example.internal:9000/minio/health/live
|
||||
```
|
||||
|
||||
---
|
||||
@@ -183,6 +174,7 @@ sudo -E /opt/silo/src/scripts/deploy.sh
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/silo/bin/silod` | Server binary |
|
||||
| `/opt/silo/data/` | File storage root |
|
||||
| `/opt/silo/src/` | Git repository checkout |
|
||||
| `/etc/silo/config.yaml` | Server configuration |
|
||||
| `/etc/silo/silod.env` | Environment variables (secrets) |
|
||||
@@ -242,7 +234,7 @@ sudo journalctl -u silod --since "2024-01-15 10:00:00"
|
||||
# Basic health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Full readiness check (includes DB and MinIO)
|
||||
# Full readiness check (includes DB)
|
||||
curl http://localhost:8080/ready
|
||||
```
|
||||
|
||||
@@ -318,24 +310,6 @@ psql -h psql.example.internal -U silo -d silo -f /opt/silo/src/migrations/008_ne
|
||||
|
||||
3. Check `pg_hba.conf` on PostgreSQL server allows connections from this host.
|
||||
|
||||
### Connection Refused to MinIO
|
||||
|
||||
1. Test network connectivity:
|
||||
```bash
|
||||
nc -zv minio.example.internal 9000
|
||||
```
|
||||
|
||||
2. Test with curl:
|
||||
```bash
|
||||
curl -I http://minio.example.internal:9000/minio/health/live
|
||||
```
|
||||
|
||||
3. Check SSL settings in config match MinIO setup:
|
||||
```yaml
|
||||
storage:
|
||||
use_ssl: true # or false
|
||||
```
|
||||
|
||||
### Health Check Fails
|
||||
|
||||
```bash
|
||||
@@ -345,7 +319,9 @@ curl -v http://localhost:8080/ready
|
||||
|
||||
# If ready fails but health passes, check external services
|
||||
psql -h psql.example.internal -U silo -d silo -c 'SELECT 1'
|
||||
curl http://minio.example.internal:9000/minio/health/live
|
||||
|
||||
# Check file storage directory
|
||||
ls -la /opt/silo/data
|
||||
```
|
||||
|
||||
### Build Fails
|
||||
@@ -460,10 +436,9 @@ sudo systemctl reload nginx
|
||||
|
||||
- [ ] `/etc/silo/silod.env` has mode 600 (`chmod 600`)
|
||||
- [ ] Database password is strong and unique
|
||||
- [ ] MinIO credentials are specific to silo (not admin)
|
||||
- [ ] SSL/TLS enabled for PostgreSQL (`sslmode: require`)
|
||||
- [ ] SSL/TLS enabled for MinIO (`use_ssl: true`) if available
|
||||
- [ ] HTTPS enabled via nginx reverse proxy
|
||||
- [ ] File storage directory (`/opt/silo/data`) owned by `silo` user with mode 750
|
||||
- [ ] Silod listens on localhost only (`host: 127.0.0.1`)
|
||||
- [ ] Firewall allows only ports 80, 443 (not 8080)
|
||||
- [ ] Service runs as non-root `silo` user
|
||||
|
||||
@@ -76,7 +76,7 @@ See [ROADMAP.md](ROADMAP.md) for the platform roadmap and dependency tier struct
|
||||
| Append-only revision history | Complete | `internal/db/items.go` |
|
||||
| Sequential revision numbering | Complete | Database trigger |
|
||||
| Property snapshots (JSONB) | Complete | `revisions.properties` |
|
||||
| File versioning (MinIO) | Complete | `internal/storage/` |
|
||||
| File storage (filesystem) | Complete | `internal/storage/` |
|
||||
| SHA256 checksums | Complete | Captured on upload |
|
||||
| Revision comments | Complete | `revisions.comment` |
|
||||
| User attribution | Complete | `revisions.created_by` |
|
||||
@@ -93,7 +93,7 @@ CREATE TABLE revisions (
|
||||
revision_number INTEGER NOT NULL,
|
||||
properties JSONB NOT NULL DEFAULT '{}',
|
||||
file_key TEXT,
|
||||
file_version TEXT, -- MinIO version ID
|
||||
file_version TEXT, -- storage version ID
|
||||
file_checksum TEXT, -- SHA256
|
||||
file_size BIGINT,
|
||||
thumbnail_key TEXT,
|
||||
@@ -283,7 +283,7 @@ Effort: Medium | Priority: Low | Risk: Low
|
||||
|
||||
**Changes:**
|
||||
- Add thumbnail generation on file upload
|
||||
- Store in MinIO at `thumbnails/{part_number}/rev{n}.png`
|
||||
- Store at `thumbnails/{part_number}/rev{n}.png`
|
||||
- Expose via `GET /api/items/{pn}/thumbnail/{rev}`
|
||||
|
||||
---
|
||||
@@ -377,7 +377,7 @@ internal/
|
||||
relationships.go # BOM repository
|
||||
projects.go # Project repository
|
||||
storage/
|
||||
storage.go # MinIO file storage helpers
|
||||
storage.go # File storage helpers
|
||||
migrations/
|
||||
001_initial.sql # Core schema
|
||||
...
|
||||
@@ -572,7 +572,7 @@ Reporting capabilities are absent. Basic reports (item counts, revision activity
|
||||
|
||||
| Feature | SOLIDWORKS PDM | Silo Status | Priority | Complexity |
|
||||
|---------|---------------|-------------|----------|------------|
|
||||
| File versioning | Automatic | Full (MinIO) | - | - |
|
||||
| File versioning | Automatic | Full (filesystem) | - | - |
|
||||
| File preview | Thumbnails, 3D preview | None | Medium | Complex |
|
||||
| File conversion | PDF, DXF generation | None | Medium | Complex |
|
||||
| Replication | Multi-site sync | None | Low | Complex |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This guide covers two installation methods:
|
||||
|
||||
- **[Option A: Docker Compose](#option-a-docker-compose)** — self-contained stack with all services. Recommended for evaluation, small teams, and environments where Docker is the standard.
|
||||
- **[Option B: Daemon Install](#option-b-daemon-install-systemd--external-services)** — systemd service with external PostgreSQL, MinIO, and optional LDAP/nginx. Recommended for production deployments integrated with existing infrastructure.
|
||||
- **[Option B: Daemon Install](#option-b-daemon-install-systemd--external-services)** — systemd service with external PostgreSQL and optional LDAP/nginx. Files are stored on the local filesystem. Recommended for production deployments integrated with existing infrastructure.
|
||||
|
||||
Both methods produce the same result: a running Silo server with a web UI, REST API, and authentication.
|
||||
|
||||
@@ -48,7 +48,7 @@ Regardless of which method you choose:
|
||||
|
||||
## Option A: Docker Compose
|
||||
|
||||
A single Docker Compose file runs everything: PostgreSQL, MinIO, OpenLDAP, and Silo. An optional nginx container can be enabled for reverse proxying.
|
||||
A single Docker Compose file runs everything: PostgreSQL, OpenLDAP, and Silo. Files are stored on the local filesystem. An optional nginx container can be enabled for reverse proxying.
|
||||
|
||||
### A.1 Prerequisites
|
||||
|
||||
@@ -80,7 +80,6 @@ The setup script generates credentials and configuration files:
|
||||
It prompts for:
|
||||
- Server domain (default: `localhost`)
|
||||
- PostgreSQL password (auto-generated if you press Enter)
|
||||
- MinIO credentials (auto-generated)
|
||||
- OpenLDAP admin password and initial user (auto-generated)
|
||||
- Silo local admin account (fallback when LDAP is unavailable)
|
||||
|
||||
@@ -106,7 +105,7 @@ Wait for all services to become healthy:
|
||||
docker compose -f deployments/docker-compose.allinone.yaml ps
|
||||
```
|
||||
|
||||
You should see `silo-postgres`, `silo-minio`, `silo-openldap`, and `silo-api` all in a healthy state.
|
||||
You should see `silo-postgres`, `silo-openldap`, and `silo-api` all in a healthy state.
|
||||
|
||||
View logs:
|
||||
|
||||
@@ -124,7 +123,7 @@ docker compose -f deployments/docker-compose.allinone.yaml logs -f silo
|
||||
# Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Readiness check (includes database and storage connectivity)
|
||||
# Readiness check (includes database connectivity)
|
||||
curl http://localhost:8080/ready
|
||||
```
|
||||
|
||||
@@ -226,7 +225,7 @@ The Silo container is rebuilt from the updated source. Database migrations in `m
|
||||
|
||||
## Option B: Daemon Install (systemd + External Services)
|
||||
|
||||
This method runs Silo as a systemd service on a dedicated host, connecting to externally managed PostgreSQL, MinIO, and optionally LDAP services.
|
||||
This method runs Silo as a systemd service on a dedicated host, connecting to externally managed PostgreSQL and optionally LDAP services. Files are stored on the local filesystem.
|
||||
|
||||
### B.1 Architecture Overview
|
||||
|
||||
@@ -240,21 +239,22 @@ This method runs Silo as a systemd service on a dedicated host, connecting to ex
|
||||
│ ┌───────▼────────┐ │
|
||||
│ │ silod │ │
|
||||
│ │ (API server) │ │
|
||||
│ └──┬─────────┬───┘ │
|
||||
└─────┼─────────┼──────┘
|
||||
│ │
|
||||
┌───────────▼──┐ ┌───▼──────────────┐
|
||||
│ PostgreSQL 16│ │ MinIO (S3) │
|
||||
│ :5432 │ │ :9000 API │
|
||||
└──────────────┘ │ :9001 Console │
|
||||
└──────────────────┘
|
||||
│ │ Files: /opt/ │ │
|
||||
│ │ silo/data │ │
|
||||
│ └──────┬─────────┘ │
|
||||
└─────────┼────────────┘
|
||||
│
|
||||
┌───────────▼──┐
|
||||
│ PostgreSQL 16│
|
||||
│ :5432 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### B.2 Prerequisites
|
||||
|
||||
- Linux host (Debian/Ubuntu or RHEL/Fedora/AlmaLinux)
|
||||
- Root or sudo access
|
||||
- Network access to your PostgreSQL and MinIO servers
|
||||
- Network access to your PostgreSQL server
|
||||
|
||||
The setup script installs Go and other build dependencies automatically.
|
||||
|
||||
@@ -281,26 +281,6 @@ Verify:
|
||||
psql -h YOUR_PG_HOST -U silo -d silo -c 'SELECT 1'
|
||||
```
|
||||
|
||||
#### MinIO
|
||||
|
||||
Install MinIO and create a bucket and service account:
|
||||
|
||||
- [MinIO quickstart](https://min.io/docs/minio/linux/index.html)
|
||||
|
||||
```bash
|
||||
# Using the MinIO client (mc):
|
||||
mc alias set local http://YOUR_MINIO_HOST:9000 minioadmin minioadmin
|
||||
mc mb local/silo-files
|
||||
mc admin user add local silouser YOUR_MINIO_SECRET
|
||||
mc admin policy attach local readwrite --user silouser
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -I http://YOUR_MINIO_HOST:9000/minio/health/live
|
||||
```
|
||||
|
||||
#### LDAP / FreeIPA (Optional)
|
||||
|
||||
For LDAP authentication, you need an LDAP server with user and group entries. Options:
|
||||
@@ -339,10 +319,10 @@ The script:
|
||||
4. Clones the repository
|
||||
5. Creates the environment file template
|
||||
|
||||
To override the default service hostnames:
|
||||
To override the default database hostname:
|
||||
|
||||
```bash
|
||||
SILO_DB_HOST=db.example.com SILO_MINIO_HOST=s3.example.com sudo -E bash scripts/setup-host.sh
|
||||
SILO_DB_HOST=db.example.com sudo -E bash scripts/setup-host.sh
|
||||
```
|
||||
|
||||
### B.5 Configure Credentials
|
||||
@@ -357,10 +337,6 @@ sudo nano /etc/silo/silod.env
|
||||
# Database
|
||||
SILO_DB_PASSWORD=your-database-password
|
||||
|
||||
# MinIO
|
||||
SILO_MINIO_ACCESS_KEY=silouser
|
||||
SILO_MINIO_SECRET_KEY=your-minio-secret
|
||||
|
||||
# Authentication
|
||||
SILO_SESSION_SECRET=generate-a-long-random-string
|
||||
SILO_ADMIN_USERNAME=admin
|
||||
@@ -379,7 +355,7 @@ Review the server configuration:
|
||||
sudo nano /etc/silo/config.yaml
|
||||
```
|
||||
|
||||
Update `database.host`, `storage.endpoint`, `server.base_url`, and authentication settings for your environment. See [CONFIGURATION.md](CONFIGURATION.md) for all options.
|
||||
Update `database.host`, `storage.filesystem.root_dir`, `server.base_url`, and authentication settings for your environment. See [CONFIGURATION.md](CONFIGURATION.md) for all options.
|
||||
|
||||
### B.6 Deploy
|
||||
|
||||
@@ -412,10 +388,10 @@ sudo /opt/silo/src/scripts/deploy.sh --restart-only
|
||||
sudo /opt/silo/src/scripts/deploy.sh --status
|
||||
```
|
||||
|
||||
To override the target host or database host:
|
||||
To override the target host:
|
||||
|
||||
```bash
|
||||
SILO_DEPLOY_TARGET=silo.example.com SILO_DB_HOST=db.example.com sudo -E scripts/deploy.sh
|
||||
SILO_DEPLOY_TARGET=silo.example.com sudo -E scripts/deploy.sh
|
||||
```
|
||||
|
||||
### B.7 Set Up Nginx and TLS
|
||||
|
||||
485
docs/src/silo-server/KC_SERVER.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# .kc Server-Side Metadata Integration
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** February 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
When a `.kc` file is committed to Silo, the server extracts and indexes the `silo/` directory contents so that metadata is queryable, diffable, and streamable without downloading the full file. This document specifies the server-side processing pipeline, database storage, API endpoints, and SSE events that support the Create viewport widgets defined in [SILO_VIEWPORT.md](SILO_VIEWPORT.md).
|
||||
|
||||
The core principle: **the `.kc` file is the transport format; Silo is the index.** The `silo/` directory entries are extracted into database columns on commit and packed back into the ZIP on checkout. The server never modifies the FreeCAD standard zone (`Document.xml`, `.brp` files, `thumbnails/`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Commit Pipeline
|
||||
|
||||
When a `.kc` file is uploaded via `POST /api/items/{partNumber}/file`, the server runs an extraction pipeline before returning success.
|
||||
|
||||
### 2.1 Pipeline Steps
|
||||
|
||||
```
|
||||
Client uploads .kc file
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 1. Store file to disk | (existing behavior -- unchanged)
|
||||
| items/{pn}/rev{N}.kc |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 2. Open ZIP, read silo/ |
|
||||
| Parse each entry |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 3. Validate manifest.json |
|
||||
| - UUID matches item |
|
||||
| - kc_version supported |
|
||||
| - revision_hash present |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 4. Index metadata |
|
||||
| - Upsert item_metadata |
|
||||
| - Upsert dependencies |
|
||||
| - Append history entry |
|
||||
| - Snapshot approvals |
|
||||
| - Register macros |
|
||||
| - Register job defs |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 5. Broadcast SSE events |
|
||||
| - revision.created |
|
||||
| - metadata.updated |
|
||||
| - bom.changed (if deps |
|
||||
| differ from previous) |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
Return 201 Created
|
||||
```
|
||||
|
||||
### 2.2 Validation Rules
|
||||
|
||||
| Check | Failure response |
|
||||
|-------|-----------------|
|
||||
| `silo/manifest.json` missing | `400 Bad Request` -- file is `.fcstd` not `.kc` |
|
||||
| `manifest.uuid` doesn't match item's UUID | `409 Conflict` -- wrong item |
|
||||
| `manifest.kc_version` > server's supported version | `422 Unprocessable` -- client newer than server |
|
||||
| `manifest.revision_hash` matches current head | `200 OK` (no-op, file unchanged) |
|
||||
| Any `silo/` JSON fails to parse | `422 Unprocessable` with path and parse error |
|
||||
|
||||
If validation fails, the blob is still stored (the user uploaded it), but no metadata indexing occurs. The item's revision is created with a `metadata_error` flag so the web UI can surface the problem.
|
||||
|
||||
### 2.3 Backward Compatibility
|
||||
|
||||
Plain `.fcstd` files (no `silo/` directory) continue to work exactly as today -- stored on disk, revision created, no metadata extraction. The pipeline short-circuits at step 2 when no `silo/` directory is found.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
### 3.1 `item_metadata` Table
|
||||
|
||||
Stores the indexed contents of `silo/metadata.json` as structured JSONB, searchable and filterable via the existing item query endpoints.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_metadata (
|
||||
item_id UUID PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
||||
schema_name TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
lifecycle_state TEXT NOT NULL DEFAULT 'draft',
|
||||
fields JSONB NOT NULL DEFAULT '{}',
|
||||
kc_version TEXT,
|
||||
manifest_uuid UUID,
|
||||
silo_instance TEXT,
|
||||
revision_hash TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_metadata_tags ON item_metadata USING GIN (tags);
|
||||
CREATE INDEX idx_item_metadata_lifecycle ON item_metadata (lifecycle_state);
|
||||
CREATE INDEX idx_item_metadata_fields ON item_metadata USING GIN (fields);
|
||||
```
|
||||
|
||||
On commit, the server upserts this row from `silo/manifest.json` and `silo/metadata.json`. The `fields` column contains the schema-driven key-value pairs exactly as they appear in the JSON.
|
||||
|
||||
### 3.2 `item_dependencies` Table
|
||||
|
||||
Stores the indexed contents of `silo/dependencies.json`. Replaces the BOM for assembly relationships that originate from the CAD model.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
child_uuid UUID NOT NULL,
|
||||
child_part_number TEXT,
|
||||
child_revision INTEGER,
|
||||
quantity DECIMAL,
|
||||
label TEXT,
|
||||
relationship TEXT NOT NULL DEFAULT 'component',
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_item_deps_parent ON item_dependencies (parent_item_id);
|
||||
CREATE INDEX idx_item_deps_child ON item_dependencies (child_uuid);
|
||||
```
|
||||
|
||||
This table complements the existing `relationships` table. The `relationships` table is the server-authoritative BOM (editable via the web UI and API). The `item_dependencies` table is the CAD-authoritative record extracted from the file. BOM merge (per [BOM_MERGE.md](BOM_MERGE.md)) reconciles the two.
|
||||
|
||||
### 3.3 `item_approvals` Table
|
||||
|
||||
Stores the indexed contents of `silo/approvals.json`. Server-authoritative -- the `.kc` snapshot is a read cache.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_approvals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
eco_number TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'draft',
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE approval_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
approval_id UUID REFERENCES item_approvals(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
signed_at TIMESTAMPTZ,
|
||||
comment TEXT
|
||||
);
|
||||
```
|
||||
|
||||
These tables exist independent of `.kc` commits -- approvals are created and managed through the web UI and API. On `.kc` checkout, the current approval state is serialized into `silo/approvals.json` for offline display.
|
||||
|
||||
### 3.4 `item_macros` Table
|
||||
|
||||
Registers macros from `silo/macros/` for server-side discoverability and the future Macro Store module.
|
||||
|
||||
```sql
|
||||
CREATE TABLE item_macros (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
content TEXT NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(item_id, filename)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Endpoints
|
||||
|
||||
These endpoints serve the viewport widgets in Create. All are under `/api/items/{partNumber}` and follow the existing auth model.
|
||||
|
||||
### 4.1 Metadata
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/metadata` | viewer | Get indexed metadata (schema fields, tags, lifecycle) |
|
||||
| `PUT` | `/metadata` | editor | Update metadata fields from client |
|
||||
| `PATCH` | `/metadata/lifecycle` | editor | Transition lifecycle state |
|
||||
| `PATCH` | `/metadata/tags` | editor | Add/remove tags |
|
||||
|
||||
**`GET /api/items/{partNumber}/metadata`**
|
||||
|
||||
Returns the indexed metadata for viewport display. This is the fast path -- reads from `item_metadata` rather than downloading and parsing the `.kc` ZIP.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "mechanical-part-v2",
|
||||
"lifecycle_state": "draft",
|
||||
"tags": ["structural", "aluminum"],
|
||||
"fields": {
|
||||
"material": "6061-T6",
|
||||
"finish": "anodized",
|
||||
"weight_kg": 0.34,
|
||||
"category": "bracket"
|
||||
},
|
||||
"manifest": {
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"silo_instance": "https://silo.example.com",
|
||||
"revision_hash": "a1b2c3d4e5f6",
|
||||
"kc_version": "1.0"
|
||||
},
|
||||
"updated_at": "2026-02-13T20:30:00Z",
|
||||
"updated_by": "joseph"
|
||||
}
|
||||
```
|
||||
|
||||
**`PUT /api/items/{partNumber}/metadata`**
|
||||
|
||||
Accepts a partial update of schema fields. The server merges into the existing `fields` JSONB. This is the write-back path for the Metadata Editor widget.
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"material": "7075-T6",
|
||||
"weight_kg": 0.31
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server validates field names against the schema descriptor. Unknown fields are rejected with `422`.
|
||||
|
||||
**`PATCH /api/items/{partNumber}/metadata/lifecycle`**
|
||||
|
||||
Transitions lifecycle state. The server validates the transition is permitted (e.g., `draft` -> `review` is allowed, `released` -> `draft` is not without admin override).
|
||||
|
||||
```json
|
||||
{ "state": "review" }
|
||||
```
|
||||
|
||||
### 4.2 Dependencies
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/dependencies` | viewer | Get CAD-extracted dependency list |
|
||||
| `GET` | `/dependencies/resolve` | viewer | Resolve UUIDs to current part numbers and file status |
|
||||
|
||||
**`GET /api/items/{partNumber}/dependencies`**
|
||||
|
||||
Returns the raw dependency list from the last `.kc` commit.
|
||||
|
||||
**`GET /api/items/{partNumber}/dependencies/resolve`**
|
||||
|
||||
Returns the dependency list with each UUID resolved to its current part number, revision, and whether the file exists on disk. This is what the Dependency Table widget calls to populate the status column.
|
||||
|
||||
```json
|
||||
{
|
||||
"links": [
|
||||
{
|
||||
"uuid": "660e8400-...",
|
||||
"part_number": "KC-BRK-0042",
|
||||
"label": "Base Plate",
|
||||
"revision": 2,
|
||||
"quantity": 1,
|
||||
"resolved": true,
|
||||
"file_available": true
|
||||
},
|
||||
{
|
||||
"uuid": "770e8400-...",
|
||||
"part_number": "KC-HDW-0108",
|
||||
"label": "M6 SHCS",
|
||||
"revision": 1,
|
||||
"quantity": 4,
|
||||
"resolved": true,
|
||||
"file_available": true
|
||||
},
|
||||
{
|
||||
"uuid": "880e8400-...",
|
||||
"part_number": null,
|
||||
"label": "Cover Panel",
|
||||
"revision": 1,
|
||||
"quantity": 1,
|
||||
"resolved": false,
|
||||
"file_available": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Approvals
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/approvals` | viewer | Get current approval state |
|
||||
| `POST` | `/approvals` | editor | Create ECO / start approval workflow |
|
||||
| `POST` | `/approvals/{id}/sign` | editor | Sign (approve/reject) |
|
||||
|
||||
These endpoints power the Approvals Viewer widget. The viewer is read-only in Create -- sign actions happen in the web UI, but the API exists for both.
|
||||
|
||||
### 4.4 Macros
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/macros` | viewer | List registered macros |
|
||||
| `GET` | `/macros/{filename}` | viewer | Get macro source |
|
||||
|
||||
Read-only server-side. Macros are authored in Create and committed inside the `.kc`. The server indexes them for discoverability in the future Macro Store.
|
||||
|
||||
### 4.5 Existing Endpoints (unchanged)
|
||||
|
||||
The viewport widgets also consume these existing endpoints:
|
||||
|
||||
| Widget | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| History Viewer | `GET /api/items/{pn}/revisions` | Full revision list |
|
||||
| History Viewer | `GET /api/items/{pn}/revisions/compare` | Property diff |
|
||||
| Job Viewer | `GET /api/jobs?item={pn}&definition={name}&limit=1` | Last job run |
|
||||
| Job Viewer | `POST /api/jobs` | Trigger job |
|
||||
| Job Viewer | `GET /api/jobs/{id}/logs` | Job log |
|
||||
| Manifest Viewer | `GET /api/items/{pn}` | Item details (UUID, etc.) |
|
||||
|
||||
No changes needed to these -- they already exist and return the data the widgets need.
|
||||
|
||||
---
|
||||
|
||||
## 5. Checkout Pipeline
|
||||
|
||||
When a client downloads a `.kc` via `GET /api/items/{partNumber}/file`, the server packs current server-side state into the `silo/` directory before serving the file. This ensures the client always gets the latest metadata, even if it was edited via the web UI since the last commit.
|
||||
|
||||
### 5.1 Pipeline Steps
|
||||
|
||||
```
|
||||
Client requests file download
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 1. Read .kc from disk |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 2. Pack silo/ from DB |
|
||||
| - manifest.json (item) |
|
||||
| - metadata.json (index) |
|
||||
| - history.json (revs) |
|
||||
| - approvals.json (ECO) |
|
||||
| - dependencies.json |
|
||||
| - macros/ (index) |
|
||||
| - jobs/ (job defs) |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
+-----------------------------+
|
||||
| 3. Replace silo/ in ZIP |
|
||||
| Remove old entries |
|
||||
| Write packed entries |
|
||||
+-----------------------------+
|
||||
|
|
||||
v
|
||||
Stream .kc to client
|
||||
```
|
||||
|
||||
### 5.2 Packing Rules
|
||||
|
||||
| `silo/` entry | Source | Notes |
|
||||
|---------------|--------|-------|
|
||||
| `manifest.json` | `item_metadata` + `items` table | UUID from item, revision_hash from latest revision |
|
||||
| `metadata.json` | `item_metadata.fields` + tags + lifecycle | Serialized from indexed columns |
|
||||
| `history.json` | `revisions` table | Last 20 revisions for this item |
|
||||
| `approvals.json` | `item_approvals` + `approval_signatures` | Current ECO state, omitted if no active ECO |
|
||||
| `dependencies.json` | `item_dependencies` | Current revision's dependency list |
|
||||
| `macros/*.py` | `item_macros` | All registered macros |
|
||||
| `jobs/*.yaml` | `job_definitions` filtered by item type | Job definitions matching this item's trigger filters |
|
||||
|
||||
### 5.3 Caching
|
||||
|
||||
Packing the `silo/` directory on every download has a cost. To mitigate:
|
||||
|
||||
- **ETag header**: The response includes an ETag computed from the revision number + metadata `updated_at`. If the client sends `If-None-Match`, the server can return `304 Not Modified`.
|
||||
- **Lazy packing**: If the `.kc` blob's `silo/manifest.json` revision_hash matches the current head *and* `item_metadata.updated_at` is older than the blob's upload time, skip repacking entirely -- the blob is already current.
|
||||
|
||||
---
|
||||
|
||||
## 6. SSE Events
|
||||
|
||||
The viewport widgets subscribe to SSE for live updates. These events are broadcast when server-side metadata changes, whether via `.kc` commit, web UI edit, or API call.
|
||||
|
||||
| Event | Payload | Trigger |
|
||||
|-------|---------|---------|
|
||||
| `metadata.updated` | `{part_number, changed_fields[], lifecycle_state, updated_by}` | Metadata PUT/PATCH |
|
||||
| `metadata.lifecycle` | `{part_number, from_state, to_state, updated_by}` | Lifecycle transition |
|
||||
| `metadata.tags` | `{part_number, added[], removed[]}` | Tag add/remove |
|
||||
| `approval.created` | `{part_number, eco_number, state}` | ECO created |
|
||||
| `approval.signed` | `{part_number, eco_number, user, role, status}` | Approver action |
|
||||
| `approval.completed` | `{part_number, eco_number, final_state}` | All approvers acted |
|
||||
| `dependencies.changed` | `{part_number, added[], removed[], changed[]}` | Dependency diff on commit |
|
||||
|
||||
Existing events (`revision.created`, `job.*`, `bom.changed`) continue to work as documented in [SPECIFICATION.md](SPECIFICATION.md) and [WORKERS.md](WORKERS.md).
|
||||
|
||||
### 6.1 Widget Subscription Map
|
||||
|
||||
| Viewport widget | Subscribes to |
|
||||
|-----------------|---------------|
|
||||
| Manifest Viewer | -- (read-only, no live updates) |
|
||||
| Metadata Editor | `metadata.updated`, `metadata.lifecycle`, `metadata.tags` |
|
||||
| History Viewer | `revision.created` |
|
||||
| Approvals Viewer | `approval.created`, `approval.signed`, `approval.completed` |
|
||||
| Dependency Table | `dependencies.changed` |
|
||||
| Job Viewer | `job.created`, `job.progress`, `job.completed`, `job.failed` |
|
||||
| Macro Editor | -- (local-only until committed) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Web UI Integration
|
||||
|
||||
The Silo web UI also benefits from indexed metadata. These are additions to existing pages, not new pages.
|
||||
|
||||
### 7.1 Items Page
|
||||
|
||||
The item detail panel gains a **Metadata** tab (alongside Main, Properties, Revisions, BOM, Where Used) showing the schema-driven form from `GET /api/items/{pn}/metadata`. Editable for editors.
|
||||
|
||||
### 7.2 Items List
|
||||
|
||||
New filterable columns: `lifecycle_state`, `tags`. The existing search endpoint gains metadata-aware filtering:
|
||||
|
||||
```
|
||||
GET /api/items?lifecycle=released&tag=aluminum
|
||||
GET /api/items/search?q=bracket&lifecycle=draft
|
||||
```
|
||||
|
||||
### 7.3 Approvals Page
|
||||
|
||||
A new page accessible from the top navigation (visible when a future `approvals` module is enabled). Lists all active ECOs with their approval progress.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration
|
||||
|
||||
### 8.1 Database Migration
|
||||
|
||||
A single migration adds the `item_metadata`, `item_dependencies`, `item_approvals`, `approval_signatures`, and `item_macros` tables. Existing items have no metadata rows -- they're created on first `.kc` commit or via `PUT /api/items/{pn}/metadata`.
|
||||
|
||||
### 8.2 Backfill
|
||||
|
||||
For items that already have `.kc` files stored on disk (committed before this feature), an admin endpoint re-runs the extraction pipeline:
|
||||
|
||||
```
|
||||
POST /api/admin/reindex-metadata
|
||||
```
|
||||
|
||||
This iterates all items with `.kc` files, opens each ZIP, and indexes the `silo/` contents. Idempotent -- safe to run multiple times.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
| Phase | Server work | Supports client phase |
|
||||
|-------|------------|----------------------|
|
||||
| 1 | `item_metadata` table + `GET/PUT /metadata` + commit extraction | SILO_VIEWPORT Phase 1-2 (Manifest, Metadata) |
|
||||
| 2 | Pack `silo/` on checkout + ETag caching | SILO_VIEWPORT Phase 1-3 |
|
||||
| 3 | `item_dependencies` table + `/dependencies/resolve` | SILO_VIEWPORT Phase 5 (Dependency Table) |
|
||||
| 4 | `item_macros` table + `/macros` endpoints | SILO_VIEWPORT Phase 6 (Macro Editor) |
|
||||
| 5 | `item_approvals` tables + `/approvals` endpoints | SILO_VIEWPORT Phase 7 (Approvals Viewer) |
|
||||
| 6 | SSE events for metadata/approvals/dependencies | SILO_VIEWPORT Phase 8 (Live integration) |
|
||||
| 7 | Web UI metadata tab + list filters | Independent of client |
|
||||
|
||||
Phases 1-2 are prerequisite for the viewport to work with live data. Phases 3-6 can be built in parallel with client widget development. Phase 7 is web-UI-only and independent.
|
||||
|
||||
---
|
||||
|
||||
## 10. References
|
||||
|
||||
- [SILO_VIEWPORT.md](SILO_VIEWPORT.md) -- Client-side viewport widget specification
|
||||
- [KC_SPECIFICATION.md](KC_SPECIFICATION.md) -- .kc file format specification
|
||||
- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server API reference
|
||||
- [BOM_MERGE.md](BOM_MERGE.md) -- BOM merge rules (dependency reconciliation)
|
||||
- [WORKERS.md](WORKERS.md) -- Job queue (job viewer data source)
|
||||
- [MODULES.md](MODULES.md) -- Module system (approval module gating)
|
||||
- [ROADMAP.md](ROADMAP.md) -- Platform roadmap tiers
|
||||
745
docs/src/silo-server/MODULES.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Module System Specification
|
||||
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-02-14
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Silo's module system defines the boundary between required infrastructure and optional capabilities. Each module groups a set of API endpoints, UI views, and configuration parameters. Modules can be enabled or disabled at runtime by administrators via the web UI, and clients can query which modules are active to adapt their feature set.
|
||||
|
||||
The goal: after initial deployment (where `config.yaml` sets database, storage, and server bind), all further operational configuration happens through the admin settings UI. The YAML file becomes the bootstrap; the database becomes the runtime source of truth.
|
||||
|
||||
---
|
||||
|
||||
## 2. Module Registry
|
||||
|
||||
### 2.1 Required Modules
|
||||
|
||||
These cannot be disabled. They define what Silo *is*.
|
||||
|
||||
| Module ID | Name | Description |
|
||||
|-----------|------|-------------|
|
||||
| `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 | Filesystem storage |
|
||||
|
||||
### 2.2 Optional Modules
|
||||
|
||||
| Module ID | Name | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `auth` | Authentication | `true` | Local, LDAP, OIDC authentication and RBAC |
|
||||
| `projects` | Projects | `true` | Project management and item tagging |
|
||||
| `audit` | Audit | `true` | Audit logging, completeness scoring |
|
||||
| `odoo` | Odoo ERP | `false` | Odoo integration (config, sync-log, push/pull) |
|
||||
| `freecad` | Create Integration | `true` | URI scheme, executable path, client settings |
|
||||
| `jobs` | Job Queue | `false` | Async compute jobs, runner management |
|
||||
| `dag` | Dependency DAG | `false` | Feature DAG sync, validation states, interference detection |
|
||||
|
||||
### 2.3 Module Dependencies
|
||||
|
||||
Some modules require others to function:
|
||||
|
||||
| Module | Requires |
|
||||
|--------|----------|
|
||||
| `dag` | `jobs` |
|
||||
| `jobs` | `auth` (runner tokens) |
|
||||
| `odoo` | `auth` |
|
||||
|
||||
When enabling a module, its dependencies are validated. The server rejects enabling `dag` without `jobs`. Disabling a module that others depend on shows a warning listing dependents.
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoint-to-Module Mapping
|
||||
|
||||
### 3.1 `core` (required)
|
||||
|
||||
```
|
||||
# Health
|
||||
GET /health
|
||||
GET /ready
|
||||
|
||||
# Items
|
||||
GET /api/items
|
||||
GET /api/items/search
|
||||
GET /api/items/by-uuid/{uuid}
|
||||
GET /api/items/export.csv
|
||||
GET /api/items/template.csv
|
||||
GET /api/items/export.ods
|
||||
GET /api/items/template.ods
|
||||
POST /api/items
|
||||
POST /api/items/import
|
||||
POST /api/items/import.ods
|
||||
GET /api/items/{partNumber}
|
||||
PUT /api/items/{partNumber}
|
||||
DELETE /api/items/{partNumber}
|
||||
|
||||
# Revisions
|
||||
GET /api/items/{partNumber}/revisions
|
||||
GET /api/items/{partNumber}/revisions/compare
|
||||
GET /api/items/{partNumber}/revisions/{revision}
|
||||
POST /api/items/{partNumber}/revisions
|
||||
PATCH /api/items/{partNumber}/revisions/{revision}
|
||||
POST /api/items/{partNumber}/revisions/{revision}/rollback
|
||||
|
||||
# Files
|
||||
GET /api/items/{partNumber}/files
|
||||
GET /api/items/{partNumber}/file
|
||||
GET /api/items/{partNumber}/file/{revision}
|
||||
POST /api/items/{partNumber}/file
|
||||
POST /api/items/{partNumber}/files
|
||||
DELETE /api/items/{partNumber}/files/{fileId}
|
||||
PUT /api/items/{partNumber}/thumbnail
|
||||
POST /api/uploads/presign
|
||||
|
||||
# BOM
|
||||
GET /api/items/{partNumber}/bom
|
||||
GET /api/items/{partNumber}/bom/expanded
|
||||
GET /api/items/{partNumber}/bom/flat
|
||||
GET /api/items/{partNumber}/bom/cost
|
||||
GET /api/items/{partNumber}/bom/where-used
|
||||
GET /api/items/{partNumber}/bom/export.csv
|
||||
GET /api/items/{partNumber}/bom/export.ods
|
||||
POST /api/items/{partNumber}/bom
|
||||
POST /api/items/{partNumber}/bom/import
|
||||
POST /api/items/{partNumber}/bom/merge
|
||||
PUT /api/items/{partNumber}/bom/{childPartNumber}
|
||||
DELETE /api/items/{partNumber}/bom/{childPartNumber}
|
||||
|
||||
# .kc Metadata
|
||||
GET /api/items/{partNumber}/metadata
|
||||
PUT /api/items/{partNumber}/metadata
|
||||
PATCH /api/items/{partNumber}/metadata/lifecycle
|
||||
PATCH /api/items/{partNumber}/metadata/tags
|
||||
|
||||
# .kc Dependencies
|
||||
GET /api/items/{partNumber}/dependencies
|
||||
GET /api/items/{partNumber}/dependencies/resolve
|
||||
|
||||
# .kc Macros
|
||||
GET /api/items/{partNumber}/macros
|
||||
GET /api/items/{partNumber}/macros/{filename}
|
||||
|
||||
# Part Number Generation
|
||||
POST /api/generate-part-number
|
||||
|
||||
# Sheets
|
||||
POST /api/sheets/diff
|
||||
|
||||
# Settings & Modules (admin)
|
||||
GET /api/modules
|
||||
GET /api/admin/settings
|
||||
GET /api/admin/settings/{module}
|
||||
PUT /api/admin/settings/{module}
|
||||
POST /api/admin/settings/{module}/test
|
||||
```
|
||||
|
||||
### 3.2 `schemas` (required)
|
||||
|
||||
```
|
||||
GET /api/schemas
|
||||
GET /api/schemas/{name}
|
||||
GET /api/schemas/{name}/form
|
||||
POST /api/schemas/{name}/segments/{segment}/values
|
||||
PUT /api/schemas/{name}/segments/{segment}/values/{code}
|
||||
DELETE /api/schemas/{name}/segments/{segment}/values/{code}
|
||||
```
|
||||
|
||||
### 3.3 `storage` (required)
|
||||
|
||||
No dedicated endpoints — storage is consumed internally by file upload/download in `core`. Exposed through admin settings for connection status visibility.
|
||||
|
||||
### 3.4 `auth`
|
||||
|
||||
```
|
||||
# Public (login flow)
|
||||
GET /login
|
||||
POST /login
|
||||
POST /logout
|
||||
GET /auth/oidc
|
||||
GET /auth/callback
|
||||
|
||||
# Authenticated
|
||||
GET /api/auth/me
|
||||
GET /api/auth/tokens
|
||||
POST /api/auth/tokens
|
||||
DELETE /api/auth/tokens/{id}
|
||||
|
||||
# Web UI
|
||||
GET /settings (account info, tokens)
|
||||
POST /settings/tokens
|
||||
POST /settings/tokens/{id}/revoke
|
||||
```
|
||||
|
||||
When `auth` is disabled, all routes are open and a synthetic `dev` admin user is injected (current behavior).
|
||||
|
||||
### 3.5 `projects`
|
||||
|
||||
```
|
||||
GET /api/projects
|
||||
GET /api/projects/{code}
|
||||
GET /api/projects/{code}/items
|
||||
GET /api/projects/{code}/sheet.ods
|
||||
POST /api/projects
|
||||
PUT /api/projects/{code}
|
||||
DELETE /api/projects/{code}
|
||||
|
||||
# Item-project tagging
|
||||
GET /api/items/{partNumber}/projects
|
||||
POST /api/items/{partNumber}/projects
|
||||
DELETE /api/items/{partNumber}/projects/{code}
|
||||
```
|
||||
|
||||
When disabled: project tag endpoints return `404`, project columns are hidden in UI list views, project filter is removed from item search.
|
||||
|
||||
### 3.6 `audit`
|
||||
|
||||
```
|
||||
GET /api/audit/completeness
|
||||
GET /api/audit/completeness/{partNumber}
|
||||
```
|
||||
|
||||
When disabled: audit log table continues to receive writes (it's part of core middleware), but the completeness scoring endpoints and the Audit page in the web UI are hidden. Future: retention policies, export, and compliance reporting endpoints live here.
|
||||
|
||||
### 3.7 `odoo`
|
||||
|
||||
```
|
||||
GET /api/integrations/odoo/config
|
||||
GET /api/integrations/odoo/sync-log
|
||||
PUT /api/integrations/odoo/config
|
||||
POST /api/integrations/odoo/test-connection
|
||||
POST /api/integrations/odoo/sync/push/{partNumber}
|
||||
POST /api/integrations/odoo/sync/pull/{odooId}
|
||||
```
|
||||
|
||||
### 3.8 `freecad`
|
||||
|
||||
No dedicated API endpoints currently. Configures URI scheme and executable path used by the web UI's "Open in Create" links and by CLI operations. Future: client configuration distribution endpoint.
|
||||
|
||||
### 3.9 `jobs`
|
||||
|
||||
```
|
||||
# User-facing
|
||||
GET /api/jobs
|
||||
GET /api/jobs/{jobID}
|
||||
GET /api/jobs/{jobID}/logs
|
||||
POST /api/jobs
|
||||
POST /api/jobs/{jobID}/cancel
|
||||
|
||||
# Job definitions
|
||||
GET /api/job-definitions
|
||||
GET /api/job-definitions/{name}
|
||||
POST /api/job-definitions/reload
|
||||
|
||||
# Runner management (admin)
|
||||
GET /api/runners
|
||||
POST /api/runners
|
||||
DELETE /api/runners/{runnerID}
|
||||
|
||||
# Runner-facing (runner token auth)
|
||||
POST /api/runner/heartbeat
|
||||
POST /api/runner/claim
|
||||
PUT /api/runner/jobs/{jobID}/progress
|
||||
POST /api/runner/jobs/{jobID}/complete
|
||||
POST /api/runner/jobs/{jobID}/fail
|
||||
POST /api/runner/jobs/{jobID}/log
|
||||
PUT /api/runner/jobs/{jobID}/dag
|
||||
```
|
||||
|
||||
### 3.10 `dag`
|
||||
|
||||
```
|
||||
GET /api/items/{partNumber}/dag
|
||||
GET /api/items/{partNumber}/dag/forward-cone/{nodeKey}
|
||||
GET /api/items/{partNumber}/dag/dirty
|
||||
PUT /api/items/{partNumber}/dag
|
||||
POST /api/items/{partNumber}/dag/mark-dirty/{nodeKey}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Disabled Module Behavior
|
||||
|
||||
When a module is disabled:
|
||||
|
||||
1. **API routes** registered by that module return `404 Not Found` with body `{"error": "module '<id>' is not enabled"}`.
|
||||
2. **Web UI** hides the module's navigation entry, page, and any inline UI elements (e.g., project tags on item cards).
|
||||
3. **SSE events** from the module are not broadcast.
|
||||
4. **Background goroutines** (e.g., job timeout sweeper, runner heartbeat checker) are not started.
|
||||
5. **Database tables** are not dropped — they remain for re-enablement. No data loss on disable/enable cycle.
|
||||
|
||||
Implementation: each module's route group is wrapped in a middleware check:
|
||||
|
||||
```go
|
||||
func RequireModule(id string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !modules.IsEnabled(id) {
|
||||
http.Error(w, `{"error":"module '`+id+`' is not enabled"}`, 404)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Configuration Persistence
|
||||
|
||||
### 5.1 Precedence
|
||||
|
||||
```
|
||||
Environment variables (highest — always wins, secrets live here)
|
||||
↓
|
||||
Database overrides (admin UI writes here)
|
||||
↓
|
||||
config.yaml (lowest — bootstrap defaults)
|
||||
```
|
||||
|
||||
### 5.2 Database Table
|
||||
|
||||
```sql
|
||||
-- Migration 014_settings.sql
|
||||
CREATE TABLE settings_overrides (
|
||||
key TEXT PRIMARY KEY, -- dotted path: "auth.ldap.enabled"
|
||||
value JSONB NOT NULL, -- typed value
|
||||
updated_by TEXT NOT NULL, -- username
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE module_state (
|
||||
module_id TEXT PRIMARY KEY, -- "auth", "projects", etc.
|
||||
enabled BOOLEAN NOT NULL,
|
||||
updated_by TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.3 Load Sequence
|
||||
|
||||
On startup:
|
||||
|
||||
1. Parse `config.yaml` into Go config struct.
|
||||
2. Query `settings_overrides` — merge each key into the struct using dotted path resolution.
|
||||
3. Apply environment variable overrides (existing `SILO_*` vars).
|
||||
4. Query `module_state` — override default enabled/disabled from YAML.
|
||||
5. Validate module dependencies.
|
||||
6. Register only enabled modules' route groups.
|
||||
7. Start only enabled modules' background goroutines.
|
||||
|
||||
### 5.4 Runtime Updates
|
||||
|
||||
When an admin saves settings via `PUT /api/admin/settings/{module}`:
|
||||
|
||||
1. Validate the payload against the module's config schema.
|
||||
2. Write changed keys to `settings_overrides`.
|
||||
3. Update `module_state` if `enabled` changed.
|
||||
4. Apply changes to the in-memory config (hot reload where safe).
|
||||
5. Broadcast `settings.changed` SSE event with `{module, enabled, changed_keys}`.
|
||||
6. For changes that require restart (e.g., `server.port`, `database.*`), return a `restart_required: true` flag in the response. The UI shows a banner.
|
||||
|
||||
### 5.5 What Requires Restart
|
||||
|
||||
| Config Area | Hot Reload | Restart Required |
|
||||
|-------------|-----------|------------------|
|
||||
| Module enable/disable | Yes | No |
|
||||
| `auth.*` provider toggles | Yes | No |
|
||||
| `auth.cors.allowed_origins` | Yes | No |
|
||||
| `odoo.*` connection settings | Yes | No |
|
||||
| `freecad.*` | Yes | No |
|
||||
| `jobs.*` timeouts, directory | Yes | No |
|
||||
| `server.host`, `server.port` | No | Yes |
|
||||
| `database.*` | No | Yes |
|
||||
| `storage.*` | No | Yes |
|
||||
| `schemas.directory` | No | Yes |
|
||||
|
||||
---
|
||||
|
||||
## 6. Public Module Discovery Endpoint
|
||||
|
||||
```
|
||||
GET /api/modules
|
||||
```
|
||||
|
||||
**No authentication required.** Clients need this pre-login to know whether OIDC is available, whether projects exist, etc.
|
||||
|
||||
### 6.1 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": {
|
||||
"core": {
|
||||
"enabled": true,
|
||||
"required": true,
|
||||
"name": "Core PDM",
|
||||
"version": "0.2"
|
||||
},
|
||||
"schemas": {
|
||||
"enabled": true,
|
||||
"required": true,
|
||||
"name": "Schemas"
|
||||
},
|
||||
"storage": {
|
||||
"enabled": true,
|
||||
"required": true,
|
||||
"name": "Storage"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"required": false,
|
||||
"name": "Authentication",
|
||||
"config": {
|
||||
"local_enabled": true,
|
||||
"ldap_enabled": true,
|
||||
"oidc_enabled": true,
|
||||
"oidc_issuer_url": "https://keycloak.example.com/realms/silo"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"enabled": true,
|
||||
"required": false,
|
||||
"name": "Projects"
|
||||
},
|
||||
"audit": {
|
||||
"enabled": true,
|
||||
"required": false,
|
||||
"name": "Audit"
|
||||
},
|
||||
"odoo": {
|
||||
"enabled": false,
|
||||
"required": false,
|
||||
"name": "Odoo ERP"
|
||||
},
|
||||
"freecad": {
|
||||
"enabled": true,
|
||||
"required": false,
|
||||
"name": "Create Integration",
|
||||
"config": {
|
||||
"uri_scheme": "silo"
|
||||
}
|
||||
},
|
||||
"jobs": {
|
||||
"enabled": false,
|
||||
"required": false,
|
||||
"name": "Job Queue"
|
||||
},
|
||||
"dag": {
|
||||
"enabled": false,
|
||||
"required": false,
|
||||
"name": "Dependency DAG",
|
||||
"depends_on": ["jobs"]
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"version": "0.2",
|
||||
"read_only": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `config` sub-object exposes only public, non-secret metadata needed by clients. Never includes passwords, tokens, or secret keys.
|
||||
|
||||
---
|
||||
|
||||
## 7. Admin Settings Endpoints
|
||||
|
||||
### 7.1 Get All Settings
|
||||
|
||||
```
|
||||
GET /api/admin/settings
|
||||
Authorization: Bearer <admin token>
|
||||
```
|
||||
|
||||
Returns full config grouped by module with secrets redacted:
|
||||
|
||||
```json
|
||||
{
|
||||
"core": {
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"base_url": "https://silo.example.com",
|
||||
"read_only": false
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"directory": "/etc/silo/schemas",
|
||||
"default": "kindred-rd"
|
||||
},
|
||||
"storage": {
|
||||
"backend": "filesystem",
|
||||
"filesystem": {
|
||||
"root_dir": "/var/lib/silo/data"
|
||||
},
|
||||
"status": "connected"
|
||||
},
|
||||
"database": {
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"name": "silo",
|
||||
"user": "silo",
|
||||
"password": "****",
|
||||
"sslmode": "disable",
|
||||
"max_connections": 10,
|
||||
"status": "connected"
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"session_secret": "****",
|
||||
"local": { "enabled": true },
|
||||
"ldap": {
|
||||
"enabled": true,
|
||||
"url": "ldaps://ipa.example.com",
|
||||
"base_dn": "dc=kindred,dc=internal",
|
||||
"user_search_dn": "cn=users,cn=accounts,dc=kindred,dc=internal",
|
||||
"bind_password": "****",
|
||||
"role_mapping": { "...": "..." }
|
||||
},
|
||||
"oidc": {
|
||||
"enabled": true,
|
||||
"issuer_url": "https://keycloak.example.com/realms/silo",
|
||||
"client_id": "silo",
|
||||
"client_secret": "****",
|
||||
"redirect_url": "https://silo.example.com/auth/callback"
|
||||
},
|
||||
"cors": { "allowed_origins": ["https://silo.example.com"] }
|
||||
},
|
||||
"projects": { "enabled": true },
|
||||
"audit": { "enabled": true },
|
||||
"odoo": { "enabled": false, "url": "", "database": "", "username": "" },
|
||||
"freecad": { "uri_scheme": "silo", "executable": "" },
|
||||
"jobs": {
|
||||
"enabled": false,
|
||||
"directory": "/etc/silo/jobdefs",
|
||||
"runner_timeout": 90,
|
||||
"job_timeout_check": 30,
|
||||
"default_priority": 100
|
||||
},
|
||||
"dag": { "enabled": false }
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Get Module Settings
|
||||
|
||||
```
|
||||
GET /api/admin/settings/{module}
|
||||
```
|
||||
|
||||
Returns just the module's config block.
|
||||
|
||||
### 7.3 Update Module Settings
|
||||
|
||||
```
|
||||
PUT /api/admin/settings/{module}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"ldap": {
|
||||
"enabled": true,
|
||||
"url": "ldaps://ipa.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"updated": ["auth.ldap.enabled", "auth.ldap.url"],
|
||||
"restart_required": false
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Test Connectivity
|
||||
|
||||
```
|
||||
POST /api/admin/settings/{module}/test
|
||||
```
|
||||
|
||||
Available for modules with external connections:
|
||||
|
||||
| Module | Test Action |
|
||||
|--------|------------|
|
||||
| `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 |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "LDAP bind successful",
|
||||
"latency_ms": 42
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Config YAML Changes
|
||||
|
||||
The existing `config.yaml` gains a `modules` section. Existing top-level keys remain for backward compatibility — the module system reads from both locations.
|
||||
|
||||
```yaml
|
||||
# Existing keys (unchanged, still work)
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
|
||||
database:
|
||||
host: postgres
|
||||
port: 5432
|
||||
name: silo
|
||||
user: silo
|
||||
password: silodev
|
||||
sslmode: disable
|
||||
|
||||
storage:
|
||||
backend: filesystem
|
||||
filesystem:
|
||||
root_dir: /var/lib/silo/data
|
||||
|
||||
schemas:
|
||||
directory: /etc/silo/schemas
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
session_secret: change-me
|
||||
local:
|
||||
enabled: true
|
||||
|
||||
# New: explicit module toggles (optional, defaults shown)
|
||||
modules:
|
||||
projects:
|
||||
enabled: true
|
||||
audit:
|
||||
enabled: true
|
||||
odoo:
|
||||
enabled: false
|
||||
freecad:
|
||||
enabled: true
|
||||
uri_scheme: silo
|
||||
jobs:
|
||||
enabled: false
|
||||
directory: /etc/silo/jobdefs
|
||||
runner_timeout: 90
|
||||
job_timeout_check: 30
|
||||
default_priority: 100
|
||||
dag:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
If a module is not listed under `modules:`, its default enabled state from Section 2.2 applies. The `auth.enabled` field continues to control the `auth` module (no duplication under `modules:`).
|
||||
|
||||
---
|
||||
|
||||
## 9. SSE Events
|
||||
|
||||
```
|
||||
settings.changed {module, enabled, changed_keys[], updated_by}
|
||||
```
|
||||
|
||||
Broadcast on any admin settings change. The web UI listens for this to:
|
||||
|
||||
- Show/hide navigation entries when modules are toggled.
|
||||
- Display a "Settings updated by another admin" toast.
|
||||
- Show a "Restart required" banner when flagged.
|
||||
|
||||
---
|
||||
|
||||
## 10. Web UI — Admin Settings Page
|
||||
|
||||
The Settings page (`/settings`) is restructured into sections:
|
||||
|
||||
### 10.1 Existing (unchanged)
|
||||
|
||||
- **Account** — username, display name, email, auth source, role badge.
|
||||
- **API Tokens** — create, list, revoke.
|
||||
|
||||
### 10.2 New: Module Configuration (admin only)
|
||||
|
||||
Visible only to admin users. Each module gets a collapsible card:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [toggle] Authentication [status] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ── Local Auth ──────────────────────────────────── │
|
||||
│ Enabled: [toggle] │
|
||||
│ │
|
||||
│ ── LDAP / FreeIPA ──────────────────────────────── │
|
||||
│ Enabled: [toggle] │
|
||||
│ URL: [ldaps://ipa.example.com ] │
|
||||
│ Base DN: [dc=kindred,dc=internal ] [Test] │
|
||||
│ │
|
||||
│ ── OIDC / Keycloak ────────────────────────────── │
|
||||
│ Enabled: [toggle] │
|
||||
│ Issuer URL: [https://keycloak.example.com] [Test] │
|
||||
│ Client ID: [silo ] │
|
||||
│ │
|
||||
│ ── CORS ────────────────────────────────────────── │
|
||||
│ Allowed Origins: [tag input] │
|
||||
│ │
|
||||
│ [Save] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Module cards for required modules (`core`, `schemas`, `storage`) show their status and config but have no enable/disable toggle.
|
||||
|
||||
Status indicators per module:
|
||||
|
||||
| Status | Badge | Meaning |
|
||||
|--------|-------|---------|
|
||||
| Active | `green` | Enabled and operational |
|
||||
| Disabled | `overlay1` | Toggled off |
|
||||
| Error | `red` | Enabled but connectivity or config issue |
|
||||
| Setup Required | `yellow` | Enabled but missing required config (e.g., LDAP URL empty) |
|
||||
|
||||
### 10.3 Infrastructure Section (admin, read-only)
|
||||
|
||||
Shows connection status for required infrastructure:
|
||||
|
||||
- **Database** — host, port, name, connection pool usage, status badge.
|
||||
- **Storage** — endpoint, bucket, SSL, status badge.
|
||||
|
||||
These are read-only in the UI (setup-only via YAML/env). The "Test" button is available to verify connectivity.
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementation Order
|
||||
|
||||
1. **Migration 014** — `settings_overrides` and `module_state` tables.
|
||||
2. **Config loader refactor** — YAML → DB merge → env override pipeline.
|
||||
3. **Module registry** — Go struct defining all modules with metadata, dependencies, defaults.
|
||||
4. **`GET /api/modules`** — public endpoint, no auth.
|
||||
5. **`RequireModule` middleware** — gate route groups by module state.
|
||||
6. **Admin settings API** — `GET/PUT /api/admin/settings/{module}`, test endpoints.
|
||||
7. **Web UI settings page** — module cards with toggles, config forms, test buttons.
|
||||
8. **SSE integration** — `settings.changed` event broadcast.
|
||||
|
||||
---
|
||||
|
||||
## 12. Future Considerations
|
||||
|
||||
- **Module manifest format** — per ROADMAP.md, each module will eventually declare routes, views, hooks, and permissions via a manifest. This spec covers the runtime module registry; the manifest format is TBD.
|
||||
- **Custom modules** — third-party modules that register against the endpoint registry. Requires the manifest contract and a plugin loading mechanism.
|
||||
- **Per-module permissions** — beyond the current role hierarchy, modules may define fine-grained scopes (e.g., `jobs:admin`, `dag:write`).
|
||||
- **Location & Inventory module** — when the Location/Inventory API is implemented (tables already exist), it becomes a new optional module.
|
||||
- **Notifications module** — per ROADMAP.md Tier 1, notifications/subscriptions will be a dedicated module.
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
- [CONFIGURATION.md](CONFIGURATION.md) — Current config reference
|
||||
- [ROADMAP.md](ROADMAP.md) — Module manifest, API endpoint registry
|
||||
- [AUTH.md](AUTH.md) — Authentication architecture
|
||||
- [WORKERS.md](WORKERS.md) — Job queue system
|
||||
- [DAG.md](DAG.md) — Dependency DAG specification
|
||||
- [SPECIFICATION.md](SPECIFICATION.md) — Full endpoint listing
|
||||
@@ -88,7 +88,7 @@ Everything depends on these. They define what Silo *is*.
|
||||
| Component | Description | Status |
|
||||
|-----------|-------------|--------|
|
||||
| **Core Silo** | Part/assembly storage, version control, auth, base REST API | Complete |
|
||||
| **.kc Format Spec** | File format contract between Create and Silo | Not Started |
|
||||
| **.kc Format Spec** | File format contract between Create and Silo | Complete |
|
||||
| **API Endpoint Registry** | Module discovery, dynamic UI rendering, health checks | Not Started |
|
||||
| **Web UI Shell** | App launcher, breadcrumbs, view framework, module rendering | Partial |
|
||||
| **Python Scripting Engine** | Server-side hook execution, module extension point | Not Started |
|
||||
@@ -313,7 +313,7 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
||||
- Rollback functionality
|
||||
|
||||
#### File Management
|
||||
- MinIO integration with versioning
|
||||
- Filesystem-based file storage
|
||||
- File upload/download via REST API
|
||||
- SHA256 checksums for integrity
|
||||
- Storage path: `items/{partNumber}/rev{N}.FCStd`
|
||||
@@ -377,8 +377,8 @@ For full SOLIDWORKS PDM comparison tables, see [GAP_ANALYSIS.md Appendix C](GAP_
|
||||
|
||||
## Appendix B: Phase 1 Detailed Tasks
|
||||
|
||||
### 1.1 MinIO Integration -- COMPLETE
|
||||
- [x] MinIO service configured in Docker Compose
|
||||
### 1.1 File Storage -- COMPLETE
|
||||
- [x] Filesystem storage backend
|
||||
- [x] File upload via REST API
|
||||
- [x] File download via REST API (latest and by revision)
|
||||
- [x] SHA256 checksums on upload
|
||||
|
||||
899
docs/src/silo-server/SOLVER.md
Normal 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
|
||||
@@ -37,7 +37,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Silo Server (silod) │
|
||||
│ - REST API (78 endpoints) │
|
||||
│ - REST API (86 endpoints) │
|
||||
│ - Authentication (local, LDAP, OIDC) │
|
||||
│ - Schema parsing and validation │
|
||||
│ - Part number generation engine │
|
||||
@@ -49,9 +49,9 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────────┐ ┌─────────────────────────────┐
|
||||
│ PostgreSQL │ │ MinIO │
|
||||
│ PostgreSQL │ │ Local Filesystem │
|
||||
│ (psql.example.internal)│ │ - File storage │
|
||||
│ - Item metadata │ │ - Versioned objects │
|
||||
│ - Item metadata │ │ - Revision files │
|
||||
│ - Relationships │ │ - Thumbnails │
|
||||
│ - Revision history │ │ │
|
||||
│ - Auth / Sessions │ │ │
|
||||
@@ -64,7 +64,7 @@ Silo treats **part numbering schemas as configuration, not code**. Multiple numb
|
||||
| Component | Technology | Notes |
|
||||
|-----------|------------|-------|
|
||||
| Database | PostgreSQL 16 | Existing instance at psql.example.internal |
|
||||
| File Storage | MinIO | S3-compatible, versioning enabled |
|
||||
| File Storage | Local filesystem | Files stored under configurable root directory |
|
||||
| CLI & API Server | Go (1.24) | chi/v5 router, pgx/v5 driver, zerolog |
|
||||
| Authentication | Multi-backend | Local (bcrypt), LDAP/FreeIPA, OIDC/Keycloak |
|
||||
| Sessions | PostgreSQL pgxstore | alexedwards/scs, 24h lifetime |
|
||||
@@ -83,7 +83,7 @@ An **item** is the fundamental entity. Items have:
|
||||
- **Properties** (key-value pairs, schema-defined and custom)
|
||||
- **Relationships** to other items
|
||||
- **Revisions** (append-only history)
|
||||
- **Files** (optional, stored in MinIO)
|
||||
- **Files** (optional, stored on the local filesystem)
|
||||
- **Location** (optional physical inventory location)
|
||||
|
||||
### 3.2 Database Schema (Conceptual)
|
||||
@@ -115,7 +115,7 @@ CREATE TABLE revisions (
|
||||
item_id UUID REFERENCES items(id) NOT NULL,
|
||||
revision_number INTEGER NOT NULL,
|
||||
properties JSONB NOT NULL, -- all properties at this revision
|
||||
file_version TEXT, -- MinIO version ID if applicable
|
||||
file_version TEXT, -- storage version ID if applicable
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
created_by TEXT, -- user identifier (future: LDAP DN)
|
||||
comment TEXT,
|
||||
@@ -345,7 +345,7 @@ CAD workbench and spreadsheet extension implementations are maintained in separa
|
||||
|
||||
### 5.1 File Storage Strategy
|
||||
|
||||
Files are stored as whole objects in MinIO with versioning enabled. Storage path convention: `items/{partNumber}/rev{N}.ext`. SHA-256 checksums are captured on upload for integrity verification.
|
||||
Files are stored on the local filesystem under a configurable root directory. Storage path convention: `items/{partNumber}/rev{N}.ext`. SHA-256 checksums are captured on upload for integrity verification.
|
||||
|
||||
Future option: exploded storage (unpack ZIP-based CAD archives for better diffing).
|
||||
|
||||
@@ -439,7 +439,7 @@ Revisions are created explicitly by user action (not automatic):
|
||||
### 7.3 Revision vs. File Version
|
||||
|
||||
- **Revision**: Silo metadata revision (tracked in PostgreSQL)
|
||||
- **File Version**: MinIO object version (automatic on upload)
|
||||
- **File Version**: File on disk corresponding to a revision
|
||||
|
||||
A single Silo revision may span multiple file uploads during editing. Only committed revisions create formal revision records.
|
||||
|
||||
@@ -598,12 +598,12 @@ See [AUTH.md](AUTH.md) for full architecture details and [AUTH_USER_GUIDE.md](AU
|
||||
|
||||
## 11. API Design
|
||||
|
||||
### 11.1 REST Endpoints (78 Implemented)
|
||||
### 11.1 REST Endpoints (86 Implemented)
|
||||
|
||||
```
|
||||
# Health (no auth)
|
||||
GET /health # Basic health check
|
||||
GET /ready # Readiness (DB + MinIO)
|
||||
GET /ready # Readiness (DB)
|
||||
|
||||
# Auth (no auth required)
|
||||
GET /login # Login page
|
||||
@@ -624,8 +624,8 @@ GET /api/auth/tokens # List user's API to
|
||||
POST /api/auth/tokens # Create API token
|
||||
DELETE /api/auth/tokens/{id} # Revoke API token
|
||||
|
||||
# Presigned Uploads (editor)
|
||||
POST /api/uploads/presign # Get presigned MinIO upload URL [editor]
|
||||
# Direct Uploads (editor)
|
||||
POST /api/uploads/presign # Get upload URL [editor]
|
||||
|
||||
# Schemas (read: viewer, write: editor)
|
||||
GET /api/schemas # List all schemas
|
||||
@@ -697,6 +697,20 @@ POST /api/items/{partNumber}/bom/merge # Merge BOM from ODS
|
||||
PUT /api/items/{partNumber}/bom/{childPartNumber} # Update BOM entry [editor]
|
||||
DELETE /api/items/{partNumber}/bom/{childPartNumber} # Remove BOM entry [editor]
|
||||
|
||||
# .kc Metadata (read: viewer, write: editor)
|
||||
GET /api/items/{partNumber}/metadata # Get indexed .kc metadata
|
||||
PUT /api/items/{partNumber}/metadata # Update metadata fields [editor]
|
||||
PATCH /api/items/{partNumber}/metadata/lifecycle # Transition lifecycle state [editor]
|
||||
PATCH /api/items/{partNumber}/metadata/tags # Add/remove tags [editor]
|
||||
|
||||
# .kc Dependencies (viewer)
|
||||
GET /api/items/{partNumber}/dependencies # List raw dependencies
|
||||
GET /api/items/{partNumber}/dependencies/resolve # Resolve UUIDs to part numbers + file availability
|
||||
|
||||
# .kc Macros (viewer)
|
||||
GET /api/items/{partNumber}/macros # List registered macros
|
||||
GET /api/items/{partNumber}/macros/{filename} # Get macro source content
|
||||
|
||||
# Audit (viewer)
|
||||
GET /api/audit/completeness # Item completeness scores
|
||||
GET /api/audit/completeness/{partNumber} # Item detail breakdown
|
||||
@@ -735,6 +749,139 @@ POST /api/inventory/{partNumber}/move
|
||||
|
||||
---
|
||||
|
||||
## 11.3 .kc File Integration
|
||||
|
||||
Silo supports the `.kc` file format — a ZIP archive that is a superset of FreeCAD's `.fcstd`. A `.kc` file contains everything an `.fcstd` does, plus a `silo/` directory with platform metadata.
|
||||
|
||||
#### Standard entries (preserved as-is)
|
||||
|
||||
`Document.xml`, `GuiDocument.xml`, BREP geometry files (`.brp`), `thumbnails/`
|
||||
|
||||
#### Silo entries (`silo/` directory)
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `silo/manifest.json` | Instance origin, part UUID, revision hash, `.kc` schema version |
|
||||
| `silo/metadata.json` | Custom schema field values, tags, lifecycle state |
|
||||
| `silo/history.json` | Local revision log (server-generated on checkout) |
|
||||
| `silo/dependencies.json` | Assembly link references by Silo UUID |
|
||||
| `silo/macros/*.py` | Embedded macro scripts bound to this part |
|
||||
|
||||
#### Commit-time extraction
|
||||
|
||||
When a `.kc` file is uploaded via `POST /api/items/{partNumber}/file`, the server:
|
||||
|
||||
1. Opens the ZIP and scans for `silo/` entries
|
||||
2. Parses `silo/manifest.json` and validates the UUID matches the item
|
||||
3. Upserts `silo/metadata.json` fields into the `item_metadata` table
|
||||
4. Replaces `silo/dependencies.json` entries in the `item_dependencies` table
|
||||
5. Replaces `silo/macros/*.py` entries in the `item_macros` table
|
||||
6. Broadcasts SSE events: `metadata.updated`, `dependencies.changed`, `macros.changed`
|
||||
|
||||
Extraction is best-effort — failures are logged as warnings but do not block the upload.
|
||||
|
||||
#### Checkout-time packing
|
||||
|
||||
When a `.kc` file is downloaded via `GET /api/items/{partNumber}/file/{revision}`, the server repacks the `silo/` directory with current database state:
|
||||
|
||||
- `silo/manifest.json` — current item UUID and metadata freshness
|
||||
- `silo/metadata.json` — latest schema fields, tags, lifecycle state
|
||||
- `silo/history.json` — last 20 revisions from the database
|
||||
- `silo/dependencies.json` — current dependency list from `item_dependencies`
|
||||
|
||||
Non-silo ZIP entries are passed through unchanged. If the file is a plain `.fcstd` (no `silo/` directory), it is served as-is.
|
||||
|
||||
ETag caching: the server computes an ETag from `revision_number:metadata.updated_at` and returns `304 Not Modified` when the client's `If-None-Match` header matches.
|
||||
|
||||
#### Lifecycle state machine
|
||||
|
||||
The `lifecycle_state` field in `item_metadata` follows this state machine:
|
||||
|
||||
```
|
||||
draft → review → released → obsolete
|
||||
↑ ↓
|
||||
└────────┘
|
||||
```
|
||||
|
||||
Valid transitions are enforced by `PATCH /metadata/lifecycle`. Invalid transitions return `422 Unprocessable Entity`.
|
||||
|
||||
#### Metadata response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_name": "kindred-rd",
|
||||
"lifecycle_state": "draft",
|
||||
"tags": ["prototype", "v2"],
|
||||
"fields": {"material": "AL6061", "finish": "anodized"},
|
||||
"manifest": {
|
||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"silo_instance": "silo.example.com",
|
||||
"revision_hash": "abc123",
|
||||
"kc_version": "1.0"
|
||||
},
|
||||
"updated_at": "2026-02-18T12:00:00Z",
|
||||
"updated_by": "forbes"
|
||||
}
|
||||
```
|
||||
|
||||
#### Dependency response shape
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"uuid": "550e8400-...",
|
||||
"part_number": "F01-0042",
|
||||
"revision": 3,
|
||||
"quantity": 4.0,
|
||||
"label": "M5 Bolt",
|
||||
"relationship": "component"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Resolved dependency response shape
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"uuid": "550e8400-...",
|
||||
"part_number": "F01-0042",
|
||||
"label": "M5 Bolt",
|
||||
"revision": 3,
|
||||
"quantity": 4.0,
|
||||
"resolved": true,
|
||||
"file_available": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Macro list response shape
|
||||
|
||||
```json
|
||||
[
|
||||
{"filename": "validate_dims.py", "trigger": "manual", "revision_number": 5}
|
||||
]
|
||||
```
|
||||
|
||||
#### Macro detail response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "validate_dims.py",
|
||||
"trigger": "manual",
|
||||
"content": "import FreeCAD\n...",
|
||||
"revision_number": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Database tables (migration 018)
|
||||
|
||||
- `item_metadata` — schema fields, lifecycle state, tags, manifest info
|
||||
- `item_dependencies` — parent/child UUID references with quantity and relationship type
|
||||
- `item_macros` — filename, trigger type, source content, indexed per item
|
||||
|
||||
---
|
||||
|
||||
## 12. MVP Scope
|
||||
|
||||
### 12.1 Implemented
|
||||
@@ -743,8 +890,8 @@ POST /api/inventory/{partNumber}/move
|
||||
- [x] YAML schema parser for part numbering
|
||||
- [x] Part number generation engine
|
||||
- [x] CLI tool (`cmd/silo`)
|
||||
- [x] API server (`cmd/silod`) with 78 endpoints
|
||||
- [x] MinIO integration for file storage with versioning
|
||||
- [x] API server (`cmd/silod`) with 86 endpoints
|
||||
- [x] Filesystem-based file storage
|
||||
- [x] BOM relationships (component, alternate, reference)
|
||||
- [x] Multi-level BOM (recursive expansion with configurable depth)
|
||||
- [x] Where-used queries (reverse parent lookup)
|
||||
@@ -765,6 +912,12 @@ POST /api/inventory/{partNumber}/move
|
||||
- [x] Audit logging and completeness scoring
|
||||
- [x] CSRF protection (nosurf)
|
||||
- [x] Fuzzy search
|
||||
- [x] .kc file extraction pipeline (metadata, dependencies, macros indexed on commit)
|
||||
- [x] .kc file packing on checkout (manifest, metadata, history, dependencies)
|
||||
- [x] .kc metadata API (get, update fields, lifecycle transitions, tags)
|
||||
- [x] .kc dependency API (list, resolve with file availability)
|
||||
- [x] .kc macro API (list, get source content)
|
||||
- [x] ETag caching for .kc file downloads
|
||||
- [x] Property schema versioning framework
|
||||
- [x] Docker Compose deployment (dev and prod)
|
||||
- [x] systemd service and deployment scripts
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| PostgreSQL schema | Complete | 13 migrations applied |
|
||||
| PostgreSQL schema | Complete | 18 migrations applied |
|
||||
| YAML schema parser | Complete | Supports enum, serial, constant, string segments |
|
||||
| Part number generator | Complete | Scoped sequences, category-based format |
|
||||
| API server (`silod`) | Complete | 78 REST endpoints via chi/v5 |
|
||||
| API server (`silod`) | Complete | 86 REST endpoints via chi/v5 |
|
||||
| CLI tool (`silo`) | Complete | Item registration and management |
|
||||
| MinIO file storage | Complete | Upload, download, versioning, checksums |
|
||||
| Filesystem file storage | Complete | Upload, download, checksums |
|
||||
| Revision control | Complete | Append-only history, rollback, comparison, status/labels |
|
||||
| Project management | Complete | CRUD, many-to-many item tagging |
|
||||
| CSV import/export | Complete | Dry-run validation, template generation |
|
||||
@@ -29,7 +29,12 @@
|
||||
| CSRF protection | Complete | nosurf on web forms |
|
||||
| Fuzzy search | Complete | sahilm/fuzzy library |
|
||||
| Web UI | Complete | React SPA (Vite + TypeScript), 6 pages, Catppuccin Mocha theme |
|
||||
| File attachments | Complete | Presigned uploads, item file association, thumbnails |
|
||||
| File attachments | Complete | Direct uploads, item file association, thumbnails |
|
||||
| .kc extraction pipeline | Complete | Metadata, dependencies, macros indexed on commit |
|
||||
| .kc checkout packing | Complete | Manifest, metadata, history, dependencies repacked on download |
|
||||
| .kc metadata API | Complete | GET/PUT metadata, lifecycle transitions, tag management |
|
||||
| .kc dependency API | Complete | List raw deps, resolve UUIDs to part numbers + file availability |
|
||||
| .kc macro API | Complete | List macros, get source content by filename |
|
||||
| Odoo ERP integration | Partial | Config and sync-log CRUD functional; push/pull are stubs |
|
||||
| Docker Compose | Complete | Dev and production configurations |
|
||||
| Deployment scripts | Complete | setup-host, deploy, init-db, setup-ipa-nginx |
|
||||
@@ -56,7 +61,7 @@ FreeCAD workbench and LibreOffice Calc extension are maintained in separate repo
|
||||
| Service | Host | Status |
|
||||
|---------|------|--------|
|
||||
| PostgreSQL | psql.example.internal:5432 | Running |
|
||||
| MinIO | localhost:9000 (API) / :9001 (console) | Configured |
|
||||
| File Storage | /opt/silo/data (filesystem) | Configured |
|
||||
| Silo API | localhost:8080 | Builds successfully |
|
||||
|
||||
---
|
||||
@@ -96,3 +101,8 @@ The schema defines 170 category codes across 10 groups:
|
||||
| 011_item_files.sql | Item file attachments (item_files table, thumbnail_key column) |
|
||||
| 012_bom_source.sql | BOM entry source tracking |
|
||||
| 013_move_cost_sourcing_to_props.sql | Move sourcing_link and standard_cost from item columns to revision properties |
|
||||
| 014_settings.sql | Settings overrides and module state tables |
|
||||
| 015_jobs.sql | Job queue, runner, and job log tables |
|
||||
| 016_dag.sql | Dependency DAG nodes and edges |
|
||||
| 017_locations.sql | Location hierarchy and inventory tracking |
|
||||
| 018_kc_metadata.sql | .kc metadata tables (item_metadata, item_dependencies, item_macros, item_approvals, approval_signatures) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
3
mods/sdk/Init.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintLog("kindred-addon-sdk loaded\n")
|
||||
3
mods/sdk/InitGui.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n")
|
||||
34
mods/sdk/kindred_sdk/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# kindred-addon-sdk — stable API for Kindred Create addon integration
|
||||
|
||||
from kindred_sdk.version import SDK_VERSION
|
||||
from kindred_sdk.context import (
|
||||
register_context,
|
||||
unregister_context,
|
||||
register_overlay,
|
||||
unregister_overlay,
|
||||
inject_commands,
|
||||
current_context,
|
||||
refresh_context,
|
||||
)
|
||||
from kindred_sdk.theme import get_theme_tokens, load_palette
|
||||
from kindred_sdk.origin import register_origin, unregister_origin
|
||||
from kindred_sdk.dock import register_dock_panel
|
||||
from kindred_sdk.compat import create_version, freecad_version
|
||||
|
||||
__all__ = [
|
||||
"SDK_VERSION",
|
||||
"register_context",
|
||||
"unregister_context",
|
||||
"register_overlay",
|
||||
"unregister_overlay",
|
||||
"inject_commands",
|
||||
"current_context",
|
||||
"refresh_context",
|
||||
"get_theme_tokens",
|
||||
"load_palette",
|
||||
"register_origin",
|
||||
"unregister_origin",
|
||||
"register_dock_panel",
|
||||
"create_version",
|
||||
"freecad_version",
|
||||
]
|
||||
21
mods/sdk/kindred_sdk/compat.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Version detection utilities."""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
def create_version():
|
||||
"""Return the Kindred Create version string (e.g. ``"0.1.3"``)."""
|
||||
try:
|
||||
from version import VERSION
|
||||
|
||||
return VERSION
|
||||
except ImportError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def freecad_version():
|
||||
"""Return the FreeCAD base version as a tuple of strings.
|
||||
|
||||
Example: ``("1", "0", "0", "2025.01.01")``.
|
||||
"""
|
||||
return tuple(FreeCAD.Version())
|
||||
152
mods/sdk/kindred_sdk/context.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Editing context and overlay registration wrappers.
|
||||
|
||||
Thin wrappers around FreeCADGui editing context bindings. If the
|
||||
underlying C++ API changes during an upstream rebase, only this module
|
||||
needs to be updated.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
def _gui():
|
||||
"""Lazy import of FreeCADGui (not available in console mode)."""
|
||||
import FreeCADGui
|
||||
|
||||
return FreeCADGui
|
||||
|
||||
|
||||
def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
"""Register an editing context.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context_id : str
|
||||
Unique identifier (e.g. ``"myaddon.edit"``).
|
||||
label : str
|
||||
Display label template. Supports ``{name}`` placeholder.
|
||||
color : str
|
||||
Hex color for the breadcrumb (e.g. ``"#f38ba8"``).
|
||||
toolbars : list[str]
|
||||
Toolbar names to show when this context is active.
|
||||
match : callable
|
||||
Zero-argument callable returning *True* when this context is active.
|
||||
priority : int, optional
|
||||
Higher values are checked first. Default 50.
|
||||
"""
|
||||
if not isinstance(context_id, str):
|
||||
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
|
||||
if not isinstance(toolbars, list):
|
||||
raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}")
|
||||
if not callable(match):
|
||||
raise TypeError("match must be callable")
|
||||
|
||||
try:
|
||||
_gui().registerEditingContext(context_id, label, color, toolbars, match, priority)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register context '{context_id}': {e}\n"
|
||||
)
|
||||
|
||||
|
||||
def unregister_context(context_id):
|
||||
"""Remove a previously registered editing context."""
|
||||
if not isinstance(context_id, str):
|
||||
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
|
||||
|
||||
try:
|
||||
_gui().unregisterEditingContext(context_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n"
|
||||
)
|
||||
|
||||
|
||||
def register_overlay(overlay_id, toolbars, match):
|
||||
"""Register an editing overlay.
|
||||
|
||||
Overlays add toolbars to whatever context is currently active when
|
||||
*match* returns True.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
overlay_id : str
|
||||
Unique overlay identifier.
|
||||
toolbars : list[str]
|
||||
Toolbar names to append.
|
||||
match : callable
|
||||
Zero-argument callable returning *True* when the overlay applies.
|
||||
"""
|
||||
if not isinstance(overlay_id, str):
|
||||
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
|
||||
if not isinstance(toolbars, list):
|
||||
raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}")
|
||||
if not callable(match):
|
||||
raise TypeError("match must be callable")
|
||||
|
||||
try:
|
||||
_gui().registerEditingOverlay(overlay_id, toolbars, match)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n"
|
||||
)
|
||||
|
||||
|
||||
def unregister_overlay(overlay_id):
|
||||
"""Remove a previously registered editing overlay."""
|
||||
if not isinstance(overlay_id, str):
|
||||
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
|
||||
|
||||
try:
|
||||
_gui().unregisterEditingOverlay(overlay_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n"
|
||||
)
|
||||
|
||||
|
||||
def inject_commands(context_id, toolbar_name, commands):
|
||||
"""Inject commands into a context's toolbar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context_id : str
|
||||
Target context identifier.
|
||||
toolbar_name : str
|
||||
Toolbar within that context.
|
||||
commands : list[str]
|
||||
Command names to add.
|
||||
"""
|
||||
if not isinstance(context_id, str):
|
||||
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
|
||||
if not isinstance(toolbar_name, str):
|
||||
raise TypeError(f"toolbar_name must be str, got {type(toolbar_name).__name__}")
|
||||
if not isinstance(commands, list):
|
||||
raise TypeError(f"commands must be list, got {type(commands).__name__}")
|
||||
|
||||
try:
|
||||
_gui().injectEditingCommands(context_id, toolbar_name, commands)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n"
|
||||
)
|
||||
|
||||
|
||||
def current_context():
|
||||
"""Return the current editing context as a dict.
|
||||
|
||||
Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``,
|
||||
``breadcrumbColors``. Returns ``None`` if no context is active.
|
||||
"""
|
||||
try:
|
||||
return _gui().currentEditingContext()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to get current context: {e}\n")
|
||||
return None
|
||||
|
||||
|
||||
def refresh_context():
|
||||
"""Force re-resolution and update of the editing context."""
|
||||
try:
|
||||
_gui().refreshEditingContext()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n")
|
||||
73
mods/sdk/kindred_sdk/dock.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Deferred dock panel registration helper.
|
||||
|
||||
Replaces the manual ``QTimer.singleShot()`` + duplicate-check +
|
||||
try/except pattern used in ``src/Mod/Create/InitGui.py``.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_AREA_MAP = {
|
||||
"left": 1, # Qt.LeftDockWidgetArea
|
||||
"right": 2, # Qt.RightDockWidgetArea
|
||||
"top": 4, # Qt.TopDockWidgetArea
|
||||
"bottom": 8, # Qt.BottomDockWidgetArea
|
||||
}
|
||||
|
||||
|
||||
def register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0):
|
||||
"""Register a dock panel, optionally deferred.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
object_name : str
|
||||
Qt object name for duplicate prevention.
|
||||
title : str
|
||||
Dock widget title bar text.
|
||||
widget_factory : callable
|
||||
Zero-argument callable returning a ``QWidget``. Called only
|
||||
when the panel is actually created (after *delay_ms*).
|
||||
area : str, optional
|
||||
Dock area: ``"left"``, ``"right"``, ``"top"``, or ``"bottom"``.
|
||||
Default ``"right"``.
|
||||
delay_ms : int, optional
|
||||
Milliseconds to wait before creating the panel. Default 0
|
||||
(immediate, but still posted to the event loop).
|
||||
"""
|
||||
if not isinstance(object_name, str):
|
||||
raise TypeError(f"object_name must be str, got {type(object_name).__name__}")
|
||||
if not callable(widget_factory):
|
||||
raise TypeError("widget_factory must be callable")
|
||||
|
||||
qt_area = _AREA_MAP.get(area)
|
||||
if qt_area is None:
|
||||
raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}")
|
||||
|
||||
def _create():
|
||||
try:
|
||||
from PySide import QtCore, QtWidgets
|
||||
|
||||
import FreeCADGui
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
|
||||
if mw.findChild(QtWidgets.QDockWidget, object_name):
|
||||
return
|
||||
|
||||
widget = widget_factory()
|
||||
panel = QtWidgets.QDockWidget(title, mw)
|
||||
panel.setObjectName(object_name)
|
||||
panel.setWidget(widget)
|
||||
mw.addDockWidget(QtCore.Qt.DockWidgetArea(qt_area), panel)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"kindred_sdk: Dock panel '{object_name}' skipped: {e}\n")
|
||||
|
||||
try:
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(max(0, delay_ms), _create)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Could not schedule dock panel '{object_name}': {e}\n"
|
||||
)
|
||||
42
mods/sdk/kindred_sdk/origin.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""FileOrigin registration wrappers.
|
||||
|
||||
Wraps ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation
|
||||
and error handling. Addons implement the FileOrigin duck-typed
|
||||
interface directly (see Silo's ``SiloOrigin`` for the full contract).
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_REQUIRED_METHODS = ("id", "name", "type", "ownsDocument")
|
||||
|
||||
|
||||
def _gui():
|
||||
import FreeCADGui
|
||||
|
||||
return FreeCADGui
|
||||
|
||||
|
||||
def register_origin(origin):
|
||||
"""Register a FileOrigin with FreeCADGui.
|
||||
|
||||
*origin* must be a Python object implementing at least ``id()``,
|
||||
``name()``, ``type()``, and ``ownsDocument(doc)`` methods.
|
||||
"""
|
||||
missing = [m for m in _REQUIRED_METHODS if not hasattr(origin, m)]
|
||||
if missing:
|
||||
raise TypeError(f"origin is missing required methods: {', '.join(missing)}")
|
||||
|
||||
try:
|
||||
_gui().addOrigin(origin)
|
||||
FreeCAD.Console.PrintLog(f"kindred_sdk: Registered origin '{origin.id()}'\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to register origin: {e}\n")
|
||||
|
||||
|
||||
def unregister_origin(origin):
|
||||
"""Remove a previously registered FileOrigin."""
|
||||
try:
|
||||
_gui().removeOrigin(origin)
|
||||
FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to unregister origin: {e}\n")
|
||||
46
mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Catppuccin Mocha
|
||||
slug: catppuccin-mocha
|
||||
|
||||
colors:
|
||||
rosewater: "#f5e0dc"
|
||||
flamingo: "#f2cdcd"
|
||||
pink: "#f5c2e7"
|
||||
mauve: "#cba6f7"
|
||||
red: "#f38ba8"
|
||||
maroon: "#eba0ac"
|
||||
peach: "#fab387"
|
||||
yellow: "#f9e2af"
|
||||
green: "#a6e3a1"
|
||||
teal: "#94e2d5"
|
||||
sky: "#89dceb"
|
||||
sapphire: "#74c7ec"
|
||||
blue: "#89b4fa"
|
||||
lavender: "#b4befe"
|
||||
text: "#cdd6f4"
|
||||
subtext1: "#bac2de"
|
||||
subtext0: "#a6adc8"
|
||||
overlay2: "#9399b2"
|
||||
overlay1: "#7f849c"
|
||||
overlay0: "#6c7086"
|
||||
surface2: "#585b70"
|
||||
surface1: "#45475a"
|
||||
surface0: "#313244"
|
||||
base: "#1e1e2e"
|
||||
mantle: "#181825"
|
||||
crust: "#11111b"
|
||||
|
||||
roles:
|
||||
background: base
|
||||
background.toolbar: mantle
|
||||
background.darkest: crust
|
||||
foreground: text
|
||||
foreground.muted: subtext0
|
||||
foreground.subtle: overlay1
|
||||
accent.primary: mauve
|
||||
accent.info: blue
|
||||
accent.success: green
|
||||
accent.warning: yellow
|
||||
accent.error: red
|
||||
border: surface1
|
||||
selection: surface2
|
||||
input.background: surface0
|
||||
181
mods/sdk/kindred_sdk/theme.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""YAML-driven theme system.
|
||||
|
||||
Loads color palettes from YAML files and provides runtime access to
|
||||
color tokens, semantic roles, QSS template formatting, and FreeCAD
|
||||
preference-pack value conversion.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
class Palette:
|
||||
"""A loaded color palette with raw tokens and semantic roles."""
|
||||
|
||||
def __init__(self, name, slug, colors, roles):
|
||||
self.name = name
|
||||
self.slug = slug
|
||||
self.colors = dict(colors)
|
||||
self.roles = {k: colors[v] for k, v in roles.items() if v in colors}
|
||||
|
||||
def get(self, key):
|
||||
"""Look up a color by role first, then by raw color name.
|
||||
|
||||
Returns the hex string or *None* if not found.
|
||||
"""
|
||||
return self.roles.get(key) or self.colors.get(key)
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba_uint(hex_color):
|
||||
"""Convert ``#RRGGBB`` to FreeCAD's unsigned-int RGBA format.
|
||||
|
||||
>>> Palette.hex_to_rgba_uint("#cdd6f4")
|
||||
3453416703
|
||||
"""
|
||||
h = hex_color.lstrip("#")
|
||||
r = int(h[0:2], 16)
|
||||
g = int(h[2:4], 16)
|
||||
b = int(h[4:6], 16)
|
||||
a = 255
|
||||
return (r << 24) | (g << 16) | (b << 8) | a
|
||||
|
||||
def format_qss(self, template):
|
||||
"""Substitute ``{token}`` placeholders in a QSS template string.
|
||||
|
||||
Both raw color names (``{blue}``) and dotted role names
|
||||
(``{accent.primary}``) are supported. Dotted names are tried
|
||||
first so they take precedence over any same-named color.
|
||||
|
||||
Unknown tokens are left as-is.
|
||||
"""
|
||||
lookup = {}
|
||||
lookup.update(self.colors)
|
||||
# Roles use dotted names which aren't valid Python identifiers,
|
||||
# so we do regex-based substitution.
|
||||
lookup.update(self.roles)
|
||||
|
||||
def _replace(m):
|
||||
key = m.group(1)
|
||||
return lookup.get(key, m.group(0))
|
||||
|
||||
return re.sub(r"\{([a-z][a-z0-9_.]*)\}", _replace, template)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Palette({self.name!r}, {len(self.colors)} colors, {len(self.roles)} roles)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML loading with fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_yaml(path):
|
||||
"""Load a YAML file, preferring PyYAML if available."""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
return _load_yaml_fallback(path)
|
||||
|
||||
|
||||
def _load_yaml_fallback(path):
|
||||
"""Minimal YAML parser for flat key-value palette files.
|
||||
|
||||
Handles the subset of YAML used by palette files: top-level keys
|
||||
with string/scalar values, and one level of nested mappings.
|
||||
"""
|
||||
data = {}
|
||||
current_section = None
|
||||
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
stripped = line.rstrip()
|
||||
|
||||
# Skip blank lines and comments
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Detect indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Top-level key
|
||||
if indent == 0 and ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if value:
|
||||
data[key] = value
|
||||
current_section = None
|
||||
else:
|
||||
# Start of a nested section
|
||||
current_section = key
|
||||
data[current_section] = {}
|
||||
continue
|
||||
|
||||
# Nested key (indented)
|
||||
if current_section is not None and indent > 0 and ":" in stripped:
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
data[current_section][key] = value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache = {}
|
||||
|
||||
_PALETTES_DIR = os.path.join(os.path.dirname(__file__), "palettes")
|
||||
|
||||
|
||||
def load_palette(name="catppuccin-mocha"):
|
||||
"""Load a named palette from the ``palettes/`` directory.
|
||||
|
||||
Results are cached; subsequent calls with the same *name* return
|
||||
the same ``Palette`` instance.
|
||||
"""
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
|
||||
path = os.path.join(_PALETTES_DIR, f"{name}.yaml")
|
||||
if not os.path.isfile(path):
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n")
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = _load_yaml(path)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to load palette '{name}': {e}\n")
|
||||
return None
|
||||
|
||||
palette = Palette(
|
||||
name=raw.get("name", name),
|
||||
slug=raw.get("slug", name),
|
||||
colors=raw.get("colors", {}),
|
||||
roles=raw.get("roles", {}),
|
||||
)
|
||||
_cache[name] = palette
|
||||
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Loaded palette '{palette.name}' ({len(palette.colors)} colors)\n"
|
||||
)
|
||||
return palette
|
||||
|
||||
|
||||
def get_theme_tokens(name="catppuccin-mocha"):
|
||||
"""Return a dict of ``{token_name: "#hex"}`` for all colors in a palette.
|
||||
|
||||
This is a convenience shorthand for ``load_palette(name).colors``.
|
||||
Returns a copy so callers cannot mutate the cached palette.
|
||||
"""
|
||||
palette = load_palette(name)
|
||||
if palette is None:
|
||||
return {}
|
||||
return dict(palette.colors)
|
||||
1
mods/sdk/kindred_sdk/version.py
Normal file
@@ -0,0 +1 @@
|
||||
SDK_VERSION = "0.1.0"
|
||||
23
mods/sdk/package.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
|
||||
<name>sdk</name>
|
||||
<description>Kindred Create addon SDK - stable API for addon integration</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
|
||||
<license file="LICENSE">LGPL-2.1-or-later</license>
|
||||
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>SdkWorkbench</classname>
|
||||
<subdirectory>./</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>0</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
</kindred>
|
||||
|
||||
</package>
|
||||
@@ -217,7 +217,9 @@ bool BitmapFactoryInst::loadPixmap(const QString& filename, QPixmap& icon) const
|
||||
QFile svgFile(fi.filePath());
|
||||
if (svgFile.open(QFile::ReadOnly | QFile::Text)) {
|
||||
QByteArray content = svgFile.readAll();
|
||||
icon = pixmapFromSvg(content, QSize(64, 64));
|
||||
static qreal dpr = getMaximumDPR();
|
||||
icon = pixmapFromSvg(content, QSize(64, 64) * dpr);
|
||||
icon.setDevicePixelRatio(dpr);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
<FCParamGroup Name="Sketcher">
|
||||
<FCUInt Name="GridLineColor" Value="1162304255" />
|
||||
</FCParamGroup>
|
||||
<FCParamGroup Name="Spreadsheet">
|
||||
<FCText Name="TextColor">#cdd6f4</FCText>
|
||||
<FCText Name="AliasedCellBackgroundColor">#313244</FCText>
|
||||
<FCText Name="PositiveNumberColor">#a6e3a1</FCText>
|
||||
<FCText Name="NegativeNumberColor">#f38ba8</FCText>
|
||||
</FCParamGroup>
|
||||
</FCParamGroup>
|
||||
</FCParamGroup>
|
||||
</FCParamGroup>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ set(Assembly_LIBS
|
||||
PartDesign
|
||||
Spreadsheet
|
||||
FreeCADApp
|
||||
OndselSolver
|
||||
KCSolve
|
||||
)
|
||||
|
||||
generate_from_py(AssemblyObject)
|
||||
|
||||
237
src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
Normal 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)
|
||||
216
src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py
Normal 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",
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
46
src/Mod/Assembly/Solver/CMakeLists.txt
Normal 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()
|
||||
189
src/Mod/Assembly/Solver/IKCSolver.h
Normal 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
|
||||
37
src/Mod/Assembly/Solver/KCSolveGlobal.h
Normal 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
|
||||
796
src/Mod/Assembly/Solver/OndselAdapter.cpp
Normal 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
|
||||
129
src/Mod/Assembly/Solver/OndselAdapter.h
Normal 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
|
||||
346
src/Mod/Assembly/Solver/SolverRegistry.cpp
Normal 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
|
||||
124
src/Mod/Assembly/Solver/SolverRegistry.h
Normal 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
|
||||
286
src/Mod/Assembly/Solver/Types.h
Normal 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
|
||||
31
src/Mod/Assembly/Solver/bindings/CMakeLists.txt
Normal 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})
|
||||
121
src/Mod/Assembly/Solver/bindings/PyIKCSolver.h
Normal 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
|
||||
359
src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp
Normal 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.");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
21
src/Mod/Create/App/AppCreate.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <Base/Console.h>
|
||||
#include <Base/PyObjectBase.h>
|
||||
|
||||
|
||||
namespace Create
|
||||
{
|
||||
extern PyObject* initModule();
|
||||
}
|
||||
|
||||
/* Python entry */
|
||||
PyMOD_INIT_FUNC(CreateApp)
|
||||
{
|
||||
PyObject* mod = Create::initModule();
|
||||
Base::Console().log("Loading Create module... done\n");
|
||||
|
||||
// Future: Create::FeatureFlipPocket::init(); etc.
|
||||
|
||||
PyMOD_Return(mod);
|
||||
}
|
||||
23
src/Mod/Create/App/AppCreatePy.cpp
Normal file
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <Base/Interpreter.h>
|
||||
|
||||
|
||||
namespace Create
|
||||
{
|
||||
class Module: public Py::ExtensionModule<Module>
|
||||
{
|
||||
public:
|
||||
Module()
|
||||
: Py::ExtensionModule<Module>("CreateApp")
|
||||
{
|
||||
initialize("Kindred Create module.");
|
||||
}
|
||||
};
|
||||
|
||||
PyObject* initModule()
|
||||
{
|
||||
return Base::Interpreter().addModule(new Module);
|
||||
}
|
||||
|
||||
} // namespace Create
|
||||
35
src/Mod/Create/App/CMakeLists.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
set(Create_LIBS
|
||||
FreeCADApp
|
||||
)
|
||||
|
||||
SET(Module_SRCS
|
||||
AppCreate.cpp
|
||||
AppCreatePy.cpp
|
||||
PreCompiled.h
|
||||
)
|
||||
SOURCE_GROUP("Module" FILES ${Module_SRCS})
|
||||
|
||||
SET(Create_SRCS
|
||||
${Module_SRCS}
|
||||
)
|
||||
|
||||
add_library(Create SHARED ${Create_SRCS})
|
||||
target_include_directories(Create PRIVATE
|
||||
${CMAKE_BINARY_DIR}
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_BINARY_DIR}/src
|
||||
${CMAKE_CURRENT_BINARY_DIR}
|
||||
)
|
||||
target_link_libraries(Create ${Create_LIBS})
|
||||
|
||||
if(FREECAD_USE_PCH)
|
||||
target_precompile_headers(Create PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:"${CMAKE_CURRENT_LIST_DIR}/PreCompiled.h">)
|
||||
endif()
|
||||
|
||||
SET_BIN_DIR(Create CreateApp /Mod/Create)
|
||||
SET_PYTHON_PREFIX_SUFFIX(Create)
|
||||
|
||||
INSTALL(TARGETS Create DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
11
src/Mod/Create/App/PreCompiled.h
Normal file
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#ifndef CREATE_PRECOMPILED_H
|
||||
#define CREATE_PRECOMPILED_H
|
||||
|
||||
#include <FCConfig.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#endif // CREATE_PRECOMPILED_H
|
||||
@@ -1,6 +1,13 @@
|
||||
# Kindred Create core module
|
||||
# Handles auto-loading of ztools and Silo addons
|
||||
|
||||
# C++ module targets
|
||||
add_subdirectory(App)
|
||||
|
||||
if(BUILD_GUI)
|
||||
add_subdirectory(Gui)
|
||||
endif(BUILD_GUI)
|
||||
|
||||
# Generate version.py from template with Kindred Create version
|
||||
configure_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/version.py.in
|
||||
@@ -13,13 +20,27 @@ install(
|
||||
FILES
|
||||
Init.py
|
||||
InitGui.py
|
||||
addon_loader.py
|
||||
kc_format.py
|
||||
silo_document.py
|
||||
silo_objects.py
|
||||
silo_tree.py
|
||||
silo_viewers.py
|
||||
silo_viewproviders.py
|
||||
update_checker.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/version.py
|
||||
DESTINATION
|
||||
Mod/Create
|
||||
)
|
||||
|
||||
# Install Silo tree-node icons
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/resources/icons/
|
||||
DESTINATION
|
||||
Mod/Create/resources/icons
|
||||
)
|
||||
|
||||
# Install ztools addon
|
||||
install(
|
||||
DIRECTORY
|
||||
@@ -27,12 +48,6 @@ install(
|
||||
DESTINATION
|
||||
mods/ztools
|
||||
)
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_SOURCE_DIR}/mods/ztools/CatppuccinMocha
|
||||
DESTINATION
|
||||
mods/ztools
|
||||
)
|
||||
install(
|
||||
FILES
|
||||
${CMAKE_SOURCE_DIR}/mods/ztools/package.xml
|
||||
@@ -53,3 +68,19 @@ install(
|
||||
DESTINATION
|
||||
mods/silo/silo-client
|
||||
)
|
||||
|
||||
# Install SDK
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_SOURCE_DIR}/mods/sdk/kindred_sdk
|
||||
DESTINATION
|
||||
mods/sdk
|
||||
)
|
||||
install(
|
||||
FILES
|
||||
${CMAKE_SOURCE_DIR}/mods/sdk/package.xml
|
||||
${CMAKE_SOURCE_DIR}/mods/sdk/Init.py
|
||||
${CMAKE_SOURCE_DIR}/mods/sdk/InitGui.py
|
||||
DESTINATION
|
||||
mods/sdk
|
||||
)
|
||||
|
||||
26
src/Mod/Create/CreateGlobal.h
Normal file
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <FCGlobal.h>
|
||||
|
||||
#ifndef CREATE_GLOBAL_H
|
||||
#define CREATE_GLOBAL_H
|
||||
|
||||
// CreateApp
|
||||
#ifndef CreateExport
|
||||
# ifdef Create_EXPORTS
|
||||
# define CreateExport FREECAD_DECL_EXPORT
|
||||
# else
|
||||
# define CreateExport FREECAD_DECL_IMPORT
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// CreateGui
|
||||
#ifndef CreateGuiExport
|
||||
# ifdef CreateGui_EXPORTS
|
||||
# define CreateGuiExport FREECAD_DECL_EXPORT
|
||||
# else
|
||||
# define CreateGuiExport FREECAD_DECL_IMPORT
|
||||
# endif
|
||||
#endif
|
||||
|
||||
#endif // CREATE_GLOBAL_H
|
||||
21
src/Mod/Create/Gui/AppCreateGui.cpp
Normal file
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <Base/Console.h>
|
||||
#include <Base/PyObjectBase.h>
|
||||
|
||||
|
||||
namespace CreateGui
|
||||
{
|
||||
extern PyObject* initModule();
|
||||
}
|
||||
|
||||
/* Python entry */
|
||||
PyMOD_INIT_FUNC(CreateGui)
|
||||
{
|
||||
PyObject* mod = CreateGui::initModule();
|
||||
Base::Console().log("Loading CreateGui module... done\n");
|
||||
|
||||
// Future: CreateGui::ViewProviderFlipPocket::init(); etc.
|
||||
|
||||
PyMOD_Return(mod);
|
||||
}
|
||||
23
src/Mod/Create/Gui/AppCreateGuiPy.cpp
Normal file
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <Base/Interpreter.h>
|
||||
|
||||
|
||||
namespace CreateGui
|
||||
{
|
||||
class Module: public Py::ExtensionModule<Module>
|
||||
{
|
||||
public:
|
||||
Module()
|
||||
: Py::ExtensionModule<Module>("CreateGui")
|
||||
{
|
||||
initialize("Kindred Create GUI module.");
|
||||
}
|
||||
};
|
||||
|
||||
PyObject* initModule()
|
||||
{
|
||||
return Base::Interpreter().addModule(new Module);
|
||||
}
|
||||
|
||||
} // namespace CreateGui
|
||||
34
src/Mod/Create/Gui/CMakeLists.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
set(CreateGui_LIBS
|
||||
Create
|
||||
FreeCADGui
|
||||
)
|
||||
|
||||
SET(Module_SRCS
|
||||
AppCreateGui.cpp
|
||||
AppCreateGuiPy.cpp
|
||||
PreCompiled.h
|
||||
)
|
||||
SOURCE_GROUP("Module" FILES ${Module_SRCS})
|
||||
|
||||
SET(CreateGui_SRCS
|
||||
${Module_SRCS}
|
||||
)
|
||||
|
||||
add_library(CreateGui SHARED ${CreateGui_SRCS})
|
||||
target_include_directories(CreateGui PRIVATE
|
||||
${CMAKE_BINARY_DIR}
|
||||
${CMAKE_CURRENT_BINARY_DIR}
|
||||
)
|
||||
target_link_libraries(CreateGui ${CreateGui_LIBS})
|
||||
|
||||
if(FREECAD_USE_PCH)
|
||||
target_precompile_headers(CreateGui PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:"${CMAKE_CURRENT_LIST_DIR}/PreCompiled.h">)
|
||||
endif()
|
||||
|
||||
SET_BIN_DIR(CreateGui CreateGui /Mod/Create)
|
||||
SET_PYTHON_PREFIX_SUFFIX(CreateGui)
|
||||
|
||||
INSTALL(TARGETS CreateGui DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
12
src/Mod/Create/Gui/PreCompiled.h
Normal file
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#ifndef CREATEGUI_PRECOMPILED_H
|
||||
#define CREATEGUI_PRECOMPILED_H
|
||||
|
||||
#include <FCConfig.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <Gui/QtAll.h>
|
||||
|
||||
#endif // CREATEGUI_PRECOMPILED_H
|
||||
@@ -1,48 +1,14 @@
|
||||
# Kindred Create - Core Module
|
||||
# Console initialization - loads ztools and Silo addons
|
||||
|
||||
import os
|
||||
import sys
|
||||
# Console initialization - loads Kindred addons via manifest-driven loader
|
||||
|
||||
import FreeCAD
|
||||
|
||||
try:
|
||||
from addon_loader import getAddonRegistry, load_addons
|
||||
|
||||
def setup_kindred_addons():
|
||||
"""Add Kindred Create addon paths and load their Init.py files."""
|
||||
# Get the FreeCAD home directory (where src/Mod/Create is installed)
|
||||
home = FreeCAD.getHomePath()
|
||||
mods_dir = os.path.join(home, "mods")
|
||||
load_addons(gui=False)
|
||||
FreeCAD.getAddonRegistry = getAddonRegistry
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Create: Addon loader failed: {e}\n")
|
||||
|
||||
# Define built-in addons with their paths relative to mods/
|
||||
addons = [
|
||||
("ztools", "ztools/ztools"), # mods/ztools/ztools/
|
||||
("silo", "silo/freecad"), # mods/silo/freecad/
|
||||
]
|
||||
|
||||
for name, subpath in addons:
|
||||
addon_path = os.path.join(mods_dir, subpath)
|
||||
if os.path.isdir(addon_path):
|
||||
# Add to sys.path if not already present
|
||||
if addon_path not in sys.path:
|
||||
sys.path.insert(0, addon_path)
|
||||
|
||||
# Execute Init.py if it exists
|
||||
init_file = os.path.join(addon_path, "Init.py")
|
||||
if os.path.isfile(init_file):
|
||||
try:
|
||||
with open(init_file) as f:
|
||||
exec_globals = globals().copy()
|
||||
exec_globals["__file__"] = init_file
|
||||
exec_globals["__name__"] = name
|
||||
exec(compile(f.read(), init_file, "exec"), exec_globals)
|
||||
FreeCAD.Console.PrintLog(f"Create: Loaded {name} Init.py\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Failed to load {name}: {e}\n"
|
||||
)
|
||||
else:
|
||||
FreeCAD.Console.PrintLog(f"Create: Addon path not found: {addon_path}\n")
|
||||
|
||||
|
||||
setup_kindred_addons()
|
||||
FreeCAD.Console.PrintLog("Create module initialized\n")
|
||||
|
||||
@@ -1,50 +1,16 @@
|
||||
# Kindred Create - Core Module
|
||||
# GUI initialization - loads ztools and Silo workbenches
|
||||
|
||||
import os
|
||||
import sys
|
||||
# GUI initialization - loads Kindred addon workbenches via manifest-driven loader
|
||||
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
try:
|
||||
from addon_loader import load_addons
|
||||
|
||||
def setup_kindred_workbenches():
|
||||
"""Load Kindred Create addon workbenches."""
|
||||
home = FreeCAD.getHomePath()
|
||||
mods_dir = os.path.join(home, "mods")
|
||||
load_addons(gui=True)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"Create: Addon GUI loader failed: {e}\n")
|
||||
|
||||
addons = [
|
||||
("ztools", "ztools/ztools"),
|
||||
("silo", "silo/freecad"),
|
||||
]
|
||||
|
||||
for name, subpath in addons:
|
||||
addon_path = os.path.join(mods_dir, subpath)
|
||||
if os.path.isdir(addon_path):
|
||||
# Ensure path is in sys.path
|
||||
if addon_path not in sys.path:
|
||||
sys.path.insert(0, addon_path)
|
||||
|
||||
# Execute InitGui.py if it exists
|
||||
init_gui_file = os.path.join(addon_path, "InitGui.py")
|
||||
if os.path.isfile(init_gui_file):
|
||||
try:
|
||||
with open(init_gui_file) as f:
|
||||
exec_globals = globals().copy()
|
||||
exec_globals["__file__"] = init_gui_file
|
||||
exec_globals["__name__"] = name
|
||||
exec(
|
||||
compile(f.read(), init_gui_file, "exec"),
|
||||
exec_globals,
|
||||
)
|
||||
FreeCAD.Console.PrintLog(f"Create: Loaded {name} workbench\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Failed to load {name} GUI: {e}\n"
|
||||
)
|
||||
|
||||
|
||||
setup_kindred_workbenches()
|
||||
FreeCAD.Console.PrintLog("Create GUI module initialized\n")
|
||||
|
||||
|
||||
@@ -58,6 +24,16 @@ def _register_kc_format():
|
||||
FreeCAD.Console.PrintLog(f"Create: kc_format registration skipped: {e}\n")
|
||||
|
||||
|
||||
def _register_silo_document_observer():
|
||||
"""Register the Silo document observer for .kc tree building."""
|
||||
try:
|
||||
import silo_document
|
||||
|
||||
silo_document.register()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: silo_document registration skipped: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Silo integration enhancements
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -89,26 +65,17 @@ def _register_silo_origin():
|
||||
def _setup_silo_auth_panel():
|
||||
"""Dock the Silo authentication panel in the right-hand side panel."""
|
||||
try:
|
||||
from PySide import QtCore, QtWidgets
|
||||
from kindred_sdk import register_dock_panel
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
def _factory():
|
||||
import silo_commands
|
||||
|
||||
# Don't create duplicate panels
|
||||
if mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseAuth"):
|
||||
return
|
||||
auth = silo_commands.SiloAuthDockWidget()
|
||||
# Prevent GC of the auth timer by stashing on the widget
|
||||
auth.widget._auth = auth
|
||||
return auth.widget
|
||||
|
||||
import silo_commands
|
||||
|
||||
auth = silo_commands.SiloAuthDockWidget()
|
||||
|
||||
panel = QtWidgets.QDockWidget("Database Auth", mw)
|
||||
panel.setObjectName("SiloDatabaseAuth")
|
||||
panel.setWidget(auth.widget)
|
||||
# Keep the auth object alive so its QTimer isn't destroyed while running
|
||||
panel._auth = auth
|
||||
mw.addDockWidget(QtCore.Qt.RightDockWidgetArea, panel)
|
||||
register_dock_panel("SiloDatabaseAuth", "Database Auth", _factory)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: Silo auth panel skipped: {e}\n")
|
||||
|
||||
@@ -116,49 +83,36 @@ def _setup_silo_auth_panel():
|
||||
def _setup_silo_activity_panel():
|
||||
"""Show a dock widget with recent Silo database activity."""
|
||||
try:
|
||||
from PySide import QtCore, QtWidgets
|
||||
from kindred_sdk import register_dock_panel
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
def _factory():
|
||||
from PySide import QtWidgets
|
||||
|
||||
# Don't create duplicate panels
|
||||
if mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity"):
|
||||
return
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
activity_list = QtWidgets.QListWidget()
|
||||
layout.addWidget(activity_list)
|
||||
|
||||
panel = QtWidgets.QDockWidget("Database Activity", mw)
|
||||
panel.setObjectName("SiloDatabaseActivity")
|
||||
try:
|
||||
import silo_commands
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
items = silo_commands._client.list_items()
|
||||
if isinstance(items, list):
|
||||
for item in items[:20]:
|
||||
pn = item.get("part_number", "")
|
||||
desc = item.get("description", "")
|
||||
updated = item.get("updated_at", "")
|
||||
if updated:
|
||||
updated = updated[:10]
|
||||
activity_list.addItem(f"{pn} - {desc} - {updated}")
|
||||
if activity_list.count() == 0:
|
||||
activity_list.addItem("(No items in database)")
|
||||
except Exception:
|
||||
activity_list.addItem("(Unable to connect to Silo database)")
|
||||
|
||||
activity_list = QtWidgets.QListWidget()
|
||||
layout.addWidget(activity_list)
|
||||
return widget
|
||||
|
||||
try:
|
||||
import silo_commands
|
||||
|
||||
items = silo_commands._client.list_items()
|
||||
if isinstance(items, list):
|
||||
for item in items[:20]:
|
||||
pn = item.get("part_number", "")
|
||||
desc = item.get("description", "")
|
||||
updated = item.get("updated_at", "")
|
||||
if updated:
|
||||
updated = updated[:10]
|
||||
activity_list.addItem(f"{pn} - {desc} - {updated}")
|
||||
if activity_list.count() == 0:
|
||||
activity_list.addItem("(No items in database)")
|
||||
except Exception:
|
||||
activity_list.addItem("(Unable to connect to Silo database)")
|
||||
|
||||
panel.setWidget(widget)
|
||||
mw.addDockWidget(QtCore.Qt.RightDockWidgetArea, panel)
|
||||
|
||||
# Give the activity panel most of the vertical space
|
||||
auth_panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseAuth")
|
||||
if auth_panel:
|
||||
mw.resizeDocks([auth_panel, panel], [120, 500], QtCore.Qt.Vertical)
|
||||
register_dock_panel("SiloDatabaseActivity", "Database Activity", _factory)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"Create: Silo activity panel skipped: {e}\n")
|
||||
|
||||
@@ -178,6 +132,7 @@ try:
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(500, _register_kc_format)
|
||||
QTimer.singleShot(600, _register_silo_document_observer)
|
||||
QTimer.singleShot(1500, _register_silo_origin)
|
||||
QTimer.singleShot(2000, _setup_silo_auth_panel)
|
||||
QTimer.singleShot(3000, _check_silo_first_start)
|
||||
|
||||
536
src/Mod/Create/addon_loader.py
Normal file
@@ -0,0 +1,536 @@
|
||||
# Kindred Create - Manifest-driven addon loader
|
||||
#
|
||||
# Replaces the hard-coded exec() loading in Init.py/InitGui.py with a
|
||||
# pipeline that scans mods/, parses package.xml manifests, validates
|
||||
# compatibility, resolves dependency order, and exposes a runtime registry.
|
||||
|
||||
import enum
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import FreeCAD
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AddonState(enum.Enum):
|
||||
DISCOVERED = "discovered"
|
||||
VALIDATED = "validated"
|
||||
LOADED = "loaded"
|
||||
SKIPPED = "skipped"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddonManifest:
|
||||
"""Parsed addon metadata from package.xml plus Kindred extensions."""
|
||||
|
||||
# Identity (from package.xml standard fields)
|
||||
name: str
|
||||
version: str
|
||||
description: str = ""
|
||||
|
||||
# Paths (resolved during discovery)
|
||||
package_xml_path: str = ""
|
||||
addon_root: str = ""
|
||||
workbench_path: str = ""
|
||||
|
||||
# Kindred extensions (from <kindred> element, all optional)
|
||||
min_create_version: Optional[str] = None
|
||||
max_create_version: Optional[str] = None
|
||||
load_priority: int = 100
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
has_kindred_element: bool = False
|
||||
|
||||
# Runtime state
|
||||
state: AddonState = AddonState.DISCOVERED
|
||||
error: str = ""
|
||||
load_time_ms: float = 0.0
|
||||
contexts: list[str] = field(default_factory=list)
|
||||
|
||||
def __repr__(self):
|
||||
return f"AddonManifest(name={self.name!r}, version={self.version!r}, state={self.state.value})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Addon registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AddonRegistry:
|
||||
"""Runtime registry of discovered addons and their load status."""
|
||||
|
||||
def __init__(self):
|
||||
self._addons: dict[str, AddonManifest] = {}
|
||||
self._load_order: list[str] = []
|
||||
|
||||
def register(self, manifest: AddonManifest):
|
||||
self._addons[manifest.name] = manifest
|
||||
|
||||
def set_load_order(self, names: list[str]):
|
||||
self._load_order = list(names)
|
||||
|
||||
def get(self, name: str) -> Optional[AddonManifest]:
|
||||
return self._addons.get(name)
|
||||
|
||||
def all(self) -> list[AddonManifest]:
|
||||
return list(self._addons.values())
|
||||
|
||||
def loaded(self) -> list[AddonManifest]:
|
||||
return [m for m in self._by_load_order() if m.state == AddonState.LOADED]
|
||||
|
||||
def failed(self) -> list[AddonManifest]:
|
||||
return [m for m in self._addons.values() if m.state == AddonState.FAILED]
|
||||
|
||||
def skipped(self) -> list[AddonManifest]:
|
||||
return [m for m in self._addons.values() if m.state == AddonState.SKIPPED]
|
||||
|
||||
def is_loaded(self, name: str) -> bool:
|
||||
m = self._addons.get(name)
|
||||
return m is not None and m.state == AddonState.LOADED
|
||||
|
||||
def register_context(self, addon_name: str, context_id: str):
|
||||
"""Register a context ID as provided by an addon."""
|
||||
m = self._addons.get(addon_name)
|
||||
if m is not None and context_id not in m.contexts:
|
||||
m.contexts.append(context_id)
|
||||
|
||||
def contexts(self) -> dict[str, list[str]]:
|
||||
"""Return a mapping of context IDs to the addon names that provide them."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for m in self._addons.values():
|
||||
for ctx in m.contexts:
|
||||
result.setdefault(ctx, []).append(m.name)
|
||||
return result
|
||||
|
||||
def _by_load_order(self) -> list[AddonManifest]:
|
||||
ordered = []
|
||||
for name in self._load_order:
|
||||
m = self._addons.get(name)
|
||||
if m is not None:
|
||||
ordered.append(m)
|
||||
# Include any addons not in load order (shouldn't happen, but safe)
|
||||
seen = set(self._load_order)
|
||||
for name, m in self._addons.items():
|
||||
if name not in seen:
|
||||
ordered.append(m)
|
||||
return ordered
|
||||
|
||||
def __repr__(self):
|
||||
loaded = len(self.loaded())
|
||||
total = len(self._addons)
|
||||
names = ", ".join(m.name for m in self.loaded())
|
||||
return f"AddonRegistry({loaded}/{total} loaded: {names})"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
_registry: Optional[AddonRegistry] = None
|
||||
|
||||
# Legacy load order for backward compatibility when no <kindred> elements exist.
|
||||
# Once addons declare <kindred> in their package.xml (issue #252), this is ignored.
|
||||
_LEGACY_ORDER = ["ztools", "silo"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def scan_addons(mods_dir: str) -> list[AddonManifest]:
|
||||
"""Scan mods/ for directories containing package.xml at depth 1-2."""
|
||||
manifests = []
|
||||
|
||||
if not os.path.isdir(mods_dir):
|
||||
FreeCAD.Console.PrintLog(f"Create: mods directory not found: {mods_dir}\n")
|
||||
return manifests
|
||||
|
||||
for entry in os.listdir(mods_dir):
|
||||
entry_path = os.path.join(mods_dir, entry)
|
||||
if not os.path.isdir(entry_path):
|
||||
continue
|
||||
|
||||
# Check depth 1: mods/<addon>/package.xml
|
||||
pkg_xml = os.path.join(entry_path, "package.xml")
|
||||
if os.path.isfile(pkg_xml):
|
||||
manifests.append(
|
||||
AddonManifest(
|
||||
name="",
|
||||
version="",
|
||||
package_xml_path=pkg_xml,
|
||||
addon_root=entry_path,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Check depth 2: mods/<addon>/<subdir>/package.xml
|
||||
for sub in os.listdir(entry_path):
|
||||
sub_path = os.path.join(entry_path, sub)
|
||||
if not os.path.isdir(sub_path):
|
||||
continue
|
||||
pkg_xml = os.path.join(sub_path, "package.xml")
|
||||
if os.path.isfile(pkg_xml):
|
||||
manifests.append(
|
||||
AddonManifest(
|
||||
name="",
|
||||
version="",
|
||||
package_xml_path=pkg_xml,
|
||||
addon_root=sub_path,
|
||||
)
|
||||
)
|
||||
|
||||
return manifests
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# FreeCAD package.xml namespace
|
||||
_PKG_NS = "https://wiki.freecad.org/Package_Metadata"
|
||||
|
||||
|
||||
def _find(parent, tag):
|
||||
"""Find a child element, trying with and without namespace."""
|
||||
el = parent.find(f"{{{_PKG_NS}}}{tag}")
|
||||
if el is None:
|
||||
el = parent.find(tag)
|
||||
return el
|
||||
|
||||
|
||||
def _findall(parent, tag):
|
||||
"""Find all child elements, trying with and without namespace."""
|
||||
els = parent.findall(f"{{{_PKG_NS}}}{tag}")
|
||||
if not els:
|
||||
els = parent.findall(tag)
|
||||
return els
|
||||
|
||||
|
||||
def _text(parent, tag, default=""):
|
||||
"""Get text content of a child element."""
|
||||
el = _find(parent, tag)
|
||||
return el.text.strip() if el is not None and el.text else default
|
||||
|
||||
|
||||
def parse_manifest(manifest: AddonManifest):
|
||||
"""Parse package.xml into the manifest, including <kindred> extensions."""
|
||||
try:
|
||||
tree = ET.parse(manifest.package_xml_path)
|
||||
root = tree.getroot()
|
||||
except ET.ParseError as e:
|
||||
manifest.state = AddonState.FAILED
|
||||
manifest.error = f"XML parse error: {e}"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Failed to parse {manifest.package_xml_path}: {e}\n"
|
||||
)
|
||||
return
|
||||
|
||||
# Standard fields
|
||||
manifest.name = _text(root, "name") or os.path.basename(manifest.addon_root)
|
||||
manifest.version = _text(root, "version", "0.0.0")
|
||||
manifest.description = _text(root, "description")
|
||||
|
||||
# Resolve workbench path from <content><workbench><subdirectory>
|
||||
content = _find(root, "content")
|
||||
if content is not None:
|
||||
workbench = _find(content, "workbench")
|
||||
if workbench is not None:
|
||||
subdir = _text(workbench, "subdirectory", ".")
|
||||
# Normalize: strip leading ./
|
||||
if subdir.startswith("./"):
|
||||
subdir = subdir[2:]
|
||||
if subdir == "" or subdir == ".":
|
||||
manifest.workbench_path = manifest.addon_root
|
||||
else:
|
||||
manifest.workbench_path = os.path.join(manifest.addon_root, subdir)
|
||||
|
||||
# Fallback: use addon_root if no workbench subdirectory found
|
||||
if not manifest.workbench_path:
|
||||
manifest.workbench_path = manifest.addon_root
|
||||
|
||||
# Kindred extensions (optional)
|
||||
kindred = _find(root, "kindred")
|
||||
if kindred is not None:
|
||||
manifest.has_kindred_element = True
|
||||
manifest.min_create_version = _text(kindred, "min_create_version") or None
|
||||
manifest.max_create_version = _text(kindred, "max_create_version") or None
|
||||
priority_str = _text(kindred, "load_priority")
|
||||
if priority_str:
|
||||
try:
|
||||
manifest.load_priority = int(priority_str)
|
||||
except ValueError:
|
||||
pass
|
||||
deps = _find(kindred, "dependencies")
|
||||
if deps is not None:
|
||||
for dep in _findall(deps, "dependency"):
|
||||
if dep.text and dep.text.strip():
|
||||
manifest.dependencies.append(dep.text.strip())
|
||||
ctxs = _find(kindred, "contexts")
|
||||
if ctxs is not None:
|
||||
for ctx in _findall(ctxs, "context"):
|
||||
if ctx.text and ctx.text.strip():
|
||||
manifest.contexts.append(ctx.text.strip())
|
||||
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"Create: Parsed {manifest.name} v{manifest.version} from {manifest.package_xml_path}\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_version(v: str) -> tuple:
|
||||
"""Parse a version string into a comparable tuple of ints."""
|
||||
try:
|
||||
return tuple(int(x) for x in v.split("."))
|
||||
except (ValueError, AttributeError):
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def validate_manifest(manifest: AddonManifest, create_version: str) -> bool:
|
||||
"""Check version compatibility and path existence. Returns True if valid."""
|
||||
if manifest.state == AddonState.FAILED:
|
||||
return False
|
||||
|
||||
cv = _parse_version(create_version)
|
||||
|
||||
if manifest.min_create_version:
|
||||
if cv < _parse_version(manifest.min_create_version):
|
||||
manifest.state = AddonState.SKIPPED
|
||||
manifest.error = f"Requires Create >= {manifest.min_create_version}, running {create_version}"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Skipping {manifest.name}: {manifest.error}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
if manifest.max_create_version:
|
||||
if cv > _parse_version(manifest.max_create_version):
|
||||
manifest.state = AddonState.SKIPPED
|
||||
manifest.error = f"Requires Create <= {manifest.max_create_version}, running {create_version}"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Skipping {manifest.name}: {manifest.error}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
if not os.path.isdir(manifest.workbench_path):
|
||||
manifest.state = AddonState.SKIPPED
|
||||
manifest.error = f"Workbench path not found: {manifest.workbench_path}"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Skipping {manifest.name}: {manifest.error}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
# At least one of Init.py or InitGui.py must exist
|
||||
has_init = os.path.isfile(os.path.join(manifest.workbench_path, "Init.py"))
|
||||
has_gui = os.path.isfile(os.path.join(manifest.workbench_path, "InitGui.py"))
|
||||
if not has_init and not has_gui:
|
||||
manifest.state = AddonState.SKIPPED
|
||||
manifest.error = f"No Init.py or InitGui.py in {manifest.workbench_path}"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Skipping {manifest.name}: {manifest.error}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
manifest.state = AddonState.VALIDATED
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_load_order(
|
||||
manifests: list[AddonManifest], mods_dir: str
|
||||
) -> list[AddonManifest]:
|
||||
"""Sort addons by dependencies, then by (load_priority, name).
|
||||
|
||||
If no addons declare a <kindred> element, fall back to the legacy
|
||||
hard-coded order for backward compatibility.
|
||||
"""
|
||||
if not manifests:
|
||||
return []
|
||||
|
||||
by_name = {m.name: m for m in manifests}
|
||||
any_kindred = any(m.has_kindred_element for m in manifests)
|
||||
|
||||
if not any_kindred:
|
||||
# Legacy fallback: use hard-coded order matched by directory name
|
||||
return _legacy_order(manifests, mods_dir)
|
||||
|
||||
# Topological sort with graphlib
|
||||
from graphlib import CycleError, TopologicalSorter
|
||||
|
||||
ts = TopologicalSorter()
|
||||
for m in manifests:
|
||||
# Only include dependencies that are actually discovered
|
||||
known_deps = [d for d in m.dependencies if d in by_name]
|
||||
unknown_deps = [d for d in m.dependencies if d not in by_name]
|
||||
for dep in unknown_deps:
|
||||
m.state = AddonState.SKIPPED
|
||||
m.error = f"Missing dependency: {dep}"
|
||||
FreeCAD.Console.PrintWarning(f"Create: Skipping {m.name}: {m.error}\n")
|
||||
if m.state != AddonState.SKIPPED:
|
||||
ts.add(m.name, *known_deps)
|
||||
|
||||
try:
|
||||
# Process level by level so we can sort within each topological level
|
||||
ts.prepare()
|
||||
order = []
|
||||
while ts.is_active():
|
||||
ready = list(ts.get_ready())
|
||||
# Sort each level by (priority, name) for determinism
|
||||
ready.sort(key=lambda n: (by_name[n].load_priority, n) if n in by_name else (999, n))
|
||||
for name in ready:
|
||||
ts.done(name)
|
||||
order.extend(ready)
|
||||
except CycleError as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Create: Dependency cycle detected: {e}. Falling back to priority order.\n"
|
||||
)
|
||||
return sorted(
|
||||
[m for m in manifests if m.state != AddonState.SKIPPED],
|
||||
key=lambda m: (m.load_priority, m.name),
|
||||
)
|
||||
|
||||
# Filter to actual manifests, preserving sorted topological order
|
||||
result = []
|
||||
for name in order:
|
||||
m = by_name.get(name)
|
||||
if m is not None and m.state != AddonState.SKIPPED:
|
||||
result.append(m)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _legacy_order(manifests: list[AddonManifest], mods_dir: str) -> list[AddonManifest]:
|
||||
"""Order addons using the legacy hard-coded list, matched by directory path."""
|
||||
by_dir = {}
|
||||
for m in manifests:
|
||||
# Extract the top-level directory name under mods/
|
||||
rel = os.path.relpath(m.addon_root, mods_dir)
|
||||
top_dir = rel.split(os.sep)[0]
|
||||
by_dir[top_dir] = m
|
||||
|
||||
ordered = []
|
||||
for dir_name in _LEGACY_ORDER:
|
||||
if dir_name in by_dir:
|
||||
ordered.append(by_dir.pop(dir_name))
|
||||
|
||||
# Append any addons not in the legacy list (alphabetically)
|
||||
for name in sorted(by_dir.keys()):
|
||||
ordered.append(by_dir[name])
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_addon(manifest: AddonManifest, gui: bool = False):
|
||||
"""Execute an addon's Init.py or InitGui.py."""
|
||||
init_file = "InitGui.py" if gui else "Init.py"
|
||||
filepath = os.path.join(manifest.workbench_path, init_file)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
return
|
||||
|
||||
# Ensure workbench path is in sys.path
|
||||
if manifest.workbench_path not in sys.path:
|
||||
sys.path.insert(0, manifest.workbench_path)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
exec_globals = globals().copy()
|
||||
exec_globals["__file__"] = filepath
|
||||
exec_globals["__name__"] = manifest.name
|
||||
exec(compile(f.read(), filepath, "exec"), exec_globals)
|
||||
elapsed = (time.monotonic() - start) * 1000
|
||||
if not gui:
|
||||
manifest.load_time_ms = elapsed
|
||||
else:
|
||||
manifest.load_time_ms += elapsed
|
||||
manifest.state = AddonState.LOADED
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"Create: Loaded {manifest.name} {init_file} ({elapsed:.0f}ms)\n"
|
||||
)
|
||||
except Exception as e:
|
||||
manifest.state = AddonState.FAILED
|
||||
manifest.error = str(e)
|
||||
FreeCAD.Console.PrintWarning(f"Create: Failed to load {manifest.name}: {e}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Top-level API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_create_version() -> str:
|
||||
try:
|
||||
from version import VERSION
|
||||
|
||||
return VERSION
|
||||
except ImportError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def load_addons(gui: bool = False):
|
||||
"""Load Kindred addons from mods/.
|
||||
|
||||
Called twice: once from Init.py (gui=False) to run each addon's Init.py,
|
||||
and once from InitGui.py (gui=True) to run each addon's InitGui.py.
|
||||
The gui=False call runs the full pipeline and builds the registry.
|
||||
The gui=True call reuses the registry and loads InitGui.py for each
|
||||
successfully loaded addon.
|
||||
"""
|
||||
global _registry
|
||||
|
||||
mods_dir = os.path.join(FreeCAD.getHomePath(), "mods")
|
||||
|
||||
if not gui:
|
||||
# Full pipeline: scan -> parse -> validate -> sort -> load -> register
|
||||
manifests = scan_addons(mods_dir)
|
||||
|
||||
for m in manifests:
|
||||
parse_manifest(m)
|
||||
|
||||
create_version = _get_create_version()
|
||||
validated = [m for m in manifests if validate_manifest(m, create_version)]
|
||||
ordered = resolve_load_order(validated, mods_dir)
|
||||
|
||||
_registry = AddonRegistry()
|
||||
for m in manifests:
|
||||
_registry.register(m)
|
||||
_registry.set_load_order([m.name for m in ordered])
|
||||
FreeCAD.KindredAddons = _registry
|
||||
|
||||
for m in ordered:
|
||||
_load_addon(m, gui=False)
|
||||
else:
|
||||
# GUI phase: reuse registry, load InitGui.py in load order
|
||||
if _registry is None:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
"Create: Addon registry not initialized, skipping GUI load\n"
|
||||
)
|
||||
return
|
||||
|
||||
for m in _registry.loaded():
|
||||
_load_addon(m, gui=True)
|
||||
|
||||
|
||||
def getAddonRegistry() -> Optional[AddonRegistry]:
|
||||
"""Return the addon registry singleton, or None if not yet initialized.
|
||||
|
||||
Exposed as FreeCAD.getAddonRegistry() for runtime introspection.
|
||||
"""
|
||||
return _registry
|
||||
@@ -18,6 +18,78 @@ import FreeCAD
|
||||
# Cache: filepath -> {entry_name: bytes}
|
||||
_silo_cache = {}
|
||||
|
||||
# Pre-reinject hooks: called with (doc, filename, entries) before ZIP write.
|
||||
_pre_reinject_hooks = []
|
||||
|
||||
|
||||
def register_pre_reinject(callback):
|
||||
"""Register a callback invoked before silo/ entries are written to ZIP.
|
||||
|
||||
Signature: callback(doc, filename, entries) -> None
|
||||
``entries`` is a dict {entry_name: bytes} or None. Mutate in place.
|
||||
"""
|
||||
_pre_reinject_hooks.append(callback)
|
||||
|
||||
|
||||
def _metadata_save_hook(doc, filename, entries):
|
||||
"""Write dirty metadata back to the silo/ cache before ZIP write."""
|
||||
obj = doc.getObject("SiloMetadata")
|
||||
if obj is None or not hasattr(obj, "Proxy"):
|
||||
return
|
||||
proxy = obj.Proxy
|
||||
if proxy is None or not proxy.is_dirty():
|
||||
return
|
||||
entries["silo/metadata.json"] = obj.RawContent.encode("utf-8")
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -62,6 +134,15 @@ class _KcFormatObserver:
|
||||
_silo_cache.pop(filename, None)
|
||||
return
|
||||
entries = _silo_cache.pop(filename, None)
|
||||
if entries is None:
|
||||
entries = {}
|
||||
for _hook in _pre_reinject_hooks:
|
||||
try:
|
||||
_hook(doc, filename, entries)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kc_format: pre_reinject hook failed: {exc}\n"
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(filename, "a") as zf:
|
||||
existing = set(zf.namelist())
|
||||
@@ -97,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())
|
||||
|
||||
8
src/Mod/Create/resources/icons/silo-approvals.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Padlock body -->
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" fill="#313244" stroke="#cba6f7"/>
|
||||
<!-- Padlock shackle -->
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" fill="none" stroke="#89dceb"/>
|
||||
<!-- Keyhole -->
|
||||
<circle cx="12" cy="16" r="1.5" fill="#89dceb" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
12
src/Mod/Create/resources/icons/silo-dependencies.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Outer box -->
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" fill="#313244"/>
|
||||
<!-- List lines (BOM rows) -->
|
||||
<line x1="8" y1="8" x2="18" y2="8" stroke="#89dceb" stroke-width="1.5"/>
|
||||
<line x1="8" y1="12" x2="18" y2="12" stroke="#89dceb" stroke-width="1.5"/>
|
||||
<line x1="8" y1="16" x2="18" y2="16" stroke="#89dceb" stroke-width="1.5"/>
|
||||
<!-- Hierarchy dots -->
|
||||
<circle cx="6" cy="8" r="1" fill="#cba6f7"/>
|
||||
<circle cx="6" cy="12" r="1" fill="#cba6f7"/>
|
||||
<circle cx="6" cy="16" r="1" fill="#cba6f7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
8
src/Mod/Create/resources/icons/silo-group.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Folder open icon -->
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#313244"/>
|
||||
<path d="M2 10h20" stroke="#6c7086"/>
|
||||
<!-- Search magnifier -->
|
||||
<circle cx="17" cy="15" r="3" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="19.5" y1="17.5" x2="22" y2="20" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
8
src/Mod/Create/resources/icons/silo-history.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Counter-clockwise arrow -->
|
||||
<polyline points="1 4 1 10 7 10" stroke="#f38ba8"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="#cba6f7"/>
|
||||
<!-- Clock hands -->
|
||||
<line x1="12" y1="7" x2="12" y2="12" stroke="#89dceb" stroke-width="1.5"/>
|
||||
<line x1="12" y1="12" x2="15" y2="14" stroke="#89dceb" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
7
src/Mod/Create/resources/icons/silo-job.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Cloud -->
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
|
||||
<!-- Upload arrow -->
|
||||
<path d="M12 18v-5m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="2"/>
|
||||
<line x1="12" y1="13" x2="12" y2="9" stroke="#a6e3a1" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
8
src/Mod/Create/resources/icons/silo-jobs-group.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Folder open icon -->
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#313244"/>
|
||||
<path d="M2 10h20" stroke="#6c7086"/>
|
||||
<!-- Search magnifier -->
|
||||
<circle cx="17" cy="15" r="3" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="19.5" y1="17.5" x2="22" y2="20" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
8
src/Mod/Create/resources/icons/silo-macro.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Document with plus -->
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="#313244"/>
|
||||
<polyline points="14 2 14 8 20 8" fill="#45475a" stroke="#cba6f7"/>
|
||||
<!-- Plus sign -->
|
||||
<line x1="12" y1="11" x2="12" y2="17" stroke="#a6e3a1" stroke-width="2"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14" stroke="#a6e3a1" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
8
src/Mod/Create/resources/icons/silo-macros-group.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Folder open icon -->
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#313244"/>
|
||||
<path d="M2 10h20" stroke="#6c7086"/>
|
||||
<!-- Search magnifier -->
|
||||
<circle cx="17" cy="15" r="3" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="19.5" y1="17.5" x2="22" y2="20" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
6
src/Mod/Create/resources/icons/silo-manifest.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Info circle -->
|
||||
<circle cx="12" cy="12" r="10" fill="#313244"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
|
||||
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
6
src/Mod/Create/resources/icons/silo-metadata.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Tag shape -->
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" fill="#313244"/>
|
||||
<!-- Tag hole -->
|
||||
<circle cx="7" cy="7" r="1.5" fill="#cba6f7" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
85
src/Mod/Create/silo_document.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
silo_document.py - Document observer that builds the Silo metadata tree
|
||||
when a .kc file is opened in FreeCAD.
|
||||
|
||||
Hooks slotCreatedDocument (primary) and slotActivateDocument (fallback)
|
||||
to detect .kc opens, then defers tree building to the next event loop
|
||||
tick via QTimer.singleShot(0, ...) so the document is fully loaded.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_observer = None
|
||||
|
||||
|
||||
def _on_kc_restored(doc):
|
||||
"""Deferred callback: build the Silo tree after document is fully loaded."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename:
|
||||
return
|
||||
from silo_tree import SiloTreeBuilder
|
||||
|
||||
contents = SiloTreeBuilder.read_silo_directory(filename)
|
||||
if contents:
|
||||
SiloTreeBuilder.build_tree(doc, contents)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: failed to build silo tree for {doc.Name!r}: {exc}\n"
|
||||
)
|
||||
|
||||
|
||||
class SiloDocumentObserver:
|
||||
"""Singleton observer that triggers Silo tree creation for .kc files."""
|
||||
|
||||
def slotCreatedDocument(self, doc):
|
||||
"""Called when a document is created or opened."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename or not filename.lower().endswith(".kc"):
|
||||
return
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(0, lambda: _on_kc_restored(doc))
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: slotCreatedDocument error: {exc}\n"
|
||||
)
|
||||
|
||||
def slotActivateDocument(self, doc):
|
||||
"""Fallback for documents opened before observer registration."""
|
||||
try:
|
||||
filename = doc.FileName
|
||||
if not filename or not filename.lower().endswith(".kc"):
|
||||
return
|
||||
if doc.getObject("Silo") is not None:
|
||||
return
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(0, lambda: _on_kc_restored(doc))
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_document: slotActivateDocument error: {exc}\n"
|
||||
)
|
||||
|
||||
def slotDeletedDocument(self, doc):
|
||||
"""Placeholder for future cleanup."""
|
||||
pass
|
||||
|
||||
|
||||
def register():
|
||||
"""Register the singleton observer. Safe to call multiple times."""
|
||||
global _observer
|
||||
if _observer is not None:
|
||||
return
|
||||
|
||||
_observer = SiloDocumentObserver()
|
||||
FreeCAD.addDocumentObserver(_observer)
|
||||
FreeCAD.Console.PrintLog("silo_document: observer registered\n")
|
||||
|
||||
# Bootstrap: handle documents already open before registration.
|
||||
try:
|
||||
for doc in FreeCAD.listDocuments().values():
|
||||
_observer.slotActivateDocument(doc)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(f"silo_document: bootstrap scan failed: {exc}\n")
|
||||
70
src/Mod/Create/silo_objects.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
silo_objects.py - Create FeaturePython proxy for Silo tree leaf nodes.
|
||||
|
||||
Each silo/ ZIP entry in a .kc file gets one SiloViewerObject in the
|
||||
FreeCAD document tree. All properties are Transient so they are never
|
||||
persisted in Document.xml.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
class SiloViewerObject:
|
||||
"""Proxy for App::FeaturePython silo viewer nodes.
|
||||
|
||||
Properties (all Transient):
|
||||
SiloPath - ZIP entry path, e.g. "silo/manifest.json"
|
||||
ContentType - "json", "yaml", or "py"
|
||||
RawContent - decoded UTF-8 content of the entry
|
||||
"""
|
||||
|
||||
def __init__(self, obj):
|
||||
obj.Proxy = self
|
||||
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"SiloPath",
|
||||
"Silo",
|
||||
"ZIP entry path of this silo item",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"ContentType",
|
||||
"Silo",
|
||||
"Content type of this silo item",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"RawContent",
|
||||
"Silo",
|
||||
"Raw text content of this silo entry",
|
||||
)
|
||||
|
||||
obj.setPropertyStatus("SiloPath", "Transient")
|
||||
obj.setPropertyStatus("ContentType", "Transient")
|
||||
obj.setPropertyStatus("RawContent", "Transient")
|
||||
|
||||
obj.setEditorMode("SiloPath", 1) # read-only in property panel
|
||||
obj.setEditorMode("ContentType", 1)
|
||||
obj.setEditorMode("RawContent", 2) # hidden in property panel
|
||||
|
||||
def execute(self, obj):
|
||||
pass
|
||||
|
||||
def mark_dirty(self):
|
||||
"""Flag this object's content as modified by a viewer widget."""
|
||||
self._dirty = True
|
||||
|
||||
def is_dirty(self):
|
||||
"""Return True if content has been modified since last save."""
|
||||
return getattr(self, "_dirty", False)
|
||||
|
||||
def clear_dirty(self):
|
||||
"""Clear the dirty flag after saving."""
|
||||
self._dirty = False
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
229
src/Mod/Create/silo_tree.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
silo_tree.py - Builds the Silo metadata tree in the FreeCAD document.
|
||||
|
||||
Reads silo/ entries from a .kc ZIP and creates a conditional hierarchy
|
||||
of App::FeaturePython and App::DocumentObjectGroup objects in the
|
||||
document tree.
|
||||
|
||||
Tree structure:
|
||||
Silo (App::DocumentObjectGroup)
|
||||
+-- Manifest (always present)
|
||||
+-- Metadata (if metadata.json is non-empty)
|
||||
+-- History (if history.json has revisions)
|
||||
+-- Approvals (if approvals.json has eco field)
|
||||
+-- Dependencies (if dependencies.json has links)
|
||||
+-- Jobs (group, if silo/jobs/ has YAML files)
|
||||
| +-- default.yaml
|
||||
+-- Macros (group, if silo/macros/ has .py files)
|
||||
+-- on_save
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_SILO_GROUP_NAME = "Silo"
|
||||
|
||||
# Top-level silo/ entries with their object names, labels, and
|
||||
# optional JSON field checks for conditional creation.
|
||||
_KNOWN_ENTRIES = [
|
||||
# (zip_name, object_name, label, json_check)
|
||||
# json_check is None (always create) or (field_name, check_fn)
|
||||
("silo/manifest.json", "SiloManifest", "Manifest", None),
|
||||
("silo/metadata.json", "SiloMetadata", "Metadata", None),
|
||||
(
|
||||
"silo/history.json",
|
||||
"SiloHistory",
|
||||
"History",
|
||||
("revisions", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
(
|
||||
"silo/approvals.json",
|
||||
"SiloApprovals",
|
||||
"Approvals",
|
||||
("eco", lambda v: v is not None),
|
||||
),
|
||||
(
|
||||
"silo/dependencies.json",
|
||||
"SiloDependencies",
|
||||
"Dependencies",
|
||||
("links", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _content_type(entry_name):
|
||||
"""Determine content type from ZIP entry name."""
|
||||
if entry_name.endswith(".json"):
|
||||
return "json"
|
||||
if entry_name.endswith((".yaml", ".yml")):
|
||||
return "yaml"
|
||||
if entry_name.endswith(".py"):
|
||||
return "py"
|
||||
return "text"
|
||||
|
||||
|
||||
def _decode(data):
|
||||
"""Decode bytes to UTF-8 string, returning '' on failure."""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _should_create(data, json_check):
|
||||
"""Check whether a conditional node should be created."""
|
||||
if json_check is None:
|
||||
return True
|
||||
field_name, check_fn = json_check
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
return check_fn(parsed.get(field_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _create_leaf(doc, parent, entry_name, data, obj_name, label):
|
||||
"""Create one App::FeaturePython leaf and add it to parent group."""
|
||||
from silo_objects import SiloViewerObject
|
||||
from silo_viewproviders import SiloViewerViewProvider
|
||||
|
||||
obj = doc.addObject("App::FeaturePython", obj_name)
|
||||
SiloViewerObject(obj)
|
||||
|
||||
obj.SiloPath = entry_name
|
||||
obj.ContentType = _content_type(entry_name)
|
||||
obj.RawContent = _decode(data)
|
||||
obj.Label = label
|
||||
|
||||
try:
|
||||
import FreeCADGui # noqa: F401
|
||||
|
||||
if obj.ViewObject is not None:
|
||||
SiloViewerViewProvider(obj.ViewObject)
|
||||
except ImportError:
|
||||
pass # headless mode
|
||||
|
||||
parent.addObject(obj)
|
||||
return obj
|
||||
|
||||
|
||||
class SiloTreeBuilder:
|
||||
"""Reads silo/ from a .kc ZIP and builds the document tree."""
|
||||
|
||||
@staticmethod
|
||||
def read_silo_directory(filename):
|
||||
"""Read silo/ entries from a .kc ZIP.
|
||||
|
||||
Returns dict {entry_name: bytes}, e.g. {"silo/manifest.json": b"..."}.
|
||||
Returns {} on failure.
|
||||
"""
|
||||
entries = {}
|
||||
try:
|
||||
with zipfile.ZipFile(filename, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("silo/") and not name.endswith("/"):
|
||||
entries[name] = zf.read(name)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_tree: could not read silo/ from {filename!r}: {exc}\n"
|
||||
)
|
||||
return entries
|
||||
|
||||
@staticmethod
|
||||
def build_tree(doc, silo_contents):
|
||||
"""Create the Silo group hierarchy in doc from silo/ entries."""
|
||||
if not silo_contents:
|
||||
return
|
||||
|
||||
SiloTreeBuilder.remove_silo_tree(doc)
|
||||
|
||||
root = doc.addObject("App::DocumentObjectGroup", _SILO_GROUP_NAME)
|
||||
root.Label = "Silo"
|
||||
|
||||
# Top-level known entries (conditional creation)
|
||||
for zip_name, obj_name, label, json_check in _KNOWN_ENTRIES:
|
||||
if zip_name not in silo_contents:
|
||||
continue
|
||||
data = silo_contents[zip_name]
|
||||
if not _should_create(data, json_check):
|
||||
continue
|
||||
_create_leaf(doc, root, zip_name, data, obj_name, label)
|
||||
|
||||
# Jobs subgroup
|
||||
job_entries = {
|
||||
k: v
|
||||
for k, v in silo_contents.items()
|
||||
if k.startswith("silo/jobs/") and not k.endswith("/")
|
||||
}
|
||||
if job_entries:
|
||||
jobs_group = doc.addObject("App::DocumentObjectGroup", "SiloJobs")
|
||||
jobs_group.Label = "Jobs"
|
||||
root.addObject(jobs_group)
|
||||
for entry_name in sorted(job_entries):
|
||||
basename = entry_name.split("/")[-1]
|
||||
safe_name = "SiloJob_" + basename.replace(".", "_").replace("-", "_")
|
||||
_create_leaf(
|
||||
doc,
|
||||
jobs_group,
|
||||
entry_name,
|
||||
job_entries[entry_name],
|
||||
safe_name,
|
||||
basename,
|
||||
)
|
||||
|
||||
# Macros subgroup
|
||||
macro_entries = {
|
||||
k: v
|
||||
for k, v in silo_contents.items()
|
||||
if k.startswith("silo/macros/") and not k.endswith("/")
|
||||
}
|
||||
if macro_entries:
|
||||
macros_group = doc.addObject("App::DocumentObjectGroup", "SiloMacros")
|
||||
macros_group.Label = "Macros"
|
||||
root.addObject(macros_group)
|
||||
for entry_name in sorted(macro_entries):
|
||||
basename = entry_name.split("/")[-1]
|
||||
label = basename[:-3] if basename.endswith(".py") else basename
|
||||
safe_name = "SiloMacro_" + basename.replace(".", "_").replace("-", "_")
|
||||
_create_leaf(
|
||||
doc,
|
||||
macros_group,
|
||||
entry_name,
|
||||
macro_entries[entry_name],
|
||||
safe_name,
|
||||
label,
|
||||
)
|
||||
|
||||
doc.recompute()
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"silo_tree: built tree with {len(silo_contents)} entries in {doc.Name!r}\n"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_silo_tree(doc):
|
||||
"""Remove the Silo group and all descendants. Safe if absent."""
|
||||
root = doc.getObject(_SILO_GROUP_NAME)
|
||||
if root is None:
|
||||
return
|
||||
|
||||
names = []
|
||||
|
||||
def _collect(obj):
|
||||
if obj.Name in names:
|
||||
return
|
||||
names.append(obj.Name)
|
||||
if hasattr(obj, "OutList"):
|
||||
for child in obj.OutList:
|
||||
_collect(child)
|
||||
|
||||
_collect(root)
|
||||
|
||||
for name in reversed(names):
|
||||
try:
|
||||
doc.removeObject(name)
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_tree: could not remove {name!r}: {exc}\n"
|
||||
)
|
||||
1345
src/Mod/Create/silo_viewers.py
Normal file
113
src/Mod/Create/silo_viewproviders.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
silo_viewproviders.py - ViewProvider proxy for Silo tree leaf nodes.
|
||||
|
||||
Controls tree icon, double-click behavior, and context menu for
|
||||
SiloViewerObject nodes in the document tree.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Icon directory — Phase 6 will add SVGs here.
|
||||
_ICON_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"resources",
|
||||
"icons",
|
||||
)
|
||||
|
||||
# Map silo/ paths to icon basenames (without .svg extension).
|
||||
_SILO_PATH_ICONS = {
|
||||
"silo/manifest.json": "silo-manifest",
|
||||
"silo/metadata.json": "silo-metadata",
|
||||
"silo/history.json": "silo-history",
|
||||
"silo/approvals.json": "silo-approvals",
|
||||
"silo/dependencies.json": "silo-dependencies",
|
||||
}
|
||||
|
||||
# Prefix-based fallbacks for subdirectory entries.
|
||||
_SILO_PREFIX_ICONS = {
|
||||
"silo/jobs/": "silo-job",
|
||||
"silo/macros/": "silo-macro",
|
||||
}
|
||||
|
||||
|
||||
def _icon_for_path(silo_path):
|
||||
"""Return absolute icon path for a silo/ entry, or '' if not found."""
|
||||
name = _SILO_PATH_ICONS.get(silo_path)
|
||||
if name is None:
|
||||
for prefix, icon_name in _SILO_PREFIX_ICONS.items():
|
||||
if silo_path.startswith(prefix):
|
||||
name = icon_name
|
||||
break
|
||||
if name is None:
|
||||
return ""
|
||||
path = os.path.join(_ICON_DIR, f"{name}.svg")
|
||||
return path if os.path.exists(path) else ""
|
||||
|
||||
|
||||
class SiloViewerViewProvider:
|
||||
"""ViewProvider proxy for SiloViewerObject leaf nodes."""
|
||||
|
||||
def __init__(self, vobj):
|
||||
vobj.Proxy = self
|
||||
self.Object = vobj.Object
|
||||
|
||||
def attach(self, vobj):
|
||||
"""Store back-reference; called on document restore."""
|
||||
self.Object = vobj.Object
|
||||
|
||||
def getIcon(self):
|
||||
"""Return icon path based on SiloPath; '' uses FreeCAD default."""
|
||||
try:
|
||||
return _icon_for_path(self.Object.SiloPath)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def doubleClicked(self, vobj):
|
||||
"""Open a read-only MDI viewer for this silo node."""
|
||||
try:
|
||||
import FreeCADGui
|
||||
from PySide import QtWidgets
|
||||
from silo_viewers import create_viewer_widget
|
||||
|
||||
obj = vobj.Object
|
||||
widget = create_viewer_widget(obj)
|
||||
if widget is None:
|
||||
return False
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
mdi = mw.findChild(QtWidgets.QMdiArea)
|
||||
if mdi is None:
|
||||
return False
|
||||
|
||||
# Reuse existing subwindow if already open for this object
|
||||
target_name = widget.objectName()
|
||||
for sw in mdi.subWindowList():
|
||||
if sw.widget() and sw.widget().objectName() == target_name:
|
||||
widget.deleteLater()
|
||||
mdi.setActiveSubWindow(sw)
|
||||
sw.show()
|
||||
return True
|
||||
|
||||
sw = mdi.addSubWindow(widget)
|
||||
sw.setWindowTitle(getattr(widget, "WINDOW_TITLE", "Silo Viewer"))
|
||||
sw.show()
|
||||
mdi.setActiveSubWindow(sw)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"silo_viewproviders: doubleClicked failed: {exc}\n"
|
||||
)
|
||||
return False
|
||||
|
||||
def setupContextMenu(self, vobj, menu):
|
||||
"""Phase 1: no context menu items."""
|
||||
pass
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
pass
|
||||
@@ -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)
|
||||
|
||||
92
tests/src/Gui/BitmapFactory.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <Gui/BitmapFactory.h>
|
||||
|
||||
#include <src/App/InitApplication.h>
|
||||
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Minimal valid SVG used as test icon
|
||||
constexpr const char* kTestSvg =
|
||||
R"(<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64")"
|
||||
R"( viewBox="0 0 64 64"><rect width="64" height="64" fill="#ff0000"/></svg>)";
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
class BitmapFactoryTest : public ::testing::Test
|
||||
{
|
||||
protected:
|
||||
static void SetUpTestSuite()
|
||||
{
|
||||
tests::initApplication();
|
||||
|
||||
// QPixmap and QSvgRenderer require a QGuiApplication.
|
||||
// gtest_main does not create one, so we construct it here.
|
||||
if (!QApplication::instance()) {
|
||||
static int argc = 1;
|
||||
static char arg0[] = "Gui_tests_run";
|
||||
static char* argv[] = {arg0, nullptr};
|
||||
static QApplication app(argc, argv);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// pixmapFromSvg(content, size) renders at the exact requested size
|
||||
TEST_F(BitmapFactoryTest, PixmapFromSvgContentRendersAtRequestedSize)
|
||||
{
|
||||
QByteArray svg(kTestSvg);
|
||||
QSize requested(48, 48);
|
||||
QPixmap px = Gui::BitmapFactory().pixmapFromSvg(svg, requested);
|
||||
|
||||
ASSERT_FALSE(px.isNull());
|
||||
EXPECT_EQ(px.width(), 48);
|
||||
EXPECT_EQ(px.height(), 48);
|
||||
}
|
||||
|
||||
// getMaximumDPR returns at least 1.0
|
||||
TEST_F(BitmapFactoryTest, MaximumDPRIsAtLeastOne)
|
||||
{
|
||||
qreal dpr = Gui::BitmapFactoryInst::getMaximumDPR();
|
||||
EXPECT_GE(dpr, 1.0);
|
||||
}
|
||||
|
||||
// pixmap() loaded from an SVG file has correct devicePixelRatio and physical size
|
||||
TEST_F(BitmapFactoryTest, PixmapFromSvgFileHasCorrectDPR)
|
||||
{
|
||||
// Write a test SVG to a temporary directory
|
||||
QTemporaryDir tmpDir;
|
||||
ASSERT_TRUE(tmpDir.isValid());
|
||||
|
||||
QString svgPath = tmpDir.path() + QDir::separator() + "test-dpi-icon.svg";
|
||||
QFile file(svgPath);
|
||||
ASSERT_TRUE(file.open(QFile::WriteOnly | QFile::Text));
|
||||
file.write(kTestSvg);
|
||||
file.close();
|
||||
|
||||
// Add the temp dir as a search path and load via pixmap()
|
||||
Gui::BitmapFactory().addPath(tmpDir.path());
|
||||
QPixmap px = Gui::BitmapFactory().pixmap("test-dpi-icon");
|
||||
|
||||
ASSERT_FALSE(px.isNull());
|
||||
|
||||
qreal expectedDpr = Gui::BitmapFactoryInst::getMaximumDPR();
|
||||
EXPECT_DOUBLE_EQ(px.devicePixelRatio(), expectedDpr);
|
||||
|
||||
// Physical size should be 64 * dpr
|
||||
int expectedPhysical = static_cast<int>(64 * expectedDpr);
|
||||
EXPECT_EQ(px.width(), expectedPhysical);
|
||||
EXPECT_EQ(px.height(), expectedPhysical);
|
||||
|
||||
Gui::BitmapFactory().removePath(tmpDir.path());
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
# Standard C++ GTest tests
|
||||
add_executable(Gui_tests_run
|
||||
Assistant.cpp
|
||||
BitmapFactory.cpp
|
||||
Camera.cpp
|
||||
StyleParameters/StyleParametersApplicationTest.cpp
|
||||
StyleParameters/ParserTest.cpp
|
||||
|
||||
@@ -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
|
||||
|
||||
13
tests/src/Mod/Assembly/Solver/CMakeLists.txt
Normal 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
|
||||
)
|
||||
251
tests/src/Mod/Assembly/Solver/OndselAdapter.cpp
Normal 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);
|
||||
}
|
||||
131
tests/src/Mod/Assembly/Solver/SolverRegistry.cpp
Normal 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());
|
||||
}
|
||||
@@ -98,6 +98,7 @@ sys.modules["silo_client._ssl"] = mock.MagicMock()
|
||||
|
||||
# Add addon source paths
|
||||
sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create"))
|
||||
sys.path.insert(0, str(_REPO_ROOT / "mods" / "sdk"))
|
||||
sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools"))
|
||||
sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad"))
|
||||
|
||||
|
||||