Compare commits
1 Commits
test/plana
...
fix/submod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d28c54b54f |
@@ -322,7 +322,6 @@ jobs:
|
||||
|
||||
env:
|
||||
BUILD_TAG: ${{ github.ref_name || inputs.tag }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
steps:
|
||||
@@ -387,7 +386,6 @@ jobs:
|
||||
'name': f'Kindred Create {tag}',
|
||||
'body': body,
|
||||
'prerelease': prerelease,
|
||||
'target_commitish': '${COMMIT_SHA}',
|
||||
}))
|
||||
")
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -77,5 +77,3 @@ docs/book/
|
||||
|
||||
# To regenerate themed icons: python3 icons/retheme.py
|
||||
# icons/themed/ is tracked (committed) so CI builds include them
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
7
.gitmodules
vendored
7
.gitmodules
vendored
@@ -18,10 +18,3 @@
|
||||
path = mods/silo
|
||||
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
||||
branch = main
|
||||
[submodule "mods/solver"]
|
||||
path = mods/solver
|
||||
url = https://git.kindred-systems.com/kindred/solver.git
|
||||
branch = main
|
||||
[submodule "mods/quicknav"]
|
||||
path = mods/quicknav
|
||||
url = https://git.kindred-systems.com/kindred/quicknav.git
|
||||
|
||||
285
CLAUDE.md
285
CLAUDE.md
@@ -1,285 +0,0 @@
|
||||
# CLAUDE.md — Developer Context for Kindred Create
|
||||
|
||||
## Project Overview
|
||||
|
||||
Kindred Create is a fork of FreeCAD 1.0+ that adds integrated tooling for professional engineering workflows. It ships a context-aware UI system, two addon command sets (ztools and Silo), a Catppuccin Mocha dark theme, and a pluggable file origin layer on top of FreeCAD's parametric modeling core.
|
||||
|
||||
- **Kindred Create version:** 0.1.5
|
||||
- **FreeCAD base version:** 1.2.0
|
||||
- **License:** LGPL-2.1-or-later
|
||||
- **Repository:** `git.kindred-systems.com/kindred/create` (Gitea)
|
||||
- **Main branch:** `main`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone --recursive ssh://git@git.kindred-systems.com:2222/kindred/create.git
|
||||
cd create
|
||||
pixi run configure # CMake configure (debug by default)
|
||||
pixi run build # Build
|
||||
pixi run install # Install to build dir
|
||||
pixi run freecad # Launch
|
||||
pixi run test # Run C++ tests (ctest)
|
||||
pixi run test-kindred # Run Python/Kindred tests
|
||||
```
|
||||
|
||||
Build variants: append `-debug` or `-release` (e.g., `pixi run build-release`). See `CMakePresets.json` for platform-specific presets (Linux x86_64/aarch64, macOS Intel/ARM, Windows x64).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
create/
|
||||
├── src/
|
||||
│ ├── App/ Core application (C++)
|
||||
│ ├── Base/ Base classes, type system, persistence (C++)
|
||||
│ ├── Gui/ GUI framework (C++)
|
||||
│ │ ├── EditingContext.h Editing context resolver (Kindred feature)
|
||||
│ │ ├── BreadcrumbToolBar.h Breadcrumb navigation widget (Kindred feature)
|
||||
│ │ ├── FileOrigin.h Abstract origin interface (Kindred feature)
|
||||
│ │ ├── OriginManager.h Origin lifecycle management
|
||||
│ │ ├── CommandOrigin.cpp Origin_Commit/Pull/Push/Info/BOM commands
|
||||
│ │ ├── ApplicationPy.h All FreeCADGui.* Python bindings
|
||||
│ │ ├── Application.h App signals (fastsignals)
|
||||
│ │ ├── Stylesheets/ QSS theme files
|
||||
│ │ └── PreferencePacks/ Preference configurations (build-time generated)
|
||||
│ ├── Mod/ FreeCAD modules (PartDesign, Assembly, Sketcher, etc.)
|
||||
│ │ └── Create/ Kindred Create module
|
||||
│ │ ├── Init.py Console bootstrap — loads addons
|
||||
│ │ ├── InitGui.py GUI bootstrap — loads addons, Silo setup, update checker
|
||||
│ │ ├── addon_loader.py Manifest-driven loader with dependency resolution
|
||||
│ │ └── kc_format.py .kc file format preservation
|
||||
│ └── 3rdParty/ Vendored dependencies
|
||||
│ ├── OndselSolver/ [submodule] Assembly constraint solver (forked)
|
||||
│ ├── FastSignals/ Signal/slot library (NOT Boost)
|
||||
│ └── GSL/ [submodule] Microsoft Guidelines Support Library
|
||||
├── mods/ Kindred addon modules
|
||||
│ ├── sdk/ Addon SDK — stable API contract (priority 0)
|
||||
│ ├── ztools/ [submodule] Command provider (priority 50)
|
||||
│ ├── silo/ [submodule] PLM workbench (priority 60)
|
||||
│ ├── solver/ [submodule] Assembly solver research (GNN-based)
|
||||
│ └── quicknav/ [submodule] Navigation addon
|
||||
├── docs/ mdBook documentation + architecture docs
|
||||
├── tests/ C++ unit tests (GoogleTest)
|
||||
├── package/ Packaging (debian/, rattler-build/)
|
||||
├── resources/ Branding, icons, desktop integration
|
||||
├── cMake/ CMake helper modules
|
||||
├── .gitea/workflows/ CI/CD pipelines
|
||||
├── CMakeLists.txt Root build configuration (CMake 3.22.0+)
|
||||
├── CMakePresets.json Platform build presets
|
||||
└── pixi.toml Pixi environment and build tasks
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
- **Primary:** CMake 3.22.0+ with Ninja generator
|
||||
- **Environment:** [Pixi](https://pixi.sh) (conda-forge) manages all dependencies
|
||||
- **Key deps:** Qt 6.8.x, Python 3.11.x, OpenCASCADE 7.8.x, PySide6, Boost, VTK, SMESH
|
||||
- **Presets:** `conda-linux-debug`, `conda-linux-release`, `conda-macos-debug`, `conda-macos-release`, `conda-windows-debug`, `conda-windows-release`
|
||||
- **Tasks summary:**
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| `pixi run configure` | CMake configure (debug) |
|
||||
| `pixi run build` | Build (debug) |
|
||||
| `pixi run install` | Install to build dir |
|
||||
| `pixi run freecad` | Launch FreeCAD |
|
||||
| `pixi run test` | C++ tests via ctest |
|
||||
| `pixi run test-kindred` | Python/Kindred test suite |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Signals — Use FastSignals, NOT Boost
|
||||
|
||||
```cpp
|
||||
#include <fastsignals/signal.h>
|
||||
// See src/Gui/Application.h:121-155 for signal declarations
|
||||
```
|
||||
|
||||
All signals in `src/Gui/` use `fastsignals::signal`. Never use Boost.Signals2.
|
||||
|
||||
### Type Checking Across Modules
|
||||
|
||||
Avoid header dependencies between `src/Gui/` and `src/Mod/` by using runtime type checks:
|
||||
|
||||
```cpp
|
||||
auto type = Base::Type::fromName("Sketcher::SketchObject");
|
||||
if (obj->isDerivedFrom(type)) { ... }
|
||||
```
|
||||
|
||||
### Python Bindings
|
||||
|
||||
All `FreeCADGui.*` functions go in `src/Gui/ApplicationPy.h` and `src/Gui/ApplicationPy.cpp`. Use `METH_VARARGS` only (no `METH_KEYWORDS` in this file). Do not create separate files for new Python bindings.
|
||||
|
||||
### Toolbar Visibility
|
||||
|
||||
Use `ToolBarItem::DefaultVisibility::Unavailable` to hide toolbars by default, then `ToolBarManager::setState(ForceAvailable)` to show them contextually. This pattern is proven by the Sketcher module.
|
||||
|
||||
The `appendToolbar` Python API accepts an optional 3rd argument: `"Visible"`, `"Hidden"`, or `"Unavailable"`.
|
||||
|
||||
### Editing Context System
|
||||
|
||||
The `EditingContextResolver` singleton (`src/Gui/EditingContext.h/.cpp`) drives the context-aware UI. It evaluates registered context definitions in priority order and activates the matching one, setting toolbar visibility and updating the `BreadcrumbToolBar`.
|
||||
|
||||
Built-in contexts: `sketcher.edit`, `assembly.edit`, `partdesign.feature`, `partdesign.body`, `assembly.idle`, `spreadsheet`, `empty_document`, `no_document`.
|
||||
|
||||
Python API:
|
||||
- `FreeCADGui.registerEditingContext()` — register a new context
|
||||
- `FreeCADGui.registerEditingOverlay()` — conditional toolbar overlay
|
||||
- `FreeCADGui.injectEditingCommands()` — add commands to existing contexts
|
||||
- `FreeCADGui.currentEditingContext()` — query active context
|
||||
- `FreeCADGui.refreshEditingContext()` — force re-evaluation
|
||||
|
||||
### Addon Loading
|
||||
|
||||
Addons in `mods/` are loaded by `src/Mod/Create/addon_loader.py`. Each addon provides a `package.xml` with `<kindred>` extensions declaring version bounds, load priority, and dependencies. The loader resolves via topological sort: **sdk** (0) -> **ztools** (50) -> **silo** (60).
|
||||
|
||||
A `<workbench>` tag in `package.xml` is required for `InitGui.py` to be loaded, even if no actual workbench is registered.
|
||||
|
||||
### Deferred Initialization
|
||||
|
||||
GUI setup uses `QTimer.singleShot` with staggered delays:
|
||||
- 500ms: `.kc` file format registration
|
||||
- 1500ms: Silo origin registration
|
||||
- 2000ms: Auth dock + ztools commands
|
||||
- 2500ms: Silo overlay
|
||||
- 3000ms: Silo first-start check
|
||||
- 4000ms: Activity panel
|
||||
- 10000ms: Update checker
|
||||
|
||||
### Unified Origin System
|
||||
|
||||
File operations (New, Open, Save, Commit, Pull, Push) are abstracted behind `FileOrigin` (`src/Gui/FileOrigin.h`). `LocalFileOrigin` handles local files; `SiloOrigin` (`mods/silo/freecad/silo_origin.py`) backs Silo-tracked documents. The active origin is selected automatically based on document properties (`SiloItemId`, `SiloPartNumber`).
|
||||
|
||||
## Submodules
|
||||
|
||||
| Path | Repository | Branch | Purpose |
|
||||
|------|------------|--------|---------|
|
||||
| `mods/ztools` | `git.kindred-systems.com/forbes/ztools` | `main` | Extended PartDesign/Assembly/Spreadsheet tools |
|
||||
| `mods/silo` | `git.kindred-systems.com/kindred/silo-mod` | `main` | PLM workbench (includes silo-client submodule) |
|
||||
| `mods/solver` | `git.kindred-systems.com/kindred/solver` | `main` | Assembly solver research (GNN-based) |
|
||||
| `mods/quicknav` | `git.kindred-systems.com/kindred/quicknav` | — | Navigation addon |
|
||||
| `src/3rdParty/OndselSolver` | `git.kindred-systems.com/kindred/solver` | — | Constraint solver (forked with NR fix) |
|
||||
| `src/3rdParty/GSL` | `github.com/microsoft/GSL` | — | Guidelines Support Library |
|
||||
| `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | — | FreeCAD addon manager |
|
||||
| `tests/lib` | `github.com/google/googletest` | — | C++ test framework |
|
||||
|
||||
Update a submodule:
|
||||
```bash
|
||||
cd mods/silo
|
||||
git checkout main && git pull
|
||||
cd ../..
|
||||
git add mods/silo
|
||||
git commit -m "chore: update silo submodule"
|
||||
```
|
||||
|
||||
Initialize all submodules: `git submodule update --init --recursive`
|
||||
|
||||
## Key Addon Modules
|
||||
|
||||
### ztools (`mods/ztools/`)
|
||||
|
||||
Command provider (NOT a workbench). Injects tools into PartDesign, Assembly, and Spreadsheet contexts via `_ZToolsManipulator` (WorkbenchManipulator) and `injectEditingCommands()`.
|
||||
|
||||
Commands: `ZTools_DatumCreator`, `ZTools_EnhancedPocket`, `ZTools_RotatedLinearPattern`, `ZTools_AssemblyLinearPattern`, `ZTools_AssemblyPolarPattern`, spreadsheet formatting (Bold, Italic, Underline, alignment, colors, QuickAlias).
|
||||
|
||||
Source: `mods/ztools/ztools/ztools/commands/` (note the double `ztools` nesting).
|
||||
|
||||
### Silo (`mods/silo/`)
|
||||
|
||||
PLM workbench with 14 commands for parts lifecycle management. Go REST API server + PostgreSQL + MinIO backend. FreeCAD client communicates via shared `silo-client` submodule.
|
||||
|
||||
Silo origin detection: `silo_origin.py:ownsDocument()` checks for `SiloItemId`/`SiloPartNumber` properties on the active document.
|
||||
|
||||
### SDK (`mods/sdk/`)
|
||||
|
||||
Stable API contract for addons. Provides wrappers for editing contexts, theme tokens (Catppuccin Mocha YAML palette), FileOrigin registration, and deferred dock panels. Addons should use `kindred_sdk.*` instead of `FreeCADGui.*` internals where possible.
|
||||
|
||||
## Theme
|
||||
|
||||
- **Canonical source:** `src/Gui/Stylesheets/KindredCreate.qss`
|
||||
- The PreferencePacks copy at `src/Gui/PreferencePacks/KindredCreate/KindredCreate.qss` is **generated at build time** via `configure_file()`. Only edit the Stylesheets copy.
|
||||
- Color palette: Catppuccin Mocha (26 colors + 14 semantic roles, defined in `mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml`)
|
||||
- Default preferences: `src/Gui/PreferencePacks/KindredCreate/KindredCreate.cfg`
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branch Names
|
||||
|
||||
`type/kebab-case-description`
|
||||
|
||||
Types: `feat/`, `fix/`, `chore/`, `docs/`, `refactor/`, `art/`
|
||||
|
||||
### Commit Messages
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
type(scope): lowercase imperative description
|
||||
```
|
||||
|
||||
| Prefix | Purpose |
|
||||
|--------|---------|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `chore:` | Maintenance, dependencies |
|
||||
| `docs:` | Documentation only |
|
||||
| `art:` | Icons, theme, visual assets |
|
||||
| `refactor:` | Code restructuring |
|
||||
|
||||
Scopes: `solver`, `sketcher`, `editing-context`, `toolbar`, `ztools`, `silo`, `breadcrumb`, `gui`, `assembly`, `ci`, `theme`, `quicknav`, or omitted.
|
||||
|
||||
### PR Workflow
|
||||
|
||||
1. Create a branch from `main`: `git checkout -b feat/my-feature main`
|
||||
2. Commit with conventional commit messages
|
||||
3. Push and open a PR against `main` via Gitea (or `tea pulls create`)
|
||||
4. CI runs automatically on PRs
|
||||
|
||||
### Code Style
|
||||
|
||||
- **C++:** clang-format (`.clang-format`), clang-tidy (`.clang-tidy`)
|
||||
- **Python:** black (100-char line length), pylint (`.pylintrc`)
|
||||
- **Pre-commit hooks:** `pre-commit install` (runs clang-format, black, trailing-whitespace, etc.)
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Build:** `.gitea/workflows/build.yml` — runs on pushes to `main` and on PRs
|
||||
- **Release:** `.gitea/workflows/release.yml` — triggered by `v*` tags, builds AppImage and .deb
|
||||
- **Platform:** Currently Linux x86_64 only in CI; other platforms have presets but no runners yet
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| `README.md` | Project overview, installation, usage |
|
||||
| `CONTRIBUTING.md` | Branch workflow, commit conventions, code style |
|
||||
| `docs/ARCHITECTURE.md` | Bootstrap flow, addon lifecycle, source layout |
|
||||
| `docs/COMPONENTS.md` | Feature inventory (ztools, Silo, origin, theme, icons) |
|
||||
| `docs/KNOWN_ISSUES.md` | Known issues, incomplete features, next steps |
|
||||
| `docs/INTEGRATION_PLAN.md` | 5-layer architecture, phase status |
|
||||
| `docs/CI_CD.md` | Build and release workflows |
|
||||
| `docs/KC_SPECIFICATION.md` | .kc file format specification |
|
||||
| `docs/UPSTREAM.md` | FreeCAD upstream merge strategy |
|
||||
| `docs/INTER_SOLVER.md` | Assembly solver integration |
|
||||
| `docs/BOM_MERGE.md` | BOM-Assembly bridge specification |
|
||||
|
||||
The `docs/src/` directory contains an mdBook site with detailed guides organized by topic (architecture, development, guide, reference, silo-server, solver).
|
||||
|
||||
## Issue Tracker
|
||||
|
||||
Issues are tracked on Gitea at `git.kindred-systems.com/kindred/create/issues`. Use the `tea` CLI for local interaction:
|
||||
|
||||
```bash
|
||||
tea issues # List open issues
|
||||
tea issues 123 # View issue #123 details
|
||||
tea pulls create # Create a PR
|
||||
```
|
||||
|
||||
## Known Issues and Pitfalls
|
||||
|
||||
1. **Silo auth not production-hardened** — LDAP/OIDC backends are coded but need infrastructure deployment
|
||||
2. **No unit tests** for ztools/Silo FreeCAD commands or Go backend
|
||||
3. **Assembly solver datum handling is minimal** — joints referencing datum planes/points may produce incorrect placement
|
||||
4. **PartDesign menu insertion fragility** — `_ZToolsPartDesignManipulator.modifyMenuBar()` inserts after `PartDesign_Boolean`; upstream renames break silently
|
||||
5. **`Silo_BOM` requires Silo-tracked document** — unregistered documents show a warning with no registration path
|
||||
6. **QSS edits** — only edit `src/Gui/Stylesheets/KindredCreate.qss`; the PreferencePacks copy is auto-generated
|
||||
@@ -53,7 +53,7 @@ project(KindredCreate)
|
||||
# Kindred Create version
|
||||
set(KINDRED_CREATE_VERSION_MAJOR "0")
|
||||
set(KINDRED_CREATE_VERSION_MINOR "1")
|
||||
set(KINDRED_CREATE_VERSION_PATCH "5")
|
||||
set(KINDRED_CREATE_VERSION_PATCH "0")
|
||||
set(KINDRED_CREATE_VERSION "${KINDRED_CREATE_VERSION_MAJOR}.${KINDRED_CREATE_VERSION_MINOR}.${KINDRED_CREATE_VERSION_PATCH}")
|
||||
|
||||
# Underlying FreeCAD version
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**An engineering-focused parametric 3D CAD platform built on FreeCAD 1.0+**
|
||||
|
||||
Kindred Create 0.1.5 | FreeCAD 1.2.0 base
|
||||
Kindred Create 0.1.0 | FreeCAD 1.2.0 base
|
||||
|
||||
[Website](https://www.kindred-systems.com/create) |
|
||||
[Downloads](https://git.kindred-systems.com/kindred/create/releases) |
|
||||
|
||||
@@ -1,568 +0,0 @@
|
||||
# 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
|
||||
@@ -12,7 +12,6 @@
|
||||
- [Workbenches](./guide/workbenches.md)
|
||||
- [ztools](./guide/ztools.md)
|
||||
- [Silo](./guide/silo.md)
|
||||
- [Document Templates](./guide/templates.md)
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -20,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)
|
||||
- [KCSolve: Pluggable Solver](./architecture/ondsel-solver.md)
|
||||
- [OndselSolver](./architecture/ondsel-solver.md)
|
||||
|
||||
# Development
|
||||
|
||||
@@ -47,19 +46,8 @@
|
||||
- [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)
|
||||
|
||||
# Kindred Solver
|
||||
|
||||
- [Overview](./solver/overview.md)
|
||||
- [Expression DAG](./solver/expression-dag.md)
|
||||
- [Constraints](./solver/constraints.md)
|
||||
- [Solving Algorithms](./solver/solving.md)
|
||||
- [Diagnostics](./solver/diagnostics.md)
|
||||
- [Assembly Integration](./solver/assembly-integration.md)
|
||||
- [Writing a Custom Solver](./solver/writing-a-solver.md)
|
||||
|
||||
# Reference
|
||||
|
||||
- [Configuration](./reference/configuration.md)
|
||||
@@ -76,4 +64,3 @@
|
||||
- [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,132 +1,27 @@
|
||||
# KCSolve: Pluggable Solver Architecture
|
||||
# OndselSolver
|
||||
|
||||
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`.
|
||||
OndselSolver is the assembly constraint solver used by FreeCAD's Assembly workbench. Kindred Create vendors a fork of the solver as a git submodule.
|
||||
|
||||
- **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)
|
||||
- **Path:** `src/3rdParty/OndselSolver/`
|
||||
- **Source:** `git.kindred-systems.com/kindred/solver` (Kindred fork)
|
||||
|
||||
## Architecture
|
||||
## How it works
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 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 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.
|
||||
|
||||
The Assembly module never references OndselSolver directly. All solver access goes through `SolverRegistry::instance().get()`, which returns a `std::unique_ptr<IKCSolver>`.
|
||||
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.
|
||||
|
||||
## IKCSolver interface
|
||||
## Why a fork
|
||||
|
||||
A solver backend implements `IKCSolver` (defined in `IKCSolver.h`). Only three methods are pure virtual; all others have sensible defaults:
|
||||
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
|
||||
|
||||
| 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 |
|
||||
## Future: GNN solver
|
||||
|
||||
## Core types
|
||||
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.
|
||||
|
||||
All types live in `Types.h` with no FreeCAD dependencies, making the header standalone for future server/worker use.
|
||||
## Related: GSL
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
@@ -13,7 +13,7 @@ Kindred Create uses **CMake** for build configuration, **pixi** (conda-based) fo
|
||||
## CMake configuration
|
||||
|
||||
The root `CMakeLists.txt` defines:
|
||||
- **Kindred Create version:** `0.1.5` (via `KINDRED_CREATE_VERSION`)
|
||||
- **Kindred Create version:** `0.1.0` (via `KINDRED_CREATE_VERSION`)
|
||||
- **FreeCAD base version:** `1.0.0` (via `FREECAD_VERSION`)
|
||||
- CMake policy settings for compatibility
|
||||
- ccache auto-detection
|
||||
@@ -25,7 +25,7 @@ The root `CMakeLists.txt` defines:
|
||||
The version flows from CMake to Python via `configure_file()`:
|
||||
|
||||
```
|
||||
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.5")
|
||||
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.0")
|
||||
→ src/Mod/Create/version.py.in (template)
|
||||
→ build/*/Mod/Create/version.py (generated)
|
||||
→ update_checker.py (imports VERSION)
|
||||
|
||||
@@ -157,7 +157,7 @@ Edit only the canonical file in `Stylesheets/` — the preference pack copy is g
|
||||
Defined in the top-level `CMakeLists.txt` and injected as compiler definitions:
|
||||
|
||||
```cmake
|
||||
set(KINDRED_CREATE_VERSION "0.1.5")
|
||||
set(KINDRED_CREATE_VERSION "0.1.0")
|
||||
set(FREECAD_VERSION "1.0.0")
|
||||
|
||||
add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}")
|
||||
|
||||
@@ -53,7 +53,6 @@ The silo-mod repository was split from a monorepo into three repos: `silo-client
|
||||
| `Silo_TagProjects` | Multi-select dialog for assigning project tags to items |
|
||||
| `Silo_Rollback` | Select a previous revision and create a new revision from that point with optional comment |
|
||||
| `Silo_SetStatus` | Change revision lifecycle status: draft → review → released → obsolete |
|
||||
| `Silo_SaveAsTemplate` | Save a copy of the current document as a reusable [template](./templates.md) with metadata |
|
||||
|
||||
### Administration
|
||||
|
||||
@@ -130,11 +129,9 @@ mods/silo/
|
||||
├── freecad/
|
||||
│ ├── InitGui.py # SiloWorkbench registration
|
||||
│ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog)
|
||||
│ ├── silo_commands.py # 15 commands + dock widgets
|
||||
│ ├── silo_commands.py # 14 commands + dock widgets
|
||||
│ ├── silo_origin.py # FileOrigin backend
|
||||
│ ├── silo_start.py # Native start panel (database items, activity feed)
|
||||
│ ├── templates.py # Template discovery, filtering, injection
|
||||
│ ├── templates/ # System template .kc files + CLI injection tool
|
||||
│ └── resources/icons/ # 10 silo-*.svg icons
|
||||
├── silo-client/ # Shared Python API client (nested submodule)
|
||||
│ └── silo_client/
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Document Templates
|
||||
|
||||
Templates let you create new parts and assemblies from pre-configured `.kc` files. Instead of starting from a bare `App::Part` or `Assembly::AssemblyObject`, a template can include predefined tree structures, jobs, metadata, and workbench-specific features.
|
||||
|
||||
## How templates work
|
||||
|
||||
A template is a normal `.kc` file with an extra `silo/template.json` descriptor inside the ZIP archive. When you select a template during **Silo > New**:
|
||||
|
||||
1. The template `.kc` is **copied** to the canonical file path
|
||||
2. `silo/template.json` and `silo/manifest.json` are **stripped** from the copy
|
||||
3. The document is **opened** in FreeCAD
|
||||
4. Silo properties (part number, item ID, revision, type) are **stamped** onto the root object
|
||||
5. On **save**, `kc_format.py` auto-creates a fresh manifest
|
||||
|
||||
The original template file is never modified.
|
||||
|
||||
## Using templates
|
||||
|
||||
### Creating a new item from a template
|
||||
|
||||
1. **Silo > New** (Ctrl+N)
|
||||
2. Select an **Item Type** (Part, Assembly, etc.)
|
||||
3. The **Template** dropdown shows templates matching the selected type and category
|
||||
4. Select a template (or leave as "No template" for a blank document)
|
||||
5. Fill in the remaining fields and click **Create**
|
||||
|
||||
The template combo updates automatically when you change the item type or category.
|
||||
|
||||
### Saving a document as a template
|
||||
|
||||
1. Open the document you want to use as a template
|
||||
2. **Silo > Save as Template**
|
||||
3. Fill in the template metadata:
|
||||
- **Name** — display name shown in the template picker (pre-filled from document label)
|
||||
- **Description** — what the template is for
|
||||
- **Item Types** — which types this template applies to (part, assembly, etc.)
|
||||
- **Categories** — category prefix filter (e.g. `F`, `M01`); leave empty for all categories
|
||||
- **Author** — pre-filled from your Silo login
|
||||
- **Tags** — comma-separated search tags
|
||||
4. Click **Save Template**
|
||||
5. Optionally upload to Silo for team sharing
|
||||
|
||||
The template is saved as a copy to your personal templates directory. The original document is unchanged.
|
||||
|
||||
## Template search paths
|
||||
|
||||
Templates are discovered from three locations, checked in order. Later paths shadow earlier ones by name (so you can override a system template with a personal one).
|
||||
|
||||
| Priority | Path | Purpose |
|
||||
|----------|------|---------|
|
||||
| 1 (lowest) | `mods/silo/freecad/templates/` | System templates shipped with the addon |
|
||||
| 2 | `~/.local/share/FreeCAD/Templates/` | Personal templates (sister to `Macro/`) |
|
||||
| 3 (highest) | `~/projects/templates/` | Org-shared project templates |
|
||||
|
||||
The personal templates directory (`Templates/`) is created automatically when you first save a template. It lives alongside the `Macro/` directory in your FreeCAD user data.
|
||||
|
||||
## Template descriptor schema
|
||||
|
||||
The `silo/template.json` file inside the `.kc` ZIP has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"template_version": "1.0",
|
||||
"name": "Sheet Metal Part",
|
||||
"description": "Body with SheetMetal base feature and laser-cut job",
|
||||
"item_types": ["part"],
|
||||
"categories": [],
|
||||
"icon": "sheet-metal",
|
||||
"author": "Kindred Systems",
|
||||
"tags": ["sheet metal", "fabrication"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `template_version` | string | yes | Schema version, currently `"1.0"` |
|
||||
| `name` | string | yes | Display name in the template picker |
|
||||
| `description` | string | no | Human-readable purpose |
|
||||
| `item_types` | string[] | yes | Controls visibility — `["part"]`, `["assembly"]`, or both |
|
||||
| `categories` | string[] | no | Category prefix filter. Empty array means all categories |
|
||||
| `icon` | string | no | Icon identifier (reserved for future use) |
|
||||
| `author` | string | no | Template author |
|
||||
| `tags` | string[] | no | Searchable metadata tags |
|
||||
|
||||
### Filtering rules
|
||||
|
||||
- **item_types**: The template only appears when the selected item type is in this list
|
||||
- **categories**: If non-empty, the template only appears when the selected category starts with one of the listed prefixes. An empty list means the template is available for all categories
|
||||
|
||||
## Creating templates from the command line
|
||||
|
||||
The `inject_template.py` CLI tool can inject `silo/template.json` into any `.kc` file:
|
||||
|
||||
```bash
|
||||
cd mods/silo/freecad/templates/
|
||||
|
||||
# Create a template from an existing .kc file
|
||||
python inject_template.py my-part.kc "My Custom Part" \
|
||||
--type part \
|
||||
--description "Part with custom features" \
|
||||
--author "Your Name" \
|
||||
--tag "custom"
|
||||
|
||||
# Assembly template
|
||||
python inject_template.py my-assembly.kc "My Assembly" \
|
||||
--type assembly \
|
||||
--description "Assembly with predefined joint groups"
|
||||
|
||||
# Template with category filtering
|
||||
python inject_template.py sheet-metal.kc "Sheet Metal Part" \
|
||||
--type part \
|
||||
--category S \
|
||||
--category X \
|
||||
--tag "sheet metal" \
|
||||
--tag "fabrication"
|
||||
```
|
||||
|
||||
## Module structure
|
||||
|
||||
```
|
||||
mods/silo/freecad/
|
||||
├── templates.py # Discovery, filtering, injection helpers
|
||||
├── templates/
|
||||
│ └── inject_template.py # CLI tool for injecting template.json
|
||||
├── schema_form.py # Template combo in New Item form
|
||||
└── silo_commands.py # SaveAsTemplateDialog, Silo_SaveAsTemplate,
|
||||
# SiloSync.create_document_from_template()
|
||||
```
|
||||
|
||||
### Key functions
|
||||
|
||||
| Function | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| `discover_templates()` | `templates.py` | Scan search paths for `.kc` files with `silo/template.json` |
|
||||
| `filter_templates()` | `templates.py` | Filter by item type and category prefix |
|
||||
| `inject_template_json()` | `templates.py` | Inject/replace `silo/template.json` in a `.kc` ZIP |
|
||||
| `get_default_template_dir()` | `templates.py` | Returns `{userAppData}/Templates/`, creating if needed |
|
||||
| `get_search_paths()` | `templates.py` | Returns the 3-tier search path list |
|
||||
| `create_document_from_template()` | `silo_commands.py` | Copy template, strip identity, stamp Silo properties |
|
||||
| `_clean_template_zip()` | `silo_commands.py` | Strip `silo/template.json` and `silo/manifest.json` from a copy |
|
||||
@@ -1,441 +0,0 @@
|
||||
# QuickNav — Keyboard Navigation Addon Specification
|
||||
|
||||
**Addon name:** QuickNav
|
||||
**Type:** Pure Python FreeCAD addon (no C++ required)
|
||||
**Compatibility:** FreeCAD 1.0+, Kindred Create 0.1+
|
||||
**Location:** `mods/quicknav/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
QuickNav provides keyboard-driven command access for FreeCAD and Kindred Create. It replaces mouse-heavy toolbar navigation with a numbered key system organized by workbench and command grouping. The addon is activated by loading its workbench and toggled on/off with the `0` key.
|
||||
|
||||
### Design Goals
|
||||
|
||||
- Numbers `1-9` execute commands within the active command grouping
|
||||
- `Shift+1-9` switches command grouping within the active workbench
|
||||
- `Ctrl+1-9` switches workbench context
|
||||
- All groupings and workbenches are ordered by most-recently-used (MRU) history
|
||||
- History is unlimited internally, top 9 shown, remainder scrollable/clickable
|
||||
- Mouse interaction remains fully functional — QuickNav is purely additive
|
||||
- Configuration persisted via `FreeCAD.ParamGet()`
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **Workbench** | A FreeCAD workbench (Sketcher, PartDesign, Assembly, etc.). Fixed assignment to Ctrl+N slots. |
|
||||
| **Command Grouping** | A logical group of commands within a workbench, mapped from existing FreeCAD toolbar groupings. Max 9 per tier. |
|
||||
| **Active Grouping** | The left-most visible grouping in the navigation bar. Its commands are accessible via `1-9`. |
|
||||
| **Navigation Bar** | Bottom toolbar displaying the current state: active workbench, groupings, and numbered commands. |
|
||||
| **MRU Stack** | Most-recently-used ordering. Position 0 = currently active, 1 = previously active, etc. |
|
||||
| **Tier** | When a workbench has >9 command groupings, they are split: Tier 1 (most common 9), Tier 2 (next 9). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Bindings
|
||||
|
||||
### 3.1 Mode Toggle
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `0` | Toggle QuickNav on/off. When off, all QuickNav key interception is disabled and the navigation bar hides. |
|
||||
|
||||
### 3.2 Command Execution
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `1-9` | Execute the Nth command in the active grouping. If the command is auto-executable (e.g., Pad after closed sketch), execute immediately. Otherwise, enter tool mode (same as clicking the toolbar button). |
|
||||
|
||||
### 3.3 Grouping Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Shift+1-9` | Switch to the Nth command grouping (MRU ordered) within the current workbench. The newly activated grouping moves to position 0 in the MRU stack. |
|
||||
| `Shift+Left/Right` | Scroll through groupings beyond the visible 9. |
|
||||
|
||||
### 3.4 Workbench Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl+1` | Sketcher |
|
||||
| `Ctrl+2` | Part Design |
|
||||
| `Ctrl+3` | Assembly |
|
||||
| `Ctrl+4` | Spreadsheet |
|
||||
| `Ctrl+5` | TechDraw |
|
||||
| `Ctrl+6-9` | User-configurable / additional workbenches |
|
||||
|
||||
Switching workbench via `Ctrl+N` also restores that workbench's last-active command grouping.
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation Bar
|
||||
|
||||
The navigation bar is a `QToolBar` positioned at the bottom of the main window (replacing or sitting alongside FreeCAD's default bottom toolbar area).
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ [WB: Sketcher] │ ❶ Primitives │ ② Constraints │ ③ Dimensions │ ◀▶ │
|
||||
│ │ 1:Line 2:Rect 3:Circle 4:Arc 5:Point 6:Slot ... │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left section:** Current workbench name with Ctrl+N hint
|
||||
- **Middle section (top row):** Command groupings, MRU ordered. Active grouping is ❶ (filled circle), others are ②③ etc. Scrollable horizontally if >9.
|
||||
- **Middle section (bottom row):** Commands within the active grouping, numbered 1-9
|
||||
- **Right section:** Scroll arrows for overflow groupings
|
||||
|
||||
### 4.2 Visual States
|
||||
|
||||
- **Active grouping:** Bold text, filled number badge, Catppuccin Mocha `blue` (#89b4fa) accent
|
||||
- **Inactive groupings:** Normal text, outlined number badge, `surface1` (#45475a) text
|
||||
- **Hovered command:** `surface2` (#585b70) background highlight
|
||||
- **Active command (tool in use):** `green` (#a6e3a1) underline indicator
|
||||
|
||||
### 4.3 Mouse Interaction
|
||||
|
||||
- Click any grouping to activate it (equivalent to Shift+N)
|
||||
- Click any command to execute it (equivalent to pressing N)
|
||||
- Scroll wheel on grouping area to cycle through overflow groupings
|
||||
- Click scroll arrows to page through overflow
|
||||
|
||||
---
|
||||
|
||||
## 5. Workbench Command Groupings
|
||||
|
||||
Each workbench's existing FreeCAD toolbars map to command groupings. Where a workbench has >9 toolbars, split into Tier 1 (default, most common) and Tier 2 (accessible via scrolling or `Shift+Left/Right`).
|
||||
|
||||
### 5.1 Sketcher (Ctrl+1)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Primitives | Line, Rectangle, Circle, Arc, Point, Slot, B-Spline, Polyline, Ellipse |
|
||||
| Constraints | Coincident, Horizontal, Vertical, Parallel, Perpendicular, Tangent, Equal, Symmetric, Block |
|
||||
| Dimensions | Distance, Horizontal Distance, Vertical Distance, Radius, Diameter, Angle, Lock, Constrain Refraction |
|
||||
| Construction | Toggle Construction, External Geometry, Carbon Copy, Offset, Trim, Extend, Split |
|
||||
| Tools | Mirror, Array (Linear), Array (Polar), Move, Rotate, Scale, Close Shape, Connect Edges |
|
||||
|
||||
### 5.2 Part Design (Ctrl+2)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Additive | Pad, Revolution, Additive Loft, Additive Pipe, Additive Helix, Additive Box, Additive Cylinder, Additive Sphere, Additive Cone |
|
||||
| Subtractive | Pocket, Hole, Groove, Subtractive Loft, Subtractive Pipe, Subtractive Helix, Subtractive Box, Subtractive Cylinder, Subtractive Sphere |
|
||||
| Datums | New Sketch, Datum Plane, Datum Line, Datum Point, Shape Binder, Sub-Shape Binder, ZTools Datum Creator, ZTools Datum Manager |
|
||||
| Transformations | Mirrored, Linear Pattern, Polar Pattern, MultiTransform, ZTools Rotated Linear Pattern |
|
||||
| Modeling | Fillet, Chamfer, Draft, Thickness, Boolean, ZTools Enhanced Pocket |
|
||||
|
||||
### 5.3 Assembly (Ctrl+3)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Components | Insert Component, Create Part, Create Assembly, Ground, BOM |
|
||||
| Joints | Fixed, Revolute, Cylindrical, Slider, Ball, Planar, Distance, Angle, Parallel |
|
||||
| Patterns | ZTools Linear Pattern, ZTools Polar Pattern |
|
||||
|
||||
### 5.4 Spreadsheet (Ctrl+4)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Editing | Merge Cells, Split Cell, Alias, Import CSV, Export CSV |
|
||||
| Formatting | Bold, Italic, Underline, Align Left, Align Center, Align Right, BG Color, Text Color, Quick Alias |
|
||||
|
||||
### 5.5 TechDraw (Ctrl+5)
|
||||
|
||||
Groupings derived from TechDraw's existing toolbars at runtime.
|
||||
|
||||
> **Note:** The exact command lists above are initial defaults. The addon discovers available commands from each workbench's toolbar structure at activation time and falls back to these defaults only if discovery fails.
|
||||
|
||||
---
|
||||
|
||||
## 6. MRU History Behavior
|
||||
|
||||
### 6.1 Grouping History (per workbench)
|
||||
|
||||
Each workbench maintains its own grouping MRU stack.
|
||||
|
||||
- When a grouping is activated (via `Shift+N` or mouse click), it moves to position 0
|
||||
- The previously active grouping moves to position 1, everything else shifts down
|
||||
- Position 0 is always the active grouping (already selected, shown leftmost)
|
||||
- `Shift+1` is a no-op (already active), `Shift+2` activates the previous grouping, etc.
|
||||
|
||||
### 6.2 Workbench History
|
||||
|
||||
- Workbenches have fixed Ctrl+N assignments (not MRU ordered)
|
||||
- However, each workbench remembers its last-active grouping
|
||||
- Switching to a workbench restores its last-active grouping as position 0
|
||||
|
||||
### 6.3 Persistence
|
||||
|
||||
Stored in `FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/QuickNav")`:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `Enabled` | Bool | Whether QuickNav is currently active |
|
||||
| `GroupHistory/<Workbench>` | String | Semicolon-delimited list of grouping names in MRU order |
|
||||
| `LastGrouping/<Workbench>` | String | Name of the last-active grouping per workbench |
|
||||
| `CustomSlots/Ctrl6` through `Ctrl9` | String | Workbench names for user-configurable slots |
|
||||
|
||||
---
|
||||
|
||||
## 7. Auto-Execution Logic
|
||||
|
||||
When a command is invoked via number key, QuickNav checks if the command can be auto-executed:
|
||||
|
||||
### 7.1 Auto-Execute Conditions
|
||||
|
||||
A command auto-executes (runs and completes without entering a persistent mode) when:
|
||||
|
||||
1. **Pad/Pocket after closed sketch:** If the active body has a sketch that was just closed (sketch edit mode exited with a closed profile), pressing the Pad or Pocket command key creates the feature with default parameters. The task panel still opens for parameter adjustment.
|
||||
2. **Boolean operations:** If exactly two bodies/shapes are selected, boolean commands execute with defaults.
|
||||
3. **Constraint application:** If appropriate geometry is pre-selected in Sketcher, constraint commands apply immediately.
|
||||
|
||||
### 7.2 Mode-Entry (Default)
|
||||
|
||||
All other commands enter their standard FreeCAD tool mode — identical to clicking the toolbar button. The user interacts with the 3D view and/or task panel as normal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Event Handling
|
||||
|
||||
### 8.1 Event Filter Architecture
|
||||
|
||||
```python
|
||||
class QuickNavEventFilter(QObject):
|
||||
"""Installed on FreeCAD's main window via installEventFilter().
|
||||
|
||||
Intercepts KeyPress events when QuickNav is active.
|
||||
Passes through all events when QuickNav is inactive.
|
||||
"""
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() != QEvent.KeyPress:
|
||||
return False
|
||||
if not self._active:
|
||||
return False
|
||||
|
||||
# Don't intercept when a text input widget has focus
|
||||
focused = QApplication.focusWidget()
|
||||
if isinstance(focused, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox)):
|
||||
return False
|
||||
|
||||
# Don't intercept when task panel input fields are focused
|
||||
if self._is_task_panel_input(focused):
|
||||
return False
|
||||
|
||||
key = event.key()
|
||||
modifiers = event.modifiers()
|
||||
|
||||
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
|
||||
self.toggle_active()
|
||||
return True
|
||||
|
||||
if key >= Qt.Key_1 and key <= Qt.Key_9:
|
||||
n = key - Qt.Key_0
|
||||
if modifiers == Qt.ControlModifier:
|
||||
self.switch_workbench(n)
|
||||
return True
|
||||
elif modifiers == Qt.ShiftModifier:
|
||||
self.switch_grouping(n)
|
||||
return True
|
||||
elif modifiers == Qt.NoModifier:
|
||||
self.execute_command(n)
|
||||
return True
|
||||
|
||||
return False # Pass through all other keys
|
||||
```
|
||||
|
||||
### 8.2 Conflict Resolution
|
||||
|
||||
QuickNav's event filter takes priority when active. FreeCAD's existing keybindings for `Ctrl+1` through `Ctrl+9` (if any) are overridden while QuickNav is enabled. The original bindings are restored when QuickNav is toggled off or unloaded.
|
||||
|
||||
Existing `Shift+` and bare number key bindings in FreeCAD are similarly overridden only while QuickNav is active. This is safe because:
|
||||
- FreeCAD does not use bare number keys as shortcuts by default
|
||||
- Shift+number is not commonly bound in default FreeCAD
|
||||
|
||||
### 8.3 Input Widget Safety
|
||||
|
||||
The event filter must NOT intercept keys when the user is:
|
||||
- Typing in the Python console
|
||||
- Entering values in the task panel (dimensions, parameters)
|
||||
- Editing spreadsheet cells
|
||||
- Typing in any `QLineEdit`, `QTextEdit`, `QSpinBox`, or `QDoubleSpinBox`
|
||||
- Using the Sketcher's inline dimension input
|
||||
|
||||
---
|
||||
|
||||
## 9. Addon Structure
|
||||
|
||||
```
|
||||
mods/quicknav/
|
||||
├── package.xml # FreeCAD addon manifest with <kindred> extension
|
||||
├── Init.py # Non-GUI initialization (no-op)
|
||||
├── InitGui.py # Registers QuickNavWorkbench
|
||||
├── quicknav/
|
||||
│ ├── __init__.py
|
||||
│ ├── core.py # QuickNavManager singleton — orchestrates state
|
||||
│ ├── event_filter.py # QuickNavEventFilter (QObject)
|
||||
│ ├── nav_bar.py # NavigationBar (QToolBar subclass)
|
||||
│ ├── workbench_map.py # Fixed workbench → Ctrl+N mapping + grouping discovery
|
||||
│ ├── history.py # MRU stack with ParamGet persistence
|
||||
│ ├── auto_exec.py # Auto-execution condition checks
|
||||
│ ├── commands.py # FreeCAD command wrappers (QuickNav_Toggle, etc.)
|
||||
│ └── resources/
|
||||
│ ├── icons/ # Number badge SVGs, QuickNav icon
|
||||
│ └── theme.py # Catppuccin Mocha color tokens
|
||||
└── tests/
|
||||
└── test_history.py # MRU stack unit tests
|
||||
```
|
||||
|
||||
### 9.1 Manifest
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1">
|
||||
<name>QuickNav</name>
|
||||
<description>Keyboard-driven toolbar navigation</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
|
||||
<license>LGPL-2.1</license>
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>QuickNavWorkbench</classname>
|
||||
</workbench>
|
||||
</content>
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>10</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
</package>
|
||||
```
|
||||
|
||||
### 9.2 Activation
|
||||
|
||||
QuickNav activates when its workbench is loaded (via the addon loader or manual activation). It installs the event filter on the main window and creates the navigation bar. The workbench itself is invisible — it does not add its own toolbars or menus beyond the navigation bar. It acts as a transparent overlay on whatever workbench the user is actually working in.
|
||||
|
||||
```python
|
||||
class QuickNavWorkbench(Gui.Workbench):
|
||||
"""Invisible workbench that installs QuickNav on load.
|
||||
|
||||
QuickNav doesn't replace the active workbench — it layers on top.
|
||||
Loading QuickNav installs the event filter and nav bar, then
|
||||
immediately re-activates the previously active workbench.
|
||||
"""
|
||||
|
||||
def Initialize(self):
|
||||
QuickNavManager.instance().install()
|
||||
|
||||
def Activated(self):
|
||||
# Re-activate the previous workbench so QuickNav is transparent
|
||||
prev = QuickNavManager.instance().previous_workbench
|
||||
if prev:
|
||||
Gui.activateWorkbench(prev)
|
||||
|
||||
def Deactivated(self):
|
||||
pass
|
||||
|
||||
def GetClassName(self):
|
||||
return "Gui::PythonWorkbench"
|
||||
```
|
||||
|
||||
**Alternative (preferred for Create):** Instead of a workbench, QuickNav can be activated directly from `Create/InitGui.py` at boot, gated by the `Enabled` preference. This avoids the workbench-switching dance entirely. The `QuickNavWorkbench` registration is kept for standalone FreeCAD compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 10. Command Discovery
|
||||
|
||||
At activation time, QuickNav introspects each workbench's toolbars to build the command grouping map.
|
||||
|
||||
```python
|
||||
def discover_groupings(workbench_name: str) -> list[CommandGrouping]:
|
||||
"""Discover command groupings from a workbench's toolbar structure.
|
||||
|
||||
1. Temporarily activate the workbench (if not already active)
|
||||
2. Enumerate QToolBars from the main window
|
||||
3. Map toolbar name → list of QAction names
|
||||
4. Filter out non-command actions (separators, widgets)
|
||||
5. Split into tiers if >9 groupings
|
||||
6. Restore the previously active workbench
|
||||
"""
|
||||
```
|
||||
|
||||
### 10.1 Fallback Defaults
|
||||
|
||||
If toolbar discovery fails (workbench not initialized, empty toolbars), QuickNav falls back to the hardcoded groupings in Section 5. These are stored as a Python dict in `workbench_map.py`.
|
||||
|
||||
### 10.2 ZTools Integration
|
||||
|
||||
ZTools commands injected via `WorkbenchManipulator` appear in the discovered toolbars and are automatically included in the relevant groupings. No special handling is needed — QuickNav discovers commands after all manipulators have run.
|
||||
|
||||
---
|
||||
|
||||
## 11. FreeCAD Compatibility
|
||||
|
||||
QuickNav is designed as a standalone FreeCAD addon that works without Kindred Create or the SDK.
|
||||
|
||||
| Feature | FreeCAD | Kindred Create |
|
||||
|---------|---------|----------------|
|
||||
| Core navigation (keys, nav bar) | ✅ | ✅ |
|
||||
| Catppuccin Mocha theming | ❌ (uses Qt defaults) | ✅ (via SDK theme tokens) |
|
||||
| Auto-boot on startup | ❌ (manual workbench activation) | ✅ (via addon loader) |
|
||||
| ZTools commands in groupings | ❌ (not present) | ✅ (discovered from manipulated toolbars) |
|
||||
|
||||
The SDK dependency is optional — QuickNav checks for `kindred_sdk` availability and degrades gracefully:
|
||||
|
||||
```python
|
||||
try:
|
||||
from kindred_sdk.theme import get_theme_tokens
|
||||
THEME = get_theme_tokens()
|
||||
except ImportError:
|
||||
THEME = None # Use Qt default palette
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
- Event filter with key interception and input widget safety
|
||||
- QuickNavManager singleton with toggle on/off
|
||||
- Navigation bar widget (QToolBar) with basic layout
|
||||
- Hardcoded workbench/grouping maps from Section 5
|
||||
- ParamGet persistence for enabled state
|
||||
|
||||
### Phase 2: Dynamic Discovery
|
||||
- Toolbar introspection for command grouping discovery
|
||||
- MRU history with persistence
|
||||
- Grouping overflow scrolling
|
||||
- Workbench restore (last-active grouping per workbench)
|
||||
|
||||
### Phase 3: Auto-Execution
|
||||
- Context-aware auto-execute logic
|
||||
- Sketcher closed-profile detection for Pad/Pocket
|
||||
- Pre-selection constraint application
|
||||
|
||||
### Phase 4: Polish
|
||||
- Number badge SVG icons
|
||||
- Catppuccin Mocha theming (conditional on SDK)
|
||||
- Scroll animations
|
||||
- Settings dialog (custom Ctrl+6-9 assignments)
|
||||
- FreeCAD standalone packaging
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
1. **Tier switching UX:** When a workbench has >9 groupings split into tiers, should `Shift+0` toggle between tiers, or should tiers be purely a scroll/mouse concept?
|
||||
|
||||
2. **Visual number badges:** Should the commands in the nav bar show keycap-style badges (like `⌨ 1`) or just prepend the number (`1: Line`)?
|
||||
|
||||
3. **Sketcher inline dimension input:** FreeCAD's Sketcher has an inline dimension entry that isn't a standard QLineEdit. Need to verify the event filter correctly identifies and skips this widget.
|
||||
|
||||
4. **Ctrl+N conflicts with Create shortcuts:** Verify that Create/Silo don't already bind Ctrl+1 through Ctrl+9. The Silo toggle uses Ctrl+O/S/N, so these should be clear.
|
||||
@@ -77,7 +77,7 @@ Defined in the root `CMakeLists.txt`:
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `KINDRED_CREATE_VERSION` | `0.1.5` | Kindred Create version |
|
||||
| `KINDRED_CREATE_VERSION` | `0.1.0` | Kindred Create version |
|
||||
| `FREECAD_VERSION` | `1.0.0` | FreeCAD base version |
|
||||
|
||||
These are injected into `src/Mod/Create/version.py` at build time via `version.py.in`.
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
# 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
|
||||
@@ -23,7 +23,7 @@ These cannot be disabled. They define what Silo *is*.
|
||||
|-----------|------|-------------|
|
||||
| `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation |
|
||||
| `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors |
|
||||
| `storage` | Storage | Filesystem storage |
|
||||
| `storage` | Storage | MinIO/S3 file storage, presigned uploads, versioning |
|
||||
|
||||
### 2.2 Optional Modules
|
||||
|
||||
@@ -470,10 +470,12 @@ Returns full config grouped by module with secrets redacted:
|
||||
"default": "kindred-rd"
|
||||
},
|
||||
"storage": {
|
||||
"backend": "filesystem",
|
||||
"filesystem": {
|
||||
"root_dir": "/var/lib/silo/data"
|
||||
},
|
||||
"endpoint": "minio:9000",
|
||||
"bucket": "silo-files",
|
||||
"access_key": "****",
|
||||
"secret_key": "****",
|
||||
"use_ssl": false,
|
||||
"region": "us-east-1",
|
||||
"status": "connected"
|
||||
},
|
||||
"database": {
|
||||
@@ -564,7 +566,7 @@ Available for modules with external connections:
|
||||
|
||||
| Module | Test Action |
|
||||
|--------|------------|
|
||||
| `storage` | Verify filesystem storage directory is accessible |
|
||||
| `storage` | Ping MinIO, verify bucket exists |
|
||||
| `auth` (ldap) | Attempt LDAP bind with configured credentials |
|
||||
| `auth` (oidc) | Fetch OIDC discovery document from issuer URL |
|
||||
| `odoo` | Attempt XML-RPC connection to Odoo |
|
||||
@@ -600,9 +602,11 @@ database:
|
||||
sslmode: disable
|
||||
|
||||
storage:
|
||||
backend: filesystem
|
||||
filesystem:
|
||||
root_dir: /var/lib/silo/data
|
||||
endpoint: minio:9000
|
||||
bucket: silo-files
|
||||
access_key: silominio
|
||||
secret_key: silominiosecret
|
||||
use_ssl: false
|
||||
|
||||
schemas:
|
||||
directory: /etc/silo/schemas
|
||||
|
||||
@@ -1,899 +0,0 @@
|
||||
# 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
|
||||
@@ -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 |
|
||||
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
||||
| `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.
|
||||
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow.
|
||||
|
||||
**Props**:
|
||||
|
||||
@@ -435,7 +435,7 @@ interface FileDropZoneProps {
|
||||
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
objectKey: string; // storage key after upload
|
||||
objectKey: string; // MinIO 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 URL using `XMLHttpRequest` (for progress tracking).
|
||||
3. `PUT` the file directly to the presigned MinIO 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://...", "expires_at": "2026-02-06T..." }
|
||||
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." }
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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 storage. Updates `item.thumbnail_key` column.
|
||||
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. 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/ # Filesystem storage
|
||||
│ ├── storage/ # MinIO file 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, OpenLDAP, and Silo):**
|
||||
**Docker Compose (quickest — includes PostgreSQL, MinIO, 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 in Docker
|
||||
make docker-up # Start PostgreSQL + MinIO in Docker
|
||||
make run # Run silo locally with Go
|
||||
```
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# Assembly Integration
|
||||
|
||||
The Kindred solver integrates with FreeCAD's Assembly workbench through the KCSolve pluggable solver framework. This page describes the bridge layer, preference system, and interactive drag protocol.
|
||||
|
||||
## KindredSolver class
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/solver.py`
|
||||
|
||||
`KindredSolver` subclasses `kcsolve.IKCSolver` and implements the solver interface:
|
||||
|
||||
```python
|
||||
class KindredSolver(kcsolve.IKCSolver):
|
||||
def name(self):
|
||||
return "Kindred (Newton-Raphson)"
|
||||
|
||||
def supported_joints(self):
|
||||
return list(_SUPPORTED) # 20 of 24 BaseJointKind values
|
||||
|
||||
def solve(self, ctx): # Static solve
|
||||
def diagnose(self, ctx): # Constraint analysis
|
||||
def pre_drag(self, ctx, drag_parts): # Begin drag session
|
||||
def drag_step(self, drag_placements): # Mouse move during drag
|
||||
def post_drag(self): # End drag session
|
||||
def is_deterministic(self): # Returns True
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
The solver is registered at addon load time via `Init.py`:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
from kindred_solver import KindredSolver
|
||||
kcsolve.register_solver("kindred", KindredSolver)
|
||||
```
|
||||
|
||||
The `mods/solver/` directory is a FreeCAD addon discovered by the addon loader through its `package.xml` manifest.
|
||||
|
||||
### Supported joints
|
||||
|
||||
The Kindred solver handles 20 of the 24 `BaseJointKind` values. The remaining 4 are stubs that produce no residuals:
|
||||
|
||||
| Supported | Stub (no residuals) |
|
||||
|-----------|-------------------|
|
||||
| Coincident, PointOnLine, PointInPlane, Concentric, Tangent, Planar, LineInPlane, Parallel, Perpendicular, Angle, Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal, Gear, RackPinion, DistancePointPoint | Cam, Slot, DistanceCylSph, Custom |
|
||||
|
||||
### Joint limits
|
||||
|
||||
Joint travel limits (`Constraint.limits`) are accepted but not enforced. The solver logs a warning once per instance when limits are encountered. Enforcing inequality constraints requires active-set or barrier-method extensions beyond the current Newton-Raphson formulation.
|
||||
|
||||
## Solver selection
|
||||
|
||||
### C++ preference
|
||||
|
||||
`AssemblyObject::getOrCreateSolver()` reads the user preference to select the solver backend:
|
||||
|
||||
```cpp
|
||||
ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
|
||||
"User parameter:BaseApp/Preferences/Mod/Assembly");
|
||||
std::string solverName = hGrp->GetASCII("Solver", "");
|
||||
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
|
||||
```
|
||||
|
||||
An empty string (`""`) returns the registry default (the first solver registered, which is OndselSolver). Setting `"kindred"` selects the Kindred solver.
|
||||
|
||||
`resetSolver()` clears the cached solver instance so the next solve picks up preference changes.
|
||||
|
||||
### Preferences UI
|
||||
|
||||
The Assembly preferences page (`Edit > Preferences > Assembly`) includes a "Solver backend" combo box populated from the registry at load time:
|
||||
|
||||
- **Default** -- empty string, uses the registry default (OndselSolver)
|
||||
- **OndselSolver (Lagrangian)** -- `"ondsel"`
|
||||
- **Kindred (Newton-Raphson)** -- `"kindred"` (available when the solver addon is loaded)
|
||||
|
||||
The preference is stored as `Mod/Assembly/Solver` in the FreeCAD parameter system.
|
||||
|
||||
### Programmatic switching
|
||||
|
||||
From the Python console:
|
||||
|
||||
```python
|
||||
import FreeCAD
|
||||
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
|
||||
|
||||
# Switch to Kindred
|
||||
pref.SetString("Solver", "kindred")
|
||||
|
||||
# Switch back to default
|
||||
pref.SetString("Solver", "")
|
||||
|
||||
# Force the active assembly to pick up the change
|
||||
if hasattr(FreeCADGui, "ActiveDocument"):
|
||||
for obj in FreeCAD.ActiveDocument.Objects:
|
||||
if hasattr(obj, "resetSolver"):
|
||||
obj.resetSolver()
|
||||
```
|
||||
|
||||
## Interactive drag protocol
|
||||
|
||||
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
|
||||
|
||||
### pre_drag(ctx, drag_parts)
|
||||
|
||||
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a `_DragCache` for reuse across subsequent `drag_step()` calls.
|
||||
|
||||
```python
|
||||
def pre_drag(self, ctx, drag_parts):
|
||||
self._drag_ctx = ctx
|
||||
self._drag_parts = set(drag_parts)
|
||||
|
||||
system = _build_system(ctx)
|
||||
|
||||
half_spaces = compute_half_spaces(...)
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
# single_equation_pass is intentionally skipped — it bakes variable
|
||||
# values as constants that become stale when dragged parts move.
|
||||
|
||||
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
|
||||
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
|
||||
|
||||
# Initial solve (Newton-Raphson + BFGS fallback)
|
||||
newton_solve(residuals, system.params, ...)
|
||||
|
||||
# Cache for drag_step() reuse
|
||||
cache = _DragCache()
|
||||
cache.system = system
|
||||
cache.residuals = residuals
|
||||
cache.jac_exprs = jac_exprs
|
||||
cache.compiled_eval = compiled_eval
|
||||
cache.half_spaces = half_spaces
|
||||
cache.weight_vec = weight_vec
|
||||
...
|
||||
return result
|
||||
```
|
||||
|
||||
**Important:** `single_equation_pass` is not used in the drag path. It analytically solves single-variable equations and bakes the results as `Const()` nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only `substitution_pass` (which replaces genuinely grounded parameters) is safe to cache.
|
||||
|
||||
### drag_step(drag_placements)
|
||||
|
||||
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached `ParamTable`, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
|
||||
|
||||
```python
|
||||
def drag_step(self, drag_placements):
|
||||
cache = self._drag_cache
|
||||
params = cache.system.params
|
||||
|
||||
# Update only the dragged part's parameters
|
||||
for pr in drag_placements:
|
||||
pfx = pr.id + "/"
|
||||
params.set_value(pfx + "tx", pr.placement.position[0])
|
||||
params.set_value(pfx + "ty", pr.placement.position[1])
|
||||
params.set_value(pfx + "tz", pr.placement.position[2])
|
||||
params.set_value(pfx + "qw", pr.placement.quaternion[0])
|
||||
params.set_value(pfx + "qx", pr.placement.quaternion[1])
|
||||
params.set_value(pfx + "qy", pr.placement.quaternion[2])
|
||||
params.set_value(pfx + "qz", pr.placement.quaternion[3])
|
||||
|
||||
# Solve with cached artifacts — no rebuild
|
||||
newton_solve(cache.residuals, params, ...,
|
||||
jac_exprs=cache.jac_exprs,
|
||||
compiled_eval=cache.compiled_eval)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### post_drag()
|
||||
|
||||
Called when the drag ends. Clears the cached state.
|
||||
|
||||
```python
|
||||
def post_drag(self):
|
||||
self._drag_ctx = None
|
||||
self._drag_parts = None
|
||||
self._drag_cache = None
|
||||
```
|
||||
|
||||
### _DragCache
|
||||
|
||||
The cache holds all artifacts built in `pre_drag()` that are invariant across drag steps (constraint topology doesn't change during a drag):
|
||||
|
||||
| Field | Contents |
|
||||
|-------|----------|
|
||||
| `system` | `_System` -- owns `ParamTable` and `Expr` trees |
|
||||
| `residuals` | `list[Expr]` -- after substitution pass |
|
||||
| `jac_exprs` | `list[list[Expr]]` -- symbolic Jacobian |
|
||||
| `compiled_eval` | `Callable` or `None` -- native compiled evaluator |
|
||||
| `half_spaces` | `list[HalfSpace]` -- branch trackers |
|
||||
| `weight_vec` | `ndarray` or `None` -- minimum-movement weights |
|
||||
| `post_step_fn` | `Callable` or `None` -- half-space correction callback |
|
||||
|
||||
### Performance
|
||||
|
||||
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each `drag_step()` only evaluates the cached expressions at updated parameter values:
|
||||
|
||||
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
|
||||
- The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
|
||||
- The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
|
||||
- DOF counting is skipped during drag for speed (`result.dof = -1`)
|
||||
|
||||
## Diagnostics integration
|
||||
|
||||
`diagnose(ctx)` builds the constraint system and runs overconstrained detection, returning a list of `kcsolve.ConstraintDiagnostic` objects. The Assembly module calls this to populate the constraint diagnostics panel.
|
||||
|
||||
```python
|
||||
def diagnose(self, ctx):
|
||||
system = _build_system(ctx)
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
return _run_diagnostics(residuals, system.params, system.residual_ranges, ctx)
|
||||
```
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
- **Kinematic simulation** (`run_kinematic`, `num_frames`, `update_for_frame`) -- the base class defaults return `Failed`. Requires time-stepping integration with motion driver expression evaluation.
|
||||
- **Joint limit enforcement** -- inequality constraints need active-set or barrier solver extensions.
|
||||
- **Fixed-joint bundling** (`supports_bundle_fixed()` returns `False`) -- the solver receives unbundled parts; the Assembly module pre-bundles when needed.
|
||||
- **Native export** (`export_native()`) -- no solver-native debug format defined.
|
||||
@@ -1,116 +0,0 @@
|
||||
# Constraints
|
||||
|
||||
Each constraint type maps to a class that produces residual expressions. The residuals equal zero when the constraint is satisfied. The number of residuals equals the number of degrees of freedom removed.
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/constraints.py`, `mods/solver/kindred_solver/geometry.py`
|
||||
|
||||
## Constraint vocabulary
|
||||
|
||||
### Point constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Coincident** | 3 | `p_i - p_j` (world-frame marker origins coincide) |
|
||||
| **PointOnLine** | 2 | Two components of `(p_i - p_j) x z_j` (point lies on line through `p_j` along `z_j`) |
|
||||
| **PointInPlane** | 1 | `(p_i - p_j) . z_j - offset` (signed distance to plane) |
|
||||
|
||||
### Orientation constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Parallel** | 2 | Two components of `z_i x z_j` (cross product of Z-axes is zero) |
|
||||
| **Perpendicular** | 1 | `z_i . z_j` (dot product of Z-axes is zero) |
|
||||
| **Angle** | 1 | `z_i . z_j - cos(angle)` |
|
||||
|
||||
### Axis/surface constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Concentric** | 4 | Parallel Z-axes (2) + point-on-line (2) |
|
||||
| **Tangent** | 1 | `(p_i - p_j) . z_j` (signed distance along normal) |
|
||||
| **Planar** | 3 | Parallel normals (2) + point-in-plane (1) |
|
||||
| **LineInPlane** | 2 | Point-in-plane (1) + `z_i . n_j` (line direction perpendicular to normal) (1) |
|
||||
|
||||
### Kinematic joints
|
||||
|
||||
| Type | DOF removed | DOF remaining | Residuals |
|
||||
|------|-------------|---------------|-----------|
|
||||
| **Fixed** | 6 | 0 | Coincident origins (3) + quaternion error imaginary parts (3) |
|
||||
| **Ball** | 3 | 3 | Coincident origins (same as Coincident) |
|
||||
| **Revolute** | 5 | 1 (rotation about Z) | Coincident origins (3) + parallel Z-axes (2) |
|
||||
| **Cylindrical** | 4 | 2 (rotation + slide) | Parallel Z-axes (2) + point-on-line (2) |
|
||||
| **Slider** | 5 | 1 (slide along Z) | Parallel Z-axes (2) + point-on-line (2) + twist lock: `x_i . y_j` (1) |
|
||||
| **Screw** | 5 | 1 (helical) | Cylindrical (4) + pitch coupling: `axial - pitch * qz_rel / pi` (1) |
|
||||
| **Universal** | 4 | 2 (rotation about each Z) | Coincident origins (3) + perpendicular Z-axes (1) |
|
||||
|
||||
### Mechanical elements
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Gear** | 1 | `r_i * qz_i + r_j * qz_j` (coupled rotation via quaternion Z-components) |
|
||||
| **RackPinion** | 1 | `translation - 2 * pitch_radius * qz_i` (rotation-translation coupling) |
|
||||
| **Cam** | 0 | Stub (no residuals) |
|
||||
| **Slot** | 0 | Stub (no residuals) |
|
||||
|
||||
### Distance constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **DistancePointPoint** | 1 | `\|p_i - p_j\|^2 - d^2` (squared form avoids sqrt in Jacobian) |
|
||||
| **DistanceCylSph** | 0 | Stub (geometry classification dependent) |
|
||||
|
||||
## Marker convention
|
||||
|
||||
Every constraint references two parts (`body_i`, `body_j`) with local coordinate frames called markers. Each marker has a position (attachment point on the part) and a quaternion (orientation).
|
||||
|
||||
The marker Z-axis defines the constraint direction:
|
||||
- **Revolute:** Z-axis = hinge axis
|
||||
- **Planar:** Z-axis = face normal
|
||||
- **PointOnLine:** Z-axis = line direction
|
||||
- **Slider:** Z-axis = slide direction
|
||||
|
||||
The solver computes world-frame marker axes by composing the body quaternion with the marker quaternion: `q_world = q_body * q_marker`, then rotating unit vectors through the result.
|
||||
|
||||
## Fixed constraint orientation
|
||||
|
||||
The Fixed constraint locks all 6 DOF using a quaternion error formulation:
|
||||
|
||||
1. Compute total orientation: `q_i = q_body_i * q_marker_i`, `q_j = q_body_j * q_marker_j`
|
||||
2. Compute relative quaternion: `q_err = conj(q_i) * q_j`
|
||||
3. When orientations match, `q_err` is the identity quaternion `(1, 0, 0, 0)`
|
||||
4. Residuals are the three imaginary components of `q_err` (should be zero)
|
||||
|
||||
The quaternion normalization constraint on each body provides the fourth equation needed to fully determine the quaternion.
|
||||
|
||||
## Rotation proxies for mechanical constraints
|
||||
|
||||
Gear, RackPinion, and Screw constraints need to measure rotation angles. Rather than extracting Euler angles (which would introduce transcendentals), they use the Z-component of a relative quaternion as a proxy:
|
||||
|
||||
```
|
||||
q_local = conj(q_marker) * q_body * q_marker
|
||||
angle ~ 2 * qz_local (for small angles)
|
||||
```
|
||||
|
||||
This is exact at the solution and has correct gradient direction, which is sufficient for Newton-Raphson convergence from a nearby initial guess.
|
||||
|
||||
## Geometry helpers
|
||||
|
||||
The `geometry.py` module provides Expr-level vector operations used by constraint classes:
|
||||
|
||||
- `marker_z_axis(body, marker_quat)` -- world-frame Z-axis via `quat_rotate(q_body * q_marker, [0,0,1])`
|
||||
- `marker_x_axis(body, marker_quat)` -- world-frame X-axis (used by Slider twist lock)
|
||||
- `marker_y_axis(body, marker_quat)` -- world-frame Y-axis (used by Slider twist lock)
|
||||
- `dot3(a, b)` -- dot product of Expr triples
|
||||
- `cross3(a, b)` -- cross product of Expr triples
|
||||
- `point_plane_distance(point, origin, normal)` -- signed distance
|
||||
- `point_line_perp_components(point, origin, dir)` -- two perpendicular distance components
|
||||
|
||||
## Writing a new constraint
|
||||
|
||||
To add a constraint type:
|
||||
|
||||
1. Subclass `ConstraintBase` in `constraints.py`
|
||||
2. Implement `residuals()` returning a list of `Expr` nodes
|
||||
3. Add a case in `solver.py:_build_constraint()` to instantiate it from `BaseJointKind`
|
||||
4. Add the `BaseJointKind` value to `_SUPPORTED` in `solver.py`
|
||||
5. Add the residual count to the tables in `decompose.py`
|
||||
@@ -1,117 +0,0 @@
|
||||
# Diagnostics
|
||||
|
||||
The solver provides three levels of constraint analysis: system-wide DOF counting, per-entity DOF decomposition, and overconstrained/conflicting constraint detection.
|
||||
|
||||
## DOF counting
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/dof.py`
|
||||
|
||||
Degrees of freedom are computed from the Jacobian rank:
|
||||
|
||||
```
|
||||
DOF = n_free_params - rank(J)
|
||||
```
|
||||
|
||||
Where `n_free_params` is the number of non-fixed parameters and `rank(J)` is the numerical rank of the Jacobian evaluated at current parameter values (SVD with tolerance `1e-8`).
|
||||
|
||||
A well-constrained assembly has `DOF = 0` (exactly enough constraints to determine all positions). Positive DOF means underconstrained (parts can still move). Negative DOF is not possible with this formulation -- instead, rank deficiency in an overdetermined system indicates redundant constraints.
|
||||
|
||||
The DOF value is reported in `SolveResult.dof` after every solve.
|
||||
|
||||
## Per-entity DOF
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/diagnostics.py`
|
||||
|
||||
`per_entity_dof()` breaks down the DOF count per body, identifying which motions remain free for each part:
|
||||
|
||||
1. Build the full Jacobian
|
||||
2. For each non-grounded body, extract the 7 columns corresponding to its parameters
|
||||
3. Compute SVD of the sub-matrix; rank = number of constrained directions
|
||||
4. `remaining_dof = 7 - rank` (includes the quaternion normalization constraint counted in the rank)
|
||||
5. Classify null-space vectors as free motions by analyzing their translation vs. rotation components:
|
||||
- Pure translation: >80% of the null vector's energy is in `tx, ty, tz` components
|
||||
- Pure rotation: >80% of the energy is in `qw, qx, qy, qz` components
|
||||
- Helical: mixed
|
||||
|
||||
Returns a list of `EntityDOF` dataclasses:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EntityDOF:
|
||||
entity_id: str
|
||||
remaining_dof: int
|
||||
free_motions: list[str] # e.g., ["rotation about Z", "translation along X"]
|
||||
```
|
||||
|
||||
## Overconstrained detection
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/diagnostics.py`
|
||||
|
||||
`find_overconstrained()` identifies redundant and conflicting constraints when the system is overconstrained (Jacobian is rank-deficient). It runs automatically when `solve()` fails to converge.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Following the approach used by SolvSpace:
|
||||
|
||||
1. **Check rank.** Build the full Jacobian `J`, compute its rank via SVD. If `rank == n_residuals`, the system is not overconstrained -- return empty.
|
||||
|
||||
2. **Find redundant constraints.** For each constraint, temporarily remove its rows from J and re-check rank. If the rank is preserved, the constraint is **redundant** (removing it doesn't change the system's effective equations).
|
||||
|
||||
3. **Distinguish conflicting from merely redundant.** Compute the left null space of J (columns of U beyond the rank). Project the residual vector onto this null space:
|
||||
```
|
||||
null_residual = U_null^T @ r
|
||||
residual_projection = U_null @ null_residual
|
||||
```
|
||||
If a redundant constraint's residuals have significant projection onto the null space, it is **conflicting** -- it's both redundant and unsatisfied, meaning it contradicts other constraints.
|
||||
|
||||
### Diagnostic output
|
||||
|
||||
Returns `ConstraintDiag` dataclasses:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ConstraintDiag:
|
||||
constraint_index: int
|
||||
kind: str # "redundant" or "conflicting"
|
||||
detail: str # Human-readable explanation
|
||||
```
|
||||
|
||||
These are converted to `kcsolve.ConstraintDiagnostic` objects in the IKCSolver bridge:
|
||||
|
||||
| ConstraintDiag.kind | kcsolve.DiagnosticKind |
|
||||
|---------------------|----------------------|
|
||||
| `"redundant"` | `Redundant` |
|
||||
| `"conflicting"` | `Conflicting` |
|
||||
|
||||
### Example
|
||||
|
||||
Two Fixed joints between the same pair of parts:
|
||||
|
||||
- Joint A: 6 residuals (3 position + 3 orientation)
|
||||
- Joint B: 6 residuals (same as Joint A)
|
||||
|
||||
Jacobian rank = 6 (Joint B's rows are linearly dependent on Joint A's). Both joints are detected as redundant. If the joints specify different relative positions, both are also flagged as conflicting.
|
||||
|
||||
## Solution preferences
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/preference.py`
|
||||
|
||||
Solution preferences guide the solver toward physically intuitive solutions when multiple valid configurations exist.
|
||||
|
||||
### Minimum-movement weighting
|
||||
|
||||
The weight vector scales the Newton step to prefer solutions near the initial configuration. Translation parameters get weight `1.0`, quaternion parameters get weight `(180/pi)^2 ~ 3283`. This makes a 1-radian rotation equally "expensive" as a ~57-unit translation.
|
||||
|
||||
The weighted minimum-norm step is:
|
||||
|
||||
```
|
||||
J_scaled = J @ diag(W^{-1/2})
|
||||
dx_scaled = lstsq(J_scaled, -r)
|
||||
dx = dx_scaled * W^{-1/2}
|
||||
```
|
||||
|
||||
This produces the minimum-norm solution in the weighted parameter space, biasing toward small movements.
|
||||
|
||||
### Half-space tracking
|
||||
|
||||
Described in detail in [Solving Algorithms: Half-space tracking](solving.md#half-space-tracking). Preserves the initial configuration's "branch" for constraints with multiple valid solutions by detecting and correcting branch crossings during iteration.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Expression DAG
|
||||
|
||||
The expression DAG is the foundation of the Kindred solver. All constraint equations, Jacobian entries, and residuals are built as immutable trees of `Expr` nodes. This lets the solver compute exact symbolic derivatives and simplify constant sub-expressions before the iterative solve loop.
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/expr.py`
|
||||
|
||||
## Node types
|
||||
|
||||
Every node is a subclass of `Expr` and implements three methods:
|
||||
|
||||
- `eval(env)` -- evaluate the expression given a name-to-value dictionary
|
||||
- `diff(var)` -- return a new Expr tree for the partial derivative with respect to `var`
|
||||
- `simplify()` -- return an algebraically simplified copy
|
||||
|
||||
### Leaf nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Const(v)` | Literal floating-point value | 0 |
|
||||
| `Var(name)` | Named parameter (from `ParamTable`) | 1 if name matches, else 0 |
|
||||
|
||||
### Unary nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Neg(f)` | Negation: `-f` | `-f'` |
|
||||
| `Sin(f)` | Sine: `sin(f)` | `cos(f) * f'` |
|
||||
| `Cos(f)` | Cosine: `cos(f)` | `-sin(f) * f'` |
|
||||
| `Sqrt(f)` | Square root: `sqrt(f)` | `f' / (2 * sqrt(f))` |
|
||||
|
||||
### Binary nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Add(a, b)` | Sum: `a + b` | `a' + b'` |
|
||||
| `Sub(a, b)` | Difference: `a - b` | `a' - b'` |
|
||||
| `Mul(a, b)` | Product: `a * b` | `a'b + ab'` (product rule) |
|
||||
| `Div(a, b)` | Quotient: `a / b` | `(a'b - ab') / b^2` (quotient rule) |
|
||||
| `Pow(a, n)` | Power: `a^n` (constant exponent only) | `n * a^(n-1) * a'` |
|
||||
|
||||
### Sentinels
|
||||
|
||||
`ZERO = Const(0.0)` and `ONE = Const(1.0)` are pre-allocated constants used by `diff()` to avoid allocating trivial nodes.
|
||||
|
||||
## Operator overloading
|
||||
|
||||
Python's arithmetic operators are overloaded on `Expr`, so constraints can be written in natural notation:
|
||||
|
||||
```python
|
||||
from kindred_solver.expr import Var, Const
|
||||
|
||||
x = Var("x")
|
||||
y = Var("y")
|
||||
|
||||
# Build the expression: x^2 + 2*x*y - 1
|
||||
expr = x**2 + 2*x*y - Const(1.0)
|
||||
|
||||
# Evaluate at x=3, y=4
|
||||
expr.eval({"x": 3.0, "y": 4.0}) # 32.0
|
||||
|
||||
# Symbolic derivative w.r.t. x
|
||||
dx = expr.diff("x").simplify() # 2*x + 2*y
|
||||
dx.eval({"x": 3.0, "y": 4.0}) # 14.0
|
||||
```
|
||||
|
||||
The `_wrap()` helper coerces plain `int` and `float` values to `Const` nodes automatically, so `2 * x` works without wrapping the `2`.
|
||||
|
||||
## Simplification
|
||||
|
||||
`simplify()` applies algebraic identities bottom-up:
|
||||
|
||||
- Constant folding: `Const(2) + Const(3)` becomes `Const(5)`
|
||||
- Identity elimination: `x + 0 = x`, `x * 1 = x`, `x^0 = 1`, `x^1 = x`
|
||||
- Zero propagation: `0 * x = 0`
|
||||
- Negation collapse: `-(-x) = x`
|
||||
- Power expansion: `x^2` becomes `x * x` (avoids `pow()` in evaluation)
|
||||
|
||||
Simplification is applied once to each Jacobian entry after symbolic differentiation, before the solve loop begins. This reduces the expression tree size and speeds up repeated evaluation.
|
||||
|
||||
## How the solver uses expressions
|
||||
|
||||
1. **Parameter registration.** `ParamTable.add("Part001/tx", 10.0)` creates a `Var("Part001/tx")` node and records its current value.
|
||||
|
||||
2. **Constraint building.** Constraint classes compose `Var` nodes with arithmetic to produce residual `Expr` trees. For example, `CoincidentConstraint` builds `body_i.world_point() - body_j.world_point()`, producing 3 residual expressions.
|
||||
|
||||
3. **Jacobian construction.** Newton-Raphson calls `r.diff(name).simplify()` for every (residual, free parameter) pair to build the symbolic Jacobian. This happens once before the solve loop.
|
||||
|
||||
4. **Evaluation.** Each Newton iteration calls `expr.eval(env)` on every residual and Jacobian entry using the current parameter snapshot. `eval()` is a simple recursive tree walk with dictionary lookups.
|
||||
|
||||
## Design notes
|
||||
|
||||
**Why not numpy directly?** Symbolic expressions give exact derivatives without finite-difference approximations, and enable pre-passes (substitution, single-equation solve) that can eliminate variables before the iterative solver runs. The overhead of tree evaluation is acceptable for the problem sizes encountered in assembly solving (typically tens to hundreds of variables).
|
||||
|
||||
**Why immutable?** Immutability means `diff()` can safely share sub-tree references between the original and derivative expressions. It also simplifies the substitution pass, which rebuilds trees with `Const` nodes replacing fixed `Var` nodes.
|
||||
|
||||
**Limitations.** `Pow` differentiation only supports constant exponents. Variable exponents would require logarithmic differentiation (`d/dx f^g = f^g * (g' * ln(f) + g * f'/f)`), which hasn't been needed for assembly constraints.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Kindred Solver Overview
|
||||
|
||||
The Kindred solver is an expression-based Newton-Raphson constraint solver for the Assembly workbench. It is a pure-Python implementation that registers as a pluggable backend through the [KCSolve framework](../architecture/ondsel-solver.md), providing an alternative to the built-in OndselSolver (Lagrangian) backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Assembly Module
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ SolverRegistry │
|
||||
│ get("kindred") │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ KindredSolver │
|
||||
│ (kcsolve.IKCSolver) │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
┌────────┴────────┐ ┌──────┴──────┐ ┌────────┴────────┐
|
||||
│ _build_system │ │ Solve │ │ Diagnostics │
|
||||
│ ────────────── │ │ ───── │ │ ─────────── │
|
||||
│ ParamTable │ │ pre-passes │ │ DOF counting │
|
||||
│ RigidBody │ │ Newton-R │ │ overconstrained│
|
||||
│ Constraints │ │ BFGS │ │ per-entity DOF │
|
||||
│ Residuals │ │ decompose │ │ half-spaces │
|
||||
└─────────────────┘ └─────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Design principles
|
||||
|
||||
**Symbolic differentiation.** All constraint equations are built as immutable expression DAGs (`Expr` trees). The Jacobian is computed symbolically via `expr.diff()` rather than finite differences. This gives exact derivatives, avoids numerical step-size tuning, and allows pre-passes to simplify or eliminate trivial equations before the iterative solver runs.
|
||||
|
||||
**Residual-based formulation.** Each constraint produces a list of residual expressions that should evaluate to zero when satisfied. A Coincident constraint produces 3 residuals (dx, dy, dz), a Revolute produces 5 (3 position + 2 axis alignment), and so on. The solver minimizes the residual vector norm.
|
||||
|
||||
**Unit quaternions for rotation.** Orientation is parameterized as a unit quaternion (w, x, y, z) rather than Euler angles, avoiding gimbal lock. A quaternion normalization residual (qw^2 + qx^2 + qy^2 + qz^2 - 1 = 0) is added for each free body, and quaternions are re-projected onto the unit sphere after each Newton step.
|
||||
|
||||
**Current placements as initial guess.** The solver uses the parts' current positions as the initial guess, so it naturally converges to the nearest solution. Combined with half-space tracking, this produces physically intuitive results without branch-switching surprises.
|
||||
|
||||
## Solve pipeline
|
||||
|
||||
When `KindredSolver.solve(ctx)` is called with a `SolveContext`:
|
||||
|
||||
1. **Build system** (`_build_system`) -- Create a `ParamTable` with 7 parameters per part (tx, ty, tz, qw, qx, qy, qz). Grounded parts have all parameters fixed. Build constraint objects from the context, collect their residual expressions, and add quaternion normalization residuals for free bodies.
|
||||
|
||||
2. **Solution preferences** -- Compute half-space trackers for branching constraints (Distance, Parallel, Angle, Perpendicular) and build a minimum-movement weight vector that penalizes quaternion changes more than translation changes.
|
||||
|
||||
3. **Pre-passes** -- Run the substitution pass (replace fixed parameters with constants) and the single-equation pass (analytically solve residuals with only one free variable).
|
||||
|
||||
4. **Solve** -- For assemblies with 8+ free bodies, decompose the constraint graph into biconnected components and solve each cluster independently. For smaller assemblies, solve the full system monolithically. In both cases, use Newton-Raphson first, falling back to L-BFGS-B if Newton doesn't converge.
|
||||
|
||||
5. **Post-process** -- Count degrees of freedom via Jacobian SVD rank. On failure, run overconstrained detection to identify redundant or conflicting constraints. Extract solved placements from the parameter table.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `solver.py` | `KindredSolver` class: IKCSolver bridge, solve/diagnose/drag entry points |
|
||||
| `expr.py` | Immutable expression DAG with eval, diff, simplify |
|
||||
| `params.py` | Parameter table: named variables with fixed/free tracking |
|
||||
| `entities.py` | `RigidBody`: 7-DOF entity owning solver parameters |
|
||||
| `quat.py` | Quaternion rotation as polynomial Expr trees |
|
||||
| `geometry.py` | Marker axis extraction, vector ops (dot, cross, point-plane, point-line) |
|
||||
| `constraints.py` | 24 constraint classes producing residual expressions |
|
||||
| `newton.py` | Newton-Raphson with symbolic Jacobian, quaternion renormalization |
|
||||
| `bfgs.py` | L-BFGS-B fallback via scipy |
|
||||
| `prepass.py` | Substitution pass and single-equation analytical solve |
|
||||
| `decompose.py` | Biconnected component graph decomposition and cluster-by-cluster solving |
|
||||
| `dof.py` | DOF counting via Jacobian SVD rank |
|
||||
| `diagnostics.py` | Overconstrained detection, per-entity DOF classification |
|
||||
| `preference.py` | Half-space tracking and minimum-movement weighting |
|
||||
|
||||
## File locations
|
||||
|
||||
- **Solver addon:** `mods/solver/` (git submodule)
|
||||
- **KCSolve C++ framework:** `src/Mod/Assembly/Solver/`
|
||||
- **Python bindings:** `src/Mod/Assembly/Solver/bindings/`
|
||||
- **Integration tests:** `src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py`
|
||||
- **Unit tests:** `mods/solver/tests/`
|
||||
|
||||
## Related
|
||||
|
||||
- [Expression DAG](expression-dag.md) -- the Expr type system
|
||||
- [Constraints](constraints.md) -- constraint vocabulary and residuals
|
||||
- [Solving algorithms](solving.md) -- Newton-Raphson, BFGS, decomposition
|
||||
- [Diagnostics](diagnostics.md) -- DOF counting, overconstrained detection
|
||||
- [Assembly integration](assembly-integration.md) -- IKCSolver bridge, preferences, drag
|
||||
- [Writing a custom solver](writing-a-solver.md) -- tutorial
|
||||
- [KCSolve architecture](../architecture/ondsel-solver.md) -- pluggable solver framework
|
||||
- [KCSolve Python API](../reference/kcsolve-python.md) -- kcsolve module reference
|
||||
@@ -1,128 +0,0 @@
|
||||
# Solving Algorithms
|
||||
|
||||
The Kindred solver uses a multi-stage pipeline: pre-passes reduce the system, Newton-Raphson iterates toward a solution, and L-BFGS-B provides a fallback. For large assemblies, graph decomposition splits the system into independent clusters solved in sequence.
|
||||
|
||||
## Pre-passes
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/prepass.py`
|
||||
|
||||
Pre-passes run before the iterative solver and can eliminate variables analytically, reducing the problem size and improving convergence.
|
||||
|
||||
### Substitution pass
|
||||
|
||||
Replaces all fixed-parameter `Var` nodes with `Const` nodes carrying their current values, then simplifies. This compiles grounded-body parameters and previously-solved variables out of the expression trees.
|
||||
|
||||
After substitution, residuals involving only fixed parameters simplify to constants (typically zero), and Jacobian entries for those parameters become exactly zero. This reduces the effective system size without changing the linear algebra.
|
||||
|
||||
### Single-equation pass
|
||||
|
||||
Scans residuals for any that depend on exactly one free variable. If the residual is linear in that variable (`a*x + b = 0`), it solves `x = -b/a` analytically, fixes the variable, and re-substitutes.
|
||||
|
||||
The pass repeats until no more single-variable residuals can be solved. This handles cascading dependencies: solving one variable may reduce another residual to single-variable form.
|
||||
|
||||
## Newton-Raphson
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/newton.py`
|
||||
|
||||
The primary iterative solver. Each iteration:
|
||||
|
||||
1. Evaluate the residual vector `r` and check convergence (`||r|| < tol`)
|
||||
2. Evaluate the Jacobian matrix `J` by calling `expr.eval()` on pre-computed symbolic derivatives
|
||||
3. Solve `J @ dx = -r` via `numpy.linalg.lstsq` (handles rank-deficient systems)
|
||||
4. Update parameters: `x += dx`
|
||||
5. Apply half-space correction (if configured)
|
||||
6. Re-normalize quaternions to unit length
|
||||
|
||||
### Symbolic Jacobian
|
||||
|
||||
The Jacobian is built once before the solve loop by calling `r.diff(name).simplify()` for every (residual, free parameter) pair. The resulting `Expr` trees are stored and re-evaluated at the current parameter values each iteration. This gives exact derivatives with no step-size tuning.
|
||||
|
||||
### Weighted minimum-norm
|
||||
|
||||
When a weight vector is provided, the step is column-scaled to produce the weighted minimum-norm solution. The solver scales J by W^{-1/2}, solves the scaled system, then unscales the step. This biases the solver toward solutions requiring smaller parameter changes in high-weight dimensions.
|
||||
|
||||
The default weight vector assigns `1.0` to translation parameters and `~3283` to quaternion parameters (the square of 180/pi), making a 1-radian rotation equivalent to a ~57-unit translation. This produces physically intuitive solutions that prefer translating over rotating.
|
||||
|
||||
### Quaternion renormalization
|
||||
|
||||
After each Newton step, quaternion parameter groups `(qw, qx, qy, qz)` are re-projected onto the unit sphere by dividing by their norm. This prevents the quaternion from drifting away from unit length during iteration (the quaternion normalization residual only enforces this at convergence, not during intermediate steps).
|
||||
|
||||
If a quaternion degenerates to near-zero norm, it is reset to the identity quaternion `(1, 0, 0, 0)`.
|
||||
|
||||
### Convergence
|
||||
|
||||
Newton-Raphson runs for up to 100 iterations with tolerance `1e-10` on the residual norm. For well-conditioned systems near the solution, convergence is typically quadratic (3-5 iterations). Interactive drag from a nearby position typically converges in 1-2 iterations.
|
||||
|
||||
## L-BFGS-B fallback
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/bfgs.py`
|
||||
|
||||
If Newton-Raphson fails to converge, L-BFGS-B minimizes the sum of squared residuals: `f(x) = 0.5 * sum(r_i^2)`. This is a quasi-Newton method that approximates the Hessian from gradient history, with bounded memory usage.
|
||||
|
||||
The gradient is computed analytically from the same symbolic Jacobian: `grad = J^T @ r`. This is passed directly to `scipy.optimize.minimize` via the `jac=True` interface to avoid redundant function evaluations.
|
||||
|
||||
L-BFGS-B is more robust for ill-conditioned systems where the Jacobian is nearly singular, but converges more slowly (superlinear rather than quadratic). It runs for up to 200 iterations.
|
||||
|
||||
If scipy is not available, the fallback is skipped gracefully.
|
||||
|
||||
## Graph decomposition
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/decompose.py`
|
||||
|
||||
For assemblies with 8 or more free bodies, the solver decomposes the constraint graph into clusters and solves them independently. This improves performance for large assemblies by reducing the Jacobian size from O(n^2) to the sum of smaller cluster Jacobians.
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Build constraint graph.** Bodies are nodes, constraints are edges weighted by their residual count (DOF removed). Grounded bodies are tagged.
|
||||
|
||||
2. **Find biconnected components.** Using `networkx.biconnected_components()`, decompose the graph into rigid clusters. Articulation points (bodies shared between clusters) are identified.
|
||||
|
||||
3. **Build block-cut tree.** A bipartite graph of clusters and articulation points, rooted at a grounded cluster.
|
||||
|
||||
4. **BFS ordering.** Traverse the block-cut tree root-to-leaf, producing a solve order where grounded clusters come first and boundary conditions propagate outward.
|
||||
|
||||
5. **Solve each cluster.** For each cluster in order:
|
||||
- Fix boundary bodies that were already solved by previous clusters (their parameters become constants)
|
||||
- Collect the cluster's residuals and quaternion normalization equations
|
||||
- Run substitution pass (compiles fixed boundary values to constants)
|
||||
- Newton-Raphson + BFGS fallback on the reduced system
|
||||
- Mark the cluster's bodies as solved
|
||||
- Unfix boundary parameters for downstream clusters
|
||||
|
||||
### Example
|
||||
|
||||
Consider a chain of 4 bodies: `Ground -- A -- B -- C` with joints at each connection. This decomposes into two biconnected components (if the joints create articulation points):
|
||||
|
||||
- Cluster 1: {Ground, A} -- solved first (grounded)
|
||||
- Cluster 2: {A, B, C} -- solved second with A's parameters fixed to Cluster 1's result
|
||||
|
||||
The 21-variable monolithic system (3 free bodies x 7 params) becomes two smaller systems solved in sequence.
|
||||
|
||||
### Disconnected sub-assemblies
|
||||
|
||||
The decomposition also handles disconnected components. Each connected component of the constraint graph is decomposed independently. Components without a grounded body will fail to solve (returning `NoGroundedParts`).
|
||||
|
||||
### Pebble game integration
|
||||
|
||||
The `classify_cluster_rigidity()` function uses the pebble game algorithm from `GNN/solver/datagen/` to classify clusters as well-constrained, underconstrained, overconstrained, or mixed. This provides fast O(n) rigidity analysis without running the full solver.
|
||||
|
||||
## Half-space tracking
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/preference.py`
|
||||
|
||||
Many constraints have multiple valid solutions (branches). A distance constraint between two points can be satisfied with the points on either side of each other. Parallel axes can point in the same or opposite directions.
|
||||
|
||||
Half-space tracking preserves the initial configuration branch:
|
||||
|
||||
1. **At setup:** Evaluate an indicator function for each branching constraint. Record its sign as the reference branch.
|
||||
|
||||
2. **After each Newton step:** Re-evaluate the indicator. If the sign flipped, apply a correction to push the solution back to the reference branch.
|
||||
|
||||
Tracked constraint types:
|
||||
|
||||
| Constraint | Indicator | Correction |
|
||||
|-----------|-----------|------------|
|
||||
| DistancePointPoint (d > 0) | Dot product of displacement with reference direction | Reflect the moving body's position |
|
||||
| Parallel | `z_i . z_j` (same vs. opposite direction) | None (tracked for monitoring) |
|
||||
| Angle | Dominant cross product component | None (tracked for monitoring) |
|
||||
| Perpendicular | Dominant cross product component | None (tracked for monitoring) |
|
||||
@@ -1,256 +0,0 @@
|
||||
# Writing a Custom Solver
|
||||
|
||||
The KCSolve framework lets you implement a solver backend in pure Python, register it at runtime, and select it through the Assembly preferences. This tutorial walks through building a minimal solver and then extending it.
|
||||
|
||||
## Minimal solver
|
||||
|
||||
A solver must subclass `kcsolve.IKCSolver` and implement three methods:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
|
||||
class MySolver(kcsolve.IKCSolver):
|
||||
def __init__(self):
|
||||
super().__init__() # required for pybind11 trampoline
|
||||
|
||||
def name(self):
|
||||
return "My Custom Solver"
|
||||
|
||||
def supported_joints(self):
|
||||
return [
|
||||
kcsolve.BaseJointKind.Fixed,
|
||||
kcsolve.BaseJointKind.Revolute,
|
||||
]
|
||||
|
||||
def solve(self, ctx):
|
||||
result = kcsolve.SolveResult()
|
||||
|
||||
# Find grounded parts
|
||||
grounded = {p.id for p in ctx.parts if p.grounded}
|
||||
if not grounded:
|
||||
result.status = kcsolve.SolveStatus.NoGroundedParts
|
||||
return result
|
||||
|
||||
# Your solving logic here...
|
||||
# For each non-grounded part, compute its solved placement
|
||||
for part in ctx.parts:
|
||||
if part.grounded:
|
||||
continue
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = part.id
|
||||
pr.placement = part.placement # use current placement as placeholder
|
||||
result.placements = result.placements + [pr]
|
||||
|
||||
result.status = kcsolve.SolveStatus.Success
|
||||
result.dof = 0
|
||||
return result
|
||||
```
|
||||
|
||||
Register it:
|
||||
|
||||
```python
|
||||
kcsolve.register_solver("my_solver", MySolver)
|
||||
```
|
||||
|
||||
Test it from the FreeCAD console:
|
||||
|
||||
```python
|
||||
solver = kcsolve.load("my_solver")
|
||||
print(solver.name()) # "My Custom Solver"
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
# ... build context ...
|
||||
result = solver.solve(ctx)
|
||||
print(result.status) # SolveStatus.Success
|
||||
```
|
||||
|
||||
## Addon packaging
|
||||
|
||||
To make your solver load automatically, create a FreeCAD addon:
|
||||
|
||||
```
|
||||
my_solver_addon/
|
||||
package.xml # Addon manifest
|
||||
Init.py # Registration entry point
|
||||
my_solver/
|
||||
__init__.py
|
||||
solver.py # MySolver class
|
||||
```
|
||||
|
||||
**package.xml:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1">
|
||||
<name>MyCustomSolver</name>
|
||||
<description>Custom assembly constraint solver</description>
|
||||
<version>0.1.0</version>
|
||||
<content>
|
||||
<preferencepack>
|
||||
<name>MySolver</name>
|
||||
</preferencepack>
|
||||
</content>
|
||||
</package>
|
||||
```
|
||||
|
||||
**Init.py:**
|
||||
```python
|
||||
import kcsolve
|
||||
from my_solver.solver import MySolver
|
||||
kcsolve.register_solver("my_solver", MySolver)
|
||||
```
|
||||
|
||||
Place the addon in the FreeCAD Mod directory or as a git submodule in `mods/`.
|
||||
|
||||
## Working with SolveContext
|
||||
|
||||
The `SolveContext` contains everything the solver needs:
|
||||
|
||||
### Parts
|
||||
|
||||
```python
|
||||
for part in ctx.parts:
|
||||
print(f"{part.id}: grounded={part.grounded}")
|
||||
print(f" position: {list(part.placement.position)}")
|
||||
print(f" quaternion: {list(part.placement.quaternion)}")
|
||||
print(f" mass: {part.mass}")
|
||||
```
|
||||
|
||||
Each part has 7 degrees of freedom: 3 translation (x, y, z) and 4 quaternion components (w, x, y, z) with a unit-norm constraint reducing the rotational DOF to 3.
|
||||
|
||||
**Quaternion convention:** `(w, x, y, z)` where `w` is the scalar part. This differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles the swap.
|
||||
|
||||
### Constraints
|
||||
|
||||
```python
|
||||
for c in ctx.constraints:
|
||||
if not c.activated:
|
||||
continue
|
||||
print(f"{c.id}: {c.type} between {c.part_i} and {c.part_j}")
|
||||
print(f" marker_i: pos={list(c.marker_i.position)}, "
|
||||
f"quat={list(c.marker_i.quaternion)}")
|
||||
print(f" params: {list(c.params)}")
|
||||
print(f" limits: {len(c.limits)}")
|
||||
```
|
||||
|
||||
The marker transforms define local coordinate frames on each part. The constraint type determines what geometric relationship is enforced between these frames.
|
||||
|
||||
### Returning results
|
||||
|
||||
```python
|
||||
result = kcsolve.SolveResult()
|
||||
result.status = kcsolve.SolveStatus.Success
|
||||
result.dof = computed_dof
|
||||
|
||||
placements = []
|
||||
for part_id, pos, quat in solved_parts:
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = part_id
|
||||
pr.placement = kcsolve.Transform()
|
||||
pr.placement.position = list(pos)
|
||||
pr.placement.quaternion = list(quat)
|
||||
placements.append(pr)
|
||||
result.placements = placements
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**Important:** pybind11 list fields return copies. Use `result.placements = [...]` (whole-list assignment), not `result.placements.append(...)`.
|
||||
|
||||
## Adding optional capabilities
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Override `diagnose()` to detect overconstrained or malformed assemblies:
|
||||
|
||||
```python
|
||||
def diagnose(self, ctx):
|
||||
diagnostics = []
|
||||
# ... analyze constraints ...
|
||||
d = kcsolve.ConstraintDiagnostic()
|
||||
d.constraint_id = "Joint001"
|
||||
d.kind = kcsolve.DiagnosticKind.Redundant
|
||||
d.detail = "This joint duplicates Joint002"
|
||||
diagnostics.append(d)
|
||||
return diagnostics
|
||||
```
|
||||
|
||||
### Interactive drag
|
||||
|
||||
Override the three drag methods for real-time viewport dragging:
|
||||
|
||||
```python
|
||||
def pre_drag(self, ctx, drag_parts):
|
||||
self._ctx = ctx
|
||||
self._dragging = set(drag_parts)
|
||||
return self.solve(ctx)
|
||||
|
||||
def drag_step(self, drag_placements):
|
||||
# Update dragged parts in stored context
|
||||
for pr in drag_placements:
|
||||
for part in self._ctx.parts:
|
||||
if part.id == pr.id:
|
||||
part.placement = pr.placement
|
||||
break
|
||||
return self.solve(self._ctx)
|
||||
|
||||
def post_drag(self):
|
||||
self._ctx = None
|
||||
self._dragging = None
|
||||
```
|
||||
|
||||
For responsive dragging, the solver should converge quickly from a nearby initial guess. Use warm-starting (current placements as initial guess) and consider caching internal state across drag steps.
|
||||
|
||||
### Incremental update
|
||||
|
||||
Override `update()` for the case where only constraint parameters changed (not topology):
|
||||
|
||||
```python
|
||||
def update(self, ctx):
|
||||
# Reuse cached factorization, only re-evaluate changed residuals
|
||||
return self.solve(ctx) # default: just re-solve
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (without FreeCAD)
|
||||
|
||||
Test your solver logic with hand-built `SolveContext` objects:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
|
||||
def test_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]
|
||||
|
||||
solver = MySolver()
|
||||
result = solver.solve(ctx)
|
||||
assert result.status == kcsolve.SolveStatus.Success
|
||||
```
|
||||
|
||||
### Integration tests (with FreeCAD)
|
||||
|
||||
For integration testing within FreeCAD, follow the pattern in `TestKindredSolverIntegration.py`: set the solver preference in `setUp()`, create document objects, and verify solve results.
|
||||
|
||||
## Reference
|
||||
|
||||
- [KCSolve Python API](../reference/kcsolve-python.md) -- complete type and function reference
|
||||
- [KCSolve Architecture](../architecture/ondsel-solver.md) -- C++ framework details
|
||||
- [Constraints](constraints.md) -- constraint types and residual counts
|
||||
- [Kindred Solver Overview](overview.md) -- how the built-in Kindred solver works
|
||||
Submodule mods/quicknav deleted from 658a427132
Submodule mods/silo updated: a88e104d94...dfa1da97dd
Submodule mods/solver deleted from f85dc047e8
@@ -30,7 +30,7 @@ fi
|
||||
|
||||
# Get version from git if not provided
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.5")
|
||||
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.0")
|
||||
fi
|
||||
|
||||
# Convert version to Debian-compatible format
|
||||
|
||||
@@ -155,7 +155,6 @@ requirements:
|
||||
- lark
|
||||
- lxml
|
||||
- matplotlib-base
|
||||
- networkx
|
||||
- nine
|
||||
- noqt5
|
||||
- numpy>=1.26,<2
|
||||
|
||||
@@ -1046,9 +1046,6 @@ void Application::slotNewDocument(const App::Document& Doc, bool isMainDoc)
|
||||
);
|
||||
pDoc->signalInEdit.connect(std::bind(&Gui::Application::slotInEdit, this, sp::_1));
|
||||
pDoc->signalResetEdit.connect(std::bind(&Gui::Application::slotResetEdit, this, sp::_1));
|
||||
pDoc->signalActivatedViewProvider.connect(
|
||||
std::bind(&Gui::Application::slotActivatedViewProvider, this, sp::_1, sp::_2)
|
||||
);
|
||||
// NOLINTEND
|
||||
|
||||
signalNewDocument(*pDoc, isMainDoc);
|
||||
@@ -1355,12 +1352,6 @@ void Application::slotResetEdit(const Gui::ViewProviderDocumentObject& vp)
|
||||
this->signalResetEdit(vp);
|
||||
}
|
||||
|
||||
void Application::slotActivatedViewProvider(
|
||||
const Gui::ViewProviderDocumentObject* vp, const char* name)
|
||||
{
|
||||
this->signalActivatedViewProvider(vp, name);
|
||||
}
|
||||
|
||||
void Application::onLastWindowClosed(Gui::Document* pcDoc)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -153,9 +153,6 @@ public:
|
||||
fastsignals::signal<void(const Gui::ViewProviderDocumentObject&)> signalInEdit;
|
||||
/// signal on leaving edit mode
|
||||
fastsignals::signal<void(const Gui::ViewProviderDocumentObject&)> signalResetEdit;
|
||||
/// signal on activated view-provider (active-object change, e.g. "pdbody", "part")
|
||||
fastsignals::signal<void(const Gui::ViewProviderDocumentObject*, const char*)>
|
||||
signalActivatedViewProvider;
|
||||
/// signal on changing user edit mode
|
||||
fastsignals::signal<void(int)> signalUserEditModeChanged;
|
||||
//@}
|
||||
@@ -177,7 +174,6 @@ protected:
|
||||
void slotActivatedObject(const ViewProvider&);
|
||||
void slotInEdit(const Gui::ViewProviderDocumentObject&);
|
||||
void slotResetEdit(const Gui::ViewProviderDocumentObject&);
|
||||
void slotActivatedViewProvider(const Gui::ViewProviderDocumentObject*, const char*);
|
||||
|
||||
public:
|
||||
/// message when a GuiDocument is about to vanish
|
||||
|
||||
@@ -121,9 +121,6 @@ EditingContextResolver::EditingContextResolver()
|
||||
app.signalActiveDocument.connect([this](const Document& doc) { onActiveDocument(doc); });
|
||||
app.signalActivateView.connect([this](const MDIView* view) { onActivateView(view); });
|
||||
app.signalActivateWorkbench.connect([this](const char*) { refresh(); });
|
||||
app.signalActivatedViewProvider.connect(
|
||||
[this](const ViewProviderDocumentObject*, const char*) { refresh(); }
|
||||
);
|
||||
}
|
||||
|
||||
EditingContextResolver::~EditingContextResolver()
|
||||
@@ -175,23 +172,6 @@ static App::DocumentObject* getActivePartObject()
|
||||
return view->getActiveObject<App::DocumentObject*>("part");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: get the active "pdbody" object from the active view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static App::DocumentObject* getActivePdBodyObject()
|
||||
{
|
||||
auto* guiDoc = Application::Instance->activeDocument();
|
||||
if (!guiDoc) {
|
||||
return nullptr;
|
||||
}
|
||||
auto* view = guiDoc->getActiveView();
|
||||
if (!view) {
|
||||
return nullptr;
|
||||
}
|
||||
return view->getActiveObject<App::DocumentObject*>("pdbody");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: get the label of the active "part" object
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,34 +213,6 @@ static QString getInEditLabel()
|
||||
|
||||
void EditingContextResolver::registerBuiltinContexts()
|
||||
{
|
||||
// --- PartDesign body active inside an assembly (supersedes assembly.edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("partdesign.in_assembly"),
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
/*.priority =*/95,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
auto* body = getActivePdBodyObject();
|
||||
if (!body || !objectIsDerivedFrom(body, "PartDesign::Body")) {
|
||||
return false;
|
||||
}
|
||||
// Only match when we're inside an assembly edit session
|
||||
auto* doc = Application::Instance->activeDocument();
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
auto* vp = doc->getInEdit();
|
||||
return vp && vpObjectIsDerivedFrom(vp, "Assembly::AssemblyObject");
|
||||
},
|
||||
});
|
||||
|
||||
// --- Sketcher edit (highest priority — VP in edit) ---
|
||||
registerContext({
|
||||
/*.id =*/QStringLiteral("sketcher.edit"),
|
||||
@@ -315,15 +267,11 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
QStringLiteral("Part Design Transformation Features")},
|
||||
/*.priority =*/40,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
auto* obj = getActivePdBodyObject();
|
||||
if (!obj) {
|
||||
obj = getActivePartObject();
|
||||
}
|
||||
auto* obj = getActivePartObject();
|
||||
if (!obj || !objectIsDerivedFrom(obj, "PartDesign::Body")) {
|
||||
return false;
|
||||
}
|
||||
@@ -344,18 +292,11 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
|
||||
/*.priority =*/30,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
auto* obj = getActivePdBodyObject();
|
||||
if (!obj) {
|
||||
obj = getActivePartObject();
|
||||
}
|
||||
auto* obj = getActivePartObject();
|
||||
return obj && objectIsDerivedFrom(obj, "PartDesign::Body");
|
||||
},
|
||||
});
|
||||
@@ -366,9 +307,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Assembly"),
|
||||
QStringLiteral("Assembly Joints"),
|
||||
QStringLiteral("Assembly Management")},
|
||||
{QStringLiteral("Assembly")},
|
||||
/*.priority =*/30,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -401,11 +340,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Part Design"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -418,12 +353,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Sketcher"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Green),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Sketcher"),
|
||||
QStringLiteral("Sketcher Tools"),
|
||||
QStringLiteral("Geometries"),
|
||||
QStringLiteral("Constraints"),
|
||||
QStringLiteral("B-Spline Tools"),
|
||||
QStringLiteral("Visual Helpers")},
|
||||
{QStringLiteral("Sketcher"), QStringLiteral("Sketcher Tools")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -436,9 +366,7 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Assembly"),
|
||||
QStringLiteral("Assembly Joints"),
|
||||
QStringLiteral("Assembly Management")},
|
||||
{QStringLiteral("Assembly")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -542,13 +470,6 @@ EditingContext EditingContextResolver::resolve() const
|
||||
if (label.contains(QStringLiteral("{name}"))) {
|
||||
// For edit-mode contexts, use the in-edit object name
|
||||
QString name = getInEditLabel();
|
||||
if (name.isEmpty()) {
|
||||
// Try pdbody first for PartDesign contexts
|
||||
auto* bodyObj = getActivePdBodyObject();
|
||||
if (bodyObj) {
|
||||
name = QString::fromUtf8(bodyObj->Label.getValue());
|
||||
}
|
||||
}
|
||||
if (name.isEmpty()) {
|
||||
name = getActivePartLabel();
|
||||
}
|
||||
@@ -609,25 +530,6 @@ QStringList EditingContextResolver::buildBreadcrumb(const EditingContext& ctx) c
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Assembly > Body breadcrumb for in-assembly part editing
|
||||
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
|
||||
auto* guiDoc = Application::Instance->activeDocument();
|
||||
if (guiDoc) {
|
||||
auto* vp = guiDoc->getInEdit();
|
||||
if (vp) {
|
||||
auto* vpd = dynamic_cast<ViewProviderDocumentObject*>(vp);
|
||||
if (vpd && vpd->getObject()) {
|
||||
crumbs << QString::fromUtf8(vpd->getObject()->Label.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
auto* body = getActivePdBodyObject();
|
||||
if (body) {
|
||||
crumbs << QString::fromUtf8(body->Label.getValue());
|
||||
}
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Always start with the active part/body/assembly label
|
||||
QString partLabel = getActivePartLabel();
|
||||
if (!partLabel.isEmpty()) {
|
||||
@@ -662,14 +564,6 @@ QStringList EditingContextResolver::buildBreadcrumbColors(const EditingContext&
|
||||
{
|
||||
QStringList colors;
|
||||
|
||||
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
|
||||
for (int i = 0; i < ctx.breadcrumb.size(); ++i) {
|
||||
colors << (i == 0 ? QLatin1String(CatppuccinMocha::Blue)
|
||||
: QLatin1String(CatppuccinMocha::Mauve));
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
if (ctx.breadcrumb.size() <= 1) {
|
||||
colors << ctx.color;
|
||||
return colors;
|
||||
|
||||
@@ -251,6 +251,7 @@ QDockWidget::title {
|
||||
text-align: left;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid #313244;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
QDockWidget::close-button,
|
||||
@@ -732,7 +733,7 @@ QGroupBox {
|
||||
background-color: #1e1e2e;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
margin-top: 16px;
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@@ -740,7 +741,7 @@ QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 12px;
|
||||
padding: 2px 4px;
|
||||
padding: 0 4px;
|
||||
color: #bac2de;
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
@@ -1233,7 +1234,7 @@ QSint--ActionGroup QToolButton {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 6px;
|
||||
min-height: 0px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
QSint--ActionGroup QToolButton:hover {
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
#include <Base/Interpreter.h>
|
||||
#include <Base/PyObjectBase.h>
|
||||
|
||||
#include <Mod/Assembly/Solver/OndselAdapter.h>
|
||||
|
||||
#include "AssemblyObject.h"
|
||||
#include "AssemblyLink.h"
|
||||
#include "BomObject.h"
|
||||
@@ -56,10 +54,6 @@ 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");
|
||||
|
||||
|
||||
|
||||
@@ -311,19 +311,6 @@ void AssemblyLink::updateContents()
|
||||
purgeTouched();
|
||||
}
|
||||
|
||||
// Generate an instance label for assembly components by appending a -N suffix.
|
||||
// All instances get a suffix (starting at -1) so that structured part numbers
|
||||
// like "P03-0001" are never mangled by UniqueNameManager's trailing-digit logic.
|
||||
static std::string makeInstanceLabel(App::Document* doc, const std::string& baseLabel)
|
||||
{
|
||||
for (int i = 1;; ++i) {
|
||||
std::string candidate = baseLabel + "-" + std::to_string(i);
|
||||
if (!doc->containsLabel(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AssemblyLink::synchronizeComponents()
|
||||
{
|
||||
App::Document* doc = getDocument();
|
||||
@@ -441,7 +428,7 @@ void AssemblyLink::synchronizeComponents()
|
||||
auto* subAsmLink = static_cast<AssemblyLink*>(newObj);
|
||||
subAsmLink->LinkedObject.setValue(obj);
|
||||
subAsmLink->Rigid.setValue(asmLink->Rigid.getValue());
|
||||
subAsmLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
|
||||
subAsmLink->Label.setValue(obj->Label.getValue());
|
||||
addObject(subAsmLink);
|
||||
link = subAsmLink;
|
||||
}
|
||||
@@ -453,7 +440,7 @@ void AssemblyLink::synchronizeComponents()
|
||||
);
|
||||
newLink->LinkedObject.setValue(srcLink->getTrueLinkedObject(false));
|
||||
|
||||
newLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
|
||||
newLink->Label.setValue(obj->Label.getValue());
|
||||
addObject(newLink);
|
||||
|
||||
newLink->ElementCount.setValue(srcLink->ElementCount.getValue());
|
||||
@@ -474,7 +461,7 @@ void AssemblyLink::synchronizeComponents()
|
||||
App::DocumentObject* newObj = doc->addObject("App::Link", obj->getNameInDocument());
|
||||
auto* newLink = static_cast<App::Link*>(newObj);
|
||||
newLink->LinkedObject.setValue(obj);
|
||||
newLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
|
||||
newLink->Label.setValue(obj->Label.getValue());
|
||||
addObject(newLink);
|
||||
link = newLink;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,21 +25,24 @@
|
||||
#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>
|
||||
|
||||
namespace KCSolve
|
||||
#include <OndselSolver/enum.h>
|
||||
|
||||
namespace MbD
|
||||
{
|
||||
class IKCSolver;
|
||||
} // namespace KCSolve
|
||||
class ASMTPart;
|
||||
class ASMTAssembly;
|
||||
class ASMTJoint;
|
||||
class ASMTMarker;
|
||||
class ASMTPart;
|
||||
} // namespace MbD
|
||||
|
||||
namespace App
|
||||
{
|
||||
@@ -84,7 +87,6 @@ public:
|
||||
return "AssemblyGui::ViewProviderAssembly";
|
||||
}
|
||||
|
||||
void setupObject() override;
|
||||
App::DocumentObjectExecReturn* execute() override;
|
||||
void onChanged(const App::Property* prop) override;
|
||||
/* Solve the assembly. It will update first the joints, solve, update placements of the parts
|
||||
@@ -99,15 +101,11 @@ public:
|
||||
void postDrag();
|
||||
void savePlacementsForUndo();
|
||||
void undoSolve();
|
||||
void resetSolver();
|
||||
void clearUndo();
|
||||
|
||||
void exportAsASMT(std::string fileName);
|
||||
|
||||
/// Build the assembly constraint graph without solving.
|
||||
/// Returns an empty SolveContext if no parts are grounded.
|
||||
KCSolve::SolveContext getSolveContext();
|
||||
|
||||
Base::Placement getMbdPlacement(std::shared_ptr<MbD::ASMTPart> mbdPart);
|
||||
bool validateNewPlacements();
|
||||
void setNewPlacements();
|
||||
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
|
||||
@@ -116,8 +114,42 @@ 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>
|
||||
@@ -137,6 +169,8 @@ 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);
|
||||
@@ -176,7 +210,7 @@ public:
|
||||
|
||||
std::vector<App::DocumentObject*> getMotionsFromSimulation(App::DocumentObject* sim);
|
||||
|
||||
bool isJointValid(App::DocumentObject* joint);
|
||||
bool isMbDJointValid(App::DocumentObject* joint);
|
||||
|
||||
bool isEmpty() const;
|
||||
int numberOfComponents() const;
|
||||
@@ -225,65 +259,17 @@ public:
|
||||
fastsignals::signal<void()> signalSolverUpdate;
|
||||
|
||||
private:
|
||||
// ── 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::shared_ptr<MbD::ASMTAssembly> mbdAssembly;
|
||||
|
||||
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;
|
||||
|
||||
bool bundleFixed;
|
||||
|
||||
// Drag diagnostic counters (reset in preDrag, reported in postDrag)
|
||||
int dragStepCount_ = 0;
|
||||
int dragStepRejected_ = 0;
|
||||
|
||||
int lastDoF;
|
||||
bool lastHasConflict;
|
||||
bool lastHasRedundancies;
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from App.DocumentObject import DocumentObject
|
||||
from App.Part import Part
|
||||
from Base.Metadata import constmethod, export
|
||||
|
||||
from App.Part import Part
|
||||
from App.DocumentObject import DocumentObject
|
||||
|
||||
@export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly")
|
||||
class AssemblyObject(Part):
|
||||
"""
|
||||
@@ -118,9 +119,7 @@ class AssemblyObject(Part):
|
||||
...
|
||||
|
||||
@constmethod
|
||||
def isJointConnectingPartToGround(
|
||||
self, joint: DocumentObject, prop_name: str, /
|
||||
) -> Any:
|
||||
def isJointConnectingPartToGround(self, joint: DocumentObject, prop_name: str, /) -> Any:
|
||||
"""
|
||||
Check if a joint is connecting a part to the ground.
|
||||
|
||||
@@ -154,16 +153,6 @@ class AssemblyObject(Part):
|
||||
"""
|
||||
...
|
||||
|
||||
@constmethod
|
||||
def getSolveContext(self) -> dict:
|
||||
"""Build the assembly constraint graph as a serializable dict.
|
||||
|
||||
Returns a dict matching kcsolve.SolveContext.to_dict() format,
|
||||
or an empty dict if the assembly has no grounded parts.
|
||||
Does NOT trigger a solve.
|
||||
"""
|
||||
...
|
||||
|
||||
@constmethod
|
||||
def getDownstreamParts(
|
||||
self, start_part: DocumentObject, joint_to_ignore: DocumentObject, /
|
||||
|
||||
@@ -21,161 +21,13 @@
|
||||
* *
|
||||
***************************************************************************/
|
||||
|
||||
|
||||
// inclusion of the generated files (generated out of AssemblyObject.xml)
|
||||
|
||||
// inclusion of the generated files (generated out of AssemblyObject.xml)
|
||||
#include "AssemblyObjectPy.h"
|
||||
#include "AssemblyObjectPy.cpp"
|
||||
|
||||
#include <Mod/Assembly/Solver/SolverRegistry.h>
|
||||
|
||||
using namespace Assembly;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// ── Enum-to-string tables for dict serialization ───────────────────
|
||||
// String values must match kcsolve_py.cpp py::enum_ .value() names exactly.
|
||||
|
||||
const char* baseJointKindStr(KCSolve::BaseJointKind k)
|
||||
{
|
||||
switch (k) {
|
||||
case KCSolve::BaseJointKind::Coincident: return "Coincident";
|
||||
case KCSolve::BaseJointKind::PointOnLine: return "PointOnLine";
|
||||
case KCSolve::BaseJointKind::PointInPlane: return "PointInPlane";
|
||||
case KCSolve::BaseJointKind::Concentric: return "Concentric";
|
||||
case KCSolve::BaseJointKind::Tangent: return "Tangent";
|
||||
case KCSolve::BaseJointKind::Planar: return "Planar";
|
||||
case KCSolve::BaseJointKind::LineInPlane: return "LineInPlane";
|
||||
case KCSolve::BaseJointKind::Parallel: return "Parallel";
|
||||
case KCSolve::BaseJointKind::Perpendicular: return "Perpendicular";
|
||||
case KCSolve::BaseJointKind::Angle: return "Angle";
|
||||
case KCSolve::BaseJointKind::Fixed: return "Fixed";
|
||||
case KCSolve::BaseJointKind::Revolute: return "Revolute";
|
||||
case KCSolve::BaseJointKind::Cylindrical: return "Cylindrical";
|
||||
case KCSolve::BaseJointKind::Slider: return "Slider";
|
||||
case KCSolve::BaseJointKind::Ball: return "Ball";
|
||||
case KCSolve::BaseJointKind::Screw: return "Screw";
|
||||
case KCSolve::BaseJointKind::Universal: return "Universal";
|
||||
case KCSolve::BaseJointKind::Gear: return "Gear";
|
||||
case KCSolve::BaseJointKind::RackPinion: return "RackPinion";
|
||||
case KCSolve::BaseJointKind::Cam: return "Cam";
|
||||
case KCSolve::BaseJointKind::Slot: return "Slot";
|
||||
case KCSolve::BaseJointKind::DistancePointPoint: return "DistancePointPoint";
|
||||
case KCSolve::BaseJointKind::DistanceCylSph: return "DistanceCylSph";
|
||||
case KCSolve::BaseJointKind::Custom: return "Custom";
|
||||
}
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
const char* limitKindStr(KCSolve::Constraint::Limit::Kind k)
|
||||
{
|
||||
switch (k) {
|
||||
case KCSolve::Constraint::Limit::Kind::TranslationMin: return "TranslationMin";
|
||||
case KCSolve::Constraint::Limit::Kind::TranslationMax: return "TranslationMax";
|
||||
case KCSolve::Constraint::Limit::Kind::RotationMin: return "RotationMin";
|
||||
case KCSolve::Constraint::Limit::Kind::RotationMax: return "RotationMax";
|
||||
}
|
||||
return "TranslationMin";
|
||||
}
|
||||
|
||||
const char* motionKindStr(KCSolve::MotionDef::Kind k)
|
||||
{
|
||||
switch (k) {
|
||||
case KCSolve::MotionDef::Kind::Rotational: return "Rotational";
|
||||
case KCSolve::MotionDef::Kind::Translational: return "Translational";
|
||||
case KCSolve::MotionDef::Kind::General: return "General";
|
||||
}
|
||||
return "Rotational";
|
||||
}
|
||||
|
||||
// ── Python dict builders ───────────────────────────────────────────
|
||||
// Layout matches solve_context_to_dict() in kcsolve_py.cpp exactly.
|
||||
|
||||
Py::Dict transformToDict(const KCSolve::Transform& t)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("position", Py::TupleN(
|
||||
Py::Float(t.position[0]),
|
||||
Py::Float(t.position[1]),
|
||||
Py::Float(t.position[2])));
|
||||
d.setItem("quaternion", Py::TupleN(
|
||||
Py::Float(t.quaternion[0]),
|
||||
Py::Float(t.quaternion[1]),
|
||||
Py::Float(t.quaternion[2]),
|
||||
Py::Float(t.quaternion[3])));
|
||||
return d;
|
||||
}
|
||||
|
||||
Py::Dict partToDict(const KCSolve::Part& p)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("id", Py::String(p.id));
|
||||
d.setItem("placement", transformToDict(p.placement));
|
||||
d.setItem("mass", Py::Float(p.mass));
|
||||
d.setItem("grounded", Py::Boolean(p.grounded));
|
||||
return d;
|
||||
}
|
||||
|
||||
Py::Dict limitToDict(const KCSolve::Constraint::Limit& lim)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("kind", Py::String(limitKindStr(lim.kind)));
|
||||
d.setItem("value", Py::Float(lim.value));
|
||||
d.setItem("tolerance", Py::Float(lim.tolerance));
|
||||
return d;
|
||||
}
|
||||
|
||||
Py::Dict constraintToDict(const KCSolve::Constraint& c)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("id", Py::String(c.id));
|
||||
d.setItem("part_i", Py::String(c.part_i));
|
||||
d.setItem("marker_i", transformToDict(c.marker_i));
|
||||
d.setItem("part_j", Py::String(c.part_j));
|
||||
d.setItem("marker_j", transformToDict(c.marker_j));
|
||||
d.setItem("type", Py::String(baseJointKindStr(c.type)));
|
||||
|
||||
Py::List params;
|
||||
for (double v : c.params) {
|
||||
params.append(Py::Float(v));
|
||||
}
|
||||
d.setItem("params", params);
|
||||
|
||||
Py::List lims;
|
||||
for (const auto& l : c.limits) {
|
||||
lims.append(limitToDict(l));
|
||||
}
|
||||
d.setItem("limits", lims);
|
||||
d.setItem("activated", Py::Boolean(c.activated));
|
||||
return d;
|
||||
}
|
||||
|
||||
Py::Dict motionToDict(const KCSolve::MotionDef& m)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("kind", Py::String(motionKindStr(m.kind)));
|
||||
d.setItem("joint_id", Py::String(m.joint_id));
|
||||
d.setItem("marker_i", Py::String(m.marker_i));
|
||||
d.setItem("marker_j", Py::String(m.marker_j));
|
||||
d.setItem("rotation_expr", Py::String(m.rotation_expr));
|
||||
d.setItem("translation_expr", Py::String(m.translation_expr));
|
||||
return d;
|
||||
}
|
||||
|
||||
Py::Dict simToDict(const KCSolve::SimulationParams& s)
|
||||
{
|
||||
Py::Dict d;
|
||||
d.setItem("t_start", Py::Float(s.t_start));
|
||||
d.setItem("t_end", Py::Float(s.t_end));
|
||||
d.setItem("h_out", Py::Float(s.h_out));
|
||||
d.setItem("h_min", Py::Float(s.h_min));
|
||||
d.setItem("h_max", Py::Float(s.h_max));
|
||||
d.setItem("error_tol", Py::Float(s.error_tol));
|
||||
return d;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// returns a string which represents the object e.g. when printed in python
|
||||
std::string AssemblyObjectPy::representation() const
|
||||
{
|
||||
@@ -391,52 +243,3 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const
|
||||
|
||||
return Py::new_reference_to(ret);
|
||||
}
|
||||
|
||||
PyObject* AssemblyObjectPy::getSolveContext(PyObject* args) const
|
||||
{
|
||||
if (!PyArg_ParseTuple(args, "")) {
|
||||
return nullptr;
|
||||
}
|
||||
PY_TRY
|
||||
{
|
||||
KCSolve::SolveContext ctx = getAssemblyObjectPtr()->getSolveContext();
|
||||
|
||||
// Empty context (no grounded parts) → return empty dict
|
||||
if (ctx.parts.empty()) {
|
||||
return Py::new_reference_to(Py::Dict());
|
||||
}
|
||||
|
||||
Py::Dict d;
|
||||
d.setItem("api_version", Py::Long(KCSolve::API_VERSION_MAJOR));
|
||||
|
||||
Py::List parts;
|
||||
for (const auto& p : ctx.parts) {
|
||||
parts.append(partToDict(p));
|
||||
}
|
||||
d.setItem("parts", parts);
|
||||
|
||||
Py::List constraints;
|
||||
for (const auto& c : ctx.constraints) {
|
||||
constraints.append(constraintToDict(c));
|
||||
}
|
||||
d.setItem("constraints", constraints);
|
||||
|
||||
Py::List motions;
|
||||
for (const auto& m : ctx.motions) {
|
||||
motions.append(motionToDict(m));
|
||||
}
|
||||
d.setItem("motions", motions);
|
||||
|
||||
if (ctx.simulation.has_value()) {
|
||||
d.setItem("simulation", simToDict(*ctx.simulation));
|
||||
}
|
||||
else {
|
||||
d.setItem("simulation", Py::None());
|
||||
}
|
||||
|
||||
d.setItem("bundle_fixed", Py::Boolean(ctx.bundle_fixed));
|
||||
|
||||
return Py::new_reference_to(d);
|
||||
}
|
||||
PY_CATCH;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
#include <BRepAdaptor_Curve.hxx>
|
||||
#include <BRepAdaptor_Surface.hxx>
|
||||
#include <TopExp_Explorer.hxx>
|
||||
#include <TopoDS.hxx>
|
||||
#include <TopoDS_Face.hxx>
|
||||
#include <gp_Circ.hxx>
|
||||
@@ -55,56 +54,10 @@
|
||||
|
||||
namespace PartApp = Part;
|
||||
|
||||
FC_LOG_LEVEL_INIT("Assembly", true, true, true)
|
||||
|
||||
// ======================================= Utils ======================================
|
||||
namespace Assembly
|
||||
{
|
||||
|
||||
const char* distanceTypeName(DistanceType dt)
|
||||
{
|
||||
switch (dt) {
|
||||
case DistanceType::PointPoint: return "PointPoint";
|
||||
case DistanceType::LineLine: return "LineLine";
|
||||
case DistanceType::LineCircle: return "LineCircle";
|
||||
case DistanceType::CircleCircle: return "CircleCircle";
|
||||
case DistanceType::PlanePlane: return "PlanePlane";
|
||||
case DistanceType::PlaneCylinder: return "PlaneCylinder";
|
||||
case DistanceType::PlaneSphere: return "PlaneSphere";
|
||||
case DistanceType::PlaneCone: return "PlaneCone";
|
||||
case DistanceType::PlaneTorus: return "PlaneTorus";
|
||||
case DistanceType::CylinderCylinder: return "CylinderCylinder";
|
||||
case DistanceType::CylinderSphere: return "CylinderSphere";
|
||||
case DistanceType::CylinderCone: return "CylinderCone";
|
||||
case DistanceType::CylinderTorus: return "CylinderTorus";
|
||||
case DistanceType::ConeCone: return "ConeCone";
|
||||
case DistanceType::ConeTorus: return "ConeTorus";
|
||||
case DistanceType::ConeSphere: return "ConeSphere";
|
||||
case DistanceType::TorusTorus: return "TorusTorus";
|
||||
case DistanceType::TorusSphere: return "TorusSphere";
|
||||
case DistanceType::SphereSphere: return "SphereSphere";
|
||||
case DistanceType::PointPlane: return "PointPlane";
|
||||
case DistanceType::PointCylinder: return "PointCylinder";
|
||||
case DistanceType::PointSphere: return "PointSphere";
|
||||
case DistanceType::PointCone: return "PointCone";
|
||||
case DistanceType::PointTorus: return "PointTorus";
|
||||
case DistanceType::LinePlane: return "LinePlane";
|
||||
case DistanceType::LineCylinder: return "LineCylinder";
|
||||
case DistanceType::LineSphere: return "LineSphere";
|
||||
case DistanceType::LineCone: return "LineCone";
|
||||
case DistanceType::LineTorus: return "LineTorus";
|
||||
case DistanceType::CurvePlane: return "CurvePlane";
|
||||
case DistanceType::CurveCylinder: return "CurveCylinder";
|
||||
case DistanceType::CurveSphere: return "CurveSphere";
|
||||
case DistanceType::CurveCone: return "CurveCone";
|
||||
case DistanceType::CurveTorus: return "CurveTorus";
|
||||
case DistanceType::PointLine: return "PointLine";
|
||||
case DistanceType::PointCurve: return "PointCurve";
|
||||
case DistanceType::Other: return "Other";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
void swapJCS(const App::DocumentObject* joint)
|
||||
{
|
||||
if (!joint) {
|
||||
@@ -198,120 +151,6 @@ double getEdgeRadius(const App::DocumentObject* obj, const std::string& elt)
|
||||
return sf.GetType() == GeomAbs_Circle ? sf.Circle().Radius() : 0.0;
|
||||
}
|
||||
|
||||
/// Determine whether \a obj represents a planar datum when referenced with an
|
||||
/// empty element type (bare sub-name ending with ".").
|
||||
///
|
||||
/// Covers three independent class hierarchies:
|
||||
/// 1. App::Plane (origin planes, Part::DatumPlane)
|
||||
/// 2. Part::Datum (PartDesign::Plane — not derived from App::Plane)
|
||||
/// 3. Any Part::Feature whose whole-object shape is a single planar face
|
||||
/// (e.g. Part::Plane primitive referenced without an element)
|
||||
static bool isDatumPlane(const App::DocumentObject* obj)
|
||||
{
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Origin planes and Part::DatumPlane (both inherit App::Plane).
|
||||
if (obj->isDerivedFrom<App::Plane>()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PartDesign datum objects inherit Part::Datum but NOT App::Plane.
|
||||
// Part::Datum is also the base for PartDesign::Line and PartDesign::Point,
|
||||
// so inspect the shape to confirm it is actually planar.
|
||||
if (obj->isDerivedFrom<PartApp::Datum>()) {
|
||||
auto* feat = static_cast<const PartApp::Feature*>(obj);
|
||||
const auto& shape = feat->Shape.getShape().getShape();
|
||||
if (shape.IsNull()) {
|
||||
return false;
|
||||
}
|
||||
TopExp_Explorer ex(shape, TopAbs_FACE);
|
||||
if (ex.More()) {
|
||||
BRepAdaptor_Surface sf(TopoDS::Face(ex.Current()));
|
||||
return sf.GetType() == GeomAbs_Plane;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback for any Part::Feature (e.g. Part::Plane primitive) referenced
|
||||
// bare — if its shape is a single planar face, treat it as a datum plane.
|
||||
if (auto* feat = dynamic_cast<const PartApp::Feature*>(obj)) {
|
||||
const auto& shape = feat->Shape.getShape().getShape();
|
||||
if (shape.IsNull()) {
|
||||
return false;
|
||||
}
|
||||
TopExp_Explorer ex(shape, TopAbs_FACE);
|
||||
if (!ex.More()) {
|
||||
return false;
|
||||
}
|
||||
BRepAdaptor_Surface sf(TopoDS::Face(ex.Current()));
|
||||
if (sf.GetType() != GeomAbs_Plane) {
|
||||
return false;
|
||||
}
|
||||
ex.Next();
|
||||
// Only treat as datum if there is exactly one face — a multi-face
|
||||
// solid referenced bare is ambiguous and should not be classified.
|
||||
return !ex.More();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Same idea for datum lines (App::Line, PartDesign::Line, etc.).
|
||||
static bool isDatumLine(const App::DocumentObject* obj)
|
||||
{
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (obj->isDerivedFrom<App::Line>()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (obj->isDerivedFrom<PartApp::Datum>()) {
|
||||
auto* feat = static_cast<const PartApp::Feature*>(obj);
|
||||
const auto& shape = feat->Shape.getShape().getShape();
|
||||
if (shape.IsNull()) {
|
||||
return false;
|
||||
}
|
||||
TopExp_Explorer ex(shape, TopAbs_EDGE);
|
||||
if (ex.More()) {
|
||||
BRepAdaptor_Curve cv(TopoDS::Edge(ex.Current()));
|
||||
return cv.GetType() == GeomAbs_Line;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Same idea for datum points (App::Point, PartDesign::Point, etc.).
|
||||
static bool isDatumPoint(const App::DocumentObject* obj)
|
||||
{
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (obj->isDerivedFrom<App::Point>()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (obj->isDerivedFrom<PartApp::Datum>()) {
|
||||
auto* feat = static_cast<const PartApp::Feature*>(obj);
|
||||
const auto& shape = feat->Shape.getShape().getShape();
|
||||
if (shape.IsNull()) {
|
||||
return false;
|
||||
}
|
||||
// A datum point has a vertex but no edges or faces.
|
||||
TopExp_Explorer exE(shape, TopAbs_EDGE);
|
||||
TopExp_Explorer exV(shape, TopAbs_VERTEX);
|
||||
return !exE.More() && exV.More();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
DistanceType getDistanceType(App::DocumentObject* joint)
|
||||
{
|
||||
if (!joint) {
|
||||
@@ -325,179 +164,6 @@ DistanceType getDistanceType(App::DocumentObject* joint)
|
||||
auto* obj1 = getLinkedObjFromRef(joint, "Reference1");
|
||||
auto* obj2 = getLinkedObjFromRef(joint, "Reference2");
|
||||
|
||||
// Datum objects referenced bare have empty element types (sub-name
|
||||
// ends with "."). PartDesign datums referenced through a body can
|
||||
// also produce non-standard element types like "Plane" (from a
|
||||
// sub-name such as "Body.DatumPlane.Plane" — Part::Datum::getSubObject
|
||||
// ignores the trailing element, but splitSubName still extracts it).
|
||||
//
|
||||
// Detect these before the main geometry chain, which only handles
|
||||
// the standard Face/Edge/Vertex element types.
|
||||
//
|
||||
// isDatumPlane/Line/Point cover all three independent hierarchies:
|
||||
// - App::Plane / App::Line / App::Point (origin datums)
|
||||
// - Part::Datum subclasses (PartDesign datums)
|
||||
// - Part::Feature with single-face shape (Part::Plane primitive, bare ref)
|
||||
auto isNonGeomElement = [](const std::string& t) {
|
||||
return t != "Face" && t != "Edge" && t != "Vertex";
|
||||
};
|
||||
const bool datumPlane1 = isNonGeomElement(type1) && isDatumPlane(obj1);
|
||||
const bool datumPlane2 = isNonGeomElement(type2) && isDatumPlane(obj2);
|
||||
const bool datumLine1 = isNonGeomElement(type1) && !datumPlane1 && isDatumLine(obj1);
|
||||
const bool datumLine2 = isNonGeomElement(type2) && !datumPlane2 && isDatumLine(obj2);
|
||||
const bool datumPoint1 = isNonGeomElement(type1) && !datumPlane1 && !datumLine1 && isDatumPoint(obj1);
|
||||
const bool datumPoint2 = isNonGeomElement(type2) && !datumPlane2 && !datumLine2 && isDatumPoint(obj2);
|
||||
const bool datum1 = datumPlane1 || datumLine1 || datumPoint1;
|
||||
const bool datum2 = datumPlane2 || datumLine2 || datumPoint2;
|
||||
|
||||
if (datum1 || datum2) {
|
||||
// Map each datum side to a synthetic element type so the same
|
||||
// classification logic applies regardless of which hierarchy
|
||||
// the object comes from.
|
||||
auto syntheticType = [](bool isPlane, bool isLine, bool isPoint,
|
||||
const std::string& elemType) -> std::string {
|
||||
if (isPlane) return "Face";
|
||||
if (isLine) return "Edge";
|
||||
if (isPoint) return "Vertex";
|
||||
return elemType; // non-datum side keeps its real type
|
||||
};
|
||||
|
||||
const std::string syn1 = syntheticType(datumPlane1, datumLine1, datumPoint1, type1);
|
||||
const std::string syn2 = syntheticType(datumPlane2, datumLine2, datumPoint2, type2);
|
||||
|
||||
// Both sides are datum planes.
|
||||
if (datumPlane1 && datumPlane2) {
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datum+datum → PlanePlane");
|
||||
return DistanceType::PlanePlane;
|
||||
}
|
||||
|
||||
// One side is a datum plane, the other has a real element type
|
||||
// (or is another datum kind).
|
||||
// For PointPlane/LinePlane, the solver's PointInPlaneConstraint
|
||||
// reads the plane normal from marker_j (Reference2). Unlike
|
||||
// real Face+Vertex joints (where both Placements carry the
|
||||
// face normal from findPlacement), datum planes only carry
|
||||
// their normal through computeMarkerTransform. So the datum
|
||||
// plane must end up on Reference2 for the normal to reach marker_j.
|
||||
//
|
||||
// For PlanePlane the convention matches the existing Face+Face
|
||||
// path (plane on Reference1).
|
||||
if (datumPlane1 || datumPlane2) {
|
||||
const auto& otherSyn = datumPlane1 ? syn2 : syn1;
|
||||
|
||||
if (otherSyn == "Vertex" || otherSyn == "Edge") {
|
||||
// Datum plane must be on Reference2 (j side).
|
||||
if (datumPlane1) {
|
||||
swapJCS(joint); // move datum from Ref1 → Ref2
|
||||
}
|
||||
DistanceType result = (otherSyn == "Vertex")
|
||||
? DistanceType::PointPlane : DistanceType::LinePlane;
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datum+" << otherSyn << " → "
|
||||
<< distanceTypeName(result)
|
||||
<< (datumPlane1 ? " (swapped)" : ""));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Face + datum plane or datum plane + datum plane → PlanePlane.
|
||||
// No swap needed: PlanarConstraint is symmetric (uses both
|
||||
// z_i and z_j), and preserving the original Reference order
|
||||
// keeps the initial Placement values consistent so the solver
|
||||
// stays in the correct orientation branch.
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datum+" << otherSyn << " → PlanePlane");
|
||||
return DistanceType::PlanePlane;
|
||||
}
|
||||
|
||||
// Datum line or datum point paired with a real element type.
|
||||
// Map to the appropriate pair using synthetic types and fall
|
||||
// through to the main geometry chain below. The synthetic
|
||||
// types ("Edge", "Vertex") will match the existing if-else
|
||||
// branches — but those branches call isEdgeType/isFaceType on
|
||||
// the object, which requires a real sub-element name. For
|
||||
// datum lines/points the element is empty, so we classify
|
||||
// directly here.
|
||||
if (datumLine1 && datumLine2) {
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumLine+datumLine → LineLine");
|
||||
return DistanceType::LineLine;
|
||||
}
|
||||
if (datumPoint1 && datumPoint2) {
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumPoint+datumPoint → PointPoint");
|
||||
return DistanceType::PointPoint;
|
||||
}
|
||||
if ((datumLine1 && datumPoint2) || (datumPoint1 && datumLine2)) {
|
||||
if (datumPoint1) {
|
||||
swapJCS(joint); // line first
|
||||
}
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumLine+datumPoint → PointLine");
|
||||
return DistanceType::PointLine;
|
||||
}
|
||||
|
||||
// One datum line/point + one real element type.
|
||||
if (datumLine1 || datumLine2) {
|
||||
const auto& otherSyn = datumLine1 ? syn2 : syn1;
|
||||
if (otherSyn == "Face") {
|
||||
// Line + Face — need line on Reference2 (edge side).
|
||||
if (datumLine1) {
|
||||
swapJCS(joint);
|
||||
}
|
||||
// We don't know the face type without inspecting the shape,
|
||||
// but LinePlane is the most common and safest classification.
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumLine+Face → LinePlane");
|
||||
return DistanceType::LinePlane;
|
||||
}
|
||||
if (otherSyn == "Vertex") {
|
||||
if (datumLine2) {
|
||||
swapJCS(joint); // line first
|
||||
}
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumLine+Vertex → PointLine");
|
||||
return DistanceType::PointLine;
|
||||
}
|
||||
if (otherSyn == "Edge") {
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumLine+Edge → LineLine");
|
||||
return DistanceType::LineLine;
|
||||
}
|
||||
}
|
||||
if (datumPoint1 || datumPoint2) {
|
||||
const auto& otherSyn = datumPoint1 ? syn2 : syn1;
|
||||
if (otherSyn == "Face") {
|
||||
// Point + Face — face first, point second.
|
||||
if (!datumPoint2) {
|
||||
swapJCS(joint); // put face on Ref1
|
||||
}
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumPoint+Face → PointPlane");
|
||||
return DistanceType::PointPlane;
|
||||
}
|
||||
if (otherSyn == "Edge") {
|
||||
// Edge first, point second.
|
||||
if (datumPoint1) {
|
||||
swapJCS(joint);
|
||||
}
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumPoint+Edge → PointLine");
|
||||
return DistanceType::PointLine;
|
||||
}
|
||||
if (otherSyn == "Vertex") {
|
||||
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — datumPoint+Vertex → PointPoint");
|
||||
return DistanceType::PointPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, it's an unrecognized datum combination.
|
||||
FC_WARN("Assembly : getDistanceType('" << joint->getFullName()
|
||||
<< "') — unrecognized datum combination (syn1="
|
||||
<< syn1 << ", syn2=" << syn2 << ")");
|
||||
}
|
||||
|
||||
if (type1 == "Vertex" && type2 == "Vertex") {
|
||||
return DistanceType::PointPoint;
|
||||
}
|
||||
@@ -925,19 +591,6 @@ App::DocumentObject* getObjFromRef(App::DocumentObject* comp, const std::string&
|
||||
if (obj->isDerivedFrom<App::Part>() || obj->isLinkGroup()) {
|
||||
continue;
|
||||
}
|
||||
else if (obj->isDerivedFrom<App::LocalCoordinateSystem>()) {
|
||||
// Resolve LCS → child datum element (e.g. Origin → XY_Plane)
|
||||
auto nextIt = std::next(it);
|
||||
if (nextIt != names.end()) {
|
||||
for (auto* child : obj->getOutList()) {
|
||||
if (child->getNameInDocument() == *nextIt
|
||||
&& child->isDerivedFrom<App::DatumElement>()) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
else if (obj->isDerivedFrom<PartDesign::Body>()) {
|
||||
return handlePartDesignBody(obj, it);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ AssemblyExport double getFaceRadius(const App::DocumentObject* obj, const std::s
|
||||
AssemblyExport double getEdgeRadius(const App::DocumentObject* obj, const std::string& elName);
|
||||
|
||||
AssemblyExport DistanceType getDistanceType(App::DocumentObject* joint);
|
||||
AssemblyExport const char* distanceTypeName(DistanceType dt);
|
||||
AssemblyExport JointGroup* getJointGroup(const App::Part* part);
|
||||
|
||||
AssemblyExport std::vector<App::DocumentObject*> getAssemblyComponents(const AssemblyObject* assembly);
|
||||
|
||||
@@ -5,7 +5,7 @@ set(Assembly_LIBS
|
||||
PartDesign
|
||||
Spreadsheet
|
||||
FreeCADApp
|
||||
KCSolve
|
||||
OndselSolver
|
||||
)
|
||||
|
||||
generate_from_py(AssemblyObject)
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
# 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/>. *
|
||||
# *
|
||||
# ***************************************************************************/
|
||||
|
||||
"""
|
||||
Tests for assembly origin reference planes.
|
||||
|
||||
Verifies that new assemblies have properly labeled, grounded origin planes
|
||||
and that joints can reference them for solving.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import FreeCAD as App
|
||||
import JointObject
|
||||
import UtilsAssembly
|
||||
|
||||
|
||||
class TestAssemblyOriginPlanes(unittest.TestCase):
|
||||
"""Tests for assembly origin planes (Top/Front/Right)."""
|
||||
|
||||
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 _get_origin(self):
|
||||
return self.assembly.Origin
|
||||
|
||||
def _make_box(self, x=0, y=0, z=0, size=10):
|
||||
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 _make_joint(self, joint_type, ref1, ref2):
|
||||
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
|
||||
|
||||
# ── Structure tests ─────────────────────────────────────────────
|
||||
|
||||
def test_assembly_has_origin(self):
|
||||
"""New assembly has an Origin with 3 planes, 3 axes, 1 point."""
|
||||
origin = self._get_origin()
|
||||
self.assertIsNotNone(origin)
|
||||
self.assertTrue(origin.isDerivedFrom("App::LocalCoordinateSystem"))
|
||||
|
||||
planes = origin.planes()
|
||||
self.assertEqual(len(planes), 3)
|
||||
|
||||
axes = origin.axes()
|
||||
self.assertEqual(len(axes), 3)
|
||||
|
||||
def test_origin_planes_labeled(self):
|
||||
"""Origin planes are labeled Top, Front, Right."""
|
||||
origin = self._get_origin()
|
||||
|
||||
xy = origin.getXY()
|
||||
xz = origin.getXZ()
|
||||
yz = origin.getYZ()
|
||||
|
||||
self.assertEqual(xy.Label, "Top")
|
||||
self.assertEqual(xz.Label, "Front")
|
||||
self.assertEqual(yz.Label, "Right")
|
||||
|
||||
def test_origin_planes_have_correct_roles(self):
|
||||
"""Origin planes retain correct internal Role names."""
|
||||
origin = self._get_origin()
|
||||
|
||||
self.assertEqual(origin.getXY().Role, "XY_Plane")
|
||||
self.assertEqual(origin.getXZ().Role, "XZ_Plane")
|
||||
self.assertEqual(origin.getYZ().Role, "YZ_Plane")
|
||||
|
||||
# ── Grounding tests ─────────────────────────────────────────────
|
||||
|
||||
def test_origin_in_grounded_set(self):
|
||||
"""Origin is part of the assembly's grounded set."""
|
||||
grounded = self.assembly.getGroundedParts()
|
||||
origin = self._get_origin()
|
||||
|
||||
grounded_names = {obj.Name for obj in grounded}
|
||||
self.assertIn(origin.Name, grounded_names)
|
||||
|
||||
# ── Reference resolution tests ──────────────────────────────────
|
||||
|
||||
def test_getObject_resolves_origin_plane(self):
|
||||
"""UtilsAssembly.getObject correctly resolves an origin plane ref."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY()
|
||||
|
||||
# Ref structure: [Origin, ["XY_Plane.", "XY_Plane."]]
|
||||
ref = [origin, [xy.Name + ".", xy.Name + "."]]
|
||||
obj = UtilsAssembly.getObject(ref)
|
||||
self.assertEqual(obj, xy)
|
||||
|
||||
def test_findPlacement_origin_plane_returns_identity(self):
|
||||
"""findPlacement for an origin plane (whole-object) returns identity."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY()
|
||||
|
||||
ref = [origin, [xy.Name + ".", xy.Name + "."]]
|
||||
plc = UtilsAssembly.findPlacement(ref)
|
||||
|
||||
# For datum planes with no element, identity is returned.
|
||||
# The actual orientation comes from the solver's getGlobalPlacement.
|
||||
self.assertTrue(
|
||||
plc.isSame(App.Placement(), 1e-6),
|
||||
"findPlacement for origin plane should return identity",
|
||||
)
|
||||
|
||||
# ── Joint / solve tests ─────────────────────────────────────────
|
||||
|
||||
def test_fixed_joint_to_origin_plane(self):
|
||||
"""Fixed joint referencing an origin plane solves correctly."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY()
|
||||
|
||||
box = self._make_box(50, 50, 50)
|
||||
|
||||
# Fixed joint (type 0): origin XY plane ↔ box Face1 (bottom, Z=0)
|
||||
self._make_joint(
|
||||
0,
|
||||
[origin, [xy.Name + ".", xy.Name + "."]],
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
)
|
||||
|
||||
# After solve, the box should have moved so that its Face1 (bottom)
|
||||
# aligns with the XY plane (Z=0). The box bottom vertex1 is at (0,0,0).
|
||||
self.assertAlmostEqual(
|
||||
box.Placement.Base.z,
|
||||
0.0,
|
||||
places=3,
|
||||
msg="Box should be on XY plane after fixed joint to Top plane",
|
||||
)
|
||||
|
||||
def test_solve_return_code_with_origin_plane(self):
|
||||
"""Solve with an origin plane joint returns success (0)."""
|
||||
origin = self._get_origin()
|
||||
xz = origin.getXZ()
|
||||
|
||||
box = self._make_box(0, 100, 0)
|
||||
|
||||
self._make_joint(
|
||||
0,
|
||||
[origin, [xz.Name + ".", xz.Name + "."]],
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
)
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertEqual(result, 0, "Solve should succeed with origin plane joint")
|
||||
|
||||
# ── Distance joint to datum plane tests ────────────────────────
|
||||
|
||||
def test_distance_vertex_to_datum_plane_solves(self):
|
||||
"""Distance(0) joint: vertex → datum plane solves and pins position."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY() # Top (Z normal)
|
||||
xz = origin.getXZ() # Front (Y normal)
|
||||
yz = origin.getYZ() # Right (X normal)
|
||||
|
||||
box = self._make_box(50, 50, 50)
|
||||
|
||||
# 3 Distance joints, each vertex→datum, distance=0.
|
||||
# This should pin the box's Vertex1 (corner at local 0,0,0) to the
|
||||
# origin, giving 3 PointInPlane constraints (1 residual each = 3 total).
|
||||
for plane in [xy, xz, yz]:
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Vertex1", "Vertex1"]],
|
||||
[origin, [plane.Name + ".", plane.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertEqual(
|
||||
result, 0, "Solve should succeed for vertex→datum Distance joints"
|
||||
)
|
||||
|
||||
# The box's Vertex1 (at local 0,0,0) should be at the origin.
|
||||
v = box.Placement.Base
|
||||
self.assertAlmostEqual(v.x, 0.0, places=2, msg="X should be pinned to 0")
|
||||
self.assertAlmostEqual(v.y, 0.0, places=2, msg="Y should be pinned to 0")
|
||||
self.assertAlmostEqual(v.z, 0.0, places=2, msg="Z should be pinned to 0")
|
||||
|
||||
def test_distance_vertex_to_datum_plane_preserves_orientation(self):
|
||||
"""Distance(0) vertex→datum should not constrain orientation."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY()
|
||||
xz = origin.getXZ()
|
||||
yz = origin.getYZ()
|
||||
|
||||
# Start box with a known rotation (45° about Z).
|
||||
rot = App.Rotation(App.Vector(0, 0, 1), 45)
|
||||
box = self._make_box(50, 50, 50)
|
||||
box.Placement = App.Placement(App.Vector(50, 50, 50), rot)
|
||||
|
||||
for plane in [xy, xz, yz]:
|
||||
joint = self._make_joint(
|
||||
5,
|
||||
[box, ["Vertex1", "Vertex1"]],
|
||||
[origin, [plane.Name + ".", plane.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
self.assembly.solve()
|
||||
|
||||
# 3 PointInPlane constraints pin position (3 DOF) but leave
|
||||
# orientation free (3 DOF). The solver should keep the original
|
||||
# orientation since it's the lowest-energy solution from the
|
||||
# initial placement.
|
||||
dof = self.assembly.getLastDoF()
|
||||
self.assertEqual(
|
||||
dof, 3, "3 PointInPlane constraints should leave 3 DOF (orientation)"
|
||||
)
|
||||
|
||||
def test_distance_face_to_datum_plane_solves(self):
|
||||
"""Distance(0) joint: face → datum plane solves (PlanePlane/Planar)."""
|
||||
origin = self._get_origin()
|
||||
xy = origin.getXY()
|
||||
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
# Face1 is the -Z face of a Part::Box.
|
||||
joint = self._make_joint(
|
||||
5,
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
[origin, [xy.Name + ".", xy.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertEqual(
|
||||
result, 0, "Solve should succeed for face→datum Distance joint"
|
||||
)
|
||||
|
||||
# ── Round-trip test ──────────────────────────────────────────────
|
||||
|
||||
def test_save_load_preserves_labels(self):
|
||||
"""Labels survive save/load round-trip."""
|
||||
origin = self._get_origin()
|
||||
|
||||
# Verify labels before save
|
||||
self.assertEqual(origin.getXY().Label, "Top")
|
||||
self.assertEqual(origin.getXZ().Label, "Front")
|
||||
self.assertEqual(origin.getYZ().Label, "Right")
|
||||
|
||||
# Save to temp file
|
||||
tmp = tempfile.mktemp(suffix=".FCStd")
|
||||
try:
|
||||
self.doc.saveAs(tmp)
|
||||
|
||||
# Close and reopen
|
||||
doc_name = self.doc.Name
|
||||
App.closeDocument(doc_name)
|
||||
App.openDocument(tmp)
|
||||
doc = App.ActiveDocument
|
||||
|
||||
assembly = doc.getObject("Assembly")
|
||||
self.assertIsNotNone(assembly)
|
||||
|
||||
origin = assembly.Origin
|
||||
self.assertEqual(origin.getXY().Label, "Top")
|
||||
self.assertEqual(origin.getXZ().Label, "Front")
|
||||
self.assertEqual(origin.getYZ().Label, "Right")
|
||||
|
||||
App.closeDocument(doc.Name)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
# Reopen a fresh doc for tearDown
|
||||
App.newDocument(self.__class__.__name__)
|
||||
@@ -1,266 +0,0 @@
|
||||
# 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/>. *
|
||||
# *
|
||||
# ***************************************************************************/
|
||||
|
||||
"""
|
||||
Tests for datum plane classification in Distance joints.
|
||||
|
||||
Verifies that getDistanceType correctly classifies joints involving datum
|
||||
planes from all three class hierarchies:
|
||||
1. App::Plane — origin planes (XY, XZ, YZ)
|
||||
2. PartDesign::Plane — datum planes inside a PartDesign body
|
||||
3. Part::Plane — Part workbench plane primitives (bare reference)
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import FreeCAD as App
|
||||
import JointObject
|
||||
|
||||
|
||||
class TestDatumClassification(unittest.TestCase):
|
||||
"""Tests that Distance joints with datum plane references are
|
||||
classified as PlanePlane (not Other) regardless of the datum
|
||||
object's class hierarchy."""
|
||||
|
||||
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):
|
||||
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 _make_joint(self, joint_type, ref1, ref2):
|
||||
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
|
||||
|
||||
def _make_pd_body_with_datum_plane(self, name="Body"):
|
||||
"""Create a PartDesign::Body with a datum plane inside the assembly."""
|
||||
body = self.assembly.newObject("PartDesign::Body", name)
|
||||
datum = body.newObject("PartDesign::Plane", "DatumPlane")
|
||||
self.doc.recompute()
|
||||
return body, datum
|
||||
|
||||
def _make_part_plane(self, name="PartPlane"):
|
||||
"""Create a Part::Plane primitive inside the assembly."""
|
||||
plane = self.assembly.newObject("Part::Plane", name)
|
||||
plane.Length = 10
|
||||
plane.Width = 10
|
||||
self.doc.recompute()
|
||||
return plane
|
||||
|
||||
# ── Origin plane tests (App::Plane — existing behaviour) ───────
|
||||
|
||||
def test_origin_plane_face_classified_as_plane_plane(self):
|
||||
"""Distance joint: box Face → origin datum plane → PlanePlane."""
|
||||
origin = self.assembly.Origin
|
||||
xy = origin.getXY()
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
[origin, [xy.Name + ".", xy.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertEqual(
|
||||
result,
|
||||
0,
|
||||
"Distance joint with origin plane should solve (not produce Other)",
|
||||
)
|
||||
|
||||
# ── PartDesign::Plane tests ────────────────────────────────────
|
||||
|
||||
def test_pd_datum_plane_face_classified_as_plane_plane(self):
|
||||
"""Distance joint: box Face → PartDesign::Plane → PlanePlane."""
|
||||
body, datum = self._make_pd_body_with_datum_plane()
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
# Ground the body so the solver has a fixed reference.
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, body)
|
||||
|
||||
# Reference the datum plane with a bare sub-name (ends with ".").
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
[body, [datum.Name + ".", datum.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint with PartDesign::Plane should not fail to solve "
|
||||
"(DistanceType should be PlanePlane, not Other)",
|
||||
)
|
||||
|
||||
def test_pd_datum_plane_vertex_classified_as_point_plane(self):
|
||||
"""Distance joint: box Vertex → PartDesign::Plane → PointPlane."""
|
||||
body, datum = self._make_pd_body_with_datum_plane()
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, body)
|
||||
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Vertex1", "Vertex1"]],
|
||||
[body, [datum.Name + ".", datum.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint vertex → PartDesign::Plane should not fail "
|
||||
"(DistanceType should be PointPlane, not Other)",
|
||||
)
|
||||
|
||||
def test_two_pd_datum_planes_classified_as_plane_plane(self):
|
||||
"""Distance joint: PartDesign::Plane → PartDesign::Plane → PlanePlane."""
|
||||
body1, datum1 = self._make_pd_body_with_datum_plane("Body1")
|
||||
body2, datum2 = self._make_pd_body_with_datum_plane("Body2")
|
||||
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, body1)
|
||||
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[body1, [datum1.Name + ".", datum1.Name + "."]],
|
||||
[body2, [datum2.Name + ".", datum2.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint PartDesign::Plane → PartDesign::Plane should not fail "
|
||||
"(DistanceType should be PlanePlane, not Other)",
|
||||
)
|
||||
|
||||
# ── Part::Plane tests (primitive, bare reference) ──────────────
|
||||
|
||||
def test_part_plane_bare_ref_face_classified_as_plane_plane(self):
|
||||
"""Distance joint: box Face → Part::Plane (bare ref) → PlanePlane."""
|
||||
plane = self._make_part_plane()
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, plane)
|
||||
|
||||
# Bare reference to Part::Plane (sub-name ends with ".").
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
[plane, [".", "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint with Part::Plane (bare ref) should not fail "
|
||||
"(DistanceType should be PlanePlane, not Other)",
|
||||
)
|
||||
|
||||
def test_part_plane_with_face1_classified_as_plane_plane(self):
|
||||
"""Distance joint: box Face → Part::Plane Face1 → PlanePlane.
|
||||
|
||||
When Part::Plane is referenced with an explicit Face1 element,
|
||||
it should enter the normal Face+Face classification path."""
|
||||
plane = self._make_part_plane()
|
||||
box = self._make_box(0, 0, 50)
|
||||
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, plane)
|
||||
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[box, ["Face1", "Vertex1"]],
|
||||
[plane, ["Face1", "Vertex1"]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint with Part::Plane Face1 should solve normally",
|
||||
)
|
||||
|
||||
# ── Cross-hierarchy tests ──────────────────────────────────────
|
||||
|
||||
def test_origin_plane_and_pd_datum_classified_as_plane_plane(self):
|
||||
"""Distance joint: origin App::Plane → PartDesign::Plane → PlanePlane."""
|
||||
origin = self.assembly.Origin
|
||||
xy = origin.getXY()
|
||||
body, datum = self._make_pd_body_with_datum_plane()
|
||||
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, body)
|
||||
|
||||
joint = self._make_joint(
|
||||
5, # Distance
|
||||
[origin, [xy.Name + ".", xy.Name + "."]],
|
||||
[body, [datum.Name + ".", datum.Name + "."]],
|
||||
)
|
||||
joint.Distance = 0.0
|
||||
|
||||
result = self.assembly.solve()
|
||||
self.assertNotEqual(
|
||||
result,
|
||||
-1,
|
||||
"Distance joint origin plane → PartDesign::Plane should not fail "
|
||||
"(DistanceType should be PlanePlane, not Other)",
|
||||
)
|
||||
@@ -1,520 +0,0 @@
|
||||
# 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 TestKCSolveSerialization(unittest.TestCase):
|
||||
"""Verify to_dict() / from_dict() round-trip on all KCSolve types."""
|
||||
|
||||
def test_transform_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
t = kcsolve.Transform()
|
||||
t.position = [1.0, 2.0, 3.0]
|
||||
t.quaternion = [0.5, 0.5, 0.5, 0.5]
|
||||
d = t.to_dict()
|
||||
self.assertEqual(list(d["position"]), [1.0, 2.0, 3.0])
|
||||
self.assertEqual(list(d["quaternion"]), [0.5, 0.5, 0.5, 0.5])
|
||||
t2 = kcsolve.Transform.from_dict(d)
|
||||
self.assertEqual(list(t2.position), [1.0, 2.0, 3.0])
|
||||
self.assertEqual(list(t2.quaternion), [0.5, 0.5, 0.5, 0.5])
|
||||
|
||||
def test_transform_identity_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
t = kcsolve.Transform.identity()
|
||||
t2 = kcsolve.Transform.from_dict(t.to_dict())
|
||||
self.assertEqual(list(t2.position), [0.0, 0.0, 0.0])
|
||||
self.assertEqual(list(t2.quaternion), [1.0, 0.0, 0.0, 0.0])
|
||||
|
||||
def test_part_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.mass = 2.5
|
||||
p.grounded = True
|
||||
p.placement = kcsolve.Transform.identity()
|
||||
d = p.to_dict()
|
||||
self.assertEqual(d["id"], "box")
|
||||
self.assertAlmostEqual(d["mass"], 2.5)
|
||||
self.assertTrue(d["grounded"])
|
||||
p2 = kcsolve.Part.from_dict(d)
|
||||
self.assertEqual(p2.id, "box")
|
||||
self.assertAlmostEqual(p2.mass, 2.5)
|
||||
self.assertTrue(p2.grounded)
|
||||
|
||||
def test_constraint_with_limits_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "Joint001"
|
||||
c.part_i = "part1"
|
||||
c.part_j = "part2"
|
||||
c.type = kcsolve.BaseJointKind.Revolute
|
||||
c.params = [1.5, 2.5]
|
||||
lim = kcsolve.Constraint.Limit()
|
||||
lim.kind = kcsolve.LimitKind.RotationMin
|
||||
lim.value = -3.14
|
||||
lim.tolerance = 0.01
|
||||
c.limits = [lim]
|
||||
d = c.to_dict()
|
||||
self.assertEqual(d["type"], "Revolute")
|
||||
self.assertEqual(len(d["limits"]), 1)
|
||||
self.assertEqual(d["limits"][0]["kind"], "RotationMin")
|
||||
c2 = kcsolve.Constraint.from_dict(d)
|
||||
self.assertEqual(c2.type, kcsolve.BaseJointKind.Revolute)
|
||||
self.assertEqual(len(c2.limits), 1)
|
||||
self.assertEqual(c2.limits[0].kind, kcsolve.LimitKind.RotationMin)
|
||||
self.assertAlmostEqual(c2.limits[0].value, -3.14)
|
||||
|
||||
def test_solve_context_full_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.grounded = True
|
||||
ctx.parts = [p]
|
||||
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "J1"
|
||||
c.part_i = "box"
|
||||
c.part_j = "cyl"
|
||||
c.type = kcsolve.BaseJointKind.Fixed
|
||||
ctx.constraints = [c]
|
||||
ctx.bundle_fixed = True
|
||||
|
||||
d = ctx.to_dict()
|
||||
self.assertEqual(d["api_version"], kcsolve.API_VERSION_MAJOR)
|
||||
self.assertEqual(len(d["parts"]), 1)
|
||||
self.assertEqual(len(d["constraints"]), 1)
|
||||
self.assertTrue(d["bundle_fixed"])
|
||||
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertEqual(ctx2.parts[0].id, "box")
|
||||
self.assertTrue(ctx2.parts[0].grounded)
|
||||
self.assertEqual(ctx2.constraints[0].type, kcsolve.BaseJointKind.Fixed)
|
||||
self.assertTrue(ctx2.bundle_fixed)
|
||||
|
||||
def test_solve_context_with_simulation(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = []
|
||||
ctx.constraints = []
|
||||
sim = kcsolve.SimulationParams()
|
||||
sim.t_start = 0.0
|
||||
sim.t_end = 10.0
|
||||
sim.h_out = 0.01
|
||||
ctx.simulation = sim
|
||||
d = ctx.to_dict()
|
||||
self.assertIsNotNone(d["simulation"])
|
||||
self.assertAlmostEqual(d["simulation"]["t_end"], 10.0)
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertIsNotNone(ctx2.simulation)
|
||||
self.assertAlmostEqual(ctx2.simulation.t_end, 10.0)
|
||||
|
||||
def test_solve_context_simulation_null(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = []
|
||||
ctx.constraints = []
|
||||
ctx.simulation = None
|
||||
d = ctx.to_dict()
|
||||
self.assertIsNone(d["simulation"])
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertIsNone(ctx2.simulation)
|
||||
|
||||
def test_solve_result_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
r = kcsolve.SolveResult()
|
||||
r.status = kcsolve.SolveStatus.Success
|
||||
r.dof = 6
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = "box"
|
||||
pr.placement = kcsolve.Transform.identity()
|
||||
r.placements = [pr]
|
||||
diag = kcsolve.ConstraintDiagnostic()
|
||||
diag.constraint_id = "J1"
|
||||
diag.kind = kcsolve.DiagnosticKind.Redundant
|
||||
diag.detail = "over-constrained"
|
||||
r.diagnostics = [diag]
|
||||
r.num_frames = 100
|
||||
|
||||
d = r.to_dict()
|
||||
self.assertEqual(d["status"], "Success")
|
||||
self.assertEqual(d["dof"], 6)
|
||||
self.assertEqual(d["num_frames"], 100)
|
||||
self.assertEqual(len(d["placements"]), 1)
|
||||
self.assertEqual(len(d["diagnostics"]), 1)
|
||||
|
||||
r2 = kcsolve.SolveResult.from_dict(d)
|
||||
self.assertEqual(r2.status, kcsolve.SolveStatus.Success)
|
||||
self.assertEqual(r2.dof, 6)
|
||||
self.assertEqual(r2.num_frames, 100)
|
||||
self.assertEqual(r2.placements[0].id, "box")
|
||||
self.assertEqual(r2.diagnostics[0].kind, kcsolve.DiagnosticKind.Redundant)
|
||||
|
||||
def test_motion_def_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
m = kcsolve.MotionDef()
|
||||
m.kind = kcsolve.MotionKind.Rotational
|
||||
m.joint_id = "J1"
|
||||
m.marker_i = "part1"
|
||||
m.marker_j = "part2"
|
||||
m.rotation_expr = "2*pi*time"
|
||||
m.translation_expr = ""
|
||||
d = m.to_dict()
|
||||
self.assertEqual(d["kind"], "Rotational")
|
||||
self.assertEqual(d["joint_id"], "J1")
|
||||
m2 = kcsolve.MotionDef.from_dict(d)
|
||||
self.assertEqual(m2.kind, kcsolve.MotionKind.Rotational)
|
||||
self.assertEqual(m2.rotation_expr, "2*pi*time")
|
||||
|
||||
def test_all_base_joint_kinds_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
all_kinds = [
|
||||
"Coincident",
|
||||
"PointOnLine",
|
||||
"PointInPlane",
|
||||
"Concentric",
|
||||
"Tangent",
|
||||
"Planar",
|
||||
"LineInPlane",
|
||||
"Parallel",
|
||||
"Perpendicular",
|
||||
"Angle",
|
||||
"Fixed",
|
||||
"Revolute",
|
||||
"Cylindrical",
|
||||
"Slider",
|
||||
"Ball",
|
||||
"Screw",
|
||||
"Universal",
|
||||
"Gear",
|
||||
"RackPinion",
|
||||
"Cam",
|
||||
"Slot",
|
||||
"DistancePointPoint",
|
||||
"DistanceCylSph",
|
||||
"Custom",
|
||||
]
|
||||
for name in all_kinds:
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "test"
|
||||
c.part_i = "a"
|
||||
c.part_j = "b"
|
||||
c.type = getattr(kcsolve.BaseJointKind, name)
|
||||
d = c.to_dict()
|
||||
self.assertEqual(d["type"], name)
|
||||
c2 = kcsolve.Constraint.from_dict(d)
|
||||
self.assertEqual(c2.type, getattr(kcsolve.BaseJointKind, name))
|
||||
|
||||
def test_all_solve_statuses_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
for name in ("Success", "Failed", "InvalidFlip", "NoGroundedParts"):
|
||||
r = kcsolve.SolveResult()
|
||||
r.status = getattr(kcsolve.SolveStatus, name)
|
||||
d = r.to_dict()
|
||||
self.assertEqual(d["status"], name)
|
||||
r2 = kcsolve.SolveResult.from_dict(d)
|
||||
self.assertEqual(r2.status, getattr(kcsolve.SolveStatus, name))
|
||||
|
||||
def test_json_stdlib_round_trip(self):
|
||||
import json
|
||||
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.grounded = True
|
||||
ctx.parts = [p]
|
||||
ctx.constraints = []
|
||||
d = ctx.to_dict()
|
||||
json_str = json.dumps(d)
|
||||
d2 = json.loads(json_str)
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d2)
|
||||
self.assertEqual(ctx2.parts[0].id, "box")
|
||||
|
||||
def test_from_dict_missing_required_key(self):
|
||||
import kcsolve
|
||||
|
||||
with self.assertRaises(KeyError):
|
||||
kcsolve.Part.from_dict({"mass": 1.0, "grounded": False})
|
||||
|
||||
def test_from_dict_invalid_enum_string(self):
|
||||
import kcsolve
|
||||
|
||||
d = {
|
||||
"id": "J1",
|
||||
"part_i": "a",
|
||||
"part_j": "b",
|
||||
"type": "Bogus",
|
||||
"marker_i": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
|
||||
"marker_j": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.Constraint.from_dict(d)
|
||||
|
||||
def test_from_dict_bad_position_length(self):
|
||||
import kcsolve
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.Transform.from_dict(
|
||||
{
|
||||
"position": [1.0, 2.0],
|
||||
"quaternion": [1, 0, 0, 0],
|
||||
}
|
||||
)
|
||||
|
||||
def test_from_dict_bad_api_version(self):
|
||||
import kcsolve
|
||||
|
||||
d = {
|
||||
"api_version": 99,
|
||||
"parts": [],
|
||||
"constraints": [],
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.SolveContext.from_dict(d)
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,180 +0,0 @@
|
||||
# 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/>. *
|
||||
# *
|
||||
# ***************************************************************************/
|
||||
|
||||
"""
|
||||
Integration tests for the Kindred solver backend.
|
||||
|
||||
These tests mirror TestSolverIntegration but force the solver preference
|
||||
to "kindred" so the full pipeline (AssemblyObject → IKCSolver →
|
||||
KindredSolver) is exercised.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import FreeCAD as App
|
||||
import JointObject
|
||||
|
||||
|
||||
def _pref():
|
||||
return App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
|
||||
|
||||
|
||||
class TestKindredSolverIntegration(unittest.TestCase):
|
||||
"""Full-stack solver tests using the Kindred (Newton-Raphson) backend."""
|
||||
|
||||
def setUp(self):
|
||||
# Force the kindred solver backend
|
||||
self._prev_solver = _pref().GetString("Solver", "")
|
||||
_pref().SetString("Solver", "kindred")
|
||||
|
||||
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")
|
||||
# Reset the solver so it picks up the new preference
|
||||
self.assembly.resetSolver()
|
||||
self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints")
|
||||
|
||||
def tearDown(self):
|
||||
App.closeDocument(self.doc.Name)
|
||||
_pref().SetString("Solver", self._prev_solver)
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _make_box(self, x=0, y=0, z=0, size=10):
|
||||
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):
|
||||
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, obj)
|
||||
return gnd
|
||||
|
||||
def _make_joint(self, joint_type, ref1, ref2):
|
||||
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)
|
||||
|
||||
self._make_joint(
|
||||
0,
|
||||
[box2, ["Face6", "Vertex7"]],
|
||||
[box1, ["Face6", "Vertex7"]],
|
||||
)
|
||||
|
||||
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."""
|
||||
box1 = self._make_box(0, 0, 0)
|
||||
box2 = self._make_box(100, 0, 0)
|
||||
self._ground(box1)
|
||||
|
||||
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)
|
||||
|
||||
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_dof_reporting(self):
|
||||
"""Revolute joint -> DOF = 1."""
|
||||
box1 = self._make_box(0, 0, 0)
|
||||
box2 = self._make_box(100, 0, 0)
|
||||
self._ground(box1)
|
||||
|
||||
self._make_joint(
|
||||
1,
|
||||
[box1, ["Face6", "Vertex7"]],
|
||||
[box2, ["Face6", "Vertex7"]],
|
||||
)
|
||||
|
||||
self.assembly.solve()
|
||||
dof = self.assembly.getLastDoF()
|
||||
self.assertEqual(dof, 1, "Revolute joint should leave 1 DOF")
|
||||
|
||||
def test_solve_stability(self):
|
||||
"""Solving 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",
|
||||
)
|
||||
@@ -1,216 +0,0 @@
|
||||
# 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,7 +11,6 @@ else ()
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
add_subdirectory(Solver)
|
||||
add_subdirectory(App)
|
||||
|
||||
if(BUILD_GUI)
|
||||
@@ -57,11 +56,6 @@ SET(AssemblyTests_SRCS
|
||||
AssemblyTests/__init__.py
|
||||
AssemblyTests/TestCore.py
|
||||
AssemblyTests/TestCommandInsertLink.py
|
||||
AssemblyTests/TestSolverIntegration.py
|
||||
AssemblyTests/TestKindredSolverIntegration.py
|
||||
AssemblyTests/TestKCSolvePy.py
|
||||
AssemblyTests/TestAssemblyOriginPlanes.py
|
||||
AssemblyTests/TestDatumClassification.py
|
||||
AssemblyTests/mocks/__init__.py
|
||||
AssemblyTests/mocks/MockGui.py
|
||||
)
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
# **************************************************************************/
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
|
||||
if App.GuiUp:
|
||||
import FreeCADGui as Gui
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
|
||||
import Preferences
|
||||
import UtilsAssembly
|
||||
import Preferences
|
||||
|
||||
translate = App.Qt.translate
|
||||
|
||||
@@ -77,22 +78,14 @@ class CommandCreateAssembly:
|
||||
'assembly = activeAssembly.newObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
)
|
||||
else:
|
||||
commands = 'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
commands = (
|
||||
'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
)
|
||||
|
||||
commands = commands + 'assembly.Type = "Assembly"\n'
|
||||
commands = commands + 'assembly.newObject("Assembly::JointGroup", "Joints")'
|
||||
|
||||
Gui.doCommand(commands)
|
||||
|
||||
# Make origin planes visible by default so they serve as
|
||||
# reference geometry (like SolidWorks Front/Top/Right planes).
|
||||
Gui.doCommandGui(
|
||||
"assembly.Origin.ViewObject.Visibility = True\n"
|
||||
"for feat in assembly.Origin.OriginFeatures:\n"
|
||||
" if feat.isDerivedFrom('App::Plane'):\n"
|
||||
" feat.ViewObject.Visibility = True\n"
|
||||
)
|
||||
|
||||
if not activeAssembly:
|
||||
Gui.doCommandGui("Gui.ActiveDocument.setEdit(assembly)")
|
||||
|
||||
@@ -105,9 +98,7 @@ class ActivateAssemblyTaskPanel:
|
||||
def __init__(self, assemblies):
|
||||
self.assemblies = assemblies
|
||||
self.form = QtWidgets.QWidget()
|
||||
self.form.setWindowTitle(
|
||||
translate("Assembly_ActivateAssembly", "Activate Assembly")
|
||||
)
|
||||
self.form.setWindowTitle(translate("Assembly_ActivateAssembly", "Activate Assembly"))
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self.form)
|
||||
label = QtWidgets.QLabel(
|
||||
@@ -141,12 +132,9 @@ class CommandActivateAssembly:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": "Assembly_ActivateAssembly",
|
||||
"MenuText": QT_TRANSLATE_NOOP(
|
||||
"Assembly_ActivateAssembly", "Activate Assembly"
|
||||
),
|
||||
"MenuText": QT_TRANSLATE_NOOP("Assembly_ActivateAssembly", "Activate Assembly"),
|
||||
"ToolTip": QT_TRANSLATE_NOOP(
|
||||
"Assembly_ActivateAssembly",
|
||||
"Sets an assembly as the active one for editing.",
|
||||
"Assembly_ActivateAssembly", "Sets an assembly as the active one for editing."
|
||||
),
|
||||
"CmdType": "ForEdit",
|
||||
}
|
||||
@@ -168,9 +156,7 @@ class CommandActivateAssembly:
|
||||
|
||||
def Activated(self):
|
||||
doc = App.ActiveDocument
|
||||
assemblies = [
|
||||
o for o in doc.Objects if o.isDerivedFrom("Assembly::AssemblyObject")
|
||||
]
|
||||
assemblies = [o for o in doc.Objects if o.isDerivedFrom("Assembly::AssemblyObject")]
|
||||
|
||||
if len(assemblies) == 1:
|
||||
# If there's only one, activate it directly without showing a dialog
|
||||
|
||||
@@ -84,20 +84,6 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="solverBackendLabel">
|
||||
<property name="text">
|
||||
<string>Solver backend</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QComboBox" name="solverBackend">
|
||||
<property name="toolTip">
|
||||
<string>Select the constraint solver used for assembly solving</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
|
||||
@@ -40,34 +40,13 @@ class PreferencesPage:
|
||||
pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked())
|
||||
pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked())
|
||||
pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex())
|
||||
idx = self.form.solverBackend.currentIndex()
|
||||
solver_name = self.form.solverBackend.itemData(idx) or ""
|
||||
pref.SetString("Solver", solver_name)
|
||||
|
||||
def loadSettings(self):
|
||||
pref = preferences()
|
||||
self.form.checkBoxEnableEscape.setChecked(
|
||||
pref.GetBool("LeaveEditWithEscape", True)
|
||||
)
|
||||
self.form.checkBoxEnableEscape.setChecked(pref.GetBool("LeaveEditWithEscape", True))
|
||||
self.form.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False))
|
||||
self.form.groundFirstPart.clear()
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Ask"))
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Always"))
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Never"))
|
||||
self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0))
|
||||
|
||||
self.form.solverBackend.clear()
|
||||
self.form.solverBackend.addItem(translate("Assembly", "Default"), "")
|
||||
try:
|
||||
import kcsolve
|
||||
|
||||
for name in kcsolve.available():
|
||||
solver = kcsolve.load(name)
|
||||
self.form.solverBackend.addItem(solver.name(), name)
|
||||
except ImportError:
|
||||
pass
|
||||
current = pref.GetString("Solver", "")
|
||||
for i in range(self.form.solverBackend.count()):
|
||||
if self.form.solverBackend.itemData(i) == current:
|
||||
self.form.solverBackend.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,189 +0,0 @@
|
||||
// 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
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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
|
||||
@@ -1,796 +0,0 @@
|
||||
// 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
|
||||
@@ -1,129 +0,0 @@
|
||||
// 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
|
||||
@@ -1,346 +0,0 @@
|
||||
// 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
|
||||
@@ -1,124 +0,0 @@
|
||||
// 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
|
||||
@@ -1,286 +0,0 @@
|
||||
// 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
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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})
|
||||
@@ -1,121 +0,0 @@
|
||||
// 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
|
||||
@@ -1,830 +0,0 @@
|
||||
// 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 <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace KCSolve;
|
||||
|
||||
|
||||
// ── Enum string mapping ────────────────────────────────────────────
|
||||
//
|
||||
// Constexpr tables for bidirectional enum <-> string conversion.
|
||||
// String values match the py::enum_ .value("Name", ...) names exactly,
|
||||
// which is also the JSON wire format specified in SOLVER.md §3.
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
template<typename E>
|
||||
struct EnumEntry
|
||||
{
|
||||
E value;
|
||||
const char* name;
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<BaseJointKind> kBaseJointKindEntries[] = {
|
||||
{BaseJointKind::Coincident, "Coincident"},
|
||||
{BaseJointKind::PointOnLine, "PointOnLine"},
|
||||
{BaseJointKind::PointInPlane, "PointInPlane"},
|
||||
{BaseJointKind::Concentric, "Concentric"},
|
||||
{BaseJointKind::Tangent, "Tangent"},
|
||||
{BaseJointKind::Planar, "Planar"},
|
||||
{BaseJointKind::LineInPlane, "LineInPlane"},
|
||||
{BaseJointKind::Parallel, "Parallel"},
|
||||
{BaseJointKind::Perpendicular, "Perpendicular"},
|
||||
{BaseJointKind::Angle, "Angle"},
|
||||
{BaseJointKind::Fixed, "Fixed"},
|
||||
{BaseJointKind::Revolute, "Revolute"},
|
||||
{BaseJointKind::Cylindrical, "Cylindrical"},
|
||||
{BaseJointKind::Slider, "Slider"},
|
||||
{BaseJointKind::Ball, "Ball"},
|
||||
{BaseJointKind::Screw, "Screw"},
|
||||
{BaseJointKind::Universal, "Universal"},
|
||||
{BaseJointKind::Gear, "Gear"},
|
||||
{BaseJointKind::RackPinion, "RackPinion"},
|
||||
{BaseJointKind::Cam, "Cam"},
|
||||
{BaseJointKind::Slot, "Slot"},
|
||||
{BaseJointKind::DistancePointPoint, "DistancePointPoint"},
|
||||
{BaseJointKind::DistanceCylSph, "DistanceCylSph"},
|
||||
{BaseJointKind::Custom, "Custom"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<SolveStatus> kSolveStatusEntries[] = {
|
||||
{SolveStatus::Success, "Success"},
|
||||
{SolveStatus::Failed, "Failed"},
|
||||
{SolveStatus::InvalidFlip, "InvalidFlip"},
|
||||
{SolveStatus::NoGroundedParts, "NoGroundedParts"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<ConstraintDiagnostic::Kind> kDiagnosticKindEntries[] = {
|
||||
{ConstraintDiagnostic::Kind::Redundant, "Redundant"},
|
||||
{ConstraintDiagnostic::Kind::Conflicting, "Conflicting"},
|
||||
{ConstraintDiagnostic::Kind::PartiallyRedundant, "PartiallyRedundant"},
|
||||
{ConstraintDiagnostic::Kind::Malformed, "Malformed"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<MotionDef::Kind> kMotionKindEntries[] = {
|
||||
{MotionDef::Kind::Rotational, "Rotational"},
|
||||
{MotionDef::Kind::Translational, "Translational"},
|
||||
{MotionDef::Kind::General, "General"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<Constraint::Limit::Kind> kLimitKindEntries[] = {
|
||||
{Constraint::Limit::Kind::TranslationMin, "TranslationMin"},
|
||||
{Constraint::Limit::Kind::TranslationMax, "TranslationMax"},
|
||||
{Constraint::Limit::Kind::RotationMin, "RotationMin"},
|
||||
{Constraint::Limit::Kind::RotationMax, "RotationMax"},
|
||||
};
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
const char* enum_to_str(E val, const EnumEntry<E> (&table)[N])
|
||||
{
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (table[i].value == val) {
|
||||
return table[i].name;
|
||||
}
|
||||
}
|
||||
throw py::value_error("Unknown enum value: " + std::to_string(static_cast<int>(val)));
|
||||
}
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
E str_to_enum(const std::string& name, const EnumEntry<E> (&table)[N],
|
||||
const char* enum_type_name)
|
||||
{
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (name == table[i].name) {
|
||||
return table[i].value;
|
||||
}
|
||||
}
|
||||
throw py::value_error(
|
||||
std::string("Invalid ") + enum_type_name + " value: '" + name + "'");
|
||||
}
|
||||
|
||||
|
||||
// ── Dict conversion helpers ────────────────────────────────────────
|
||||
//
|
||||
// Standalone functions for each type so SolveContext/SolveResult can
|
||||
// reuse them without duplicating serialization logic.
|
||||
|
||||
py::dict transform_to_dict(const Transform& t)
|
||||
{
|
||||
py::dict d;
|
||||
d["position"] = py::make_tuple(t.position[0], t.position[1], t.position[2]);
|
||||
d["quaternion"] = py::make_tuple(
|
||||
t.quaternion[0], t.quaternion[1], t.quaternion[2], t.quaternion[3]);
|
||||
return d;
|
||||
}
|
||||
|
||||
Transform transform_from_dict(const py::dict& d)
|
||||
{
|
||||
Transform t;
|
||||
auto pos = d["position"].cast<py::sequence>();
|
||||
if (py::len(pos) != 3) {
|
||||
throw py::value_error("position must have exactly 3 elements");
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
t.position[static_cast<std::size_t>(i)] = pos[i].cast<double>();
|
||||
}
|
||||
auto quat = d["quaternion"].cast<py::sequence>();
|
||||
if (py::len(quat) != 4) {
|
||||
throw py::value_error("quaternion must have exactly 4 elements");
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
t.quaternion[static_cast<std::size_t>(i)] = quat[i].cast<double>();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
py::dict part_to_dict(const Part& p)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = p.id;
|
||||
d["placement"] = transform_to_dict(p.placement);
|
||||
d["mass"] = p.mass;
|
||||
d["grounded"] = p.grounded;
|
||||
return d;
|
||||
}
|
||||
|
||||
Part part_from_dict(const py::dict& d)
|
||||
{
|
||||
Part p;
|
||||
p.id = d["id"].cast<std::string>();
|
||||
p.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
if (d.contains("mass")) {
|
||||
p.mass = d["mass"].cast<double>();
|
||||
}
|
||||
if (d.contains("grounded")) {
|
||||
p.grounded = d["grounded"].cast<bool>();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
py::dict limit_to_dict(const Constraint::Limit& lim)
|
||||
{
|
||||
py::dict d;
|
||||
d["kind"] = enum_to_str(lim.kind, kLimitKindEntries);
|
||||
d["value"] = lim.value;
|
||||
d["tolerance"] = lim.tolerance;
|
||||
return d;
|
||||
}
|
||||
|
||||
Constraint::Limit limit_from_dict(const py::dict& d)
|
||||
{
|
||||
Constraint::Limit lim;
|
||||
lim.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kLimitKindEntries, "LimitKind");
|
||||
lim.value = d["value"].cast<double>();
|
||||
if (d.contains("tolerance")) {
|
||||
lim.tolerance = d["tolerance"].cast<double>();
|
||||
}
|
||||
return lim;
|
||||
}
|
||||
|
||||
py::dict constraint_to_dict(const Constraint& c)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = c.id;
|
||||
d["part_i"] = c.part_i;
|
||||
d["marker_i"] = transform_to_dict(c.marker_i);
|
||||
d["part_j"] = c.part_j;
|
||||
d["marker_j"] = transform_to_dict(c.marker_j);
|
||||
d["type"] = enum_to_str(c.type, kBaseJointKindEntries);
|
||||
d["params"] = py::cast(c.params);
|
||||
py::list lims;
|
||||
for (const auto& lim : c.limits) {
|
||||
lims.append(limit_to_dict(lim));
|
||||
}
|
||||
d["limits"] = lims;
|
||||
d["activated"] = c.activated;
|
||||
return d;
|
||||
}
|
||||
|
||||
Constraint constraint_from_dict(const py::dict& d)
|
||||
{
|
||||
Constraint c;
|
||||
c.id = d["id"].cast<std::string>();
|
||||
c.part_i = d["part_i"].cast<std::string>();
|
||||
c.marker_i = transform_from_dict(d["marker_i"].cast<py::dict>());
|
||||
c.part_j = d["part_j"].cast<std::string>();
|
||||
c.marker_j = transform_from_dict(d["marker_j"].cast<py::dict>());
|
||||
c.type = str_to_enum(d["type"].cast<std::string>(),
|
||||
kBaseJointKindEntries, "BaseJointKind");
|
||||
if (d.contains("params")) {
|
||||
c.params = d["params"].cast<std::vector<double>>();
|
||||
}
|
||||
if (d.contains("limits")) {
|
||||
for (auto item : d["limits"]) {
|
||||
c.limits.push_back(limit_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
if (d.contains("activated")) {
|
||||
c.activated = d["activated"].cast<bool>();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
py::dict motion_to_dict(const MotionDef& m)
|
||||
{
|
||||
py::dict d;
|
||||
d["kind"] = enum_to_str(m.kind, kMotionKindEntries);
|
||||
d["joint_id"] = m.joint_id;
|
||||
d["marker_i"] = m.marker_i;
|
||||
d["marker_j"] = m.marker_j;
|
||||
d["rotation_expr"] = m.rotation_expr;
|
||||
d["translation_expr"] = m.translation_expr;
|
||||
return d;
|
||||
}
|
||||
|
||||
MotionDef motion_from_dict(const py::dict& d)
|
||||
{
|
||||
MotionDef m;
|
||||
m.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kMotionKindEntries, "MotionKind");
|
||||
m.joint_id = d["joint_id"].cast<std::string>();
|
||||
if (d.contains("marker_i")) {
|
||||
m.marker_i = d["marker_i"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("marker_j")) {
|
||||
m.marker_j = d["marker_j"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("rotation_expr")) {
|
||||
m.rotation_expr = d["rotation_expr"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("translation_expr")) {
|
||||
m.translation_expr = d["translation_expr"].cast<std::string>();
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
py::dict sim_to_dict(const SimulationParams& s)
|
||||
{
|
||||
py::dict d;
|
||||
d["t_start"] = s.t_start;
|
||||
d["t_end"] = s.t_end;
|
||||
d["h_out"] = s.h_out;
|
||||
d["h_min"] = s.h_min;
|
||||
d["h_max"] = s.h_max;
|
||||
d["error_tol"] = s.error_tol;
|
||||
return d;
|
||||
}
|
||||
|
||||
SimulationParams sim_from_dict(const py::dict& d)
|
||||
{
|
||||
SimulationParams s;
|
||||
if (d.contains("t_start")) {
|
||||
s.t_start = d["t_start"].cast<double>();
|
||||
}
|
||||
if (d.contains("t_end")) {
|
||||
s.t_end = d["t_end"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_out")) {
|
||||
s.h_out = d["h_out"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_min")) {
|
||||
s.h_min = d["h_min"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_max")) {
|
||||
s.h_max = d["h_max"].cast<double>();
|
||||
}
|
||||
if (d.contains("error_tol")) {
|
||||
s.error_tol = d["error_tol"].cast<double>();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
py::dict diagnostic_to_dict(const ConstraintDiagnostic& diag)
|
||||
{
|
||||
py::dict d;
|
||||
d["constraint_id"] = diag.constraint_id;
|
||||
d["kind"] = enum_to_str(diag.kind, kDiagnosticKindEntries);
|
||||
d["detail"] = diag.detail;
|
||||
return d;
|
||||
}
|
||||
|
||||
ConstraintDiagnostic diagnostic_from_dict(const py::dict& d)
|
||||
{
|
||||
ConstraintDiagnostic diag;
|
||||
diag.constraint_id = d["constraint_id"].cast<std::string>();
|
||||
diag.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kDiagnosticKindEntries, "DiagnosticKind");
|
||||
if (d.contains("detail")) {
|
||||
diag.detail = d["detail"].cast<std::string>();
|
||||
}
|
||||
return diag;
|
||||
}
|
||||
|
||||
py::dict part_result_to_dict(const SolveResult::PartResult& pr)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = pr.id;
|
||||
d["placement"] = transform_to_dict(pr.placement);
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveResult::PartResult part_result_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveResult::PartResult pr;
|
||||
pr.id = d["id"].cast<std::string>();
|
||||
pr.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
return pr;
|
||||
}
|
||||
|
||||
py::dict solve_context_to_dict(const SolveContext& ctx)
|
||||
{
|
||||
py::dict d;
|
||||
d["api_version"] = API_VERSION_MAJOR;
|
||||
|
||||
py::list parts;
|
||||
for (const auto& p : ctx.parts) {
|
||||
parts.append(part_to_dict(p));
|
||||
}
|
||||
d["parts"] = parts;
|
||||
|
||||
py::list constraints;
|
||||
for (const auto& c : ctx.constraints) {
|
||||
constraints.append(constraint_to_dict(c));
|
||||
}
|
||||
d["constraints"] = constraints;
|
||||
|
||||
py::list motions;
|
||||
for (const auto& m : ctx.motions) {
|
||||
motions.append(motion_to_dict(m));
|
||||
}
|
||||
d["motions"] = motions;
|
||||
|
||||
if (ctx.simulation.has_value()) {
|
||||
d["simulation"] = sim_to_dict(*ctx.simulation);
|
||||
}
|
||||
else {
|
||||
d["simulation"] = py::none();
|
||||
}
|
||||
|
||||
d["bundle_fixed"] = ctx.bundle_fixed;
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveContext solve_context_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveContext ctx;
|
||||
|
||||
if (d.contains("api_version")) {
|
||||
int v = d["api_version"].cast<int>();
|
||||
if (v != API_VERSION_MAJOR) {
|
||||
throw py::value_error(
|
||||
"Unsupported api_version " + std::to_string(v)
|
||||
+ ", expected " + std::to_string(API_VERSION_MAJOR));
|
||||
}
|
||||
}
|
||||
|
||||
for (auto item : d["parts"]) {
|
||||
ctx.parts.push_back(part_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
|
||||
for (auto item : d["constraints"]) {
|
||||
ctx.constraints.push_back(constraint_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
|
||||
if (d.contains("motions")) {
|
||||
for (auto item : d["motions"]) {
|
||||
ctx.motions.push_back(motion_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("simulation") && !d["simulation"].is_none()) {
|
||||
ctx.simulation = sim_from_dict(d["simulation"].cast<py::dict>());
|
||||
}
|
||||
|
||||
if (d.contains("bundle_fixed")) {
|
||||
ctx.bundle_fixed = d["bundle_fixed"].cast<bool>();
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
py::dict solve_result_to_dict(const SolveResult& r)
|
||||
{
|
||||
py::dict d;
|
||||
d["status"] = enum_to_str(r.status, kSolveStatusEntries);
|
||||
|
||||
py::list placements;
|
||||
for (const auto& pr : r.placements) {
|
||||
placements.append(part_result_to_dict(pr));
|
||||
}
|
||||
d["placements"] = placements;
|
||||
|
||||
d["dof"] = r.dof;
|
||||
|
||||
py::list diagnostics;
|
||||
for (const auto& diag : r.diagnostics) {
|
||||
diagnostics.append(diagnostic_to_dict(diag));
|
||||
}
|
||||
d["diagnostics"] = diagnostics;
|
||||
|
||||
d["num_frames"] = r.num_frames;
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveResult solve_result_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveResult r;
|
||||
r.status = str_to_enum(d["status"].cast<std::string>(),
|
||||
kSolveStatusEntries, "SolveStatus");
|
||||
|
||||
if (d.contains("placements")) {
|
||||
for (auto item : d["placements"]) {
|
||||
r.placements.push_back(part_result_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("dof")) {
|
||||
r.dof = d["dof"].cast<int>();
|
||||
}
|
||||
|
||||
if (d.contains("diagnostics")) {
|
||||
for (auto item : d["diagnostics"]) {
|
||||
r.diagnostics.push_back(diagnostic_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("num_frames")) {
|
||||
r.num_frames = d["num_frames"].cast<std::size_t>();
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
|
||||
// ── 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]) + "]>";
|
||||
})
|
||||
.def("to_dict", [](const Transform& t) { return transform_to_dict(t); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return transform_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const Part& p) { return part_to_dict(p); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return part_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const Constraint::Limit& l) { return limit_to_dict(l); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return limit_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const Constraint& c) { return constraint_to_dict(c); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return constraint_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const MotionDef& m) { return motion_to_dict(m); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return motion_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const SimulationParams& s) { return sim_to_dict(s); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return sim_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const SolveContext& ctx) { return solve_context_to_dict(ctx); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return solve_context_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const ConstraintDiagnostic& d) { return diagnostic_to_dict(d); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return diagnostic_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const SolveResult::PartResult& pr) { return part_result_to_dict(pr); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return part_result_from_dict(d); });
|
||||
|
||||
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)
|
||||
.def("to_dict", [](const SolveResult& r) { return solve_result_to_dict(r); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return solve_result_from_dict(d); });
|
||||
|
||||
// ── 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,21 +22,11 @@
|
||||
# **************************************************************************/
|
||||
|
||||
import TestApp
|
||||
from AssemblyTests.TestAssemblyOriginPlanes import TestAssemblyOriginPlanes
|
||||
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.TestKindredSolverIntegration import TestKindredSolverIntegration
|
||||
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
|
||||
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
|
||||
|
||||
|
||||
# Use the modules so that code checkers don't complain (flake8)
|
||||
True if TestCore else False
|
||||
True if TestCommandInsertLink else False
|
||||
True if TestAssemblyOriginPlanes else False
|
||||
True if TestSolverIntegration else False
|
||||
True if TestKindredSolverIntegration else False
|
||||
|
||||
@@ -84,34 +84,3 @@ install(
|
||||
DESTINATION
|
||||
mods/sdk
|
||||
)
|
||||
|
||||
# Install QuickNav addon
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_SOURCE_DIR}/mods/quicknav/quicknav
|
||||
DESTINATION
|
||||
mods/quicknav
|
||||
)
|
||||
install(
|
||||
FILES
|
||||
${CMAKE_SOURCE_DIR}/mods/quicknav/package.xml
|
||||
${CMAKE_SOURCE_DIR}/mods/quicknav/Init.py
|
||||
${CMAKE_SOURCE_DIR}/mods/quicknav/InitGui.py
|
||||
DESTINATION
|
||||
mods/quicknav
|
||||
)
|
||||
|
||||
# Install Kindred Solver addon
|
||||
install(
|
||||
DIRECTORY
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/kindred_solver
|
||||
DESTINATION
|
||||
mods/solver
|
||||
)
|
||||
install(
|
||||
FILES
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/package.xml
|
||||
${CMAKE_SOURCE_DIR}/mods/solver/Init.py
|
||||
DESTINATION
|
||||
mods/solver
|
||||
)
|
||||
|
||||
@@ -90,24 +90,6 @@ def _manifest_enrich_hook(doc, filename, entries):
|
||||
register_pre_reinject(_manifest_enrich_hook)
|
||||
|
||||
|
||||
def _solver_context_hook(doc, filename, entries):
|
||||
"""Pack solver context into silo/solver/context.json for assemblies."""
|
||||
try:
|
||||
for obj in doc.Objects:
|
||||
if obj.TypeId == "Assembly::AssemblyObject":
|
||||
ctx = obj.getSolveContext()
|
||||
if ctx: # non-empty means we have grounded parts
|
||||
entries["silo/solver/context.json"] = (
|
||||
json.dumps(ctx, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
break # one assembly per document
|
||||
except Exception as exc:
|
||||
FreeCAD.Console.PrintWarning(f"kc_format: solver context hook failed: {exc}\n")
|
||||
|
||||
|
||||
register_pre_reinject(_solver_context_hook)
|
||||
|
||||
|
||||
KC_VERSION = "1.0"
|
||||
|
||||
|
||||
@@ -162,28 +144,34 @@ class _KcFormatObserver:
|
||||
f"kc_format: pre_reinject hook failed: {exc}\n"
|
||||
)
|
||||
try:
|
||||
# Ensure silo/manifest.json exists in entries and update modified_at.
|
||||
# All manifest mutations happen here so only one copy is written.
|
||||
if "silo/manifest.json" in entries:
|
||||
try:
|
||||
manifest = json.loads(entries["silo/manifest.json"])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
manifest = _default_manifest()
|
||||
else:
|
||||
manifest = _default_manifest()
|
||||
manifest["modified_at"] = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
entries["silo/manifest.json"] = (
|
||||
json.dumps(manifest, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
with zipfile.ZipFile(filename, "a") as zf:
|
||||
existing = set(zf.namelist())
|
||||
for name, data in entries.items():
|
||||
if name not in existing:
|
||||
zf.writestr(name, data)
|
||||
existing.add(name)
|
||||
# Re-inject cached silo/ entries
|
||||
if entries:
|
||||
for name, data in entries.items():
|
||||
if name not in existing:
|
||||
zf.writestr(name, data)
|
||||
existing.add(name)
|
||||
# Ensure silo/manifest.json exists
|
||||
if "silo/manifest.json" not in existing:
|
||||
manifest = _default_manifest()
|
||||
zf.writestr(
|
||||
"silo/manifest.json",
|
||||
json.dumps(manifest, indent=2) + "\n",
|
||||
)
|
||||
else:
|
||||
# Update modified_at timestamp
|
||||
raw = zf.read("silo/manifest.json")
|
||||
manifest = json.loads(raw)
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if manifest.get("modified_at") != now:
|
||||
manifest["modified_at"] = now
|
||||
# ZipFile append mode can't overwrite; write new entry
|
||||
# (last duplicate wins in most ZIP readers)
|
||||
zf.writestr(
|
||||
"silo/manifest.json",
|
||||
json.dumps(manifest, indent=2) + "\n",
|
||||
)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kc_format: failed to update .kc silo/ entries: {e}\n"
|
||||
@@ -203,36 +191,17 @@ def update_manifest_fields(filename, updates):
|
||||
return
|
||||
if not os.path.isfile(filename):
|
||||
return
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(suffix=".kc", dir=os.path.dirname(filename))
|
||||
os.close(fd)
|
||||
try:
|
||||
with (
|
||||
zipfile.ZipFile(filename, "r") as zin,
|
||||
zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout,
|
||||
):
|
||||
found = False
|
||||
for item in zin.infolist():
|
||||
if item.filename == "silo/manifest.json":
|
||||
if found:
|
||||
continue # skip duplicate entries
|
||||
found = True
|
||||
raw = zin.read(item.filename)
|
||||
manifest = json.loads(raw)
|
||||
manifest.update(updates)
|
||||
zout.writestr(
|
||||
item.filename,
|
||||
json.dumps(manifest, indent=2) + "\n",
|
||||
)
|
||||
else:
|
||||
zout.writestr(item, zin.read(item.filename))
|
||||
shutil.move(tmp, filename)
|
||||
except BaseException:
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
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")
|
||||
|
||||
|
||||
@@ -50,12 +50,6 @@ _KNOWN_ENTRIES = [
|
||||
"Dependencies",
|
||||
("links", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
(
|
||||
"silo/solver/context.json",
|
||||
"SiloSolverContext",
|
||||
"Solver Context",
|
||||
("parts", lambda v: isinstance(v, list) and len(v) > 0),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ 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)
|
||||
|
||||
@@ -122,15 +122,4 @@ TEST(UniqueNameManager, UniqueNameWith9NDigits)
|
||||
manager.addExactName("Compound123456789");
|
||||
EXPECT_EQ(manager.makeUniqueName("Compound", 3), "Compound123456790");
|
||||
}
|
||||
TEST(UniqueNameManager, StructuredPartNumberDecomposition)
|
||||
{
|
||||
// Structured part numbers like P03-0001 have their trailing digits
|
||||
// treated as the uniquifying suffix by UniqueNameManager. This is
|
||||
// correct for default FreeCAD objects (Body -> Body001) but wrong
|
||||
// for structured identifiers. Assembly module handles this separately
|
||||
// via makeInstanceLabel which appends -N instance suffixes instead.
|
||||
Base::UniqueNameManager manager;
|
||||
manager.addExactName("P03-0001");
|
||||
EXPECT_EQ(manager.makeUniqueName("P03-0001", 3), "P03-0002");
|
||||
}
|
||||
// NOLINTEND(cppcoreguidelines-*,readability-*)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
)
|
||||
@@ -1,251 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// 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());
|
||||
}
|
||||
@@ -101,7 +101,6 @@ 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"))
|
||||
sys.path.insert(0, str(_REPO_ROOT / "mods" / "quicknav"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Now import the modules under test
|
||||
@@ -124,15 +123,6 @@ from silo_commands import _safe_float # noqa: E402
|
||||
import silo_start # noqa: E402
|
||||
import silo_origin # noqa: E402
|
||||
|
||||
from quicknav.workbench_map import ( # noqa: E402
|
||||
WORKBENCH_SLOTS,
|
||||
WORKBENCH_GROUPINGS,
|
||||
get_workbench_slot,
|
||||
get_groupings,
|
||||
get_grouping,
|
||||
get_command,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test: update_checker._parse_version
|
||||
@@ -564,110 +554,6 @@ class TestDatumModes(unittest.TestCase):
|
||||
self.assertEqual(len(points), 5)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Test: quicknav workbench_map
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestWorkbenchMap(unittest.TestCase):
|
||||
"""Tests for quicknav.workbench_map data and helpers."""
|
||||
|
||||
def test_all_slots_defined(self):
|
||||
for n in range(1, 6):
|
||||
slot = WORKBENCH_SLOTS.get(n)
|
||||
self.assertIsNotNone(slot, f"Slot {n} missing from WORKBENCH_SLOTS")
|
||||
|
||||
def test_slot_keys(self):
|
||||
for n, slot in WORKBENCH_SLOTS.items():
|
||||
self.assertIn("key", slot)
|
||||
self.assertIn("class_name", slot)
|
||||
self.assertIn("display", slot)
|
||||
self.assertIsInstance(slot["key"], str)
|
||||
self.assertIsInstance(slot["class_name"], str)
|
||||
self.assertIsInstance(slot["display"], str)
|
||||
|
||||
def test_each_slot_has_groupings(self):
|
||||
for n, slot in WORKBENCH_SLOTS.items():
|
||||
groupings = WORKBENCH_GROUPINGS.get(slot["key"])
|
||||
self.assertIsNotNone(groupings, f"No groupings for workbench key '{slot['key']}'")
|
||||
self.assertGreater(len(groupings), 0, f"Empty groupings for slot {n}")
|
||||
|
||||
def test_max_nine_groupings_per_workbench(self):
|
||||
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
|
||||
self.assertLessEqual(len(groupings), 9, f"More than 9 groupings for '{wb_key}'")
|
||||
|
||||
def test_max_nine_commands_per_grouping(self):
|
||||
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
|
||||
for i, grp in enumerate(groupings):
|
||||
self.assertLessEqual(
|
||||
len(grp["commands"]),
|
||||
9,
|
||||
f"More than 9 commands in '{wb_key}' grouping {i}",
|
||||
)
|
||||
|
||||
def test_command_tuples_are_str_str(self):
|
||||
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
|
||||
for i, grp in enumerate(groupings):
|
||||
self.assertIn("name", grp)
|
||||
self.assertIn("commands", grp)
|
||||
for j, cmd in enumerate(grp["commands"]):
|
||||
self.assertIsInstance(cmd, tuple, f"{wb_key}[{i}][{j}] not tuple")
|
||||
self.assertEqual(len(cmd), 2, f"{wb_key}[{i}][{j}] not length 2")
|
||||
self.assertIsInstance(cmd[0], str, f"{wb_key}[{i}][{j}][0] not str")
|
||||
self.assertIsInstance(cmd[1], str, f"{wb_key}[{i}][{j}][1] not str")
|
||||
|
||||
def test_get_workbench_slot_valid(self):
|
||||
for n in range(1, 6):
|
||||
slot = get_workbench_slot(n)
|
||||
self.assertIsNotNone(slot)
|
||||
self.assertEqual(slot, WORKBENCH_SLOTS[n])
|
||||
|
||||
def test_get_workbench_slot_invalid(self):
|
||||
self.assertIsNone(get_workbench_slot(0))
|
||||
self.assertIsNone(get_workbench_slot(6))
|
||||
self.assertIsNone(get_workbench_slot(99))
|
||||
|
||||
def test_get_groupings_valid(self):
|
||||
for slot in WORKBENCH_SLOTS.values():
|
||||
result = get_groupings(slot["key"])
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_get_groupings_invalid(self):
|
||||
self.assertEqual(get_groupings("nonexistent"), [])
|
||||
|
||||
def test_get_grouping_valid(self):
|
||||
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
|
||||
for i in range(len(groupings)):
|
||||
grp = get_grouping(wb_key, i)
|
||||
self.assertIsNotNone(grp)
|
||||
self.assertEqual(grp, groupings[i])
|
||||
|
||||
def test_get_grouping_invalid_index(self):
|
||||
wb_key = WORKBENCH_SLOTS[1]["key"]
|
||||
self.assertIsNone(get_grouping(wb_key, 99))
|
||||
self.assertIsNone(get_grouping(wb_key, -1))
|
||||
|
||||
def test_get_grouping_invalid_key(self):
|
||||
self.assertIsNone(get_grouping("nonexistent", 0))
|
||||
|
||||
def test_get_command_valid(self):
|
||||
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
|
||||
for gi, grp in enumerate(groupings):
|
||||
for ci in range(len(grp["commands"])):
|
||||
cmd_id = get_command(wb_key, gi, ci + 1)
|
||||
self.assertIsNotNone(cmd_id, f"None for {wb_key}[{gi}][{ci + 1}]")
|
||||
self.assertEqual(cmd_id, grp["commands"][ci][0])
|
||||
|
||||
def test_get_command_invalid_number(self):
|
||||
wb_key = WORKBENCH_SLOTS[1]["key"]
|
||||
self.assertIsNone(get_command(wb_key, 0, 0))
|
||||
self.assertIsNone(get_command(wb_key, 0, 99))
|
||||
|
||||
def test_get_command_invalid_workbench(self):
|
||||
self.assertIsNone(get_command("nonexistent", 0, 1))
|
||||
|
||||
|
||||
# ===================================================================
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user