diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ae0fe39642..33644da7de 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -69,6 +69,10 @@ jobs: export PATH="$HOME/.pixi/bin:$PATH" pixi --version + - name: Run Kindred addon tests (pure logic, no build needed) + timeout-minutes: 2 + run: python3 tests/run_kindred_tests.py + - name: Restore ccache id: ccache-restore uses: https://git.kindred-systems.com/actions/cache.git/restore@v4 diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index f9b23dc2e5..2e94d33301 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -74,7 +74,7 @@ These appear in the File menu and "Origin Tools" toolbar across all workbenches - Database Activity (4000ms) -- Real-time server event feed via SSE (Server-Sent Events) with automatic reconnection and exponential backoff - Start Panel -- In-viewport landing page with recent files and Silo integration -**Server architecture:** Go REST API (38+ routes) + PostgreSQL + MinIO S3. Authentication via local (bcrypt), LDAP, or OIDC backends. SSE endpoint for real-time event streaming. See `mods/silo/docs/` for server documentation. +**Server architecture:** Go REST API (38+ routes) + PostgreSQL + MinIO S3. Authentication via local (bcrypt), LDAP, or OIDC backends. SSE endpoint for real-time event streaming. See `docs/src/silo-server/` for server documentation. **LibreOffice Calc extension** ([silo-calc](https://git.kindred-systems.com/kindred/silo-calc.git)): BOM management, item creation, and AI-assisted descriptions via OpenRouter API. Shares the same Silo REST API and auth token system via the shared [silo-client](https://git.kindred-systems.com/kindred/silo-client.git) package. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 25edbc3faf..6ef04701c5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -18,6 +18,7 @@ - [Overview](./architecture/overview.md) - [Python as Source of Truth](./architecture/python-source-of-truth.md) - [Silo Server](./architecture/silo-server.md) +- [Signal Architecture](./architecture/signal-architecture.md) - [OndselSolver](./architecture/ondsel-solver.md) # Development @@ -26,6 +27,7 @@ - [Code Quality](./development/code-quality.md) - [Repository Structure](./development/repo-structure.md) - [Build System](./development/build-system.md) +- [Gui Module Build](./development/gui-build-integration.md) # Silo Server @@ -46,3 +48,13 @@ - [Configuration](./reference/configuration.md) - [Glossary](./reference/glossary.md) + +# C++ API Reference + +- [FileOrigin Interface](./reference/cpp-file-origin.md) +- [LocalFileOrigin](./reference/cpp-local-file-origin.md) +- [OriginManager](./reference/cpp-origin-manager.md) +- [CommandOrigin](./reference/cpp-command-origin.md) +- [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md) +- [FileOriginPython Bridge](./reference/cpp-file-origin-python.md) +- [Creating a Custom Origin (C++)](./reference/cpp-custom-origin-guide.md) diff --git a/docs/src/architecture/signal-architecture.md b/docs/src/architecture/signal-architecture.md new file mode 100644 index 0000000000..dbc0db4dd0 --- /dev/null +++ b/docs/src/architecture/signal-architecture.md @@ -0,0 +1,274 @@ +# Signal Architecture + +Kindred Create uses two signal systems side by side: **fastsignals** for domain and application events, and **Qt signals** for UI framework integration. This page explains why both exist, where each is used, and the patterns for working with them. + +## Why two signal systems + +Qt signals require the `Q_OBJECT` macro, MOC preprocessing, and a `QObject` base class. This makes them the right tool for widget-to-widget communication but a poor fit for domain classes like `FileOrigin`, `OriginManager`, and `App::Document` that are not `QObject` subclasses and should not depend on the Qt meta-object system. + +fastsignals is a header-only C++ library that provides type-safe signals with lambda support, RAII connection management, and no build-tool preprocessing. It lives at `src/3rdParty/FastSignals/` and is linked into the Gui module via CMake. + +| | Qt signals | fastsignals | +|---|-----------|-------------| +| **Requires** | `Q_OBJECT`, MOC, `QObject` base | Nothing (header-only) | +| **Dispatch** | Event loop (can be queued) | Synchronous, immediate | +| **Connection** | `QObject::connect()` | `signal.connect(lambda)` | +| **Lifetime** | Tied to `QObject` parent-child tree | `scoped_connection` RAII | +| **Thread model** | Can queue across threads | Fires on emitter's thread | +| **Slot types** | Slots, lambdas, `std::function` | Lambdas, `std::function`, function pointers | + +## Where each is used + +### fastsignals — domain events + +These are events about application state that multiple independent listeners may care about. The emitting class is not a `QObject`. + +**FileOrigin** (`src/Gui/FileOrigin.h`): + +```cpp +fastsignals::signal signalConnectionStateChanged; +``` + +**OriginManager** (`src/Gui/OriginManager.h`): + +```cpp +fastsignals::signal signalOriginRegistered; +fastsignals::signal signalOriginUnregistered; +fastsignals::signal signalCurrentOriginChanged; +fastsignals::signal signalDocumentOriginChanged; +``` + +**Gui::Document** (`src/Gui/Document.h`): + +```cpp +mutable fastsignals::signal signalNewObject; +mutable fastsignals::signal signalDeletedObject; +mutable fastsignals::signal signalChangedObject; +// ~9 more document-level signals +``` + +**Gui::Application** (`src/Gui/Application.h`): + +```cpp +fastsignals::signal signalNewDocument; +fastsignals::signal signalDeleteDocument; +fastsignals::signal signalNewObject; +// ~7 more application-level signals +``` + +### Qt signals — UI interaction + +These are events between Qt widgets, actions, and framework components where `QObject` is already the base class and the Qt event loop handles dispatch. + +**OriginSelectorWidget** (a `QToolButton` subclass): + +```cpp +// QActionGroup::triggered → onOriginActionTriggered +connect(m_originActions, &QActionGroup::triggered, + this, &OriginSelectorWidget::onOriginActionTriggered); + +// QAction::triggered → onManageOriginsClicked +connect(m_manageAction, &QAction::triggered, + this, &OriginSelectorWidget::onManageOriginsClicked); +``` + +### The boundary + +The pattern is consistent: **fastsignals for observable state changes in domain classes, Qt signals for UI framework plumbing**. A typical flow crosses the boundary once: + +``` +OriginManager emits signalCurrentOriginChanged (fastsignals) + → OriginSelectorWidget::onCurrentOriginChanged (lambda listener) + → updates QToolButton text/icon (Qt API calls) +``` + +## fastsignals API + +### Declaring a signal + +Signals are public member variables with a template signature: + +```cpp +fastsignals::signal signalSomethingHappened; +``` + +Use `mutable` if the signal needs to fire from `const` methods (as `Gui::Document` does): + +```cpp +mutable fastsignals::signal signalReadOnlyEvent; +``` + +Name signals with the `signal` prefix by convention. + +### Emitting + +Call the signal like a function: + +```cpp +signalSomethingHappened("hello"); +``` + +All connected slots execute **synchronously**, in connection order, before the call returns. There is no event loop queuing. + +### Connecting + +`signal.connect()` accepts any callable and returns a connection object: + +```cpp +auto conn = mySignal.connect([](const std::string& s) { + Base::Console().log("Got: %s\n", s.c_str()); +}); +``` + +### Connection types + +| Type | RAII | Copyable | Use case | +|------|------|----------|----------| +| `fastsignals::connection` | No | Yes | Long-lived, manually managed | +| `fastsignals::scoped_connection` | Yes | No (move only) | Member variable, automatic cleanup | +| `fastsignals::advanced_connection` | No | No | Temporary blocking via `shared_connection_block` | + +`scoped_connection` is the standard choice for class members. It disconnects automatically when destroyed. + +### Disconnecting + +```cpp +conn.disconnect(); // Explicit +// or let scoped_connection destructor handle it +``` + +## Connection patterns + +### Pattern 1: Scoped member connections + +The most common pattern. Store `scoped_connection` as a class member and connect in the constructor or an `attach()` method. + +```cpp +class MyListener { + fastsignals::scoped_connection m_conn; + +public: + MyListener(OriginManager* mgr) + { + m_conn = mgr->signalOriginRegistered.connect( + [this](const std::string& id) { onRegistered(id); } + ); + } + + // Destructor auto-disconnects via m_conn + ~MyListener() = default; + +private: + void onRegistered(const std::string& id) { /* ... */ } +}; +``` + +### Pattern 2: Explicit disconnect in destructor + +`OriginSelectorWidget` disconnects explicitly before destruction to prevent any signal delivery during teardown: + +```cpp +OriginSelectorWidget::~OriginSelectorWidget() +{ + disconnectSignals(); // m_conn*.disconnect() +} +``` + +This is defensive — `scoped_connection` would disconnect on its own, but explicit disconnection avoids edge cases where a signal fires between member destruction order. + +### Pattern 3: DocumentObserver base class + +`Gui::DocumentObserver` wraps ~10 document signals behind virtual methods: + +```cpp +class DocumentObserver { + using Connection = fastsignals::scoped_connection; + Connection connectDocumentCreatedObject; + Connection connectDocumentDeletedObject; + // ... + + void attachDocument(Document* doc); // connects all + void detachDocument(); // disconnects all +}; +``` + +Subclasses override `slotCreatedObject()`, `slotDeletedObject()`, etc. This pattern avoids repeating connection boilerplate in every document listener. + +### Pattern 4: Temporary blocking + +`advanced_connection` supports blocking a slot without disconnecting: + +```cpp +auto conn = signal.connect(handler, fastsignals::advanced_tag()); +fastsignals::shared_connection_block block(conn, true); // blocked + +signal(); // handler does NOT execute + +block.unblock(); +signal(); // handler executes +``` + +Use this to prevent recursive signal handling. + +## Thread safety + +### What is thread-safe + +- **Emitting** a signal from any thread (internal spin mutex protects the slot list). +- **Connecting and disconnecting** from any thread, even while slots are executing on another thread. + +### What is not thread-safe + +- **Accessing the same `connection` object** from multiple threads. Protect with your own mutex or keep connection objects thread-local. +- **Slot execution context.** Slots run on the emitter's thread. If a fastsignal fires on a background thread and the slot touches Qt widgets, you must marshal to the main thread: + +```cpp +mgr->signalOriginRegistered.connect([this](const std::string& id) { + QMetaObject::invokeMethod(this, [this, id]() { + // Now on the main thread — safe to update UI + updateUI(id); + }, Qt::QueuedConnection); +}); +``` + +In practice, all origin and document signals in Kindred Create fire on the main thread, so this marshalling is not currently needed. It would become necessary if background workers emitted signals. + +## Performance + +- **Emission:** O(n) where n = connected slots. No allocation, no event loop overhead. +- **Connection:** O(1) with spin mutex. +- **Memory:** Each signal stores a shared pointer to a slot vector. Each `scoped_connection` is ~16 bytes. +- fastsignals is rarely a bottleneck. Profile before optimising signal infrastructure. + +## Adding a new signal + +1. Declare the signal as a public member (or `mutable` if emitting from const methods). +2. Name it with the `signal` prefix. +3. Emit it at the appropriate point in your code. +4. Listeners store `scoped_connection` members and connect via lambdas. +5. Document the signal's signature and when it fires. + +Do not create a fastsignal for single-listener scenarios — a direct method call is simpler. + +## Common mistakes + +**Dangling `this` capture.** If a lambda captures `this` and the object is destroyed before the connection, the next emission crashes. Always store the connection as a `scoped_connection` member so it disconnects on destruction. + +**Assuming queued dispatch.** fastsignals are synchronous. A slot that blocks will block the emitter. Keep slots fast or offload work to a background thread. + +**Forgetting `mutable`.** If you need to emit from a `const` method, the signal member must be `mutable`. Otherwise the compiler rejects the call. + +**Copying `scoped_connection`.** It is move-only. Use `std::move()` when putting connections into containers: + +```cpp +std::vector conns; +conns.push_back(std::move(conn)); // OK +``` + +## See also + +- [OriginManager](../reference/cpp-origin-manager.md) — signal catalog for origin lifecycle events +- [FileOrigin Interface](../reference/cpp-file-origin.md) — `signalConnectionStateChanged` +- [OriginSelectorWidget](../reference/cpp-origin-selector-widget.md) — listener patterns in practice +- `src/3rdParty/FastSignals/` — library source and headers diff --git a/docs/src/architecture/silo-server.md b/docs/src/architecture/silo-server.md index a8b29f1fcb..3b787f0bb4 100644 --- a/docs/src/architecture/silo-server.md +++ b/docs/src/architecture/silo-server.md @@ -1,50 +1,11 @@ # Silo Server -The Silo server is a Go REST API that provides the backend for the Silo workbench. It manages part numbers, revisions, bills of materials, and file storage for engineering teams. +The Silo server architecture is documented in the dedicated [Silo Server](../silo-server/overview.md) section. -## Components - -``` -silo/ -├── cmd/ -│ ├── silo/ # CLI tool -│ └── silod/ # API server -├── internal/ -│ ├── api/ # HTTP handlers, routes, templates -│ ├── config/ # Configuration loading -│ ├── db/ # PostgreSQL access -│ ├── migration/ # Property migration utilities -│ ├── partnum/ # Part number generation -│ ├── schema/ # YAML schema parsing -│ └── storage/ # MinIO file storage -├── migrations/ # Database migration SQL scripts -├── schemas/ # Part numbering schema definitions (YAML) -└── deployments/ # Docker Compose and systemd configs -``` - -## Stack - -- **Go** REST API with 38+ routes -- **PostgreSQL** for metadata, revisions, BOM relationships -- **MinIO** (S3-compatible) for binary `.FCStd` file storage -- **LDAP / OIDC** for authentication -- **SSE** (Server-Sent Events) for real-time activity feed - -## Key features - -- **Configurable part number generation** via YAML schemas -- **Revision tracking** with append-only history -- **BOM management** with reference designators and alternates -- **Physical inventory** tracking with hierarchical locations - -## Database migrations - -Migrations live in `migrations/` as numbered SQL scripts (e.g., `001_initial.sql`). Apply them sequentially against the database: - -```bash -psql -h psql.kindred.internal -U silo -d silo -f migrations/001_initial.sql -``` - -## Deployment - -See `mods/silo/deployments/` for Docker Compose and systemd configurations. A typical deployment runs the Go server alongside PostgreSQL and MinIO containers. +- [Overview](../silo-server/overview.md) — Architecture, stack, and key features +- [Specification](../silo-server/SPECIFICATION.md) — Full API specification with 38+ routes +- [Configuration](../silo-server/CONFIGURATION.md) — YAML config reference +- [Deployment](../silo-server/DEPLOYMENT.md) — Docker Compose, systemd, production setup +- [Authentication](../silo-server/AUTH.md) — LDAP, OIDC, and local auth backends +- [Status System](../silo-server/STATUS.md) — Revision lifecycle states +- [Gap Analysis](../silo-server/GAP_ANALYSIS.md) — Current gaps and planned improvements diff --git a/docs/src/development/gui-build-integration.md b/docs/src/development/gui-build-integration.md new file mode 100644 index 0000000000..177a4147a4 --- /dev/null +++ b/docs/src/development/gui-build-integration.md @@ -0,0 +1,239 @@ +# Gui Module Build Integration + +The `FreeCADGui` shared library is the main GUI module. It contains the origin system, toolbar widgets, 3D viewport, command dispatch, and the Python bridge layer. This page explains how it is built, what it links against, and how to add new source files. + +## Target overview + +```cmake +add_library(FreeCADGui SHARED) +``` + +Built when `BUILD_GUI=ON` (the default). The output is a shared library installed to `${CMAKE_INSTALL_LIBDIR}`. + +**CMake file:** `src/Gui/CMakeLists.txt` + +## Dependency chain + +``` +FreeCADGui (SHARED) +├── FreeCADApp (core application module) +├── libfastsignals (STATIC, from src/3rdParty/FastSignals/) +├── Qt6::Core, Widgets, OpenGL, OpenGLWidgets, Network, +│ PrintSupport, Svg, SvgWidgets, UiTools, Xml +├── Coin3D / Quarter (3D scene graph) +├── PySide6 / Shiboken6 (Python-Qt bridge, if FREECAD_USE_PYSIDE=ON) +├── PyCXX (C++-Python interop, header-only) +├── Boost (various components) +├── OpenGL +├── yaml-cpp +├── ICU (Unicode) +└── (optional) 3Dconnexion, Tracy profiler, VR +``` + +### fastsignals + +fastsignals is built as a **static library** with position-independent code: + +``` +src/3rdParty/FastSignals/ +└── libfastsignals/ + ├── CMakeLists.txt # builds libfastsignals.a (STATIC, -fPIC) + ├── include/fastsignals/ # public headers (INTERFACE include dir) + └── src/ # implementation +``` + +Linked into FreeCADGui via: + +```cmake +target_link_libraries(FreeCADGui ... libfastsignals ...) +target_include_directories(FreeCADGui SYSTEM PRIVATE ${FastSignals_INCLUDE_DIRS}) +``` + +The `fastsignals/signal.h` header is also included in `src/Gui/PreCompiled.h` so it is available without explicit `#include` in any Gui source file. + +## Source file organisation + +Source files are grouped into named blocks in `CMakeLists.txt`. Each group becomes a Visual Studio filter / IDE source group. + +| Group variable | Contains | Origin system files in this group | +|----------------|----------|-----------------------------------| +| `Command_CPP_SRCS` | Command classes | `CommandOrigin.cpp` | +| `Widget_CPP_SRCS` | Toolbar/dock widgets | `OriginSelectorWidget.h/.cpp` | +| `Workbench_CPP_SRCS` | Workbench infrastructure | `OriginManager.h/.cpp`, `OriginManagerDialog.h/.cpp` | +| `FreeCADGui_CPP_SRCS` | Core Gui classes | `FileOrigin.h/.cpp`, `FileOriginPython.h/.cpp` | + +All groups are collected into `FreeCADGui_SRCS` and added to the target: + +```cmake +target_sources(FreeCADGui PRIVATE ${FreeCADGui_SRCS}) +``` + +## Adding new source files + +1. Create your `.h` and `.cpp` files in `src/Gui/`. + +2. Add them to the appropriate source group in `src/Gui/CMakeLists.txt`. For origin system code, follow the existing pattern: + + - Widget → `Widget_CPP_SRCS` + - Command → `Command_CPP_SRCS` + - Manager/infrastructure → `Workbench_CPP_SRCS` + - Core class → `FreeCADGui_CPP_SRCS` + + ```cmake + SET(FreeCADGui_CPP_SRCS + ... + MyNewOrigin.cpp + MyNewOrigin.h + ... + ) + ``` + +3. If the file has a `Q_OBJECT` macro, CMake's `AUTOMOC` handles MOC generation automatically. No manual steps needed. + +4. If adding Python bindings via PyCXX, add the `.pyi` stub and register it: + + ```cmake + generate_from_py(MyNewOrigin) + ``` + +5. If linking a new external library: + + ```cmake + list(APPEND FreeCADGui_LIBS MyNewLibrary) + ``` + +6. Reconfigure and build: + + ```bash + pixi run configure + pixi run build + ``` + +No changes are needed to `CommandOrigin.cpp`, `OriginSelectorWidget.cpp`, or `Workbench.cpp` when adding a new origin — those modules discover origins dynamically through `OriginManager` at runtime. + +## Qt integration + +### Modules linked + +QtCore, QtWidgets, QtOpenGL, QtOpenGLWidgets, QtPrintSupport, QtSvg, QtSvgWidgets, QtNetwork, QtUiTools, QtXml. Qt version `>=6.8,<6.9` is specified in `pixi.toml`. + +### MOC (Meta Object Compiler) + +Handled automatically by CMake's `AUTOMOC` for any header containing `Q_OBJECT`. Exception: `GraphvizView` has manual MOC commands due to moc-from-cpp requirements. + +### UI files + +~100 `.ui` files are compiled by Qt's `uic` into C++ headers. Declared in the `Gui_UIC_SRCS` block. + +### Resources + +Icons are embedded via `Icons/resource.qrc`. Translations use Qt Linguist (`.ts` → `.qm`) with auto-generated resource files. + +## Theme and stylesheet build + +Stylesheets are **copied, not compiled**. The canonical stylesheet is `src/Gui/Stylesheets/KindredCreate.qss`. + +`src/Gui/Stylesheets/CMakeLists.txt` defines a custom target that copies `.qss` files and SVG/PNG images to the build tree: + +```cmake +fc_copy_sources(Stylesheets_data + "${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Gui/Stylesheets" + ${Stylesheets_Files} ${Images_Files} ...) +``` + +`src/Gui/PreferencePacks/CMakeLists.txt` copies the same stylesheet into the preference pack directory using `configure_file(... COPYONLY)`, avoiding a duplicate source: + +```cmake +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/../Stylesheets/KindredCreate.qss + ${CMAKE_BINARY_DIR}/.../PreferencePacks/KindredCreate/KindredCreate.qss + COPYONLY) +``` + +Edit only the canonical file in `Stylesheets/` — the preference pack copy is generated. + +## Version constants + +Defined in the top-level `CMakeLists.txt` and injected as compiler definitions: + +```cmake +set(KINDRED_CREATE_VERSION "0.1.0") +set(FREECAD_VERSION "1.0.0") + +add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}") +add_definitions(-DFREECAD_VERSION="${FREECAD_VERSION}") +``` + +Available in C++ code as preprocessor macros. The Python update checker uses `version.py` generated from `version.py.in` at build time with `configure_file()`. + +## Precompiled headers + +Controlled by `FREECAD_USE_PCH` (off by default in conda builds): + +```cmake +if(FREECAD_USE_PCH) + target_precompile_headers(FreeCADGui PRIVATE + $<$:"PreCompiled.h">) +endif() +``` + +`PreCompiled.h` includes ``, all Qt headers (via `QtAll.h`), Coin3D headers, Boost, and Xerces. QSint and Quarter sources are excluded from PCH via `SKIP_PRECOMPILE_HEADERS`. + +## Build presets + +Defined in `CMakePresets.json`: + +| Preset | Platform | Build type | +|--------|----------|------------| +| `conda-linux-debug` | Linux | Debug | +| `conda-linux-release` | Linux | Release | +| `conda-macos-debug` | macOS | Debug | +| `conda-macos-release` | macOS | Release | +| `conda-windows-debug` | Windows | Debug | +| `conda-windows-release` | Windows | Release | + +All presets use the conda/pixi environment for dependency resolution. + +## ccache + +Enabled by default (`FREECAD_USE_CCACHE=ON`). CMake searches the system PATH for `ccache` at configure time and sets it as the compiler launcher: + +```cmake +set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") +set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") +``` + +Disable with `-DFREECAD_USE_CCACHE=OFF` if needed. Clear the cache with `ccache -C`. + +## Pixi tasks + +Common build workflow: + +```bash +pixi run initialize # git submodule update --init --recursive +pixi run configure # cmake --preset conda-linux-debug (or platform equivalent) +pixi run build # cmake --build build/debug +pixi run install # cmake --install build/debug +pixi run freecad # launch the built binary +pixi run test-kindred # run Kindred addon tests (no build needed) +``` + +Release variants: `configure-release`, `build-release`, `install-release`, `freecad-release`. + +## Key CMake variables + +| Variable | Default | Effect | +|----------|---------|--------| +| `BUILD_GUI` | ON | Build FreeCADGui at all | +| `FREECAD_USE_CCACHE` | ON | Compiler caching | +| `FREECAD_USE_PCH` | OFF (conda) | Precompiled headers | +| `FREECAD_USE_PYSIDE` | auto | PySide6 Python-Qt bridge | +| `FREECAD_USE_SHIBOKEN` | auto | Shiboken6 binding generator | +| `BUILD_VR` | OFF | Oculus VR support | +| `BUILD_TRACY_FRAME_PROFILER` | OFF | Tracy profiler integration | + +## See also + +- [Build System](./build-system.md) — general build instructions +- [Signal Architecture](../architecture/signal-architecture.md) — how fastsignals integrates at runtime +- [Creating a Custom Origin (C++)](../reference/cpp-custom-origin-guide.md) — what to link when adding an origin diff --git a/docs/src/guide/silo.md b/docs/src/guide/silo.md index cc08c0c2aa..a88ad6710f 100644 --- a/docs/src/guide/silo.md +++ b/docs/src/guide/silo.md @@ -102,72 +102,14 @@ Stored in `User parameter:BaseApp/Preferences/Mod/KindredSilo`: | `SILO_API_URL` | `http://localhost:8080/api` | Override for server API endpoint | | `SILO_PROJECTS_DIR` | `~/projects` | Override for local projects directory | -## Server setup +## Server -### Quick start +The Silo server is documented in detail in the [Silo Server](../silo-server/overview.md) section: -```bash -# Database setup (apply migrations sequentially) -psql -h psql.kindred.internal -U silo -d silo -f migrations/001_initial.sql -# ... through 010_item_extended_fields.sql - -# Configure -cp config.example.yaml config.yaml -# Edit config.yaml with your database, MinIO, and auth settings - -# Run server -go run ./cmd/silod -``` - -### Production deployment - -Production configs live in `mods/silo/deployments/`: - -``` -deployments/ -├── config.prod.yaml # Database, MinIO, auth settings -├── docker-compose.prod.yaml # Production container orchestration -├── docker-compose.yaml # Development Docker Compose -└── systemd/ - ├── silod.env.example # Service environment template - └── silod.service # systemd unit file -``` - -The systemd service runs as user `silo` with security hardening (`ProtectSystem=strict`, `NoNewPrivileges`, `PrivateTmp`). Config at `/etc/silo/config.yaml`, binary at `/opt/silo/bin/silod`. - -### Server stack - -- **Go** REST API with 38+ routes -- **PostgreSQL** for metadata, revisions, BOM relationships -- **MinIO** (S3-compatible) for binary `.FCStd` file storage -- **LDAP / OIDC** for authentication -- **SSE** (Server-Sent Events) for real-time activity feed - -## Database migrations - -Migrations live in `mods/silo/migrations/` as numbered SQL scripts: - -| Migration | Purpose | -|-----------|---------| -| `001_initial.sql` | Core schema — items, revisions, properties | -| `002_sequence_by_name.sql` | Part number sequence generation | -| `003_remove_material.sql` | Property cleanup | -| `004_cad_sync_state.sql` | CAD file sync tracking | -| `005_property_schema_version.sql` | Schema versioning for properties | -| `006_project_tags.sql` | Project-to-item relationships | -| `007_revision_status.sql` | Revision lifecycle status tracking | -| `008_odoo_integration.sql` | ERP integration preparation | -| `009_auth.sql` | User authentication tables | -| `010_item_extended_fields.sql` | Extended item metadata | - -Apply sequentially: `psql -f migrations/001_initial.sql`, then `002`, etc. There is no automated migration runner — apply manually against the database. - -## Part numbering schemas - -Part number generation is configured via YAML schemas in `mods/silo/schemas/`: - -- `kindred-rd.yaml` — Primary R&D part numbering schema with category codes, sequence segments, and validation rules -- `kindred-locations.yaml` — Location hierarchy schema for physical inventory tracking +- [Configuration](../silo-server/CONFIGURATION.md) — YAML config, database, MinIO, auth settings +- [Deployment](../silo-server/DEPLOYMENT.md) — Docker Compose, systemd, production setup +- [Specification](../silo-server/SPECIFICATION.md) — Full API specification with 38+ routes +- [Authentication](../silo-server/AUTH.md) — LDAP, OIDC, and local auth backends ## Directory structure diff --git a/docs/src/reference/configuration.md b/docs/src/reference/configuration.md index 863a9bc6dc..43300197fc 100644 --- a/docs/src/reference/configuration.md +++ b/docs/src/reference/configuration.md @@ -107,11 +107,4 @@ ccache is auto-detected by CMake at configure time. Clear with `ccache -C`. ## Silo server -Server configuration is defined in YAML. See `mods/silo/deployments/config.prod.yaml` for production settings and `mods/silo/config.example.yaml` for all available options. - -Key sections: -- **database** — PostgreSQL connection string -- **storage** — MinIO endpoint, bucket, access keys -- **auth** — LDAP/OIDC provider settings -- **server** — Listen address, TLS, CORS -- **schemas** — Path to part numbering YAML schemas +Server configuration is documented in the dedicated [Silo Server Configuration](../silo-server/CONFIGURATION.md) reference, which covers all YAML config sections (database, storage, auth, server, schemas) with full option tables and examples. diff --git a/docs/src/reference/cpp-command-origin.md b/docs/src/reference/cpp-command-origin.md new file mode 100644 index 0000000000..f895328554 --- /dev/null +++ b/docs/src/reference/cpp-command-origin.md @@ -0,0 +1,142 @@ +# CommandOrigin — File Menu Origin Commands + +> **Source:** `src/Gui/CommandOrigin.cpp` +> **Namespace:** `Gui` +> **Registration:** `Gui::CreateOriginCommands()` + +Five FreeCAD commands expose the extended operations defined by +[FileOrigin](./cpp-file-origin.md). Each command follows the same +pattern: look up the owning origin for the active document via +[OriginManager](./cpp-origin-manager.md), check whether that origin +advertises the required capability, and dispatch to the corresponding +`FileOrigin` virtual method. + +## Command Table + +| Command ID | Menu Text | Shortcut | Capability Gate | Dispatches To | Icon | +|-----------|-----------|----------|-----------------|---------------|------| +| `Origin_Commit` | &Commit | `Ctrl+Shift+C` | `supportsRevisions()` | `commitDocument(doc)` | `silo-commit` | +| `Origin_Pull` | &Pull | `Ctrl+Shift+P` | `supportsRevisions()` | `pullDocument(doc)` | `silo-pull` | +| `Origin_Push` | Pu&sh | `Ctrl+Shift+U` | `supportsRevisions()` | `pushDocument(doc)` | `silo-push` | +| `Origin_Info` | &Info | — | `supportsPartNumbers()` | `showInfo(doc)` | `silo-info` | +| `Origin_BOM` | &Bill of Materials | — | `supportsBOM()` | `showBOM(doc)` | `silo-bom` | + +All commands belong to the `"File"` group. + +## Activation Pattern + +Every command shares the same `isActive()` / `activated()` structure: + +``` +isActive(): + doc = App::GetApplication().getActiveDocument() + if doc is null → return false + origin = OriginManager::instance()->findOwningOrigin(doc) + return origin != null AND origin->supportsXxx() + +activated(): + doc = App::GetApplication().getActiveDocument() + if doc is null → return + origin = OriginManager::instance()->findOwningOrigin(doc) + if origin AND origin->supportsXxx(): + origin->xxxDocument(doc) +``` + +This means the commands **automatically grey out** in the menu when: + +- No document is open. +- The active document is owned by an origin that doesn't support the + capability (e.g., a local document has no Commit/Pull/Push). + +For a local-only document, all five commands are inactive. When a Silo +document is active, all five become available because +[SiloOrigin](../reference/cpp-file-origin.md) returns `true` for +`supportsRevisions()`, `supportsBOM()`, and `supportsPartNumbers()`. + +## Ownership Resolution + +The commands use `findOwningOrigin(doc)` rather than `currentOrigin()` +because the active document may belong to a **different** origin than the +one currently selected in the toolbar. For example, a user might have +Silo selected as the current origin but be viewing a local document in +another tab — the commands correctly detect that the local document has +no revision support. + +See [OriginManager § Document-Origin Resolution](./cpp-origin-manager.md#document-origin-resolution) +for the full lookup algorithm. + +## Command Type Flags + +| Command | `eType` | Meaning | +|---------|---------|---------| +| Commit | `AlterDoc` | Marks document as modified (undo integration) | +| Pull | `AlterDoc` | Marks document as modified | +| Push | `AlterDoc` | Marks document as modified | +| Info | `0` | Read-only, no undo integration | +| BOM | `0` | Read-only, no undo integration | + +`AlterDoc` commands participate in FreeCAD's transaction/undo system. +Info and BOM are view-only dialogs that don't modify the document. + +## Registration + +All five commands are registered in a single function called during +application startup: + +```cpp +void Gui::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()); +} +``` + +This is called from the Gui module initialization alongside other +command registration functions (`CreateDocCommands`, +`CreateMacroCommands`, etc.). + +## What the Commands Actually Do + +The C++ commands are **thin dispatchers** — they contain no business +logic. The actual work happens in the origin implementation: + +- **Local origin** — all five extended methods are no-ops (defaults from + `FileOrigin` base class return `false` or do nothing). +- **Silo origin** (Python) — each method delegates to the corresponding + `Silo_*` FreeCAD command: + +| C++ dispatch | Python SiloOrigin method | Delegates to | +|-------------|-------------------------|--------------| +| `commitDocument()` | `SiloOrigin.commitDocument()` | `Silo_Commit` command | +| `pullDocument()` | `SiloOrigin.pullDocument()` | `Silo_Pull` command | +| `pushDocument()` | `SiloOrigin.pushDocument()` | `Silo_Push` command | +| `showInfo()` | `SiloOrigin.showInfo()` | `Silo_Info` command | +| `showBOM()` | `SiloOrigin.showBOM()` | `Silo_BOM` command | + +The call chain for a Commit, for example: + +``` +User clicks File > Commit (or Ctrl+Shift+C) + → OriginCmdCommit::activated() + → OriginManager::findOwningOrigin(doc) [C++] + → SiloOrigin.ownsDocument(doc) [Python via bridge] + → origin->commitDocument(doc) [C++ virtual] + → FileOriginPython::commitDocument(doc) [bridge] + → SiloOrigin.commitDocument(doc) [Python] + → FreeCADGui.runCommand("Silo_Commit") [Python command] +``` + +## See Also + +- [FileOrigin Interface](./cpp-file-origin.md) — defines the virtual + methods these commands dispatch to +- [OriginManager](./cpp-origin-manager.md) — provides + `findOwningOrigin()` used by every command +- [FileOriginPython](./cpp-file-origin-python.md) — bridges the dispatch + from C++ to Python origins +- [SiloOrigin adapter](./cpp-file-origin.md) — Python implementation + that handles the actual Silo operations diff --git a/docs/src/reference/cpp-custom-origin-guide.md b/docs/src/reference/cpp-custom-origin-guide.md new file mode 100644 index 0000000000..36b804aa4b --- /dev/null +++ b/docs/src/reference/cpp-custom-origin-guide.md @@ -0,0 +1,537 @@ +# Creating a Custom Origin in C++ + +This guide walks through implementing and registering a new `FileOrigin` backend in C++. By the end you will have a working origin that appears in the toolbar selector, responds to File menu commands, and integrates with the command dispatch system. + +For the Python equivalent, see [Creating a Custom Origin in Python](./python-custom-origin-guide.md). + +## Prerequisites + +Familiarity with: + +- [FileOrigin Interface](./cpp-file-origin.md) — the abstract base class +- [OriginManager](./cpp-origin-manager.md) — registration and document resolution +- [CommandOrigin](./cpp-command-origin.md) — how commands dispatch to origins + +## What an origin does + +An origin defines **how documents are tracked and stored**. It answers: + +- Does this document belong to me? (`ownsDocument`) +- What is this document's identity? (`documentIdentity`) +- How do I create, open, and save documents? (6 document operations) +- What extended capabilities do I support? (revisions, BOM, part numbers) + +Once registered, the origin appears in the toolbar selector and all File/Origin commands automatically dispatch to it for documents it owns. + +## Step 1: Choose your origin type + +```cpp +enum class OriginType { Local, PLM, Cloud, Custom }; +``` + +| Type | Use when | `tracksExternally` | `requiresAuthentication` | +|------|----------|-------------------|-------------------------| +| `Local` | Filesystem storage, no sync | `false` | `false` | +| `PLM` | Database-backed with revisions (Silo, Teamcenter, Windchill) | `true` | usually `true` | +| `Cloud` | Remote file storage (S3, OneDrive, Google Drive) | `true` | usually `true` | +| `Custom` | Anything else | your choice | your choice | + +## Step 2: Define the class + +Create a header and source file in `src/Gui/`: + +```cpp +// src/Gui/MyOrigin.h +#ifndef GUI_MY_ORIGIN_H +#define GUI_MY_ORIGIN_H + +#include "FileOrigin.h" + +namespace Gui { + +class MyOrigin : public FileOrigin +{ +public: + MyOrigin(); + ~MyOrigin() override = default; + + // --- Identity (required) --- + std::string id() const override; + std::string name() const override; + std::string nickname() const override; + QIcon icon() const override; + OriginType type() const override; + + // --- Ownership (required) --- + bool ownsDocument(App::Document* doc) const override; + std::string documentIdentity(App::Document* doc) const override; + std::string documentDisplayId(App::Document* doc) const override; + + // --- Document operations (required) --- + 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; + + // --- Characteristics (override defaults) --- + bool tracksExternally() const override; + bool requiresAuthentication() const override; + + // --- Capabilities (override if supported) --- + bool supportsRevisions() const override; + bool supportsBOM() const override; + bool supportsPartNumbers() const override; + + // --- Extended operations (implement if capabilities are true) --- + bool commitDocument(App::Document* doc) override; + bool pullDocument(App::Document* doc) override; + bool pushDocument(App::Document* doc) override; + void showInfo(App::Document* doc) override; + void showBOM(App::Document* doc) override; + + // --- Connection (override for authenticated origins) --- + ConnectionState connectionState() const override; + bool connect() override; + void disconnect() override; +}; + +} // namespace Gui +#endif +``` + +## Step 3: Implement identity methods + +Every origin needs a unique `id()` (lowercase, alphanumeric with hyphens), a human-readable `name()`, a short `nickname()` for toolbar display, an `icon()`, and a `type()`. + +```cpp +std::string MyOrigin::id() const { return "my-plm"; } +std::string MyOrigin::name() const { return "My PLM System"; } +std::string MyOrigin::nickname() const { return "My PLM"; } +OriginType MyOrigin::type() const { return OriginType::PLM; } + +QIcon MyOrigin::icon() const +{ + return BitmapFactory().iconFromTheme("server-database"); +} +``` + +The `nickname()` appears in the toolbar button (keep it under ~15 characters). The full `name()` appears in tooltips. + +## Step 4: Implement ownership detection + +`ownsDocument()` is called by `OriginManager::findOwningOrigin()` to determine which origin owns a given document. The standard pattern is to check for a **tracking property** on objects in the document. + +```cpp +static const char* MY_TRACKING_PROP = "MyPlmItemId"; + +bool MyOrigin::ownsDocument(App::Document* doc) const +{ + if (!doc) { + return false; + } + for (auto* obj : doc->getObjects()) { + if (obj->getPropertyByName(MY_TRACKING_PROP)) { + return true; + } + } + return false; +} +``` + +**Key rules:** + +- Return `true` only for documents that have your tracking marker. +- The built-in `LocalFileOrigin` claims documents by *exclusion* — it owns anything that no other origin claims. Your origin must positively identify its documents. +- OriginManager checks non-local origins first, then falls back to local. Your `ownsDocument()` takes priority. +- Keep the check fast — it runs on every document open and on command activation queries. + +### Setting the tracking property + +When your origin creates or first saves a document, add the tracking property: + +```cpp +void MyOrigin::markDocument(App::Document* doc, const std::string& itemId) +{ + // Add to the first object, or create a marker object + auto* obj = doc->getObjects().empty() + ? doc->addObject("App::DocumentObject", "MyPlm_Marker") + : doc->getObjects().front(); + + auto* prop = dynamic_cast( + obj->getPropertyByName(MY_TRACKING_PROP)); + if (!prop) { + prop = static_cast( + obj->addDynamicProperty("App::PropertyString", MY_TRACKING_PROP)); + } + prop->setValue(itemId.c_str()); +} +``` + +## Step 5: Implement document identity + +Two methods distinguish machine identity from display identity: + +| Method | Purpose | Example (local) | Example (PLM) | +|--------|---------|-----------------|----------------| +| `documentIdentity()` | Immutable tracking key | `/home/user/part.FCStd` | `550e8400-...` (UUID) | +| `documentDisplayId()` | Human-readable label | `/home/user/part.FCStd` | `WHEEL-001-RevC` | + +`documentIdentity()` must be stable — the same document must always produce the same identity. `openDocument(identity)` must be able to reopen the document from this value. + +```cpp +std::string MyOrigin::documentIdentity(App::Document* doc) const +{ + if (!doc || !ownsDocument(doc)) { + return {}; + } + for (auto* obj : doc->getObjects()) { + auto* prop = dynamic_cast( + obj->getPropertyByName(MY_TRACKING_PROP)); + if (prop) { + return prop->getValue(); + } + } + return {}; +} + +std::string MyOrigin::documentDisplayId(App::Document* doc) const +{ + if (!doc || !ownsDocument(doc)) { + return {}; + } + // Show a friendly part number instead of UUID + for (auto* obj : doc->getObjects()) { + auto* prop = dynamic_cast( + obj->getPropertyByName("PartNumber")); + if (prop && prop->getValue()[0] != '\0') { + return prop->getValue(); + } + } + // Fall back to identity + return documentIdentity(doc); +} +``` + +## Step 6: Implement document operations + +All six document operations are pure virtual and must be implemented. + +### newDocument + +Called when the user creates a new document while your origin is active. + +```cpp +App::Document* MyOrigin::newDocument(const std::string& name) +{ + // For a PLM origin, you might show a part creation dialog first + MyPartDialog dlg(getMainWindow()); + if (dlg.exec() != QDialog::Accepted) { + return nullptr; + } + + std::string docName = name.empty() ? "Unnamed" : name; + App::Document* doc = App::GetApplication().newDocument(docName.c_str()); + + // Mark the document as ours + markDocument(doc, dlg.generatedUUID()); + return doc; +} +``` + +### openDocument / openDocumentInteractive + +```cpp +App::Document* MyOrigin::openDocument(const std::string& identity) +{ + if (identity.empty()) { + return nullptr; + } + // Download file from backend if not cached locally + std::string localPath = fetchFromBackend(identity); + if (localPath.empty()) { + return nullptr; + } + return App::GetApplication().openDocument(localPath.c_str()); +} + +App::Document* MyOrigin::openDocumentInteractive() +{ + // Show a search/browse dialog + MySearchDialog dlg(getMainWindow()); + if (dlg.exec() != QDialog::Accepted) { + return nullptr; + } + return openDocument(dlg.selectedIdentity()); +} +``` + +### saveDocument / saveDocumentAs / saveDocumentAsInteractive + +```cpp +bool MyOrigin::saveDocument(App::Document* doc) +{ + if (!doc) { + return false; + } + const char* path = doc->FileName.getValue(); + if (!path || path[0] == '\0') { + return false; // No path yet — caller will use saveDocumentAsInteractive + } + if (!doc->save()) { + return false; + } + // Sync metadata to backend + return syncToBackend(doc); +} + +bool MyOrigin::saveDocumentAs(App::Document* doc, + const std::string& newIdentity) +{ + if (!doc || newIdentity.empty()) { + return false; + } + std::string localPath = localPathForIdentity(newIdentity); + if (!doc->saveAs(localPath.c_str())) { + return false; + } + markDocument(doc, newIdentity); + return syncToBackend(doc); +} + +bool MyOrigin::saveDocumentAsInteractive(App::Document* doc) +{ + if (!doc) { + return false; + } + Gui::Document* guiDoc = Application::Instance->getDocument(doc); + if (!guiDoc) { + return false; + } + return guiDoc->saveAs(); +} +``` + +**Save workflow:** When the user presses Ctrl+S, the command layer calls `saveDocument()`. If it returns `false` (no path set), the command layer automatically falls through to `saveDocumentAsInteractive()`. + +## Step 7: Implement capabilities and extended operations + +Override capability flags to enable the corresponding toolbar commands: + +```cpp +bool MyOrigin::supportsRevisions() const { return true; } +bool MyOrigin::supportsBOM() const { return true; } +bool MyOrigin::supportsPartNumbers() const { return true; } +``` + +Then implement the operations they gate: + +| Flag | Enables commands | Methods to implement | +|------|-----------------|---------------------| +| `supportsRevisions()` | Origin_Commit, Origin_Pull, Origin_Push | `commitDocument`, `pullDocument`, `pushDocument` | +| `supportsBOM()` | Origin_BOM | `showBOM` | +| `supportsPartNumbers()` | Origin_Info | `showInfo` | + +```cpp +bool MyOrigin::commitDocument(App::Document* doc) +{ + if (!doc) return false; + // Show commit dialog, upload revision + MyCommitDialog dlg(getMainWindow(), doc); + if (dlg.exec() != QDialog::Accepted) return false; + return uploadRevision(documentIdentity(doc), dlg.message()); +} + +bool MyOrigin::pullDocument(App::Document* doc) +{ + if (!doc) return false; + // Show revision picker, download selected revision + MyRevisionDialog dlg(getMainWindow(), documentIdentity(doc)); + if (dlg.exec() != QDialog::Accepted) return false; + return downloadRevision(doc, dlg.selectedRevisionId()); +} + +bool MyOrigin::pushDocument(App::Document* doc) +{ + if (!doc) return false; + if (!doc->save()) return false; + return uploadCurrentState(documentIdentity(doc)); +} + +void MyOrigin::showInfo(App::Document* doc) +{ + if (!doc) return; + MyInfoDialog dlg(getMainWindow(), doc); + dlg.exec(); +} + +void MyOrigin::showBOM(App::Document* doc) +{ + if (!doc) return; + MyBOMDialog dlg(getMainWindow(), doc); + dlg.exec(); +} +``` + +Commands that are not supported simply remain at their base-class defaults (`return false` / no-op). The toolbar buttons for unsupported commands are automatically greyed out. + +### How command dispatch works + +When the user clicks Origin_Commit: + +1. `OriginCmdCommit::isActive()` checks `origin->supportsRevisions()` — if `false`, the button is greyed out. +2. `OriginCmdCommit::activated()` calls `OriginManager::instance()->findOwningOrigin(doc)` to get the origin for the active document. +3. It then calls `origin->commitDocument(doc)`. +4. Your implementation runs. + +No routing code is needed — the command system handles dispatch automatically based on document ownership. + +## Step 8: Implement connection lifecycle (authenticated origins) + +If your origin requires authentication, override the connection methods: + +```cpp +bool MyOrigin::requiresAuthentication() const { return true; } + +ConnectionState MyOrigin::connectionState() const +{ + return m_connectionState; // private member +} + +bool MyOrigin::connect() +{ + m_connectionState = ConnectionState::Connecting; + signalConnectionStateChanged(ConnectionState::Connecting); + + // Show login dialog or attempt token-based auth + MyLoginDialog dlg(getMainWindow()); + if (dlg.exec() != QDialog::Accepted) { + m_connectionState = ConnectionState::Disconnected; + signalConnectionStateChanged(ConnectionState::Disconnected); + return false; + } + + if (!authenticateWithServer(dlg.credentials())) { + m_connectionState = ConnectionState::Error; + signalConnectionStateChanged(ConnectionState::Error); + return false; + } + + m_connectionState = ConnectionState::Connected; + signalConnectionStateChanged(ConnectionState::Connected); + return true; +} + +void MyOrigin::disconnect() +{ + invalidateSession(); + m_connectionState = ConnectionState::Disconnected; + signalConnectionStateChanged(ConnectionState::Disconnected); +} +``` + +**Connection state lifecycle:** + +``` +Disconnected ──connect()──→ Connecting ──success──→ Connected + ──failure──→ Error +Connected ──disconnect()──→ Disconnected +Error ──connect()──→ Connecting ──... +``` + +The `OriginSelectorWidget` listens to `signalConnectionStateChanged` and: +- Adds a red overlay for `Disconnected` +- Adds a yellow overlay for `Error` +- Shows no overlay for `Connected` + +When the user selects a disconnected origin in the toolbar, the widget calls `connect()` automatically. If `connect()` returns `false`, the selection reverts to the previous origin. + +## Step 9: Register with OriginManager + +Call `registerOrigin()` during module initialisation: + +```cpp +#include "OriginManager.h" +#include "MyOrigin.h" + +void initMyOriginModule() +{ + auto* origin = new MyOrigin(); + if (!OriginManager::instance()->registerOrigin(origin)) { + Base::Console().error("Failed to register MyOrigin\n"); + // origin is deleted by OriginManager on failure + } +} +``` + +**Registration rules:** + +- `id()` must be unique. Registration fails if the ID already exists. +- `OriginManager` takes ownership of the pointer via `std::unique_ptr`. +- The origin appears in `OriginSelectorWidget` immediately (the widget listens to `signalOriginRegistered`). +- The built-in `"local"` origin cannot be replaced. + +**Unregistration** (if your module is unloaded): + +```cpp +OriginManager::instance()->unregisterOrigin("my-plm"); +``` + +If the unregistered origin was the current origin, `OriginManager` switches back to `"local"`. + +## Step 10: Build integration + +Add your files to `src/Gui/CMakeLists.txt`: + +```cmake +SET(Gui_SRCS + # ... existing files ... + MyOrigin.cpp + MyOrigin.h +) +``` + +Include dependencies in your source file: + +```cpp +#include "MyOrigin.h" +#include "BitmapFactory.h" // Icons +#include "Application.h" // Gui::Application +#include "FileDialog.h" // File dialogs +#include "MainWindow.h" // getMainWindow() +#include // App::GetApplication() +#include // App::Document +#include // Logging +``` + +No changes are needed to `CommandOrigin.cpp`, `OriginSelectorWidget.cpp`, or `Workbench.cpp` — they discover origins dynamically through `OriginManager`. + +## Implementation checklist + +| Phase | Task | Status | +|-------|------|--------| +| Identity | `id()`, `name()`, `nickname()`, `icon()`, `type()` | | +| Ownership | `ownsDocument()` with tracking property check | | +| Identity | `documentIdentity()`, `documentDisplayId()` | | +| Tracking | Set tracking property on new/first-save | | +| Operations | `newDocument()`, `openDocument()`, `openDocumentInteractive()` | | +| Operations | `saveDocument()`, `saveDocumentAs()`, `saveDocumentAsInteractive()` | | +| Characteristics | `tracksExternally()`, `requiresAuthentication()` | | +| Capabilities | `supportsRevisions()`, `supportsBOM()`, `supportsPartNumbers()` | | +| Extended ops | `commitDocument()`, `pullDocument()`, `pushDocument()` (if revisions) | | +| Extended ops | `showInfo()` (if part numbers), `showBOM()` (if BOM) | | +| Connection | `connectionState()`, `connect()`, `disconnect()` (if auth) | | +| Registration | `registerOrigin()` call in module init | | +| Build | Source files added to `CMakeLists.txt` | | + +## See also + +- [FileOrigin Interface](./cpp-file-origin.md) — complete API reference +- [LocalFileOrigin](./cpp-local-file-origin.md) — reference implementation (simplest origin) +- [FileOriginPython Bridge](./cpp-file-origin-python.md) — how Python origins connect to the C++ layer +- [Creating a Custom Origin in Python](./python-custom-origin-guide.md) — Python alternative (no rebuild needed) +- [OriginManager](./cpp-origin-manager.md) — registration and document resolution +- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar UI integration +- [CommandOrigin](./cpp-command-origin.md) — command dispatch diff --git a/docs/src/reference/cpp-file-origin-python.md b/docs/src/reference/cpp-file-origin-python.md new file mode 100644 index 0000000000..14941a0c67 --- /dev/null +++ b/docs/src/reference/cpp-file-origin-python.md @@ -0,0 +1,257 @@ +# FileOriginPython — Python-to-C++ Bridge + +> **Header:** `src/Gui/FileOriginPython.h` +> **Implementation:** `src/Gui/FileOriginPython.cpp` +> **Python binding:** `src/Gui/ApplicationPy.cpp` +> **Namespace:** `Gui` + +`FileOriginPython` is an Adapter that wraps a Python object and presents +it as a C++ [FileOrigin](./cpp-file-origin.md). This is how Python +addons (like SiloOrigin) integrate with the C++ +[OriginManager](./cpp-origin-manager.md) without compiling any C++ code. + +## Registration from Python + +The bridge is exposed to Python through three `FreeCADGui` module +functions: + +```python +import FreeCADGui + +FreeCADGui.addOrigin(obj) # register a Python origin +FreeCADGui.removeOrigin(obj) # unregister it +FreeCADGui.getOrigin(obj) # look up the wrapper (or None) +``` + +### `FreeCADGui.addOrigin(obj)` + +1. Checks that `obj` is not already registered (duplicate check via + `findOrigin`). +2. Constructs a `FileOriginPython` wrapper around `obj`. +3. Calls `obj.id()` immediately and caches the result — the ID must be + non-empty or registration fails. +4. Passes the wrapper to + `OriginManager::instance()->registerOrigin(wrapper)`. +5. If `registerOrigin` fails (e.g., duplicate ID), the wrapper is removed + from the internal instances list (OriginManager already deleted it). + +### `FreeCADGui.removeOrigin(obj)` + +1. Finds the wrapper via `findOrigin(obj)`. +2. Removes it from the internal `_instances` vector. +3. Calls `OriginManager::instance()->unregisterOrigin(id)`, which deletes + the wrapper. + +### Typical Usage (from SiloOrigin) + +```python +# mods/silo/freecad/silo_origin.py + +class SiloOrigin: + def id(self): return "silo" + def name(self): return "Kindred Silo" + # ... remaining interface methods ... + +_silo_origin = SiloOrigin() + +def register_silo_origin(): + FreeCADGui.addOrigin(_silo_origin) + +def unregister_silo_origin(): + FreeCADGui.removeOrigin(_silo_origin) +``` + +This is called from `InitGui.py` via a deferred `QTimer.singleShot` +(1500 ms after GUI init) to ensure the Gui module is fully loaded. + +## Method Dispatch + +Every C++ `FileOrigin` virtual method is implemented by delegating to the +corresponding Python method on the wrapped object. Three internal +helpers handle the marshalling: + +| Helper | Signature | Used For | +|--------|-----------|----------| +| `callStringMethod` | `(name, default) → std::string` | `id()`, `name()`, `nickname()`, `icon()` | +| `callBoolMethod` | `(name, default) → bool` | All `supportsXxx()`, `tracksExternally()`, etc. | +| `callMethod` | `(name, args) → Py::Object` | Everything else (document ops, state queries) | + +### Dispatch Pattern + +Each overridden method follows the same structure: + +``` +1. Acquire GIL ← Base::PyGILStateLocker lock +2. Check if Python object has attr ← _inst.hasAttr("methodName") +3. If missing → return default ← graceful degradation +4. Build Py::Tuple args ← marshal C++ args to Python +5. Call Python method ← func.apply(args) +6. Convert result ← Py::String/Boolean/Long → C++ type +7. Return ← release GIL on scope exit +``` + +If the Python method is missing, the bridge returns sensible defaults +(empty string, `false`, `nullptr`). If the Python method raises an +exception, it is caught, reported to the FreeCAD console via +`Base::PyException::reportException()`, and the default is returned. + +### Document Argument Marshalling + +Methods that take `App::Document*` convert it to a Python object using: + +```cpp +Py::Object FileOriginPython::getDocPyObject(App::Document* doc) const +{ + if (!doc) return Py::None(); + return Py::asObject(doc->getPyObject()); +} +``` + +This returns the standard `App.Document` Python wrapper so the Python +origin can call `doc.Objects`, `doc.FileName`, `doc.save()`, etc. + +Methods that **return** an `App::Document*` (like `newDocument`, +`openDocument`) check the return value with: + +```cpp +if (PyObject_TypeCheck(result.ptr(), &(App::DocumentPy::Type))) { + return static_cast(result.ptr())->getDocumentPtr(); +} +``` + +If the Python method returns `None` or a non-Document object, the bridge +returns `nullptr`. + +## GIL Management + +Every method acquires the GIL (`Base::PyGILStateLocker`) before touching +any Python objects. This is critical because the C++ origin methods can +be called from: + +- The main Qt event loop (menu clicks, toolbar actions) +- `OriginManager::findOwningOrigin()` during document operations +- Signal handlers (fastsignals callbacks) + +All of these may run on the main thread while the GIL is not held. + +## Enum Mapping + +Python origins return integers for enum values: + +### `OriginType` (from `type()`) + +| Python `int` | C++ Enum | +|-------------|----------| +| `0` | `OriginType::Local` | +| `1` | `OriginType::PLM` | +| `2` | `OriginType::Cloud` | +| `3` | `OriginType::Custom` | + +### `ConnectionState` (from `connectionState()`) + +| Python `int` | C++ Enum | +|-------------|----------| +| `0` | `ConnectionState::Disconnected` | +| `1` | `ConnectionState::Connecting` | +| `2` | `ConnectionState::Connected` | +| `3` | `ConnectionState::Error` | + +Out-of-range values fall back to `OriginType::Custom` and +`ConnectionState::Connected` respectively. + +## Icon Resolution + +The `icon()` override calls `callStringMethod("icon")` to get a string +name, then passes it to `BitmapFactory().iconFromTheme(name)`. The +Python origin returns an icon name (e.g., `"silo"`) rather than a QIcon +object. + +## Required Python Interface + +Methods the Python object **must** implement (called unconditionally): + +| Method | Signature | Purpose | +|--------|-----------|---------| +| `id()` | `→ str` | Unique origin ID (cached on registration) | +| `name()` | `→ str` | Display name | +| `nickname()` | `→ str` | Short toolbar label | +| `icon()` | `→ str` | Icon name for `BitmapFactory` | +| `type()` | `→ int` | `OriginType` enum value | +| `tracksExternally()` | `→ bool` | Whether origin syncs externally | +| `requiresAuthentication()` | `→ bool` | Whether login is needed | +| `ownsDocument(doc)` | `→ bool` | Ownership detection | +| `documentIdentity(doc)` | `→ str` | Immutable tracking key | +| `documentDisplayId(doc)` | `→ str` | Human-readable ID | + +Methods the Python object **may** implement (checked with `hasAttr`): + +| Method | Signature | Default | +|--------|-----------|---------| +| `supportsRevisions()` | `→ bool` | `False` | +| `supportsBOM()` | `→ bool` | `False` | +| `supportsPartNumbers()` | `→ bool` | `False` | +| `supportsAssemblies()` | `→ bool` | `False` | +| `connectionState()` | `→ int` | `2` (Connected) | +| `connect()` | `→ bool` | `True` | +| `disconnect()` | `→ None` | no-op | +| `syncProperties(doc)` | `→ bool` | `True` | +| `newDocument(name)` | `→ Document` | `None` | +| `openDocument(identity)` | `→ Document` | `None` | +| `openDocumentInteractive()` | `→ Document` | `None` | +| `saveDocument(doc)` | `→ bool` | `False` | +| `saveDocumentAs(doc, id)` | `→ bool` | `False` | +| `saveDocumentAsInteractive(doc)` | `→ bool` | `False` | +| `commitDocument(doc)` | `→ bool` | `False` | +| `pullDocument(doc)` | `→ bool` | `False` | +| `pushDocument(doc)` | `→ bool` | `False` | +| `showInfo(doc)` | `→ None` | no-op | +| `showBOM(doc)` | `→ None` | no-op | + +## Error Handling + +All Python exceptions are caught and reported to the FreeCAD console. +The bridge **never** propagates a Python exception into C++ — callers +always receive a safe default value. + +``` +[Python exception in SiloOrigin.ownsDocument] + → Base::PyException::reportException() + → FreeCAD Console: "Python error: ..." + → return false +``` + +This prevents a buggy Python origin from crashing the application. + +## Lifetime and Ownership + +``` +FreeCADGui.addOrigin(py_obj) + │ + ├─ FileOriginPython(py_obj) ← wraps, holds Py::Object ref + │ └─ _inst = py_obj ← prevents Python GC + │ + ├─ _instances.push_back(wrapper) ← static vector for findOrigin() + │ + └─ OriginManager::registerOrigin(wrapper) + └─ unique_ptr ← OriginManager owns the wrapper +``` + +- `FileOriginPython` holds a `Py::Object` reference to the Python + instance, preventing garbage collection. +- `OriginManager` owns the wrapper via `std::unique_ptr`. +- The static `_instances` vector provides `findOrigin()` lookup but does + **not** own the pointers. +- On `removeOrigin()`: the wrapper is removed from `_instances`, then + `OriginManager::unregisterOrigin()` deletes the wrapper, which releases + the `Py::Object` reference. + +## See Also + +- [FileOrigin Interface](./cpp-file-origin.md) — the abstract interface + being adapted +- [OriginManager](./cpp-origin-manager.md) — where the wrapped origin + gets registered +- [CommandOrigin](./cpp-command-origin.md) — commands that dispatch + through this bridge +- [Creating a Custom Origin (Python)](../guide/custom-origin-python.md) + — step-by-step guide using this bridge diff --git a/docs/src/reference/cpp-file-origin.md b/docs/src/reference/cpp-file-origin.md new file mode 100644 index 0000000000..94a363c1a9 --- /dev/null +++ b/docs/src/reference/cpp-file-origin.md @@ -0,0 +1,199 @@ +# FileOrigin — Abstract Interface + +> **Header:** `src/Gui/FileOrigin.h` +> **Implementation:** `src/Gui/FileOrigin.cpp` +> **Namespace:** `Gui` + +`FileOrigin` is the abstract base class that all document storage backends +implement. It defines the contract for creating, opening, saving, and +tracking FreeCAD documents through a pluggable origin system. + +## Key Design Principle + +Origins do **not** change where files are stored — all documents are always +saved to the local filesystem. Origins change the **workflow** and +**identity model**: + +| Origin | Identity | Tracking | Authentication | +|--------|----------|----------|----------------| +| Local | File path | None | None | +| PLM (Silo) | Database UUID | External DB + MinIO | Required | +| Cloud | URL / key | External service | Required | + +## Enums + +### `OriginType` + +```cpp +enum class OriginType { + Local, // Local filesystem storage + PLM, // Product Lifecycle Management system (e.g., Silo) + Cloud, // Generic cloud storage + Custom // User-defined origin type +}; +``` + +### `ConnectionState` + +```cpp +enum class ConnectionState { + Disconnected, // Not connected + Connecting, // Connection in progress + Connected, // Successfully connected + Error // Connection error occurred +}; +``` + +Used by the [OriginSelectorWidget](./cpp-origin-selector-widget.md) to +render status overlays on origin icons. + +## Pure Virtual Methods (must override) + +Every `FileOrigin` subclass must implement these methods. + +### Identity + +| Method | Return | Purpose | +|--------|--------|---------| +| `id()` | `std::string` | Unique origin ID (`"local"`, `"silo"`, …) | +| `name()` | `std::string` | Display name for menus (`"Local Files"`, `"Kindred Silo"`) | +| `nickname()` | `std::string` | Short label for toolbar (`"Local"`, `"Silo"`) | +| `icon()` | `QIcon` | Icon for UI representation | +| `type()` | `OriginType` | Classification enum | + +### Workflow Characteristics + +| Method | Return | Purpose | +|--------|--------|---------| +| `tracksExternally()` | `bool` | `true` if origin syncs to a remote system | +| `requiresAuthentication()` | `bool` | `true` if origin needs login | + +### Document Identity + +| Method | Return | Purpose | +|--------|--------|---------| +| `documentIdentity(doc)` | `std::string` | Immutable tracking key. File path for Local, UUID for PLM. | +| `documentDisplayId(doc)` | `std::string` | Human-readable ID. File path for Local, part number for PLM. | +| `ownsDocument(doc)` | `bool` | Whether this origin owns the document. | + +**Ownership detection** is the mechanism that determines which origin +manages a given document. The +[OriginManager](./cpp-origin-manager.md) calls `ownsDocument()` on each +registered origin to resolve ownership. + +- `LocalFileOrigin` owns documents that have **no** `SiloItemId` property + on any object. +- `SiloOrigin` (Python) owns documents where any object **has** a + `SiloItemId` property. + +### Core Document Operations + +| Method | Parameters | Return | Purpose | +|--------|-----------|--------|---------| +| `newDocument` | `name = ""` | `App::Document*` | Create a new document | +| `openDocument` | `identity` | `App::Document*` | Open by identity (non-interactive) | +| `openDocumentInteractive` | — | `App::Document*` | Open via dialog (file picker / search) | +| `saveDocument` | `doc` | `bool` | Save document | +| `saveDocumentAs` | `doc, newIdentity` | `bool` | Save with new identity | +| `saveDocumentAsInteractive` | `doc` | `bool` | Save via dialog | + +Returns `nullptr` / `false` on failure or cancellation. + +## Virtual Methods with Defaults (optional overrides) + +### Capability Queries + +These default to `false`. Override to advertise capabilities that the +[CommandOrigin](./cpp-command-origin.md) commands check before enabling +menu items. + +| Method | Default | Enables | +|--------|---------|---------| +| `supportsRevisions()` | `false` | Commit / Pull / Push commands | +| `supportsBOM()` | `false` | BOM command | +| `supportsPartNumbers()` | `false` | Info command | +| `supportsAssemblies()` | `false` | (reserved for future use) | + +### Connection State + +| Method | Default | Purpose | +|--------|---------|---------| +| `connectionState()` | `Connected` | Current connection state | +| `connect()` | `return true` | Attempt to connect / authenticate | +| `disconnect()` | no-op | Disconnect from origin | + +### Extended PLM Operations + +These default to no-op / `false`. Override in PLM origins. + +| Method | Default | Purpose | +|--------|---------|---------| +| `commitDocument(doc)` | `false` | Create a versioned snapshot | +| `pullDocument(doc)` | `false` | Fetch latest from remote | +| `pushDocument(doc)` | `false` | Upload changes to remote | +| `showInfo(doc)` | no-op | Show metadata dialog | +| `showBOM(doc)` | no-op | Show Bill of Materials dialog | +| `syncProperties(doc)` | `true` | Push property changes to backend | + +## Signal + +```cpp +fastsignals::signal signalConnectionStateChanged; +``` + +Origins must emit this signal when their connection state changes. The +`OriginSelectorWidget` subscribes to it to update the toolbar icon +overlay (green = connected, red X = disconnected, warning = error). + +## Construction and Lifetime + +- `FileOrigin` is non-copyable (deleted copy constructor and assignment + operator). +- The protected default constructor prevents direct instantiation. +- Instances are owned by `OriginManager` via `std::unique_ptr`. +- Python origins are wrapped by `FileOriginPython` (see + [Python-C++ bridge](./cpp-file-origin-python.md)). + +## LocalFileOrigin + +`LocalFileOrigin` is the built-in concrete implementation that ships with +Kindred Create. It is always registered by `OriginManager` as the +`"local"` origin and serves as the universal fallback. + +### Behavior Summary + +| Method | Behavior | +|--------|----------| +| `ownsDocument` | Returns `true` if **no** object has a `SiloItemId` property | +| `documentIdentity` | Returns `doc->FileName` (full path) | +| `newDocument` | Delegates to `App::GetApplication().newDocument()` | +| `openDocument` | Delegates to `App::GetApplication().openDocument()` | +| `openDocumentInteractive` | Shows standard FreeCAD file-open dialog with format filters | +| `saveDocument` | Calls `doc->save()`, returns `false` if no filename set | +| `saveDocumentAs` | Calls `doc->saveAs()` | +| `saveDocumentAsInteractive` | Calls `Gui::Document::saveAs()` (shows save dialog) | + +### Ownership Detection Algorithm + +``` +for each object in document: + if object has property "SiloItemId": + return false ← owned by PLM, not local +return true ← local owns this document +``` + +This negative-match approach means `LocalFileOrigin` is the **universal +fallback** — it owns any document that no other origin claims. + +## See Also + +- [OriginManager](./cpp-origin-manager.md) — singleton registry that + manages origin instances +- [FileOriginPython](./cpp-file-origin-python.md) — bridge for + implementing origins in Python +- [CommandOrigin](./cpp-command-origin.md) — File menu commands that + dispatch to origins +- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar + dropdown for switching origins +- [Creating a Custom Origin (C++)](../guide/custom-origin-cpp.md) +- [Creating a Custom Origin (Python)](../guide/custom-origin-python.md) diff --git a/docs/src/reference/cpp-local-file-origin.md b/docs/src/reference/cpp-local-file-origin.md new file mode 100644 index 0000000000..ce7d5ab611 --- /dev/null +++ b/docs/src/reference/cpp-local-file-origin.md @@ -0,0 +1,218 @@ +# LocalFileOrigin + +`LocalFileOrigin` is the built-in, default `FileOrigin` implementation. It handles documents stored on the local filesystem without external tracking or synchronisation. + +- **Header:** `src/Gui/FileOrigin.h` +- **Source:** `src/Gui/FileOrigin.cpp` +- **Origin ID:** `"local"` +- **Display name:** `"Local Files"` (nickname `"Local"`) +- **Type:** `OriginType::Local` + +## Design principles + +1. **Identity is the file path.** A document's identity under LocalFileOrigin is its `FileName` property — the absolute path on disk. +2. **Ownership by exclusion.** LocalFileOrigin claims every document that is *not* claimed by a PLM origin. It detects this by scanning for the `SiloItemId` property; if no object in the document has it, the document is local. +3. **Always available.** LocalFileOrigin is created by `OriginManager` at startup and cannot be unregistered. It is the mandatory fallback origin. +4. **Thin delegation.** Most operations delegate directly to `App::GetApplication()` or `App::Document` methods. LocalFileOrigin adds no persistence layer of its own. + +## Identity and capabilities + +```cpp +std::string id() const override; // "local" +std::string name() const override; // "Local Files" +std::string nickname() const override; // "Local" +QIcon icon() const override; // theme icon "document-new" +OriginType type() const override; // OriginType::Local +``` + +### Capability flags + +All capability queries return `false`: + +| Method | Returns | Reason | +|--------|---------|--------| +| `tracksExternally()` | `false` | No external database or server | +| `requiresAuthentication()` | `false` | No login required | +| `supportsRevisions()` | `false` | No revision history | +| `supportsBOM()` | `false` | No Bill of Materials | +| `supportsPartNumbers()` | `false` | No part number system | +| `supportsAssemblies()` | `false` | No native assembly tracking | + +These flags control which toolbar buttons and menu items are enabled. With all flags false, the origin toolbar commands (Commit, Pull, Push, Info, BOM) are disabled for local documents. + +### Connection state + +LocalFileOrigin uses the base-class defaults — it is always `ConnectionState::Connected`: + +| Method | Behaviour | +|--------|-----------| +| `connectionState()` | Returns `ConnectionState::Connected` | +| `connect()` | Returns `true` immediately | +| `disconnect()` | No-op | +| `signalConnectionStateChanged` | Never emitted | + +## Ownership detection + +```cpp +bool ownsDocument(App::Document* doc) const override; +``` + +**Algorithm:** + +1. Return `false` if `doc` is null. +2. Iterate every object in the document (`doc->getObjects()`). +3. For each object, check `obj->getPropertyByName("SiloItemId")`. +4. If **any** object has the property, return `false` — the document belongs to a PLM origin. +5. If **no** object has it, return `true`. + +The constant `SiloItemId` is defined as a `static const char*` in `FileOrigin.cpp`. + +**Edge cases:** + +- An empty document (no objects) is owned by LocalFileOrigin. +- A document where only some objects have `SiloItemId` is still considered PLM-owned. +- Performance is O(n) where n is the number of objects in the document. + +### Ownership resolution order + +`OriginManager::findOwningOrigin()` checks non-local origins first, then falls back to LocalFileOrigin. This ensures PLM origins with specific ownership criteria take priority: + +``` +for each registered origin (excluding "local"): + if origin.ownsDocument(doc) → return that origin + +if localOrigin.ownsDocument(doc) → return localOrigin + +return nullptr +``` + +Results are cached in `OriginManager::_documentOrigins` for O(1) subsequent lookups. + +## Document identity + +```cpp +std::string documentIdentity(App::Document* doc) const override; +std::string documentDisplayId(App::Document* doc) const override; +``` + +Both return the same value: the document's `FileName` property (its absolute filesystem path). Returns an empty string if the document is null, not owned, or has never been saved. + +For PLM origins these would differ — `documentIdentity` might return a UUID while `documentDisplayId` returns a part number. For local files, the path serves both purposes. + +## Document operations + +### Creating documents + +```cpp +App::Document* newDocument(const std::string& name = "") override; +``` + +Delegates to `App::GetApplication().newDocument()`. If `name` is empty, defaults to `"Unnamed"`. The returned document has no `FileName` set until saved. + +### Opening documents + +```cpp +App::Document* openDocument(const std::string& identity) override; +App::Document* openDocumentInteractive() override; +``` + +**`openDocument`** — Non-interactive. Takes a file path, delegates to `App::GetApplication().openDocument()`. Returns `nullptr` if the path is empty or the file cannot be loaded. + +**`openDocumentInteractive`** — Shows a file dialog with the following behaviour: + +1. Builds a format filter list from FreeCAD's registered import types, with `.FCStd` first. +2. Shows `FileDialog::getOpenFileNames()` (multi-file selection). +3. For each selected file, uses `SelectModule::importHandler()` to determine the loader module. +4. Calls `Application::Instance->open()` with the `UserInitiatedOpenDocument` flag set. +5. Runs `checkPartialRestore()` and `checkRestoreError()` for validation. +6. Returns the last loaded document, or `nullptr` if the dialog was cancelled. + +### Saving documents + +```cpp +bool saveDocument(App::Document* doc) override; +bool saveDocumentAs(App::Document* doc, const std::string& newIdentity) override; +bool saveDocumentAsInteractive(App::Document* doc) override; +``` + +**`saveDocument`** — Requires that the document already has a `FileName` set. If the path is empty (never-saved document), returns `false`. Otherwise calls `doc->save()`. + +**`saveDocumentAs`** — Non-interactive. Calls `doc->saveAs(newIdentity)` with the given path. Updates the document's `FileName` property as a side effect. + +**`saveDocumentAsInteractive`** — Gets the `Gui::Document` wrapper and delegates to `guiDoc->saveAs()`, which shows a save dialog. Returns `false` if the GUI document cannot be found or the user cancels. + +**Save workflow pattern:** + +``` +First save: saveDocument() → false (no path) + → command layer calls saveDocumentAsInteractive() + → user picks path → saveAs() + +Later saves: saveDocument() → true (path exists) + → doc->save() +``` + +### PLM operations (not implemented) + +These inherited base-class methods are no-ops for LocalFileOrigin: + +| Method | Returns | Behaviour | +|--------|---------|-----------| +| `commitDocument(doc)` | `false` | No external commits | +| `pullDocument(doc)` | `false` | No remote to pull from | +| `pushDocument(doc)` | `false` | No remote to push to | +| `showInfo(doc)` | void | No-op | +| `showBOM(doc)` | void | No-op | + +## OriginManager integration + +### Lifecycle + +LocalFileOrigin is created in the `OriginManager` constructor via `ensureLocalOrigin()`: + +```cpp +auto localOrigin = std::make_unique(); +_origins[LOCAL_ORIGIN_ID] = std::move(localOrigin); +_currentOriginId = LOCAL_ORIGIN_ID; +``` + +It is set as the default current origin. It is the only origin that exists before any Python workbenches load. + +### Unregister protection + +`OriginManager::unregisterOrigin()` explicitly blocks removal of the local origin: + +```cpp +if (id == LOCAL_ORIGIN_ID) { + Base::Console().warning( + "OriginManager: Cannot unregister built-in local origin\n"); + return false; +} +``` + +### Preference persistence + +The current origin ID is saved to `User parameter:BaseApp/Preferences/General/Origin` under the key `CurrentOriginId`. On restart, if the saved ID is not `"local"` but the corresponding origin hasn't been registered yet, LocalFileOrigin remains the active origin until a Python workbench registers the saved origin. + +## Constants + +Defined in `FileOrigin.cpp`: + +```cpp +static const char* SILO_ITEM_ID_PROP = "SiloItemId"; +``` + +Defined in `OriginManager.cpp`: + +```cpp +static const char* LOCAL_ORIGIN_ID = "local"; +static const char* PREF_PATH = "User parameter:BaseApp/Preferences/General/Origin"; +static const char* PREF_CURRENT_ORIGIN = "CurrentOriginId"; +``` + +## See also + +- [FileOrigin Interface](./cpp-file-origin.md) — abstract base class +- [OriginManager](./cpp-origin-manager.md) — singleton registry and document resolution +- [FileOriginPython Bridge](./cpp-file-origin-python.md) — Python adapter for custom origins +- [SiloOrigin](./python-silo-origin.md) — PLM origin that contrasts with LocalFileOrigin diff --git a/docs/src/reference/cpp-origin-manager.md b/docs/src/reference/cpp-origin-manager.md new file mode 100644 index 0000000000..4283cd2d2f --- /dev/null +++ b/docs/src/reference/cpp-origin-manager.md @@ -0,0 +1,226 @@ +# OriginManager — Singleton Registry + +> **Header:** `src/Gui/OriginManager.h` +> **Implementation:** `src/Gui/OriginManager.cpp` +> **Namespace:** `Gui` + +`OriginManager` is the central singleton that tracks all registered +[FileOrigin](./cpp-file-origin.md) instances, maintains the user's current +origin selection, and resolves which origin owns a given document. + +## Lifecycle + +``` +Application startup + │ + ├─ OriginManager::instance() ← singleton created + │ ├─ ensureLocalOrigin() ← "local" origin always exists + │ └─ loadPreferences() ← restore last-used origin from prefs + │ + ├─ Python addons register origins ← e.g. SiloOrigin via FreeCADGui.addOrigin() + │ + ├─ ... application runs ... + │ + └─ OriginManager::destruct() ← saves prefs, deletes all origins +``` + +The `"local"` origin is created in the constructor and **cannot be +unregistered**. It serves as the universal fallback. + +## Singleton Access + +```cpp +// Get the instance (created on first call) +OriginManager* mgr = OriginManager::instance(); + +// Destroy at application shutdown +OriginManager::destruct(); +``` + +## Origin Registration + +### `registerOrigin(FileOrigin* origin) → bool` + +Register a new origin. The manager takes **ownership** via +`std::unique_ptr` — do not delete the pointer after registration. + +- Returns `false` and deletes the origin if: + - `origin` is `nullptr` + - `origin->id()` is empty + - An origin with the same ID is already registered +- Emits `signalOriginRegistered(id)` on success. + +```cpp +auto* cloud = new MyCloudOrigin(); +bool ok = OriginManager::instance()->registerOrigin(cloud); +// cloud is now owned by OriginManager — do not delete it +``` + +### `unregisterOrigin(const std::string& id) → bool` + +Remove and delete an origin by ID. + +- Returns `false` if: + - `id` is `"local"` (built-in, cannot be removed) + - No origin with that ID exists +- If the unregistered origin was the current origin, the current origin + automatically reverts to `"local"`. +- Emits `signalOriginUnregistered(id)` on success. + +### `getOrigin(const std::string& id) → FileOrigin*` + +Look up an origin by ID. Returns `nullptr` if not found. + +### `originIds() → std::vector` + +Returns all registered origin IDs. Order is the `std::map` key order +(alphabetical). + +## Current Origin Selection + +The "current origin" determines which backend is used for **new +documents** (File > New) and appears selected in the +[OriginSelectorWidget](./cpp-origin-selector-widget.md) toolbar dropdown. + +### `currentOrigin() → FileOrigin*` + +Returns the currently selected origin. Never returns `nullptr` — falls +back to `"local"` if the stored ID is somehow invalid. + +### `currentOriginId() → std::string` + +Returns the current origin's ID string. + +### `setCurrentOrigin(const std::string& id) → bool` + +Switch the current origin. Returns `false` if the ID is not registered. + +- Persists the selection to FreeCAD preferences at + `User parameter:BaseApp/Preferences/General/Origin/CurrentOriginId`. +- Emits `signalCurrentOriginChanged(id)` when the selection actually + changes (no signal if setting to the already-current origin). + +### `originForNewDocument() → FileOrigin*` + +Convenience method — returns `currentOrigin()`. Called by the File > New +command to determine which origin should handle new document creation. + +## Document-Origin Resolution + +When FreeCAD needs to know which origin owns a document (e.g., for +File > Save), it uses the resolution chain below. + +### `originForDocument(App::Document* doc) → FileOrigin*` + +Primary lookup method. Resolution order: + +1. **Explicit association** — check the `_documentOrigins` cache for a + prior `setDocumentOrigin()` call. +2. **Ownership detection** — call `findOwningOrigin(doc)` to scan all + origins. +3. **Cache the result** — store in `_documentOrigins` for future lookups. + +Returns `nullptr` only if no origin claims the document (should not +happen in practice since `LocalFileOrigin` is the universal fallback). + +### `findOwningOrigin(App::Document* doc) → FileOrigin*` + +Scans all registered origins by calling `ownsDocument(doc)` on each. + +**Algorithm:** + +``` +1. For each origin where id ≠ "local": + if origin->ownsDocument(doc): + return origin ← PLM/Cloud origin claims it + +2. if localOrigin->ownsDocument(doc): + return localOrigin ← fallback to local + +3. return nullptr ← no owner (shouldn't happen) +``` + +Non-local origins are checked **first** because they have specific +ownership criteria (e.g., presence of `SiloItemId` property). The local +origin uses negative-match logic (owns anything not claimed by others), +so it must be checked last to avoid false positives. + +### `setDocumentOrigin(doc, origin)` + +Explicitly associate a document with an origin. Used when creating new +documents to mark them with the origin that created them. + +- Passing `origin = nullptr` clears the association. +- Emits `signalDocumentOriginChanged(doc, originId)`. + +### `clearDocumentOrigin(doc)` + +Remove a document from the association cache. Called when a document is +closed to prevent stale pointers. + +## Signals + +All signals use the [fastsignals](https://github.com/nicktrandafil/fastsignals) +library (not Qt signals). + +| Signal | Parameters | When | +|--------|-----------|------| +| `signalOriginRegistered` | `const std::string& id` | After a new origin is registered | +| `signalOriginUnregistered` | `const std::string& id` | After an origin is removed | +| `signalCurrentOriginChanged` | `const std::string& id` | After the user switches the current origin | +| `signalDocumentOriginChanged` | `App::Document*, const std::string& id` | After a document-origin association changes | + +### Subscribers + +- **[OriginSelectorWidget](./cpp-origin-selector-widget.md)** subscribes + to `signalOriginRegistered`, `signalOriginUnregistered`, and + `signalCurrentOriginChanged` to rebuild the dropdown menu and update + the toolbar button. +- **[CommandOrigin](./cpp-command-origin.md)** commands query the manager + on each `isActive()` call to check document ownership and origin + capabilities. + +## Preference Persistence + +| Path | Key | Type | Default | +|------|-----|------|---------| +| `User parameter:BaseApp/Preferences/General/Origin` | `CurrentOriginId` | ASCII string | `"local"` | + +Loaded in the constructor, saved on destruction and on each +`setCurrentOrigin()` call. If the saved origin ID is not registered at +load time (e.g., the Silo addon hasn't loaded yet), the manager falls +back to `"local"` and will re-check when origins are registered later. + +## Memory Model + +``` +OriginManager (singleton) + │ + ├─ _origins: std::map> + │ ├─ "local" → LocalFileOrigin (always present) + │ ├─ "silo" → FileOriginPython (wraps Python SiloOrigin) + │ └─ ... → other registered origins + │ + ├─ _currentOriginId: std::string + │ + └─ _documentOrigins: std::map + ├─ doc1 → "silo" + ├─ doc2 → "local" + └─ ... (cache, cleared on document close) +``` + +- Origins are owned exclusively by the manager via `unique_ptr`. +- The document-origin map uses raw `App::Document*` pointers. Callers + **must** call `clearDocumentOrigin()` when a document is destroyed to + prevent dangling pointers. + +## See Also + +- [FileOrigin Interface](./cpp-file-origin.md) — the abstract interface + that registered origins implement +- [FileOriginPython](./cpp-file-origin-python.md) — bridge for + Python-implemented origins +- [CommandOrigin](./cpp-command-origin.md) — commands that dispatch to + origins via this manager +- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar UI + driven by this manager's signals diff --git a/docs/src/reference/cpp-origin-selector-widget.md b/docs/src/reference/cpp-origin-selector-widget.md new file mode 100644 index 0000000000..376bc253e3 --- /dev/null +++ b/docs/src/reference/cpp-origin-selector-widget.md @@ -0,0 +1,224 @@ +# OriginSelectorWidget + +`OriginSelectorWidget` is a toolbar dropdown that lets users switch between registered file origins (Local Files, Silo, etc.). It appears as the first item in the File toolbar across all workbenches. + +- **Header:** `src/Gui/OriginSelectorWidget.h` +- **Source:** `src/Gui/OriginSelectorWidget.cpp` +- **Base class:** `QToolButton` +- **Command ID:** `Std_Origin` + +## Widget appearance + +The button shows the current origin's nickname and icon. Clicking opens a dropdown menu listing all registered origins with a checkmark on the active one. + +``` +┌────────────────┐ +│ Local ▼ │ ← nickname + icon, InstantPopup mode +└────────────────┘ + +Dropdown: + ✓ Local ← checked = current origin + Kindred Silo ← disconnected origins show status overlay + ────────────────── + Manage Origins... ← opens OriginManagerDialog +``` + +### Size constraints + +| Property | Value | +|----------|-------| +| Popup mode | `QToolButton::InstantPopup` | +| Button style | `Qt::ToolButtonTextBesideIcon` | +| Minimum width | 80 px | +| Maximum width | 160 px | +| Size policy | `Preferred, Fixed` | + +## Lifecycle + +### Construction + +The constructor calls four setup methods in order: + +1. **`setupUi()`** — Configures the button, creates `m_menu` (QMenu) and `m_originActions` (exclusive QActionGroup). Connects the action group's `triggered` signal to `onOriginActionTriggered`. +2. **`connectSignals()`** — Subscribes to three OriginManager fastsignals via `scoped_connection` objects. +3. **`rebuildMenu()`** — Populates the menu from the current OriginManager state. +4. **`updateDisplay()`** — Sets the button text, icon, and tooltip to match the current origin. + +### Destruction + +The destructor calls `disconnectSignals()`, which explicitly disconnects the three `fastsignals::scoped_connection` members. The scoped connections would auto-disconnect on destruction regardless, but explicit disconnection prevents any signal delivery during teardown. + +## Signal connections + +The widget subscribes to three `OriginManager` fastsignals: + +| OriginManager signal | Widget handler | Response | +|----------------------|----------------|----------| +| `signalOriginRegistered` | `onOriginRegistered(id)` | Rebuild menu | +| `signalOriginUnregistered` | `onOriginUnregistered(id)` | Rebuild menu | +| `signalCurrentOriginChanged` | `onCurrentOriginChanged(id)` | Update display + menu checkmarks | + +Connections are stored as `fastsignals::scoped_connection` members for RAII lifetime management: + +```cpp +fastsignals::scoped_connection m_connRegistered; +fastsignals::scoped_connection m_connUnregistered; +fastsignals::scoped_connection m_connChanged; +``` + +## Menu population + +`rebuildMenu()` rebuilds the entire dropdown from scratch each time an origin is registered or unregistered: + +1. Clear `m_menu` and remove all actions from `m_originActions`. +2. Iterate `OriginManager::originIds()` (returns `std::vector` in registration order). +3. For each origin ID, create a `QAction` with: + - Icon from `iconForOrigin(origin)` (with connection-state overlay) + - Text from `origin->nickname()` + - Tooltip from `origin->name()` + - `setCheckable(true)`, checked if this is the current origin + - Data set to the origin ID string +4. Add a separator. +5. Add a "Manage Origins..." action with the `preferences-system` theme icon, connected to `onManageOriginsClicked`. + +Origins appear in the menu in the order returned by `OriginManager::originIds()`. The local origin is always first (registered at startup), followed by Python-registered origins in registration order. + +## Origin selection + +When the user clicks an origin in the dropdown, `onOriginActionTriggered(QAction*)` runs: + +1. Extract the origin ID from `action->data()`. +2. Look up the `FileOrigin` from OriginManager. +3. **Authentication gate:** If `origin->requiresAuthentication()` is true and the connection state is `Disconnected` or `Error`: + - Call `origin->connect()`. + - If connection fails, revert the menu checkmark to the previous origin and return without changing. +4. On success, call `OriginManager::setCurrentOrigin(originId)`. + +This means selecting a disconnected PLM origin triggers an automatic reconnection attempt. The user sees no change if the connection fails. + +## Icon overlays + +`iconForOrigin(FileOrigin*)` generates a display icon with optional connection-state indicators: + +| Connection state | Overlay | Position | +|------------------|---------|----------| +| `Connected` | None | — | +| `Connecting` | None (TODO: animated) | — | +| `Disconnected` | `dagViewFail` (8x8 px, red) | Bottom-right | +| `Error` | `Warning` (8x8 px, yellow) | Bottom-right | + +Overlays are only applied to origins where `requiresAuthentication()` returns `true`. Local origins never get overlays. The merge uses `BitmapFactoryInst::mergePixmap` with `BottomRight` placement. + +## Display updates + +`updateDisplay()` sets the button face to reflect the current origin: + +- **Text:** `origin->nickname()` (e.g. "Local", "Silo") +- **Icon:** Result of `iconForOrigin(origin)` (with possible overlay) +- **Tooltip:** `origin->name()` (e.g. "Local Files", "Kindred Silo") +- **No origin:** Text becomes "No Origin", icon and tooltip are cleared + +This method runs on construction, on `signalCurrentOriginChanged`, and after the Manage Origins dialog closes. + +## Command wrapper + +The widget is exposed to the command/toolbar system through two classes. + +### StdCmdOrigin + +Defined in `src/Gui/CommandStd.cpp` using `DEF_STD_CMD_AC(StdCmdOrigin)`: + +```cpp +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; +} +``` + +| Property | Value | +|----------|-------| +| Command ID | `Std_Origin` | +| Menu group | `File` | +| Icon | `folder` | +| `isActive()` | Always `true` | +| `activated()` | No-op (widget handles interaction) | + +`createAction()` returns an `OriginSelectorAction` instance. + +### OriginSelectorAction + +Defined in `src/Gui/Action.h` / `Action.cpp`. Bridges the command system and the widget: + +```cpp +void OriginSelectorAction::addTo(QWidget* widget) +{ + if (widget->inherits("QToolBar")) { + auto* selector = new OriginSelectorWidget(widget); + static_cast(widget)->addWidget(selector); + } else { + widget->addAction(action()); + } +} +``` + +When added to a `QToolBar`, it instantiates an `OriginSelectorWidget`. When added to a menu or other container, it falls back to adding a plain `QAction`. + +## Toolbar integration + +`StdWorkbench::setupToolBars()` in `src/Gui/Workbench.cpp` places the widget: + +```cpp +auto file = new ToolBarItem(root); +file->setCommand("File"); +*file << "Std_Origin" // ← origin selector (first) + << "Std_New" + << "Std_Open" + << "Std_Save"; +``` + +The widget appears as the **first item** in the File toolbar. Because `StdWorkbench` is the base workbench, this placement is inherited by all workbenches (Part Design, Assembly, Silo, etc.). + +A separate "Origin Tools" toolbar follows with PLM-specific commands: + +```cpp +auto originTools = new ToolBarItem(root); +originTools->setCommand("Origin Tools"); +*originTools << "Origin_Commit" << "Origin_Pull" << "Origin_Push" + << "Separator" + << "Origin_Info" << "Origin_BOM"; +``` + +These commands auto-disable based on the current origin's capability flags (`supportsRevisions`, `supportsBOM`, etc.). + +## Member variables + +```cpp +QMenu* m_menu; // dropdown menu +QActionGroup* m_originActions; // exclusive checkmark group +QAction* m_manageAction; // "Manage Origins..." action + +fastsignals::scoped_connection m_connRegistered; // signalOriginRegistered +fastsignals::scoped_connection m_connUnregistered; // signalOriginUnregistered +fastsignals::scoped_connection m_connChanged; // signalCurrentOriginChanged +``` + +## Behavioural notes + +- **Origin scope is global, not per-document.** Switching origin affects all subsequent New/Open/Save operations. Existing open documents retain their origin association via `OriginManager::_documentOrigins`. +- **Menu rebuilds are full rebuilds.** On any registration/unregistration event, the entire menu is cleared and rebuilt. This is simple and correct — origin counts are small (typically 2-3). +- **No document-switch tracking.** The widget does not respond to active document changes. The current origin is a user preference, not derived from the active document. +- **Thread safety.** All operations assume the Qt main thread. Signal emissions from OriginManager are synchronous. + +## See also + +- [FileOrigin Interface](./cpp-file-origin.md) — abstract base class +- [LocalFileOrigin](./cpp-local-file-origin.md) — built-in default origin +- [OriginManager](./cpp-origin-manager.md) — singleton registry and signal source +- [CommandOrigin](./cpp-command-origin.md) — PLM commands in the Origin Tools toolbar diff --git a/pixi.toml b/pixi.toml index 5d33ffc770..19416f777c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -191,5 +191,6 @@ install-debug = { cmd = ["cmake", "--install", "build/debug"]} install-release = { cmd = ["cmake", "--install", "build/release"]} test-debug = { cmd = ["ctest", "--test-dir", "build/debug"]} test-release = { cmd = ["ctest", "--test-dir", "build/release"]} +test-kindred = { cmd = ["python3", "tests/run_kindred_tests.py"]} freecad-debug = "build/debug/bin/FreeCAD" freecad-release = "build/release/bin/FreeCAD" diff --git a/tests/run_kindred_tests.py b/tests/run_kindred_tests.py new file mode 100644 index 0000000000..130c3d84e3 --- /dev/null +++ b/tests/run_kindred_tests.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Runner for Kindred Create addon tests. + +Tier 1 (pure logic) tests run with the system Python — no FreeCAD binary +required. Tier 2 (FreeCAD headless) tests are skipped unless FreeCADCmd +is found on PATH. + +Usage: + python3 tests/run_kindred_tests.py # Tier 1 only + python3 tests/run_kindred_tests.py --all # Tier 1 + Tier 2 (needs FreeCADCmd) + +Exit codes: + 0 All tests passed + 1 One or more tests failed +""" + +import os +import shutil +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def run_pure_tests() -> bool: + """Discover and run Tier 1 pure-logic tests. Returns True on success.""" + loader = unittest.TestLoader() + suite = loader.discover( + start_dir=str(REPO_ROOT / "tests"), + pattern="test_kindred_pure.py", + top_level_dir=str(REPO_ROOT / "tests"), + ) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + return result.wasSuccessful() + + +def run_freecad_tests() -> bool: + """Run Tier 2 tests inside FreeCADCmd. Returns True on success.""" + freecad_cmd = shutil.which("FreeCADCmd") + if not freecad_cmd: + # Check build directories + for candidate in ( + REPO_ROOT / "build" / "debug" / "bin" / "FreeCADCmd", + REPO_ROOT / "build" / "release" / "bin" / "FreeCADCmd", + ): + if candidate.exists(): + freecad_cmd = str(candidate) + break + + if not freecad_cmd: + print("\n[SKIP] FreeCADCmd not found — skipping Tier 2 tests") + return True # Not a failure, just skipped + + print(f"\n{'=' * 70}") + print(f"Tier 2: FreeCAD headless tests via {freecad_cmd}") + print(f"{'=' * 70}\n") + + # Tier 2 test modules registered via FreeCAD.__unit_test__ + test_modules = ["TestKindredCreate"] + all_ok = True + + for mod in test_modules: + print(f"--- Running {mod} ---") + proc = subprocess.run( + [freecad_cmd, "-t", mod], + cwd=str(REPO_ROOT), + timeout=120, + ) + if proc.returncode != 0: + all_ok = False + + return all_ok + + +def main(): + os.chdir(REPO_ROOT) + + run_all = "--all" in sys.argv + + print(f"{'=' * 70}") + print("Tier 1: Pure-logic tests (no FreeCAD binary required)") + print(f"{'=' * 70}\n") + + tier1_ok = run_pure_tests() + + tier2_ok = True + if run_all: + tier2_ok = run_freecad_tests() + + print(f"\n{'=' * 70}") + print(f" Tier 1 (pure): {'PASS' if tier1_ok else 'FAIL'}") + if run_all: + print(f" Tier 2 (FreeCAD): {'PASS' if tier2_ok else 'FAIL'}") + print(f"{'=' * 70}") + + sys.exit(0 if (tier1_ok and tier2_ok) else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_kindred_pure.py b/tests/test_kindred_pure.py new file mode 100644 index 0000000000..308f078440 --- /dev/null +++ b/tests/test_kindred_pure.py @@ -0,0 +1,560 @@ +"""Tier 1 — Pure-logic tests for Kindred Create addons. + +These tests exercise standalone functions from update_checker, datum_commands, +spreadsheet_commands, silo_commands, silo_start, and silo_origin WITHOUT +requiring a FreeCAD binary, running GUI, or Silo server. + +Run directly: python tests/test_kindred_pure.py +Via runner: python tests/run_kindred_tests.py +Via pixi: pixi run test-kindred +""" + +import math +import os +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +# --------------------------------------------------------------------------- +# Mock the FreeCAD ecosystem BEFORE importing any target modules. +# Every module under test does `import FreeCAD` at the top level. +# --------------------------------------------------------------------------- + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Build a FreeCAD mock with a working ParamGet stub +_param_store: dict = {} + + +class _MockParamGroup: + """Minimal stand-in for FreeCAD ParameterGrp.""" + + def __init__(self): + self._data: dict = {} + + # Getters + def GetBool(self, key, default=False): + return self._data.get(key, default) + + def GetString(self, key, default=""): + return self._data.get(key, default) + + def GetInt(self, key, default=0): + return self._data.get(key, default) + + # Setters + def SetBool(self, key, val): + self._data[key] = val + + def SetString(self, key, val): + self._data[key] = val + + def SetInt(self, key, val): + self._data[key] = val + + +def _mock_param_get(path): + if path not in _param_store: + _param_store[path] = _MockParamGroup() + return _param_store[path] + + +_fc = mock.MagicMock() +_fc.ParamGet = _mock_param_get +_fc.Console = mock.MagicMock() + +# Insert mocks for all FreeCAD-related modules +for mod_name in ( + "FreeCAD", + "FreeCADGui", + "Part", + "PySide", + "PySide.QtCore", + "PySide.QtGui", + "PySide.QtWidgets", + "PySide.QtWebEngineWidgets", +): + sys.modules.setdefault(mod_name, mock.MagicMock()) + +# Replace FreeCAD with our richer mock so ParamGet actually works +sys.modules["FreeCAD"] = _fc + +# Mock silo_client with the specific names that silo_commands imports +_silo_client_mock = mock.MagicMock() +_silo_client_mock.CATEGORY_NAMES = { + "EL": "Electrical", + "ME": "Mechanical", + "SW": "Software", +} +_silo_client_mock.parse_part_number = mock.MagicMock(return_value=("ME", "001")) +_silo_client_mock.get_category_folder_name = mock.MagicMock(return_value="ME_Mechanical") +_silo_client_mock.sanitize_filename = mock.MagicMock(side_effect=lambda s: s.replace(" ", "_")) +_silo_client_mock.SiloClient = mock.MagicMock() +_silo_client_mock.SiloSettings = type("SiloSettings", (), {}) +sys.modules["silo_client"] = _silo_client_mock +sys.modules["silo_client._ssl"] = mock.MagicMock() + +# Add addon source paths +sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create")) +sys.path.insert(0, str(_REPO_ROOT / "mods" / "ztools" / "ztools")) +sys.path.insert(0, str(_REPO_ROOT / "mods" / "silo" / "freecad")) + +# --------------------------------------------------------------------------- +# Now import the modules under test +# --------------------------------------------------------------------------- + +from update_checker import _parse_version, _should_check # noqa: E402 + + +# For datum_commands, the module registers Gui.addCommand at import time. +# We need Gui.addCommand to be a no-op mock (already is via MagicMock). +from ztools.commands.datum_commands import ( # noqa: E402 + DatumCreatorTaskPanel, + SelectionItem, +) + +from ztools.commands.spreadsheet_commands import column_to_index # noqa: E402 + +from silo_commands import _safe_float # noqa: E402 + +import silo_start # noqa: E402 +import silo_origin # noqa: E402 + + +# =================================================================== +# Test: update_checker._parse_version +# =================================================================== + + +class TestParseVersion(unittest.TestCase): + """Tests for update_checker._parse_version.""" + + def test_standard_version(self): + self.assertEqual(_parse_version("0.1.3"), (0, 1, 3)) + + def test_v_prefix(self): + self.assertEqual(_parse_version("v0.1.3"), (0, 1, 3)) + + def test_major_only_fails(self): + self.assertIsNone(_parse_version("1")) + + def test_two_part_fails(self): + self.assertIsNone(_parse_version("1.2")) + + def test_four_part_fails(self): + self.assertIsNone(_parse_version("1.2.3.4")) + + def test_empty_string(self): + self.assertIsNone(_parse_version("")) + + def test_latest_tag(self): + self.assertIsNone(_parse_version("latest")) + + def test_large_numbers(self): + self.assertEqual(_parse_version("v99.88.77"), (99, 88, 77)) + + def test_zero_version(self): + self.assertEqual(_parse_version("0.0.0"), (0, 0, 0)) + + def test_alpha_suffix_fails(self): + self.assertIsNone(_parse_version("v1.0.0-beta")) + + def test_comparison(self): + v1 = _parse_version("v0.1.0") + v2 = _parse_version("v0.2.0") + v3 = _parse_version("v1.0.0") + self.assertLess(v1, v2) + self.assertLess(v2, v3) + self.assertGreater(v3, v1) + + +# =================================================================== +# Test: update_checker._should_check +# =================================================================== + + +class TestShouldCheck(unittest.TestCase): + """Tests for update_checker._should_check interval logic.""" + + def _make_param(self, **kwargs): + p = _MockParamGroup() + for k, v in kwargs.items(): + p._data[k] = v + return p + + def test_default_enabled_no_timestamp(self): + p = self._make_param() + self.assertTrue(_should_check(p)) + + def test_disabled(self): + p = self._make_param(CheckEnabled=False) + self.assertFalse(_should_check(p)) + + def test_recent_check_skipped(self): + now = datetime.now(timezone.utc) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=now.isoformat(), + CheckIntervalDays=1, + ) + self.assertFalse(_should_check(p)) + + def test_old_check_triggers(self): + old = datetime.now(timezone.utc) - timedelta(days=2) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=old.isoformat(), + CheckIntervalDays=1, + ) + self.assertTrue(_should_check(p)) + + def test_zero_interval_always_checks(self): + now = datetime.now(timezone.utc) + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp=now.isoformat(), + CheckIntervalDays=0, + ) + self.assertTrue(_should_check(p)) + + def test_invalid_timestamp_triggers(self): + p = self._make_param( + CheckEnabled=True, + LastCheckTimestamp="not-a-date", + CheckIntervalDays=1, + ) + self.assertTrue(_should_check(p)) + + +# =================================================================== +# Test: datum_commands._match_score and _type_matches +# =================================================================== + + +class _StubPanel: + """Minimal stub to access DatumCreatorTaskPanel methods without GUI.""" + + _match_score = DatumCreatorTaskPanel._match_score + _type_matches = DatumCreatorTaskPanel._type_matches + + +class TestTypeMatches(unittest.TestCase): + """Tests for DatumCreatorTaskPanel._type_matches.""" + + def setUp(self): + self.p = _StubPanel() + + def test_exact_face(self): + self.assertTrue(self.p._type_matches("face", "face")) + + def test_exact_edge(self): + self.assertTrue(self.p._type_matches("edge", "edge")) + + def test_exact_vertex(self): + self.assertTrue(self.p._type_matches("vertex", "vertex")) + + def test_cylinder_matches_face(self): + self.assertTrue(self.p._type_matches("cylinder", "face")) + + def test_circle_matches_edge(self): + self.assertTrue(self.p._type_matches("circle", "edge")) + + def test_face_does_not_match_edge(self): + self.assertFalse(self.p._type_matches("face", "edge")) + + def test_vertex_does_not_match_face(self): + self.assertFalse(self.p._type_matches("vertex", "face")) + + def test_face_does_not_match_cylinder(self): + # face is NOT a cylinder (cylinder IS a face, not reverse) + self.assertFalse(self.p._type_matches("face", "cylinder")) + + def test_edge_does_not_match_circle(self): + self.assertFalse(self.p._type_matches("edge", "circle")) + + def test_unknown_matches_nothing(self): + self.assertFalse(self.p._type_matches("unknown", "face")) + self.assertFalse(self.p._type_matches("unknown", "edge")) + + +class TestMatchScore(unittest.TestCase): + """Tests for DatumCreatorTaskPanel._match_score.""" + + def setUp(self): + self.p = _StubPanel() + + def test_exact_single_face(self): + score = self.p._match_score(("face",), ("face",)) + self.assertEqual(score, 101) # 100 + 1 matched, exact count + + def test_exact_two_faces(self): + score = self.p._match_score(("face", "face"), ("face", "face")) + self.assertEqual(score, 102) # 100 + 2 + + def test_exact_three_vertices(self): + score = self.p._match_score( + ("vertex", "vertex", "vertex"), + ("vertex", "vertex", "vertex"), + ) + self.assertEqual(score, 103) + + def test_surplus_selection_lower_score(self): + exact = self.p._match_score(("face",), ("face",)) + surplus = self.p._match_score(("face", "edge"), ("face",)) + self.assertGreater(exact, surplus) + self.assertGreater(surplus, 0) + + def test_not_enough_items_zero(self): + score = self.p._match_score(("face",), ("face", "face")) + self.assertEqual(score, 0) + + def test_wrong_type_zero(self): + score = self.p._match_score(("vertex",), ("face",)) + self.assertEqual(score, 0) + + def test_empty_selection_zero(self): + score = self.p._match_score((), ("face",)) + self.assertEqual(score, 0) + + def test_cylinder_satisfies_face(self): + score = self.p._match_score(("cylinder",), ("face",)) + self.assertEqual(score, 101) + + def test_face_and_edge(self): + score = self.p._match_score(("face", "edge"), ("face", "edge")) + self.assertEqual(score, 102) + + def test_order_independence(self): + # edge,face should match face,edge requirement + score = self.p._match_score(("edge", "face"), ("face", "edge")) + self.assertEqual(score, 102) + + +# =================================================================== +# Test: datum_commands.SelectionItem properties +# =================================================================== + + +class TestSelectionItemProperties(unittest.TestCase): + """Tests for SelectionItem.display_name and type_icon.""" + + def _make_item(self, label, subname, geo_type): + obj = mock.MagicMock() + obj.Label = label + item = SelectionItem.__new__(SelectionItem) + item.obj = obj + item.subname = subname + item.shape = None + item.geo_type = geo_type + return item + + def test_display_name_with_subname(self): + item = self._make_item("Box", "Face1", "face") + self.assertEqual(item.display_name, "Box.Face1") + + def test_display_name_without_subname(self): + item = self._make_item("DatumPlane", "", "plane") + self.assertEqual(item.display_name, "DatumPlane") + + def test_type_icon_face(self): + item = self._make_item("X", "Face1", "face") + self.assertEqual(item.type_icon, "▢") + + def test_type_icon_vertex(self): + item = self._make_item("X", "Vertex1", "vertex") + self.assertEqual(item.type_icon, "•") + + def test_type_icon_unknown(self): + item = self._make_item("X", "", "unknown") + self.assertEqual(item.type_icon, "?") + + +# =================================================================== +# Test: spreadsheet_commands.column_to_index +# =================================================================== + + +class TestColumnToIndex(unittest.TestCase): + """Tests for spreadsheet column_to_index conversion.""" + + def test_a(self): + self.assertEqual(column_to_index("A"), 0) + + def test_b(self): + self.assertEqual(column_to_index("B"), 1) + + def test_z(self): + self.assertEqual(column_to_index("Z"), 25) + + def test_aa(self): + self.assertEqual(column_to_index("AA"), 26) + + def test_ab(self): + self.assertEqual(column_to_index("AB"), 27) + + def test_az(self): + self.assertEqual(column_to_index("AZ"), 51) + + def test_ba(self): + self.assertEqual(column_to_index("BA"), 52) + + +# =================================================================== +# Test: silo_commands._safe_float +# =================================================================== + + +class TestSafeFloat(unittest.TestCase): + """Tests for silo_commands._safe_float.""" + + def test_normal_float(self): + self.assertEqual(_safe_float(3.14), 3.14) + + def test_nan(self): + self.assertEqual(_safe_float(float("nan")), 0.0) + + def test_inf(self): + self.assertEqual(_safe_float(float("inf")), 0.0) + + def test_neg_inf(self): + self.assertEqual(_safe_float(float("-inf")), 0.0) + + def test_zero(self): + self.assertEqual(_safe_float(0.0), 0.0) + + def test_integer_passthrough(self): + self.assertEqual(_safe_float(42), 42) + + def test_string_passthrough(self): + self.assertEqual(_safe_float("hello"), "hello") + + +# =================================================================== +# Test: silo_start._get_silo_base_url +# =================================================================== + + +class TestSiloBaseUrl(unittest.TestCase): + """Tests for silo_start._get_silo_base_url URL construction.""" + + def setUp(self): + # Reset the param store for the silo pref group + _param_store.clear() + + def test_default_strips_api(self): + url = silo_start._get_silo_base_url() + # Default env var is http://localhost:8080/api -> strip /api + self.assertFalse(url.endswith("/api"), f"URL should not end with /api: {url}") + + def test_preference_with_api_suffix(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com/api") + url = silo_start._get_silo_base_url() + self.assertEqual(url, "https://silo.example.com") + + def test_preference_without_api_suffix(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com") + url = silo_start._get_silo_base_url() + self.assertEqual(url, "https://silo.example.com") + + def test_trailing_slash_stripped(self): + p = _mock_param_get(silo_start._PREF_GROUP) + p.SetString("ApiUrl", "https://silo.example.com/api/") + url = silo_start._get_silo_base_url() + # After rstrip("/") and strip /api + self.assertFalse(url.endswith("/")) + + +# =================================================================== +# Test: silo_origin.SiloOrigin capability constants +# =================================================================== + + +class TestSiloOriginCapabilities(unittest.TestCase): + """Tests for SiloOrigin constant methods (no network, no FreeCAD doc).""" + + def setUp(self): + self.origin = silo_origin.SiloOrigin() + + def test_id(self): + self.assertEqual(self.origin.id(), "silo") + + def test_name(self): + self.assertEqual(self.origin.name(), "Kindred Silo") + + def test_nickname(self): + self.assertEqual(self.origin.nickname(), "Silo") + + def test_type_is_plm(self): + self.assertEqual(self.origin.type(), 1) + + def test_tracks_externally(self): + self.assertTrue(self.origin.tracksExternally()) + + def test_requires_authentication(self): + self.assertTrue(self.origin.requiresAuthentication()) + + def test_supports_revisions(self): + self.assertTrue(self.origin.supportsRevisions()) + + def test_supports_bom(self): + self.assertTrue(self.origin.supportsBOM()) + + def test_supports_part_numbers(self): + self.assertTrue(self.origin.supportsPartNumbers()) + + def test_supports_assemblies(self): + self.assertTrue(self.origin.supportsAssemblies()) + + def test_custom_nickname(self): + origin = silo_origin.SiloOrigin(origin_id="prod", nickname="Production") + self.assertEqual(origin.id(), "prod") + self.assertEqual(origin.nickname(), "Production") + + +# =================================================================== +# Test: DatumCreatorTaskPanel.MODES integrity +# =================================================================== + + +class TestDatumModes(unittest.TestCase): + """Verify the MODES table is internally consistent.""" + + def test_all_modes_have_four_fields(self): + for mode in DatumCreatorTaskPanel.MODES: + self.assertEqual(len(mode), 4, f"Mode tuple wrong length: {mode}") + + def test_mode_ids_unique(self): + ids = [m[1] for m in DatumCreatorTaskPanel.MODES] + self.assertEqual(len(ids), len(set(ids)), "Duplicate mode IDs found") + + def test_categories_valid(self): + valid = {"plane", "axis", "point"} + for _, mode_id, _, category in DatumCreatorTaskPanel.MODES: + self.assertIn(category, valid, f"Invalid category for {mode_id}") + + def test_required_types_are_tuples(self): + for _, mode_id, req, _ in DatumCreatorTaskPanel.MODES: + self.assertIsInstance(req, tuple, f"required_types not a tuple: {mode_id}") + + def test_plane_modes_count(self): + planes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "plane"] + self.assertEqual(len(planes), 7) + + def test_axis_modes_count(self): + axes = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "axis"] + self.assertEqual(len(axes), 4) + + def test_point_modes_count(self): + points = [m for m in DatumCreatorTaskPanel.MODES if m[3] == "point"] + self.assertEqual(len(points), 5) + + +# =================================================================== + + +if __name__ == "__main__": + unittest.main()