Compare commits

...

14 Commits

Author SHA1 Message Date
forbes
a2200b4042 docs: split REPOSITORY_STATE.md into topic files
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Replace the monolithic REPOSITORY_STATE.md with four focused files:

- OVERVIEW.md: metadata, submodule pins, doc index
- ARCHITECTURE.md: bootstrap flow, source layout
- COMPONENTS.md: ztools, Silo, Origin commands, theme, icons
- KNOWN_ISSUES.md: bugs, incomplete features, next steps

Updates reflected in the split:
- Silo auth: corrected from 'not implemented' to 'local auth
  complete; LDAP/OIDC pending infrastructure'
- CSRF: corrected from 'not implemented' to 'implemented (nosurf)'
- Silo commands: 14 (was 13, added Silo_Auth)
- New Origin commands section (5 C++ commands)
- New icon infrastructure section with missing icon tracking
- New issues: Newton-Raphson convergence fix (#12), Assembly
  restore crash fix (#13), missing Silo icons (#11)
- Updated submodule pins (silo 27e112e, OndselSolver 5d1988b)
2026-02-06 10:43:34 -06:00
forbes
c858706d48 fix(gui): add silo-* icons to Qt resource file for origin commands
Some checks failed
Build and Test / build (push) Successful in 1h14m34s
Release Build / build-linux (push) Failing after 1h33m10s
Release Build / publish-release (push) Has been skipped
Copy silo-bom, silo-commit, silo-info, silo-pull, and silo-push SVG
icons from mods/silo/pkg/freecad/resources/icons/ into src/Gui/Icons/
and register them in resource.qrc so they are compiled into the binary
and discoverable by BitmapFactory at runtime.
2026-02-05 22:00:35 -06:00
forbes
724440dcb7 fix(gui): fix build errors in OriginSelectorWidget and OriginManager
Some checks failed
Build and Test / build (push) Has been cancelled
- OriginSelectorWidget: use BitmapFactoryInst::mergePixmap() instead of
  BitmapFactory::mergePixmap() since BitmapFactory is a function, not a class
- OriginManager: use Console().log() instead of Console().Log() which
  does not exist on ConsoleSingleton
2026-02-05 20:48:28 -06:00
forbes
2f594dac0a fix(gui): use Python API for viewDefaultOrientation in StdCmdNew
Some checks failed
Build and Test / build (push) Failing after 20m15s
viewDefaultOrientation is a method on View3DInventorPy (Python wrapper),
not on View3DInventorViewer (C++ viewer). Use doCommand with the Python
API, matching the pattern used in StdCmdViewHome.
2026-02-05 19:13:45 -06:00
forbes
939b81385e fix(build): pin swig <4.4 for pivy runtime compatibility
Some checks failed
Build and Test / build (push) Failing after 19m41s
SWIG 4.4.x uses runtime API version 5, which is incompatible with
conda-forge pivy 0.6.9 built with SWIG <=4.3.x (runtime version 4).
Pin to >=4.0,<4.4 to ensure matching runtime versions.
2026-02-05 18:38:19 -06:00
forbes
84b69b935b fix(build): remove non-existent SelectModule.h include in FileOrigin.cpp
Some checks failed
Build and Test / build (push) Failing after 19m45s
Release Build / publish-release (push) Has been skipped
Release Build / build-linux (push) Failing after 31m20s
SelectModule is declared in FileDialog.h which was already included.
The separate #include "SelectModule.h" referenced a header that
doesn't exist, causing a fatal build error.
2026-02-05 16:53:14 -06:00
a6e84552da feat(gui): add cross-origin detection in StdCmdSaveAs (#17)
Some checks failed
Build and Test / build (push) Failing after 12m50s
- Detect document's origin vs current (target) origin
- Route to appropriate workflow:
  - Same origin: standard SaveAs
  - Local → PLM: migration (handled by PLM origin)
  - PLM → Local: export (handled by local origin)
  - PLM → PLM: transfer (handled by target PLM origin)

This provides the infrastructure for Issue #17: Mixed origin workflows.
The actual migration/export dialogs will be implemented in the
respective origin adapters (SiloOrigin, LocalFileOrigin).
2026-02-05 14:54:36 -06:00
015df38328 feat(gui): add document-origin tracking and display in window title (#16)
Some checks failed
Build and Test / build (push) Has been cancelled
- Add originForDocument(), setDocumentOrigin(), clearDocumentOrigin()
  methods to OriginManager
- Add signalDocumentOriginChanged signal for UI updates
- Add document-to-origin tracking map in OriginManager
- Update MDIView::buildWindowTitle() to append origin suffix for
  non-local origins (e.g., 'Part001 [Silo]')

This implements Issue #16: Document origin tracking and display
2026-02-05 14:53:45 -06:00
db85277f26 feat(gui): add OriginManagerDialog for managing file origins (#15)
Some checks failed
Build and Test / build (push) Has been cancelled
- Create OriginManagerDialog with list of configured origins
- Show connection status for remote origins
- Allow setting default origin
- Add/Edit/Remove buttons (Add/Edit show placeholder for now)
- Wire up 'Manage Origins...' button in OriginSelectorWidget
- Prevent removal of built-in local origin

Part of Issue #15: Multi-instance Silo configuration UI
2026-02-05 14:51:30 -06:00
679aaec6d4 feat(gui): add unified origin commands for PLM operations (#14)
Some checks are pending
Build and Test / build (push) Has started running
- Create CommandOrigin.cpp with Origin_Commit, Origin_Pull, Origin_Push,
  Origin_Info, Origin_BOM commands
- Commands delegate to current origin's extended operations
- Commands auto-disable based on origin capabilities (supportsRevisions,
  supportsBOM, supportsPartNumbers)
- Add 'Origin Tools' toolbar with PLM operations
- Add origin commands to File menu
- Register commands via CreateOriginCommands() in Application startup

This implements Issue #14: Dynamic toolbar extension for Silo commands
2026-02-05 14:49:22 -06:00
deeb6376f7 feat(gui): add OriginSelectorWidget for file origin selection (#13)
Some checks failed
Build and Test / build (push) Has been cancelled
- Create OriginSelectorWidget class (QToolButton with dropdown menu)
- Add OriginSelectorAction to create widget in toolbars
- Add Std_Origin command registered in CommandStd.cpp
- Add widget to File toolbar (before New/Open/Save)
- Connect to OriginManager fastsignals for origin changes
- Add Catppuccin Mocha styling for the widget
- Widget shows current origin name/icon with connection status overlay

This implements Issue #13: Origin selector toolbar widget
2026-02-05 14:47:18 -06:00
103fc28bc6 ci: retrigger after pushing silo submodule
Some checks failed
Build and Test / build (push) Has been cancelled
2026-02-05 14:35:59 -06:00
79c85ed2e5 fix(gui): add interactive methods to FileOriginPython and fix Console API calls
Some checks failed
Build and Test / build (push) Failing after 2m13s
- Add openDocumentInteractive() and saveDocumentAsInteractive() to FileOriginPython
- Fix Console API: Warning -> warning, Error -> error in OriginManager.cpp
- These methods bridge Python origins to the new interactive document operations
2026-02-05 14:25:21 -06:00
38358e431d feat(gui): implement issues #10 and #12 - LocalFileOrigin and Std_* delegation
Issue #10: Local filesystem origin implementation
- Add openDocumentInteractive() method to FileOrigin interface for UI-based
  file opening (shows file dialog)
- Add saveDocumentAsInteractive() method for UI-based save as
- Implement LocalFileOrigin::openDocumentInteractive() with full file dialog
  support, filter list building, and module handler integration
- Implement LocalFileOrigin::saveDocumentAsInteractive() delegating to
  Gui::Document::saveAs()

Issue #12: Modify Std_* commands to delegate to current origin
- StdCmdNew::activated() now delegates to origin->newDocument() and sets
  up view orientation for the new document
- StdCmdOpen::activated() delegates to origin->openDocumentInteractive()
  with connection state checking for authenticated origins
- StdCmdSave::activated() uses document's owning origin (via findOwningOrigin)
  for save, falling back to saveDocumentAsInteractive if no filename
- StdCmdSaveAs::activated() delegates to origin->saveDocumentAsInteractive()
- Updated isActive() methods to check for active document

The Std_* commands now work seamlessly with both LocalFileOrigin and
SiloOrigin. When Local origin is selected, standard file dialogs appear.
When Silo origin is selected, Silo's search/creation dialogs appear.

Import and Export commands are left unchanged as they operate on document
content rather than document lifecycle.

Closes #10, Closes #12
2026-02-05 14:02:26 -06:00
43 changed files with 2346 additions and 380 deletions

61
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,61 @@
# Architecture
## Bootstrap flow
```
FreeCAD startup
└─ src/Mod/Create/Init.py
└─ setup_kindred_addons()
├─ exec(mods/ztools/ztools/Init.py)
└─ exec(mods/silo/pkg/freecad/Init.py)
└─ src/Mod/Create/InitGui.py
├─ setup_kindred_workbenches()
│ ├─ exec(mods/ztools/ztools/InitGui.py)
│ │ ├─ registers ZToolsWorkbench
│ │ └─ installs _ZToolsPartDesignManipulator (global)
│ └─ exec(mods/silo/pkg/freecad/InitGui.py)
│ └─ registers SiloWorkbench
└─ Deferred setup (QTimer):
├─ 1500ms: _setup_silo_auth_panel() → "Database Auth" dock
├─ 2000ms: _setup_silo_menu() → SiloMenuManipulator
├─ 3000ms: _check_silo_first_start() → settings prompt
└─ 4000ms: _setup_silo_activity_panel() → "Database Activity" dock
```
## Key source layout
```
src/Mod/Create/ Kindred bootstrap module (Python)
├── Init.py Adds mods/ addon paths, loads Init.py files
└── InitGui.py Loads workbenches, installs Silo manipulators
src/Gui/FileOrigin.h/.cpp FileOrigin base class + LocalFileOrigin
src/Gui/CommandOrigin.cpp Origin_Commit/Pull/Push/Info/BOM commands
src/Gui/OriginManager.h/.cpp Origin lifecycle management
src/Gui/OriginSelectorWidget.h/.cpp UI for origin selection
mods/ztools/ [submodule] ztools workbench
├── ztools/InitGui.py ZToolsWorkbench + PartDesign manipulator
├── ztools/ztools/
│ ├── commands/ Datum, pattern, pocket, assembly, spreadsheet
│ ├── datums/core.py Datum creation via Part::AttachExtension
│ └── resources/ Icons, theme utilities
└── CatppuccinMocha/ Theme preference pack (QSS)
mods/silo/ [submodule] Silo parts database
├── cmd/ Go server entry points
├── internal/ Go API, database, auth, storage packages
├── pkg/freecad/ FreeCAD workbench (Python)
│ ├── InitGui.py SiloWorkbench
│ ├── silo_commands.py Commands + SiloClient API
│ └── silo_origin.py FileOrigin backend for Silo
├── pkg/calc/ LibreOffice Calc extension (Python)
├── deployments/ Docker compose configuration
└── migrations/ PostgreSQL schema migrations (001010)
src/Gui/Stylesheets/ QSS themes and SVG assets
resources/preferences/ Canonical preference pack (KindredCreate)
```
See [INTEGRATION_PLAN.md](INTEGRATION_PLAN.md) for architecture layers and phase status.

110
docs/COMPONENTS.md Normal file
View File

@@ -0,0 +1,110 @@
# Components
## ztools workbench
**Registered commands (9):**
| Command | Function |
|---------|----------|
| `ZTools_DatumCreator` | Create datum planes, axes, points (16 modes) |
| `ZTools_DatumManager` | Manage existing datum objects |
| `ZTools_EnhancedPocket` | Flip-side pocket (cut outside sketch profile) |
| `ZTools_RotatedLinearPattern` | Linear pattern with incremental rotation |
| `ZTools_AssemblyLinearPattern` | Pattern assembly components linearly |
| `ZTools_AssemblyPolarPattern` | Pattern assembly components around axis |
| `ZTools_SpreadsheetStyle{Bold,Italic,Underline}` | Text style toggles |
| `ZTools_SpreadsheetAlign{Left,Center,Right}` | Cell alignment |
| `ZTools_Spreadsheet{BgColor,TextColor,QuickAlias}` | Colors and alias creation |
**PartDesign integration** via `_ZToolsPartDesignManipulator`:
- `ZTools_DatumCreator`, `ZTools_DatumManager` → "Part Design Helper Features" toolbar
- `ZTools_EnhancedPocket` → "Part Design Modeling Features" toolbar
- `ZTools_RotatedLinearPattern` → "Part Design Transformation Features" toolbar
- Same commands inserted into Part Design menu after `PartDesign_Boolean`
**Datum types (7):** offset_from_face, offset_from_plane, midplane, 3_points, normal_to_edge, angled, tangent_to_cylinder. All except tangent_to_cylinder use `Part::AttachExtension` for automatic parametric updates.
---
## Origin commands (C++)
The Origin abstraction (`src/Gui/FileOrigin.h`) provides a backend-agnostic interface for document storage. Commands delegate to the active `FileOrigin` implementation (currently `LocalFileOrigin` for local files, `SiloOrigin` via `mods/silo/pkg/freecad/silo_origin.py` for Silo-tracked documents).
**Registered commands (5):**
| Command | Function | Icon |
|---------|----------|------|
| `Origin_Commit` | Commit changes as a new revision | `silo-commit` |
| `Origin_Pull` | Pull a specific revision from the origin | `silo-pull` |
| `Origin_Push` | Push local changes to the origin | `silo-push` |
| `Origin_Info` | Show document information from origin | `silo-info` |
| `Origin_BOM` | Show Bill of Materials for this document | `silo-bom` |
These appear in the File menu and "Origin Tools" toolbar across all workbenches (see `src/Gui/Workbench.cpp`).
---
## Silo workbench
**Registered commands (14):**
| Command | Function |
|---------|----------|
| `Silo_New` | Create new Silo-tracked document |
| `Silo_Open` | Open file from Silo database |
| `Silo_Save` | Save to Silo (create revision) |
| `Silo_Commit` | Commit current revision |
| `Silo_Pull` | Pull latest revision from server |
| `Silo_Push` | Push local changes to server |
| `Silo_Info` | View item metadata and history |
| `Silo_BOM` | Bill of materials dialog (BOM + Where Used) |
| `Silo_TagProjects` | Assign project tags |
| `Silo_Rollback` | Rollback to previous revision |
| `Silo_SetStatus` | Set revision status (draft/review/released/obsolete) |
| `Silo_Settings` | Configure API URL, projects dir, SSL certificates |
| `Silo_ToggleMode` | Swap Ctrl+O/S/N between FreeCAD and Silo commands |
| `Silo_Auth` | Login/logout authentication panel |
**Global integration** via `SiloMenuManipulator` in `src/Mod/Create/InitGui.py`:
- File menu: Silo_New, Silo_Open, Silo_Save, Silo_Commit, Silo_Pull, Silo_Push, Silo_BOM
- File toolbar: Silo_ToggleMode button
**Server architecture:** Go REST API (38+ routes) + PostgreSQL + MinIO S3. Authentication via local (bcrypt), LDAP, or OIDC backends. See `mods/silo/docs/` for server documentation.
**LibreOffice Calc extension** (`mods/silo/pkg/calc/`): BOM management, item creation, and AI-assisted descriptions via OpenRouter API. Shares the same Silo REST API and auth token system.
---
## Theme
**Canonical source:** `resources/preferences/KindredCreate/KindredCreate.qss`
Four copies must stay in sync:
1. `resources/preferences/KindredCreate/KindredCreate.qss` (canonical)
2. `src/Gui/Stylesheets/KindredCreate.qss`
3. `src/Gui/PreferencePacks/KindredCreate/KindredCreate.qss`
4. `mods/ztools/CatppuccinMocha/CatppuccinMocha.qss`
---
## Icon infrastructure
### Qt resource icons (`src/Gui/Icons/`)
5 `silo-*` SVGs registered in `resource.qrc`, used by C++ Origin commands:
`silo-bom.svg`, `silo-commit.svg`, `silo-info.svg`, `silo-pull.svg`, `silo-push.svg`
### Silo module icons (`mods/silo/pkg/freecad/resources/icons/`)
10 SVGs loaded at runtime by the `_icon()` function in `silo_commands.py`:
`silo-auth.svg`, `silo-bom.svg`, `silo-commit.svg`, `silo-info.svg`, `silo-new.svg`, `silo-open.svg`, `silo-pull.svg`, `silo-push.svg`, `silo-save.svg`, `silo.svg`
### Missing icons
3 command icon names have no corresponding SVG file: `silo-tag`, `silo-rollback`, `silo-status`. The `_icon()` function returns an empty string for these, so `Silo_TagProjects`, `Silo_Rollback`, and `Silo_SetStatus` render without toolbar icons.
### Palette
All silo-* icons use the Catppuccin Mocha color scheme. See `kindred-icons/README.md` for palette specification and icon design standards.

75
docs/KNOWN_ISSUES.md Normal file
View File

@@ -0,0 +1,75 @@
# Known Issues
## Issues
### Critical
1. **QSS duplication.** Four copies of the stylesheet must be kept in sync manually. A build step or symlinks should eliminate this.
2. **WorkbenchManipulator timing.** The `_ZToolsPartDesignManipulator` appends commands by name. If ZToolsWorkbench hasn't been activated when the user switches to PartDesign, the commands may not be registered. The manipulator API tolerates missing commands silently, but buttons won't appear.
3. **Silo shortcut persistence.** `Silo_ToggleMode` stores original shortcuts in a module-level dict. If FreeCAD crashes with Silo mode on, original shortcuts are lost on next launch.
### High
4. **Silo authentication not production-hardened.** Local auth (bcrypt) works end-to-end. LDAP (FreeIPA) and OIDC (Keycloak) backends are coded but depend on infrastructure not yet deployed. FreeCAD client has `Silo_Auth` dock panel for login and API token management. Server has session middleware (`alexedwards/scs`), CSRF protection (`nosurf`), and role-based access control (admin/editor/viewer). Migration `009_auth.sql` adds users, api_tokens, and sessions tables.
5. **No unit tests.** Zero test coverage for ztools and Silo FreeCAD commands. Silo Go backend also lacks tests.
6. **Assembly solver datum handling is minimal.** The `findPlacement()` fix in `src/Mod/Assembly/UtilsAssembly.py` extracts placement from `obj.Shape.Faces[0]` for `PartDesign::Plane` and from shape vertex for `PartDesign::Point`. Does not handle empty shapes or non-planar datum objects.
### Medium
7. **`Silo_BOM` requires Silo-tracked document.** Depends on `SiloPartNumber` property. Unregistered documents show a warning with no registration path.
8. **PartDesign menu insertion fragility.** `_ZToolsPartDesignManipulator.modifyMenuBar()` inserts after `PartDesign_Boolean`. If upstream renames this command, insertions silently fail.
9. **tangent_to_cylinder falls back to manual placement.** TangentPlane MapMode requires a vertex reference not collected by the current UI.
10. **`delete_bom_entry()` bypasses error normalization.** Uses raw `urllib.request` instead of `SiloClient._request()`.
11. **Missing Silo icons.** Three commands reference icons that don't exist: `silo-tag.svg` (`Silo_TagProjects`), `silo-rollback.svg` (`Silo_Rollback`), `silo-status.svg` (`Silo_SetStatus`). The `_icon()` function returns an empty string, so these commands render without toolbar icons.
### Fixed (retain for reference)
12. **OndselSolver Newton-Raphson convergence.** `NewtonRaphson::isConvergedToNumericalLimit()` compared `dxNorms->at(iterNo)` to itself instead of `dxNorms->at(iterNo - 1)`. This prevented convergence detection on complex assemblies, causing solver exhaustion and "grounded object moved" warnings. Fixed in Kindred fork (`src/3rdParty/OndselSolver`). Needs upstreaming to `FreeCAD/OndselSolver`.
13. **Assembly solver crash on document restore.** `AssemblyObject::onChanged()` called `updateSolveStatus()` when the Group property changed during document restore, triggering the solver while child objects were still deserializing (SIGSEGV). Fixed with `isRestoring()` and `isPerformingTransaction()` guards at `src/Mod/Assembly/App/AssemblyObject.cpp:143`.
---
## Incomplete features
### Silo
| Feature | Status | Notes |
|---------|--------|-------|
| Authentication | Local auth complete | LDAP/OIDC backends coded, pending infrastructure |
| CSRF protection | Implemented | `nosurf` library on web form routes |
| File locking | Not implemented | Needed to prevent concurrent edits |
| Odoo ERP integration | Stub only | Returns "not yet implemented" |
| Part number date segments | Broken | `formatDate()` returns error |
| Location/inventory APIs | Tables exist, no handlers | |
| CSV import rollback | Not implemented | `bom_handlers.go` |
### ztools
| Feature | Status | Notes |
|---------|--------|-------|
| Tangent-to-cylinder attachment | Manual fallback | No vertex ref in UI |
| Angled datum live editing | Incomplete | AttachmentOffset not updated in panel |
| Assembly pattern undo | Not implemented | |
---
## Next steps
1. **Authentication hardening** -- Deploy FreeIPA and Keycloak infrastructure. End-to-end test LDAP and OIDC flows. Harden token rotation and session expiry.
2. **BOM-Assembly bridge** -- Auto-populate Silo BOM from Assembly component links on save.
3. **File locking** -- Pessimistic locks on `Silo_Open` to prevent concurrent edits. Requires server-side lock table and client-side lock display.
4. **Build system** -- CMake install rules for `mods/` submodules so packages include ztools and Silo without manual steps.
5. **Test coverage** -- Unit tests for ztools datum creation, Silo FreeCAD commands, and Go API endpoints.

29
docs/OVERVIEW.md Normal file
View File

@@ -0,0 +1,29 @@
# Kindred Create
**Last updated:** 2026-02-06
**Branch:** main @ `c858706d480`
**Kindred Create:** v0.1.0
**FreeCAD base:** v1.0.0
## Documentation
| Document | Contents |
|----------|----------|
| [ARCHITECTURE.md](ARCHITECTURE.md) | Bootstrap flow, source layout, submodules |
| [COMPONENTS.md](COMPONENTS.md) | ztools, Silo, Origin commands, theme, icons |
| [KNOWN_ISSUES.md](KNOWN_ISSUES.md) | Bugs, incomplete features, next steps |
| [INTEGRATION_PLAN.md](INTEGRATION_PLAN.md) | Architecture layers, integration phases |
| [CI_CD.md](CI_CD.md) | Build and release workflows |
## Submodules
| Submodule | Path | Source | Pinned commit |
|-----------|------|--------|---------------|
| ztools | `mods/ztools` | `gitea.kindred.internal/kindred/ztools-0065` | `d2f94c3` |
| silo | `mods/silo` | `gitea.kindred.internal/kindred/silo-0062` | `27e112e` |
| OndselSolver | `src/3rdParty/OndselSolver` | `gitea.kindred.internal/kindred/ondsel` | `5d1988b` |
| GSL | `src/3rdParty/GSL` | `github.com/microsoft/GSL` | `756c91a` |
| AddonManager | `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | `01e242e` |
| googletest | `tests/lib` | `github.com/google/googletest` | `56efe39` |
OndselSolver is forked from `github.com/FreeCAD/OndselSolver` to carry a Newton-Raphson convergence fix (see [KNOWN_ISSUES.md](KNOWN_ISSUES.md#12)).

View File

@@ -1,215 +0,0 @@
# Repository State
**Last updated:** 2026-02-03
**Branch:** main @ `0ef9ffcf51`
**Kindred Create:** v0.1.0
**FreeCAD base:** v1.0.0
## Submodules
| Submodule | Path | Source | Pinned commit |
|-----------|------|--------|---------------|
| ztools | `mods/ztools` | `gitea.kindred.internal/kindred/ztools-0065` | `d2f94c3` |
| silo | `mods/silo` | `gitea.kindred.internal/kindred/silo-0062` | `17a10ab` |
| OndselSolver | `src/3rdParty/OndselSolver` | `gitea.kindred.internal/kindred/ondsel` | `e32c9cd` |
| GSL | `src/3rdParty/GSL` | `github.com/microsoft/GSL` | `756c91a` |
| AddonManager | `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | `01e242e` |
| googletest | `tests/lib` | `github.com/google/googletest` | `56efe39` |
---
## Architecture
### Bootstrap flow
```
FreeCAD startup
└─ src/Mod/Create/Init.py
└─ setup_kindred_addons()
├─ exec(mods/ztools/ztools/Init.py)
└─ exec(mods/silo/pkg/freecad/Init.py)
└─ src/Mod/Create/InitGui.py
├─ setup_kindred_workbenches()
│ ├─ exec(mods/ztools/ztools/InitGui.py)
│ │ ├─ registers ZToolsWorkbench
│ │ └─ installs _ZToolsPartDesignManipulator (global)
│ └─ exec(mods/silo/pkg/freecad/InitGui.py)
│ └─ registers SiloWorkbench
└─ Deferred setup (QTimer):
├─ 1500ms: _setup_silo_auth_panel() → "Database Auth" dock
├─ 2000ms: _setup_silo_menu() → SiloMenuManipulator
├─ 3000ms: _check_silo_first_start() → settings prompt
└─ 4000ms: _setup_silo_activity_panel() → "Database Activity" dock
```
### Key source layout
```
src/Mod/Create/ Kindred bootstrap module (Python)
├── Init.py Adds mods/ addon paths, loads Init.py files
└── InitGui.py Loads workbenches, installs Silo manipulators
mods/ztools/ [submodule] ztools workbench
├── ztools/InitGui.py ZToolsWorkbench + PartDesign manipulator
├── ztools/ztools/
│ ├── commands/ Datum, pattern, pocket, assembly, spreadsheet
│ ├── datums/core.py Datum creation via Part::AttachExtension
│ └── resources/ Icons, theme utilities
└── CatppuccinMocha/ Theme preference pack (QSS)
mods/silo/ [submodule] Silo parts database
├── cmd/ Go server entry points
├── internal/ Go API, database, storage packages
├── pkg/freecad/ FreeCAD workbench (Python)
│ ├── InitGui.py SiloWorkbench
│ └── silo_commands.py Commands + SiloClient API
├── deployments/ Docker compose configuration
└── migrations/ PostgreSQL schema migrations
src/Gui/Stylesheets/ QSS themes and SVG assets
resources/preferences/ Canonical preference pack (KindredCreate)
```
---
## Component status
### ztools workbench
**Registered commands (9):**
| Command | Function |
|---------|----------|
| `ZTools_DatumCreator` | Create datum planes, axes, points (16 modes) |
| `ZTools_DatumManager` | Manage existing datum objects |
| `ZTools_EnhancedPocket` | Flip-side pocket (cut outside sketch profile) |
| `ZTools_RotatedLinearPattern` | Linear pattern with incremental rotation |
| `ZTools_AssemblyLinearPattern` | Pattern assembly components linearly |
| `ZTools_AssemblyPolarPattern` | Pattern assembly components around axis |
| `ZTools_SpreadsheetStyle{Bold,Italic,Underline}` | Text style toggles |
| `ZTools_SpreadsheetAlign{Left,Center,Right}` | Cell alignment |
| `ZTools_Spreadsheet{BgColor,TextColor,QuickAlias}` | Colors and alias creation |
**PartDesign integration** via `_ZToolsPartDesignManipulator`:
- `ZTools_DatumCreator`, `ZTools_DatumManager` → "Part Design Helper Features" toolbar
- `ZTools_EnhancedPocket` → "Part Design Modeling Features" toolbar
- `ZTools_RotatedLinearPattern` → "Part Design Transformation Features" toolbar
- Same commands inserted into Part Design menu after `PartDesign_Boolean`
**Datum types (7):** offset_from_face, offset_from_plane, midplane, 3_points, normal_to_edge, angled, tangent_to_cylinder. All except tangent_to_cylinder use `Part::AttachExtension` for automatic parametric updates.
### Silo workbench
**Registered commands (13):**
| Command | Function |
|---------|----------|
| `Silo_New` | Create new Silo-tracked document |
| `Silo_Open` | Open file from Silo database |
| `Silo_Save` | Save to Silo (create revision) |
| `Silo_Commit` | Commit current revision |
| `Silo_Pull` | Pull latest revision from server |
| `Silo_Push` | Push local changes to server |
| `Silo_Info` | View item metadata and history |
| `Silo_BOM` | Bill of materials dialog (BOM + Where Used) |
| `Silo_TagProjects` | Assign project tags |
| `Silo_Rollback` | Rollback to previous revision |
| `Silo_SetStatus` | Set revision status (draft/review/released/obsolete) |
| `Silo_Settings` | Configure API URL, projects dir, SSL certificates |
| `Silo_ToggleMode` | Swap Ctrl+O/S/N between FreeCAD and Silo commands |
**Global integration** via `SiloMenuManipulator` in `src/Mod/Create/InitGui.py`:
- File menu: Silo_New, Silo_Open, Silo_Save, Silo_Commit, Silo_Pull, Silo_Push, Silo_BOM
- File toolbar: Silo_ToggleMode button
**Server architecture:** Go REST API (38 routes) + PostgreSQL + MinIO. See `mods/silo/docs/REPOSITORY_STATUS.md` for route details.
### Theme
**Canonical source:** `resources/preferences/KindredCreate/KindredCreate.qss`
Four copies must stay in sync:
1. `resources/preferences/KindredCreate/KindredCreate.qss` (canonical)
2. `src/Gui/Stylesheets/KindredCreate.qss`
3. `src/Gui/PreferencePacks/KindredCreate/KindredCreate.qss`
4. `mods/ztools/CatppuccinMocha/CatppuccinMocha.qss`
---
## Known issues
### Critical
1. **QSS duplication.** Four copies of the stylesheet must be kept in sync manually. A build step or symlinks should eliminate this.
2. **WorkbenchManipulator timing.** The `_ZToolsPartDesignManipulator` appends commands by name. If ZToolsWorkbench hasn't been activated when the user switches to PartDesign, the commands may not be registered. The manipulator API tolerates missing commands silently, but buttons won't appear.
3. **Silo shortcut persistence.** `Silo_ToggleMode` stores original shortcuts in a module-level dict. If FreeCAD crashes with Silo mode on, original shortcuts are lost on next launch.
### High
4. **No authentication on Silo server.** All API endpoints are publicly accessible. Required before multi-user deployment.
5. **No unit tests.** Zero test coverage for ztools and Silo FreeCAD commands. Silo Go backend also lacks tests.
6. **Assembly solver datum handling is minimal.** The `findPlacement()` fix extracts placement from `obj.Shape.Faces[0]` for `PartDesign::Plane`. Does not handle empty shapes or non-planar datum objects.
### Medium
7. **`Silo_BOM` requires Silo-tracked document.** Depends on `SiloPartNumber` property. Unregistered documents show a warning with no registration path.
8. **PartDesign menu insertion fragility.** `_ZToolsPartDesignManipulator.modifyMenuBar()` inserts after `PartDesign_Boolean`. If upstream renames this command, insertions silently fail.
9. **tangent_to_cylinder falls back to manual placement.** TangentPlane MapMode requires a vertex reference not collected by the current UI.
10. **`delete_bom_entry()` bypasses error normalization.** Uses raw `urllib.request` instead of `SiloClient._request()`.
---
## Incomplete features
### Silo
| Feature | Status | Notes |
|---------|--------|-------|
| Authentication/authorization | Not implemented | Required for multi-user |
| File locking | Not implemented | Needed to prevent concurrent edits |
| Odoo ERP integration | Stub only | Returns "not yet implemented" |
| Part number date segments | Broken | `formatDate()` returns error |
| Location/inventory APIs | Tables exist, no handlers | |
| CSRF protection | Not implemented | Web UI only |
| CSV import rollback | Not implemented | `bom_handlers.go` |
### ztools
| Feature | Status | Notes |
|---------|--------|-------|
| Tangent-to-cylinder attachment | Manual fallback | No vertex ref in UI |
| Angled datum live editing | Incomplete | AttachmentOffset not updated in panel |
| Assembly pattern undo | Not implemented | |
### Integration plan
| Phase | Feature | Status |
|-------|---------|--------|
| 1 | Addon auto-loading | Done |
| 2 | Enhanced Pocket as C++ feature | Not started |
| 3 | Datum C++ helpers | Not started (Python approach used) |
| 4 | Theme moved to Create module | Partial (QSS synced, not relocated) |
| 5 | Silo deep integration | Done |
| 6 | Build system install rules for mods/ | Partial (CI/CD done, CMake install rules pending) |
---
## Next steps
1. **Authentication** -- LDAP/FreeIPA integration for Silo multi-user deployment. Server needs auth middleware; FreeCAD client needs credential storage.
2. **BOM-Assembly bridge** -- Auto-populate Silo BOM from Assembly component links on save.
3. **File locking** -- Pessimistic locks on `Silo_Open` to prevent concurrent edits. Requires server-side lock table and client-side lock display.
4. **Build system** -- CMake install rules for `mods/` submodules so packages include ztools and Silo without manual steps.
5. **Test coverage** -- Unit tests for ztools datum creation, Silo FreeCAD commands, and Go API endpoints.

View File

@@ -1,6 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#313244"/>
<path d="M12 20 L8 20 A4 4 0 0 1 8 12 L12 12" fill="none" stroke="#89b4fa" stroke-width="2"/>
<path d="M20 12 L24 12 A4 4 0 0 1 24 20 L20 20" fill="none" stroke="#74c7ec" stroke-width="2"/>
<line x1="12" y1="16" x2="20" y2="16" stroke="#89b4fa" stroke-width="2"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg3"
sodipodi:docname="Link.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="25.632621"
inkscape:cx="14.005591"
inkscape:cy="15.839192"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<rect
width="32"
height="32"
rx="4"
fill="#313244"
id="rect1" />
<path
d="M 12.013793,20 H 8.0137931 a 4,4 0 0 1 0,-8 h 3.9999999"
fill="none"
stroke="#89b4fa"
stroke-width="1.5"
id="path1" />
<path
d="m 20.013793,12 h 4 a 4,4 0 0 1 0,8 h -4"
fill="none"
stroke="#74c7ec"
stroke-width="1.5"
id="path2"
style="stroke:#89b4fa;stroke-opacity:1" />
<path
style="fill:none;stroke:#89b4fa;stroke-width:1.77369;stroke-dasharray:none;stroke-opacity:1"
d="M 9.6965515,16 H 22.303449"
id="path3" />
</svg>

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,7 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#313244"/>
<path d="M10 18 L6 18 A4 4 0 0 1 6 10 L10 10" fill="none" stroke="#89b4fa" stroke-width="1.5"/>
<path d="M18 10 L22 10 A4 4 0 0 1 22 18 L18 18" fill="none" stroke="#74c7ec" stroke-width="1.5"/>
<path d="M24 22 L24 28 L16 24 Z" fill="#a6e3a1"/>
<line x1="24" y1="28" x2="24" y2="20" stroke="#a6e3a1" stroke-width="2"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg3"
sodipodi:docname="LinkImport.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="25.632621"
inkscape:cx="14.005591"
inkscape:cy="15.800179"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<rect
width="32"
height="32"
rx="4"
fill="#313244"
id="rect1" />
<path
d="M 12.013793,20 H 8.0137931 a 4,4 0 0 1 0,-8 h 3.9999999"
fill="none"
stroke="#89b4fa"
stroke-width="1.5"
id="path1" />
<path
d="m 20.013793,12 h 4 a 4,4 0 0 1 0,8 h -4"
fill="none"
stroke="#74c7ec"
stroke-width="1.5"
id="path2"
style="stroke:#89b4fa;stroke-opacity:1" />
<path
style="fill:none;stroke:#89b4fa;stroke-width:1.77369;stroke-dasharray:none;stroke-opacity:1"
d="M 9.6965515,16 H 22.303449"
id="path3" />
<circle
cx="25.513792"
cy="-9.2321157"
fill="#a6e3a1"
id="circle2"
style="stroke-width:0.999999"
transform="scale(1,-1)"
r="5" />
<path
d="M 25.51379,11.732116 V 6.7321179 m -2.499999,2.4999988 h 5"
stroke="#1e1e2e"
stroke-width="1.5"
stroke-linecap="round"
id="path3-7" />
</svg>

Before

Width:  |  Height:  |  Size: 449 B

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,7 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#313244"/>
<path d="M12 18 L8 18 A4 4 0 0 1 8 10 L12 10" fill="none" stroke="#89b4fa" stroke-width="1.5"/>
<path d="M18 10 L22 10 A4 4 0 0 1 22 18 L18 18" fill="none" stroke="#74c7ec" stroke-width="1.5"/>
<line x1="12" y1="14" x2="18" y2="14" stroke="#89b4fa" stroke-width="1.5"/>
<rect x="14" y="20" width="12" height="8" rx="1" fill="none" stroke="#f9e2af" stroke-width="1.5" stroke-dasharray="2,2"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg3"
sodipodi:docname="LinkSelect.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="25.632621"
inkscape:cx="14.005591"
inkscape:cy="15.800179"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<rect
width="32"
height="32"
rx="4"
fill="#313244"
id="rect1" />
<path
d="M 12.013793,20 H 8.0137931 a 4,4 0 0 1 0,-8 h 3.9999999"
fill="none"
stroke="#89b4fa"
stroke-width="1.5"
id="path1" />
<path
d="m 20.013793,12 h 4 a 4,4 0 0 1 0,8 h -4"
fill="none"
stroke="#74c7ec"
stroke-width="1.5"
id="path2"
style="stroke:#89b4fa;stroke-opacity:1" />
<path
style="fill:none;stroke:#89b4fa;stroke-width:1.77369;stroke-dasharray:none;stroke-opacity:1"
d="M 9.6965515,16 H 22.303449"
id="path3" />
<rect
style="fill:none;stroke:#fab387;stroke-width:1.056;stroke-dasharray:1.05599999, 2.11199999;stroke-linejoin:round;stroke-linecap:round;stroke-dashoffset:15.41759968;stroke-opacity:1"
id="rect2"
width="28.12822"
height="14.785847"
x="1.9358902"
y="8.6070766" />
</svg>

Before

Width:  |  Height:  |  Size: 523 B

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,12 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Block with rounded edge -->
<path d="M6 24 L6 10 L16 6 L26 10 L26 24 L16 28 Z" fill="#45475a" stroke="#89b4fa" stroke-width="1.5"/>
<path d="M6 10 L16 14 L26 10" stroke="#89b4fa" stroke-width="1.5" fill="none"/>
<path d="M16 14 L16 28" stroke="#89b4fa" stroke-width="1.5"/>
<!-- Fillet radius on edge -->
<path d="M6 10 Q10 10 12 14" stroke="#a6e3a1" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Radius indicator -->
<path d="M6 10 L9 12" stroke="#cdd6f4" stroke-width="1" stroke-dasharray="2,1"/>
<text x="4" y="8" font-family="sans-serif" font-size="6" fill="#a6e3a1">R</text>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg5"
sodipodi:docname="PartDesign_Fillet.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs5" />
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="22.627417"
inkscape:cx="2.8284271"
inkscape:cy="17.412504"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<rect
x="2"
y="2"
width="28"
height="28"
rx="4"
fill="#313244"
id="rect1" />
<!-- Block with chamfered edge -->
<path
d="M 6,24 6.1584709,15.885723 10.508095,8.6005883 16,6 26,10 V 24 L 16,28 Z M 20.923535,12.327039 26.006643,9.9805673 Z"
fill="#45475a"
stroke="#89b4fa"
stroke-width="1.5"
id="path1"
sodipodi:nodetypes="ccccccccccc" />
<path
d="M 16,19.88 V 28"
stroke="#89b4fa"
stroke-width="1.5"
id="path3"
sodipodi:nodetypes="cc" />
<path
d="M 5.721457,16 10.416889,7.8973687 21,12 l -3.708852,3.943415 -1.158565,4.20175 z"
fill="#a6e3a1"
fill-opacity="0.3"
id="path5"
sodipodi:nodetypes="cccccc"
style="opacity:1;fill:#a6e3a1;fill-opacity:1" />
<!-- Chamfer cut on edge -->
<!-- Chamfer face -->
<path
d="m 25.554002,10.020735 c 0,0 -2.839088,1.13525 -4.950437,2.544214 -1.652011,1.102435 -2.601422,2.423855 -3.100562,3.159378 -1.011406,1.490386 -1.47202,3.667857 -1.538841,4.165681 -0.0066,0.04949 -0.01392,7.861307 -0.01392,7.861307 m 7.653323,-14.186366"
stroke="#89b4fa"
stroke-width="1.5847"
id="path3-0"
sodipodi:nodetypes="csssc"
style="fill:none" />
<path
d="m 16.23145,6.006509 c 0,0 -3.524098,1.0247646 -5.635447,2.4337286 -1.6520107,1.1024351 -2.6014217,2.4238554 -3.1005617,3.1593784 -1.011406,1.490386 -1.47202,3.667857 -1.538841,4.165681 -0.0066,0.04949 0.2070509,8.612608 0.2070509,8.612608"
stroke="#89b4fa"
stroke-width="1.5847"
id="path3-0-6"
sodipodi:nodetypes="csssc"
style="fill:none" />
</svg>

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,13 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg3"
sodipodi:docname="PartDesign_NewSketch.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="36.25"
inkscape:cx="16"
inkscape:cy="16"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3" />
<rect
x="2"
y="2"
width="28"
height="28"
rx="4"
fill="#313244"
id="rect1" />
<!-- Sketch plane -->
<path d="M4 20 L16 12 L28 20 L16 28 Z" fill="#45475a" stroke="#f9e2af" stroke-width="1.5"/>
<path
d="m 4,17.158621 12,-8.0000003 12,8.0000003 -12,8 z"
fill="#45475a"
stroke="#f9e2af"
stroke-width="1.5"
id="path1" />
<!-- Grid on plane -->
<line x1="10" y1="20" x2="22" y2="20" stroke="#6c7086" stroke-width="0.75"/>
<line x1="16" y1="16" x2="16" y2="24" stroke="#6c7086" stroke-width="0.75"/>
<!-- Sketch geometry -->
<path d="M12 20 L16 16 L20 20 L16 22 Z" fill="none" stroke="#fab387" stroke-width="1.5"/>
<!-- Plus sign for "new" -->
<circle cx="24" cy="8" r="5" fill="#a6e3a1"/>
<path d="M24 5.5 L24 10.5 M21.5 8 L26.5 8" stroke="#1e1e2e" stroke-width="1.5" stroke-linecap="round"/>
<circle
cx="23.092838"
cy="-10.553846"
fill="#a6e3a1"
id="circle2"
style="stroke-width:0.999999"
transform="scale(1,-1)"
r="5" />
<path
d="M 23.092837,13.053846 V 8.0538483 m -2.499999,2.4999987 h 5"
stroke="#1e1e2e"
stroke-width="1.5"
stroke-linecap="round"
id="path3-7" />
</svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,9 +1,98 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#313244"/>
<circle cx="16" cy="13" r="7" fill="none" stroke="#f9e2af" stroke-width="1.5"/>
<path d="M12 20 L12 24 L20 24 L20 20" fill="none" stroke="#fab387" stroke-width="1.5"/>
<line x1="13" y1="27" x2="19" y2="27" stroke="#fab387" stroke-width="1.5"/>
<line x1="16" y1="6" x2="16" y2="4" stroke="#f9e2af" stroke-width="1.5"/>
<line x1="8" y1="8" x2="6" y2="6" stroke="#f9e2af" stroke-width="1.5"/>
<line x1="24" y1="8" x2="26" y2="6" stroke="#f9e2af" stroke-width="1.5"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg4"
sodipodi:docname="bulb.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="18.125"
inkscape:cx="12.634483"
inkscape:cy="17.048276"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<rect
width="32"
height="32"
rx="4"
fill="#313244"
id="rect1" />
<path
d="m 13.398145,19.664706 v 4.206463 h 5.120952 v -4.206463"
fill="none"
stroke="#fab387"
stroke-width="1.23069"
id="path1" />
<line
x1="12.95862"
y1="26.492428"
x2="18.958622"
y2="26.492428"
stroke="#fab387"
stroke-width="1.5"
id="line1"
style="stroke-width:1.3;stroke-dasharray:none" />
<line
x1="12.95862"
y1="28.743103"
x2="18.958622"
y2="28.743103"
stroke="#fab387"
stroke-width="1.5"
id="line1-2"
style="stroke-width:1.2;stroke-dasharray:none" />
<line
x1="16"
y1="8.2896547"
x2="16"
y2="4"
stroke="#f9e2af"
stroke-width="2.19678"
id="line2"
style="stroke-width:1.45;stroke-dasharray:none" />
<line
x1="21.842089"
y1="9.2134304"
x2="24.801268"
y2="6.1714916"
stroke="#f9e2af"
stroke-width="2.25021"
id="line4"
style="stroke-width:1.45;stroke-dasharray:none" />
<line
x1="9.5786028"
y1="9.2134304"
x2="6.6194234"
y2="6.1714916"
stroke="#f9e2af"
stroke-width="2.25021"
id="line4-5"
style="stroke-width:1.45;stroke-dasharray:none" />
<ellipse
cx="15.710345"
cy="15.234483"
fill="none"
stroke="#f9e2af"
stroke-width="1.07682"
id="circle1"
rx="5.0736599"
ry="4.977108" />
</svg>

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,5 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#313244"/>
<path d="M22 10 A8 8 0 1 1 10 10" fill="none" stroke="#cba6f7" stroke-width="2"/>
<path d="M22 6 L22 14 L14 10 Z" fill="#b4befe"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg2"
sodipodi:docname="button_rotate.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="25.632621"
inkscape:cx="7.8025576"
inkscape:cy="17.282665"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<rect
width="32"
height="32"
rx="4"
fill="#313244"
id="rect1" />
<path
d="M22 10 A8 8 0 1 1 10 10"
fill="none"
stroke="#cba6f7"
stroke-width="2"
id="path1" />
<path
d="M 26.440057,9.4371459 19.892123,14.033292 18.569944,5.1872849 Z"
fill="#b4befe"
id="path2" />
</svg>

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,10 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
version="1.1"
id="svg2"
sodipodi:docname="help-browser.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="36.25"
inkscape:cx="16"
inkscape:cy="16"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<rect
x="2"
y="2"
width="28"
height="28"
rx="4"
fill="#313244"
id="rect1" />
<!-- Circle background -->
<circle cx="16" cy="16" r="10" fill="#45475a" stroke="#cba6f7" stroke-width="2"/>
<circle
cx="16.020691"
cy="15.889656"
r="10"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="circle1" />
<!-- Question mark -->
<path d="M13 12 C13 9 15 8 17 8 C19 8 21 9.5 21 12 C21 14 19 14.5 17 15.5 L17 18"
stroke="#b4befe" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path
d="m 12.000002,12.965517 c 0,-2.9999996 2,-3.9999998 4,-3.9999998 2,0 4,1.4999998 4,3.9999998 0,2 -2,2.5 -4,3.5 v 2.5"
stroke="#b4befe"
stroke-width="2.5"
stroke-linecap="round"
fill="none"
id="path1" />
<!-- Dot -->
<circle cx="17" cy="22" r="1.5" fill="#b4befe"/>
<circle
cx="16"
cy="22.965519"
r="1.5"
fill="#b4befe"
id="circle2" />
</svg>

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -22,7 +22,7 @@ requirements:
- noqt5
- python>=3.11,<3.12
- qt6-main>=6.8,<6.9
- swig
- swig >=4.0,<4.4
- if: linux and x86_64
then:

View File

@@ -54,6 +54,7 @@
#include "Workbench.h"
#include "WorkbenchManager.h"
#include "WorkbenchSelector.h"
#include "OriginSelectorWidget.h"
#include "ShortcutManager.h"
#include "Tools.h"
@@ -1470,4 +1471,25 @@ void WindowAction::addTo(QWidget* widget)
}
}
// --------------------------------------------------------------------
OriginSelectorAction::OriginSelectorAction(Command* pcCmd, QObject* parent)
: Action(pcCmd, parent)
{}
OriginSelectorAction::~OriginSelectorAction() = default;
void OriginSelectorAction::addTo(QWidget* widget)
{
if (widget->inherits("QToolBar")) {
auto* toolbar = static_cast<QToolBar*>(widget);
auto* selector = new OriginSelectorWidget(widget);
toolbar->addWidget(selector);
}
else {
// For menus, just add the action
widget->addAction(action());
}
}
#include "moc_Action.cpp"

View File

@@ -421,6 +421,25 @@ private:
Q_DISABLE_COPY(WindowAction)
};
// --------------------------------------------------------------------
/**
* Action for origin selector widget in toolbars.
* Creates OriginSelectorWidget when added to a toolbar.
*/
class GuiExport OriginSelectorAction: public Action
{
Q_OBJECT
public:
explicit OriginSelectorAction(Command* pcCmd, QObject* parent = nullptr);
~OriginSelectorAction() override;
void addTo(QWidget* widget) override;
private:
Q_DISABLE_COPY(OriginSelectorAction)
};
} // namespace Gui
#endif // GUI_ACTION_H

View File

@@ -1022,6 +1022,7 @@ void Application::createStandardOperations()
Gui::CreateStructureCommands();
Gui::CreateTestCommands();
Gui::CreateLinkCommands();
Gui::CreateOriginCommands();
}
void Application::slotNewDocument(const App::Document& Doc, bool isMainDoc)

View File

@@ -529,6 +529,7 @@ SET(Command_CPP_SRCS
CommandFeat.cpp
CommandMacro.cpp
CommandStd.cpp
CommandOrigin.cpp
CommandWindow.cpp
CommandTest.cpp
CommandView.cpp
@@ -592,6 +593,7 @@ SET(Dialog_CPP_SRCS
Dialogs/DlgObjectSelection.cpp
Dialogs/DlgAddProperty.cpp
VectorListEditor.cpp
OriginManagerDialog.cpp
)
SET(Dialog_HPP_SRCS
@@ -634,6 +636,7 @@ SET(Dialog_HPP_SRCS
Dialogs/DlgObjectSelection.h
Dialogs/DlgAddProperty.h
VectorListEditor.h
OriginManagerDialog.h
)
SET(Dialog_SRCS
@@ -1236,6 +1239,7 @@ SET(Widget_CPP_SRCS
ElideCheckBox.cpp
FontScaledSVG.cpp
SplitButton.cpp
OriginSelectorWidget.cpp
)
SET(Widget_HPP_SRCS
ComboLinks.h
@@ -1262,6 +1266,7 @@ SET(Widget_HPP_SRCS
ElideCheckBox.h
FontScaledSVG.h
SplitButton.h
OriginSelectorWidget.h
)
SET(Widget_SRCS
${Widget_CPP_SRCS}

View File

@@ -247,6 +247,7 @@ void CreateWindowStdCommands();
void CreateStructureCommands();
void CreateTestCommands();
void CreateLinkCommands();
void CreateOriginCommands();
/** The CommandBase class

View File

@@ -49,7 +49,9 @@
#include "Control.h"
#include "DockWindowManager.h"
#include "FileDialog.h"
#include "FileOrigin.h"
#include "MainWindow.h"
#include "OriginManager.h"
#include "Selection.h"
#include "Dialogs/DlgObjectSelection.h"
#include "Dialogs/DlgProjectInformationImp.h"
@@ -95,81 +97,25 @@ void StdCmdOpen::activated(int iMsg)
{
Q_UNUSED(iMsg);
// fill the list of registered endings
QString formatList;
const char* supported = QT_TR_NOOP("Supported formats");
const char* allFiles = QT_TR_NOOP("All files (*.*)");
formatList = QObject::tr(supported);
formatList += QLatin1String(" (");
std::vector<std::string> filetypes = App::GetApplication().getImportTypes();
// Make sure FCStd is the very first fileformat
auto it = std::ranges::find(filetypes, "FCStd");
if (it != filetypes.end()) {
filetypes.erase(it);
filetypes.insert(filetypes.begin(), "FCStd");
}
for (it = filetypes.begin(); it != filetypes.end(); ++it) {
formatList += QLatin1String(" *.");
formatList += QLatin1String(it->c_str());
}
formatList += QLatin1String(");;");
std::map<std::string, std::string> FilterList = App::GetApplication().getImportFilters();
std::map<std::string, std::string>::iterator jt;
// Make sure the format name for FCStd is the very first in the list
for (jt = FilterList.begin(); jt != FilterList.end(); ++jt) {
if (jt->first.find("*.FCStd") != std::string::npos) {
formatList += QLatin1String(jt->first.c_str());
formatList += QLatin1String(";;");
FilterList.erase(jt);
break;
}
}
for (jt = FilterList.begin(); jt != FilterList.end(); ++jt) {
formatList += QLatin1String(jt->first.c_str());
formatList += QLatin1String(";;");
}
formatList += QObject::tr(allFiles);
QString selectedFilter;
QStringList fileList = FileDialog::getOpenFileNames(
getMainWindow(),
QObject::tr("Open Document"),
QString(),
formatList,
&selectedFilter
);
if (fileList.isEmpty()) {
// Delegate to current origin
FileOrigin* origin = OriginManager::instance()->currentOrigin();
if (!origin) {
return;
}
// load the files with the associated modules
SelectModule::Dict dict = SelectModule::importHandler(fileList, selectedFilter);
if (dict.isEmpty()) {
QMessageBox::critical(
// Check connection for origins that require authentication
if (origin->requiresAuthentication() &&
origin->connectionState() != ConnectionState::Connected) {
QMessageBox::warning(
getMainWindow(),
qApp->translate("StdCmdOpen", "Cannot Open File"),
qApp->translate("StdCmdOpen", "Loading the file %1 is not supported").arg(fileList.front())
qApp->translate("StdCmdOpen", "Not Connected"),
qApp->translate("StdCmdOpen", "Please connect to %1 before opening files.")
.arg(QString::fromStdString(origin->name()))
);
return;
}
else {
for (SelectModule::Dict::iterator it = dict.begin(); it != dict.end(); ++it) {
// Set flag indicating that this load/restore has been initiated by the user (not by a macro)
getGuiApplication()->setStatus(Gui::Application::UserInitiatedOpenDocument, true);
getGuiApplication()->open(it.key().toUtf8(), it.value().toLatin1());
getGuiApplication()->setStatus(Gui::Application::UserInitiatedOpenDocument, false);
App::Document* doc = App::GetApplication().getActiveDocument();
getGuiApplication()->checkPartialRestore(doc);
getGuiApplication()->checkRestoreError(doc);
}
}
origin->openDocumentInteractive();
}
//===========================================================================
@@ -715,10 +661,28 @@ StdCmdNew::StdCmdNew()
void StdCmdNew::activated(int iMsg)
{
Q_UNUSED(iMsg);
QString cmd;
cmd = QStringLiteral("App.newDocument()");
runCommand(Command::Doc, cmd.toUtf8());
doCommand(Command::Gui, "Gui.activeDocument().activeView().viewDefaultOrientation()");
// Delegate to current origin
FileOrigin* origin = OriginManager::instance()->currentOrigin();
if (!origin) {
return;
}
App::Document* doc = origin->newDocument();
if (!doc) {
return;
}
// Set default view orientation for the new document
auto hGrp = App::GetApplication().GetParameterGroupByPath(
"User parameter:BaseApp/Preferences/View"
);
std::string default_view = hGrp->GetASCII("NewDocumentCameraOrientation", "Top");
doCommand(
Command::Gui,
"Gui.activeDocument().activeView().viewDefaultOrientation('%s',0)",
default_view.c_str()
);
ParameterGrp::handle hViewGrp = App::GetApplication().GetParameterGroupByPath(
"User parameter:BaseApp/Preferences/View"
@@ -749,12 +713,33 @@ StdCmdSave::StdCmdSave()
void StdCmdSave::activated(int iMsg)
{
Q_UNUSED(iMsg);
doCommand(Command::Gui, "Gui.SendMsgToActiveView(\"Save\")");
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
// Use document's origin for save, not current origin
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (!origin) {
// Document has no origin yet - use current origin for first save
origin = OriginManager::instance()->currentOrigin();
}
if (!origin) {
return;
}
// Try to save the document
if (!origin->saveDocument(doc)) {
// If save failed (e.g., no filename), try SaveAs
origin->saveDocumentAsInteractive(doc);
}
}
bool StdCmdSave::isActive()
{
return getGuiApplication()->sendHasMsgToActiveView("Save");
return App::GetApplication().getActiveDocument() != nullptr;
}
//===========================================================================
@@ -778,12 +763,51 @@ StdCmdSaveAs::StdCmdSaveAs()
void StdCmdSaveAs::activated(int iMsg)
{
Q_UNUSED(iMsg);
doCommand(Command::Gui, "Gui.SendMsgToActiveView(\"SaveAs\")");
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
auto* mgr = OriginManager::instance();
FileOrigin* currentOrigin = mgr->currentOrigin();
FileOrigin* docOrigin = mgr->originForDocument(doc);
if (!currentOrigin) {
return;
}
// Determine workflow based on document and target origins
OriginType currentType = currentOrigin->type();
OriginType docType = docOrigin ? docOrigin->type() : OriginType::Local;
if (docOrigin == currentOrigin || !docOrigin) {
// Same origin or new document - standard SaveAs
currentOrigin->saveDocumentAsInteractive(doc);
}
else if (currentType == OriginType::PLM && docType == OriginType::Local) {
// Local → PLM: Migration workflow
// The PLM origin's saveDocumentAsInteractive should handle this
currentOrigin->saveDocumentAsInteractive(doc);
}
else if (currentType == OriginType::Local && docType == OriginType::PLM) {
// PLM → Local: Export workflow
// Use local origin to save without PLM tracking
currentOrigin->saveDocumentAsInteractive(doc);
}
else if (currentType == OriginType::PLM && docType == OriginType::PLM) {
// PLM → Different PLM: Transfer workflow
currentOrigin->saveDocumentAsInteractive(doc);
}
else {
// Default: use current origin
currentOrigin->saveDocumentAsInteractive(doc);
}
}
bool StdCmdSaveAs::isActive()
{
return getGuiApplication()->sendHasMsgToActiveView("SaveAs");
return App::GetApplication().getActiveDocument() != nullptr;
}
//===========================================================================

277
src/Gui/CommandOrigin.cpp Normal file
View File

@@ -0,0 +1,277 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/***************************************************************************
* Copyright (c) 2025 Kindred Systems *
* *
* 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/>. *
* *
***************************************************************************/
/**
* @file CommandOrigin.cpp
* @brief Unified origin commands that work with the current origin
*
* These commands delegate to the current FileOrigin's extended operations.
* They are only active when the current origin supports the required capability.
*/
#include <App/Application.h>
#include <App/Document.h>
#include "Application.h"
#include "BitmapFactory.h"
#include "Command.h"
#include "Document.h"
#include "FileOrigin.h"
#include "MainWindow.h"
#include "OriginManager.h"
using namespace Gui;
//===========================================================================
// Origin_Commit
//===========================================================================
DEF_STD_CMD_A(OriginCmdCommit)
OriginCmdCommit::OriginCmdCommit()
: Command("Origin_Commit")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("&Commit");
sToolTipText = QT_TR_NOOP("Commit changes as a new revision");
sWhatsThis = "Origin_Commit";
sStatusTip = sToolTipText;
sPixmap = "silo-commit";
sAccel = "Ctrl+Shift+C";
eType = AlterDoc;
}
void OriginCmdCommit::activated(int /*iMsg*/)
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (origin && origin->supportsRevisions()) {
origin->commitDocument(doc);
}
}
bool OriginCmdCommit::isActive()
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return false;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
return origin && origin->supportsRevisions();
}
//===========================================================================
// Origin_Pull
//===========================================================================
DEF_STD_CMD_A(OriginCmdPull)
OriginCmdPull::OriginCmdPull()
: Command("Origin_Pull")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("&Pull");
sToolTipText = QT_TR_NOOP("Pull a specific revision from the origin");
sWhatsThis = "Origin_Pull";
sStatusTip = sToolTipText;
sPixmap = "silo-pull";
sAccel = "Ctrl+Shift+P";
eType = AlterDoc;
}
void OriginCmdPull::activated(int /*iMsg*/)
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (origin && origin->supportsRevisions()) {
origin->pullDocument(doc);
}
}
bool OriginCmdPull::isActive()
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return false;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
return origin && origin->supportsRevisions();
}
//===========================================================================
// Origin_Push
//===========================================================================
DEF_STD_CMD_A(OriginCmdPush)
OriginCmdPush::OriginCmdPush()
: Command("Origin_Push")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("Pu&sh");
sToolTipText = QT_TR_NOOP("Push local changes to the origin");
sWhatsThis = "Origin_Push";
sStatusTip = sToolTipText;
sPixmap = "silo-push";
sAccel = "Ctrl+Shift+U";
eType = AlterDoc;
}
void OriginCmdPush::activated(int /*iMsg*/)
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (origin && origin->supportsRevisions()) {
origin->pushDocument(doc);
}
}
bool OriginCmdPush::isActive()
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return false;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
return origin && origin->supportsRevisions();
}
//===========================================================================
// Origin_Info
//===========================================================================
DEF_STD_CMD_A(OriginCmdInfo)
OriginCmdInfo::OriginCmdInfo()
: Command("Origin_Info")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("&Info");
sToolTipText = QT_TR_NOOP("Show document information from origin");
sWhatsThis = "Origin_Info";
sStatusTip = sToolTipText;
sPixmap = "silo-info";
eType = 0;
}
void OriginCmdInfo::activated(int /*iMsg*/)
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (origin && origin->supportsPartNumbers()) {
origin->showInfo(doc);
}
}
bool OriginCmdInfo::isActive()
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return false;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
return origin && origin->supportsPartNumbers();
}
//===========================================================================
// Origin_BOM
//===========================================================================
DEF_STD_CMD_A(OriginCmdBOM)
OriginCmdBOM::OriginCmdBOM()
: Command("Origin_BOM")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("&Bill of Materials");
sToolTipText = QT_TR_NOOP("Show Bill of Materials for this document");
sWhatsThis = "Origin_BOM";
sStatusTip = sToolTipText;
sPixmap = "silo-bom";
eType = 0;
}
void OriginCmdBOM::activated(int /*iMsg*/)
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
if (origin && origin->supportsBOM()) {
origin->showBOM(doc);
}
}
bool OriginCmdBOM::isActive()
{
App::Document* doc = App::GetApplication().getActiveDocument();
if (!doc) {
return false;
}
FileOrigin* origin = OriginManager::instance()->findOwningOrigin(doc);
return origin && origin->supportsBOM();
}
//===========================================================================
// Command Registration
//===========================================================================
namespace Gui {
void CreateOriginCommands()
{
CommandManager& rcCmdMgr = Application::Instance->commandManager();
rcCmdMgr.addCommand(new OriginCmdCommit());
rcCmdMgr.addCommand(new OriginCmdPull());
rcCmdMgr.addCommand(new OriginCmdPush());
rcCmdMgr.addCommand(new OriginCmdInfo());
rcCmdMgr.addCommand(new OriginCmdBOM());
}
} // namespace Gui

View File

@@ -132,6 +132,45 @@ Action* StdCmdWorkbench::createAction()
return pcAction;
}
//===========================================================================
// Std_Origin
//===========================================================================
DEF_STD_CMD_AC(StdCmdOrigin)
StdCmdOrigin::StdCmdOrigin()
: Command("Std_Origin")
{
sGroup = "File";
sMenuText = QT_TR_NOOP("&Origin");
sToolTipText = QT_TR_NOOP("Select file origin (Local Files, Silo, etc.)");
sWhatsThis = "Std_Origin";
sStatusTip = sToolTipText;
sPixmap = "folder";
eType = 0;
}
void StdCmdOrigin::activated(int /*iMsg*/)
{
// Action is handled by OriginSelectorWidget
}
bool StdCmdOrigin::isActive()
{
return true;
}
Action* StdCmdOrigin::createAction()
{
Action* pcAction = new OriginSelectorAction(this, getMainWindow());
pcAction->setShortcut(QString::fromLatin1(getAccel()));
applyCommandData(this->className(), pcAction);
if (getPixmap()) {
pcAction->setIcon(Gui::BitmapFactory().iconFromTheme(getPixmap()));
}
return pcAction;
}
//===========================================================================
// Std_RecentFiles
//===========================================================================
@@ -1057,6 +1096,7 @@ void CreateStdCommands()
rcCmdMgr.addCommand(new StdCmdDlgCustomize());
rcCmdMgr.addCommand(new StdCmdCommandLine());
rcCmdMgr.addCommand(new StdCmdWorkbench());
rcCmdMgr.addCommand(new StdCmdOrigin());
rcCmdMgr.addCommand(new StdCmdRecentFiles());
rcCmdMgr.addCommand(new StdCmdRecentMacros());
rcCmdMgr.addCommand(new StdCmdWhatsThis());

