Compare commits

...

109 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
314955c3ef fix(solver): update solver submodule — skip prepass during drag
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Updates solver submodule to include fix for planar distance=0
constraints not holding during drag operations.
2026-02-25 12:58:03 -06:00
c7a7436e7b Merge pull request 'chore: update solver submodule' (#328) from chore/update-solver-submodule into main
All checks were successful
Sync Silo Server Docs / sync (push) Successful in 38s
Build and Test / build (push) Successful in 30m20s
Reviewed-on: #328
2026-02-25 02:49:57 +00:00
forbes
40dd8e09d7 chore: update solver submodule
All checks were successful
Build and Test / build (pull_request) Successful in 29m42s
Picks up fix/drag-orientation-stability (kindred/solver#36):
- Half-space tracking for all compound constraints with branch ambiguity
- Quaternion continuity enforcement during interactive drag
2026-02-24 20:49:27 -06:00
1fd52ccf1c Merge pull request 'feat: add QuickNav addon — Phase 1 core infrastructure (#320)' (#324) from feat/quicknav-phase1 into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 46s
Build and Test / build (push) Successful in 29m41s
Reviewed-on: #324
2026-02-24 18:37:19 +00:00
e73c5fc750 feat: add QuickNav addon — Phase 1 core infrastructure (#320)
All checks were successful
Build and Test / build (pull_request) Successful in 29m58s
Add quicknav submodule and create-side integration for keyboard-driven
command navigation.

Submodule: mods/quicknav (https://git.kindred-systems.com/kindred/quicknav)

Create-side changes:
- CMakeLists.txt: add quicknav install rules
- test_kindred_pure.py: add 16 workbench_map validation tests
- docs/src/quicknav/SPEC.md: QuickNav specification

QuickNav provides numbered-key access to workbenches (Ctrl+1-5),
command groupings (Shift+1-9), and individual commands (1-9), with
a navigation bar toolbar and input-widget safety guards.
2026-02-23 14:12:02 -06:00
f652d6ccf8 Merge pull request 'fix(assembly): handle non-standard datum element types in Distance joint classification' (#319) from fix/datum-plane-classification-all-hierarchies into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 37s
Reviewed-on: #319
2026-02-23 03:20:04 +00:00
forbes
14ee8c673f fix(assembly): classify datum planes from all class hierarchies in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
The datum plane detection in getDistanceType() only checked for
App::Plane (origin planes). This missed two other class hierarchies:

  - PartDesign::Plane (inherits Part::Datum, NOT App::Plane)
  - Part::Plane primitive referenced bare (no Face element)

Both produce empty element types (sub-name ends with ".") but failed
the isDerivedFrom<App::Plane>() check, falling through to
DistanceType::Other and the Planar fallback. This caused incorrect
constraint geometry, leading to conflicting/unsatisfiable constraints
and solver failures.

Add shape-based isDatumPlane/Line/Point helpers that cover all three
hierarchies by inspecting the actual OCCT geometry rather than relying
on class identity alone. Extend getDistanceType() to use these helpers
for all datum-vs-datum and datum-vs-element combinations.

Adds TestDatumClassification.py with tests for PartDesign::Plane,
Part::Plane (bare ref), and cross-hierarchy datum combinations.
2026-02-22 21:18:34 -06:00
a6a5db11f8 Merge pull request 'fix(assembly): classify datum planes from all class hierarchies in Distance joints' (#318) from fix/datum-plane-classification-all-hierarchies into main
All checks were successful
Build and Test / build (push) Successful in 31m20s
Reviewed-on: #318
2026-02-23 00:56:20 +00:00
forbes
962b521f5c fix(assembly): classify datum planes from all class hierarchies in Distance joints
All checks were successful
Build and Test / build (pull_request) Successful in 30m13s
The datum plane detection in getDistanceType() only checked for
App::Plane (origin planes). This missed two other class hierarchies:

  - PartDesign::Plane (inherits Part::Datum, NOT App::Plane)
  - Part::Plane primitive referenced bare (no Face element)

Both produce empty element types (sub-name ends with ".") but failed
the isDerivedFrom<App::Plane>() check, falling through to
DistanceType::Other and the Planar fallback. This caused incorrect
constraint geometry, leading to conflicting/unsatisfiable constraints
and solver failures.

Add shape-based isDatumPlane/Line/Point helpers that cover all three
hierarchies by inspecting the actual OCCT geometry rather than relying
on class identity alone. Extend getDistanceType() to use these helpers
for all datum-vs-datum and datum-vs-element combinations.

Adds TestDatumClassification.py with tests for PartDesign::Plane,
Part::Plane (bare ref), and cross-hierarchy datum combinations.
2026-02-22 18:55:39 -06:00
5c9212247a Merge pull request 'fix(assembly): add datum plane logging + fix cross-product singularity' (#317) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #317
2026-02-22 22:05:48 +00:00
forbes
cf0cd3db7e fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 15:52:22 -06:00
50dc8c8ea1 Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#316) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #316
2026-02-22 20:21:07 +00:00
forbes
b4835e1b05 fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 14:19:11 -06:00
cf2fc82eac Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#315) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #315
2026-02-22 18:25:32 +00:00
forbes
e5b07449d7 fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 12:24:44 -06:00
58d98c6d92 Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#314) from fix/distance-datum-plane-classification into main
All checks were successful
Build and Test / build (push) Successful in 41m35s
Sync Silo Server Docs / sync (push) Successful in 34s
Reviewed-on: #314
2026-02-22 04:04:41 +00:00
forbes
a10b9d9a9f fix(assembly): classify datum plane references in Distance joints
All checks were successful
Build and Test / build (pull_request) Successful in 41m13s
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-21 22:04:18 -06:00
forbes
d0e6d91642 chore: update solver submodule (drag step caching)
Some checks failed
Build and Test / build (push) Has been cancelled
2026-02-21 12:23:42 -06:00
forbes
05428f8a1c chore: bump version to 0.1.5
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Has been cancelled
2026-02-21 12:05:09 -06:00
forbes
14f314e137 chore: update solver submodule (distance=0 fix)
Some checks failed
Build and Test / build (push) Has been cancelled
2026-02-21 11:46:52 -06:00
forbes
30c35af3be chore: update solver submodule (compiled Jacobian evaluation)
Some checks are pending
Build and Test / build (push) Has started running
2026-02-21 11:42:48 -06:00
441cf9e826 Merge pull request 'feat(assembly): add diagnostic logging to solver and assembly' (#313) from feat/solver-diagnostic-logging into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #313
2026-02-21 16:11:34 +00:00
forbes
c682c5d153 feat(assembly): add diagnostic logging to solver and assembly
Some checks failed
Build and Test / build (pull_request) Has been cancelled
C++ (AssemblyObject):
- getOrCreateSolver: log which solver backend was loaded
- solve: log assembly name, grounded/joint counts, context size,
  result status with DOF and placement count, per-constraint
  diagnostics on failure
- preDrag/doDragStep/postDrag: log drag part count, per-step
  validation failures, and summary (total steps / rejected count)
- buildSolveContext: log grounded/free part counts, constraint count,
  limits count, and bundle_fixed flag

Python (kindred_solver submodule):
- solver.py: log solve entry/exit with timing, system build stats,
  decomposition decisions, Newton/BFGS fallback events, drag lifecycle
- decompose.py: log cluster stats and per-cluster convergence
- Init.py: FreeCAD log handler routing Python logging to Console
2026-02-21 10:08:51 -06:00
f65a4a5e2b Merge pull request 'fix(assembly): update flip-detection baseline during drag steps' (#312) from fix/assembly-drag-flip-detection into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #312
2026-02-21 15:59:55 +00:00
forbes
5d55f091d0 fix(assembly): update flip-detection baseline during drag steps
Some checks failed
Build and Test / build (pull_request) Has been cancelled
During drag operations, validateNewPlacements() compared each solver
result against the pre-drag positions saved once in preDrag().  As the
user dragged further, the cumulative rotation from that fixed baseline
easily exceeded the 91-degree threshold, causing valid intermediate
results to be rejected with 'flipped orientation' warnings and making
parts appear to explode.

Fix: call savePlacementsForUndo() after each accepted drag step so
that the flip check compares against the last accepted state rather
than the original pre-drag origin.
2026-02-21 09:59:04 -06:00
a445275fd2 Merge pull request 'fix(kc_format): eliminate duplicate silo/manifest.json in .kc files' (#311) from fix/kc-duplicate-manifest into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #311
2026-02-21 15:50:58 +00:00
forbes
88efa2a6ae fix(kc_format): eliminate duplicate silo/manifest.json entries in .kc files
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Two code paths were appending silo/manifest.json to the ZIP without
removing the previous entry, causing Python's zipfile module to warn
about duplicate names:

1. slotFinishSaveDocument() re-injected the cached manifest from
   entries, then the modified_at update branch wrote a second copy.

2. update_manifest_fields() opened the ZIP in append mode and wrote
   an updated manifest without removing the old one.

Fix slotFinishSaveDocument() by preparing the final manifest (with
updated modified_at) in the entries dict before writing, so only one
copy is written to the ZIP.

Fix update_manifest_fields() by rewriting the ZIP atomically via a
temp file, deduplicating any pre-existing duplicate entries in the
process.
2026-02-21 09:49:36 -06:00
62f077a267 Merge pull request 'fix(assembly): prevent segfault when all joints are removed' (#310) from fix/assembly-empty-joints-segfault into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #310
2026-02-21 15:47:59 +00:00
forbes
b6b0ebb4dc fix(assembly): prevent segfault when all joints are removed
Some checks failed
Build and Test / build (pull_request) Has been cancelled
updateSolveStatus() calls solve() when lastResult_.placements is empty,
but solve() calls updateSolveStatus() at the end. When an assembly has
zero constraints (all joints removed), the solver returns zero
placements, causing infinite recursion until stack overflow (segfault).

Add a static re-entrancy guard so the recursive solve() call is skipped
if updateSolveStatus() is already on the call stack.
2026-02-21 09:47:15 -06:00
a6d0427639 Merge pull request 'fix(gui): resolve PartDesign toolbars and breadcrumb when editing body inside assembly' (#309) from fix/partdesign-context-in-assembly into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #309
2026-02-21 15:44:49 +00:00
forbes
5883ac8a0d fix(gui): resolve PartDesign toolbars and breadcrumb when editing body inside assembly
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When double-clicking a PartDesign Body inside an Assembly, the editing
context resolver failed to show PartDesign toolbars or a proper
breadcrumb. Three root causes:

1. Priority preemption: assembly.edit (priority 90) always matched
   because the Assembly VP remained in edit mode, blocking all
   PartDesign contexts (priorities 30-40).

2. Wrong active-object key: PartDesign matchers only queried the
   "part" active object, but ViewProviderBody::toggleActiveBody()
   sets "part" to the containing App::Part (which is the Assembly
   itself). The Body is set under "pdbody", which was never queried.

3. Missing refresh trigger: ActiveObjectList::setObject() fires
   Document::signalActivatedViewProvider, but EditingContextResolver
   had no connection to it, so setActiveObject("pdbody", body) never
   triggered a context re-resolve.

Changes:
- Forward signalActivatedViewProvider from Gui::Document to
  Gui::Application (same pattern as signalInEdit/signalResetEdit)
- Connect EditingContextResolver to the new application-level signal
- Add getActivePdBodyObject() helper querying the "pdbody" key
- Add partdesign.in_assembly context (priority 95) that matches when
  a Body is active pdbody while an Assembly is in edit
- Update partdesign.body and partdesign.feature matchers to check
  pdbody before falling back to the part key
- Add Assembly > Body breadcrumb with Blue > Mauve coloring
- Update label resolution to prefer pdbody name
2026-02-21 09:43:51 -06:00
f9b13710f3 Merge pull request 'fix(solver): add networkx to runtime dependencies' (#308) from fix/solver-networkx-dep into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #308
2026-02-21 15:29:30 +00:00
forbes
39e78ee0a2 fix(solver): add networkx to runtime dependencies
All checks were successful
Build and Test / build (pull_request) Successful in 30m3s
The kindred-solver addon imports networkx in its decompose module.
Without it, KindredSolver fails to import and solver registration
silently fails, leaving only the ondsel backend available.
2026-02-21 09:24:30 -06:00
0f8fa0be86 Merge pull request 'feat(assembly): fixed reference planes (Top/Front/Right) + solver docs' (#307) from feat/assembly-origin-planes into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 51s
Build and Test / build (push) Has been cancelled
Reviewed-on: #307
2026-02-21 15:09:55 +00:00
forbes
acc255972d feat(assembly): fixed reference planes + solver docs
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
Assembly Origin Planes:
- AssemblyObject::setupObject() relabels origin planes to
  Top (XY), Front (XZ), Right (YZ) on assembly creation
- CommandCreateAssembly.py makes origin planes visible by default
- AssemblyUtils.cpp getObjFromRef() resolves LocalCoordinateSystem
  to child datum elements for joint references to origin planes
- TestAssemblyOriginPlanes.py: 9 integration tests covering
  structure, labels, grounding, reference resolution, solver,
  and save/load round-trip

Solver Documentation:
- docs/src/solver/: 7 new pages covering architecture overview,
  expression DAG, constraints, solving algorithms, diagnostics,
  assembly integration, and writing custom solvers
- docs/src/SUMMARY.md: added Kindred Solver section
2026-02-21 09:09:16 -06:00
148bed59f6 Merge pull request 'feat(templates): document templating system for .kc files' (#306) from feat/document-templates into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Has been cancelled
Reviewed-on: #306
2026-02-21 15:07:43 +00:00
forbes
b8cb7ca267 feat(templates): document templating system for .kc files
All checks were successful
Build and Test / build (pull_request) Successful in 29m12s
Add a template system integrated with Silo new-item creation that lets
users create parts from pre-configured .kc templates and save existing
documents as reusable templates.

Changes:
- mods/silo: template discovery, picker UI, Save as Template command,
  3-tier search paths (system, personal, org-shared)
- docs: template guide, SUMMARY.md entry, silo.md command reference
2026-02-21 09:06:36 -06:00
ae576629c5 Merge pull request 'fix(assembly): move resetSolver() out-of-line to fix incomplete type error' (#305) from fix/resetsolver-incomplete-type into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #305
2026-02-21 13:09:42 +00:00
forbes
6e7d2b582e fix(assembly): move resetSolver() out-of-line to fix incomplete type error
Some checks failed
Build and Test / build (pull_request) Has been cancelled
unique_ptr::reset() requires the complete type for its deleter, but
IKCSolver is only forward-declared in AssemblyObject.h. Move the
definition to AssemblyObject.cpp where the full header is included.
2026-02-21 07:08:59 -06:00
6d08161ae6 Merge pull request 'feat(solver): KCSolve solver addon with assembly integration (#289)' (#303) from feat/solver-context-packing into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Successful in 52s
Reviewed-on: #303
2026-02-21 05:48:46 +00:00
forbes
72e7e32133 feat(solver): KCSolve solver addon with assembly integration (#289)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Adds the Kindred constraint solver as a pluggable Assembly workbench
backend, covering phases 3d through 5 of the solver roadmap.

Phase 3d: SolveContext packing
- Pack/unpack SolveContext into .kc archive on document save

Solver addon (mods/solver):
- Phase 1: Expression DAG, Newton-Raphson + BFGS, 3 basic constraints
- Phase 2: Full constraint vocabulary — all 24 BaseJointKind types
- Phase 3: Graph decomposition for cluster-by-cluster solving
- Phase 4: Per-entity DOF diagnostics, overconstrained detection,
  half-space preference tracking, minimum-movement weighting
- Phase 5: _build_system extraction, diagnose(), drag protocol,
  joint limits warning

Assembly workbench integration:
- Preference-driven solver selection (reads Mod/Assembly/Solver param)
- Solver backend combo box in Assembly preferences UI
- resetSolver() on AssemblyObject for live preference switching
- Integration tests (TestKindredSolverIntegration.py)
- In-client console test script (console_test_phase5.py)
2026-02-20 23:47:50 -06:00
805be1e213 Merge pull request 'feat(solver): pack SolveContext into .kc archives on save (#289 phase 3d)' (#302) from feat/solver-context-packing into main
All checks were successful
Build and Test / build (push) Successful in 30m10s
Reviewed-on: #302
2026-02-21 00:03:13 +00:00
forbes
4cf54caf7b feat(solver): pack SolveContext into .kc archives on save (#289 phase 3d)
All checks were successful
Build and Test / build (pull_request) Successful in 29m51s
Expose AssemblyObject::getSolveContext() to Python and hook into the
.kc save flow so that silo/solver/context.json is packed into every
assembly archive. This lets server-side solver runners operate on
pre-extracted constraint graphs without a full FreeCAD installation.

Changes:
- Add public getSolveContext() to AssemblyObject (C++ and Python)
- Build Python dict via CPython C API matching kcsolve.SolveContext.to_dict()
- Register _solver_context_hook in kc_format.py pre-reinject hooks
- Add silo/solver/context.json to silo_tree.py _KNOWN_ENTRIES
2026-02-20 17:12:25 -06:00
311b3ea4f1 Merge pull request 'fix(gui): complete toolbar whitelists in EditingContextResolver' (#301) from fix/toolbar-context-whitelists into main
All checks were successful
Build and Test / build (push) Successful in 30m19s
Reviewed-on: #301
2026-02-20 18:14:11 +00:00
forbes
686d8699c9 fix(gui): complete toolbar whitelists in EditingContextResolver
All checks were successful
Build and Test / build (pull_request) Successful in 29m59s
The EditingContextResolver controls toolbar visibility via explicit
whitelists per editing context. Several contexts had incomplete lists,
causing workbench toolbars to be missing compared to base FreeCAD.

Changes:

partdesign.feature (priority 40):
  - Add 'Sketcher' toolbar so users can create new sketches from an
    active Body with features

partdesign.body (priority 30):
  - Add Modeling, Dress-Up, and Transformation toolbars (previously
    only showed Helper + Sketcher)

partdesign.workbench (priority 20):
  - Add Modeling, Dress-Up, and Transformation toolbars (same as body)

sketcher.workbench (priority 20):
  - Add Geometries, Constraints, B-Spline Tools, Visual Helpers
    (previously only showed Sketcher + Sketcher Tools)

assembly.idle (priority 30):
  - Add 'Assembly Joints' and 'Assembly Management' toolbars

assembly.workbench (priority 20):
  - Add 'Assembly Joints' and 'Assembly Management' toolbars

No changes to sketcher.edit or assembly.edit contexts — those were
already correct.
2026-02-20 12:13:23 -06:00
7dc7aac935 Merge pull request 'docs(solver): KCSolve architecture, API reference, and server specification' (#300) from docs/solver-spec-update into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #300
2026-02-20 18:04:12 +00:00
84f83a9d18 Merge branch 'main' into docs/solver-spec-update
All checks were successful
Build and Test / build (pull_request) Successful in 30m48s
2026-02-20 18:03:53 +00:00
forbes
64fbc167f7 docs(solver): server specification for KCSolve solver service
All checks were successful
Build and Test / build (pull_request) Successful in 30m11s
Comprehensive specification covering:
- Architecture: solver module integrated into Silo's job queue system
- Data model: JSON schemas for SolveContext and SolveResult transport
- REST API: submit, status, list, cancel endpoints under /api/solver/
- SSE events: solver.created, solver.progress, solver.completed, solver.failed
- Runner integration: standalone kcsolve execution, capability reporting
- Job definitions: manual solve, commit-time validation, kinematic simulation
- SolveContext extraction: headless Create and .kc archive packing
- Database schema: solver_results table with per-revision result caching
- Configuration: server and runner config patterns
- Security: input validation, runner isolation, authentication
- Client SDK: Python client and Create workbench integration sketches
- Implementation plan: Phase 3a-3e breakdown
2026-02-20 12:02:54 -06:00
forbes
315ac2a25d docs(kcsolve): expand Python API reference with full method docs
Expand SolveContext field descriptions (motions, simulation, bundle_fixed),
Constraint params table, marker explanations, Constraint.Limit descriptions,
MotionDef field descriptions, SimulationParams field descriptions, and all
optional IKCSolver methods with signatures, parameter docs, and usage
examples (interactive drag protocol, kinematic simulation, diagnostics,
export_native, capability queries).
2026-02-20 12:02:54 -06:00
forbes
98b0f72352 docs: KCSolve architecture and Python API reference
- Replace OndselSolver architecture doc with KCSolve pluggable solver
  architecture covering IKCSolver interface, SolverRegistry, OndselAdapter,
  Python bindings, file layout, and testing
- Add kcsolve Python API reference with full type documentation, module
  functions, usage examples, and pybind11 vector-copy caveat
- Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2
  marked as complete
- Update SUMMARY.md with new page links
2026-02-20 12:02:54 -06:00
Kindred Bot
c0ee4ecccf docs: sync Silo server documentation
Auto-synced from kindred/silo main branch.
2026-02-20 12:02:54 -06:00
1ed73e3eb0 Merge pull request 'feat(kcsolve): JSON serialization for all solver types (Phase 3a)' (#299) from feat/kcsolve-serialization into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 45s
Build and Test / build (push) Has been cancelled
Reviewed-on: #299
2026-02-20 18:01:30 +00:00
forbes
7e766a228e feat(kcsolve): add to_dict()/from_dict() JSON serialization for all types
All checks were successful
Build and Test / build (pull_request) Successful in 31m19s
Phase 3a of the solver server integration: add dict/JSON serialization
to all KCSolve pybind11 types so SolveContext and SolveResult can be
transported as JSON between the Create client, Silo server, and solver
runners.

Implementation:
- Constexpr enum string mapping tables for all 5 enums (BaseJointKind,
  SolveStatus, DiagnosticKind, MotionKind, LimitKind) with template
  bidirectional lookup helpers
- File-local to_dict/from_dict conversion functions for all 10 types
  (Transform, Part, Constraint::Limit, Constraint, MotionDef,
  SimulationParams, SolveContext, ConstraintDiagnostic,
  SolveResult::PartResult, SolveResult)
- .def("to_dict") and .def_static("from_dict") on every py::class_<>
  binding chain

Serialization details per SOLVER.md §3:
- SolveContext.to_dict() includes api_version field
- SolveContext.from_dict() validates api_version, raises ValueError on
  mismatch
- Enums serialize as strings matching pybind11 .value() names
- Transform: {position: [x,y,z], quaternion: [w,x,y,z]}
- Optional simulation serializes as None/null
- Pure pybind11 py::dict construction, no new dependencies

Tests: 16 new tests in TestKCSolveSerialization covering round-trips for
all types, all 24 BaseJointKind values, all 4 SolveStatus values,
json.dumps/loads stdlib round-trip, and error cases (missing key,
invalid enum, bad array length, wrong api_version).
2026-02-20 11:58:18 -06:00
forbes
a8fc1388ba docs(solver): server specification for KCSolve solver service
Comprehensive specification covering:
- Architecture: solver module integrated into Silo's job queue system
- Data model: JSON schemas for SolveContext and SolveResult transport
- REST API: submit, status, list, cancel endpoints under /api/solver/
- SSE events: solver.created, solver.progress, solver.completed, solver.failed
- Runner integration: standalone kcsolve execution, capability reporting
- Job definitions: manual solve, commit-time validation, kinematic simulation
- SolveContext extraction: headless Create and .kc archive packing
- Database schema: solver_results table with per-revision result caching
- Configuration: server and runner config patterns
- Security: input validation, runner isolation, authentication
- Client SDK: Python client and Create workbench integration sketches
- Implementation plan: Phase 3a-3e breakdown
2026-02-19 19:22:51 -06:00
b02bcbfe46 Merge pull request 'feat(kcsolve): pybind11 bindings and Python solver support' (#298) from feat/solver-api-types into main
All checks were successful
Deploy Docs / build-and-deploy (push) Successful in 56s
Build and Test / build (push) Successful in 29m41s
Sync Silo Server Docs / sync (push) Successful in 48s
Reviewed-on: #298
2026-02-20 01:10:00 +00:00
forbes
bd43e62822 docs(kcsolve): expand Python API reference with full method docs
All checks were successful
Build and Test / build (pull_request) Successful in 29m49s
Expand SolveContext field descriptions (motions, simulation, bundle_fixed),
Constraint params table, marker explanations, Constraint.Limit descriptions,
MotionDef field descriptions, SimulationParams field descriptions, and all
optional IKCSolver methods with signatures, parameter docs, and usage
examples (interactive drag protocol, kinematic simulation, diagnostics,
export_native, capability queries).
2026-02-19 19:06:08 -06:00
forbes
406e120180 docs: KCSolve architecture and Python API reference
All checks were successful
Build and Test / build (pull_request) Successful in 28m58s
- Replace OndselSolver architecture doc with KCSolve pluggable solver
  architecture covering IKCSolver interface, SolverRegistry, OndselAdapter,
  Python bindings, file layout, and testing
- Add kcsolve Python API reference with full type documentation, module
  functions, usage examples, and pybind11 vector-copy caveat
- Add INTER_SOLVER.md spec (previously untracked) with Phase 1 and Phase 2
  marked as complete
- Update SUMMARY.md with new page links
2026-02-19 18:59:05 -06:00
forbes
7ea0078ba3 feat(kcsolve): pybind11 bindings and Python solver support
All checks were successful
Build and Test / build (pull_request) Successful in 29m19s
Add the kcsolve pybind11 module exposing the KCSolve C++ API to Python:

- PyIKCSolver trampoline enabling Python IKCSolver subclasses
- Bindings for all 5 enums, 10 structs, IKCSolver, and OndselAdapter
- Module functions wrapping SolverRegistry (available, load, joints_for,
  set_default, get_default, register_solver)
- PySolverHolder class forwarding virtual calls with GIL acquisition
- register_solver() for runtime Python solver registration

IKCSolver constructor moved from protected to public for pybind11
trampoline access (class remains abstract via 3 pure virtuals).

Includes 16 Python tests covering module import, type bindings, enum
values, registry functions, Python solver subclassing, and full
register/load/solve round-trip.

Closes #288
2026-02-19 18:04:49 -06:00
f20ae3a667 Merge pull request 'feat(assembly): pluggable solver system — Phase 1 (#287)' (#297) from feat/solver-api-types into main
All checks were successful
Build and Test / build (push) Successful in 29m44s
Reviewed-on: #297
2026-02-19 22:58:33 +00:00
forbes
934cdf5767 test(assembly): regression tests for KCSolve solver refactor (#296)
All checks were successful
Build and Test / build (pull_request) Successful in 29m11s
Phase 1e: Add C++ gtest and Python unittest coverage for the pluggable
solver system introduced in Phases 1a-1d.

C++ tests (KCSolve_tests_run):
- SolverRegistryTest (8 tests): register/get, duplicates, defaults,
  available list, joints_for capability queries
- OndselAdapterTest (10 tests): identity checks, supported/unsupported
  joints, Fixed/Revolute solve round-trips, no-grounded-parts handling,
  exception safety, drag protocol (pre_drag/drag_step/post_drag),
  redundant constraint diagnostics

Python tests (TestSolverIntegration):
- Full-stack solve via AssemblyObject → IKCSolver → OndselAdapter
- Fixed joint placement matching, revolute joint success
- No-ground error code (-6), redundancy detection (-2)
- ASMT export produces non-empty file
- Deterministic solve stability (solve twice → same result)
2026-02-19 16:56:11 -06:00
forbes
5c33aacecb feat(solver): refactor AssemblyObject to use IKCSolver interface (#295)
Rewire AssemblyObject to call through KCSolve::IKCSolver instead of
directly manipulating OndselSolver ASMT types.

Key changes:
- Remove all 30+ #include <OndselSolver/*> from AssemblyObject.cpp
- Remove MbDPartData, objectPartMap, mbdAssembly members
- Add solver_ (IKCSolver), lastResult_ (SolveResult), partIdToObjs_ maps
- New buildSolveContext() builds SolveContext from FreeCAD document objects
  with JointType/DistanceType -> BaseJointKind decomposition
- New computeMarkerTransform() replaces handleOneSideOfJoint()
- New computeRackPinionMarkers() replaces getRackPinionMarkers()
- Rewrite solve/preDrag/doDragStep/postDrag/generateSimulation to call
  through IKCSolver interface
- Rewrite setNewPlacements/validateNewPlacements to use SolveResult
- Rewrite updateSolveStatus to use ConstraintDiagnostic
- Add export_native() to IKCSolver for ASMT export support
- Register OndselAdapter at Assembly module init in AppAssembly.cpp
- Remove OndselSolver from Assembly_LIBS (linked transitively via KCSolve)

Assembly module now has zero OndselSolver includes. All solver coupling
is confined to KCSolve/OndselAdapter.cpp.
2026-02-19 16:43:52 -06:00
forbes
32dbe20ce0 feat(solver): implement OndselAdapter wrapping OndselSolver behind IKCSolver (#294)
Phase 1c of the pluggable solver system. Creates OndselAdapter — the
concrete IKCSolver implementation that wraps OndselSolver's Lagrangian
MBD engine behind the KCSolve abstraction layer.

The adapter translates KCSolve types (SolveContext, BaseJointKind,
Transform) to OndselSolver's ASMT hierarchy (ASMTAssembly, ASMTPart,
ASMTJoint, ASMTMarker) and extracts results back into SolveResult.
All 30+ OndselSolver #includes are confined to OndselAdapter.cpp.

Key implementation details:
- build_assembly(): creates ASMTAssembly from SolveContext
- create_joint(): maps 20 BaseJointKind values to ASMT joint types
  (eliminates the 35-case DistanceType switch — decomposition done upstream)
- Quaternion-to-rotation-matrix conversion for OndselSolver input
- Direct quaternion extraction for output (both use w,x,y,z convention)
- Drag lifecycle: pre_drag/drag_step/post_drag with stateful assembly
- Simulation lifecycle: run_kinematic/num_frames/update_for_frame
- Diagnostic extraction: iterates MBD system constraints for redundancy
- Static register_solver() for SolverRegistry integration
- ExternalSystem back-pointer NOT set — breaks bidirectional coupling

New files:
- Solver/OndselAdapter.h — class declaration with KCSolveExport
- Solver/OndselAdapter.cpp — full implementation (~530 lines)

Modified:
- Solver/CMakeLists.txt — add sources, link OndselSolver (PRIVATE)
2026-02-19 16:19:44 -06:00
forbes
76b91c6597 feat(solver): implement SolverRegistry with plugin loading (#293)
Phase 1b of the pluggable solver system. Converts KCSolve from a
header-only INTERFACE target to a SHARED library and implements
the SolverRegistry with dynamic plugin discovery.

Changes:
- Add KCSolveGlobal.h export macro header (KCSolveExport)
- Move SolverRegistry method bodies from header to SolverRegistry.cpp
- Implement scan() with dlopen/LoadLibrary plugin loading
- Add scan_default_paths() for KCSOLVE_PLUGIN_PATH + system paths
- Plugin entry points: kcsolve_api_version() + kcsolve_create()
- API version checking (major version compatibility)
- Convert CMakeLists.txt from INTERFACE to SHARED library
- Link FreeCADBase (PRIVATE) for Console logging
- Link dl on POSIX for dynamic loading
- Fix -Wmissing-field-initializers warnings in IKCSolver.h defaults

The registry discovers plugins by scanning directories for shared
libraries that export the kcsolve C entry points. Plugins are
validated for API version compatibility before registration.
Manual registration via register_solver() remains available for
built-in solvers (e.g. OndselAdapter in Phase 1c).
2026-02-19 16:07:37 -06:00
forbes
47e6c14461 feat(solver): define IKCSolver C++ API types and interface (#292)
Add the pluggable solver API as header-only files under
src/Mod/Assembly/Solver/. This is Phase 1a of the pluggable solver
system (INTER_SOLVER.md).

New files:
- Types.h: BaseJointKind enum (24 decomposed constraint types),
  Transform, Part, Constraint, SolveContext, SolveResult, and
  supporting types. Uses standalone types (no FreeCAD dependency)
  for future server worker compatibility.
- IKCSolver.h: Abstract solver interface with solve(), drag protocol
  (pre_drag/drag_step/post_drag), kinematic simulation
  (run_kinematic/num_frames/update_for_frame), and diagnostics.
  Only solve(), name(), and supported_joints() are pure virtual;
  all other methods have default implementations.
- SolverRegistry.h: Thread-safe singleton registry for solver
  backends with factory-based registration and default solver
  selection.
- CMakeLists.txt: INTERFACE library target (header-only for now).

Modified:
- Assembly/CMakeLists.txt: add_subdirectory(Solver)
- Assembly/App/CMakeLists.txt: link KCSolve INTERFACE target
2026-02-19 15:55:57 -06:00
551979b441 Merge pull request 'chore: configure Kindred submodules to track main branch' (#286) from chore/submodule-branch-tracking into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #286
2026-02-19 20:58:27 +00:00
Kindred Bot
8897399a10 docs: sync Silo server documentation
Some checks failed
Build and Test / build (push) Has been cancelled
Deploy Docs / build-and-deploy (push) Successful in 51s
Auto-synced from kindred/silo main branch.
2026-02-19 20:58:03 +00:00
forbes
6dc4341a5f chore: configure Kindred submodules to track main branch
All checks were successful
Build and Test / build (pull_request) Successful in 29m19s
Add branch = main to mods/silo and mods/ztools in .gitmodules.
Enables 'git submodule update --remote' to auto-advance to latest main.
Third-party deps (GSL, googletest, AddonManager) remain pinned.
2026-02-19 14:57:31 -06:00
ab6d09c138 Merge pull request 'chore: update submodule pointers to latest main' (#285) from fix/submodule-pointers into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #285
2026-02-19 20:52:46 +00:00
forbes
133af52f11 chore: update submodule pointers to latest main
All checks were successful
Build and Test / build (pull_request) Successful in 29m12s
- mods/silo: f67d9a0 (feature branch) -> 43e905c (main, includes merged #49)
- mods/ztools: 296a94e (pre-rebase) -> 08e439b (rebased on main, includes theme removal)
2026-02-19 14:52:22 -06:00
186 changed files with 23684 additions and 1571 deletions

View File

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

View File

@@ -29,7 +29,7 @@ body:
attributes: attributes:
label: Location label: Location
description: Where should this documentation live? Link to existing pages if applicable. 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: validations:
required: false required: false

View File

@@ -27,7 +27,6 @@ body:
description: Which part of Kindred Create does this relate to? description: Which part of Kindred Create does this relate to?
options: options:
- General / Core - General / Core
- ztools Workbench
- Silo (Parts Database) - Silo (Parts Database)
- Theme / QSS - Theme / QSS
- Assembly - Assembly

View File

@@ -322,6 +322,7 @@ jobs:
env: env:
BUILD_TAG: ${{ github.ref_name || inputs.tag }} BUILD_TAG: ${{ github.ref_name || inputs.tag }}
COMMIT_SHA: ${{ github.sha }}
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
steps: steps:
@@ -386,6 +387,7 @@ jobs:
'name': f'Kindred Create {tag}', 'name': f'Kindred Create {tag}',
'body': body, 'body': body,
'prerelease': prerelease, '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 # To regenerate themed icons: python3 icons/retheme.py
# icons/themed/ is tracked (committed) so CI builds include them # icons/themed/ is tracked (committed) so CI builds include them
CLAUDE.md

12
.gitmodules vendored
View File

@@ -10,9 +10,15 @@
[submodule "src/Mod/AddonManager"] [submodule "src/Mod/AddonManager"]
path = src/Mod/AddonManager path = src/Mod/AddonManager
url = https://github.com/FreeCAD/AddonManager.git url = https://github.com/FreeCAD/AddonManager.git
[submodule "mods/ztools"]
path = mods/ztools
url = https://git.kindred-systems.com/forbes/ztools.git
[submodule "mods/silo"] [submodule "mods/silo"]
path = mods/silo path = mods/silo
url = https://git.kindred-systems.com/kindred/silo-mod.git url = https://git.kindred-systems.com/kindred/silo-mod.git
branch = main
[submodule "mods/solver"]
path = mods/solver
url = https://git.kindred-systems.com/kindred/solver.git
branch = main
[submodule "mods/gears"]
path = mods/gears
url = https://git.kindred-systems.com/kindred/gears.git
branch = main

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

@@ -53,7 +53,7 @@ project(KindredCreate)
# Kindred Create version # Kindred Create version
set(KINDRED_CREATE_VERSION_MAJOR "0") set(KINDRED_CREATE_VERSION_MAJOR "0")
set(KINDRED_CREATE_VERSION_MINOR "1") set(KINDRED_CREATE_VERSION_MINOR "1")
set(KINDRED_CREATE_VERSION_PATCH "0") set(KINDRED_CREATE_VERSION_PATCH "5")
set(KINDRED_CREATE_VERSION "${KINDRED_CREATE_VERSION_MAJOR}.${KINDRED_CREATE_VERSION_MINOR}.${KINDRED_CREATE_VERSION_PATCH}") set(KINDRED_CREATE_VERSION "${KINDRED_CREATE_VERSION_MAJOR}.${KINDRED_CREATE_VERSION_MINOR}.${KINDRED_CREATE_VERSION_PATCH}")
# Underlying FreeCAD version # Underlying FreeCAD version

View File

@@ -2,7 +2,7 @@
**An engineering-focused parametric 3D CAD platform built on FreeCAD 1.0+** **An engineering-focused parametric 3D CAD platform built on FreeCAD 1.0+**
Kindred Create 0.1.0 | FreeCAD 1.2.0 base Kindred Create 0.1.5 | FreeCAD 1.2.0 base
[Website](https://www.kindred-systems.com/create) | [Website](https://www.kindred-systems.com/create) |
[Downloads](https://git.kindred-systems.com/kindred/create/releases) | [Downloads](https://git.kindred-systems.com/kindred/create/releases) |

View File

@@ -116,6 +116,17 @@ mods/silo/ [submodule → silo-mod.git] FreeCAD workbench
├── silo_commands.py Commands + FreeCADSiloSettings adapter ├── silo_commands.py Commands + FreeCADSiloSettings adapter
└── silo_origin.py FileOrigin backend for Silo (via SDK) └── 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/EditingContext.h/.cpp EditingContextResolver singleton + context registry
src/Gui/BreadcrumbToolBar.h/.cpp Color-coded breadcrumb toolbar (Catppuccin Mocha) src/Gui/BreadcrumbToolBar.h/.cpp Color-coded breadcrumb toolbar (Catppuccin Mocha)
src/Gui/FileOrigin.h/.cpp FileOrigin base class + LocalFileOrigin 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 ## Design decisions
1. **`Create::` namespace prefix.** All Kindred Create C++ features use this prefix to distinguish them from FreeCAD core. 1. **`Create::` namespace prefix.** All Kindred Create C++ features use this prefix to distinguish them from FreeCAD core.

568
docs/INTER_SOLVER.md Normal file
View File

@@ -0,0 +1,568 @@
# Pluggable Assembly Solver Architecture
**Status:** Phase 2 complete
**Last Updated:** 2026-02-19
---
## 1. Problem
Kindred Create currently vendors OndselSolver as a monolithic assembly constraint solver. Different engineering domains benefit from different solver strategies — Lagrangian methods work well for rigid body assemblies but poorly for over-constrained or soft-constraint systems. A pluggable architecture lets us ship multiple solvers (including experimental ones) without touching core assembly logic, and lets the server farm out solve jobs to headless worker processes.
---
## 2. Design Goals
1. **Stable C++ API** — A solver-agnostic interface that the Assembly module calls. Solvers are shared libraries loaded at runtime.
2. **Python binding layer** — Every C++ solver is exposed to Python via pybind11, enabling rapid prototyping, debugging, and server-side execution without a full GUI build.
3. **Solver-defined joint types** — Each solver declares its own joint/mate vocabulary, mapped from a common base set (inspired by SOLIDWORKS mates: coincident, concentric, tangent, distance, angle, lock, etc.).
4. **Semi-deterministic solving** — Consistent results given consistent input ordering, with configurable tolerance and iteration limits.
5. **Server-compatible** — Solvers run as detached processes claimed by `silorunner` workers via the existing job queue.
---
## 3. Architecture Layers
```
┌──────────────────────────────────────────────────────┐
│ Layer 4: Server / Worker │
│ silorunner claims solve jobs, executes via Python │
│ Headless Create or standalone solver process │
├──────────────────────────────────────────────────────┤
│ Layer 3: Python Debug & Scripting │
│ pybind11 bindings for all solvers │
│ Introspection, step-through, constraint viz │
│ import kcsolve; s = kcsolve.load("ondsel") │
├──────────────────────────────────────────────────────┤
│ Layer 2: Solver Plugins (.so / .dll / .dylib) │
│ Each implements IKCSolver interface │
│ Registers joint types via manifest │
│ Loaded by SolverRegistry at runtime │
├──────────────────────────────────────────────────────┤
│ Layer 1: C++ Solver API (libkcsolve) │
│ IKCSolver, JointDef, SolveContext, SolveResult │
│ SolverRegistry (discovery, loading, selection) │
│ Ships as a shared library linked by Assembly module │
└──────────────────────────────────────────────────────┘
```
---
## 4. Layer 1: C++ Solver API
Located at `src/Mod/Assembly/Solver/` (or `src/Lib/KCSolve/` if we want it independent of Assembly).
### 4.1 Core Types
```cpp
namespace KCSolve {
// Unique identifier for a joint type within a solver
struct JointTypeId {
std::string solver_id; // e.g. "ondsel", "gnn", "relaxation"
std::string joint_name; // e.g. "coincident", "distance"
};
// Base joint categories (SOLIDWORKS-inspired vocabulary)
enum class BaseJointKind {
Coincident,
Concentric,
Tangent,
Distance,
Angle,
Lock,
Parallel,
Perpendicular,
PointOnLine,
SymmetricPlane,
Gear,
Rack,
Cam,
Slot,
Hinge,
Slider,
Cylindrical,
Planar,
Ball,
Screw,
Universal,
Custom // solver-specific extension
};
// A joint definition registered by a solver plugin
struct JointDef {
JointTypeId id;
BaseJointKind base_kind; // which vanilla category it maps to
std::string display_name;
std::string description;
uint32_t dof_removed; // degrees of freedom this joint removes
std::vector<std::string> params; // parameter names (e.g. "distance", "angle")
bool supports_limits = false;
bool supports_friction = false;
};
// A constraint instance in a solve problem
struct Constraint {
JointTypeId joint_type;
std::string part_a; // part label or id
std::string part_b;
// Geometry references (face, edge, vertex indices)
std::vector<std::string> refs_a;
std::vector<std::string> refs_b;
std::map<std::string, double> params; // param_name -> value
bool suppressed = false;
};
// Input to a solve operation
struct SolveContext {
std::vector<Constraint> constraints;
// Part placements as 4x4 transforms (initial guess)
std::map<std::string, std::array<double, 16>> placements;
// Which parts are grounded (fixed)
std::set<std::string> grounded;
// Solver config
double tolerance = 1e-10;
uint32_t max_iterations = 500;
bool deterministic = true; // force consistent ordering
// Optional: previous solution for warm-starting
std::map<std::string, std::array<double, 16>> warm_start;
};
enum class SolveStatus {
Converged,
MaxIterationsReached,
Overconstrained,
Underconstrained,
Redundant,
Failed
};
struct ConstraintDiagnostic {
std::string constraint_id;
double residual;
bool satisfied;
std::string message;
};
struct SolveResult {
SolveStatus status;
uint32_t iterations;
double final_residual;
double solve_time_ms;
std::map<std::string, std::array<double, 16>> placements;
std::vector<ConstraintDiagnostic> diagnostics;
// For semi-deterministic: hash of input ordering
uint64_t input_hash;
};
} // namespace KCSolve
```
### 4.2 Solver Interface
```cpp
namespace KCSolve {
class IKCSolver {
public:
virtual ~IKCSolver() = default;
// Identity
virtual std::string id() const = 0;
virtual std::string name() const = 0;
virtual std::string version() const = 0;
// Joint type registry — called once at load
virtual std::vector<JointDef> supported_joints() const = 0;
// Solve
virtual SolveResult solve(const SolveContext& ctx) = 0;
// Incremental: update a single constraint without full re-solve
// Default impl falls back to full solve
virtual SolveResult update(const SolveContext& ctx,
const std::string& changed_constraint) {
return solve(ctx);
}
// Diagnostic: check if a constraint set is well-posed before solving
virtual SolveStatus diagnose(const SolveContext& ctx) {
return SolveStatus::Converged; // optimistic default
}
// Determinism: given identical input, produce identical output
virtual bool is_deterministic() const { return false; }
};
// Plugin entry point — each .so exports this symbol
using CreateSolverFn = IKCSolver* (*)();
} // namespace KCSolve
```
### 4.3 Solver Registry
```cpp
namespace KCSolve {
class SolverRegistry {
public:
// Scan a directory for solver plugins (*.so / *.dll / *.dylib)
void scan(const std::filesystem::path& plugin_dir);
// Manual registration (for built-in solvers like Ondsel)
void register_solver(std::unique_ptr<IKCSolver> solver);
// Lookup
IKCSolver* get(const std::string& solver_id) const;
std::vector<std::string> available() const;
// Joint type resolution: find which solvers support a given base kind
std::vector<JointTypeId> joints_for(BaseJointKind kind) const;
// Global default solver
void set_default(const std::string& solver_id);
IKCSolver* get_default() const;
};
} // namespace KCSolve
```
### 4.4 Plugin Loading
Each solver plugin is a shared library exporting:
```cpp
extern "C" KCSolve::IKCSolver* kcsolve_create();
extern "C" const char* kcsolve_api_version(); // "1.0"
```
The registry `dlopen`s each library, checks `kcsolve_api_version()` compatibility, and calls `kcsolve_create()`. Plugins are discovered from:
1. `<install_prefix>/lib/kcsolve/` — system-installed solvers
2. `~/.config/KindredCreate/solvers/` — user-installed solvers
3. `KCSOLVE_PLUGIN_PATH` env var — development overrides
---
## 5. Layer 2: OndselSolver Adapter
The first plugin wraps the existing OndselSolver, mapping its internal constraint types to the `IKCSolver` interface.
```
src/Mod/Assembly/Solver/
├── IKCSolver.h # Interface + types from §4
├── SolverRegistry.cpp # Plugin discovery and loading
├── OndselAdapter.cpp # Wraps OndselSolver as IKCSolver plugin
└── CMakeLists.txt
```
`OndselAdapter` translates between `SolveContext` ↔ OndselSolver's Lagrangian formulation. This is the reference implementation and proves the API works before any new solvers are written.
Joint mapping for OndselAdapter:
| BaseJointKind | Ondsel Constraint | DOF Removed |
|---------------|-------------------|-------------|
| Coincident | PointOnPoint | 3 |
| Concentric | CylindricalOnCylindrical | 4 |
| Tangent | FaceOnFace (tangent mode) | 1 |
| Distance | PointOnPoint + offset | 2 |
| Angle | AxisAngle | 1 |
| Lock | FullLock | 6 |
| Hinge | RevoluteJoint | 5 |
| Slider | PrismaticJoint | 5 |
| Cylindrical | CylindricalJoint | 4 |
| Ball | SphericalJoint | 3 |
---
## 6. Layer 3: Python Bindings
### 6.1 pybind11 Module
```
src/Mod/Assembly/Solver/bindings/
├── kcsolve_py.cpp # pybind11 module definition
└── CMakeLists.txt
```
```python
import kcsolve
# List available solvers
print(kcsolve.available()) # ["ondsel", ...]
# Load a solver
solver = kcsolve.load("ondsel")
print(solver.name, solver.version)
print(solver.supported_joints())
# Build a problem
ctx = kcsolve.SolveContext()
ctx.add_part("base", placement=..., grounded=True)
ctx.add_part("arm", placement=...)
ctx.add_constraint("coincident", "base", "arm",
refs_a=["Face6"], refs_b=["Face1"])
# Solve
result = solver.solve(ctx)
print(result.status) # SolveStatus.Converged
print(result.iterations) # 12
print(result.solve_time_ms) # 3.4
print(result.placements["arm"])
# Diagnostics per constraint
for d in result.diagnostics:
print(f"{d.constraint_id}: residual={d.residual:.2e} ok={d.satisfied}")
```
### 6.2 Debug / Introspection API
The Python layer adds capabilities the C++ interface intentionally omits for performance:
```python
# Step-through solving (debug mode)
debugger = kcsolve.Debugger(solver, ctx)
for step in debugger.iterate():
print(f"iter {step.iteration}: residual={step.residual:.6e}")
print(f" moved: {step.parts_moved}")
print(f" worst constraint: {step.worst_constraint}")
if step.residual < 1e-8:
break
# Constraint dependency graph
graph = kcsolve.dependency_graph(ctx)
# Returns dict: constraint_id -> [dependent_constraint_ids]
# DOF analysis
analysis = kcsolve.dof_analysis(ctx)
print(f"Total DOF: {analysis.total_dof}")
print(f"Removed: {analysis.constrained_dof}")
print(f"Remaining: {analysis.free_dof}")
for part, dofs in analysis.per_part.items():
print(f" {part}: {dofs} free")
```
### 6.3 Pure-Python Solver Support
The Python layer also supports solvers written entirely in Python (no C++ required). This is the fast path for prototyping new approaches (GNN, relaxation, etc.):
```python
class RelaxationSolver(kcsolve.PySolver):
"""A pure-Python iterative relaxation solver for prototyping."""
id = "relaxation"
name = "Iterative Relaxation"
version = "0.1.0"
def supported_joints(self):
return [
kcsolve.JointDef("coincident", kcsolve.BaseJointKind.Coincident, dof_removed=3),
kcsolve.JointDef("distance", kcsolve.BaseJointKind.Distance, dof_removed=2),
# ...
]
def solve(self, ctx: kcsolve.SolveContext) -> kcsolve.SolveResult:
placements = dict(ctx.placements)
for i in range(ctx.max_iterations):
max_residual = 0.0
for c in ctx.constraints:
residual = self._eval_constraint(c, placements)
correction = self._compute_correction(c, residual)
self._apply_correction(placements, c, correction)
max_residual = max(max_residual, abs(residual))
if max_residual < ctx.tolerance:
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.Converged,
iterations=i + 1,
final_residual=max_residual,
placements=placements
)
return kcsolve.SolveResult(
status=kcsolve.SolveStatus.MaxIterationsReached,
iterations=ctx.max_iterations,
final_residual=max_residual,
placements=placements
)
# Register at runtime
kcsolve.register(RelaxationSolver())
```
Python solvers are discovered from:
- `<user_macros>/solvers/*.py` — user-written solvers
- `mods/*/solvers/*.py` — addon-provided solvers
---
## 7. Layer 4: Server Integration
### 7.1 Solve Job Definition
Extends the existing worker system (WORKERS.md) with a new job type:
```yaml
job:
name: assembly-solve
version: 1
description: "Solve assembly constraints using specified solver"
trigger:
type: revision_created
filter:
item_type: assembly
scope:
type: assembly
compute:
type: solve
command: create-solve
args:
solver: ondsel # or "auto" for registry default
tolerance: 1e-10
max_iterations: 500
deterministic: true
output_placements: true # write solved placements back to revision
output_diagnostics: true # store constraint diagnostics in job result
runner:
tags: [create, solver]
timeout: 300
max_retries: 1
priority: 75
```
### 7.2 Headless Solve via Runner
The `create-solve` command in `silorunner`:
1. Claims job from Silo server
2. Downloads the assembly `.kc` file
3. Launches Headless Create (or standalone Python if pure-Python solver)
4. Loads the assembly, extracts constraint graph → `SolveContext`
5. Calls `solver.solve(ctx)`
6. Reports `SolveResult` back via `POST /api/runner/jobs/{id}/complete`
7. Optionally writes updated placements as a new revision
### 7.3 Standalone Solve Process (No GUI)
For server-side batch solving without Headless Create overhead:
```python
#!/usr/bin/env python3
"""Standalone solver worker — no FreeCAD dependency."""
import kcsolve
import json, sys
problem = json.load(sys.stdin)
ctx = kcsolve.SolveContext.from_dict(problem)
solver = kcsolve.load(problem.get("solver", "ondsel"))
result = solver.solve(ctx)
json.dump(result.to_dict(), sys.stdout)
```
This enables lightweight solver containers that don't need the full Create installation — useful for CI validation, quick constraint checks, and scaling solver capacity independently of geometry workers.
---
## 8. Semi-Deterministic Behavior
"Semi-deterministic" means: given the same constraint set and initial placements, the solver produces the same result. This is achieved by:
1. **Canonical input ordering**`SolveContext` sorts constraints and parts by a stable key (part label + constraint index) before passing to the solver. The ordering hash is stored in `SolveResult.input_hash`.
2. **Solver contract**`IKCSolver::is_deterministic()` reports whether the implementation guarantees this. OndselAdapter does (Lagrangian formulation with fixed pivot ordering). A GNN solver might not.
3. **Tolerance-aware comparison** — Two `SolveResult`s are "equivalent" if all placement deltas are within tolerance, even if iteration counts differ. Used for regression testing.
4. **Warm-start stability** — When `warm_start` placements are provided, the solver should converge to the same solution as a cold start (within tolerance), just faster. This is validated in the test suite.
---
## 9. Implementation Phases
### Phase 1: API + OndselAdapter (foundation) -- COMPLETE
- Defined `IKCSolver.h`, core types (`Types.h`), `SolverRegistry`
- Implemented `OndselAdapter` wrapping existing solver
- Assembly module calls through `SolverRegistry` instead of directly calling OndselSolver
- 18 C++ tests, 6 Python integration tests
- **PR:** #297 (merged)
### Phase 2: pybind11 Bindings -- COMPLETE
- Built `kcsolve` pybind11 module exposing all enums, structs, and classes
- `PyIKCSolver` trampoline for pure-Python solver subclasses
- `register_solver()` for runtime Python solver registration
- `PySolverHolder` for GIL-safe forwarding of virtual calls
- 16 Python tests covering types, registry, and Python solver round-trips
- Debug/introspection API (Debugger, `dependency_graph()`, `dof_analysis()`) deferred to Phase 4+
- Automatic Python solver discovery (`mods/*/solvers/`) deferred -- users call `register_solver()` explicitly
- **PR:** #298
- **Docs:** `docs/src/architecture/ondsel-solver.md`, `docs/src/reference/kcsolve-python.md`
### Phase 3: Server Integration
- `create-solve` command for `silorunner`
- YAML job definition for solve jobs
- Standalone solver process (no FreeCAD dependency)
- `SolveContext` JSON serialization for inter-process communication
- **Deliverable:** Solve jobs run async through the worker system
### Phase 4: Second Solver (validation)
- Implement a simple relaxation or gradient-descent solver as a Python plugin
- Validates that the API actually supports different solving strategies
- Benchmark against OndselAdapter for correctness and performance
- **Deliverable:** Two interchangeable solvers, selectable per-assembly
### Phase 5: GNN Solver (future)
- Graph Neural Network approach from existing roadmap
- Likely a Python solver wrapping a trained model
- Focus on fast approximate solutions for interactive editing
- Falls back to OndselAdapter for final precision solve
- **Deliverable:** Hybrid solve pipeline (GNN fast-guess → Lagrangian refinement)
---
## 10. File Locations
```
src/Lib/KCSolve/ # or src/Mod/Assembly/Solver/
├── include/
│ └── KCSolve/
│ ├── IKCSolver.h # Interface + all types
│ ├── SolverRegistry.h # Plugin loading and lookup
│ └── Types.h # Enums, structs
├── src/
│ ├── SolverRegistry.cpp
│ └── OndselAdapter.cpp
├── bindings/
│ └── kcsolve_py.cpp # pybind11
├── plugins/ # Additional compiled solver plugins
└── CMakeLists.txt
```
---
## 11. Open Questions
1. **Location**: `src/Lib/KCSolve/` (independent library, usable without Assembly module) vs `src/Mod/Assembly/Solver/` (tighter coupling, simpler build)? Leaning toward `src/Lib/` since server workers need it without the full Assembly module.
2. **Geometry abstraction**: The C++ API uses string references for faces/edges/vertices. Should we pass actual OCC geometry (TopoDS_Shape) through the interface, or keep it abstract and let each solver adapter resolve references? Abstract is more portable but adds a translation step.
3. **Constraint persistence**: Currently constraints live in the FCStd XML. Should the pluggable layer introduce its own serialization, or always read/write through FreeCAD's property system?
4. **API versioning**: `kcsolve_api_version()` returns a string. Semver with major-only breaking changes? How strict on backward compat for the plugin ABI?
5. **License implications**: OndselSolver is LGPL. New solver plugins could be any license since they're loaded at runtime via a stable C API boundary. Confirm this interpretation.
---
## 12. References
- [ondsel-solver.md](ondsel-solver.md) — Current solver documentation
- [WORKERS.md](WORKERS.md) — Worker/runner job system
- [MULTI_USER_EDITS.md](MULTI_USER_EDITS.md) — Async validation pipeline
- [DAG.md](DAG.md) — Dependency graph for incremental recompute
- [ROADMAP.md](ROADMAP.md) — Tier 3 compute modules, GNN solver plans

View File

@@ -12,6 +12,7 @@
- [Workbenches](./guide/workbenches.md) - [Workbenches](./guide/workbenches.md)
- [ztools](./guide/ztools.md) - [ztools](./guide/ztools.md)
- [Silo](./guide/silo.md) - [Silo](./guide/silo.md)
- [Document Templates](./guide/templates.md)
# Architecture # Architecture
@@ -19,7 +20,7 @@
- [Python as Source of Truth](./architecture/python-source-of-truth.md) - [Python as Source of Truth](./architecture/python-source-of-truth.md)
- [Silo Server](./architecture/silo-server.md) - [Silo Server](./architecture/silo-server.md)
- [Signal Architecture](./architecture/signal-architecture.md) - [Signal Architecture](./architecture/signal-architecture.md)
- [OndselSolver](./architecture/ondsel-solver.md) - [KCSolve: Pluggable Solver](./architecture/ondsel-solver.md)
# Development # Development
@@ -29,6 +30,7 @@
- [Build System](./development/build-system.md) - [Build System](./development/build-system.md)
- [Gui Module Build](./development/gui-build-integration.md) - [Gui Module Build](./development/gui-build-integration.md)
- [Package.xml Schema Extensions](./development/package-xml-schema.md) - [Package.xml Schema Extensions](./development/package-xml-schema.md)
- [Writing an Addon](./development/writing-an-addon.md)
# Silo Server # Silo Server
@@ -46,8 +48,19 @@
- [Gap Analysis](./silo-server/GAP_ANALYSIS.md) - [Gap Analysis](./silo-server/GAP_ANALYSIS.md)
- [Frontend Spec](./silo-server/frontend-spec.md) - [Frontend Spec](./silo-server/frontend-spec.md)
- [Installation](./silo-server/INSTALL.md) - [Installation](./silo-server/INSTALL.md)
- [Solver Service](./silo-server/SOLVER.md)
- [Roadmap](./silo-server/ROADMAP.md) - [Roadmap](./silo-server/ROADMAP.md)
# Kindred Solver
- [Overview](./solver/overview.md)
- [Expression DAG](./solver/expression-dag.md)
- [Constraints](./solver/constraints.md)
- [Solving Algorithms](./solver/solving.md)
- [Diagnostics](./solver/diagnostics.md)
- [Assembly Integration](./solver/assembly-integration.md)
- [Writing a Custom Solver](./solver/writing-a-solver.md)
# Reference # Reference
- [Configuration](./reference/configuration.md) - [Configuration](./reference/configuration.md)
@@ -64,3 +77,5 @@
- [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md) - [OriginSelectorWidget](./reference/cpp-origin-selector-widget.md)
- [FileOriginPython Bridge](./reference/cpp-file-origin-python.md) - [FileOriginPython Bridge](./reference/cpp-file-origin-python.md)
- [Creating a Custom Origin (C++)](./reference/cpp-custom-origin-guide.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

@@ -1,27 +1,132 @@
# OndselSolver # KCSolve: Pluggable Solver Architecture
OndselSolver is the assembly constraint solver used by FreeCAD's Assembly workbench. Kindred Create vendors a fork of the solver as a git submodule. KCSolve is the pluggable assembly constraint solver framework for Kindred Create. It defines an abstract solver interface (`IKCSolver`) and a runtime registry (`SolverRegistry`) that lets the Assembly module work with any conforming solver backend. The default backend wraps OndselSolver via `OndselAdapter`.
- **Path:** `src/3rdParty/OndselSolver/` - **Library:** `src/Mod/Assembly/Solver/` (builds `libKCSolve.so`)
- **Source:** `git.kindred-systems.com/kindred/solver` (Kindred fork) - **Python module:** `src/Mod/Assembly/Solver/bindings/` (builds `kcsolve.so`)
- **Tests:** `tests/src/Mod/Assembly/Solver/` (C++), `src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py` (Python)
## How it works ## Architecture
The solver uses a **Lagrangian constraint formulation** to resolve assembly constraints (mates, joints, fixed positions). Given a set of parts with geometric constraints between them, it computes positions and orientations that satisfy all constraints simultaneously. ```
┌──────────────────────────────────────────────────┐
│ Assembly Module (AssemblyObject.cpp) │
│ Builds SolveContext from FreeCAD document, │
│ calls solver via SolverRegistry │
├──────────────────────────────────────────────────┤
│ SolverRegistry (singleton) │
│ register_solver(), get(), available() │
│ Plugin discovery via scan() / scan_default_paths │
├──────────────┬───────────────────────────────────┤
│ OndselAdapter │ Python solvers │ Future plugins │
│ (C++ built-in)│ (via kcsolve) │ (.so plugins) │
└──────────────┴───────────────────────────────────┘
```
The Assembly workbench (`src/Mod/Assembly/`) calls the solver whenever constraints are added or modified. Kindred Create has patches to `Assembly/` that extend `findPlacement()` for better datum and origin handling. The Assembly module never references OndselSolver directly. All solver access goes through `SolverRegistry::instance().get()`, which returns a `std::unique_ptr<IKCSolver>`.
## Why a fork ## IKCSolver interface
The solver is forked from the upstream Ondsel project for: A solver backend implements `IKCSolver` (defined in `IKCSolver.h`). Only three methods are pure virtual; all others have sensible defaults:
- **Pinned stability** — the submodule is pinned to a known-good commit
- **Potential modifications** — the fork allows Kindred-specific patches if needed
- **Availability** — hosted on Kindred's Gitea instance for reliable access
## Future: GNN solver | Method | Required | Purpose |
|--------|----------|---------|
| `name()` | yes | Human-readable solver name |
| `supported_joints()` | yes | List of `BaseJointKind` values the solver handles |
| `solve(ctx)` | yes | Solve for static equilibrium |
| `update(ctx)` | no | Incremental re-solve after parameter changes |
| `pre_drag(ctx, parts)` | no | Begin interactive drag session |
| `drag_step(placements)` | no | One mouse-move during drag |
| `post_drag()` | no | End drag session |
| `run_kinematic(ctx)` | no | Run kinematic simulation |
| `num_frames()` | no | Frame count after simulation |
| `update_for_frame(i)` | no | Retrieve frame placements |
| `diagnose(ctx)` | no | Detect redundant/conflicting constraints |
| `is_deterministic()` | no | Whether output is reproducible (default: true) |
| `export_native(path)` | no | Write solver-native debug file (e.g. ASMT) |
| `supports_bundle_fixed()` | no | Whether solver handles Fixed-joint bundling internally |
There are plans to explore a Graph Neural Network (GNN) approach to constraint solving that could complement or supplement the Lagrangian solver for specific use cases. This is not yet implemented. ## Core types
## Related: GSL All types live in `Types.h` with no FreeCAD dependencies, making the header standalone for future server/worker use.
The `src/3rdParty/GSL/` submodule is Microsoft's Guidelines Support Library (`github.com/microsoft/GSL`), providing C++ core guidelines utilities like `gsl::span` and `gsl::not_null`. It is a build dependency, not related to the constraint solver. **Transform** -- position `[x, y, z]` + unit quaternion `[w, x, y, z]`. Equivalent to `Base::Placement` but independent. Note the quaternion convention differs from `Base::Rotation` which uses `(x, y, z, w)` ordering; the adapter layer handles the swap.
**BaseJointKind** -- 24 primitive constraint types decomposed from FreeCAD's `JointType` and `DistanceType` enums. Covers point constraints (Coincident, PointOnLine, PointInPlane), axis/surface constraints (Concentric, Tangent, Planar), kinematic joints (Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal), mechanical elements (Gear, RackPinion), distance variants, and a `Custom` extension point.
**SolveContext** -- complete solver input: parts (with placements, mass, grounded flag), constraints (with markers, parameters, limits), optional motion definitions and simulation parameters.
**SolveResult** -- solver output: status code, updated part placements, DOF count, constraint diagnostics, and simulation frame count.
## SolverRegistry
Thread-safe singleton managing solver backends:
```cpp
auto& reg = SolverRegistry::instance();
// Registration (at module init)
reg.register_solver("ondsel", []() {
return std::make_unique<OndselAdapter>();
});
// Retrieval
auto solver = reg.get(); // default solver
auto solver = reg.get("ondsel"); // by name
// Queries
reg.available(); // ["ondsel", ...]
reg.joints_for("ondsel"); // [Fixed, Revolute, ...]
reg.set_default("ondsel");
```
Plugin discovery scans directories for shared libraries exporting `kcsolve_api_version()` and `kcsolve_create()`. Default paths: `KCSOLVE_PLUGIN_PATH` env var and `<prefix>/lib/kcsolve/`.
## OndselAdapter
The built-in solver backend wrapping OndselSolver's Lagrangian constraint formulation. Registered as `"ondsel"` at Assembly module initialization.
Supports all 24 joint types. The adapter translates between `SolveContext`/`SolveResult` and OndselSolver's internal `ASMTAssembly` representation, including:
- Part placement conversion (Transform <-> Base::Placement quaternion ordering)
- Constraint parameter mapping (BaseJointKind -> OndselSolver joint classes)
- Interactive drag protocol (pre_drag/drag_step/post_drag)
- Kinematic simulation (run_kinematic/num_frames/update_for_frame)
- Constraint diagnostics (redundancy detection via MbD system)
## Python bindings (kcsolve module)
The `kcsolve` pybind11 module exposes the full C++ API to Python. See [KCSolve Python API](../reference/kcsolve-python.md) for details.
Key capabilities:
- All enums, structs, and classes accessible from Python
- Subclass `IKCSolver` in pure Python to create new solver backends
- Register Python solvers at runtime via `kcsolve.register_solver()`
- Query the registry from the FreeCAD console
## File layout
```
src/Mod/Assembly/Solver/
├── Types.h # Enums and structs (no FreeCAD deps)
├── IKCSolver.h # Abstract solver interface
├── SolverRegistry.h/cpp # Singleton registry + plugin loading
├── OndselAdapter.h/cpp # OndselSolver wrapper
├── KCSolveGlobal.h # DLL export macros
├── CMakeLists.txt # Builds libKCSolve.so
└── bindings/
├── PyIKCSolver.h # pybind11 trampoline for Python subclasses
├── kcsolve_py.cpp # Module definition (enums, structs, classes)
└── CMakeLists.txt # Builds kcsolve.so (pybind11 module)
```
## Testing
- **18 C++ tests** (`KCSolve_tests_run`) covering SolverRegistry (8 tests) and OndselAdapter (10 tests including drag protocol and redundancy diagnosis)
- **16 Python tests** (`TestKCSolvePy`) covering module import, type bindings, registry functions, Python solver subclassing, and full register/load/solve round-trips
- **6 Python integration tests** (`TestSolverIntegration`) testing solver behavior through FreeCAD document objects
## Related
- [KCSolve Python API Reference](../reference/kcsolve-python.md)
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification

View File

@@ -13,7 +13,7 @@ Kindred Create uses **CMake** for build configuration, **pixi** (conda-based) fo
## CMake configuration ## CMake configuration
The root `CMakeLists.txt` defines: The root `CMakeLists.txt` defines:
- **Kindred Create version:** `0.1.0` (via `KINDRED_CREATE_VERSION`) - **Kindred Create version:** `0.1.5` (via `KINDRED_CREATE_VERSION`)
- **FreeCAD base version:** `1.0.0` (via `FREECAD_VERSION`) - **FreeCAD base version:** `1.0.0` (via `FREECAD_VERSION`)
- CMake policy settings for compatibility - CMake policy settings for compatibility
- ccache auto-detection - ccache auto-detection
@@ -25,7 +25,7 @@ The root `CMakeLists.txt` defines:
The version flows from CMake to Python via `configure_file()`: The version flows from CMake to Python via `configure_file()`:
``` ```
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.0") CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.5")
→ src/Mod/Create/version.py.in (template) → src/Mod/Create/version.py.in (template)
→ build/*/Mod/Create/version.py (generated) → build/*/Mod/Create/version.py (generated)
→ update_checker.py (imports VERSION) → update_checker.py (imports VERSION)

View File

@@ -157,7 +157,7 @@ Edit only the canonical file in `Stylesheets/` — the preference pack copy is g
Defined in the top-level `CMakeLists.txt` and injected as compiler definitions: Defined in the top-level `CMakeLists.txt` and injected as compiler definitions:
```cmake ```cmake
set(KINDRED_CREATE_VERSION "0.1.0") set(KINDRED_CREATE_VERSION "0.1.5")
set(FREECAD_VERSION "1.0.0") set(FREECAD_VERSION "1.0.0")
add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}") add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}")

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

@@ -53,6 +53,7 @@ The silo-mod repository was split from a monorepo into three repos: `silo-client
| `Silo_TagProjects` | Multi-select dialog for assigning project tags to items | | `Silo_TagProjects` | Multi-select dialog for assigning project tags to items |
| `Silo_Rollback` | Select a previous revision and create a new revision from that point with optional comment | | `Silo_Rollback` | Select a previous revision and create a new revision from that point with optional comment |
| `Silo_SetStatus` | Change revision lifecycle status: draft → review → released → obsolete | | `Silo_SetStatus` | Change revision lifecycle status: draft → review → released → obsolete |
| `Silo_SaveAsTemplate` | Save a copy of the current document as a reusable [template](./templates.md) with metadata |
### Administration ### Administration
@@ -129,9 +130,11 @@ mods/silo/
├── freecad/ ├── freecad/
│ ├── InitGui.py # SiloWorkbench registration │ ├── InitGui.py # SiloWorkbench registration
│ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog) │ ├── schema_form.py # Schema-driven item creation dialog (SchemaFormDialog)
│ ├── silo_commands.py # 14 commands + dock widgets │ ├── silo_commands.py # 15 commands + dock widgets
│ ├── silo_origin.py # FileOrigin backend │ ├── silo_origin.py # FileOrigin backend
│ ├── silo_start.py # Native start panel (database items, activity feed) │ ├── silo_start.py # Native start panel (database items, activity feed)
│ ├── templates.py # Template discovery, filtering, injection
│ ├── templates/ # System template .kc files + CLI injection tool
│ └── resources/icons/ # 10 silo-*.svg icons │ └── resources/icons/ # 10 silo-*.svg icons
├── silo-client/ # Shared Python API client (nested submodule) ├── silo-client/ # Shared Python API client (nested submodule)
│ └── silo_client/ │ └── silo_client/

140
docs/src/guide/templates.md Normal file
View File

@@ -0,0 +1,140 @@
# Document Templates
Templates let you create new parts and assemblies from pre-configured `.kc` files. Instead of starting from a bare `App::Part` or `Assembly::AssemblyObject`, a template can include predefined tree structures, jobs, metadata, and workbench-specific features.
## How templates work
A template is a normal `.kc` file with an extra `silo/template.json` descriptor inside the ZIP archive. When you select a template during **Silo > New**:
1. The template `.kc` is **copied** to the canonical file path
2. `silo/template.json` and `silo/manifest.json` are **stripped** from the copy
3. The document is **opened** in FreeCAD
4. Silo properties (part number, item ID, revision, type) are **stamped** onto the root object
5. On **save**, `kc_format.py` auto-creates a fresh manifest
The original template file is never modified.
## Using templates
### Creating a new item from a template
1. **Silo > New** (Ctrl+N)
2. Select an **Item Type** (Part, Assembly, etc.)
3. The **Template** dropdown shows templates matching the selected type and category
4. Select a template (or leave as "No template" for a blank document)
5. Fill in the remaining fields and click **Create**
The template combo updates automatically when you change the item type or category.
### Saving a document as a template
1. Open the document you want to use as a template
2. **Silo > Save as Template**
3. Fill in the template metadata:
- **Name** — display name shown in the template picker (pre-filled from document label)
- **Description** — what the template is for
- **Item Types** — which types this template applies to (part, assembly, etc.)
- **Categories** — category prefix filter (e.g. `F`, `M01`); leave empty for all categories
- **Author** — pre-filled from your Silo login
- **Tags** — comma-separated search tags
4. Click **Save Template**
5. Optionally upload to Silo for team sharing
The template is saved as a copy to your personal templates directory. The original document is unchanged.
## Template search paths
Templates are discovered from three locations, checked in order. Later paths shadow earlier ones by name (so you can override a system template with a personal one).
| Priority | Path | Purpose |
|----------|------|---------|
| 1 (lowest) | `mods/silo/freecad/templates/` | System templates shipped with the addon |
| 2 | `~/.local/share/FreeCAD/Templates/` | Personal templates (sister to `Macro/`) |
| 3 (highest) | `~/projects/templates/` | Org-shared project templates |
The personal templates directory (`Templates/`) is created automatically when you first save a template. It lives alongside the `Macro/` directory in your FreeCAD user data.
## Template descriptor schema
The `silo/template.json` file inside the `.kc` ZIP has the following structure:
```json
{
"template_version": "1.0",
"name": "Sheet Metal Part",
"description": "Body with SheetMetal base feature and laser-cut job",
"item_types": ["part"],
"categories": [],
"icon": "sheet-metal",
"author": "Kindred Systems",
"tags": ["sheet metal", "fabrication"]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `template_version` | string | yes | Schema version, currently `"1.0"` |
| `name` | string | yes | Display name in the template picker |
| `description` | string | no | Human-readable purpose |
| `item_types` | string[] | yes | Controls visibility — `["part"]`, `["assembly"]`, or both |
| `categories` | string[] | no | Category prefix filter. Empty array means all categories |
| `icon` | string | no | Icon identifier (reserved for future use) |
| `author` | string | no | Template author |
| `tags` | string[] | no | Searchable metadata tags |
### Filtering rules
- **item_types**: The template only appears when the selected item type is in this list
- **categories**: If non-empty, the template only appears when the selected category starts with one of the listed prefixes. An empty list means the template is available for all categories
## Creating templates from the command line
The `inject_template.py` CLI tool can inject `silo/template.json` into any `.kc` file:
```bash
cd mods/silo/freecad/templates/
# Create a template from an existing .kc file
python inject_template.py my-part.kc "My Custom Part" \
--type part \
--description "Part with custom features" \
--author "Your Name" \
--tag "custom"
# Assembly template
python inject_template.py my-assembly.kc "My Assembly" \
--type assembly \
--description "Assembly with predefined joint groups"
# Template with category filtering
python inject_template.py sheet-metal.kc "Sheet Metal Part" \
--type part \
--category S \
--category X \
--tag "sheet metal" \
--tag "fabrication"
```
## Module structure
```
mods/silo/freecad/
├── templates.py # Discovery, filtering, injection helpers
├── templates/
│ └── inject_template.py # CLI tool for injecting template.json
├── schema_form.py # Template combo in New Item form
└── silo_commands.py # SaveAsTemplateDialog, Silo_SaveAsTemplate,
# SiloSync.create_document_from_template()
```
### Key functions
| Function | File | Purpose |
|----------|------|---------|
| `discover_templates()` | `templates.py` | Scan search paths for `.kc` files with `silo/template.json` |
| `filter_templates()` | `templates.py` | Filter by item type and category prefix |
| `inject_template_json()` | `templates.py` | Inject/replace `silo/template.json` in a `.kc` ZIP |
| `get_default_template_dir()` | `templates.py` | Returns `{userAppData}/Templates/`, creating if needed |
| `get_search_paths()` | `templates.py` | Returns the 3-tier search path list |
| `create_document_from_template()` | `silo_commands.py` | Copy template, strip identity, stamp Silo properties |
| `_clean_template_zip()` | `silo_commands.py` | Strip `silo/template.json` and `silo/manifest.json` from a copy |

441
docs/src/quicknav/SPEC.md Normal file
View File

@@ -0,0 +1,441 @@
# QuickNav — Keyboard Navigation Addon Specification
**Addon name:** QuickNav
**Type:** Pure Python FreeCAD addon (no C++ required)
**Compatibility:** FreeCAD 1.0+, Kindred Create 0.1+
**Location:** `mods/quicknav/`
---
## 1. Overview
QuickNav provides keyboard-driven command access for FreeCAD and Kindred Create. It replaces mouse-heavy toolbar navigation with a numbered key system organized by workbench and command grouping. The addon is activated by loading its workbench and toggled on/off with the `0` key.
### Design Goals
- Numbers `1-9` execute commands within the active command grouping
- `Shift+1-9` switches command grouping within the active workbench
- `Ctrl+1-9` switches workbench context
- All groupings and workbenches are ordered by most-recently-used (MRU) history
- History is unlimited internally, top 9 shown, remainder scrollable/clickable
- Mouse interaction remains fully functional — QuickNav is purely additive
- Configuration persisted via `FreeCAD.ParamGet()`
---
## 2. Terminology
| Term | Definition |
|------|-----------|
| **Workbench** | A FreeCAD workbench (Sketcher, PartDesign, Assembly, etc.). Fixed assignment to Ctrl+N slots. |
| **Command Grouping** | A logical group of commands within a workbench, mapped from existing FreeCAD toolbar groupings. Max 9 per tier. |
| **Active Grouping** | The left-most visible grouping in the navigation bar. Its commands are accessible via `1-9`. |
| **Navigation Bar** | Bottom toolbar displaying the current state: active workbench, groupings, and numbered commands. |
| **MRU Stack** | Most-recently-used ordering. Position 0 = currently active, 1 = previously active, etc. |
| **Tier** | When a workbench has >9 command groupings, they are split: Tier 1 (most common 9), Tier 2 (next 9). |
---
## 3. Key Bindings
### 3.1 Mode Toggle
| Key | Action |
|-----|--------|
| `0` | Toggle QuickNav on/off. When off, all QuickNav key interception is disabled and the navigation bar hides. |
### 3.2 Command Execution
| Key | Action |
|-----|--------|
| `1-9` | Execute the Nth command in the active grouping. If the command is auto-executable (e.g., Pad after closed sketch), execute immediately. Otherwise, enter tool mode (same as clicking the toolbar button). |
### 3.3 Grouping Navigation
| Key | Action |
|-----|--------|
| `Shift+1-9` | Switch to the Nth command grouping (MRU ordered) within the current workbench. The newly activated grouping moves to position 0 in the MRU stack. |
| `Shift+Left/Right` | Scroll through groupings beyond the visible 9. |
### 3.4 Workbench Navigation
| Key | Action |
|-----|--------|
| `Ctrl+1` | Sketcher |
| `Ctrl+2` | Part Design |
| `Ctrl+3` | Assembly |
| `Ctrl+4` | Spreadsheet |
| `Ctrl+5` | TechDraw |
| `Ctrl+6-9` | User-configurable / additional workbenches |
Switching workbench via `Ctrl+N` also restores that workbench's last-active command grouping.
---
## 4. Navigation Bar
The navigation bar is a `QToolBar` positioned at the bottom of the main window (replacing or sitting alongside FreeCAD's default bottom toolbar area).
### 4.1 Layout
```
┌─────────────────────────────────────────────────────────────────────┐
│ [WB: Sketcher] │ ❶ Primitives │ ② Constraints │ ③ Dimensions │ ◀▶ │
│ │ 1:Line 2:Rect 3:Circle 4:Arc 5:Point 6:Slot ... │
└─────────────────────────────────────────────────────────────────────┘
```
- **Left section:** Current workbench name with Ctrl+N hint
- **Middle section (top row):** Command groupings, MRU ordered. Active grouping is ❶ (filled circle), others are ②③ etc. Scrollable horizontally if >9.
- **Middle section (bottom row):** Commands within the active grouping, numbered 1-9
- **Right section:** Scroll arrows for overflow groupings
### 4.2 Visual States
- **Active grouping:** Bold text, filled number badge, Catppuccin Mocha `blue` (#89b4fa) accent
- **Inactive groupings:** Normal text, outlined number badge, `surface1` (#45475a) text
- **Hovered command:** `surface2` (#585b70) background highlight
- **Active command (tool in use):** `green` (#a6e3a1) underline indicator
### 4.3 Mouse Interaction
- Click any grouping to activate it (equivalent to Shift+N)
- Click any command to execute it (equivalent to pressing N)
- Scroll wheel on grouping area to cycle through overflow groupings
- Click scroll arrows to page through overflow
---
## 5. Workbench Command Groupings
Each workbench's existing FreeCAD toolbars map to command groupings. Where a workbench has >9 toolbars, split into Tier 1 (default, most common) and Tier 2 (accessible via scrolling or `Shift+Left/Right`).
### 5.1 Sketcher (Ctrl+1)
| Grouping | Commands (1-9) |
|----------|---------------|
| Primitives | Line, Rectangle, Circle, Arc, Point, Slot, B-Spline, Polyline, Ellipse |
| Constraints | Coincident, Horizontal, Vertical, Parallel, Perpendicular, Tangent, Equal, Symmetric, Block |
| Dimensions | Distance, Horizontal Distance, Vertical Distance, Radius, Diameter, Angle, Lock, Constrain Refraction |
| Construction | Toggle Construction, External Geometry, Carbon Copy, Offset, Trim, Extend, Split |
| Tools | Mirror, Array (Linear), Array (Polar), Move, Rotate, Scale, Close Shape, Connect Edges |
### 5.2 Part Design (Ctrl+2)
| Grouping | Commands (1-9) |
|----------|---------------|
| Additive | Pad, Revolution, Additive Loft, Additive Pipe, Additive Helix, Additive Box, Additive Cylinder, Additive Sphere, Additive Cone |
| Subtractive | Pocket, Hole, Groove, Subtractive Loft, Subtractive Pipe, Subtractive Helix, Subtractive Box, Subtractive Cylinder, Subtractive Sphere |
| Datums | New Sketch, Datum Plane, Datum Line, Datum Point, Shape Binder, Sub-Shape Binder, ZTools Datum Creator, ZTools Datum Manager |
| Transformations | Mirrored, Linear Pattern, Polar Pattern, MultiTransform, ZTools Rotated Linear Pattern |
| Modeling | Fillet, Chamfer, Draft, Thickness, Boolean, ZTools Enhanced Pocket |
### 5.3 Assembly (Ctrl+3)
| Grouping | Commands (1-9) |
|----------|---------------|
| Components | Insert Component, Create Part, Create Assembly, Ground, BOM |
| Joints | Fixed, Revolute, Cylindrical, Slider, Ball, Planar, Distance, Angle, Parallel |
| Patterns | ZTools Linear Pattern, ZTools Polar Pattern |
### 5.4 Spreadsheet (Ctrl+4)
| Grouping | Commands (1-9) |
|----------|---------------|
| Editing | Merge Cells, Split Cell, Alias, Import CSV, Export CSV |
| Formatting | Bold, Italic, Underline, Align Left, Align Center, Align Right, BG Color, Text Color, Quick Alias |
### 5.5 TechDraw (Ctrl+5)
Groupings derived from TechDraw's existing toolbars at runtime.
> **Note:** The exact command lists above are initial defaults. The addon discovers available commands from each workbench's toolbar structure at activation time and falls back to these defaults only if discovery fails.
---
## 6. MRU History Behavior
### 6.1 Grouping History (per workbench)
Each workbench maintains its own grouping MRU stack.
- When a grouping is activated (via `Shift+N` or mouse click), it moves to position 0
- The previously active grouping moves to position 1, everything else shifts down
- Position 0 is always the active grouping (already selected, shown leftmost)
- `Shift+1` is a no-op (already active), `Shift+2` activates the previous grouping, etc.
### 6.2 Workbench History
- Workbenches have fixed Ctrl+N assignments (not MRU ordered)
- However, each workbench remembers its last-active grouping
- Switching to a workbench restores its last-active grouping as position 0
### 6.3 Persistence
Stored in `FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/QuickNav")`:
| Parameter | Type | Description |
|-----------|------|-------------|
| `Enabled` | Bool | Whether QuickNav is currently active |
| `GroupHistory/<Workbench>` | String | Semicolon-delimited list of grouping names in MRU order |
| `LastGrouping/<Workbench>` | String | Name of the last-active grouping per workbench |
| `CustomSlots/Ctrl6` through `Ctrl9` | String | Workbench names for user-configurable slots |
---
## 7. Auto-Execution Logic
When a command is invoked via number key, QuickNav checks if the command can be auto-executed:
### 7.1 Auto-Execute Conditions
A command auto-executes (runs and completes without entering a persistent mode) when:
1. **Pad/Pocket after closed sketch:** If the active body has a sketch that was just closed (sketch edit mode exited with a closed profile), pressing the Pad or Pocket command key creates the feature with default parameters. The task panel still opens for parameter adjustment.
2. **Boolean operations:** If exactly two bodies/shapes are selected, boolean commands execute with defaults.
3. **Constraint application:** If appropriate geometry is pre-selected in Sketcher, constraint commands apply immediately.
### 7.2 Mode-Entry (Default)
All other commands enter their standard FreeCAD tool mode — identical to clicking the toolbar button. The user interacts with the 3D view and/or task panel as normal.
---
## 8. Key Event Handling
### 8.1 Event Filter Architecture
```python
class QuickNavEventFilter(QObject):
"""Installed on FreeCAD's main window via installEventFilter().
Intercepts KeyPress events when QuickNav is active.
Passes through all events when QuickNav is inactive.
"""
def eventFilter(self, obj, event):
if event.type() != QEvent.KeyPress:
return False
if not self._active:
return False
# Don't intercept when a text input widget has focus
focused = QApplication.focusWidget()
if isinstance(focused, (QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox)):
return False
# Don't intercept when task panel input fields are focused
if self._is_task_panel_input(focused):
return False
key = event.key()
modifiers = event.modifiers()
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
self.toggle_active()
return True
if key >= Qt.Key_1 and key <= Qt.Key_9:
n = key - Qt.Key_0
if modifiers == Qt.ControlModifier:
self.switch_workbench(n)
return True
elif modifiers == Qt.ShiftModifier:
self.switch_grouping(n)
return True
elif modifiers == Qt.NoModifier:
self.execute_command(n)
return True
return False # Pass through all other keys
```
### 8.2 Conflict Resolution
QuickNav's event filter takes priority when active. FreeCAD's existing keybindings for `Ctrl+1` through `Ctrl+9` (if any) are overridden while QuickNav is enabled. The original bindings are restored when QuickNav is toggled off or unloaded.
Existing `Shift+` and bare number key bindings in FreeCAD are similarly overridden only while QuickNav is active. This is safe because:
- FreeCAD does not use bare number keys as shortcuts by default
- Shift+number is not commonly bound in default FreeCAD
### 8.3 Input Widget Safety
The event filter must NOT intercept keys when the user is:
- Typing in the Python console
- Entering values in the task panel (dimensions, parameters)
- Editing spreadsheet cells
- Typing in any `QLineEdit`, `QTextEdit`, `QSpinBox`, or `QDoubleSpinBox`
- Using the Sketcher's inline dimension input
---
## 9. Addon Structure
```
mods/quicknav/
├── package.xml # FreeCAD addon manifest with <kindred> extension
├── Init.py # Non-GUI initialization (no-op)
├── InitGui.py # Registers QuickNavWorkbench
├── quicknav/
│ ├── __init__.py
│ ├── core.py # QuickNavManager singleton — orchestrates state
│ ├── event_filter.py # QuickNavEventFilter (QObject)
│ ├── nav_bar.py # NavigationBar (QToolBar subclass)
│ ├── workbench_map.py # Fixed workbench → Ctrl+N mapping + grouping discovery
│ ├── history.py # MRU stack with ParamGet persistence
│ ├── auto_exec.py # Auto-execution condition checks
│ ├── commands.py # FreeCAD command wrappers (QuickNav_Toggle, etc.)
│ └── resources/
│ ├── icons/ # Number badge SVGs, QuickNav icon
│ └── theme.py # Catppuccin Mocha color tokens
└── tests/
└── test_history.py # MRU stack unit tests
```
### 9.1 Manifest
```xml
<?xml version="1.0" encoding="UTF-8"?>
<package format="1">
<name>QuickNav</name>
<description>Keyboard-driven toolbar navigation</description>
<version>0.1.0</version>
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
<license>LGPL-2.1</license>
<content>
<workbench>
<classname>QuickNavWorkbench</classname>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>10</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>
```
### 9.2 Activation
QuickNav activates when its workbench is loaded (via the addon loader or manual activation). It installs the event filter on the main window and creates the navigation bar. The workbench itself is invisible — it does not add its own toolbars or menus beyond the navigation bar. It acts as a transparent overlay on whatever workbench the user is actually working in.
```python
class QuickNavWorkbench(Gui.Workbench):
"""Invisible workbench that installs QuickNav on load.
QuickNav doesn't replace the active workbench — it layers on top.
Loading QuickNav installs the event filter and nav bar, then
immediately re-activates the previously active workbench.
"""
def Initialize(self):
QuickNavManager.instance().install()
def Activated(self):
# Re-activate the previous workbench so QuickNav is transparent
prev = QuickNavManager.instance().previous_workbench
if prev:
Gui.activateWorkbench(prev)
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
```
**Alternative (preferred for Create):** Instead of a workbench, QuickNav can be activated directly from `Create/InitGui.py` at boot, gated by the `Enabled` preference. This avoids the workbench-switching dance entirely. The `QuickNavWorkbench` registration is kept for standalone FreeCAD compatibility.
---
## 10. Command Discovery
At activation time, QuickNav introspects each workbench's toolbars to build the command grouping map.
```python
def discover_groupings(workbench_name: str) -> list[CommandGrouping]:
"""Discover command groupings from a workbench's toolbar structure.
1. Temporarily activate the workbench (if not already active)
2. Enumerate QToolBars from the main window
3. Map toolbar name → list of QAction names
4. Filter out non-command actions (separators, widgets)
5. Split into tiers if >9 groupings
6. Restore the previously active workbench
"""
```
### 10.1 Fallback Defaults
If toolbar discovery fails (workbench not initialized, empty toolbars), QuickNav falls back to the hardcoded groupings in Section 5. These are stored as a Python dict in `workbench_map.py`.
### 10.2 ZTools Integration
ZTools commands injected via `WorkbenchManipulator` appear in the discovered toolbars and are automatically included in the relevant groupings. No special handling is needed — QuickNav discovers commands after all manipulators have run.
---
## 11. FreeCAD Compatibility
QuickNav is designed as a standalone FreeCAD addon that works without Kindred Create or the SDK.
| Feature | FreeCAD | Kindred Create |
|---------|---------|----------------|
| Core navigation (keys, nav bar) | ✅ | ✅ |
| Catppuccin Mocha theming | ❌ (uses Qt defaults) | ✅ (via SDK theme tokens) |
| Auto-boot on startup | ❌ (manual workbench activation) | ✅ (via addon loader) |
| ZTools commands in groupings | ❌ (not present) | ✅ (discovered from manipulated toolbars) |
The SDK dependency is optional — QuickNav checks for `kindred_sdk` availability and degrades gracefully:
```python
try:
from kindred_sdk.theme import get_theme_tokens
THEME = get_theme_tokens()
except ImportError:
THEME = None # Use Qt default palette
```
---
## 12. Implementation Phases
### Phase 1: Core Infrastructure
- Event filter with key interception and input widget safety
- QuickNavManager singleton with toggle on/off
- Navigation bar widget (QToolBar) with basic layout
- Hardcoded workbench/grouping maps from Section 5
- ParamGet persistence for enabled state
### Phase 2: Dynamic Discovery
- Toolbar introspection for command grouping discovery
- MRU history with persistence
- Grouping overflow scrolling
- Workbench restore (last-active grouping per workbench)
### Phase 3: Auto-Execution
- Context-aware auto-execute logic
- Sketcher closed-profile detection for Pad/Pocket
- Pre-selection constraint application
### Phase 4: Polish
- Number badge SVG icons
- Catppuccin Mocha theming (conditional on SDK)
- Scroll animations
- Settings dialog (custom Ctrl+6-9 assignments)
- FreeCAD standalone packaging
---
## 13. Open Questions
1. **Tier switching UX:** When a workbench has >9 groupings split into tiers, should `Shift+0` toggle between tiers, or should tiers be purely a scroll/mouse concept?
2. **Visual number badges:** Should the commands in the nav bar show keycap-style badges (like `⌨ 1`) or just prepend the number (`1: Line`)?
3. **Sketcher inline dimension input:** FreeCAD's Sketcher has an inline dimension entry that isn't a standard QLineEdit. Need to verify the event filter correctly identifies and skips this widget.
4. **Ctrl+N conflicts with Create shortcuts:** Verify that Create/Silo don't already bind Ctrl+1 through Ctrl+9. The Silo toggle uses Ctrl+O/S/N, so these should be clear.

View File

@@ -77,7 +77,7 @@ Defined in the root `CMakeLists.txt`:
| Constant | Value | Description | | Constant | Value | Description |
|----------|-------|-------------| |----------|-------|-------------|
| `KINDRED_CREATE_VERSION` | `0.1.0` | Kindred Create version | | `KINDRED_CREATE_VERSION` | `0.1.5` | Kindred Create version |
| `FREECAD_VERSION` | `1.0.0` | FreeCAD base version | | `FREECAD_VERSION` | `1.0.0` | FreeCAD base version |
These are injected into `src/Mod/Create/version.py` at build time via `version.py.in`. These are injected into `src/Mod/Create/version.py` at build time via `version.py.in`.

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)

View File

@@ -0,0 +1,429 @@
# KCSolve Python API Reference
The `kcsolve` module provides Python access to the KCSolve pluggable solver framework. It is built with pybind11 and installed alongside the Assembly module.
```python
import kcsolve
```
## Module constants
| Name | Value | Description |
|------|-------|-------------|
| `API_VERSION_MAJOR` | `1` | KCSolve API major version |
## Enums
### BaseJointKind
Primitive constraint types. 24 values:
`Coincident`, `PointOnLine`, `PointInPlane`, `Concentric`, `Tangent`, `Planar`, `LineInPlane`, `Parallel`, `Perpendicular`, `Angle`, `Fixed`, `Revolute`, `Cylindrical`, `Slider`, `Ball`, `Screw`, `Universal`, `Gear`, `RackPinion`, `Cam`, `Slot`, `DistancePointPoint`, `DistanceCylSph`, `Custom`
### SolveStatus
| Value | Meaning |
|-------|---------|
| `Success` | Solve converged |
| `Failed` | Solve did not converge |
| `InvalidFlip` | Orientation flipped past threshold |
| `NoGroundedParts` | No grounded parts in assembly |
### DiagnosticKind
`Redundant`, `Conflicting`, `PartiallyRedundant`, `Malformed`
### MotionKind
`Rotational`, `Translational`, `General`
### LimitKind
`TranslationMin`, `TranslationMax`, `RotationMin`, `RotationMax`
## Structs
### Transform
Rigid-body transform: position + unit quaternion.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `position` | `list[float]` (3) | `[0, 0, 0]` | Translation (x, y, z) |
| `quaternion` | `list[float]` (4) | `[1, 0, 0, 0]` | Unit quaternion (w, x, y, z) |
```python
t = kcsolve.Transform()
t = kcsolve.Transform.identity() # same as default
```
Note: quaternion convention is `(w, x, y, z)`, which differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles conversion.
### Part
| Field | Type | Default |
|-------|------|---------|
| `id` | `str` | `""` |
| `placement` | `Transform` | identity |
| `mass` | `float` | `1.0` |
| `grounded` | `bool` | `False` |
### Constraint
A constraint between two parts, built from a FreeCAD JointObject by the adapter layer.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | `str` | `""` | FreeCAD document object name (e.g. `"Joint001"`) |
| `part_i` | `str` | `""` | Solver-side part ID for first reference |
| `marker_i` | `Transform` | identity | Coordinate system on `part_i` (attachment point/orientation) |
| `part_j` | `str` | `""` | Solver-side part ID for second reference |
| `marker_j` | `Transform` | identity | Coordinate system on `part_j` (attachment point/orientation) |
| `type` | `BaseJointKind` | `Coincident` | Constraint type |
| `params` | `list[float]` | `[]` | Scalar parameters (interpretation depends on `type`) |
| `limits` | `list[Constraint.Limit]` | `[]` | Joint travel limits |
| `activated` | `bool` | `True` | Whether this constraint is active |
**`marker_i` / `marker_j`** -- Define the local coordinate frames on each part where the joint acts. For example, a Revolute joint's markers define the hinge axis direction and attachment points on each part.
**`params`** -- Interpretation depends on `type`:
| Type | params[0] | params[1] |
|------|-----------|-----------|
| `Angle` | angle (radians) | |
| `RackPinion` | pitch radius | |
| `Screw` | pitch | |
| `Gear` | radius I | radius J (negative for belt) |
| `DistancePointPoint` | distance | |
| `DistanceCylSph` | distance | |
| `Planar` | offset | |
| `Concentric` | distance | |
| `PointInPlane` | offset | |
| `LineInPlane` | offset | |
### Constraint.Limit
Joint travel limits (translation or rotation bounds).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `kind` | `LimitKind` | `TranslationMin` | Which degree of freedom to limit |
| `value` | `float` | `0.0` | Limit value (meters for translation, radians for rotation) |
| `tolerance` | `float` | `1e-9` | Solver tolerance for limit enforcement |
### MotionDef
A motion driver for kinematic simulation. Defines time-dependent actuation of a constraint.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `kind` | `MotionKind` | `Rotational` | Type of motion: `Rotational`, `Translational`, or `General` (both) |
| `joint_id` | `str` | `""` | ID of the constraint this motion drives |
| `marker_i` | `str` | `""` | Reference marker on first part |
| `marker_j` | `str` | `""` | Reference marker on second part |
| `rotation_expr` | `str` | `""` | Rotation law as a function of time `t` (e.g. `"2*pi*t"`) |
| `translation_expr` | `str` | `""` | Translation law as a function of time `t` (e.g. `"10*t"`) |
For `Rotational` kind, only `rotation_expr` is used. For `Translational`, only `translation_expr`. For `General`, both are set.
### SimulationParams
Time-stepping parameters for kinematic simulation via `run_kinematic()`.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `t_start` | `float` | `0.0` | Simulation start time (seconds) |
| `t_end` | `float` | `1.0` | Simulation end time (seconds) |
| `h_out` | `float` | `0.01` | Output time step -- controls frame rate (e.g. `0.04` = 25 fps) |
| `h_min` | `float` | `1e-9` | Minimum internal integration step |
| `h_max` | `float` | `1.0` | Maximum internal integration step |
| `error_tol` | `float` | `1e-6` | Error tolerance for adaptive time stepping |
### SolveContext
Complete input to a solve operation. Built by the adapter layer from FreeCAD document objects, or constructed manually for scripted solving.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `parts` | `list[Part]` | `[]` | All parts in the assembly |
| `constraints` | `list[Constraint]` | `[]` | Constraints between parts |
| `motions` | `list[MotionDef]` | `[]` | Motion drivers for kinematic simulation |
| `simulation` | `SimulationParams` or `None` | `None` | Time-stepping parameters for `run_kinematic()` |
| `bundle_fixed` | `bool` | `False` | Hint to merge Fixed-joint-connected parts into rigid bodies |
**`motions`** -- Motion drivers define time-dependent joint actuation for kinematic simulation. Each `MotionDef` references a constraint by `joint_id` and provides expressions (functions of time `t`) for rotation and/or translation. Only used when calling `run_kinematic()`.
**`simulation`** -- When set, provides time-stepping parameters (`t_start`, `t_end`, step sizes, error tolerance) for kinematic simulation via `run_kinematic()`. When `None`, kinematic simulation is not requested.
**`bundle_fixed`** -- When `True`, parts connected by `Fixed` joints should be merged into single rigid bodies before solving, reducing the problem size. If the solver reports `supports_bundle_fixed() == True`, it handles this internally. Otherwise, the caller (adapter layer) pre-bundles before building the context.
**Important:** pybind11 returns copies of `list` fields, not references. Use whole-list assignment:
```python
ctx = kcsolve.SolveContext()
p = kcsolve.Part()
p.id = "box1"
ctx.parts = [p] # correct
# ctx.parts.append(p) # does NOT modify ctx
```
### ConstraintDiagnostic
| Field | Type | Default |
|-------|------|---------|
| `constraint_id` | `str` | `""` |
| `kind` | `DiagnosticKind` | `Redundant` |
| `detail` | `str` | `""` |
### SolveResult
| Field | Type | Default |
|-------|------|---------|
| `status` | `SolveStatus` | `Success` |
| `placements` | `list[SolveResult.PartResult]` | `[]` |
| `dof` | `int` | `-1` |
| `diagnostics` | `list[ConstraintDiagnostic]` | `[]` |
| `num_frames` | `int` | `0` |
### SolveResult.PartResult
| Field | Type | Default |
|-------|------|---------|
| `id` | `str` | `""` |
| `placement` | `Transform` | identity |
## Classes
### IKCSolver
Abstract base class for solver backends. Subclass in Python to create custom solvers.
Three methods must be implemented:
```python
class MySolver(kcsolve.IKCSolver):
def name(self):
return "My Solver"
def supported_joints(self):
return [kcsolve.BaseJointKind.Fixed, kcsolve.BaseJointKind.Revolute]
def solve(self, ctx):
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success
return result
```
All other methods are optional and have default implementations. Override them to add capabilities beyond basic solving.
#### update(ctx) -> SolveResult
Incrementally re-solve after parameter changes (e.g. joint angle adjusted during creation). Solvers can optimize this path since only parameters changed, not topology. Default: delegates to `solve()`.
```python
def update(self, ctx):
# Only re-evaluate changed constraints, reuse cached factorization
return self._incremental_solve(ctx)
```
#### Interactive drag protocol
Three-phase protocol for interactive part dragging in the viewport. Solvers can maintain internal state across the drag session for better performance.
**pre_drag(ctx, drag_parts) -> SolveResult** -- Prepare for a drag session. `drag_parts` is a `list[str]` of part IDs being dragged. Solve the initial state and cache internal data. Default: delegates to `solve()`.
**drag_step(drag_placements) -> SolveResult** -- Called on each mouse move. `drag_placements` is a `list[SolveResult.PartResult]` with the current positions of dragged parts. Returns updated placements for all affected parts. Default: returns Success with no placements.
**post_drag()** -- End the drag session and release internal state. Default: no-op.
```python
def pre_drag(self, ctx, drag_parts):
self._cached_system = self._build_system(ctx)
return self.solve(ctx)
def drag_step(self, drag_placements):
# Use cached system for fast incremental solve
for dp in drag_placements:
self._cached_system.set_placement(dp.id, dp.placement)
return self._cached_system.solve_incremental()
def post_drag(self):
self._cached_system = None
```
#### Kinematic simulation
**run_kinematic(ctx) -> SolveResult** -- Run a kinematic simulation over the time range in `ctx.simulation`. After this call, `num_frames()` returns the frame count and `update_for_frame(i)` retrieves individual frames. Requires `ctx.simulation` to be set and `ctx.motions` to contain at least one motion driver. Default: returns Failed.
**num_frames() -> int** -- Number of simulation frames available after `run_kinematic()`. Default: returns 0.
**update_for_frame(index) -> SolveResult** -- Retrieve part placements for simulation frame at `index` (0-based, must be < `num_frames()`). Default: returns Failed.
```python
# Run a kinematic simulation
ctx.simulation = kcsolve.SimulationParams()
ctx.simulation.t_start = 0.0
ctx.simulation.t_end = 2.0
ctx.simulation.h_out = 0.04 # 25 fps
motion = kcsolve.MotionDef()
motion.kind = kcsolve.MotionKind.Rotational
motion.joint_id = "Joint001"
motion.rotation_expr = "2*pi*t" # one revolution per second
ctx.motions = [motion]
solver = kcsolve.load("ondsel")
result = solver.run_kinematic(ctx)
for i in range(solver.num_frames()):
frame = solver.update_for_frame(i)
for pr in frame.placements:
print(f"frame {i}: {pr.id} at {list(pr.placement.position)}")
```
#### diagnose(ctx) -> list[ConstraintDiagnostic]
Analyze the assembly for redundant, conflicting, or malformed constraints. May require a prior `solve()` call for some solvers. Returns a list of `ConstraintDiagnostic` objects. Default: returns empty list.
```python
diags = solver.diagnose(ctx)
for d in diags:
if d.kind == kcsolve.DiagnosticKind.Redundant:
print(f"Redundant: {d.constraint_id} - {d.detail}")
elif d.kind == kcsolve.DiagnosticKind.Conflicting:
print(f"Conflict: {d.constraint_id} - {d.detail}")
```
#### is_deterministic() -> bool
Whether this solver produces identical results given identical input. Used for regression testing and result caching. Default: returns `True`.
#### export_native(path)
Write a solver-native debug/diagnostic file (e.g. ASMT format for OndselSolver). Requires a prior `solve()` or `run_kinematic()` call. Default: no-op.
```python
solver.solve(ctx)
solver.export_native("/tmp/debug.asmt")
```
#### supports_bundle_fixed() -> bool
Whether this solver handles Fixed-joint part bundling internally. When `False`, the caller merges Fixed-joint-connected parts into single rigid bodies before building the `SolveContext`, reducing problem size. When `True`, the solver receives unbundled parts and optimizes internally. Default: returns `False`.
### OndselAdapter
Built-in solver wrapping OndselSolver's Lagrangian constraint formulation. Inherits `IKCSolver`.
```python
solver = kcsolve.OndselAdapter()
solver.name() # "OndselSolver (Lagrangian)"
```
In practice, use `kcsolve.load("ondsel")` rather than constructing directly, as this goes through the registry.
## Module functions
### available()
Return names of all registered solvers.
```python
kcsolve.available() # ["ondsel"]
```
### load(name="")
Create an instance of the named solver. If `name` is empty, uses the default. Returns `None` if the solver is not found.
```python
solver = kcsolve.load("ondsel")
solver = kcsolve.load() # default solver
```
### joints_for(name)
Query supported joint types for a registered solver.
```python
joints = kcsolve.joints_for("ondsel")
# [BaseJointKind.Coincident, BaseJointKind.Fixed, ...]
```
### set_default(name)
Set the default solver name. Returns `True` if the name is registered.
```python
kcsolve.set_default("ondsel") # True
kcsolve.set_default("unknown") # False
```
### get_default()
Get the current default solver name.
```python
kcsolve.get_default() # "ondsel"
```
### register_solver(name, solver_class)
Register a Python solver class with the SolverRegistry. `solver_class` must be a callable that returns an `IKCSolver` subclass instance.
```python
class MySolver(kcsolve.IKCSolver):
def name(self): return "MySolver"
def supported_joints(self): return [kcsolve.BaseJointKind.Fixed]
def solve(self, ctx):
r = kcsolve.SolveResult()
r.status = kcsolve.SolveStatus.Success
return r
kcsolve.register_solver("my_solver", MySolver)
solver = kcsolve.load("my_solver")
```
## Complete example
```python
import kcsolve
# Build a two-part assembly with a Fixed joint
ctx = kcsolve.SolveContext()
base = kcsolve.Part()
base.id = "base"
base.grounded = True
arm = kcsolve.Part()
arm.id = "arm"
arm.placement.position = [100.0, 0.0, 0.0]
joint = kcsolve.Constraint()
joint.id = "Joint001"
joint.part_i = "base"
joint.part_j = "arm"
joint.type = kcsolve.BaseJointKind.Fixed
ctx.parts = [base, arm]
ctx.constraints = [joint]
# Solve
solver = kcsolve.load("ondsel")
result = solver.solve(ctx)
print(result.status) # SolveStatus.Success
for pr in result.placements:
print(f"{pr.id}: pos={list(pr.placement.position)}")
# Diagnostics
diags = solver.diagnose(ctx)
for d in diags:
print(f"{d.constraint_id}: {d.kind} - {d.detail}")
```
## Related
- [KCSolve Architecture](../architecture/ondsel-solver.md)
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- full architecture specification

View File

@@ -23,7 +23,7 @@ These cannot be disabled. They define what Silo *is*.
|-----------|------|-------------| |-----------|------|-------------|
| `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation | | `core` | Core PDM | Items, revisions, files, BOM, search, import/export, part number generation |
| `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors | | `schemas` | Schemas | Part numbering schema parsing, segment management, form descriptors |
| `storage` | Storage | MinIO/S3 file storage, presigned uploads, versioning | | `storage` | Storage | Filesystem storage |
### 2.2 Optional Modules ### 2.2 Optional Modules
@@ -470,12 +470,10 @@ Returns full config grouped by module with secrets redacted:
"default": "kindred-rd" "default": "kindred-rd"
}, },
"storage": { "storage": {
"endpoint": "minio:9000", "backend": "filesystem",
"bucket": "silo-files", "filesystem": {
"access_key": "****", "root_dir": "/var/lib/silo/data"
"secret_key": "****", },
"use_ssl": false,
"region": "us-east-1",
"status": "connected" "status": "connected"
}, },
"database": { "database": {
@@ -566,7 +564,7 @@ Available for modules with external connections:
| Module | Test Action | | Module | Test Action |
|--------|------------| |--------|------------|
| `storage` | Ping MinIO, verify bucket exists | | `storage` | Verify filesystem storage directory is accessible |
| `auth` (ldap) | Attempt LDAP bind with configured credentials | | `auth` (ldap) | Attempt LDAP bind with configured credentials |
| `auth` (oidc) | Fetch OIDC discovery document from issuer URL | | `auth` (oidc) | Fetch OIDC discovery document from issuer URL |
| `odoo` | Attempt XML-RPC connection to Odoo | | `odoo` | Attempt XML-RPC connection to Odoo |
@@ -602,11 +600,9 @@ database:
sslmode: disable sslmode: disable
storage: storage:
endpoint: minio:9000 backend: filesystem
bucket: silo-files filesystem:
access_key: silominio root_dir: /var/lib/silo/data
secret_key: silominiosecret
use_ssl: false
schemas: schemas:
directory: /etc/silo/schemas directory: /etc/silo/schemas

View File

@@ -0,0 +1,899 @@
# Solver Service Specification
**Status:** Draft
**Last Updated:** 2026-02-19
**Depends on:** KCSolve Phase 1 (PR #297), Phase 2 (PR #298)
---
## 1. Overview
The solver service extends Silo's job queue system with assembly constraint solving capabilities. It enables server-side solving of assemblies stored in Silo, with results streamed back to clients in real time via SSE.
This specification describes how the existing KCSolve client-side API (C++ library + pybind11 `kcsolve` module) integrates with Silo's worker infrastructure to provide headless, asynchronous constraint solving.
### 1.1 Goals
1. **Offload solving** -- Move heavy solve operations off the user's machine to server workers.
2. **Batch validation** -- Automatically validate assemblies on commit (e.g. check for over-constrained systems).
3. **Solver selection** -- Allow the server to run different solvers than the client (e.g. a more thorough solver for validation, a fast one for interactive editing).
4. **Standalone execution** -- Solver workers can run without a full FreeCAD installation, using just the `kcsolve` Python module and the `.kc` file.
### 1.2 Non-Goals
- **Interactive drag** -- Real-time drag solving stays client-side (latency-sensitive).
- **Geometry processing** -- Workers don't compute geometry; they receive pre-extracted constraint graphs.
- **Solver development** -- Writing new solver backends is out of scope; this spec covers the transport and execution layer.
---
## 2. Architecture
```
┌─────────────────────┐
│ Kindred Create │
│ (FreeCAD client) │
└───────┬──────────────┘
│ 1. POST /api/solver/jobs
│ (SolveContext JSON)
│ 4. GET /api/events (SSE)
│ solver.progress, solver.completed
┌─────────────────────┐
│ Silo Server │
│ (silod) │
│ │
│ solver module │
│ REST + SSE + queue │
└───────┬──────────────┘
│ 2. POST /api/runner/claim
│ 3. POST /api/runner/jobs/{id}/complete
┌─────────────────────┐
│ Solver Runner │
│ (silorunner) │
│ │
│ kcsolve module │
│ OndselAdapter │
│ Python solvers │
└─────────────────────┘
```
### 2.1 Components
| Component | Role | Deployment |
|-----------|------|------------|
| **Silo server** | Job queue management, REST API, SSE broadcast, result storage | Existing `silod` binary |
| **Solver runner** | Claims solver jobs, executes `kcsolve`, reports results | New runner tag `solver` on existing `silorunner` |
| **kcsolve module** | Python/C++ solver library (Phase 1+2) | Installed on runner nodes |
| **Create client** | Submits jobs, receives results via SSE | Existing FreeCAD client |
### 2.2 Module Registration
The solver service is a Silo module with ID `solver`, gated behind the existing module system:
```yaml
# config.yaml
modules:
solver:
enabled: true
```
It depends on the `jobs` module being enabled. All solver endpoints return `404` with `{"error": "module not enabled"}` when disabled.
---
## 3. Data Model
### 3.1 SolveContext JSON Schema
The `SolveContext` is the input to a solve operation. Currently it exists only as a C++ struct and pybind11 binding with no serialization. Phase 3 adds JSON serialization to enable server transport.
```json
{
"api_version": 1,
"parts": [
{
"id": "Part001",
"placement": {
"position": [0.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"mass": 1.0,
"grounded": true
},
{
"id": "Part002",
"placement": {
"position": [100.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"mass": 1.0,
"grounded": false
}
],
"constraints": [
{
"id": "Joint001",
"part_i": "Part001",
"marker_i": {
"position": [50.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"part_j": "Part002",
"marker_j": {
"position": [0.0, 0.0, 0.0],
"quaternion": [1.0, 0.0, 0.0, 0.0]
},
"type": "Revolute",
"params": [],
"limits": [],
"activated": true
}
],
"motions": [],
"simulation": null,
"bundle_fixed": false
}
```
**Field reference:** See [KCSolve Python API](../reference/kcsolve-python.md) for full field documentation. The JSON schema maps 1:1 to the Python/C++ types.
**Enum serialization:** Enums serialize as strings matching their Python names (e.g. `"Revolute"`, `"Success"`, `"Redundant"`).
**Transform shorthand:** The `placement` and `marker_*` fields use the `Transform` struct: `position` is `[x, y, z]`, `quaternion` is `[w, x, y, z]`.
**Constraint.Limit:**
```json
{
"kind": "RotationMin",
"value": -1.5708,
"tolerance": 1e-9
}
```
**MotionDef:**
```json
{
"kind": "Rotational",
"joint_id": "Joint001",
"marker_i": "",
"marker_j": "",
"rotation_expr": "2*pi*t",
"translation_expr": ""
}
```
**SimulationParams:**
```json
{
"t_start": 0.0,
"t_end": 2.0,
"h_out": 0.04,
"h_min": 1e-9,
"h_max": 1.0,
"error_tol": 1e-6
}
```
### 3.2 SolveResult JSON Schema
```json
{
"status": "Success",
"placements": [
{
"id": "Part002",
"placement": {
"position": [50.0, 0.0, 0.0],
"quaternion": [0.707, 0.0, 0.707, 0.0]
}
}
],
"dof": 1,
"diagnostics": [
{
"constraint_id": "Joint003",
"kind": "Redundant",
"detail": "6 DOF removed by Joint003 are already constrained"
}
],
"num_frames": 0
}
```
### 3.3 Solver Job Record
Solver jobs are stored in the existing `jobs` table. The solver-specific data is in the `args` and `result` JSONB columns.
**Job args (input):**
```json
{
"solver": "ondsel",
"operation": "solve",
"context": { /* SolveContext JSON */ },
"item_part_number": "ASM-001",
"revision_number": 3
}
```
**Operation types:**
| Operation | Description | Requires simulation? |
|-----------|-------------|---------------------|
| `solve` | Static equilibrium solve | No |
| `diagnose` | Constraint analysis only (no placement update) | No |
| `kinematic` | Time-domain kinematic simulation | Yes |
**Job result (output):**
```json
{
"result": { /* SolveResult JSON */ },
"solver_name": "OndselSolver (Lagrangian)",
"solver_version": "1.0",
"solve_time_ms": 127.4
}
```
---
## 4. REST API
All endpoints are prefixed with `/api/solver/` and gated behind `RequireModule("solver")`.
### 4.1 Submit Solve Job
```
POST /api/solver/jobs
Authorization: Bearer silo_...
Content-Type: application/json
{
"solver": "ondsel",
"operation": "solve",
"context": { /* SolveContext */ },
"priority": 50
}
```
**Optional fields:**
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `solver` | string | `""` (default solver) | Solver name from registry |
| `operation` | string | `"solve"` | `solve`, `diagnose`, or `kinematic` |
| `context` | object | required | SolveContext JSON |
| `priority` | int | `50` | Lower = higher priority |
| `item_part_number` | string | `null` | Silo item reference (for result association) |
| `revision_number` | int | `null` | Revision that generated this context |
| `callback_url` | string | `null` | Webhook URL for completion notification |
**Response `201 Created`:**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"created_at": "2026-02-19T18:30:00Z"
}
```
**Error responses:**
| Code | Condition |
|------|-----------|
| `400` | Invalid SolveContext (missing required fields, unknown enum values) |
| `401` | Not authenticated |
| `404` | Module not enabled |
| `422` | Unknown solver name, invalid operation |
### 4.2 Get Job Status
```
GET /api/solver/jobs/{jobID}
```
**Response `200 OK`:**
```json
{
"job_id": "550e8400-...",
"status": "completed",
"operation": "solve",
"solver": "ondsel",
"priority": 50,
"item_part_number": "ASM-001",
"revision_number": 3,
"runner_id": "runner-01",
"runner_name": "solver-worker-01",
"created_at": "2026-02-19T18:30:00Z",
"claimed_at": "2026-02-19T18:30:01Z",
"completed_at": "2026-02-19T18:30:02Z",
"result": {
"result": { /* SolveResult */ },
"solver_name": "OndselSolver (Lagrangian)",
"solve_time_ms": 127.4
}
}
```
### 4.3 List Solver Jobs
```
GET /api/solver/jobs?status=completed&item=ASM-001&limit=20&offset=0
```
**Query parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `status` | string | Filter by status: `pending`, `claimed`, `running`, `completed`, `failed` |
| `item` | string | Filter by item part number |
| `operation` | string | Filter by operation type |
| `solver` | string | Filter by solver name |
| `limit` | int | Page size (default 20, max 100) |
| `offset` | int | Pagination offset |
**Response `200 OK`:**
```json
{
"jobs": [ /* array of job objects */ ],
"total": 42,
"limit": 20,
"offset": 0
}
```
### 4.4 Cancel Job
```
POST /api/solver/jobs/{jobID}/cancel
```
Only `pending` and `claimed` jobs can be cancelled. Running jobs must complete or time out.
**Response `200 OK`:**
```json
{
"job_id": "550e8400-...",
"status": "cancelled"
}
```
### 4.5 Get Solver Registry
```
GET /api/solver/solvers
```
Returns available solvers on registered runners. Runners report their solver capabilities during heartbeat.
**Response `200 OK`:**
```json
{
"solvers": [
{
"name": "ondsel",
"display_name": "OndselSolver (Lagrangian)",
"deterministic": true,
"supported_joints": [
"Coincident", "Fixed", "Revolute", "Cylindrical",
"Slider", "Ball", "Screw", "Gear", "RackPinion",
"Parallel", "Perpendicular", "Angle", "Planar",
"Concentric", "PointOnLine", "PointInPlane",
"LineInPlane", "Tangent", "DistancePointPoint",
"DistanceCylSph", "Universal"
],
"runner_count": 2
}
],
"default_solver": "ondsel"
}
```
---
## 5. Server-Sent Events
Solver jobs emit events on the existing `/api/events` SSE stream.
### 5.1 Event Types
| Event | Payload | When |
|-------|---------|------|
| `solver.created` | `{job_id, operation, solver, item_part_number}` | Job submitted |
| `solver.claimed` | `{job_id, runner_id, runner_name}` | Runner starts work |
| `solver.progress` | `{job_id, progress, message}` | Progress update (0-100) |
| `solver.completed` | `{job_id, status, dof, diagnostics_count, solve_time_ms}` | Job succeeded |
| `solver.failed` | `{job_id, error_message}` | Job failed |
### 5.2 Example Stream
```
event: solver.created
data: {"job_id":"abc-123","operation":"solve","solver":"ondsel","item_part_number":"ASM-001"}
event: solver.claimed
data: {"job_id":"abc-123","runner_id":"r1","runner_name":"solver-worker-01"}
event: solver.progress
data: {"job_id":"abc-123","progress":50,"message":"Building constraint system..."}
event: solver.completed
data: {"job_id":"abc-123","status":"Success","dof":3,"diagnostics_count":1,"solve_time_ms":127.4}
```
### 5.3 Client Integration
The Create client subscribes to the SSE stream and updates the Assembly workbench UI:
1. **Silo viewport widget** shows job status indicator (pending/running/done/failed)
2. On `solver.completed`, the client can fetch the full result via `GET /api/solver/jobs/{id}` and apply placements
3. On `solver.failed`, the client shows the error in the report panel
4. Diagnostic results (redundant/conflicting constraints) surface in the constraint tree
---
## 6. Runner Integration
### 6.1 Runner Requirements
Solver runners are standard `silorunner` instances with the `solver` tag. They require:
- Python 3.11+ with `kcsolve` module installed
- `libKCSolve.so` and solver backend libraries (e.g. `libOndselSolver.so`)
- Network access to the Silo server
No FreeCAD installation is required. The runner operates on pre-extracted `SolveContext` JSON.
### 6.2 Runner Registration
```bash
# Register a solver runner (admin)
curl -X POST https://silo.example.com/api/runners \
-H "Authorization: Bearer admin_token" \
-d '{"name":"solver-01","tags":["solver"]}'
# Response includes one-time token
{"id":"uuid","token":"silo_runner_xyz..."}
```
### 6.3 Runner Heartbeat
Runners report solver capabilities during heartbeat:
```json
POST /api/runner/heartbeat
{
"capabilities": {
"solvers": ["ondsel"],
"api_version": 1,
"python_version": "3.11.11"
}
}
```
### 6.4 Runner Execution Flow
```python
#!/usr/bin/env python3
"""Solver runner entry point."""
import json
import kcsolve
def execute_solve_job(args: dict) -> dict:
"""Execute a solver job from parsed args."""
solver_name = args.get("solver", "")
operation = args.get("operation", "solve")
ctx_dict = args["context"]
# Deserialize SolveContext from JSON
ctx = kcsolve.SolveContext.from_dict(ctx_dict)
# Load solver
solver = kcsolve.load(solver_name)
if solver is None:
raise ValueError(f"Unknown solver: {solver_name!r}")
# Execute operation
if operation == "solve":
result = solver.solve(ctx)
elif operation == "diagnose":
diags = solver.diagnose(ctx)
result = kcsolve.SolveResult()
result.diagnostics = diags
elif operation == "kinematic":
result = solver.run_kinematic(ctx)
else:
raise ValueError(f"Unknown operation: {operation!r}")
# Serialize result
return {
"result": result.to_dict(),
"solver_name": solver.name(),
"solver_version": "1.0",
}
```
### 6.5 Standalone Process Mode
For minimal deployments, the runner can invoke a standalone solver process:
```bash
echo '{"solver":"ondsel","operation":"solve","context":{...}}' | \
python3 -m kcsolve.runner
```
The `kcsolve.runner` module reads JSON from stdin, executes the solve, and writes the result JSON to stdout. Exit code 0 = success, non-zero = failure with error JSON on stderr.
---
## 7. Job Definitions
### 7.1 Manual Solve Job
Triggered by the client when the user requests a server-side solve:
```yaml
job:
name: assembly-solve
version: 1
description: "Solve assembly constraints on server"
trigger:
type: manual
scope:
type: assembly
compute:
type: solver
command: solver-run
runner:
tags: [solver]
timeout: 300
max_retries: 1
priority: 50
```
### 7.2 Commit-Time Validation
Automatically validates assembly constraints when a new revision is committed:
```yaml
job:
name: assembly-validate
version: 1
description: "Validate assembly constraints on commit"
trigger:
type: revision_created
filter:
item_type: assembly
scope:
type: assembly
compute:
type: solver
command: solver-diagnose
args:
operation: diagnose
runner:
tags: [solver]
timeout: 120
max_retries: 2
priority: 75
```
### 7.3 Kinematic Simulation
Server-side kinematic simulation for assemblies with motion definitions:
```yaml
job:
name: assembly-kinematic
version: 1
description: "Run kinematic simulation"
trigger:
type: manual
scope:
type: assembly
compute:
type: solver
command: solver-kinematic
args:
operation: kinematic
runner:
tags: [solver]
timeout: 1800
max_retries: 0
priority: 100
```
---
## 8. SolveContext Extraction
When a solver job is triggered by a revision commit (rather than a direct context submission), the server or runner must extract a `SolveContext` from the `.kc` file.
### 8.1 Extraction via Headless Create
For full-fidelity extraction that handles geometry classification:
```bash
create --console -e "
import kcsolve_extract
kcsolve_extract.extract_and_solve('input.kc', 'output.json', solver='ondsel')
"
```
This requires a full Create installation on the runner and uses the Assembly module's existing adapter layer to build `SolveContext` from document objects.
### 8.2 Extraction from .kc Silo Directory
For lightweight extraction without FreeCAD, the constraint graph can be stored in the `.kc` archive's `silo/` directory during commit:
```
silo/solver/context.json # Pre-extracted SolveContext
silo/solver/result.json # Last solve result (if any)
```
The client extracts the `SolveContext` locally before committing the `.kc` file. The server reads it from the archive, avoiding the need for geometry processing on the runner.
**Commit-time packing** (client side):
```python
# In the Assembly workbench commit hook:
ctx = assembly_object.build_solve_context()
kc_archive.write("silo/solver/context.json", ctx.to_json())
```
**Runner-side extraction:**
```python
import zipfile, json
with zipfile.ZipFile("assembly.kc") as zf:
ctx_json = json.loads(zf.read("silo/solver/context.json"))
```
---
## 9. Database Schema
### 9.1 Migration
The solver module uses the existing `jobs` table. One new table is added for result caching:
```sql
-- Migration: 020_solver_results.sql
CREATE TABLE solver_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL,
job_id UUID REFERENCES jobs(id) ON DELETE SET NULL,
operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic'
solver_name TEXT NOT NULL,
status TEXT NOT NULL, -- SolveStatus string
dof INTEGER,
diagnostics JSONB DEFAULT '[]',
placements JSONB DEFAULT '[]',
num_frames INTEGER DEFAULT 0,
solve_time_ms DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(item_id, revision_number, operation)
);
CREATE INDEX idx_solver_results_item ON solver_results(item_id);
CREATE INDEX idx_solver_results_status ON solver_results(status);
```
The `UNIQUE(item_id, revision_number, operation)` constraint means each revision has at most one result per operation type. Re-running overwrites the previous result.
### 9.2 Result Association
When a solver job completes, the server:
1. Stores the full result in the `jobs.result` JSONB column (standard job result)
2. Upserts a row in `solver_results` for quick lookup by item/revision
3. Broadcasts `solver.completed` SSE event
---
## 10. Configuration
### 10.1 Server Config
```yaml
# config.yaml
modules:
solver:
enabled: true
default_solver: "ondsel"
max_context_size_mb: 10 # Reject oversized SolveContext payloads
default_timeout: 300 # Default job timeout (seconds)
auto_diagnose_on_commit: true # Auto-submit diagnose job on revision commit
```
### 10.2 Environment Variables
| Variable | Description |
|----------|-------------|
| `SILO_SOLVER_ENABLED` | Override module enabled state |
| `SILO_SOLVER_DEFAULT` | Default solver name |
### 10.3 Runner Config
```yaml
# runner.yaml
server_url: https://silo.example.com
token: silo_runner_xyz...
tags: [solver]
solver:
kcsolve_path: /opt/create/lib # LD_LIBRARY_PATH for kcsolve.so
python: /opt/create/bin/python3
max_concurrent: 2 # Parallel job slots per runner
```
---
## 11. Security
### 11.1 Authentication
All solver endpoints use the existing Silo authentication:
- **User endpoints** (`/api/solver/jobs`): Session or API token, requires `viewer` role to read, `editor` role to submit
- **Runner endpoints** (`/api/runner/...`): Runner token authentication (existing)
### 11.2 Input Validation
The server validates SolveContext JSON before queuing:
- Maximum payload size (configurable, default 10 MB)
- Required fields present (`parts`, `constraints`)
- Enum values are valid strings
- Transform arrays have correct length (position: 3, quaternion: 4)
- No duplicate part or constraint IDs
### 11.3 Runner Isolation
Solver runners execute untrusted constraint data. Mitigations:
- Runners should run in containers or sandboxed environments
- Python solver registration (`kcsolve.register_solver()`) is disabled in runner mode
- Solver execution has a configurable timeout (killed on expiry)
- Result size is bounded (large kinematic simulations are truncated)
---
## 12. Client SDK
### 12.1 Python Client
The existing `silo-client` package is extended with solver methods:
```python
from silo_client import SiloClient
client = SiloClient("https://silo.example.com", token="silo_...")
# Submit a solve job
import kcsolve
ctx = kcsolve.SolveContext()
# ... build context ...
job = client.solver.submit(ctx.to_dict(), solver="ondsel")
print(job.id, job.status) # "pending"
# Poll for completion
result = client.solver.wait(job.id, timeout=60)
print(result.status) # "Success"
# Or use SSE for real-time updates
for event in client.solver.stream(job.id):
print(event.type, event.data)
# Query results for an item
results = client.solver.results("ASM-001")
```
### 12.2 Create Workbench Integration
The Assembly workbench adds a "Solve on Server" command:
```python
# CommandSolveOnServer.py (sketch)
def activated(self):
assembly = get_active_assembly()
ctx = assembly.build_solve_context()
# Submit to Silo
from silo_client import get_client
client = get_client()
job = client.solver.submit(ctx.to_dict())
# Subscribe to SSE for updates
self.watch_job(job.id)
def on_solver_completed(self, job_id, result):
# Apply placements back to assembly
assembly = get_active_assembly()
for pr in result["placements"]:
assembly.set_part_placement(pr["id"], pr["placement"])
assembly.recompute()
```
---
## 13. Implementation Plan
### Phase 3a: JSON Serialization
Add `to_dict()` / `from_dict()` methods to all KCSolve types in the pybind11 module.
**Files to modify:**
- `src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp` -- add dict conversion methods
**Verification:** `ctx.to_dict()` round-trips through `SolveContext.from_dict()`.
### Phase 3b: Server Endpoints
Add the solver module to the Silo server.
**Files to create (in silo repository):**
- `internal/modules/solver/solver.go` -- Module registration and config
- `internal/modules/solver/handlers.go` -- REST endpoint handlers
- `internal/modules/solver/events.go` -- SSE event definitions
- `migrations/020_solver_results.sql` -- Database migration
### Phase 3c: Runner Support
Add solver job execution to `silorunner`.
**Files to create:**
- `src/Mod/Assembly/Solver/bindings/runner.py` -- `kcsolve.runner` entry point
- Runner capability reporting during heartbeat
### Phase 3d: .kc Context Packing
Pack `SolveContext` into `.kc` archives on commit.
**Files to modify:**
- `mods/silo/freecad/silo_origin.py` -- Hook into commit to pack solver context
### Phase 3e: Client Integration
Add "Solve on Server" command to the Assembly workbench.
**Files to modify:**
- `mods/silo/freecad/` -- Solver client methods
- `src/Mod/Assembly/` -- Server solve command
---
## 14. Open Questions
1. **Context size limits** -- Large assemblies may produce multi-MB SolveContext JSON. Should we compress (gzip) or use a binary format (msgpack)?
2. **Result persistence** -- How long should solver results be retained? Per-revision (overwritten on next commit) or historical (keep all)?
3. **Kinematic frame storage** -- Kinematic simulations can produce thousands of frames. Store all frames in JSONB, or write to a separate file and reference it?
4. **Multi-solver comparison** -- Should the API support running the same context through multiple solvers and comparing results? Useful for Phase 4 (second solver validation).
5. **Webhook notifications** -- The `callback_url` field allows external integrations (e.g. CI). What authentication should the webhook use?
---
## 15. References
- [KCSolve Architecture](../architecture/ondsel-solver.md)
- [KCSolve Python API Reference](../reference/kcsolve-python.md)
- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- Full pluggable solver spec
- [WORKERS.md](WORKERS.md) -- Worker/runner job system
- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server specification
- [MODULES.md](MODULES.md) -- Module system

View File

@@ -337,7 +337,7 @@ Supporting files:
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector | | `web/src/components/items/CategoryPicker.tsx` | Multi-stage domain/subcategory selector |
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs | | `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload |
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects | | `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
| `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` | | `web/src/hooks/useFormDescriptor.ts` | Fetches and caches form descriptor from `/api/schemas/{name}/form` |
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow | | `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
@@ -421,7 +421,7 @@ Below the picker, the selected category is shown as a breadcrumb: `Fasteners
### FileDropZone ### FileDropZone
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow. Handles drag-and-drop and click-to-browse file uploads.
**Props**: **Props**:
@@ -435,7 +435,7 @@ interface FileDropZoneProps {
interface PendingAttachment { interface PendingAttachment {
file: File; file: File;
objectKey: string; // MinIO key after upload objectKey: string; // storage key after upload
uploadProgress: number; // 0-100 uploadProgress: number; // 0-100
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
error?: string; error?: string;
@@ -462,7 +462,7 @@ Clicking the zone opens a hidden `<input type="file" multiple>`.
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`. 1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
2. Backend returns `{ object_key, upload_url, expires_at }`. 2. Backend returns `{ object_key, upload_url, expires_at }`.
3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking). 3. `PUT` the file directly to the presigned URL using `XMLHttpRequest` (for progress tracking).
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`. 4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
5. The `object_key` is later sent to the item creation endpoint to associate the file. 5. The `object_key` is later sent to the item creation endpoint to associate the file.
@@ -589,10 +589,10 @@ Items 1-5 below are implemented. Item 4 (hierarchical categories) is resolved by
``` ```
POST /api/uploads/presign POST /api/uploads/presign
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 } Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." } Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://...", "expires_at": "2026-02-06T..." }
``` ```
The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. The Go handler generates a presigned PUT URL for direct upload. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
### 2. File Association -- IMPLEMENTED ### 2. File Association -- IMPLEMENTED
@@ -612,7 +612,7 @@ Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
Response: 204 Response: 204
``` ```
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column. Stores the thumbnail at `items/{item_id}/thumbnail.png` in storage. Updates `item.thumbnail_key` column.
### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor) ### 4. Hierarchical Categories -- IMPLEMENTED (via Form Descriptor)

View File

@@ -34,7 +34,7 @@ silo/
│ ├── ods/ # ODS spreadsheet library │ ├── ods/ # ODS spreadsheet library
│ ├── partnum/ # Part number generation │ ├── partnum/ # Part number generation
│ ├── schema/ # YAML schema parsing │ ├── schema/ # YAML schema parsing
│ ├── storage/ # MinIO file storage │ ├── storage/ # Filesystem storage
│ └── testutil/ # Test helpers │ └── testutil/ # Test helpers
├── web/ # React SPA (Vite + TypeScript) ├── web/ # React SPA (Vite + TypeScript)
│ └── src/ │ └── src/
@@ -55,7 +55,7 @@ silo/
See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions. See the **[Installation Guide](docs/INSTALL.md)** for complete setup instructions.
**Docker Compose (quickest — includes PostgreSQL, MinIO, OpenLDAP, and Silo):** **Docker Compose (quickest — includes PostgreSQL, OpenLDAP, and Silo):**
```bash ```bash
./scripts/setup-docker.sh ./scripts/setup-docker.sh
@@ -65,7 +65,7 @@ docker compose -f deployments/docker-compose.allinone.yaml up -d
**Development (local Go + Docker services):** **Development (local Go + Docker services):**
```bash ```bash
make docker-up # Start PostgreSQL + MinIO in Docker make docker-up # Start PostgreSQL in Docker
make run # Run silo locally with Go make run # Run silo locally with Go
``` ```

View File

@@ -0,0 +1,219 @@
# Assembly Integration
The Kindred solver integrates with FreeCAD's Assembly workbench through the KCSolve pluggable solver framework. This page describes the bridge layer, preference system, and interactive drag protocol.
## KindredSolver class
**Source:** `mods/solver/kindred_solver/solver.py`
`KindredSolver` subclasses `kcsolve.IKCSolver` and implements the solver interface:
```python
class KindredSolver(kcsolve.IKCSolver):
def name(self):
return "Kindred (Newton-Raphson)"
def supported_joints(self):
return list(_SUPPORTED) # 20 of 24 BaseJointKind values
def solve(self, ctx): # Static solve
def diagnose(self, ctx): # Constraint analysis
def pre_drag(self, ctx, drag_parts): # Begin drag session
def drag_step(self, drag_placements): # Mouse move during drag
def post_drag(self): # End drag session
def is_deterministic(self): # Returns True
```
### Registration
The solver is registered at addon load time via `Init.py`:
```python
import kcsolve
from kindred_solver import KindredSolver
kcsolve.register_solver("kindred", KindredSolver)
```
The `mods/solver/` directory is a FreeCAD addon discovered by the addon loader through its `package.xml` manifest.
### Supported joints
The Kindred solver handles 20 of the 24 `BaseJointKind` values. The remaining 4 are stubs that produce no residuals:
| Supported | Stub (no residuals) |
|-----------|-------------------|
| Coincident, PointOnLine, PointInPlane, Concentric, Tangent, Planar, LineInPlane, Parallel, Perpendicular, Angle, Fixed, Revolute, Cylindrical, Slider, Ball, Screw, Universal, Gear, RackPinion, DistancePointPoint | Cam, Slot, DistanceCylSph, Custom |
### Joint limits
Joint travel limits (`Constraint.limits`) are accepted but not enforced. The solver logs a warning once per instance when limits are encountered. Enforcing inequality constraints requires active-set or barrier-method extensions beyond the current Newton-Raphson formulation.
## Solver selection
### C++ preference
`AssemblyObject::getOrCreateSolver()` reads the user preference to select the solver backend:
```cpp
ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
"User parameter:BaseApp/Preferences/Mod/Assembly");
std::string solverName = hGrp->GetASCII("Solver", "");
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
```
An empty string (`""`) returns the registry default (the first solver registered, which is OndselSolver). Setting `"kindred"` selects the Kindred solver.
`resetSolver()` clears the cached solver instance so the next solve picks up preference changes.
### Preferences UI
The Assembly preferences page (`Edit > Preferences > Assembly`) includes a "Solver backend" combo box populated from the registry at load time:
- **Default** -- empty string, uses the registry default (OndselSolver)
- **OndselSolver (Lagrangian)** -- `"ondsel"`
- **Kindred (Newton-Raphson)** -- `"kindred"` (available when the solver addon is loaded)
The preference is stored as `Mod/Assembly/Solver` in the FreeCAD parameter system.
### Programmatic switching
From the Python console:
```python
import FreeCAD
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
# Switch to Kindred
pref.SetString("Solver", "kindred")
# Switch back to default
pref.SetString("Solver", "")
# Force the active assembly to pick up the change
if hasattr(FreeCADGui, "ActiveDocument"):
for obj in FreeCAD.ActiveDocument.Objects:
if hasattr(obj, "resetSolver"):
obj.resetSolver()
```
## Interactive drag protocol
The drag protocol provides real-time constraint solving during viewport part dragging. It is a three-phase protocol with a caching layer that avoids rebuilding the constraint system on every mouse move.
### pre_drag(ctx, drag_parts)
Called when the user begins dragging. Builds the constraint system once, runs the substitution pre-pass, constructs the symbolic Jacobian, compiles the evaluator, performs an initial solve, and caches everything in a `_DragCache` for reuse across subsequent `drag_step()` calls.
```python
def pre_drag(self, ctx, drag_parts):
self._drag_ctx = ctx
self._drag_parts = set(drag_parts)
system = _build_system(ctx)
half_spaces = compute_half_spaces(...)
weight_vec = build_weight_vector(system.params)
residuals = substitution_pass(system.all_residuals, system.params)
# single_equation_pass is intentionally skipped — it bakes variable
# values as constants that become stale when dragged parts move.
jac_exprs = [[r.diff(name).simplify() for name in free] for r in residuals]
compiled_eval = try_compile_system(residuals, jac_exprs, ...)
# Initial solve (Newton-Raphson + BFGS fallback)
newton_solve(residuals, system.params, ...)
# Cache for drag_step() reuse
cache = _DragCache()
cache.system = system
cache.residuals = residuals
cache.jac_exprs = jac_exprs
cache.compiled_eval = compiled_eval
cache.half_spaces = half_spaces
cache.weight_vec = weight_vec
...
return result
```
**Important:** `single_equation_pass` is not used in the drag path. It analytically solves single-variable equations and bakes the results as `Const()` nodes into downstream expressions. During drag, those baked values become stale when part positions change, causing constraints to silently stop being enforced. Only `substitution_pass` (which replaces genuinely grounded parameters) is safe to cache.
### drag_step(drag_placements)
Called on each mouse move. Updates only the dragged part's 7 parameter values in the cached `ParamTable`, then re-solves using the cached residuals, Jacobian, and compiled evaluator. No system rebuild occurs.
```python
def drag_step(self, drag_placements):
cache = self._drag_cache
params = cache.system.params
# Update only the dragged part's parameters
for pr in drag_placements:
pfx = pr.id + "/"
params.set_value(pfx + "tx", pr.placement.position[0])
params.set_value(pfx + "ty", pr.placement.position[1])
params.set_value(pfx + "tz", pr.placement.position[2])
params.set_value(pfx + "qw", pr.placement.quaternion[0])
params.set_value(pfx + "qx", pr.placement.quaternion[1])
params.set_value(pfx + "qy", pr.placement.quaternion[2])
params.set_value(pfx + "qz", pr.placement.quaternion[3])
# Solve with cached artifacts — no rebuild
newton_solve(cache.residuals, params, ...,
jac_exprs=cache.jac_exprs,
compiled_eval=cache.compiled_eval)
return result
```
### post_drag()
Called when the drag ends. Clears the cached state.
```python
def post_drag(self):
self._drag_ctx = None
self._drag_parts = None
self._drag_cache = None
```
### _DragCache
The cache holds all artifacts built in `pre_drag()` that are invariant across drag steps (constraint topology doesn't change during a drag):
| Field | Contents |
|-------|----------|
| `system` | `_System` -- owns `ParamTable` and `Expr` trees |
| `residuals` | `list[Expr]` -- after substitution pass |
| `jac_exprs` | `list[list[Expr]]` -- symbolic Jacobian |
| `compiled_eval` | `Callable` or `None` -- native compiled evaluator |
| `half_spaces` | `list[HalfSpace]` -- branch trackers |
| `weight_vec` | `ndarray` or `None` -- minimum-movement weights |
| `post_step_fn` | `Callable` or `None` -- half-space correction callback |
### Performance
The caching layer eliminates the expensive per-frame overhead (~150 ms for system build + Jacobian construction + compilation). Each `drag_step()` only evaluates the cached expressions at updated parameter values:
- Newton-Raphson converges in 1-2 iterations from a nearby initial guess
- The compiled evaluator (`codegen.py`) uses native Python `exec` for flat evaluation, avoiding the recursive tree-walk overhead
- The substitution pass compiles grounded-body parameters to constants, reducing the effective system size
- DOF counting is skipped during drag for speed (`result.dof = -1`)
## Diagnostics integration
`diagnose(ctx)` builds the constraint system and runs overconstrained detection, returning a list of `kcsolve.ConstraintDiagnostic` objects. The Assembly module calls this to populate the constraint diagnostics panel.
```python
def diagnose(self, ctx):
system = _build_system(ctx)
residuals = substitution_pass(system.all_residuals, system.params)
return _run_diagnostics(residuals, system.params, system.residual_ranges, ctx)
```
## Not yet implemented
- **Kinematic simulation** (`run_kinematic`, `num_frames`, `update_for_frame`) -- the base class defaults return `Failed`. Requires time-stepping integration with motion driver expression evaluation.
- **Joint limit enforcement** -- inequality constraints need active-set or barrier solver extensions.
- **Fixed-joint bundling** (`supports_bundle_fixed()` returns `False`) -- the solver receives unbundled parts; the Assembly module pre-bundles when needed.
- **Native export** (`export_native()`) -- no solver-native debug format defined.

View File

@@ -0,0 +1,116 @@
# Constraints
Each constraint type maps to a class that produces residual expressions. The residuals equal zero when the constraint is satisfied. The number of residuals equals the number of degrees of freedom removed.
**Source:** `mods/solver/kindred_solver/constraints.py`, `mods/solver/kindred_solver/geometry.py`
## Constraint vocabulary
### Point constraints
| Type | DOF removed | Residuals |
|------|-------------|-----------|
| **Coincident** | 3 | `p_i - p_j` (world-frame marker origins coincide) |
| **PointOnLine** | 2 | Two components of `(p_i - p_j) x z_j` (point lies on line through `p_j` along `z_j`) |
| **PointInPlane** | 1 | `(p_i - p_j) . z_j - offset` (signed distance to plane) |
### Orientation constraints
| Type | DOF removed | Residuals |
|------|-------------|-----------|
| **Parallel** | 2 | Two components of `z_i x z_j` (cross product of Z-axes is zero) |
| **Perpendicular** | 1 | `z_i . z_j` (dot product of Z-axes is zero) |
| **Angle** | 1 | `z_i . z_j - cos(angle)` |
### Axis/surface constraints
| Type | DOF removed | Residuals |
|------|-------------|-----------|
| **Concentric** | 4 | Parallel Z-axes (2) + point-on-line (2) |
| **Tangent** | 1 | `(p_i - p_j) . z_j` (signed distance along normal) |
| **Planar** | 3 | Parallel normals (2) + point-in-plane (1) |
| **LineInPlane** | 2 | Point-in-plane (1) + `z_i . n_j` (line direction perpendicular to normal) (1) |
### Kinematic joints
| Type | DOF removed | DOF remaining | Residuals |
|------|-------------|---------------|-----------|
| **Fixed** | 6 | 0 | Coincident origins (3) + quaternion error imaginary parts (3) |
| **Ball** | 3 | 3 | Coincident origins (same as Coincident) |
| **Revolute** | 5 | 1 (rotation about Z) | Coincident origins (3) + parallel Z-axes (2) |
| **Cylindrical** | 4 | 2 (rotation + slide) | Parallel Z-axes (2) + point-on-line (2) |
| **Slider** | 5 | 1 (slide along Z) | Parallel Z-axes (2) + point-on-line (2) + twist lock: `x_i . y_j` (1) |
| **Screw** | 5 | 1 (helical) | Cylindrical (4) + pitch coupling: `axial - pitch * qz_rel / pi` (1) |
| **Universal** | 4 | 2 (rotation about each Z) | Coincident origins (3) + perpendicular Z-axes (1) |
### Mechanical elements
| Type | DOF removed | Residuals |
|------|-------------|-----------|
| **Gear** | 1 | `r_i * qz_i + r_j * qz_j` (coupled rotation via quaternion Z-components) |
| **RackPinion** | 1 | `translation - 2 * pitch_radius * qz_i` (rotation-translation coupling) |
| **Cam** | 0 | Stub (no residuals) |
| **Slot** | 0 | Stub (no residuals) |
### Distance constraints
| Type | DOF removed | Residuals |
|------|-------------|-----------|
| **DistancePointPoint** | 1 | `\|p_i - p_j\|^2 - d^2` (squared form avoids sqrt in Jacobian) |
| **DistanceCylSph** | 0 | Stub (geometry classification dependent) |
## Marker convention
Every constraint references two parts (`body_i`, `body_j`) with local coordinate frames called markers. Each marker has a position (attachment point on the part) and a quaternion (orientation).
The marker Z-axis defines the constraint direction:
- **Revolute:** Z-axis = hinge axis
- **Planar:** Z-axis = face normal
- **PointOnLine:** Z-axis = line direction
- **Slider:** Z-axis = slide direction
The solver computes world-frame marker axes by composing the body quaternion with the marker quaternion: `q_world = q_body * q_marker`, then rotating unit vectors through the result.
## Fixed constraint orientation
The Fixed constraint locks all 6 DOF using a quaternion error formulation:
1. Compute total orientation: `q_i = q_body_i * q_marker_i`, `q_j = q_body_j * q_marker_j`
2. Compute relative quaternion: `q_err = conj(q_i) * q_j`
3. When orientations match, `q_err` is the identity quaternion `(1, 0, 0, 0)`
4. Residuals are the three imaginary components of `q_err` (should be zero)
The quaternion normalization constraint on each body provides the fourth equation needed to fully determine the quaternion.
## Rotation proxies for mechanical constraints
Gear, RackPinion, and Screw constraints need to measure rotation angles. Rather than extracting Euler angles (which would introduce transcendentals), they use the Z-component of a relative quaternion as a proxy:
```
q_local = conj(q_marker) * q_body * q_marker
angle ~ 2 * qz_local (for small angles)
```
This is exact at the solution and has correct gradient direction, which is sufficient for Newton-Raphson convergence from a nearby initial guess.
## Geometry helpers
The `geometry.py` module provides Expr-level vector operations used by constraint classes:
- `marker_z_axis(body, marker_quat)` -- world-frame Z-axis via `quat_rotate(q_body * q_marker, [0,0,1])`
- `marker_x_axis(body, marker_quat)` -- world-frame X-axis (used by Slider twist lock)
- `marker_y_axis(body, marker_quat)` -- world-frame Y-axis (used by Slider twist lock)
- `dot3(a, b)` -- dot product of Expr triples
- `cross3(a, b)` -- cross product of Expr triples
- `point_plane_distance(point, origin, normal)` -- signed distance
- `point_line_perp_components(point, origin, dir)` -- two perpendicular distance components
## Writing a new constraint
To add a constraint type:
1. Subclass `ConstraintBase` in `constraints.py`
2. Implement `residuals()` returning a list of `Expr` nodes
3. Add a case in `solver.py:_build_constraint()` to instantiate it from `BaseJointKind`
4. Add the `BaseJointKind` value to `_SUPPORTED` in `solver.py`
5. Add the residual count to the tables in `decompose.py`

View File

@@ -0,0 +1,117 @@
# Diagnostics
The solver provides three levels of constraint analysis: system-wide DOF counting, per-entity DOF decomposition, and overconstrained/conflicting constraint detection.
## DOF counting
**Source:** `mods/solver/kindred_solver/dof.py`
Degrees of freedom are computed from the Jacobian rank:
```
DOF = n_free_params - rank(J)
```
Where `n_free_params` is the number of non-fixed parameters and `rank(J)` is the numerical rank of the Jacobian evaluated at current parameter values (SVD with tolerance `1e-8`).
A well-constrained assembly has `DOF = 0` (exactly enough constraints to determine all positions). Positive DOF means underconstrained (parts can still move). Negative DOF is not possible with this formulation -- instead, rank deficiency in an overdetermined system indicates redundant constraints.
The DOF value is reported in `SolveResult.dof` after every solve.
## Per-entity DOF
**Source:** `mods/solver/kindred_solver/diagnostics.py`
`per_entity_dof()` breaks down the DOF count per body, identifying which motions remain free for each part:
1. Build the full Jacobian
2. For each non-grounded body, extract the 7 columns corresponding to its parameters
3. Compute SVD of the sub-matrix; rank = number of constrained directions
4. `remaining_dof = 7 - rank` (includes the quaternion normalization constraint counted in the rank)
5. Classify null-space vectors as free motions by analyzing their translation vs. rotation components:
- Pure translation: >80% of the null vector's energy is in `tx, ty, tz` components
- Pure rotation: >80% of the energy is in `qw, qx, qy, qz` components
- Helical: mixed
Returns a list of `EntityDOF` dataclasses:
```python
@dataclass
class EntityDOF:
entity_id: str
remaining_dof: int
free_motions: list[str] # e.g., ["rotation about Z", "translation along X"]
```
## Overconstrained detection
**Source:** `mods/solver/kindred_solver/diagnostics.py`
`find_overconstrained()` identifies redundant and conflicting constraints when the system is overconstrained (Jacobian is rank-deficient). It runs automatically when `solve()` fails to converge.
### Algorithm
Following the approach used by SolvSpace:
1. **Check rank.** Build the full Jacobian `J`, compute its rank via SVD. If `rank == n_residuals`, the system is not overconstrained -- return empty.
2. **Find redundant constraints.** For each constraint, temporarily remove its rows from J and re-check rank. If the rank is preserved, the constraint is **redundant** (removing it doesn't change the system's effective equations).
3. **Distinguish conflicting from merely redundant.** Compute the left null space of J (columns of U beyond the rank). Project the residual vector onto this null space:
```
null_residual = U_null^T @ r
residual_projection = U_null @ null_residual
```
If a redundant constraint's residuals have significant projection onto the null space, it is **conflicting** -- it's both redundant and unsatisfied, meaning it contradicts other constraints.
### Diagnostic output
Returns `ConstraintDiag` dataclasses:
```python
@dataclass
class ConstraintDiag:
constraint_index: int
kind: str # "redundant" or "conflicting"
detail: str # Human-readable explanation
```
These are converted to `kcsolve.ConstraintDiagnostic` objects in the IKCSolver bridge:
| ConstraintDiag.kind | kcsolve.DiagnosticKind |
|---------------------|----------------------|
| `"redundant"` | `Redundant` |
| `"conflicting"` | `Conflicting` |
### Example
Two Fixed joints between the same pair of parts:
- Joint A: 6 residuals (3 position + 3 orientation)
- Joint B: 6 residuals (same as Joint A)
Jacobian rank = 6 (Joint B's rows are linearly dependent on Joint A's). Both joints are detected as redundant. If the joints specify different relative positions, both are also flagged as conflicting.
## Solution preferences
**Source:** `mods/solver/kindred_solver/preference.py`
Solution preferences guide the solver toward physically intuitive solutions when multiple valid configurations exist.
### Minimum-movement weighting
The weight vector scales the Newton step to prefer solutions near the initial configuration. Translation parameters get weight `1.0`, quaternion parameters get weight `(180/pi)^2 ~ 3283`. This makes a 1-radian rotation equally "expensive" as a ~57-unit translation.
The weighted minimum-norm step is:
```
J_scaled = J @ diag(W^{-1/2})
dx_scaled = lstsq(J_scaled, -r)
dx = dx_scaled * W^{-1/2}
```
This produces the minimum-norm solution in the weighted parameter space, biasing toward small movements.
### Half-space tracking
Described in detail in [Solving Algorithms: Half-space tracking](solving.md#half-space-tracking). Preserves the initial configuration's "branch" for constraints with multiple valid solutions by detecting and correcting branch crossings during iteration.

View File

@@ -0,0 +1,96 @@
# Expression DAG
The expression DAG is the foundation of the Kindred solver. All constraint equations, Jacobian entries, and residuals are built as immutable trees of `Expr` nodes. This lets the solver compute exact symbolic derivatives and simplify constant sub-expressions before the iterative solve loop.
**Source:** `mods/solver/kindred_solver/expr.py`
## Node types
Every node is a subclass of `Expr` and implements three methods:
- `eval(env)` -- evaluate the expression given a name-to-value dictionary
- `diff(var)` -- return a new Expr tree for the partial derivative with respect to `var`
- `simplify()` -- return an algebraically simplified copy
### Leaf nodes
| Node | Description | diff(x) |
|------|-------------|---------|
| `Const(v)` | Literal floating-point value | 0 |
| `Var(name)` | Named parameter (from `ParamTable`) | 1 if name matches, else 0 |
### Unary nodes
| Node | Description | diff(x) |
|------|-------------|---------|
| `Neg(f)` | Negation: `-f` | `-f'` |
| `Sin(f)` | Sine: `sin(f)` | `cos(f) * f'` |
| `Cos(f)` | Cosine: `cos(f)` | `-sin(f) * f'` |
| `Sqrt(f)` | Square root: `sqrt(f)` | `f' / (2 * sqrt(f))` |
### Binary nodes
| Node | Description | diff(x) |
|------|-------------|---------|
| `Add(a, b)` | Sum: `a + b` | `a' + b'` |
| `Sub(a, b)` | Difference: `a - b` | `a' - b'` |
| `Mul(a, b)` | Product: `a * b` | `a'b + ab'` (product rule) |
| `Div(a, b)` | Quotient: `a / b` | `(a'b - ab') / b^2` (quotient rule) |
| `Pow(a, n)` | Power: `a^n` (constant exponent only) | `n * a^(n-1) * a'` |
### Sentinels
`ZERO = Const(0.0)` and `ONE = Const(1.0)` are pre-allocated constants used by `diff()` to avoid allocating trivial nodes.
## Operator overloading
Python's arithmetic operators are overloaded on `Expr`, so constraints can be written in natural notation:
```python
from kindred_solver.expr import Var, Const
x = Var("x")
y = Var("y")
# Build the expression: x^2 + 2*x*y - 1
expr = x**2 + 2*x*y - Const(1.0)
# Evaluate at x=3, y=4
expr.eval({"x": 3.0, "y": 4.0}) # 32.0
# Symbolic derivative w.r.t. x
dx = expr.diff("x").simplify() # 2*x + 2*y
dx.eval({"x": 3.0, "y": 4.0}) # 14.0
```
The `_wrap()` helper coerces plain `int` and `float` values to `Const` nodes automatically, so `2 * x` works without wrapping the `2`.
## Simplification
`simplify()` applies algebraic identities bottom-up:
- Constant folding: `Const(2) + Const(3)` becomes `Const(5)`
- Identity elimination: `x + 0 = x`, `x * 1 = x`, `x^0 = 1`, `x^1 = x`
- Zero propagation: `0 * x = 0`
- Negation collapse: `-(-x) = x`
- Power expansion: `x^2` becomes `x * x` (avoids `pow()` in evaluation)
Simplification is applied once to each Jacobian entry after symbolic differentiation, before the solve loop begins. This reduces the expression tree size and speeds up repeated evaluation.
## How the solver uses expressions
1. **Parameter registration.** `ParamTable.add("Part001/tx", 10.0)` creates a `Var("Part001/tx")` node and records its current value.
2. **Constraint building.** Constraint classes compose `Var` nodes with arithmetic to produce residual `Expr` trees. For example, `CoincidentConstraint` builds `body_i.world_point() - body_j.world_point()`, producing 3 residual expressions.
3. **Jacobian construction.** Newton-Raphson calls `r.diff(name).simplify()` for every (residual, free parameter) pair to build the symbolic Jacobian. This happens once before the solve loop.
4. **Evaluation.** Each Newton iteration calls `expr.eval(env)` on every residual and Jacobian entry using the current parameter snapshot. `eval()` is a simple recursive tree walk with dictionary lookups.
## Design notes
**Why not numpy directly?** Symbolic expressions give exact derivatives without finite-difference approximations, and enable pre-passes (substitution, single-equation solve) that can eliminate variables before the iterative solver runs. The overhead of tree evaluation is acceptable for the problem sizes encountered in assembly solving (typically tens to hundreds of variables).
**Why immutable?** Immutability means `diff()` can safely share sub-tree references between the original and derivative expressions. It also simplifies the substitution pass, which rebuilds trees with `Const` nodes replacing fixed `Var` nodes.
**Limitations.** `Pow` differentiation only supports constant exponents. Variable exponents would require logarithmic differentiation (`d/dx f^g = f^g * (g' * ln(f) + g * f'/f)`), which hasn't been needed for assembly constraints.

View File

@@ -0,0 +1,92 @@
# Kindred Solver Overview
The Kindred solver is an expression-based Newton-Raphson constraint solver for the Assembly workbench. It is a pure-Python implementation that registers as a pluggable backend through the [KCSolve framework](../architecture/ondsel-solver.md), providing an alternative to the built-in OndselSolver (Lagrangian) backend.
## Architecture
```
Assembly Module
┌───────────┴───────────┐
│ SolverRegistry │
│ get("kindred") │
└───────────┬───────────┘
┌───────────┴───────────┐
│ KindredSolver │
│ (kcsolve.IKCSolver) │
└───────────┬───────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌────────┴────────┐ ┌──────┴──────┐ ┌────────┴────────┐
│ _build_system │ │ Solve │ │ Diagnostics │
│ ────────────── │ │ ───── │ │ ─────────── │
│ ParamTable │ │ pre-passes │ │ DOF counting │
│ RigidBody │ │ Newton-R │ │ overconstrained│
│ Constraints │ │ BFGS │ │ per-entity DOF │
│ Residuals │ │ decompose │ │ half-spaces │
└─────────────────┘ └─────────────┘ └─────────────────┘
```
## Design principles
**Symbolic differentiation.** All constraint equations are built as immutable expression DAGs (`Expr` trees). The Jacobian is computed symbolically via `expr.diff()` rather than finite differences. This gives exact derivatives, avoids numerical step-size tuning, and allows pre-passes to simplify or eliminate trivial equations before the iterative solver runs.
**Residual-based formulation.** Each constraint produces a list of residual expressions that should evaluate to zero when satisfied. A Coincident constraint produces 3 residuals (dx, dy, dz), a Revolute produces 5 (3 position + 2 axis alignment), and so on. The solver minimizes the residual vector norm.
**Unit quaternions for rotation.** Orientation is parameterized as a unit quaternion (w, x, y, z) rather than Euler angles, avoiding gimbal lock. A quaternion normalization residual (qw^2 + qx^2 + qy^2 + qz^2 - 1 = 0) is added for each free body, and quaternions are re-projected onto the unit sphere after each Newton step.
**Current placements as initial guess.** The solver uses the parts' current positions as the initial guess, so it naturally converges to the nearest solution. Combined with half-space tracking, this produces physically intuitive results without branch-switching surprises.
## Solve pipeline
When `KindredSolver.solve(ctx)` is called with a `SolveContext`:
1. **Build system** (`_build_system`) -- Create a `ParamTable` with 7 parameters per part (tx, ty, tz, qw, qx, qy, qz). Grounded parts have all parameters fixed. Build constraint objects from the context, collect their residual expressions, and add quaternion normalization residuals for free bodies.
2. **Solution preferences** -- Compute half-space trackers for branching constraints (Distance, Parallel, Angle, Perpendicular) and build a minimum-movement weight vector that penalizes quaternion changes more than translation changes.
3. **Pre-passes** -- Run the substitution pass (replace fixed parameters with constants) and the single-equation pass (analytically solve residuals with only one free variable).
4. **Solve** -- For assemblies with 8+ free bodies, decompose the constraint graph into biconnected components and solve each cluster independently. For smaller assemblies, solve the full system monolithically. In both cases, use Newton-Raphson first, falling back to L-BFGS-B if Newton doesn't converge.
5. **Post-process** -- Count degrees of freedom via Jacobian SVD rank. On failure, run overconstrained detection to identify redundant or conflicting constraints. Extract solved placements from the parameter table.
## Module map
| Module | Purpose |
|--------|---------|
| `solver.py` | `KindredSolver` class: IKCSolver bridge, solve/diagnose/drag entry points |
| `expr.py` | Immutable expression DAG with eval, diff, simplify |
| `params.py` | Parameter table: named variables with fixed/free tracking |
| `entities.py` | `RigidBody`: 7-DOF entity owning solver parameters |
| `quat.py` | Quaternion rotation as polynomial Expr trees |
| `geometry.py` | Marker axis extraction, vector ops (dot, cross, point-plane, point-line) |
| `constraints.py` | 24 constraint classes producing residual expressions |
| `newton.py` | Newton-Raphson with symbolic Jacobian, quaternion renormalization |
| `bfgs.py` | L-BFGS-B fallback via scipy |
| `prepass.py` | Substitution pass and single-equation analytical solve |
| `decompose.py` | Biconnected component graph decomposition and cluster-by-cluster solving |
| `dof.py` | DOF counting via Jacobian SVD rank |
| `diagnostics.py` | Overconstrained detection, per-entity DOF classification |
| `preference.py` | Half-space tracking and minimum-movement weighting |
## File locations
- **Solver addon:** `mods/solver/` (git submodule)
- **KCSolve C++ framework:** `src/Mod/Assembly/Solver/`
- **Python bindings:** `src/Mod/Assembly/Solver/bindings/`
- **Integration tests:** `src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py`
- **Unit tests:** `mods/solver/tests/`
## Related
- [Expression DAG](expression-dag.md) -- the Expr type system
- [Constraints](constraints.md) -- constraint vocabulary and residuals
- [Solving algorithms](solving.md) -- Newton-Raphson, BFGS, decomposition
- [Diagnostics](diagnostics.md) -- DOF counting, overconstrained detection
- [Assembly integration](assembly-integration.md) -- IKCSolver bridge, preferences, drag
- [Writing a custom solver](writing-a-solver.md) -- tutorial
- [KCSolve architecture](../architecture/ondsel-solver.md) -- pluggable solver framework
- [KCSolve Python API](../reference/kcsolve-python.md) -- kcsolve module reference

128
docs/src/solver/solving.md Normal file
View File

@@ -0,0 +1,128 @@
# Solving Algorithms
The Kindred solver uses a multi-stage pipeline: pre-passes reduce the system, Newton-Raphson iterates toward a solution, and L-BFGS-B provides a fallback. For large assemblies, graph decomposition splits the system into independent clusters solved in sequence.
## Pre-passes
**Source:** `mods/solver/kindred_solver/prepass.py`
Pre-passes run before the iterative solver and can eliminate variables analytically, reducing the problem size and improving convergence.
### Substitution pass
Replaces all fixed-parameter `Var` nodes with `Const` nodes carrying their current values, then simplifies. This compiles grounded-body parameters and previously-solved variables out of the expression trees.
After substitution, residuals involving only fixed parameters simplify to constants (typically zero), and Jacobian entries for those parameters become exactly zero. This reduces the effective system size without changing the linear algebra.
### Single-equation pass
Scans residuals for any that depend on exactly one free variable. If the residual is linear in that variable (`a*x + b = 0`), it solves `x = -b/a` analytically, fixes the variable, and re-substitutes.
The pass repeats until no more single-variable residuals can be solved. This handles cascading dependencies: solving one variable may reduce another residual to single-variable form.
## Newton-Raphson
**Source:** `mods/solver/kindred_solver/newton.py`
The primary iterative solver. Each iteration:
1. Evaluate the residual vector `r` and check convergence (`||r|| < tol`)
2. Evaluate the Jacobian matrix `J` by calling `expr.eval()` on pre-computed symbolic derivatives
3. Solve `J @ dx = -r` via `numpy.linalg.lstsq` (handles rank-deficient systems)
4. Update parameters: `x += dx`
5. Apply half-space correction (if configured)
6. Re-normalize quaternions to unit length
### Symbolic Jacobian
The Jacobian is built once before the solve loop by calling `r.diff(name).simplify()` for every (residual, free parameter) pair. The resulting `Expr` trees are stored and re-evaluated at the current parameter values each iteration. This gives exact derivatives with no step-size tuning.
### Weighted minimum-norm
When a weight vector is provided, the step is column-scaled to produce the weighted minimum-norm solution. The solver scales J by W^{-1/2}, solves the scaled system, then unscales the step. This biases the solver toward solutions requiring smaller parameter changes in high-weight dimensions.
The default weight vector assigns `1.0` to translation parameters and `~3283` to quaternion parameters (the square of 180/pi), making a 1-radian rotation equivalent to a ~57-unit translation. This produces physically intuitive solutions that prefer translating over rotating.
### Quaternion renormalization
After each Newton step, quaternion parameter groups `(qw, qx, qy, qz)` are re-projected onto the unit sphere by dividing by their norm. This prevents the quaternion from drifting away from unit length during iteration (the quaternion normalization residual only enforces this at convergence, not during intermediate steps).
If a quaternion degenerates to near-zero norm, it is reset to the identity quaternion `(1, 0, 0, 0)`.
### Convergence
Newton-Raphson runs for up to 100 iterations with tolerance `1e-10` on the residual norm. For well-conditioned systems near the solution, convergence is typically quadratic (3-5 iterations). Interactive drag from a nearby position typically converges in 1-2 iterations.
## L-BFGS-B fallback
**Source:** `mods/solver/kindred_solver/bfgs.py`
If Newton-Raphson fails to converge, L-BFGS-B minimizes the sum of squared residuals: `f(x) = 0.5 * sum(r_i^2)`. This is a quasi-Newton method that approximates the Hessian from gradient history, with bounded memory usage.
The gradient is computed analytically from the same symbolic Jacobian: `grad = J^T @ r`. This is passed directly to `scipy.optimize.minimize` via the `jac=True` interface to avoid redundant function evaluations.
L-BFGS-B is more robust for ill-conditioned systems where the Jacobian is nearly singular, but converges more slowly (superlinear rather than quadratic). It runs for up to 200 iterations.
If scipy is not available, the fallback is skipped gracefully.
## Graph decomposition
**Source:** `mods/solver/kindred_solver/decompose.py`
For assemblies with 8 or more free bodies, the solver decomposes the constraint graph into clusters and solves them independently. This improves performance for large assemblies by reducing the Jacobian size from O(n^2) to the sum of smaller cluster Jacobians.
### Algorithm
1. **Build constraint graph.** Bodies are nodes, constraints are edges weighted by their residual count (DOF removed). Grounded bodies are tagged.
2. **Find biconnected components.** Using `networkx.biconnected_components()`, decompose the graph into rigid clusters. Articulation points (bodies shared between clusters) are identified.
3. **Build block-cut tree.** A bipartite graph of clusters and articulation points, rooted at a grounded cluster.
4. **BFS ordering.** Traverse the block-cut tree root-to-leaf, producing a solve order where grounded clusters come first and boundary conditions propagate outward.
5. **Solve each cluster.** For each cluster in order:
- Fix boundary bodies that were already solved by previous clusters (their parameters become constants)
- Collect the cluster's residuals and quaternion normalization equations
- Run substitution pass (compiles fixed boundary values to constants)
- Newton-Raphson + BFGS fallback on the reduced system
- Mark the cluster's bodies as solved
- Unfix boundary parameters for downstream clusters
### Example
Consider a chain of 4 bodies: `Ground -- A -- B -- C` with joints at each connection. This decomposes into two biconnected components (if the joints create articulation points):
- Cluster 1: {Ground, A} -- solved first (grounded)
- Cluster 2: {A, B, C} -- solved second with A's parameters fixed to Cluster 1's result
The 21-variable monolithic system (3 free bodies x 7 params) becomes two smaller systems solved in sequence.
### Disconnected sub-assemblies
The decomposition also handles disconnected components. Each connected component of the constraint graph is decomposed independently. Components without a grounded body will fail to solve (returning `NoGroundedParts`).
### Pebble game integration
The `classify_cluster_rigidity()` function uses the pebble game algorithm from `GNN/solver/datagen/` to classify clusters as well-constrained, underconstrained, overconstrained, or mixed. This provides fast O(n) rigidity analysis without running the full solver.
## Half-space tracking
**Source:** `mods/solver/kindred_solver/preference.py`
Many constraints have multiple valid solutions (branches). A distance constraint between two points can be satisfied with the points on either side of each other. Parallel axes can point in the same or opposite directions.
Half-space tracking preserves the initial configuration branch:
1. **At setup:** Evaluate an indicator function for each branching constraint. Record its sign as the reference branch.
2. **After each Newton step:** Re-evaluate the indicator. If the sign flipped, apply a correction to push the solution back to the reference branch.
Tracked constraint types:
| Constraint | Indicator | Correction |
|-----------|-----------|------------|
| DistancePointPoint (d > 0) | Dot product of displacement with reference direction | Reflect the moving body's position |
| Parallel | `z_i . z_j` (same vs. opposite direction) | None (tracked for monitoring) |
| Angle | Dominant cross product component | None (tracked for monitoring) |
| Perpendicular | Dominant cross product component | None (tracked for monitoring) |

View File

@@ -0,0 +1,256 @@
# Writing a Custom Solver
The KCSolve framework lets you implement a solver backend in pure Python, register it at runtime, and select it through the Assembly preferences. This tutorial walks through building a minimal solver and then extending it.
## Minimal solver
A solver must subclass `kcsolve.IKCSolver` and implement three methods:
```python
import kcsolve
class MySolver(kcsolve.IKCSolver):
def __init__(self):
super().__init__() # required for pybind11 trampoline
def name(self):
return "My Custom Solver"
def supported_joints(self):
return [
kcsolve.BaseJointKind.Fixed,
kcsolve.BaseJointKind.Revolute,
]
def solve(self, ctx):
result = kcsolve.SolveResult()
# Find grounded parts
grounded = {p.id for p in ctx.parts if p.grounded}
if not grounded:
result.status = kcsolve.SolveStatus.NoGroundedParts
return result
# Your solving logic here...
# For each non-grounded part, compute its solved placement
for part in ctx.parts:
if part.grounded:
continue
pr = kcsolve.SolveResult.PartResult()
pr.id = part.id
pr.placement = part.placement # use current placement as placeholder
result.placements = result.placements + [pr]
result.status = kcsolve.SolveStatus.Success
result.dof = 0
return result
```
Register it:
```python
kcsolve.register_solver("my_solver", MySolver)
```
Test it from the FreeCAD console:
```python
solver = kcsolve.load("my_solver")
print(solver.name()) # "My Custom Solver"
ctx = kcsolve.SolveContext()
# ... build context ...
result = solver.solve(ctx)
print(result.status) # SolveStatus.Success
```
## Addon packaging
To make your solver load automatically, create a FreeCAD addon:
```
my_solver_addon/
package.xml # Addon manifest
Init.py # Registration entry point
my_solver/
__init__.py
solver.py # MySolver class
```
**package.xml:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<package format="1">
<name>MyCustomSolver</name>
<description>Custom assembly constraint solver</description>
<version>0.1.0</version>
<content>
<preferencepack>
<name>MySolver</name>
</preferencepack>
</content>
</package>
```
**Init.py:**
```python
import kcsolve
from my_solver.solver import MySolver
kcsolve.register_solver("my_solver", MySolver)
```
Place the addon in the FreeCAD Mod directory or as a git submodule in `mods/`.
## Working with SolveContext
The `SolveContext` contains everything the solver needs:
### Parts
```python
for part in ctx.parts:
print(f"{part.id}: grounded={part.grounded}")
print(f" position: {list(part.placement.position)}")
print(f" quaternion: {list(part.placement.quaternion)}")
print(f" mass: {part.mass}")
```
Each part has 7 degrees of freedom: 3 translation (x, y, z) and 4 quaternion components (w, x, y, z) with a unit-norm constraint reducing the rotational DOF to 3.
**Quaternion convention:** `(w, x, y, z)` where `w` is the scalar part. This differs from FreeCAD's `Base.Rotation(x, y, z, w)`. The adapter layer handles the swap.
### Constraints
```python
for c in ctx.constraints:
if not c.activated:
continue
print(f"{c.id}: {c.type} between {c.part_i} and {c.part_j}")
print(f" marker_i: pos={list(c.marker_i.position)}, "
f"quat={list(c.marker_i.quaternion)}")
print(f" params: {list(c.params)}")
print(f" limits: {len(c.limits)}")
```
The marker transforms define local coordinate frames on each part. The constraint type determines what geometric relationship is enforced between these frames.
### Returning results
```python
result = kcsolve.SolveResult()
result.status = kcsolve.SolveStatus.Success
result.dof = computed_dof
placements = []
for part_id, pos, quat in solved_parts:
pr = kcsolve.SolveResult.PartResult()
pr.id = part_id
pr.placement = kcsolve.Transform()
pr.placement.position = list(pos)
pr.placement.quaternion = list(quat)
placements.append(pr)
result.placements = placements
return result
```
**Important:** pybind11 list fields return copies. Use `result.placements = [...]` (whole-list assignment), not `result.placements.append(...)`.
## Adding optional capabilities
### Diagnostics
Override `diagnose()` to detect overconstrained or malformed assemblies:
```python
def diagnose(self, ctx):
diagnostics = []
# ... analyze constraints ...
d = kcsolve.ConstraintDiagnostic()
d.constraint_id = "Joint001"
d.kind = kcsolve.DiagnosticKind.Redundant
d.detail = "This joint duplicates Joint002"
diagnostics.append(d)
return diagnostics
```
### Interactive drag
Override the three drag methods for real-time viewport dragging:
```python
def pre_drag(self, ctx, drag_parts):
self._ctx = ctx
self._dragging = set(drag_parts)
return self.solve(ctx)
def drag_step(self, drag_placements):
# Update dragged parts in stored context
for pr in drag_placements:
for part in self._ctx.parts:
if part.id == pr.id:
part.placement = pr.placement
break
return self.solve(self._ctx)
def post_drag(self):
self._ctx = None
self._dragging = None
```
For responsive dragging, the solver should converge quickly from a nearby initial guess. Use warm-starting (current placements as initial guess) and consider caching internal state across drag steps.
### Incremental update
Override `update()` for the case where only constraint parameters changed (not topology):
```python
def update(self, ctx):
# Reuse cached factorization, only re-evaluate changed residuals
return self.solve(ctx) # default: just re-solve
```
## Testing
### Unit tests (without FreeCAD)
Test your solver logic with hand-built `SolveContext` objects:
```python
import kcsolve
def test_fixed_joint():
ctx = kcsolve.SolveContext()
base = kcsolve.Part()
base.id = "base"
base.grounded = True
arm = kcsolve.Part()
arm.id = "arm"
arm.placement.position = [100.0, 0.0, 0.0]
joint = kcsolve.Constraint()
joint.id = "Joint001"
joint.part_i = "base"
joint.part_j = "arm"
joint.type = kcsolve.BaseJointKind.Fixed
ctx.parts = [base, arm]
ctx.constraints = [joint]
solver = MySolver()
result = solver.solve(ctx)
assert result.status == kcsolve.SolveStatus.Success
```
### Integration tests (with FreeCAD)
For integration testing within FreeCAD, follow the pattern in `TestKindredSolverIntegration.py`: set the solver preference in `setUp()`, create document objects, and verify solve results.
## Reference
- [KCSolve Python API](../reference/kcsolve-python.md) -- complete type and function reference
- [KCSolve Architecture](../architecture/ondsel-solver.md) -- C++ framework details
- [Constraints](constraints.md) -- constraint types and residual counts
- [Kindred Solver Overview](overview.md) -- how the built-in Kindred solver works

1
mods/gears Submodule

Submodule mods/gears added at 1e26c393f3

View File

@@ -1,34 +1,53 @@
# kindred-addon-sdk — stable API for Kindred Create addon integration # kindred-addon-sdk — stable API for Kindred Create addon integration
from kindred_sdk.version import SDK_VERSION from kindred_sdk.command import register_command
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.compat import create_version, freecad_version 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__ = [ __all__ = [
"SDK_VERSION", "SDK_VERSION",
"register_context", "active_origin",
"unregister_context",
"register_overlay",
"unregister_overlay",
"inject_commands",
"current_context",
"refresh_context",
"get_theme_tokens",
"load_palette",
"register_origin",
"unregister_origin",
"register_dock_panel",
"create_version", "create_version",
"current_context",
"freecad_version", "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. """Editing context and overlay registration wrappers.
Thin wrappers around FreeCADGui editing context bindings. If the Routes through the ``kcsdk`` C++ module. The legacy ``FreeCADGui``
underlying C++ API changes during an upstream rebase, only this module Python bindings are deprecated — kcsdk is required.
needs to be updated.
""" """
import FreeCAD 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): 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): if not callable(match):
raise TypeError("match must be callable") raise TypeError("match must be callable")
_require_kcsdk()
try: try:
_gui().registerEditingContext(context_id, label, color, toolbars, match, priority) _kcsdk.register_context(context_id, label, color, toolbars, match, priority)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to register context '{context_id}': {e}\n" 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): if not isinstance(context_id, str):
raise TypeError(f"context_id must be str, got {type(context_id).__name__}") raise TypeError(f"context_id must be str, got {type(context_id).__name__}")
_require_kcsdk()
try: try:
_gui().unregisterEditingContext(context_id) _kcsdk.unregister_context(context_id)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n" 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): if not callable(match):
raise TypeError("match must be callable") raise TypeError("match must be callable")
_require_kcsdk()
try: try:
_gui().registerEditingOverlay(overlay_id, toolbars, match) _kcsdk.register_overlay(overlay_id, toolbars, match)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n" 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): if not isinstance(overlay_id, str):
raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}")
_require_kcsdk()
try: try:
_gui().unregisterEditingOverlay(overlay_id) _kcsdk.unregister_overlay(overlay_id)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n" 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): if not isinstance(commands, list):
raise TypeError(f"commands must be list, got {type(commands).__name__}") raise TypeError(f"commands must be list, got {type(commands).__name__}")
_require_kcsdk()
try: try:
_gui().injectEditingCommands(context_id, toolbar_name, commands) _kcsdk.inject_commands(context_id, toolbar_name, commands)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n" 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``, Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``,
``breadcrumbColors``. Returns ``None`` if no context is active. ``breadcrumbColors``. Returns ``None`` if no context is active.
""" """
_require_kcsdk()
try: try:
return _gui().currentEditingContext() return _kcsdk.current_context()
except Exception as e: 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 return None
def refresh_context(): def refresh_context():
"""Force re-resolution and update of the editing context.""" """Force re-resolution and update of the editing context."""
_require_kcsdk()
try: try:
_gui().refreshEditingContext() _kcsdk.refresh()
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n") 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 + Routes through the ``kcsdk`` C++ module (IPanelProvider / DockWindowManager).
try/except pattern used in ``src/Mod/Create/InitGui.py``. The kcsdk module is required — legacy PySide fallback has been removed.
""" """
import FreeCAD import FreeCAD
_AREA_MAP = { try:
"left": 1, # Qt.LeftDockWidgetArea import kcsdk as _kcsdk
"right": 2, # Qt.RightDockWidgetArea except ImportError:
"top": 4, # Qt.TopDockWidgetArea _kcsdk = None
"bottom": 8, # Qt.BottomDockWidgetArea
} _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): 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__}") raise TypeError(f"object_name must be str, got {type(object_name).__name__}")
if not callable(widget_factory): if not callable(widget_factory):
raise TypeError("widget_factory must be callable") 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) _require_kcsdk()
if qt_area is None:
raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}")
def _create(): dock_area = _get_dock_area(area)
try:
from PySide import QtCore, QtWidgets
import FreeCADGui class _AnonymousProvider(_kcsdk.IPanelProvider):
def id(self):
return object_name
mw = FreeCADGui.getMainWindow() def title(self):
if mw is None: return title
return
if mw.findChild(QtWidgets.QDockWidget, object_name): def create_widget(self):
return return widget_factory()
widget = widget_factory() def preferred_area(self):
panel = QtWidgets.QDockWidget(title, mw) return dock_area
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")
try: 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 from PySide.QtCore import QTimer
QTimer.singleShot(max(0, delay_ms), _create) QTimer.singleShot(max(0, delay_ms), _create)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintLog( FreeCAD.Console.PrintWarning(
f"kindred_sdk: Could not schedule dock panel '{object_name}': {e}\n" 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 Registration (``register_origin`` / ``unregister_origin``) wraps
and error handling. Addons implement the FileOrigin duck-typed ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation.
interface directly (see Silo's ``SiloOrigin`` for the full contract).
Query functions (``list_origins``, ``active_origin``, etc.) route
through the ``kcsdk`` C++ module. The kcsdk module is required for
query operations.
""" """
import FreeCAD import FreeCAD
try:
import kcsdk as _kcsdk
except ImportError:
_kcsdk = None
_REQUIRED_METHODS = ("id", "name", "type", "ownsDocument") _REQUIRED_METHODS = ("id", "name", "type", "ownsDocument")
def _gui(): def _gui():
"""Lazy import of FreeCADGui (not available in console mode)."""
import FreeCADGui import FreeCADGui
return 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): def register_origin(origin):
"""Register a FileOrigin with FreeCADGui. """Register a FileOrigin with FreeCADGui.
@@ -40,3 +57,80 @@ def unregister_origin(origin):
FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n") FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n")
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to unregister origin: {e}\n") 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") _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"): def load_palette(name="catppuccin-mocha"):
"""Load a named palette from the ``palettes/`` directory. """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 Results are cached; subsequent calls with the same *name* return
the same ``Palette`` instance. the same ``Palette`` instance.
""" """
if name in _cache: if name in _cache:
return _cache[name] 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") path = os.path.join(_PALETTES_DIR, f"{name}.yaml")
if not os.path.isfile(path): if not os.path.isfile(path):
FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n") FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n")
@@ -152,7 +188,9 @@ def load_palette(name="catppuccin-mocha"):
try: try:
raw = _load_yaml(path) raw = _load_yaml(path)
except Exception as e: 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 return None
palette = Palette( palette = Palette(
@@ -172,9 +210,20 @@ def load_palette(name="catppuccin-mocha"):
def get_theme_tokens(name="catppuccin-mocha"): def get_theme_tokens(name="catppuccin-mocha"):
"""Return a dict of ``{token_name: "#hex"}`` for all colors in a palette. """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. 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) palette = load_palette(name)
if palette is None: if palette is None:
return {} 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> <name>sdk</name>
<description>Kindred Create addon SDK - stable API for addon integration</description> <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> <maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
<license file="LICENSE">LGPL-2.1-or-later</license> <license file="LICENSE">LGPL-2.1-or-later</license>
@@ -17,7 +17,6 @@
<kindred> <kindred>
<min_create_version>0.1.0</min_create_version> <min_create_version>0.1.0</min_create_version>
<load_priority>0</load_priority> <load_priority>0</load_priority>
<pure_python>true</pure_python>
</kindred> </kindred>
</package> </package>

1
mods/solver Submodule

Submodule mods/solver added at cd7f66f20a

Submodule mods/ztools deleted from 296a94e2d0

View File

@@ -30,7 +30,7 @@ fi
# Get version from git if not provided # Get version from git if not provided
if [ -z "$VERSION" ]; then if [ -z "$VERSION" ]; then
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.0") VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.5")
fi fi
# Convert version to Debian-compatible format # Convert version to Debian-compatible format

View File

@@ -155,6 +155,7 @@ requirements:
- lark - lark
- lxml - lxml
- matplotlib-base - matplotlib-base
- networkx
- nine - nine
- noqt5 - noqt5
- numpy>=1.26,<2 - numpy>=1.26,<2

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

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