Compare commits

...

12 Commits

Author SHA1 Message Date
forbes
c225ba7da2 feat(assembly): add diagnostic logging to solver and assembly
C++ (AssemblyObject):
- getOrCreateSolver: log which solver backend was loaded
- solve: log assembly name, grounded/joint counts, context size,
  result status with DOF and placement count, per-constraint
  diagnostics on failure
- preDrag/doDragStep/postDrag: log drag part count, per-step
  validation failures, and summary (total steps / rejected count)
- buildSolveContext: log grounded/free part counts, constraint count,
  limits count, and bundle_fixed flag

Python (kindred_solver submodule):
- solver.py: log solve entry/exit with timing, system build stats,
  decomposition decisions, Newton/BFGS fallback events, drag lifecycle
- decompose.py: log cluster stats and per-cluster convergence
- Init.py: FreeCAD log handler routing Python logging to Console
2026-02-21 10:08:10 -06:00
forbes
5d55f091d0 fix(assembly): update flip-detection baseline during drag steps
Some checks failed
Build and Test / build (pull_request) Has been cancelled
During drag operations, validateNewPlacements() compared each solver
result against the pre-drag positions saved once in preDrag().  As the
user dragged further, the cumulative rotation from that fixed baseline
easily exceeded the 91-degree threshold, causing valid intermediate
results to be rejected with 'flipped orientation' warnings and making
parts appear to explode.

Fix: call savePlacementsForUndo() after each accepted drag step so
that the flip check compares against the last accepted state rather
than the original pre-drag origin.
2026-02-21 09:59:04 -06:00
0f8fa0be86 Merge pull request 'feat(assembly): fixed reference planes (Top/Front/Right) + solver docs' (#307) from feat/assembly-origin-planes into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 51s
Build and Test / build (push) Has been cancelled
Reviewed-on: #307
2026-02-21 15:09:55 +00:00
forbes
acc255972d feat(assembly): fixed reference planes + solver docs
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
Assembly Origin Planes:
- AssemblyObject::setupObject() relabels origin planes to
  Top (XY), Front (XZ), Right (YZ) on assembly creation
- CommandCreateAssembly.py makes origin planes visible by default
- AssemblyUtils.cpp getObjFromRef() resolves LocalCoordinateSystem
  to child datum elements for joint references to origin planes
- TestAssemblyOriginPlanes.py: 9 integration tests covering
  structure, labels, grounding, reference resolution, solver,
  and save/load round-trip

Solver Documentation:
- docs/src/solver/: 7 new pages covering architecture overview,
  expression DAG, constraints, solving algorithms, diagnostics,
  assembly integration, and writing custom solvers
- docs/src/SUMMARY.md: added Kindred Solver section
2026-02-21 09:09:16 -06:00
148bed59f6 Merge pull request 'feat(templates): document templating system for .kc files' (#306) from feat/document-templates into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Has been cancelled
Reviewed-on: #306
2026-02-21 15:07:43 +00:00
forbes
b8cb7ca267 feat(templates): document templating system for .kc files
All checks were successful
Build and Test / build (pull_request) Successful in 29m12s
Add a template system integrated with Silo new-item creation that lets
users create parts from pre-configured .kc templates and save existing
documents as reusable templates.

Changes:
- mods/silo: template discovery, picker UI, Save as Template command,
  3-tier search paths (system, personal, org-shared)
- docs: template guide, SUMMARY.md entry, silo.md command reference
2026-02-21 09:06:36 -06:00
ae576629c5 Merge pull request 'fix(assembly): move resetSolver() out-of-line to fix incomplete type error' (#305) from fix/resetsolver-incomplete-type into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #305
2026-02-21 13:09:42 +00:00
forbes
6e7d2b582e fix(assembly): move resetSolver() out-of-line to fix incomplete type error
Some checks failed
Build and Test / build (pull_request) Has been cancelled
unique_ptr::reset() requires the complete type for its deleter, but
IKCSolver is only forward-declared in AssemblyObject.h. Move the
definition to AssemblyObject.cpp where the full header is included.
2026-02-21 07:08:59 -06:00
6d08161ae6 Merge pull request 'feat(solver): KCSolve solver addon with assembly integration (#289)' (#303) from feat/solver-context-packing into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 52s
Reviewed-on: #303
2026-02-21 05:48:46 +00:00
forbes
72e7e32133 feat(solver): KCSolve solver addon with assembly integration (#289)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Adds the Kindred constraint solver as a pluggable Assembly workbench
backend, covering phases 3d through 5 of the solver roadmap.

Phase 3d: SolveContext packing
- Pack/unpack SolveContext into .kc archive on document save

Solver addon (mods/solver):
- Phase 1: Expression DAG, Newton-Raphson + BFGS, 3 basic constraints
- Phase 2: Full constraint vocabulary — all 24 BaseJointKind types
- Phase 3: Graph decomposition for cluster-by-cluster solving
- Phase 4: Per-entity DOF diagnostics, overconstrained detection,
  half-space preference tracking, minimum-movement weighting
- Phase 5: _build_system extraction, diagnose(), drag protocol,
  joint limits warning

Assembly workbench integration:
- Preference-driven solver selection (reads Mod/Assembly/Solver param)
- Solver backend combo box in Assembly preferences UI
- resetSolver() on AssemblyObject for live preference switching
- Integration tests (TestKindredSolverIntegration.py)
- In-client console test script (console_test_phase5.py)
2026-02-20 23:47:50 -06:00
805be1e213 Merge pull request 'feat(solver): pack SolveContext into .kc archives on save (#289 phase 3d)' (#302) from feat/solver-context-packing into main
All checks were successful
Build and Test / build (push) Successful in 30m10s
Reviewed-on: #302
2026-02-21 00:03:13 +00:00
forbes
4cf54caf7b feat(solver): pack SolveContext into .kc archives on save (#289 phase 3d)
All checks were successful
Build and Test / build (pull_request) Successful in 29m51s
Expose AssemblyObject::getSolveContext() to Python and hook into the
.kc save flow so that silo/solver/context.json is packed into every
assembly archive. This lets server-side solver runners operate on
pre-extracted constraint graphs without a full FreeCAD installation.

Changes:
- Add public getSolveContext() to AssemblyObject (C++ and Python)
- Build Python dict via CPython C API matching kcsolve.SolveContext.to_dict()
- Register _solver_context_hook in kc_format.py pre-reinject hooks
- Add silo/solver/context.json to silo_tree.py _KNOWN_ENTRIES
2026-02-20 17:12:25 -06:00
28 changed files with 1994 additions and 20 deletions

4
.gitmodules vendored
View File

@@ -18,3 +18,7 @@
path = mods/silo path = mods/silo
url = https://git.kindred-systems.com/kindred/silo-mod.git url = https://git.kindred-systems.com/kindred/silo-mod.git
branch = main branch = main
[submodule "mods/solver"]
path = mods/solver
url = https://git.kindred-systems.com/kindred/solver.git
branch = main

View File

@@ -12,6 +12,7 @@
- [Workbenches](./guide/workbenches.md) - [Workbenches](./guide/workbenches.md)
- [ztools](./guide/ztools.md) - [ztools](./guide/ztools.md)
- [Silo](./guide/silo.md) - [Silo](./guide/silo.md)
- [Document Templates](./guide/templates.md)
# Architecture # Architecture
@@ -49,6 +50,16 @@
- [Solver Service](./silo-server/SOLVER.md) - [Solver Service](./silo-server/SOLVER.md)
- [Roadmap](./silo-server/ROADMAP.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 # Reference
- [Configuration](./reference/configuration.md) - [Configuration](./reference/configuration.md)

View File

@@ -53,6 +53,7 @@ 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_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_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_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 ### Administration
@@ -129,9 +130,11 @@ mods/silo/
├── freecad/ ├── freecad/
│ ├── InitGui.py # SiloWorkbench registration │ ├── InitGui.py # SiloWorkbench registration
│ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog) │ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog)
│ ├── silo_commands.py # 14 commands + dock widgets │ ├── silo_commands.py # 15 commands + dock widgets
│ ├── silo_origin.py # FileOrigin backend │ ├── silo_origin.py # FileOrigin backend
│ ├── silo_start.py # Native start panel (database items, activity feed) │ ├── 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 │ └── resources/icons/ # 10 silo-*.svg icons
├── silo-client/ # Shared Python API client (nested submodule) ├── silo-client/ # Shared Python API client (nested submodule)
│ └── silo_client/ │ └── silo_client/

140
docs/src/guide/templates.md Normal file
View File

@@ -0,0 +1,140 @@
# 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 |

View File

@@ -0,0 +1,165 @@
# 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:
### pre_drag(ctx, drag_parts)
Called when the user begins dragging. Stores the context and dragged part IDs, then runs a full solve to establish the starting state.
```python
def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
return self.solve(ctx)
```
### drag_step(drag_placements)
Called on each mouse move. Updates the dragged parts' placements in the stored context, then re-solves. Since the parts moved only slightly from the previous position, Newton-Raphson converges in 1-2 iterations.
```python
def drag_step(self, drag_placements):
ctx = self._drag_ctx
for pr in drag_placements:
for part in ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
return self.solve(ctx)
```
### post_drag()
Called when the drag ends. Clears the stored state.
```python
def post_drag(self):
self._drag_ctx = None
self._drag_parts = None
```
### Performance notes
The current implementation re-solves from scratch on each drag step, using the updated placements as the initial guess. This is correct and simple. For assemblies with fewer than ~50 parts, interactive frame rates are maintained because:
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- Pre-passes eliminate fixed parameters before the iterative loop
- The symbolic Jacobian is recomputed each step (no caching yet)
For larger assemblies, cached incremental solving (reusing the decomposition and Jacobian structure across drag steps) is planned as a future optimization.
## 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.

View File

@@ -0,0 +1,116 @@
# 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`

View File

@@ -0,0 +1,117 @@
# 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.

View File

@@ -0,0 +1,96 @@
# 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.

View File

@@ -0,0 +1,92 @@
# 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

128
docs/src/solver/solving.md Normal file
View File

@@ -0,0 +1,128 @@
# 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) |

View File

@@ -0,0 +1,256 @@
# 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

1
mods/solver Submodule

Submodule mods/solver added at d20b38e760

View File

@@ -30,6 +30,7 @@
#include <App/Application.h> #include <App/Application.h>
#include <App/Datums.h> #include <App/Datums.h>
#include <App/Origin.h>
#include <App/Document.h> #include <App/Document.h>
#include <App/DocumentObjectGroup.h> #include <App/DocumentObjectGroup.h>
#include <App/FeaturePythonPyImp.h> #include <App/FeaturePythonPyImp.h>
@@ -106,6 +107,24 @@ AssemblyObject::AssemblyObject()
AssemblyObject::~AssemblyObject() = default; AssemblyObject::~AssemblyObject() = default;
void AssemblyObject::setupObject()
{
App::Part::setupObject();
// Relabel origin planes with assembly-friendly names (SolidWorks convention)
if (auto* origin = getOrigin()) {
if (auto* xy = origin->getXY()) {
xy->Label.setValue("Top");
}
if (auto* xz = origin->getXZ()) {
xz->Label.setValue("Front");
}
if (auto* yz = origin->getYZ()) {
yz->Label.setValue("Right");
}
}
}
PyObject* AssemblyObject::getPyObject() PyObject* AssemblyObject::getPyObject()
{ {
if (PythonObject.is(Py::_None())) { if (PythonObject.is(Py::_None())) {
@@ -144,14 +163,43 @@ void AssemblyObject::onChanged(const App::Property* prop)
// ── Solver integration ───────────────────────────────────────────── // ── Solver integration ─────────────────────────────────────────────
void AssemblyObject::resetSolver()
{
solver_.reset();
}
KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver() KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
{ {
if (!solver_) { if (!solver_) {
solver_ = KCSolve::SolverRegistry::instance().get("ondsel"); ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
"User parameter:BaseApp/Preferences/Mod/Assembly");
std::string solverName = hGrp->GetASCII("Solver", "");
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
// get("") returns the registry default (first registered solver)
if (solver_) {
FC_LOG("Assembly : loaded solver '" << solver_->name()
<< "' (requested='" << solverName << "')");
}
} }
return solver_.get(); return solver_.get();
} }
KCSolve::SolveContext AssemblyObject::getSolveContext()
{
partIdToObjs_.clear();
objToPartId_.clear();
auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
return {};
}
std::vector<App::DocumentObject*> joints = getJoints(false);
removeUnconnectedJoints(joints, groundedObjs);
return buildSolveContext(joints);
}
int AssemblyObject::solve(bool enableRedo, bool updateJCS) int AssemblyObject::solve(bool enableRedo, bool updateJCS)
{ {
ensureIdentityPlacements(); ensureIdentityPlacements();
@@ -168,14 +216,22 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
auto groundedObjs = getGroundedParts(); auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) { if (groundedObjs.empty()) {
FC_LOG("Assembly : solve skipped — no grounded parts");
return -6; return -6;
} }
std::vector<App::DocumentObject*> joints = getJoints(updateJCS); std::vector<App::DocumentObject*> joints = getJoints(updateJCS);
removeUnconnectedJoints(joints, groundedObjs); removeUnconnectedJoints(joints, groundedObjs);
FC_LOG("Assembly : solve on '" << getFullLabel()
<< "' — " << groundedObjs.size() << " grounded, "
<< joints.size() << " joints");
KCSolve::SolveContext ctx = buildSolveContext(joints); KCSolve::SolveContext ctx = buildSolveContext(joints);
FC_LOG("Assembly : solve context — " << ctx.parts.size() << " parts, "
<< ctx.constraints.size() << " constraints");
// Always save placements to enable orientation flip detection // Always save placements to enable orientation flip detection
savePlacementsForUndo(); savePlacementsForUndo();
@@ -197,6 +253,13 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
} }
if (lastResult_.status == KCSolve::SolveStatus::Failed) { if (lastResult_.status == KCSolve::SolveStatus::Failed) {
FC_LOG("Assembly : solve failed — status="
<< static_cast<int>(lastResult_.status)
<< ", " << lastResult_.diagnostics.size() << " diagnostics");
for (const auto& d : lastResult_.diagnostics) {
Base::Console().warning("Assembly : diagnostic [%s]: %s\n",
d.constraint_id.c_str(), d.detail.c_str());
}
updateSolveStatus(); updateSolveStatus();
return -1; return -1;
} }
@@ -204,6 +267,7 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
// Validate that the solve didn't cause any parts to flip orientation // Validate that the solve didn't cause any parts to flip orientation
if (!validateNewPlacements()) { if (!validateNewPlacements()) {
// Restore previous placements - the solve found an invalid configuration // Restore previous placements - the solve found an invalid configuration
FC_LOG("Assembly : solve rejected — placement validation failed, undoing");
undoSolve(); undoSolve();
lastSolverStatus = -2; lastSolverStatus = -2;
updateSolveStatus(); updateSolveStatus();
@@ -221,6 +285,9 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
updateSolveStatus(); updateSolveStatus();
FC_LOG("Assembly : solve succeeded — dof=" << lastResult_.dof
<< ", " << lastResult_.placements.size() << " placements");
return 0; return 0;
} }
@@ -365,6 +432,8 @@ size_t Assembly::AssemblyObject::numberOfFrames()
void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts) void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
{ {
bundleFixed = true; bundleFixed = true;
dragStepCount_ = 0;
dragStepRejected_ = 0;
auto* solver = getOrCreateSolver(); auto* solver = getOrCreateSolver();
if (!solver) { if (!solver) {
@@ -377,6 +446,7 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
auto groundedObjs = getGroundedParts(); auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) { if (groundedObjs.empty()) {
FC_LOG("Assembly : preDrag skipped — no grounded parts");
bundleFixed = false; bundleFixed = false;
return; return;
} }
@@ -430,6 +500,10 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
} }
} }
FC_LOG("Assembly : preDrag — " << dragPartIds.size() << " drag part(s), "
<< joints.size() << " joints, " << ctx.parts.size() << " parts, "
<< ctx.constraints.size() << " constraints");
savePlacementsForUndo(); savePlacementsForUndo();
try { try {
@@ -438,11 +512,13 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
} }
catch (...) { catch (...) {
// If pre_drag fails, we still need to be in a valid state // If pre_drag fails, we still need to be in a valid state
FC_LOG("Assembly : preDrag — solver pre_drag threw exception");
} }
} }
void AssemblyObject::doDragStep() void AssemblyObject::doDragStep()
{ {
dragStepCount_++;
try { try {
std::vector<KCSolve::SolveResult::PartResult> dragPlacements; std::vector<KCSolve::SolveResult::PartResult> dragPlacements;
@@ -462,9 +538,21 @@ void AssemblyObject::doDragStep()
lastResult_ = solver_->drag_step(dragPlacements); lastResult_ = solver_->drag_step(dragPlacements);
if (lastResult_.status == KCSolve::SolveStatus::Failed) {
FC_LOG("Assembly : dragStep #" << dragStepCount_ << " — solver failed");
}
if (validateNewPlacements()) { if (validateNewPlacements()) {
setNewPlacements(); setNewPlacements();
// Update the baseline positions after each accepted drag step so that
// the orientation-flip check in validateNewPlacements() compares against
// the last accepted state rather than the pre-drag origin. Without this,
// cumulative rotation during a long drag easily exceeds the 91-degree
// threshold and causes the solver result to be rejected ("flipped
// orientation"), making parts appear to explode.
savePlacementsForUndo();
auto joints = getJoints(false); auto joints = getJoints(false);
for (auto* joint : joints) { for (auto* joint : joints) {
if (joint->Visibility.getValue()) { if (joint->Visibility.getValue()) {
@@ -473,9 +561,12 @@ void AssemblyObject::doDragStep()
} }
} }
} }
else {
dragStepRejected_++;
}
} }
catch (...) { catch (...) {
// We do nothing if a solve step fails. FC_LOG("Assembly : dragStep #" << dragStepCount_ << " — exception");
} }
} }
@@ -587,6 +678,8 @@ bool AssemblyObject::validateNewPlacements()
void AssemblyObject::postDrag() void AssemblyObject::postDrag()
{ {
FC_LOG("Assembly : postDrag — " << dragStepCount_ << " steps, "
<< dragStepRejected_ << " rejected");
if (solver_) { if (solver_) {
solver_->post_drag(); solver_->post_drag();
} }
@@ -1298,6 +1391,23 @@ KCSolve::SolveContext AssemblyObject::buildSolveContext(
ctx.simulation = sp; ctx.simulation = sp;
} }
// Log context summary
{
int nGrounded = 0, nFree = 0, nLimits = 0;
for (const auto& p : ctx.parts) {
if (p.grounded) nGrounded++;
else nFree++;
}
for (const auto& c : ctx.constraints) {
if (!c.limits.empty()) nLimits++;
}
FC_LOG("Assembly : buildSolveContext — "
<< nGrounded << " grounded + " << nFree << " free parts, "
<< ctx.constraints.size() << " constraints"
<< (nLimits ? (std::string(", ") + std::to_string(nLimits) + " with limits") : "")
<< (ctx.bundle_fixed ? ", bundle_fixed=true" : ""));
}
return ctx; return ctx;
} }

View File

@@ -84,6 +84,7 @@ public:
return "AssemblyGui::ViewProviderAssembly"; return "AssemblyGui::ViewProviderAssembly";
} }
void setupObject() override;
App::DocumentObjectExecReturn* execute() override; App::DocumentObjectExecReturn* execute() override;
void onChanged(const App::Property* prop) override; void onChanged(const App::Property* prop) override;
/* Solve the assembly. It will update first the joints, solve, update placements of the parts /* Solve the assembly. It will update first the joints, solve, update placements of the parts
@@ -98,10 +99,15 @@ public:
void postDrag(); void postDrag();
void savePlacementsForUndo(); void savePlacementsForUndo();
void undoSolve(); void undoSolve();
void resetSolver();
void clearUndo(); void clearUndo();
void exportAsASMT(std::string fileName); void exportAsASMT(std::string fileName);
/// Build the assembly constraint graph without solving.
/// Returns an empty SolveContext if no parts are grounded.
KCSolve::SolveContext getSolveContext();
bool validateNewPlacements(); bool validateNewPlacements();
void setNewPlacements(); void setNewPlacements();
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints); static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
@@ -274,6 +280,10 @@ private:
bool bundleFixed; bool bundleFixed;
// Drag diagnostic counters (reset in preDrag, reported in postDrag)
int dragStepCount_ = 0;
int dragStepRejected_ = 0;
int lastDoF; int lastDoF;
bool lastHasConflict; bool lastHasConflict;
bool lastHasRedundancies; bool lastHasRedundancies;

View File

@@ -4,10 +4,9 @@ from __future__ import annotations
from typing import Any, Final from typing import Any, Final
from Base.Metadata import constmethod, export
from App.Part import Part
from App.DocumentObject import DocumentObject from App.DocumentObject import DocumentObject
from App.Part import Part
from Base.Metadata import constmethod, export
@export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly") @export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly")
class AssemblyObject(Part): class AssemblyObject(Part):
@@ -119,7 +118,9 @@ class AssemblyObject(Part):
... ...
@constmethod @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. Check if a joint is connecting a part to the ground.
@@ -153,6 +154,16 @@ 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 @constmethod
def getDownstreamParts( def getDownstreamParts(
self, start_part: DocumentObject, joint_to_ignore: DocumentObject, / self, start_part: DocumentObject, joint_to_ignore: DocumentObject, /

View File

@@ -21,13 +21,161 @@
* * * *
***************************************************************************/ ***************************************************************************/
// 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.h"
#include "AssemblyObjectPy.cpp" #include "AssemblyObjectPy.cpp"
#include <Mod/Assembly/Solver/SolverRegistry.h>
using namespace Assembly; 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 // returns a string which represents the object e.g. when printed in python
std::string AssemblyObjectPy::representation() const std::string AssemblyObjectPy::representation() const
{ {
@@ -243,3 +391,52 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const
return Py::new_reference_to(ret); 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;
}

View File

@@ -591,6 +591,19 @@ App::DocumentObject* getObjFromRef(App::DocumentObject* comp, const std::string&
if (obj->isDerivedFrom<App::Part>() || obj->isLinkGroup()) { if (obj->isDerivedFrom<App::Part>() || obj->isLinkGroup()) {
continue; 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>()) { else if (obj->isDerivedFrom<PartDesign::Body>()) {
return handlePartDesignBody(obj, it); return handlePartDesignBody(obj, it);
} }

View File

@@ -0,0 +1,230 @@
# 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")
# ── 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__)

View File

@@ -0,0 +1,180 @@
# 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",
)

View File

@@ -58,7 +58,9 @@ SET(AssemblyTests_SRCS
AssemblyTests/TestCore.py AssemblyTests/TestCore.py
AssemblyTests/TestCommandInsertLink.py AssemblyTests/TestCommandInsertLink.py
AssemblyTests/TestSolverIntegration.py AssemblyTests/TestSolverIntegration.py
AssemblyTests/TestKindredSolverIntegration.py
AssemblyTests/TestKCSolvePy.py AssemblyTests/TestKCSolvePy.py
AssemblyTests/TestAssemblyOriginPlanes.py
AssemblyTests/mocks/__init__.py AssemblyTests/mocks/__init__.py
AssemblyTests/mocks/MockGui.py AssemblyTests/mocks/MockGui.py
) )

View File

@@ -22,15 +22,14 @@
# **************************************************************************/ # **************************************************************************/
import FreeCAD as App import FreeCAD as App
from PySide.QtCore import QT_TRANSLATE_NOOP from PySide.QtCore import QT_TRANSLATE_NOOP
if App.GuiUp: if App.GuiUp:
import FreeCADGui as Gui import FreeCADGui as Gui
from PySide import QtCore, QtGui, QtWidgets from PySide import QtCore, QtGui, QtWidgets
import UtilsAssembly
import Preferences import Preferences
import UtilsAssembly
translate = App.Qt.translate translate = App.Qt.translate
@@ -78,14 +77,22 @@ class CommandCreateAssembly:
'assembly = activeAssembly.newObject("Assembly::AssemblyObject", "Assembly")\n' 'assembly = activeAssembly.newObject("Assembly::AssemblyObject", "Assembly")\n'
) )
else: else:
commands = ( commands = 'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
)
commands = commands + 'assembly.Type = "Assembly"\n' commands = commands + 'assembly.Type = "Assembly"\n'
commands = commands + 'assembly.newObject("Assembly::JointGroup", "Joints")' commands = commands + 'assembly.newObject("Assembly::JointGroup", "Joints")'
Gui.doCommand(commands) 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: if not activeAssembly:
Gui.doCommandGui("Gui.ActiveDocument.setEdit(assembly)") Gui.doCommandGui("Gui.ActiveDocument.setEdit(assembly)")
@@ -98,7 +105,9 @@ class ActivateAssemblyTaskPanel:
def __init__(self, assemblies): def __init__(self, assemblies):
self.assemblies = assemblies self.assemblies = assemblies
self.form = QtWidgets.QWidget() 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) layout = QtWidgets.QVBoxLayout(self.form)
label = QtWidgets.QLabel( label = QtWidgets.QLabel(
@@ -132,9 +141,12 @@ class CommandActivateAssembly:
def GetResources(self): def GetResources(self):
return { return {
"Pixmap": "Assembly_ActivateAssembly", "Pixmap": "Assembly_ActivateAssembly",
"MenuText": QT_TRANSLATE_NOOP("Assembly_ActivateAssembly", "Activate Assembly"), "MenuText": QT_TRANSLATE_NOOP(
"Assembly_ActivateAssembly", "Activate Assembly"
),
"ToolTip": QT_TRANSLATE_NOOP( "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", "CmdType": "ForEdit",
} }
@@ -156,7 +168,9 @@ class CommandActivateAssembly:
def Activated(self): def Activated(self):
doc = App.ActiveDocument 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 len(assemblies) == 1:
# If there's only one, activate it directly without showing a dialog # If there's only one, activate it directly without showing a dialog

View File

@@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the
</spacer> </spacer>
</item> </item>
<item row="3" column="0"> <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"> <spacer name="verticalSpacer">
<property name="orientation"> <property name="orientation">
<enum>Qt::Vertical</enum> <enum>Qt::Vertical</enum>

View File

@@ -40,13 +40,34 @@ class PreferencesPage:
pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked()) pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked())
pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked()) pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked())
pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex()) 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): def loadSettings(self):
pref = preferences() 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.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False))
self.form.groundFirstPart.clear() self.form.groundFirstPart.clear()
self.form.groundFirstPart.addItem(translate("Assembly", "Ask")) self.form.groundFirstPart.addItem(translate("Assembly", "Ask"))
self.form.groundFirstPart.addItem(translate("Assembly", "Always")) self.form.groundFirstPart.addItem(translate("Assembly", "Always"))
self.form.groundFirstPart.addItem(translate("Assembly", "Never")) self.form.groundFirstPart.addItem(translate("Assembly", "Never"))
self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0)) 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