View File

@@ -22,15 +22,22 @@
#include "PreCompiled.h"
#include <algorithm>
#include <QApplication>
#include <QMessageBox>
#include <App/Application.h>
#include <App/Document.h>
#include <App/DocumentObject.h>
#include <App/PropertyStandard.h>
#include "FileOrigin.h"
#include "Application.h"
#include "BitmapFactory.h"
#include "Document.h"
#include "Application.h"
#include "FileDialog.h"
#include "MainWindow.h"
namespace Gui {
@@ -99,6 +106,86 @@ App::Document* LocalFileOrigin::openDocument(const std::string& identity)
return App::GetApplication().openDocument(identity.c_str());
}
App::Document* LocalFileOrigin::openDocumentInteractive()
{
// Build file filter list for Open dialog
QString formatList;
const char* supported = QT_TR_NOOP("Supported formats");
const char* allFiles = QT_TR_NOOP("All files (*.*)");
formatList = QObject::tr(supported);
formatList += QLatin1String(" (");
std::vector<std::string> filetypes = App::GetApplication().getImportTypes();
// Make sure FCStd is the very first fileformat
auto it = std::find(filetypes.begin(), filetypes.end(), "FCStd");
if (it != filetypes.end()) {
filetypes.erase(it);
filetypes.insert(filetypes.begin(), "FCStd");
}
for (it = filetypes.begin(); it != filetypes.end(); ++it) {
formatList += QLatin1String(" *.");
formatList += QLatin1String(it->c_str());
}
formatList += QLatin1String(");;");
std::map<std::string, std::string> FilterList = App::GetApplication().getImportFilters();
// Make sure the format name for FCStd is the very first in the list
for (auto jt = FilterList.begin(); jt != FilterList.end(); ++jt) {
if (jt->first.find("*.FCStd") != std::string::npos) {
formatList += QLatin1String(jt->first.c_str());
formatList += QLatin1String(";;");
FilterList.erase(jt);
break;
}
}
for (const auto& filter : FilterList) {
formatList += QLatin1String(filter.first.c_str());
formatList += QLatin1String(";;");
}
formatList += QObject::tr(allFiles);
QString selectedFilter;
QStringList fileList = FileDialog::getOpenFileNames(
getMainWindow(),
QObject::tr("Open Document"),
QString(),
formatList,
&selectedFilter
);
if (fileList.isEmpty()) {
return nullptr;
}
// Load the files with the associated modules
SelectModule::Dict dict = SelectModule::importHandler(fileList, selectedFilter);
if (dict.isEmpty()) {
QMessageBox::critical(
getMainWindow(),
qApp->translate("StdCmdOpen", "Cannot Open File"),
qApp->translate("StdCmdOpen", "Loading the file %1 is not supported").arg(fileList.front())
);
return nullptr;
}
App::Document* lastDoc = nullptr;
for (SelectModule::Dict::iterator it = dict.begin(); it != dict.end(); ++it) {
// Set flag indicating that this load/restore has been initiated by the user
Application::Instance->setStatus(Gui::Application::UserInitiatedOpenDocument, true);
Application::Instance->open(it.key().toUtf8(), it.value().toLatin1());
Application::Instance->setStatus(Gui::Application::UserInitiatedOpenDocument, false);
lastDoc = App::GetApplication().getActiveDocument();
Application::Instance->checkPartialRestore(lastDoc);
Application::Instance->checkRestoreError(lastDoc);
}
return lastDoc;
}
bool LocalFileOrigin::saveDocument(App::Document* doc)
{
if (!doc) {
@@ -125,4 +212,20 @@ bool LocalFileOrigin::saveDocumentAs(App::Document* doc, const std::string& newI
return doc->saveAs(newIdentity.c_str());
}
bool LocalFileOrigin::saveDocumentAsInteractive(App::Document* doc)
{
if (!doc) {
return false;
}
// Get Gui document for save dialog
Gui::Document* guiDoc = Application::Instance->getDocument(doc);
if (!guiDoc) {
return false;
}
// Use Gui::Document::saveAs() which handles the file dialog
return guiDoc->saveAs();
}
} // namespace Gui

View File

@@ -168,7 +168,7 @@ public:
virtual App::Document* newDocument(const std::string& name = "") = 0;
/**
* Open a document by identity.
* Open a document by identity (non-interactive).
* Local: Opens file at path
* PLM: Opens document by UUID (downloads if needed)
* @param identity Document identity (path or UUID)
@@ -176,9 +176,17 @@ public:
*/
virtual App::Document* openDocument(const std::string& identity) = 0;
/**
* Open a document interactively (shows dialog).
* Local: Shows file picker dialog
* PLM: Shows search/browse dialog
* @return The opened document or nullptr if cancelled/failed
*/
virtual App::Document* openDocumentInteractive() = 0;
/**
* Save the document.
* Local: Saves to disk
* Local: Saves to disk (if path known)
* PLM: Saves to disk and syncs with external system
* @param doc The document to save
* @return true if save succeeded
@@ -186,14 +194,21 @@ public:
virtual bool saveDocument(App::Document* doc) = 0;
/**
* Save document with new identity.
* Local: File picker for new path
* PLM: Migration or copy workflow
* Save document with new identity (non-interactive).
* @param doc The document to save
* @param newIdentity New identity (path or part number)
* @return true if save succeeded
*/
virtual bool saveDocumentAs(App::Document* doc, const std::string& newIdentity) = 0;
/**
* Save document interactively (shows dialog).
* Local: Shows file picker for new path
* PLM: Shows migration or copy workflow dialog
* @param doc The document to save
* @return true if save succeeded
*/
virtual bool saveDocumentAsInteractive(App::Document* doc) = 0;
//@}
///@name Extended Operations (PLM-specific, default to no-op)
@@ -250,8 +265,10 @@ public:
// Document operations
App::Document* newDocument(const std::string& name = "") override;
App::Document* openDocument(const std::string& identity) override;
App::Document* openDocumentInteractive() override;
bool saveDocument(App::Document* doc) override;
bool saveDocumentAs(App::Document* doc, const std::string& newIdentity) override;
bool saveDocumentAsInteractive(App::Document* doc) override;
};
} // namespace Gui

