From 6e15b25134e2c5227ac617922e812f01532807e6 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Wed, 25 Feb 2026 13:16:51 -0600 Subject: [PATCH 1/5] docs(solver): update drag protocol docs to reflect implemented caching 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. --- docs/src/solver/assembly-integration.md | 88 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/docs/src/solver/assembly-integration.md b/docs/src/solver/assembly-integration.md index b3c5a5b32e..feb9738aa0 100644 --- a/docs/src/solver/assembly-integration.md +++ b/docs/src/solver/assembly-integration.md @@ -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 From d94e8c8294dee3b4753b7eb3029fe023158dff3f Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 26 Feb 2026 08:40:34 -0600 Subject: [PATCH 2/5] docs: add CLAUDE.md for developer context --- CLAUDE.md | 285 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..584bc7119a --- /dev/null +++ b/CLAUDE.md @@ -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 +// 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 `` extensions declaring version bounds, load priority, and dependencies. The loader resolves via topological sort: **sdk** (0) -> **ztools** (50) -> **silo** (60). + +A `` 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 From 4ef8e64a7c6cc579490f26d7f079eb0294902429 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 26 Feb 2026 08:45:58 -0600 Subject: [PATCH 3/5] fix(ci): add target_commitish to release payload to fix HTTP 500 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 --- .gitea/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 50297850dd..6686c64d71 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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}', })) ") From 311d911cfae45a1730886a199e85e463ece4655d Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 26 Feb 2026 08:48:01 -0600 Subject: [PATCH 4/5] fix(theme): prevent panel element headings from being clipped 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 --- src/Gui/Stylesheets/KindredCreate.qss | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Gui/Stylesheets/KindredCreate.qss b/src/Gui/Stylesheets/KindredCreate.qss index 5025fb8ae8..3ca93e63d8 100644 --- a/src/Gui/Stylesheets/KindredCreate.qss +++ b/src/Gui/Stylesheets/KindredCreate.qss @@ -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 { From 7c85b2ad93f3f477e2c47a7524f6feaff7178e96 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 26 Feb 2026 09:00:13 -0600 Subject: [PATCH 5/5] fix(assembly): use instance suffixes for duplicate part labels 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 --- src/Mod/Assembly/App/AssemblyLink.cpp | 19 ++++++++++++++++--- tests/src/Base/UniqueNameManager.cpp | 11 +++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Mod/Assembly/App/AssemblyLink.cpp b/src/Mod/Assembly/App/AssemblyLink.cpp index 48e8c8d1cc..4263c3ecee 100644 --- a/src/Mod/Assembly/App/AssemblyLink.cpp +++ b/src/Mod/Assembly/App/AssemblyLink.cpp @@ -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(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(newObj); newLink->LinkedObject.setValue(obj); - newLink->Label.setValue(obj->Label.getValue()); + newLink->Label.setValue(makeInstanceLabel(doc, obj->Label.getValue())); addObject(newLink); link = newLink; } diff --git a/tests/src/Base/UniqueNameManager.cpp b/tests/src/Base/UniqueNameManager.cpp index ba502eafd2..20955efec8 100644 --- a/tests/src/Base/UniqueNameManager.cpp +++ b/tests/src/Base/UniqueNameManager.cpp @@ -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-*)