Compare commits

..

40 Commits

Author SHA1 Message Date
forbes
99a08e4e99 feat(sdk): remove fallbacks, add deprecation warnings, bump v1.0.0 (#357)
All checks were successful
Build and Test / build (pull_request) Successful in 30m5s
- Bump SDK_VERSION to 1.0.0 in version.py and package.xml
- Remove <pure_python> tag from package.xml (kcsdk is C++)
- Remove all FreeCADGui.* fallback paths in context.py, dock.py,
  toolbar.py, menu.py; require kcsdk module
- Remove query fallbacks in origin.py (keep register/unregister
  which still need FreeCADGui.addOrigin/removeOrigin)
- Add deprecation warnings to 11 superseded FreeCADGui methods
  in ApplicationPy.cpp (not addOrigin/removeOrigin)
- All 39 Tier 1 tests pass
2026-03-01 14:32:08 -06:00
forbes
ab8519c272 feat(sdk): add status bar widget wrapper and origin query bindings (#356)
All checks were successful
Build and Test / build (pull_request) Successful in 29m13s
register_status_widget(): pure Python wrapper that adds a live widget
to the main window status bar with context menu discoverability.

Origin query bindings (kcsdk.list_origins, active_origin, get_origin,
set_active_origin): thin C++ forwarding to OriginManager with Python
wrappers using kcsdk-first routing.

IOriginProvider and IStatusBarProvider C++ interfaces dropped — existing
FileOrigin stack is already complete, and status bar widgets don't need
C++ lifecycle management.
2026-03-01 14:13:31 -06:00
forbes
2bf083609d feat(sdk): add IMenuProvider interface and register_command wrapper (#355)
All checks were successful
Build and Test / build (pull_request) Successful in 28m41s
IMenuProvider: declarative menu placement with optional context awareness.
C++ interface with pybind11 bindings + GIL-safe holder. SDKMenuManipulator
(shared WorkbenchManipulator) injects menu items on workbench switch,
filtered by editing context when context_ids() is non-empty.

register_command(): thin Python wrapper around FreeCADGui.addCommand()
that standardizes the calling convention within the SDK contract.

Python wrappers (kindred_sdk.register_menu, kindred_sdk.register_command)
use kcsdk-first routing with FreeCADGui fallback.
2026-03-01 13:13:44 -06:00
forbes
76e448d0d7 feat(sdk): add IToolbarProvider interface to kcsdk (#354)
All checks were successful
Build and Test / build (pull_request) Successful in 30m28s
2026-03-01 09:32:25 -06:00
forbes
51f7635ceb chore: update silo and solver submodules 2026-03-01 09:31:59 -06:00
forbes
ecdc9c2067 chore: set gears submodule default branch to main 2026-03-01 09:31:46 -06:00
forbes
04065e83f3 feat: add gears workbench as default addon
All checks were successful
Build and Test / build (pull_request) Successful in 29m39s
Add kindred/gears as a submodule under mods/gears. The gears workbench
provides 13 parametric gear commands (involute, cycloid, bevel, worm,
crown, timing, lantern, hypocycloid gears + connectors).

Integration:
- Kindred package.xml with load_priority 40 (sdk -> solver -> gears -> silo)
- Init.py adds repo root to sys.path for pygears + namespace package
- InitGui.py imports upstream GearWorkbench and injects gear commands
  into partdesign.body and partdesign.feature editing contexts
- context.py kcsdk routing already complete from #351
2026-02-28 15:17:06 -06:00
forbes
5a0be2804d docs(sdk): add KCSDK API reference and addon developer guide
All checks were successful
Build and Test / build (pull_request) Successful in 29m17s
- docs/src/reference/kcsdk-python.md: full kcsdk Python API reference
- docs/src/development/writing-an-addon.md: step-by-step addon guide
- docs/INTEGRATION_PLAN.md: add Phase 7 KCSDK section
- docs/ARCHITECTURE.md: add src/Gui/SDK/ to source layout
- docs/src/SUMMARY.md: add new pages to mdBook navigation
2026-02-28 14:53:51 -06:00
forbes
1b38d7b24b fix(editing-context): resolve initial context on construction
EditingContextResolver constructor did not call refresh(), leaving
d->current as a default empty EditingContext. When BreadcrumbToolBar
queried currentContext() on creation, it received an empty context
with no breadcrumb segments, causing the navbar to appear blank.

Add refresh() at end of constructor so the initial state is resolved
before any View3DInventor queries it.
2026-02-28 14:53:44 -06:00
forbes
18532e3bd7 feat(sdk): add panel provider and theme engine to kcsdk (#352, #353)
- IPanelProvider: abstract interface for dock panels with PySide widget bridging
- PyIPanelProvider/PyProviderHolder: pybind11 trampoline + GIL-safe holder
- WidgetBridge: PySide QWidget → C++ QWidget* conversion via Shiboken
- SDKRegistry: panel registration, creation, and lifecycle management
- ThemeEngine: C++ singleton with minimal YAML parser, palette cache,
  getColor/allTokens/formatQss matching Python Palette API
- kcsdk bindings: DockArea, PanelPersistence enums, panel functions,
  theme_color, theme_tokens, format_qss, load_palette
- dock.py: kcsdk delegation with FreeCADGui fallback
- theme.py: kcsdk delegation with Python YAML fallback
2026-02-28 14:53:38 -06:00
forbes
f44aba7388 feat(sdk): migrate editing context API to kcsdk (#351)
All checks were successful
Build and Test / build (pull_request) Successful in 29m14s
Add context/overlay registration, injection, query, and refresh to the
KCSDK C++ library and kcsdk pybind11 module.

New files:
- src/Gui/SDK/Types.h — ContextDef, OverlayDef, ContextSnapshot structs
  (plain C++, no Qt in public API)

Modified:
- src/Gui/SDK/SDKRegistry.h/.cpp — register_context/overlay, unregister,
  inject_commands, current_context, refresh (delegates to
  EditingContextResolver with std↔Qt conversion)
- src/Gui/SDK/CMakeLists.txt — add Types.h, link FreeCADGui
- src/Gui/SDK/bindings/kcsdk_py.cpp — bind all context functions with
  GIL-safe match callable wrapping and dict-based snapshot return
- mods/sdk/kindred_sdk/context.py — try kcsdk first, fall back to
  FreeCADGui for backwards compatibility
2026-02-27 14:05:07 -06:00
forbes
9b28feb8ca feat(sdk): scaffold KCSDK library + kcsdk pybind11 module (#350)
All checks were successful
Build and Test / build (pull_request) Successful in 29m2s
Add the KCSDK C++ shared library and kcsdk pybind11 module, establishing
the build infrastructure for the C++-backed addon SDK.

New files:
- src/Gui/SDK/KCSDKGlobal.h — DLL export macros
- src/Gui/SDK/SDKRegistry.h/.cpp — empty singleton with API_VERSION_MAJOR=1
- src/Gui/SDK/CMakeLists.txt — builds KCSDK shared library
- src/Gui/SDK/bindings/kcsdk_py.cpp — pybind11 module exposing version + available()
- src/Gui/SDK/bindings/CMakeLists.txt — builds kcsdk pybind11 module

Modified:
- src/Gui/CMakeLists.txt — add_subdirectory(SDK)

Verified: import kcsdk; kcsdk.API_VERSION_MAJOR == 1; kcsdk.available() == []
2026-02-27 13:36:48 -06:00
fcb5c2b00f Merge pull request 'chore: archive QuickNav and ZTools into reference folder (#345)' (#349) from chore/archive-addons-reference into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #349
2026-02-27 18:59:37 +00:00
forbes
c8b0706a1d chore: archive QuickNav and ZTools into reference folder (#345)
All checks were successful
Build and Test / build (pull_request) Successful in 32m17s
Copy QuickNav and ZTools source trees into reference/ for developer
reference during the UI/UX rework. These are plain directories (not
submodules) and are not included in the build.

- reference/quicknav/ — QuickNav addon source
- reference/ztools/ — ZTools addon source

Part of the UI/UX rework preparation. See #346.
2026-02-27 12:54:40 -06:00
a623f280da Merge pull request 'chore: remove ZTools addon from build and loader (#344)' (#348) from chore/remove-ztools into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #348
2026-02-27 18:51:53 +00:00
forbes
485f69f257 chore: remove ZTools addon from build and loader (#344)
All checks were successful
Build and Test / build (pull_request) Successful in 29m28s
- Remove ZTools install block from src/Mod/Create/CMakeLists.txt
- Remove mods/ztools submodule entry from .gitmodules
- Remove 'ztools' from legacy fallback order in addon_loader.py
- Remove ztools imports and test classes from test_kindred_pure.py
  (TestTypeMatches, TestMatchScore, TestSelectionItemProperties,
   TestColumnToIndex, TestDatumModes)
- Remove 'ztools Workbench' from issue template component lists
- Remove mods/ztools submodule from git tracking

ZTools will be archived to a reference folder in a separate step (#345).
This is part of the UI/UX rework epic (#346).
2026-02-27 12:46:32 -06:00
a32f405990 Merge pull request 'chore: remove QuickNav addon from build and loader (#343)' (#347) from chore/remove-quicknav into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #347
2026-02-27 18:29:18 +00:00
forbes
d47c94af8d chore: remove QuickNav addon from build and loader (#343)
All checks were successful
Build and Test / build (pull_request) Successful in 30m57s
- Remove QuickNav install block from src/Mod/Create/CMakeLists.txt
- Remove mods/quicknav submodule entry from .gitmodules
- Remove quicknav imports and TestWorkbenchMap tests from test_kindred_pure.py
- Remove mods/quicknav submodule from git tracking

QuickNav will be archived to a reference folder in a separate step (#345).
This is part of the UI/UX rework epic (#346).
2026-02-27 12:27:33 -06:00
b1d75fa237 Merge pull request 'fix(assembly): enforce drag quaternion continuity + short-arc validation (#338)' (#342) from fix/drag-quat-continuity into main
All checks were successful
Build and Test / build (push) Successful in 29m40s
Reviewed-on: #342
2026-02-27 16:41:57 +00:00
forbes
d174ef7a8d fix(assembly): use short-arc angle in validateNewPlacements (#338)
All checks were successful
Build and Test / build (pull_request) Successful in 30m5s
Rotation::evaluateVector() computes angle = 2*acos(w) which gives
values in [0, 2*pi].  When the relative quaternion has w < 0 (opposite
hemisphere), the angle exceeds pi even though q and -q represent the
same rotation.  This caused the validator to report ~350 degree 'flips'
and reject valid solver output.

Fix: map the angle to [0, pi] before comparing against the 91-degree
threshold.  This is the short-arc equivalent — the minimum rotation
angle between two orientations regardless of quaternion sign convention.
2026-02-27 10:38:37 -06:00
bc157ddb72 Merge pull request 'fix(assembly): update solver submodule — enforce drag quaternion continuity (#338)' (#341) from fix/drag-quat-continuity into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #341
2026-02-27 15:41:19 +00:00
forbes
07a51aefb1 fix(assembly): update solver submodule — enforce drag quaternion continuity (#338)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Points solver to fix/drag-quat-continuity (solver#40) which fixes
the planar constraint drift during interactive drag by enforcing
quaternion continuity on dragged parts and detecting branch jumps
beyond simple hemisphere negation.
2026-02-27 09:40:51 -06:00
9f644294c5 Merge pull request 'chore: update solver submodule — add planar drag console test (#338)' (#340) from test/planar-drag-console-test into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #340
2026-02-27 15:32:03 +00:00
forbes
983e211f12 chore: update solver submodule — add planar drag console test (#338)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
2026-02-27 09:31:31 -06:00
forbes
1788b5778a Add CLAUDE.md to .gitignore
All checks were successful
Build and Test / build (push) Successful in 29m54s
2026-02-27 08:19:52 -06:00
418e947cbd Merge pull request 'fix(solver): world-anchored reference normal prevents planar axial drift' (#336) from fix/planar-halfspace-drag-flip into main
All checks were successful
Build and Test / build (push) Successful in 29m17s
Sync Silo Server Docs / sync (push) Successful in 34s
Reviewed-on: #336
2026-02-26 17:10:39 +00:00
4c9ff957e3 Merge branch 'main' into fix/planar-halfspace-drag-flip
All checks were successful
Build and Test / build (pull_request) Successful in 28m48s
2026-02-26 17:10:27 +00:00
9aaf244179 fix(solver): update solver submodule — world-anchored planar reference normal
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
Updates solver to include the fix for PlanarConstraint axial drift
when combined with CylindricalConstraint. The distance residual now
uses a world-frame reference normal (Const nodes) instead of the
body-attached normal that rotates with the body.
2026-02-26 11:07:06 -06:00
69ccdbf742 Merge pull request 'fix(assembly): use instance suffixes for duplicate part labels' (#335) from fix/assembly-part-number-suffix into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #335
2026-02-26 16:34:34 +00:00
4acd09171e Merge pull request 'fix(theme): prevent panel element headings from being clipped' (#334) from fix/theme-panel-heading-clipping into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #334
2026-02-26 16:34:17 +00:00
7f909f166f Merge pull request 'fix(ci): add target_commitish to release payload to fix HTTP 500' (#333) from fix/ci-release-target-commitish into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #333
2026-02-26 16:34:04 +00:00
f69e0efec7 Merge pull request 'docs: add CLAUDE.md for developer context' (#332) from docs/claude-md into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #332
2026-02-26 16:33:36 +00:00
forbes
559a240799 fix(assembly): update solver submodule — fix planar half-space drag flip
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Updates mods/solver to include fix for the planar half-space correction
that caused 'flipped orientation' rejections when dragging a body
connected by a Cylindrical joint + distance=0 Planar constraint.

The solver's PlanarConstraint half-space tracker was reflecting the body
through the plane when the face normal dot product crossed zero during
legitimate rotation about the cylindrical axis. Now returns a tracking-
only HalfSpace (no correction) for on-plane constraints, matching the
pattern used by Cylindrical/Revolute/Concentric trackers.

See: kindred/solver#38
2026-02-26 09:04:14 -06:00
7c85b2ad93 fix(assembly): use instance suffixes for duplicate part labels
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
When parts with structured part numbers (e.g., P03-0001) are inserted
into an assembly multiple times, UniqueNameManager::decomposeName()
treats the trailing digits as an auto-generated suffix and increments
them (P03-0002, P03-0003), corrupting the part number.

Add a makeInstanceLabel() helper in AssemblyLink.cpp that appends -N
instance suffixes instead (P03-0001-1, P03-0001-2). All instances get
a suffix starting at -1 so the original part number is never modified.

Applied at all three Label.setValue() sites in
synchronizeComponents() (AssemblyLink, link group, and regular link
creation paths).

Also add a UniqueNameManager test documenting the trailing-digit
decomposition behavior for structured part numbers.

Closes #327
2026-02-26 09:00:13 -06:00
311d911cfa fix(theme): prevent panel element headings from being clipped
All checks were successful
Build and Test / build (pull_request) Successful in 29m23s
Three QSS issues caused headings to render with only the top ~60%
visible:

- QGroupBox: margin-top 12px was insufficient for the title rendered
  in subcontrol-origin: margin. Increased to 16px and added 2px
  vertical padding to the title.
- QDockWidget::title: min-height 18px conflicted with padding 8px 6px,
  constraining the content area. Removed min-height to let Qt auto-size
  from padding + font metrics.
- QSint--ActionGroup QToolButton: min-height 18px forced a height that
  was then clipped by the C++ setFixedHeight(headerSize) calculation.
  Set min-height to 0px so the C++ layout controls sizing.

Closes #325
2026-02-26 08:48:01 -06:00
4ef8e64a7c fix(ci): add target_commitish to release payload to fix HTTP 500
All checks were successful
Build and Test / build (pull_request) Successful in 29m30s
The publish-release job was missing the target_commitish field in the
Gitea release creation API payload. Without it, Gitea cannot resolve
the tag to a git object and returns HTTP 500 with 'object does not
exist'.

Add COMMIT_SHA (from github.sha) to the job env and pass it as
target_commitish in the JSON payload.

Closes #326
2026-02-26 08:45:58 -06:00
d94e8c8294 docs: add CLAUDE.md for developer context
All checks were successful
Build and Test / build (pull_request) Successful in 29m9s
2026-02-26 08:40:34 -06:00
3550d916bd Merge pull request 'docs(solver): update drag protocol docs to reflect implemented caching' (#330) from docs/solver-drag-cache into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Successful in 29m53s
Reviewed-on: #330
2026-02-26 14:25:17 +00:00
6e15b25134 docs(solver): update drag protocol docs to reflect implemented caching
All checks were successful
Build and Test / build (pull_request) Successful in 30m27s
The interactive drag section described the original naive implementation
(re-solve from scratch each step) and called the caching layer a
'planned future optimization'. In reality _DragCache is fully
implemented: pre_drag() builds the system, Jacobian, and compiled
evaluator once, and drag_step() reuses them.

Update code snippets, add _DragCache field table, and document the
single_equation_pass exclusion from the drag path.
2026-02-25 13:23:41 -06:00
82f2422285 Merge pull request 'fix(solver): skip single_equation_pass during drag to prevent stale constraints' (#329) from fix/planar-drag-prepass into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 40s
Reviewed-on: #329
2026-02-25 19:03:16 +00:00
126 changed files with 13119 additions and 483 deletions

View File

@@ -47,7 +47,6 @@ body:
description: Which part of Kindred Create is affected?
options:
- General / Core
- ztools Workbench
- Silo (Parts Database)
- Theme / QSS
- Assembly

View File

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

View File

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

View File

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

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

11
.gitmodules vendored
View File

@@ -10,10 +10,6 @@
[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
@@ -22,6 +18,7 @@
path = mods/solver
url = https://git.kindred-systems.com/kindred/solver.git
branch = main
[submodule "mods/quicknav"]
path = mods/quicknav
url = https://git.kindred-systems.com/kindred/quicknav.git
[submodule "mods/gears"]
path = mods/gears
url = https://git.kindred-systems.com/kindred/gears.git
branch = main

285
CLAUDE.md Normal file
View 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

View File

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

View File

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

View File

@@ -30,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
@@ -76,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)

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

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

1
mods/gears Submodule

Submodule mods/gears added at 1e26c393f3

Submodule mods/quicknav deleted from 658a427132

View File

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

View 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"
)

View File

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

View File

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

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

View File

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

View 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"
)

View File

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

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

View File

@@ -1 +1 @@
SDK_VERSION = "0.1.0"
SDK_VERSION = "1.0.0"

View File

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

Submodule mods/ztools deleted from 08e439b9ca

1
reference/quicknav/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -0,0 +1,3 @@
import FreeCAD
FreeCAD.Console.PrintLog("quicknav addon loaded\n")

View 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

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

View File

@@ -0,0 +1 @@
"""QuickNav -- keyboard-driven command navigation for FreeCAD and Kindred Create."""

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

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

View 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

View 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};"

View 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

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

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

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

View 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*

View 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")

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

View 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

View 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

View File

@@ -0,0 +1,2 @@
# ztools - Extended PartDesign for FreeCAD
__version__ = "0.1.0"

View 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",
]

View File

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

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

View 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

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

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

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

View 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",
]

File diff suppressed because it is too large Load Diff

View 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",
]

View 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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="12" y1="14" x2="26" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="19" x2="26" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="24" x2="26" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -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"/>
<!-- 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="#f9e2af" stroke="#fab387" stroke-width="1.5"/>
<!-- Handle -->
<path d="M16 6 L16 3" stroke="#7f849c" stroke-width="2" stroke-linecap="round"/>
<!-- Paint drip -->
<ellipse cx="25" cy="22" rx="2" ry="3" fill="#f9e2af"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1,4 @@
<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"/>
<text x="16" y="23" font-family="sans-serif" font-size="18" font-weight="bold" fill="#cdd6f4" text-anchor="middle">B</text>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1,4 @@
<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"/>
<text x="16" y="23" font-family="serif" font-size="18" font-style="italic" fill="#cdd6f4" text-anchor="middle">I</text>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -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"/>
<!-- Tag shape -->
<path d="M6 10 L18 10 L24 16 L18 22 L6 22 Z" fill="#94e2d5" stroke="#a6e3a1" stroke-width="1.5"/>
<!-- Tag hole -->
<circle cx="10" cy="16" r="2" fill="#313244"/>
<!-- Equals sign (alias) -->
<line x1="20" y1="12" x2="26" y2="12" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="17" x2="26" y2="17" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -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"/>
<!-- Letter A -->
<text x="16" y="20" font-family="sans-serif" font-size="16" font-weight="bold" fill="#cdd6f4" text-anchor="middle">A</text>
<!-- Color bar -->
<rect x="8" y="24" width="16" height="3" rx="1" fill="#f38ba8"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1,5 @@
<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"/>
<text x="16" y="20" font-family="sans-serif" font-size="16" fill="#cdd6f4" text-anchor="middle">U</text>
<line x1="10" y1="24" x2="22" y2="24" stroke="#cdd6f4" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,15 @@
<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"/>
<!-- Paint palette -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Color dots on palette -->
<circle cx="9" cy="16" r="2" fill="#f38ba8"/>
<circle cx="14" cy="13" r="2" fill="#f9e2af"/>
<circle cx="19" cy="16" r="2" fill="#a6e3a1"/>
<circle cx="14" cy="21" r="2" fill="#89b4fa"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- Check mark -->
<circle cx="24" cy="8" r="5" fill="#a6e3a1"/>
<path d="M21.5 8 L23 9.5 L26.5 6" stroke="#11111b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 767 B

View File

@@ -0,0 +1,15 @@
<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"/>
<!-- Paint palette -->
<ellipse cx="14" cy="16" rx="10" ry="8" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Color dots -->
<circle cx="9" cy="14" r="2" fill="#f38ba8"/>
<circle cx="14" cy="11" r="2" fill="#f9e2af"/>
<circle cx="19" cy="14" r="2" fill="#a6e3a1"/>
<circle cx="14" cy="19" r="2" fill="#89b4fa"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="18" rx="2" ry="1.5" fill="#313244"/>
<!-- Download arrow -->
<rect x="21" y="20" width="8" height="8" rx="2" fill="#fab387"/>
<path d="M25 22 L25 26 M23 24.5 L25 26.5 L27 24.5" stroke="#11111b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 796 B

View File

@@ -0,0 +1,15 @@
<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"/>
<!-- Paint palette (dimmed) -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#6c7086" stroke-width="1.5"/>
<!-- Color dots (dimmed) -->
<circle cx="9" cy="16" r="2" fill="#7f849c"/>
<circle cx="14" cy="13" r="2" fill="#7f849c"/>
<circle cx="19" cy="16" r="2" fill="#7f849c"/>
<circle cx="14" cy="21" r="2" fill="#7f849c"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- X mark -->
<circle cx="24" cy="8" r="5" fill="#f38ba8"/>
<path d="M22 6 L26 10 M26 6 L22 10" stroke="#11111b" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

View File

@@ -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"/>
<!-- Paint palette -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Color dots -->
<circle cx="9" cy="16" r="2" fill="#f38ba8"/>
<circle cx="14" cy="13" r="2" fill="#f9e2af"/>
<circle cx="19" cy="16" r="2" fill="#a6e3a1"/>
<circle cx="14" cy="21" r="2" fill="#89b4fa"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- Toggle arrows -->
<path d="M22 5 A4 4 0 0 1 26 9" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<path d="M25 5 L22 5 L22 8" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M28 11 A4 4 0 0 1 24 7" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<path d="M25 11 L28 11 L28 8" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,5 @@
<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"/>
<path d="M8 10 L24 10 L10 22 L24 22" stroke="#cba6f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="24" cy="10" r="2" fill="#94e2d5"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -2110,6 +2110,7 @@ PyObject* ApplicationPy::sRemoveOrigin(PyObject* /*self*/, PyObject* args)
PyObject* ApplicationPy::sGetOrigin(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.getOrigin() is deprecated. Use kindred_sdk.get_origin() instead.\n");
const char* originId = nullptr;
if (!PyArg_ParseTuple(args, "s", &originId)) {
return nullptr;
@@ -2142,6 +2143,7 @@ PyObject* ApplicationPy::sGetOrigin(PyObject* /*self*/, PyObject* args)
PyObject* ApplicationPy::sListOrigins(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.listOrigins() is deprecated. Use kindred_sdk.list_origins() instead.\n");
if (!PyArg_ParseTuple(args, "")) {
return nullptr;
}
@@ -2159,6 +2161,7 @@ PyObject* ApplicationPy::sListOrigins(PyObject* /*self*/, PyObject* args)
PyObject* ApplicationPy::sActiveOrigin(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.activeOrigin() is deprecated. Use kindred_sdk.active_origin() instead.\n");
if (!PyArg_ParseTuple(args, "")) {
return nullptr;
}
@@ -2190,6 +2193,7 @@ PyObject* ApplicationPy::sActiveOrigin(PyObject* /*self*/, PyObject* args)
PyObject* ApplicationPy::sSetActiveOrigin(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.setActiveOrigin() is deprecated. Use kindred_sdk.set_active_origin() instead.\n");
const char* originId = nullptr;
if (!PyArg_ParseTuple(args, "s", &originId)) {
return nullptr;
@@ -2234,6 +2238,7 @@ static PyObject* qStringListToPyList(const QStringList& list)
PyObject* ApplicationPy::sRegisterEditingContext(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.registerEditingContext() is deprecated. Use kindred_sdk.register_context() instead.\n");
const char* id = nullptr;
const char* label = nullptr;
const char* color = nullptr;
@@ -2282,6 +2287,7 @@ PyObject* ApplicationPy::sRegisterEditingContext(PyObject* /*self*/, PyObject* a
PyObject* ApplicationPy::sUnregisterEditingContext(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.unregisterEditingContext() is deprecated. Use kindred_sdk.unregister_context() instead.\n");
const char* id = nullptr;
if (!PyArg_ParseTuple(args, "s", &id)) {
return nullptr;
@@ -2292,6 +2298,7 @@ PyObject* ApplicationPy::sUnregisterEditingContext(PyObject* /*self*/, PyObject*
PyObject* ApplicationPy::sRegisterEditingOverlay(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.registerEditingOverlay() is deprecated. Use kindred_sdk.register_overlay() instead.\n");
const char* id = nullptr;
PyObject* toolbarsList = nullptr;
PyObject* matchCallable = nullptr;
@@ -2333,6 +2340,7 @@ PyObject* ApplicationPy::sRegisterEditingOverlay(PyObject* /*self*/, PyObject* a
PyObject* ApplicationPy::sUnregisterEditingOverlay(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.unregisterEditingOverlay() is deprecated. Use kindred_sdk.unregister_overlay() instead.\n");
const char* id = nullptr;
if (!PyArg_ParseTuple(args, "s", &id)) {
return nullptr;
@@ -2343,6 +2351,7 @@ PyObject* ApplicationPy::sUnregisterEditingOverlay(PyObject* /*self*/, PyObject*
PyObject* ApplicationPy::sInjectEditingCommands(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.injectEditingCommands() is deprecated. Use kindred_sdk.inject_commands() instead.\n");
const char* contextId = nullptr;
const char* toolbarName = nullptr;
PyObject* commandsList = nullptr;
@@ -2359,6 +2368,7 @@ PyObject* ApplicationPy::sInjectEditingCommands(PyObject* /*self*/, PyObject* ar
PyObject* ApplicationPy::sCurrentEditingContext(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.currentEditingContext() is deprecated. Use kindred_sdk.current_context() instead.\n");
if (!PyArg_ParseTuple(args, "")) {
return nullptr;
}
@@ -2378,6 +2388,7 @@ PyObject* ApplicationPy::sCurrentEditingContext(PyObject* /*self*/, PyObject* ar
PyObject* ApplicationPy::sRefreshEditingContext(PyObject* /*self*/, PyObject* args)
{
Base::Console().warning("FreeCADGui.refreshEditingContext() is deprecated. Use kindred_sdk.refresh_context() instead.\n");
if (!PyArg_ParseTuple(args, "")) {
return nullptr;
}

View File

@@ -14,6 +14,7 @@ endif()
add_subdirectory(Stylesheets)
add_subdirectory(PreferencePacks)
add_subdirectory(PreferencePackTemplates)
add_subdirectory(SDK)
if(BUILD_WITH_CONDA)
add_definitions(-DFC_CONDA)

View File

@@ -124,6 +124,10 @@ EditingContextResolver::EditingContextResolver()
app.signalActivatedViewProvider.connect(
[this](const ViewProviderDocumentObject*, const char*) { refresh(); }
);
// Resolve the initial context so currentContext() returns a valid state
// before any signals fire (e.g. when BreadcrumbToolBar queries on creation).
refresh();
}
EditingContextResolver::~EditingContextResolver()

View File

@@ -0,0 +1,40 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
set(KCSDK_SRCS
KCSDKGlobal.h
Types.h
IMenuProvider.h
IPanelProvider.h
IToolbarProvider.h
WidgetBridge.h
WidgetBridge.cpp
ThemeEngine.h
ThemeEngine.cpp
SDKRegistry.h
SDKRegistry.cpp
)
add_library(KCSDK SHARED ${KCSDK_SRCS})
target_include_directories(KCSDK
PUBLIC
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/src
)
target_link_libraries(KCSDK
PRIVATE
FreeCADBase
FreeCADGui
)
if(FREECAD_WARN_ERROR)
target_compile_warn_error(KCSDK)
endif()
SET_BIN_DIR(KCSDK KCSDK /Mod/Create)
INSTALL(TARGETS KCSDK DESTINATION ${CMAKE_INSTALL_LIBDIR})
if(FREECAD_USE_PYBIND11)
add_subdirectory(bindings)
endif()

Some files were not shown because too many files have changed in this diff Show More