Merge pull request 'docs: C++ API reference, signal architecture, and build integration' (#166) from docs/api-reference into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 29s
Build and Test / build (push) Has been cancelled

Reviewed-on: #166
This commit was merged in pull request #166.
This commit is contained in:
2026-02-10 14:41:51 +00:00
18 changed files with 3012 additions and 120 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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<void(ConnectionState)> signalConnectionStateChanged;
```
**OriginManager** (`src/Gui/OriginManager.h`):
```cpp
fastsignals::signal<void(const std::string&)> signalOriginRegistered;
fastsignals::signal<void(const std::string&)> signalOriginUnregistered;
fastsignals::signal<void(const std::string&)> signalCurrentOriginChanged;
fastsignals::signal<void(App::Document*, const std::string&)> signalDocumentOriginChanged;
```
**Gui::Document** (`src/Gui/Document.h`):
```cpp
mutable fastsignals::signal<void(const ViewProviderDocumentObject&)> signalNewObject;
mutable fastsignals::signal<void(const ViewProviderDocumentObject&)> signalDeletedObject;
mutable fastsignals::signal<void(const ViewProviderDocumentObject&,
const App::Property&)> signalChangedObject;
// ~9 more document-level signals
```
**Gui::Application** (`src/Gui/Application.h`):
```cpp
fastsignals::signal<void(const Gui::Document&, bool)> signalNewDocument;
fastsignals::signal<void(const Gui::Document&)> signalDeleteDocument;
fastsignals::signal<void(const Gui::ViewProvider&)> 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<void(const std::string&)> signalSomethingHappened;
```
Use `mutable` if the signal needs to fire from `const` methods (as `Gui::Document` does):
```cpp
mutable fastsignals::signal<void(int)> 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<fastsignals::scoped_connection> 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

View File

@@ -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

View File

@@ -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
$<$<COMPILE_LANGUAGE:CXX>:"PreCompiled.h">)
endif()
```
`PreCompiled.h` includes `<fastsignals/signal.h>`, 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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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<App::PropertyString*>(
obj->getPropertyByName(MY_TRACKING_PROP));
if (!prop) {
prop = static_cast<App::PropertyString*>(
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<App::PropertyString*>(
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<App::PropertyString*>(
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/Application.h> // App::GetApplication()
#include <App/Document.h> // App::Document
#include <Base/Console.h> // 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

View File

@@ -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<App::DocumentPy*>(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<FileOrigin> ← 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

View File

@@ -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<void(ConnectionState)> 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)

View File

@@ -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<LocalFileOrigin>();
_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

View File

@@ -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<std::string>`
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<string, std::unique_ptr<FileOrigin>>
│ ├─ "local" → LocalFileOrigin (always present)
│ ├─ "silo" → FileOriginPython (wraps Python SiloOrigin)
│ └─ ... → other registered origins
├─ _currentOriginId: std::string
└─ _documentOrigins: std::map<App::Document*, string>
├─ 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

View File

@@ -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<std::string>` 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<QToolBar*>(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

View File

@@ -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"

103
tests/run_kindred_tests.py Normal file
View File

@@ -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()

560
tests/test_kindred_pure.py Normal file
View File

@@ -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()