diff --git a/reference/quicknav/.gitignore b/reference/quicknav/.gitignore
new file mode 100644
index 0000000000..c18dd8d83c
--- /dev/null
+++ b/reference/quicknav/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/reference/quicknav/Init.py b/reference/quicknav/Init.py
new file mode 100644
index 0000000000..9ad3834343
--- /dev/null
+++ b/reference/quicknav/Init.py
@@ -0,0 +1,3 @@
+import FreeCAD
+
+FreeCAD.Console.PrintLog("quicknav addon loaded\n")
diff --git a/reference/quicknav/InitGui.py b/reference/quicknav/InitGui.py
new file mode 100644
index 0000000000..cc49b17a79
--- /dev/null
+++ b/reference/quicknav/InitGui.py
@@ -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
diff --git a/reference/quicknav/package.xml b/reference/quicknav/package.xml
new file mode 100644
index 0000000000..4836cffb13
--- /dev/null
+++ b/reference/quicknav/package.xml
@@ -0,0 +1,25 @@
+
+
+ QuickNav
+ Keyboard-driven command navigation
+ 0.1.0
+ Kindred Systems
+ LGPL-2.1
+ https://git.kindred-systems.com/kindred/quicknav
+
+
+
+ QuickNavWorkbench
+ ./
+
+
+
+
+ 0.1.0
+ 20
+ true
+
+ sdk
+
+
+
diff --git a/reference/quicknav/quicknav/__init__.py b/reference/quicknav/quicknav/__init__.py
new file mode 100644
index 0000000000..4ece8f698e
--- /dev/null
+++ b/reference/quicknav/quicknav/__init__.py
@@ -0,0 +1 @@
+"""QuickNav -- keyboard-driven command navigation for FreeCAD and Kindred Create."""
diff --git a/reference/quicknav/quicknav/commands.py b/reference/quicknav/quicknav/commands.py
new file mode 100644
index 0000000000..fb71108f90
--- /dev/null
+++ b/reference/quicknav/quicknav/commands.py
@@ -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())
diff --git a/reference/quicknav/quicknav/core.py b/reference/quicknav/quicknav/core.py
new file mode 100644
index 0000000000..14bca920b0
--- /dev/null
+++ b/reference/quicknav/quicknav/core.py
@@ -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)
diff --git a/reference/quicknav/quicknav/event_filter.py b/reference/quicknav/quicknav/event_filter.py
new file mode 100644
index 0000000000..7509bca549
--- /dev/null
+++ b/reference/quicknav/quicknav/event_filter.py
@@ -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
diff --git a/reference/quicknav/quicknav/nav_bar.py b/reference/quicknav/quicknav/nav_bar.py
new file mode 100644
index 0000000000..e332672451
--- /dev/null
+++ b/reference/quicknav/quicknav/nav_bar.py
@@ -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};"
diff --git a/reference/quicknav/quicknav/workbench_map.py b/reference/quicknav/quicknav/workbench_map.py
new file mode 100644
index 0000000000..cf14151f1b
--- /dev/null
+++ b/reference/quicknav/quicknav/workbench_map.py
@@ -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
diff --git a/reference/ztools/KINDRED_INTEGRATION.md b/reference/ztools/KINDRED_INTEGRATION.md
new file mode 100644
index 0000000000..0d19282bf0
--- /dev/null
+++ b/reference/ztools/KINDRED_INTEGRATION.md
@@ -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.
diff --git a/reference/ztools/PLAN.md b/reference/ztools/PLAN.md
new file mode 100644
index 0000000000..5c57208367
--- /dev/null
+++ b/reference/ztools/PLAN.md
@@ -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
diff --git a/reference/ztools/ROADMAP.md b/reference/ztools/ROADMAP.md
new file mode 100644
index 0000000000..bf662ba3de
--- /dev/null
+++ b/reference/ztools/ROADMAP.md
@@ -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
diff --git a/reference/ztools/TODO_ATTACHMENT_WORK.md b/reference/ztools/TODO_ATTACHMENT_WORK.md
new file mode 100644
index 0000000000..0c13a7f41a
--- /dev/null
+++ b/reference/ztools/TODO_ATTACHMENT_WORK.md
@@ -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.
diff --git a/reference/ztools/package.xml b/reference/ztools/package.xml
new file mode 100644
index 0000000000..ac2658b446
--- /dev/null
+++ b/reference/ztools/package.xml
@@ -0,0 +1,36 @@
+
+
+
+ ZTools
+
+ Extended PartDesign workbench with velocity-focused tools and advanced datum creation.
+
+ 0.1.0
+
+ 2026-01-24
+
+ LGPL-3.0-or-later
+
+
+
+ ZTools
+ ZToolsWorkbench
+ ./ztools
+
+
+
+
+
+ 0.1.0
+ 50
+ true
+
+ sdk
+
+
+
+
+
+
+
+
diff --git a/reference/ztools/partdesign.md b/reference/ztools/partdesign.md
new file mode 100644
index 0000000000..333dbf9f30
--- /dev/null
+++ b/reference/ztools/partdesign.md
@@ -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', '<>.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*
diff --git a/reference/ztools/ztools/Init.py b/reference/ztools/ztools/Init.py
new file mode 100644
index 0000000000..79204b3c57
--- /dev/null
+++ b/reference/ztools/ztools/Init.py
@@ -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")
diff --git a/reference/ztools/ztools/InitGui.py b/reference/ztools/ztools/InitGui.py
new file mode 100644
index 0000000000..724a40aff2
--- /dev/null
+++ b/reference/ztools/ztools/InitGui.py
@@ -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())
diff --git a/reference/ztools/ztools/README.md b/reference/ztools/ztools/README.md
new file mode 100644
index 0000000000..b1d9e4add4
--- /dev/null
+++ b/reference/ztools/ztools/README.md
@@ -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
diff --git a/reference/ztools/ztools/setup.cfg b/reference/ztools/ztools/setup.cfg
new file mode 100644
index 0000000000..672d152dc6
--- /dev/null
+++ b/reference/ztools/ztools/setup.cfg
@@ -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
diff --git a/reference/ztools/ztools/ztools/__init__.py b/reference/ztools/ztools/ztools/__init__.py
new file mode 100644
index 0000000000..d0c14a3d2c
--- /dev/null
+++ b/reference/ztools/ztools/ztools/__init__.py
@@ -0,0 +1,2 @@
+# ztools - Extended PartDesign for FreeCAD
+__version__ = "0.1.0"
diff --git a/reference/ztools/ztools/ztools/commands/__init__.py b/reference/ztools/ztools/ztools/commands/__init__.py
new file mode 100644
index 0000000000..8a1b211d98
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/__init__.py
@@ -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",
+]
diff --git a/reference/ztools/ztools/ztools/commands/assembly_pattern_commands.py b/reference/ztools/ztools/ztools/commands/assembly_pattern_commands.py
new file mode 100644
index 0000000000..926b6a358c
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/assembly_pattern_commands.py
@@ -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())
diff --git a/reference/ztools/ztools/ztools/commands/datum_commands.py b/reference/ztools/ztools/ztools/commands/datum_commands.py
new file mode 100644
index 0000000000..14fd78e4d8
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/datum_commands.py
@@ -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())
diff --git a/reference/ztools/ztools/ztools/commands/datum_viewprovider.py b/reference/ztools/ztools/ztools/commands/datum_viewprovider.py
new file mode 100644
index 0000000000..2959e93398
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/datum_viewprovider.py
@@ -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
diff --git a/reference/ztools/ztools/ztools/commands/pattern_commands.py b/reference/ztools/ztools/ztools/commands/pattern_commands.py
new file mode 100644
index 0000000000..e94a58ea57
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/pattern_commands.py
@@ -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())
diff --git a/reference/ztools/ztools/ztools/commands/pocket_commands.py b/reference/ztools/ztools/ztools/commands/pocket_commands.py
new file mode 100644
index 0000000000..286ead09ee
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/pocket_commands.py
@@ -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(
+ "When enabled, material outside the sketch profile is removed,\n"
+ "leaving only the material inside the sketch boundary."
+ )
+ 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())
diff --git a/reference/ztools/ztools/ztools/commands/spreadsheet_commands.py b/reference/ztools/ztools/ztools/commands/spreadsheet_commands.py
new file mode 100644
index 0000000000..26425308b2
--- /dev/null
+++ b/reference/ztools/ztools/ztools/commands/spreadsheet_commands.py
@@ -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())
diff --git a/reference/ztools/ztools/ztools/datums/__init__.py b/reference/ztools/ztools/ztools/datums/__init__.py
new file mode 100644
index 0000000000..e9755d3686
--- /dev/null
+++ b/reference/ztools/ztools/ztools/datums/__init__.py
@@ -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",
+]
diff --git a/reference/ztools/ztools/ztools/datums/core.py b/reference/ztools/ztools/ztools/datums/core.py
new file mode 100644
index 0000000000..e1b3d1e359
--- /dev/null
+++ b/reference/ztools/ztools/ztools/datums/core.py
@@ -0,0 +1,1588 @@
+# ztools/datums/core.py
+# Core datum creation functions with custom ZTools attachment system
+
+import json
+import math
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+import FreeCAD as App
+import Part
+
+# Metadata property prefix
+ZTOOLS_META_PREFIX = "ZTools_"
+
+# ZTools default colors (Catppuccin Mocha palette)
+ZTOOLS_PLANE_COLOR = (0.796, 0.651, 0.969) # Mauve #cba6f7 as RGB 0-1
+ZTOOLS_PLANE_TRANSPARENCY = 70 # 70% transparent
+
+
+def _get_next_index(doc: App.Document, prefix: str) -> int:
+ """Get next available index for auto-naming."""
+ existing = [obj.Name for obj in doc.Objects if obj.Name.startswith(prefix)]
+ if not existing:
+ return 1
+ indices = []
+ for name in existing:
+ try:
+ idx = int(name.replace(prefix, "").lstrip("_").split("_")[0])
+ indices.append(idx)
+ except ValueError:
+ continue
+ return max(indices, default=0) + 1
+
+
+# =============================================================================
+# ZTOOLS ATTACHMENT SYSTEM
+# =============================================================================
+#
+# ZTools leverages FreeCAD's built-in AttachExtension (MapMode, AttachmentSupport,
+# AttachmentOffset) on PartDesign datums for automatic parametric updates on
+# recompute. Each datum type maps to a vanilla MapMode:
+#
+# offset_from_face -> FlatFace + AttachmentOffset.Base.z = distance
+# offset_from_plane -> FlatFace + AttachmentOffset.Base.z = distance
+# midplane -> FlatFace on face1 + AttachmentOffset.Base.z = half_gap
+# 3_points -> ThreePointsPlane
+# normal_to_edge -> NormalToEdge + MapPathParameter
+# angled -> FlatFace + AttachmentOffset.Rotation = angle about edge
+# tangent_cylinder -> TangentPlane (with vertex ref) or manual fallback
+#
+# ZTools metadata (ZTools_Type, ZTools_Params, ZTools_SourceRefs) is kept for
+# the edit UI. Attachment properties are hidden from the property panel.
+#
+# Non-Body datums (Part::Plane) lack AttachExtension and use manual placement.
+# =============================================================================
+
+
+def _style_ztools_plane(obj):
+ """
+ Apply ZTools default styling to a datum plane.
+ Makes the plane transparent purple (Catppuccin Mocha mauve).
+ """
+ if hasattr(obj, "ViewObject") and obj.ViewObject is not None:
+ vo = obj.ViewObject
+ # Set shape color to mauve purple
+ if hasattr(vo, "ShapeColor"):
+ vo.ShapeColor = ZTOOLS_PLANE_COLOR
+ # Set transparency (0 = opaque, 100 = fully transparent)
+ if hasattr(vo, "Transparency"):
+ vo.Transparency = ZTOOLS_PLANE_TRANSPARENCY
+ # Also set line color for edges
+ if hasattr(vo, "LineColor"):
+ vo.LineColor = ZTOOLS_PLANE_COLOR
+
+
+def _hide_attachment_properties(obj):
+ """
+ Hide FreeCAD's vanilla attachment properties from the property editor.
+ This prevents user confusion since ZTools uses its own attachment system.
+
+ Uses setPropertyStatus with "Hidden" which persists across save/restore,
+ unlike setEditorMode which is transient.
+ """
+ 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 # Property might not exist on all datum types
+
+
+def _setup_ztools_viewprovider(obj):
+ """
+ Set up a custom ViewProvider proxy for ZTools datums.
+ This enables custom double-click behavior to open ZTools editor.
+
+ Note: PartDesign datum ViewProviders (e.g. ViewProviderDatumPlane) are
+ pure C++ and do not support Python proxies. In that case we skip the
+ custom ViewProvider setup — the datum still works, just without custom
+ double-click edit behavior.
+ """
+ if not hasattr(obj, "ViewObject") or obj.ViewObject is None:
+ return
+
+ vo = obj.ViewObject
+
+ # C++ ViewProviders for PartDesign datums don't expose a Proxy attribute
+ if not hasattr(vo, "Proxy"):
+ return
+
+ # Only set up if not already a ZTools ViewProvider
+ if vo.Proxy is not None:
+ if hasattr(vo.Proxy, "_is_ztools"):
+ return
+
+ # Import here to avoid circular imports
+ from ztools.commands.datum_viewprovider import ZToolsDatumViewProvider
+
+ try:
+ ZToolsDatumViewProvider(vo)
+ except Exception:
+ pass # C++ ViewProvider doesn't support Python proxy assignment
+
+
+def _configure_attachment(obj, map_mode, support, offset=None, path_param=None):
+ """
+ Configure FreeCAD's vanilla AttachExtension on a datum object.
+
+ This enables automatic placement updates on recompute — the C++ engine
+ recalculates placement from source geometry via extensionExecute().
+
+ Args:
+ obj: The datum object (must have AttachExtension, e.g. PartDesign::Plane)
+ map_mode: MapMode string (e.g. "FlatFace", "NormalToEdge")
+ support: List of (object, subname) tuples for AttachmentSupport
+ offset: Optional App.Placement for AttachmentOffset
+ path_param: Optional float for MapPathParameter (0-1, for curve modes)
+
+ Returns:
+ True if attachment was configured, False if object lacks AttachExtension
+ """
+ if not hasattr(obj, "MapMode"):
+ return False
+
+ obj.AttachmentSupport = support
+ obj.MapMode = map_mode
+ if offset is not None:
+ obj.AttachmentOffset = offset
+ if path_param is not None and hasattr(obj, "MapPathParameter"):
+ obj.MapPathParameter = path_param
+ return True
+
+
+def _setup_ztools_datum(
+ obj,
+ placement: App.Placement,
+ datum_type: str,
+ params: Dict[str, Any],
+ source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None,
+ is_plane: bool = False,
+ map_mode: Optional[str] = None,
+ support: Optional[list] = None,
+ offset: Optional[App.Placement] = None,
+ path_param: Optional[float] = None,
+):
+ """
+ Set up a ZTools datum with attachment or manual placement.
+
+ When map_mode and support are provided and the object has AttachExtension,
+ configures vanilla attachment for automatic recompute. Otherwise falls back
+ to manual placement with MapMode="Deactivated".
+
+ Args:
+ obj: The datum object (PartDesign::Plane, Line, or Point)
+ placement: Calculated placement (used as fallback for non-Body datums)
+ datum_type: ZTools creation method identifier
+ params: Creation parameters to store
+ source_refs: List of (object, subname) tuples for source geometry
+ is_plane: If True, apply transparent purple styling
+ map_mode: Optional MapMode string for vanilla attachment
+ support: Optional AttachmentSupport list for vanilla attachment
+ offset: Optional AttachmentOffset placement
+ path_param: Optional MapPathParameter value (0-1)
+ """
+ if map_mode and support and hasattr(obj, "MapMode"):
+ _configure_attachment(obj, map_mode, support, offset, path_param)
+ else:
+ # Fallback: manual placement (non-Body datums like Part::Plane)
+ if hasattr(obj, "MapMode"):
+ obj.MapMode = "Deactivated"
+ obj.Placement = placement
+
+ # Store ZTools metadata
+ _add_ztools_metadata(obj, datum_type, params, source_refs)
+
+ # Apply plane styling if this is a plane
+ if is_plane:
+ _style_ztools_plane(obj)
+
+ # Hide vanilla attachment properties from property editor
+ _hide_attachment_properties(obj)
+
+ # Set up custom ViewProvider for edit behavior
+ _setup_ztools_viewprovider(obj)
+
+
+def _add_ztools_metadata(
+ obj,
+ datum_type: str,
+ params: Dict[str, Any],
+ source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None,
+):
+ """Store ZTools metadata in object properties."""
+ # Add ZTools_Type property
+ if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"):
+ obj.addProperty(
+ "App::PropertyString",
+ f"{ZTOOLS_META_PREFIX}Type",
+ "ZTools",
+ "Datum creation method",
+ )
+
+ # Add ZTools_Params property
+ if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"):
+ obj.addProperty(
+ "App::PropertyString",
+ f"{ZTOOLS_META_PREFIX}Params",
+ "ZTools",
+ "Creation parameters (JSON)",
+ )
+
+ # Add ZTools_SourceRefs property for storing references
+ if not hasattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs"):
+ obj.addProperty(
+ "App::PropertyString",
+ f"{ZTOOLS_META_PREFIX}SourceRefs",
+ "ZTools",
+ "Source geometry references (JSON)",
+ )
+
+ setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type)
+
+ # Convert vectors/placements to serializable format
+ serializable_params = {}
+ for k, v in params.items():
+ if isinstance(v, App.Vector):
+ serializable_params[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
+ elif isinstance(v, App.Placement):
+ serializable_params[k] = {
+ "_type": "Placement",
+ "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z},
+ "rotation": list(v.Rotation.Q),
+ }
+ else:
+ serializable_params[k] = v
+
+ setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params))
+
+ # Store source references
+ if source_refs:
+ ref_data = []
+ for src_obj, subname in source_refs:
+ if src_obj:
+ ref_data.append({"object": src_obj.Name, "subname": subname or ""})
+ setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", json.dumps(ref_data))
+ else:
+ setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", "[]")
+
+
+def _get_subname_from_shape(parent_obj, shape):
+ """
+ Find the sub-element name (e.g., 'Face1', 'Edge2') for a shape within a parent object.
+
+ Args:
+ parent_obj: The FreeCAD object containing the shape
+ shape: The Part.Face, Part.Edge, or Part.Vertex to find
+
+ Returns:
+ String like 'Face1', 'Edge2', 'Vertex3' or None if not found
+ """
+ if not hasattr(parent_obj, "Shape"):
+ return None
+
+ parent_shape = parent_obj.Shape
+
+ # Check faces
+ if isinstance(shape, Part.Face):
+ for i, face in enumerate(parent_shape.Faces, 1):
+ if face.isSame(shape):
+ return f"Face{i}"
+
+ # Check edges
+ elif isinstance(shape, Part.Edge):
+ for i, edge in enumerate(parent_shape.Edges, 1):
+ if edge.isSame(shape):
+ return f"Edge{i}"
+
+ # Check vertices
+ elif isinstance(shape, Part.Vertex):
+ for i, vertex in enumerate(parent_shape.Vertexes, 1):
+ if vertex.isSame(shape):
+ return f"Vertex{i}"
+
+ return None
+
+
+def _find_shape_owner(doc, shape):
+ """
+ Find the object that owns a given shape.
+
+ Args:
+ doc: FreeCAD document
+ shape: The shape to find the owner of
+
+ Returns:
+ Tuple of (object, subname) or (None, None) if not found
+ """
+ for obj in doc.Objects:
+ if not hasattr(obj, "Shape"):
+ continue
+
+ subname = _get_subname_from_shape(obj, shape)
+ if subname:
+ return obj, subname
+
+ return None, None
+
+
+def _link_to_spreadsheet(
+ doc: App.Document, obj, param_name: str, value: float, alias: str
+):
+ """Optionally link a parameter to spreadsheet."""
+ sheet = doc.getObject("Spreadsheet")
+ if not sheet:
+ sheet = doc.addObject("Spreadsheet::Sheet", "Spreadsheet")
+
+ # Find next empty row
+ row = 1
+ while sheet.getContents(f"A{row}"):
+ row += 1
+
+ sheet.set(f"A{row}", alias)
+ sheet.set(f"B{row}", f"{value} mm")
+ sheet.setAlias(f"B{row}", alias)
+
+ # Set expression on object
+ obj.setExpression(param_name, f"Spreadsheet.{alias}")
+
+ return alias
+
+
+# =============================================================================
+# DATUM PLANES
+# =============================================================================
+
+
+def plane_offset_from_face(
+ face: Part.Face,
+ distance: float,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane offset from a planar face.
+
+ Args:
+ face: Source face (must be planar)
+ distance: Offset distance in mm (positive = along normal)
+ name: Optional custom name
+ body: Optional body to add plane to (None = document level)
+ link_spreadsheet: Create spreadsheet alias for distance
+ source_object: The object containing the face (for reference tracking)
+ source_subname: The sub-element name like 'Face1' (for reference tracking)
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ if not face.Surface.isPlanar():
+ raise ValueError("Face must be planar for offset plane")
+
+ # Try to find the source object if not provided
+ if source_object is None or source_subname is None:
+ source_object, source_subname = _find_shape_owner(doc, face)
+
+ # Get face center and normal
+ uv = face.Surface.parameter(face.CenterOfMass)
+ normal = face.normalAt(uv[0], uv[1])
+ base = face.CenterOfMass + normal * distance
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Offset")
+ name = f"ZPlane_Offset_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(base, rot)
+
+ # Create plane
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(source_object, source_subname)] if source_object else None
+ att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "offset_from_face",
+ {"distance": distance},
+ source_refs,
+ is_plane=True,
+ map_mode="FlatFace" if att_support else None,
+ support=att_support,
+ offset=att_offset,
+ )
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "offset_from_face",
+ {"distance": distance},
+ source_refs,
+ is_plane=True,
+ )
+
+ # Spreadsheet link
+ if link_spreadsheet and body:
+ alias = f"{name}_offset"
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias)
+
+ doc.recompute()
+ return plane
+
+
+def plane_offset_from_plane(
+ source_plane: App.DocumentObject,
+ distance: float,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+) -> App.DocumentObject:
+ """
+ Create datum plane offset from another datum plane.
+
+ Args:
+ source_plane: Source datum plane object (PartDesign::Plane or Part::Plane)
+ distance: Offset distance in mm (positive = along normal)
+ name: Optional custom name
+ body: Optional body to add plane to (None = document level)
+ link_spreadsheet: Create spreadsheet alias for distance
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ # Get the plane's shape and extract normal/position
+ if not hasattr(source_plane, "Shape"):
+ raise ValueError("Source must be a plane object with a Shape")
+
+ shape = source_plane.Shape
+ if not shape.Faces:
+ raise ValueError("Source plane has no faces")
+
+ face = shape.Faces[0]
+ if not face.Surface.isPlanar():
+ raise ValueError("Source must be a planar object")
+
+ # Get normal from the plane's face
+ uv = face.Surface.parameter(face.CenterOfMass)
+ normal = face.normalAt(uv[0], uv[1])
+
+ # Get the plane's center position
+ center = face.CenterOfMass
+
+ # Calculate offset position
+ base = center + normal * distance
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Offset")
+ name = f"ZPlane_Offset_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(base, rot)
+
+ # Create plane
+ source_refs = [(source_plane, "")]
+
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "offset_from_plane",
+ {"distance": distance, "source_plane": source_plane.Name},
+ source_refs,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_plane, "")],
+ offset=att_offset,
+ )
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "offset_from_plane",
+ {"distance": distance, "source_plane": source_plane.Name},
+ source_refs,
+ is_plane=True,
+ )
+
+ # Spreadsheet link
+ if link_spreadsheet and body:
+ alias = f"{name}_offset"
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias)
+
+ doc.recompute()
+ return plane
+
+
+def plane_midplane(
+ face1: Part.Face,
+ face2: Part.Face,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object1: Optional[App.DocumentObject] = None,
+ source_subname1: Optional[str] = None,
+ source_object2: Optional[App.DocumentObject] = None,
+ source_subname2: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane midway between two parallel faces.
+
+ Args:
+ face1: First face
+ face2: Second face (must be parallel to face1)
+ name: Optional custom name
+ body: Optional body to add plane to
+ source_object1, source_subname1: Reference for face1
+ source_object2, source_subname2: Reference for face2
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ if not (face1.Surface.isPlanar() and face2.Surface.isPlanar()):
+ raise ValueError("Both faces must be planar")
+
+ # Get normals
+ uv1 = face1.Surface.parameter(face1.CenterOfMass)
+ uv2 = face2.Surface.parameter(face2.CenterOfMass)
+ n1 = face1.normalAt(uv1[0], uv1[1])
+ n2 = face2.normalAt(uv2[0], uv2[1])
+
+ # Check parallel (dot product ~1 or ~-1)
+ dot = abs(n1.dot(n2))
+ if dot < 0.9999:
+ raise ValueError("Faces must be parallel for midplane")
+
+ # Compute half-distance between faces along face1 normal
+ c1 = face1.CenterOfMass
+ c2 = face2.CenterOfMass
+ half_dist = (c2 - c1).dot(n1) / 2.0
+ mid = (c1 + c2) * 0.5
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Mid")
+ name = f"ZPlane_Mid_{idx:03d}"
+
+ # Calculate placement (used as fallback for non-Body datums)
+ rot = App.Rotation(App.Vector(0, 0, 1), n1)
+ placement = App.Placement(mid, rot)
+
+ # Create plane
+ source_refs = []
+ if source_object1:
+ source_refs.append((source_object1, source_subname1))
+ if source_object2:
+ source_refs.append((source_object2, source_subname2))
+
+ if body and source_object1 and source_subname1:
+ plane = body.newObject("PartDesign::Plane", name)
+ att_offset = App.Placement(App.Vector(0, 0, half_dist), App.Rotation())
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "midplane",
+ {"half_distance": half_dist},
+ source_refs if source_refs else None,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_object1, source_subname1)],
+ offset=att_offset,
+ )
+ else:
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "midplane",
+ {"half_distance": half_dist},
+ source_refs if source_refs else None,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_from_3_points(
+ p1: App.Vector,
+ p2: App.Vector,
+ p3: App.Vector,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_refs: Optional[List[Tuple]] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane from 3 points.
+
+ Args:
+ p1, p2, p3: Three non-collinear points
+ name: Optional custom name
+ body: Optional body to add plane to
+ source_refs: List of (object, subname) tuples for the 3 vertices
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ # Calculate normal
+ v1 = p2 - p1
+ v2 = p3 - p1
+ normal = v1.cross(v2)
+
+ if normal.Length < 1e-6:
+ raise ValueError("Points are collinear, cannot define plane")
+
+ normal.normalize()
+ center = (p1 + p2 + p3) / 3
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_3Pt")
+ name = f"ZPlane_3Pt_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(center, rot)
+
+ # Create plane
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(ref[0], ref[1]) for ref in source_refs] if source_refs else None
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "3_points",
+ {"p1": p1, "p2": p2, "p3": p3},
+ source_refs,
+ is_plane=True,
+ map_mode="ThreePointsPlane"
+ if att_support and len(att_support) == 3
+ else None,
+ support=att_support,
+ )
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(center - rot.multVec(App.Vector(25, 25, 0)), rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "3_points",
+ {"p1": p1, "p2": p2, "p3": p3},
+ source_refs,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_normal_to_edge(
+ edge: Part.Edge,
+ parameter: float = 0.5,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane normal to edge at parameter location.
+
+ Args:
+ edge: Source edge/curve
+ parameter: Location along edge (0.0 to 1.0)
+ name: Optional custom name
+ body: Optional body to add plane to
+ source_object, source_subname: Reference for the edge
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ # Get point and tangent at parameter
+ param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter)
+ point = edge.valueAt(param)
+ tangent = edge.tangentAt(param)
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Normal")
+ name = f"ZPlane_Normal_{idx:03d}"
+
+ # Plane normal = edge tangent
+ rot = App.Rotation(App.Vector(0, 0, 1), tangent)
+ placement = App.Placement(point, rot)
+
+ # Create plane
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "normal_to_edge",
+ {"parameter": parameter},
+ source_refs,
+ is_plane=True,
+ map_mode="NormalToEdge" if att_support else None,
+ support=att_support,
+ path_param=parameter,
+ )
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "normal_to_edge",
+ {"parameter": parameter},
+ source_refs,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_angled(
+ face: Part.Face,
+ edge: Part.Edge,
+ angle: float,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+ source_face_obj: Optional[App.DocumentObject] = None,
+ source_face_sub: Optional[str] = None,
+ source_edge_obj: Optional[App.DocumentObject] = None,
+ source_edge_sub: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane at angle to face, rotating about edge.
+
+ Args:
+ face: Reference face
+ edge: Rotation axis (should lie on face)
+ angle: Rotation angle in degrees
+ name: Optional custom name
+ body: Optional body to add plane to
+ link_spreadsheet: Create spreadsheet alias for angle
+ source_face_obj, source_face_sub: Reference for the face
+ source_edge_obj, source_edge_sub: Reference for the edge
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ if not face.Surface.isPlanar():
+ raise ValueError("Face must be planar")
+
+ # Get face normal and edge direction
+ uv = face.Surface.parameter(face.CenterOfMass)
+ face_normal = face.normalAt(uv[0], uv[1])
+
+ edge_dir = (
+ edge.Curve.Direction
+ if hasattr(edge.Curve, "Direction")
+ else (
+ edge.valueAt(edge.LastParameter) - edge.valueAt(edge.FirstParameter)
+ ).normalize()
+ )
+
+ edge_mid = edge.valueAt((edge.FirstParameter + edge.LastParameter) / 2)
+
+ # Rotate face normal about edge
+ rot_axis = App.Rotation(edge_dir, angle)
+ new_normal = rot_axis.multVec(face_normal)
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Angled")
+ name = f"ZPlane_Angled_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), new_normal)
+ placement = App.Placement(edge_mid, rot)
+
+ # Create plane
+ source_refs = []
+ if source_face_obj:
+ source_refs.append((source_face_obj, source_face_sub))
+ if source_edge_obj:
+ source_refs.append((source_edge_obj, source_edge_sub))
+
+ if body and source_face_obj and source_face_sub:
+ plane = body.newObject("PartDesign::Plane", name)
+ # Encode the angle as a rotation about the edge direction in the
+ # face-local coordinate system. The FlatFace attachment aligns Z with
+ # face normal, so we rotate about the edge direction projected into
+ # the face-local frame.
+ 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)
+ att_offset = App.Placement(App.Vector(0, 0, 0), angle_rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "angled",
+ {"angle": angle},
+ source_refs if source_refs else None,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_face_obj, source_face_sub)],
+ offset=att_offset,
+ )
+ else:
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(
+ edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot
+ )
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "angled",
+ {"angle": angle},
+ source_refs if source_refs else None,
+ is_plane=True,
+ )
+
+ if link_spreadsheet and body:
+ alias = f"{name}_angle"
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Angle", angle, alias)
+
+ doc.recompute()
+ return plane
+
+
+def _find_cylinder_vertex(obj, face_subname):
+ """Find a vertex subname from a cylindrical face's edges."""
+ face = obj.getSubObject(face_subname)
+ if not face or not face.Edges:
+ return None
+ edge = face.Edges[0]
+ if not edge.Vertexes:
+ return None
+ vertex_point = edge.Vertexes[0].Point
+ for i, v in enumerate(obj.Shape.Vertexes, 1):
+ if v.Point.isEqual(vertex_point, 1e-6):
+ return f"Vertex{i}"
+ return None
+
+
+def _vertex_angle_on_cylinder(obj, vertex_sub, cylinder):
+ """Compute the angular position of a vertex on a cylinder surface."""
+ vertex = obj.getSubObject(vertex_sub)
+ if not vertex:
+ return 0.0
+ point = vertex.Point
+ relative = point - cylinder.Center
+ axis = cylinder.Axis
+ radial = relative - axis * relative.dot(axis)
+ if radial.Length < 1e-10:
+ return 0.0
+ radial.normalize()
+ 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)
+ return math.degrees(math.atan2(radial.dot(local_y), radial.dot(local_x)))
+
+
+def plane_tangent_to_cylinder(
+ face: Part.Face,
+ angle: float = 0,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum plane tangent to cylindrical face at angle.
+
+ Args:
+ face: Cylindrical face
+ angle: Angular position in degrees (0 = +X direction)
+ name: Optional custom name
+ body: Optional body to add plane to
+ link_spreadsheet: Create spreadsheet alias for angle
+ source_object, source_subname: Reference for the cylindrical face
+
+ Returns:
+ Created datum plane object
+ """
+ doc = App.ActiveDocument
+
+ if not isinstance(face.Surface, Part.Cylinder):
+ raise ValueError("Face must be cylindrical")
+
+ cyl = face.Surface
+ axis = cyl.Axis
+ center = cyl.Center
+ radius = cyl.Radius
+
+ # Calculate tangent point at angle
+ rad = math.radians(angle)
+ # Local X direction perpendicular to axis
+ 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)
+
+ # Point on cylinder surface
+ radial = local_x * math.cos(rad) + local_y * math.sin(rad)
+ tangent_point = center + radial * radius
+
+ # Plane normal is radial direction (tangent plane)
+ plane_normal = radial
+
+ # Auto-name
+ if name is None:
+ idx = _get_next_index(doc, "ZPlane_Tangent")
+ name = f"ZPlane_Tangent_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), plane_normal)
+ placement = App.Placement(tangent_point, rot)
+
+ # Create plane
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ if body:
+ plane = body.newObject("PartDesign::Plane", name)
+ # TangentPlane MapMode needs (face, vertex). Derive a vertex from
+ # the cylinder face's edges and encode the angular offset.
+ vertex_sub = (
+ _find_cylinder_vertex(source_object, source_subname)
+ if source_object and source_subname
+ else None
+ )
+ if vertex_sub:
+ vertex_angle = _vertex_angle_on_cylinder(source_object, vertex_sub, cyl)
+ offset_angle = angle - vertex_angle
+ offset_rot = App.Rotation(App.Vector(0, 0, 1), offset_angle)
+ att_offset = App.Placement(App.Vector(0, 0, 0), offset_rot)
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "tangent_cylinder",
+ {"angle": angle, "radius": radius, "vertex_angle": vertex_angle},
+ source_refs,
+ is_plane=True,
+ map_mode="TangentPlane",
+ support=[
+ (source_object, source_subname),
+ (source_object, vertex_sub),
+ ],
+ offset=att_offset,
+ )
+ else:
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "tangent_cylinder",
+ {"angle": angle, "radius": radius},
+ source_refs,
+ is_plane=True,
+ )
+ else:
+ plane = doc.addObject("Part::Plane", name)
+ plane.Length = 50
+ plane.Width = 50
+ placement = App.Placement(
+ tangent_point - rot.multVec(App.Vector(25, 25, 0)), rot
+ )
+ _setup_ztools_datum(
+ plane,
+ placement,
+ "tangent_cylinder",
+ {"angle": angle, "radius": radius},
+ source_refs,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+# =============================================================================
+# DATUM AXES
+# =============================================================================
+
+
+def axis_from_2_points(
+ p1: App.Vector,
+ p2: App.Vector,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_refs: Optional[List[Tuple]] = None,
+) -> App.DocumentObject:
+ """
+ Create datum axis from two points.
+
+ Args:
+ p1, p2: Two distinct points
+ name: Optional custom name
+ body: Optional body to add axis to
+ source_refs: List of (object, subname) tuples for the vertices
+
+ Returns:
+ Created datum axis object
+ """
+ doc = App.ActiveDocument
+
+ direction = p2 - p1
+ if direction.Length < 1e-6:
+ raise ValueError("Points must be distinct")
+
+ length = direction.Length
+ direction.normalize()
+ midpoint = (p1 + p2) * 0.5
+
+ if name is None:
+ idx = _get_next_index(doc, "ZAxis_2Pt")
+ name = f"ZAxis_2Pt_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+
+ # Create axis
+ if body:
+ axis = body.newObject("PartDesign::Line", name)
+ else:
+ axis = doc.addObject("Part::Line", name)
+ axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z
+ axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z
+
+ # Set up with ZTools attachment system
+ _setup_ztools_datum(
+ axis,
+ placement,
+ "2_points",
+ {"p1": p1, "p2": p2, "direction": direction, "length": length},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_from_edge(
+ edge: Part.Edge,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum axis from linear edge.
+
+ Args:
+ edge: Linear edge
+ name: Optional custom name
+ body: Optional body to add axis to
+ source_object, source_subname: Reference for the edge
+
+ Returns:
+ Created datum axis object
+ """
+ doc = App.ActiveDocument
+
+ if not isinstance(edge.Curve, Part.Line):
+ raise ValueError("Edge must be linear")
+
+ if name is None:
+ idx = _get_next_index(doc, "ZAxis_Edge")
+ name = f"ZAxis_Edge_{idx:03d}"
+
+ p1 = edge.valueAt(edge.FirstParameter)
+ p2 = edge.valueAt(edge.LastParameter)
+ direction = (p2 - p1).normalize()
+ midpoint = (p1 + p2) * 0.5
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+
+ # Create axis
+ if body:
+ axis = body.newObject("PartDesign::Line", name)
+ else:
+ axis = doc.addObject("Part::Line", name)
+ axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z
+ axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ axis,
+ placement,
+ "from_edge",
+ {"p1": p1, "p2": p2, "direction": direction},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_cylinder_center(
+ face: Part.Face,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum axis at center of cylindrical face.
+
+ Args:
+ face: Cylindrical face
+ name: Optional custom name
+ body: Optional body to add axis to
+ source_object: Object that owns the face (for reference tracking)
+ source_subname: Sub-element name like 'Face1' (for reference tracking)
+
+ Returns:
+ Created datum axis object
+ """
+ doc = App.ActiveDocument
+
+ if not isinstance(face.Surface, Part.Cylinder):
+ raise ValueError("Face must be cylindrical")
+
+ cyl = face.Surface
+ center = cyl.Center
+ axis_dir = cyl.Axis
+
+ # Get cylinder extent from face bounds
+ p1 = center + axis_dir * (-50) # Arbitrary length
+ p2 = center + axis_dir * 50
+
+ if name is None:
+ idx = _get_next_index(doc, "ZAxis_Cyl")
+ name = f"ZAxis_Cyl_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), axis_dir)
+ placement = App.Placement(center, rot)
+
+ # Create axis
+ if body:
+ axis = body.newObject("PartDesign::Line", name)
+ else:
+ axis = doc.addObject("Part::Line", name)
+ axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z
+ axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ axis,
+ placement,
+ "cylinder_center",
+ {"center": center, "direction": axis_dir, "radius": cyl.Radius},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_intersection_planes(
+ plane1,
+ plane2,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object1: Optional[App.DocumentObject] = None,
+ source_subname1: Optional[str] = None,
+ source_object2: Optional[App.DocumentObject] = None,
+ source_subname2: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum axis at intersection of two planes.
+
+ Args:
+ plane1, plane2: Two non-parallel planes
+ name: Optional custom name
+ body: Optional body to add axis to
+ source_object1: Object that owns plane1 (for reference tracking)
+ source_subname1: Sub-element name for plane1 (for reference tracking)
+ source_object2: Object that owns plane2 (for reference tracking)
+ source_subname2: Sub-element name for plane2 (for reference tracking)
+
+ Returns:
+ Created datum axis object
+ """
+ doc = App.ActiveDocument
+
+ # Get plane shapes
+ shape1 = plane1.Shape if hasattr(plane1, "Shape") else plane1
+ shape2 = plane2.Shape if hasattr(plane2, "Shape") else plane2
+
+ # Find intersection
+ common = shape1.common(shape2)
+ if not common.Edges:
+ raise ValueError("Planes do not intersect or are parallel")
+
+ edge = common.Edges[0]
+ p1 = edge.valueAt(edge.FirstParameter)
+ p2 = edge.valueAt(edge.LastParameter)
+ direction = (p2 - p1).normalize()
+ midpoint = (p1 + p2) * 0.5
+
+ if name is None:
+ idx = _get_next_index(doc, "ZAxis_Intersect")
+ name = f"ZAxis_Intersect_{idx:03d}"
+
+ # Calculate placement
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+
+ # Create axis
+ if body:
+ axis = body.newObject("PartDesign::Line", name)
+ else:
+ axis = doc.addObject("Part::Line", name)
+ axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z
+ axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z
+
+ # Set up with ZTools attachment system
+ source_refs = []
+ if source_object1:
+ source_refs.append((source_object1, source_subname1))
+ if source_object2:
+ source_refs.append((source_object2, source_subname2))
+
+ _setup_ztools_datum(
+ axis,
+ placement,
+ "plane_intersection",
+ {"point1": p1, "point2": p2, "direction": direction},
+ source_refs if source_refs else None,
+ )
+
+ doc.recompute()
+ return axis
+
+
+# =============================================================================
+# DATUM POINTS
+# =============================================================================
+
+
+def point_at_vertex(
+ vertex: Part.Vertex,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum point at vertex location.
+
+ Args:
+ vertex: Source vertex
+ name: Optional custom name
+ body: Optional body to add point to
+ source_object: Object that owns the vertex (for reference tracking)
+ source_subname: Sub-element name like 'Vertex1' (for reference tracking)
+
+ Returns:
+ Created datum point object
+ """
+ doc = App.ActiveDocument
+ pos = vertex.Point
+
+ if name is None:
+ idx = _get_next_index(doc, "ZPoint_Vtx")
+ name = f"ZPoint_Vtx_{idx:03d}"
+
+ # Calculate placement
+ placement = App.Placement(pos, App.Rotation())
+
+ # Create point
+ if body:
+ point = body.newObject("PartDesign::Point", name)
+ else:
+ point = doc.addObject("Part::Vertex", name)
+ point.X, point.Y, point.Z = pos.x, pos.y, pos.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ point,
+ placement,
+ "vertex",
+ {"position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_at_coordinates(
+ x: float,
+ y: float,
+ z: float,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+) -> App.DocumentObject:
+ """
+ Create datum point at XYZ coordinates.
+
+ Args:
+ x, y, z: Coordinates in mm
+ name: Optional custom name
+ body: Optional body to add point to
+ link_spreadsheet: Create spreadsheet aliases for coordinates
+
+ Returns:
+ Created datum point object
+ """
+ doc = App.ActiveDocument
+
+ if name is None:
+ idx = _get_next_index(doc, "ZPoint_XYZ")
+ name = f"ZPoint_XYZ_{idx:03d}"
+
+ pos = App.Vector(x, y, z)
+ placement = App.Placement(pos, App.Rotation())
+
+ # Create point
+ if body:
+ point = body.newObject("PartDesign::Point", name)
+ else:
+ point = doc.addObject("Part::Vertex", name)
+ point.X, point.Y, point.Z = x, y, z
+
+ # Set up with ZTools attachment system (no source refs for explicit coordinates)
+ _setup_ztools_datum(
+ point,
+ placement,
+ "coordinates",
+ {"x": x, "y": y, "z": z},
+ None,
+ )
+
+ if link_spreadsheet and not body:
+ _link_to_spreadsheet(doc, point, "X", x, f"{name}_X")
+ _link_to_spreadsheet(doc, point, "Y", y, f"{name}_Y")
+ _link_to_spreadsheet(doc, point, "Z", z, f"{name}_Z")
+
+ doc.recompute()
+ return point
+
+
+def point_on_edge(
+ edge: Part.Edge,
+ parameter: float = 0.5,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ link_spreadsheet: bool = False,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum point on edge at parameter.
+
+ Args:
+ edge: Source edge
+ parameter: Location along edge (0.0 to 1.0)
+ name: Optional custom name
+ body: Optional body to add point to
+ link_spreadsheet: Create spreadsheet alias for parameter
+ source_object: Object that owns the edge (for reference tracking)
+ source_subname: Sub-element name like 'Edge1' (for reference tracking)
+
+ Returns:
+ Created datum point object
+ """
+ doc = App.ActiveDocument
+
+ param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter)
+ pos = edge.valueAt(param)
+
+ if name is None:
+ idx = _get_next_index(doc, "ZPoint_Edge")
+ name = f"ZPoint_Edge_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+
+ # Create point
+ if body:
+ point = body.newObject("PartDesign::Point", name)
+ else:
+ point = doc.addObject("Part::Vertex", name)
+ point.X, point.Y, point.Z = pos.x, pos.y, pos.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ point,
+ placement,
+ "on_edge",
+ {"parameter": parameter, "position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_center_of_face(
+ face: Part.Face,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum point at center of mass of face.
+
+ Args:
+ face: Source face
+ name: Optional custom name
+ body: Optional body to add point to
+ source_object: Object that owns the face (for reference tracking)
+ source_subname: Sub-element name like 'Face1' (for reference tracking)
+
+ Returns:
+ Created datum point object
+ """
+ doc = App.ActiveDocument
+ pos = face.CenterOfMass
+
+ if name is None:
+ idx = _get_next_index(doc, "ZPoint_FaceCenter")
+ name = f"ZPoint_FaceCenter_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+
+ # Create point
+ if body:
+ point = body.newObject("PartDesign::Point", name)
+ else:
+ point = doc.addObject("Part::Vertex", name)
+ point.X, point.Y, point.Z = pos.x, pos.y, pos.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ point,
+ placement,
+ "face_center",
+ {"position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_center_of_circle(
+ edge: Part.Edge,
+ name: Optional[str] = None,
+ body: Optional[App.DocumentObject] = None,
+ source_object: Optional[App.DocumentObject] = None,
+ source_subname: Optional[str] = None,
+) -> App.DocumentObject:
+ """
+ Create datum point at center of circular edge.
+
+ Args:
+ edge: Circular edge (circle or arc)
+ name: Optional custom name
+ body: Optional body to add point to
+ source_object: Object that owns the edge (for reference tracking)
+ source_subname: Sub-element name like 'Edge1' (for reference tracking)
+
+ Returns:
+ Created datum point object
+ """
+ doc = App.ActiveDocument
+
+ if not isinstance(edge.Curve, (Part.Circle, Part.ArcOfCircle)):
+ raise ValueError("Edge must be circular")
+
+ pos = edge.Curve.Center
+
+ if name is None:
+ idx = _get_next_index(doc, "ZPoint_CircleCenter")
+ name = f"ZPoint_CircleCenter_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+
+ # Create point
+ if body:
+ point = body.newObject("PartDesign::Point", name)
+ else:
+ point = doc.addObject("Part::Vertex", name)
+ point.X, point.Y, point.Z = pos.x, pos.y, pos.z
+
+ # Set up with ZTools attachment system
+ source_refs = [(source_object, source_subname)] if source_object else None
+ _setup_ztools_datum(
+ point,
+ placement,
+ "circle_center",
+ {"position": pos, "radius": edge.Curve.Radius},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
diff --git a/reference/ztools/ztools/ztools/resources/__init__.py b/reference/ztools/ztools/ztools/resources/__init__.py
new file mode 100644
index 0000000000..01d944b135
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/__init__.py
@@ -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",
+]
diff --git a/reference/ztools/ztools/ztools/resources/icons.py b/reference/ztools/ztools/ztools/resources/icons.py
new file mode 100644
index 0000000000..a565330ec0
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons.py
@@ -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''''''
+
+# Datum Creator icon - plane with plus
+ICON_DATUM_CREATOR_SVG = f''''''
+
+# Datum Manager icon - stacked planes with list
+ICON_DATUM_MANAGER_SVG = f''''''
+
+# Plane Offset icon
+ICON_PLANE_OFFSET_SVG = f''''''
+
+# Plane Midplane icon
+ICON_PLANE_MIDPLANE_SVG = f''''''
+
+# Plane 3 Points icon
+ICON_PLANE_3PT_SVG = f''''''
+
+# Plane Normal to Edge icon
+ICON_PLANE_NORMAL_SVG = f''''''
+
+# Plane Angled icon
+ICON_PLANE_ANGLED_SVG = f''''''
+
+# Plane Tangent icon
+ICON_PLANE_TANGENT_SVG = f''''''
+
+# Axis 2 Points icon
+ICON_AXIS_2PT_SVG = f''''''
+
+# Axis from Edge icon
+ICON_AXIS_EDGE_SVG = f''''''
+
+# Axis Cylinder Center icon
+ICON_AXIS_CYL_SVG = f''''''
+
+# Axis Intersection icon
+ICON_AXIS_INTERSECT_SVG = f''''''
+
+# Point at Vertex icon
+ICON_POINT_VERTEX_SVG = f''''''
+
+# Point XYZ icon
+ICON_POINT_XYZ_SVG = f''''''
+
+# Point on Edge icon
+ICON_POINT_EDGE_SVG = f''''''
+
+# Point Face Center icon
+ICON_POINT_FACE_SVG = f''''''
+
+# Point Circle Center icon
+ICON_POINT_CIRCLE_SVG = f''''''
+
+# Rotated Linear Pattern icon - objects along line with rotation
+ICON_ROTATED_PATTERN_SVG = f''''''
+
+# Enhanced Pocket icon - pocket with plus/settings indicator
+ICON_POCKET_ENHANCED_SVG = f''''''
+
+# Flipped Pocket icon - pocket cutting outside the profile
+ICON_POCKET_FLIPPED_SVG = f''''''
+
+# Assembly Linear Pattern icon - components along a line
+ICON_ASSEMBLY_LINEAR_PATTERN_SVG = f''''''
+
+# Assembly Polar Pattern icon - components around a center
+ICON_ASSEMBLY_POLAR_PATTERN_SVG = f''''''
+
+# =============================================================================
+# Spreadsheet Icons
+# =============================================================================
+
+# Bold text icon
+ICON_SPREADSHEET_BOLD_SVG = f''''''
+
+# Italic text icon
+ICON_SPREADSHEET_ITALIC_SVG = f''''''
+
+# Underline text icon
+ICON_SPREADSHEET_UNDERLINE_SVG = f''''''
+
+# Align left icon
+ICON_SPREADSHEET_ALIGN_LEFT_SVG = f''''''
+
+# Align center icon
+ICON_SPREADSHEET_ALIGN_CENTER_SVG = f''''''
+
+# Align right icon
+ICON_SPREADSHEET_ALIGN_RIGHT_SVG = f''''''
+
+# Background color icon (paint bucket)
+ICON_SPREADSHEET_BG_COLOR_SVG = f''''''
+
+# Text color icon (A with color bar)
+ICON_SPREADSHEET_TEXT_COLOR_SVG = f''''''
+
+# Quick alias icon (tag/label)
+ICON_SPREADSHEET_QUICK_ALIAS_SVG = f''''''
+
+
+# =============================================================================
+# 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}")
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg
new file mode 100644
index 0000000000..c4ff109e16
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg
new file mode 100644
index 0000000000..8bcd08930f
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg
@@ -0,0 +1,27 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_axis_2pt.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_2pt.svg
new file mode 100644
index 0000000000..439f092fc1
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_2pt.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_axis_cyl.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_cyl.svg
new file mode 100644
index 0000000000..0e723e7dab
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_cyl.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_axis_edge.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_edge.svg
new file mode 100644
index 0000000000..ab54592d17
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_edge.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_axis_intersect.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_intersect.svg
new file mode 100644
index 0000000000..4069f734ae
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_axis_intersect.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_datum_creator.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_datum_creator.svg
new file mode 100644
index 0000000000..de02931b3c
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_datum_creator.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_datum_manager.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_datum_manager.svg
new file mode 100644
index 0000000000..9c800907a1
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_datum_manager.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_3pt.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_3pt.svg
new file mode 100644
index 0000000000..4ed7efb1e9
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_3pt.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_angled.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_angled.svg
new file mode 100644
index 0000000000..6790fcdfa4
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_angled.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_midplane.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_midplane.svg
new file mode 100644
index 0000000000..bdd5ea96ae
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_midplane.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_normal.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_normal.svg
new file mode 100644
index 0000000000..e564b74c80
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_normal.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_offset.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_offset.svg
new file mode 100644
index 0000000000..7f1f35bcda
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_offset.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_plane_tangent.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_tangent.svg
new file mode 100644
index 0000000000..b683e2f3ae
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_plane_tangent.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg
new file mode 100644
index 0000000000..16eb0b8674
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_flipped.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_flipped.svg
new file mode 100644
index 0000000000..6e667a70eb
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_pocket_flipped.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_point_circle.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_point_circle.svg
new file mode 100644
index 0000000000..b05da8bc34
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_point_circle.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_point_edge.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_point_edge.svg
new file mode 100644
index 0000000000..7032701305
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_point_edge.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_point_face.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_point_face.svg
new file mode 100644
index 0000000000..887201af3d
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_point_face.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_point_vertex.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_point_vertex.svg
new file mode 100644
index 0000000000..4db1731423
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_point_vertex.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_point_xyz.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_point_xyz.svg
new file mode 100644
index 0000000000..37a355a363
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_point_xyz.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_rotated_pattern.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_rotated_pattern.svg
new file mode 100644
index 0000000000..9b75476462
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_rotated_pattern.svg
@@ -0,0 +1,18 @@
+
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_center.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_center.svg
new file mode 100644
index 0000000000..b1e276700d
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_center.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_left.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_left.svg
new file mode 100644
index 0000000000..c36df180ee
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_left.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_right.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_right.svg
new file mode 100644
index 0000000000..6b3cbbb53c
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_right.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bg_color.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bg_color.svg
new file mode 100644
index 0000000000..50082f70b0
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bg_color.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bold.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bold.svg
new file mode 100644
index 0000000000..58957e83f7
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bold.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_italic.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_italic.svg
new file mode 100644
index 0000000000..df64883583
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_italic.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_quick_alias.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_quick_alias.svg
new file mode 100644
index 0000000000..86018a8ac1
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_quick_alias.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_text_color.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_text_color.svg
new file mode 100644
index 0000000000..c8ccc229c9
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_text_color.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_underline.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_underline.svg
new file mode 100644
index 0000000000..e64192f064
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_underline.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_theme_apply.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_apply.svg
new file mode 100644
index 0000000000..5dfafd7912
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_apply.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_theme_export.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_export.svg
new file mode 100644
index 0000000000..a4456e28af
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_export.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_theme_remove.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_remove.svg
new file mode 100644
index 0000000000..aaba292dff
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_remove.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_theme_toggle.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_toggle.svg
new file mode 100644
index 0000000000..2845df58a4
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_theme_toggle.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/reference/ztools/ztools/ztools/resources/icons/ztools_workbench.svg b/reference/ztools/ztools/ztools/resources/icons/ztools_workbench.svg
new file mode 100644
index 0000000000..e879935396
--- /dev/null
+++ b/reference/ztools/ztools/ztools/resources/icons/ztools_workbench.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file