View File

@@ -24,6 +24,7 @@
#include <App/Application.h>
#include <App/Document.h>
#include <App/DocumentPy.h>
#include <Base/Console.h>
#include <Base/Interpreter.h>
#include <Base/PyObjectBase.h>
@@ -41,7 +42,7 @@ void FileOriginPython::addOrigin(const Py::Object& obj)
{
// Check if already registered
if (findOrigin(obj)) {
Base::Console().Warning("FileOriginPython: Origin already registered\n");
Base::Console().warning("FileOriginPython: Origin already registered\n");
return;
}
@@ -50,7 +51,7 @@ void FileOriginPython::addOrigin(const Py::Object& obj)
// Cache the ID immediately for registration
origin->_cachedId = origin->callStringMethod("id");
if (origin->_cachedId.empty()) {
Base::Console().Error("FileOriginPython: Origin must have non-empty id()\n");
Base::Console().error("FileOriginPython: Origin must have non-empty id()\n");
delete origin;
return;
}
@@ -117,7 +118,7 @@ Py::Object FileOriginPython::callMethod(const char* method, const Py::Tuple& arg
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return Py::None();
}
@@ -139,7 +140,7 @@ bool FileOriginPython::callBoolMethod(const char* method, bool defaultValue) con
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return defaultValue;
}
@@ -158,7 +159,7 @@ std::string FileOriginPython::callStringMethod(const char* method, const std::st
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return defaultValue;
}
@@ -210,7 +211,7 @@ OriginType FileOriginPython::type() const
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return OriginType::Custom;
}
@@ -265,7 +266,7 @@ ConnectionState FileOriginPython::connectionState() const
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return ConnectionState::Connected;
}
@@ -297,7 +298,7 @@ std::string FileOriginPython::documentIdentity(App::Document* doc) const
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return {};
}
@@ -318,7 +319,7 @@ std::string FileOriginPython::documentDisplayId(App::Document* doc) const
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return documentIdentity(doc);
}
@@ -342,7 +343,7 @@ bool FileOriginPython::ownsDocument(App::Document* doc) const
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -366,7 +367,7 @@ bool FileOriginPython::syncProperties(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return true;
}
@@ -391,7 +392,7 @@ App::Document* FileOriginPython::newDocument(const std::string& name)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return nullptr;
}
@@ -414,7 +415,7 @@ App::Document* FileOriginPython::openDocument(const std::string& identity)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return nullptr;
}
@@ -438,7 +439,7 @@ bool FileOriginPython::saveDocument(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -463,7 +464,7 @@ bool FileOriginPython::saveDocumentAs(App::Document* doc, const std::string& new
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -488,7 +489,7 @@ bool FileOriginPython::commitDocument(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -512,7 +513,7 @@ bool FileOriginPython::pullDocument(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -536,7 +537,7 @@ bool FileOriginPython::pushDocument(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
return false;
}
@@ -554,7 +555,7 @@ void FileOriginPython::showInfo(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
}
@@ -571,8 +572,53 @@ void FileOriginPython::showBOM(App::Document* doc)
}
catch (Py::Exception&) {
Base::PyException e;
e.ReportException();
e.reportException();
}
}
App::Document* FileOriginPython::openDocumentInteractive()
{
Base::PyGILStateLocker lock;
try {
if (_inst.hasAttr("openDocumentInteractive")) {
Py::Callable func(_inst.getAttr("openDocumentInteractive"));
Py::Object result = func.apply(Py::Tuple());
if (!result.isNone()) {
if (PyObject_TypeCheck(result.ptr(), &(App::DocumentPy::Type))) {
return static_cast<App::DocumentPy*>(result.ptr())->getDocumentPtr();
}
}
}
}
catch (Py::Exception&) {
Base::PyException e;
e.reportException();
}
return nullptr;
}
bool FileOriginPython::saveDocumentAsInteractive(App::Document* doc)
{
Base::PyGILStateLocker lock;
try {
if (_inst.hasAttr("saveDocumentAsInteractive")) {
Py::Callable func(_inst.getAttr("saveDocumentAsInteractive"));
Py::Tuple args(1);
args.setItem(0, getDocPyObject(doc));
Py::Object result = func.apply(args);
if (result.isBoolean()) {
return Py::Boolean(result);
}
if (result.isNumeric()) {
return Py::Long(result) != 0;
}
}
}
catch (Py::Exception&) {
Base::PyException e;
e.reportException();
}
return false;
}
} // namespace Gui

View File

@@ -115,8 +115,10 @@ public:
App::Document* newDocument(const std::string& name = "") override;
App::Document* openDocument(const std::string& identity) override;
App::Document* openDocumentInteractive() override;
bool saveDocument(App::Document* doc) override;
bool saveDocumentAs(App::Document* doc, const std::string& newIdentity) override;
bool saveDocumentAsInteractive(App::Document* doc) override;
bool commitDocument(App::Document* doc) override;
bool pullDocument(App::Document* doc) override;

View File

@@ -185,6 +185,11 @@
<file>sel-bbox.svg</file>
<file>sel-forward.svg</file>
<file>sel-instance.svg</file>
<file>silo-bom.svg</file>
<file>silo-commit.svg</file>
<file>silo-info.svg</file>
<file>silo-pull.svg</file>
<file>silo-push.svg</file>
<file>spaceball_button.svg</file>
<file>SpNav-PanLR.svg</file>
<file>SpNav-PanUD.svg</file>

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Outer box -->
<rect x="3" y="3" width="18" height="18" rx="2" fill="#313244"/>
<!-- List lines (BOM rows) -->
<line x1="8" y1="8" x2="18" y2="8" stroke="#89dceb" stroke-width="1.5"/>
<line x1="8" y1="12" x2="18" y2="12" stroke="#89dceb" stroke-width="1.5"/>
<line x1="8" y1="16" x2="18" y2="16" stroke="#89dceb" stroke-width="1.5"/>
<!-- Hierarchy dots -->
<circle cx="6" cy="8" r="1" fill="#cba6f7"/>
<circle cx="6" cy="12" r="1" fill="#cba6f7"/>
<circle cx="6" cy="16" r="1" fill="#cba6f7"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Git commit style -->
<circle cx="12" cy="12" r="4" fill="#313244" stroke="#a6e3a1"/>
<line x1="12" y1="2" x2="12" y2="8" stroke="#cba6f7"/>
<line x1="12" y1="16" x2="12" y2="22" stroke="#cba6f7"/>
<!-- Checkmark inside -->
<polyline points="9.5 12 11 13.5 14.5 10" stroke="#a6e3a1" stroke-width="1.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Info circle -->
<circle cx="12" cy="12" r="10" fill="#313244"/>
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Download arrow -->
<path d="M12 13v5m0 0l-2-2m2 2l2-2" stroke="#89b4fa" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#89b4fa" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Upload arrow -->
<path d="M12 18v-5m0 0l-2 2m2-2l2 2" stroke="#a6e3a1" stroke-width="2"/>
<line x1="12" y1="13" x2="12" y2="9" stroke="#a6e3a1" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -44,7 +44,9 @@
#include "Application.h"
#include "Document.h"
#include "FileDialog.h"
#include "FileOrigin.h"
#include "MainWindow.h"
#include "OriginManager.h"
#include "ViewProviderDocumentObject.h"
@@ -522,6 +524,13 @@ QString MDIView::buildWindowTitle() const
QString windowTitle;
if (auto document = getAppDocument()) {
windowTitle.append(QString::fromStdString(document->Label.getStrValue()));
// Append origin suffix for non-local origins
FileOrigin* origin = OriginManager::instance()->originForDocument(document);
if (origin && origin->type() != OriginType::Local) {
windowTitle.append(QStringLiteral(" [%1]")
.arg(QString::fromStdString(origin->nickname())));
}
}
return windowTitle;

View File

@@ -111,20 +111,20 @@ bool OriginManager::registerOrigin(FileOrigin* origin)
std::string originId = origin->id();
if (originId.empty()) {
Base::Console().Warning("OriginManager: Cannot register origin with empty ID\n");
Base::Console().warning("OriginManager: Cannot register origin with empty ID\n");
delete origin;
return false;
}
// Check if ID already in use
if (_origins.find(originId) != _origins.end()) {
Base::Console().Warning("OriginManager: Origin '%s' already registered\n", originId.c_str());
Base::Console().warning("OriginManager: Origin '%s' already registered\n", originId.c_str());
delete origin;
return false;
}
_origins[originId] = std::unique_ptr<FileOrigin>(origin);
Base::Console().Log("OriginManager: Registered origin '%s'\n", originId.c_str());
Base::Console().log("OriginManager: Registered origin '%s'\n", originId.c_str());
signalOriginRegistered(originId);
return true;
@@ -134,7 +134,7 @@ bool OriginManager::unregisterOrigin(const std::string& id)
{
// Cannot unregister the built-in local origin
if (id == LOCAL_ORIGIN_ID) {
Base::Console().Warning("OriginManager: Cannot unregister built-in local origin\n");
Base::Console().warning("OriginManager: Cannot unregister built-in local origin\n");
return false;
}
@@ -150,7 +150,7 @@ bool OriginManager::unregisterOrigin(const std::string& id)
}
_origins.erase(it);
Base::Console().Log("OriginManager: Unregistered origin '%s'\n", id.c_str());
Base::Console().log("OriginManager: Unregistered origin '%s'\n", id.c_str());
signalOriginUnregistered(id);
return true;
@@ -231,6 +231,63 @@ FileOrigin* OriginManager::findOwningOrigin(App::Document* doc) const
return nullptr;
}
FileOrigin* OriginManager::originForDocument(App::Document* doc) const
{
if (!doc) {
return nullptr;
}
// Check explicit association first
auto it = _documentOrigins.find(doc);
if (it != _documentOrigins.end()) {
FileOrigin* origin = getOrigin(it->second);
if (origin) {
return origin;
}
// Origin was unregistered, clear stale association
_documentOrigins.erase(it);
}
// Fall back to ownership detection
FileOrigin* owner = findOwningOrigin(doc);
if (owner) {
// Cache the result
_documentOrigins[doc] = owner->id();
return owner;
}
return nullptr;
}
void OriginManager::setDocumentOrigin(App::Document* doc, FileOrigin* origin)
{
if (!doc) {
return;
}
std::string originId = origin ? origin->id() : "";
if (origin) {
_documentOrigins[doc] = originId;
} else {
_documentOrigins.erase(doc);
}
signalDocumentOriginChanged(doc, originId);
}
void OriginManager::clearDocumentOrigin(App::Document* doc)
{
if (!doc) {
return;
}
auto it = _documentOrigins.find(doc);
if (it != _documentOrigins.end()) {
_documentOrigins.erase(it);
}
}
FileOrigin* OriginManager::originForNewDocument() const
{
return currentOrigin();

View File

@@ -121,6 +121,27 @@ public:
*/
FileOrigin* findOwningOrigin(App::Document* doc) const;
/**
* Get the origin associated with a document.
* First checks explicit association, then uses findOwningOrigin().
* @param doc The document to check
* @return The document's origin or nullptr if unknown
*/
FileOrigin* originForDocument(App::Document* doc) const;
/**
* Associate a document with an origin.
* @param doc The document
* @param origin The origin to associate (nullptr to clear)
*/
void setDocumentOrigin(App::Document* doc, FileOrigin* origin);
/**
* Clear document origin association (called when document closes).
* @param doc The document being closed
*/
void clearDocumentOrigin(App::Document* doc);
/**
* Get the appropriate origin for a new document.
* Returns the current origin.
@@ -137,6 +158,8 @@ public:
fastsignals::signal<void(const std::string&)> signalOriginUnregistered;
/** Emitted when current origin changes */
fastsignals::signal<void(const std::string&)> signalCurrentOriginChanged;
/** Emitted when a document's origin association changes */
fastsignals::signal<void(App::Document*, const std::string&)> signalDocumentOriginChanged;
//@}
protected:
@@ -151,6 +174,9 @@ private:
static OriginManager* _instance;
std::map<std::string, std::unique_ptr<FileOrigin>> _origins;
std::string _currentOriginId;
// Document-to-origin associations (doc -> origin ID)
mutable std::map<App::Document*, std::string> _documentOrigins;
};
} // namespace Gui

View File

@@ -0,0 +1,251 @@
/***************************************************************************
* Copyright (c) 2025 Kindred Systems *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library 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 Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
***************************************************************************/
#include "PreCompiled.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include "OriginManagerDialog.h"
#include "OriginManager.h"
#include "FileOrigin.h"
#include "BitmapFactory.h"
namespace Gui {
OriginManagerDialog::OriginManagerDialog(QWidget* parent)
: QDialog(parent)
{
setupUi();
populateOriginList();
updateButtonStates();
}
OriginManagerDialog::~OriginManagerDialog() = default;
void OriginManagerDialog::setupUi()
{
setWindowTitle(tr("Manage File Origins"));
setMinimumSize(450, 350);
auto* mainLayout = new QVBoxLayout(this);
// Description
auto* descLabel = new QLabel(tr("Configure file origins for storing and retrieving documents."));
descLabel->setWordWrap(true);
mainLayout->addWidget(descLabel);
// Origin list
m_originList = new QListWidget(this);
m_originList->setIconSize(QSize(24, 24));
m_originList->setSelectionMode(QAbstractItemView::SingleSelection);
connect(m_originList, &QListWidget::itemSelectionChanged,
this, &OriginManagerDialog::onOriginSelectionChanged);
connect(m_originList, &QListWidget::itemDoubleClicked,
this, &OriginManagerDialog::onOriginDoubleClicked);
mainLayout->addWidget(m_originList);
// Action buttons
auto* actionLayout = new QHBoxLayout();
m_addButton = new QPushButton(tr("Add Silo..."));
m_addButton->setIcon(BitmapFactory().iconFromTheme("list-add"));
connect(m_addButton, &QPushButton::clicked, this, &OriginManagerDialog::onAddSilo);
actionLayout->addWidget(m_addButton);
m_editButton = new QPushButton(tr("Edit..."));
m_editButton->setIcon(BitmapFactory().iconFromTheme("document-edit"));
connect(m_editButton, &QPushButton::clicked, this, &OriginManagerDialog::onEditOrigin);
actionLayout->addWidget(m_editButton);
m_removeButton = new QPushButton(tr("Remove"));
m_removeButton->setIcon(BitmapFactory().iconFromTheme("list-remove"));
connect(m_removeButton, &QPushButton::clicked, this, &OriginManagerDialog::onRemoveOrigin);
actionLayout->addWidget(m_removeButton);
actionLayout->addStretch();
m_defaultButton = new QPushButton(tr("Set as Default"));
connect(m_defaultButton, &QPushButton::clicked, this, &OriginManagerDialog::onSetDefault);
actionLayout->addWidget(m_defaultButton);
mainLayout->addLayout(actionLayout);
// Dialog buttons
m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Close);
connect(m_buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(m_buttonBox);
}
void OriginManagerDialog::populateOriginList()
{
m_originList->clear();
auto* mgr = OriginManager::instance();
std::string currentId = mgr->currentOriginId();
for (const std::string& originId : mgr->originIds()) {
FileOrigin* origin = mgr->getOrigin(originId);
if (!origin) {
continue;
}
auto* item = new QListWidgetItem(m_originList);
item->setIcon(origin->icon());
QString displayText = QString::fromStdString(origin->name());
// Add connection status for remote origins
if (origin->requiresAuthentication()) {
ConnectionState state = origin->connectionState();
switch (state) {
case ConnectionState::Connected:
displayText += tr(" [Connected]");
break;
case ConnectionState::Connecting:
displayText += tr(" [Connecting...]");
break;
case ConnectionState::Disconnected:
displayText += tr(" [Disconnected]");
break;
case ConnectionState::Error:
displayText += tr(" [Error]");
break;
}
}
// Mark default origin
if (originId == currentId) {
displayText += tr(" (Default)");
QFont font = item->font();
font.setBold(true);
item->setFont(font);
}
item->setText(displayText);
item->setData(Qt::UserRole, QString::fromStdString(originId));
item->setToolTip(QString::fromStdString(origin->name()));
}
}
void OriginManagerDialog::updateButtonStates()
{
FileOrigin* origin = selectedOrigin();
bool hasSelection = (origin != nullptr);
bool isLocal = hasSelection && (origin->id() == "local");
bool isDefault = hasSelection && (origin->id() == OriginManager::instance()->currentOriginId());
// Can't edit or remove local origin
m_editButton->setEnabled(hasSelection && !isLocal);
m_removeButton->setEnabled(hasSelection && !isLocal);
m_defaultButton->setEnabled(hasSelection && !isDefault);
}
FileOrigin* OriginManagerDialog::selectedOrigin() const
{
QListWidgetItem* item = m_originList->currentItem();
if (!item) {
return nullptr;
}
std::string originId = item->data(Qt::UserRole).toString().toStdString();
return OriginManager::instance()->getOrigin(originId);
}
void OriginManagerDialog::onAddSilo()
{
// TODO: Open SiloConfigDialog for adding new instance
QMessageBox::information(this, tr("Add Silo"),
tr("Silo configuration dialog not yet implemented.\n\n"
"To add a Silo instance, configure it in the Silo workbench preferences."));
}
void OriginManagerDialog::onEditOrigin()
{
FileOrigin* origin = selectedOrigin();
if (!origin || origin->id() == "local") {
return;
}
// TODO: Open SiloConfigDialog for editing
QMessageBox::information(this, tr("Edit Origin"),
tr("Origin editing not yet implemented.\n\n"
"To edit this origin, modify settings in the Silo workbench preferences."));
}
void OriginManagerDialog::onRemoveOrigin()
{
FileOrigin* origin = selectedOrigin();
if (!origin || origin->id() == "local") {
return;
}
QString name = QString::fromStdString(origin->name());
QMessageBox::StandardButton reply = QMessageBox::question(this,
tr("Remove Origin"),
tr("Are you sure you want to remove '%1'?\n\n"
"This will not delete any files, but you will need to reconfigure "
"the connection to use this origin again.").arg(name),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
if (reply == QMessageBox::Yes) {
std::string originId = origin->id();
OriginManager::instance()->unregisterOrigin(originId);
populateOriginList();
updateButtonStates();
}
}
void OriginManagerDialog::onSetDefault()
{
FileOrigin* origin = selectedOrigin();
if (!origin) {
return;
}
OriginManager::instance()->setCurrentOrigin(origin->id());
populateOriginList();
updateButtonStates();
}
void OriginManagerDialog::onOriginSelectionChanged()
{
updateButtonStates();
}
void OriginManagerDialog::onOriginDoubleClicked(QListWidgetItem* item)
{
if (!item) {
return;
}
std::string originId = item->data(Qt::UserRole).toString().toStdString();
if (originId != "local") {
onEditOrigin();
}
}
} // namespace Gui

View File

@@ -0,0 +1,74 @@
/***************************************************************************
* Copyright (c) 2025 Kindred Systems *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library 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 Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
***************************************************************************/
#ifndef GUI_ORIGINMANAGERDIALOG_H
#define GUI_ORIGINMANAGERDIALOG_H
#include <QDialog>
#include <QListWidget>
#include <QPushButton>
#include <QDialogButtonBox>
#include <FCGlobal.h>
namespace Gui {
class FileOrigin;
/**
* @brief Dialog for managing file origins
*
* This dialog allows users to view, add, edit, and remove file origins
* (Silo instances). The local filesystem origin cannot be removed.
*/
class GuiExport OriginManagerDialog : public QDialog
{
Q_OBJECT
public:
explicit OriginManagerDialog(QWidget* parent = nullptr);
~OriginManagerDialog() override;
private Q_SLOTS:
void onAddSilo();
void onEditOrigin();
void onRemoveOrigin();
void onSetDefault();
void onOriginSelectionChanged();
void onOriginDoubleClicked(QListWidgetItem* item);
private:
void setupUi();
void populateOriginList();
void updateButtonStates();
FileOrigin* selectedOrigin() const;
QListWidget* m_originList;
QPushButton* m_addButton;
QPushButton* m_editButton;
QPushButton* m_removeButton;
QPushButton* m_defaultButton;
QDialogButtonBox* m_buttonBox;
};
} // namespace Gui
#endif // GUI_ORIGINMANAGERDIALOG_H

View File

@@ -0,0 +1,270 @@
/***************************************************************************
* Copyright (c) 2025 Kindred Systems *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library 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 Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
***************************************************************************/
#include "PreCompiled.h"
#include <QApplication>
#include "OriginSelectorWidget.h"
#include "OriginManager.h"
#include "OriginManagerDialog.h"
#include "FileOrigin.h"
#include "BitmapFactory.h"
namespace Gui {
OriginSelectorWidget::OriginSelectorWidget(QWidget* parent)
: QToolButton(parent)
, m_menu(nullptr)
, m_originActions(nullptr)
, m_manageAction(nullptr)
{
setupUi();
connectSignals();
rebuildMenu();
updateDisplay();
}
OriginSelectorWidget::~OriginSelectorWidget()
{
disconnectSignals();
}
void OriginSelectorWidget::setupUi()
{
setPopupMode(QToolButton::InstantPopup);
setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
setMinimumWidth(70);
setMaximumWidth(120);
// Create menu
m_menu = new QMenu(this);
setMenu(m_menu);
// Create action group for exclusive selection
m_originActions = new QActionGroup(this);
m_originActions->setExclusive(true);
// Connect action group to selection handler
connect(m_originActions, &QActionGroup::triggered,
this, &OriginSelectorWidget::onOriginActionTriggered);
}
void OriginSelectorWidget::connectSignals()
{
auto* mgr = OriginManager::instance();
// Connect to OriginManager fastsignals
m_connRegistered = mgr->signalOriginRegistered.connect(
[this](const std::string& id) { onOriginRegistered(id); }
);
m_connUnregistered = mgr->signalOriginUnregistered.connect(
[this](const std::string& id) { onOriginUnregistered(id); }
);
m_connChanged = mgr->signalCurrentOriginChanged.connect(
[this](const std::string& id) { onCurrentOriginChanged(id); }
);
}
void OriginSelectorWidget::disconnectSignals()
{
m_connRegistered.disconnect();
m_connUnregistered.disconnect();
m_connChanged.disconnect();
}
void OriginSelectorWidget::onOriginRegistered(const std::string& /*originId*/)
{
// Rebuild menu to include new origin
rebuildMenu();
}
void OriginSelectorWidget::onOriginUnregistered(const std::string& /*originId*/)
{
// Rebuild menu to remove origin
rebuildMenu();
}
void OriginSelectorWidget::onCurrentOriginChanged(const std::string& /*originId*/)
{
// Update display and menu checkmarks
updateDisplay();
// Update checked state in menu
auto* mgr = OriginManager::instance();
std::string currentId = mgr->currentOriginId();
for (QAction* action : m_originActions->actions()) {
std::string actionId = action->data().toString().toStdString();
action->setChecked(actionId == currentId);
}
}
void OriginSelectorWidget::onOriginActionTriggered(QAction* action)
{
if (!action) {
return;
}
std::string originId = action->data().toString().toStdString();
auto* mgr = OriginManager::instance();
// Check if origin requires connection
FileOrigin* origin = mgr->getOrigin(originId);
if (origin && origin->requiresAuthentication()) {
ConnectionState state = origin->connectionState();
if (state == ConnectionState::Disconnected || state == ConnectionState::Error) {
// Try to connect
if (!origin->connect()) {
// Connection failed - don't switch
// Revert the checkmark to current origin
std::string currentId = mgr->currentOriginId();
for (QAction* a : m_originActions->actions()) {
a->setChecked(a->data().toString().toStdString() == currentId);
}
return;
}
}
}
mgr->setCurrentOrigin(originId);
}
void OriginSelectorWidget::onManageOriginsClicked()
{
OriginManagerDialog dialog(this);
dialog.exec();
// Refresh the menu in case origins changed
rebuildMenu();
updateDisplay();
}
void OriginSelectorWidget::updateDisplay()
{
FileOrigin* origin = OriginManager::instance()->currentOrigin();
if (!origin) {
setText(tr("No Origin"));
setIcon(QIcon());
setToolTip(QString());
return;
}
setText(QString::fromStdString(origin->nickname()));
setIcon(iconForOrigin(origin));
setToolTip(QString::fromStdString(origin->name()));
}
void OriginSelectorWidget::rebuildMenu()
{
m_menu->clear();
// Remove old actions from action group
for (QAction* action : m_originActions->actions()) {
m_originActions->removeAction(action);
}
auto* mgr = OriginManager::instance();
std::string currentId = mgr->currentOriginId();
// Add origin entries
for (const std::string& originId : mgr->originIds()) {
FileOrigin* origin = mgr->getOrigin(originId);
if (!origin) {
continue;
}
QAction* action = m_menu->addAction(
iconForOrigin(origin),
QString::fromStdString(origin->nickname())
);
action->setCheckable(true);
action->setChecked(originId == currentId);
action->setData(QString::fromStdString(originId));
action->setToolTip(QString::fromStdString(origin->name()));
m_originActions->addAction(action);
}
// Add separator and manage action
m_menu->addSeparator();
m_manageAction = m_menu->addAction(
BitmapFactory().iconFromTheme("preferences-system"),
tr("Manage Origins...")
);
connect(m_manageAction, &QAction::triggered,
this, &OriginSelectorWidget::onManageOriginsClicked);
}
QIcon OriginSelectorWidget::iconForOrigin(FileOrigin* origin) const
{
if (!origin) {
return QIcon();
}
QIcon baseIcon = origin->icon();
// For origins that require authentication, overlay connection status
if (origin->requiresAuthentication()) {
ConnectionState state = origin->connectionState();
switch (state) {
case ConnectionState::Connected:
// No overlay needed - use base icon
break;
case ConnectionState::Connecting:
// TODO: Animated connecting indicator
break;
case ConnectionState::Disconnected:
// Overlay disconnected indicator
{
QPixmap overlay = BitmapFactory().pixmapFromSvg(
"dagViewFail", QSizeF(8, 8));
if (!overlay.isNull()) {
baseIcon = BitmapFactoryInst::mergePixmap(
baseIcon, overlay, BitmapFactoryInst::BottomRight);
}
}
break;
case ConnectionState::Error:
// Overlay error indicator
{
QPixmap overlay = BitmapFactory().pixmapFromSvg(
"Warning", QSizeF(8, 8));
if (!overlay.isNull()) {
baseIcon = BitmapFactoryInst::mergePixmap(
baseIcon, overlay, BitmapFactoryInst::BottomRight);
}
}
break;
}
}
return baseIcon;
}
} // namespace Gui

View File

@@ -0,0 +1,95 @@
/***************************************************************************
* Copyright (c) 2025 Kindred Systems *
* *
* This file is part of the FreeCAD CAx development system. *
* *
* This library is free software; you can redistribute it and/or *
* modify it under the terms of the GNU Library General Public *
* License as published by the Free Software Foundation; either *
* version 2 of the License, or (at your option) any later version. *
* *
* This library 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 Library General Public License for more details. *
* *
* You should have received a copy of the GNU Library General Public *
* License along with this library; see the file COPYING.LIB. If not, *
* write to the Free Software Foundation, Inc., 59 Temple Place, *
* Suite 330, Boston, MA 02111-1307, USA *
* *
***************************************************************************/
#ifndef GUI_ORIGINSELECTORWIDGET_H
#define GUI_ORIGINSELECTORWIDGET_H
#include <QToolButton>
#include <QMenu>
#include <QActionGroup>
#include <fastsignals/signal.h>
#include <FCGlobal.h>
namespace Gui {
class FileOrigin;
/**
* @brief Toolbar widget for selecting the current file origin
*
* OriginSelectorWidget displays the currently selected origin and provides
* a dropdown menu to switch between available origins (Local Files, Silo
* instances, etc.).
*
* Visual design:
* Collapsed (toolbar state):
* ┌──────────────────┐
* │ ☁️ Work ▼ │ ~70-100px wide
* └──────────────────┘
*
* Expanded (dropdown open):
* ┌──────────────────┐
* │ ✓ ☁️ Work │ ← Current selection (checkmark)
* │ ☁️ Prod │
* │ 📁 Local │
* ├──────────────────┤
* │ ⚙️ Manage... │ ← Opens config dialog
* └──────────────────┘
*/
class GuiExport OriginSelectorWidget : public QToolButton
{
Q_OBJECT
public:
explicit OriginSelectorWidget(QWidget* parent = nullptr);
~OriginSelectorWidget() override;
private Q_SLOTS:
void onOriginActionTriggered(QAction* action);
void onManageOriginsClicked();
private:
void setupUi();
void connectSignals();
void disconnectSignals();
void onOriginRegistered(const std::string& originId);
void onOriginUnregistered(const std::string& originId);
void onCurrentOriginChanged(const std::string& originId);
void updateDisplay();
void rebuildMenu();
QIcon iconForOrigin(FileOrigin* origin) const;
QMenu* m_menu;
QActionGroup* m_originActions;
QAction* m_manageAction;
// Signal connections
fastsignals::scoped_connection m_connRegistered;
fastsignals::scoped_connection m_connUnregistered;
fastsignals::scoped_connection m_connChanged;
};
} // namespace Gui
#endif // GUI_ORIGINSELECTORWIDGET_H

View File

@@ -1142,6 +1142,28 @@ Gui--WorkbenchComboBox::drop-down {
border-radius: 0 4px 4px 0;
}
/* Origin Selector */
Gui--OriginSelectorWidget {
background-color: #313244;
color: #cdd6f4;
border: 1px solid #45475a;
border-radius: 4px;
padding: 4px 8px;
min-width: 70px;
max-width: 120px;
}
Gui--OriginSelectorWidget:hover {
border-color: #585b70;
background-color: #45475a;
}
Gui--OriginSelectorWidget::menu-indicator {
subcontrol-origin: padding;
subcontrol-position: center right;
right: 4px;
}
/* Task Panel */
QSint--ActionGroup {
background-color: #313244;

View File

@@ -1163,6 +1163,28 @@ Gui--WorkbenchComboBox::drop-down {
border-radius: 0 4px 4px 0;
}
/* Origin Selector */
Gui--OriginSelectorWidget {
background-color: #313244;
color: #cdd6f4;
border: 1px solid #45475a;
border-radius: 4px;
padding: 4px 8px;
min-width: 70px;
max-width: 120px;
}
Gui--OriginSelectorWidget:hover {
border-color: #585b70;
background-color: #45475a;
}
Gui--OriginSelectorWidget::menu-indicator {
subcontrol-origin: padding;
subcontrol-position: center right;
right: 4px;
}
/* Task Panel */
QSint--ActionGroup {
background-color: #313244;

View File

@@ -682,7 +682,10 @@ MenuItem* StdWorkbench::setupMenuBar() const
file->setCommand("&File");
*file << "Std_New" << "Std_Open" << "Std_RecentFiles" << "Separator" << "Std_CloseActiveWindow"
<< "Std_CloseAllWindows" << "Separator" << "Std_Save" << "Std_SaveAs"
<< "Std_SaveCopy" << "Std_SaveAll" << "Std_Revert" << "Separator" << "Std_Import"
<< "Std_SaveCopy" << "Std_SaveAll" << "Std_Revert"
<< "Separator" << "Origin_Commit" << "Origin_Pull" << "Origin_Push"
<< "Origin_Info" << "Origin_BOM"
<< "Separator" << "Std_Import"
<< "Std_Export" << "Std_MergeProjects" << "Std_ProjectInfo"
<< "Separator" << "Std_Print" << "Std_PrintPreview" << "Std_PrintPdf"
<< "Separator" << "Std_Quit";
@@ -834,7 +837,13 @@ ToolBarItem* StdWorkbench::setupToolBars() const
// File
auto file = new ToolBarItem(root);
file->setCommand("File");
*file << "Std_New" << "Std_Open" << "Std_Save";
*file << "Std_Origin" << "Std_New" << "Std_Open" << "Std_Save";
// Origin Tools (PLM operations - commands auto-disable when not applicable)
auto originTools = new ToolBarItem(root);
originTools->setCommand("Origin Tools");
*originTools << "Origin_Commit" << "Origin_Pull" << "Origin_Push"
<< "Separator" << "Origin_Info" << "Origin_BOM";
// Edit
auto edit = new ToolBarItem(root);