View File

@@ -22,6 +22,7 @@
# **************************************************************************/ # **************************************************************************/
import TestApp import TestApp
from AssemblyTests.TestAssemblyOriginPlanes import TestAssemblyOriginPlanes
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
from AssemblyTests.TestCore import TestCore from AssemblyTests.TestCore import TestCore
from AssemblyTests.TestKCSolvePy import ( from AssemblyTests.TestKCSolvePy import (
@@ -30,9 +31,12 @@ from AssemblyTests.TestKCSolvePy import (
TestKCSolveTypes, # noqa: F401 TestKCSolveTypes, # noqa: F401
TestPySolver, # noqa: F401 TestPySolver, # noqa: F401
) )
from AssemblyTests.TestKindredSolverIntegration import TestKindredSolverIntegration
from AssemblyTests.TestSolverIntegration import TestSolverIntegration from AssemblyTests.TestSolverIntegration import TestSolverIntegration
# Use the modules so that code checkers don't complain (flake8) # Use the modules so that code checkers don't complain (flake8)
True if TestCore else False True if TestCore else False
True if TestCommandInsertLink else False True if TestCommandInsertLink else False
True if TestAssemblyOriginPlanes else False
True if TestSolverIntegration else False True if TestSolverIntegration else False
True if TestKindredSolverIntegration else False

View File

@@ -84,3 +84,18 @@ install(
DESTINATION DESTINATION
mods/sdk mods/sdk
) )
# 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
)

View File

@@ -90,6 +90,24 @@ def _manifest_enrich_hook(doc, filename, entries):
register_pre_reinject(_manifest_enrich_hook) 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" KC_VERSION = "1.0"

View File

@@ -50,6 +50,12 @@ _KNOWN_ENTRIES = [
"Dependencies", "Dependencies",
("links", lambda v: isinstance(v, list) and len(v) > 0), ("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),
),
] ]