Compare commits
83 Commits
feat/solve
...
feat/gears
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a08e4e99 | ||
|
|
ab8519c272 | ||
|
|
2bf083609d | ||
|
|
76e448d0d7 | ||
|
|
51f7635ceb | ||
|
|
ecdc9c2067 | ||
|
|
04065e83f3 | ||
|
|
5a0be2804d | ||
|
|
1b38d7b24b | ||
|
|
18532e3bd7 | ||
|
|
f44aba7388 | ||
|
|
9b28feb8ca | ||
| fcb5c2b00f | |||
|
|
c8b0706a1d | ||
| a623f280da | |||
|
|
485f69f257 | ||
| a32f405990 | |||
|
|
d47c94af8d | ||
| b1d75fa237 | |||
|
|
d174ef7a8d | ||
| bc157ddb72 | |||
|
|
07a51aefb1 | ||
| 9f644294c5 | |||
|
|
983e211f12 | ||
|
|
1788b5778a | ||
| 418e947cbd | |||
| 4c9ff957e3 | |||
| 9aaf244179 | |||
| 69ccdbf742 | |||
| 4acd09171e | |||
| 7f909f166f | |||
| f69e0efec7 | |||
|
|
559a240799 | ||
| 7c85b2ad93 | |||
| 311d911cfa | |||
| 4ef8e64a7c | |||
| d94e8c8294 | |||
| 3550d916bd | |||
| 6e15b25134 | |||
| 82f2422285 | |||
| 314955c3ef | |||
| c7a7436e7b | |||
|
|
40dd8e09d7 | ||
| 1fd52ccf1c | |||
| e73c5fc750 | |||
| f652d6ccf8 | |||
|
|
14ee8c673f | ||
| a6a5db11f8 | |||
|
|
962b521f5c | ||
| 5c9212247a | |||
|
|
cf0cd3db7e | ||
| 50dc8c8ea1 | |||
|
|
b4835e1b05 | ||
| cf2fc82eac | |||
|
|
e5b07449d7 | ||
| 58d98c6d92 | |||
|
|
a10b9d9a9f | ||
|
|
d0e6d91642 | ||
|
|
05428f8a1c | ||
|
|
14f314e137 | ||
|
|
30c35af3be | ||
| 441cf9e826 | |||
|
|
c682c5d153 | ||
| f65a4a5e2b | |||
|
|
5d55f091d0 | ||
| a445275fd2 | |||
|
|
88efa2a6ae | ||
| 62f077a267 | |||
|
|
b6b0ebb4dc | ||
| a6d0427639 | |||
|
|
5883ac8a0d | ||
| f9b13710f3 | |||
|
|
39e78ee0a2 | ||
| 0f8fa0be86 | |||
|
|
acc255972d | ||
| 148bed59f6 | |||
|
|
b8cb7ca267 | ||
| ae576629c5 | |||
|
|
6e7d2b582e | ||
| 6d08161ae6 | |||
|
|
72e7e32133 | ||
| 805be1e213 | |||
|
|
4cf54caf7b |
@@ -47,7 +47,6 @@ body:
|
||||
description: Which part of Kindred Create is affected?
|
||||
options:
|
||||
- General / Core
|
||||
- ztools Workbench
|
||||
- Silo (Parts Database)
|
||||
- Theme / QSS
|
||||
- Assembly
|
||||
|
||||
@@ -29,7 +29,7 @@ body:
|
||||
attributes:
|
||||
label: Location
|
||||
description: Where should this documentation live? Link to existing pages if applicable.
|
||||
placeholder: e.g. docs/src/guide/ztools.md, or "new page under Reference"
|
||||
placeholder: e.g. docs/src/guide/assembly.md, or "new page under Reference"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ body:
|
||||
description: Which part of Kindred Create does this relate to?
|
||||
options:
|
||||
- General / Core
|
||||
- ztools Workbench
|
||||
- Silo (Parts Database)
|
||||
- Theme / QSS
|
||||
- Assembly
|
||||
|
||||
@@ -322,6 +322,7 @@ jobs:
|
||||
|
||||
env:
|
||||
BUILD_TAG: ${{ github.ref_name || inputs.tag }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
steps:
|
||||
@@ -386,6 +387,7 @@ jobs:
|
||||
'name': f'Kindred Create {tag}',
|
||||
'body': body,
|
||||
'prerelease': prerelease,
|
||||
'target_commitish': '${COMMIT_SHA}',
|
||||
}))
|
||||
")
|
||||
|
||||
|
||||
2
.gitignore
vendored
@@ -77,3 +77,5 @@ docs/book/
|
||||
|
||||
# To regenerate themed icons: python3 icons/retheme.py
|
||||
# icons/themed/ is tracked (committed) so CI builds include them
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
12
.gitmodules
vendored
@@ -10,11 +10,15 @@
|
||||
[submodule "src/Mod/AddonManager"]
|
||||
path = src/Mod/AddonManager
|
||||
url = https://github.com/FreeCAD/AddonManager.git
|
||||
[submodule "mods/ztools"]
|
||||
path = mods/ztools
|
||||
url = https://git.kindred-systems.com/forbes/ztools.git
|
||||
branch = main
|
||||
[submodule "mods/silo"]
|
||||
path = mods/silo
|
||||
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
||||
branch = main
|
||||
[submodule "mods/solver"]
|
||||
path = mods/solver
|
||||
url = https://git.kindred-systems.com/kindred/solver.git
|
||||
branch = main
|
||||
[submodule "mods/gears"]
|
||||
path = mods/gears
|
||||
url = https://git.kindred-systems.com/kindred/gears.git
|
||||
branch = main
|
||||
|
||||
285
CLAUDE.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# CLAUDE.md — Developer Context for Kindred Create
|
||||
|
||||
## Project Overview
|
||||
|
||||
Kindred Create is a fork of FreeCAD 1.0+ that adds integrated tooling for professional engineering workflows. It ships a context-aware UI system, two addon command sets (ztools and Silo), a Catppuccin Mocha dark theme, and a pluggable file origin layer on top of FreeCAD's parametric modeling core.
|
||||
|
||||
- **Kindred Create version:** 0.1.5
|
||||
- **FreeCAD base version:** 1.2.0
|
||||
- **License:** LGPL-2.1-or-later
|
||||
- **Repository:** `git.kindred-systems.com/kindred/create` (Gitea)
|
||||
- **Main branch:** `main`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone --recursive ssh://git@git.kindred-systems.com:2222/kindred/create.git
|
||||
cd create
|
||||
pixi run configure # CMake configure (debug by default)
|
||||
pixi run build # Build
|
||||
pixi run install # Install to build dir
|
||||
pixi run freecad # Launch
|
||||
pixi run test # Run C++ tests (ctest)
|
||||
pixi run test-kindred # Run Python/Kindred tests
|
||||
```
|
||||
|
||||
Build variants: append `-debug` or `-release` (e.g., `pixi run build-release`). See `CMakePresets.json` for platform-specific presets (Linux x86_64/aarch64, macOS Intel/ARM, Windows x64).
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
create/
|
||||
├── src/
|
||||
│ ├── App/ Core application (C++)
|
||||
│ ├── Base/ Base classes, type system, persistence (C++)
|
||||
│ ├── Gui/ GUI framework (C++)
|
||||
│ │ ├── EditingContext.h Editing context resolver (Kindred feature)
|
||||
│ │ ├── BreadcrumbToolBar.h Breadcrumb navigation widget (Kindred feature)
|
||||
│ │ ├── FileOrigin.h Abstract origin interface (Kindred feature)
|
||||
│ │ ├── OriginManager.h Origin lifecycle management
|
||||
│ │ ├── CommandOrigin.cpp Origin_Commit/Pull/Push/Info/BOM commands
|
||||
│ │ ├── ApplicationPy.h All FreeCADGui.* Python bindings
|
||||
│ │ ├── Application.h App signals (fastsignals)
|
||||
│ │ ├── Stylesheets/ QSS theme files
|
||||
│ │ └── PreferencePacks/ Preference configurations (build-time generated)
|
||||
│ ├── Mod/ FreeCAD modules (PartDesign, Assembly, Sketcher, etc.)
|
||||
│ │ └── Create/ Kindred Create module
|
||||
│ │ ├── Init.py Console bootstrap — loads addons
|
||||
│ │ ├── InitGui.py GUI bootstrap — loads addons, Silo setup, update checker
|
||||
│ │ ├── addon_loader.py Manifest-driven loader with dependency resolution
|
||||
│ │ └── kc_format.py .kc file format preservation
|
||||
│ └── 3rdParty/ Vendored dependencies
|
||||
│ ├── OndselSolver/ [submodule] Assembly constraint solver (forked)
|
||||
│ ├── FastSignals/ Signal/slot library (NOT Boost)
|
||||
│ └── GSL/ [submodule] Microsoft Guidelines Support Library
|
||||
├── mods/ Kindred addon modules
|
||||
│ ├── sdk/ Addon SDK — stable API contract (priority 0)
|
||||
│ ├── ztools/ [submodule] Command provider (priority 50)
|
||||
│ ├── silo/ [submodule] PLM workbench (priority 60)
|
||||
│ ├── solver/ [submodule] Assembly solver research (GNN-based)
|
||||
│ └── quicknav/ [submodule] Navigation addon
|
||||
├── docs/ mdBook documentation + architecture docs
|
||||
├── tests/ C++ unit tests (GoogleTest)
|
||||
├── package/ Packaging (debian/, rattler-build/)
|
||||
├── resources/ Branding, icons, desktop integration
|
||||
├── cMake/ CMake helper modules
|
||||
├── .gitea/workflows/ CI/CD pipelines
|
||||
├── CMakeLists.txt Root build configuration (CMake 3.22.0+)
|
||||
├── CMakePresets.json Platform build presets
|
||||
└── pixi.toml Pixi environment and build tasks
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
- **Primary:** CMake 3.22.0+ with Ninja generator
|
||||
- **Environment:** [Pixi](https://pixi.sh) (conda-forge) manages all dependencies
|
||||
- **Key deps:** Qt 6.8.x, Python 3.11.x, OpenCASCADE 7.8.x, PySide6, Boost, VTK, SMESH
|
||||
- **Presets:** `conda-linux-debug`, `conda-linux-release`, `conda-macos-debug`, `conda-macos-release`, `conda-windows-debug`, `conda-windows-release`
|
||||
- **Tasks summary:**
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| `pixi run configure` | CMake configure (debug) |
|
||||
| `pixi run build` | Build (debug) |
|
||||
| `pixi run install` | Install to build dir |
|
||||
| `pixi run freecad` | Launch FreeCAD |
|
||||
| `pixi run test` | C++ tests via ctest |
|
||||
| `pixi run test-kindred` | Python/Kindred test suite |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Signals — Use FastSignals, NOT Boost
|
||||
|
||||
```cpp
|
||||
#include <fastsignals/signal.h>
|
||||
// See src/Gui/Application.h:121-155 for signal declarations
|
||||
```
|
||||
|
||||
All signals in `src/Gui/` use `fastsignals::signal`. Never use Boost.Signals2.
|
||||
|
||||
### Type Checking Across Modules
|
||||
|
||||
Avoid header dependencies between `src/Gui/` and `src/Mod/` by using runtime type checks:
|
||||
|
||||
```cpp
|
||||
auto type = Base::Type::fromName("Sketcher::SketchObject");
|
||||
if (obj->isDerivedFrom(type)) { ... }
|
||||
```
|
||||
|
||||
### Python Bindings
|
||||
|
||||
All `FreeCADGui.*` functions go in `src/Gui/ApplicationPy.h` and `src/Gui/ApplicationPy.cpp`. Use `METH_VARARGS` only (no `METH_KEYWORDS` in this file). Do not create separate files for new Python bindings.
|
||||
|
||||
### Toolbar Visibility
|
||||
|
||||
Use `ToolBarItem::DefaultVisibility::Unavailable` to hide toolbars by default, then `ToolBarManager::setState(ForceAvailable)` to show them contextually. This pattern is proven by the Sketcher module.
|
||||
|
||||
The `appendToolbar` Python API accepts an optional 3rd argument: `"Visible"`, `"Hidden"`, or `"Unavailable"`.
|
||||
|
||||
### Editing Context System
|
||||
|
||||
The `EditingContextResolver` singleton (`src/Gui/EditingContext.h/.cpp`) drives the context-aware UI. It evaluates registered context definitions in priority order and activates the matching one, setting toolbar visibility and updating the `BreadcrumbToolBar`.
|
||||
|
||||
Built-in contexts: `sketcher.edit`, `assembly.edit`, `partdesign.feature`, `partdesign.body`, `assembly.idle`, `spreadsheet`, `empty_document`, `no_document`.
|
||||
|
||||
Python API:
|
||||
- `FreeCADGui.registerEditingContext()` — register a new context
|
||||
- `FreeCADGui.registerEditingOverlay()` — conditional toolbar overlay
|
||||
- `FreeCADGui.injectEditingCommands()` — add commands to existing contexts
|
||||
- `FreeCADGui.currentEditingContext()` — query active context
|
||||
- `FreeCADGui.refreshEditingContext()` — force re-evaluation
|
||||
|
||||
### Addon Loading
|
||||
|
||||
Addons in `mods/` are loaded by `src/Mod/Create/addon_loader.py`. Each addon provides a `package.xml` with `<kindred>` extensions declaring version bounds, load priority, and dependencies. The loader resolves via topological sort: **sdk** (0) -> **ztools** (50) -> **silo** (60).
|
||||
|
||||
A `<workbench>` tag in `package.xml` is required for `InitGui.py` to be loaded, even if no actual workbench is registered.
|
||||
|
||||
### Deferred Initialization
|
||||
|
||||
GUI setup uses `QTimer.singleShot` with staggered delays:
|
||||
- 500ms: `.kc` file format registration
|
||||
- 1500ms: Silo origin registration
|
||||
- 2000ms: Auth dock + ztools commands
|
||||
- 2500ms: Silo overlay
|
||||
- 3000ms: Silo first-start check
|
||||
- 4000ms: Activity panel
|
||||
- 10000ms: Update checker
|
||||
|
||||
### Unified Origin System
|
||||
|
||||
File operations (New, Open, Save, Commit, Pull, Push) are abstracted behind `FileOrigin` (`src/Gui/FileOrigin.h`). `LocalFileOrigin` handles local files; `SiloOrigin` (`mods/silo/freecad/silo_origin.py`) backs Silo-tracked documents. The active origin is selected automatically based on document properties (`SiloItemId`, `SiloPartNumber`).
|
||||
|
||||
## Submodules
|
||||
|
||||
| Path | Repository | Branch | Purpose |
|
||||
|------|------------|--------|---------|
|
||||
| `mods/ztools` | `git.kindred-systems.com/forbes/ztools` | `main` | Extended PartDesign/Assembly/Spreadsheet tools |
|
||||
| `mods/silo` | `git.kindred-systems.com/kindred/silo-mod` | `main` | PLM workbench (includes silo-client submodule) |
|
||||
| `mods/solver` | `git.kindred-systems.com/kindred/solver` | `main` | Assembly solver research (GNN-based) |
|
||||
| `mods/quicknav` | `git.kindred-systems.com/kindred/quicknav` | — | Navigation addon |
|
||||
| `src/3rdParty/OndselSolver` | `git.kindred-systems.com/kindred/solver` | — | Constraint solver (forked with NR fix) |
|
||||
| `src/3rdParty/GSL` | `github.com/microsoft/GSL` | — | Guidelines Support Library |
|
||||
| `src/Mod/AddonManager` | `github.com/FreeCAD/AddonManager` | — | FreeCAD addon manager |
|
||||
| `tests/lib` | `github.com/google/googletest` | — | C++ test framework |
|
||||
|
||||
Update a submodule:
|
||||
```bash
|
||||
cd mods/silo
|
||||
git checkout main && git pull
|
||||
cd ../..
|
||||
git add mods/silo
|
||||
git commit -m "chore: update silo submodule"
|
||||
```
|
||||
|
||||
Initialize all submodules: `git submodule update --init --recursive`
|
||||
|
||||
## Key Addon Modules
|
||||
|
||||
### ztools (`mods/ztools/`)
|
||||
|
||||
Command provider (NOT a workbench). Injects tools into PartDesign, Assembly, and Spreadsheet contexts via `_ZToolsManipulator` (WorkbenchManipulator) and `injectEditingCommands()`.
|
||||
|
||||
Commands: `ZTools_DatumCreator`, `ZTools_EnhancedPocket`, `ZTools_RotatedLinearPattern`, `ZTools_AssemblyLinearPattern`, `ZTools_AssemblyPolarPattern`, spreadsheet formatting (Bold, Italic, Underline, alignment, colors, QuickAlias).
|
||||
|
||||
Source: `mods/ztools/ztools/ztools/commands/` (note the double `ztools` nesting).
|
||||
|
||||
### Silo (`mods/silo/`)
|
||||
|
||||
PLM workbench with 14 commands for parts lifecycle management. Go REST API server + PostgreSQL + MinIO backend. FreeCAD client communicates via shared `silo-client` submodule.
|
||||
|
||||
Silo origin detection: `silo_origin.py:ownsDocument()` checks for `SiloItemId`/`SiloPartNumber` properties on the active document.
|
||||
|
||||
### SDK (`mods/sdk/`)
|
||||
|
||||
Stable API contract for addons. Provides wrappers for editing contexts, theme tokens (Catppuccin Mocha YAML palette), FileOrigin registration, and deferred dock panels. Addons should use `kindred_sdk.*` instead of `FreeCADGui.*` internals where possible.
|
||||
|
||||
## Theme
|
||||
|
||||
- **Canonical source:** `src/Gui/Stylesheets/KindredCreate.qss`
|
||||
- The PreferencePacks copy at `src/Gui/PreferencePacks/KindredCreate/KindredCreate.qss` is **generated at build time** via `configure_file()`. Only edit the Stylesheets copy.
|
||||
- Color palette: Catppuccin Mocha (26 colors + 14 semantic roles, defined in `mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml`)
|
||||
- Default preferences: `src/Gui/PreferencePacks/KindredCreate/KindredCreate.cfg`
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branch Names
|
||||
|
||||
`type/kebab-case-description`
|
||||
|
||||
Types: `feat/`, `fix/`, `chore/`, `docs/`, `refactor/`, `art/`
|
||||
|
||||
### Commit Messages
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
type(scope): lowercase imperative description
|
||||
```
|
||||
|
||||
| Prefix | Purpose |
|
||||
|--------|---------|
|
||||
| `feat:` | New feature |
|
||||
| `fix:` | Bug fix |
|
||||
| `chore:` | Maintenance, dependencies |
|
||||
| `docs:` | Documentation only |
|
||||
| `art:` | Icons, theme, visual assets |
|
||||
| `refactor:` | Code restructuring |
|
||||
|
||||
Scopes: `solver`, `sketcher`, `editing-context`, `toolbar`, `ztools`, `silo`, `breadcrumb`, `gui`, `assembly`, `ci`, `theme`, `quicknav`, or omitted.
|
||||
|
||||
### PR Workflow
|
||||
|
||||
1. Create a branch from `main`: `git checkout -b feat/my-feature main`
|
||||
2. Commit with conventional commit messages
|
||||
3. Push and open a PR against `main` via Gitea (or `tea pulls create`)
|
||||
4. CI runs automatically on PRs
|
||||
|
||||
### Code Style
|
||||
|
||||
- **C++:** clang-format (`.clang-format`), clang-tidy (`.clang-tidy`)
|
||||
- **Python:** black (100-char line length), pylint (`.pylintrc`)
|
||||
- **Pre-commit hooks:** `pre-commit install` (runs clang-format, black, trailing-whitespace, etc.)
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Build:** `.gitea/workflows/build.yml` — runs on pushes to `main` and on PRs
|
||||
- **Release:** `.gitea/workflows/release.yml` — triggered by `v*` tags, builds AppImage and .deb
|
||||
- **Platform:** Currently Linux x86_64 only in CI; other platforms have presets but no runners yet
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| `README.md` | Project overview, installation, usage |
|
||||
| `CONTRIBUTING.md` | Branch workflow, commit conventions, code style |
|
||||
| `docs/ARCHITECTURE.md` | Bootstrap flow, addon lifecycle, source layout |
|
||||
| `docs/COMPONENTS.md` | Feature inventory (ztools, Silo, origin, theme, icons) |
|
||||
| `docs/KNOWN_ISSUES.md` | Known issues, incomplete features, next steps |
|
||||
| `docs/INTEGRATION_PLAN.md` | 5-layer architecture, phase status |
|
||||
| `docs/CI_CD.md` | Build and release workflows |
|
||||
| `docs/KC_SPECIFICATION.md` | .kc file format specification |
|
||||
| `docs/UPSTREAM.md` | FreeCAD upstream merge strategy |
|
||||
| `docs/INTER_SOLVER.md` | Assembly solver integration |
|
||||
| `docs/BOM_MERGE.md` | BOM-Assembly bridge specification |
|
||||
|
||||
The `docs/src/` directory contains an mdBook site with detailed guides organized by topic (architecture, development, guide, reference, silo-server, solver).
|
||||
|
||||
## Issue Tracker
|
||||
|
||||
Issues are tracked on Gitea at `git.kindred-systems.com/kindred/create/issues`. Use the `tea` CLI for local interaction:
|
||||
|
||||
```bash
|
||||
tea issues # List open issues
|
||||
tea issues 123 # View issue #123 details
|
||||
tea pulls create # Create a PR
|
||||
```
|
||||
|
||||
## Known Issues and Pitfalls
|
||||
|
||||
1. **Silo auth not production-hardened** — LDAP/OIDC backends are coded but need infrastructure deployment
|
||||
2. **No unit tests** for ztools/Silo FreeCAD commands or Go backend
|
||||
3. **Assembly solver datum handling is minimal** — joints referencing datum planes/points may produce incorrect placement
|
||||
4. **PartDesign menu insertion fragility** — `_ZToolsPartDesignManipulator.modifyMenuBar()` inserts after `PartDesign_Boolean`; upstream renames break silently
|
||||
5. **`Silo_BOM` requires Silo-tracked document** — unregistered documents show a warning with no registration path
|
||||
6. **QSS edits** — only edit `src/Gui/Stylesheets/KindredCreate.qss`; the PreferencePacks copy is auto-generated
|
||||
@@ -53,7 +53,7 @@ project(KindredCreate)
|
||||
# Kindred Create version
|
||||
set(KINDRED_CREATE_VERSION_MAJOR "0")
|
||||
set(KINDRED_CREATE_VERSION_MINOR "1")
|
||||
set(KINDRED_CREATE_VERSION_PATCH "0")
|
||||
set(KINDRED_CREATE_VERSION_PATCH "5")
|
||||
set(KINDRED_CREATE_VERSION "${KINDRED_CREATE_VERSION_MAJOR}.${KINDRED_CREATE_VERSION_MINOR}.${KINDRED_CREATE_VERSION_PATCH}")
|
||||
|
||||
# Underlying FreeCAD version
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**An engineering-focused parametric 3D CAD platform built on FreeCAD 1.0+**
|
||||
|
||||
Kindred Create 0.1.0 | FreeCAD 1.2.0 base
|
||||
Kindred Create 0.1.5 | FreeCAD 1.2.0 base
|
||||
|
||||
[Website](https://www.kindred-systems.com/create) |
|
||||
[Downloads](https://git.kindred-systems.com/kindred/create/releases) |
|
||||
|
||||
@@ -116,6 +116,17 @@ mods/silo/ [submodule → silo-mod.git] FreeCAD workbench
|
||||
├── silo_commands.py Commands + FreeCADSiloSettings adapter
|
||||
└── silo_origin.py FileOrigin backend for Silo (via SDK)
|
||||
|
||||
src/Gui/SDK/ KCSDK C++ shared library (libKCSDK.so)
|
||||
├── KCSDKGlobal.h DLL export macros
|
||||
├── Types.h Plain C++ types (ContextDef, DockArea, PanelPersistence)
|
||||
├── IPanelProvider.h Abstract dock panel interface
|
||||
├── WidgetBridge.h/.cpp PySide QWidget <-> C++ QWidget* (via Gui::PythonWrapper)
|
||||
├── SDKRegistry.h/.cpp Singleton registry — contexts, panels, providers
|
||||
└── bindings/ pybind11 module (kcsdk.so)
|
||||
├── kcsdk_py.cpp Module definition — enums, functions, classes
|
||||
├── PyIPanelProvider.h Trampoline for Python subclassing
|
||||
└── PyProviderHolder.h GIL-safe forwarding wrapper
|
||||
|
||||
src/Gui/EditingContext.h/.cpp EditingContextResolver singleton + context registry
|
||||
src/Gui/BreadcrumbToolBar.h/.cpp Color-coded breadcrumb toolbar (Catppuccin Mocha)
|
||||
src/Gui/FileOrigin.h/.cpp FileOrigin base class + LocalFileOrigin
|
||||
|
||||
@@ -167,6 +167,52 @@ Theme colors are now centralized in the SDK's YAML palette (`mods/sdk/kindred_sd
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: KCSDK — C++-backed SDK module -- IN PROGRESS
|
||||
|
||||
**Goal:** Replace the pure-Python SDK wrappers with a C++ shared library (`libKCSDK.so`) and pybind11 bindings (`kcsdk.so`). This gives addons a stable, typed API with proper GIL safety and enables future C++ addon development without Python.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Python Addons (silo, future addons, ...)
|
||||
|
|
||||
kindred_sdk (mods/sdk/) <- convenience layer (try kcsdk, fallback FreeCADGui)
|
||||
|
|
||||
kcsdk.so (pybind11 module) <- C++ API bindings
|
||||
|
|
||||
KCSDK (C++ shared library) <- SDKRegistry + provider interfaces
|
||||
|
|
||||
FreeCADGui (EditingContextResolver, DockWindowManager, OriginManager, ...)
|
||||
```
|
||||
|
||||
**Sub-phases:**
|
||||
|
||||
| # | Issue | Status | Description |
|
||||
|---|-------|--------|-------------|
|
||||
| 1 | #350 | DONE | Scaffold KCSDK library + kcsdk pybind11 module |
|
||||
| 2 | #351 | DONE | Migrate editing context API to kcsdk |
|
||||
| 3 | #352 | DONE | Panel provider system (IPanelProvider) |
|
||||
| 4 | #353 | — | C++ theme engine |
|
||||
| 5 | #354 | — | Toolbar provider system (IToolbarProvider) |
|
||||
| 6 | #355 | — | Menu and action system |
|
||||
| 7 | #356 | — | Status bar provider + origin migration |
|
||||
| 8 | #357 | — | Deprecation cleanup + SDK v1.0.0 |
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `src/Gui/SDK/` — C++ library (KCSDKGlobal.h, Types.h, SDKRegistry, IPanelProvider, WidgetBridge)
|
||||
- `src/Gui/SDK/bindings/` — pybind11 module (kcsdk_py.cpp, PyIPanelProvider, PyProviderHolder)
|
||||
- `mods/sdk/kindred_sdk/` — Python wrappers with kcsdk/legacy fallback
|
||||
|
||||
**Design decisions:**
|
||||
|
||||
- **No Qt in public C++ API** — `Types.h` uses `std::string`, `std::vector`, `std::function`. Qt conversion happens internally in `SDKRegistry.cpp`.
|
||||
- **GIL-safe Python callables** — Python callbacks stored via `std::make_shared<py::object>` with `py::gil_scoped_acquire` before every invocation.
|
||||
- **PySide widget bridging** — `WidgetBridge::toQWidget()` converts PySide QWidget objects to C++ `QWidget*` via `Gui::PythonWrapper` (Shiboken).
|
||||
- **Provider pattern** — Interfaces like `IPanelProvider` enable addons to register factories. The registry calls `create_widget()` once and manages the lifecycle through `DockWindowManager`.
|
||||
|
||||
---
|
||||
|
||||
## Design decisions
|
||||
|
||||
1. **`Create::` namespace prefix.** All Kindred Create C++ features use this prefix to distinguish them from FreeCAD core.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- [Workbenches](./guide/workbenches.md)
|
||||
- [ztools](./guide/ztools.md)
|
||||
- [Silo](./guide/silo.md)
|
||||
- [Document Templates](./guide/templates.md)
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -29,6 +30,7 @@
|
||||
- [Build System](./development/build-system.md)
|
||||
- [Gui Module Build](./development/gui-build-integration.md)
|
||||
- [Package.xml Schema Extensions](./development/package-xml-schema.md)
|
||||
- [Writing an Addon](./development/writing-an-addon.md)
|
||||
|
||||
# Silo Server
|
||||
|
||||
@@ -49,6 +51,16 @@
|
||||
- [Solver Service](./silo-server/SOLVER.md)
|
||||
- [Roadmap](./silo-server/ROADMAP.md)
|
||||
|
||||
# Kindred Solver
|
||||
|
||||
- [Overview](./solver/overview.md)
|
||||
- [Expression DAG](./solver/expression-dag.md)
|
||||
- [Constraints](./solver/constraints.md)
|
||||
- [Solving Algorithms](./solver/solving.md)
|
||||
- [Diagnostics](./solver/diagnostics.md)
|
||||
- [Assembly Integration](./solver/assembly-integration.md)
|
||||
- [Writing a Custom Solver](./solver/writing-a-solver.md)
|
||||
|
||||
# Reference
|
||||
|
||||
- [Configuration](./reference/configuration.md)
|
||||
@@ -65,4 +77,5 @@
|
||||
- [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)
|
||||
- [KCSDK Python API](./reference/kcsdk-python.md)
|
||||
- [KCSolve Python API](./reference/kcsolve-python.md)
|
||||
|
||||
@@ -13,7 +13,7 @@ Kindred Create uses **CMake** for build configuration, **pixi** (conda-based) fo
|
||||
## CMake configuration
|
||||
|
||||
The root `CMakeLists.txt` defines:
|
||||
- **Kindred Create version:** `0.1.0` (via `KINDRED_CREATE_VERSION`)
|
||||
- **Kindred Create version:** `0.1.5` (via `KINDRED_CREATE_VERSION`)
|
||||
- **FreeCAD base version:** `1.0.0` (via `FREECAD_VERSION`)
|
||||
- CMake policy settings for compatibility
|
||||
- ccache auto-detection
|
||||
@@ -25,7 +25,7 @@ The root `CMakeLists.txt` defines:
|
||||
The version flows from CMake to Python via `configure_file()`:
|
||||
|
||||
```
|
||||
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.0")
|
||||
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.5")
|
||||
→ src/Mod/Create/version.py.in (template)
|
||||
→ build/*/Mod/Create/version.py (generated)
|
||||
→ update_checker.py (imports VERSION)
|
||||
|
||||
@@ -157,7 +157,7 @@ Edit only the canonical file in `Stylesheets/` — the preference pack copy is g
|
||||
Defined in the top-level `CMakeLists.txt` and injected as compiler definitions:
|
||||
|
||||
```cmake
|
||||
set(KINDRED_CREATE_VERSION "0.1.0")
|
||||
set(KINDRED_CREATE_VERSION "0.1.5")
|
||||
set(FREECAD_VERSION "1.0.0")
|
||||
|
||||
add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}")
|
||||
|
||||
283
docs/src/development/writing-an-addon.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Writing an Addon
|
||||
|
||||
This guide walks through creating a Kindred Create addon from scratch. Addons are Python packages in the `mods/` directory that extend Create with commands, panels, and UI modifications through the SDK.
|
||||
|
||||
## Addon structure
|
||||
|
||||
A minimal addon has this layout:
|
||||
|
||||
```
|
||||
mods/my-addon/
|
||||
├── package.xml # Manifest (required)
|
||||
├── Init.py # Console-phase bootstrap
|
||||
├── InitGui.py # GUI-phase bootstrap
|
||||
└── my_addon/
|
||||
├── __init__.py
|
||||
└── commands.py # Your commands
|
||||
```
|
||||
|
||||
## Step 1: Create the manifest
|
||||
|
||||
Every addon needs a `package.xml` with a `<kindred>` extension block. The `<workbench>` tag is required for `InitGui.py` to be loaded, even if your addon doesn't register a workbench.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1">
|
||||
<name>my-addon</name>
|
||||
<description>My custom addon for Kindred Create.</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="you@example.com">Your Name</maintainer>
|
||||
<license>LGPL-2.1-or-later</license>
|
||||
|
||||
<!-- Required for InitGui.py loading -->
|
||||
<workbench>
|
||||
<classname>MyAddonWorkbench</classname>
|
||||
</workbench>
|
||||
|
||||
<kindred>
|
||||
<min_create_version>0.1.5</min_create_version>
|
||||
<load_priority>70</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
</package>
|
||||
```
|
||||
|
||||
### Priority ranges
|
||||
|
||||
| Range | Use |
|
||||
|-------|-----|
|
||||
| 0-9 | SDK and core infrastructure |
|
||||
| 10-49 | Foundation addons |
|
||||
| 50-99 | Standard addons (ztools, silo) |
|
||||
| 100+ | Optional/user addons |
|
||||
|
||||
See [Package.xml Schema Extensions](./package-xml-schema.md) for the full schema.
|
||||
|
||||
## Step 2: Console bootstrap (Init.py)
|
||||
|
||||
`Init.py` runs during FreeCAD's console initialization, before the GUI exists. Use it for non-GUI setup.
|
||||
|
||||
```python
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintLog("my-addon: loaded (console)\n")
|
||||
```
|
||||
|
||||
## Step 3: GUI bootstrap (InitGui.py)
|
||||
|
||||
`InitGui.py` runs when the GUI is ready. This is where you register commands, contexts, panels, and overlays.
|
||||
|
||||
```python
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
FreeCAD.Console.PrintLog("my-addon: loaded (GUI)\n")
|
||||
|
||||
|
||||
def _deferred_setup():
|
||||
"""Register commands and UI after the main window is ready."""
|
||||
from my_addon import commands
|
||||
commands.register()
|
||||
|
||||
|
||||
from PySide.QtCore import QTimer
|
||||
QTimer.singleShot(2000, _deferred_setup)
|
||||
```
|
||||
|
||||
Deferred setup via `QTimer.singleShot()` avoids timing issues during startup. See [Create Module Bootstrap](../reference/create-module-bootstrap.md) for the full timer cascade.
|
||||
|
||||
## Step 4: Register commands
|
||||
|
||||
FreeCAD commands use `Gui.addCommand()`. This is a stable FreeCAD API and does not need SDK wrappers.
|
||||
|
||||
```python
|
||||
# my_addon/commands.py
|
||||
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
|
||||
class MyCommand:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "My Command",
|
||||
"ToolTip": "Does something useful",
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
FreeCAD.Console.PrintMessage("My command activated\n")
|
||||
|
||||
def IsActive(self):
|
||||
return FreeCAD.ActiveDocument is not None
|
||||
|
||||
|
||||
def register():
|
||||
FreeCADGui.addCommand("MyAddon_MyCommand", MyCommand())
|
||||
```
|
||||
|
||||
## Step 5: Inject into editing contexts
|
||||
|
||||
Use the SDK to add your commands to existing toolbar contexts, rather than creating a standalone workbench.
|
||||
|
||||
```python
|
||||
from kindred_sdk import inject_commands
|
||||
|
||||
# Add your command to the PartDesign body context toolbar
|
||||
inject_commands("partdesign.body", "PartDesign", ["MyAddon_MyCommand"])
|
||||
```
|
||||
|
||||
Built-in contexts you can inject into: `sketcher.edit`, `assembly.edit`, `partdesign.feature`, `partdesign.body`, `assembly.idle`, `spreadsheet`, `empty_document`, `no_document`.
|
||||
|
||||
## Step 6: Register a custom context
|
||||
|
||||
If your addon has its own editing mode, register a context to control which toolbars are visible.
|
||||
|
||||
```python
|
||||
from kindred_sdk import register_context
|
||||
|
||||
def _is_my_object_in_edit():
|
||||
import FreeCADGui
|
||||
doc = FreeCADGui.activeDocument()
|
||||
if doc and doc.getInEdit():
|
||||
obj = doc.getInEdit().Object
|
||||
return obj.isDerivedFrom("App::FeaturePython") and hasattr(obj, "MyAddonType")
|
||||
return False
|
||||
|
||||
register_context(
|
||||
"myaddon.edit",
|
||||
"Editing {name}",
|
||||
"#f9e2af", # Catppuccin yellow
|
||||
["MyAddonToolbar", "StandardViews"],
|
||||
_is_my_object_in_edit,
|
||||
priority=55,
|
||||
)
|
||||
```
|
||||
|
||||
## Step 7: Register a dock panel
|
||||
|
||||
For panels that live in the dock area (like Silo's database panels), use the SDK panel registration.
|
||||
|
||||
### Simple approach (recommended for most addons)
|
||||
|
||||
```python
|
||||
from kindred_sdk import register_dock_panel
|
||||
|
||||
def _create_my_panel():
|
||||
from PySide import QtWidgets
|
||||
widget = QtWidgets.QTreeWidget()
|
||||
widget.setHeaderLabels(["Name", "Value"])
|
||||
return widget
|
||||
|
||||
register_dock_panel(
|
||||
"MyAddonPanel", # unique object name
|
||||
"My Addon", # title bar text
|
||||
_create_my_panel,
|
||||
area="right",
|
||||
delay_ms=3000, # create 3 seconds after startup
|
||||
)
|
||||
```
|
||||
|
||||
### Advanced approach (IPanelProvider)
|
||||
|
||||
For full control over panel behavior, implement the `IPanelProvider` interface directly:
|
||||
|
||||
```python
|
||||
import kcsdk
|
||||
|
||||
class MyPanelProvider(kcsdk.IPanelProvider):
|
||||
def id(self):
|
||||
return "myaddon.inspector"
|
||||
|
||||
def title(self):
|
||||
return "Inspector"
|
||||
|
||||
def create_widget(self):
|
||||
from PySide import QtWidgets
|
||||
tree = QtWidgets.QTreeWidget()
|
||||
tree.setHeaderLabels(["Property", "Value"])
|
||||
return tree
|
||||
|
||||
def preferred_area(self):
|
||||
return kcsdk.DockArea.Left
|
||||
|
||||
def context_affinity(self):
|
||||
return "myaddon.edit" # only visible in your custom context
|
||||
|
||||
# Register and create
|
||||
kcsdk.register_panel(MyPanelProvider())
|
||||
kcsdk.create_panel("myaddon.inspector")
|
||||
```
|
||||
|
||||
## Step 8: Use theme colors
|
||||
|
||||
The SDK provides the Catppuccin Mocha palette for consistent theming.
|
||||
|
||||
```python
|
||||
from kindred_sdk import get_theme_tokens, load_palette
|
||||
|
||||
# Quick lookup
|
||||
tokens = get_theme_tokens()
|
||||
blue = tokens["blue"] # "#89b4fa"
|
||||
error = tokens["error"] # mapped from semantic role
|
||||
|
||||
# Full palette object
|
||||
palette = load_palette()
|
||||
palette.get("accent.primary") # semantic role lookup
|
||||
palette.get("mauve") # direct color lookup
|
||||
|
||||
# Format QSS templates
|
||||
qss = palette.format_qss("background: {base}; color: {text};")
|
||||
```
|
||||
|
||||
## Complete example
|
||||
|
||||
Putting it all together, here's a minimal addon that adds a command and a dock panel:
|
||||
|
||||
```
|
||||
mods/my-addon/
|
||||
├── package.xml
|
||||
├── Init.py
|
||||
├── InitGui.py
|
||||
└── my_addon/
|
||||
├── __init__.py
|
||||
└── commands.py
|
||||
```
|
||||
|
||||
**InitGui.py:**
|
||||
```python
|
||||
import FreeCAD
|
||||
|
||||
def _setup():
|
||||
from my_addon.commands import register
|
||||
from kindred_sdk import inject_commands, register_dock_panel
|
||||
|
||||
register()
|
||||
inject_commands("partdesign.body", "PartDesign", ["MyAddon_MyCommand"])
|
||||
|
||||
from PySide import QtWidgets
|
||||
register_dock_panel(
|
||||
"MyAddonPanel", "My Addon",
|
||||
lambda: QtWidgets.QLabel("Hello from my addon"),
|
||||
area="right", delay_ms=0,
|
||||
)
|
||||
|
||||
from PySide.QtCore import QTimer
|
||||
QTimer.singleShot(2500, _setup)
|
||||
```
|
||||
|
||||
## Key patterns
|
||||
|
||||
- **Use `kindred_sdk` wrappers** instead of `FreeCADGui.*` internals. The SDK handles fallback and error logging.
|
||||
- **Defer initialization** with `QTimer.singleShot()` to avoid startup timing issues.
|
||||
- **Declare `<dependency>sdk</dependency>`** in your manifest to ensure the SDK loads before your addon.
|
||||
- **Inject commands into existing contexts** rather than creating standalone workbenches. This gives users a unified toolbar experience.
|
||||
- **Use theme tokens** from the palette for colors. Don't hardcode hex values.
|
||||
|
||||
## Related
|
||||
|
||||
- [KCSDK Python API Reference](../reference/kcsdk-python.md)
|
||||
- [Package.xml Schema Extensions](./package-xml-schema.md)
|
||||
- [Create Module Bootstrap](../reference/create-module-bootstrap.md)
|
||||
@@ -53,6 +53,7 @@ The silo-mod repository was split from a monorepo into three repos: `silo-client
|
||||
| `Silo_TagProjects` | Multi-select dialog for assigning project tags to items |
|
||||
| `Silo_Rollback` | Select a previous revision and create a new revision from that point with optional comment |
|
||||
| `Silo_SetStatus` | Change revision lifecycle status: draft → review → released → obsolete |
|
||||
| `Silo_SaveAsTemplate` | Save a copy of the current document as a reusable [template](./templates.md) with metadata |
|
||||
|
||||
### Administration
|
||||
|
||||
@@ -129,9 +130,11 @@ mods/silo/
|
||||
├── freecad/
|
||||
│ ├── InitGui.py # SiloWorkbench registration
|
||||
│ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog)
|
||||
│ ├── silo_commands.py # 14 commands + dock widgets
|
||||
│ ├── silo_commands.py # 15 commands + dock widgets
|
||||
│ ├── silo_origin.py # FileOrigin backend
|
||||
│ ├── silo_start.py # Native start panel (database items, activity feed)
|
||||
│ ├── templates.py # Template discovery, filtering, injection
|
||||
│ ├── templates/ # System template .kc files + CLI injection tool
|
||||
│ └── resources/icons/ # 10 silo-*.svg icons
|
||||
├── silo-client/ # Shared Python API client (nested submodule)
|
||||
│ └── silo_client/
|
||||
|
||||
140
docs/src/guide/templates.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Document Templates
|
||||
|
||||
Templates let you create new parts and assemblies from pre-configured `.kc` files. Instead of starting from a bare `App::Part` or `Assembly::AssemblyObject`, a template can include predefined tree structures, jobs, metadata, and workbench-specific features.
|
||||
|
||||
## How templates work
|
||||
|
||||
A template is a normal `.kc` file with an extra `silo/template.json` descriptor inside the ZIP archive. When you select a template during **Silo > New**:
|
||||
|
||||
1. The template `.kc` is **copied** to the canonical file path
|
||||
2. `silo/template.json` and `silo/manifest.json` are **stripped** from the copy
|
||||
3. The document is **opened** in FreeCAD
|
||||
4. Silo properties (part number, item ID, revision, type) are **stamped** onto the root object
|
||||
5. On **save**, `kc_format.py` auto-creates a fresh manifest
|
||||
|
||||
The original template file is never modified.
|
||||
|
||||
## Using templates
|
||||
|
||||
### Creating a new item from a template
|
||||
|
||||
1. **Silo > New** (Ctrl+N)
|
||||
2. Select an **Item Type** (Part, Assembly, etc.)
|
||||
3. The **Template** dropdown shows templates matching the selected type and category
|
||||
4. Select a template (or leave as "No template" for a blank document)
|
||||
5. Fill in the remaining fields and click **Create**
|
||||
|
||||
The template combo updates automatically when you change the item type or category.
|
||||
|
||||
### Saving a document as a template
|
||||
|
||||
1. Open the document you want to use as a template
|
||||
2. **Silo > Save as Template**
|
||||
3. Fill in the template metadata:
|
||||
- **Name** — display name shown in the template picker (pre-filled from document label)
|
||||
- **Description** — what the template is for
|
||||
- **Item Types** — which types this template applies to (part, assembly, etc.)
|
||||
- **Categories** — category prefix filter (e.g. `F`, `M01`); leave empty for all categories
|
||||
- **Author** — pre-filled from your Silo login
|
||||
- **Tags** — comma-separated search tags
|
||||
4. Click **Save Template**
|
||||
5. Optionally upload to Silo for team sharing
|
||||
|
||||
The template is saved as a copy to your personal templates directory. The original document is unchanged.
|
||||
|
||||
## Template search paths
|
||||
|
||||
Templates are discovered from three locations, checked in order. Later paths shadow earlier ones by name (so you can override a system template with a personal one).
|
||||
|
||||
| Priority | Path | Purpose |
|
||||
|----------|------|---------|
|
||||
| 1 (lowest) | `mods/silo/freecad/templates/` | System templates shipped with the addon |
|
||||
| 2 | `~/.local/share/FreeCAD/Templates/` | Personal templates (sister to `Macro/`) |
|
||||
| 3 (highest) | `~/projects/templates/` | Org-shared project templates |
|
||||
|
||||
The personal templates directory (`Templates/`) is created automatically when you first save a template. It lives alongside the `Macro/` directory in your FreeCAD user data.
|
||||
|
||||
## Template descriptor schema
|
||||
|
||||
The `silo/template.json` file inside the `.kc` ZIP has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"template_version": "1.0",
|
||||
"name": "Sheet Metal Part",
|
||||
"description": "Body with SheetMetal base feature and laser-cut job",
|
||||
"item_types": ["part"],
|
||||
"categories": [],
|
||||
"icon": "sheet-metal",
|
||||
"author": "Kindred Systems",
|
||||
"tags": ["sheet metal", "fabrication"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `template_version` | string | yes | Schema version, currently `"1.0"` |
|
||||
| `name` | string | yes | Display name in the template picker |
|
||||
| `description` | string | no | Human-readable purpose |
|
||||
| `item_types` | string[] | yes | Controls visibility — `["part"]`, `["assembly"]`, or both |
|
||||
| `categories` | string[] | no | Category prefix filter. Empty array means all categories |
|
||||
| `icon` | string | no | Icon identifier (reserved for future use) |
|
||||
| `author` | string | no | Template author |
|
||||
| `tags` | string[] | no | Searchable metadata tags |
|
||||
|
||||
### Filtering rules
|
||||
|
||||
- **item_types**: The template only appears when the selected item type is in this list
|
||||
- **categories**: If non-empty, the template only appears when the selected category starts with one of the listed prefixes. An empty list means the template is available for all categories
|
||||
|
||||
## Creating templates from the command line
|
||||
|
||||
The `inject_template.py` CLI tool can inject `silo/template.json` into any `.kc` file:
|
||||
|
||||
```bash
|
||||
cd mods/silo/freecad/templates/
|
||||
|
||||
# Create a template from an existing .kc file
|
||||
python inject_template.py my-part.kc "My Custom Part" \
|
||||
--type part \
|
||||
--description "Part with custom features" \
|
||||
--author "Your Name" \
|
||||
--tag "custom"
|
||||
|
||||
# Assembly template
|
||||
python inject_template.py my-assembly.kc "My Assembly" \
|
||||
--type assembly \
|
||||
--description "Assembly with predefined joint groups"
|
||||
|
||||
# Template with category filtering
|
||||
python inject_template.py sheet-metal.kc "Sheet Metal Part" \
|
||||
--type part \
|
||||
--category S \
|
||||
--category X \
|
||||
--tag "sheet metal" \
|
||||
--tag "fabrication"
|
||||
```
|
||||
|
||||
## Module structure
|
||||
|
||||
```
|
||||
mods/silo/freecad/
|
||||
├── templates.py # Discovery, filtering, injection helpers
|
||||
├── templates/
|
||||
│ └── inject_template.py # CLI tool for injecting template.json
|
||||
├── schema_form.py # Template combo in New Item form
|
||||
└── silo_commands.py # SaveAsTemplateDialog, Silo_SaveAsTemplate,
|
||||
# SiloSync.create_document_from_template()
|
||||
```
|
||||
|
||||
### Key functions
|
||||
|
||||
| Function | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| `discover_templates()` | `templates.py` | Scan search paths for `.kc` files with `silo/template.json` |
|
||||
| `filter_templates()` | `templates.py` | Filter by item type and category prefix |
|
||||
| `inject_template_json()` | `templates.py` | Inject/replace `silo/template.json` in a `.kc` ZIP |
|
||||
| `get_default_template_dir()` | `templates.py` | Returns `{userAppData}/Templates/`, creating if needed |
|
||||
| `get_search_paths()` | `templates.py` | Returns the 3-tier search path list |
|
||||
| `create_document_from_template()` | `silo_commands.py` | Copy template, strip identity, stamp Silo properties |
|
||||
| `_clean_template_zip()` | `silo_commands.py` | Strip `silo/template.json` and `silo/manifest.json` from a copy |
|
||||
441
docs/src/quicknav/SPEC.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# QuickNav — Keyboard Navigation Addon Specification
|
||||
|
||||
**Addon name:** QuickNav
|
||||
**Type:** Pure Python FreeCAD addon (no C++ required)
|
||||
**Compatibility:** FreeCAD 1.0+, Kindred Create 0.1+
|
||||
**Location:** `mods/quicknav/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
QuickNav provides keyboard-driven command access for FreeCAD and Kindred Create. It replaces mouse-heavy toolbar navigation with a numbered key system organized by workbench and command grouping. The addon is activated by loading its workbench and toggled on/off with the `0` key.
|
||||
|
||||
### Design Goals
|
||||
|
||||
- Numbers `1-9` execute commands within the active command grouping
|
||||
- `Shift+1-9` switches command grouping within the active workbench
|
||||
- `Ctrl+1-9` switches workbench context
|
||||
- All groupings and workbenches are ordered by most-recently-used (MRU) history
|
||||
- History is unlimited internally, top 9 shown, remainder scrollable/clickable
|
||||
- Mouse interaction remains fully functional — QuickNav is purely additive
|
||||
- Configuration persisted via `FreeCAD.ParamGet()`
|
||||
|
||||
---
|
||||
|
||||
## 2. Terminology
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **Workbench** | A FreeCAD workbench (Sketcher, PartDesign, Assembly, etc.). Fixed assignment to Ctrl+N slots. |
|
||||
| **Command Grouping** | A logical group of commands within a workbench, mapped from existing FreeCAD toolbar groupings. Max 9 per tier. |
|
||||
| **Active Grouping** | The left-most visible grouping in the navigation bar. Its commands are accessible via `1-9`. |
|
||||
| **Navigation Bar** | Bottom toolbar displaying the current state: active workbench, groupings, and numbered commands. |
|
||||
| **MRU Stack** | Most-recently-used ordering. Position 0 = currently active, 1 = previously active, etc. |
|
||||
| **Tier** | When a workbench has >9 command groupings, they are split: Tier 1 (most common 9), Tier 2 (next 9). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Bindings
|
||||
|
||||
### 3.1 Mode Toggle
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `0` | Toggle QuickNav on/off. When off, all QuickNav key interception is disabled and the navigation bar hides. |
|
||||
|
||||
### 3.2 Command Execution
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `1-9` | Execute the Nth command in the active grouping. If the command is auto-executable (e.g., Pad after closed sketch), execute immediately. Otherwise, enter tool mode (same as clicking the toolbar button). |
|
||||
|
||||
### 3.3 Grouping Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Shift+1-9` | Switch to the Nth command grouping (MRU ordered) within the current workbench. The newly activated grouping moves to position 0 in the MRU stack. |
|
||||
| `Shift+Left/Right` | Scroll through groupings beyond the visible 9. |
|
||||
|
||||
### 3.4 Workbench Navigation
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl+1` | Sketcher |
|
||||
| `Ctrl+2` | Part Design |
|
||||
| `Ctrl+3` | Assembly |
|
||||
| `Ctrl+4` | Spreadsheet |
|
||||
| `Ctrl+5` | TechDraw |
|
||||
| `Ctrl+6-9` | User-configurable / additional workbenches |
|
||||
|
||||
Switching workbench via `Ctrl+N` also restores that workbench's last-active command grouping.
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation Bar
|
||||
|
||||
The navigation bar is a `QToolBar` positioned at the bottom of the main window (replacing or sitting alongside FreeCAD's default bottom toolbar area).
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ [WB: Sketcher] │ ❶ Primitives │ ② Constraints │ ③ Dimensions │ ◀▶ │
|
||||
│ │ 1:Line 2:Rect 3:Circle 4:Arc 5:Point 6:Slot ... │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Left section:** Current workbench name with Ctrl+N hint
|
||||
- **Middle section (top row):** Command groupings, MRU ordered. Active grouping is ❶ (filled circle), others are ②③ etc. Scrollable horizontally if >9.
|
||||
- **Middle section (bottom row):** Commands within the active grouping, numbered 1-9
|
||||
- **Right section:** Scroll arrows for overflow groupings
|
||||
|
||||
### 4.2 Visual States
|
||||
|
||||
- **Active grouping:** Bold text, filled number badge, Catppuccin Mocha `blue` (#89b4fa) accent
|
||||
- **Inactive groupings:** Normal text, outlined number badge, `surface1` (#45475a) text
|
||||
- **Hovered command:** `surface2` (#585b70) background highlight
|
||||
- **Active command (tool in use):** `green` (#a6e3a1) underline indicator
|
||||
|
||||
### 4.3 Mouse Interaction
|
||||
|
||||
- Click any grouping to activate it (equivalent to Shift+N)
|
||||
- Click any command to execute it (equivalent to pressing N)
|
||||
- Scroll wheel on grouping area to cycle through overflow groupings
|
||||
- Click scroll arrows to page through overflow
|
||||
|
||||
---
|
||||
|
||||
## 5. Workbench Command Groupings
|
||||
|
||||
Each workbench's existing FreeCAD toolbars map to command groupings. Where a workbench has >9 toolbars, split into Tier 1 (default, most common) and Tier 2 (accessible via scrolling or `Shift+Left/Right`).
|
||||
|
||||
### 5.1 Sketcher (Ctrl+1)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Primitives | Line, Rectangle, Circle, Arc, Point, Slot, B-Spline, Polyline, Ellipse |
|
||||
| Constraints | Coincident, Horizontal, Vertical, Parallel, Perpendicular, Tangent, Equal, Symmetric, Block |
|
||||
| Dimensions | Distance, Horizontal Distance, Vertical Distance, Radius, Diameter, Angle, Lock, Constrain Refraction |
|
||||
| Construction | Toggle Construction, External Geometry, Carbon Copy, Offset, Trim, Extend, Split |
|
||||
| Tools | Mirror, Array (Linear), Array (Polar), Move, Rotate, Scale, Close Shape, Connect Edges |
|
||||
|
||||
### 5.2 Part Design (Ctrl+2)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Additive | Pad, Revolution, Additive Loft, Additive Pipe, Additive Helix, Additive Box, Additive Cylinder, Additive Sphere, Additive Cone |
|
||||
| Subtractive | Pocket, Hole, Groove, Subtractive Loft, Subtractive Pipe, Subtractive Helix, Subtractive Box, Subtractive Cylinder, Subtractive Sphere |
|
||||
| Datums | New Sketch, Datum Plane, Datum Line, Datum Point, Shape Binder, Sub-Shape Binder, ZTools Datum Creator, ZTools Datum Manager |
|
||||
| Transformations | Mirrored, Linear Pattern, Polar Pattern, MultiTransform, ZTools Rotated Linear Pattern |
|
||||
| Modeling | Fillet, Chamfer, Draft, Thickness, Boolean, ZTools Enhanced Pocket |
|
||||
|
||||
### 5.3 Assembly (Ctrl+3)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Components | Insert Component, Create Part, Create Assembly, Ground, BOM |
|
||||
| Joints | Fixed, Revolute, Cylindrical, Slider, Ball, Planar, Distance, Angle, Parallel |
|
||||
| Patterns | ZTools Linear Pattern, ZTools Polar Pattern |
|
||||
|
||||
### 5.4 Spreadsheet (Ctrl+4)
|
||||
|
||||
| Grouping | Commands (1-9) |
|
||||
|----------|---------------|
|
||||
| Editing | Merge Cells, Split Cell, Alias, Import CSV, Export CSV |
|
||||
| Formatting | Bold, Italic, Underline, Align Left, Align Center, Align Right, BG Color, Text Color, Quick Alias |
|
||||
|
||||
### 5.5 TechDraw (Ctrl+5)
|
||||
|
||||
Groupings derived from TechDraw's existing toolbars at runtime.
|
||||
|
||||
> **Note:** The exact command lists above are initial defaults. The addon discovers available commands from each workbench's toolbar structure at activation time and falls back to these defaults only if discovery fails.
|
||||
|
||||
---
|
||||
|
||||
## 6. MRU History Behavior
|
||||
|
||||
### 6.1 Grouping History (per workbench)
|
||||
|
||||
Each workbench maintains its own grouping MRU stack.
|
||||
|
||||
- When a grouping is activated (via `Shift+N` or mouse click), it moves to position 0
|
||||
- The previously active grouping moves to position 1, everything else shifts down
|
||||
- Position 0 is always the active grouping (already selected, shown leftmost)
|
||||
- `Shift+1` is a no-op (already active), `Shift+2` activates the previous grouping, etc.
|
||||
|
||||
### 6.2 Workbench History
|
||||
|
||||
- Workbenches have fixed Ctrl+N assignments (not MRU ordered)
|
||||
- However, each workbench remembers its last-active grouping
|
||||
- Switching to a workbench restores its last-active grouping as position 0
|
||||
|
||||
### 6.3 Persistence
|
||||
|
||||
Stored in `FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/QuickNav")`:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `Enabled` | Bool | Whether QuickNav is currently active |
|
||||
| `GroupHistory/<Workbench>` | String | Semicolon-delimited list of grouping names in MRU order |
|
||||
| `LastGrouping/<Workbench>` | String | Name of the last-active grouping per workbench |
|
||||
| `CustomSlots/Ctrl6` through `Ctrl9` | String | Workbench names for user-configurable slots |
|
||||
|
||||
---
|
||||
|
||||
## 7. Auto-Execution Logic
|
||||
|
||||
When a command is invoked via number key, QuickNav checks if the command can be auto-executed:
|
||||
|
||||
### 7.1 Auto-Execute Conditions
|
||||
|
||||
A command auto-executes (runs and completes without entering a persistent mode) when:
|
||||
|
||||
1. **Pad/Pocket after closed sketch:** If the active body has a sketch that was just closed (sketch edit mode exited with a closed profile), pressing the Pad or Pocket command key creates the feature with default parameters. The task panel still opens for parameter adjustment.
|
||||
2. **Boolean operations:** If exactly two bodies/shapes are selected, boolean commands execute with defaults.
|
||||
3. **Constraint application:** If appropriate geometry is pre-selected in Sketcher, constraint commands apply immediately.
|
||||
|
||||
### 7.2 Mode-Entry (Default)
|
||||
|
||||
All other commands enter their standard FreeCAD tool mode — identical to clicking the toolbar button. The user interacts with the 3D view and/or task panel as normal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Event Handling
|
||||
|
||||
### 8.1 Event Filter Architecture
|
||||
|
||||
```python
|
||||
class QuickNavEventFilter(QObject):
|
||||
"""Installed on FreeCAD's main window via installEventFilter().
|
||||
|
||||
Intercepts KeyPress events when QuickNav is active.
|
||||
Passes through all events when QuickNav is inactive.
|
||||
"""
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() != QEvent.KeyPress:
|
||||
return False
|
||||
if not self._active:
|
||||
return False
|
||||
|
||||
# Don't intercept when a text input widget has focus
|
||||
focused = QApplication.focusWidget()
|
||||
if isinstance(focused, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox)):
|
||||
return False
|
||||
|
||||
# Don't intercept when task panel input fields are focused
|
||||
if self._is_task_panel_input(focused):
|
||||
return False
|
||||
|
||||
key = event.key()
|
||||
modifiers = event.modifiers()
|
||||
|
||||
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
|
||||
self.toggle_active()
|
||||
return True
|
||||
|
||||
if key >= Qt.Key_1 and key <= Qt.Key_9:
|
||||
n = key - Qt.Key_0
|
||||
if modifiers == Qt.ControlModifier:
|
||||
self.switch_workbench(n)
|
||||
return True
|
||||
elif modifiers == Qt.ShiftModifier:
|
||||
self.switch_grouping(n)
|
||||
return True
|
||||
elif modifiers == Qt.NoModifier:
|
||||
self.execute_command(n)
|
||||
return True
|
||||
|
||||
return False # Pass through all other keys
|
||||
```
|
||||
|
||||
### 8.2 Conflict Resolution
|
||||
|
||||
QuickNav's event filter takes priority when active. FreeCAD's existing keybindings for `Ctrl+1` through `Ctrl+9` (if any) are overridden while QuickNav is enabled. The original bindings are restored when QuickNav is toggled off or unloaded.
|
||||
|
||||
Existing `Shift+` and bare number key bindings in FreeCAD are similarly overridden only while QuickNav is active. This is safe because:
|
||||
- FreeCAD does not use bare number keys as shortcuts by default
|
||||
- Shift+number is not commonly bound in default FreeCAD
|
||||
|
||||
### 8.3 Input Widget Safety
|
||||
|
||||
The event filter must NOT intercept keys when the user is:
|
||||
- Typing in the Python console
|
||||
- Entering values in the task panel (dimensions, parameters)
|
||||
- Editing spreadsheet cells
|
||||
- Typing in any `QLineEdit`, `QTextEdit`, `QSpinBox`, or `QDoubleSpinBox`
|
||||
- Using the Sketcher's inline dimension input
|
||||
|
||||
---
|
||||
|
||||
## 9. Addon Structure
|
||||
|
||||
```
|
||||
mods/quicknav/
|
||||
├── package.xml # FreeCAD addon manifest with <kindred> extension
|
||||
├── Init.py # Non-GUI initialization (no-op)
|
||||
├── InitGui.py # Registers QuickNavWorkbench
|
||||
├── quicknav/
|
||||
│ ├── __init__.py
|
||||
│ ├── core.py # QuickNavManager singleton — orchestrates state
|
||||
│ ├── event_filter.py # QuickNavEventFilter (QObject)
|
||||
│ ├── nav_bar.py # NavigationBar (QToolBar subclass)
|
||||
│ ├── workbench_map.py # Fixed workbench → Ctrl+N mapping + grouping discovery
|
||||
│ ├── history.py # MRU stack with ParamGet persistence
|
||||
│ ├── auto_exec.py # Auto-execution condition checks
|
||||
│ ├── commands.py # FreeCAD command wrappers (QuickNav_Toggle, etc.)
|
||||
│ └── resources/
|
||||
│ ├── icons/ # Number badge SVGs, QuickNav icon
|
||||
│ └── theme.py # Catppuccin Mocha color tokens
|
||||
└── tests/
|
||||
└── test_history.py # MRU stack unit tests
|
||||
```
|
||||
|
||||
### 9.1 Manifest
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1">
|
||||
<name>QuickNav</name>
|
||||
<description>Keyboard-driven toolbar navigation</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
|
||||
<license>LGPL-2.1</license>
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>QuickNavWorkbench</classname>
|
||||
</workbench>
|
||||
</content>
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>10</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
</package>
|
||||
```
|
||||
|
||||
### 9.2 Activation
|
||||
|
||||
QuickNav activates when its workbench is loaded (via the addon loader or manual activation). It installs the event filter on the main window and creates the navigation bar. The workbench itself is invisible — it does not add its own toolbars or menus beyond the navigation bar. It acts as a transparent overlay on whatever workbench the user is actually working in.
|
||||
|
||||
```python
|
||||
class QuickNavWorkbench(Gui.Workbench):
|
||||
"""Invisible workbench that installs QuickNav on load.
|
||||
|
||||
QuickNav doesn't replace the active workbench — it layers on top.
|
||||
Loading QuickNav installs the event filter and nav bar, then
|
||||
immediately re-activates the previously active workbench.
|
||||
"""
|
||||
|
||||
def Initialize(self):
|
||||
QuickNavManager.instance().install()
|
||||
|
||||
def Activated(self):
|
||||
# Re-activate the previous workbench so QuickNav is transparent
|
||||
prev = QuickNavManager.instance().previous_workbench
|
||||
if prev:
|
||||
Gui.activateWorkbench(prev)
|
||||
|
||||
def Deactivated(self):
|
||||
pass
|
||||
|
||||
def GetClassName(self):
|
||||
return "Gui::PythonWorkbench"
|
||||
```
|
||||
|
||||
**Alternative (preferred for Create):** Instead of a workbench, QuickNav can be activated directly from `Create/InitGui.py` at boot, gated by the `Enabled` preference. This avoids the workbench-switching dance entirely. The `QuickNavWorkbench` registration is kept for standalone FreeCAD compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 10. Command Discovery
|
||||
|
||||
At activation time, QuickNav introspects each workbench's toolbars to build the command grouping map.
|
||||
|
||||
```python
|
||||
def discover_groupings(workbench_name: str) -> list[CommandGrouping]:
|
||||
"""Discover command groupings from a workbench's toolbar structure.
|
||||
|
||||
1. Temporarily activate the workbench (if not already active)
|
||||
2. Enumerate QToolBars from the main window
|
||||
3. Map toolbar name → list of QAction names
|
||||
4. Filter out non-command actions (separators, widgets)
|
||||
5. Split into tiers if >9 groupings
|
||||
6. Restore the previously active workbench
|
||||
"""
|
||||
```
|
||||
|
||||
### 10.1 Fallback Defaults
|
||||
|
||||
If toolbar discovery fails (workbench not initialized, empty toolbars), QuickNav falls back to the hardcoded groupings in Section 5. These are stored as a Python dict in `workbench_map.py`.
|
||||
|
||||
### 10.2 ZTools Integration
|
||||
|
||||
ZTools commands injected via `WorkbenchManipulator` appear in the discovered toolbars and are automatically included in the relevant groupings. No special handling is needed — QuickNav discovers commands after all manipulators have run.
|
||||
|
||||
---
|
||||
|
||||
## 11. FreeCAD Compatibility
|
||||
|
||||
QuickNav is designed as a standalone FreeCAD addon that works without Kindred Create or the SDK.
|
||||
|
||||
| Feature | FreeCAD | Kindred Create |
|
||||
|---------|---------|----------------|
|
||||
| Core navigation (keys, nav bar) | ✅ | ✅ |
|
||||
| Catppuccin Mocha theming | ❌ (uses Qt defaults) | ✅ (via SDK theme tokens) |
|
||||
| Auto-boot on startup | ❌ (manual workbench activation) | ✅ (via addon loader) |
|
||||
| ZTools commands in groupings | ❌ (not present) | ✅ (discovered from manipulated toolbars) |
|
||||
|
||||
The SDK dependency is optional — QuickNav checks for `kindred_sdk` availability and degrades gracefully:
|
||||
|
||||
```python
|
||||
try:
|
||||
from kindred_sdk.theme import get_theme_tokens
|
||||
THEME = get_theme_tokens()
|
||||
except ImportError:
|
||||
THEME = None # Use Qt default palette
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
- Event filter with key interception and input widget safety
|
||||
- QuickNavManager singleton with toggle on/off
|
||||
- Navigation bar widget (QToolBar) with basic layout
|
||||
- Hardcoded workbench/grouping maps from Section 5
|
||||
- ParamGet persistence for enabled state
|
||||
|
||||
### Phase 2: Dynamic Discovery
|
||||
- Toolbar introspection for command grouping discovery
|
||||
- MRU history with persistence
|
||||
- Grouping overflow scrolling
|
||||
- Workbench restore (last-active grouping per workbench)
|
||||
|
||||
### Phase 3: Auto-Execution
|
||||
- Context-aware auto-execute logic
|
||||
- Sketcher closed-profile detection for Pad/Pocket
|
||||
- Pre-selection constraint application
|
||||
|
||||
### Phase 4: Polish
|
||||
- Number badge SVG icons
|
||||
- Catppuccin Mocha theming (conditional on SDK)
|
||||
- Scroll animations
|
||||
- Settings dialog (custom Ctrl+6-9 assignments)
|
||||
- FreeCAD standalone packaging
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
1. **Tier switching UX:** When a workbench has >9 groupings split into tiers, should `Shift+0` toggle between tiers, or should tiers be purely a scroll/mouse concept?
|
||||
|
||||
2. **Visual number badges:** Should the commands in the nav bar show keycap-style badges (like `⌨ 1`) or just prepend the number (`1: Line`)?
|
||||
|
||||
3. **Sketcher inline dimension input:** FreeCAD's Sketcher has an inline dimension entry that isn't a standard QLineEdit. Need to verify the event filter correctly identifies and skips this widget.
|
||||
|
||||
4. **Ctrl+N conflicts with Create shortcuts:** Verify that Create/Silo don't already bind Ctrl+1 through Ctrl+9. The Silo toggle uses Ctrl+O/S/N, so these should be clear.
|
||||
@@ -77,7 +77,7 @@ Defined in the root `CMakeLists.txt`:
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `KINDRED_CREATE_VERSION` | `0.1.0` | Kindred Create version |
|
||||
| `KINDRED_CREATE_VERSION` | `0.1.5` | Kindred Create version |
|
||||
| `FREECAD_VERSION` | `1.0.0` | FreeCAD base version |
|
||||
|
||||
These are injected into `src/Mod/Create/version.py` at build time via `version.py.in`.
|
||||
|
||||
252
docs/src/reference/kcsdk-python.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# KCSDK Python API Reference
|
||||
|
||||
The `kcsdk` module provides Python access to the Kindred Create addon SDK. It is built with pybind11 and installed alongside the Create module.
|
||||
|
||||
The `kindred_sdk` package (`mods/sdk/kindred_sdk/`) provides convenience wrappers that route through `kcsdk` when available, falling back to legacy `FreeCADGui.*` bindings. Addons should prefer `kindred_sdk` over importing `kcsdk` directly.
|
||||
|
||||
```python
|
||||
import kcsdk # C++ bindings (low-level)
|
||||
import kindred_sdk # Python wrappers (recommended)
|
||||
```
|
||||
|
||||
## Module constants
|
||||
|
||||
| Name | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| `API_VERSION_MAJOR` | `1` | KCSDK API major version |
|
||||
|
||||
## Enums
|
||||
|
||||
### DockArea
|
||||
|
||||
Dock widget placement area. Values match `Qt::DockWidgetArea`.
|
||||
|
||||
| Value | Integer | Description |
|
||||
|-------|---------|-------------|
|
||||
| `DockArea.Left` | 1 | Left dock area |
|
||||
| `DockArea.Right` | 2 | Right dock area |
|
||||
| `DockArea.Top` | 4 | Top dock area |
|
||||
| `DockArea.Bottom` | 8 | Bottom dock area |
|
||||
|
||||
### PanelPersistence
|
||||
|
||||
Whether a dock panel's visibility survives application restarts.
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `PanelPersistence.Session` | Visible until application close |
|
||||
| `PanelPersistence.Persistent` | Saved to preferences and restored on next launch |
|
||||
|
||||
## Editing Context API
|
||||
|
||||
These functions manage the context-aware UI system. Contexts control which toolbars are visible based on the current editing state.
|
||||
|
||||
### register_context(id, label, color, toolbars, match, priority=50)
|
||||
|
||||
Register an editing context.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `str` | Unique identifier (e.g. `"myaddon.edit"`) |
|
||||
| `label` | `str` | Display label template. Supports `{name}` placeholder |
|
||||
| `color` | `str` | Hex color for breadcrumb (e.g. `"#f38ba8"`) |
|
||||
| `toolbars` | `list[str]` | Toolbar names to show when active |
|
||||
| `match` | `callable` | Zero-arg callable returning `True` when active |
|
||||
| `priority` | `int` | Higher values checked first. Default 50 |
|
||||
|
||||
```python
|
||||
kcsdk.register_context(
|
||||
"myworkbench.edit",
|
||||
"Editing {name}",
|
||||
"#89b4fa",
|
||||
["MyToolbar", "StandardViews"],
|
||||
lambda: is_my_object_in_edit(),
|
||||
priority=60,
|
||||
)
|
||||
```
|
||||
|
||||
### unregister_context(id)
|
||||
|
||||
Remove a previously registered editing context.
|
||||
|
||||
### register_overlay(id, toolbars, match)
|
||||
|
||||
Register an editing overlay. Overlays add toolbars to whatever context is currently active when `match()` returns `True`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | `str` | Unique overlay identifier |
|
||||
| `toolbars` | `list[str]` | Toolbar names to append |
|
||||
| `match` | `callable` | Zero-arg callable returning `True` when the overlay applies |
|
||||
|
||||
### unregister_overlay(id)
|
||||
|
||||
Remove a previously registered overlay.
|
||||
|
||||
### inject_commands(context_id, toolbar_name, commands)
|
||||
|
||||
Inject additional commands into an existing context's toolbar.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `context_id` | `str` | Target context identifier |
|
||||
| `toolbar_name` | `str` | Toolbar within that context |
|
||||
| `commands` | `list[str]` | Command names to add |
|
||||
|
||||
```python
|
||||
kcsdk.inject_commands("partdesign.body", "PartDesign", ["MyAddon_CustomFeature"])
|
||||
```
|
||||
|
||||
### current_context()
|
||||
|
||||
Return the current editing context as a dict, or `None`.
|
||||
|
||||
Keys: `id`, `label`, `color`, `toolbars`, `breadcrumb`, `breadcrumbColors`.
|
||||
|
||||
### refresh()
|
||||
|
||||
Force re-resolution of the editing context.
|
||||
|
||||
## Panel Provider API
|
||||
|
||||
These functions manage dock panel registration. Panels are created through the `IPanelProvider` interface and managed by `DockWindowManager`.
|
||||
|
||||
### IPanelProvider
|
||||
|
||||
Abstract base class for dock panel providers. Subclass in Python to create custom panels.
|
||||
|
||||
Three methods must be implemented:
|
||||
|
||||
```python
|
||||
class MyPanel(kcsdk.IPanelProvider):
|
||||
def id(self):
|
||||
return "myaddon.panel"
|
||||
|
||||
def title(self):
|
||||
return "My Panel"
|
||||
|
||||
def create_widget(self):
|
||||
from PySide import QtWidgets
|
||||
label = QtWidgets.QLabel("Hello from my addon")
|
||||
return label
|
||||
```
|
||||
|
||||
Optional methods with defaults:
|
||||
|
||||
| Method | Return type | Default | Description |
|
||||
|--------|-------------|---------|-------------|
|
||||
| `preferred_area()` | `DockArea` | `DockArea.Right` | Dock placement area |
|
||||
| `persistence()` | `PanelPersistence` | `PanelPersistence.Session` | Visibility persistence |
|
||||
| `context_affinity()` | `str` | `""` (always visible) | Only show in named context |
|
||||
|
||||
```python
|
||||
class SidePanel(kcsdk.IPanelProvider):
|
||||
def id(self): return "myaddon.side"
|
||||
def title(self): return "Side Panel"
|
||||
def create_widget(self):
|
||||
from PySide import QtWidgets
|
||||
return QtWidgets.QTreeWidget()
|
||||
def preferred_area(self):
|
||||
return kcsdk.DockArea.Left
|
||||
def context_affinity(self):
|
||||
return "partdesign.body" # only visible in PartDesign body context
|
||||
```
|
||||
|
||||
### register_panel(provider)
|
||||
|
||||
Register a dock panel provider. The provider is stored in the registry until `create_panel()` is called to instantiate the actual dock widget.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `provider` | `IPanelProvider` | Panel provider instance |
|
||||
|
||||
### unregister_panel(id)
|
||||
|
||||
Remove a registered panel provider and destroy its dock widget if created.
|
||||
|
||||
### create_panel(id)
|
||||
|
||||
Instantiate the dock widget for a registered panel. Calls the provider's `create_widget()` once and embeds the result in a `QDockWidget` via `DockWindowManager`. Skips silently if the panel already exists.
|
||||
|
||||
### create_all_panels()
|
||||
|
||||
Instantiate dock widgets for all registered panels.
|
||||
|
||||
### registered_panels()
|
||||
|
||||
Return IDs of all registered panel providers as `list[str]`.
|
||||
|
||||
### available()
|
||||
|
||||
Return names of all registered providers (across all provider types) as `list[str]`.
|
||||
|
||||
## `kindred_sdk` Convenience Wrappers
|
||||
|
||||
The `kindred_sdk` Python package wraps the `kcsdk` C++ module with input validation, error handling, and fallback to legacy APIs.
|
||||
|
||||
### kindred_sdk.register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0)
|
||||
|
||||
High-level dock panel registration. Creates an anonymous `IPanelProvider` internally and schedules creation via `QTimer`.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `object_name` | `str` | | Qt object name (used as panel ID) |
|
||||
| `title` | `str` | | Dock widget title |
|
||||
| `widget_factory` | `callable` | | Zero-arg callable returning a `QWidget` |
|
||||
| `area` | `str` | `"right"` | `"left"`, `"right"`, `"top"`, or `"bottom"` |
|
||||
| `delay_ms` | `int` | `0` | Defer creation by this many milliseconds |
|
||||
|
||||
```python
|
||||
from kindred_sdk import register_dock_panel
|
||||
from PySide import QtWidgets
|
||||
|
||||
register_dock_panel(
|
||||
"MyAddonPanel",
|
||||
"My Addon",
|
||||
lambda: QtWidgets.QLabel("Hello"),
|
||||
area="left",
|
||||
delay_ms=2000,
|
||||
)
|
||||
```
|
||||
|
||||
### Other `kindred_sdk` Wrappers
|
||||
|
||||
These mirror the `kcsdk` functions with added type validation and try/except error handling:
|
||||
|
||||
| Function | Maps to |
|
||||
|----------|---------|
|
||||
| `kindred_sdk.register_context()` | `kcsdk.register_context()` |
|
||||
| `kindred_sdk.unregister_context()` | `kcsdk.unregister_context()` |
|
||||
| `kindred_sdk.register_overlay()` | `kcsdk.register_overlay()` |
|
||||
| `kindred_sdk.unregister_overlay()` | `kcsdk.unregister_overlay()` |
|
||||
| `kindred_sdk.inject_commands()` | `kcsdk.inject_commands()` |
|
||||
| `kindred_sdk.current_context()` | `kcsdk.current_context()` |
|
||||
| `kindred_sdk.refresh_context()` | `kcsdk.refresh()` |
|
||||
| `kindred_sdk.register_origin()` | `FreeCADGui.addOrigin()` |
|
||||
| `kindred_sdk.unregister_origin()` | `FreeCADGui.removeOrigin()` |
|
||||
| `kindred_sdk.get_theme_tokens()` | YAML palette lookup |
|
||||
| `kindred_sdk.load_palette()` | `Palette` object from YAML |
|
||||
| `kindred_sdk.create_version()` | Kindred Create version string |
|
||||
| `kindred_sdk.freecad_version()` | FreeCAD version tuple |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Python Addon Code
|
||||
|
|
||||
kindred_sdk (mods/sdk/) <- convenience wrappers + validation
|
||||
|
|
||||
kcsdk.so (pybind11 module) <- C++ API bindings
|
||||
|
|
||||
libKCSDK.so (C++ shared library) <- SDKRegistry + provider interfaces
|
||||
|
|
||||
FreeCADGui (EditingContextResolver, DockWindowManager, OriginManager, ...)
|
||||
```
|
||||
|
||||
When `kcsdk` is not available (console mode, build not installed), `kindred_sdk` falls back to legacy `FreeCADGui.*` Python bindings.
|
||||
|
||||
## Related
|
||||
|
||||
- [Writing an Addon](../development/writing-an-addon.md)
|
||||
- [Package.xml Schema Extensions](../development/package-xml-schema.md)
|
||||
- [Create Module Bootstrap](./create-module-bootstrap.md)
|
||||
219
docs/src/solver/assembly-integration.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Assembly Integration
|
||||
|
||||
The Kindred solver integrates with FreeCAD's Assembly workbench through the KCSolve pluggable solver framework. This page describes the bridge layer, preference system, and interactive drag protocol.
|
||||
|
||||
## KindredSolver class
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/solver.py`
|
||||
|
||||
`KindredSolver` subclasses `kcsolve.IKCSolver` and implements the solver interface:
|
||||
|
||||
```python
|
||||
class KindredSolver(kcsolve.IKCSolver):
|
||||
def name(self):
|
||||
return "Kindred (Newton-Raphson)"
|
||||
|
||||
def supported_joints(self):
|
||||
return list(_SUPPORTED) # 20 of 24 BaseJointKind values
|
||||
|
||||
def solve(self, ctx): # Static solve
|
||||
def diagnose(self, ctx): # Constraint analysis
|
||||
def pre_drag(self, ctx, drag_parts): # Begin drag session
|
||||
def drag_step(self, drag_placements): # Mouse move during drag
|
||||
def post_drag(self): # End drag session
|
||||
def is_deterministic(self): # Returns True
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
The solver is registered at addon load time via `Init.py`:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
from kindred_solver import KindredSolver
|
||||
kcsolve.register_solver("kindred", KindredSolver)
|
||||
```
|
||||
|
||||
The `mods/solver/` directory is a FreeCAD addon discovered by the addon loader through its `package.xml` manifest.
|
||||
|
||||
### Supported joints
|
||||
|
||||
The Kindred solver handles 20 of the 24 `BaseJointKind` values. The remaining 4 are stubs that produce no residuals:
|
||||
|
||||
| Supported | Stub (no residuals) |
|
||||
|-----------|-------------------|
|
||||
| Coincident, PointOnLine, PointInPlane, Concentric, Tangent, Planar, LineInPlane, Parallel, Perpendicular, Angle, Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal, Gear, RackPinion, DistancePointPoint | Cam, Slot, DistanceCylSph, Custom |
|
||||
|
||||
### Joint limits
|
||||
|
||||
Joint travel limits (`Constraint.limits`) are accepted but not enforced. The solver logs a warning once per instance when limits are encountered. Enforcing inequality constraints requires active-set or barrier-method extensions beyond the current Newton-Raphson formulation.
|
||||
|
||||
## Solver selection
|
||||
|
||||
### C++ preference
|
||||
|
||||
`AssemblyObject::getOrCreateSolver()` reads the user preference to select the solver backend:
|
||||
|
||||
```cpp
|
||||
ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
|
||||
"User parameter:BaseApp/Preferences/Mod/Assembly");
|
||||
std::string solverName = hGrp->GetASCII("Solver", "");
|
||||
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
|
||||
```
|
||||
|
||||
An empty string (`""`) returns the registry default (the first solver registered, which is OndselSolver). Setting `"kindred"` selects the Kindred solver.
|
||||
|
||||
`resetSolver()` clears the cached solver instance so the next solve picks up preference changes.
|
||||
|
||||
### Preferences UI
|
||||
|
||||
The Assembly preferences page (`Edit > Preferences > Assembly`) includes a "Solver backend" combo box populated from the registry at load time:
|
||||
|
||||
- **Default** -- empty string, uses the registry default (OndselSolver)
|
||||
- **OndselSolver (Lagrangian)** -- `"ondsel"`
|
||||
- **Kindred (Newton-Raphson)** -- `"kindred"` (available when the solver addon is loaded)
|
||||
|
||||
The preference is stored as `Mod/Assembly/Solver` in the FreeCAD parameter system.
|
||||
|
||||
### Programmatic switching
|
||||
|
||||
From the Python console:
|
||||
|
||||
```python
|
||||
import FreeCAD
|
||||
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
|
||||
|
||||
# Switch to Kindred
|
||||
pref.SetString("Solver", "kindred")
|
||||
|
||||
# Switch back to default
|
||||
pref.SetString("Solver", "")
|
||||
|
||||
# Force the active assembly to pick up the change
|
||||
if hasattr(FreeCADGui, "ActiveDocument"):
|
||||
for obj in FreeCAD.ActiveDocument.Objects:
|
||||
if hasattr(obj, "resetSolver"):
|
||||
obj.resetSolver()
|
||||
```
|
||||
|
||||
## Interactive drag protocol
|
||||
|
||||
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
|
||||
|
||||
### pre_drag(ctx, drag_parts)
|
||||
|
||||
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a `_DragCache` for reuse across subsequent `drag_step()` calls.
|
||||
|
||||
```python
|
||||
def pre_drag(self, ctx, drag_parts):
|
||||
self._drag_ctx = ctx
|
||||
self._drag_parts = set(drag_parts)
|
||||
|
||||
system = _build_system(ctx)
|
||||
|
||||
half_spaces = compute_half_spaces(...)
|
||||
weight_vec = build_weight_vector(system.params)
|
||||
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
# single_equation_pass is intentionally skipped — it bakes variable
|
||||
# values as constants that become stale when dragged parts move.
|
||||
|
||||
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
|
||||
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
|
||||
|
||||
# Initial solve (Newton-Raphson + BFGS fallback)
|
||||
newton_solve(residuals, system.params, ...)
|
||||
|
||||
# Cache for drag_step() reuse
|
||||
cache = _DragCache()
|
||||
cache.system = system
|
||||
cache.residuals = residuals
|
||||
cache.jac_exprs = jac_exprs
|
||||
cache.compiled_eval = compiled_eval
|
||||
cache.half_spaces = half_spaces
|
||||
cache.weight_vec = weight_vec
|
||||
...
|
||||
return result
|
||||
```
|
||||
|
||||
**Important:** `single_equation_pass` is not used in the drag path. It analytically solves single-variable equations and bakes the results as `Const()` nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only `substitution_pass` (which replaces genuinely grounded parameters) is safe to cache.
|
||||
|
||||
### drag_step(drag_placements)
|
||||
|
||||
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached `ParamTable`, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
|
||||
|
||||
```python
|
||||
def drag_step(self, drag_placements):
|
||||
cache = self._drag_cache
|
||||
params = cache.system.params
|
||||
|
||||
# Update only the dragged part's parameters
|
||||
for pr in drag_placements:
|
||||
pfx = pr.id + "/"
|
||||
params.set_value(pfx + "tx", pr.placement.position[0])
|
||||
params.set_value(pfx + "ty", pr.placement.position[1])
|
||||
params.set_value(pfx + "tz", pr.placement.position[2])
|
||||
params.set_value(pfx + "qw", pr.placement.quaternion[0])
|
||||
params.set_value(pfx + "qx", pr.placement.quaternion[1])
|
||||
params.set_value(pfx + "qy", pr.placement.quaternion[2])
|
||||
params.set_value(pfx + "qz", pr.placement.quaternion[3])
|
||||
|
||||
# Solve with cached artifacts — no rebuild
|
||||
newton_solve(cache.residuals, params, ...,
|
||||
jac_exprs=cache.jac_exprs,
|
||||
compiled_eval=cache.compiled_eval)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### post_drag()
|
||||
|
||||
Called when the drag ends. Clears the cached state.
|
||||
|
||||
```python
|
||||
def post_drag(self):
|
||||
self._drag_ctx = None
|
||||
self._drag_parts = None
|
||||
self._drag_cache = None
|
||||
```
|
||||
|
||||
### _DragCache
|
||||
|
||||
The cache holds all artifacts built in `pre_drag()` that are invariant across drag steps (constraint topology doesn't change during a drag):
|
||||
|
||||
| Field | Contents |
|
||||
|-------|----------|
|
||||
| `system` | `_System` -- owns `ParamTable` and `Expr` trees |
|
||||
| `residuals` | `list[Expr]` -- after substitution pass |
|
||||
| `jac_exprs` | `list[list[Expr]]` -- symbolic Jacobian |
|
||||
| `compiled_eval` | `Callable` or `None` -- native compiled evaluator |
|
||||
| `half_spaces` | `list[HalfSpace]` -- branch trackers |
|
||||
| `weight_vec` | `ndarray` or `None` -- minimum-movement weights |
|
||||
| `post_step_fn` | `Callable` or `None` -- half-space correction callback |
|
||||
|
||||
### Performance
|
||||
|
||||
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each `drag_step()` only evaluates the cached expressions at updated parameter values:
|
||||
|
||||
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
|
||||
- The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
|
||||
- The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
|
||||
- DOF counting is skipped during drag for speed (`result.dof = -1`)
|
||||
|
||||
## Diagnostics integration
|
||||
|
||||
`diagnose(ctx)` builds the constraint system and runs overconstrained detection, returning a list of `kcsolve.ConstraintDiagnostic` objects. The Assembly module calls this to populate the constraint diagnostics panel.
|
||||
|
||||
```python
|
||||
def diagnose(self, ctx):
|
||||
system = _build_system(ctx)
|
||||
residuals = substitution_pass(system.all_residuals, system.params)
|
||||
return _run_diagnostics(residuals, system.params, system.residual_ranges, ctx)
|
||||
```
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
- **Kinematic simulation** (`run_kinematic`, `num_frames`, `update_for_frame`) -- the base class defaults return `Failed`. Requires time-stepping integration with motion driver expression evaluation.
|
||||
- **Joint limit enforcement** -- inequality constraints need active-set or barrier solver extensions.
|
||||
- **Fixed-joint bundling** (`supports_bundle_fixed()` returns `False`) -- the solver receives unbundled parts; the Assembly module pre-bundles when needed.
|
||||
- **Native export** (`export_native()`) -- no solver-native debug format defined.
|
||||
116
docs/src/solver/constraints.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Constraints
|
||||
|
||||
Each constraint type maps to a class that produces residual expressions. The residuals equal zero when the constraint is satisfied. The number of residuals equals the number of degrees of freedom removed.
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/constraints.py`, `mods/solver/kindred_solver/geometry.py`
|
||||
|
||||
## Constraint vocabulary
|
||||
|
||||
### Point constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Coincident** | 3 | `p_i - p_j` (world-frame marker origins coincide) |
|
||||
| **PointOnLine** | 2 | Two components of `(p_i - p_j) x z_j` (point lies on line through `p_j` along `z_j`) |
|
||||
| **PointInPlane** | 1 | `(p_i - p_j) . z_j - offset` (signed distance to plane) |
|
||||
|
||||
### Orientation constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Parallel** | 2 | Two components of `z_i x z_j` (cross product of Z-axes is zero) |
|
||||
| **Perpendicular** | 1 | `z_i . z_j` (dot product of Z-axes is zero) |
|
||||
| **Angle** | 1 | `z_i . z_j - cos(angle)` |
|
||||
|
||||
### Axis/surface constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Concentric** | 4 | Parallel Z-axes (2) + point-on-line (2) |
|
||||
| **Tangent** | 1 | `(p_i - p_j) . z_j` (signed distance along normal) |
|
||||
| **Planar** | 3 | Parallel normals (2) + point-in-plane (1) |
|
||||
| **LineInPlane** | 2 | Point-in-plane (1) + `z_i . n_j` (line direction perpendicular to normal) (1) |
|
||||
|
||||
### Kinematic joints
|
||||
|
||||
| Type | DOF removed | DOF remaining | Residuals |
|
||||
|------|-------------|---------------|-----------|
|
||||
| **Fixed** | 6 | 0 | Coincident origins (3) + quaternion error imaginary parts (3) |
|
||||
| **Ball** | 3 | 3 | Coincident origins (same as Coincident) |
|
||||
| **Revolute** | 5 | 1 (rotation about Z) | Coincident origins (3) + parallel Z-axes (2) |
|
||||
| **Cylindrical** | 4 | 2 (rotation + slide) | Parallel Z-axes (2) + point-on-line (2) |
|
||||
| **Slider** | 5 | 1 (slide along Z) | Parallel Z-axes (2) + point-on-line (2) + twist lock: `x_i . y_j` (1) |
|
||||
| **Screw** | 5 | 1 (helical) | Cylindrical (4) + pitch coupling: `axial - pitch * qz_rel / pi` (1) |
|
||||
| **Universal** | 4 | 2 (rotation about each Z) | Coincident origins (3) + perpendicular Z-axes (1) |
|
||||
|
||||
### Mechanical elements
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **Gear** | 1 | `r_i * qz_i + r_j * qz_j` (coupled rotation via quaternion Z-components) |
|
||||
| **RackPinion** | 1 | `translation - 2 * pitch_radius * qz_i` (rotation-translation coupling) |
|
||||
| **Cam** | 0 | Stub (no residuals) |
|
||||
| **Slot** | 0 | Stub (no residuals) |
|
||||
|
||||
### Distance constraints
|
||||
|
||||
| Type | DOF removed | Residuals |
|
||||
|------|-------------|-----------|
|
||||
| **DistancePointPoint** | 1 | `\|p_i - p_j\|^2 - d^2` (squared form avoids sqrt in Jacobian) |
|
||||
| **DistanceCylSph** | 0 | Stub (geometry classification dependent) |
|
||||
|
||||
## Marker convention
|
||||
|
||||
Every constraint references two parts (`body_i`, `body_j`) with local coordinate frames called markers. Each marker has a position (attachment point on the part) and a quaternion (orientation).
|
||||
|
||||
The marker Z-axis defines the constraint direction:
|
||||
- **Revolute:** Z-axis = hinge axis
|
||||
- **Planar:** Z-axis = face normal
|
||||
- **PointOnLine:** Z-axis = line direction
|
||||
- **Slider:** Z-axis = slide direction
|
||||
|
||||
The solver computes world-frame marker axes by composing the body quaternion with the marker quaternion: `q_world = q_body * q_marker`, then rotating unit vectors through the result.
|
||||
|
||||
## Fixed constraint orientation
|
||||
|
||||
The Fixed constraint locks all 6 DOF using a quaternion error formulation:
|
||||
|
||||
1. Compute total orientation: `q_i = q_body_i * q_marker_i`, `q_j = q_body_j * q_marker_j`
|
||||
2. Compute relative quaternion: `q_err = conj(q_i) * q_j`
|
||||
3. When orientations match, `q_err` is the identity quaternion `(1, 0, 0, 0)`
|
||||
4. Residuals are the three imaginary components of `q_err` (should be zero)
|
||||
|
||||
The quaternion normalization constraint on each body provides the fourth equation needed to fully determine the quaternion.
|
||||
|
||||
## Rotation proxies for mechanical constraints
|
||||
|
||||
Gear, RackPinion, and Screw constraints need to measure rotation angles. Rather than extracting Euler angles (which would introduce transcendentals), they use the Z-component of a relative quaternion as a proxy:
|
||||
|
||||
```
|
||||
q_local = conj(q_marker) * q_body * q_marker
|
||||
angle ~ 2 * qz_local (for small angles)
|
||||
```
|
||||
|
||||
This is exact at the solution and has correct gradient direction, which is sufficient for Newton-Raphson convergence from a nearby initial guess.
|
||||
|
||||
## Geometry helpers
|
||||
|
||||
The `geometry.py` module provides Expr-level vector operations used by constraint classes:
|
||||
|
||||
- `marker_z_axis(body, marker_quat)` -- world-frame Z-axis via `quat_rotate(q_body * q_marker, [0,0,1])`
|
||||
- `marker_x_axis(body, marker_quat)` -- world-frame X-axis (used by Slider twist lock)
|
||||
- `marker_y_axis(body, marker_quat)` -- world-frame Y-axis (used by Slider twist lock)
|
||||
- `dot3(a, b)` -- dot product of Expr triples
|
||||
- `cross3(a, b)` -- cross product of Expr triples
|
||||
- `point_plane_distance(point, origin, normal)` -- signed distance
|
||||
- `point_line_perp_components(point, origin, dir)` -- two perpendicular distance components
|
||||
|
||||
## Writing a new constraint
|
||||
|
||||
To add a constraint type:
|
||||
|
||||
1. Subclass `ConstraintBase` in `constraints.py`
|
||||
2. Implement `residuals()` returning a list of `Expr` nodes
|
||||
3. Add a case in `solver.py:_build_constraint()` to instantiate it from `BaseJointKind`
|
||||
4. Add the `BaseJointKind` value to `_SUPPORTED` in `solver.py`
|
||||
5. Add the residual count to the tables in `decompose.py`
|
||||
117
docs/src/solver/diagnostics.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Diagnostics
|
||||
|
||||
The solver provides three levels of constraint analysis: system-wide DOF counting, per-entity DOF decomposition, and overconstrained/conflicting constraint detection.
|
||||
|
||||
## DOF counting
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/dof.py`
|
||||
|
||||
Degrees of freedom are computed from the Jacobian rank:
|
||||
|
||||
```
|
||||
DOF = n_free_params - rank(J)
|
||||
```
|
||||
|
||||
Where `n_free_params` is the number of non-fixed parameters and `rank(J)` is the numerical rank of the Jacobian evaluated at current parameter values (SVD with tolerance `1e-8`).
|
||||
|
||||
A well-constrained assembly has `DOF = 0` (exactly enough constraints to determine all positions). Positive DOF means underconstrained (parts can still move). Negative DOF is not possible with this formulation -- instead, rank deficiency in an overdetermined system indicates redundant constraints.
|
||||
|
||||
The DOF value is reported in `SolveResult.dof` after every solve.
|
||||
|
||||
## Per-entity DOF
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/diagnostics.py`
|
||||
|
||||
`per_entity_dof()` breaks down the DOF count per body, identifying which motions remain free for each part:
|
||||
|
||||
1. Build the full Jacobian
|
||||
2. For each non-grounded body, extract the 7 columns corresponding to its parameters
|
||||
3. Compute SVD of the sub-matrix; rank = number of constrained directions
|
||||
4. `remaining_dof = 7 - rank` (includes the quaternion normalization constraint counted in the rank)
|
||||
5. Classify null-space vectors as free motions by analyzing their translation vs. rotation components:
|
||||
- Pure translation: >80% of the null vector's energy is in `tx, ty, tz` components
|
||||
- Pure rotation: >80% of the energy is in `qw, qx, qy, qz` components
|
||||
- Helical: mixed
|
||||
|
||||
Returns a list of `EntityDOF` dataclasses:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EntityDOF:
|
||||
entity_id: str
|
||||
remaining_dof: int
|
||||
free_motions: list[str] # e.g., ["rotation about Z", "translation along X"]
|
||||
```
|
||||
|
||||
## Overconstrained detection
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/diagnostics.py`
|
||||
|
||||
`find_overconstrained()` identifies redundant and conflicting constraints when the system is overconstrained (Jacobian is rank-deficient). It runs automatically when `solve()` fails to converge.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Following the approach used by SolvSpace:
|
||||
|
||||
1. **Check rank.** Build the full Jacobian `J`, compute its rank via SVD. If `rank == n_residuals`, the system is not overconstrained -- return empty.
|
||||
|
||||
2. **Find redundant constraints.** For each constraint, temporarily remove its rows from J and re-check rank. If the rank is preserved, the constraint is **redundant** (removing it doesn't change the system's effective equations).
|
||||
|
||||
3. **Distinguish conflicting from merely redundant.** Compute the left null space of J (columns of U beyond the rank). Project the residual vector onto this null space:
|
||||
```
|
||||
null_residual = U_null^T @ r
|
||||
residual_projection = U_null @ null_residual
|
||||
```
|
||||
If a redundant constraint's residuals have significant projection onto the null space, it is **conflicting** -- it's both redundant and unsatisfied, meaning it contradicts other constraints.
|
||||
|
||||
### Diagnostic output
|
||||
|
||||
Returns `ConstraintDiag` dataclasses:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ConstraintDiag:
|
||||
constraint_index: int
|
||||
kind: str # "redundant" or "conflicting"
|
||||
detail: str # Human-readable explanation
|
||||
```
|
||||
|
||||
These are converted to `kcsolve.ConstraintDiagnostic` objects in the IKCSolver bridge:
|
||||
|
||||
| ConstraintDiag.kind | kcsolve.DiagnosticKind |
|
||||
|---------------------|----------------------|
|
||||
| `"redundant"` | `Redundant` |
|
||||
| `"conflicting"` | `Conflicting` |
|
||||
|
||||
### Example
|
||||
|
||||
Two Fixed joints between the same pair of parts:
|
||||
|
||||
- Joint A: 6 residuals (3 position + 3 orientation)
|
||||
- Joint B: 6 residuals (same as Joint A)
|
||||
|
||||
Jacobian rank = 6 (Joint B's rows are linearly dependent on Joint A's). Both joints are detected as redundant. If the joints specify different relative positions, both are also flagged as conflicting.
|
||||
|
||||
## Solution preferences
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/preference.py`
|
||||
|
||||
Solution preferences guide the solver toward physically intuitive solutions when multiple valid configurations exist.
|
||||
|
||||
### Minimum-movement weighting
|
||||
|
||||
The weight vector scales the Newton step to prefer solutions near the initial configuration. Translation parameters get weight `1.0`, quaternion parameters get weight `(180/pi)^2 ~ 3283`. This makes a 1-radian rotation equally "expensive" as a ~57-unit translation.
|
||||
|
||||
The weighted minimum-norm step is:
|
||||
|
||||
```
|
||||
J_scaled = J @ diag(W^{-1/2})
|
||||
dx_scaled = lstsq(J_scaled, -r)
|
||||
dx = dx_scaled * W^{-1/2}
|
||||
```
|
||||
|
||||
This produces the minimum-norm solution in the weighted parameter space, biasing toward small movements.
|
||||
|
||||
### Half-space tracking
|
||||
|
||||
Described in detail in [Solving Algorithms: Half-space tracking](solving.md#half-space-tracking). Preserves the initial configuration's "branch" for constraints with multiple valid solutions by detecting and correcting branch crossings during iteration.
|
||||
96
docs/src/solver/expression-dag.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Expression DAG
|
||||
|
||||
The expression DAG is the foundation of the Kindred solver. All constraint equations, Jacobian entries, and residuals are built as immutable trees of `Expr` nodes. This lets the solver compute exact symbolic derivatives and simplify constant sub-expressions before the iterative solve loop.
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/expr.py`
|
||||
|
||||
## Node types
|
||||
|
||||
Every node is a subclass of `Expr` and implements three methods:
|
||||
|
||||
- `eval(env)` -- evaluate the expression given a name-to-value dictionary
|
||||
- `diff(var)` -- return a new Expr tree for the partial derivative with respect to `var`
|
||||
- `simplify()` -- return an algebraically simplified copy
|
||||
|
||||
### Leaf nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Const(v)` | Literal floating-point value | 0 |
|
||||
| `Var(name)` | Named parameter (from `ParamTable`) | 1 if name matches, else 0 |
|
||||
|
||||
### Unary nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Neg(f)` | Negation: `-f` | `-f'` |
|
||||
| `Sin(f)` | Sine: `sin(f)` | `cos(f) * f'` |
|
||||
| `Cos(f)` | Cosine: `cos(f)` | `-sin(f) * f'` |
|
||||
| `Sqrt(f)` | Square root: `sqrt(f)` | `f' / (2 * sqrt(f))` |
|
||||
|
||||
### Binary nodes
|
||||
|
||||
| Node | Description | diff(x) |
|
||||
|------|-------------|---------|
|
||||
| `Add(a, b)` | Sum: `a + b` | `a' + b'` |
|
||||
| `Sub(a, b)` | Difference: `a - b` | `a' - b'` |
|
||||
| `Mul(a, b)` | Product: `a * b` | `a'b + ab'` (product rule) |
|
||||
| `Div(a, b)` | Quotient: `a / b` | `(a'b - ab') / b^2` (quotient rule) |
|
||||
| `Pow(a, n)` | Power: `a^n` (constant exponent only) | `n * a^(n-1) * a'` |
|
||||
|
||||
### Sentinels
|
||||
|
||||
`ZERO = Const(0.0)` and `ONE = Const(1.0)` are pre-allocated constants used by `diff()` to avoid allocating trivial nodes.
|
||||
|
||||
## Operator overloading
|
||||
|
||||
Python's arithmetic operators are overloaded on `Expr`, so constraints can be written in natural notation:
|
||||
|
||||
```python
|
||||
from kindred_solver.expr import Var, Const
|
||||
|
||||
x = Var("x")
|
||||
y = Var("y")
|
||||
|
||||
# Build the expression: x^2 + 2*x*y - 1
|
||||
expr = x**2 + 2*x*y - Const(1.0)
|
||||
|
||||
# Evaluate at x=3, y=4
|
||||
expr.eval({"x": 3.0, "y": 4.0}) # 32.0
|
||||
|
||||
# Symbolic derivative w.r.t. x
|
||||
dx = expr.diff("x").simplify() # 2*x + 2*y
|
||||
dx.eval({"x": 3.0, "y": 4.0}) # 14.0
|
||||
```
|
||||
|
||||
The `_wrap()` helper coerces plain `int` and `float` values to `Const` nodes automatically, so `2 * x` works without wrapping the `2`.
|
||||
|
||||
## Simplification
|
||||
|
||||
`simplify()` applies algebraic identities bottom-up:
|
||||
|
||||
- Constant folding: `Const(2) + Const(3)` becomes `Const(5)`
|
||||
- Identity elimination: `x + 0 = x`, `x * 1 = x`, `x^0 = 1`, `x^1 = x`
|
||||
- Zero propagation: `0 * x = 0`
|
||||
- Negation collapse: `-(-x) = x`
|
||||
- Power expansion: `x^2` becomes `x * x` (avoids `pow()` in evaluation)
|
||||
|
||||
Simplification is applied once to each Jacobian entry after symbolic differentiation, before the solve loop begins. This reduces the expression tree size and speeds up repeated evaluation.
|
||||
|
||||
## How the solver uses expressions
|
||||
|
||||
1. **Parameter registration.** `ParamTable.add("Part001/tx", 10.0)` creates a `Var("Part001/tx")` node and records its current value.
|
||||
|
||||
2. **Constraint building.** Constraint classes compose `Var` nodes with arithmetic to produce residual `Expr` trees. For example, `CoincidentConstraint` builds `body_i.world_point() - body_j.world_point()`, producing 3 residual expressions.
|
||||
|
||||
3. **Jacobian construction.** Newton-Raphson calls `r.diff(name).simplify()` for every (residual, free parameter) pair to build the symbolic Jacobian. This happens once before the solve loop.
|
||||
|
||||
4. **Evaluation.** Each Newton iteration calls `expr.eval(env)` on every residual and Jacobian entry using the current parameter snapshot. `eval()` is a simple recursive tree walk with dictionary lookups.
|
||||
|
||||
## Design notes
|
||||
|
||||
**Why not numpy directly?** Symbolic expressions give exact derivatives without finite-difference approximations, and enable pre-passes (substitution, single-equation solve) that can eliminate variables before the iterative solver runs. The overhead of tree evaluation is acceptable for the problem sizes encountered in assembly solving (typically tens to hundreds of variables).
|
||||
|
||||
**Why immutable?** Immutability means `diff()` can safely share sub-tree references between the original and derivative expressions. It also simplifies the substitution pass, which rebuilds trees with `Const` nodes replacing fixed `Var` nodes.
|
||||
|
||||
**Limitations.** `Pow` differentiation only supports constant exponents. Variable exponents would require logarithmic differentiation (`d/dx f^g = f^g * (g' * ln(f) + g * f'/f)`), which hasn't been needed for assembly constraints.
|
||||
92
docs/src/solver/overview.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Kindred Solver Overview
|
||||
|
||||
The Kindred solver is an expression-based Newton-Raphson constraint solver for the Assembly workbench. It is a pure-Python implementation that registers as a pluggable backend through the [KCSolve framework](../architecture/ondsel-solver.md), providing an alternative to the built-in OndselSolver (Lagrangian) backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Assembly Module
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ SolverRegistry │
|
||||
│ get("kindred") │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ KindredSolver │
|
||||
│ (kcsolve.IKCSolver) │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
┌────────┴────────┐ ┌──────┴──────┐ ┌────────┴────────┐
|
||||
│ _build_system │ │ Solve │ │ Diagnostics │
|
||||
│ ────────────── │ │ ───── │ │ ─────────── │
|
||||
│ ParamTable │ │ pre-passes │ │ DOF counting │
|
||||
│ RigidBody │ │ Newton-R │ │ overconstrained│
|
||||
│ Constraints │ │ BFGS │ │ per-entity DOF │
|
||||
│ Residuals │ │ decompose │ │ half-spaces │
|
||||
└─────────────────┘ └─────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Design principles
|
||||
|
||||
**Symbolic differentiation.** All constraint equations are built as immutable expression DAGs (`Expr` trees). The Jacobian is computed symbolically via `expr.diff()` rather than finite differences. This gives exact derivatives, avoids numerical step-size tuning, and allows pre-passes to simplify or eliminate trivial equations before the iterative solver runs.
|
||||
|
||||
**Residual-based formulation.** Each constraint produces a list of residual expressions that should evaluate to zero when satisfied. A Coincident constraint produces 3 residuals (dx, dy, dz), a Revolute produces 5 (3 position + 2 axis alignment), and so on. The solver minimizes the residual vector norm.
|
||||
|
||||
**Unit quaternions for rotation.** Orientation is parameterized as a unit quaternion (w, x, y, z) rather than Euler angles, avoiding gimbal lock. A quaternion normalization residual (qw^2 + qx^2 + qy^2 + qz^2 - 1 = 0) is added for each free body, and quaternions are re-projected onto the unit sphere after each Newton step.
|
||||
|
||||
**Current placements as initial guess.** The solver uses the parts' current positions as the initial guess, so it naturally converges to the nearest solution. Combined with half-space tracking, this produces physically intuitive results without branch-switching surprises.
|
||||
|
||||
## Solve pipeline
|
||||
|
||||
When `KindredSolver.solve(ctx)` is called with a `SolveContext`:
|
||||
|
||||
1. **Build system** (`_build_system`) -- Create a `ParamTable` with 7 parameters per part (tx, ty, tz, qw, qx, qy, qz). Grounded parts have all parameters fixed. Build constraint objects from the context, collect their residual expressions, and add quaternion normalization residuals for free bodies.
|
||||
|
||||
2. **Solution preferences** -- Compute half-space trackers for branching constraints (Distance, Parallel, Angle, Perpendicular) and build a minimum-movement weight vector that penalizes quaternion changes more than translation changes.
|
||||
|
||||
3. **Pre-passes** -- Run the substitution pass (replace fixed parameters with constants) and the single-equation pass (analytically solve residuals with only one free variable).
|
||||
|
||||
4. **Solve** -- For assemblies with 8+ free bodies, decompose the constraint graph into biconnected components and solve each cluster independently. For smaller assemblies, solve the full system monolithically. In both cases, use Newton-Raphson first, falling back to L-BFGS-B if Newton doesn't converge.
|
||||
|
||||
5. **Post-process** -- Count degrees of freedom via Jacobian SVD rank. On failure, run overconstrained detection to identify redundant or conflicting constraints. Extract solved placements from the parameter table.
|
||||
|
||||
## Module map
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `solver.py` | `KindredSolver` class: IKCSolver bridge, solve/diagnose/drag entry points |
|
||||
| `expr.py` | Immutable expression DAG with eval, diff, simplify |
|
||||
| `params.py` | Parameter table: named variables with fixed/free tracking |
|
||||
| `entities.py` | `RigidBody`: 7-DOF entity owning solver parameters |
|
||||
| `quat.py` | Quaternion rotation as polynomial Expr trees |
|
||||
| `geometry.py` | Marker axis extraction, vector ops (dot, cross, point-plane, point-line) |
|
||||
| `constraints.py` | 24 constraint classes producing residual expressions |
|
||||
| `newton.py` | Newton-Raphson with symbolic Jacobian, quaternion renormalization |
|
||||
| `bfgs.py` | L-BFGS-B fallback via scipy |
|
||||
| `prepass.py` | Substitution pass and single-equation analytical solve |
|
||||
| `decompose.py` | Biconnected component graph decomposition and cluster-by-cluster solving |
|
||||
| `dof.py` | DOF counting via Jacobian SVD rank |
|
||||
| `diagnostics.py` | Overconstrained detection, per-entity DOF classification |
|
||||
| `preference.py` | Half-space tracking and minimum-movement weighting |
|
||||
|
||||
## File locations
|
||||
|
||||
- **Solver addon:** `mods/solver/` (git submodule)
|
||||
- **KCSolve C++ framework:** `src/Mod/Assembly/Solver/`
|
||||
- **Python bindings:** `src/Mod/Assembly/Solver/bindings/`
|
||||
- **Integration tests:** `src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py`
|
||||
- **Unit tests:** `mods/solver/tests/`
|
||||
|
||||
## Related
|
||||
|
||||
- [Expression DAG](expression-dag.md) -- the Expr type system
|
||||
- [Constraints](constraints.md) -- constraint vocabulary and residuals
|
||||
- [Solving algorithms](solving.md) -- Newton-Raphson, BFGS, decomposition
|
||||
- [Diagnostics](diagnostics.md) -- DOF counting, overconstrained detection
|
||||
- [Assembly integration](assembly-integration.md) -- IKCSolver bridge, preferences, drag
|
||||
- [Writing a custom solver](writing-a-solver.md) -- tutorial
|
||||
- [KCSolve architecture](../architecture/ondsel-solver.md) -- pluggable solver framework
|
||||
- [KCSolve Python API](../reference/kcsolve-python.md) -- kcsolve module reference
|
||||
128
docs/src/solver/solving.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Solving Algorithms
|
||||
|
||||
The Kindred solver uses a multi-stage pipeline: pre-passes reduce the system, Newton-Raphson iterates toward a solution, and L-BFGS-B provides a fallback. For large assemblies, graph decomposition splits the system into independent clusters solved in sequence.
|
||||
|
||||
## Pre-passes
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/prepass.py`
|
||||
|
||||
Pre-passes run before the iterative solver and can eliminate variables analytically, reducing the problem size and improving convergence.
|
||||
|
||||
### Substitution pass
|
||||
|
||||
Replaces all fixed-parameter `Var` nodes with `Const` nodes carrying their current values, then simplifies. This compiles grounded-body parameters and previously-solved variables out of the expression trees.
|
||||
|
||||
After substitution, residuals involving only fixed parameters simplify to constants (typically zero), and Jacobian entries for those parameters become exactly zero. This reduces the effective system size without changing the linear algebra.
|
||||
|
||||
### Single-equation pass
|
||||
|
||||
Scans residuals for any that depend on exactly one free variable. If the residual is linear in that variable (`a*x + b = 0`), it solves `x = -b/a` analytically, fixes the variable, and re-substitutes.
|
||||
|
||||
The pass repeats until no more single-variable residuals can be solved. This handles cascading dependencies: solving one variable may reduce another residual to single-variable form.
|
||||
|
||||
## Newton-Raphson
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/newton.py`
|
||||
|
||||
The primary iterative solver. Each iteration:
|
||||
|
||||
1. Evaluate the residual vector `r` and check convergence (`||r|| < tol`)
|
||||
2. Evaluate the Jacobian matrix `J` by calling `expr.eval()` on pre-computed symbolic derivatives
|
||||
3. Solve `J @ dx = -r` via `numpy.linalg.lstsq` (handles rank-deficient systems)
|
||||
4. Update parameters: `x += dx`
|
||||
5. Apply half-space correction (if configured)
|
||||
6. Re-normalize quaternions to unit length
|
||||
|
||||
### Symbolic Jacobian
|
||||
|
||||
The Jacobian is built once before the solve loop by calling `r.diff(name).simplify()` for every (residual, free parameter) pair. The resulting `Expr` trees are stored and re-evaluated at the current parameter values each iteration. This gives exact derivatives with no step-size tuning.
|
||||
|
||||
### Weighted minimum-norm
|
||||
|
||||
When a weight vector is provided, the step is column-scaled to produce the weighted minimum-norm solution. The solver scales J by W^{-1/2}, solves the scaled system, then unscales the step. This biases the solver toward solutions requiring smaller parameter changes in high-weight dimensions.
|
||||
|
||||
The default weight vector assigns `1.0` to translation parameters and `~3283` to quaternion parameters (the square of 180/pi), making a 1-radian rotation equivalent to a ~57-unit translation. This produces physically intuitive solutions that prefer translating over rotating.
|
||||
|
||||
### Quaternion renormalization
|
||||
|
||||
After each Newton step, quaternion parameter groups `(qw, qx, qy, qz)` are re-projected onto the unit sphere by dividing by their norm. This prevents the quaternion from drifting away from unit length during iteration (the quaternion normalization residual only enforces this at convergence, not during intermediate steps).
|
||||
|
||||
If a quaternion degenerates to near-zero norm, it is reset to the identity quaternion `(1, 0, 0, 0)`.
|
||||
|
||||
### Convergence
|
||||
|
||||
Newton-Raphson runs for up to 100 iterations with tolerance `1e-10` on the residual norm. For well-conditioned systems near the solution, convergence is typically quadratic (3-5 iterations). Interactive drag from a nearby position typically converges in 1-2 iterations.
|
||||
|
||||
## L-BFGS-B fallback
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/bfgs.py`
|
||||
|
||||
If Newton-Raphson fails to converge, L-BFGS-B minimizes the sum of squared residuals: `f(x) = 0.5 * sum(r_i^2)`. This is a quasi-Newton method that approximates the Hessian from gradient history, with bounded memory usage.
|
||||
|
||||
The gradient is computed analytically from the same symbolic Jacobian: `grad = J^T @ r`. This is passed directly to `scipy.optimize.minimize` via the `jac=True` interface to avoid redundant function evaluations.
|
||||
|
||||
L-BFGS-B is more robust for ill-conditioned systems where the Jacobian is nearly singular, but converges more slowly (superlinear rather than quadratic). It runs for up to 200 iterations.
|
||||
|
||||
If scipy is not available, the fallback is skipped gracefully.
|
||||
|
||||
## Graph decomposition
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/decompose.py`
|
||||
|
||||
For assemblies with 8 or more free bodies, the solver decomposes the constraint graph into clusters and solves them independently. This improves performance for large assemblies by reducing the Jacobian size from O(n^2) to the sum of smaller cluster Jacobians.
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Build constraint graph.** Bodies are nodes, constraints are edges weighted by their residual count (DOF removed). Grounded bodies are tagged.
|
||||
|
||||
2. **Find biconnected components.** Using `networkx.biconnected_components()`, decompose the graph into rigid clusters. Articulation points (bodies shared between clusters) are identified.
|
||||
|
||||
3. **Build block-cut tree.** A bipartite graph of clusters and articulation points, rooted at a grounded cluster.
|
||||
|
||||
4. **BFS ordering.** Traverse the block-cut tree root-to-leaf, producing a solve order where grounded clusters come first and boundary conditions propagate outward.
|
||||
|
||||
5. **Solve each cluster.** For each cluster in order:
|
||||
- Fix boundary bodies that were already solved by previous clusters (their parameters become constants)
|
||||
- Collect the cluster's residuals and quaternion normalization equations
|
||||
- Run substitution pass (compiles fixed boundary values to constants)
|
||||
- Newton-Raphson + BFGS fallback on the reduced system
|
||||
- Mark the cluster's bodies as solved
|
||||
- Unfix boundary parameters for downstream clusters
|
||||
|
||||
### Example
|
||||
|
||||
Consider a chain of 4 bodies: `Ground -- A -- B -- C` with joints at each connection. This decomposes into two biconnected components (if the joints create articulation points):
|
||||
|
||||
- Cluster 1: {Ground, A} -- solved first (grounded)
|
||||
- Cluster 2: {A, B, C} -- solved second with A's parameters fixed to Cluster 1's result
|
||||
|
||||
The 21-variable monolithic system (3 free bodies x 7 params) becomes two smaller systems solved in sequence.
|
||||
|
||||
### Disconnected sub-assemblies
|
||||
|
||||
The decomposition also handles disconnected components. Each connected component of the constraint graph is decomposed independently. Components without a grounded body will fail to solve (returning `NoGroundedParts`).
|
||||
|
||||
### Pebble game integration
|
||||
|
||||
The `classify_cluster_rigidity()` function uses the pebble game algorithm from `GNN/solver/datagen/` to classify clusters as well-constrained, underconstrained, overconstrained, or mixed. This provides fast O(n) rigidity analysis without running the full solver.
|
||||
|
||||
## Half-space tracking
|
||||
|
||||
**Source:** `mods/solver/kindred_solver/preference.py`
|
||||
|
||||
Many constraints have multiple valid solutions (branches). A distance constraint between two points can be satisfied with the points on either side of each other. Parallel axes can point in the same or opposite directions.
|
||||
|
||||
Half-space tracking preserves the initial configuration branch:
|
||||
|
||||
1. **At setup:** Evaluate an indicator function for each branching constraint. Record its sign as the reference branch.
|
||||
|
||||
2. **After each Newton step:** Re-evaluate the indicator. If the sign flipped, apply a correction to push the solution back to the reference branch.
|
||||
|
||||
Tracked constraint types:
|
||||
|
||||
| Constraint | Indicator | Correction |
|
||||
|-----------|-----------|------------|
|
||||
| DistancePointPoint (d > 0) | Dot product of displacement with reference direction | Reflect the moving body's position |
|
||||
| Parallel | `z_i . z_j` (same vs. opposite direction) | None (tracked for monitoring) |
|
||||
| Angle | Dominant cross product component | None (tracked for monitoring) |
|
||||
| Perpendicular | Dominant cross product component | None (tracked for monitoring) |
|
||||
256
docs/src/solver/writing-a-solver.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# Writing a Custom Solver
|
||||
|
||||
The KCSolve framework lets you implement a solver backend in pure Python, register it at runtime, and select it through the Assembly preferences. This tutorial walks through building a minimal solver and then extending it.
|
||||
|
||||
## Minimal solver
|
||||
|
||||
A solver must subclass `kcsolve.IKCSolver` and implement three methods:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
|
||||
class MySolver(kcsolve.IKCSolver):
|
||||
def __init__(self):
|
||||
super().__init__() # required for pybind11 trampoline
|
||||
|
||||
def name(self):
|
||||
return "My Custom Solver"
|
||||
|
||||
def supported_joints(self):
|
||||
return [
|
||||
kcsolve.BaseJointKind.Fixed,
|
||||
kcsolve.BaseJointKind.Revolute,
|
||||
]
|
||||
|
||||
def solve(self, ctx):
|
||||
result = kcsolve.SolveResult()
|
||||
|
||||
# Find grounded parts
|
||||
grounded = {p.id for p in ctx.parts if p.grounded}
|
||||
if not grounded:
|
||||
result.status = kcsolve.SolveStatus.NoGroundedParts
|
||||
return result
|
||||
|
||||
# Your solving logic here...
|
||||
# For each non-grounded part, compute its solved placement
|
||||
for part in ctx.parts:
|
||||
if part.grounded:
|
||||
continue
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = part.id
|
||||
pr.placement = part.placement # use current placement as placeholder
|
||||
result.placements = result.placements + [pr]
|
||||
|
||||
result.status = kcsolve.SolveStatus.Success
|
||||
result.dof = 0
|
||||
return result
|
||||
```
|
||||
|
||||
Register it:
|
||||
|
||||
```python
|
||||
kcsolve.register_solver("my_solver", MySolver)
|
||||
```
|
||||
|
||||
Test it from the FreeCAD console:
|
||||
|
||||
```python
|
||||
solver = kcsolve.load("my_solver")
|
||||
print(solver.name()) # "My Custom Solver"
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
# ... build context ...
|
||||
result = solver.solve(ctx)
|
||||
print(result.status) # SolveStatus.Success
|
||||
```
|
||||
|
||||
## Addon packaging
|
||||
|
||||
To make your solver load automatically, create a FreeCAD addon:
|
||||
|
||||
```
|
||||
my_solver_addon/
|
||||
package.xml # Addon manifest
|
||||
Init.py # Registration entry point
|
||||
my_solver/
|
||||
__init__.py
|
||||
solver.py # MySolver class
|
||||
```
|
||||
|
||||
**package.xml:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1">
|
||||
<name>MyCustomSolver</name>
|
||||
<description>Custom assembly constraint solver</description>
|
||||
<version>0.1.0</version>
|
||||
<content>
|
||||
<preferencepack>
|
||||
<name>MySolver</name>
|
||||
</preferencepack>
|
||||
</content>
|
||||
</package>
|
||||
```
|
||||
|
||||
**Init.py:**
|
||||
```python
|
||||
import kcsolve
|
||||
from my_solver.solver import MySolver
|
||||
kcsolve.register_solver("my_solver", MySolver)
|
||||
```
|
||||
|
||||
Place the addon in the FreeCAD Mod directory or as a git submodule in `mods/`.
|
||||
|
||||
## Working with SolveContext
|
||||
|
||||
The `SolveContext` contains everything the solver needs:
|
||||
|
||||
### Parts
|
||||
|
||||
```python
|
||||
for part in ctx.parts:
|
||||
print(f"{part.id}: grounded={part.grounded}")
|
||||
print(f" position: {list(part.placement.position)}")
|
||||
print(f" quaternion: {list(part.placement.quaternion)}")
|
||||
print(f" mass: {part.mass}")
|
||||
```
|
||||
|
||||
Each part has 7 degrees of freedom: 3 translation (x, y, z) and 4 quaternion components (w, x, y, z) with a unit-norm constraint reducing the rotational DOF to 3.
|
||||
|
||||
**Quaternion convention:** `(w, x, y, z)` where `w` is the scalar part. This differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles the swap.
|
||||
|
||||
### Constraints
|
||||
|
||||
```python
|
||||
for c in ctx.constraints:
|
||||
if not c.activated:
|
||||
continue
|
||||
print(f"{c.id}: {c.type} between {c.part_i} and {c.part_j}")
|
||||
print(f" marker_i: pos={list(c.marker_i.position)}, "
|
||||
f"quat={list(c.marker_i.quaternion)}")
|
||||
print(f" params: {list(c.params)}")
|
||||
print(f" limits: {len(c.limits)}")
|
||||
```
|
||||
|
||||
The marker transforms define local coordinate frames on each part. The constraint type determines what geometric relationship is enforced between these frames.
|
||||
|
||||
### Returning results
|
||||
|
||||
```python
|
||||
result = kcsolve.SolveResult()
|
||||
result.status = kcsolve.SolveStatus.Success
|
||||
result.dof = computed_dof
|
||||
|
||||
placements = []
|
||||
for part_id, pos, quat in solved_parts:
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = part_id
|
||||
pr.placement = kcsolve.Transform()
|
||||
pr.placement.position = list(pos)
|
||||
pr.placement.quaternion = list(quat)
|
||||
placements.append(pr)
|
||||
result.placements = placements
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**Important:** pybind11 list fields return copies. Use `result.placements = [...]` (whole-list assignment), not `result.placements.append(...)`.
|
||||
|
||||
## Adding optional capabilities
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Override `diagnose()` to detect overconstrained or malformed assemblies:
|
||||
|
||||
```python
|
||||
def diagnose(self, ctx):
|
||||
diagnostics = []
|
||||
# ... analyze constraints ...
|
||||
d = kcsolve.ConstraintDiagnostic()
|
||||
d.constraint_id = "Joint001"
|
||||
d.kind = kcsolve.DiagnosticKind.Redundant
|
||||
d.detail = "This joint duplicates Joint002"
|
||||
diagnostics.append(d)
|
||||
return diagnostics
|
||||
```
|
||||
|
||||
### Interactive drag
|
||||
|
||||
Override the three drag methods for real-time viewport dragging:
|
||||
|
||||
```python
|
||||
def pre_drag(self, ctx, drag_parts):
|
||||
self._ctx = ctx
|
||||
self._dragging = set(drag_parts)
|
||||
return self.solve(ctx)
|
||||
|
||||
def drag_step(self, drag_placements):
|
||||
# Update dragged parts in stored context
|
||||
for pr in drag_placements:
|
||||
for part in self._ctx.parts:
|
||||
if part.id == pr.id:
|
||||
part.placement = pr.placement
|
||||
break
|
||||
return self.solve(self._ctx)
|
||||
|
||||
def post_drag(self):
|
||||
self._ctx = None
|
||||
self._dragging = None
|
||||
```
|
||||
|
||||
For responsive dragging, the solver should converge quickly from a nearby initial guess. Use warm-starting (current placements as initial guess) and consider caching internal state across drag steps.
|
||||
|
||||
### Incremental update
|
||||
|
||||
Override `update()` for the case where only constraint parameters changed (not topology):
|
||||
|
||||
```python
|
||||
def update(self, ctx):
|
||||
# Reuse cached factorization, only re-evaluate changed residuals
|
||||
return self.solve(ctx) # default: just re-solve
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (without FreeCAD)
|
||||
|
||||
Test your solver logic with hand-built `SolveContext` objects:
|
||||
|
||||
```python
|
||||
import kcsolve
|
||||
|
||||
def test_fixed_joint():
|
||||
ctx = kcsolve.SolveContext()
|
||||
|
||||
base = kcsolve.Part()
|
||||
base.id = "base"
|
||||
base.grounded = True
|
||||
|
||||
arm = kcsolve.Part()
|
||||
arm.id = "arm"
|
||||
arm.placement.position = [100.0, 0.0, 0.0]
|
||||
|
||||
joint = kcsolve.Constraint()
|
||||
joint.id = "Joint001"
|
||||
joint.part_i = "base"
|
||||
joint.part_j = "arm"
|
||||
joint.type = kcsolve.BaseJointKind.Fixed
|
||||
|
||||
ctx.parts = [base, arm]
|
||||
ctx.constraints = [joint]
|
||||
|
||||
solver = MySolver()
|
||||
result = solver.solve(ctx)
|
||||
assert result.status == kcsolve.SolveStatus.Success
|
||||
```
|
||||
|
||||
### Integration tests (with FreeCAD)
|
||||
|
||||
For integration testing within FreeCAD, follow the pattern in `TestKindredSolverIntegration.py`: set the solver preference in `setUp()`, create document objects, and verify solve results.
|
||||
|
||||
## Reference
|
||||
|
||||
- [KCSolve Python API](../reference/kcsolve-python.md) -- complete type and function reference
|
||||
- [KCSolve Architecture](../architecture/ondsel-solver.md) -- C++ framework details
|
||||
- [Constraints](constraints.md) -- constraint types and residual counts
|
||||
- [Kindred Solver Overview](overview.md) -- how the built-in Kindred solver works
|
||||
1
mods/gears
Submodule
@@ -1,34 +1,53 @@
|
||||
# kindred-addon-sdk — stable API for Kindred Create addon integration
|
||||
|
||||
from kindred_sdk.version import SDK_VERSION
|
||||
from kindred_sdk.context import (
|
||||
register_context,
|
||||
unregister_context,
|
||||
register_overlay,
|
||||
unregister_overlay,
|
||||
inject_commands,
|
||||
current_context,
|
||||
refresh_context,
|
||||
)
|
||||
from kindred_sdk.theme import get_theme_tokens, load_palette
|
||||
from kindred_sdk.origin import register_origin, unregister_origin
|
||||
from kindred_sdk.dock import register_dock_panel
|
||||
from kindred_sdk.command import register_command
|
||||
from kindred_sdk.compat import create_version, freecad_version
|
||||
from kindred_sdk.context import (
|
||||
current_context,
|
||||
inject_commands,
|
||||
refresh_context,
|
||||
register_context,
|
||||
register_overlay,
|
||||
unregister_context,
|
||||
unregister_overlay,
|
||||
)
|
||||
from kindred_sdk.dock import register_dock_panel
|
||||
from kindred_sdk.menu import register_menu
|
||||
from kindred_sdk.origin import (
|
||||
active_origin,
|
||||
get_origin,
|
||||
list_origins,
|
||||
register_origin,
|
||||
set_active_origin,
|
||||
unregister_origin,
|
||||
)
|
||||
from kindred_sdk.statusbar import register_status_widget
|
||||
from kindred_sdk.theme import get_theme_tokens, load_palette
|
||||
from kindred_sdk.toolbar import register_toolbar
|
||||
from kindred_sdk.version import SDK_VERSION
|
||||
|
||||
__all__ = [
|
||||
"SDK_VERSION",
|
||||
"register_context",
|
||||
"unregister_context",
|
||||
"register_overlay",
|
||||
"unregister_overlay",
|
||||
"inject_commands",
|
||||
"current_context",
|
||||
"refresh_context",
|
||||
"get_theme_tokens",
|
||||
"load_palette",
|
||||
"register_origin",
|
||||
"unregister_origin",
|
||||
"register_dock_panel",
|
||||
"active_origin",
|
||||
"create_version",
|
||||
"current_context",
|
||||
"freecad_version",
|
||||
"get_origin",
|
||||
"get_theme_tokens",
|
||||
"inject_commands",
|
||||
"list_origins",
|
||||
"load_palette",
|
||||
"refresh_context",
|
||||
"register_command",
|
||||
"register_context",
|
||||
"register_dock_panel",
|
||||
"register_menu",
|
||||
"register_origin",
|
||||
"register_status_widget",
|
||||
"register_toolbar",
|
||||
"set_active_origin",
|
||||
"unregister_context",
|
||||
"unregister_origin",
|
||||
"unregister_overlay",
|
||||
"register_overlay",
|
||||
]
|
||||
|
||||
58
mods/sdk/kindred_sdk/command.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Command registration wrapper.
|
||||
|
||||
Provides a standardized SDK entry point for registering FreeCAD commands.
|
||||
This is a thin wrapper around ``FreeCADGui.addCommand()`` — no C++ interface
|
||||
is needed since FreeCAD's command system is already stable and well-known.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
def register_command(name, activated, resources, is_active=None):
|
||||
"""Register a FreeCAD command through the SDK.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Command name (e.g. ``"MyAddon_DoThing"``).
|
||||
activated : callable
|
||||
Called when command is triggered. Receives an optional ``int``
|
||||
index argument (for group commands).
|
||||
resources : dict
|
||||
Command resources passed to ``GetResources()``. Common keys:
|
||||
``MenuText``, ``ToolTip``, ``Pixmap``, ``Accel``.
|
||||
is_active : callable, optional
|
||||
Zero-arg callable returning ``True`` when the command should be
|
||||
enabled. Default: always active.
|
||||
"""
|
||||
if not callable(activated):
|
||||
raise TypeError("activated must be callable")
|
||||
if not isinstance(resources, dict):
|
||||
raise TypeError("resources must be a dict")
|
||||
if is_active is not None and not callable(is_active):
|
||||
raise TypeError("is_active must be callable or None")
|
||||
|
||||
_resources = dict(resources)
|
||||
_activated = activated
|
||||
_is_active = is_active
|
||||
|
||||
class _SDKCommand:
|
||||
def GetResources(self):
|
||||
return _resources
|
||||
|
||||
def Activated(self, index=0):
|
||||
_activated()
|
||||
|
||||
def IsActive(self):
|
||||
if _is_active is not None:
|
||||
return bool(_is_active())
|
||||
return True
|
||||
|
||||
try:
|
||||
import FreeCADGui
|
||||
|
||||
FreeCADGui.addCommand(name, _SDKCommand())
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register command '{name}': {e}\n"
|
||||
)
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Editing context and overlay registration wrappers.
|
||||
|
||||
Thin wrappers around FreeCADGui editing context bindings. If the
|
||||
underlying C++ API changes during an upstream rebase, only this module
|
||||
needs to be updated.
|
||||
Routes through the ``kcsdk`` C++ module. The legacy ``FreeCADGui``
|
||||
Python bindings are deprecated — kcsdk is required.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
try:
|
||||
import kcsdk as _kcsdk
|
||||
except ImportError:
|
||||
_kcsdk = None
|
||||
|
||||
def _gui():
|
||||
"""Lazy import of FreeCADGui (not available in console mode)."""
|
||||
import FreeCADGui
|
||||
|
||||
return FreeCADGui
|
||||
def _require_kcsdk():
|
||||
if _kcsdk is None:
|
||||
raise RuntimeError(
|
||||
"kcsdk module not available. "
|
||||
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
|
||||
)
|
||||
|
||||
|
||||
def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
@@ -40,8 +45,9 @@ def register_context(context_id, label, color, toolbars, match, priority=50):
|
||||
if not callable(match):
|
||||
raise TypeError("match must be callable")
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().registerEditingContext(context_id, label, color, toolbars, match, priority)
|
||||
_kcsdk.register_context(context_id, label, color, toolbars, match, priority)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register context '{context_id}': {e}\n"
|
||||
@@ -53,8 +59,9 @@ def unregister_context(context_id):
|
||||
if not isinstance(context_id, str):
|
||||
raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().unregisterEditingContext(context_id)
|
||||
_kcsdk.unregister_context(context_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n"
|
||||
@@ -83,8 +90,9 @@ def register_overlay(overlay_id, toolbars, match):
|
||||
if not callable(match):
|
||||
raise TypeError("match must be callable")
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().registerEditingOverlay(overlay_id, toolbars, match)
|
||||
_kcsdk.register_overlay(overlay_id, toolbars, match)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n"
|
||||
@@ -96,8 +104,9 @@ def unregister_overlay(overlay_id):
|
||||
if not isinstance(overlay_id, str):
|
||||
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().unregisterEditingOverlay(overlay_id)
|
||||
_kcsdk.unregister_overlay(overlay_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n"
|
||||
@@ -123,8 +132,9 @@ def inject_commands(context_id, toolbar_name, commands):
|
||||
if not isinstance(commands, list):
|
||||
raise TypeError(f"commands must be list, got {type(commands).__name__}")
|
||||
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().injectEditingCommands(context_id, toolbar_name, commands)
|
||||
_kcsdk.inject_commands(context_id, toolbar_name, commands)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n"
|
||||
@@ -137,16 +147,20 @@ def current_context():
|
||||
Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``,
|
||||
``breadcrumbColors``. Returns ``None`` if no context is active.
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
return _gui().currentEditingContext()
|
||||
return _kcsdk.current_context()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to get current context: {e}\n")
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to get current context: {e}\n"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def refresh_context():
|
||||
"""Force re-resolution and update of the editing context."""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
_gui().refreshEditingContext()
|
||||
_kcsdk.refresh()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n")
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
"""Deferred dock panel registration helper.
|
||||
"""Dock panel registration helper.
|
||||
|
||||
Replaces the manual ``QTimer.singleShot()`` + duplicate-check +
|
||||
try/except pattern used in ``src/Mod/Create/InitGui.py``.
|
||||
Routes through the ``kcsdk`` C++ module (IPanelProvider / DockWindowManager).
|
||||
The kcsdk module is required — legacy PySide fallback has been removed.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
_AREA_MAP = {
|
||||
"left": 1, # Qt.LeftDockWidgetArea
|
||||
"right": 2, # Qt.RightDockWidgetArea
|
||||
"top": 4, # Qt.TopDockWidgetArea
|
||||
"bottom": 8, # Qt.BottomDockWidgetArea
|
||||
}
|
||||
try:
|
||||
import kcsdk as _kcsdk
|
||||
except ImportError:
|
||||
_kcsdk = None
|
||||
|
||||
_DOCK_AREA_MAP = None # lazily populated from kcsdk
|
||||
|
||||
|
||||
def _require_kcsdk():
|
||||
if _kcsdk is None:
|
||||
raise RuntimeError(
|
||||
"kcsdk module not available. "
|
||||
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
|
||||
)
|
||||
|
||||
|
||||
def _get_dock_area(area_str):
|
||||
"""Convert area string to kcsdk.DockArea enum value."""
|
||||
global _DOCK_AREA_MAP
|
||||
if _DOCK_AREA_MAP is None:
|
||||
_DOCK_AREA_MAP = {
|
||||
"left": _kcsdk.DockArea.Left,
|
||||
"right": _kcsdk.DockArea.Right,
|
||||
"top": _kcsdk.DockArea.Top,
|
||||
"bottom": _kcsdk.DockArea.Bottom,
|
||||
}
|
||||
return _DOCK_AREA_MAP.get(area_str)
|
||||
|
||||
|
||||
_AREA_NAMES = ("left", "right", "top", "bottom")
|
||||
|
||||
|
||||
def register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0):
|
||||
@@ -37,37 +61,41 @@ def register_dock_panel(object_name, title, widget_factory, area="right", delay_
|
||||
raise TypeError(f"object_name must be str, got {type(object_name).__name__}")
|
||||
if not callable(widget_factory):
|
||||
raise TypeError("widget_factory must be callable")
|
||||
if area not in _AREA_NAMES:
|
||||
raise ValueError(f"area must be one of {list(_AREA_NAMES)}, got {area!r}")
|
||||
|
||||
qt_area = _AREA_MAP.get(area)
|
||||
if qt_area is None:
|
||||
raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}")
|
||||
_require_kcsdk()
|
||||
|
||||
def _create():
|
||||
try:
|
||||
from PySide import QtCore, QtWidgets
|
||||
dock_area = _get_dock_area(area)
|
||||
|
||||
import FreeCADGui
|
||||
class _AnonymousProvider(_kcsdk.IPanelProvider):
|
||||
def id(self):
|
||||
return object_name
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
def title(self):
|
||||
return title
|
||||
|
||||
if mw.findChild(QtWidgets.QDockWidget, object_name):
|
||||
return
|
||||
def create_widget(self):
|
||||
return widget_factory()
|
||||
|
||||
widget = widget_factory()
|
||||
panel = QtWidgets.QDockWidget(title, mw)
|
||||
panel.setObjectName(object_name)
|
||||
panel.setWidget(widget)
|
||||
mw.addDockWidget(QtCore.Qt.DockWidgetArea(qt_area), panel)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(f"kindred_sdk: Dock panel '{object_name}' skipped: {e}\n")
|
||||
def preferred_area(self):
|
||||
return dock_area
|
||||
|
||||
try:
|
||||
_kcsdk.register_panel(_AnonymousProvider())
|
||||
|
||||
def _create():
|
||||
try:
|
||||
_kcsdk.create_panel(object_name)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Panel '{object_name}' creation failed: {e}\n"
|
||||
)
|
||||
|
||||
from PySide.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(max(0, delay_ms), _create)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Could not schedule dock panel '{object_name}': {e}\n"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Panel registration failed for '{object_name}': {e}\n"
|
||||
)
|
||||
|
||||
21
mods/sdk/kindred_sdk/menu.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Menu provider registration.
|
||||
|
||||
Routes through the ``kcsdk`` C++ module. The kcsdk module is required.
|
||||
"""
|
||||
|
||||
|
||||
def register_menu(provider):
|
||||
"""Register a menu provider for declarative menu placement.
|
||||
|
||||
Delegates to ``kcsdk.register_menu()`` which installs a shared
|
||||
WorkbenchManipulator that injects items at the specified menu path.
|
||||
"""
|
||||
try:
|
||||
import kcsdk
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"kcsdk module not available. "
|
||||
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
|
||||
)
|
||||
|
||||
kcsdk.register_menu(provider)
|
||||
@@ -1,21 +1,38 @@
|
||||
"""FileOrigin registration wrappers.
|
||||
"""FileOrigin registration and query wrappers.
|
||||
|
||||
Wraps ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation
|
||||
and error handling. Addons implement the FileOrigin duck-typed
|
||||
interface directly (see Silo's ``SiloOrigin`` for the full contract).
|
||||
Registration (``register_origin`` / ``unregister_origin``) wraps
|
||||
``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation.
|
||||
|
||||
Query functions (``list_origins``, ``active_origin``, etc.) route
|
||||
through the ``kcsdk`` C++ module. The kcsdk module is required for
|
||||
query operations.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
try:
|
||||
import kcsdk as _kcsdk
|
||||
except ImportError:
|
||||
_kcsdk = None
|
||||
|
||||
_REQUIRED_METHODS = ("id", "name", "type", "ownsDocument")
|
||||
|
||||
|
||||
def _gui():
|
||||
"""Lazy import of FreeCADGui (not available in console mode)."""
|
||||
import FreeCADGui
|
||||
|
||||
return FreeCADGui
|
||||
|
||||
|
||||
def _require_kcsdk():
|
||||
if _kcsdk is None:
|
||||
raise RuntimeError(
|
||||
"kcsdk module not available. "
|
||||
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
|
||||
)
|
||||
|
||||
|
||||
def register_origin(origin):
|
||||
"""Register a FileOrigin with FreeCADGui.
|
||||
|
||||
@@ -40,3 +57,80 @@ def unregister_origin(origin):
|
||||
FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n")
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to unregister origin: {e}\n")
|
||||
|
||||
|
||||
def list_origins():
|
||||
"""Return IDs of all registered origins.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
Origin IDs (always includes ``"local"``).
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
return _kcsdk.list_origins()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: list_origins failed: {e}\n")
|
||||
return []
|
||||
|
||||
|
||||
def active_origin():
|
||||
"""Return info about the currently active origin.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None
|
||||
Origin info dict with keys: ``id``, ``name``, ``nickname``,
|
||||
``type``, ``tracksExternally``, ``requiresAuthentication``,
|
||||
``supportsRevisions``, ``supportsBOM``, ``supportsPartNumbers``,
|
||||
``connectionState``.
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
return _kcsdk.active_origin()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: active_origin failed: {e}\n")
|
||||
return None
|
||||
|
||||
|
||||
def set_active_origin(origin_id):
|
||||
"""Set the active origin by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
origin_id : str
|
||||
The origin ID to activate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the origin was found and activated.
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
return _kcsdk.set_active_origin(origin_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: set_active_origin failed: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def get_origin(origin_id):
|
||||
"""Get info about a specific origin by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
origin_id : str
|
||||
The origin ID to look up.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None
|
||||
Origin info dict, or None if not found.
|
||||
"""
|
||||
_require_kcsdk()
|
||||
try:
|
||||
return _kcsdk.get_origin(origin_id)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: get_origin failed: {e}\n")
|
||||
return None
|
||||
|
||||
74
mods/sdk/kindred_sdk/statusbar.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Status bar widget registration wrapper.
|
||||
|
||||
Provides a standardized SDK entry point for adding widgets to the main
|
||||
window's status bar. This is a pure Python wrapper — no C++ interface
|
||||
is needed since the widget is created once and Qt manages its lifecycle.
|
||||
"""
|
||||
|
||||
import FreeCAD
|
||||
|
||||
|
||||
def register_status_widget(object_name, label, widget, position="right"):
|
||||
"""Add a widget to the main window status bar.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
object_name : str
|
||||
Qt object name for duplicate prevention.
|
||||
label : str
|
||||
Display name shown in the status bar's right-click context menu
|
||||
(becomes the widget's ``windowTitle``).
|
||||
widget : QWidget
|
||||
The widget instance to add. The caller keeps its own reference
|
||||
for live updates (e.g. connecting signals, updating text).
|
||||
position : str, optional
|
||||
``"left"`` (stretches) or ``"right"`` (permanent, fixed width).
|
||||
Default ``"right"``.
|
||||
"""
|
||||
if not isinstance(object_name, str) or not object_name:
|
||||
raise TypeError("object_name must be a non-empty string")
|
||||
if not isinstance(label, str) or not label:
|
||||
raise TypeError("label must be a non-empty string")
|
||||
if position not in ("left", "right"):
|
||||
raise ValueError(f"position must be 'left' or 'right', got {position!r}")
|
||||
|
||||
try:
|
||||
import FreeCADGui
|
||||
from PySide import QtWidgets
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
"kindred_sdk: Main window not available, "
|
||||
f"cannot register status widget '{object_name}'\n"
|
||||
)
|
||||
return
|
||||
|
||||
sb = mw.statusBar()
|
||||
|
||||
# Duplicate check
|
||||
for child in sb.children():
|
||||
if (
|
||||
isinstance(child, QtWidgets.QWidget)
|
||||
and child.objectName() == object_name
|
||||
):
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Status widget '{object_name}' already exists, skipping\n"
|
||||
)
|
||||
return
|
||||
|
||||
widget.setObjectName(object_name)
|
||||
widget.setWindowTitle(label)
|
||||
|
||||
if position == "left":
|
||||
sb.addWidget(widget)
|
||||
else:
|
||||
sb.addPermanentWidget(widget)
|
||||
|
||||
FreeCAD.Console.PrintLog(
|
||||
f"kindred_sdk: Registered status widget '{object_name}'\n"
|
||||
)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to register status widget '{object_name}': {e}\n"
|
||||
)
|
||||
@@ -135,15 +135,51 @@ _cache = {}
|
||||
_PALETTES_DIR = os.path.join(os.path.dirname(__file__), "palettes")
|
||||
|
||||
|
||||
def _kcsdk_available():
|
||||
"""Return the kcsdk module if available, else None."""
|
||||
try:
|
||||
import kcsdk
|
||||
|
||||
return kcsdk
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def load_palette(name="catppuccin-mocha"):
|
||||
"""Load a named palette from the ``palettes/`` directory.
|
||||
|
||||
When the C++ ``kcsdk`` module is available (GUI mode), delegates to
|
||||
``kcsdk.load_palette()`` and builds a ``Palette`` from the C++ token
|
||||
map. Falls back to the Python YAML loader for console mode.
|
||||
|
||||
Results are cached; subsequent calls with the same *name* return
|
||||
the same ``Palette`` instance.
|
||||
"""
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
|
||||
# Try C++ backend first
|
||||
kcsdk = _kcsdk_available()
|
||||
if kcsdk is not None:
|
||||
try:
|
||||
if kcsdk.load_palette(name):
|
||||
tokens = kcsdk.theme_tokens()
|
||||
# Separate colors from roles by checking if the token
|
||||
# existed in the original colors set. Since the C++ engine
|
||||
# merges them, we rebuild by loading the YAML for metadata.
|
||||
# Simpler approach: use all tokens as colors (roles are
|
||||
# already resolved to hex values in the C++ engine).
|
||||
palette = Palette(
|
||||
name=name,
|
||||
slug=name,
|
||||
colors=tokens,
|
||||
roles={},
|
||||
)
|
||||
_cache[name] = palette
|
||||
return palette
|
||||
except Exception:
|
||||
pass # Fall through to Python loader
|
||||
|
||||
path = os.path.join(_PALETTES_DIR, f"{name}.yaml")
|
||||
if not os.path.isfile(path):
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n")
|
||||
@@ -152,7 +188,9 @@ def load_palette(name="catppuccin-mocha"):
|
||||
try:
|
||||
raw = _load_yaml(path)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to load palette '{name}': {e}\n")
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"kindred_sdk: Failed to load palette '{name}': {e}\n"
|
||||
)
|
||||
return None
|
||||
|
||||
palette = Palette(
|
||||
@@ -172,9 +210,20 @@ def load_palette(name="catppuccin-mocha"):
|
||||
def get_theme_tokens(name="catppuccin-mocha"):
|
||||
"""Return a dict of ``{token_name: "#hex"}`` for all colors in a palette.
|
||||
|
||||
This is a convenience shorthand for ``load_palette(name).colors``.
|
||||
When the C++ ``kcsdk`` module is available, delegates directly to
|
||||
``kcsdk.theme_tokens()`` for best performance. Falls back to the
|
||||
Python palette loader otherwise.
|
||||
|
||||
Returns a copy so callers cannot mutate the cached palette.
|
||||
"""
|
||||
kcsdk = _kcsdk_available()
|
||||
if kcsdk is not None:
|
||||
try:
|
||||
kcsdk.load_palette(name)
|
||||
return dict(kcsdk.theme_tokens())
|
||||
except Exception:
|
||||
pass # Fall through to Python loader
|
||||
|
||||
palette = load_palette(name)
|
||||
if palette is None:
|
||||
return {}
|
||||
|
||||
21
mods/sdk/kindred_sdk/toolbar.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Toolbar provider registration.
|
||||
|
||||
Routes through the ``kcsdk`` C++ module. The kcsdk module is required.
|
||||
"""
|
||||
|
||||
|
||||
def register_toolbar(provider):
|
||||
"""Register a toolbar provider for automatic context injection.
|
||||
|
||||
Delegates to ``kcsdk.register_toolbar()`` which stores the provider
|
||||
and auto-injects commands into the target editing contexts.
|
||||
"""
|
||||
try:
|
||||
import kcsdk
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"kcsdk module not available. "
|
||||
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
|
||||
)
|
||||
|
||||
kcsdk.register_toolbar(provider)
|
||||
@@ -1 +1 @@
|
||||
SDK_VERSION = "0.1.0"
|
||||
SDK_VERSION = "1.0.0"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<name>sdk</name>
|
||||
<description>Kindred Create addon SDK - stable API for addon integration</description>
|
||||
<version>0.1.0</version>
|
||||
<version>1.0.0</version>
|
||||
<maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
|
||||
<license file="LICENSE">LGPL-2.1-or-later</license>
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>0</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
</kindred>
|
||||
|
||||
</package>
|
||||
|
||||
1
mods/solver
Submodule
@@ -30,7 +30,7 @@ fi
|
||||
|
||||
# Get version from git if not provided
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.0")
|
||||
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.5")
|
||||
fi
|
||||
|
||||
# Convert version to Debian-compatible format
|
||||
|
||||
@@ -155,6 +155,7 @@ requirements:
|
||||
- lark
|
||||
- lxml
|
||||
- matplotlib-base
|
||||
- networkx
|
||||
- nine
|
||||
- noqt5
|
||||
- numpy>=1.26,<2
|
||||
|
||||
1
reference/quicknav/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
3
reference/quicknav/Init.py
Normal file
@@ -0,0 +1,3 @@
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintLog("quicknav addon loaded\n")
|
||||
37
reference/quicknav/InitGui.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
|
||||
class QuickNavWorkbench(Gui.Workbench):
|
||||
"""Invisible workbench that installs QuickNav on load.
|
||||
|
||||
QuickNav does not replace the active workbench -- it layers on top.
|
||||
Loading QuickNav installs the event filter and nav bar, then
|
||||
immediately re-activates the previously active workbench.
|
||||
"""
|
||||
|
||||
MenuText = "QuickNav"
|
||||
ToolTip = "Keyboard-driven command navigation"
|
||||
|
||||
def Initialize(self):
|
||||
from quicknav.core import QuickNavManager
|
||||
|
||||
QuickNavManager.instance().install()
|
||||
App.Console.PrintMessage("QuickNav workbench initialized\n")
|
||||
|
||||
def Activated(self):
|
||||
from quicknav.core import QuickNavManager
|
||||
|
||||
QuickNavManager.instance().handle_workbench_activated()
|
||||
|
||||
def Deactivated(self):
|
||||
pass
|
||||
|
||||
def GetClassName(self):
|
||||
return "Gui::PythonWorkbench"
|
||||
|
||||
|
||||
Gui.addWorkbench(QuickNavWorkbench())
|
||||
|
||||
# Eager command registration
|
||||
from quicknav.commands import QuickNav_Toggle # noqa: F401
|
||||
25
reference/quicknav/package.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
<name>QuickNav</name>
|
||||
<description>Keyboard-driven command navigation</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
|
||||
<license>LGPL-2.1</license>
|
||||
<url type="repository">https://git.kindred-systems.com/kindred/quicknav</url>
|
||||
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>QuickNavWorkbench</classname>
|
||||
<subdirectory>./</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>20</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
</package>
|
||||
1
reference/quicknav/quicknav/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""QuickNav -- keyboard-driven command navigation for FreeCAD and Kindred Create."""
|
||||
24
reference/quicknav/quicknav/commands.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""FreeCAD command registrations for QuickNav."""
|
||||
|
||||
import FreeCADGui as Gui
|
||||
|
||||
|
||||
class QuickNav_Toggle:
|
||||
"""Toggle QuickNav keyboard navigation on/off."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Toggle QuickNav",
|
||||
"ToolTip": "Toggle keyboard-driven command navigation on/off",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
|
||||
def Activated(self):
|
||||
from quicknav.core import QuickNavManager
|
||||
|
||||
QuickNavManager.instance().toggle_active()
|
||||
|
||||
|
||||
Gui.addCommand("QuickNav_Toggle", QuickNav_Toggle())
|
||||
208
reference/quicknav/quicknav/core.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""QuickNav manager singleton.
|
||||
|
||||
Orchestrates the event filter, navigation bar, and workbench/grouping
|
||||
state. Created once when the QuickNavWorkbench is first activated.
|
||||
"""
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
from quicknav.workbench_map import (
|
||||
get_command,
|
||||
get_grouping,
|
||||
get_groupings,
|
||||
get_workbench_slot,
|
||||
)
|
||||
|
||||
_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/QuickNav"
|
||||
|
||||
|
||||
class QuickNavManager:
|
||||
"""Singleton managing QuickNav lifecycle and state."""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
self._active = False
|
||||
self._installed = False
|
||||
self._event_filter = None
|
||||
self._nav_bar = None
|
||||
self._current_workbench_slot = 2 # PartDesign default
|
||||
self._current_grouping_idx = 0
|
||||
self.previous_workbench = None
|
||||
self._reactivating = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install(self):
|
||||
"""Install the event filter and navigation bar."""
|
||||
if self._installed:
|
||||
return
|
||||
|
||||
mw = Gui.getMainWindow()
|
||||
if mw is None:
|
||||
App.Console.PrintWarning("QuickNav: main window not available\n")
|
||||
return
|
||||
|
||||
# Capture which workbench was active before QuickNav loaded.
|
||||
try:
|
||||
wb = Gui.activeWorkbench()
|
||||
if wb:
|
||||
self.previous_workbench = wb.__class__.__name__
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Event filter
|
||||
from quicknav.event_filter import QuickNavEventFilter
|
||||
|
||||
self._event_filter = QuickNavEventFilter(manager=self, parent=mw)
|
||||
mw.installEventFilter(self._event_filter)
|
||||
|
||||
# Navigation bar
|
||||
from quicknav.nav_bar import QuickNavBar
|
||||
|
||||
self._nav_bar = QuickNavBar(manager=self, parent=mw)
|
||||
mw.addToolBar(self._nav_bar)
|
||||
mw.insertToolBarBreak(self._nav_bar)
|
||||
|
||||
# Read saved preference
|
||||
self._active = self._load_preference()
|
||||
if not self._active:
|
||||
self._nav_bar.hide()
|
||||
|
||||
self._installed = True
|
||||
self._update_nav_bar()
|
||||
App.Console.PrintLog("QuickNav: installed\n")
|
||||
|
||||
def uninstall(self):
|
||||
"""Remove the event filter and navigation bar."""
|
||||
if not self._installed:
|
||||
return
|
||||
|
||||
mw = Gui.getMainWindow()
|
||||
if mw and self._event_filter:
|
||||
mw.removeEventFilter(self._event_filter)
|
||||
self._event_filter = None
|
||||
|
||||
if self._nav_bar:
|
||||
self._nav_bar.hide()
|
||||
self._nav_bar.setParent(None)
|
||||
self._nav_bar.deleteLater()
|
||||
self._nav_bar = None
|
||||
|
||||
self._installed = False
|
||||
App.Console.PrintLog("QuickNav: uninstalled\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_active(self):
|
||||
return self._active
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def toggle_active(self):
|
||||
"""Toggle QuickNav on/off."""
|
||||
self._active = not self._active
|
||||
self._save_preference()
|
||||
if self._nav_bar:
|
||||
if self._active:
|
||||
self._nav_bar.show()
|
||||
self._update_nav_bar()
|
||||
else:
|
||||
self._nav_bar.hide()
|
||||
state = "on" if self._active else "off"
|
||||
App.Console.PrintMessage(f"QuickNav: {state}\n")
|
||||
|
||||
def switch_workbench(self, n):
|
||||
"""Switch to the workbench assigned to Ctrl+N."""
|
||||
slot = get_workbench_slot(n)
|
||||
if slot is None:
|
||||
return
|
||||
self._current_workbench_slot = n
|
||||
self._current_grouping_idx = 0
|
||||
|
||||
# Activate the target workbench in FreeCAD.
|
||||
try:
|
||||
Gui.activateWorkbench(slot["class_name"])
|
||||
except Exception as e:
|
||||
App.Console.PrintWarning(f"QuickNav: could not activate {slot['class_name']}: {e}\n")
|
||||
|
||||
self._update_nav_bar()
|
||||
|
||||
def switch_grouping(self, n):
|
||||
"""Switch to the Nth grouping (1-based) in the current workbench."""
|
||||
slot = get_workbench_slot(self._current_workbench_slot)
|
||||
if slot is None:
|
||||
return
|
||||
groupings = get_groupings(slot["key"])
|
||||
idx = n - 1
|
||||
if 0 <= idx < len(groupings):
|
||||
self._current_grouping_idx = idx
|
||||
self._update_nav_bar()
|
||||
|
||||
def execute_command(self, n):
|
||||
"""Execute the Nth command (1-based) in the active grouping."""
|
||||
slot = get_workbench_slot(self._current_workbench_slot)
|
||||
if slot is None:
|
||||
return
|
||||
cmd_id = get_command(slot["key"], self._current_grouping_idx, n)
|
||||
if cmd_id:
|
||||
try:
|
||||
Gui.runCommand(cmd_id)
|
||||
except Exception as e:
|
||||
App.Console.PrintWarning(f"QuickNav: command {cmd_id} failed: {e}\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workbench re-activation guard
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_workbench_activated(self):
|
||||
"""Called from QuickNavWorkbench.Activated() to restore the
|
||||
previous workbench without infinite recursion."""
|
||||
if self._reactivating:
|
||||
return
|
||||
if self.previous_workbench:
|
||||
self._reactivating = True
|
||||
try:
|
||||
Gui.activateWorkbench(self.previous_workbench)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._reactivating = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_nav_bar(self):
|
||||
if not self._nav_bar:
|
||||
return
|
||||
slot = get_workbench_slot(self._current_workbench_slot)
|
||||
if slot is None:
|
||||
return
|
||||
groupings = get_groupings(slot["key"])
|
||||
grouping = get_grouping(slot["key"], self._current_grouping_idx)
|
||||
commands = grouping["commands"] if grouping else []
|
||||
self._nav_bar.update_display(
|
||||
slot["display"], groupings, self._current_grouping_idx, commands
|
||||
)
|
||||
|
||||
def _load_preference(self):
|
||||
param = App.ParamGet(_PREF_PATH)
|
||||
return param.GetBool("Enabled", True)
|
||||
|
||||
def _save_preference(self):
|
||||
param = App.ParamGet(_PREF_PATH)
|
||||
param.SetBool("Enabled", self._active)
|
||||
84
reference/quicknav/quicknav/event_filter.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Key event filter for QuickNav.
|
||||
|
||||
Installed on FreeCAD's main window to intercept number key presses
|
||||
when QuickNav is active. Passes through all events when inactive
|
||||
(except Key_0 for re-enable toggle).
|
||||
"""
|
||||
|
||||
from PySide.QtCore import QEvent, QObject, Qt
|
||||
from PySide.QtWidgets import (
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
# Widget types that accept text input. QAbstractSpinBox covers
|
||||
# QSpinBox, QDoubleSpinBox, and FreeCAD's custom QuantitySpinBox
|
||||
# (used for Sketcher inline dimension entry).
|
||||
_TEXT_INPUT_TYPES = (QLineEdit, QTextEdit, QPlainTextEdit, QAbstractSpinBox)
|
||||
|
||||
|
||||
class QuickNavEventFilter(QObject):
|
||||
"""Intercepts key presses for QuickNav dispatch."""
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self._manager = manager
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() != QEvent.KeyPress:
|
||||
return False
|
||||
|
||||
key = event.key()
|
||||
modifiers = event.modifiers() & ~Qt.KeypadModifier
|
||||
|
||||
# Key_0 with no modifiers always toggles, even when inactive.
|
||||
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
|
||||
if not self._focus_is_text_input():
|
||||
self._manager.toggle_active()
|
||||
return True
|
||||
return False
|
||||
|
||||
if not self._manager.is_active():
|
||||
return False
|
||||
|
||||
# Don't intercept when a text input has focus.
|
||||
if self._focus_is_text_input():
|
||||
return False
|
||||
|
||||
if Qt.Key_1 <= key <= Qt.Key_9:
|
||||
n = key - Qt.Key_0
|
||||
if modifiers == Qt.ControlModifier:
|
||||
self._manager.switch_workbench(n)
|
||||
return True
|
||||
elif modifiers == Qt.ShiftModifier:
|
||||
self._manager.switch_grouping(n)
|
||||
return True
|
||||
elif modifiers == Qt.NoModifier:
|
||||
self._manager.execute_command(n)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _focus_is_text_input(self):
|
||||
"""Return True if the focused widget is a text/number input."""
|
||||
focused = QApplication.focusWidget()
|
||||
if focused is None:
|
||||
return False
|
||||
if isinstance(focused, _TEXT_INPUT_TYPES):
|
||||
return True
|
||||
return self._is_in_task_panel(focused)
|
||||
|
||||
@staticmethod
|
||||
def _is_in_task_panel(widget):
|
||||
"""Return True if *widget* is inside FreeCAD's task panel."""
|
||||
parent = widget.parent() if widget else None
|
||||
while parent is not None:
|
||||
name = parent.objectName() or ""
|
||||
cls = parent.__class__.__name__
|
||||
if "TaskView" in name or "TaskView" in cls:
|
||||
return True
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return False
|
||||
142
reference/quicknav/quicknav/nav_bar.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Navigation bar widget for QuickNav.
|
||||
|
||||
A QToolBar that displays the current workbench, command groupings,
|
||||
and numbered commands. Positioned at the bottom of the toolbar area.
|
||||
"""
|
||||
|
||||
from PySide.QtCore import Signal
|
||||
from PySide.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget
|
||||
|
||||
|
||||
class _ClickableLabel(QLabel):
|
||||
"""QLabel that emits *clicked* on mouse press."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class QuickNavBar(QToolBar):
|
||||
"""Bottom toolbar showing QuickNav state."""
|
||||
|
||||
_MAX_SLOTS = 9
|
||||
|
||||
def __init__(self, manager, parent=None):
|
||||
super().__init__("QuickNav", parent)
|
||||
self._manager = manager
|
||||
self.setObjectName("QuickNavBar")
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
|
||||
container = QWidget()
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Workbench label
|
||||
self._wb_label = QLabel()
|
||||
self._wb_label.setContentsMargins(4, 0, 8, 0)
|
||||
layout.addWidget(self._wb_label)
|
||||
|
||||
# Vertical separator
|
||||
sep1 = QLabel("|")
|
||||
sep1.setContentsMargins(4, 0, 4, 0)
|
||||
layout.addWidget(sep1)
|
||||
|
||||
# Grouping labels
|
||||
self._grouping_labels = []
|
||||
for i in range(self._MAX_SLOTS):
|
||||
label = _ClickableLabel()
|
||||
label.setContentsMargins(4, 0, 4, 0)
|
||||
label.clicked.connect(lambda n=i + 1: self._manager.switch_grouping(n))
|
||||
self._grouping_labels.append(label)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Vertical separator
|
||||
self._cmd_sep = QLabel("|")
|
||||
self._cmd_sep.setContentsMargins(4, 0, 4, 0)
|
||||
layout.addWidget(self._cmd_sep)
|
||||
|
||||
# Command labels
|
||||
self._command_labels = []
|
||||
for i in range(self._MAX_SLOTS):
|
||||
label = _ClickableLabel()
|
||||
label.setContentsMargins(4, 0, 4, 0)
|
||||
label.clicked.connect(lambda n=i + 1: self._manager.execute_command(n))
|
||||
self._command_labels.append(label)
|
||||
layout.addWidget(label)
|
||||
|
||||
layout.addStretch()
|
||||
self.addWidget(container)
|
||||
|
||||
self._apply_theme()
|
||||
|
||||
def update_display(self, wb_name, groupings, active_grouping_idx, commands):
|
||||
"""Refresh the bar with current state.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
wb_name : str
|
||||
Workbench display name.
|
||||
groupings : list[dict]
|
||||
Grouping dicts with ``"name"`` keys.
|
||||
active_grouping_idx : int
|
||||
0-based index of the active grouping.
|
||||
commands : list[tuple[str, str]]
|
||||
``(command_id, display_name)`` for the active grouping.
|
||||
"""
|
||||
self._wb_label.setText(f"WB: {wb_name}")
|
||||
|
||||
for i, label in enumerate(self._grouping_labels):
|
||||
if i < len(groupings):
|
||||
prefix = f"\u276{i + 1}" if i == active_grouping_idx else f"{i + 1}"
|
||||
label.setText(f" {prefix}: {groupings[i]['name']} ")
|
||||
if i == active_grouping_idx:
|
||||
label.setStyleSheet(self._active_grouping_style)
|
||||
else:
|
||||
label.setStyleSheet(self._inactive_grouping_style)
|
||||
label.show()
|
||||
else:
|
||||
label.hide()
|
||||
|
||||
for i, label in enumerate(self._command_labels):
|
||||
if i < len(commands):
|
||||
label.setText(f" {i + 1}: {commands[i][1]} ")
|
||||
label.setStyleSheet(self._command_style)
|
||||
label.show()
|
||||
else:
|
||||
label.hide()
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Apply Catppuccin Mocha theme if SDK is available, else Qt defaults."""
|
||||
palette = None
|
||||
try:
|
||||
from kindred_sdk import load_palette
|
||||
|
||||
palette = load_palette()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if palette:
|
||||
bg = palette.get("background.toolbar") or "#181825"
|
||||
fg = palette.get("foreground") or "#cdd6f4"
|
||||
accent = palette.get("accent.info") or "#89b4fa"
|
||||
border = palette.get("border") or "#45475a"
|
||||
muted = palette.get("foreground.muted") or "#a6adc8"
|
||||
else:
|
||||
# No theme — leave styles empty so Qt defaults apply.
|
||||
self._active_grouping_style = "font-weight: bold;"
|
||||
self._inactive_grouping_style = ""
|
||||
self._command_style = ""
|
||||
self._wb_label.setStyleSheet("font-weight: bold;")
|
||||
return
|
||||
|
||||
self.setStyleSheet(
|
||||
f"QToolBar#QuickNavBar {{ background: {bg}; border-top: 1px solid {border}; }}"
|
||||
)
|
||||
self._wb_label.setStyleSheet(f"color: {accent}; font-weight: bold;")
|
||||
self._active_grouping_style = f"color: {accent}; font-weight: bold;"
|
||||
self._inactive_grouping_style = f"color: {muted};"
|
||||
self._command_style = f"color: {fg};"
|
||||
285
reference/quicknav/quicknav/workbench_map.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Static workbench slot assignments and command groupings.
|
||||
|
||||
Phase 1 uses hardcoded data from the QuickNav spec (SPEC.md section 5).
|
||||
Phase 2 replaces this with dynamic toolbar introspection.
|
||||
"""
|
||||
|
||||
# Fixed Ctrl+N workbench assignments
|
||||
WORKBENCH_SLOTS = {
|
||||
1: {
|
||||
"key": "sketcher",
|
||||
"class_name": "SketcherWorkbench",
|
||||
"display": "Sketcher",
|
||||
},
|
||||
2: {
|
||||
"key": "partdesign",
|
||||
"class_name": "PartDesignWorkbench",
|
||||
"display": "Part Design",
|
||||
},
|
||||
3: {
|
||||
"key": "assembly",
|
||||
"class_name": "AssemblyWorkbench",
|
||||
"display": "Assembly",
|
||||
},
|
||||
4: {
|
||||
"key": "spreadsheet",
|
||||
"class_name": "SpreadsheetWorkbench",
|
||||
"display": "Spreadsheet",
|
||||
},
|
||||
5: {
|
||||
"key": "techdraw",
|
||||
"class_name": "TechDrawWorkbench",
|
||||
"display": "TechDraw",
|
||||
},
|
||||
}
|
||||
|
||||
# Command groupings per workbench. Each grouping has a name and up to
|
||||
# 9 commands as (FreeCAD_command_id, display_name) tuples.
|
||||
WORKBENCH_GROUPINGS = {
|
||||
"sketcher": [
|
||||
{
|
||||
"name": "Primitives",
|
||||
"commands": [
|
||||
("Sketcher_CreateLine", "Line"),
|
||||
("Sketcher_CreateRectangle", "Rectangle"),
|
||||
("Sketcher_CreateCircle", "Circle"),
|
||||
("Sketcher_CreateArc", "Arc"),
|
||||
("Sketcher_CreatePoint", "Point"),
|
||||
("Sketcher_CreateSlot", "Slot"),
|
||||
("Sketcher_CreateBSpline", "B-Spline"),
|
||||
("Sketcher_CreatePolyline", "Polyline"),
|
||||
("Sketcher_CreateEllipseByCenter", "Ellipse"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Constraints",
|
||||
"commands": [
|
||||
("Sketcher_ConstrainCoincidentUnified", "Coincident"),
|
||||
("Sketcher_ConstrainHorizontal", "Horizontal"),
|
||||
("Sketcher_ConstrainVertical", "Vertical"),
|
||||
("Sketcher_ConstrainParallel", "Parallel"),
|
||||
("Sketcher_ConstrainPerpendicular", "Perpendicular"),
|
||||
("Sketcher_ConstrainTangent", "Tangent"),
|
||||
("Sketcher_ConstrainEqual", "Equal"),
|
||||
("Sketcher_ConstrainSymmetric", "Symmetric"),
|
||||
("Sketcher_ConstrainBlock", "Block"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Dimensions",
|
||||
"commands": [
|
||||
("Sketcher_ConstrainDistance", "Distance"),
|
||||
("Sketcher_ConstrainDistanceX", "Horiz. Distance"),
|
||||
("Sketcher_ConstrainDistanceY", "Vert. Distance"),
|
||||
("Sketcher_ConstrainRadius", "Radius"),
|
||||
("Sketcher_ConstrainDiameter", "Diameter"),
|
||||
("Sketcher_ConstrainAngle", "Angle"),
|
||||
("Sketcher_ConstrainLock", "Lock"),
|
||||
("Sketcher_ConstrainSnellsLaw", "Refraction"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Construction",
|
||||
"commands": [
|
||||
("Sketcher_ToggleConstruction", "Toggle Constr."),
|
||||
("Sketcher_External", "External Geom."),
|
||||
("Sketcher_CarbonCopy", "Carbon Copy"),
|
||||
("Sketcher_Offset", "Offset"),
|
||||
("Sketcher_Trimming", "Trim"),
|
||||
("Sketcher_Extend", "Extend"),
|
||||
("Sketcher_Split", "Split"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Tools",
|
||||
"commands": [
|
||||
("Sketcher_Symmetry", "Mirror"),
|
||||
("Sketcher_RectangularArray", "Linear Array"),
|
||||
("Sketcher_Move", "Move"),
|
||||
("Sketcher_Rotate", "Rotate"),
|
||||
("Sketcher_Scale", "Scale"),
|
||||
("Sketcher_CloseShape", "Close Shape"),
|
||||
("Sketcher_ConnectLines", "Connect Edges"),
|
||||
],
|
||||
},
|
||||
],
|
||||
"partdesign": [
|
||||
{
|
||||
"name": "Additive",
|
||||
"commands": [
|
||||
("PartDesign_Pad", "Pad"),
|
||||
("PartDesign_Revolution", "Revolution"),
|
||||
("PartDesign_AdditiveLoft", "Add. Loft"),
|
||||
("PartDesign_AdditivePipe", "Add. Pipe"),
|
||||
("PartDesign_AdditiveHelix", "Add. Helix"),
|
||||
("PartDesign_CompPrimitiveAdditive", "Add. Primitive"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Subtractive",
|
||||
"commands": [
|
||||
("PartDesign_Pocket", "Pocket"),
|
||||
("PartDesign_Hole", "Hole"),
|
||||
("PartDesign_Groove", "Groove"),
|
||||
("PartDesign_SubtractiveLoft", "Sub. Loft"),
|
||||
("PartDesign_SubtractivePipe", "Sub. Pipe"),
|
||||
("PartDesign_SubtractiveHelix", "Sub. Helix"),
|
||||
("PartDesign_CompPrimitiveSubtractive", "Sub. Primitive"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Datums",
|
||||
"commands": [
|
||||
("PartDesign_NewSketch", "New Sketch"),
|
||||
("PartDesign_Plane", "Datum Plane"),
|
||||
("PartDesign_Line", "Datum Line"),
|
||||
("PartDesign_Point", "Datum Point"),
|
||||
("PartDesign_ShapeBinder", "Shape Binder"),
|
||||
("PartDesign_SubShapeBinder", "Sub-Shape Binder"),
|
||||
("ZTools_DatumCreator", "ZT Datum Creator"),
|
||||
("ZTools_DatumManager", "ZT Datum Manager"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Transformations",
|
||||
"commands": [
|
||||
("PartDesign_Mirrored", "Mirrored"),
|
||||
("PartDesign_LinearPattern", "Linear Pattern"),
|
||||
("PartDesign_PolarPattern", "Polar Pattern"),
|
||||
("PartDesign_MultiTransform", "MultiTransform"),
|
||||
("ZTools_RotatedLinearPattern", "ZT Rot. Linear"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Modeling",
|
||||
"commands": [
|
||||
("PartDesign_Fillet", "Fillet"),
|
||||
("PartDesign_Chamfer", "Chamfer"),
|
||||
("PartDesign_Draft", "Draft"),
|
||||
("PartDesign_Thickness", "Thickness"),
|
||||
("PartDesign_Boolean", "Boolean"),
|
||||
("ZTools_EnhancedPocket", "ZT Enh. Pocket"),
|
||||
],
|
||||
},
|
||||
],
|
||||
"assembly": [
|
||||
{
|
||||
"name": "Components",
|
||||
"commands": [
|
||||
("Assembly_InsertLink", "Insert Component"),
|
||||
("Assembly_InsertNewPart", "Create Part"),
|
||||
("Assembly_CreateAssembly", "Create Assembly"),
|
||||
("Assembly_ToggleGrounded", "Ground"),
|
||||
("Assembly_CreateBom", "BOM"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Joints",
|
||||
"commands": [
|
||||
("Assembly_CreateJointFixed", "Fixed"),
|
||||
("Assembly_CreateJointRevolute", "Revolute"),
|
||||
("Assembly_CreateJointCylindrical", "Cylindrical"),
|
||||
("Assembly_CreateJointSlider", "Slider"),
|
||||
("Assembly_CreateJointBall", "Ball"),
|
||||
("Assembly_CreateJointDistance", "Distance"),
|
||||
("Assembly_CreateJointAngle", "Angle"),
|
||||
("Assembly_CreateJointParallel", "Parallel"),
|
||||
("Assembly_CreateJointPerpendicular", "Perpendicular"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Patterns",
|
||||
"commands": [
|
||||
("ZTools_AssemblyLinearPattern", "Linear Pattern"),
|
||||
("ZTools_AssemblyPolarPattern", "Polar Pattern"),
|
||||
],
|
||||
},
|
||||
],
|
||||
"spreadsheet": [
|
||||
{
|
||||
"name": "Editing",
|
||||
"commands": [
|
||||
("Spreadsheet_MergeCells", "Merge Cells"),
|
||||
("Spreadsheet_SplitCell", "Split Cell"),
|
||||
("Spreadsheet_SetAlias", "Alias"),
|
||||
("Spreadsheet_Import", "Import CSV"),
|
||||
("Spreadsheet_Export", "Export CSV"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Formatting",
|
||||
"commands": [
|
||||
("ZTools_SpreadsheetStyleBold", "Bold"),
|
||||
("ZTools_SpreadsheetStyleItalic", "Italic"),
|
||||
("ZTools_SpreadsheetStyleUnderline", "Underline"),
|
||||
("ZTools_SpreadsheetAlignLeft", "Align Left"),
|
||||
("ZTools_SpreadsheetAlignCenter", "Align Center"),
|
||||
("ZTools_SpreadsheetAlignRight", "Align Right"),
|
||||
("ZTools_SpreadsheetBgColor", "BG Color"),
|
||||
("ZTools_SpreadsheetTextColor", "Text Color"),
|
||||
("ZTools_SpreadsheetQuickAlias", "Quick Alias"),
|
||||
],
|
||||
},
|
||||
],
|
||||
"techdraw": [
|
||||
{
|
||||
"name": "Views",
|
||||
"commands": [
|
||||
("TechDraw_PageDefault", "New Page"),
|
||||
("TechDraw_View", "Insert View"),
|
||||
("TechDraw_ProjectionGroup", "Projection Group"),
|
||||
("TechDraw_SectionView", "Section View"),
|
||||
("TechDraw_DetailView", "Detail View"),
|
||||
("TechDraw_ActiveView", "Active View"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Dimensions",
|
||||
"commands": [
|
||||
("TechDraw_LengthDimension", "Length"),
|
||||
("TechDraw_HorizontalDimension", "Horizontal"),
|
||||
("TechDraw_VerticalDimension", "Vertical"),
|
||||
("TechDraw_RadiusDimension", "Radius"),
|
||||
("TechDraw_DiameterDimension", "Diameter"),
|
||||
("TechDraw_AngleDimension", "Angle"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Annotations",
|
||||
"commands": [
|
||||
("TechDraw_Annotation", "Annotation"),
|
||||
("TechDraw_Balloon", "Balloon"),
|
||||
("TechDraw_LeaderLine", "Leader Line"),
|
||||
("TechDraw_CosmeticVertex", "Cosmetic Vertex"),
|
||||
("TechDraw_Midpoints", "Midpoints"),
|
||||
("TechDraw_CenterLine", "Center Line"),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_workbench_slot(n):
|
||||
"""Return workbench info dict for Ctrl+N, or None if unassigned."""
|
||||
return WORKBENCH_SLOTS.get(n)
|
||||
|
||||
|
||||
def get_groupings(workbench_key):
|
||||
"""Return the grouping list for a workbench key."""
|
||||
return WORKBENCH_GROUPINGS.get(workbench_key, [])
|
||||
|
||||
|
||||
def get_grouping(workbench_key, grouping_idx):
|
||||
"""Return a specific grouping dict by 0-based index, or None."""
|
||||
groupings = get_groupings(workbench_key)
|
||||
if 0 <= grouping_idx < len(groupings):
|
||||
return groupings[grouping_idx]
|
||||
return None
|
||||
|
||||
|
||||
def get_command(workbench_key, grouping_idx, n):
|
||||
"""Return the FreeCAD command ID for the Nth command (1-based), or None."""
|
||||
grouping = get_grouping(workbench_key, grouping_idx)
|
||||
if grouping and 1 <= n <= len(grouping["commands"]):
|
||||
return grouping["commands"][n - 1][0]
|
||||
return None
|
||||
385
reference/ztools/KINDRED_INTEGRATION.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Kindred Create Integration Guide
|
||||
|
||||
This document outlines the requirements, options, and steps for integrating the ztools workbench into Kindred Create as a native workbench.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current Architecture Overview](#current-architecture-overview)
|
||||
2. [Integration Options](#integration-options)
|
||||
3. [Files Requiring Modification](#files-requiring-modification)
|
||||
4. [Branding Changes](#branding-changes)
|
||||
5. [FreeCAD API Dependencies](#freecad-api-dependencies)
|
||||
6. [Recommended Integration Path](#recommended-integration-path)
|
||||
7. [Testing Checklist](#testing-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture Overview
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
ztools-0065/
|
||||
├── package.xml # FreeCAD addon metadata
|
||||
├── CatppuccinMocha/ # Preference pack (theme)
|
||||
└── ztools/ # Main addon directory
|
||||
├── Init.py # Pre-GUI initialization
|
||||
├── InitGui.py # Workbench class definition
|
||||
└── ztools/ # Python package
|
||||
├── __init__.py
|
||||
├── commands/ # GUI commands
|
||||
│ ├── __init__.py
|
||||
│ ├── datum_commands.py
|
||||
│ ├── datum_viewprovider.py
|
||||
│ ├── pattern_commands.py
|
||||
│ ├── pocket_commands.py
|
||||
│ ├── assembly_pattern_commands.py
|
||||
│ └── spreadsheet_commands.py
|
||||
├── datums/ # Datum creation logic
|
||||
│ ├── __init__.py
|
||||
│ └── core.py
|
||||
└── resources/ # Icons and theming
|
||||
├── __init__.py
|
||||
├── icons.py
|
||||
└── theme.py
|
||||
```
|
||||
|
||||
### Module Statistics
|
||||
|
||||
| Component | Files | Commands | Description |
|
||||
|-----------|-------|----------|-------------|
|
||||
| Datum Tools | 3 | 2 | 15 attachment modes, DatumCreator + DatumManager |
|
||||
| Pattern Tools | 1 | 1 | Rotated linear pattern |
|
||||
| Pocket Tools | 1 | 1 | Enhanced pocket with face selection |
|
||||
| Assembly Tools | 1 | 2 | Linear and polar assembly patterns |
|
||||
| Spreadsheet Tools | 1 | 9 | Formatting and quick alias |
|
||||
| Resources | 2 | - | 33 SVG icons, theme colors |
|
||||
|
||||
### External Dependencies
|
||||
|
||||
- **Python Standard Library Only** - No pip dependencies
|
||||
- **FreeCAD Modules**: FreeCAD, FreeCADGui, Part, PartDesign, Sketcher, Assembly (1.0+), Spreadsheet
|
||||
- **Qt/PySide**: QtCore, QtGui, QtWidgets (via FreeCAD's bundled PySide)
|
||||
|
||||
---
|
||||
|
||||
## Integration Options
|
||||
|
||||
### Option A: Addon-Style Integration (Minimal Changes)
|
||||
|
||||
**Effort**: Low
|
||||
**Compatibility**: Maintains upstream FreeCAD compatibility
|
||||
|
||||
Bundle ztools as a pre-installed addon in Kindred Create's addon directory.
|
||||
|
||||
**Pros**:
|
||||
- Minimal code changes
|
||||
- Can still be distributed as standalone addon
|
||||
- Easy to update independently
|
||||
|
||||
**Cons**:
|
||||
- Not truly "native" - still appears as addon
|
||||
- Users could accidentally uninstall
|
||||
|
||||
**Changes Required**:
|
||||
- Update `package.xml` metadata with Kindred Create branding
|
||||
- Optionally rename workbench display name
|
||||
|
||||
### Option B: Native Workbench Integration (Moderate Changes)
|
||||
|
||||
**Effort**: Medium
|
||||
**Compatibility**: Kindred Create specific
|
||||
|
||||
Move ztools into FreeCAD's `Mod/` directory as a first-party workbench.
|
||||
|
||||
**Pros**:
|
||||
- Appears alongside PartDesign, Assembly, etc.
|
||||
- Cannot be uninstalled via Addon Manager
|
||||
- Full integration with Kindred Create identity
|
||||
|
||||
**Cons**:
|
||||
- Requires maintaining fork-specific code
|
||||
- Branding changes throughout codebase
|
||||
|
||||
**Changes Required**:
|
||||
- Rename all `ZTools_*` commands to `KindredCreate_*` or similar
|
||||
- Update workbench class name and identifiers
|
||||
- Modify directory structure to match FreeCAD conventions
|
||||
- Update all user-facing strings
|
||||
|
||||
### Option C: Full Workbench Replacement (Major Changes)
|
||||
|
||||
**Effort**: High
|
||||
**Compatibility**: Kindred Create only
|
||||
|
||||
Replace existing PartDesign/Assembly workbenches with unified Kindred workbench.
|
||||
|
||||
**Pros**:
|
||||
- Unified user experience
|
||||
- Complete control over workflow
|
||||
|
||||
**Cons**:
|
||||
- Significant development effort
|
||||
- Diverges heavily from upstream FreeCAD
|
||||
- Harder to merge upstream improvements
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Modification
|
||||
|
||||
### Critical Files
|
||||
|
||||
| File | Changes Needed |
|
||||
|------|----------------|
|
||||
| `package.xml` | Package name, description, workbench name, classname |
|
||||
| `ztools/InitGui.py` | Class name, MenuText, ToolTip, Icon, console messages |
|
||||
| `ztools/Init.py` | Console messages |
|
||||
| `ztools/ztools/commands/__init__.py` | No changes (internal imports) |
|
||||
|
||||
### Command Registration (All Command Files)
|
||||
|
||||
Each command file registers commands with `Gui.addCommand("ZTools_*", ...)`. These need renaming:
|
||||
|
||||
| File | Commands to Rename |
|
||||
|------|-------------------|
|
||||
| `datum_commands.py` | `ZTools_DatumCreator`, `ZTools_DatumManager` |
|
||||
| `pattern_commands.py` | `ZTools_RotatedLinearPattern` |
|
||||
| `pocket_commands.py` | `ZTools_EnhancedPocket` |
|
||||
| `assembly_pattern_commands.py` | `ZTools_AssemblyLinearPattern`, `ZTools_AssemblyPolarPattern` |
|
||||
| `spreadsheet_commands.py` | `ZTools_SpreadsheetStyleBold`, `ZTools_SpreadsheetStyleItalic`, `ZTools_SpreadsheetStyleUnderline`, `ZTools_SpreadsheetAlignLeft`, `ZTools_SpreadsheetAlignCenter`, `ZTools_SpreadsheetAlignRight`, `ZTools_SpreadsheetBgColor`, `ZTools_SpreadsheetTextColor`, `ZTools_SpreadsheetQuickAlias` |
|
||||
|
||||
### Internal References
|
||||
|
||||
Custom properties use `ZTools_` prefix for disambiguation:
|
||||
|
||||
| File | Properties |
|
||||
|------|-----------|
|
||||
| `datums/core.py` | `ZTools_SourceRefs`, `ZTools_Params`, `ZTools_DatumMode` |
|
||||
| `datum_viewprovider.py` | Same properties accessed |
|
||||
|
||||
**Note**: These property names are stored in FreeCAD documents. Renaming them would break backward compatibility with existing documents. Consider keeping these internal or implementing migration.
|
||||
|
||||
---
|
||||
|
||||
## Branding Changes
|
||||
|
||||
### User-Visible Strings
|
||||
|
||||
| Location | Current | Suggested |
|
||||
|----------|---------|-----------|
|
||||
| `InitGui.py` MenuText | `"ztools"` | `"Kindred Design"` or similar |
|
||||
| `InitGui.py` ToolTip | `"Extended PartDesign replacement..."` | Kindred-specific description |
|
||||
| `InitGui.py` console messages | `"ztools workbench..."` | `"Kindred Design workbench..."` |
|
||||
| `Init.py` console messages | `"ztools addon loaded"` | `"Kindred Design loaded"` |
|
||||
| Toolbar names | `"ztools Datums"`, etc. | `"Kindred Datums"`, etc. |
|
||||
| Menu names | `"ztools"` | `"Kindred"` |
|
||||
|
||||
### Command Prefixes
|
||||
|
||||
Current: `ZTools_*`
|
||||
Suggested: `KindredCreate_*`, `Kindred_*`, or `KC_*`
|
||||
|
||||
### Class Names
|
||||
|
||||
| Current | Suggested |
|
||||
|---------|-----------|
|
||||
| `ZToolsWorkbench` | `KindredDesignWorkbench` |
|
||||
| `ZToolsDatumObject` | `KindredDatumObject` |
|
||||
| `ZToolsDatumViewProvider` | `KindredDatumViewProvider` |
|
||||
|
||||
---
|
||||
|
||||
## FreeCAD API Dependencies
|
||||
|
||||
### Core APIs Used
|
||||
|
||||
```python
|
||||
# Application
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
# Part/PartDesign
|
||||
import Part
|
||||
import PartDesign
|
||||
|
||||
# Workbench patterns
|
||||
Gui.Workbench # Base class
|
||||
Gui.addCommand() # Command registration
|
||||
Gui.Control.showDialog() # Task panels
|
||||
Gui.Selection # Selection observer
|
||||
|
||||
# Document operations
|
||||
App.ActiveDocument
|
||||
doc.openTransaction()
|
||||
doc.commitTransaction()
|
||||
doc.abortTransaction()
|
||||
doc.recompute()
|
||||
|
||||
# Feature Python
|
||||
Part.makeCompound()
|
||||
doc.addObject("Part::FeaturePython", name)
|
||||
doc.addObject("PartDesign::Plane", name)
|
||||
doc.addObject("PartDesign::Line", name)
|
||||
doc.addObject("PartDesign::Point", name)
|
||||
doc.addObject("App::Link", name)
|
||||
```
|
||||
|
||||
### Assembly Workbench APIs (FreeCAD 1.0+)
|
||||
|
||||
```python
|
||||
# Assembly operations
|
||||
asm.TypeId == "Assembly::AssemblyObject"
|
||||
App.Link objects for component instances
|
||||
```
|
||||
|
||||
### Spreadsheet APIs
|
||||
|
||||
```python
|
||||
sheet.set(cell, value)
|
||||
sheet.getContents(cell)
|
||||
sheet.setStyle(cell, style)
|
||||
sheet.setAlignment(cell, alignment)
|
||||
sheet.setBackground(cell, (r, g, b))
|
||||
sheet.setForeground(cell, (r, g, b))
|
||||
sheet.setAlias(cell, alias)
|
||||
```
|
||||
|
||||
### Qt/PySide APIs
|
||||
|
||||
```python
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
|
||||
# Dialogs
|
||||
QtWidgets.QColorDialog.getColor()
|
||||
|
||||
# Task panels
|
||||
QtWidgets.QWidget subclasses
|
||||
|
||||
# Selection from table views
|
||||
QTableView.selectionModel()
|
||||
QItemSelectionModel.selectedIndexes()
|
||||
```
|
||||
|
||||
### Preference System
|
||||
|
||||
```python
|
||||
App.ParamGet("User parameter:BaseApp/Preferences/Mod/Spreadsheet")
|
||||
params.SetUnsigned(key, value)
|
||||
params.GetUnsigned(key)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Integration Path
|
||||
|
||||
### Phase 1: Branding Update (Option B)
|
||||
|
||||
1. **Fork the repository** for Kindred Create
|
||||
2. **Rename package and workbench**:
|
||||
- `package.xml`: Update name, description
|
||||
- `InitGui.py`: Update class name, MenuText, ToolTip
|
||||
- `Init.py`: Update log messages
|
||||
3. **Rename command prefixes**:
|
||||
- Global find/replace: `ZTools_` → `Kindred_`
|
||||
- Update all `Gui.addCommand()` calls
|
||||
- Update all toolbar/menu references in `InitGui.py`
|
||||
4. **Update toolbar/menu labels**:
|
||||
- `"ztools Datums"` → `"Kindred Datums"`
|
||||
- etc.
|
||||
5. **Keep internal property names** (`ZTools_SourceRefs`, etc.) for document compatibility
|
||||
|
||||
### Phase 2: Directory Restructure
|
||||
|
||||
1. **Move to FreeCAD's Mod directory**:
|
||||
```
|
||||
freecad-source/src/Mod/KindredDesign/
|
||||
├── Init.py
|
||||
├── InitGui.py
|
||||
└── KindredDesign/
|
||||
├── commands/
|
||||
├── datums/
|
||||
└── resources/
|
||||
```
|
||||
2. **Update CMakeLists.txt** in FreeCAD source to include new module
|
||||
3. **Add to default workbench list** if desired
|
||||
|
||||
### Phase 3: Theme Integration
|
||||
|
||||
1. **Move CatppuccinMocha** to Kindred Create's default themes
|
||||
2. **Optionally set as default theme** for new installations
|
||||
3. **Update `apply_spreadsheet_colors()`** to use Kindred theme colors
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Functional Tests
|
||||
|
||||
- [ ] Workbench activates without errors
|
||||
- [ ] All toolbars appear with correct icons
|
||||
- [ ] All menus appear with correct structure
|
||||
- [ ] Datum Creator: All 15 modes work
|
||||
- [ ] Datum Manager: Opens (stub functionality)
|
||||
- [ ] Rotated Linear Pattern: Creates patterns correctly
|
||||
- [ ] Enhanced Pocket: Face selection works
|
||||
- [ ] Assembly Linear Pattern: Creates component arrays
|
||||
- [ ] Assembly Polar Pattern: Creates circular arrays
|
||||
- [ ] Spreadsheet Bold/Italic/Underline: Toggle correctly
|
||||
- [ ] Spreadsheet Alignment: Left/Center/Right work
|
||||
- [ ] Spreadsheet Colors: Dialogs open, colors apply
|
||||
- [ ] Spreadsheet Quick Alias: Creates aliases from labels
|
||||
- [ ] Undo/Redo works for all operations
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Existing FreeCAD documents open correctly
|
||||
- [ ] New documents created with Kindred tools save/load properly
|
||||
- [ ] PartDesign features work alongside Kindred datums
|
||||
- [ ] Assembly joints work with Kindred patterns
|
||||
- [ ] Spreadsheet aliases work in PartDesign expressions
|
||||
|
||||
### Theme Tests
|
||||
|
||||
- [ ] Catppuccin Mocha preference pack loads
|
||||
- [ ] Spreadsheet text colors are correct (light on dark)
|
||||
- [ ] Icons render with correct theme colors
|
||||
- [ ] QSS styling applies to all custom widgets
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Document Compatibility
|
||||
|
||||
Documents created with ztools will contain:
|
||||
- Objects with `ZTools_*` custom properties
|
||||
- References to `ZTools_*` in expressions (unlikely but possible)
|
||||
|
||||
**Recommendation**: Keep internal property names unchanged, or implement a document migration script that updates property names on load.
|
||||
|
||||
### User Preference Migration
|
||||
|
||||
If users have existing FreeCAD installations:
|
||||
- Spreadsheet color preferences are stored per-user
|
||||
- Consider running `apply_spreadsheet_colors()` on first Kindred Create launch
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The ztools workbench is well-structured for integration into Kindred Create. The recommended path is **Option B (Native Workbench Integration)** which provides:
|
||||
|
||||
- Native appearance alongside other FreeCAD workbenches
|
||||
- Kindred Create branding throughout
|
||||
- Maintained document compatibility
|
||||
- Reasonable development effort
|
||||
|
||||
Key points:
|
||||
- **No external dependencies** - clean integration
|
||||
- **Standard FreeCAD APIs** - future-compatible
|
||||
- **Modular structure** - easy to extend
|
||||
- **33 custom icons** - complete visual identity ready for recoloring
|
||||
|
||||
Estimated effort for full integration: 2-4 hours for branding changes, additional time for build system integration depending on Kindred Create's structure.
|
||||
420
reference/ztools/PLAN.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# ZTools Development Plan
|
||||
|
||||
## Current Status: v0.3.0 (80% complete)
|
||||
|
||||
### What's Working
|
||||
- Workbench registration with 17 toolbars and menus
|
||||
- All 15 datum creation functions with custom ZTools attachment system
|
||||
- Datum Creator GUI (task panel with Planes/Axes/Points tabs)
|
||||
- OK button creates datum, Cancel dismisses without creating
|
||||
- Rotated Linear Pattern feature (complete)
|
||||
- Icon system (32+ Catppuccin-themed SVGs)
|
||||
- Metadata storage system (ZTools_Type, ZTools_Params, ZTools_SourceRefs)
|
||||
- Spreadsheet linking for parametric control
|
||||
- FreeCAD 1.0+ Assembly workbench integration (all stock commands)
|
||||
- Assembly Linear Pattern tool (complete)
|
||||
- Assembly Polar Pattern tool (complete)
|
||||
- FreeCAD Spreadsheet workbench integration (all stock commands)
|
||||
- zSpreadsheet formatting toolbar (9 commands)
|
||||
|
||||
### Recent Changes (2026-01-25)
|
||||
- Added zSpreadsheet module with formatting toolbar
|
||||
- Native Spreadsheet commands exposed (CreateSheet, Import, Export, SetAlias, MergeCells, SplitCell)
|
||||
- Created 9 formatting commands: Bold, Italic, Underline, Align Left/Center/Right, Background Color, Text Color, Quick Alias
|
||||
- Added 9 spreadsheet icons (Catppuccin Mocha theme)
|
||||
- Spreadsheet text color now defaults to white for dark theme compatibility
|
||||
|
||||
### Previous Changes (2026-01-25)
|
||||
- Added FreeCAD 1.0+ Assembly workbench integration
|
||||
- All native Assembly commands exposed in ztools workbench (21 commands)
|
||||
- Created Assembly Linear Pattern tool with task panel UI
|
||||
- Created Assembly Polar Pattern tool with task panel UI
|
||||
- Added assembly pattern icons (Catppuccin Mocha theme)
|
||||
|
||||
### Previous Changes (2026-01-24)
|
||||
- Replaced FreeCAD's vanilla attachment system with custom ZTools attachment
|
||||
- All datums now use `MapMode='Deactivated'` with calculated placements
|
||||
- Source references stored in `ZTools_SourceRefs` property for future update capability
|
||||
- Fixed all 3 point functions (`point_on_edge`, `point_center_of_face`, `point_center_of_circle`) to accept source parameters
|
||||
- Removed redundant "Create Datum" button - OK now creates the datum
|
||||
- Task panel properly cleans up selection observer on close
|
||||
|
||||
---
|
||||
|
||||
## ZTools Attachment System
|
||||
|
||||
FreeCAD's vanilla attachment system has reliability issues. ZTools uses a custom approach:
|
||||
|
||||
1. **Calculate placement directly** from source geometry at creation time
|
||||
2. **Store source references** in `ZTools_SourceRefs` property (JSON)
|
||||
3. **Use `MapMode='Deactivated'`** to prevent FreeCAD attachment interference
|
||||
4. **Store creation parameters** in `ZTools_Params` for potential recalculation
|
||||
|
||||
This gives full control over datum positioning while maintaining the ability to update datums when source geometry changes (future feature).
|
||||
|
||||
### Metadata Properties
|
||||
|
||||
All ZTools datums have these custom properties:
|
||||
- `ZTools_Type`: Creation method identifier (e.g., "offset_from_face", "midplane")
|
||||
- `ZTools_Params`: JSON-encoded creation parameters
|
||||
- `ZTools_SourceRefs`: JSON-encoded list of source geometry references
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Complete (Assembly Integration)
|
||||
|
||||
### FreeCAD 1.0+ Assembly Workbench Commands
|
||||
|
||||
ZTools exposes all native FreeCAD Assembly workbench commands in 3 toolbars:
|
||||
|
||||
**Assembly Structure:**
|
||||
- `Assembly_CreateAssembly` - Create new assembly container
|
||||
- `Assembly_InsertLink` - Insert component as link
|
||||
- `Assembly_InsertNewPart` - Create and insert new part
|
||||
|
||||
**Assembly Joints (13 types):**
|
||||
- `Assembly_CreateJointFixed` - Lock parts together (0 DOF)
|
||||
- `Assembly_CreateJointRevolute` - Rotation around axis
|
||||
- `Assembly_CreateJointCylindrical` - Rotation + translation along axis
|
||||
- `Assembly_CreateJointSlider` - Translation along axis
|
||||
- `Assembly_CreateJointBall` - Spherical rotation
|
||||
- `Assembly_CreateJointDistance` - Maintain distance
|
||||
- `Assembly_CreateJointParallel` - Keep parallel
|
||||
- `Assembly_CreateJointPerpendicular` - Keep perpendicular
|
||||
- `Assembly_CreateJointAngle` - Maintain angle
|
||||
- `Assembly_CreateJointRackPinion` - Rack and pinion motion
|
||||
- `Assembly_CreateJointScrew` - Helical motion
|
||||
- `Assembly_CreateJointGears` - Gear ratio constraint
|
||||
- `Assembly_CreateJointBelt` - Belt/pulley constraint
|
||||
|
||||
**Assembly Management:**
|
||||
- `Assembly_ToggleGrounded` - Lock part in place
|
||||
- `Assembly_SolveAssembly` - Run constraint solver
|
||||
- `Assembly_CreateView` - Create exploded view
|
||||
- `Assembly_CreateBom` - Generate bill of materials
|
||||
- `Assembly_ExportASMT` - Export assembly file
|
||||
|
||||
### ZTools Assembly Pattern Tools
|
||||
|
||||
**Assembly Linear Pattern** (`ZTools_AssemblyLinearPattern`)
|
||||
|
||||
Creates copies of assembly components along a linear direction.
|
||||
|
||||
Features:
|
||||
- Multi-component selection via table UI
|
||||
- Direction vector (X, Y, Z)
|
||||
- Occurrence count (2-100)
|
||||
- Spacing modes: Total Length or Fixed Spacing
|
||||
- Creates as Links (recommended) or copies
|
||||
- Option to hide original components
|
||||
- Auto-detects parent assembly
|
||||
|
||||
UI Layout:
|
||||
```
|
||||
+----------------------------------+
|
||||
| Components |
|
||||
| +------------------------------+ |
|
||||
| | Component_1 [X] | |
|
||||
| | Component_2 [X] | |
|
||||
| +------------------------------+ |
|
||||
| Select components in 3D view |
|
||||
+----------------------------------+
|
||||
| Pattern Parameters |
|
||||
| Direction: X[1] Y[0] Z[0] |
|
||||
| Occurrences: [3] |
|
||||
| Mode: [Total Length v] |
|
||||
| Total Length: [100 mm] |
|
||||
+----------------------------------+
|
||||
| Options |
|
||||
| [x] Create as Links |
|
||||
| [ ] Hide original components |
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
**Assembly Polar Pattern** (`ZTools_AssemblyPolarPattern`)
|
||||
|
||||
Creates copies of assembly components around a rotation axis.
|
||||
|
||||
Features:
|
||||
- Multi-component selection via table UI
|
||||
- Axis presets (X, Y, Z) or custom axis vector
|
||||
- Center point specification
|
||||
- Occurrence count (2-100)
|
||||
- Angle modes: Full Circle (360) or Custom Angle
|
||||
- Creates as Links (recommended) or copies
|
||||
- Option to hide original components
|
||||
|
||||
UI Layout:
|
||||
```
|
||||
+----------------------------------+
|
||||
| Components |
|
||||
| +------------------------------+ |
|
||||
| | Component_1 [X] | |
|
||||
| +------------------------------+ |
|
||||
+----------------------------------+
|
||||
| Rotation Axis |
|
||||
| Axis: [Z Axis v] |
|
||||
| Direction: X[0] Y[0] Z[1] |
|
||||
| Center: X[0] Y[0] Z[0] |
|
||||
+----------------------------------+
|
||||
| Pattern Parameters |
|
||||
| Occurrences: [6] |
|
||||
| Mode: [Full Circle v] |
|
||||
| Total Angle: [360 deg] |
|
||||
+----------------------------------+
|
||||
| Options |
|
||||
| [x] Create as Links |
|
||||
| [ ] Hide original components |
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0.5: Complete (zSpreadsheet)
|
||||
|
||||
### FreeCAD Spreadsheet Workbench Commands
|
||||
|
||||
ZTools exposes native Spreadsheet commands in the "Spreadsheet" toolbar:
|
||||
|
||||
- `Spreadsheet_CreateSheet` - Create new spreadsheet
|
||||
- `Spreadsheet_Import` - Import CSV file
|
||||
- `Spreadsheet_Export` - Export to CSV
|
||||
- `Spreadsheet_SetAlias` - Set cell alias
|
||||
- `Spreadsheet_MergeCells` - Merge selected cells
|
||||
- `Spreadsheet_SplitCell` - Split merged cell
|
||||
|
||||
### ZTools Spreadsheet Formatting Tools
|
||||
|
||||
Quick formatting toolbar for cell styling without dialogs:
|
||||
|
||||
**Style Commands:**
|
||||
- `ZTools_SpreadsheetStyleBold` - Toggle bold (B icon)
|
||||
- `ZTools_SpreadsheetStyleItalic` - Toggle italic (I icon)
|
||||
- `ZTools_SpreadsheetStyleUnderline` - Toggle underline (U icon)
|
||||
|
||||
**Alignment Commands:**
|
||||
- `ZTools_SpreadsheetAlignLeft` - Align text left
|
||||
- `ZTools_SpreadsheetAlignCenter` - Align text center
|
||||
- `ZTools_SpreadsheetAlignRight` - Align text right
|
||||
|
||||
**Color Commands:**
|
||||
- `ZTools_SpreadsheetBgColor` - Set cell background color (color picker)
|
||||
- `ZTools_SpreadsheetTextColor` - Set cell text color (color picker)
|
||||
|
||||
**Utility Commands:**
|
||||
- `ZTools_SpreadsheetQuickAlias` - Create alias from adjacent label cell
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**Cell Selection Helper:**
|
||||
The `get_selected_cells()` function:
|
||||
1. Gets active MDI subwindow
|
||||
2. Finds QTableView widget
|
||||
3. Gets selected indexes from selection model
|
||||
4. Converts to A1 notation (handles AA, AB, etc.)
|
||||
|
||||
**Style Toggle Pattern:**
|
||||
```python
|
||||
current = sheet.getStyle(cell) or ""
|
||||
styles = set(s.strip() for s in current.split("|") if s.strip())
|
||||
if "bold" in styles:
|
||||
styles.discard("bold")
|
||||
else:
|
||||
styles.add("bold")
|
||||
sheet.setStyle(cell, "|".join(sorted(styles)))
|
||||
```
|
||||
|
||||
**Color Picker Integration:**
|
||||
Uses Qt's `QColorDialog.getColor()` with Catppuccin defaults for dark theme.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Complete (Datum Tools)
|
||||
|
||||
All datum creation functions now work:
|
||||
|
||||
### Planes (6 modes)
|
||||
- Offset from Face
|
||||
- Midplane (2 Faces)
|
||||
- 3 Points
|
||||
- Normal to Edge
|
||||
- Angled from Face
|
||||
- Tangent to Cylinder
|
||||
|
||||
### Axes (4 modes)
|
||||
- 2 Points
|
||||
- From Edge
|
||||
- Cylinder Center
|
||||
- Plane Intersection
|
||||
|
||||
### Points (5 modes)
|
||||
- At Vertex
|
||||
- XYZ Coordinates
|
||||
- On Edge (with parameter)
|
||||
- Face Center
|
||||
- Circle Center
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Complete Enhanced Pocket
|
||||
|
||||
### 2.1 Wire Up Pocket Execution (pocket_commands.py)
|
||||
|
||||
The EnhancedPocketTaskPanel has complete UI but no execute logic.
|
||||
|
||||
Required implementation:
|
||||
1. Get selected sketch from user
|
||||
2. Create PartDesign::Pocket with selected type
|
||||
3. Apply "Flip Side to Cut" by:
|
||||
- Reversing the pocket direction, OR
|
||||
- Using a boolean cut approach with inverted profile
|
||||
4. Handle all pocket types: Dimension, Through All, To First, Up To Face, Two Dimensions
|
||||
|
||||
### 2.2 Register Pocket Command
|
||||
|
||||
Add to InitGui.py toolbar if not already present.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Datum Manager
|
||||
|
||||
### 3.1 Implement DatumManagerTaskPanel
|
||||
|
||||
Replace the stub in datum_commands.py with functional panel:
|
||||
|
||||
Features:
|
||||
- List all datum objects (planes, axes, points) in document
|
||||
- Filter by type (ZTools-created vs native)
|
||||
- Toggle visibility (eye icon per item)
|
||||
- Rename datums inline
|
||||
- Delete selected datums
|
||||
- Jump to datum in model tree
|
||||
|
||||
UI Layout:
|
||||
```
|
||||
+----------------------------------+
|
||||
| Filter: [All v] [ZTools only ☐] |
|
||||
+----------------------------------+
|
||||
| ☑ ZPlane_Offset_001 [👁] [🗑] |
|
||||
| ☑ ZPlane_Mid_001 [👁] [🗑] |
|
||||
| ☐ ZAxis_Cyl_001 [👁] [🗑] |
|
||||
+----------------------------------+
|
||||
| [Rename] [Show All] [Hide All] |
|
||||
+----------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Additional Features (Future)
|
||||
|
||||
### 4.1 Module 2 Completion: Enhanced Pad
|
||||
- Multi-body support
|
||||
- Draft angles on pad
|
||||
- Lip/groove profiles
|
||||
|
||||
### 4.2 Module 3: Body Operations
|
||||
- Split body at plane
|
||||
- Combine bodies
|
||||
- Shell improvements
|
||||
|
||||
### 4.3 Module 4: Pattern Tools
|
||||
- Curve-driven pattern (sweep instances along spline)
|
||||
- Fill pattern (populate region with instances)
|
||||
- Pattern with variable spacing
|
||||
|
||||
### 4.4 Datum Update Feature
|
||||
- Use stored `ZTools_SourceRefs` to recalculate datum positions
|
||||
- Handle topology changes gracefully
|
||||
- Option to "freeze" datums (disconnect from sources)
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose | Lines |
|
||||
|------|---------|-------|
|
||||
| `ztools/ztools/datums/core.py` | Datum creation functions | ~750 |
|
||||
| `ztools/ztools/commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
|
||||
| `ztools/ztools/commands/pocket_commands.py` | Enhanced Pocket GUI | ~600 |
|
||||
| `ztools/ztools/commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
|
||||
| `ztools/ztools/commands/assembly_pattern_commands.py` | Assembly Linear/Polar Patterns | ~580 |
|
||||
| `ztools/ztools/commands/spreadsheet_commands.py` | Spreadsheet formatting tools | ~480 |
|
||||
| `ztools/InitGui.py` | Workbench registration | ~330 |
|
||||
| `ztools/ztools/resources/icons.py` | SVG icon definitions | ~540 |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Phase 1 Tests (Datum Tools)
|
||||
- [ ] Create plane offset from face
|
||||
- [ ] Create midplane between 2 faces
|
||||
- [ ] Create plane from 3 points
|
||||
- [ ] Create plane normal to edge at various parameters
|
||||
- [ ] Create angled plane from face about edge
|
||||
- [ ] Create plane tangent to cylinder
|
||||
- [ ] Create axis from 2 points
|
||||
- [ ] Create axis from edge
|
||||
- [ ] Create axis at cylinder center
|
||||
- [ ] Create axis at plane intersection
|
||||
- [ ] Create point at vertex
|
||||
- [ ] Create point at XYZ coordinates
|
||||
- [ ] Create point on edge at parameter 0.0, 0.5, 1.0
|
||||
- [ ] Create point at face center (planar and cylindrical)
|
||||
- [ ] Create point at circle center (full circle and arc)
|
||||
- [ ] Verify ZTools_Type, ZTools_Params, ZTools_SourceRefs properties exist
|
||||
- [ ] Verify no "deactivated attachment mode" warnings in console
|
||||
|
||||
### Phase 2 Tests (Enhanced Pocket)
|
||||
- [ ] Create pocket with Dimension type
|
||||
- [ ] Create pocket with Through All
|
||||
- [ ] Create pocket with Flip Side to Cut enabled
|
||||
- [ ] Verify pocket respects taper angle
|
||||
|
||||
### Phase 3 Tests (Datum Manager)
|
||||
- [ ] Datum Manager lists all datums
|
||||
- [ ] Visibility toggle works
|
||||
- [ ] Rename persists after recompute
|
||||
- [ ] Delete removes datum cleanly
|
||||
|
||||
### Assembly Integration Tests
|
||||
- [ ] Assembly workbench commands appear in toolbars
|
||||
- [ ] Assembly_CreateAssembly works from ztools
|
||||
- [ ] Assembly_InsertLink works from ztools
|
||||
- [ ] All joint commands accessible
|
||||
- [ ] Assembly_SolveAssembly works
|
||||
|
||||
### Assembly Pattern Tests
|
||||
- [ ] Linear pattern with 3 occurrences along X axis
|
||||
- [ ] Linear pattern with Total Length mode
|
||||
- [ ] Linear pattern with Spacing mode
|
||||
- [ ] Linear pattern creates links (not copies)
|
||||
- [ ] Polar pattern with 6 occurrences (full circle)
|
||||
- [ ] Polar pattern with custom angle (90 degrees, 4 occurrences)
|
||||
- [ ] Polar pattern around Z axis
|
||||
- [ ] Polar pattern with custom center point
|
||||
- [ ] Multiple components can be patterned simultaneously
|
||||
- [ ] Pattern instances added to parent assembly
|
||||
|
||||
### zSpreadsheet Tests
|
||||
- [ ] Spreadsheet toolbar appears with stock commands
|
||||
- [ ] ztools Spreadsheet toolbar appears with formatting commands
|
||||
- [ ] Create new spreadsheet via toolbar
|
||||
- [ ] Select cells and toggle Bold style
|
||||
- [ ] Select cells and toggle Italic style
|
||||
- [ ] Select cells and toggle Underline style
|
||||
- [ ] Align cells left/center/right
|
||||
- [ ] Set background color via color picker
|
||||
- [ ] Set text color via color picker (verify white default for dark theme)
|
||||
- [ ] Quick Alias creates alias from left-adjacent cell content
|
||||
- [ ] Undo/redo works for all formatting operations
|
||||
- [ ] Commands are disabled when no cells selected
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- FreeCAD 1.0+ required (TNP mitigation assumed)
|
||||
- ZTools uses custom attachment system (not FreeCAD's vanilla attachment)
|
||||
- Catppuccin Mocha theme is bundled as preference pack
|
||||
- LGPL-3.0-or-later license
|
||||
388
reference/ztools/ROADMAP.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# ZTools Roadmap
|
||||
|
||||
**Version:** 0.3.0
|
||||
**Last Updated:** 2026-01-25
|
||||
**Target Platform:** FreeCAD 1.0+
|
||||
**License:** LGPL-3.0-or-later
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
ZTools is an extended PartDesign workbench replacement for FreeCAD, focused on velocity-driven CAD workflows. It integrates enhanced datum creation, assembly patterning, spreadsheet formatting, and a unified dark theme (Catppuccin Mocha).
|
||||
|
||||
**Current State:** 80% complete for v1.0 release
|
||||
**Active Development Areas:** Datum management, Enhanced Pocket completion, documentation
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Implemented Features](#implemented-features)
|
||||
2. [Known Gaps & Incomplete Features](#known-gaps--incomplete-features)
|
||||
3. [FreeCAD Ecosystem Alignment](#freecad-ecosystem-alignment)
|
||||
4. [Development Roadmap](#development-roadmap)
|
||||
5. [Technical Architecture](#technical-architecture)
|
||||
6. [File Reference](#file-reference)
|
||||
|
||||
---
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### 1. Workbench Integration (17 Toolbars)
|
||||
|
||||
ZTools consolidates multiple FreeCAD workbenches into a single unified interface:
|
||||
|
||||
| Category | Toolbars | Commands |
|
||||
|----------|----------|----------|
|
||||
| PartDesign | Structure, Datums, Additive, Subtractive, Transformations, Dress-Up, Boolean | 35+ native commands |
|
||||
| Sketcher | Sketcher | 4 native commands |
|
||||
| Assembly | Assembly, Assembly Joints, Assembly Management | 21 native commands |
|
||||
| Spreadsheet | Spreadsheet | 6 native commands |
|
||||
| ZTools Custom | ztools Datums, ztools Patterns, ztools Features, ztools Assembly, ztools Spreadsheet | 14 custom commands |
|
||||
|
||||
### 2. Datum Creation System (16 Functions)
|
||||
|
||||
**Custom Attachment System** - Replaces FreeCAD's unreliable vanilla attachment:
|
||||
- Calculates placement directly from source geometry
|
||||
- Stores source references in `ZTools_SourceRefs` (JSON)
|
||||
- Uses `MapMode='Deactivated'` to prevent interference
|
||||
- Stores creation parameters in `ZTools_Params` for recalculation
|
||||
|
||||
#### Datum Planes (7 modes)
|
||||
| Mode | Function | Parameters |
|
||||
|------|----------|------------|
|
||||
| Offset from Face | `plane_offset_from_face()` | distance (mm) |
|
||||
| Offset from Plane | `plane_offset_from_plane()` | distance (mm) |
|
||||
| Midplane | `plane_midplane()` | 2 parallel faces |
|
||||
| 3 Points | `plane_from_3_points()` | 3 vertices |
|
||||
| Normal to Edge | `plane_normal_to_edge()` | parameter (0.0-1.0) |
|
||||
| Angled | `plane_angled()` | angle (degrees) |
|
||||
| Tangent to Cylinder | `plane_tangent_to_cylinder()` | angle (degrees) |
|
||||
|
||||
#### Datum Axes (4 modes)
|
||||
| Mode | Function | Parameters |
|
||||
|------|----------|------------|
|
||||
| 2 Points | `axis_from_2_points()` | 2 vertices |
|
||||
| From Edge | `axis_from_edge()` | linear edge |
|
||||
| Cylinder Center | `axis_cylinder_center()` | cylindrical face |
|
||||
| Plane Intersection | `axis_intersection_planes()` | 2 planes |
|
||||
|
||||
#### Datum Points (5 modes)
|
||||
| Mode | Function | Parameters |
|
||||
|------|----------|------------|
|
||||
| At Vertex | `point_at_vertex()` | vertex |
|
||||
| XYZ Coordinates | `point_at_coordinates()` | x, y, z |
|
||||
| On Edge | `point_on_edge()` | parameter (0.0-1.0) |
|
||||
| Face Center | `point_center_of_face()` | face |
|
||||
| Circle Center | `point_center_of_circle()` | circular edge |
|
||||
|
||||
**Datum Creator GUI:**
|
||||
- Auto-detection of 15+ creation modes based on selection
|
||||
- Manual mode override
|
||||
- Spreadsheet linking option
|
||||
- Custom naming
|
||||
- Real-time selection observer
|
||||
|
||||
### 3. Pattern Tools (3 Commands)
|
||||
|
||||
| Command | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `ZTools_RotatedLinearPattern` | Linear pattern with incremental rotation per instance | Complete |
|
||||
| `ZTools_AssemblyLinearPattern` | Pattern assembly components linearly | Complete |
|
||||
| `ZTools_AssemblyPolarPattern` | Pattern assembly components around axis | Complete |
|
||||
|
||||
**Assembly Pattern Features:**
|
||||
- Multi-component selection via table UI
|
||||
- Creates as Links (recommended) or copies
|
||||
- Direction/axis presets or custom vectors
|
||||
- Spacing modes: Total Length or Fixed Spacing
|
||||
- Angle modes: Full Circle or Custom Angle
|
||||
- Auto-detects parent assembly container
|
||||
|
||||
### 4. Spreadsheet Formatting (9 Commands)
|
||||
|
||||
| Command | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `ZTools_SpreadsheetStyleBold` | Toggle bold | Complete |
|
||||
| `ZTools_SpreadsheetStyleItalic` | Toggle italic | Complete |
|
||||
| `ZTools_SpreadsheetStyleUnderline` | Toggle underline | Complete |
|
||||
| `ZTools_SpreadsheetAlignLeft` | Left align | Complete |
|
||||
| `ZTools_SpreadsheetAlignCenter` | Center align | Complete |
|
||||
| `ZTools_SpreadsheetAlignRight` | Right align | Complete |
|
||||
| `ZTools_SpreadsheetBgColor` | Background color picker | Complete |
|
||||
| `ZTools_SpreadsheetTextColor` | Text color picker | Complete |
|
||||
| `ZTools_SpreadsheetQuickAlias` | Auto-create alias from label | Complete |
|
||||
|
||||
### 5. Enhanced Features
|
||||
|
||||
| Command | Description | Status |
|
||||
|---------|-------------|--------|
|
||||
| `ZTools_EnhancedPocket` | Pocket with "Flip Side to Cut" (SOLIDWORKS-style) | 90% Complete |
|
||||
|
||||
**Flip Side to Cut:** Removes material OUTSIDE the sketch profile instead of inside, using Boolean Common operation.
|
||||
|
||||
### 6. Theme System (Catppuccin Mocha)
|
||||
|
||||
- Complete Qt StyleSheet (QSS) for entire FreeCAD interface
|
||||
- 26-color palette consistently applied
|
||||
- 50+ widget types styled
|
||||
- FreeCAD-specific widgets: PropertyEditor, Python Console, Spreadsheet
|
||||
- Spreadsheet colors auto-applied on workbench activation
|
||||
|
||||
### 7. Icon System (33 Icons)
|
||||
|
||||
All icons use Catppuccin Mocha palette:
|
||||
- Workbench icon
|
||||
- Datum icons (planes, axes, points - 13 total)
|
||||
- Pattern icons (3 total)
|
||||
- Pocket icons (2 total)
|
||||
- Assembly pattern icons (2 total)
|
||||
- Spreadsheet formatting icons (9 total)
|
||||
|
||||
---
|
||||
|
||||
## Known Gaps & Incomplete Features
|
||||
|
||||
### Critical (Must Fix)
|
||||
|
||||
| Issue | Location | Description | Priority |
|
||||
|-------|----------|-------------|----------|
|
||||
| Datum Manager stub | `datum_commands.py:853` | Placeholder only - needs full implementation | High |
|
||||
| Datum edit recalculation | `datum_viewprovider.py:351,355,359` | Parameter changes don't recalculate placement from source geometry | High |
|
||||
|
||||
### Non-Critical (Should Fix)
|
||||
|
||||
| Issue | Location | Description | Priority |
|
||||
|-------|----------|-------------|----------|
|
||||
| Enhanced Pocket incomplete | `pocket_commands.py` | Taper angle disabled for flipped pockets | Medium |
|
||||
| Pocket execution logic | `pocket_commands.py` | UI complete but execution needs verification | Medium |
|
||||
|
||||
### Future Enhancements (Nice to Have)
|
||||
|
||||
| Feature | Description | Priority |
|
||||
|---------|-------------|----------|
|
||||
| Curve-driven pattern | Sweep instances along spline | Low |
|
||||
| Fill pattern | Populate region with instances | Low |
|
||||
| Variable spacing pattern | Non-uniform spacing | Low |
|
||||
| Enhanced Pad | Multi-body support, draft angles | Low |
|
||||
| Body operations | Split, combine, shell improvements | Low |
|
||||
|
||||
---
|
||||
|
||||
## FreeCAD Ecosystem Alignment
|
||||
|
||||
### FreeCAD 1.0 (November 2024) - Current Target
|
||||
|
||||
**Key Features ZTools Leverages:**
|
||||
- **TNP Mitigation:** Topological Naming Problem largely resolved
|
||||
- **Integrated Assembly Workbench:** Ondsel's assembly system now core
|
||||
- **Material System Overhaul:** New material handling
|
||||
- **UI/UX Improvements:** Dark/light themes, selection filters
|
||||
|
||||
**ZTools Alignment:**
|
||||
- Custom attachment system complements TNP fix
|
||||
- Full integration with native Assembly workbench
|
||||
- Catppuccin theme extends FreeCAD's theming
|
||||
|
||||
### FreeCAD 1.1 (Expected Late 2025)
|
||||
|
||||
**Planned Features:**
|
||||
- New Transform Manipulator
|
||||
- UI Material Rendering Improvements
|
||||
- Continued TNP refinement for Sketcher/PartDesign
|
||||
|
||||
**ZTools Opportunities:**
|
||||
- Watch for new Assembly API standardization
|
||||
- Monitor Sketcher improvements for datum integration
|
||||
|
||||
### FreeCAD Strategic Priorities (from Roadmap)
|
||||
|
||||
| FreeCAD Priority | ZTools Alignment |
|
||||
|------------------|------------------|
|
||||
| Model Stability | Custom attachment system prevents fragile models |
|
||||
| Assembly Integration | Full native Assembly command exposure |
|
||||
| Flatten Learning Curve | Unified toolbar consolidation |
|
||||
| UI Modernization | Catppuccin Mocha theme |
|
||||
| Streamlined Workflow | Quick formatting toolbars, auto-detection |
|
||||
|
||||
### Ondsel Contributions (Note: Ondsel shut down October 2025)
|
||||
|
||||
Ondsel's contributions now maintained by FreeCAD community:
|
||||
- Assembly workbench (ZTools integrates)
|
||||
- VarSets custom properties (potential future integration)
|
||||
- Sketcher improvements
|
||||
|
||||
---
|
||||
|
||||
## Development Roadmap
|
||||
|
||||
### Phase 1: v0.3.x - Stability & Completion (Current)
|
||||
|
||||
**Timeline:** Q1 2026
|
||||
|
||||
| Task | Status | Priority |
|
||||
|------|--------|----------|
|
||||
| Complete Datum Manager GUI | Not Started | High |
|
||||
| Implement datum parameter recalculation | Not Started | High |
|
||||
| Verify Enhanced Pocket execution | Partial | Medium |
|
||||
| Add comprehensive test coverage | Not Started | Medium |
|
||||
| Documentation completion | In Progress | Medium |
|
||||
|
||||
### Phase 2: v0.4.0 - Polish & UX
|
||||
|
||||
**Timeline:** Q2 2026
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| Keyboard shortcuts | Add hotkeys for common operations |
|
||||
| Context menus | Right-click menus in 3D view |
|
||||
| Undo/redo improvements | Better transaction naming |
|
||||
| Error handling | User-friendly error messages |
|
||||
| Preferences panel | ZTools configuration UI |
|
||||
|
||||
### Phase 3: v0.5.0 - Advanced Features
|
||||
|
||||
**Timeline:** Q3 2026
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| Curve-driven pattern | Pattern along splines |
|
||||
| Enhanced Pad | Draft angles, lip/groove |
|
||||
| Body operations | Split, combine, shell |
|
||||
| Datum freeze/update | Control source geometry updates |
|
||||
|
||||
### Phase 4: v1.0.0 - Production Release
|
||||
|
||||
**Timeline:** Q4 2026
|
||||
|
||||
| Task | Description |
|
||||
|------|-------------|
|
||||
| Full test suite | Automated testing |
|
||||
| User documentation | Complete user guide |
|
||||
| Video tutorials | Getting started series |
|
||||
| FreeCAD Addon Manager | Official listing |
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
ztools/
|
||||
├── Init.py # Startup (non-GUI)
|
||||
├── InitGui.py # Workbench registration
|
||||
└── ztools/
|
||||
├── __init__.py
|
||||
├── commands/ # GUI commands
|
||||
│ ├── __init__.py
|
||||
│ ├── datum_commands.py # Datum Creator/Manager
|
||||
│ ├── datum_viewprovider.py # Custom ViewProvider
|
||||
│ ├── pattern_commands.py # Rotated Linear Pattern
|
||||
│ ├── pocket_commands.py # Enhanced Pocket
|
||||
│ ├── assembly_pattern_commands.py # Assembly patterns
|
||||
│ └── spreadsheet_commands.py # Spreadsheet formatting
|
||||
├── datums/ # Core datum library
|
||||
│ ├── __init__.py
|
||||
│ └── core.py # 16 datum functions
|
||||
└── resources/ # Assets
|
||||
├── __init__.py
|
||||
├── icons.py # 33 SVG icons
|
||||
├── theme.py # Catppuccin QSS
|
||||
└── icons/ # Generated SVG files
|
||||
```
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
1. **Command Pattern:** All tools follow FreeCAD's `GetResources()`, `Activated()`, `IsActive()` pattern
|
||||
2. **Task Panel Pattern:** Complex UIs use `QWidget` with selection observers
|
||||
3. **Feature Python Pattern:** Custom features use `Part::FeaturePython` with ViewProvider
|
||||
4. **Metadata System:** JSON properties for tracking ZTools objects
|
||||
|
||||
### Metadata Properties
|
||||
|
||||
All ZTools objects have:
|
||||
- `ZTools_Type`: Feature type identifier
|
||||
- `ZTools_Params`: JSON creation parameters
|
||||
- `ZTools_SourceRefs`: JSON source geometry references
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| File | Purpose | Lines |
|
||||
|------|---------|-------|
|
||||
| `InitGui.py` | Workbench registration, toolbars, menus | ~330 |
|
||||
| `datums/core.py` | 16 datum creation functions | ~1300 |
|
||||
| `commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
|
||||
| `commands/datum_viewprovider.py` | Custom ViewProvider, edit panel | ~400 |
|
||||
| `commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
|
||||
| `commands/pocket_commands.py` | Enhanced Pocket | ~600 |
|
||||
| `commands/assembly_pattern_commands.py` | Assembly patterns | ~580 |
|
||||
| `commands/spreadsheet_commands.py` | Spreadsheet formatting | ~480 |
|
||||
| `resources/icons.py` | 33 SVG icon definitions | ~540 |
|
||||
| `resources/theme.py` | Catppuccin Mocha QSS | ~1500 |
|
||||
|
||||
**Total:** ~6,400+ lines of code
|
||||
|
||||
---
|
||||
|
||||
## Statistics Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| Command Files | 6 |
|
||||
| Command Classes | 24+ |
|
||||
| Datum Creation Functions | 16 |
|
||||
| Icons Defined | 33 |
|
||||
| Toolbars Registered | 17 |
|
||||
| Menu Hierarchies | 7 major |
|
||||
| Native Commands Exposed | 66 |
|
||||
| Custom ZTools Commands | 14 |
|
||||
| Theme Colors | 26 |
|
||||
| Styled Widget Types | 50+ |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
ZTools follows FreeCAD's contribution guidelines. Key areas needing help:
|
||||
|
||||
1. **Testing:** Manual testing on different platforms
|
||||
2. **Documentation:** User guides and tutorials
|
||||
3. **Translations:** Internationalization support
|
||||
4. **Bug Reports:** Issue tracking and reproduction
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0-or-later
|
||||
|
||||
Compatible with FreeCAD's licensing model.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.0 (2026-01-25)
|
||||
- Added zSpreadsheet module with 9 formatting commands
|
||||
- Added Spreadsheet workbench integration (6 native commands)
|
||||
- Added 9 spreadsheet formatting icons
|
||||
|
||||
### v0.2.0 (2026-01-25)
|
||||
- Added Assembly workbench integration (21 native commands)
|
||||
- Added Assembly Linear Pattern tool
|
||||
- Added Assembly Polar Pattern tool
|
||||
- Added assembly pattern icons
|
||||
|
||||
### v0.1.0 (2026-01-24)
|
||||
- Initial release
|
||||
- Custom attachment system for datums
|
||||
- 16 datum creation functions
|
||||
- Datum Creator GUI with auto-detection
|
||||
- Rotated Linear Pattern
|
||||
- Enhanced Pocket (partial)
|
||||
- Catppuccin Mocha theme
|
||||
- 21 initial icons
|
||||
58
reference/ztools/TODO_ATTACHMENT_WORK.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Datum Attachment Work - In Progress
|
||||
|
||||
## Context
|
||||
Implementing proper FreeCAD attachment for datum objects to avoid "deactivated attachment mode" warnings.
|
||||
The pattern is adding `source_object` and `source_subname` parameters to each datum function and using `_setup_datum_attachment()` with appropriate MapModes.
|
||||
|
||||
## Completed Functions (in core.py)
|
||||
|
||||
### Planes
|
||||
- `plane_offset_from_face` - MapMode='FlatFace'
|
||||
- `plane_midplane` - MapMode='TwoFace'
|
||||
- `plane_from_3_points` - MapMode='ThreePointPlane'
|
||||
- `plane_normal_to_edge` - MapMode='NormalToPath'
|
||||
- `plane_angled` - MapMode='FlatFace' with rotation offset
|
||||
- `plane_tangent_to_cylinder` - MapMode='Tangent'
|
||||
|
||||
### Axes
|
||||
- `axis_from_2_points` - MapMode='TwoPointLine'
|
||||
- `axis_from_edge` - MapMode='ObjectXY'
|
||||
- `axis_cylinder_center` - MapMode='ObjectZ'
|
||||
- `axis_intersection_planes` - MapMode='TwoFace'
|
||||
|
||||
### Points
|
||||
- `point_at_vertex` - MapMode='Vertex'
|
||||
|
||||
## Remaining Functions to Update (in core.py)
|
||||
|
||||
- `point_at_coordinates` - No attachment needed (explicit coordinates), but could use 'Translate' mode
|
||||
- `point_on_edge` - Use MapMode='OnEdge' with MapPathParameter for position
|
||||
- `point_center_of_face` - Use MapMode='CenterOfCurvature' or similar
|
||||
- `point_center_of_circle` - Use MapMode='CenterOfCurvature'
|
||||
|
||||
## After core.py Updates
|
||||
|
||||
Update `datum_commands.py` to pass source references to the remaining point functions:
|
||||
- `create_point_at_vertex` - already done
|
||||
- `create_point_on_edge` - needs update
|
||||
- `create_point_center_face` - needs update
|
||||
- `create_point_center_circle` - needs update
|
||||
|
||||
## Pattern for Updates
|
||||
|
||||
1. Add parameters to function signature:
|
||||
```python
|
||||
source_object: Optional[App.DocumentObject] = None,
|
||||
source_subname: Optional[str] = None,
|
||||
```
|
||||
|
||||
2. In the body section, use attachment instead of placement:
|
||||
```python
|
||||
if source_object and source_subname:
|
||||
support = [(source_object, source_subname)]
|
||||
_setup_datum_attachment(point, support, "MapMode")
|
||||
else:
|
||||
_setup_datum_placement(point, App.Placement(...))
|
||||
```
|
||||
|
||||
3. Update datum_commands.py to extract and pass source references from selection.
|
||||
36
reference/ztools/package.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
|
||||
<name>ZTools</name>
|
||||
|
||||
<description>Extended PartDesign workbench with velocity-focused tools and advanced datum creation.</description>
|
||||
|
||||
<version>0.1.0</version>
|
||||
|
||||
<date>2026-01-24</date>
|
||||
|
||||
<license file="LICENSE">LGPL-3.0-or-later</license>
|
||||
|
||||
<content>
|
||||
<workbench>
|
||||
<name>ZTools</name>
|
||||
<classname>ZToolsWorkbench</classname>
|
||||
<subdirectory>./ztools</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<!-- Kindred Create extensions -->
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>50</load_priority>
|
||||
<pure_python>true</pure_python>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
<contexts>
|
||||
<context id="partdesign.body" action="inject"/>
|
||||
<context id="partdesign.feature" action="inject"/>
|
||||
</contexts>
|
||||
</kindred>
|
||||
|
||||
</package>
|
||||
591
reference/ztools/partdesign.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# FreeCAD 1.0.2 PartDesign Workbench Command Reference
|
||||
|
||||
## Overview
|
||||
|
||||
The PartDesign Workbench uses a **feature-based parametric methodology** where a component is represented by a Body container. Features are cumulative—each builds on the result of preceding features. Most features are based on parametric sketches and are either additive (adding material) or subtractive (removing material).
|
||||
|
||||
FreeCAD 1.0 introduced significant improvements including **Topological Naming Problem (TNP) mitigation**, making parametric models more stable when earlier features are modified.
|
||||
|
||||
---
|
||||
|
||||
## Structure & Containers
|
||||
|
||||
### Body
|
||||
The fundamental container for PartDesign features. Defines a local coordinate system and contains all features that define a single solid component.
|
||||
|
||||
```python
|
||||
body = doc.addObject('PartDesign::Body', 'Body')
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
- `Tip` — The feature representing the current state of the body
|
||||
- `BaseFeature` — Optional external solid to build upon
|
||||
- `Origin` — Contains reference planes (XY, XZ, YZ) and axes (X, Y, Z)
|
||||
|
||||
### Part Container
|
||||
Groups multiple Bodies for organization. Not a PartDesign-specific object but commonly used.
|
||||
|
||||
```python
|
||||
part = doc.addObject('App::Part', 'Part')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sketch Tools
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **Create Sketch** | Creates a new sketch on a selected face or datum plane |
|
||||
| **Attach Sketch** | Attaches a sketch to geometry from the active body |
|
||||
| **Edit Sketch** | Opens selected sketch for editing |
|
||||
| **Validate Sketch** | Verifies tolerance of points and adjusts them |
|
||||
| **Check Geometry** | Checks geometry for errors |
|
||||
|
||||
```python
|
||||
# Create sketch attached to XY plane
|
||||
sketch = body.newObject('Sketcher::SketchObject', 'Sketch')
|
||||
sketch.AttachmentSupport = [(body.getObject('Origin').getObject('XY_Plane'), '')]
|
||||
sketch.MapMode = 'FlatFace'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Geometry (Datums)
|
||||
|
||||
### Datum Plane
|
||||
Creates a reference plane for sketch attachment or as a mirror/pattern reference.
|
||||
|
||||
```python
|
||||
plane = body.newObject('PartDesign::Plane', 'DatumPlane')
|
||||
plane.AttachmentSupport = [(face_reference, '')]
|
||||
plane.MapMode = 'FlatFace'
|
||||
plane.Offset = App.Vector(0, 0, 10) # Offset along normal
|
||||
```
|
||||
|
||||
### Datum Line
|
||||
Creates a reference axis for revolutions, grooves, or patterns.
|
||||
|
||||
```python
|
||||
line = body.newObject('PartDesign::Line', 'DatumLine')
|
||||
line.AttachmentSupport = [(edge_reference, '')]
|
||||
line.MapMode = 'ObjectXY'
|
||||
```
|
||||
|
||||
### Datum Point
|
||||
Creates a reference point for geometry attachment.
|
||||
|
||||
```python
|
||||
point = body.newObject('PartDesign::Point', 'DatumPoint')
|
||||
point.AttachmentSupport = [(vertex_reference, '')]
|
||||
```
|
||||
|
||||
### Local Coordinate System
|
||||
Creates a local coordinate system (LCS) attached to datum geometry.
|
||||
|
||||
```python
|
||||
lcs = body.newObject('PartDesign::CoordinateSystem', 'LocalCS')
|
||||
```
|
||||
|
||||
### Shape Binder
|
||||
References geometry from a single parent object.
|
||||
|
||||
```python
|
||||
binder = body.newObject('PartDesign::ShapeBinder', 'ShapeBinder')
|
||||
binder.Support = [(external_object, ['Face1'])]
|
||||
```
|
||||
|
||||
### SubShapeBinder
|
||||
References geometry from one or more parent objects (more flexible than ShapeBinder).
|
||||
|
||||
```python
|
||||
subbinder = body.newObject('PartDesign::SubShapeBinder', 'SubShapeBinder')
|
||||
subbinder.Support = [(obj1, ['Face1']), (obj2, ['Edge2'])]
|
||||
```
|
||||
|
||||
### Clone
|
||||
Creates a clone of a selected body.
|
||||
|
||||
```python
|
||||
clone = doc.addObject('PartDesign::FeatureBase', 'Clone')
|
||||
clone.BaseFeature = source_body
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additive Features (Add Material)
|
||||
|
||||
### Pad
|
||||
Extrudes a sketch profile to create a solid.
|
||||
|
||||
```python
|
||||
pad = body.newObject('PartDesign::Pad', 'Pad')
|
||||
pad.Profile = sketch
|
||||
pad.Length = 20.0
|
||||
pad.Type = 0 # 0=Dimension, 1=UpToLast, 2=UpToFirst, 3=UpToFace, 4=TwoLengths, 5=UpToShape
|
||||
pad.Reversed = False
|
||||
pad.Midplane = False
|
||||
pad.Symmetric = False
|
||||
pad.Length2 = 10.0 # For TwoLengths type
|
||||
pad.UseCustomVector = False
|
||||
pad.Direction = App.Vector(0, 0, 1)
|
||||
pad.TaperAngle = 0.0 # Draft angle (new in 1.0)
|
||||
pad.TaperAngle2 = 0.0
|
||||
```
|
||||
|
||||
**Type Options:**
|
||||
| Value | Mode | Description |
|
||||
|-------|------|-------------|
|
||||
| 0 | Dimension | Fixed length |
|
||||
| 1 | UpToLast | Extends to last face in direction |
|
||||
| 2 | UpToFirst | Extends to first face encountered |
|
||||
| 3 | UpToFace | Extends to selected face |
|
||||
| 4 | TwoLengths | Extends in both directions |
|
||||
| 5 | UpToShape | Extends to selected shape (new in 1.0) |
|
||||
|
||||
### Revolution
|
||||
Creates a solid by revolving a sketch around an axis.
|
||||
|
||||
```python
|
||||
revolution = body.newObject('PartDesign::Revolution', 'Revolution')
|
||||
revolution.Profile = sketch
|
||||
revolution.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
|
||||
revolution.Angle = 360.0
|
||||
revolution.Midplane = False
|
||||
revolution.Reversed = False
|
||||
```
|
||||
|
||||
### Additive Loft
|
||||
Creates a solid by transitioning between two or more sketch profiles.
|
||||
|
||||
```python
|
||||
loft = body.newObject('PartDesign::AdditiveLoft', 'AdditiveLoft')
|
||||
loft.Profile = sketch1
|
||||
loft.Sections = [sketch2, sketch3]
|
||||
loft.Ruled = False
|
||||
loft.Closed = False
|
||||
```
|
||||
|
||||
### Additive Pipe (Sweep)
|
||||
Creates a solid by sweeping a profile along a path.
|
||||
|
||||
```python
|
||||
pipe = body.newObject('PartDesign::AdditivePipe', 'AdditivePipe')
|
||||
pipe.Profile = profile_sketch
|
||||
pipe.Spine = path_sketch # or (object, ['Edge1', 'Edge2'])
|
||||
pipe.Transition = 0 # 0=Transformed, 1=RightCorner, 2=RoundCorner
|
||||
pipe.Mode = 0 # 0=Standard, 1=Fixed, 2=Frenet, 3=Auxiliary
|
||||
pipe.Auxiliary = None # Auxiliary spine for Mode=3
|
||||
```
|
||||
|
||||
### Additive Helix
|
||||
Creates a solid by sweeping a sketch along a helix.
|
||||
|
||||
```python
|
||||
helix = body.newObject('PartDesign::AdditiveHelix', 'AdditiveHelix')
|
||||
helix.Profile = sketch
|
||||
helix.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
|
||||
helix.Pitch = 5.0
|
||||
helix.Height = 30.0
|
||||
helix.Turns = 6.0
|
||||
helix.Mode = 0 # 0=pitch-height, 1=pitch-turns, 2=height-turns
|
||||
helix.LeftHanded = False
|
||||
helix.Reversed = False
|
||||
helix.Angle = 0.0 # Taper angle
|
||||
helix.Growth = 0.0 # Radius growth per turn
|
||||
```
|
||||
|
||||
### Additive Primitives
|
||||
Direct primitive creation without sketches.
|
||||
|
||||
```python
|
||||
# Box
|
||||
box = body.newObject('PartDesign::AdditiveBox', 'Box')
|
||||
box.Length = 10.0
|
||||
box.Width = 10.0
|
||||
box.Height = 10.0
|
||||
|
||||
# Cylinder
|
||||
cyl = body.newObject('PartDesign::AdditiveCylinder', 'Cylinder')
|
||||
cyl.Radius = 5.0
|
||||
cyl.Height = 20.0
|
||||
cyl.Angle = 360.0
|
||||
|
||||
# Sphere
|
||||
sphere = body.newObject('PartDesign::AdditiveSphere', 'Sphere')
|
||||
sphere.Radius = 10.0
|
||||
sphere.Angle1 = -90.0
|
||||
sphere.Angle2 = 90.0
|
||||
sphere.Angle3 = 360.0
|
||||
|
||||
# Cone
|
||||
cone = body.newObject('PartDesign::AdditiveCone', 'Cone')
|
||||
cone.Radius1 = 10.0
|
||||
cone.Radius2 = 5.0
|
||||
cone.Height = 15.0
|
||||
cone.Angle = 360.0
|
||||
|
||||
# Ellipsoid
|
||||
ellipsoid = body.newObject('PartDesign::AdditiveEllipsoid', 'Ellipsoid')
|
||||
ellipsoid.Radius1 = 10.0
|
||||
ellipsoid.Radius2 = 5.0
|
||||
ellipsoid.Radius3 = 8.0
|
||||
|
||||
# Torus
|
||||
torus = body.newObject('PartDesign::AdditiveTorus', 'Torus')
|
||||
torus.Radius1 = 20.0
|
||||
torus.Radius2 = 5.0
|
||||
|
||||
# Prism
|
||||
prism = body.newObject('PartDesign::AdditivePrism', 'Prism')
|
||||
prism.Polygon = 6
|
||||
prism.Circumradius = 10.0
|
||||
prism.Height = 20.0
|
||||
|
||||
# Wedge
|
||||
wedge = body.newObject('PartDesign::AdditiveWedge', 'Wedge')
|
||||
wedge.Xmin = 0.0
|
||||
wedge.Xmax = 10.0
|
||||
wedge.Ymin = 0.0
|
||||
wedge.Ymax = 10.0
|
||||
wedge.Zmin = 0.0
|
||||
wedge.Zmax = 10.0
|
||||
wedge.X2min = 2.0
|
||||
wedge.X2max = 8.0
|
||||
wedge.Z2min = 2.0
|
||||
wedge.Z2max = 8.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Subtractive Features (Remove Material)
|
||||
|
||||
### Pocket
|
||||
Cuts material by extruding a sketch inward.
|
||||
|
||||
```python
|
||||
pocket = body.newObject('PartDesign::Pocket', 'Pocket')
|
||||
pocket.Profile = sketch
|
||||
pocket.Length = 15.0
|
||||
pocket.Type = 0 # Same options as Pad, plus 1=ThroughAll
|
||||
pocket.Reversed = False
|
||||
pocket.Midplane = False
|
||||
pocket.Symmetric = False
|
||||
pocket.TaperAngle = 0.0
|
||||
```
|
||||
|
||||
### Hole
|
||||
Creates parametric holes with threading options.
|
||||
|
||||
```python
|
||||
hole = body.newObject('PartDesign::Hole', 'Hole')
|
||||
hole.Profile = sketch # Sketch with center points
|
||||
hole.Diameter = 6.0
|
||||
hole.Depth = 15.0
|
||||
hole.DepthType = 0 # 0=Dimension, 1=ThroughAll
|
||||
hole.Threaded = True
|
||||
hole.ThreadType = 0 # 0=None, 1=ISOMetricCoarse, 2=ISOMetricFine, 3=UNC, 4=UNF, 5=NPT, etc.
|
||||
hole.ThreadSize = 'M6'
|
||||
hole.ThreadFit = 0 # 0=Standard, 1=Close
|
||||
hole.ThreadDirection = 0 # 0=Right, 1=Left
|
||||
hole.HoleCutType = 0 # 0=None, 1=Counterbore, 2=Countersink
|
||||
hole.HoleCutDiameter = 10.0
|
||||
hole.HoleCutDepth = 3.0
|
||||
hole.HoleCutCountersinkAngle = 90.0
|
||||
hole.DrillPoint = 0 # 0=Flat, 1=Angled
|
||||
hole.DrillPointAngle = 118.0
|
||||
hole.DrillForDepth = False
|
||||
```
|
||||
|
||||
**Thread Types:**
|
||||
- ISO Metric Coarse/Fine
|
||||
- UNC/UNF (Unified National)
|
||||
- NPT/NPTF (National Pipe Thread)
|
||||
- BSW/BSF (British Standard)
|
||||
- UTS (Unified Thread Standard)
|
||||
|
||||
### Groove
|
||||
Creates a cut by revolving a sketch around an axis (subtractive revolution).
|
||||
|
||||
```python
|
||||
groove = body.newObject('PartDesign::Groove', 'Groove')
|
||||
groove.Profile = sketch
|
||||
groove.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
|
||||
groove.Angle = 360.0
|
||||
groove.Midplane = False
|
||||
groove.Reversed = False
|
||||
```
|
||||
|
||||
### Subtractive Loft
|
||||
Cuts by transitioning between profiles.
|
||||
|
||||
```python
|
||||
subloft = body.newObject('PartDesign::SubtractiveLoft', 'SubtractiveLoft')
|
||||
subloft.Profile = sketch1
|
||||
subloft.Sections = [sketch2]
|
||||
```
|
||||
|
||||
### Subtractive Pipe
|
||||
Cuts by sweeping a profile along a path.
|
||||
|
||||
```python
|
||||
subpipe = body.newObject('PartDesign::SubtractivePipe', 'SubtractivePipe')
|
||||
subpipe.Profile = profile_sketch
|
||||
subpipe.Spine = path_sketch
|
||||
```
|
||||
|
||||
### Subtractive Helix
|
||||
Cuts by sweeping along a helix (e.g., for threads).
|
||||
|
||||
```python
|
||||
subhelix = body.newObject('PartDesign::SubtractiveHelix', 'SubtractiveHelix')
|
||||
subhelix.Profile = thread_profile_sketch
|
||||
subhelix.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
|
||||
subhelix.Pitch = 1.0
|
||||
subhelix.Height = 10.0
|
||||
```
|
||||
|
||||
### Subtractive Primitives
|
||||
Same primitives as additive, but subtract material:
|
||||
- `PartDesign::SubtractiveBox`
|
||||
- `PartDesign::SubtractiveCylinder`
|
||||
- `PartDesign::SubtractiveSphere`
|
||||
- `PartDesign::SubtractiveCone`
|
||||
- `PartDesign::SubtractiveEllipsoid`
|
||||
- `PartDesign::SubtractiveTorus`
|
||||
- `PartDesign::SubtractivePrism`
|
||||
- `PartDesign::SubtractiveWedge`
|
||||
|
||||
---
|
||||
|
||||
## Transformation Features (Patterns)
|
||||
|
||||
### Mirrored
|
||||
Creates a mirror copy of features across a plane.
|
||||
|
||||
```python
|
||||
mirrored = body.newObject('PartDesign::Mirrored', 'Mirrored')
|
||||
mirrored.Originals = [pad, pocket]
|
||||
mirrored.MirrorPlane = (body.getObject('Origin').getObject('XZ_Plane'), [''])
|
||||
```
|
||||
|
||||
### Linear Pattern
|
||||
Creates copies in a linear arrangement.
|
||||
|
||||
```python
|
||||
linear = body.newObject('PartDesign::LinearPattern', 'LinearPattern')
|
||||
linear.Originals = [pocket]
|
||||
linear.Direction = (body.getObject('Origin').getObject('X_Axis'), [''])
|
||||
linear.Length = 100.0
|
||||
linear.Occurrences = 5
|
||||
linear.Mode = 0 # 0=OverallLength, 1=Offset
|
||||
```
|
||||
|
||||
### Polar Pattern
|
||||
Creates copies in a circular arrangement.
|
||||
|
||||
```python
|
||||
polar = body.newObject('PartDesign::PolarPattern', 'PolarPattern')
|
||||
polar.Originals = [pocket]
|
||||
polar.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
|
||||
polar.Angle = 360.0
|
||||
polar.Occurrences = 6
|
||||
polar.Mode = 0 # 0=OverallAngle, 1=Offset
|
||||
```
|
||||
|
||||
### MultiTransform
|
||||
Combines multiple transformations (mirrored, linear, polar, scaled).
|
||||
|
||||
```python
|
||||
multi = body.newObject('PartDesign::MultiTransform', 'MultiTransform')
|
||||
multi.Originals = [pocket]
|
||||
|
||||
# Add transformations (created within MultiTransform)
|
||||
# Typically done via GUI or by setting Transformations property
|
||||
multi.Transformations = [mirrored_transform, linear_transform]
|
||||
```
|
||||
|
||||
### Scaled
|
||||
Scales features (only available within MultiTransform).
|
||||
|
||||
```python
|
||||
# Only accessible as part of MultiTransform
|
||||
scaled = body.newObject('PartDesign::Scaled', 'Scaled')
|
||||
scaled.Factor = 0.5
|
||||
scaled.Occurrences = 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dress-Up Features (Edge/Face Treatment)
|
||||
|
||||
### Fillet
|
||||
Rounds edges with a specified radius.
|
||||
|
||||
```python
|
||||
fillet = body.newObject('PartDesign::Fillet', 'Fillet')
|
||||
fillet.Base = (pad, ['Edge1', 'Edge5', 'Edge9'])
|
||||
fillet.Radius = 2.0
|
||||
```
|
||||
|
||||
### Chamfer
|
||||
Bevels edges.
|
||||
|
||||
```python
|
||||
chamfer = body.newObject('PartDesign::Chamfer', 'Chamfer')
|
||||
chamfer.Base = (pad, ['Edge2', 'Edge6'])
|
||||
chamfer.ChamferType = 'Equal Distance' # or 'Two Distances' or 'Distance and Angle'
|
||||
chamfer.Size = 1.5
|
||||
chamfer.Size2 = 2.0 # For asymmetric
|
||||
chamfer.Angle = 45.0 # For 'Distance and Angle'
|
||||
```
|
||||
|
||||
### Draft
|
||||
Applies angular draft to faces (for mold release).
|
||||
|
||||
```python
|
||||
draft = body.newObject('PartDesign::Draft', 'Draft')
|
||||
draft.Base = (pad, ['Face2', 'Face4'])
|
||||
draft.Angle = 3.0 # Degrees
|
||||
draft.NeutralPlane = (body.getObject('Origin').getObject('XY_Plane'), [''])
|
||||
draft.PullDirection = App.Vector(0, 0, 1)
|
||||
draft.Reversed = False
|
||||
```
|
||||
|
||||
### Thickness
|
||||
Creates a shell by hollowing out a solid, keeping selected faces open.
|
||||
|
||||
```python
|
||||
thickness = body.newObject('PartDesign::Thickness', 'Thickness')
|
||||
thickness.Base = (pad, ['Face6']) # Faces to remove (open)
|
||||
thickness.Value = 2.0 # Wall thickness
|
||||
thickness.Mode = 0 # 0=Skin, 1=Pipe, 2=RectoVerso
|
||||
thickness.Join = 0 # 0=Arc, 1=Intersection
|
||||
thickness.Reversed = False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Boolean Operations
|
||||
|
||||
### Boolean
|
||||
Imports bodies and applies boolean operations.
|
||||
|
||||
```python
|
||||
boolean = body.newObject('PartDesign::Boolean', 'Boolean')
|
||||
boolean.Type = 0 # 0=Fuse, 1=Cut, 2=Common (intersection)
|
||||
boolean.Bodies = [other_body1, other_body2]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Menu Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **Set Tip** | Sets selected feature as the body's current state (tip) |
|
||||
| **Move object to other body** | Transfers feature to a different body |
|
||||
| **Move object after other object** | Reorders features in the tree |
|
||||
| **Appearance** | Sets color and transparency |
|
||||
| **Color per face** | Assigns different colors to individual faces |
|
||||
|
||||
```python
|
||||
# Set tip programmatically
|
||||
body.Tip = pocket
|
||||
|
||||
# Move feature order
|
||||
doc.moveObject(feature, body, after_feature)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Tools
|
||||
|
||||
### Sprocket
|
||||
Creates a sprocket profile for chain drives.
|
||||
|
||||
```python
|
||||
# Available via Gui.runCommand('PartDesign_Sprocket')
|
||||
```
|
||||
|
||||
### Involute Gear
|
||||
Creates an involute gear profile.
|
||||
|
||||
```python
|
||||
# Available via Gui.runCommand('PartDesign_InvoluteGear')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Properties (All Features)
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Label` | String | User-visible name |
|
||||
| `Placement` | Placement | Position and orientation |
|
||||
| `BaseFeature` | Link | Feature this builds upon |
|
||||
| `Shape` | Shape | Resulting geometry |
|
||||
|
||||
---
|
||||
|
||||
## Expression Binding
|
||||
|
||||
All dimensional properties can be driven by expressions:
|
||||
|
||||
```python
|
||||
pad.setExpression('Length', 'Spreadsheet.plate_height')
|
||||
fillet.setExpression('Radius', 'Spreadsheet.fillet_r * 0.5')
|
||||
hole.setExpression('Diameter', '<<Parameters>>.hole_dia')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always work within a Body** — PartDesign features require a body container
|
||||
2. **Use fully constrained sketches** — Prevents unexpected behavior when parameters change
|
||||
3. **Leverage datum geometry** — Creates stable references that survive TNP issues
|
||||
4. **Name constraints** — Enables expression-based parametric design
|
||||
5. **Use spreadsheets** — Centralizes parameters for easy modification
|
||||
6. **Set meaningful Labels** — Internal Names are auto-generated; Labels are user-friendly
|
||||
7. **Check isSolid()** — Before subtractive operations, verify the body has solid geometry
|
||||
|
||||
```python
|
||||
if not body.isSolid():
|
||||
raise ValueError("Body must contain solid geometry for subtractive features")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FreeCAD 1.0 Changes
|
||||
|
||||
| Change | Description |
|
||||
|--------|-------------|
|
||||
| **TNP Mitigation** | Topological naming more stable |
|
||||
| **UpToShape** | New Pad/Pocket type extending to arbitrary shapes |
|
||||
| **Draft Angle** | Taper angles on Pad/Pocket |
|
||||
| **Improved Hole** | More thread types, better UI |
|
||||
| **Assembly Integration** | Native assembly workbench |
|
||||
| **Arch → BIM** | Workbench rename |
|
||||
| **Path → CAM** | Workbench rename |
|
||||
|
||||
---
|
||||
|
||||
## Python Module Access
|
||||
|
||||
```python
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
import Sketcher
|
||||
import PartDesign
|
||||
import PartDesignGui
|
||||
|
||||
# Access feature classes
|
||||
print(dir(PartDesign))
|
||||
# ['Additive', 'AdditiveBox', 'AdditiveCone', 'AdditiveCylinder', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Document version: FreeCAD 1.0.2 / January 2026*
|
||||
*Reference: FreeCAD Wiki, GitHub FreeCAD-documentation, FreeCAD Forum*
|
||||
12
reference/ztools/ztools/Init.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# ztools Addon Initialization
|
||||
# This file runs at FreeCAD startup (before GUI)
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
# The Catppuccin Mocha theme is now provided as a Preference Pack.
|
||||
# It will be automatically available in:
|
||||
# Edit > Preferences > General > Preference packs > CatppuccinMocha
|
||||
#
|
||||
# No manual installation is required - FreeCAD's addon system handles it.
|
||||
|
||||
App.Console.PrintLog("ztools addon loaded\n")
|
||||
367
reference/ztools/ztools/InitGui.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# ztools Workbench for FreeCAD 1.0+
|
||||
# Extended PartDesign replacement with velocity-focused tools
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
|
||||
class ZToolsWorkbench(Gui.Workbench):
|
||||
"""Extended PartDesign workbench with velocity-focused tools."""
|
||||
|
||||
MenuText = "ztools"
|
||||
ToolTip = "Extended PartDesign replacement for faster CAD workflows"
|
||||
|
||||
# Catppuccin Mocha themed icon
|
||||
Icon = """
|
||||
/* XPM */
|
||||
static char * ztools_xpm[] = {
|
||||
"16 16 5 1",
|
||||
" c None",
|
||||
". c #313244",
|
||||
"+ c #cba6f7",
|
||||
"@ c #94e2d5",
|
||||
"# c #45475a",
|
||||
" ",
|
||||
" ############ ",
|
||||
" #..........# ",
|
||||
" #.++++++++.# ",
|
||||
" #.+......+.# ",
|
||||
" #.....+++..# ",
|
||||
" #....++....# ",
|
||||
" #...++.....# ",
|
||||
" #..++......# ",
|
||||
" #.++.......# ",
|
||||
" #.++++++++@# ",
|
||||
" #..........# ",
|
||||
" ############ ",
|
||||
" ",
|
||||
" ",
|
||||
" "};
|
||||
"""
|
||||
|
||||
def Initialize(self):
|
||||
"""Called on workbench first activation."""
|
||||
# Load PartDesign and Sketcher workbenches to register their commands
|
||||
# We need to actually activate them briefly to ensure commands are registered
|
||||
# Activate dependent workbenches so their commands are registered.
|
||||
# Use activateWorkbench() instead of calling Initialize() directly,
|
||||
# since direct calls skip the C++ __Workbench__ injection step.
|
||||
# Wrap each individually so one failure doesn't block the rest.
|
||||
wb_list = Gui.listWorkbenches()
|
||||
current_wb = Gui.activeWorkbench()
|
||||
for wb_name in (
|
||||
"PartDesignWorkbench",
|
||||
"SketcherWorkbench",
|
||||
"AssemblyWorkbench",
|
||||
"SpreadsheetWorkbench",
|
||||
):
|
||||
if wb_name in wb_list:
|
||||
try:
|
||||
Gui.activateWorkbench(wb_name)
|
||||
except Exception as e:
|
||||
App.Console.PrintWarning(f"Could not initialize {wb_name}: {e}\n")
|
||||
# Restore ztools as the active workbench
|
||||
try:
|
||||
Gui.activateWorkbench("ZToolsWorkbench")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Command imports moved to module scope (after Gui.addWorkbench) so they
|
||||
# are available before Initialize() runs. See end of file.
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Structure Tools
|
||||
# =====================================================================
|
||||
self.structure_tools = [
|
||||
"PartDesign_Body",
|
||||
"PartDesign_NewSketch",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Reference Geometry (Datums)
|
||||
# =====================================================================
|
||||
self.partdesign_datum_tools = [
|
||||
"PartDesign_Plane",
|
||||
"PartDesign_Line",
|
||||
"PartDesign_Point",
|
||||
"PartDesign_CoordinateSystem",
|
||||
"PartDesign_ShapeBinder",
|
||||
"PartDesign_SubShapeBinder",
|
||||
"PartDesign_Clone",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Additive Features
|
||||
# =====================================================================
|
||||
self.additive_tools = [
|
||||
"PartDesign_Pad",
|
||||
"PartDesign_Revolution",
|
||||
"PartDesign_AdditiveLoft",
|
||||
"PartDesign_AdditivePipe",
|
||||
"PartDesign_AdditiveHelix",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Additive Primitives (compound command with dropdown)
|
||||
# =====================================================================
|
||||
self.additive_primitives = [
|
||||
"PartDesign_CompPrimitiveAdditive",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Subtractive Features
|
||||
# =====================================================================
|
||||
self.subtractive_tools = [
|
||||
"PartDesign_Pocket",
|
||||
"PartDesign_Hole",
|
||||
"PartDesign_Groove",
|
||||
"PartDesign_SubtractiveLoft",
|
||||
"PartDesign_SubtractivePipe",
|
||||
"PartDesign_SubtractiveHelix",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Subtractive Primitives (compound command with dropdown)
|
||||
# =====================================================================
|
||||
self.subtractive_primitives = [
|
||||
"PartDesign_CompPrimitiveSubtractive",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Transformation Features (Patterns)
|
||||
# =====================================================================
|
||||
self.transformation_tools = [
|
||||
"PartDesign_Mirrored",
|
||||
"PartDesign_LinearPattern",
|
||||
"PartDesign_PolarPattern",
|
||||
"PartDesign_MultiTransform",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Dress-Up Features
|
||||
# =====================================================================
|
||||
self.dressup_tools = [
|
||||
"PartDesign_Fillet",
|
||||
"PartDesign_Chamfer",
|
||||
"PartDesign_Draft",
|
||||
"PartDesign_Thickness",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# PartDesign Boolean Operations
|
||||
# =====================================================================
|
||||
self.boolean_tools = [
|
||||
"PartDesign_Boolean",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Sketcher Tools (commonly used with PartDesign)
|
||||
# =====================================================================
|
||||
self.sketcher_tools = [
|
||||
"Sketcher_NewSketch",
|
||||
"Sketcher_EditSketch",
|
||||
"Sketcher_MapSketch",
|
||||
"Sketcher_ValidateSketch",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# ZTools Custom Tools
|
||||
# =====================================================================
|
||||
self.ztools_datum_tools = [
|
||||
"ZTools_DatumCreator",
|
||||
"ZTools_DatumManager",
|
||||
]
|
||||
|
||||
self.ztools_pattern_tools = [
|
||||
"ZTools_RotatedLinearPattern",
|
||||
]
|
||||
|
||||
self.ztools_pocket_tools = [
|
||||
"ZTools_EnhancedPocket",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Assembly Workbench Tools (FreeCAD 1.0+)
|
||||
# =====================================================================
|
||||
self.assembly_structure_tools = [
|
||||
"Assembly_CreateAssembly",
|
||||
"Assembly_InsertLink",
|
||||
"Assembly_InsertNewPart",
|
||||
]
|
||||
|
||||
self.assembly_joint_tools = [
|
||||
"Assembly_CreateJointFixed",
|
||||
"Assembly_CreateJointRevolute",
|
||||
"Assembly_CreateJointCylindrical",
|
||||
"Assembly_CreateJointSlider",
|
||||
"Assembly_CreateJointBall",
|
||||
"Assembly_CreateJointDistance",
|
||||
"Assembly_CreateJointParallel",
|
||||
"Assembly_CreateJointPerpendicular",
|
||||
"Assembly_CreateJointAngle",
|
||||
"Assembly_CreateJointRackPinion",
|
||||
"Assembly_CreateJointScrew",
|
||||
"Assembly_CreateJointGears",
|
||||
"Assembly_CreateJointBelt",
|
||||
]
|
||||
|
||||
self.assembly_management_tools = [
|
||||
"Assembly_ToggleGrounded",
|
||||
"Assembly_SolveAssembly",
|
||||
"Assembly_CreateView",
|
||||
"Assembly_CreateBom",
|
||||
"Assembly_ExportASMT",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# ZTools Assembly Pattern Tools
|
||||
# =====================================================================
|
||||
self.ztools_assembly_tools = [
|
||||
"ZTools_AssemblyLinearPattern",
|
||||
"ZTools_AssemblyPolarPattern",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Spreadsheet Workbench Tools
|
||||
# =====================================================================
|
||||
self.spreadsheet_tools = [
|
||||
"Spreadsheet_CreateSheet",
|
||||
"Spreadsheet_Import",
|
||||
"Spreadsheet_Export",
|
||||
"Spreadsheet_SetAlias",
|
||||
"Spreadsheet_MergeCells",
|
||||
"Spreadsheet_SplitCell",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# ZTools Spreadsheet Formatting Tools
|
||||
# =====================================================================
|
||||
self.ztools_spreadsheet_tools = [
|
||||
"ZTools_SpreadsheetStyleBold",
|
||||
"ZTools_SpreadsheetStyleItalic",
|
||||
"ZTools_SpreadsheetStyleUnderline",
|
||||
"ZTools_SpreadsheetAlignLeft",
|
||||
"ZTools_SpreadsheetAlignCenter",
|
||||
"ZTools_SpreadsheetAlignRight",
|
||||
"ZTools_SpreadsheetBgColor",
|
||||
"ZTools_SpreadsheetTextColor",
|
||||
"ZTools_SpreadsheetQuickAlias",
|
||||
]
|
||||
|
||||
# =====================================================================
|
||||
# Append Toolbars
|
||||
# =====================================================================
|
||||
self.appendToolbar("Structure", self.structure_tools)
|
||||
self.appendToolbar("Sketcher", self.sketcher_tools)
|
||||
self.appendToolbar("Datums", self.partdesign_datum_tools)
|
||||
self.appendToolbar("Additive", self.additive_tools + self.additive_primitives)
|
||||
self.appendToolbar(
|
||||
"Subtractive", self.subtractive_tools + self.subtractive_primitives
|
||||
)
|
||||
self.appendToolbar("Transformations", self.transformation_tools)
|
||||
self.appendToolbar("Dress-Up", self.dressup_tools)
|
||||
self.appendToolbar("Boolean", self.boolean_tools)
|
||||
self.appendToolbar("Assembly", self.assembly_structure_tools)
|
||||
self.appendToolbar("Assembly Joints", self.assembly_joint_tools)
|
||||
self.appendToolbar("Assembly Management", self.assembly_management_tools)
|
||||
self.appendToolbar("ztools Datums", self.ztools_datum_tools)
|
||||
self.appendToolbar("ztools Patterns", self.ztools_pattern_tools)
|
||||
self.appendToolbar("ztools Features", self.ztools_pocket_tools)
|
||||
self.appendToolbar("ztools Assembly", self.ztools_assembly_tools)
|
||||
self.appendToolbar("Spreadsheet", self.spreadsheet_tools)
|
||||
self.appendToolbar("ztools Spreadsheet", self.ztools_spreadsheet_tools)
|
||||
|
||||
# =====================================================================
|
||||
# Append Menus
|
||||
# =====================================================================
|
||||
self.appendMenu("Structure", self.structure_tools)
|
||||
self.appendMenu("Sketch", self.sketcher_tools)
|
||||
self.appendMenu(["PartDesign", "Datums"], self.partdesign_datum_tools)
|
||||
self.appendMenu(
|
||||
["PartDesign", "Additive"], self.additive_tools + self.additive_primitives
|
||||
)
|
||||
self.appendMenu(
|
||||
["PartDesign", "Subtractive"],
|
||||
self.subtractive_tools + self.subtractive_primitives,
|
||||
)
|
||||
self.appendMenu(["PartDesign", "Transformations"], self.transformation_tools)
|
||||
self.appendMenu(["PartDesign", "Dress-Up"], self.dressup_tools)
|
||||
self.appendMenu(["PartDesign", "Boolean"], self.boolean_tools)
|
||||
self.appendMenu(["Assembly", "Structure"], self.assembly_structure_tools)
|
||||
self.appendMenu(["Assembly", "Joints"], self.assembly_joint_tools)
|
||||
self.appendMenu(["Assembly", "Management"], self.assembly_management_tools)
|
||||
self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools)
|
||||
self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools)
|
||||
self.appendMenu(
|
||||
"ztools",
|
||||
self.ztools_datum_tools
|
||||
+ self.ztools_pattern_tools
|
||||
+ self.ztools_pocket_tools
|
||||
+ self.ztools_assembly_tools
|
||||
+ self.ztools_spreadsheet_tools,
|
||||
)
|
||||
|
||||
App.Console.PrintMessage("ztools workbench initialized\n")
|
||||
|
||||
def Activated(self):
|
||||
"""Called when workbench is activated."""
|
||||
App.Console.PrintMessage("ztools workbench activated\n")
|
||||
|
||||
def Deactivated(self):
|
||||
"""Called when workbench is deactivated."""
|
||||
pass
|
||||
|
||||
def GetClassName(self):
|
||||
return "Gui::PythonWorkbench"
|
||||
|
||||
|
||||
Gui.addWorkbench(ZToolsWorkbench())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eager command registration
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import command modules at module scope so Gui.addCommand() calls run before
|
||||
# any workbench activates. This ensures the PartDesign manipulator can
|
||||
# reference them regardless of workbench activation order (#52).
|
||||
|
||||
from ztools.commands import (
|
||||
assembly_pattern_commands,
|
||||
datum_commands,
|
||||
pattern_commands,
|
||||
pocket_commands,
|
||||
spreadsheet_commands,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WorkbenchManipulator: inject ZTools commands into PartDesign workbench
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ZToolsPartDesignManipulator:
|
||||
"""Adds ZTools commands to PartDesign toolbars and menus."""
|
||||
|
||||
def modifyToolBars(self):
|
||||
return [
|
||||
{"append": "ZTools_DatumCreator", "toolBar": "Part Design Helper Features"},
|
||||
{"append": "ZTools_DatumManager", "toolBar": "Part Design Helper Features"},
|
||||
{
|
||||
"append": "ZTools_EnhancedPocket",
|
||||
"toolBar": "Part Design Modeling Features",
|
||||
},
|
||||
{
|
||||
"append": "ZTools_RotatedLinearPattern",
|
||||
"toolBar": "Part Design Transformation Features",
|
||||
},
|
||||
]
|
||||
|
||||
def modifyMenuBar(self):
|
||||
return [
|
||||
{"append": "ZTools_DatumCreator", "menuItem": "PartDesign_Body"},
|
||||
{"append": "ZTools_DatumManager", "menuItem": "PartDesign_Body"},
|
||||
{"append": "ZTools_EnhancedPocket", "menuItem": "PartDesign_Body"},
|
||||
{"append": "ZTools_RotatedLinearPattern", "menuItem": "PartDesign_Body"},
|
||||
]
|
||||
|
||||
|
||||
Gui.addWorkbenchManipulator(_ZToolsPartDesignManipulator())
|
||||
123
reference/ztools/ztools/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ztools - Extended PartDesign for FreeCAD
|
||||
|
||||
Velocity-focused CAD tools extending FreeCAD 1.0+ PartDesign workbench.
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Copy the `ztools` folder to your FreeCAD Mod directory:
|
||||
- **Linux**: `~/.local/share/FreeCAD/Mod/ztools/`
|
||||
- **Windows**: `%APPDATA%\FreeCAD\Mod\ztools\`
|
||||
- **macOS**: `~/Library/Application Support/FreeCAD/Mod/ztools/`
|
||||
|
||||
2. Restart FreeCAD
|
||||
|
||||
3. Select **ztools** from the workbench dropdown
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
ztools/
|
||||
├── InitGui.py # Workbench registration
|
||||
├── ztools/
|
||||
│ ├── __init__.py
|
||||
│ ├── datums/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── core.py # Datum creation functions
|
||||
│ └── commands/
|
||||
│ ├── __init__.py
|
||||
│ └── datum_commands.py # GUI commands
|
||||
├── setup.cfg
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Module 1: Datum Tools
|
||||
|
||||
### Datum Creator (GUI)
|
||||
|
||||
Unified task panel for creating:
|
||||
|
||||
**Planes**
|
||||
- Offset from Face
|
||||
- Midplane (2 parallel faces)
|
||||
- 3 Points
|
||||
- Normal to Edge (at parameter)
|
||||
- Angled from Face (about edge)
|
||||
- Tangent to Cylinder
|
||||
|
||||
**Axes**
|
||||
- 2 Points
|
||||
- From Edge
|
||||
- Cylinder Center
|
||||
- Plane Intersection
|
||||
|
||||
**Points**
|
||||
- At Vertex
|
||||
- XYZ Coordinates
|
||||
- On Edge (at parameter)
|
||||
- Face Center
|
||||
- Circle Center
|
||||
|
||||
### Options
|
||||
|
||||
- **Link to Spreadsheet**: Creates aliases in Spreadsheet for parametric control
|
||||
- **Add to Active Body**: Creates PartDesign datums vs Part geometry
|
||||
- **Custom Name**: Override auto-naming (e.g., `ZPlane_Offset_001`)
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from ztools.datums import (
|
||||
plane_offset_from_face,
|
||||
plane_midplane,
|
||||
axis_cylinder_center,
|
||||
point_at_coordinates,
|
||||
)
|
||||
|
||||
doc = App.ActiveDocument
|
||||
body = doc.getObject('Body')
|
||||
|
||||
# Offset plane from selected face
|
||||
face = body.Shape.Faces[0]
|
||||
plane = plane_offset_from_face(face, 15.0, body=body, link_spreadsheet=True)
|
||||
|
||||
# Midplane between two faces
|
||||
mid = plane_midplane(face1, face2, name="MidPlane_Custom")
|
||||
|
||||
# Axis at cylinder center
|
||||
cyl_face = body.Shape.Faces[2]
|
||||
axis = axis_cylinder_center(cyl_face, body=body)
|
||||
|
||||
# Point at coordinates with spreadsheet link
|
||||
pt = point_at_coordinates(50, 25, 0, link_spreadsheet=True)
|
||||
```
|
||||
|
||||
### Metadata
|
||||
|
||||
All ztools datums store creation metadata in custom properties:
|
||||
|
||||
- `ZTools_Type`: Creation method (e.g., "offset_from_face")
|
||||
- `ZTools_Params`: JSON-encoded parameters
|
||||
|
||||
Access via:
|
||||
```python
|
||||
plane = doc.getObject('ZPlane_Offset_001')
|
||||
print(plane.ZTools_Type) # "offset_from_face"
|
||||
print(plane.ZTools_Params) # {"distance": 15.0, ...}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] **Module 2**: Enhanced Pad/Pocket (multi-body, draft angles, lip/groove)
|
||||
- [ ] **Module 3**: Body operations (split, combine, shell improvements)
|
||||
- [ ] **Module 4**: Pattern tools (curve-driven, fill patterns)
|
||||
- [ ] **Datum Manager**: Panel to list/toggle/rename all datums
|
||||
|
||||
## License
|
||||
|
||||
LGPL-2.1 (same as FreeCAD)
|
||||
|
||||
## Contributing
|
||||
|
||||
Kindred Systems LLC - Kansas City
|
||||
11
reference/ztools/ztools/setup.cfg
Normal file
@@ -0,0 +1,11 @@
|
||||
[metadata]
|
||||
name = ztools
|
||||
version = 0.1.0
|
||||
description = Extended PartDesign workbench for FreeCAD with velocity-focused tools
|
||||
author = Kindred Systems LLC
|
||||
license = LGPL-2.1
|
||||
url = https://github.com/kindredsystems/ztools
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
python_requires = >=3.8
|
||||
2
reference/ztools/ztools/ztools/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# ztools - Extended PartDesign for FreeCAD
|
||||
__version__ = "0.1.0"
|
||||
18
reference/ztools/ztools/ztools/commands/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# ztools/commands - GUI commands
|
||||
from . import (
|
||||
assembly_pattern_commands,
|
||||
datum_commands,
|
||||
datum_viewprovider,
|
||||
pattern_commands,
|
||||
pocket_commands,
|
||||
spreadsheet_commands,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"datum_commands",
|
||||
"datum_viewprovider",
|
||||
"pattern_commands",
|
||||
"pocket_commands",
|
||||
"assembly_pattern_commands",
|
||||
"spreadsheet_commands",
|
||||
]
|
||||
@@ -0,0 +1,787 @@
|
||||
# ztools/commands/assembly_pattern_commands.py
|
||||
# Assembly patterning tools for FreeCAD 1.0+ Assembly workbench
|
||||
# Creates linear and polar patterns of assembly components
|
||||
|
||||
import math
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
# =============================================================================
|
||||
# Assembly Linear Pattern
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AssemblyLinearPatternTaskPanel:
|
||||
"""Task panel for creating linear patterns of assembly components."""
|
||||
|
||||
def __init__(self):
|
||||
self.form = QtGui.QWidget()
|
||||
self.setup_ui()
|
||||
self.setup_selection_observer()
|
||||
self.selected_components = []
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QtGui.QVBoxLayout(self.form)
|
||||
|
||||
# Component selection section
|
||||
component_group = QtGui.QGroupBox("Components")
|
||||
component_layout = QtGui.QVBoxLayout(component_group)
|
||||
|
||||
# Selection table
|
||||
self.component_table = QtGui.QTableWidget()
|
||||
self.component_table.setColumnCount(2)
|
||||
self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
|
||||
self.component_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.component_table.horizontalHeader().setSectionResizeMode(
|
||||
0, QtGui.QHeaderView.Stretch
|
||||
)
|
||||
self.component_table.horizontalHeader().setSectionResizeMode(
|
||||
1, QtGui.QHeaderView.Fixed
|
||||
)
|
||||
self.component_table.setColumnWidth(1, 60)
|
||||
self.component_table.setMaximumHeight(120)
|
||||
component_layout.addWidget(self.component_table)
|
||||
|
||||
# Selection hint
|
||||
hint_label = QtGui.QLabel("Select assembly components in the 3D view")
|
||||
hint_label.setStyleSheet("color: gray; font-style: italic;")
|
||||
component_layout.addWidget(hint_label)
|
||||
|
||||
layout.addWidget(component_group)
|
||||
|
||||
# Pattern parameters section
|
||||
params_group = QtGui.QGroupBox("Pattern Parameters")
|
||||
params_layout = QtGui.QFormLayout(params_group)
|
||||
|
||||
# Direction
|
||||
direction_layout = QtGui.QHBoxLayout()
|
||||
self.dir_x_spin = QtGui.QDoubleSpinBox()
|
||||
self.dir_x_spin.setRange(-1000, 1000)
|
||||
self.dir_x_spin.setValue(1)
|
||||
self.dir_x_spin.setDecimals(3)
|
||||
self.dir_y_spin = QtGui.QDoubleSpinBox()
|
||||
self.dir_y_spin.setRange(-1000, 1000)
|
||||
self.dir_y_spin.setValue(0)
|
||||
self.dir_y_spin.setDecimals(3)
|
||||
self.dir_z_spin = QtGui.QDoubleSpinBox()
|
||||
self.dir_z_spin.setRange(-1000, 1000)
|
||||
self.dir_z_spin.setValue(0)
|
||||
self.dir_z_spin.setDecimals(3)
|
||||
direction_layout.addWidget(QtGui.QLabel("X:"))
|
||||
direction_layout.addWidget(self.dir_x_spin)
|
||||
direction_layout.addWidget(QtGui.QLabel("Y:"))
|
||||
direction_layout.addWidget(self.dir_y_spin)
|
||||
direction_layout.addWidget(QtGui.QLabel("Z:"))
|
||||
direction_layout.addWidget(self.dir_z_spin)
|
||||
params_layout.addRow("Direction:", direction_layout)
|
||||
|
||||
# Occurrences
|
||||
self.occurrences_spin = QtGui.QSpinBox()
|
||||
self.occurrences_spin.setRange(2, 100)
|
||||
self.occurrences_spin.setValue(3)
|
||||
params_layout.addRow("Occurrences:", self.occurrences_spin)
|
||||
|
||||
# Spacing mode
|
||||
self.spacing_mode = QtGui.QComboBox()
|
||||
self.spacing_mode.addItems(["Total Length", "Spacing"])
|
||||
self.spacing_mode.currentIndexChanged.connect(self.on_spacing_mode_changed)
|
||||
params_layout.addRow("Mode:", self.spacing_mode)
|
||||
|
||||
# Length/Spacing value
|
||||
self.length_spin = QtGui.QDoubleSpinBox()
|
||||
self.length_spin.setRange(0.001, 100000)
|
||||
self.length_spin.setValue(100)
|
||||
self.length_spin.setDecimals(3)
|
||||
self.length_spin.setSuffix(" mm")
|
||||
self.length_label = QtGui.QLabel("Total Length:")
|
||||
params_layout.addRow(self.length_label, self.length_spin)
|
||||
|
||||
layout.addWidget(params_group)
|
||||
|
||||
# Options section
|
||||
options_group = QtGui.QGroupBox("Options")
|
||||
options_layout = QtGui.QVBoxLayout(options_group)
|
||||
|
||||
self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
|
||||
self.link_checkbox.setChecked(True)
|
||||
self.link_checkbox.setToolTip(
|
||||
"Links reference the original component, reducing file size"
|
||||
)
|
||||
options_layout.addWidget(self.link_checkbox)
|
||||
|
||||
self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
|
||||
self.hide_original_checkbox.setChecked(False)
|
||||
options_layout.addWidget(self.hide_original_checkbox)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def setup_selection_observer(self):
|
||||
"""Set up observer to track selection changes."""
|
||||
|
||||
class SelectionObserver:
|
||||
def __init__(self, panel):
|
||||
self.panel = panel
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
self.panel.on_selection_added(doc, obj, sub)
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
self.panel.on_selection_removed(doc, obj, sub)
|
||||
|
||||
def clearSelection(self, doc):
|
||||
pass
|
||||
|
||||
self.observer = SelectionObserver(self)
|
||||
Gui.Selection.addObserver(self.observer)
|
||||
|
||||
def on_selection_added(self, doc_name, obj_name, sub):
|
||||
"""Handle new selection."""
|
||||
doc = App.getDocument(doc_name)
|
||||
if not doc:
|
||||
return
|
||||
|
||||
obj = doc.getObject(obj_name)
|
||||
if not obj:
|
||||
return
|
||||
|
||||
# Check if this is an assembly component (App::Link or Part)
|
||||
if not self._is_valid_component(obj):
|
||||
return
|
||||
|
||||
# Avoid duplicates
|
||||
if obj in self.selected_components:
|
||||
return
|
||||
|
||||
self.selected_components.append(obj)
|
||||
self._update_table()
|
||||
|
||||
def on_selection_removed(self, doc_name, obj_name, sub):
|
||||
"""Handle selection removal."""
|
||||
doc = App.getDocument(doc_name)
|
||||
if not doc:
|
||||
return
|
||||
|
||||
obj = doc.getObject(obj_name)
|
||||
if obj in self.selected_components:
|
||||
self.selected_components.remove(obj)
|
||||
self._update_table()
|
||||
|
||||
def _is_valid_component(self, obj):
|
||||
"""Check if object is a valid assembly component."""
|
||||
valid_types = [
|
||||
"App::Link",
|
||||
"App::LinkGroup",
|
||||
"Part::Feature",
|
||||
"PartDesign::Body",
|
||||
"App::Part",
|
||||
]
|
||||
return obj.TypeId in valid_types
|
||||
|
||||
def _update_table(self):
|
||||
"""Update the component table."""
|
||||
self.component_table.setRowCount(len(self.selected_components))
|
||||
|
||||
for i, comp in enumerate(self.selected_components):
|
||||
# Component name
|
||||
name_item = QtGui.QTableWidgetItem(comp.Label)
|
||||
name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
|
||||
self.component_table.setItem(i, 0, name_item)
|
||||
|
||||
# Remove button
|
||||
remove_btn = QtGui.QPushButton("X")
|
||||
remove_btn.setMaximumWidth(40)
|
||||
remove_btn.clicked.connect(
|
||||
lambda checked, idx=i: self._remove_component(idx)
|
||||
)
|
||||
self.component_table.setCellWidget(i, 1, remove_btn)
|
||||
|
||||
def _remove_component(self, index):
|
||||
"""Remove component from selection."""
|
||||
if 0 <= index < len(self.selected_components):
|
||||
self.selected_components.pop(index)
|
||||
self._update_table()
|
||||
|
||||
def on_spacing_mode_changed(self, index):
|
||||
"""Update label based on spacing mode."""
|
||||
if index == 0:
|
||||
self.length_label.setText("Total Length:")
|
||||
else:
|
||||
self.length_label.setText("Spacing:")
|
||||
|
||||
def accept(self):
|
||||
"""Create the linear pattern."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
|
||||
if not self.selected_components:
|
||||
App.Console.PrintError("No components selected for pattern\n")
|
||||
return False
|
||||
|
||||
doc = App.ActiveDocument
|
||||
if not doc:
|
||||
return False
|
||||
|
||||
# Get parameters
|
||||
direction = App.Vector(
|
||||
self.dir_x_spin.value(),
|
||||
self.dir_y_spin.value(),
|
||||
self.dir_z_spin.value(),
|
||||
)
|
||||
|
||||
if direction.Length < 1e-6:
|
||||
App.Console.PrintError("Direction vector cannot be zero\n")
|
||||
return False
|
||||
|
||||
direction.normalize()
|
||||
|
||||
occurrences = self.occurrences_spin.value()
|
||||
length_value = self.length_spin.value()
|
||||
|
||||
if self.spacing_mode.currentIndex() == 0:
|
||||
# Total length mode
|
||||
spacing = length_value / (occurrences - 1) if occurrences > 1 else 0
|
||||
else:
|
||||
# Spacing mode
|
||||
spacing = length_value
|
||||
|
||||
use_links = self.link_checkbox.isChecked()
|
||||
hide_original = self.hide_original_checkbox.isChecked()
|
||||
|
||||
# Find parent assembly
|
||||
assembly = self._find_parent_assembly(self.selected_components[0])
|
||||
|
||||
doc.openTransaction("Assembly Linear Pattern")
|
||||
|
||||
try:
|
||||
created_objects = []
|
||||
|
||||
for comp in self.selected_components:
|
||||
# Get base placement
|
||||
if hasattr(comp, "Placement"):
|
||||
base_placement = comp.Placement
|
||||
else:
|
||||
base_placement = App.Placement()
|
||||
|
||||
# Create pattern instances (skip i=0 as that's the original)
|
||||
for i in range(1, occurrences):
|
||||
offset = direction * spacing * i
|
||||
new_placement = App.Placement(
|
||||
base_placement.Base + offset,
|
||||
base_placement.Rotation,
|
||||
)
|
||||
|
||||
if use_links:
|
||||
# Create link to original
|
||||
if comp.TypeId == "App::Link":
|
||||
# Link to the linked object
|
||||
link_target = comp.LinkedObject
|
||||
else:
|
||||
link_target = comp
|
||||
|
||||
new_obj = doc.addObject("App::Link", f"{comp.Label}_Pattern{i}")
|
||||
new_obj.LinkedObject = link_target
|
||||
new_obj.Placement = new_placement
|
||||
else:
|
||||
# Create copy
|
||||
new_obj = doc.copyObject(comp, False)
|
||||
new_obj.Label = f"{comp.Label}_Pattern{i}"
|
||||
new_obj.Placement = new_placement
|
||||
|
||||
# Add to assembly if found
|
||||
if assembly and hasattr(assembly, "addObject"):
|
||||
assembly.addObject(new_obj)
|
||||
|
||||
created_objects.append(new_obj)
|
||||
|
||||
# Hide original if requested
|
||||
if hide_original and hasattr(comp, "ViewObject"):
|
||||
comp.ViewObject.Visibility = False
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
|
||||
App.Console.PrintMessage(
|
||||
f"Created {len(created_objects)} pattern instances\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to create pattern: {e}\n")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
"""Cancel the operation."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
return True
|
||||
|
||||
def _find_parent_assembly(self, obj):
|
||||
"""Find the parent assembly of an object."""
|
||||
doc = obj.Document
|
||||
|
||||
for candidate in doc.Objects:
|
||||
if candidate.TypeId == "Assembly::AssemblyObject":
|
||||
# Check if obj is in this assembly's group
|
||||
if hasattr(candidate, "Group"):
|
||||
if obj in candidate.Group:
|
||||
return candidate
|
||||
# Check nested
|
||||
for member in candidate.Group:
|
||||
if hasattr(member, "Group") and obj in member.Group:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||
|
||||
|
||||
class AssemblyLinearPatternCommand:
|
||||
"""Command to create a linear pattern of assembly components."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("assembly_linear_pattern"),
|
||||
"MenuText": "Assembly Linear Pattern",
|
||||
"ToolTip": "Create a linear pattern of assembly components",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
def Activated(self):
|
||||
panel = AssemblyLinearPatternTaskPanel()
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Assembly Polar Pattern
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AssemblyPolarPatternTaskPanel:
|
||||
"""Task panel for creating polar patterns of assembly components."""
|
||||
|
||||
def __init__(self):
|
||||
self.form = QtGui.QWidget()
|
||||
self.setup_ui()
|
||||
self.setup_selection_observer()
|
||||
self.selected_components = []
|
||||
self.axis_selection = None
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QtGui.QVBoxLayout(self.form)
|
||||
|
||||
# Component selection section
|
||||
component_group = QtGui.QGroupBox("Components")
|
||||
component_layout = QtGui.QVBoxLayout(component_group)
|
||||
|
||||
# Selection table
|
||||
self.component_table = QtGui.QTableWidget()
|
||||
self.component_table.setColumnCount(2)
|
||||
self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
|
||||
self.component_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.component_table.horizontalHeader().setSectionResizeMode(
|
||||
0, QtGui.QHeaderView.Stretch
|
||||
)
|
||||
self.component_table.horizontalHeader().setSectionResizeMode(
|
||||
1, QtGui.QHeaderView.Fixed
|
||||
)
|
||||
self.component_table.setColumnWidth(1, 60)
|
||||
self.component_table.setMaximumHeight(100)
|
||||
component_layout.addWidget(self.component_table)
|
||||
|
||||
hint_label = QtGui.QLabel("Select assembly components in the 3D view")
|
||||
hint_label.setStyleSheet("color: gray; font-style: italic;")
|
||||
component_layout.addWidget(hint_label)
|
||||
|
||||
layout.addWidget(component_group)
|
||||
|
||||
# Axis section
|
||||
axis_group = QtGui.QGroupBox("Rotation Axis")
|
||||
axis_layout = QtGui.QFormLayout(axis_group)
|
||||
|
||||
# Axis definition mode
|
||||
self.axis_mode = QtGui.QComboBox()
|
||||
self.axis_mode.addItems(["Custom Axis", "X Axis", "Y Axis", "Z Axis"])
|
||||
self.axis_mode.currentIndexChanged.connect(self.on_axis_mode_changed)
|
||||
axis_layout.addRow("Axis:", self.axis_mode)
|
||||
|
||||
# Custom axis direction
|
||||
self.axis_widget = QtGui.QWidget()
|
||||
axis_dir_layout = QtGui.QHBoxLayout(self.axis_widget)
|
||||
axis_dir_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.axis_x_spin = QtGui.QDoubleSpinBox()
|
||||
self.axis_x_spin.setRange(-1, 1)
|
||||
self.axis_x_spin.setValue(0)
|
||||
self.axis_x_spin.setDecimals(3)
|
||||
self.axis_x_spin.setSingleStep(0.1)
|
||||
self.axis_y_spin = QtGui.QDoubleSpinBox()
|
||||
self.axis_y_spin.setRange(-1, 1)
|
||||
self.axis_y_spin.setValue(0)
|
||||
self.axis_y_spin.setDecimals(3)
|
||||
self.axis_y_spin.setSingleStep(0.1)
|
||||
self.axis_z_spin = QtGui.QDoubleSpinBox()
|
||||
self.axis_z_spin.setRange(-1, 1)
|
||||
self.axis_z_spin.setValue(1)
|
||||
self.axis_z_spin.setDecimals(3)
|
||||
self.axis_z_spin.setSingleStep(0.1)
|
||||
axis_dir_layout.addWidget(QtGui.QLabel("X:"))
|
||||
axis_dir_layout.addWidget(self.axis_x_spin)
|
||||
axis_dir_layout.addWidget(QtGui.QLabel("Y:"))
|
||||
axis_dir_layout.addWidget(self.axis_y_spin)
|
||||
axis_dir_layout.addWidget(QtGui.QLabel("Z:"))
|
||||
axis_dir_layout.addWidget(self.axis_z_spin)
|
||||
axis_layout.addRow("Direction:", self.axis_widget)
|
||||
|
||||
# Center point
|
||||
center_layout = QtGui.QHBoxLayout()
|
||||
self.center_x_spin = QtGui.QDoubleSpinBox()
|
||||
self.center_x_spin.setRange(-100000, 100000)
|
||||
self.center_x_spin.setValue(0)
|
||||
self.center_x_spin.setDecimals(3)
|
||||
self.center_y_spin = QtGui.QDoubleSpinBox()
|
||||
self.center_y_spin.setRange(-100000, 100000)
|
||||
self.center_y_spin.setValue(0)
|
||||
self.center_y_spin.setDecimals(3)
|
||||
self.center_z_spin = QtGui.QDoubleSpinBox()
|
||||
self.center_z_spin.setRange(-100000, 100000)
|
||||
self.center_z_spin.setValue(0)
|
||||
self.center_z_spin.setDecimals(3)
|
||||
center_layout.addWidget(QtGui.QLabel("X:"))
|
||||
center_layout.addWidget(self.center_x_spin)
|
||||
center_layout.addWidget(QtGui.QLabel("Y:"))
|
||||
center_layout.addWidget(self.center_y_spin)
|
||||
center_layout.addWidget(QtGui.QLabel("Z:"))
|
||||
center_layout.addWidget(self.center_z_spin)
|
||||
axis_layout.addRow("Center:", center_layout)
|
||||
|
||||
layout.addWidget(axis_group)
|
||||
|
||||
# Pattern parameters section
|
||||
params_group = QtGui.QGroupBox("Pattern Parameters")
|
||||
params_layout = QtGui.QFormLayout(params_group)
|
||||
|
||||
# Occurrences
|
||||
self.occurrences_spin = QtGui.QSpinBox()
|
||||
self.occurrences_spin.setRange(2, 100)
|
||||
self.occurrences_spin.setValue(6)
|
||||
params_layout.addRow("Occurrences:", self.occurrences_spin)
|
||||
|
||||
# Angle mode
|
||||
self.angle_mode = QtGui.QComboBox()
|
||||
self.angle_mode.addItems(["Full Circle (360)", "Custom Angle"])
|
||||
self.angle_mode.currentIndexChanged.connect(self.on_angle_mode_changed)
|
||||
params_layout.addRow("Mode:", self.angle_mode)
|
||||
|
||||
# Angle value
|
||||
self.angle_spin = QtGui.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(0.001, 360)
|
||||
self.angle_spin.setValue(360)
|
||||
self.angle_spin.setDecimals(2)
|
||||
self.angle_spin.setSuffix(" deg")
|
||||
self.angle_spin.setEnabled(False)
|
||||
params_layout.addRow("Total Angle:", self.angle_spin)
|
||||
|
||||
layout.addWidget(params_group)
|
||||
|
||||
# Options section
|
||||
options_group = QtGui.QGroupBox("Options")
|
||||
options_layout = QtGui.QVBoxLayout(options_group)
|
||||
|
||||
self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
|
||||
self.link_checkbox.setChecked(True)
|
||||
options_layout.addWidget(self.link_checkbox)
|
||||
|
||||
self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
|
||||
self.hide_original_checkbox.setChecked(False)
|
||||
options_layout.addWidget(self.hide_original_checkbox)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def setup_selection_observer(self):
|
||||
"""Set up observer to track selection changes."""
|
||||
|
||||
class SelectionObserver:
|
||||
def __init__(self, panel):
|
||||
self.panel = panel
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
self.panel.on_selection_added(doc, obj, sub)
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
self.panel.on_selection_removed(doc, obj, sub)
|
||||
|
||||
def clearSelection(self, doc):
|
||||
pass
|
||||
|
||||
self.observer = SelectionObserver(self)
|
||||
Gui.Selection.addObserver(self.observer)
|
||||
|
||||
def on_selection_added(self, doc_name, obj_name, sub):
|
||||
"""Handle new selection."""
|
||||
doc = App.getDocument(doc_name)
|
||||
if not doc:
|
||||
return
|
||||
|
||||
obj = doc.getObject(obj_name)
|
||||
if not obj:
|
||||
return
|
||||
|
||||
# Check if this is an assembly component
|
||||
if not self._is_valid_component(obj):
|
||||
return
|
||||
|
||||
if obj in self.selected_components:
|
||||
return
|
||||
|
||||
self.selected_components.append(obj)
|
||||
self._update_table()
|
||||
|
||||
def on_selection_removed(self, doc_name, obj_name, sub):
|
||||
"""Handle selection removal."""
|
||||
doc = App.getDocument(doc_name)
|
||||
if not doc:
|
||||
return
|
||||
|
||||
obj = doc.getObject(obj_name)
|
||||
if obj in self.selected_components:
|
||||
self.selected_components.remove(obj)
|
||||
self._update_table()
|
||||
|
||||
def _is_valid_component(self, obj):
|
||||
"""Check if object is a valid assembly component."""
|
||||
valid_types = [
|
||||
"App::Link",
|
||||
"App::LinkGroup",
|
||||
"Part::Feature",
|
||||
"PartDesign::Body",
|
||||
"App::Part",
|
||||
]
|
||||
return obj.TypeId in valid_types
|
||||
|
||||
def _update_table(self):
|
||||
"""Update the component table."""
|
||||
self.component_table.setRowCount(len(self.selected_components))
|
||||
|
||||
for i, comp in enumerate(self.selected_components):
|
||||
name_item = QtGui.QTableWidgetItem(comp.Label)
|
||||
name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
|
||||
self.component_table.setItem(i, 0, name_item)
|
||||
|
||||
remove_btn = QtGui.QPushButton("X")
|
||||
remove_btn.setMaximumWidth(40)
|
||||
remove_btn.clicked.connect(
|
||||
lambda checked, idx=i: self._remove_component(idx)
|
||||
)
|
||||
self.component_table.setCellWidget(i, 1, remove_btn)
|
||||
|
||||
def _remove_component(self, index):
|
||||
"""Remove component from selection."""
|
||||
if 0 <= index < len(self.selected_components):
|
||||
self.selected_components.pop(index)
|
||||
self._update_table()
|
||||
|
||||
def on_axis_mode_changed(self, index):
|
||||
"""Update axis inputs based on mode."""
|
||||
if index == 0:
|
||||
# Custom axis
|
||||
self.axis_widget.setEnabled(True)
|
||||
else:
|
||||
# Preset axis
|
||||
self.axis_widget.setEnabled(False)
|
||||
if index == 1: # X
|
||||
self.axis_x_spin.setValue(1)
|
||||
self.axis_y_spin.setValue(0)
|
||||
self.axis_z_spin.setValue(0)
|
||||
elif index == 2: # Y
|
||||
self.axis_x_spin.setValue(0)
|
||||
self.axis_y_spin.setValue(1)
|
||||
self.axis_z_spin.setValue(0)
|
||||
elif index == 3: # Z
|
||||
self.axis_x_spin.setValue(0)
|
||||
self.axis_y_spin.setValue(0)
|
||||
self.axis_z_spin.setValue(1)
|
||||
|
||||
def on_angle_mode_changed(self, index):
|
||||
"""Update angle input based on mode."""
|
||||
if index == 0:
|
||||
# Full circle
|
||||
self.angle_spin.setValue(360)
|
||||
self.angle_spin.setEnabled(False)
|
||||
else:
|
||||
# Custom angle
|
||||
self.angle_spin.setEnabled(True)
|
||||
|
||||
def accept(self):
|
||||
"""Create the polar pattern."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
|
||||
if not self.selected_components:
|
||||
App.Console.PrintError("No components selected for pattern\n")
|
||||
return False
|
||||
|
||||
doc = App.ActiveDocument
|
||||
if not doc:
|
||||
return False
|
||||
|
||||
# Get axis
|
||||
axis = App.Vector(
|
||||
self.axis_x_spin.value(),
|
||||
self.axis_y_spin.value(),
|
||||
self.axis_z_spin.value(),
|
||||
)
|
||||
|
||||
if axis.Length < 1e-6:
|
||||
App.Console.PrintError("Axis vector cannot be zero\n")
|
||||
return False
|
||||
|
||||
axis.normalize()
|
||||
|
||||
# Get center
|
||||
center = App.Vector(
|
||||
self.center_x_spin.value(),
|
||||
self.center_y_spin.value(),
|
||||
self.center_z_spin.value(),
|
||||
)
|
||||
|
||||
occurrences = self.occurrences_spin.value()
|
||||
total_angle = self.angle_spin.value()
|
||||
|
||||
# Calculate angle increment
|
||||
if self.angle_mode.currentIndex() == 0:
|
||||
# Full circle - don't include last (would overlap first)
|
||||
angle_step = 360.0 / occurrences
|
||||
else:
|
||||
# Custom angle
|
||||
angle_step = total_angle / (occurrences - 1) if occurrences > 1 else 0
|
||||
|
||||
use_links = self.link_checkbox.isChecked()
|
||||
hide_original = self.hide_original_checkbox.isChecked()
|
||||
|
||||
# Find parent assembly
|
||||
assembly = self._find_parent_assembly(self.selected_components[0])
|
||||
|
||||
doc.openTransaction("Assembly Polar Pattern")
|
||||
|
||||
try:
|
||||
created_objects = []
|
||||
|
||||
for comp in self.selected_components:
|
||||
# Get base placement
|
||||
if hasattr(comp, "Placement"):
|
||||
base_placement = comp.Placement
|
||||
else:
|
||||
base_placement = App.Placement()
|
||||
|
||||
# Create pattern instances
|
||||
for i in range(1, occurrences):
|
||||
angle = angle_step * i
|
||||
|
||||
# Create rotation around axis through center
|
||||
rotation = App.Rotation(axis, angle)
|
||||
|
||||
# Calculate new position
|
||||
# Translate to origin, rotate, translate back
|
||||
base_pos = base_placement.Base
|
||||
relative_pos = base_pos - center
|
||||
rotated_pos = rotation.multVec(relative_pos)
|
||||
new_pos = rotated_pos + center
|
||||
|
||||
# Combine rotations
|
||||
new_rotation = rotation.multiply(base_placement.Rotation)
|
||||
|
||||
new_placement = App.Placement(new_pos, new_rotation)
|
||||
|
||||
if use_links:
|
||||
if comp.TypeId == "App::Link":
|
||||
link_target = comp.LinkedObject
|
||||
else:
|
||||
link_target = comp
|
||||
|
||||
new_obj = doc.addObject("App::Link", f"{comp.Label}_Polar{i}")
|
||||
new_obj.LinkedObject = link_target
|
||||
new_obj.Placement = new_placement
|
||||
else:
|
||||
new_obj = doc.copyObject(comp, False)
|
||||
new_obj.Label = f"{comp.Label}_Polar{i}"
|
||||
new_obj.Placement = new_placement
|
||||
|
||||
if assembly and hasattr(assembly, "addObject"):
|
||||
assembly.addObject(new_obj)
|
||||
|
||||
created_objects.append(new_obj)
|
||||
|
||||
if hide_original and hasattr(comp, "ViewObject"):
|
||||
comp.ViewObject.Visibility = False
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
|
||||
App.Console.PrintMessage(
|
||||
f"Created {len(created_objects)} polar pattern instances\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to create pattern: {e}\n")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
"""Cancel the operation."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
return True
|
||||
|
||||
def _find_parent_assembly(self, obj):
|
||||
"""Find the parent assembly of an object."""
|
||||
doc = obj.Document
|
||||
|
||||
for candidate in doc.Objects:
|
||||
if candidate.TypeId == "Assembly::AssemblyObject":
|
||||
if hasattr(candidate, "Group"):
|
||||
if obj in candidate.Group:
|
||||
return candidate
|
||||
for member in candidate.Group:
|
||||
if hasattr(member, "Group") and obj in member.Group:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||
|
||||
|
||||
class AssemblyPolarPatternCommand:
|
||||
"""Command to create a polar pattern of assembly components."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("assembly_polar_pattern"),
|
||||
"MenuText": "Assembly Polar Pattern",
|
||||
"ToolTip": "Create a polar (circular) pattern of assembly components",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
def Activated(self):
|
||||
panel = AssemblyPolarPatternTaskPanel()
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Register Commands
|
||||
# =============================================================================
|
||||
|
||||
Gui.addCommand("ZTools_AssemblyLinearPattern", AssemblyLinearPatternCommand())
|
||||
Gui.addCommand("ZTools_AssemblyPolarPattern", AssemblyPolarPatternCommand())
|
||||
880
reference/ztools/ztools/ztools/commands/datum_commands.py
Normal file
@@ -0,0 +1,880 @@
|
||||
# ztools/commands/datum_commands.py
|
||||
# GUI commands and task panel for datum creation
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
|
||||
class SelectionItem:
|
||||
"""Represents a selected geometry item."""
|
||||
|
||||
def __init__(self, obj, subname, shape=None):
|
||||
self.obj = obj
|
||||
self.subname = subname
|
||||
self.shape = shape
|
||||
self.geo_type = self._determine_type()
|
||||
|
||||
def _determine_type(self):
|
||||
"""Determine the geometry type of this selection."""
|
||||
if self.shape is None:
|
||||
# Try to get shape from object
|
||||
if hasattr(self.obj, "Shape"):
|
||||
if self.subname and self.subname.startswith("Face"):
|
||||
try:
|
||||
self.shape = self.obj.Shape.getElement(self.subname)
|
||||
except Exception:
|
||||
pass
|
||||
elif self.subname and self.subname.startswith("Edge"):
|
||||
try:
|
||||
self.shape = self.obj.Shape.getElement(self.subname)
|
||||
except Exception:
|
||||
pass
|
||||
elif self.subname and self.subname.startswith("Vertex"):
|
||||
try:
|
||||
self.shape = self.obj.Shape.getElement(self.subname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.shape is None:
|
||||
# Check if it's a datum plane object
|
||||
type_id = getattr(self.obj, "TypeId", "")
|
||||
if "Plane" in type_id or (
|
||||
hasattr(self.obj, "Shape")
|
||||
and self.obj.Shape.Faces
|
||||
and self.obj.Shape.Faces[0].Surface.isPlanar()
|
||||
):
|
||||
return "plane"
|
||||
return "unknown"
|
||||
|
||||
if isinstance(self.shape, Part.Face):
|
||||
# Check if it's a cylindrical face
|
||||
if isinstance(self.shape.Surface, Part.Cylinder):
|
||||
return "cylinder"
|
||||
elif self.shape.Surface.isPlanar():
|
||||
return "face"
|
||||
return "face"
|
||||
elif isinstance(self.shape, Part.Edge):
|
||||
# Check if it's a circular edge
|
||||
if isinstance(self.shape.Curve, (Part.Circle, Part.ArcOfCircle)):
|
||||
return "circle"
|
||||
elif isinstance(self.shape.Curve, Part.Line):
|
||||
return "edge"
|
||||
return "edge"
|
||||
elif isinstance(self.shape, Part.Vertex):
|
||||
return "vertex"
|
||||
|
||||
return "unknown"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name for UI."""
|
||||
if self.subname:
|
||||
return f"{self.obj.Label}.{self.subname}"
|
||||
return self.obj.Label
|
||||
|
||||
@property
|
||||
def type_icon(self):
|
||||
"""Get icon character for geometry type."""
|
||||
icons = {
|
||||
"face": "▢",
|
||||
"plane": "▣",
|
||||
"cylinder": "◎",
|
||||
"edge": "―",
|
||||
"circle": "○",
|
||||
"vertex": "•",
|
||||
"unknown": "?",
|
||||
}
|
||||
return icons.get(self.geo_type, "?")
|
||||
|
||||
|
||||
class DatumCreatorTaskPanel:
|
||||
"""Unified task panel for creating datum planes, axes, and points.
|
||||
|
||||
Features a selection table where users can add/remove geometry items.
|
||||
The datum type is automatically detected based on selection contents.
|
||||
"""
|
||||
|
||||
# Mode definitions: (display_name, mode_id, required_types, datum_category)
|
||||
# required_types is a tuple describing what selection is needed
|
||||
MODES = [
|
||||
# Planes
|
||||
("Offset from Face", "offset_face", ("face",), "plane"),
|
||||
("Offset from Plane", "offset_plane", ("plane",), "plane"),
|
||||
("Midplane (2 Faces)", "midplane", ("face", "face"), "plane"),
|
||||
("3 Points", "3_points", ("vertex", "vertex", "vertex"), "plane"),
|
||||
("Normal to Edge", "normal_edge", ("edge",), "plane"),
|
||||
("Angled from Face", "angled", ("face", "edge"), "plane"),
|
||||
("Tangent to Cylinder", "tangent_cyl", ("cylinder",), "plane"),
|
||||
# Axes
|
||||
("Axis from 2 Points", "axis_2pt", ("vertex", "vertex"), "axis"),
|
||||
("Axis from Edge", "axis_edge", ("edge",), "axis"),
|
||||
("Axis at Cylinder Center", "axis_cyl", ("cylinder",), "axis"),
|
||||
("Axis at Plane Intersection", "axis_intersect", ("plane", "plane"), "axis"),
|
||||
# Points
|
||||
("Point at Vertex", "point_vertex", ("vertex",), "point"),
|
||||
("Point at XYZ", "point_xyz", (), "point"),
|
||||
("Point on Edge", "point_edge", ("edge",), "point"),
|
||||
("Point at Face Center", "point_face", ("face",), "point"),
|
||||
("Point at Circle Center", "point_circle", ("circle",), "point"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.form = QtGui.QWidget()
|
||||
self.form.setWindowTitle("ZTools Datum Creator")
|
||||
self.selection_list = [] # List of SelectionItem
|
||||
self.setup_ui()
|
||||
self.setup_selection_observer()
|
||||
self.update_mode_from_selection()
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QtGui.QVBoxLayout(self.form)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Selection table section
|
||||
sel_group = QtGui.QGroupBox("Selection")
|
||||
sel_layout = QtGui.QVBoxLayout(sel_group)
|
||||
|
||||
# Selection table
|
||||
self.sel_table = QtGui.QTableWidget()
|
||||
self.sel_table.setColumnCount(3)
|
||||
self.sel_table.setHorizontalHeaderLabels(["", "Element", ""])
|
||||
header = self.sel_table.horizontalHeader()
|
||||
header.setStretchLastSection(False)
|
||||
header.setSectionResizeMode(0, QtGui.QHeaderView.Fixed)
|
||||
header.setSectionResizeMode(1, QtGui.QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(2, QtGui.QHeaderView.Fixed)
|
||||
header.resizeSection(0, 28)
|
||||
header.resizeSection(2, 28)
|
||||
self.sel_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self.sel_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self.sel_table.setMaximumHeight(150)
|
||||
self.sel_table.verticalHeader().setVisible(False)
|
||||
sel_layout.addWidget(self.sel_table)
|
||||
|
||||
# Selection buttons
|
||||
sel_btn_layout = QtGui.QHBoxLayout()
|
||||
self.add_sel_btn = QtGui.QPushButton("Add Selected")
|
||||
self.add_sel_btn.clicked.connect(self.add_current_selection)
|
||||
self.remove_sel_btn = QtGui.QPushButton("Remove")
|
||||
self.remove_sel_btn.clicked.connect(self.remove_selected_row)
|
||||
self.clear_sel_btn = QtGui.QPushButton("Clear All")
|
||||
self.clear_sel_btn.clicked.connect(self.clear_selection)
|
||||
sel_btn_layout.addWidget(self.add_sel_btn)
|
||||
sel_btn_layout.addWidget(self.remove_sel_btn)
|
||||
sel_btn_layout.addWidget(self.clear_sel_btn)
|
||||
sel_layout.addLayout(sel_btn_layout)
|
||||
|
||||
layout.addWidget(sel_group)
|
||||
|
||||
# Detected mode display
|
||||
mode_group = QtGui.QGroupBox("Datum Type")
|
||||
mode_layout = QtGui.QVBoxLayout(mode_group)
|
||||
|
||||
self.mode_label = QtGui.QLabel("Select geometry to auto-detect mode")
|
||||
self.mode_label.setStyleSheet("font-weight: bold; color: #888;")
|
||||
mode_layout.addWidget(self.mode_label)
|
||||
|
||||
# Manual mode override
|
||||
override_layout = QtGui.QHBoxLayout()
|
||||
override_layout.addWidget(QtGui.QLabel("Override:"))
|
||||
self.mode_combo = QtGui.QComboBox()
|
||||
self.mode_combo.addItem("(Auto-detect)", None)
|
||||
for display_name, mode_id, _, category in self.MODES:
|
||||
self.mode_combo.addItem(f"[{category[0].upper()}] {display_name}", mode_id)
|
||||
self.mode_combo.currentIndexChanged.connect(self.on_mode_override_changed)
|
||||
override_layout.addWidget(self.mode_combo)
|
||||
mode_layout.addLayout(override_layout)
|
||||
|
||||
layout.addWidget(mode_group)
|
||||
|
||||
# Parameters section
|
||||
self.params_group = QtGui.QGroupBox("Parameters")
|
||||
self.params_layout = QtGui.QFormLayout(self.params_group)
|
||||
|
||||
# Offset spinner
|
||||
self.offset_spin = QtGui.QDoubleSpinBox()
|
||||
self.offset_spin.setRange(-10000, 10000)
|
||||
self.offset_spin.setValue(10)
|
||||
self.offset_spin.setSuffix(" mm")
|
||||
|
||||
# Angle spinner
|
||||
self.angle_spin = QtGui.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(-360, 360)
|
||||
self.angle_spin.setValue(45)
|
||||
self.angle_spin.setSuffix(" °")
|
||||
|
||||
# Parameter spinner (0-1)
|
||||
self.param_spin = QtGui.QDoubleSpinBox()
|
||||
self.param_spin.setRange(0, 1)
|
||||
self.param_spin.setValue(0.5)
|
||||
self.param_spin.setSingleStep(0.1)
|
||||
|
||||
# XYZ coordinates
|
||||
self.x_spin = QtGui.QDoubleSpinBox()
|
||||
self.x_spin.setRange(-10000, 10000)
|
||||
self.x_spin.setSuffix(" mm")
|
||||
self.y_spin = QtGui.QDoubleSpinBox()
|
||||
self.y_spin.setRange(-10000, 10000)
|
||||
self.y_spin.setSuffix(" mm")
|
||||
self.z_spin = QtGui.QDoubleSpinBox()
|
||||
self.z_spin.setRange(-10000, 10000)
|
||||
self.z_spin.setSuffix(" mm")
|
||||
|
||||
layout.addWidget(self.params_group)
|
||||
|
||||
# Options section
|
||||
options_group = QtGui.QGroupBox("Options")
|
||||
options_layout = QtGui.QVBoxLayout(options_group)
|
||||
|
||||
self.link_spreadsheet_cb = QtGui.QCheckBox("Link to Spreadsheet")
|
||||
options_layout.addWidget(self.link_spreadsheet_cb)
|
||||
|
||||
self.use_body_cb = QtGui.QCheckBox("Add to Active Body")
|
||||
self.use_body_cb.setChecked(True)
|
||||
options_layout.addWidget(self.use_body_cb)
|
||||
|
||||
# Custom name
|
||||
name_layout = QtGui.QHBoxLayout()
|
||||
self.custom_name_cb = QtGui.QCheckBox("Custom Name:")
|
||||
self.custom_name_edit = QtGui.QLineEdit()
|
||||
self.custom_name_edit.setEnabled(False)
|
||||
self.custom_name_cb.toggled.connect(self.custom_name_edit.setEnabled)
|
||||
name_layout.addWidget(self.custom_name_cb)
|
||||
name_layout.addWidget(self.custom_name_edit)
|
||||
options_layout.addLayout(name_layout)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
# Initial state
|
||||
self.update_params_ui(None)
|
||||
|
||||
def setup_selection_observer(self):
|
||||
"""Setup selection observer to track user selections."""
|
||||
|
||||
class SelectionObserver:
|
||||
def __init__(self, panel):
|
||||
self.panel = panel
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
self.panel.on_freecad_selection_changed()
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
self.panel.on_freecad_selection_changed()
|
||||
|
||||
def clearSelection(self, doc):
|
||||
self.panel.on_freecad_selection_changed()
|
||||
|
||||
self.observer = SelectionObserver(self)
|
||||
Gui.Selection.addObserver(self.observer)
|
||||
|
||||
def on_freecad_selection_changed(self):
|
||||
"""Called when FreeCAD selection changes - update add button state."""
|
||||
sel = Gui.Selection.getSelectionEx()
|
||||
has_sel = len(sel) > 0
|
||||
self.add_sel_btn.setEnabled(has_sel)
|
||||
|
||||
def add_current_selection(self):
|
||||
"""Add current FreeCAD selection to the selection table."""
|
||||
sel = Gui.Selection.getSelectionEx()
|
||||
for s in sel:
|
||||
obj = s.Object
|
||||
if s.SubElementNames:
|
||||
for i, sub in enumerate(s.SubElementNames):
|
||||
# Get the shape if available
|
||||
shape = None
|
||||
if i < len(s.SubObjects):
|
||||
shape = s.SubObjects[i]
|
||||
item = SelectionItem(obj, sub, shape)
|
||||
self._add_selection_item(item)
|
||||
else:
|
||||
# Object selected without sub-element
|
||||
item = SelectionItem(obj, "", None)
|
||||
self._add_selection_item(item)
|
||||
|
||||
self.refresh_selection_table()
|
||||
self.update_mode_from_selection()
|
||||
|
||||
def _add_selection_item(self, item):
|
||||
"""Add item to selection list if not already present."""
|
||||
# Check for duplicates
|
||||
for existing in self.selection_list:
|
||||
if existing.obj == item.obj and existing.subname == item.subname:
|
||||
return # Already in list
|
||||
self.selection_list.append(item)
|
||||
|
||||
def remove_selected_row(self):
|
||||
"""Remove selected row from selection table."""
|
||||
rows = self.sel_table.selectionModel().selectedRows()
|
||||
if rows:
|
||||
# Remove in reverse order to maintain indices
|
||||
for row in sorted([r.row() for r in rows], reverse=True):
|
||||
if row < len(self.selection_list):
|
||||
del self.selection_list[row]
|
||||
self.refresh_selection_table()
|
||||
self.update_mode_from_selection()
|
||||
|
||||
def clear_selection(self):
|
||||
"""Clear all items from selection table."""
|
||||
self.selection_list.clear()
|
||||
self.refresh_selection_table()
|
||||
self.update_mode_from_selection()
|
||||
|
||||
def refresh_selection_table(self):
|
||||
"""Refresh the selection table display."""
|
||||
self.sel_table.setRowCount(len(self.selection_list))
|
||||
for i, item in enumerate(self.selection_list):
|
||||
# Type icon
|
||||
type_item = QtGui.QTableWidgetItem(item.type_icon)
|
||||
type_item.setTextAlignment(QtCore.Qt.AlignCenter)
|
||||
type_item.setToolTip(item.geo_type)
|
||||
self.sel_table.setItem(i, 0, type_item)
|
||||
|
||||
# Element name
|
||||
name_item = QtGui.QTableWidgetItem(item.display_name)
|
||||
self.sel_table.setItem(i, 1, name_item)
|
||||
|
||||
# Remove button
|
||||
remove_btn = QtGui.QPushButton("✕")
|
||||
remove_btn.setFixedSize(24, 24)
|
||||
remove_btn.clicked.connect(lambda checked, row=i: self._remove_row(row))
|
||||
self.sel_table.setCellWidget(i, 2, remove_btn)
|
||||
|
||||
def _remove_row(self, row):
|
||||
"""Remove a specific row."""
|
||||
if row < len(self.selection_list):
|
||||
del self.selection_list[row]
|
||||
self.refresh_selection_table()
|
||||
self.update_mode_from_selection()
|
||||
|
||||
def get_selection_types(self):
|
||||
"""Get tuple of geometry types in current selection."""
|
||||
return tuple(item.geo_type for item in self.selection_list)
|
||||
|
||||
def update_mode_from_selection(self):
|
||||
"""Auto-detect the best mode based on current selection."""
|
||||
if self.mode_combo.currentIndex() > 0:
|
||||
# Manual override is active
|
||||
mode_id = self.mode_combo.currentData()
|
||||
self._set_detected_mode(mode_id)
|
||||
return
|
||||
|
||||
sel_types = self.get_selection_types()
|
||||
|
||||
if not sel_types:
|
||||
self.mode_label.setText("Select geometry to auto-detect mode")
|
||||
self.mode_label.setStyleSheet("font-weight: bold; color: #888;")
|
||||
self.update_params_ui(None)
|
||||
return
|
||||
|
||||
# Find matching modes
|
||||
best_match = None
|
||||
best_score = -1
|
||||
|
||||
for display_name, mode_id, required_types, category in self.MODES:
|
||||
if not required_types:
|
||||
continue # Skip modes with no requirements (like XYZ point)
|
||||
|
||||
score = self._match_score(sel_types, required_types)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = (display_name, mode_id, category)
|
||||
|
||||
if best_match and best_score > 0:
|
||||
display_name, mode_id, category = best_match
|
||||
cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"}
|
||||
color = cat_colors.get(category, "#cdd6f4")
|
||||
self.mode_label.setText(f"{display_name}")
|
||||
self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};")
|
||||
self.update_params_ui(mode_id)
|
||||
else:
|
||||
self.mode_label.setText("No matching mode for selection")
|
||||
self.mode_label.setStyleSheet("font-weight: bold; color: #f38ba8;")
|
||||
self.update_params_ui(None)
|
||||
|
||||
def _match_score(self, sel_types, required_types):
|
||||
"""
|
||||
Calculate how well selection matches required types.
|
||||
Returns score >= 0, higher is better. 0 means no match.
|
||||
"""
|
||||
if len(sel_types) < len(required_types):
|
||||
return 0 # Not enough items
|
||||
|
||||
# Check if we can satisfy all requirements
|
||||
sel_list = list(sel_types)
|
||||
matched = 0
|
||||
for req in required_types:
|
||||
# Try to find a matching type in selection
|
||||
found = False
|
||||
for i, sel in enumerate(sel_list):
|
||||
if self._type_matches(sel, req):
|
||||
sel_list.pop(i)
|
||||
matched += 1
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return 0 # Can't satisfy this requirement
|
||||
|
||||
# Score based on how exact the match is
|
||||
# Exact match (same count) scores higher
|
||||
if len(sel_types) == len(required_types):
|
||||
return 100 + matched
|
||||
else:
|
||||
return matched
|
||||
|
||||
def _type_matches(self, sel_type, req_type):
|
||||
"""Check if a selected type matches a required type."""
|
||||
if sel_type == req_type:
|
||||
return True
|
||||
# Face can match cylinder (cylinder is a special face)
|
||||
if req_type == "face" and sel_type in ("face", "cylinder"):
|
||||
return True
|
||||
# Edge can match circle (circle is a special edge)
|
||||
if req_type == "edge" and sel_type in ("edge", "circle"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_mode_override_changed(self, index):
|
||||
"""Handle manual mode override selection."""
|
||||
if index == 0:
|
||||
# Auto-detect
|
||||
self.update_mode_from_selection()
|
||||
else:
|
||||
mode_id = self.mode_combo.currentData()
|
||||
self._set_detected_mode(mode_id)
|
||||
|
||||
def _set_detected_mode(self, mode_id):
|
||||
"""Set the mode and update UI."""
|
||||
for display_name, mid, _, category in self.MODES:
|
||||
if mid == mode_id:
|
||||
cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"}
|
||||
color = cat_colors.get(category, "#cdd6f4")
|
||||
self.mode_label.setText(f"{display_name}")
|
||||
self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};")
|
||||
self.update_params_ui(mode_id)
|
||||
return
|
||||
|
||||
def _clear_params_layout(self):
|
||||
"""Remove all rows from params_layout without deleting the widgets.
|
||||
|
||||
QFormLayout.removeRow() destroys the widgets it owns. Instead we
|
||||
detach every item from the layout (which relinquishes ownership)
|
||||
and hide the widgets so they can be re-added later.
|
||||
"""
|
||||
while self.params_layout.count():
|
||||
item = self.params_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
widget.setParent(None)
|
||||
|
||||
def update_params_ui(self, mode_id):
|
||||
"""Update parameters UI based on mode."""
|
||||
# Clear existing params without destroying widgets
|
||||
self._clear_params_layout()
|
||||
|
||||
if mode_id is None:
|
||||
self.params_group.setVisible(False)
|
||||
return
|
||||
|
||||
self.params_group.setVisible(True)
|
||||
|
||||
if mode_id in ("offset_face", "offset_plane"):
|
||||
self.offset_spin.show()
|
||||
self.params_layout.addRow("Offset:", self.offset_spin)
|
||||
elif mode_id == "angled":
|
||||
self.angle_spin.show()
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif mode_id == "normal_edge":
|
||||
self.param_spin.show()
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
elif mode_id == "tangent_cyl":
|
||||
self.angle_spin.show()
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif mode_id == "point_xyz":
|
||||
self.x_spin.show()
|
||||
self.y_spin.show()
|
||||
self.z_spin.show()
|
||||
self.params_layout.addRow("X:", self.x_spin)
|
||||
self.params_layout.addRow("Y:", self.y_spin)
|
||||
self.params_layout.addRow("Z:", self.z_spin)
|
||||
elif mode_id == "point_edge":
|
||||
self.param_spin.show()
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
else:
|
||||
# No parameters needed
|
||||
self.params_group.setVisible(False)
|
||||
|
||||
def get_current_mode(self):
|
||||
"""Get the currently active mode ID."""
|
||||
if self.mode_combo.currentIndex() > 0:
|
||||
return self.mode_combo.currentData()
|
||||
|
||||
# Auto-detected mode
|
||||
sel_types = self.get_selection_types()
|
||||
if not sel_types:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
best_score = -1
|
||||
for _, mode_id, required_types, _ in self.MODES:
|
||||
if not required_types:
|
||||
continue
|
||||
score = self._match_score(sel_types, required_types)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = mode_id
|
||||
|
||||
return best_match if best_score > 0 else None
|
||||
|
||||
def get_body(self):
|
||||
"""Get active body if checkbox is checked."""
|
||||
if not self.use_body_cb.isChecked():
|
||||
return None
|
||||
|
||||
if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument:
|
||||
active_view = Gui.ActiveDocument.ActiveView
|
||||
if hasattr(active_view, "getActiveObject"):
|
||||
body = active_view.getActiveObject("pdbody")
|
||||
if body:
|
||||
return body
|
||||
|
||||
doc = App.ActiveDocument
|
||||
for obj in doc.Objects:
|
||||
if obj.TypeId == "PartDesign::Body":
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
def get_name(self):
|
||||
"""Get custom name or None for auto-naming."""
|
||||
if self.custom_name_cb.isChecked() and self.custom_name_edit.text():
|
||||
return self.custom_name_edit.text()
|
||||
return None
|
||||
|
||||
def get_items_by_type(self, *geo_types):
|
||||
"""Get selection items matching given geometry types."""
|
||||
results = []
|
||||
for item in self.selection_list:
|
||||
if item.geo_type in geo_types:
|
||||
results.append(item)
|
||||
return results
|
||||
|
||||
def create_datum(self):
|
||||
"""Create the datum based on current settings."""
|
||||
from ztools.datums import core
|
||||
|
||||
mode = self.get_current_mode()
|
||||
if mode is None:
|
||||
raise ValueError("No valid mode detected. Add geometry to the selection.")
|
||||
|
||||
body = self.get_body()
|
||||
name = self.get_name()
|
||||
link_ss = self.link_spreadsheet_cb.isChecked()
|
||||
|
||||
# Planes
|
||||
if mode == "offset_face":
|
||||
items = self.get_items_by_type("face", "cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a face")
|
||||
item = items[0]
|
||||
face = item.shape if item.shape else item.obj.Shape.Faces[0]
|
||||
core.plane_offset_from_face(
|
||||
face,
|
||||
self.offset_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=item.obj,
|
||||
source_subname=item.subname,
|
||||
)
|
||||
|
||||
elif mode == "offset_plane":
|
||||
items = self.get_items_by_type("plane")
|
||||
if not items:
|
||||
raise ValueError("Select a datum plane")
|
||||
core.plane_offset_from_plane(
|
||||
items[0].obj,
|
||||
self.offset_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
)
|
||||
|
||||
elif mode == "midplane":
|
||||
items = self.get_items_by_type("face", "cylinder")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 faces")
|
||||
face1 = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
|
||||
face2 = items[1].shape if items[1].shape else items[1].obj.Shape.Faces[0]
|
||||
core.plane_midplane(
|
||||
face1,
|
||||
face2,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object1=items[0].obj,
|
||||
source_subname1=items[0].subname,
|
||||
source_object2=items[1].obj,
|
||||
source_subname2=items[1].subname,
|
||||
)
|
||||
|
||||
elif mode == "3_points":
|
||||
items = self.get_items_by_type("vertex")
|
||||
if len(items) < 3:
|
||||
raise ValueError("Select 3 vertices")
|
||||
v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
|
||||
v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0]
|
||||
v3 = items[2].shape if items[2].shape else items[2].obj.Shape.Vertexes[0]
|
||||
core.plane_from_3_points(
|
||||
v1.Point,
|
||||
v2.Point,
|
||||
v3.Point,
|
||||
name=name,
|
||||
body=body,
|
||||
source_refs=[
|
||||
(items[0].obj, items[0].subname),
|
||||
(items[1].obj, items[1].subname),
|
||||
(items[2].obj, items[2].subname),
|
||||
],
|
||||
)
|
||||
|
||||
elif mode == "normal_edge":
|
||||
items = self.get_items_by_type("edge", "circle")
|
||||
if not items:
|
||||
raise ValueError("Select an edge")
|
||||
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
|
||||
core.plane_normal_to_edge(
|
||||
edge,
|
||||
parameter=self.param_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "angled":
|
||||
faces = self.get_items_by_type("face", "cylinder")
|
||||
edges = self.get_items_by_type("edge", "circle")
|
||||
if not faces or not edges:
|
||||
raise ValueError("Select a face and an edge")
|
||||
face = faces[0].shape if faces[0].shape else faces[0].obj.Shape.Faces[0]
|
||||
edge = edges[0].shape if edges[0].shape else edges[0].obj.Shape.Edges[0]
|
||||
core.plane_angled(
|
||||
face,
|
||||
edge,
|
||||
self.angle_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
source_face_obj=faces[0].obj,
|
||||
source_face_sub=faces[0].subname,
|
||||
source_edge_obj=edges[0].obj,
|
||||
source_edge_sub=edges[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "tangent_cyl":
|
||||
items = self.get_items_by_type("cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a cylindrical face")
|
||||
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
|
||||
core.plane_tangent_to_cylinder(
|
||||
face,
|
||||
angle=self.angle_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
# Axes
|
||||
elif mode == "axis_2pt":
|
||||
items = self.get_items_by_type("vertex")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 vertices")
|
||||
v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
|
||||
v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0]
|
||||
core.axis_from_2_points(
|
||||
v1.Point,
|
||||
v2.Point,
|
||||
name=name,
|
||||
body=body,
|
||||
source_refs=[
|
||||
(items[0].obj, items[0].subname),
|
||||
(items[1].obj, items[1].subname),
|
||||
],
|
||||
)
|
||||
|
||||
elif mode == "axis_edge":
|
||||
items = self.get_items_by_type("edge")
|
||||
if not items:
|
||||
raise ValueError("Select a linear edge")
|
||||
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
|
||||
core.axis_from_edge(
|
||||
edge,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "axis_cyl":
|
||||
items = self.get_items_by_type("cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a cylindrical face")
|
||||
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
|
||||
core.axis_cylinder_center(
|
||||
face,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "axis_intersect":
|
||||
items = self.get_items_by_type("plane")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 datum planes")
|
||||
core.axis_intersection_planes(
|
||||
items[0].obj,
|
||||
items[1].obj,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object1=items[0].obj,
|
||||
source_subname1="",
|
||||
source_object2=items[1].obj,
|
||||
source_subname2="",
|
||||
)
|
||||
|
||||
# Points
|
||||
elif mode == "point_vertex":
|
||||
items = self.get_items_by_type("vertex")
|
||||
if not items:
|
||||
raise ValueError("Select a vertex")
|
||||
vert = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
|
||||
core.point_at_vertex(
|
||||
vert,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "point_xyz":
|
||||
core.point_at_coordinates(
|
||||
self.x_spin.value(),
|
||||
self.y_spin.value(),
|
||||
self.z_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
)
|
||||
|
||||
elif mode == "point_edge":
|
||||
items = self.get_items_by_type("edge", "circle")
|
||||
if not items:
|
||||
raise ValueError("Select an edge")
|
||||
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
|
||||
core.point_on_edge(
|
||||
edge,
|
||||
parameter=self.param_spin.value(),
|
||||
name=name,
|
||||
body=body,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "point_face":
|
||||
items = self.get_items_by_type("face", "cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a face")
|
||||
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
|
||||
core.point_center_of_face(
|
||||
face,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
elif mode == "point_circle":
|
||||
items = self.get_items_by_type("circle")
|
||||
if not items:
|
||||
raise ValueError("Select a circular edge")
|
||||
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
|
||||
core.point_center_of_circle(
|
||||
edge,
|
||||
name=name,
|
||||
body=body,
|
||||
source_object=items[0].obj,
|
||||
source_subname=items[0].subname,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
def accept(self):
|
||||
"""Called when OK is clicked. Creates the datum."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
try:
|
||||
self.create_datum()
|
||||
App.Console.PrintMessage("ZTools: Datum created successfully\n")
|
||||
except Exception as e:
|
||||
App.Console.PrintError(f"ZTools: Failed to create datum: {e}\n")
|
||||
QtGui.QMessageBox.warning(self.form, "Error", str(e))
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
"""Called when Cancel is clicked."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
return True
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||
|
||||
|
||||
class ZTools_DatumCreator:
|
||||
"""Command to open datum creator task panel."""
|
||||
|
||||
def GetResources(self):
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
return {
|
||||
"Pixmap": get_icon("datum_creator"),
|
||||
"MenuText": "Datum Creator",
|
||||
"ToolTip": "Create datum planes, axes, and points with advanced options",
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
panel = DatumCreatorTaskPanel()
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
|
||||
class ZTools_DatumManager:
|
||||
"""Command to open datum manager panel."""
|
||||
|
||||
def GetResources(self):
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
return {
|
||||
"Pixmap": get_icon("datum_manager"),
|
||||
"MenuText": "Datum Manager",
|
||||
"ToolTip": "List, toggle visibility, and rename datums in document",
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
# TODO: Implement datum manager panel
|
||||
App.Console.PrintMessage("ZTools: Datum Manager - Coming soon\n")
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
|
||||
# Register commands
|
||||
Gui.addCommand("ZTools_DatumCreator", ZTools_DatumCreator())
|
||||
Gui.addCommand("ZTools_DatumManager", ZTools_DatumManager())
|
||||
487
reference/ztools/ztools/ztools/commands/datum_viewprovider.py
Normal file
@@ -0,0 +1,487 @@
|
||||
# ztools/commands/datum_viewprovider.py
|
||||
# Custom ViewProvider for ZTools datum objects
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
|
||||
class ZToolsDatumViewProvider:
|
||||
"""
|
||||
Custom ViewProvider for ZTools datum objects.
|
||||
|
||||
Features:
|
||||
- Overrides double-click to open ZTools editor instead of vanilla attachment
|
||||
- Hides attachment properties from property editor
|
||||
- Provides custom icons based on datum type
|
||||
"""
|
||||
|
||||
_is_ztools = True # Marker to identify ZTools ViewProviders
|
||||
|
||||
def __init__(self, vobj):
|
||||
"""Initialize and attach to ViewObject."""
|
||||
vobj.Proxy = self
|
||||
self.Object = vobj.Object if vobj else None
|
||||
|
||||
def attach(self, vobj):
|
||||
"""Called when ViewProvider is attached to object."""
|
||||
self.Object = vobj.Object
|
||||
self._hide_attachment_props(vobj)
|
||||
|
||||
def _hide_attachment_props(self, vobj):
|
||||
"""Hide FreeCAD attachment properties using persistent property status."""
|
||||
if not vobj or not vobj.Object:
|
||||
return
|
||||
|
||||
obj = vobj.Object
|
||||
attachment_props = [
|
||||
"MapMode",
|
||||
"MapPathParameter",
|
||||
"MapReversed",
|
||||
"AttachmentOffset",
|
||||
"AttachmentSupport",
|
||||
"Support",
|
||||
]
|
||||
|
||||
for prop in attachment_props:
|
||||
try:
|
||||
if hasattr(obj, prop):
|
||||
obj.setPropertyStatus(prop, "Hidden")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def updateData(self, obj, prop):
|
||||
"""Called when data properties change."""
|
||||
pass
|
||||
|
||||
def onChanged(self, vobj, prop):
|
||||
"""Called when view properties change."""
|
||||
# Re-hide attachment properties if they become visible
|
||||
if prop in ("MapMode", "Support"):
|
||||
self._hide_attachment_props(vobj)
|
||||
|
||||
def doubleClicked(self, vobj):
|
||||
"""
|
||||
Handle double-click - open ZTools datum editor.
|
||||
Returns True if handled, False to let FreeCAD handle it.
|
||||
"""
|
||||
if Gui.Control.activeDialog():
|
||||
# Task panel already open
|
||||
return False
|
||||
|
||||
# Check if this is a ZTools datum
|
||||
obj = vobj.Object
|
||||
if not hasattr(obj, "ZTools_Type"):
|
||||
# Not a ZTools datum, let FreeCAD handle it
|
||||
return False
|
||||
|
||||
# Open ZTools editor
|
||||
panel = DatumEditTaskPanel(obj)
|
||||
Gui.Control.showDialog(panel)
|
||||
return True
|
||||
|
||||
def setEdit(self, vobj, mode=0):
|
||||
"""
|
||||
Called when entering edit mode.
|
||||
Mode 0 = default edit, Mode 1 = transform
|
||||
"""
|
||||
if mode == 0:
|
||||
obj = vobj.Object
|
||||
if hasattr(obj, "ZTools_Type"):
|
||||
panel = DatumEditTaskPanel(obj)
|
||||
Gui.Control.showDialog(panel)
|
||||
return True
|
||||
return False
|
||||
|
||||
def unsetEdit(self, vobj, mode=0):
|
||||
"""Called when exiting edit mode."""
|
||||
return False
|
||||
|
||||
def getIcon(self):
|
||||
"""Return icon for tree view based on datum type."""
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
if not self.Object:
|
||||
return get_icon("datum_creator")
|
||||
|
||||
ztools_type = getattr(self.Object, "ZTools_Type", "")
|
||||
|
||||
# Map ZTools type to icon
|
||||
icon_map = {
|
||||
"offset_from_face": "plane_offset",
|
||||
"offset_from_plane": "plane_offset",
|
||||
"midplane": "plane_midplane",
|
||||
"3_points": "plane_3pt",
|
||||
"normal_to_edge": "plane_normal",
|
||||
"angled": "plane_angled",
|
||||
"tangent_cylinder": "plane_tangent",
|
||||
"2_points": "axis_2pt",
|
||||
"from_edge": "axis_edge",
|
||||
"cylinder_center": "axis_cyl",
|
||||
"plane_intersection": "axis_intersect",
|
||||
"vertex": "point_vertex",
|
||||
"coordinates": "point_xyz",
|
||||
"on_edge": "point_edge",
|
||||
"face_center": "point_face",
|
||||
"circle_center": "point_circle",
|
||||
}
|
||||
|
||||
icon_name = icon_map.get(ztools_type, "datum_creator")
|
||||
return get_icon(icon_name)
|
||||
|
||||
def __getstate__(self):
|
||||
"""Serialization - don't save proxy state."""
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
"""Deserialization."""
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_source_refs(datum_obj):
|
||||
"""Parse ZTools_SourceRefs and resolve to (object, subname, shape) tuples."""
|
||||
refs_json = getattr(datum_obj, "ZTools_SourceRefs", "[]")
|
||||
try:
|
||||
refs = json.loads(refs_json)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
doc = datum_obj.Document
|
||||
resolved = []
|
||||
for ref in refs:
|
||||
obj = doc.getObject(ref.get("object", ""))
|
||||
sub = ref.get("subname", "")
|
||||
shape = obj.getSubObject(sub) if obj and sub else None
|
||||
resolved.append((obj, sub, shape))
|
||||
return resolved
|
||||
|
||||
|
||||
class DatumEditTaskPanel:
|
||||
"""
|
||||
Task panel for editing existing ZTools datum objects.
|
||||
|
||||
Allows modification of:
|
||||
- Offset distance (for offset-type datums)
|
||||
- Angle (for angled/tangent datums)
|
||||
- Parameter position (for edge-based datums)
|
||||
- Source references (future)
|
||||
"""
|
||||
|
||||
def __init__(self, datum_obj):
|
||||
self.datum_obj = datum_obj
|
||||
self.form = QtGui.QWidget()
|
||||
self.form.setWindowTitle(f"Edit {datum_obj.Label}")
|
||||
self.original_placement = datum_obj.Placement.copy()
|
||||
self.setup_ui()
|
||||
self.load_current_values()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Create the edit panel UI."""
|
||||
layout = QtGui.QVBoxLayout(self.form)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Header with datum info
|
||||
info_group = QtGui.QGroupBox("Datum Info")
|
||||
info_layout = QtGui.QFormLayout(info_group)
|
||||
|
||||
self.name_edit = QtGui.QLineEdit(self.datum_obj.Label)
|
||||
info_layout.addRow("Name:", self.name_edit)
|
||||
|
||||
ztools_type = getattr(self.datum_obj, "ZTools_Type", "unknown")
|
||||
type_label = QtGui.QLabel(self._format_type_name(ztools_type))
|
||||
type_label.setStyleSheet("color: #cba6f7; font-weight: bold;")
|
||||
info_layout.addRow("Type:", type_label)
|
||||
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# Parameters group
|
||||
self.params_group = QtGui.QGroupBox("Parameters")
|
||||
self.params_layout = QtGui.QFormLayout(self.params_group)
|
||||
|
||||
# Offset spinner
|
||||
self.offset_spin = QtGui.QDoubleSpinBox()
|
||||
self.offset_spin.setRange(-10000, 10000)
|
||||
self.offset_spin.setSuffix(" mm")
|
||||
self.offset_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
# Angle spinner
|
||||
self.angle_spin = QtGui.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(-360, 360)
|
||||
self.angle_spin.setSuffix(" °")
|
||||
self.angle_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
# Parameter spinner (0-1)
|
||||
self.param_spin = QtGui.QDoubleSpinBox()
|
||||
self.param_spin.setRange(0, 1)
|
||||
self.param_spin.setSingleStep(0.1)
|
||||
self.param_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
# XYZ spinners for point coordinates
|
||||
self.x_spin = QtGui.QDoubleSpinBox()
|
||||
self.x_spin.setRange(-10000, 10000)
|
||||
self.x_spin.setSuffix(" mm")
|
||||
self.x_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
self.y_spin = QtGui.QDoubleSpinBox()
|
||||
self.y_spin.setRange(-10000, 10000)
|
||||
self.y_spin.setSuffix(" mm")
|
||||
self.y_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
self.z_spin = QtGui.QDoubleSpinBox()
|
||||
self.z_spin.setRange(-10000, 10000)
|
||||
self.z_spin.setSuffix(" mm")
|
||||
self.z_spin.valueChanged.connect(self.on_param_changed)
|
||||
|
||||
layout.addWidget(self.params_group)
|
||||
|
||||
# Source references (read-only for now)
|
||||
refs_group = QtGui.QGroupBox("Source References")
|
||||
refs_layout = QtGui.QVBoxLayout(refs_group)
|
||||
|
||||
self.refs_list = QtGui.QListWidget()
|
||||
self.refs_list.setMaximumHeight(80)
|
||||
refs_layout.addWidget(self.refs_list)
|
||||
|
||||
layout.addWidget(refs_group)
|
||||
|
||||
# Placement info (read-only)
|
||||
placement_group = QtGui.QGroupBox("Current Placement")
|
||||
placement_layout = QtGui.QFormLayout(placement_group)
|
||||
|
||||
pos = self.datum_obj.Placement.Base
|
||||
self.pos_label = QtGui.QLabel(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
|
||||
placement_layout.addRow("Position:", self.pos_label)
|
||||
|
||||
layout.addWidget(placement_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def _format_type_name(self, ztools_type):
|
||||
"""Format ZTools type string for display."""
|
||||
type_names = {
|
||||
"offset_from_face": "Offset from Face",
|
||||
"offset_from_plane": "Offset from Plane",
|
||||
"midplane": "Midplane",
|
||||
"3_points": "3 Points",
|
||||
"normal_to_edge": "Normal to Edge",
|
||||
"angled": "Angled from Face",
|
||||
"tangent_cylinder": "Tangent to Cylinder",
|
||||
"2_points": "2 Points",
|
||||
"from_edge": "From Edge",
|
||||
"cylinder_center": "Cylinder Center",
|
||||
"plane_intersection": "Plane Intersection",
|
||||
"vertex": "At Vertex",
|
||||
"coordinates": "XYZ Coordinates",
|
||||
"on_edge": "On Edge",
|
||||
"face_center": "Face Center",
|
||||
"circle_center": "Circle Center",
|
||||
}
|
||||
return type_names.get(ztools_type, ztools_type)
|
||||
|
||||
def load_current_values(self):
|
||||
"""Load current values from datum object."""
|
||||
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
|
||||
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
|
||||
refs_json = getattr(self.datum_obj, "ZTools_SourceRefs", "[]")
|
||||
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
|
||||
try:
|
||||
refs = json.loads(refs_json)
|
||||
except json.JSONDecodeError:
|
||||
refs = []
|
||||
|
||||
# Clear params layout
|
||||
while self.params_layout.rowCount() > 0:
|
||||
self.params_layout.removeRow(0)
|
||||
|
||||
# Add appropriate parameter controls based on type
|
||||
if ztools_type in ("offset_from_face", "offset_from_plane"):
|
||||
distance = params.get("distance", 10)
|
||||
self.offset_spin.setValue(distance)
|
||||
self.params_layout.addRow("Offset:", self.offset_spin)
|
||||
|
||||
elif ztools_type == "angled":
|
||||
angle = params.get("angle", 45)
|
||||
self.angle_spin.setValue(angle)
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
|
||||
elif ztools_type == "tangent_cylinder":
|
||||
angle = params.get("angle", 0)
|
||||
self.angle_spin.setValue(angle)
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
|
||||
elif ztools_type in ("normal_to_edge", "on_edge"):
|
||||
parameter = params.get("parameter", 0.5)
|
||||
self.param_spin.setValue(parameter)
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
|
||||
elif ztools_type == "coordinates":
|
||||
x = params.get("x", 0)
|
||||
y = params.get("y", 0)
|
||||
z = params.get("z", 0)
|
||||
self.x_spin.setValue(x)
|
||||
self.y_spin.setValue(y)
|
||||
self.z_spin.setValue(z)
|
||||
self.params_layout.addRow("X:", self.x_spin)
|
||||
self.params_layout.addRow("Y:", self.y_spin)
|
||||
self.params_layout.addRow("Z:", self.z_spin)
|
||||
else:
|
||||
# No editable parameters
|
||||
no_params_label = QtGui.QLabel("No editable parameters")
|
||||
no_params_label.setStyleSheet("color: #888;")
|
||||
self.params_layout.addRow(no_params_label)
|
||||
|
||||
# Load source references
|
||||
self.refs_list.clear()
|
||||
for ref in refs:
|
||||
obj_name = ref.get("object", "?")
|
||||
subname = ref.get("subname", "")
|
||||
if subname:
|
||||
self.refs_list.addItem(f"{obj_name}.{subname}")
|
||||
else:
|
||||
self.refs_list.addItem(obj_name)
|
||||
|
||||
if not refs:
|
||||
self.refs_list.addItem("(no references)")
|
||||
|
||||
def _has_attachment(self):
|
||||
"""Check if datum uses vanilla AttachExtension (MapMode != Deactivated)."""
|
||||
return (
|
||||
hasattr(self.datum_obj, "MapMode")
|
||||
and self.datum_obj.MapMode != "Deactivated"
|
||||
)
|
||||
|
||||
def on_param_changed(self):
|
||||
"""Handle parameter value changes - update datum in real-time.
|
||||
|
||||
For datums with active AttachExtension, writes to AttachmentOffset or
|
||||
MapPathParameter — the C++ engine recalculates placement automatically.
|
||||
For manual datums, updates Placement directly.
|
||||
"""
|
||||
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
|
||||
|
||||
# For coordinate-based points, update placement directly
|
||||
if ztools_type == "coordinates":
|
||||
new_pos = App.Vector(
|
||||
self.x_spin.value(), self.y_spin.value(), self.z_spin.value()
|
||||
)
|
||||
self.datum_obj.Placement.Base = new_pos
|
||||
self._update_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z})
|
||||
|
||||
elif ztools_type in ("offset_from_face", "offset_from_plane", "midplane"):
|
||||
distance = self.offset_spin.value()
|
||||
if self._has_attachment():
|
||||
new_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
|
||||
self.datum_obj.AttachmentOffset = new_offset
|
||||
self._update_params({"distance": distance})
|
||||
|
||||
elif ztools_type == "angled":
|
||||
angle = self.angle_spin.value()
|
||||
if self._has_attachment():
|
||||
refs = _resolve_source_refs(self.datum_obj)
|
||||
if len(refs) >= 2 and refs[0][2] and refs[1][2]:
|
||||
face_normal = refs[0][2].normalAt(0, 0)
|
||||
edge_shape = refs[1][2]
|
||||
edge_dir = (
|
||||
edge_shape.Vertexes[-1].Point - edge_shape.Vertexes[0].Point
|
||||
).normalize()
|
||||
face_rot = App.Rotation(App.Vector(0, 0, 1), face_normal)
|
||||
local_edge_dir = face_rot.inverted().multVec(edge_dir)
|
||||
angle_rot = App.Rotation(local_edge_dir, angle)
|
||||
self.datum_obj.AttachmentOffset = App.Placement(
|
||||
App.Vector(0, 0, 0), angle_rot
|
||||
)
|
||||
self._update_params({"angle": angle})
|
||||
|
||||
elif ztools_type == "tangent_cylinder":
|
||||
angle = self.angle_spin.value()
|
||||
if self._has_attachment():
|
||||
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
vertex_angle = params.get("vertex_angle", 0.0)
|
||||
offset_rot = App.Rotation(App.Vector(0, 0, 1), angle - vertex_angle)
|
||||
self.datum_obj.AttachmentOffset = App.Placement(
|
||||
App.Vector(0, 0, 0), offset_rot
|
||||
)
|
||||
else:
|
||||
refs = _resolve_source_refs(self.datum_obj)
|
||||
if refs and refs[0][2]:
|
||||
face = refs[0][2]
|
||||
if isinstance(face.Surface, Part.Cylinder):
|
||||
cyl = face.Surface
|
||||
axis = cyl.Axis
|
||||
center = cyl.Center
|
||||
radius = cyl.Radius
|
||||
rad = math.radians(angle)
|
||||
if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99:
|
||||
local_x = axis.cross(App.Vector(1, 0, 0)).normalize()
|
||||
else:
|
||||
local_x = axis.cross(App.Vector(0, 1, 0)).normalize()
|
||||
local_y = axis.cross(local_x)
|
||||
radial = local_x * math.cos(rad) + local_y * math.sin(rad)
|
||||
tangent_point = center + radial * radius
|
||||
rot = App.Rotation(App.Vector(0, 0, 1), radial)
|
||||
self.datum_obj.Placement = App.Placement(tangent_point, rot)
|
||||
self._update_params({"angle": angle})
|
||||
|
||||
elif ztools_type in ("normal_to_edge", "on_edge"):
|
||||
parameter = self.param_spin.value()
|
||||
if self._has_attachment() and hasattr(self.datum_obj, "MapPathParameter"):
|
||||
self.datum_obj.MapPathParameter = parameter
|
||||
self._update_params({"parameter": parameter})
|
||||
|
||||
# Update position display
|
||||
pos = self.datum_obj.Placement.Base
|
||||
self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
|
||||
|
||||
App.ActiveDocument.recompute()
|
||||
|
||||
def _update_params(self, new_values):
|
||||
"""Update stored parameters with new values."""
|
||||
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
|
||||
params.update(new_values)
|
||||
|
||||
# Re-serialize (handle vectors)
|
||||
serializable = {}
|
||||
for k, v in params.items():
|
||||
if hasattr(v, "x") and hasattr(v, "y") and hasattr(v, "z"):
|
||||
serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
|
||||
else:
|
||||
serializable[k] = v
|
||||
|
||||
self.datum_obj.ZTools_Params = json.dumps(serializable)
|
||||
|
||||
def accept(self):
|
||||
"""Handle OK button - apply changes."""
|
||||
# Update label if changed
|
||||
new_label = self.name_edit.text().strip()
|
||||
if new_label and new_label != self.datum_obj.Label:
|
||||
self.datum_obj.Label = new_label
|
||||
|
||||
App.ActiveDocument.recompute()
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
"""Handle Cancel button - restore original placement."""
|
||||
self.datum_obj.Placement = self.original_placement
|
||||
App.ActiveDocument.recompute()
|
||||
return True
|
||||
|
||||
def getStandardButtons(self):
|
||||
"""Return dialog buttons."""
|
||||
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||
206
reference/ztools/ztools/ztools/commands/pattern_commands.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# ztools/commands/pattern_commands.py
|
||||
# Rotated Linear Pattern command
|
||||
# Creates a linear pattern with incremental rotation for each instance
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
|
||||
class RotatedLinearPatternFeature:
|
||||
"""Feature object for rotated linear pattern."""
|
||||
|
||||
def __init__(self, obj):
|
||||
obj.Proxy = self
|
||||
|
||||
obj.addProperty(
|
||||
"App::PropertyLink", "Source", "Base", "Source object to pattern"
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyVector",
|
||||
"Direction",
|
||||
"Pattern",
|
||||
"Direction of the linear pattern",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyDistance", "Length", "Pattern", "Total length of the pattern"
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyInteger",
|
||||
"Occurrences",
|
||||
"Pattern",
|
||||
"Number of occurrences (including original)",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyVector",
|
||||
"RotationAxis",
|
||||
"Rotation",
|
||||
"Axis of rotation for each instance",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyAngle",
|
||||
"RotationAngle",
|
||||
"Rotation",
|
||||
"Rotation angle increment per instance",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyVector",
|
||||
"RotationCenter",
|
||||
"Rotation",
|
||||
"Center point for rotation (relative to each instance)",
|
||||
)
|
||||
obj.addProperty(
|
||||
"App::PropertyBool",
|
||||
"CumulativeRotation",
|
||||
"Rotation",
|
||||
"If true, rotation accumulates with each instance",
|
||||
)
|
||||
|
||||
# Set defaults
|
||||
obj.Direction = App.Vector(1, 0, 0)
|
||||
obj.Length = 100.0
|
||||
obj.Occurrences = 3
|
||||
obj.RotationAxis = App.Vector(0, 0, 1)
|
||||
obj.RotationAngle = 15.0
|
||||
obj.RotationCenter = App.Vector(0, 0, 0)
|
||||
obj.CumulativeRotation = True
|
||||
|
||||
# Store metadata for ztools tracking
|
||||
obj.addProperty(
|
||||
"App::PropertyString",
|
||||
"ZTools_Type",
|
||||
"ZTools",
|
||||
"ZTools feature type",
|
||||
)
|
||||
obj.ZTools_Type = "RotatedLinearPattern"
|
||||
|
||||
def execute(self, obj):
|
||||
"""Recompute the feature."""
|
||||
if not obj.Source or not hasattr(obj.Source, "Shape"):
|
||||
return
|
||||
|
||||
source_shape = obj.Source.Shape
|
||||
if source_shape.isNull():
|
||||
return
|
||||
|
||||
occurrences = max(1, obj.Occurrences)
|
||||
if occurrences == 1:
|
||||
obj.Shape = source_shape.copy()
|
||||
return
|
||||
|
||||
# Calculate spacing
|
||||
direction = App.Vector(obj.Direction)
|
||||
if direction.Length < 1e-6:
|
||||
direction = App.Vector(1, 0, 0)
|
||||
direction.normalize()
|
||||
|
||||
spacing = float(obj.Length) / (occurrences - 1) if occurrences > 1 else 0
|
||||
|
||||
shapes = []
|
||||
for i in range(occurrences):
|
||||
# Create translation
|
||||
offset = direction * spacing * i
|
||||
translated = source_shape.copy()
|
||||
translated.translate(offset)
|
||||
|
||||
# Apply rotation
|
||||
if abs(float(obj.RotationAngle)) > 1e-6:
|
||||
if obj.CumulativeRotation:
|
||||
angle = float(obj.RotationAngle) * i
|
||||
else:
|
||||
angle = float(obj.RotationAngle)
|
||||
|
||||
# Rotation center is relative to the translated position
|
||||
center = App.Vector(obj.RotationCenter) + offset
|
||||
axis = App.Vector(obj.RotationAxis)
|
||||
if axis.Length < 1e-6:
|
||||
axis = App.Vector(0, 0, 1)
|
||||
axis.normalize()
|
||||
|
||||
translated.rotate(center, axis, angle)
|
||||
|
||||
shapes.append(translated)
|
||||
|
||||
if shapes:
|
||||
obj.Shape = Part.makeCompound(shapes)
|
||||
|
||||
def onChanged(self, obj, prop):
|
||||
"""Handle property changes."""
|
||||
pass
|
||||
|
||||
|
||||
class RotatedLinearPatternViewProvider:
|
||||
"""View provider for rotated linear pattern."""
|
||||
|
||||
def __init__(self, vobj):
|
||||
vobj.Proxy = self
|
||||
|
||||
def attach(self, vobj):
|
||||
self.Object = vobj.Object
|
||||
|
||||
def updateData(self, obj, prop):
|
||||
pass
|
||||
|
||||
def onChanged(self, vobj, prop):
|
||||
pass
|
||||
|
||||
def getIcon(self):
|
||||
return get_icon("rotated_pattern")
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
return None
|
||||
|
||||
|
||||
class RotatedLinearPatternCommand:
|
||||
"""Command to create a rotated linear pattern."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("rotated_pattern"),
|
||||
"MenuText": "Rotated Linear Pattern",
|
||||
"ToolTip": "Create a linear pattern with rotation for each instance",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
"""Command is active when there's a document and selection."""
|
||||
if App.ActiveDocument is None:
|
||||
return False
|
||||
sel = Gui.Selection.getSelection()
|
||||
return len(sel) == 1
|
||||
|
||||
def Activated(self):
|
||||
"""Execute the command."""
|
||||
sel = Gui.Selection.getSelection()
|
||||
if not sel:
|
||||
App.Console.PrintError("Please select an object first\n")
|
||||
return
|
||||
|
||||
source = sel[0]
|
||||
|
||||
# Create the feature
|
||||
doc = App.ActiveDocument
|
||||
obj = doc.addObject("Part::FeaturePython", "RotatedLinearPattern")
|
||||
RotatedLinearPatternFeature(obj)
|
||||
RotatedLinearPatternViewProvider(obj.ViewObject)
|
||||
|
||||
obj.Source = source
|
||||
obj.Label = f"RotatedPattern_{source.Label}"
|
||||
|
||||
# Hide source object
|
||||
if hasattr(source, "ViewObject"):
|
||||
source.ViewObject.Visibility = False
|
||||
|
||||
doc.recompute()
|
||||
|
||||
App.Console.PrintMessage(
|
||||
f"Created rotated linear pattern from {source.Label}\n"
|
||||
)
|
||||
|
||||
|
||||
# Register the command
|
||||
Gui.addCommand("ZTools_RotatedLinearPattern", RotatedLinearPatternCommand())
|
||||
601
reference/ztools/ztools/ztools/commands/pocket_commands.py
Normal file
@@ -0,0 +1,601 @@
|
||||
# ztools/commands/pocket_commands.py
|
||||
# Enhanced Pocket feature with "Flip side to cut" option
|
||||
#
|
||||
# This provides an enhanced pocket workflow that includes the ability to
|
||||
# cut material OUTSIDE the sketch profile rather than inside (like SOLIDWORKS
|
||||
# "Flip side to cut" feature).
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
import Part
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
|
||||
class EnhancedPocketTaskPanel:
|
||||
"""Task panel for creating enhanced pocket features with flip option."""
|
||||
|
||||
# Pocket type modes matching FreeCAD's PartDesign::Pocket
|
||||
POCKET_TYPES = [
|
||||
("Dimension", 0),
|
||||
("Through All", 1),
|
||||
("To First", 2),
|
||||
("Up To Face", 3),
|
||||
("Two Dimensions", 4),
|
||||
]
|
||||
|
||||
def __init__(self, sketch=None):
|
||||
self.form = QtGui.QWidget()
|
||||
self.form.setWindowTitle("ztools Enhanced Pocket")
|
||||
self.sketch = sketch
|
||||
self.selected_face = None
|
||||
self.setup_ui()
|
||||
self.setup_selection_observer()
|
||||
|
||||
# If sketch provided, show it in selection
|
||||
if self.sketch:
|
||||
self.update_sketch_display()
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QtGui.QVBoxLayout(self.form)
|
||||
|
||||
# Sketch selection display
|
||||
sketch_group = QtGui.QGroupBox("Sketch")
|
||||
sketch_layout = QtGui.QVBoxLayout(sketch_group)
|
||||
self.sketch_label = QtGui.QLabel("No sketch selected")
|
||||
self.sketch_label.setWordWrap(True)
|
||||
sketch_layout.addWidget(self.sketch_label)
|
||||
layout.addWidget(sketch_group)
|
||||
|
||||
# Type selection
|
||||
type_group = QtGui.QGroupBox("Type")
|
||||
type_layout = QtGui.QFormLayout(type_group)
|
||||
|
||||
self.type_combo = QtGui.QComboBox()
|
||||
for label, _ in self.POCKET_TYPES:
|
||||
self.type_combo.addItem(label)
|
||||
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
|
||||
type_layout.addRow("Type:", self.type_combo)
|
||||
|
||||
layout.addWidget(type_group)
|
||||
|
||||
# Dimensions group
|
||||
self.dim_group = QtGui.QGroupBox("Dimensions")
|
||||
self.dim_layout = QtGui.QFormLayout(self.dim_group)
|
||||
|
||||
# Length input
|
||||
self.length_spin = QtGui.QDoubleSpinBox()
|
||||
self.length_spin.setRange(0.001, 10000)
|
||||
self.length_spin.setValue(10.0)
|
||||
self.length_spin.setSuffix(" mm")
|
||||
self.length_spin.setDecimals(3)
|
||||
self.dim_layout.addRow("Length:", self.length_spin)
|
||||
|
||||
# Length2 input (for Two Dimensions mode)
|
||||
self.length2_spin = QtGui.QDoubleSpinBox()
|
||||
self.length2_spin.setRange(0.001, 10000)
|
||||
self.length2_spin.setValue(10.0)
|
||||
self.length2_spin.setSuffix(" mm")
|
||||
self.length2_spin.setDecimals(3)
|
||||
self.length2_label = QtGui.QLabel("Length 2:")
|
||||
# Hidden by default
|
||||
self.length2_spin.setVisible(False)
|
||||
self.length2_label.setVisible(False)
|
||||
self.dim_layout.addRow(self.length2_label, self.length2_spin)
|
||||
|
||||
layout.addWidget(self.dim_group)
|
||||
|
||||
# Up To Face selection (hidden by default)
|
||||
self.face_group = QtGui.QGroupBox("Up To Face")
|
||||
face_layout = QtGui.QVBoxLayout(self.face_group)
|
||||
self.face_label = QtGui.QLabel("Select a face...")
|
||||
self.face_label.setWordWrap(True)
|
||||
face_layout.addWidget(self.face_label)
|
||||
self.face_group.setVisible(False)
|
||||
layout.addWidget(self.face_group)
|
||||
|
||||
# Direction options
|
||||
dir_group = QtGui.QGroupBox("Direction")
|
||||
dir_layout = QtGui.QVBoxLayout(dir_group)
|
||||
|
||||
self.reversed_cb = QtGui.QCheckBox("Reversed")
|
||||
self.reversed_cb.setToolTip("Reverse the pocket direction")
|
||||
dir_layout.addWidget(self.reversed_cb)
|
||||
|
||||
self.symmetric_cb = QtGui.QCheckBox("Symmetric to plane")
|
||||
self.symmetric_cb.setToolTip(
|
||||
"Extend pocket equally on both sides of sketch plane"
|
||||
)
|
||||
self.symmetric_cb.toggled.connect(self.on_symmetric_changed)
|
||||
dir_layout.addWidget(self.symmetric_cb)
|
||||
|
||||
layout.addWidget(dir_group)
|
||||
|
||||
# FLIP SIDE TO CUT - The main new feature
|
||||
flip_group = QtGui.QGroupBox("Flip Side to Cut")
|
||||
flip_layout = QtGui.QVBoxLayout(flip_group)
|
||||
|
||||
self.flipped_cb = QtGui.QCheckBox("Cut outside profile (keep inside)")
|
||||
self.flipped_cb.setToolTip(
|
||||
"Instead of removing material inside the sketch profile,\n"
|
||||
"remove material OUTSIDE the profile.\n\n"
|
||||
"This keeps only the material covered by the sketch,\n"
|
||||
"similar to SOLIDWORKS 'Flip side to cut' option."
|
||||
)
|
||||
flip_layout.addWidget(self.flipped_cb)
|
||||
|
||||
# Info label
|
||||
flip_info = QtGui.QLabel(
|
||||
"<i>When enabled, material outside the sketch profile is removed,\n"
|
||||
"leaving only the material inside the sketch boundary.</i>"
|
||||
)
|
||||
flip_info.setWordWrap(True)
|
||||
flip_info.setStyleSheet("color: gray; font-size: 10px;")
|
||||
flip_layout.addWidget(flip_info)
|
||||
|
||||
layout.addWidget(flip_group)
|
||||
|
||||
# Taper angle (optional)
|
||||
taper_group = QtGui.QGroupBox("Taper")
|
||||
taper_layout = QtGui.QFormLayout(taper_group)
|
||||
|
||||
self.taper_spin = QtGui.QDoubleSpinBox()
|
||||
self.taper_spin.setRange(-89.99, 89.99)
|
||||
self.taper_spin.setValue(0.0)
|
||||
self.taper_spin.setSuffix(" °")
|
||||
self.taper_spin.setDecimals(2)
|
||||
taper_layout.addRow("Taper Angle:", self.taper_spin)
|
||||
|
||||
layout.addWidget(taper_group)
|
||||
|
||||
# Create button
|
||||
self.create_btn = QtGui.QPushButton("Create Pocket")
|
||||
self.create_btn.clicked.connect(self.on_create)
|
||||
layout.addWidget(self.create_btn)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def setup_selection_observer(self):
|
||||
"""Setup selection observer to track user selections."""
|
||||
|
||||
class SelectionObserver:
|
||||
def __init__(self, panel):
|
||||
self.panel = panel
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
self.panel.on_selection_changed()
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
self.panel.on_selection_changed()
|
||||
|
||||
def clearSelection(self, doc):
|
||||
self.panel.on_selection_changed()
|
||||
|
||||
self.observer = SelectionObserver(self)
|
||||
Gui.Selection.addObserver(self.observer)
|
||||
|
||||
def on_selection_changed(self):
|
||||
"""Handle selection changes."""
|
||||
sel = Gui.Selection.getSelectionEx()
|
||||
|
||||
for s in sel:
|
||||
obj = s.Object
|
||||
|
||||
# Check if it's a sketch (for sketch selection)
|
||||
if obj.TypeId == "Sketcher::SketchObject" and not self.sketch:
|
||||
self.sketch = obj
|
||||
self.update_sketch_display()
|
||||
|
||||
# Check for face selection (for Up To Face mode)
|
||||
if s.SubElementNames:
|
||||
for sub in s.SubElementNames:
|
||||
if sub.startswith("Face"):
|
||||
shape = obj.Shape.getElement(sub)
|
||||
if isinstance(shape, Part.Face):
|
||||
self.selected_face = (obj, sub)
|
||||
self.face_label.setText(f"Face: {obj.Name}.{sub}")
|
||||
|
||||
def update_sketch_display(self):
|
||||
"""Update sketch label."""
|
||||
if self.sketch:
|
||||
self.sketch_label.setText(f"Sketch: {self.sketch.Label}")
|
||||
else:
|
||||
self.sketch_label.setText("No sketch selected")
|
||||
|
||||
def on_type_changed(self, index):
|
||||
"""Update UI based on pocket type."""
|
||||
pocket_type = self.POCKET_TYPES[index][1]
|
||||
|
||||
# Show/hide dimension inputs based on type
|
||||
show_length = pocket_type in [0, 4] # Dimension or Two Dimensions
|
||||
self.dim_group.setVisible(show_length or pocket_type == 4)
|
||||
self.length_spin.setVisible(show_length)
|
||||
|
||||
# Two Dimensions mode
|
||||
show_length2 = pocket_type == 4
|
||||
self.length2_spin.setVisible(show_length2)
|
||||
self.length2_label.setVisible(show_length2)
|
||||
|
||||
# Up To Face mode
|
||||
self.face_group.setVisible(pocket_type == 3)
|
||||
|
||||
def on_symmetric_changed(self, checked):
|
||||
"""Handle symmetric checkbox change."""
|
||||
if checked:
|
||||
self.reversed_cb.setEnabled(False)
|
||||
self.reversed_cb.setChecked(False)
|
||||
else:
|
||||
self.reversed_cb.setEnabled(True)
|
||||
|
||||
def get_body(self):
|
||||
"""Get the active PartDesign body."""
|
||||
if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument:
|
||||
active_view = Gui.ActiveDocument.ActiveView
|
||||
if hasattr(active_view, "getActiveObject"):
|
||||
body = active_view.getActiveObject("pdbody")
|
||||
if body:
|
||||
return body
|
||||
|
||||
# Fallback: find body containing the sketch
|
||||
if self.sketch:
|
||||
for obj in App.ActiveDocument.Objects:
|
||||
if obj.TypeId == "PartDesign::Body":
|
||||
if self.sketch in obj.Group:
|
||||
return obj
|
||||
|
||||
return None
|
||||
|
||||
def on_create(self):
|
||||
"""Create the pocket feature."""
|
||||
if not self.sketch:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.form, "Error", "Please select a sketch first."
|
||||
)
|
||||
return
|
||||
|
||||
body = self.get_body()
|
||||
if not body:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.form, "Error", "No active body found. Please activate a body."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
App.ActiveDocument.openTransaction("Create Enhanced Pocket")
|
||||
|
||||
flipped = self.flipped_cb.isChecked()
|
||||
|
||||
if flipped:
|
||||
self.create_flipped_pocket(body)
|
||||
else:
|
||||
self.create_standard_pocket(body)
|
||||
|
||||
App.ActiveDocument.commitTransaction()
|
||||
App.ActiveDocument.recompute()
|
||||
|
||||
App.Console.PrintMessage("Enhanced Pocket created successfully\n")
|
||||
|
||||
except Exception as e:
|
||||
App.ActiveDocument.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to create pocket: {e}\n")
|
||||
QtGui.QMessageBox.critical(self.form, "Error", str(e))
|
||||
|
||||
def create_standard_pocket(self, body):
|
||||
"""Create a standard PartDesign Pocket."""
|
||||
pocket = body.newObject("PartDesign::Pocket", "Pocket")
|
||||
pocket.Profile = self.sketch
|
||||
|
||||
# Set type
|
||||
pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1]
|
||||
pocket.Type = pocket_type
|
||||
|
||||
# Set dimensions
|
||||
pocket.Length = self.length_spin.value()
|
||||
if pocket_type == 4: # Two Dimensions
|
||||
pocket.Length2 = self.length2_spin.value()
|
||||
|
||||
# Set direction options
|
||||
pocket.Reversed = self.reversed_cb.isChecked()
|
||||
pocket.Midplane = self.symmetric_cb.isChecked()
|
||||
|
||||
# Set taper
|
||||
if abs(self.taper_spin.value()) > 0.001:
|
||||
pocket.TaperAngle = self.taper_spin.value()
|
||||
|
||||
# Up To Face
|
||||
if pocket_type == 3 and self.selected_face:
|
||||
obj, sub = self.selected_face
|
||||
pocket.UpToFace = (obj, [sub])
|
||||
|
||||
# Hide sketch
|
||||
self.sketch.ViewObject.Visibility = False
|
||||
|
||||
# Add metadata
|
||||
pocket.addProperty(
|
||||
"App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type"
|
||||
)
|
||||
pocket.ZTools_Type = "EnhancedPocket"
|
||||
|
||||
def create_flipped_pocket(self, body):
|
||||
"""Create a flipped pocket (cut outside profile).
|
||||
|
||||
This uses Boolean Common operation: keeps only the intersection
|
||||
of the body with the extruded profile.
|
||||
"""
|
||||
# Get current body shape (the Tip)
|
||||
tip = body.Tip
|
||||
if not tip or not hasattr(tip, "Shape"):
|
||||
raise ValueError("Body has no valid tip shape")
|
||||
|
||||
base_shape = tip.Shape.copy()
|
||||
|
||||
# Get sketch profile
|
||||
sketch_shape = self.sketch.Shape
|
||||
if not sketch_shape.Wires:
|
||||
raise ValueError("Sketch has no closed profile")
|
||||
|
||||
# Create face from sketch wires
|
||||
wires = sketch_shape.Wires
|
||||
if len(wires) == 0:
|
||||
raise ValueError("Sketch has no wires")
|
||||
|
||||
# Create a face from the outer wire
|
||||
# For multiple wires, the first is outer, rest are holes
|
||||
face = Part.Face(wires[0])
|
||||
if len(wires) > 1:
|
||||
# Handle holes in the profile
|
||||
face = Part.Face(wires)
|
||||
|
||||
# Get extrusion direction (sketch normal)
|
||||
sketch_placement = self.sketch.Placement
|
||||
normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1))
|
||||
|
||||
if self.reversed_cb.isChecked():
|
||||
normal = normal.negative()
|
||||
|
||||
# Calculate extrusion length/direction
|
||||
pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1]
|
||||
|
||||
if pocket_type == 0: # Dimension
|
||||
length = self.length_spin.value()
|
||||
if self.symmetric_cb.isChecked():
|
||||
# Symmetric: extrude half in each direction
|
||||
half_length = length / 2
|
||||
tool_solid = face.extrude(normal * half_length)
|
||||
tool_solid2 = face.extrude(normal.negative() * half_length)
|
||||
tool_solid = tool_solid.fuse(tool_solid2)
|
||||
else:
|
||||
tool_solid = face.extrude(normal * length)
|
||||
|
||||
elif pocket_type == 1: # Through All
|
||||
# Use a large value based on bounding box
|
||||
bbox = base_shape.BoundBox
|
||||
diagonal = bbox.DiagonalLength
|
||||
length = diagonal * 2
|
||||
|
||||
if self.symmetric_cb.isChecked():
|
||||
tool_solid = face.extrude(normal * length)
|
||||
tool_solid2 = face.extrude(normal.negative() * length)
|
||||
tool_solid = tool_solid.fuse(tool_solid2)
|
||||
else:
|
||||
tool_solid = face.extrude(normal * length)
|
||||
|
||||
elif pocket_type == 4: # Two Dimensions
|
||||
length1 = self.length_spin.value()
|
||||
length2 = self.length2_spin.value()
|
||||
tool_solid = face.extrude(normal * length1)
|
||||
tool_solid2 = face.extrude(normal.negative() * length2)
|
||||
tool_solid = tool_solid.fuse(tool_solid2)
|
||||
|
||||
else:
|
||||
# For other types, fall back to Through All behavior
|
||||
bbox = base_shape.BoundBox
|
||||
length = bbox.DiagonalLength * 2
|
||||
tool_solid = face.extrude(normal * length)
|
||||
|
||||
# Apply taper if specified
|
||||
# Note: Taper with flipped pocket is complex, skip for now
|
||||
if abs(self.taper_spin.value()) > 0.001:
|
||||
App.Console.PrintWarning(
|
||||
"Taper angle is not supported with Flip Side to Cut. Ignoring.\n"
|
||||
)
|
||||
|
||||
# Boolean Common: keep only intersection
|
||||
result_shape = base_shape.common(tool_solid)
|
||||
|
||||
if result_shape.isNull() or result_shape.Volume < 1e-6:
|
||||
raise ValueError(
|
||||
"Flip pocket resulted in empty shape. "
|
||||
"Make sure the sketch profile intersects with the body."
|
||||
)
|
||||
|
||||
# Create a FeaturePython object to hold the result
|
||||
feature = body.newObject("PartDesign::FeaturePython", "FlippedPocket")
|
||||
|
||||
# Set up the feature
|
||||
FlippedPocketFeature(feature, self.sketch, result_shape)
|
||||
FlippedPocketViewProvider(feature.ViewObject)
|
||||
|
||||
# Store parameters as properties
|
||||
feature.addProperty("App::PropertyDistance", "Length", "Pocket", "Pocket depth")
|
||||
feature.Length = self.length_spin.value()
|
||||
|
||||
feature.addProperty(
|
||||
"App::PropertyBool", "Reversed", "Pocket", "Reverse direction"
|
||||
)
|
||||
feature.Reversed = self.reversed_cb.isChecked()
|
||||
|
||||
feature.addProperty(
|
||||
"App::PropertyBool", "Symmetric", "Pocket", "Symmetric to plane"
|
||||
)
|
||||
feature.Symmetric = self.symmetric_cb.isChecked()
|
||||
|
||||
feature.addProperty(
|
||||
"App::PropertyInteger", "PocketType", "Pocket", "Pocket type"
|
||||
)
|
||||
feature.PocketType = pocket_type
|
||||
|
||||
# Hide sketch
|
||||
self.sketch.ViewObject.Visibility = False
|
||||
|
||||
def accept(self):
|
||||
"""Called when OK is clicked."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
"""Called when Cancel is clicked."""
|
||||
Gui.Selection.removeObserver(self.observer)
|
||||
return True
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
|
||||
|
||||
|
||||
class FlippedPocketFeature:
|
||||
"""Feature object for flipped pocket (cuts outside profile)."""
|
||||
|
||||
def __init__(self, obj, sketch, initial_shape):
|
||||
obj.Proxy = self
|
||||
self.sketch = sketch
|
||||
|
||||
# Store reference to sketch
|
||||
obj.addProperty("App::PropertyLink", "Profile", "Base", "Sketch profile")
|
||||
obj.Profile = sketch
|
||||
|
||||
# ZTools metadata
|
||||
obj.addProperty(
|
||||
"App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type"
|
||||
)
|
||||
obj.ZTools_Type = "FlippedPocket"
|
||||
|
||||
# Set initial shape
|
||||
obj.Shape = initial_shape
|
||||
|
||||
def execute(self, obj):
|
||||
"""Recompute the flipped pocket."""
|
||||
if not obj.Profile:
|
||||
return
|
||||
|
||||
# Get the base feature (previous feature in the body)
|
||||
body = obj.getParentGeoFeatureGroup()
|
||||
if not body:
|
||||
return
|
||||
|
||||
# Find the feature before this one
|
||||
base_feature = None
|
||||
group = body.Group
|
||||
for i, feat in enumerate(group):
|
||||
if feat == obj and i > 0:
|
||||
base_feature = group[i - 1]
|
||||
break
|
||||
|
||||
if not base_feature or not hasattr(base_feature, "Shape"):
|
||||
return
|
||||
|
||||
base_shape = base_feature.Shape.copy()
|
||||
sketch = obj.Profile
|
||||
|
||||
# Get sketch profile
|
||||
sketch_shape = sketch.Shape
|
||||
if not sketch_shape.Wires:
|
||||
return
|
||||
|
||||
wires = sketch_shape.Wires
|
||||
face = Part.Face(wires[0])
|
||||
if len(wires) > 1:
|
||||
face = Part.Face(wires)
|
||||
|
||||
# Get direction
|
||||
sketch_placement = sketch.Placement
|
||||
normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1))
|
||||
|
||||
if hasattr(obj, "Reversed") and obj.Reversed:
|
||||
normal = normal.negative()
|
||||
|
||||
# Get length
|
||||
length = obj.Length.Value if hasattr(obj, "Length") else 10.0
|
||||
symmetric = obj.Symmetric if hasattr(obj, "Symmetric") else False
|
||||
pocket_type = obj.PocketType if hasattr(obj, "PocketType") else 0
|
||||
|
||||
# Create tool solid
|
||||
if pocket_type == 1: # Through All
|
||||
bbox = base_shape.BoundBox
|
||||
length = bbox.DiagonalLength * 2
|
||||
|
||||
if symmetric:
|
||||
half = length / 2
|
||||
tool_solid = face.extrude(normal * half)
|
||||
tool_solid2 = face.extrude(normal.negative() * half)
|
||||
tool_solid = tool_solid.fuse(tool_solid2)
|
||||
else:
|
||||
tool_solid = face.extrude(normal * length)
|
||||
|
||||
# Boolean Common
|
||||
result_shape = base_shape.common(tool_solid)
|
||||
obj.Shape = result_shape
|
||||
|
||||
def onChanged(self, obj, prop):
|
||||
"""Handle property changes."""
|
||||
pass
|
||||
|
||||
|
||||
class FlippedPocketViewProvider:
|
||||
"""View provider for flipped pocket."""
|
||||
|
||||
def __init__(self, vobj):
|
||||
vobj.Proxy = self
|
||||
|
||||
def attach(self, vobj):
|
||||
self.Object = vobj.Object
|
||||
|
||||
def updateData(self, obj, prop):
|
||||
pass
|
||||
|
||||
def onChanged(self, vobj, prop):
|
||||
pass
|
||||
|
||||
def getIcon(self):
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
return get_icon("pocket_flipped")
|
||||
|
||||
def __getstate__(self):
|
||||
return None
|
||||
|
||||
def __setstate__(self, state):
|
||||
return None
|
||||
|
||||
|
||||
class ZTools_EnhancedPocket:
|
||||
"""Command to create enhanced pocket with flip option."""
|
||||
|
||||
def GetResources(self):
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
return {
|
||||
"Pixmap": get_icon("pocket_enhanced"),
|
||||
"MenuText": "Enhanced Pocket",
|
||||
"ToolTip": (
|
||||
"Create a pocket with additional options including\n"
|
||||
"'Flip side to cut' - removes material outside the sketch profile"
|
||||
),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
# Check if a sketch is selected
|
||||
sketch = None
|
||||
sel = Gui.Selection.getSelection()
|
||||
for obj in sel:
|
||||
if obj.TypeId == "Sketcher::SketchObject":
|
||||
sketch = obj
|
||||
break
|
||||
|
||||
panel = EnhancedPocketTaskPanel(sketch)
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
def IsActive(self):
|
||||
return App.ActiveDocument is not None
|
||||
|
||||
|
||||
# Register the command
|
||||
Gui.addCommand("ZTools_EnhancedPocket", ZTools_EnhancedPocket())
|
||||
567
reference/ztools/ztools/ztools/commands/spreadsheet_commands.py
Normal file
@@ -0,0 +1,567 @@
|
||||
# ztools/commands/spreadsheet_commands.py
|
||||
# Enhanced spreadsheet formatting tools for FreeCAD
|
||||
# Provides quick formatting toolbar for cell styling
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
from ztools.resources.icons import get_icon
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_active_spreadsheet():
|
||||
"""Get the currently active spreadsheet object and its view.
|
||||
|
||||
Returns:
|
||||
tuple: (sheet_object, sheet_view) or (None, None)
|
||||
"""
|
||||
doc = App.ActiveDocument
|
||||
if not doc:
|
||||
return None, None
|
||||
|
||||
# Get MDI area and active subwindow
|
||||
main_window = Gui.getMainWindow()
|
||||
mdi = main_window.centralWidget()
|
||||
if not mdi:
|
||||
return None, None
|
||||
|
||||
subwindow = mdi.activeSubWindow()
|
||||
if not subwindow:
|
||||
return None, None
|
||||
|
||||
# Get widget from subwindow
|
||||
widget = subwindow.widget()
|
||||
if not widget:
|
||||
return None, None
|
||||
|
||||
# Check if it's a spreadsheet view by looking for the table view
|
||||
# FreeCAD's spreadsheet view contains a QTableView
|
||||
table_view = None
|
||||
if hasattr(widget, "findChild"):
|
||||
table_view = widget.findChild(QtGui.QTableView)
|
||||
|
||||
if not table_view:
|
||||
# Try if widget itself is the table view
|
||||
if isinstance(widget, QtGui.QTableView):
|
||||
table_view = widget
|
||||
else:
|
||||
return None, None
|
||||
|
||||
# Get the spreadsheet object from window title
|
||||
# Window title format varies: "Spreadsheet" or "Spreadsheet - DocName"
|
||||
title = subwindow.windowTitle()
|
||||
sheet_name = title.split(" - ")[0].split(" : ")[0].strip()
|
||||
|
||||
# Try to find the sheet object
|
||||
sheet = doc.getObject(sheet_name)
|
||||
if sheet and sheet.TypeId == "Spreadsheet::Sheet":
|
||||
return sheet, table_view
|
||||
|
||||
# Fallback: search for any spreadsheet object
|
||||
for obj in doc.Objects:
|
||||
if obj.TypeId == "Spreadsheet::Sheet":
|
||||
return obj, table_view
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def get_selected_cells():
|
||||
"""Get list of selected cell addresses from active spreadsheet.
|
||||
|
||||
Returns:
|
||||
tuple: (sheet_object, list_of_cell_addresses) or (None, [])
|
||||
"""
|
||||
sheet, table_view = get_active_spreadsheet()
|
||||
if not sheet or not table_view:
|
||||
return None, []
|
||||
|
||||
# Get selection model
|
||||
selection_model = table_view.selectionModel()
|
||||
if not selection_model:
|
||||
return sheet, []
|
||||
|
||||
indexes = selection_model.selectedIndexes()
|
||||
if not indexes:
|
||||
return sheet, []
|
||||
|
||||
cells = []
|
||||
for idx in indexes:
|
||||
col = idx.column()
|
||||
row = idx.row()
|
||||
|
||||
# Convert to cell address (A1 notation)
|
||||
# Handle columns beyond Z (AA, AB, etc.)
|
||||
col_str = ""
|
||||
temp_col = col
|
||||
while temp_col >= 0:
|
||||
col_str = chr(65 + (temp_col % 26)) + col_str
|
||||
temp_col = temp_col // 26 - 1
|
||||
|
||||
cell_addr = f"{col_str}{row + 1}"
|
||||
cells.append(cell_addr)
|
||||
|
||||
return sheet, cells
|
||||
|
||||
|
||||
def column_to_index(col_str):
|
||||
"""Convert column string (A, B, ..., Z, AA, AB, ...) to index."""
|
||||
result = 0
|
||||
for char in col_str:
|
||||
result = result * 26 + (ord(char) - ord("A") + 1)
|
||||
return result - 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Style Commands (Bold, Italic, Underline)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ZTools_SpreadsheetStyleBold:
|
||||
"""Toggle bold style on selected cells."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_bold"),
|
||||
"MenuText": "Bold",
|
||||
"ToolTip": "Toggle bold style on selected cells (Ctrl+B)",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Toggle Bold")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
current = sheet.getStyle(cell) or ""
|
||||
styles = set(s.strip() for s in current.split("|") if s.strip())
|
||||
|
||||
if "bold" in styles:
|
||||
styles.discard("bold")
|
||||
else:
|
||||
styles.add("bold")
|
||||
|
||||
new_style = "|".join(sorted(styles)) if styles else ""
|
||||
sheet.setStyle(cell, new_style)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to toggle bold: {e}\n")
|
||||
|
||||
|
||||
class ZTools_SpreadsheetStyleItalic:
|
||||
"""Toggle italic style on selected cells."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_italic"),
|
||||
"MenuText": "Italic",
|
||||
"ToolTip": "Toggle italic style on selected cells (Ctrl+I)",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Toggle Italic")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
current = sheet.getStyle(cell) or ""
|
||||
styles = set(s.strip() for s in current.split("|") if s.strip())
|
||||
|
||||
if "italic" in styles:
|
||||
styles.discard("italic")
|
||||
else:
|
||||
styles.add("italic")
|
||||
|
||||
new_style = "|".join(sorted(styles)) if styles else ""
|
||||
sheet.setStyle(cell, new_style)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to toggle italic: {e}\n")
|
||||
|
||||
|
||||
class ZTools_SpreadsheetStyleUnderline:
|
||||
"""Toggle underline style on selected cells."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_underline"),
|
||||
"MenuText": "Underline",
|
||||
"ToolTip": "Toggle underline style on selected cells (Ctrl+U)",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Toggle Underline")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
current = sheet.getStyle(cell) or ""
|
||||
styles = set(s.strip() for s in current.split("|") if s.strip())
|
||||
|
||||
if "underline" in styles:
|
||||
styles.discard("underline")
|
||||
else:
|
||||
styles.add("underline")
|
||||
|
||||
new_style = "|".join(sorted(styles)) if styles else ""
|
||||
sheet.setStyle(cell, new_style)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to toggle underline: {e}\n")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Alignment Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ZTools_SpreadsheetAlignLeft:
|
||||
"""Align cell content to the left."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_align_left"),
|
||||
"MenuText": "Align Left",
|
||||
"ToolTip": "Align selected cells to the left",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Align Left")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
sheet.setAlignment(cell, "left")
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to align left: {e}\n")
|
||||
|
||||
|
||||
class ZTools_SpreadsheetAlignCenter:
|
||||
"""Align cell content to the center."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_align_center"),
|
||||
"MenuText": "Align Center",
|
||||
"ToolTip": "Align selected cells to the center",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Align Center")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
sheet.setAlignment(cell, "center")
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to align center: {e}\n")
|
||||
|
||||
|
||||
class ZTools_SpreadsheetAlignRight:
|
||||
"""Align cell content to the right."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_align_right"),
|
||||
"MenuText": "Align Right",
|
||||
"ToolTip": "Align selected cells to the right",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Align Right")
|
||||
|
||||
try:
|
||||
for cell in cells:
|
||||
sheet.setAlignment(cell, "right")
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to align right: {e}\n")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Color Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ZTools_SpreadsheetBgColor:
|
||||
"""Set background color of selected cells."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_bg_color"),
|
||||
"MenuText": "Background Color",
|
||||
"ToolTip": "Set background color of selected cells",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
# Show color picker dialog
|
||||
color = QtGui.QColorDialog.getColor(
|
||||
QtCore.Qt.white, Gui.getMainWindow(), "Select Background Color"
|
||||
)
|
||||
|
||||
if not color.isValid():
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Set Background Color")
|
||||
|
||||
try:
|
||||
# FreeCAD expects RGB as tuple of floats 0-1
|
||||
rgb = (
|
||||
color.redF(),
|
||||
color.greenF(),
|
||||
color.blueF(),
|
||||
)
|
||||
|
||||
for cell in cells:
|
||||
sheet.setBackground(cell, rgb)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to set background color: {e}\n")
|
||||
|
||||
|
||||
class ZTools_SpreadsheetTextColor:
|
||||
"""Set text color of selected cells."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_text_color"),
|
||||
"MenuText": "Text Color",
|
||||
"ToolTip": "Set text color of selected cells",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
# Show color picker dialog with default white for dark theme
|
||||
initial_color = QtGui.QColor(205, 214, 244) # Catppuccin Mocha text color
|
||||
color = QtGui.QColorDialog.getColor(
|
||||
initial_color, Gui.getMainWindow(), "Select Text Color"
|
||||
)
|
||||
|
||||
if not color.isValid():
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Set Text Color")
|
||||
|
||||
try:
|
||||
# FreeCAD expects RGB as tuple of floats 0-1
|
||||
rgb = (
|
||||
color.redF(),
|
||||
color.greenF(),
|
||||
color.blueF(),
|
||||
)
|
||||
|
||||
for cell in cells:
|
||||
sheet.setForeground(cell, rgb)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to set text color: {e}\n")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Commands
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ZTools_SpreadsheetQuickAlias:
|
||||
"""Create alias from cell content."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": get_icon("spreadsheet_quick_alias"),
|
||||
"MenuText": "Quick Alias",
|
||||
"ToolTip": "Create alias for selected cell based on adjacent label cell",
|
||||
}
|
||||
|
||||
def IsActive(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
return sheet is not None and len(cells) > 0
|
||||
|
||||
def Activated(self):
|
||||
sheet, cells = get_selected_cells()
|
||||
if not sheet or not cells:
|
||||
App.Console.PrintWarning("No cells selected\n")
|
||||
return
|
||||
|
||||
doc = App.ActiveDocument
|
||||
doc.openTransaction("Quick Alias")
|
||||
|
||||
try:
|
||||
aliases_created = 0
|
||||
|
||||
for cell in cells:
|
||||
# Parse cell address
|
||||
import re
|
||||
|
||||
match = re.match(r"([A-Z]+)(\d+)", cell)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
col_str = match.group(1)
|
||||
row = int(match.group(2))
|
||||
|
||||
# Get content of cell to the left (label cell)
|
||||
col_idx = column_to_index(col_str)
|
||||
if col_idx > 0:
|
||||
# Convert back to column string
|
||||
label_col_idx = col_idx - 1
|
||||
label_col_str = ""
|
||||
temp = label_col_idx
|
||||
while temp >= 0:
|
||||
label_col_str = chr(65 + (temp % 26)) + label_col_str
|
||||
temp = temp // 26 - 1
|
||||
|
||||
label_cell = f"{label_col_str}{row}"
|
||||
label_content = sheet.getContents(label_cell)
|
||||
|
||||
if label_content:
|
||||
# Clean the label to make a valid alias
|
||||
# Must be alphanumeric + underscore, start with letter
|
||||
alias = "".join(
|
||||
c if c.isalnum() or c == "_" else "_" for c in label_content
|
||||
)
|
||||
# Ensure it starts with a letter
|
||||
if alias and not alias[0].isalpha():
|
||||
alias = "var_" + alias
|
||||
# Truncate if too long
|
||||
alias = alias[:30]
|
||||
|
||||
if alias:
|
||||
try:
|
||||
sheet.setAlias(cell, alias)
|
||||
aliases_created += 1
|
||||
except Exception as alias_err:
|
||||
App.Console.PrintWarning(
|
||||
f"Could not set alias '{alias}' for {cell}: {alias_err}\n"
|
||||
)
|
||||
|
||||
doc.commitTransaction()
|
||||
doc.recompute()
|
||||
|
||||
if aliases_created > 0:
|
||||
App.Console.PrintMessage(f"Created {aliases_created} alias(es)\n")
|
||||
else:
|
||||
App.Console.PrintWarning(
|
||||
"No aliases created. Select value cells with labels to the left.\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
doc.abortTransaction()
|
||||
App.Console.PrintError(f"Failed to create aliases: {e}\n")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Register Commands
|
||||
# =============================================================================
|
||||
|
||||
Gui.addCommand("ZTools_SpreadsheetStyleBold", ZTools_SpreadsheetStyleBold())
|
||||
Gui.addCommand("ZTools_SpreadsheetStyleItalic", ZTools_SpreadsheetStyleItalic())
|
||||
Gui.addCommand("ZTools_SpreadsheetStyleUnderline", ZTools_SpreadsheetStyleUnderline())
|
||||
Gui.addCommand("ZTools_SpreadsheetAlignLeft", ZTools_SpreadsheetAlignLeft())
|
||||
Gui.addCommand("ZTools_SpreadsheetAlignCenter", ZTools_SpreadsheetAlignCenter())
|
||||
Gui.addCommand("ZTools_SpreadsheetAlignRight", ZTools_SpreadsheetAlignRight())
|
||||
Gui.addCommand("ZTools_SpreadsheetBgColor", ZTools_SpreadsheetBgColor())
|
||||
Gui.addCommand("ZTools_SpreadsheetTextColor", ZTools_SpreadsheetTextColor())
|
||||
Gui.addCommand("ZTools_SpreadsheetQuickAlias", ZTools_SpreadsheetQuickAlias())
|
||||
41
reference/ztools/ztools/ztools/datums/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# ztools/datums - Datum creation tools
|
||||
from .core import (
|
||||
axis_cylinder_center,
|
||||
# Axes
|
||||
axis_from_2_points,
|
||||
axis_from_edge,
|
||||
axis_intersection_planes,
|
||||
plane_angled,
|
||||
plane_from_3_points,
|
||||
plane_midplane,
|
||||
plane_normal_to_edge,
|
||||
# Planes
|
||||
plane_offset_from_face,
|
||||
plane_offset_from_plane,
|
||||
plane_tangent_to_cylinder,
|
||||
point_at_coordinates,
|
||||
# Points
|
||||
point_at_vertex,
|
||||
point_center_of_circle,
|
||||
point_center_of_face,
|
||||
point_on_edge,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"plane_offset_from_face",
|
||||
"plane_offset_from_plane",
|
||||
"plane_midplane",
|
||||
"plane_from_3_points",
|
||||
"plane_normal_to_edge",
|
||||
"plane_angled",
|
||||
"plane_tangent_to_cylinder",
|
||||
"axis_from_2_points",
|
||||
"axis_from_edge",
|
||||
"axis_cylinder_center",
|
||||
"axis_intersection_planes",
|
||||
"point_at_vertex",
|
||||
"point_at_coordinates",
|
||||
"point_on_edge",
|
||||
"point_center_of_face",
|
||||
"point_center_of_circle",
|
||||
]
|
||||
1588
reference/ztools/ztools/ztools/datums/core.py
Normal file
8
reference/ztools/ztools/ztools/resources/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# ztools/resources - Icons and assets
|
||||
from .icons import MOCHA, get_icon, save_icons_to_disk
|
||||
|
||||
__all__ = [
|
||||
"get_icon",
|
||||
"save_icons_to_disk",
|
||||
"MOCHA",
|
||||
]
|
||||
513
reference/ztools/ztools/ztools/resources/icons.py
Normal file
@@ -0,0 +1,513 @@
|
||||
# ztools/resources/icons.py
|
||||
# Catppuccin Mocha themed icons for ztools
|
||||
|
||||
# Catppuccin Mocha Palette — sourced from kindred-addon-sdk
|
||||
from kindred_sdk.theme import get_theme_tokens
|
||||
|
||||
MOCHA = get_theme_tokens()
|
||||
|
||||
|
||||
def _svg_to_base64(svg_content: str) -> str:
|
||||
"""Convert SVG string to base64 data URI for FreeCAD."""
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(svg_content.encode("utf-8")).decode("utf-8")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SVG Icon Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Workbench main icon - stylized "Z"
|
||||
ICON_WORKBENCH_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<path d="M8 10 L24 10 L10 22 L24 22" stroke="{MOCHA["mauve"]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="24" cy="10" r="2" fill="{MOCHA["teal"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Datum Creator icon - plane with plus
|
||||
ICON_DATUM_CREATOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Plane representation -->
|
||||
<path d="M6 20 L16 8 L26 20 L16 26 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Plus sign -->
|
||||
<circle cx="24" cy="8" r="6" fill="{MOCHA["green"]}"/>
|
||||
<path d="M24 5 L24 11 M21 8 L27 8" stroke="{MOCHA["base"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Datum Manager icon - stacked planes with list
|
||||
ICON_DATUM_MANAGER_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Stacked planes -->
|
||||
<path d="M4 18 L12 12 L20 18 L12 22 Z" fill="{MOCHA["blue"]}" fill-opacity="0.5" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
|
||||
<path d="M4 14 L12 8 L20 14 L12 18 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.5" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
|
||||
<!-- List lines -->
|
||||
<line x1="22" y1="10" x2="28" y2="10" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="22" y1="16" x2="28" y2="16" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="22" y1="22" x2="28" y2="22" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane Offset icon
|
||||
ICON_PLANE_OFFSET_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 14 L28 22 L16 28 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
|
||||
<!-- Offset plane -->
|
||||
<path d="M4 14 L16 6 L28 14 L16 20 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Offset arrow -->
|
||||
<path d="M16 24 L16 18" stroke="{MOCHA["peach"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 20 L16 17 L18 20" stroke="{MOCHA["peach"]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane Midplane icon
|
||||
ICON_PLANE_MIDPLANE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Top plane -->
|
||||
<path d="M4 10 L16 4 L28 10 L16 14 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
|
||||
<!-- Middle plane (result) -->
|
||||
<path d="M4 16 L16 10 L28 16 L16 20 Z" fill="{MOCHA["green"]}" fill-opacity="0.7" stroke="{MOCHA["teal"]}" stroke-width="1.5"/>
|
||||
<!-- Bottom plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane 3 Points icon
|
||||
ICON_PLANE_3PT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Plane -->
|
||||
<path d="M4 20 L16 8 L28 18 L14 28 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Three points -->
|
||||
<circle cx="8" cy="20" r="3" fill="{MOCHA["peach"]}"/>
|
||||
<circle cx="16" cy="10" r="3" fill="{MOCHA["peach"]}"/>
|
||||
<circle cx="24" cy="18" r="3" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane Normal to Edge icon
|
||||
ICON_PLANE_NORMAL_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 26 Q16 6 26 16" stroke="{MOCHA["yellow"]}" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Plane perpendicular -->
|
||||
<path d="M12 8 L20 12 L20 24 L12 20 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Point on curve -->
|
||||
<circle cx="16" cy="16" r="2.5" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane Angled icon
|
||||
ICON_PLANE_ANGLED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
|
||||
<!-- Angled plane -->
|
||||
<path d="M8 10 L20 6 L24 18 L12 22 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.7" stroke="{MOCHA["lavender"]}" stroke-width="1.5"/>
|
||||
<!-- Angle arc -->
|
||||
<path d="M14 20 Q18 18 18 14" stroke="{MOCHA["peach"]}" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Plane Tangent icon
|
||||
ICON_PLANE_TANGENT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Cylinder outline -->
|
||||
<ellipse cx="12" cy="16" rx="6" ry="10" fill="none" stroke="{MOCHA["yellow"]}" stroke-width="2"/>
|
||||
<!-- Tangent plane -->
|
||||
<path d="M18 6 L28 10 L28 26 L18 22 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Tangent point -->
|
||||
<circle cx="18" cy="16" r="2.5" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Axis 2 Points icon
|
||||
ICON_AXIS_2PT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Axis line -->
|
||||
<line x1="6" y1="26" x2="26" y2="6" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- End points -->
|
||||
<circle cx="6" cy="26" r="3" fill="{MOCHA["peach"]}"/>
|
||||
<circle cx="26" cy="6" r="3" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Axis from Edge icon
|
||||
ICON_AXIS_EDGE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Box edge representation -->
|
||||
<path d="M8 24 L8 12 L20 8 L20 20 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<!-- Selected edge highlighted -->
|
||||
<line x1="8" y1="24" x2="8" y2="12" stroke="{MOCHA["yellow"]}" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Resulting axis -->
|
||||
<line x1="8" y1="28" x2="8" y2="4" stroke="{MOCHA["red"]}" stroke-width="2" stroke-dasharray="4,2"/>
|
||||
</svg>'''
|
||||
|
||||
# Axis Cylinder Center icon
|
||||
ICON_AXIS_CYL_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Cylinder -->
|
||||
<ellipse cx="16" cy="8" rx="8" ry="3" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<path d="M8 8 L8 24" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<path d="M24 8 L24 24" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<ellipse cx="16" cy="24" rx="8" ry="3" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<!-- Center axis -->
|
||||
<line x1="16" y1="4" x2="16" y2="28" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="2" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Axis Intersection icon
|
||||
ICON_AXIS_INTERSECT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- First plane -->
|
||||
<path d="M4 12 L16 6 L28 12 L16 18 Z" fill="{MOCHA["blue"]}" fill-opacity="0.5" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
|
||||
<!-- Second plane -->
|
||||
<path d="M4 20 L16 14 L28 20 L16 26 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.5" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
|
||||
<!-- Intersection axis -->
|
||||
<line x1="4" y1="16" x2="28" y2="16" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Point at Vertex icon
|
||||
ICON_POINT_VERTEX_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Wireframe box corner -->
|
||||
<path d="M10 20 L10 10 L20 6" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L20 16 L20 6" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L4 24" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
|
||||
<!-- Vertex point -->
|
||||
<circle cx="10" cy="20" r="4" fill="{MOCHA["green"]}"/>
|
||||
<circle cx="10" cy="20" r="2" fill="{MOCHA["teal"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Point XYZ icon
|
||||
ICON_POINT_XYZ_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Coordinate axes -->
|
||||
<line x1="6" y1="24" x2="26" y2="24" stroke="{MOCHA["red"]}" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="6" y2="6" stroke="{MOCHA["green"]}" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="16" y2="28" stroke="{MOCHA["blue"]}" stroke-width="1.5"/>
|
||||
<!-- Point -->
|
||||
<circle cx="18" cy="12" r="4" fill="{MOCHA["peach"]}"/>
|
||||
<!-- Projection lines -->
|
||||
<line x1="18" y1="12" x2="18" y2="24" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
<line x1="18" y1="12" x2="6" y2="12" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>'''
|
||||
|
||||
# Point on Edge icon
|
||||
ICON_POINT_EDGE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 24 Q16 4 26 20" stroke="{MOCHA["yellow"]}" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Point on edge -->
|
||||
<circle cx="14" cy="12" r="4" fill="{MOCHA["green"]}"/>
|
||||
<!-- Parameter indicator -->
|
||||
<text x="20" y="10" font-family="monospace" font-size="8" fill="{MOCHA["text"]}">t</text>
|
||||
</svg>'''
|
||||
|
||||
# Point Face Center icon
|
||||
ICON_POINT_FACE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Face -->
|
||||
<path d="M6 20 L16 10 L26 20 L16 26 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="19" r="4" fill="{MOCHA["green"]}"/>
|
||||
<circle cx="16" cy="19" r="2" fill="{MOCHA["teal"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Point Circle Center icon
|
||||
ICON_POINT_CIRCLE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Circle -->
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="{MOCHA["yellow"]}" stroke-width="2.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="16" r="4" fill="{MOCHA["green"]}"/>
|
||||
<circle cx="16" cy="16" r="2" fill="{MOCHA["teal"]}"/>
|
||||
<!-- Radius line -->
|
||||
<line x1="16" y1="16" x2="26" y2="16" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>'''
|
||||
|
||||
# Rotated Linear Pattern icon - objects along line with rotation
|
||||
ICON_ROTATED_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Direction line -->
|
||||
<line x1="4" y1="24" x2="28" y2="24" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<!-- First cube (original) -->
|
||||
<rect x="4" y="16" width="6" height="6" fill="{MOCHA["blue"]}" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
|
||||
<!-- Second cube (rotated 15deg) -->
|
||||
<g transform="translate(14,19) rotate(-15)">
|
||||
<rect x="-3" y="-3" width="6" height="6" fill="{MOCHA["mauve"]}" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Third cube (rotated 30deg) -->
|
||||
<g transform="translate(24,19) rotate(-30)">
|
||||
<rect x="-3" y="-3" width="6" height="6" fill="{MOCHA["pink"]}" stroke="{MOCHA["flamingo"]}" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Rotation arrow -->
|
||||
<path d="M24 8 A5 5 0 0 1 19 13" stroke="{MOCHA["peach"]}" stroke-width="1.5" fill="none"/>
|
||||
<path d="M18 11 L19 13 L21 12" stroke="{MOCHA["peach"]}" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Enhanced Pocket icon - pocket with plus/settings indicator
|
||||
ICON_POCKET_ENHANCED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- 3D block with pocket cutout -->
|
||||
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<path d="M6 10 L16 14 L26 10" stroke="{MOCHA["overlay1"]}" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 26" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<!-- Pocket depression -->
|
||||
<path d="M10 12 L16 10 L22 12 L22 18 L16 20 L10 18 Z" fill="{MOCHA["base"]}" stroke="{MOCHA["mauve"]}" stroke-width="1.5"/>
|
||||
<!-- Down arrow indicating cut -->
|
||||
<path d="M16 13 L16 17" stroke="{MOCHA["red"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 15 L16 18 L18 15" stroke="{MOCHA["red"]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>'''
|
||||
|
||||
# Flipped Pocket icon - pocket cutting outside the profile
|
||||
ICON_POCKET_FLIPPED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Outer material removed (dark) -->
|
||||
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="{MOCHA["base"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<path d="M6 10 L16 14 L26 10" stroke="{MOCHA["overlay0"]}" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 26" stroke="{MOCHA["overlay0"]}" stroke-width="1"/>
|
||||
<!-- Inner remaining material (raised) -->
|
||||
<path d="M10 20 L10 12 L16 10 L22 12 L22 20 L16 22 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["teal"]}" stroke-width="1.5"/>
|
||||
<path d="M10 12 L16 14 L22 12" stroke="{MOCHA["overlay1"]}" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 22" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
|
||||
<!-- Flip arrows -->
|
||||
<path d="M4 16 L8 14 L8 18 Z" fill="{MOCHA["peach"]}"/>
|
||||
<path d="M28 16 L24 14 L24 18 Z" fill="{MOCHA["peach"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Assembly Linear Pattern icon - components along a line
|
||||
ICON_ASSEMBLY_LINEAR_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Direction arrow -->
|
||||
<line x1="4" y1="16" x2="26" y2="16" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<path d="M24 13 L28 16 L24 19" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Component 1 (original) -->
|
||||
<rect x="4" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
|
||||
<circle cx="7" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
|
||||
<!-- Component 2 -->
|
||||
<rect x="13" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
|
||||
<circle cx="16" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
|
||||
<!-- Component 3 -->
|
||||
<rect x="22" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
|
||||
<circle cx="25" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
|
||||
<!-- Count indicator -->
|
||||
<text x="16" y="26" font-family="monospace" font-size="7" fill="{MOCHA["text"]}" text-anchor="middle">1-2-3</text>
|
||||
</svg>'''
|
||||
|
||||
# Assembly Polar Pattern icon - components around a center
|
||||
ICON_ASSEMBLY_POLAR_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Center axis indicator -->
|
||||
<circle cx="16" cy="16" r="2" fill="{MOCHA["peach"]}"/>
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<!-- Rotation arrow -->
|
||||
<path d="M23 8 A9 9 0 0 1 26 14" stroke="{MOCHA["green"]}" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M25 11 L26 14 L23 14" stroke="{MOCHA["green"]}" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
|
||||
<!-- Component 1 (top) -->
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
|
||||
<!-- Component 2 (right) -->
|
||||
<g transform="translate(16,16) rotate(72) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["mauve"]}" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 3 (bottom-right) -->
|
||||
<g transform="translate(16,16) rotate(144) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["pink"]}" stroke="{MOCHA["flamingo"]}" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 4 (bottom-left) -->
|
||||
<g transform="translate(16,16) rotate(216) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["peach"]}" stroke="{MOCHA["maroon"]}" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 5 (left) -->
|
||||
<g transform="translate(16,16) rotate(288) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["yellow"]}" stroke="{MOCHA["peach"]}" stroke-width="1"/>
|
||||
</g>
|
||||
</svg>'''
|
||||
|
||||
# =============================================================================
|
||||
# Spreadsheet Icons
|
||||
# =============================================================================
|
||||
|
||||
# Bold text icon
|
||||
ICON_SPREADSHEET_BOLD_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<text x="16" y="23" font-family="sans-serif" font-size="18" font-weight="bold" fill="{MOCHA["text"]}" text-anchor="middle">B</text>
|
||||
</svg>'''
|
||||
|
||||
# Italic text icon
|
||||
ICON_SPREADSHEET_ITALIC_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<text x="16" y="23" font-family="serif" font-size="18" font-style="italic" fill="{MOCHA["text"]}" text-anchor="middle">I</text>
|
||||
</svg>'''
|
||||
|
||||
# Underline text icon
|
||||
ICON_SPREADSHEET_UNDERLINE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<text x="16" y="20" font-family="sans-serif" font-size="16" fill="{MOCHA["text"]}" text-anchor="middle">U</text>
|
||||
<line x1="10" y1="24" x2="22" y2="24" stroke="{MOCHA["text"]}" stroke-width="2"/>
|
||||
</svg>'''
|
||||
|
||||
# Align left icon
|
||||
ICON_SPREADSHEET_ALIGN_LEFT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="14" x2="20" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="19" x2="24" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="24" x2="16" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Align center icon
|
||||
ICON_SPREADSHEET_ALIGN_CENTER_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="10" y1="14" x2="22" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="19" x2="24" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="24" x2="20" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Align right icon
|
||||
ICON_SPREADSHEET_ALIGN_RIGHT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="14" x2="26" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="19" x2="26" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="16" y1="24" x2="26" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
# Background color icon (paint bucket)
|
||||
ICON_SPREADSHEET_BG_COLOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Paint bucket -->
|
||||
<path d="M10 12 L16 6 L22 12 L22 20 C22 22 20 24 16 24 C12 24 10 22 10 20 Z" fill="{MOCHA["yellow"]}" stroke="{MOCHA["peach"]}" stroke-width="1.5"/>
|
||||
<!-- Handle -->
|
||||
<path d="M16 6 L16 3" stroke="{MOCHA["overlay1"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<!-- Paint drip -->
|
||||
<ellipse cx="25" cy="22" rx="2" ry="3" fill="{MOCHA["yellow"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Text color icon (A with color bar)
|
||||
ICON_SPREADSHEET_TEXT_COLOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Letter A -->
|
||||
<text x="16" y="20" font-family="sans-serif" font-size="16" font-weight="bold" fill="{MOCHA["text"]}" text-anchor="middle">A</text>
|
||||
<!-- Color bar -->
|
||||
<rect x="8" y="24" width="16" height="3" rx="1" fill="{MOCHA["red"]}"/>
|
||||
</svg>'''
|
||||
|
||||
# Quick alias icon (tag/label)
|
||||
ICON_SPREADSHEET_QUICK_ALIAS_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Tag shape -->
|
||||
<path d="M6 10 L18 10 L24 16 L18 22 L6 22 Z" fill="{MOCHA["teal"]}" stroke="{MOCHA["green"]}" stroke-width="1.5"/>
|
||||
<!-- Tag hole -->
|
||||
<circle cx="10" cy="16" r="2" fill="{MOCHA["surface0"]}"/>
|
||||
<!-- Equals sign (alias) -->
|
||||
<line x1="20" y1="12" x2="26" y2="12" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="20" y1="17" x2="26" y2="17" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>'''
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Icon Registry - Base64 encoded for FreeCAD
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_icon(name: str) -> str:
|
||||
"""Get icon file path by name.
|
||||
|
||||
Returns the path to an SVG icon file. If the file doesn't exist,
|
||||
it will be created from the embedded SVG definitions.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Map of short names to SVG content
|
||||
icons = {
|
||||
"workbench": ICON_WORKBENCH_SVG,
|
||||
"datum_creator": ICON_DATUM_CREATOR_SVG,
|
||||
"datum_manager": ICON_DATUM_MANAGER_SVG,
|
||||
"plane_offset": ICON_PLANE_OFFSET_SVG,
|
||||
"plane_midplane": ICON_PLANE_MIDPLANE_SVG,
|
||||
"plane_3pt": ICON_PLANE_3PT_SVG,
|
||||
"plane_normal": ICON_PLANE_NORMAL_SVG,
|
||||
"plane_angled": ICON_PLANE_ANGLED_SVG,
|
||||
"plane_tangent": ICON_PLANE_TANGENT_SVG,
|
||||
"axis_2pt": ICON_AXIS_2PT_SVG,
|
||||
"axis_edge": ICON_AXIS_EDGE_SVG,
|
||||
"axis_cyl": ICON_AXIS_CYL_SVG,
|
||||
"axis_intersect": ICON_AXIS_INTERSECT_SVG,
|
||||
"point_vertex": ICON_POINT_VERTEX_SVG,
|
||||
"point_xyz": ICON_POINT_XYZ_SVG,
|
||||
"point_edge": ICON_POINT_EDGE_SVG,
|
||||
"point_face": ICON_POINT_FACE_SVG,
|
||||
"point_circle": ICON_POINT_CIRCLE_SVG,
|
||||
"rotated_pattern": ICON_ROTATED_PATTERN_SVG,
|
||||
"pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
|
||||
"pocket_flipped": ICON_POCKET_FLIPPED_SVG,
|
||||
"assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
|
||||
"assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
|
||||
"spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
|
||||
"spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
|
||||
"spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
|
||||
"spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
|
||||
"spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
|
||||
"spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
|
||||
"spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
|
||||
"spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
|
||||
"spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
|
||||
}
|
||||
|
||||
if name not in icons:
|
||||
return ""
|
||||
|
||||
# Get the icons directory path (relative to this file)
|
||||
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
|
||||
icon_path = os.path.join(icons_dir, f"ztools_{name}.svg")
|
||||
|
||||
# If the icon file doesn't exist, create it
|
||||
if not os.path.exists(icon_path):
|
||||
os.makedirs(icons_dir, exist_ok=True)
|
||||
with open(icon_path, "w") as f:
|
||||
f.write(icons[name])
|
||||
|
||||
return icon_path
|
||||
|
||||
|
||||
def save_icons_to_disk(directory: str):
|
||||
"""Save all icons as SVG files to a directory."""
|
||||
import os
|
||||
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
icons = {
|
||||
"ztools_workbench": ICON_WORKBENCH_SVG,
|
||||
"ztools_datum_creator": ICON_DATUM_CREATOR_SVG,
|
||||
"ztools_datum_manager": ICON_DATUM_MANAGER_SVG,
|
||||
"ztools_plane_offset": ICON_PLANE_OFFSET_SVG,
|
||||
"ztools_plane_midplane": ICON_PLANE_MIDPLANE_SVG,
|
||||
"ztools_plane_3pt": ICON_PLANE_3PT_SVG,
|
||||
"ztools_plane_normal": ICON_PLANE_NORMAL_SVG,
|
||||
"ztools_plane_angled": ICON_PLANE_ANGLED_SVG,
|
||||
"ztools_plane_tangent": ICON_PLANE_TANGENT_SVG,
|
||||
"ztools_axis_2pt": ICON_AXIS_2PT_SVG,
|
||||
"ztools_axis_edge": ICON_AXIS_EDGE_SVG,
|
||||
"ztools_axis_cyl": ICON_AXIS_CYL_SVG,
|
||||
"ztools_axis_intersect": ICON_AXIS_INTERSECT_SVG,
|
||||
"ztools_point_vertex": ICON_POINT_VERTEX_SVG,
|
||||
"ztools_point_xyz": ICON_POINT_XYZ_SVG,
|
||||
"ztools_point_edge": ICON_POINT_EDGE_SVG,
|
||||
"ztools_point_face": ICON_POINT_FACE_SVG,
|
||||
"ztools_point_circle": ICON_POINT_CIRCLE_SVG,
|
||||
"ztools_rotated_pattern": ICON_ROTATED_PATTERN_SVG,
|
||||
"ztools_pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
|
||||
"ztools_pocket_flipped": ICON_POCKET_FLIPPED_SVG,
|
||||
"ztools_assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
|
||||
"ztools_assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
|
||||
"ztools_spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
|
||||
"ztools_spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
|
||||
"ztools_spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
|
||||
"ztools_spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
|
||||
"ztools_spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
|
||||
"ztools_spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
|
||||
"ztools_spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
|
||||
"ztools_spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
|
||||
"ztools_spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
|
||||
}
|
||||
|
||||
for name, svg in icons.items():
|
||||
filepath = os.path.join(directory, f"{name}.svg")
|
||||
with open(filepath, "w") as f:
|
||||
f.write(svg)
|
||||
print(f"Saved: {filepath}")
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Direction arrow -->
|
||||
<line x1="4" y1="16" x2="26" y2="16" stroke="#7f849c" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
<path d="M24 13 L28 16 L24 19" stroke="#7f849c" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Component 1 (original) -->
|
||||
<rect x="4" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
|
||||
<circle cx="7" cy="13" r="1.5" fill="#94e2d5"/>
|
||||
<!-- Component 2 -->
|
||||
<rect x="13" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
|
||||
<circle cx="16" cy="13" r="1.5" fill="#94e2d5"/>
|
||||
<!-- Component 3 -->
|
||||
<rect x="22" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
|
||||
<circle cx="25" cy="13" r="1.5" fill="#94e2d5"/>
|
||||
<!-- Count indicator -->
|
||||
<text x="16" y="26" font-family="monospace" font-size="7" fill="#cdd6f4" text-anchor="middle">1-2-3</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Center axis indicator -->
|
||||
<circle cx="16" cy="16" r="2" fill="#fab387"/>
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="#7f849c" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<!-- Rotation arrow -->
|
||||
<path d="M23 8 A9 9 0 0 1 26 14" stroke="#a6e3a1" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M25 11 L26 14 L23 14" stroke="#a6e3a1" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
|
||||
<!-- Component 1 (top) -->
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
|
||||
<!-- Component 2 (right) -->
|
||||
<g transform="translate(16,16) rotate(72) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="#cba6f7" stroke="#b4befe" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 3 (bottom-right) -->
|
||||
<g transform="translate(16,16) rotate(144) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="#f5c2e7" stroke="#f2cdcd" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 4 (bottom-left) -->
|
||||
<g transform="translate(16,16) rotate(216) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="#fab387" stroke="#eba0ac" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Component 5 (left) -->
|
||||
<g transform="translate(16,16) rotate(288) translate(-16,-16)">
|
||||
<rect x="13" y="4" width="6" height="5" rx="1" fill="#f9e2af" stroke="#fab387" stroke-width="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Axis line -->
|
||||
<line x1="6" y1="26" x2="26" y2="6" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- End points -->
|
||||
<circle cx="6" cy="26" r="3" fill="#fab387"/>
|
||||
<circle cx="26" cy="6" r="3" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Cylinder -->
|
||||
<ellipse cx="16" cy="8" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M8 8 L8 24" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M24 8 L24 24" stroke="#7f849c" stroke-width="1"/>
|
||||
<ellipse cx="16" cy="24" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Center axis -->
|
||||
<line x1="16" y1="4" x2="16" y2="28" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="2" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Box edge representation -->
|
||||
<path d="M8 24 L8 12 L20 8 L20 20 Z" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Selected edge highlighted -->
|
||||
<line x1="8" y1="24" x2="8" y2="12" stroke="#f9e2af" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Resulting axis -->
|
||||
<line x1="8" y1="28" x2="8" y2="4" stroke="#f38ba8" stroke-width="2" stroke-dasharray="4,2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- First plane -->
|
||||
<path d="M4 12 L16 6 L28 12 L16 18 Z" fill="#89b4fa" fill-opacity="0.5" stroke="#74c7ec" stroke-width="1"/>
|
||||
<!-- Second plane -->
|
||||
<path d="M4 20 L16 14 L28 20 L16 26 Z" fill="#cba6f7" fill-opacity="0.5" stroke="#b4befe" stroke-width="1"/>
|
||||
<!-- Intersection axis -->
|
||||
<line x1="4" y1="16" x2="28" y2="16" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Plane representation -->
|
||||
<path d="M6 20 L16 8 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Plus sign -->
|
||||
<circle cx="24" cy="8" r="6" fill="#a6e3a1"/>
|
||||
<path d="M24 5 L24 11 M21 8 L27 8" stroke="#1e1e2e" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Stacked planes -->
|
||||
<path d="M4 18 L12 12 L20 18 L12 22 Z" fill="#89b4fa" fill-opacity="0.5" stroke="#74c7ec" stroke-width="1"/>
|
||||
<path d="M4 14 L12 8 L20 14 L12 18 Z" fill="#cba6f7" fill-opacity="0.5" stroke="#b4befe" stroke-width="1"/>
|
||||
<!-- List lines -->
|
||||
<line x1="22" y1="10" x2="28" y2="10" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="22" y1="16" x2="28" y2="16" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="22" y1="22" x2="28" y2="22" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 700 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Plane -->
|
||||
<path d="M4 20 L16 8 L28 18 L14 28 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Three points -->
|
||||
<circle cx="8" cy="20" r="3" fill="#fab387"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#fab387"/>
|
||||
<circle cx="24" cy="18" r="3" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Angled plane -->
|
||||
<path d="M8 10 L20 6 L24 18 L12 22 Z" fill="#cba6f7" fill-opacity="0.7" stroke="#b4befe" stroke-width="1.5"/>
|
||||
<!-- Angle arc -->
|
||||
<path d="M14 20 Q18 18 18 14" stroke="#fab387" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Top plane -->
|
||||
<path d="M4 10 L16 4 L28 10 L16 14 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Middle plane (result) -->
|
||||
<path d="M4 16 L16 10 L28 16 L16 20 Z" fill="#a6e3a1" fill-opacity="0.7" stroke="#94e2d5" stroke-width="1.5"/>
|
||||
<!-- Bottom plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 26 Q16 6 26 16" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Plane perpendicular -->
|
||||
<path d="M12 8 L20 12 L20 24 L12 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Point on curve -->
|
||||
<circle cx="16" cy="16" r="2.5" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 14 L28 22 L16 28 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Offset plane -->
|
||||
<path d="M4 14 L16 6 L28 14 L16 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Offset arrow -->
|
||||
<path d="M16 24 L16 18" stroke="#fab387" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 20 L16 17 L18 20" stroke="#fab387" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Cylinder outline -->
|
||||
<ellipse cx="12" cy="16" rx="6" ry="10" fill="none" stroke="#f9e2af" stroke-width="2"/>
|
||||
<!-- Tangent plane -->
|
||||
<path d="M18 6 L28 10 L28 26 L18 22 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Tangent point -->
|
||||
<circle cx="18" cy="16" r="2.5" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- 3D block with pocket cutout -->
|
||||
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M6 10 L16 14 L26 10" stroke="#7f849c" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 26" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Pocket depression -->
|
||||
<path d="M10 12 L16 10 L22 12 L22 18 L16 20 L10 18 Z" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
|
||||
<!-- Down arrow indicating cut -->
|
||||
<path d="M16 13 L16 17" stroke="#f38ba8" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 15 L16 18 L18 15" stroke="#f38ba8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Outer material removed (dark) -->
|
||||
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="#1e1e2e" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M6 10 L16 14 L26 10" stroke="#6c7086" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 26" stroke="#6c7086" stroke-width="1"/>
|
||||
<!-- Inner remaining material (raised) -->
|
||||
<path d="M10 20 L10 12 L16 10 L22 12 L22 20 L16 22 Z" fill="#585b70" stroke="#94e2d5" stroke-width="1.5"/>
|
||||
<path d="M10 12 L16 14 L22 12" stroke="#7f849c" stroke-width="1" fill="none"/>
|
||||
<path d="M16 14 L16 22" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Flip arrows -->
|
||||
<path d="M4 16 L8 14 L8 18 Z" fill="#fab387"/>
|
||||
<path d="M28 16 L24 14 L24 18 Z" fill="#fab387"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 842 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Circle -->
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="#f9e2af" stroke-width="2.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="16" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="16" cy="16" r="2" fill="#94e2d5"/>
|
||||
<!-- Radius line -->
|
||||
<line x1="16" y1="16" x2="26" y2="16" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 24 Q16 4 26 20" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Point on edge -->
|
||||
<circle cx="14" cy="12" r="4" fill="#a6e3a1"/>
|
||||
<!-- Parameter indicator -->
|
||||
<text x="20" y="10" font-family="monospace" font-size="8" fill="#cdd6f4">t</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Face -->
|
||||
<path d="M6 20 L16 10 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="19" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="16" cy="19" r="2" fill="#94e2d5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Wireframe box corner -->
|
||||
<path d="M10 20 L10 10 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L20 16 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L4 24" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<!-- Vertex point -->
|
||||
<circle cx="10" cy="20" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="10" cy="20" r="2" fill="#94e2d5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Coordinate axes -->
|
||||
<line x1="6" y1="24" x2="26" y2="24" stroke="#f38ba8" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="6" y2="6" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="16" y2="28" stroke="#89b4fa" stroke-width="1.5"/>
|
||||
<!-- Point -->
|
||||
<circle cx="18" cy="12" r="4" fill="#fab387"/>
|
||||
<!-- Projection lines -->
|
||||
<line x1="18" y1="12" x2="18" y2="24" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
<line x1="18" y1="12" x2="6" y2="12" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 681 B |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Direction line -->
|
||||
<line x1="4" y1="24" x2="28" y2="24" stroke="#7f849c" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<!-- First cube (original) -->
|
||||
<rect x="4" y="16" width="6" height="6" fill="#89b4fa" stroke="#74c7ec" stroke-width="1"/>
|
||||
<!-- Second cube (rotated 15deg) -->
|
||||
<g transform="translate(14,19) rotate(-15)">
|
||||
<rect x="-3" y="-3" width="6" height="6" fill="#cba6f7" stroke="#b4befe" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Third cube (rotated 30deg) -->
|
||||
<g transform="translate(24,19) rotate(-30)">
|
||||
<rect x="-3" y="-3" width="6" height="6" fill="#f5c2e7" stroke="#f2cdcd" stroke-width="1"/>
|
||||
</g>
|
||||
<!-- Rotation arrow -->
|
||||
<path d="M24 8 A5 5 0 0 1 19 13" stroke="#fab387" stroke-width="1.5" fill="none"/>
|
||||
<path d="M18 11 L19 13 L21 12" stroke="#fab387" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<line x1="6" y1="9" x2="26" y2="9" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="10" y1="14" x2="22" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="19" x2="24" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="24" x2="20" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<line x1="6" y1="9" x2="26" y2="9" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="14" x2="20" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="19" x2="24" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="24" x2="16" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |