Compare commits

...

30 Commits

Author SHA1 Message Date
forbes
d5c2887f5a fix(assembly): update solver submodule — enforce drag quaternion continuity (#338)
Points solver to fix/drag-quat-continuity (solver#40) which fixes
the planar constraint drift during interactive drag by enforcing
quaternion continuity on dragged parts and detecting branch jumps
beyond simple hemisphere negation.
2026-02-27 09:37:42 -06:00
forbes
983e211f12 chore: update solver submodule — add planar drag console test (#338)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
2026-02-27 09:31:31 -06:00
forbes
1788b5778a Add CLAUDE.md to .gitignore
All checks were successful
Build and Test / build (push) Successful in 29m54s
2026-02-27 08:19:52 -06:00
418e947cbd Merge pull request 'fix(solver): world-anchored reference normal prevents planar axial drift' (#336) from fix/planar-halfspace-drag-flip into main
All checks were successful
Build and Test / build (push) Successful in 29m17s
Sync Silo Server Docs / sync (push) Successful in 34s
Reviewed-on: #336
2026-02-26 17:10:39 +00:00
4c9ff957e3 Merge branch 'main' into fix/planar-halfspace-drag-flip
All checks were successful
Build and Test / build (pull_request) Successful in 28m48s
2026-02-26 17:10:27 +00:00
9aaf244179 fix(solver): update solver submodule — world-anchored planar reference normal
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
Updates solver to include the fix for PlanarConstraint axial drift
when combined with CylindricalConstraint. The distance residual now
uses a world-frame reference normal (Const nodes) instead of the
body-attached normal that rotates with the body.
2026-02-26 11:07:06 -06:00
69ccdbf742 Merge pull request 'fix(assembly): use instance suffixes for duplicate part labels' (#335) from fix/assembly-part-number-suffix into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #335
2026-02-26 16:34:34 +00:00
4acd09171e Merge pull request 'fix(theme): prevent panel element headings from being clipped' (#334) from fix/theme-panel-heading-clipping into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #334
2026-02-26 16:34:17 +00:00
7f909f166f Merge pull request 'fix(ci): add target_commitish to release payload to fix HTTP 500' (#333) from fix/ci-release-target-commitish into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #333
2026-02-26 16:34:04 +00:00
f69e0efec7 Merge pull request 'docs: add CLAUDE.md for developer context' (#332) from docs/claude-md into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #332
2026-02-26 16:33:36 +00:00
forbes
559a240799 fix(assembly): update solver submodule — fix planar half-space drag flip
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Updates mods/solver to include fix for the planar half-space correction
that caused 'flipped orientation' rejections when dragging a body
connected by a Cylindrical joint + distance=0 Planar constraint.

The solver's PlanarConstraint half-space tracker was reflecting the body
through the plane when the face normal dot product crossed zero during
legitimate rotation about the cylindrical axis. Now returns a tracking-
only HalfSpace (no correction) for on-plane constraints, matching the
pattern used by Cylindrical/Revolute/Concentric trackers.

See: kindred/solver#38
2026-02-26 09:04:14 -06:00
7c85b2ad93 fix(assembly): use instance suffixes for duplicate part labels
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
When parts with structured part numbers (e.g., P03-0001) are inserted
into an assembly multiple times, UniqueNameManager::decomposeName()
treats the trailing digits as an auto-generated suffix and increments
them (P03-0002, P03-0003), corrupting the part number.

Add a makeInstanceLabel() helper in AssemblyLink.cpp that appends -N
instance suffixes instead (P03-0001-1, P03-0001-2). All instances get
a suffix starting at -1 so the original part number is never modified.

Applied at all three Label.setValue() sites in
synchronizeComponents() (AssemblyLink, link group, and regular link
creation paths).

Also add a UniqueNameManager test documenting the trailing-digit
decomposition behavior for structured part numbers.

Closes #327
2026-02-26 09:00:13 -06:00
311d911cfa fix(theme): prevent panel element headings from being clipped
All checks were successful
Build and Test / build (pull_request) Successful in 29m23s
Three QSS issues caused headings to render with only the top ~60%
visible:

- QGroupBox: margin-top 12px was insufficient for the title rendered
  in subcontrol-origin: margin. Increased to 16px and added 2px
  vertical padding to the title.
- QDockWidget::title: min-height 18px conflicted with padding 8px 6px,
  constraining the content area. Removed min-height to let Qt auto-size
  from padding + font metrics.
- QSint--ActionGroup QToolButton: min-height 18px forced a height that
  was then clipped by the C++ setFixedHeight(headerSize) calculation.
  Set min-height to 0px so the C++ layout controls sizing.

Closes #325
2026-02-26 08:48:01 -06:00
4ef8e64a7c fix(ci): add target_commitish to release payload to fix HTTP 500
All checks were successful
Build and Test / build (pull_request) Successful in 29m30s
The publish-release job was missing the target_commitish field in the
Gitea release creation API payload. Without it, Gitea cannot resolve
the tag to a git object and returns HTTP 500 with 'object does not
exist'.

Add COMMIT_SHA (from github.sha) to the job env and pass it as
target_commitish in the JSON payload.

Closes #326
2026-02-26 08:45:58 -06:00
d94e8c8294 docs: add CLAUDE.md for developer context
All checks were successful
Build and Test / build (pull_request) Successful in 29m9s
2026-02-26 08:40:34 -06:00
3550d916bd Merge pull request 'docs(solver): update drag protocol docs to reflect implemented caching' (#330) from docs/solver-drag-cache into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Successful in 29m53s
Reviewed-on: #330
2026-02-26 14:25:17 +00:00
6e15b25134 docs(solver): update drag protocol docs to reflect implemented caching
All checks were successful
Build and Test / build (pull_request) Successful in 30m27s
The interactive drag section described the original naive implementation
(re-solve from scratch each step) and called the caching layer a
'planned future optimization'. In reality _DragCache is fully
implemented: pre_drag() builds the system, Jacobian, and compiled
evaluator once, and drag_step() reuses them.

Update code snippets, add _DragCache field table, and document the
single_equation_pass exclusion from the drag path.
2026-02-25 13:23:41 -06:00
82f2422285 Merge pull request 'fix(solver): skip single_equation_pass during drag to prevent stale constraints' (#329) from fix/planar-drag-prepass into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 40s
Reviewed-on: #329
2026-02-25 19:03:16 +00:00
314955c3ef fix(solver): update solver submodule — skip prepass during drag
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Updates solver submodule to include fix for planar distance=0
constraints not holding during drag operations.
2026-02-25 12:58:03 -06:00
c7a7436e7b Merge pull request 'chore: update solver submodule' (#328) from chore/update-solver-submodule into main
All checks were successful
Sync Silo Server Docs / sync (push) Successful in 38s
Build and Test / build (push) Successful in 30m20s
Reviewed-on: #328
2026-02-25 02:49:57 +00:00
forbes
40dd8e09d7 chore: update solver submodule
All checks were successful
Build and Test / build (pull_request) Successful in 29m42s
Picks up fix/drag-orientation-stability (kindred/solver#36):
- Half-space tracking for all compound constraints with branch ambiguity
- Quaternion continuity enforcement during interactive drag
2026-02-24 20:49:27 -06:00
1fd52ccf1c Merge pull request 'feat: add QuickNav addon — Phase 1 core infrastructure (#320)' (#324) from feat/quicknav-phase1 into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 46s
Build and Test / build (push) Successful in 29m41s
Reviewed-on: #324
2026-02-24 18:37:19 +00:00
e73c5fc750 feat: add QuickNav addon — Phase 1 core infrastructure (#320)
All checks were successful
Build and Test / build (pull_request) Successful in 29m58s
Add quicknav submodule and create-side integration for keyboard-driven
command navigation.

Submodule: mods/quicknav (https://git.kindred-systems.com/kindred/quicknav)

Create-side changes:
- CMakeLists.txt: add quicknav install rules
- test_kindred_pure.py: add 16 workbench_map validation tests
- docs/src/quicknav/SPEC.md: QuickNav specification

QuickNav provides numbered-key access to workbenches (Ctrl+1-5),
command groupings (Shift+1-9), and individual commands (1-9), with
a navigation bar toolbar and input-widget safety guards.
2026-02-23 14:12:02 -06:00
f652d6ccf8 Merge pull request 'fix(assembly): handle non-standard datum element types in Distance joint classification' (#319) from fix/datum-plane-classification-all-hierarchies into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 37s
Reviewed-on: #319
2026-02-23 03:20:04 +00:00
forbes
14ee8c673f fix(assembly): classify datum planes from all class hierarchies in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
The datum plane detection in getDistanceType() only checked for
App::Plane (origin planes). This missed two other class hierarchies:

  - PartDesign::Plane (inherits Part::Datum, NOT App::Plane)
  - Part::Plane primitive referenced bare (no Face element)

Both produce empty element types (sub-name ends with ".") but failed
the isDerivedFrom<App::Plane>() check, falling through to
DistanceType::Other and the Planar fallback. This caused incorrect
constraint geometry, leading to conflicting/unsatisfiable constraints
and solver failures.

Add shape-based isDatumPlane/Line/Point helpers that cover all three
hierarchies by inspecting the actual OCCT geometry rather than relying
on class identity alone. Extend getDistanceType() to use these helpers
for all datum-vs-datum and datum-vs-element combinations.

Adds TestDatumClassification.py with tests for PartDesign::Plane,
Part::Plane (bare ref), and cross-hierarchy datum combinations.
2026-02-22 21:18:34 -06:00
a6a5db11f8 Merge pull request 'fix(assembly): classify datum planes from all class hierarchies in Distance joints' (#318) from fix/datum-plane-classification-all-hierarchies into main
All checks were successful
Build and Test / build (push) Successful in 31m20s
Reviewed-on: #318
2026-02-23 00:56:20 +00:00
forbes
962b521f5c fix(assembly): classify datum planes from all class hierarchies in Distance joints
All checks were successful
Build and Test / build (pull_request) Successful in 30m13s
The datum plane detection in getDistanceType() only checked for
App::Plane (origin planes). This missed two other class hierarchies:

  - PartDesign::Plane (inherits Part::Datum, NOT App::Plane)
  - Part::Plane primitive referenced bare (no Face element)

Both produce empty element types (sub-name ends with ".") but failed
the isDerivedFrom<App::Plane>() check, falling through to
DistanceType::Other and the Planar fallback. This caused incorrect
constraint geometry, leading to conflicting/unsatisfiable constraints
and solver failures.

Add shape-based isDatumPlane/Line/Point helpers that cover all three
hierarchies by inspecting the actual OCCT geometry rather than relying
on class identity alone. Extend getDistanceType() to use these helpers
for all datum-vs-datum and datum-vs-element combinations.

Adds TestDatumClassification.py with tests for PartDesign::Plane,
Part::Plane (bare ref), and cross-hierarchy datum combinations.
2026-02-22 18:55:39 -06:00
5c9212247a Merge pull request 'fix(assembly): add datum plane logging + fix cross-product singularity' (#317) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #317
2026-02-22 22:05:48 +00:00
50dc8c8ea1 Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#316) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #316
2026-02-22 20:21:07 +00:00
forbes
b4835e1b05 fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 14:19:11 -06:00
16 changed files with 1499 additions and 53 deletions

View File

@@ -322,6 +322,7 @@ jobs:
env:
BUILD_TAG: ${{ github.ref_name || inputs.tag }}
COMMIT_SHA: ${{ github.sha }}
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
steps:
@@ -386,6 +387,7 @@ jobs:
'name': f'Kindred Create {tag}',
'body': body,
'prerelease': prerelease,
'target_commitish': '${COMMIT_SHA}',
}))
")

2
.gitignore vendored
View File

@@ -77,3 +77,5 @@ docs/book/
# To regenerate themed icons: python3 icons/retheme.py
# icons/themed/ is tracked (committed) so CI builds include them
CLAUDE.md

3
.gitmodules vendored
View File

@@ -22,3 +22,6 @@
path = mods/solver
url = https://git.kindred-systems.com/kindred/solver.git
branch = main
[submodule "mods/quicknav"]
path = mods/quicknav
url = https://git.kindred-systems.com/kindred/quicknav.git

285
CLAUDE.md Normal file
View File

@@ -0,0 +1,285 @@
# CLAUDE.md — Developer Context for Kindred Create
## Project Overview
Kindred Create is a fork of FreeCAD 1.0+ that adds integrated tooling for professional engineering workflows. It ships a context-aware UI system, two addon command sets (ztools and Silo), a Catppuccin Mocha dark theme, and a pluggable file origin layer on top of FreeCAD's parametric modeling core.
- **Kindred Create version:** 0.1.5
- **FreeCAD base version:** 1.2.0
- **License:** LGPL-2.1-or-later
- **Repository:** `git.kindred-systems.com/kindred/create` (Gitea)
- **Main branch:** `main`
## Quick Start
```bash
git clone --recursive ssh://git@git.kindred-systems.com:2222/kindred/create.git
cd create
pixi run configure # CMake configure (debug by default)
pixi run build # Build
pixi run install # Install to build dir
pixi run freecad # Launch
pixi run test # Run C++ tests (ctest)
pixi run test-kindred # Run Python/Kindred tests
```
Build variants: append `-debug` or `-release` (e.g., `pixi run build-release`). See `CMakePresets.json` for platform-specific presets (Linux x86_64/aarch64, macOS Intel/ARM, Windows x64).
## Repository Structure
```
create/
├── src/
│ ├── App/ Core application (C++)
│ ├── Base/ Base classes, type system, persistence (C++)
│ ├── Gui/ GUI framework (C++)
│ │ ├── EditingContext.h Editing context resolver (Kindred feature)
│ │ ├── BreadcrumbToolBar.h Breadcrumb navigation widget (Kindred feature)
│ │ ├── FileOrigin.h Abstract origin interface (Kindred feature)
│ │ ├── OriginManager.h Origin lifecycle management
│ │ ├── CommandOrigin.cpp Origin_Commit/Pull/Push/Info/BOM commands
│ │ ├── ApplicationPy.h All FreeCADGui.* Python bindings
│ │ ├── Application.h App signals (fastsignals)
│ │ ├── Stylesheets/ QSS theme files
│ │ └── PreferencePacks/ Preference configurations (build-time generated)
│ ├── Mod/ FreeCAD modules (PartDesign, Assembly, Sketcher, etc.)
│ │ └── Create/ Kindred Create module
│ │ ├── Init.py Console bootstrap — loads addons
│ │ ├── InitGui.py GUI bootstrap — loads addons, Silo setup, update checker
│ │ ├── addon_loader.py Manifest-driven loader with dependency resolution
│ │ └── kc_format.py .kc file format preservation
│ └── 3rdParty/ Vendored dependencies
│ ├── OndselSolver/ [submodule] Assembly constraint solver (forked)
│ ├── FastSignals/ Signal/slot library (NOT Boost)
│ └── GSL/ [submodule] Microsoft Guidelines Support Library
├── mods/ Kindred addon modules
│ ├── sdk/ Addon SDK — stable API contract (priority 0)
│ ├── ztools/ [submodule] Command provider (priority 50)
│ ├── silo/ [submodule] PLM workbench (priority 60)
│ ├── solver/ [submodule] Assembly solver research (GNN-based)
│ └── quicknav/ [submodule] Navigation addon
├── docs/ mdBook documentation + architecture docs
├── tests/ C++ unit tests (GoogleTest)
├── package/ Packaging (debian/, rattler-build/)
├── resources/ Branding, icons, desktop integration
├── cMake/ CMake helper modules
├── .gitea/workflows/ CI/CD pipelines
├── CMakeLists.txt Root build configuration (CMake 3.22.0+)
├── CMakePresets.json Platform build presets
└── pixi.toml Pixi environment and build tasks
```
## Build System
- **Primary:** CMake 3.22.0+ with Ninja generator
- **Environment:** [Pixi](https://pixi.sh) (conda-forge) manages all dependencies
- **Key deps:** Qt 6.8.x, Python 3.11.x, OpenCASCADE 7.8.x, PySide6, Boost, VTK, SMESH
- **Presets:** `conda-linux-debug`, `conda-linux-release`, `conda-macos-debug`, `conda-macos-release`, `conda-windows-debug`, `conda-windows-release`
- **Tasks summary:**
| Task | Description |
|------|-------------|
| `pixi run configure` | CMake configure (debug) |
| `pixi run build` | Build (debug) |
| `pixi run install` | Install to build dir |
| `pixi run freecad` | Launch FreeCAD |
| `pixi run test` | C++ tests via ctest |
| `pixi run test-kindred` | Python/Kindred test suite |
## Architecture Patterns
### Signals — Use FastSignals, NOT Boost
```cpp
#include <fastsignals/signal.h>
// See src/Gui/Application.h:121-155 for signal declarations
```
All signals in `src/Gui/` use `fastsignals::signal`. Never use Boost.Signals2.
### Type Checking Across Modules
Avoid header dependencies between `src/Gui/` and `src/Mod/` by using runtime type checks:
```cpp
auto type = Base::Type::fromName("Sketcher::SketchObject");
if (obj->isDerivedFrom(type)) { ... }
```
### Python Bindings
All `FreeCADGui.*` functions go in `src/Gui/ApplicationPy.h` and `src/Gui/ApplicationPy.cpp`. Use `METH_VARARGS` only (no `METH_KEYWORDS` in this file). Do not create separate files for new Python bindings.
### Toolbar Visibility
Use `ToolBarItem::DefaultVisibility::Unavailable` to hide toolbars by default, then `ToolBarManager::setState(ForceAvailable)` to show them contextually. This pattern is proven by the Sketcher module.
The `appendToolbar` Python API accepts an optional 3rd argument: `"Visible"`, `"Hidden"`, or `"Unavailable"`.
### Editing Context System
The `EditingContextResolver` singleton (`src/Gui/EditingContext.h/.cpp`) drives the context-aware UI. It evaluates registered context definitions in priority order and activates the matching one, setting toolbar visibility and updating the `BreadcrumbToolBar`.
Built-in contexts: `sketcher.edit`, `assembly.edit`, `partdesign.feature`, `partdesign.body`, `assembly.idle`, `spreadsheet`, `empty_document`, `no_document`.
Python API:
- `FreeCADGui.registerEditingContext()` — register a new context
- `FreeCADGui.registerEditingOverlay()` — conditional toolbar overlay
- `FreeCADGui.injectEditingCommands()` — add commands to existing contexts
- `FreeCADGui.currentEditingContext()` — query active context
- `FreeCADGui.refreshEditingContext()` — force re-evaluation
### Addon Loading
Addons in `mods/` are loaded by `src/Mod/Create/addon_loader.py`. Each addon provides a `package.xml` with `<kindred>` extensions declaring version bounds, load priority, and dependencies. The loader resolves via topological sort: **sdk** (0) -> **ztools** (50) -> **silo** (60).
A `<workbench>` tag in `package.xml` is required for `InitGui.py` to be loaded, even if no actual workbench is registered.
### Deferred Initialization
GUI setup uses `QTimer.singleShot` with staggered delays:
- 500ms: `.kc` file format registration
- 1500ms: Silo origin registration
- 2000ms: Auth dock + ztools commands
- 2500ms: Silo overlay
- 3000ms: Silo first-start check
- 4000ms: Activity panel
- 10000ms: Update checker
### Unified Origin System
File operations (New, Open, Save, Commit, Pull, Push) are abstracted behind `FileOrigin` (`src/Gui/FileOrigin.h`). `LocalFileOrigin` handles local files; `SiloOrigin` (`mods/silo/freecad/silo_origin.py`) backs Silo-tracked documents. The active origin is selected automatically based on document properties (`SiloItemId`, `SiloPartNumber`).
## Submodules
| Path | Repository | Branch | Purpose |
|------|------------|--------|---------|
| `mods/ztools` | `git.kindred-systems.com/forbes/ztools` | `main` | Extended PartDesign/Assembly/Spreadsheet tools |
| `mods/silo` | `git.kindred-systems.com/kindred/silo-mod` | `main` | PLM workbench (includes silo-client submodule) |
| `mods/solver` | `git.kindred-systems.com/kindred/solver` | `main` | Assembly solver research (GNN-based) |
| `mods/quicknav` | `git.kindred-systems.com/kindred/quicknav` | — | Navigation addon |
| `src/3rdParty/OndselSolver` | `git.kindred-systems.com/kindred/solver` | — | Constraint solver (forked with NR fix) |
| `src/3rdParty/GSL` | `github.com/microsoft/GSL` | — | Guidelines Support Library |
| `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | — | FreeCAD addon manager |
| `tests/lib` | `github.com/google/googletest` | — | C++ test framework |
Update a submodule:
```bash
cd mods/silo
git checkout main && git pull
cd ../..
git add mods/silo
git commit -m "chore: update silo submodule"
```
Initialize all submodules: `git submodule update --init --recursive`
## Key Addon Modules
### ztools (`mods/ztools/`)
Command provider (NOT a workbench). Injects tools into PartDesign, Assembly, and Spreadsheet contexts via `_ZToolsManipulator` (WorkbenchManipulator) and `injectEditingCommands()`.
Commands: `ZTools_DatumCreator`, `ZTools_EnhancedPocket`, `ZTools_RotatedLinearPattern`, `ZTools_AssemblyLinearPattern`, `ZTools_AssemblyPolarPattern`, spreadsheet formatting (Bold, Italic, Underline, alignment, colors, QuickAlias).
Source: `mods/ztools/ztools/ztools/commands/` (note the double `ztools` nesting).
### Silo (`mods/silo/`)
PLM workbench with 14 commands for parts lifecycle management. Go REST API server + PostgreSQL + MinIO backend. FreeCAD client communicates via shared `silo-client` submodule.
Silo origin detection: `silo_origin.py:ownsDocument()` checks for `SiloItemId`/`SiloPartNumber` properties on the active document.
### SDK (`mods/sdk/`)
Stable API contract for addons. Provides wrappers for editing contexts, theme tokens (Catppuccin Mocha YAML palette), FileOrigin registration, and deferred dock panels. Addons should use `kindred_sdk.*` instead of `FreeCADGui.*` internals where possible.
## Theme
- **Canonical source:** `src/Gui/Stylesheets/KindredCreate.qss`
- The PreferencePacks copy at `src/Gui/PreferencePacks/KindredCreate/KindredCreate.qss` is **generated at build time** via `configure_file()`. Only edit the Stylesheets copy.
- Color palette: Catppuccin Mocha (26 colors + 14 semantic roles, defined in `mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml`)
- Default preferences: `src/Gui/PreferencePacks/KindredCreate/KindredCreate.cfg`
## Git Conventions
### Branch Names
`type/kebab-case-description`
Types: `feat/`, `fix/`, `chore/`, `docs/`, `refactor/`, `art/`
### Commit Messages
[Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): lowercase imperative description
```
| Prefix | Purpose |
|--------|---------|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `chore:` | Maintenance, dependencies |
| `docs:` | Documentation only |
| `art:` | Icons, theme, visual assets |
| `refactor:` | Code restructuring |
Scopes: `solver`, `sketcher`, `editing-context`, `toolbar`, `ztools`, `silo`, `breadcrumb`, `gui`, `assembly`, `ci`, `theme`, `quicknav`, or omitted.
### PR Workflow
1. Create a branch from `main`: `git checkout -b feat/my-feature main`
2. Commit with conventional commit messages
3. Push and open a PR against `main` via Gitea (or `tea pulls create`)
4. CI runs automatically on PRs
### Code Style
- **C++:** clang-format (`.clang-format`), clang-tidy (`.clang-tidy`)
- **Python:** black (100-char line length), pylint (`.pylintrc`)
- **Pre-commit hooks:** `pre-commit install` (runs clang-format, black, trailing-whitespace, etc.)
## CI/CD
- **Build:** `.gitea/workflows/build.yml` — runs on pushes to `main` and on PRs
- **Release:** `.gitea/workflows/release.yml` — triggered by `v*` tags, builds AppImage and .deb
- **Platform:** Currently Linux x86_64 only in CI; other platforms have presets but no runners yet
## Documentation
| Document | Content |
|----------|---------|
| `README.md` | Project overview, installation, usage |
| `CONTRIBUTING.md` | Branch workflow, commit conventions, code style |
| `docs/ARCHITECTURE.md` | Bootstrap flow, addon lifecycle, source layout |
| `docs/COMPONENTS.md` | Feature inventory (ztools, Silo, origin, theme, icons) |
| `docs/KNOWN_ISSUES.md` | Known issues, incomplete features, next steps |
| `docs/INTEGRATION_PLAN.md` | 5-layer architecture, phase status |
| `docs/CI_CD.md` | Build and release workflows |
| `docs/KC_SPECIFICATION.md` | .kc file format specification |
| `docs/UPSTREAM.md` | FreeCAD upstream merge strategy |
| `docs/INTER_SOLVER.md` | Assembly solver integration |
| `docs/BOM_MERGE.md` | BOM-Assembly bridge specification |
The `docs/src/` directory contains an mdBook site with detailed guides organized by topic (architecture, development, guide, reference, silo-server, solver).
## Issue Tracker
Issues are tracked on Gitea at `git.kindred-systems.com/kindred/create/issues`. Use the `tea` CLI for local interaction:
```bash
tea issues # List open issues
tea issues 123 # View issue #123 details
tea pulls create # Create a PR
```
## Known Issues and Pitfalls
1. **Silo auth not production-hardened** — LDAP/OIDC backends are coded but need infrastructure deployment
2. **No unit tests** for ztools/Silo FreeCAD commands or Go backend
3. **Assembly solver datum handling is minimal** — joints referencing datum planes/points may produce incorrect placement
4. **PartDesign menu insertion fragility**`_ZToolsPartDesignManipulator.modifyMenuBar()` inserts after `PartDesign_Boolean`; upstream renames break silently
5. **`Silo_BOM` requires Silo-tracked document** — unregistered documents show a warning with no registration path
6. **QSS edits** — only edit `src/Gui/Stylesheets/KindredCreate.qss`; the PreferencePacks copy is auto-generated

441
docs/src/quicknav/SPEC.md Normal file
View File

@@ -0,0 +1,441 @@
# QuickNav — Keyboard Navigation Addon Specification
**Addon name:** QuickNav
**Type:** Pure Python FreeCAD addon (no C++ required)
**Compatibility:** FreeCAD 1.0+, Kindred Create 0.1+
**Location:** `mods/quicknav/`
---
## 1. Overview
QuickNav provides keyboard-driven command access for FreeCAD and Kindred Create. It replaces mouse-heavy toolbar navigation with a numbered key system organized by workbench and command grouping. The addon is activated by loading its workbench and toggled on/off with the `0` key.
### Design Goals
- Numbers `1-9` execute commands within the active command grouping
- `Shift+1-9` switches command grouping within the active workbench
- `Ctrl+1-9` switches workbench context
- All groupings and workbenches are ordered by most-recently-used (MRU) history
- History is unlimited internally, top 9 shown, remainder scrollable/clickable
- Mouse interaction remains fully functional — QuickNav is purely additive
- Configuration persisted via `FreeCAD.ParamGet()`
---
## 2. Terminology
| Term | Definition |
|------|-----------|
| **Workbench** | A FreeCAD workbench (Sketcher, PartDesign, Assembly, etc.). Fixed assignment to Ctrl+N slots. |
| **Command Grouping** | A logical group of commands within a workbench, mapped from existing FreeCAD toolbar groupings. Max 9 per tier. |
| **Active Grouping** | The left-most visible grouping in the navigation bar. Its commands are accessible via `1-9`. |
| **Navigation Bar** | Bottom toolbar displaying the current state: active workbench, groupings, and numbered commands. |
| **MRU Stack** | Most-recently-used ordering. Position 0 = currently active, 1 = previously active, etc. |
| **Tier** | When a workbench has >9 command groupings, they are split: Tier 1 (most common 9), Tier 2 (next 9). |
---
## 3. Key Bindings
### 3.1 Mode Toggle
| Key | Action |
|-----|--------|
| `0` | Toggle QuickNav on/off. When off, all QuickNav key interception is disabled and the navigation bar hides. |
### 3.2 Command Execution
| Key | Action |
|-----|--------|
| `1-9` | Execute the Nth command in the active grouping. If the command is auto-executable (e.g., Pad after closed sketch), execute immediately. Otherwise, enter tool mode (same as clicking the toolbar button). |
### 3.3 Grouping Navigation
| Key | Action |
|-----|--------|
| `Shift+1-9` | Switch to the Nth command grouping (MRU ordered) within the current workbench. The newly activated grouping moves to position 0 in the MRU stack. |
| `Shift+Left/Right` | Scroll through groupings beyond the visible 9. |
### 3.4 Workbench Navigation
| Key | Action |
|-----|--------|
| `Ctrl+1` | Sketcher |
| `Ctrl+2` | Part Design |
| `Ctrl+3` | Assembly |
| `Ctrl+4` | Spreadsheet |
| `Ctrl+5` | TechDraw |
| `Ctrl+6-9` | User-configurable / additional workbenches |
Switching workbench via `Ctrl+N` also restores that workbench's last-active command grouping.
---
## 4. Navigation Bar
The navigation bar is a `QToolBar` positioned at the bottom of the main window (replacing or sitting alongside FreeCAD's default bottom toolbar area).
### 4.1 Layout
```
┌─────────────────────────────────────────────────────────────────────┐
│ [WB: Sketcher] │ ❶ Primitives │ ② Constraints │ ③ Dimensions │ ◀▶ │
│ │ 1:Line 2:Rect 3:Circle 4:Arc 5:Point 6:Slot ... │
└─────────────────────────────────────────────────────────────────────┘
```
- **Left section:** Current workbench name with Ctrl+N hint
- **Middle section (top row):** Command groupings, MRU ordered. Active grouping is ❶ (filled circle), others are ②③ etc. Scrollable horizontally if >9.
- **Middle section (bottom row):** Commands within the active grouping, numbered 1-9
- **Right section:** Scroll arrows for overflow groupings
### 4.2 Visual States
- **Active grouping:** Bold text, filled number badge, Catppuccin Mocha `blue` (#89b4fa) accent
- **Inactive groupings:** Normal text, outlined number badge, `surface1` (#45475a) text
- **Hovered command:** `surface2` (#585b70) background highlight
- **Active command (tool in use):** `green` (#a6e3a1) underline indicator
### 4.3 Mouse Interaction
- Click any grouping to activate it (equivalent to Shift+N)
- Click any command to execute it (equivalent to pressing N)
- Scroll wheel on grouping area to cycle through overflow groupings
- Click scroll arrows to page through overflow
---
## 5. Workbench Command Groupings
Each workbench's existing FreeCAD toolbars map to command groupings. Where a workbench has >9 toolbars, split into Tier 1 (default, most common) and Tier 2 (accessible via scrolling or `Shift+Left/Right`).
### 5.1 Sketcher (Ctrl+1)
| Grouping | Commands (1-9) |
|----------|---------------|
| Primitives | Line, Rectangle, Circle, Arc, Point, Slot, B-Spline, Polyline, Ellipse |
| Constraints | Coincident, Horizontal, Vertical, Parallel, Perpendicular, Tangent, Equal, Symmetric, Block |
| Dimensions | Distance, Horizontal Distance, Vertical Distance, Radius, Diameter, Angle, Lock, Constrain Refraction |
| Construction | Toggle Construction, External Geometry, Carbon Copy, Offset, Trim, Extend, Split |
| Tools | Mirror, Array (Linear), Array (Polar), Move, Rotate, Scale, Close Shape, Connect Edges |
### 5.2 Part Design (Ctrl+2)
| Grouping | Commands (1-9) |
|----------|---------------|
| Additive | Pad, Revolution, Additive Loft, Additive Pipe, Additive Helix, Additive Box, Additive Cylinder, Additive Sphere, Additive Cone |
| Subtractive | Pocket, Hole, Groove, Subtractive Loft, Subtractive Pipe, Subtractive Helix, Subtractive Box, Subtractive Cylinder, Subtractive Sphere |
| Datums | New Sketch, Datum Plane, Datum Line, Datum Point, Shape Binder, Sub-Shape Binder, ZTools Datum Creator, ZTools Datum Manager |
| Transformations | Mirrored, Linear Pattern, Polar Pattern, MultiTransform, ZTools Rotated Linear Pattern |
| Modeling | Fillet, Chamfer, Draft, Thickness, Boolean, ZTools Enhanced Pocket |
### 5.3 Assembly (Ctrl+3)
| Grouping | Commands (1-9) |
|----------|---------------|
| Components | Insert Component, Create Part, Create Assembly, Ground, BOM |
| Joints | Fixed, Revolute, Cylindrical, Slider, Ball, Planar, Distance, Angle, Parallel |
| Patterns | ZTools Linear Pattern, ZTools Polar Pattern |
### 5.4 Spreadsheet (Ctrl+4)
| Grouping | Commands (1-9) |
|----------|---------------|
| Editing | Merge Cells, Split Cell, Alias, Import CSV, Export CSV |
| Formatting | Bold, Italic, Underline, Align Left, Align Center, Align Right, BG Color, Text Color, Quick Alias |
### 5.5 TechDraw (Ctrl+5)
Groupings derived from TechDraw's existing toolbars at runtime.
> **Note:** The exact command lists above are initial defaults. The addon discovers available commands from each workbench's toolbar structure at activation time and falls back to these defaults only if discovery fails.
---
## 6. MRU History Behavior
### 6.1 Grouping History (per workbench)
Each workbench maintains its own grouping MRU stack.
- When a grouping is activated (via `Shift+N` or mouse click), it moves to position 0
- The previously active grouping moves to position 1, everything else shifts down
- Position 0 is always the active grouping (already selected, shown leftmost)
- `Shift+1` is a no-op (already active), `Shift+2` activates the previous grouping, etc.
### 6.2 Workbench History
- Workbenches have fixed Ctrl+N assignments (not MRU ordered)
- However, each workbench remembers its last-active grouping
- Switching to a workbench restores its last-active grouping as position 0
### 6.3 Persistence
Stored in `FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/QuickNav")`:
| Parameter | Type | Description |
|-----------|------|-------------|
| `Enabled` | Bool | Whether QuickNav is currently active |
| `GroupHistory/<Workbench>` | String | Semicolon-delimited list of grouping names in MRU order |
| `LastGrouping/<Workbench>` | String | Name of the last-active grouping per workbench |
| `CustomSlots/Ctrl6` through `Ctrl9` | String | Workbench names for user-configurable slots |
---
## 7. Auto-Execution Logic
When a command is invoked via number key, QuickNav checks if the command can be auto-executed:
### 7.1 Auto-Execute Conditions
A command auto-executes (runs and completes without entering a persistent mode) when:
1. **Pad/Pocket after closed sketch:** If the active body has a sketch that was just closed (sketch edit mode exited with a closed profile), pressing the Pad or Pocket command key creates the feature with default parameters. The task panel still opens for parameter adjustment.
2. **Boolean operations:** If exactly two bodies/shapes are selected, boolean commands execute with defaults.
3. **Constraint application:** If appropriate geometry is pre-selected in Sketcher, constraint commands apply immediately.
### 7.2 Mode-Entry (Default)
All other commands enter their standard FreeCAD tool mode — identical to clicking the toolbar button. The user interacts with the 3D view and/or task panel as normal.
---
## 8. Key Event Handling
### 8.1 Event Filter Architecture
```python
class QuickNavEventFilter(QObject):
"""Installed on FreeCAD's main window via installEventFilter().
Intercepts KeyPress events when QuickNav is active.
Passes through all events when QuickNav is inactive.
"""
def eventFilter(self, obj, event):
if event.type() != QEvent.KeyPress:
return False
if not self._active:
return False
# Don't intercept when a text input widget has focus
focused = QApplication.focusWidget()
if isinstance(focused, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox)):
return False
# Don't intercept when task panel input fields are focused
if self._is_task_panel_input(focused):
return False
key = event.key()
modifiers = event.modifiers()
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
self.toggle_active()
return True
if key >= Qt.Key_1 and key <= Qt.Key_9:
n = key - Qt.Key_0
if modifiers == Qt.ControlModifier:
self.switch_workbench(n)
return True
elif modifiers == Qt.ShiftModifier:
self.switch_grouping(n)
return True
elif modifiers == Qt.NoModifier:
self.execute_command(n)
return True
return False # Pass through all other keys
```
### 8.2 Conflict Resolution
QuickNav's event filter takes priority when active. FreeCAD's existing keybindings for `Ctrl+1` through `Ctrl+9` (if any) are overridden while QuickNav is enabled. The original bindings are restored when QuickNav is toggled off or unloaded.
Existing `Shift+` and bare number key bindings in FreeCAD are similarly overridden only while QuickNav is active. This is safe because:
- FreeCAD does not use bare number keys as shortcuts by default
- Shift+number is not commonly bound in default FreeCAD
### 8.3 Input Widget Safety
The event filter must NOT intercept keys when the user is:
- Typing in the Python console
- Entering values in the task panel (dimensions, parameters)
- Editing spreadsheet cells
- Typing in any `QLineEdit`, `QTextEdit`, `QSpinBox`, or `QDoubleSpinBox`
- Using the Sketcher's inline dimension input
---
## 9. Addon Structure
```
mods/quicknav/
├── package.xml # FreeCAD addon manifest with <kindred> extension
├── Init.py # Non-GUI initialization (no-op)
├── InitGui.py # Registers QuickNavWorkbench
├── quicknav/
│ ├── __init__.py
│ ├── core.py # QuickNavManager singleton — orchestrates state
│ ├── event_filter.py # QuickNavEventFilter (QObject)
│ ├── nav_bar.py # NavigationBar (QToolBar subclass)
│ ├── workbench_map.py # Fixed workbench → Ctrl+N mapping + grouping discovery
│ ├── history.py # MRU stack with ParamGet persistence
│ ├── auto_exec.py # Auto-execution condition checks
│ ├── commands.py # FreeCAD command wrappers (QuickNav_Toggle, etc.)
│ └── resources/
│ ├── icons/ # Number badge SVGs, QuickNav icon
│ └── theme.py # Catppuccin Mocha color tokens
└── tests/
└── test_history.py # MRU stack unit tests
```
### 9.1 Manifest
```xml
<?xml version="1.0" encoding="UTF-8"?>
<package format="1">
<name>QuickNav</name>
<description>Keyboard-driven toolbar navigation</description>
<version>0.1.0</version>
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
<license>LGPL-2.1</license>
<content>
<workbench>
<classname>QuickNavWorkbench</classname>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>10</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>
```
### 9.2 Activation
QuickNav activates when its workbench is loaded (via the addon loader or manual activation). It installs the event filter on the main window and creates the navigation bar. The workbench itself is invisible — it does not add its own toolbars or menus beyond the navigation bar. It acts as a transparent overlay on whatever workbench the user is actually working in.
```python
class QuickNavWorkbench(Gui.Workbench):
"""Invisible workbench that installs QuickNav on load.
QuickNav doesn't replace the active workbench — it layers on top.
Loading QuickNav installs the event filter and nav bar, then
immediately re-activates the previously active workbench.
"""
def Initialize(self):
QuickNavManager.instance().install()
def Activated(self):
# Re-activate the previous workbench so QuickNav is transparent
prev = QuickNavManager.instance().previous_workbench
if prev:
Gui.activateWorkbench(prev)
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
```
**Alternative (preferred for Create):** Instead of a workbench, QuickNav can be activated directly from `Create/InitGui.py` at boot, gated by the `Enabled` preference. This avoids the workbench-switching dance entirely. The `QuickNavWorkbench` registration is kept for standalone FreeCAD compatibility.
---
## 10. Command Discovery
At activation time, QuickNav introspects each workbench's toolbars to build the command grouping map.
```python
def discover_groupings(workbench_name: str) -> list[CommandGrouping]:
"""Discover command groupings from a workbench's toolbar structure.
1. Temporarily activate the workbench (if not already active)
2. Enumerate QToolBars from the main window
3. Map toolbar name → list of QAction names
4. Filter out non-command actions (separators, widgets)
5. Split into tiers if >9 groupings
6. Restore the previously active workbench
"""
```
### 10.1 Fallback Defaults
If toolbar discovery fails (workbench not initialized, empty toolbars), QuickNav falls back to the hardcoded groupings in Section 5. These are stored as a Python dict in `workbench_map.py`.
### 10.2 ZTools Integration
ZTools commands injected via `WorkbenchManipulator` appear in the discovered toolbars and are automatically included in the relevant groupings. No special handling is needed — QuickNav discovers commands after all manipulators have run.
---
## 11. FreeCAD Compatibility
QuickNav is designed as a standalone FreeCAD addon that works without Kindred Create or the SDK.
| Feature | FreeCAD | Kindred Create |
|---------|---------|----------------|
| Core navigation (keys, nav bar) | ✅ | ✅ |
| Catppuccin Mocha theming | ❌ (uses Qt defaults) | ✅ (via SDK theme tokens) |
| Auto-boot on startup | ❌ (manual workbench activation) | ✅ (via addon loader) |
| ZTools commands in groupings | ❌ (not present) | ✅ (discovered from manipulated toolbars) |
The SDK dependency is optional — QuickNav checks for `kindred_sdk` availability and degrades gracefully:
```python
try:
from kindred_sdk.theme import get_theme_tokens
THEME = get_theme_tokens()
except ImportError:
THEME = None # Use Qt default palette
```
---
## 12. Implementation Phases
### Phase 1: Core Infrastructure
- Event filter with key interception and input widget safety
- QuickNavManager singleton with toggle on/off
- Navigation bar widget (QToolBar) with basic layout
- Hardcoded workbench/grouping maps from Section 5
- ParamGet persistence for enabled state
### Phase 2: Dynamic Discovery
- Toolbar introspection for command grouping discovery
- MRU history with persistence
- Grouping overflow scrolling
- Workbench restore (last-active grouping per workbench)
### Phase 3: Auto-Execution
- Context-aware auto-execute logic
- Sketcher closed-profile detection for Pad/Pocket
- Pre-selection constraint application
### Phase 4: Polish
- Number badge SVG icons
- Catppuccin Mocha theming (conditional on SDK)
- Scroll animations
- Settings dialog (custom Ctrl+6-9 assignments)
- FreeCAD standalone packaging
---
## 13. Open Questions
1. **Tier switching UX:** When a workbench has >9 groupings split into tiers, should `Shift+0` toggle between tiers, or should tiers be purely a scroll/mouse concept?
2. **Visual number badges:** Should the commands in the nav bar show keycap-style badges (like `⌨ 1`) or just prepend the number (`1: Line`)?
3. **Sketcher inline dimension input:** FreeCAD's Sketcher has an inline dimension entry that isn't a standard QLineEdit. Need to verify the event filter correctly identifies and skips this widget.
4. **Ctrl+N conflicts with Create shortcuts:** Verify that Create/Silo don't already bind Ctrl+1 through Ctrl+9. The Silo toggle uses Ctrl+O/S/N, so these should be clear.

View File

@@ -98,53 +98,107 @@ if hasattr(FreeCADGui, "ActiveDocument"):
## Interactive drag protocol
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol:
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
### pre_drag(ctx, drag_parts)
Called when the user begins dragging. Stores the context and dragged part IDs, then runs a full solve to establish the starting state.
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a `_DragCache` for reuse across subsequent `drag_step()` calls.
```python
def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
return self.solve(ctx)
system = _build_system(ctx)
half_spaces = compute_half_spaces(...)
weight_vec = build_weight_vector(system.params)
residuals = substitution_pass(system.all_residuals, system.params)
# single_equation_pass is intentionally skipped — it bakes variable
# values as constants that become stale when dragged parts move.
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
# Initial solve (Newton-Raphson + BFGS fallback)
newton_solve(residuals, system.params, ...)
# Cache for drag_step() reuse
cache = _DragCache()
cache.system = system
cache.residuals = residuals
cache.jac_exprs = jac_exprs
cache.compiled_eval = compiled_eval
cache.half_spaces = half_spaces
cache.weight_vec = weight_vec
...
return result
```
**Important:** `single_equation_pass` is not used in the drag path. It analytically solves single-variable equations and bakes the results as `Const()` nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only `substitution_pass` (which replaces genuinely grounded parameters) is safe to cache.
### drag_step(drag_placements)
Called on each mouse move. Updates 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.
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached `ParamTable`, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
```python
def drag_step(self, drag_placements):
ctx = self._drag_ctx
cache = self._drag_cache
params = cache.system.params
# Update only the dragged part's parameters
for pr in drag_placements:
for part in ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
return self.solve(ctx)
pfx = pr.id + "/"
params.set_value(pfx + "tx", pr.placement.position[0])
params.set_value(pfx + "ty", pr.placement.position[1])
params.set_value(pfx + "tz", pr.placement.position[2])
params.set_value(pfx + "qw", pr.placement.quaternion[0])
params.set_value(pfx + "qx", pr.placement.quaternion[1])
params.set_value(pfx + "qy", pr.placement.quaternion[2])
params.set_value(pfx + "qz", pr.placement.quaternion[3])
# Solve with cached artifacts — no rebuild
newton_solve(cache.residuals, params, ...,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval)
return result
```
### post_drag()
Called when the drag ends. Clears the stored state.
Called when the drag ends. Clears the cached state.
```python
def post_drag(self):
self._drag_ctx = None
self._drag_parts = None
self._drag_cache = None
```
### Performance notes
### _DragCache
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:
The cache holds all artifacts built in `pre_drag()` that are invariant across drag steps (constraint topology doesn't change during a drag):
| Field | Contents |
|-------|----------|
| `system` | `_System` -- owns `ParamTable` and `Expr` trees |
| `residuals` | `list[Expr]` -- after substitution pass |
| `jac_exprs` | `list[list[Expr]]` -- symbolic Jacobian |
| `compiled_eval` | `Callable` or `None` -- native compiled evaluator |
| `half_spaces` | `list[HalfSpace]` -- branch trackers |
| `weight_vec` | `ndarray` or `None` -- minimum-movement weights |
| `post_step_fn` | `Callable` or `None` -- half-space correction callback |
### Performance
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each `drag_step()` only evaluates the cached expressions at updated parameter values:
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- 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.
- The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
- The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
- DOF counting is skipped during drag for speed (`result.dof = -1`)
## Diagnostics integration

1
mods/quicknav Submodule

Submodule mods/quicknav added at 658a427132

View File

@@ -251,7 +251,6 @@ QDockWidget::title {
text-align: left;
padding: 8px 6px;
border-bottom: 1px solid #313244;
min-height: 18px;
}
QDockWidget::close-button,
@@ -733,7 +732,7 @@ QGroupBox {
background-color: #1e1e2e;
border: 1px solid #45475a;
border-radius: 6px;
margin-top: 12px;
margin-top: 16px;
padding-top: 8px;
}
@@ -741,7 +740,7 @@ QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
left: 12px;
padding: 0 4px;
padding: 2px 4px;
color: #bac2de;
background-color: #1e1e2e;
}
@@ -1234,7 +1233,7 @@ QSint--ActionGroup QToolButton {
border: none;
border-radius: 4px;
padding: 8px 6px;
min-height: 18px;
min-height: 0px;
}
QSint--ActionGroup QToolButton:hover {

View File

@@ -311,6 +311,19 @@ void AssemblyLink::updateContents()
purgeTouched();
}
// Generate an instance label for assembly components by appending a -N suffix.
// All instances get a suffix (starting at -1) so that structured part numbers
// like "P03-0001" are never mangled by UniqueNameManager's trailing-digit logic.
static std::string makeInstanceLabel(App::Document* doc, const std::string& baseLabel)
{
for (int i = 1;; ++i) {
std::string candidate = baseLabel + "-" + std::to_string(i);
if (!doc->containsLabel(candidate)) {
return candidate;
}
}
}
void AssemblyLink::synchronizeComponents()
{
App::Document* doc = getDocument();
@@ -428,7 +441,7 @@ void AssemblyLink::synchronizeComponents()
auto* subAsmLink = static_cast<AssemblyLink*>(newObj);
subAsmLink->LinkedObject.setValue(obj);
subAsmLink->Rigid.setValue(asmLink->Rigid.getValue());
subAsmLink->Label.setValue(obj->Label.getValue());
subAsmLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
addObject(subAsmLink);
link = subAsmLink;
}
@@ -440,7 +453,7 @@ void AssemblyLink::synchronizeComponents()
);
newLink->LinkedObject.setValue(srcLink->getTrueLinkedObject(false));
newLink->Label.setValue(obj->Label.getValue());
newLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
addObject(newLink);
newLink->ElementCount.setValue(srcLink->ElementCount.getValue());
@@ -461,7 +474,7 @@ void AssemblyLink::synchronizeComponents()
App::DocumentObject* newObj = doc->addObject("App::Link", obj->getNameInDocument());
auto* newLink = static_cast<App::Link*>(newObj);
newLink->LinkedObject.setValue(obj);
newLink->Label.setValue(obj->Label.getValue());
newLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue()));
addObject(newLink);
link = newLink;
}

View File

@@ -23,6 +23,7 @@
#include <BRepAdaptor_Curve.hxx>
#include <BRepAdaptor_Surface.hxx>
#include <TopExp_Explorer.hxx>
#include <TopoDS.hxx>
#include <TopoDS_Face.hxx>
#include <gp_Circ.hxx>
@@ -197,6 +198,120 @@ double getEdgeRadius(const App::DocumentObject* obj, const std::string& elt)
return sf.GetType() == GeomAbs_Circle ? sf.Circle().Radius() : 0.0;
}
/// Determine whether \a obj represents a planar datum when referenced with an
/// empty element type (bare sub-name ending with ".").
///
/// Covers three independent class hierarchies:
/// 1. App::Plane (origin planes, Part::DatumPlane)
/// 2. Part::Datum (PartDesign::Plane — not derived from App::Plane)
/// 3. Any Part::Feature whose whole-object shape is a single planar face
/// (e.g. Part::Plane primitive referenced without an element)
static bool isDatumPlane(const App::DocumentObject* obj)
{
if (!obj) {
return false;
}
// Origin planes and Part::DatumPlane (both inherit App::Plane).
if (obj->isDerivedFrom<App::Plane>()) {
return true;
}
// PartDesign datum objects inherit Part::Datum but NOT App::Plane.
// Part::Datum is also the base for PartDesign::Line and PartDesign::Point,
// so inspect the shape to confirm it is actually planar.
if (obj->isDerivedFrom<PartApp::Datum>()) {
auto* feat = static_cast<const PartApp::Feature*>(obj);
const auto& shape = feat->Shape.getShape().getShape();
if (shape.IsNull()) {
return false;
}
TopExp_Explorer ex(shape, TopAbs_FACE);
if (ex.More()) {
BRepAdaptor_Surface sf(TopoDS::Face(ex.Current()));
return sf.GetType() == GeomAbs_Plane;
}
return false;
}
// Fallback for any Part::Feature (e.g. Part::Plane primitive) referenced
// bare — if its shape is a single planar face, treat it as a datum plane.
if (auto* feat = dynamic_cast<const PartApp::Feature*>(obj)) {
const auto& shape = feat->Shape.getShape().getShape();
if (shape.IsNull()) {
return false;
}
TopExp_Explorer ex(shape, TopAbs_FACE);
if (!ex.More()) {
return false;
}
BRepAdaptor_Surface sf(TopoDS::Face(ex.Current()));
if (sf.GetType() != GeomAbs_Plane) {
return false;
}
ex.Next();
// Only treat as datum if there is exactly one face — a multi-face
// solid referenced bare is ambiguous and should not be classified.
return !ex.More();
}
return false;
}
/// Same idea for datum lines (App::Line, PartDesign::Line, etc.).
static bool isDatumLine(const App::DocumentObject* obj)
{
if (!obj) {
return false;
}
if (obj->isDerivedFrom<App::Line>()) {
return true;
}
if (obj->isDerivedFrom<PartApp::Datum>()) {
auto* feat = static_cast<const PartApp::Feature*>(obj);
const auto& shape = feat->Shape.getShape().getShape();
if (shape.IsNull()) {
return false;
}
TopExp_Explorer ex(shape, TopAbs_EDGE);
if (ex.More()) {
BRepAdaptor_Curve cv(TopoDS::Edge(ex.Current()));
return cv.GetType() == GeomAbs_Line;
}
return false;
}
return false;
}
/// Same idea for datum points (App::Point, PartDesign::Point, etc.).
static bool isDatumPoint(const App::DocumentObject* obj)
{
if (!obj) {
return false;
}
if (obj->isDerivedFrom<App::Point>()) {
return true;
}
if (obj->isDerivedFrom<PartApp::Datum>()) {
auto* feat = static_cast<const PartApp::Feature*>(obj);
const auto& shape = feat->Shape.getShape().getShape();
if (shape.IsNull()) {
return false;
}
// A datum point has a vertex but no edges or faces.
TopExp_Explorer exE(shape, TopAbs_EDGE);
TopExp_Explorer exV(shape, TopAbs_VERTEX);
return !exE.More() && exV.More();
}
return false;
}
DistanceType getDistanceType(App::DocumentObject* joint)
{
if (!joint) {
@@ -210,54 +325,177 @@ DistanceType getDistanceType(App::DocumentObject* joint)
auto* obj1 = getLinkedObjFromRef(joint, "Reference1");
auto* obj2 = getLinkedObjFromRef(joint, "Reference2");
// Datum planes (App::Plane) have empty element types because their
// sub-name ends with "." and yields no Face/Edge/Vertex element.
// Detect them here and classify before the main geometry chain,
// which cannot handle the empty element type.
const bool datum1 = type1.empty() && obj1 && obj1->isDerivedFrom<App::Plane>();
const bool datum2 = type2.empty() && obj2 && obj2->isDerivedFrom<App::Plane>();
// Datum objects referenced bare have empty element types (sub-name
// ends with "."). PartDesign datums referenced through a body can
// also produce non-standard element types like "Plane" (from a
// sub-name such as "Body.DatumPlane.Plane" — Part::Datum::getSubObject
// ignores the trailing element, but splitSubName still extracts it).
//
// Detect these before the main geometry chain, which only handles
// the standard Face/Edge/Vertex element types.
//
// isDatumPlane/Line/Point cover all three independent hierarchies:
// - App::Plane / App::Line / App::Point (origin datums)
// - Part::Datum subclasses (PartDesign datums)
// - Part::Feature with single-face shape (Part::Plane primitive, bare ref)
auto isNonGeomElement = [](const std::string& t) {
return t != "Face" && t != "Edge" && t != "Vertex";
};
const bool datumPlane1 = isNonGeomElement(type1) && isDatumPlane(obj1);
const bool datumPlane2 = isNonGeomElement(type2) && isDatumPlane(obj2);
const bool datumLine1 = isNonGeomElement(type1) && !datumPlane1 && isDatumLine(obj1);
const bool datumLine2 = isNonGeomElement(type2) && !datumPlane2 && isDatumLine(obj2);
const bool datumPoint1 = isNonGeomElement(type1) && !datumPlane1 && !datumLine1 && isDatumPoint(obj1);
const bool datumPoint2 = isNonGeomElement(type2) && !datumPlane2 && !datumLine2 && isDatumPoint(obj2);
const bool datum1 = datumPlane1 || datumLine1 || datumPoint1;
const bool datum2 = datumPlane2 || datumLine2 || datumPoint2;
if (datum1 || datum2) {
if (datum1 && datum2) {
// Map each datum side to a synthetic element type so the same
// classification logic applies regardless of which hierarchy
// the object comes from.
auto syntheticType = [](bool isPlane, bool isLine, bool isPoint,
const std::string& elemType) -> std::string {
if (isPlane) return "Face";
if (isLine) return "Edge";
if (isPoint) return "Vertex";
return elemType; // non-datum side keeps its real type
};
const std::string syn1 = syntheticType(datumPlane1, datumLine1, datumPoint1, type1);
const std::string syn2 = syntheticType(datumPlane2, datumLine2, datumPoint2, type2);
// Both sides are datum planes.
if (datumPlane1 && datumPlane2) {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+datum → PlanePlane");
return DistanceType::PlanePlane;
}
// One side is a datum plane, the other has a real element type.
// One side is a datum plane, the other has a real element type
// (or is another datum kind).
// For PointPlane/LinePlane, the solver's PointInPlaneConstraint
// reads the plane normal from marker_j (Reference2). Unlike
// real Face+Vertex joints (where both Placements carry the
// face normal from findPlacement), datum planes only carry
// their normal through computeMarkerTransform. So the datum
// must end up on Reference2 for the normal to reach marker_j.
// plane must end up on Reference2 for the normal to reach marker_j.
//
// For PlanePlane the convention matches the existing Face+Face
// path (plane on Reference1).
const auto& otherType = datum1 ? type2 : type1;
if (datumPlane1 || datumPlane2) {
const auto& otherSyn = datumPlane1 ? syn2 : syn1;
if (otherType == "Vertex" || otherType == "Edge") {
// Datum must be on Reference2 (j side).
if (datum1) {
swapJCS(joint); // move datum from Ref1 → Ref2
if (otherSyn == "Vertex" || otherSyn == "Edge") {
// Datum plane must be on Reference2 (j side).
if (datumPlane1) {
swapJCS(joint); // move datum from Ref1 → Ref2
}
DistanceType result = (otherSyn == "Vertex")
? DistanceType::PointPlane : DistanceType::LinePlane;
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+" << otherSyn << ""
<< distanceTypeName(result)
<< (datumPlane1 ? " (swapped)" : ""));
return result;
}
DistanceType result = (otherType == "Vertex")
? DistanceType::PointPlane : DistanceType::LinePlane;
// Face + datum plane or datum plane + datum plane → PlanePlane.
// No swap needed: PlanarConstraint is symmetric (uses both
// z_i and z_j), and preserving the original Reference order
// keeps the initial Placement values consistent so the solver
// stays in the correct orientation branch.
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+" << otherType << ""
<< distanceTypeName(result)
<< (datum1 ? " (swapped)" : ""));
return result;
<< "') — datum+" << otherSyn << "PlanePlane");
return DistanceType::PlanePlane;
}
// Face + datum or unknown + datum → PlanePlane.
// No swap needed: PlanarConstraint is symmetric (uses both
// z_i and z_j), and preserving the original Reference order
// keeps the initial Placement values consistent so the solver
// stays in the correct orientation branch.
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+" << otherType << " → PlanePlane");
return DistanceType::PlanePlane;
// Datum line or datum point paired with a real element type.
// Map to the appropriate pair using synthetic types and fall
// through to the main geometry chain below. The synthetic
// types ("Edge", "Vertex") will match the existing if-else
// branches — but those branches call isEdgeType/isFaceType on
// the object, which requires a real sub-element name. For
// datum lines/points the element is empty, so we classify
// directly here.
if (datumLine1 && datumLine2) {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumLine+datumLine → LineLine");
return DistanceType::LineLine;
}
if (datumPoint1 && datumPoint2) {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumPoint+datumPoint → PointPoint");
return DistanceType::PointPoint;
}
if ((datumLine1 && datumPoint2) || (datumPoint1 && datumLine2)) {
if (datumPoint1) {
swapJCS(joint); // line first
}
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumLine+datumPoint → PointLine");
return DistanceType::PointLine;
}
// One datum line/point + one real element type.
if (datumLine1 || datumLine2) {
const auto& otherSyn = datumLine1 ? syn2 : syn1;
if (otherSyn == "Face") {
// Line + Face — need line on Reference2 (edge side).
if (datumLine1) {
swapJCS(joint);
}
// We don't know the face type without inspecting the shape,
// but LinePlane is the most common and safest classification.
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumLine+Face → LinePlane");
return DistanceType::LinePlane;
}
if (otherSyn == "Vertex") {
if (datumLine2) {
swapJCS(joint); // line first
}
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumLine+Vertex → PointLine");
return DistanceType::PointLine;
}
if (otherSyn == "Edge") {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumLine+Edge → LineLine");
return DistanceType::LineLine;
}
}
if (datumPoint1 || datumPoint2) {
const auto& otherSyn = datumPoint1 ? syn2 : syn1;
if (otherSyn == "Face") {
// Point + Face — face first, point second.
if (!datumPoint2) {
swapJCS(joint); // put face on Ref1
}
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumPoint+Face → PointPlane");
return DistanceType::PointPlane;
}
if (otherSyn == "Edge") {
// Edge first, point second.
if (datumPoint1) {
swapJCS(joint);
}
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumPoint+Edge → PointLine");
return DistanceType::PointLine;
}
if (otherSyn == "Vertex") {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datumPoint+Vertex → PointPoint");
return DistanceType::PointPoint;
}
}
// If we get here, it's an unrecognized datum combination.
FC_WARN("Assembly : getDistanceType('" << joint->getFullName()
<< "') — unrecognized datum combination (syn1="
<< syn1 << ", syn2=" << syn2 << ")");
}
if (type1 == "Vertex" && type2 == "Vertex") {

View File

@@ -0,0 +1,266 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# /****************************************************************************
# *
# Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
# *
# This file is part of FreeCAD. *
# *
# FreeCAD is free software: you can redistribute it and/or modify it *
# under the terms of the GNU Lesser General Public License as *
# published by the Free Software Foundation, either version 2.1 of the *
# License, or (at your option) any later version. *
# *
# FreeCAD is distributed in the hope that it will be useful, but *
# WITHOUT ANY WARRANTY; without even the implied warranty of *
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# Lesser General Public License for more details. *
# *
# You should have received a copy of the GNU Lesser General Public *
# License along with FreeCAD. If not, see *
# <https://www.gnu.org/licenses/>. *
# *
# ***************************************************************************/
"""
Tests for datum plane classification in Distance joints.
Verifies that getDistanceType correctly classifies joints involving datum
planes from all three class hierarchies:
1. App::Plane — origin planes (XY, XZ, YZ)
2. PartDesign::Plane — datum planes inside a PartDesign body
3. Part::Plane — Part workbench plane primitives (bare reference)
"""
import unittest
import FreeCAD as App
import JointObject
class TestDatumClassification(unittest.TestCase):
"""Tests that Distance joints with datum plane references are
classified as PlanePlane (not Other) regardless of the datum
object's class hierarchy."""
def setUp(self):
doc_name = self.__class__.__name__
if App.ActiveDocument:
if App.ActiveDocument.Name != doc_name:
App.newDocument(doc_name)
else:
App.newDocument(doc_name)
App.setActiveDocument(doc_name)
self.doc = App.ActiveDocument
self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly")
self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints")
def tearDown(self):
App.closeDocument(self.doc.Name)
# ── Helpers ─────────────────────────────────────────────────────
def _make_box(self, x=0, y=0, z=0, size=10):
box = self.assembly.newObject("Part::Box", "Box")
box.Length = size
box.Width = size
box.Height = size
box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation())
return box
def _make_joint(self, joint_type, ref1, ref2):
joint = self.jointgroup.newObject("App::FeaturePython", "Joint")
JointObject.Joint(joint, joint_type)
refs = [
[ref1[0], ref1[1]],
[ref2[0], ref2[1]],
]
joint.Proxy.setJointConnectors(joint, refs)
return joint
def _make_pd_body_with_datum_plane(self, name="Body"):
"""Create a PartDesign::Body with a datum plane inside the assembly."""
body = self.assembly.newObject("PartDesign::Body", name)
datum = body.newObject("PartDesign::Plane", "DatumPlane")
self.doc.recompute()
return body, datum
def _make_part_plane(self, name="PartPlane"):
"""Create a Part::Plane primitive inside the assembly."""
plane = self.assembly.newObject("Part::Plane", name)
plane.Length = 10
plane.Width = 10
self.doc.recompute()
return plane
# ── Origin plane tests (App::Plane — existing behaviour) ───────
def test_origin_plane_face_classified_as_plane_plane(self):
"""Distance joint: box Face → origin datum plane → PlanePlane."""
origin = self.assembly.Origin
xy = origin.getXY()
box = self._make_box(0, 0, 50)
joint = self._make_joint(
5, # Distance
[box, ["Face1", "Vertex1"]],
[origin, [xy.Name + ".", xy.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertEqual(
result,
0,
"Distance joint with origin plane should solve (not produce Other)",
)
# ── PartDesign::Plane tests ────────────────────────────────────
def test_pd_datum_plane_face_classified_as_plane_plane(self):
"""Distance joint: box Face → PartDesign::Plane → PlanePlane."""
body, datum = self._make_pd_body_with_datum_plane()
box = self._make_box(0, 0, 50)
# Ground the body so the solver has a fixed reference.
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, body)
# Reference the datum plane with a bare sub-name (ends with ".").
joint = self._make_joint(
5, # Distance
[box, ["Face1", "Vertex1"]],
[body, [datum.Name + ".", datum.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint with PartDesign::Plane should not fail to solve "
"(DistanceType should be PlanePlane, not Other)",
)
def test_pd_datum_plane_vertex_classified_as_point_plane(self):
"""Distance joint: box Vertex → PartDesign::Plane → PointPlane."""
body, datum = self._make_pd_body_with_datum_plane()
box = self._make_box(0, 0, 50)
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, body)
joint = self._make_joint(
5, # Distance
[box, ["Vertex1", "Vertex1"]],
[body, [datum.Name + ".", datum.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint vertex → PartDesign::Plane should not fail "
"(DistanceType should be PointPlane, not Other)",
)
def test_two_pd_datum_planes_classified_as_plane_plane(self):
"""Distance joint: PartDesign::Plane → PartDesign::Plane → PlanePlane."""
body1, datum1 = self._make_pd_body_with_datum_plane("Body1")
body2, datum2 = self._make_pd_body_with_datum_plane("Body2")
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, body1)
joint = self._make_joint(
5, # Distance
[body1, [datum1.Name + ".", datum1.Name + "."]],
[body2, [datum2.Name + ".", datum2.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint PartDesign::Plane → PartDesign::Plane should not fail "
"(DistanceType should be PlanePlane, not Other)",
)
# ── Part::Plane tests (primitive, bare reference) ──────────────
def test_part_plane_bare_ref_face_classified_as_plane_plane(self):
"""Distance joint: box Face → Part::Plane (bare ref) → PlanePlane."""
plane = self._make_part_plane()
box = self._make_box(0, 0, 50)
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, plane)
# Bare reference to Part::Plane (sub-name ends with ".").
joint = self._make_joint(
5, # Distance
[box, ["Face1", "Vertex1"]],
[plane, [".", "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint with Part::Plane (bare ref) should not fail "
"(DistanceType should be PlanePlane, not Other)",
)
def test_part_plane_with_face1_classified_as_plane_plane(self):
"""Distance joint: box Face → Part::Plane Face1 → PlanePlane.
When Part::Plane is referenced with an explicit Face1 element,
it should enter the normal Face+Face classification path."""
plane = self._make_part_plane()
box = self._make_box(0, 0, 50)
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, plane)
joint = self._make_joint(
5, # Distance
[box, ["Face1", "Vertex1"]],
[plane, ["Face1", "Vertex1"]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint with Part::Plane Face1 should solve normally",
)
# ── Cross-hierarchy tests ──────────────────────────────────────
def test_origin_plane_and_pd_datum_classified_as_plane_plane(self):
"""Distance joint: origin App::Plane → PartDesign::Plane → PlanePlane."""
origin = self.assembly.Origin
xy = origin.getXY()
body, datum = self._make_pd_body_with_datum_plane()
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
JointObject.GroundedJoint(gnd, body)
joint = self._make_joint(
5, # Distance
[origin, [xy.Name + ".", xy.Name + "."]],
[body, [datum.Name + ".", datum.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertNotEqual(
result,
-1,
"Distance joint origin plane → PartDesign::Plane should not fail "
"(DistanceType should be PlanePlane, not Other)",
)

View File

@@ -61,6 +61,7 @@ SET(AssemblyTests_SRCS
AssemblyTests/TestKindredSolverIntegration.py
AssemblyTests/TestKCSolvePy.py
AssemblyTests/TestAssemblyOriginPlanes.py
AssemblyTests/TestDatumClassification.py
AssemblyTests/mocks/__init__.py
AssemblyTests/mocks/MockGui.py
)

View File

@@ -85,6 +85,22 @@ install(
mods/sdk
)
# Install QuickNav addon
install(
DIRECTORY
${CMAKE_SOURCE_DIR}/mods/quicknav/quicknav
DESTINATION
mods/quicknav
)
install(
FILES
${CMAKE_SOURCE_DIR}/mods/quicknav/package.xml
${CMAKE_SOURCE_DIR}/mods/quicknav/Init.py
${CMAKE_SOURCE_DIR}/mods/quicknav/InitGui.py
DESTINATION
mods/quicknav
)
# Install Kindred Solver addon
install(
DIRECTORY

View File

@@ -122,4 +122,15 @@ TEST(UniqueNameManager, UniqueNameWith9NDigits)
manager.addExactName("Compound123456789");
EXPECT_EQ(manager.makeUniqueName("Compound", 3), "Compound123456790");
}
TEST(UniqueNameManager, StructuredPartNumberDecomposition)
{
// Structured part numbers like P03-0001 have their trailing digits
// treated as the uniquifying suffix by UniqueNameManager. This is
// correct for default FreeCAD objects (Body -> Body001) but wrong
// for structured identifiers. Assembly module handles this separately
// via makeInstanceLabel which appends -N instance suffixes instead.
Base::UniqueNameManager manager;
manager.addExactName("P03-0001");
EXPECT_EQ(manager.makeUniqueName("P03-0001", 3), "P03-0002");
}
// NOLINTEND(cppcoreguidelines-*,readability-*)

View File

@@ -101,6 +101,7 @@ sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "sdk"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad"))
sys.path.insert(0, str(_REPO_ROOT / "mods" / "quicknav"))
# ---------------------------------------------------------------------------
# Now import the modules under test
@@ -123,6 +124,15 @@ from silo_commands import _safe_float # noqa: E402
import silo_start # noqa: E402
import silo_origin # noqa: E402
from quicknav.workbench_map import ( # noqa: E402
WORKBENCH_SLOTS,
WORKBENCH_GROUPINGS,
get_workbench_slot,
get_groupings,
get_grouping,
get_command,
)
# ===================================================================
# Test: update_checker._parse_version
@@ -554,6 +564,110 @@ class TestDatumModes(unittest.TestCase):
self.assertEqual(len(points), 5)
# ===================================================================
# Test: quicknav workbench_map
# ===================================================================
class TestWorkbenchMap(unittest.TestCase):
"""Tests for quicknav.workbench_map data and helpers."""
def test_all_slots_defined(self):
for n in range(1, 6):
slot = WORKBENCH_SLOTS.get(n)
self.assertIsNotNone(slot, f"Slot {n} missing from WORKBENCH_SLOTS")
def test_slot_keys(self):
for n, slot in WORKBENCH_SLOTS.items():
self.assertIn("key", slot)
self.assertIn("class_name", slot)
self.assertIn("display", slot)
self.assertIsInstance(slot["key"], str)
self.assertIsInstance(slot["class_name"], str)
self.assertIsInstance(slot["display"], str)
def test_each_slot_has_groupings(self):
for n, slot in WORKBENCH_SLOTS.items():
groupings = WORKBENCH_GROUPINGS.get(slot["key"])
self.assertIsNotNone(groupings, f"No groupings for workbench key '{slot['key']}'")
self.assertGreater(len(groupings), 0, f"Empty groupings for slot {n}")
def test_max_nine_groupings_per_workbench(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
self.assertLessEqual(len(groupings), 9, f"More than 9 groupings for '{wb_key}'")
def test_max_nine_commands_per_grouping(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i, grp in enumerate(groupings):
self.assertLessEqual(
len(grp["commands"]),
9,
f"More than 9 commands in '{wb_key}' grouping {i}",
)
def test_command_tuples_are_str_str(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i, grp in enumerate(groupings):
self.assertIn("name", grp)
self.assertIn("commands", grp)
for j, cmd in enumerate(grp["commands"]):
self.assertIsInstance(cmd, tuple, f"{wb_key}[{i}][{j}] not tuple")
self.assertEqual(len(cmd), 2, f"{wb_key}[{i}][{j}] not length 2")
self.assertIsInstance(cmd[0], str, f"{wb_key}[{i}][{j}][0] not str")
self.assertIsInstance(cmd[1], str, f"{wb_key}[{i}][{j}][1] not str")
def test_get_workbench_slot_valid(self):
for n in range(1, 6):
slot = get_workbench_slot(n)
self.assertIsNotNone(slot)
self.assertEqual(slot, WORKBENCH_SLOTS[n])
def test_get_workbench_slot_invalid(self):
self.assertIsNone(get_workbench_slot(0))
self.assertIsNone(get_workbench_slot(6))
self.assertIsNone(get_workbench_slot(99))
def test_get_groupings_valid(self):
for slot in WORKBENCH_SLOTS.values():
result = get_groupings(slot["key"])
self.assertIsNotNone(result)
self.assertIsInstance(result, list)
def test_get_groupings_invalid(self):
self.assertEqual(get_groupings("nonexistent"), [])
def test_get_grouping_valid(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for i in range(len(groupings)):
grp = get_grouping(wb_key, i)
self.assertIsNotNone(grp)
self.assertEqual(grp, groupings[i])
def test_get_grouping_invalid_index(self):
wb_key = WORKBENCH_SLOTS[1]["key"]
self.assertIsNone(get_grouping(wb_key, 99))
self.assertIsNone(get_grouping(wb_key, -1))
def test_get_grouping_invalid_key(self):
self.assertIsNone(get_grouping("nonexistent", 0))
def test_get_command_valid(self):
for wb_key, groupings in WORKBENCH_GROUPINGS.items():
for gi, grp in enumerate(groupings):
for ci in range(len(grp["commands"])):
cmd_id = get_command(wb_key, gi, ci + 1)
self.assertIsNotNone(cmd_id, f"None for {wb_key}[{gi}][{ci + 1}]")
self.assertEqual(cmd_id, grp["commands"][ci][0])
def test_get_command_invalid_number(self):
wb_key = WORKBENCH_SLOTS[1]["key"]
self.assertIsNone(get_command(wb_key, 0, 0))
self.assertIsNone(get_command(wb_key, 0, 99))
def test_get_command_invalid_workbench(self):
self.assertIsNone(get_command("nonexistent", 0, 1))
# ===================================================================