From c8b0706a1dca4f016f8a2dffc032e707119ebe60 Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 27 Feb 2026 12:54:40 -0600 Subject: [PATCH] chore: archive QuickNav and ZTools into reference folder (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy QuickNav and ZTools source trees into reference/ for developer reference during the UI/UX rework. These are plain directories (not submodules) and are not included in the build. - reference/quicknav/ — QuickNav addon source - reference/ztools/ — ZTools addon source Part of the UI/UX rework preparation. See #346. --- reference/quicknav/.gitignore | 1 + reference/quicknav/Init.py | 3 + reference/quicknav/InitGui.py | 37 + reference/quicknav/package.xml | 25 + reference/quicknav/quicknav/__init__.py | 1 + reference/quicknav/quicknav/commands.py | 24 + reference/quicknav/quicknav/core.py | 208 +++ reference/quicknav/quicknav/event_filter.py | 84 + reference/quicknav/quicknav/nav_bar.py | 142 ++ reference/quicknav/quicknav/workbench_map.py | 285 +++ reference/ztools/KINDRED_INTEGRATION.md | 385 ++++ reference/ztools/PLAN.md | 420 +++++ reference/ztools/ROADMAP.md | 388 ++++ reference/ztools/TODO_ATTACHMENT_WORK.md | 58 + reference/ztools/package.xml | 36 + reference/ztools/partdesign.md | 591 ++++++ reference/ztools/ztools/Init.py | 12 + reference/ztools/ztools/InitGui.py | 367 ++++ reference/ztools/ztools/README.md | 123 ++ reference/ztools/ztools/setup.cfg | 11 + reference/ztools/ztools/ztools/__init__.py | 2 + .../ztools/ztools/ztools/commands/__init__.py | 18 + .../commands/assembly_pattern_commands.py | 787 ++++++++ .../ztools/ztools/commands/datum_commands.py | 880 +++++++++ .../ztools/commands/datum_viewprovider.py | 487 +++++ .../ztools/commands/pattern_commands.py | 206 +++ .../ztools/ztools/commands/pocket_commands.py | 601 +++++++ .../ztools/commands/spreadsheet_commands.py | 567 ++++++ .../ztools/ztools/ztools/datums/__init__.py | 41 + reference/ztools/ztools/ztools/datums/core.py | 1588 +++++++++++++++++ .../ztools/ztools/resources/__init__.py | 8 + .../ztools/ztools/ztools/resources/icons.py | 513 ++++++ .../icons/ztools_assembly_linear_pattern.svg | 17 + .../icons/ztools_assembly_polar_pattern.svg | 27 + .../resources/icons/ztools_axis_2pt.svg | 8 + .../resources/icons/ztools_axis_cyl.svg | 11 + .../resources/icons/ztools_axis_edge.svg | 9 + .../resources/icons/ztools_axis_intersect.svg | 9 + .../resources/icons/ztools_datum_creator.svg | 8 + .../resources/icons/ztools_datum_manager.svg | 10 + .../resources/icons/ztools_plane_3pt.svg | 9 + .../resources/icons/ztools_plane_angled.svg | 9 + .../resources/icons/ztools_plane_midplane.svg | 9 + .../resources/icons/ztools_plane_normal.svg | 9 + .../resources/icons/ztools_plane_offset.svg | 10 + .../resources/icons/ztools_plane_tangent.svg | 9 + .../icons/ztools_pocket_enhanced.svg | 12 + .../resources/icons/ztools_pocket_flipped.svg | 14 + .../resources/icons/ztools_point_circle.svg | 10 + .../resources/icons/ztools_point_edge.svg | 9 + .../resources/icons/ztools_point_face.svg | 8 + .../resources/icons/ztools_point_vertex.svg | 10 + .../resources/icons/ztools_point_xyz.svg | 12 + .../icons/ztools_rotated_pattern.svg | 18 + .../icons/ztools_spreadsheet_align_center.svg | 7 + .../icons/ztools_spreadsheet_align_left.svg | 7 + .../icons/ztools_spreadsheet_align_right.svg | 7 + .../icons/ztools_spreadsheet_bg_color.svg | 9 + .../icons/ztools_spreadsheet_bold.svg | 4 + .../icons/ztools_spreadsheet_italic.svg | 4 + .../icons/ztools_spreadsheet_quick_alias.svg | 10 + .../icons/ztools_spreadsheet_text_color.svg | 7 + .../icons/ztools_spreadsheet_underline.svg | 5 + .../resources/icons/ztools_theme_apply.svg | 15 + .../resources/icons/ztools_theme_export.svg | 15 + .../resources/icons/ztools_theme_remove.svg | 15 + .../resources/icons/ztools_theme_toggle.svg | 17 + .../resources/icons/ztools_workbench.svg | 5 + 68 files changed, 9273 insertions(+) create mode 100644 reference/quicknav/.gitignore create mode 100644 reference/quicknav/Init.py create mode 100644 reference/quicknav/InitGui.py create mode 100644 reference/quicknav/package.xml create mode 100644 reference/quicknav/quicknav/__init__.py create mode 100644 reference/quicknav/quicknav/commands.py create mode 100644 reference/quicknav/quicknav/core.py create mode 100644 reference/quicknav/quicknav/event_filter.py create mode 100644 reference/quicknav/quicknav/nav_bar.py create mode 100644 reference/quicknav/quicknav/workbench_map.py create mode 100644 reference/ztools/KINDRED_INTEGRATION.md create mode 100644 reference/ztools/PLAN.md create mode 100644 reference/ztools/ROADMAP.md create mode 100644 reference/ztools/TODO_ATTACHMENT_WORK.md create mode 100644 reference/ztools/package.xml create mode 100644 reference/ztools/partdesign.md create mode 100644 reference/ztools/ztools/Init.py create mode 100644 reference/ztools/ztools/InitGui.py create mode 100644 reference/ztools/ztools/README.md create mode 100644 reference/ztools/ztools/setup.cfg create mode 100644 reference/ztools/ztools/ztools/__init__.py create mode 100644 reference/ztools/ztools/ztools/commands/__init__.py create mode 100644 reference/ztools/ztools/ztools/commands/assembly_pattern_commands.py create mode 100644 reference/ztools/ztools/ztools/commands/datum_commands.py create mode 100644 reference/ztools/ztools/ztools/commands/datum_viewprovider.py create mode 100644 reference/ztools/ztools/ztools/commands/pattern_commands.py create mode 100644 reference/ztools/ztools/ztools/commands/pocket_commands.py create mode 100644 reference/ztools/ztools/ztools/commands/spreadsheet_commands.py create mode 100644 reference/ztools/ztools/ztools/datums/__init__.py create mode 100644 reference/ztools/ztools/ztools/datums/core.py create mode 100644 reference/ztools/ztools/ztools/resources/__init__.py create mode 100644 reference/ztools/ztools/ztools/resources/icons.py create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_axis_2pt.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_axis_cyl.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_axis_edge.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_axis_intersect.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_datum_creator.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_datum_manager.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_3pt.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_angled.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_midplane.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_normal.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_offset.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_plane_tangent.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_pocket_flipped.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_point_circle.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_point_edge.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_point_face.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_point_vertex.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_point_xyz.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_rotated_pattern.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_center.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_left.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_align_right.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bg_color.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_bold.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_italic.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_quick_alias.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_text_color.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_spreadsheet_underline.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_theme_apply.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_theme_export.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_theme_remove.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_theme_toggle.svg create mode 100644 reference/ztools/ztools/ztools/resources/icons/ztools_workbench.svg 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''' + + + + + + + t +''' + +# 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''' + + + + + + + + + + + + + + + 1-2-3 +''' + +# Assembly Polar Pattern icon - components around a center +ICON_ASSEMBLY_POLAR_PATTERN_SVG = f''' + + + + + + + + + + + + + + + + + + + + + + + + + +''' + +# ============================================================================= +# Spreadsheet Icons +# ============================================================================= + +# Bold text icon +ICON_SPREADSHEET_BOLD_SVG = f''' + + B +''' + +# Italic text icon +ICON_SPREADSHEET_ITALIC_SVG = f''' + + I +''' + +# Underline text icon +ICON_SPREADSHEET_UNDERLINE_SVG = f''' + + U + +''' + +# 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''' + + + A + + +''' + +# 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 @@ + + + + + + + + + + + + + + + + 1-2-3 + \ 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 @@ + + + + + + + + t + \ 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 @@ + + + B + \ 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 @@ + + + I + \ 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 @@ + + + + A + + + \ 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 @@ + + + U + + \ 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