chore: archive QuickNav and ZTools into reference folder (#345)
All checks were successful
Build and Test / build (pull_request) Successful in 32m17s

Copy QuickNav and ZTools source trees into reference/ for developer
reference during the UI/UX rework. These are plain directories (not
submodules) and are not included in the build.

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

Part of the UI/UX rework preparation. See #346.
This commit is contained in:
forbes
2026-02-27 12:54:40 -06:00
parent a623f280da
commit c8b0706a1d
68 changed files with 9273 additions and 0 deletions

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

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

View File

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

View File

@@ -0,0 +1,37 @@
import FreeCAD as App
import FreeCADGui as Gui
class QuickNavWorkbench(Gui.Workbench):
"""Invisible workbench that installs QuickNav on load.
QuickNav does not replace the active workbench -- it layers on top.
Loading QuickNav installs the event filter and nav bar, then
immediately re-activates the previously active workbench.
"""
MenuText = "QuickNav"
ToolTip = "Keyboard-driven command navigation"
def Initialize(self):
from quicknav.core import QuickNavManager
QuickNavManager.instance().install()
App.Console.PrintMessage("QuickNav workbench initialized\n")
def Activated(self):
from quicknav.core import QuickNavManager
QuickNavManager.instance().handle_workbench_activated()
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(QuickNavWorkbench())
# Eager command registration
from quicknav.commands import QuickNav_Toggle # noqa: F401

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>QuickNav</name>
<description>Keyboard-driven command navigation</description>
<version>0.1.0</version>
<maintainer email="dev@kindred-systems.com">Kindred Systems</maintainer>
<license>LGPL-2.1</license>
<url type="repository">https://git.kindred-systems.com/kindred/quicknav</url>
<content>
<workbench>
<classname>QuickNavWorkbench</classname>
<subdirectory>./</subdirectory>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>20</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>

View File

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

View File

@@ -0,0 +1,24 @@
"""FreeCAD command registrations for QuickNav."""
import FreeCADGui as Gui
class QuickNav_Toggle:
"""Toggle QuickNav keyboard navigation on/off."""
def GetResources(self):
return {
"MenuText": "Toggle QuickNav",
"ToolTip": "Toggle keyboard-driven command navigation on/off",
}
def IsActive(self):
return True
def Activated(self):
from quicknav.core import QuickNavManager
QuickNavManager.instance().toggle_active()
Gui.addCommand("QuickNav_Toggle", QuickNav_Toggle())

View File

@@ -0,0 +1,208 @@
"""QuickNav manager singleton.
Orchestrates the event filter, navigation bar, and workbench/grouping
state. Created once when the QuickNavWorkbench is first activated.
"""
import FreeCAD as App
import FreeCADGui as Gui
from quicknav.workbench_map import (
get_command,
get_grouping,
get_groupings,
get_workbench_slot,
)
_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/QuickNav"
class QuickNavManager:
"""Singleton managing QuickNav lifecycle and state."""
_instance = None
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self._active = False
self._installed = False
self._event_filter = None
self._nav_bar = None
self._current_workbench_slot = 2 # PartDesign default
self._current_grouping_idx = 0
self.previous_workbench = None
self._reactivating = False
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def install(self):
"""Install the event filter and navigation bar."""
if self._installed:
return
mw = Gui.getMainWindow()
if mw is None:
App.Console.PrintWarning("QuickNav: main window not available\n")
return
# Capture which workbench was active before QuickNav loaded.
try:
wb = Gui.activeWorkbench()
if wb:
self.previous_workbench = wb.__class__.__name__
except Exception:
pass
# Event filter
from quicknav.event_filter import QuickNavEventFilter
self._event_filter = QuickNavEventFilter(manager=self, parent=mw)
mw.installEventFilter(self._event_filter)
# Navigation bar
from quicknav.nav_bar import QuickNavBar
self._nav_bar = QuickNavBar(manager=self, parent=mw)
mw.addToolBar(self._nav_bar)
mw.insertToolBarBreak(self._nav_bar)
# Read saved preference
self._active = self._load_preference()
if not self._active:
self._nav_bar.hide()
self._installed = True
self._update_nav_bar()
App.Console.PrintLog("QuickNav: installed\n")
def uninstall(self):
"""Remove the event filter and navigation bar."""
if not self._installed:
return
mw = Gui.getMainWindow()
if mw and self._event_filter:
mw.removeEventFilter(self._event_filter)
self._event_filter = None
if self._nav_bar:
self._nav_bar.hide()
self._nav_bar.setParent(None)
self._nav_bar.deleteLater()
self._nav_bar = None
self._installed = False
App.Console.PrintLog("QuickNav: uninstalled\n")
# ------------------------------------------------------------------
# State queries
# ------------------------------------------------------------------
def is_active(self):
return self._active
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def toggle_active(self):
"""Toggle QuickNav on/off."""
self._active = not self._active
self._save_preference()
if self._nav_bar:
if self._active:
self._nav_bar.show()
self._update_nav_bar()
else:
self._nav_bar.hide()
state = "on" if self._active else "off"
App.Console.PrintMessage(f"QuickNav: {state}\n")
def switch_workbench(self, n):
"""Switch to the workbench assigned to Ctrl+N."""
slot = get_workbench_slot(n)
if slot is None:
return
self._current_workbench_slot = n
self._current_grouping_idx = 0
# Activate the target workbench in FreeCAD.
try:
Gui.activateWorkbench(slot["class_name"])
except Exception as e:
App.Console.PrintWarning(f"QuickNav: could not activate {slot['class_name']}: {e}\n")
self._update_nav_bar()
def switch_grouping(self, n):
"""Switch to the Nth grouping (1-based) in the current workbench."""
slot = get_workbench_slot(self._current_workbench_slot)
if slot is None:
return
groupings = get_groupings(slot["key"])
idx = n - 1
if 0 <= idx < len(groupings):
self._current_grouping_idx = idx
self._update_nav_bar()
def execute_command(self, n):
"""Execute the Nth command (1-based) in the active grouping."""
slot = get_workbench_slot(self._current_workbench_slot)
if slot is None:
return
cmd_id = get_command(slot["key"], self._current_grouping_idx, n)
if cmd_id:
try:
Gui.runCommand(cmd_id)
except Exception as e:
App.Console.PrintWarning(f"QuickNav: command {cmd_id} failed: {e}\n")
# ------------------------------------------------------------------
# Workbench re-activation guard
# ------------------------------------------------------------------
def handle_workbench_activated(self):
"""Called from QuickNavWorkbench.Activated() to restore the
previous workbench without infinite recursion."""
if self._reactivating:
return
if self.previous_workbench:
self._reactivating = True
try:
Gui.activateWorkbench(self.previous_workbench)
except Exception:
pass
finally:
self._reactivating = False
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _update_nav_bar(self):
if not self._nav_bar:
return
slot = get_workbench_slot(self._current_workbench_slot)
if slot is None:
return
groupings = get_groupings(slot["key"])
grouping = get_grouping(slot["key"], self._current_grouping_idx)
commands = grouping["commands"] if grouping else []
self._nav_bar.update_display(
slot["display"], groupings, self._current_grouping_idx, commands
)
def _load_preference(self):
param = App.ParamGet(_PREF_PATH)
return param.GetBool("Enabled", True)
def _save_preference(self):
param = App.ParamGet(_PREF_PATH)
param.SetBool("Enabled", self._active)

View File

@@ -0,0 +1,84 @@
"""Key event filter for QuickNav.
Installed on FreeCAD's main window to intercept number key presses
when QuickNav is active. Passes through all events when inactive
(except Key_0 for re-enable toggle).
"""
from PySide.QtCore import QEvent, QObject, Qt
from PySide.QtWidgets import (
QAbstractSpinBox,
QApplication,
QLineEdit,
QPlainTextEdit,
QTextEdit,
)
# Widget types that accept text input. QAbstractSpinBox covers
# QSpinBox, QDoubleSpinBox, and FreeCAD's custom QuantitySpinBox
# (used for Sketcher inline dimension entry).
_TEXT_INPUT_TYPES = (QLineEdit, QTextEdit, QPlainTextEdit, QAbstractSpinBox)
class QuickNavEventFilter(QObject):
"""Intercepts key presses for QuickNav dispatch."""
def __init__(self, manager, parent=None):
super().__init__(parent)
self._manager = manager
def eventFilter(self, obj, event):
if event.type() != QEvent.KeyPress:
return False
key = event.key()
modifiers = event.modifiers() & ~Qt.KeypadModifier
# Key_0 with no modifiers always toggles, even when inactive.
if key == Qt.Key_0 and modifiers == Qt.NoModifier:
if not self._focus_is_text_input():
self._manager.toggle_active()
return True
return False
if not self._manager.is_active():
return False
# Don't intercept when a text input has focus.
if self._focus_is_text_input():
return False
if Qt.Key_1 <= key <= Qt.Key_9:
n = key - Qt.Key_0
if modifiers == Qt.ControlModifier:
self._manager.switch_workbench(n)
return True
elif modifiers == Qt.ShiftModifier:
self._manager.switch_grouping(n)
return True
elif modifiers == Qt.NoModifier:
self._manager.execute_command(n)
return True
return False
def _focus_is_text_input(self):
"""Return True if the focused widget is a text/number input."""
focused = QApplication.focusWidget()
if focused is None:
return False
if isinstance(focused, _TEXT_INPUT_TYPES):
return True
return self._is_in_task_panel(focused)
@staticmethod
def _is_in_task_panel(widget):
"""Return True if *widget* is inside FreeCAD's task panel."""
parent = widget.parent() if widget else None
while parent is not None:
name = parent.objectName() or ""
cls = parent.__class__.__name__
if "TaskView" in name or "TaskView" in cls:
return True
parent = parent.parent() if hasattr(parent, "parent") else None
return False

View File

@@ -0,0 +1,142 @@
"""Navigation bar widget for QuickNav.
A QToolBar that displays the current workbench, command groupings,
and numbered commands. Positioned at the bottom of the toolbar area.
"""
from PySide.QtCore import Signal
from PySide.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget
class _ClickableLabel(QLabel):
"""QLabel that emits *clicked* on mouse press."""
clicked = Signal()
def mousePressEvent(self, event):
self.clicked.emit()
super().mousePressEvent(event)
class QuickNavBar(QToolBar):
"""Bottom toolbar showing QuickNav state."""
_MAX_SLOTS = 9
def __init__(self, manager, parent=None):
super().__init__("QuickNav", parent)
self._manager = manager
self.setObjectName("QuickNavBar")
self.setMovable(False)
self.setFloatable(False)
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(0)
# Workbench label
self._wb_label = QLabel()
self._wb_label.setContentsMargins(4, 0, 8, 0)
layout.addWidget(self._wb_label)
# Vertical separator
sep1 = QLabel("|")
sep1.setContentsMargins(4, 0, 4, 0)
layout.addWidget(sep1)
# Grouping labels
self._grouping_labels = []
for i in range(self._MAX_SLOTS):
label = _ClickableLabel()
label.setContentsMargins(4, 0, 4, 0)
label.clicked.connect(lambda n=i + 1: self._manager.switch_grouping(n))
self._grouping_labels.append(label)
layout.addWidget(label)
# Vertical separator
self._cmd_sep = QLabel("|")
self._cmd_sep.setContentsMargins(4, 0, 4, 0)
layout.addWidget(self._cmd_sep)
# Command labels
self._command_labels = []
for i in range(self._MAX_SLOTS):
label = _ClickableLabel()
label.setContentsMargins(4, 0, 4, 0)
label.clicked.connect(lambda n=i + 1: self._manager.execute_command(n))
self._command_labels.append(label)
layout.addWidget(label)
layout.addStretch()
self.addWidget(container)
self._apply_theme()
def update_display(self, wb_name, groupings, active_grouping_idx, commands):
"""Refresh the bar with current state.
Parameters
----------
wb_name : str
Workbench display name.
groupings : list[dict]
Grouping dicts with ``"name"`` keys.
active_grouping_idx : int
0-based index of the active grouping.
commands : list[tuple[str, str]]
``(command_id, display_name)`` for the active grouping.
"""
self._wb_label.setText(f"WB: {wb_name}")
for i, label in enumerate(self._grouping_labels):
if i < len(groupings):
prefix = f"\u276{i + 1}" if i == active_grouping_idx else f"{i + 1}"
label.setText(f" {prefix}: {groupings[i]['name']} ")
if i == active_grouping_idx:
label.setStyleSheet(self._active_grouping_style)
else:
label.setStyleSheet(self._inactive_grouping_style)
label.show()
else:
label.hide()
for i, label in enumerate(self._command_labels):
if i < len(commands):
label.setText(f" {i + 1}: {commands[i][1]} ")
label.setStyleSheet(self._command_style)
label.show()
else:
label.hide()
def _apply_theme(self):
"""Apply Catppuccin Mocha theme if SDK is available, else Qt defaults."""
palette = None
try:
from kindred_sdk import load_palette
palette = load_palette()
except ImportError:
pass
if palette:
bg = palette.get("background.toolbar") or "#181825"
fg = palette.get("foreground") or "#cdd6f4"
accent = palette.get("accent.info") or "#89b4fa"
border = palette.get("border") or "#45475a"
muted = palette.get("foreground.muted") or "#a6adc8"
else:
# No theme — leave styles empty so Qt defaults apply.
self._active_grouping_style = "font-weight: bold;"
self._inactive_grouping_style = ""
self._command_style = ""
self._wb_label.setStyleSheet("font-weight: bold;")
return
self.setStyleSheet(
f"QToolBar#QuickNavBar {{ background: {bg}; border-top: 1px solid {border}; }}"
)
self._wb_label.setStyleSheet(f"color: {accent}; font-weight: bold;")
self._active_grouping_style = f"color: {accent}; font-weight: bold;"
self._inactive_grouping_style = f"color: {muted};"
self._command_style = f"color: {fg};"

View File

@@ -0,0 +1,285 @@
"""Static workbench slot assignments and command groupings.
Phase 1 uses hardcoded data from the QuickNav spec (SPEC.md section 5).
Phase 2 replaces this with dynamic toolbar introspection.
"""
# Fixed Ctrl+N workbench assignments
WORKBENCH_SLOTS = {
1: {
"key": "sketcher",
"class_name": "SketcherWorkbench",
"display": "Sketcher",
},
2: {
"key": "partdesign",
"class_name": "PartDesignWorkbench",
"display": "Part Design",
},
3: {
"key": "assembly",
"class_name": "AssemblyWorkbench",
"display": "Assembly",
},
4: {
"key": "spreadsheet",
"class_name": "SpreadsheetWorkbench",
"display": "Spreadsheet",
},
5: {
"key": "techdraw",
"class_name": "TechDrawWorkbench",
"display": "TechDraw",
},
}
# Command groupings per workbench. Each grouping has a name and up to
# 9 commands as (FreeCAD_command_id, display_name) tuples.
WORKBENCH_GROUPINGS = {
"sketcher": [
{
"name": "Primitives",
"commands": [
("Sketcher_CreateLine", "Line"),
("Sketcher_CreateRectangle", "Rectangle"),
("Sketcher_CreateCircle", "Circle"),
("Sketcher_CreateArc", "Arc"),
("Sketcher_CreatePoint", "Point"),
("Sketcher_CreateSlot", "Slot"),
("Sketcher_CreateBSpline", "B-Spline"),
("Sketcher_CreatePolyline", "Polyline"),
("Sketcher_CreateEllipseByCenter", "Ellipse"),
],
},
{
"name": "Constraints",
"commands": [
("Sketcher_ConstrainCoincidentUnified", "Coincident"),
("Sketcher_ConstrainHorizontal", "Horizontal"),
("Sketcher_ConstrainVertical", "Vertical"),
("Sketcher_ConstrainParallel", "Parallel"),
("Sketcher_ConstrainPerpendicular", "Perpendicular"),
("Sketcher_ConstrainTangent", "Tangent"),
("Sketcher_ConstrainEqual", "Equal"),
("Sketcher_ConstrainSymmetric", "Symmetric"),
("Sketcher_ConstrainBlock", "Block"),
],
},
{
"name": "Dimensions",
"commands": [
("Sketcher_ConstrainDistance", "Distance"),
("Sketcher_ConstrainDistanceX", "Horiz. Distance"),
("Sketcher_ConstrainDistanceY", "Vert. Distance"),
("Sketcher_ConstrainRadius", "Radius"),
("Sketcher_ConstrainDiameter", "Diameter"),
("Sketcher_ConstrainAngle", "Angle"),
("Sketcher_ConstrainLock", "Lock"),
("Sketcher_ConstrainSnellsLaw", "Refraction"),
],
},
{
"name": "Construction",
"commands": [
("Sketcher_ToggleConstruction", "Toggle Constr."),
("Sketcher_External", "External Geom."),
("Sketcher_CarbonCopy", "Carbon Copy"),
("Sketcher_Offset", "Offset"),
("Sketcher_Trimming", "Trim"),
("Sketcher_Extend", "Extend"),
("Sketcher_Split", "Split"),
],
},
{
"name": "Tools",
"commands": [
("Sketcher_Symmetry", "Mirror"),
("Sketcher_RectangularArray", "Linear Array"),
("Sketcher_Move", "Move"),
("Sketcher_Rotate", "Rotate"),
("Sketcher_Scale", "Scale"),
("Sketcher_CloseShape", "Close Shape"),
("Sketcher_ConnectLines", "Connect Edges"),
],
},
],
"partdesign": [
{
"name": "Additive",
"commands": [
("PartDesign_Pad", "Pad"),
("PartDesign_Revolution", "Revolution"),
("PartDesign_AdditiveLoft", "Add. Loft"),
("PartDesign_AdditivePipe", "Add. Pipe"),
("PartDesign_AdditiveHelix", "Add. Helix"),
("PartDesign_CompPrimitiveAdditive", "Add. Primitive"),
],
},
{
"name": "Subtractive",
"commands": [
("PartDesign_Pocket", "Pocket"),
("PartDesign_Hole", "Hole"),
("PartDesign_Groove", "Groove"),
("PartDesign_SubtractiveLoft", "Sub. Loft"),
("PartDesign_SubtractivePipe", "Sub. Pipe"),
("PartDesign_SubtractiveHelix", "Sub. Helix"),
("PartDesign_CompPrimitiveSubtractive", "Sub. Primitive"),
],
},
{
"name": "Datums",
"commands": [
("PartDesign_NewSketch", "New Sketch"),
("PartDesign_Plane", "Datum Plane"),
("PartDesign_Line", "Datum Line"),
("PartDesign_Point", "Datum Point"),
("PartDesign_ShapeBinder", "Shape Binder"),
("PartDesign_SubShapeBinder", "Sub-Shape Binder"),
("ZTools_DatumCreator", "ZT Datum Creator"),
("ZTools_DatumManager", "ZT Datum Manager"),
],
},
{
"name": "Transformations",
"commands": [
("PartDesign_Mirrored", "Mirrored"),
("PartDesign_LinearPattern", "Linear Pattern"),
("PartDesign_PolarPattern", "Polar Pattern"),
("PartDesign_MultiTransform", "MultiTransform"),
("ZTools_RotatedLinearPattern", "ZT Rot. Linear"),
],
},
{
"name": "Modeling",
"commands": [
("PartDesign_Fillet", "Fillet"),
("PartDesign_Chamfer", "Chamfer"),
("PartDesign_Draft", "Draft"),
("PartDesign_Thickness", "Thickness"),
("PartDesign_Boolean", "Boolean"),
("ZTools_EnhancedPocket", "ZT Enh. Pocket"),
],
},
],
"assembly": [
{
"name": "Components",
"commands": [
("Assembly_InsertLink", "Insert Component"),
("Assembly_InsertNewPart", "Create Part"),
("Assembly_CreateAssembly", "Create Assembly"),
("Assembly_ToggleGrounded", "Ground"),
("Assembly_CreateBom", "BOM"),
],
},
{
"name": "Joints",
"commands": [
("Assembly_CreateJointFixed", "Fixed"),
("Assembly_CreateJointRevolute", "Revolute"),
("Assembly_CreateJointCylindrical", "Cylindrical"),
("Assembly_CreateJointSlider", "Slider"),
("Assembly_CreateJointBall", "Ball"),
("Assembly_CreateJointDistance", "Distance"),
("Assembly_CreateJointAngle", "Angle"),
("Assembly_CreateJointParallel", "Parallel"),
("Assembly_CreateJointPerpendicular", "Perpendicular"),
],
},
{
"name": "Patterns",
"commands": [
("ZTools_AssemblyLinearPattern", "Linear Pattern"),
("ZTools_AssemblyPolarPattern", "Polar Pattern"),
],
},
],
"spreadsheet": [
{
"name": "Editing",
"commands": [
("Spreadsheet_MergeCells", "Merge Cells"),
("Spreadsheet_SplitCell", "Split Cell"),
("Spreadsheet_SetAlias", "Alias"),
("Spreadsheet_Import", "Import CSV"),
("Spreadsheet_Export", "Export CSV"),
],
},
{
"name": "Formatting",
"commands": [
("ZTools_SpreadsheetStyleBold", "Bold"),
("ZTools_SpreadsheetStyleItalic", "Italic"),
("ZTools_SpreadsheetStyleUnderline", "Underline"),
("ZTools_SpreadsheetAlignLeft", "Align Left"),
("ZTools_SpreadsheetAlignCenter", "Align Center"),
("ZTools_SpreadsheetAlignRight", "Align Right"),
("ZTools_SpreadsheetBgColor", "BG Color"),
("ZTools_SpreadsheetTextColor", "Text Color"),
("ZTools_SpreadsheetQuickAlias", "Quick Alias"),
],
},
],
"techdraw": [
{
"name": "Views",
"commands": [
("TechDraw_PageDefault", "New Page"),
("TechDraw_View", "Insert View"),
("TechDraw_ProjectionGroup", "Projection Group"),
("TechDraw_SectionView", "Section View"),
("TechDraw_DetailView", "Detail View"),
("TechDraw_ActiveView", "Active View"),
],
},
{
"name": "Dimensions",
"commands": [
("TechDraw_LengthDimension", "Length"),
("TechDraw_HorizontalDimension", "Horizontal"),
("TechDraw_VerticalDimension", "Vertical"),
("TechDraw_RadiusDimension", "Radius"),
("TechDraw_DiameterDimension", "Diameter"),
("TechDraw_AngleDimension", "Angle"),
],
},
{
"name": "Annotations",
"commands": [
("TechDraw_Annotation", "Annotation"),
("TechDraw_Balloon", "Balloon"),
("TechDraw_LeaderLine", "Leader Line"),
("TechDraw_CosmeticVertex", "Cosmetic Vertex"),
("TechDraw_Midpoints", "Midpoints"),
("TechDraw_CenterLine", "Center Line"),
],
},
],
}
def get_workbench_slot(n):
"""Return workbench info dict for Ctrl+N, or None if unassigned."""
return WORKBENCH_SLOTS.get(n)
def get_groupings(workbench_key):
"""Return the grouping list for a workbench key."""
return WORKBENCH_GROUPINGS.get(workbench_key, [])
def get_grouping(workbench_key, grouping_idx):
"""Return a specific grouping dict by 0-based index, or None."""
groupings = get_groupings(workbench_key)
if 0 <= grouping_idx < len(groupings):
return groupings[grouping_idx]
return None
def get_command(workbench_key, grouping_idx, n):
"""Return the FreeCAD command ID for the Nth command (1-based), or None."""
grouping = get_grouping(workbench_key, grouping_idx)
if grouping and 1 <= n <= len(grouping["commands"]):
return grouping["commands"][n - 1][0]
return None

View File

@@ -0,0 +1,385 @@
# Kindred Create Integration Guide
This document outlines the requirements, options, and steps for integrating the ztools workbench into Kindred Create as a native workbench.
---
## Table of Contents
1. [Current Architecture Overview](#current-architecture-overview)
2. [Integration Options](#integration-options)
3. [Files Requiring Modification](#files-requiring-modification)
4. [Branding Changes](#branding-changes)
5. [FreeCAD API Dependencies](#freecad-api-dependencies)
6. [Recommended Integration Path](#recommended-integration-path)
7. [Testing Checklist](#testing-checklist)
---
## Current Architecture Overview
### Directory Structure
```
ztools-0065/
├── package.xml # FreeCAD addon metadata
├── CatppuccinMocha/ # Preference pack (theme)
└── ztools/ # Main addon directory
├── Init.py # Pre-GUI initialization
├── InitGui.py # Workbench class definition
└── ztools/ # Python package
├── __init__.py
├── commands/ # GUI commands
│ ├── __init__.py
│ ├── datum_commands.py
│ ├── datum_viewprovider.py
│ ├── pattern_commands.py
│ ├── pocket_commands.py
│ ├── assembly_pattern_commands.py
│ └── spreadsheet_commands.py
├── datums/ # Datum creation logic
│ ├── __init__.py
│ └── core.py
└── resources/ # Icons and theming
├── __init__.py
├── icons.py
└── theme.py
```
### Module Statistics
| Component | Files | Commands | Description |
|-----------|-------|----------|-------------|
| Datum Tools | 3 | 2 | 15 attachment modes, DatumCreator + DatumManager |
| Pattern Tools | 1 | 1 | Rotated linear pattern |
| Pocket Tools | 1 | 1 | Enhanced pocket with face selection |
| Assembly Tools | 1 | 2 | Linear and polar assembly patterns |
| Spreadsheet Tools | 1 | 9 | Formatting and quick alias |
| Resources | 2 | - | 33 SVG icons, theme colors |
### External Dependencies
- **Python Standard Library Only** - No pip dependencies
- **FreeCAD Modules**: FreeCAD, FreeCADGui, Part, PartDesign, Sketcher, Assembly (1.0+), Spreadsheet
- **Qt/PySide**: QtCore, QtGui, QtWidgets (via FreeCAD's bundled PySide)
---
## Integration Options
### Option A: Addon-Style Integration (Minimal Changes)
**Effort**: Low
**Compatibility**: Maintains upstream FreeCAD compatibility
Bundle ztools as a pre-installed addon in Kindred Create's addon directory.
**Pros**:
- Minimal code changes
- Can still be distributed as standalone addon
- Easy to update independently
**Cons**:
- Not truly "native" - still appears as addon
- Users could accidentally uninstall
**Changes Required**:
- Update `package.xml` metadata with Kindred Create branding
- Optionally rename workbench display name
### Option B: Native Workbench Integration (Moderate Changes)
**Effort**: Medium
**Compatibility**: Kindred Create specific
Move ztools into FreeCAD's `Mod/` directory as a first-party workbench.
**Pros**:
- Appears alongside PartDesign, Assembly, etc.
- Cannot be uninstalled via Addon Manager
- Full integration with Kindred Create identity
**Cons**:
- Requires maintaining fork-specific code
- Branding changes throughout codebase
**Changes Required**:
- Rename all `ZTools_*` commands to `KindredCreate_*` or similar
- Update workbench class name and identifiers
- Modify directory structure to match FreeCAD conventions
- Update all user-facing strings
### Option C: Full Workbench Replacement (Major Changes)
**Effort**: High
**Compatibility**: Kindred Create only
Replace existing PartDesign/Assembly workbenches with unified Kindred workbench.
**Pros**:
- Unified user experience
- Complete control over workflow
**Cons**:
- Significant development effort
- Diverges heavily from upstream FreeCAD
- Harder to merge upstream improvements
---
## Files Requiring Modification
### Critical Files
| File | Changes Needed |
|------|----------------|
| `package.xml` | Package name, description, workbench name, classname |
| `ztools/InitGui.py` | Class name, MenuText, ToolTip, Icon, console messages |
| `ztools/Init.py` | Console messages |
| `ztools/ztools/commands/__init__.py` | No changes (internal imports) |
### Command Registration (All Command Files)
Each command file registers commands with `Gui.addCommand("ZTools_*", ...)`. These need renaming:
| File | Commands to Rename |
|------|-------------------|
| `datum_commands.py` | `ZTools_DatumCreator`, `ZTools_DatumManager` |
| `pattern_commands.py` | `ZTools_RotatedLinearPattern` |
| `pocket_commands.py` | `ZTools_EnhancedPocket` |
| `assembly_pattern_commands.py` | `ZTools_AssemblyLinearPattern`, `ZTools_AssemblyPolarPattern` |
| `spreadsheet_commands.py` | `ZTools_SpreadsheetStyleBold`, `ZTools_SpreadsheetStyleItalic`, `ZTools_SpreadsheetStyleUnderline`, `ZTools_SpreadsheetAlignLeft`, `ZTools_SpreadsheetAlignCenter`, `ZTools_SpreadsheetAlignRight`, `ZTools_SpreadsheetBgColor`, `ZTools_SpreadsheetTextColor`, `ZTools_SpreadsheetQuickAlias` |
### Internal References
Custom properties use `ZTools_` prefix for disambiguation:
| File | Properties |
|------|-----------|
| `datums/core.py` | `ZTools_SourceRefs`, `ZTools_Params`, `ZTools_DatumMode` |
| `datum_viewprovider.py` | Same properties accessed |
**Note**: These property names are stored in FreeCAD documents. Renaming them would break backward compatibility with existing documents. Consider keeping these internal or implementing migration.
---
## Branding Changes
### User-Visible Strings
| Location | Current | Suggested |
|----------|---------|-----------|
| `InitGui.py` MenuText | `"ztools"` | `"Kindred Design"` or similar |
| `InitGui.py` ToolTip | `"Extended PartDesign replacement..."` | Kindred-specific description |
| `InitGui.py` console messages | `"ztools workbench..."` | `"Kindred Design workbench..."` |
| `Init.py` console messages | `"ztools addon loaded"` | `"Kindred Design loaded"` |
| Toolbar names | `"ztools Datums"`, etc. | `"Kindred Datums"`, etc. |
| Menu names | `"ztools"` | `"Kindred"` |
### Command Prefixes
Current: `ZTools_*`
Suggested: `KindredCreate_*`, `Kindred_*`, or `KC_*`
### Class Names
| Current | Suggested |
|---------|-----------|
| `ZToolsWorkbench` | `KindredDesignWorkbench` |
| `ZToolsDatumObject` | `KindredDatumObject` |
| `ZToolsDatumViewProvider` | `KindredDatumViewProvider` |
---
## FreeCAD API Dependencies
### Core APIs Used
```python
# Application
import FreeCAD as App
import FreeCADGui as Gui
# Part/PartDesign
import Part
import PartDesign
# Workbench patterns
Gui.Workbench # Base class
Gui.addCommand() # Command registration
Gui.Control.showDialog() # Task panels
Gui.Selection # Selection observer
# Document operations
App.ActiveDocument
doc.openTransaction()
doc.commitTransaction()
doc.abortTransaction()
doc.recompute()
# Feature Python
Part.makeCompound()
doc.addObject("Part::FeaturePython", name)
doc.addObject("PartDesign::Plane", name)
doc.addObject("PartDesign::Line", name)
doc.addObject("PartDesign::Point", name)
doc.addObject("App::Link", name)
```
### Assembly Workbench APIs (FreeCAD 1.0+)
```python
# Assembly operations
asm.TypeId == "Assembly::AssemblyObject"
App.Link objects for component instances
```
### Spreadsheet APIs
```python
sheet.set(cell, value)
sheet.getContents(cell)
sheet.setStyle(cell, style)
sheet.setAlignment(cell, alignment)
sheet.setBackground(cell, (r, g, b))
sheet.setForeground(cell, (r, g, b))
sheet.setAlias(cell, alias)
```
### Qt/PySide APIs
```python
from PySide import QtCore, QtGui, QtWidgets
# Dialogs
QtWidgets.QColorDialog.getColor()
# Task panels
QtWidgets.QWidget subclasses
# Selection from table views
QTableView.selectionModel()
QItemSelectionModel.selectedIndexes()
```
### Preference System
```python
App.ParamGet("User parameter:BaseApp/Preferences/Mod/Spreadsheet")
params.SetUnsigned(key, value)
params.GetUnsigned(key)
```
---
## Recommended Integration Path
### Phase 1: Branding Update (Option B)
1. **Fork the repository** for Kindred Create
2. **Rename package and workbench**:
- `package.xml`: Update name, description
- `InitGui.py`: Update class name, MenuText, ToolTip
- `Init.py`: Update log messages
3. **Rename command prefixes**:
- Global find/replace: `ZTools_``Kindred_`
- Update all `Gui.addCommand()` calls
- Update all toolbar/menu references in `InitGui.py`
4. **Update toolbar/menu labels**:
- `"ztools Datums"``"Kindred Datums"`
- etc.
5. **Keep internal property names** (`ZTools_SourceRefs`, etc.) for document compatibility
### Phase 2: Directory Restructure
1. **Move to FreeCAD's Mod directory**:
```
freecad-source/src/Mod/KindredDesign/
├── Init.py
├── InitGui.py
└── KindredDesign/
├── commands/
├── datums/
└── resources/
```
2. **Update CMakeLists.txt** in FreeCAD source to include new module
3. **Add to default workbench list** if desired
### Phase 3: Theme Integration
1. **Move CatppuccinMocha** to Kindred Create's default themes
2. **Optionally set as default theme** for new installations
3. **Update `apply_spreadsheet_colors()`** to use Kindred theme colors
---
## Testing Checklist
### Functional Tests
- [ ] Workbench activates without errors
- [ ] All toolbars appear with correct icons
- [ ] All menus appear with correct structure
- [ ] Datum Creator: All 15 modes work
- [ ] Datum Manager: Opens (stub functionality)
- [ ] Rotated Linear Pattern: Creates patterns correctly
- [ ] Enhanced Pocket: Face selection works
- [ ] Assembly Linear Pattern: Creates component arrays
- [ ] Assembly Polar Pattern: Creates circular arrays
- [ ] Spreadsheet Bold/Italic/Underline: Toggle correctly
- [ ] Spreadsheet Alignment: Left/Center/Right work
- [ ] Spreadsheet Colors: Dialogs open, colors apply
- [ ] Spreadsheet Quick Alias: Creates aliases from labels
- [ ] Undo/Redo works for all operations
### Integration Tests
- [ ] Existing FreeCAD documents open correctly
- [ ] New documents created with Kindred tools save/load properly
- [ ] PartDesign features work alongside Kindred datums
- [ ] Assembly joints work with Kindred patterns
- [ ] Spreadsheet aliases work in PartDesign expressions
### Theme Tests
- [ ] Catppuccin Mocha preference pack loads
- [ ] Spreadsheet text colors are correct (light on dark)
- [ ] Icons render with correct theme colors
- [ ] QSS styling applies to all custom widgets
---
## Migration Notes
### Document Compatibility
Documents created with ztools will contain:
- Objects with `ZTools_*` custom properties
- References to `ZTools_*` in expressions (unlikely but possible)
**Recommendation**: Keep internal property names unchanged, or implement a document migration script that updates property names on load.
### User Preference Migration
If users have existing FreeCAD installations:
- Spreadsheet color preferences are stored per-user
- Consider running `apply_spreadsheet_colors()` on first Kindred Create launch
---
## Summary
The ztools workbench is well-structured for integration into Kindred Create. The recommended path is **Option B (Native Workbench Integration)** which provides:
- Native appearance alongside other FreeCAD workbenches
- Kindred Create branding throughout
- Maintained document compatibility
- Reasonable development effort
Key points:
- **No external dependencies** - clean integration
- **Standard FreeCAD APIs** - future-compatible
- **Modular structure** - easy to extend
- **33 custom icons** - complete visual identity ready for recoloring
Estimated effort for full integration: 2-4 hours for branding changes, additional time for build system integration depending on Kindred Create's structure.

420
reference/ztools/PLAN.md Normal file
View File

@@ -0,0 +1,420 @@
# ZTools Development Plan
## Current Status: v0.3.0 (80% complete)
### What's Working
- Workbench registration with 17 toolbars and menus
- All 15 datum creation functions with custom ZTools attachment system
- Datum Creator GUI (task panel with Planes/Axes/Points tabs)
- OK button creates datum, Cancel dismisses without creating
- Rotated Linear Pattern feature (complete)
- Icon system (32+ Catppuccin-themed SVGs)
- Metadata storage system (ZTools_Type, ZTools_Params, ZTools_SourceRefs)
- Spreadsheet linking for parametric control
- FreeCAD 1.0+ Assembly workbench integration (all stock commands)
- Assembly Linear Pattern tool (complete)
- Assembly Polar Pattern tool (complete)
- FreeCAD Spreadsheet workbench integration (all stock commands)
- zSpreadsheet formatting toolbar (9 commands)
### Recent Changes (2026-01-25)
- Added zSpreadsheet module with formatting toolbar
- Native Spreadsheet commands exposed (CreateSheet, Import, Export, SetAlias, MergeCells, SplitCell)
- Created 9 formatting commands: Bold, Italic, Underline, Align Left/Center/Right, Background Color, Text Color, Quick Alias
- Added 9 spreadsheet icons (Catppuccin Mocha theme)
- Spreadsheet text color now defaults to white for dark theme compatibility
### Previous Changes (2026-01-25)
- Added FreeCAD 1.0+ Assembly workbench integration
- All native Assembly commands exposed in ztools workbench (21 commands)
- Created Assembly Linear Pattern tool with task panel UI
- Created Assembly Polar Pattern tool with task panel UI
- Added assembly pattern icons (Catppuccin Mocha theme)
### Previous Changes (2026-01-24)
- Replaced FreeCAD's vanilla attachment system with custom ZTools attachment
- All datums now use `MapMode='Deactivated'` with calculated placements
- Source references stored in `ZTools_SourceRefs` property for future update capability
- Fixed all 3 point functions (`point_on_edge`, `point_center_of_face`, `point_center_of_circle`) to accept source parameters
- Removed redundant "Create Datum" button - OK now creates the datum
- Task panel properly cleans up selection observer on close
---
## ZTools Attachment System
FreeCAD's vanilla attachment system has reliability issues. ZTools uses a custom approach:
1. **Calculate placement directly** from source geometry at creation time
2. **Store source references** in `ZTools_SourceRefs` property (JSON)
3. **Use `MapMode='Deactivated'`** to prevent FreeCAD attachment interference
4. **Store creation parameters** in `ZTools_Params` for potential recalculation
This gives full control over datum positioning while maintaining the ability to update datums when source geometry changes (future feature).
### Metadata Properties
All ZTools datums have these custom properties:
- `ZTools_Type`: Creation method identifier (e.g., "offset_from_face", "midplane")
- `ZTools_Params`: JSON-encoded creation parameters
- `ZTools_SourceRefs`: JSON-encoded list of source geometry references
---
## Phase 0: Complete (Assembly Integration)
### FreeCAD 1.0+ Assembly Workbench Commands
ZTools exposes all native FreeCAD Assembly workbench commands in 3 toolbars:
**Assembly Structure:**
- `Assembly_CreateAssembly` - Create new assembly container
- `Assembly_InsertLink` - Insert component as link
- `Assembly_InsertNewPart` - Create and insert new part
**Assembly Joints (13 types):**
- `Assembly_CreateJointFixed` - Lock parts together (0 DOF)
- `Assembly_CreateJointRevolute` - Rotation around axis
- `Assembly_CreateJointCylindrical` - Rotation + translation along axis
- `Assembly_CreateJointSlider` - Translation along axis
- `Assembly_CreateJointBall` - Spherical rotation
- `Assembly_CreateJointDistance` - Maintain distance
- `Assembly_CreateJointParallel` - Keep parallel
- `Assembly_CreateJointPerpendicular` - Keep perpendicular
- `Assembly_CreateJointAngle` - Maintain angle
- `Assembly_CreateJointRackPinion` - Rack and pinion motion
- `Assembly_CreateJointScrew` - Helical motion
- `Assembly_CreateJointGears` - Gear ratio constraint
- `Assembly_CreateJointBelt` - Belt/pulley constraint
**Assembly Management:**
- `Assembly_ToggleGrounded` - Lock part in place
- `Assembly_SolveAssembly` - Run constraint solver
- `Assembly_CreateView` - Create exploded view
- `Assembly_CreateBom` - Generate bill of materials
- `Assembly_ExportASMT` - Export assembly file
### ZTools Assembly Pattern Tools
**Assembly Linear Pattern** (`ZTools_AssemblyLinearPattern`)
Creates copies of assembly components along a linear direction.
Features:
- Multi-component selection via table UI
- Direction vector (X, Y, Z)
- Occurrence count (2-100)
- Spacing modes: Total Length or Fixed Spacing
- Creates as Links (recommended) or copies
- Option to hide original components
- Auto-detects parent assembly
UI Layout:
```
+----------------------------------+
| Components |
| +------------------------------+ |
| | Component_1 [X] | |
| | Component_2 [X] | |
| +------------------------------+ |
| Select components in 3D view |
+----------------------------------+
| Pattern Parameters |
| Direction: X[1] Y[0] Z[0] |
| Occurrences: [3] |
| Mode: [Total Length v] |
| Total Length: [100 mm] |
+----------------------------------+
| Options |
| [x] Create as Links |
| [ ] Hide original components |
+----------------------------------+
```
**Assembly Polar Pattern** (`ZTools_AssemblyPolarPattern`)
Creates copies of assembly components around a rotation axis.
Features:
- Multi-component selection via table UI
- Axis presets (X, Y, Z) or custom axis vector
- Center point specification
- Occurrence count (2-100)
- Angle modes: Full Circle (360) or Custom Angle
- Creates as Links (recommended) or copies
- Option to hide original components
UI Layout:
```
+----------------------------------+
| Components |
| +------------------------------+ |
| | Component_1 [X] | |
| +------------------------------+ |
+----------------------------------+
| Rotation Axis |
| Axis: [Z Axis v] |
| Direction: X[0] Y[0] Z[1] |
| Center: X[0] Y[0] Z[0] |
+----------------------------------+
| Pattern Parameters |
| Occurrences: [6] |
| Mode: [Full Circle v] |
| Total Angle: [360 deg] |
+----------------------------------+
| Options |
| [x] Create as Links |
| [ ] Hide original components |
+----------------------------------+
```
---
## Phase 0.5: Complete (zSpreadsheet)
### FreeCAD Spreadsheet Workbench Commands
ZTools exposes native Spreadsheet commands in the "Spreadsheet" toolbar:
- `Spreadsheet_CreateSheet` - Create new spreadsheet
- `Spreadsheet_Import` - Import CSV file
- `Spreadsheet_Export` - Export to CSV
- `Spreadsheet_SetAlias` - Set cell alias
- `Spreadsheet_MergeCells` - Merge selected cells
- `Spreadsheet_SplitCell` - Split merged cell
### ZTools Spreadsheet Formatting Tools
Quick formatting toolbar for cell styling without dialogs:
**Style Commands:**
- `ZTools_SpreadsheetStyleBold` - Toggle bold (B icon)
- `ZTools_SpreadsheetStyleItalic` - Toggle italic (I icon)
- `ZTools_SpreadsheetStyleUnderline` - Toggle underline (U icon)
**Alignment Commands:**
- `ZTools_SpreadsheetAlignLeft` - Align text left
- `ZTools_SpreadsheetAlignCenter` - Align text center
- `ZTools_SpreadsheetAlignRight` - Align text right
**Color Commands:**
- `ZTools_SpreadsheetBgColor` - Set cell background color (color picker)
- `ZTools_SpreadsheetTextColor` - Set cell text color (color picker)
**Utility Commands:**
- `ZTools_SpreadsheetQuickAlias` - Create alias from adjacent label cell
### Implementation Details
**Cell Selection Helper:**
The `get_selected_cells()` function:
1. Gets active MDI subwindow
2. Finds QTableView widget
3. Gets selected indexes from selection model
4. Converts to A1 notation (handles AA, AB, etc.)
**Style Toggle Pattern:**
```python
current = sheet.getStyle(cell) or ""
styles = set(s.strip() for s in current.split("|") if s.strip())
if "bold" in styles:
styles.discard("bold")
else:
styles.add("bold")
sheet.setStyle(cell, "|".join(sorted(styles)))
```
**Color Picker Integration:**
Uses Qt's `QColorDialog.getColor()` with Catppuccin defaults for dark theme.
---
## Phase 1: Complete (Datum Tools)
All datum creation functions now work:
### Planes (6 modes)
- Offset from Face
- Midplane (2 Faces)
- 3 Points
- Normal to Edge
- Angled from Face
- Tangent to Cylinder
### Axes (4 modes)
- 2 Points
- From Edge
- Cylinder Center
- Plane Intersection
### Points (5 modes)
- At Vertex
- XYZ Coordinates
- On Edge (with parameter)
- Face Center
- Circle Center
---
## Phase 2: Complete Enhanced Pocket
### 2.1 Wire Up Pocket Execution (pocket_commands.py)
The EnhancedPocketTaskPanel has complete UI but no execute logic.
Required implementation:
1. Get selected sketch from user
2. Create PartDesign::Pocket with selected type
3. Apply "Flip Side to Cut" by:
- Reversing the pocket direction, OR
- Using a boolean cut approach with inverted profile
4. Handle all pocket types: Dimension, Through All, To First, Up To Face, Two Dimensions
### 2.2 Register Pocket Command
Add to InitGui.py toolbar if not already present.
---
## Phase 3: Datum Manager
### 3.1 Implement DatumManagerTaskPanel
Replace the stub in datum_commands.py with functional panel:
Features:
- List all datum objects (planes, axes, points) in document
- Filter by type (ZTools-created vs native)
- Toggle visibility (eye icon per item)
- Rename datums inline
- Delete selected datums
- Jump to datum in model tree
UI Layout:
```
+----------------------------------+
| Filter: [All v] [ZTools only ☐] |
+----------------------------------+
| ☑ ZPlane_Offset_001 [👁] [🗑] |
| ☑ ZPlane_Mid_001 [👁] [🗑] |
| ☐ ZAxis_Cyl_001 [👁] [🗑] |
+----------------------------------+
| [Rename] [Show All] [Hide All] |
+----------------------------------+
```
---
## Phase 4: Additional Features (Future)
### 4.1 Module 2 Completion: Enhanced Pad
- Multi-body support
- Draft angles on pad
- Lip/groove profiles
### 4.2 Module 3: Body Operations
- Split body at plane
- Combine bodies
- Shell improvements
### 4.3 Module 4: Pattern Tools
- Curve-driven pattern (sweep instances along spline)
- Fill pattern (populate region with instances)
- Pattern with variable spacing
### 4.4 Datum Update Feature
- Use stored `ZTools_SourceRefs` to recalculate datum positions
- Handle topology changes gracefully
- Option to "freeze" datums (disconnect from sources)
---
## File Reference
| File | Purpose | Lines |
|------|---------|-------|
| `ztools/ztools/datums/core.py` | Datum creation functions | ~750 |
| `ztools/ztools/commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
| `ztools/ztools/commands/pocket_commands.py` | Enhanced Pocket GUI | ~600 |
| `ztools/ztools/commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
| `ztools/ztools/commands/assembly_pattern_commands.py` | Assembly Linear/Polar Patterns | ~580 |
| `ztools/ztools/commands/spreadsheet_commands.py` | Spreadsheet formatting tools | ~480 |
| `ztools/InitGui.py` | Workbench registration | ~330 |
| `ztools/ztools/resources/icons.py` | SVG icon definitions | ~540 |
---
## Testing Checklist
### Phase 1 Tests (Datum Tools)
- [ ] Create plane offset from face
- [ ] Create midplane between 2 faces
- [ ] Create plane from 3 points
- [ ] Create plane normal to edge at various parameters
- [ ] Create angled plane from face about edge
- [ ] Create plane tangent to cylinder
- [ ] Create axis from 2 points
- [ ] Create axis from edge
- [ ] Create axis at cylinder center
- [ ] Create axis at plane intersection
- [ ] Create point at vertex
- [ ] Create point at XYZ coordinates
- [ ] Create point on edge at parameter 0.0, 0.5, 1.0
- [ ] Create point at face center (planar and cylindrical)
- [ ] Create point at circle center (full circle and arc)
- [ ] Verify ZTools_Type, ZTools_Params, ZTools_SourceRefs properties exist
- [ ] Verify no "deactivated attachment mode" warnings in console
### Phase 2 Tests (Enhanced Pocket)
- [ ] Create pocket with Dimension type
- [ ] Create pocket with Through All
- [ ] Create pocket with Flip Side to Cut enabled
- [ ] Verify pocket respects taper angle
### Phase 3 Tests (Datum Manager)
- [ ] Datum Manager lists all datums
- [ ] Visibility toggle works
- [ ] Rename persists after recompute
- [ ] Delete removes datum cleanly
### Assembly Integration Tests
- [ ] Assembly workbench commands appear in toolbars
- [ ] Assembly_CreateAssembly works from ztools
- [ ] Assembly_InsertLink works from ztools
- [ ] All joint commands accessible
- [ ] Assembly_SolveAssembly works
### Assembly Pattern Tests
- [ ] Linear pattern with 3 occurrences along X axis
- [ ] Linear pattern with Total Length mode
- [ ] Linear pattern with Spacing mode
- [ ] Linear pattern creates links (not copies)
- [ ] Polar pattern with 6 occurrences (full circle)
- [ ] Polar pattern with custom angle (90 degrees, 4 occurrences)
- [ ] Polar pattern around Z axis
- [ ] Polar pattern with custom center point
- [ ] Multiple components can be patterned simultaneously
- [ ] Pattern instances added to parent assembly
### zSpreadsheet Tests
- [ ] Spreadsheet toolbar appears with stock commands
- [ ] ztools Spreadsheet toolbar appears with formatting commands
- [ ] Create new spreadsheet via toolbar
- [ ] Select cells and toggle Bold style
- [ ] Select cells and toggle Italic style
- [ ] Select cells and toggle Underline style
- [ ] Align cells left/center/right
- [ ] Set background color via color picker
- [ ] Set text color via color picker (verify white default for dark theme)
- [ ] Quick Alias creates alias from left-adjacent cell content
- [ ] Undo/redo works for all formatting operations
- [ ] Commands are disabled when no cells selected
---
## Notes
- FreeCAD 1.0+ required (TNP mitigation assumed)
- ZTools uses custom attachment system (not FreeCAD's vanilla attachment)
- Catppuccin Mocha theme is bundled as preference pack
- LGPL-3.0-or-later license

388
reference/ztools/ROADMAP.md Normal file
View File

@@ -0,0 +1,388 @@
# ZTools Roadmap
**Version:** 0.3.0
**Last Updated:** 2026-01-25
**Target Platform:** FreeCAD 1.0+
**License:** LGPL-3.0-or-later
---
## Executive Summary
ZTools is an extended PartDesign workbench replacement for FreeCAD, focused on velocity-driven CAD workflows. It integrates enhanced datum creation, assembly patterning, spreadsheet formatting, and a unified dark theme (Catppuccin Mocha).
**Current State:** 80% complete for v1.0 release
**Active Development Areas:** Datum management, Enhanced Pocket completion, documentation
---
## Table of Contents
1. [Implemented Features](#implemented-features)
2. [Known Gaps & Incomplete Features](#known-gaps--incomplete-features)
3. [FreeCAD Ecosystem Alignment](#freecad-ecosystem-alignment)
4. [Development Roadmap](#development-roadmap)
5. [Technical Architecture](#technical-architecture)
6. [File Reference](#file-reference)
---
## Implemented Features
### 1. Workbench Integration (17 Toolbars)
ZTools consolidates multiple FreeCAD workbenches into a single unified interface:
| Category | Toolbars | Commands |
|----------|----------|----------|
| PartDesign | Structure, Datums, Additive, Subtractive, Transformations, Dress-Up, Boolean | 35+ native commands |
| Sketcher | Sketcher | 4 native commands |
| Assembly | Assembly, Assembly Joints, Assembly Management | 21 native commands |
| Spreadsheet | Spreadsheet | 6 native commands |
| ZTools Custom | ztools Datums, ztools Patterns, ztools Features, ztools Assembly, ztools Spreadsheet | 14 custom commands |
### 2. Datum Creation System (16 Functions)
**Custom Attachment System** - Replaces FreeCAD's unreliable vanilla attachment:
- Calculates placement directly from source geometry
- Stores source references in `ZTools_SourceRefs` (JSON)
- Uses `MapMode='Deactivated'` to prevent interference
- Stores creation parameters in `ZTools_Params` for recalculation
#### Datum Planes (7 modes)
| Mode | Function | Parameters |
|------|----------|------------|
| Offset from Face | `plane_offset_from_face()` | distance (mm) |
| Offset from Plane | `plane_offset_from_plane()` | distance (mm) |
| Midplane | `plane_midplane()` | 2 parallel faces |
| 3 Points | `plane_from_3_points()` | 3 vertices |
| Normal to Edge | `plane_normal_to_edge()` | parameter (0.0-1.0) |
| Angled | `plane_angled()` | angle (degrees) |
| Tangent to Cylinder | `plane_tangent_to_cylinder()` | angle (degrees) |
#### Datum Axes (4 modes)
| Mode | Function | Parameters |
|------|----------|------------|
| 2 Points | `axis_from_2_points()` | 2 vertices |
| From Edge | `axis_from_edge()` | linear edge |
| Cylinder Center | `axis_cylinder_center()` | cylindrical face |
| Plane Intersection | `axis_intersection_planes()` | 2 planes |
#### Datum Points (5 modes)
| Mode | Function | Parameters |
|------|----------|------------|
| At Vertex | `point_at_vertex()` | vertex |
| XYZ Coordinates | `point_at_coordinates()` | x, y, z |
| On Edge | `point_on_edge()` | parameter (0.0-1.0) |
| Face Center | `point_center_of_face()` | face |
| Circle Center | `point_center_of_circle()` | circular edge |
**Datum Creator GUI:**
- Auto-detection of 15+ creation modes based on selection
- Manual mode override
- Spreadsheet linking option
- Custom naming
- Real-time selection observer
### 3. Pattern Tools (3 Commands)
| Command | Description | Status |
|---------|-------------|--------|
| `ZTools_RotatedLinearPattern` | Linear pattern with incremental rotation per instance | Complete |
| `ZTools_AssemblyLinearPattern` | Pattern assembly components linearly | Complete |
| `ZTools_AssemblyPolarPattern` | Pattern assembly components around axis | Complete |
**Assembly Pattern Features:**
- Multi-component selection via table UI
- Creates as Links (recommended) or copies
- Direction/axis presets or custom vectors
- Spacing modes: Total Length or Fixed Spacing
- Angle modes: Full Circle or Custom Angle
- Auto-detects parent assembly container
### 4. Spreadsheet Formatting (9 Commands)
| Command | Description | Status |
|---------|-------------|--------|
| `ZTools_SpreadsheetStyleBold` | Toggle bold | Complete |
| `ZTools_SpreadsheetStyleItalic` | Toggle italic | Complete |
| `ZTools_SpreadsheetStyleUnderline` | Toggle underline | Complete |
| `ZTools_SpreadsheetAlignLeft` | Left align | Complete |
| `ZTools_SpreadsheetAlignCenter` | Center align | Complete |
| `ZTools_SpreadsheetAlignRight` | Right align | Complete |
| `ZTools_SpreadsheetBgColor` | Background color picker | Complete |
| `ZTools_SpreadsheetTextColor` | Text color picker | Complete |
| `ZTools_SpreadsheetQuickAlias` | Auto-create alias from label | Complete |
### 5. Enhanced Features
| Command | Description | Status |
|---------|-------------|--------|
| `ZTools_EnhancedPocket` | Pocket with "Flip Side to Cut" (SOLIDWORKS-style) | 90% Complete |
**Flip Side to Cut:** Removes material OUTSIDE the sketch profile instead of inside, using Boolean Common operation.
### 6. Theme System (Catppuccin Mocha)
- Complete Qt StyleSheet (QSS) for entire FreeCAD interface
- 26-color palette consistently applied
- 50+ widget types styled
- FreeCAD-specific widgets: PropertyEditor, Python Console, Spreadsheet
- Spreadsheet colors auto-applied on workbench activation
### 7. Icon System (33 Icons)
All icons use Catppuccin Mocha palette:
- Workbench icon
- Datum icons (planes, axes, points - 13 total)
- Pattern icons (3 total)
- Pocket icons (2 total)
- Assembly pattern icons (2 total)
- Spreadsheet formatting icons (9 total)
---
## Known Gaps & Incomplete Features
### Critical (Must Fix)
| Issue | Location | Description | Priority |
|-------|----------|-------------|----------|
| Datum Manager stub | `datum_commands.py:853` | Placeholder only - needs full implementation | High |
| Datum edit recalculation | `datum_viewprovider.py:351,355,359` | Parameter changes don't recalculate placement from source geometry | High |
### Non-Critical (Should Fix)
| Issue | Location | Description | Priority |
|-------|----------|-------------|----------|
| Enhanced Pocket incomplete | `pocket_commands.py` | Taper angle disabled for flipped pockets | Medium |
| Pocket execution logic | `pocket_commands.py` | UI complete but execution needs verification | Medium |
### Future Enhancements (Nice to Have)
| Feature | Description | Priority |
|---------|-------------|----------|
| Curve-driven pattern | Sweep instances along spline | Low |
| Fill pattern | Populate region with instances | Low |
| Variable spacing pattern | Non-uniform spacing | Low |
| Enhanced Pad | Multi-body support, draft angles | Low |
| Body operations | Split, combine, shell improvements | Low |
---
## FreeCAD Ecosystem Alignment
### FreeCAD 1.0 (November 2024) - Current Target
**Key Features ZTools Leverages:**
- **TNP Mitigation:** Topological Naming Problem largely resolved
- **Integrated Assembly Workbench:** Ondsel's assembly system now core
- **Material System Overhaul:** New material handling
- **UI/UX Improvements:** Dark/light themes, selection filters
**ZTools Alignment:**
- Custom attachment system complements TNP fix
- Full integration with native Assembly workbench
- Catppuccin theme extends FreeCAD's theming
### FreeCAD 1.1 (Expected Late 2025)
**Planned Features:**
- New Transform Manipulator
- UI Material Rendering Improvements
- Continued TNP refinement for Sketcher/PartDesign
**ZTools Opportunities:**
- Watch for new Assembly API standardization
- Monitor Sketcher improvements for datum integration
### FreeCAD Strategic Priorities (from Roadmap)
| FreeCAD Priority | ZTools Alignment |
|------------------|------------------|
| Model Stability | Custom attachment system prevents fragile models |
| Assembly Integration | Full native Assembly command exposure |
| Flatten Learning Curve | Unified toolbar consolidation |
| UI Modernization | Catppuccin Mocha theme |
| Streamlined Workflow | Quick formatting toolbars, auto-detection |
### Ondsel Contributions (Note: Ondsel shut down October 2025)
Ondsel's contributions now maintained by FreeCAD community:
- Assembly workbench (ZTools integrates)
- VarSets custom properties (potential future integration)
- Sketcher improvements
---
## Development Roadmap
### Phase 1: v0.3.x - Stability & Completion (Current)
**Timeline:** Q1 2026
| Task | Status | Priority |
|------|--------|----------|
| Complete Datum Manager GUI | Not Started | High |
| Implement datum parameter recalculation | Not Started | High |
| Verify Enhanced Pocket execution | Partial | Medium |
| Add comprehensive test coverage | Not Started | Medium |
| Documentation completion | In Progress | Medium |
### Phase 2: v0.4.0 - Polish & UX
**Timeline:** Q2 2026
| Task | Description |
|------|-------------|
| Keyboard shortcuts | Add hotkeys for common operations |
| Context menus | Right-click menus in 3D view |
| Undo/redo improvements | Better transaction naming |
| Error handling | User-friendly error messages |
| Preferences panel | ZTools configuration UI |
### Phase 3: v0.5.0 - Advanced Features
**Timeline:** Q3 2026
| Task | Description |
|------|-------------|
| Curve-driven pattern | Pattern along splines |
| Enhanced Pad | Draft angles, lip/groove |
| Body operations | Split, combine, shell |
| Datum freeze/update | Control source geometry updates |
### Phase 4: v1.0.0 - Production Release
**Timeline:** Q4 2026
| Task | Description |
|------|-------------|
| Full test suite | Automated testing |
| User documentation | Complete user guide |
| Video tutorials | Getting started series |
| FreeCAD Addon Manager | Official listing |
---
## Technical Architecture
### Directory Structure
```
ztools/
├── Init.py # Startup (non-GUI)
├── InitGui.py # Workbench registration
└── ztools/
├── __init__.py
├── commands/ # GUI commands
│ ├── __init__.py
│ ├── datum_commands.py # Datum Creator/Manager
│ ├── datum_viewprovider.py # Custom ViewProvider
│ ├── pattern_commands.py # Rotated Linear Pattern
│ ├── pocket_commands.py # Enhanced Pocket
│ ├── assembly_pattern_commands.py # Assembly patterns
│ └── spreadsheet_commands.py # Spreadsheet formatting
├── datums/ # Core datum library
│ ├── __init__.py
│ └── core.py # 16 datum functions
└── resources/ # Assets
├── __init__.py
├── icons.py # 33 SVG icons
├── theme.py # Catppuccin QSS
└── icons/ # Generated SVG files
```
### Key Design Patterns
1. **Command Pattern:** All tools follow FreeCAD's `GetResources()`, `Activated()`, `IsActive()` pattern
2. **Task Panel Pattern:** Complex UIs use `QWidget` with selection observers
3. **Feature Python Pattern:** Custom features use `Part::FeaturePython` with ViewProvider
4. **Metadata System:** JSON properties for tracking ZTools objects
### Metadata Properties
All ZTools objects have:
- `ZTools_Type`: Feature type identifier
- `ZTools_Params`: JSON creation parameters
- `ZTools_SourceRefs`: JSON source geometry references
---
## File Reference
| File | Purpose | Lines |
|------|---------|-------|
| `InitGui.py` | Workbench registration, toolbars, menus | ~330 |
| `datums/core.py` | 16 datum creation functions | ~1300 |
| `commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
| `commands/datum_viewprovider.py` | Custom ViewProvider, edit panel | ~400 |
| `commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
| `commands/pocket_commands.py` | Enhanced Pocket | ~600 |
| `commands/assembly_pattern_commands.py` | Assembly patterns | ~580 |
| `commands/spreadsheet_commands.py` | Spreadsheet formatting | ~480 |
| `resources/icons.py` | 33 SVG icon definitions | ~540 |
| `resources/theme.py` | Catppuccin Mocha QSS | ~1500 |
**Total:** ~6,400+ lines of code
---
## Statistics Summary
| Category | Count |
|----------|-------|
| Command Files | 6 |
| Command Classes | 24+ |
| Datum Creation Functions | 16 |
| Icons Defined | 33 |
| Toolbars Registered | 17 |
| Menu Hierarchies | 7 major |
| Native Commands Exposed | 66 |
| Custom ZTools Commands | 14 |
| Theme Colors | 26 |
| Styled Widget Types | 50+ |
---
## Contributing
ZTools follows FreeCAD's contribution guidelines. Key areas needing help:
1. **Testing:** Manual testing on different platforms
2. **Documentation:** User guides and tutorials
3. **Translations:** Internationalization support
4. **Bug Reports:** Issue tracking and reproduction
---
## License
LGPL-3.0-or-later
Compatible with FreeCAD's licensing model.
---
## Changelog
### v0.3.0 (2026-01-25)
- Added zSpreadsheet module with 9 formatting commands
- Added Spreadsheet workbench integration (6 native commands)
- Added 9 spreadsheet formatting icons
### v0.2.0 (2026-01-25)
- Added Assembly workbench integration (21 native commands)
- Added Assembly Linear Pattern tool
- Added Assembly Polar Pattern tool
- Added assembly pattern icons
### v0.1.0 (2026-01-24)
- Initial release
- Custom attachment system for datums
- 16 datum creation functions
- Datum Creator GUI with auto-detection
- Rotated Linear Pattern
- Enhanced Pocket (partial)
- Catppuccin Mocha theme
- 21 initial icons

View File

@@ -0,0 +1,58 @@
# Datum Attachment Work - In Progress
## Context
Implementing proper FreeCAD attachment for datum objects to avoid "deactivated attachment mode" warnings.
The pattern is adding `source_object` and `source_subname` parameters to each datum function and using `_setup_datum_attachment()` with appropriate MapModes.
## Completed Functions (in core.py)
### Planes
- `plane_offset_from_face` - MapMode='FlatFace'
- `plane_midplane` - MapMode='TwoFace'
- `plane_from_3_points` - MapMode='ThreePointPlane'
- `plane_normal_to_edge` - MapMode='NormalToPath'
- `plane_angled` - MapMode='FlatFace' with rotation offset
- `plane_tangent_to_cylinder` - MapMode='Tangent'
### Axes
- `axis_from_2_points` - MapMode='TwoPointLine'
- `axis_from_edge` - MapMode='ObjectXY'
- `axis_cylinder_center` - MapMode='ObjectZ'
- `axis_intersection_planes` - MapMode='TwoFace'
### Points
- `point_at_vertex` - MapMode='Vertex'
## Remaining Functions to Update (in core.py)
- `point_at_coordinates` - No attachment needed (explicit coordinates), but could use 'Translate' mode
- `point_on_edge` - Use MapMode='OnEdge' with MapPathParameter for position
- `point_center_of_face` - Use MapMode='CenterOfCurvature' or similar
- `point_center_of_circle` - Use MapMode='CenterOfCurvature'
## After core.py Updates
Update `datum_commands.py` to pass source references to the remaining point functions:
- `create_point_at_vertex` - already done
- `create_point_on_edge` - needs update
- `create_point_center_face` - needs update
- `create_point_center_circle` - needs update
## Pattern for Updates
1. Add parameters to function signature:
```python
source_object: Optional[App.DocumentObject] = None,
source_subname: Optional[str] = None,
```
2. In the body section, use attachment instead of placement:
```python
if source_object and source_subname:
support = [(source_object, source_subname)]
_setup_datum_attachment(point, support, "MapMode")
else:
_setup_datum_placement(point, App.Placement(...))
```
3. Update datum_commands.py to extract and pass source references from selection.

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>ZTools</name>
<description>Extended PartDesign workbench with velocity-focused tools and advanced datum creation.</description>
<version>0.1.0</version>
<date>2026-01-24</date>
<license file="LICENSE">LGPL-3.0-or-later</license>
<content>
<workbench>
<name>ZTools</name>
<classname>ZToolsWorkbench</classname>
<subdirectory>./ztools</subdirectory>
</workbench>
</content>
<!-- Kindred Create extensions -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>50</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
<contexts>
<context id="partdesign.body" action="inject"/>
<context id="partdesign.feature" action="inject"/>
</contexts>
</kindred>
</package>

View File

@@ -0,0 +1,591 @@
# FreeCAD 1.0.2 PartDesign Workbench Command Reference
## Overview
The PartDesign Workbench uses a **feature-based parametric methodology** where a component is represented by a Body container. Features are cumulative—each builds on the result of preceding features. Most features are based on parametric sketches and are either additive (adding material) or subtractive (removing material).
FreeCAD 1.0 introduced significant improvements including **Topological Naming Problem (TNP) mitigation**, making parametric models more stable when earlier features are modified.
---
## Structure & Containers
### Body
The fundamental container for PartDesign features. Defines a local coordinate system and contains all features that define a single solid component.
```python
body = doc.addObject('PartDesign::Body', 'Body')
```
**Properties:**
- `Tip` — The feature representing the current state of the body
- `BaseFeature` — Optional external solid to build upon
- `Origin` — Contains reference planes (XY, XZ, YZ) and axes (X, Y, Z)
### Part Container
Groups multiple Bodies for organization. Not a PartDesign-specific object but commonly used.
```python
part = doc.addObject('App::Part', 'Part')
```
---
## Sketch Tools
| Command | Description |
|---------|-------------|
| **Create Sketch** | Creates a new sketch on a selected face or datum plane |
| **Attach Sketch** | Attaches a sketch to geometry from the active body |
| **Edit Sketch** | Opens selected sketch for editing |
| **Validate Sketch** | Verifies tolerance of points and adjusts them |
| **Check Geometry** | Checks geometry for errors |
```python
# Create sketch attached to XY plane
sketch = body.newObject('Sketcher::SketchObject', 'Sketch')
sketch.AttachmentSupport = [(body.getObject('Origin').getObject('XY_Plane'), '')]
sketch.MapMode = 'FlatFace'
```
---
## Reference Geometry (Datums)
### Datum Plane
Creates a reference plane for sketch attachment or as a mirror/pattern reference.
```python
plane = body.newObject('PartDesign::Plane', 'DatumPlane')
plane.AttachmentSupport = [(face_reference, '')]
plane.MapMode = 'FlatFace'
plane.Offset = App.Vector(0, 0, 10) # Offset along normal
```
### Datum Line
Creates a reference axis for revolutions, grooves, or patterns.
```python
line = body.newObject('PartDesign::Line', 'DatumLine')
line.AttachmentSupport = [(edge_reference, '')]
line.MapMode = 'ObjectXY'
```
### Datum Point
Creates a reference point for geometry attachment.
```python
point = body.newObject('PartDesign::Point', 'DatumPoint')
point.AttachmentSupport = [(vertex_reference, '')]
```
### Local Coordinate System
Creates a local coordinate system (LCS) attached to datum geometry.
```python
lcs = body.newObject('PartDesign::CoordinateSystem', 'LocalCS')
```
### Shape Binder
References geometry from a single parent object.
```python
binder = body.newObject('PartDesign::ShapeBinder', 'ShapeBinder')
binder.Support = [(external_object, ['Face1'])]
```
### SubShapeBinder
References geometry from one or more parent objects (more flexible than ShapeBinder).
```python
subbinder = body.newObject('PartDesign::SubShapeBinder', 'SubShapeBinder')
subbinder.Support = [(obj1, ['Face1']), (obj2, ['Edge2'])]
```
### Clone
Creates a clone of a selected body.
```python
clone = doc.addObject('PartDesign::FeatureBase', 'Clone')
clone.BaseFeature = source_body
```
---
## Additive Features (Add Material)
### Pad
Extrudes a sketch profile to create a solid.
```python
pad = body.newObject('PartDesign::Pad', 'Pad')
pad.Profile = sketch
pad.Length = 20.0
pad.Type = 0 # 0=Dimension, 1=UpToLast, 2=UpToFirst, 3=UpToFace, 4=TwoLengths, 5=UpToShape
pad.Reversed = False
pad.Midplane = False
pad.Symmetric = False
pad.Length2 = 10.0 # For TwoLengths type
pad.UseCustomVector = False
pad.Direction = App.Vector(0, 0, 1)
pad.TaperAngle = 0.0 # Draft angle (new in 1.0)
pad.TaperAngle2 = 0.0
```
**Type Options:**
| Value | Mode | Description |
|-------|------|-------------|
| 0 | Dimension | Fixed length |
| 1 | UpToLast | Extends to last face in direction |
| 2 | UpToFirst | Extends to first face encountered |
| 3 | UpToFace | Extends to selected face |
| 4 | TwoLengths | Extends in both directions |
| 5 | UpToShape | Extends to selected shape (new in 1.0) |
### Revolution
Creates a solid by revolving a sketch around an axis.
```python
revolution = body.newObject('PartDesign::Revolution', 'Revolution')
revolution.Profile = sketch
revolution.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
revolution.Angle = 360.0
revolution.Midplane = False
revolution.Reversed = False
```
### Additive Loft
Creates a solid by transitioning between two or more sketch profiles.
```python
loft = body.newObject('PartDesign::AdditiveLoft', 'AdditiveLoft')
loft.Profile = sketch1
loft.Sections = [sketch2, sketch3]
loft.Ruled = False
loft.Closed = False
```
### Additive Pipe (Sweep)
Creates a solid by sweeping a profile along a path.
```python
pipe = body.newObject('PartDesign::AdditivePipe', 'AdditivePipe')
pipe.Profile = profile_sketch
pipe.Spine = path_sketch # or (object, ['Edge1', 'Edge2'])
pipe.Transition = 0 # 0=Transformed, 1=RightCorner, 2=RoundCorner
pipe.Mode = 0 # 0=Standard, 1=Fixed, 2=Frenet, 3=Auxiliary
pipe.Auxiliary = None # Auxiliary spine for Mode=3
```
### Additive Helix
Creates a solid by sweeping a sketch along a helix.
```python
helix = body.newObject('PartDesign::AdditiveHelix', 'AdditiveHelix')
helix.Profile = sketch
helix.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
helix.Pitch = 5.0
helix.Height = 30.0
helix.Turns = 6.0
helix.Mode = 0 # 0=pitch-height, 1=pitch-turns, 2=height-turns
helix.LeftHanded = False
helix.Reversed = False
helix.Angle = 0.0 # Taper angle
helix.Growth = 0.0 # Radius growth per turn
```
### Additive Primitives
Direct primitive creation without sketches.
```python
# Box
box = body.newObject('PartDesign::AdditiveBox', 'Box')
box.Length = 10.0
box.Width = 10.0
box.Height = 10.0
# Cylinder
cyl = body.newObject('PartDesign::AdditiveCylinder', 'Cylinder')
cyl.Radius = 5.0
cyl.Height = 20.0
cyl.Angle = 360.0
# Sphere
sphere = body.newObject('PartDesign::AdditiveSphere', 'Sphere')
sphere.Radius = 10.0
sphere.Angle1 = -90.0
sphere.Angle2 = 90.0
sphere.Angle3 = 360.0
# Cone
cone = body.newObject('PartDesign::AdditiveCone', 'Cone')
cone.Radius1 = 10.0
cone.Radius2 = 5.0
cone.Height = 15.0
cone.Angle = 360.0
# Ellipsoid
ellipsoid = body.newObject('PartDesign::AdditiveEllipsoid', 'Ellipsoid')
ellipsoid.Radius1 = 10.0
ellipsoid.Radius2 = 5.0
ellipsoid.Radius3 = 8.0
# Torus
torus = body.newObject('PartDesign::AdditiveTorus', 'Torus')
torus.Radius1 = 20.0
torus.Radius2 = 5.0
# Prism
prism = body.newObject('PartDesign::AdditivePrism', 'Prism')
prism.Polygon = 6
prism.Circumradius = 10.0
prism.Height = 20.0
# Wedge
wedge = body.newObject('PartDesign::AdditiveWedge', 'Wedge')
wedge.Xmin = 0.0
wedge.Xmax = 10.0
wedge.Ymin = 0.0
wedge.Ymax = 10.0
wedge.Zmin = 0.0
wedge.Zmax = 10.0
wedge.X2min = 2.0
wedge.X2max = 8.0
wedge.Z2min = 2.0
wedge.Z2max = 8.0
```
---
## Subtractive Features (Remove Material)
### Pocket
Cuts material by extruding a sketch inward.
```python
pocket = body.newObject('PartDesign::Pocket', 'Pocket')
pocket.Profile = sketch
pocket.Length = 15.0
pocket.Type = 0 # Same options as Pad, plus 1=ThroughAll
pocket.Reversed = False
pocket.Midplane = False
pocket.Symmetric = False
pocket.TaperAngle = 0.0
```
### Hole
Creates parametric holes with threading options.
```python
hole = body.newObject('PartDesign::Hole', 'Hole')
hole.Profile = sketch # Sketch with center points
hole.Diameter = 6.0
hole.Depth = 15.0
hole.DepthType = 0 # 0=Dimension, 1=ThroughAll
hole.Threaded = True
hole.ThreadType = 0 # 0=None, 1=ISOMetricCoarse, 2=ISOMetricFine, 3=UNC, 4=UNF, 5=NPT, etc.
hole.ThreadSize = 'M6'
hole.ThreadFit = 0 # 0=Standard, 1=Close
hole.ThreadDirection = 0 # 0=Right, 1=Left
hole.HoleCutType = 0 # 0=None, 1=Counterbore, 2=Countersink
hole.HoleCutDiameter = 10.0
hole.HoleCutDepth = 3.0
hole.HoleCutCountersinkAngle = 90.0
hole.DrillPoint = 0 # 0=Flat, 1=Angled
hole.DrillPointAngle = 118.0
hole.DrillForDepth = False
```
**Thread Types:**
- ISO Metric Coarse/Fine
- UNC/UNF (Unified National)
- NPT/NPTF (National Pipe Thread)
- BSW/BSF (British Standard)
- UTS (Unified Thread Standard)
### Groove
Creates a cut by revolving a sketch around an axis (subtractive revolution).
```python
groove = body.newObject('PartDesign::Groove', 'Groove')
groove.Profile = sketch
groove.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
groove.Angle = 360.0
groove.Midplane = False
groove.Reversed = False
```
### Subtractive Loft
Cuts by transitioning between profiles.
```python
subloft = body.newObject('PartDesign::SubtractiveLoft', 'SubtractiveLoft')
subloft.Profile = sketch1
subloft.Sections = [sketch2]
```
### Subtractive Pipe
Cuts by sweeping a profile along a path.
```python
subpipe = body.newObject('PartDesign::SubtractivePipe', 'SubtractivePipe')
subpipe.Profile = profile_sketch
subpipe.Spine = path_sketch
```
### Subtractive Helix
Cuts by sweeping along a helix (e.g., for threads).
```python
subhelix = body.newObject('PartDesign::SubtractiveHelix', 'SubtractiveHelix')
subhelix.Profile = thread_profile_sketch
subhelix.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
subhelix.Pitch = 1.0
subhelix.Height = 10.0
```
### Subtractive Primitives
Same primitives as additive, but subtract material:
- `PartDesign::SubtractiveBox`
- `PartDesign::SubtractiveCylinder`
- `PartDesign::SubtractiveSphere`
- `PartDesign::SubtractiveCone`
- `PartDesign::SubtractiveEllipsoid`
- `PartDesign::SubtractiveTorus`
- `PartDesign::SubtractivePrism`
- `PartDesign::SubtractiveWedge`
---
## Transformation Features (Patterns)
### Mirrored
Creates a mirror copy of features across a plane.
```python
mirrored = body.newObject('PartDesign::Mirrored', 'Mirrored')
mirrored.Originals = [pad, pocket]
mirrored.MirrorPlane = (body.getObject('Origin').getObject('XZ_Plane'), [''])
```
### Linear Pattern
Creates copies in a linear arrangement.
```python
linear = body.newObject('PartDesign::LinearPattern', 'LinearPattern')
linear.Originals = [pocket]
linear.Direction = (body.getObject('Origin').getObject('X_Axis'), [''])
linear.Length = 100.0
linear.Occurrences = 5
linear.Mode = 0 # 0=OverallLength, 1=Offset
```
### Polar Pattern
Creates copies in a circular arrangement.
```python
polar = body.newObject('PartDesign::PolarPattern', 'PolarPattern')
polar.Originals = [pocket]
polar.Axis = (body.getObject('Origin').getObject('Z_Axis'), [''])
polar.Angle = 360.0
polar.Occurrences = 6
polar.Mode = 0 # 0=OverallAngle, 1=Offset
```
### MultiTransform
Combines multiple transformations (mirrored, linear, polar, scaled).
```python
multi = body.newObject('PartDesign::MultiTransform', 'MultiTransform')
multi.Originals = [pocket]
# Add transformations (created within MultiTransform)
# Typically done via GUI or by setting Transformations property
multi.Transformations = [mirrored_transform, linear_transform]
```
### Scaled
Scales features (only available within MultiTransform).
```python
# Only accessible as part of MultiTransform
scaled = body.newObject('PartDesign::Scaled', 'Scaled')
scaled.Factor = 0.5
scaled.Occurrences = 3
```
---
## Dress-Up Features (Edge/Face Treatment)
### Fillet
Rounds edges with a specified radius.
```python
fillet = body.newObject('PartDesign::Fillet', 'Fillet')
fillet.Base = (pad, ['Edge1', 'Edge5', 'Edge9'])
fillet.Radius = 2.0
```
### Chamfer
Bevels edges.
```python
chamfer = body.newObject('PartDesign::Chamfer', 'Chamfer')
chamfer.Base = (pad, ['Edge2', 'Edge6'])
chamfer.ChamferType = 'Equal Distance' # or 'Two Distances' or 'Distance and Angle'
chamfer.Size = 1.5
chamfer.Size2 = 2.0 # For asymmetric
chamfer.Angle = 45.0 # For 'Distance and Angle'
```
### Draft
Applies angular draft to faces (for mold release).
```python
draft = body.newObject('PartDesign::Draft', 'Draft')
draft.Base = (pad, ['Face2', 'Face4'])
draft.Angle = 3.0 # Degrees
draft.NeutralPlane = (body.getObject('Origin').getObject('XY_Plane'), [''])
draft.PullDirection = App.Vector(0, 0, 1)
draft.Reversed = False
```
### Thickness
Creates a shell by hollowing out a solid, keeping selected faces open.
```python
thickness = body.newObject('PartDesign::Thickness', 'Thickness')
thickness.Base = (pad, ['Face6']) # Faces to remove (open)
thickness.Value = 2.0 # Wall thickness
thickness.Mode = 0 # 0=Skin, 1=Pipe, 2=RectoVerso
thickness.Join = 0 # 0=Arc, 1=Intersection
thickness.Reversed = False
```
---
## Boolean Operations
### Boolean
Imports bodies and applies boolean operations.
```python
boolean = body.newObject('PartDesign::Boolean', 'Boolean')
boolean.Type = 0 # 0=Fuse, 1=Cut, 2=Common (intersection)
boolean.Bodies = [other_body1, other_body2]
```
---
## Context Menu Commands
| Command | Description |
|---------|-------------|
| **Set Tip** | Sets selected feature as the body's current state (tip) |
| **Move object to other body** | Transfers feature to a different body |
| **Move object after other object** | Reorders features in the tree |
| **Appearance** | Sets color and transparency |
| **Color per face** | Assigns different colors to individual faces |
```python
# Set tip programmatically
body.Tip = pocket
# Move feature order
doc.moveObject(feature, body, after_feature)
```
---
## Additional Tools
### Sprocket
Creates a sprocket profile for chain drives.
```python
# Available via Gui.runCommand('PartDesign_Sprocket')
```
### Involute Gear
Creates an involute gear profile.
```python
# Available via Gui.runCommand('PartDesign_InvoluteGear')
```
---
## Common Properties (All Features)
| Property | Type | Description |
|----------|------|-------------|
| `Label` | String | User-visible name |
| `Placement` | Placement | Position and orientation |
| `BaseFeature` | Link | Feature this builds upon |
| `Shape` | Shape | Resulting geometry |
---
## Expression Binding
All dimensional properties can be driven by expressions:
```python
pad.setExpression('Length', 'Spreadsheet.plate_height')
fillet.setExpression('Radius', 'Spreadsheet.fillet_r * 0.5')
hole.setExpression('Diameter', '<<Parameters>>.hole_dia')
```
---
## Best Practices
1. **Always work within a Body** — PartDesign features require a body container
2. **Use fully constrained sketches** — Prevents unexpected behavior when parameters change
3. **Leverage datum geometry** — Creates stable references that survive TNP issues
4. **Name constraints** — Enables expression-based parametric design
5. **Use spreadsheets** — Centralizes parameters for easy modification
6. **Set meaningful Labels** — Internal Names are auto-generated; Labels are user-friendly
7. **Check isSolid()** — Before subtractive operations, verify the body has solid geometry
```python
if not body.isSolid():
raise ValueError("Body must contain solid geometry for subtractive features")
```
---
## FreeCAD 1.0 Changes
| Change | Description |
|--------|-------------|
| **TNP Mitigation** | Topological naming more stable |
| **UpToShape** | New Pad/Pocket type extending to arbitrary shapes |
| **Draft Angle** | Taper angles on Pad/Pocket |
| **Improved Hole** | More thread types, better UI |
| **Assembly Integration** | Native assembly workbench |
| **Arch → BIM** | Workbench rename |
| **Path → CAM** | Workbench rename |
---
## Python Module Access
```python
import FreeCAD as App
import FreeCADGui as Gui
import Part
import Sketcher
import PartDesign
import PartDesignGui
# Access feature classes
print(dir(PartDesign))
# ['Additive', 'AdditiveBox', 'AdditiveCone', 'AdditiveCylinder', ...]
```
---
*Document version: FreeCAD 1.0.2 / January 2026*
*Reference: FreeCAD Wiki, GitHub FreeCAD-documentation, FreeCAD Forum*

View File

@@ -0,0 +1,12 @@
# ztools Addon Initialization
# This file runs at FreeCAD startup (before GUI)
import FreeCAD as App
# The Catppuccin Mocha theme is now provided as a Preference Pack.
# It will be automatically available in:
# Edit > Preferences > General > Preference packs > CatppuccinMocha
#
# No manual installation is required - FreeCAD's addon system handles it.
App.Console.PrintLog("ztools addon loaded\n")

View File

@@ -0,0 +1,367 @@
# ztools Workbench for FreeCAD 1.0+
# Extended PartDesign replacement with velocity-focused tools
import FreeCAD as App
import FreeCADGui as Gui
class ZToolsWorkbench(Gui.Workbench):
"""Extended PartDesign workbench with velocity-focused tools."""
MenuText = "ztools"
ToolTip = "Extended PartDesign replacement for faster CAD workflows"
# Catppuccin Mocha themed icon
Icon = """
/* XPM */
static char * ztools_xpm[] = {
"16 16 5 1",
" c None",
". c #313244",
"+ c #cba6f7",
"@ c #94e2d5",
"# c #45475a",
" ",
" ############ ",
" #..........# ",
" #.++++++++.# ",
" #.+......+.# ",
" #.....+++..# ",
" #....++....# ",
" #...++.....# ",
" #..++......# ",
" #.++.......# ",
" #.++++++++@# ",
" #..........# ",
" ############ ",
" ",
" ",
" "};
"""
def Initialize(self):
"""Called on workbench first activation."""
# Load PartDesign and Sketcher workbenches to register their commands
# We need to actually activate them briefly to ensure commands are registered
# Activate dependent workbenches so their commands are registered.
# Use activateWorkbench() instead of calling Initialize() directly,
# since direct calls skip the C++ __Workbench__ injection step.
# Wrap each individually so one failure doesn't block the rest.
wb_list = Gui.listWorkbenches()
current_wb = Gui.activeWorkbench()
for wb_name in (
"PartDesignWorkbench",
"SketcherWorkbench",
"AssemblyWorkbench",
"SpreadsheetWorkbench",
):
if wb_name in wb_list:
try:
Gui.activateWorkbench(wb_name)
except Exception as e:
App.Console.PrintWarning(f"Could not initialize {wb_name}: {e}\n")
# Restore ztools as the active workbench
try:
Gui.activateWorkbench("ZToolsWorkbench")
except Exception:
pass
# Command imports moved to module scope (after Gui.addWorkbench) so they
# are available before Initialize() runs. See end of file.
# =====================================================================
# PartDesign Structure Tools
# =====================================================================
self.structure_tools = [
"PartDesign_Body",
"PartDesign_NewSketch",
]
# =====================================================================
# PartDesign Reference Geometry (Datums)
# =====================================================================
self.partdesign_datum_tools = [
"PartDesign_Plane",
"PartDesign_Line",
"PartDesign_Point",
"PartDesign_CoordinateSystem",
"PartDesign_ShapeBinder",
"PartDesign_SubShapeBinder",
"PartDesign_Clone",
]
# =====================================================================
# PartDesign Additive Features
# =====================================================================
self.additive_tools = [
"PartDesign_Pad",
"PartDesign_Revolution",
"PartDesign_AdditiveLoft",
"PartDesign_AdditivePipe",
"PartDesign_AdditiveHelix",
]
# =====================================================================
# PartDesign Additive Primitives (compound command with dropdown)
# =====================================================================
self.additive_primitives = [
"PartDesign_CompPrimitiveAdditive",
]
# =====================================================================
# PartDesign Subtractive Features
# =====================================================================
self.subtractive_tools = [
"PartDesign_Pocket",
"PartDesign_Hole",
"PartDesign_Groove",
"PartDesign_SubtractiveLoft",
"PartDesign_SubtractivePipe",
"PartDesign_SubtractiveHelix",
]
# =====================================================================
# PartDesign Subtractive Primitives (compound command with dropdown)
# =====================================================================
self.subtractive_primitives = [
"PartDesign_CompPrimitiveSubtractive",
]
# =====================================================================
# PartDesign Transformation Features (Patterns)
# =====================================================================
self.transformation_tools = [
"PartDesign_Mirrored",
"PartDesign_LinearPattern",
"PartDesign_PolarPattern",
"PartDesign_MultiTransform",
]
# =====================================================================
# PartDesign Dress-Up Features
# =====================================================================
self.dressup_tools = [
"PartDesign_Fillet",
"PartDesign_Chamfer",
"PartDesign_Draft",
"PartDesign_Thickness",
]
# =====================================================================
# PartDesign Boolean Operations
# =====================================================================
self.boolean_tools = [
"PartDesign_Boolean",
]
# =====================================================================
# Sketcher Tools (commonly used with PartDesign)
# =====================================================================
self.sketcher_tools = [
"Sketcher_NewSketch",
"Sketcher_EditSketch",
"Sketcher_MapSketch",
"Sketcher_ValidateSketch",
]
# =====================================================================
# ZTools Custom Tools
# =====================================================================
self.ztools_datum_tools = [
"ZTools_DatumCreator",
"ZTools_DatumManager",
]
self.ztools_pattern_tools = [
"ZTools_RotatedLinearPattern",
]
self.ztools_pocket_tools = [
"ZTools_EnhancedPocket",
]
# =====================================================================
# Assembly Workbench Tools (FreeCAD 1.0+)
# =====================================================================
self.assembly_structure_tools = [
"Assembly_CreateAssembly",
"Assembly_InsertLink",
"Assembly_InsertNewPart",
]
self.assembly_joint_tools = [
"Assembly_CreateJointFixed",
"Assembly_CreateJointRevolute",
"Assembly_CreateJointCylindrical",
"Assembly_CreateJointSlider",
"Assembly_CreateJointBall",
"Assembly_CreateJointDistance",
"Assembly_CreateJointParallel",
"Assembly_CreateJointPerpendicular",
"Assembly_CreateJointAngle",
"Assembly_CreateJointRackPinion",
"Assembly_CreateJointScrew",
"Assembly_CreateJointGears",
"Assembly_CreateJointBelt",
]
self.assembly_management_tools = [
"Assembly_ToggleGrounded",
"Assembly_SolveAssembly",
"Assembly_CreateView",
"Assembly_CreateBom",
"Assembly_ExportASMT",
]
# =====================================================================
# ZTools Assembly Pattern Tools
# =====================================================================
self.ztools_assembly_tools = [
"ZTools_AssemblyLinearPattern",
"ZTools_AssemblyPolarPattern",
]
# =====================================================================
# Spreadsheet Workbench Tools
# =====================================================================
self.spreadsheet_tools = [
"Spreadsheet_CreateSheet",
"Spreadsheet_Import",
"Spreadsheet_Export",
"Spreadsheet_SetAlias",
"Spreadsheet_MergeCells",
"Spreadsheet_SplitCell",
]
# =====================================================================
# ZTools Spreadsheet Formatting Tools
# =====================================================================
self.ztools_spreadsheet_tools = [
"ZTools_SpreadsheetStyleBold",
"ZTools_SpreadsheetStyleItalic",
"ZTools_SpreadsheetStyleUnderline",
"ZTools_SpreadsheetAlignLeft",
"ZTools_SpreadsheetAlignCenter",
"ZTools_SpreadsheetAlignRight",
"ZTools_SpreadsheetBgColor",
"ZTools_SpreadsheetTextColor",
"ZTools_SpreadsheetQuickAlias",
]
# =====================================================================
# Append Toolbars
# =====================================================================
self.appendToolbar("Structure", self.structure_tools)
self.appendToolbar("Sketcher", self.sketcher_tools)
self.appendToolbar("Datums", self.partdesign_datum_tools)
self.appendToolbar("Additive", self.additive_tools + self.additive_primitives)
self.appendToolbar(
"Subtractive", self.subtractive_tools + self.subtractive_primitives
)
self.appendToolbar("Transformations", self.transformation_tools)
self.appendToolbar("Dress-Up", self.dressup_tools)
self.appendToolbar("Boolean", self.boolean_tools)
self.appendToolbar("Assembly", self.assembly_structure_tools)
self.appendToolbar("Assembly Joints", self.assembly_joint_tools)
self.appendToolbar("Assembly Management", self.assembly_management_tools)
self.appendToolbar("ztools Datums", self.ztools_datum_tools)
self.appendToolbar("ztools Patterns", self.ztools_pattern_tools)
self.appendToolbar("ztools Features", self.ztools_pocket_tools)
self.appendToolbar("ztools Assembly", self.ztools_assembly_tools)
self.appendToolbar("Spreadsheet", self.spreadsheet_tools)
self.appendToolbar("ztools Spreadsheet", self.ztools_spreadsheet_tools)
# =====================================================================
# Append Menus
# =====================================================================
self.appendMenu("Structure", self.structure_tools)
self.appendMenu("Sketch", self.sketcher_tools)
self.appendMenu(["PartDesign", "Datums"], self.partdesign_datum_tools)
self.appendMenu(
["PartDesign", "Additive"], self.additive_tools + self.additive_primitives
)
self.appendMenu(
["PartDesign", "Subtractive"],
self.subtractive_tools + self.subtractive_primitives,
)
self.appendMenu(["PartDesign", "Transformations"], self.transformation_tools)
self.appendMenu(["PartDesign", "Dress-Up"], self.dressup_tools)
self.appendMenu(["PartDesign", "Boolean"], self.boolean_tools)
self.appendMenu(["Assembly", "Structure"], self.assembly_structure_tools)
self.appendMenu(["Assembly", "Joints"], self.assembly_joint_tools)
self.appendMenu(["Assembly", "Management"], self.assembly_management_tools)
self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools)
self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools)
self.appendMenu(
"ztools",
self.ztools_datum_tools
+ self.ztools_pattern_tools
+ self.ztools_pocket_tools
+ self.ztools_assembly_tools
+ self.ztools_spreadsheet_tools,
)
App.Console.PrintMessage("ztools workbench initialized\n")
def Activated(self):
"""Called when workbench is activated."""
App.Console.PrintMessage("ztools workbench activated\n")
def Deactivated(self):
"""Called when workbench is deactivated."""
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(ZToolsWorkbench())
# ---------------------------------------------------------------------------
# Eager command registration
# ---------------------------------------------------------------------------
# Import command modules at module scope so Gui.addCommand() calls run before
# any workbench activates. This ensures the PartDesign manipulator can
# reference them regardless of workbench activation order (#52).
from ztools.commands import (
assembly_pattern_commands,
datum_commands,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
# ---------------------------------------------------------------------------
# WorkbenchManipulator: inject ZTools commands into PartDesign workbench
# ---------------------------------------------------------------------------
class _ZToolsPartDesignManipulator:
"""Adds ZTools commands to PartDesign toolbars and menus."""
def modifyToolBars(self):
return [
{"append": "ZTools_DatumCreator", "toolBar": "Part Design Helper Features"},
{"append": "ZTools_DatumManager", "toolBar": "Part Design Helper Features"},
{
"append": "ZTools_EnhancedPocket",
"toolBar": "Part Design Modeling Features",
},
{
"append": "ZTools_RotatedLinearPattern",
"toolBar": "Part Design Transformation Features",
},
]
def modifyMenuBar(self):
return [
{"append": "ZTools_DatumCreator", "menuItem": "PartDesign_Body"},
{"append": "ZTools_DatumManager", "menuItem": "PartDesign_Body"},
{"append": "ZTools_EnhancedPocket", "menuItem": "PartDesign_Body"},
{"append": "ZTools_RotatedLinearPattern", "menuItem": "PartDesign_Body"},
]
Gui.addWorkbenchManipulator(_ZToolsPartDesignManipulator())

View File

@@ -0,0 +1,123 @@
# ztools - Extended PartDesign for FreeCAD
Velocity-focused CAD tools extending FreeCAD 1.0+ PartDesign workbench.
## Installation
### Manual Installation
1. Copy the `ztools` folder to your FreeCAD Mod directory:
- **Linux**: `~/.local/share/FreeCAD/Mod/ztools/`
- **Windows**: `%APPDATA%\FreeCAD\Mod\ztools\`
- **macOS**: `~/Library/Application Support/FreeCAD/Mod/ztools/`
2. Restart FreeCAD
3. Select **ztools** from the workbench dropdown
### Directory Structure
```
ztools/
├── InitGui.py # Workbench registration
├── ztools/
│ ├── __init__.py
│ ├── datums/
│ │ ├── __init__.py
│ │ └── core.py # Datum creation functions
│ └── commands/
│ ├── __init__.py
│ └── datum_commands.py # GUI commands
├── setup.cfg
└── README.md
```
## Module 1: Datum Tools
### Datum Creator (GUI)
Unified task panel for creating:
**Planes**
- Offset from Face
- Midplane (2 parallel faces)
- 3 Points
- Normal to Edge (at parameter)
- Angled from Face (about edge)
- Tangent to Cylinder
**Axes**
- 2 Points
- From Edge
- Cylinder Center
- Plane Intersection
**Points**
- At Vertex
- XYZ Coordinates
- On Edge (at parameter)
- Face Center
- Circle Center
### Options
- **Link to Spreadsheet**: Creates aliases in Spreadsheet for parametric control
- **Add to Active Body**: Creates PartDesign datums vs Part geometry
- **Custom Name**: Override auto-naming (e.g., `ZPlane_Offset_001`)
### Python API
```python
from ztools.datums import (
plane_offset_from_face,
plane_midplane,
axis_cylinder_center,
point_at_coordinates,
)
doc = App.ActiveDocument
body = doc.getObject('Body')
# Offset plane from selected face
face = body.Shape.Faces[0]
plane = plane_offset_from_face(face, 15.0, body=body, link_spreadsheet=True)
# Midplane between two faces
mid = plane_midplane(face1, face2, name="MidPlane_Custom")
# Axis at cylinder center
cyl_face = body.Shape.Faces[2]
axis = axis_cylinder_center(cyl_face, body=body)
# Point at coordinates with spreadsheet link
pt = point_at_coordinates(50, 25, 0, link_spreadsheet=True)
```
### Metadata
All ztools datums store creation metadata in custom properties:
- `ZTools_Type`: Creation method (e.g., "offset_from_face")
- `ZTools_Params`: JSON-encoded parameters
Access via:
```python
plane = doc.getObject('ZPlane_Offset_001')
print(plane.ZTools_Type) # "offset_from_face"
print(plane.ZTools_Params) # {"distance": 15.0, ...}
```
## Roadmap
- [ ] **Module 2**: Enhanced Pad/Pocket (multi-body, draft angles, lip/groove)
- [ ] **Module 3**: Body operations (split, combine, shell improvements)
- [ ] **Module 4**: Pattern tools (curve-driven, fill patterns)
- [ ] **Datum Manager**: Panel to list/toggle/rename all datums
## License
LGPL-2.1 (same as FreeCAD)
## Contributing
Kindred Systems LLC - Kansas City

View File

@@ -0,0 +1,11 @@
[metadata]
name = ztools
version = 0.1.0
description = Extended PartDesign workbench for FreeCAD with velocity-focused tools
author = Kindred Systems LLC
license = LGPL-2.1
url = https://github.com/kindredsystems/ztools
[options]
packages = find:
python_requires = >=3.8

View File

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

View File

@@ -0,0 +1,18 @@
# ztools/commands - GUI commands
from . import (
assembly_pattern_commands,
datum_commands,
datum_viewprovider,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
__all__ = [
"datum_commands",
"datum_viewprovider",
"pattern_commands",
"pocket_commands",
"assembly_pattern_commands",
"spreadsheet_commands",
]

View File

@@ -0,0 +1,787 @@
# ztools/commands/assembly_pattern_commands.py
# Assembly patterning tools for FreeCAD 1.0+ Assembly workbench
# Creates linear and polar patterns of assembly components
import math
import FreeCAD as App
import FreeCADGui as Gui
from PySide import QtCore, QtGui
from ztools.resources.icons import get_icon
# =============================================================================
# Assembly Linear Pattern
# =============================================================================
class AssemblyLinearPatternTaskPanel:
"""Task panel for creating linear patterns of assembly components."""
def __init__(self):
self.form = QtGui.QWidget()
self.setup_ui()
self.setup_selection_observer()
self.selected_components = []
def setup_ui(self):
layout = QtGui.QVBoxLayout(self.form)
# Component selection section
component_group = QtGui.QGroupBox("Components")
component_layout = QtGui.QVBoxLayout(component_group)
# Selection table
self.component_table = QtGui.QTableWidget()
self.component_table.setColumnCount(2)
self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
self.component_table.horizontalHeader().setStretchLastSection(False)
self.component_table.horizontalHeader().setSectionResizeMode(
0, QtGui.QHeaderView.Stretch
)
self.component_table.horizontalHeader().setSectionResizeMode(
1, QtGui.QHeaderView.Fixed
)
self.component_table.setColumnWidth(1, 60)
self.component_table.setMaximumHeight(120)
component_layout.addWidget(self.component_table)
# Selection hint
hint_label = QtGui.QLabel("Select assembly components in the 3D view")
hint_label.setStyleSheet("color: gray; font-style: italic;")
component_layout.addWidget(hint_label)
layout.addWidget(component_group)
# Pattern parameters section
params_group = QtGui.QGroupBox("Pattern Parameters")
params_layout = QtGui.QFormLayout(params_group)
# Direction
direction_layout = QtGui.QHBoxLayout()
self.dir_x_spin = QtGui.QDoubleSpinBox()
self.dir_x_spin.setRange(-1000, 1000)
self.dir_x_spin.setValue(1)
self.dir_x_spin.setDecimals(3)
self.dir_y_spin = QtGui.QDoubleSpinBox()
self.dir_y_spin.setRange(-1000, 1000)
self.dir_y_spin.setValue(0)
self.dir_y_spin.setDecimals(3)
self.dir_z_spin = QtGui.QDoubleSpinBox()
self.dir_z_spin.setRange(-1000, 1000)
self.dir_z_spin.setValue(0)
self.dir_z_spin.setDecimals(3)
direction_layout.addWidget(QtGui.QLabel("X:"))
direction_layout.addWidget(self.dir_x_spin)
direction_layout.addWidget(QtGui.QLabel("Y:"))
direction_layout.addWidget(self.dir_y_spin)
direction_layout.addWidget(QtGui.QLabel("Z:"))
direction_layout.addWidget(self.dir_z_spin)
params_layout.addRow("Direction:", direction_layout)
# Occurrences
self.occurrences_spin = QtGui.QSpinBox()
self.occurrences_spin.setRange(2, 100)
self.occurrences_spin.setValue(3)
params_layout.addRow("Occurrences:", self.occurrences_spin)
# Spacing mode
self.spacing_mode = QtGui.QComboBox()
self.spacing_mode.addItems(["Total Length", "Spacing"])
self.spacing_mode.currentIndexChanged.connect(self.on_spacing_mode_changed)
params_layout.addRow("Mode:", self.spacing_mode)
# Length/Spacing value
self.length_spin = QtGui.QDoubleSpinBox()
self.length_spin.setRange(0.001, 100000)
self.length_spin.setValue(100)
self.length_spin.setDecimals(3)
self.length_spin.setSuffix(" mm")
self.length_label = QtGui.QLabel("Total Length:")
params_layout.addRow(self.length_label, self.length_spin)
layout.addWidget(params_group)
# Options section
options_group = QtGui.QGroupBox("Options")
options_layout = QtGui.QVBoxLayout(options_group)
self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
self.link_checkbox.setChecked(True)
self.link_checkbox.setToolTip(
"Links reference the original component, reducing file size"
)
options_layout.addWidget(self.link_checkbox)
self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
self.hide_original_checkbox.setChecked(False)
options_layout.addWidget(self.hide_original_checkbox)
layout.addWidget(options_group)
layout.addStretch()
def setup_selection_observer(self):
"""Set up observer to track selection changes."""
class SelectionObserver:
def __init__(self, panel):
self.panel = panel
def addSelection(self, doc, obj, sub, pos):
self.panel.on_selection_added(doc, obj, sub)
def removeSelection(self, doc, obj, sub):
self.panel.on_selection_removed(doc, obj, sub)
def clearSelection(self, doc):
pass
self.observer = SelectionObserver(self)
Gui.Selection.addObserver(self.observer)
def on_selection_added(self, doc_name, obj_name, sub):
"""Handle new selection."""
doc = App.getDocument(doc_name)
if not doc:
return
obj = doc.getObject(obj_name)
if not obj:
return
# Check if this is an assembly component (App::Link or Part)
if not self._is_valid_component(obj):
return
# Avoid duplicates
if obj in self.selected_components:
return
self.selected_components.append(obj)
self._update_table()
def on_selection_removed(self, doc_name, obj_name, sub):
"""Handle selection removal."""
doc = App.getDocument(doc_name)
if not doc:
return
obj = doc.getObject(obj_name)
if obj in self.selected_components:
self.selected_components.remove(obj)
self._update_table()
def _is_valid_component(self, obj):
"""Check if object is a valid assembly component."""
valid_types = [
"App::Link",
"App::LinkGroup",
"Part::Feature",
"PartDesign::Body",
"App::Part",
]
return obj.TypeId in valid_types
def _update_table(self):
"""Update the component table."""
self.component_table.setRowCount(len(self.selected_components))
for i, comp in enumerate(self.selected_components):
# Component name
name_item = QtGui.QTableWidgetItem(comp.Label)
name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
self.component_table.setItem(i, 0, name_item)
# Remove button
remove_btn = QtGui.QPushButton("X")
remove_btn.setMaximumWidth(40)
remove_btn.clicked.connect(
lambda checked, idx=i: self._remove_component(idx)
)
self.component_table.setCellWidget(i, 1, remove_btn)
def _remove_component(self, index):
"""Remove component from selection."""
if 0 <= index < len(self.selected_components):
self.selected_components.pop(index)
self._update_table()
def on_spacing_mode_changed(self, index):
"""Update label based on spacing mode."""
if index == 0:
self.length_label.setText("Total Length:")
else:
self.length_label.setText("Spacing:")
def accept(self):
"""Create the linear pattern."""
Gui.Selection.removeObserver(self.observer)
if not self.selected_components:
App.Console.PrintError("No components selected for pattern\n")
return False
doc = App.ActiveDocument
if not doc:
return False
# Get parameters
direction = App.Vector(
self.dir_x_spin.value(),
self.dir_y_spin.value(),
self.dir_z_spin.value(),
)
if direction.Length < 1e-6:
App.Console.PrintError("Direction vector cannot be zero\n")
return False
direction.normalize()
occurrences = self.occurrences_spin.value()
length_value = self.length_spin.value()
if self.spacing_mode.currentIndex() == 0:
# Total length mode
spacing = length_value / (occurrences - 1) if occurrences > 1 else 0
else:
# Spacing mode
spacing = length_value
use_links = self.link_checkbox.isChecked()
hide_original = self.hide_original_checkbox.isChecked()
# Find parent assembly
assembly = self._find_parent_assembly(self.selected_components[0])
doc.openTransaction("Assembly Linear Pattern")
try:
created_objects = []
for comp in self.selected_components:
# Get base placement
if hasattr(comp, "Placement"):
base_placement = comp.Placement
else:
base_placement = App.Placement()
# Create pattern instances (skip i=0 as that's the original)
for i in range(1, occurrences):
offset = direction * spacing * i
new_placement = App.Placement(
base_placement.Base + offset,
base_placement.Rotation,
)
if use_links:
# Create link to original
if comp.TypeId == "App::Link":
# Link to the linked object
link_target = comp.LinkedObject
else:
link_target = comp
new_obj = doc.addObject("App::Link", f"{comp.Label}_Pattern{i}")
new_obj.LinkedObject = link_target
new_obj.Placement = new_placement
else:
# Create copy
new_obj = doc.copyObject(comp, False)
new_obj.Label = f"{comp.Label}_Pattern{i}"
new_obj.Placement = new_placement
# Add to assembly if found
if assembly and hasattr(assembly, "addObject"):
assembly.addObject(new_obj)
created_objects.append(new_obj)
# Hide original if requested
if hide_original and hasattr(comp, "ViewObject"):
comp.ViewObject.Visibility = False
doc.commitTransaction()
doc.recompute()
App.Console.PrintMessage(
f"Created {len(created_objects)} pattern instances\n"
)
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to create pattern: {e}\n")
return False
return True
def reject(self):
"""Cancel the operation."""
Gui.Selection.removeObserver(self.observer)
return True
def _find_parent_assembly(self, obj):
"""Find the parent assembly of an object."""
doc = obj.Document
for candidate in doc.Objects:
if candidate.TypeId == "Assembly::AssemblyObject":
# Check if obj is in this assembly's group
if hasattr(candidate, "Group"):
if obj in candidate.Group:
return candidate
# Check nested
for member in candidate.Group:
if hasattr(member, "Group") and obj in member.Group:
return candidate
return None
def getStandardButtons(self):
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
class AssemblyLinearPatternCommand:
"""Command to create a linear pattern of assembly components."""
def GetResources(self):
return {
"Pixmap": get_icon("assembly_linear_pattern"),
"MenuText": "Assembly Linear Pattern",
"ToolTip": "Create a linear pattern of assembly components",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
panel = AssemblyLinearPatternTaskPanel()
Gui.Control.showDialog(panel)
# =============================================================================
# Assembly Polar Pattern
# =============================================================================
class AssemblyPolarPatternTaskPanel:
"""Task panel for creating polar patterns of assembly components."""
def __init__(self):
self.form = QtGui.QWidget()
self.setup_ui()
self.setup_selection_observer()
self.selected_components = []
self.axis_selection = None
def setup_ui(self):
layout = QtGui.QVBoxLayout(self.form)
# Component selection section
component_group = QtGui.QGroupBox("Components")
component_layout = QtGui.QVBoxLayout(component_group)
# Selection table
self.component_table = QtGui.QTableWidget()
self.component_table.setColumnCount(2)
self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
self.component_table.horizontalHeader().setStretchLastSection(False)
self.component_table.horizontalHeader().setSectionResizeMode(
0, QtGui.QHeaderView.Stretch
)
self.component_table.horizontalHeader().setSectionResizeMode(
1, QtGui.QHeaderView.Fixed
)
self.component_table.setColumnWidth(1, 60)
self.component_table.setMaximumHeight(100)
component_layout.addWidget(self.component_table)
hint_label = QtGui.QLabel("Select assembly components in the 3D view")
hint_label.setStyleSheet("color: gray; font-style: italic;")
component_layout.addWidget(hint_label)
layout.addWidget(component_group)
# Axis section
axis_group = QtGui.QGroupBox("Rotation Axis")
axis_layout = QtGui.QFormLayout(axis_group)
# Axis definition mode
self.axis_mode = QtGui.QComboBox()
self.axis_mode.addItems(["Custom Axis", "X Axis", "Y Axis", "Z Axis"])
self.axis_mode.currentIndexChanged.connect(self.on_axis_mode_changed)
axis_layout.addRow("Axis:", self.axis_mode)
# Custom axis direction
self.axis_widget = QtGui.QWidget()
axis_dir_layout = QtGui.QHBoxLayout(self.axis_widget)
axis_dir_layout.setContentsMargins(0, 0, 0, 0)
self.axis_x_spin = QtGui.QDoubleSpinBox()
self.axis_x_spin.setRange(-1, 1)
self.axis_x_spin.setValue(0)
self.axis_x_spin.setDecimals(3)
self.axis_x_spin.setSingleStep(0.1)
self.axis_y_spin = QtGui.QDoubleSpinBox()
self.axis_y_spin.setRange(-1, 1)
self.axis_y_spin.setValue(0)
self.axis_y_spin.setDecimals(3)
self.axis_y_spin.setSingleStep(0.1)
self.axis_z_spin = QtGui.QDoubleSpinBox()
self.axis_z_spin.setRange(-1, 1)
self.axis_z_spin.setValue(1)
self.axis_z_spin.setDecimals(3)
self.axis_z_spin.setSingleStep(0.1)
axis_dir_layout.addWidget(QtGui.QLabel("X:"))
axis_dir_layout.addWidget(self.axis_x_spin)
axis_dir_layout.addWidget(QtGui.QLabel("Y:"))
axis_dir_layout.addWidget(self.axis_y_spin)
axis_dir_layout.addWidget(QtGui.QLabel("Z:"))
axis_dir_layout.addWidget(self.axis_z_spin)
axis_layout.addRow("Direction:", self.axis_widget)
# Center point
center_layout = QtGui.QHBoxLayout()
self.center_x_spin = QtGui.QDoubleSpinBox()
self.center_x_spin.setRange(-100000, 100000)
self.center_x_spin.setValue(0)
self.center_x_spin.setDecimals(3)
self.center_y_spin = QtGui.QDoubleSpinBox()
self.center_y_spin.setRange(-100000, 100000)
self.center_y_spin.setValue(0)
self.center_y_spin.setDecimals(3)
self.center_z_spin = QtGui.QDoubleSpinBox()
self.center_z_spin.setRange(-100000, 100000)
self.center_z_spin.setValue(0)
self.center_z_spin.setDecimals(3)
center_layout.addWidget(QtGui.QLabel("X:"))
center_layout.addWidget(self.center_x_spin)
center_layout.addWidget(QtGui.QLabel("Y:"))
center_layout.addWidget(self.center_y_spin)
center_layout.addWidget(QtGui.QLabel("Z:"))
center_layout.addWidget(self.center_z_spin)
axis_layout.addRow("Center:", center_layout)
layout.addWidget(axis_group)
# Pattern parameters section
params_group = QtGui.QGroupBox("Pattern Parameters")
params_layout = QtGui.QFormLayout(params_group)
# Occurrences
self.occurrences_spin = QtGui.QSpinBox()
self.occurrences_spin.setRange(2, 100)
self.occurrences_spin.setValue(6)
params_layout.addRow("Occurrences:", self.occurrences_spin)
# Angle mode
self.angle_mode = QtGui.QComboBox()
self.angle_mode.addItems(["Full Circle (360)", "Custom Angle"])
self.angle_mode.currentIndexChanged.connect(self.on_angle_mode_changed)
params_layout.addRow("Mode:", self.angle_mode)
# Angle value
self.angle_spin = QtGui.QDoubleSpinBox()
self.angle_spin.setRange(0.001, 360)
self.angle_spin.setValue(360)
self.angle_spin.setDecimals(2)
self.angle_spin.setSuffix(" deg")
self.angle_spin.setEnabled(False)
params_layout.addRow("Total Angle:", self.angle_spin)
layout.addWidget(params_group)
# Options section
options_group = QtGui.QGroupBox("Options")
options_layout = QtGui.QVBoxLayout(options_group)
self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
self.link_checkbox.setChecked(True)
options_layout.addWidget(self.link_checkbox)
self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
self.hide_original_checkbox.setChecked(False)
options_layout.addWidget(self.hide_original_checkbox)
layout.addWidget(options_group)
layout.addStretch()
def setup_selection_observer(self):
"""Set up observer to track selection changes."""
class SelectionObserver:
def __init__(self, panel):
self.panel = panel
def addSelection(self, doc, obj, sub, pos):
self.panel.on_selection_added(doc, obj, sub)
def removeSelection(self, doc, obj, sub):
self.panel.on_selection_removed(doc, obj, sub)
def clearSelection(self, doc):
pass
self.observer = SelectionObserver(self)
Gui.Selection.addObserver(self.observer)
def on_selection_added(self, doc_name, obj_name, sub):
"""Handle new selection."""
doc = App.getDocument(doc_name)
if not doc:
return
obj = doc.getObject(obj_name)
if not obj:
return
# Check if this is an assembly component
if not self._is_valid_component(obj):
return
if obj in self.selected_components:
return
self.selected_components.append(obj)
self._update_table()
def on_selection_removed(self, doc_name, obj_name, sub):
"""Handle selection removal."""
doc = App.getDocument(doc_name)
if not doc:
return
obj = doc.getObject(obj_name)
if obj in self.selected_components:
self.selected_components.remove(obj)
self._update_table()
def _is_valid_component(self, obj):
"""Check if object is a valid assembly component."""
valid_types = [
"App::Link",
"App::LinkGroup",
"Part::Feature",
"PartDesign::Body",
"App::Part",
]
return obj.TypeId in valid_types
def _update_table(self):
"""Update the component table."""
self.component_table.setRowCount(len(self.selected_components))
for i, comp in enumerate(self.selected_components):
name_item = QtGui.QTableWidgetItem(comp.Label)
name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
self.component_table.setItem(i, 0, name_item)
remove_btn = QtGui.QPushButton("X")
remove_btn.setMaximumWidth(40)
remove_btn.clicked.connect(
lambda checked, idx=i: self._remove_component(idx)
)
self.component_table.setCellWidget(i, 1, remove_btn)
def _remove_component(self, index):
"""Remove component from selection."""
if 0 <= index < len(self.selected_components):
self.selected_components.pop(index)
self._update_table()
def on_axis_mode_changed(self, index):
"""Update axis inputs based on mode."""
if index == 0:
# Custom axis
self.axis_widget.setEnabled(True)
else:
# Preset axis
self.axis_widget.setEnabled(False)
if index == 1: # X
self.axis_x_spin.setValue(1)
self.axis_y_spin.setValue(0)
self.axis_z_spin.setValue(0)
elif index == 2: # Y
self.axis_x_spin.setValue(0)
self.axis_y_spin.setValue(1)
self.axis_z_spin.setValue(0)
elif index == 3: # Z
self.axis_x_spin.setValue(0)
self.axis_y_spin.setValue(0)
self.axis_z_spin.setValue(1)
def on_angle_mode_changed(self, index):
"""Update angle input based on mode."""
if index == 0:
# Full circle
self.angle_spin.setValue(360)
self.angle_spin.setEnabled(False)
else:
# Custom angle
self.angle_spin.setEnabled(True)
def accept(self):
"""Create the polar pattern."""
Gui.Selection.removeObserver(self.observer)
if not self.selected_components:
App.Console.PrintError("No components selected for pattern\n")
return False
doc = App.ActiveDocument
if not doc:
return False
# Get axis
axis = App.Vector(
self.axis_x_spin.value(),
self.axis_y_spin.value(),
self.axis_z_spin.value(),
)
if axis.Length < 1e-6:
App.Console.PrintError("Axis vector cannot be zero\n")
return False
axis.normalize()
# Get center
center = App.Vector(
self.center_x_spin.value(),
self.center_y_spin.value(),
self.center_z_spin.value(),
)
occurrences = self.occurrences_spin.value()
total_angle = self.angle_spin.value()
# Calculate angle increment
if self.angle_mode.currentIndex() == 0:
# Full circle - don't include last (would overlap first)
angle_step = 360.0 / occurrences
else:
# Custom angle
angle_step = total_angle / (occurrences - 1) if occurrences > 1 else 0
use_links = self.link_checkbox.isChecked()
hide_original = self.hide_original_checkbox.isChecked()
# Find parent assembly
assembly = self._find_parent_assembly(self.selected_components[0])
doc.openTransaction("Assembly Polar Pattern")
try:
created_objects = []
for comp in self.selected_components:
# Get base placement
if hasattr(comp, "Placement"):
base_placement = comp.Placement
else:
base_placement = App.Placement()
# Create pattern instances
for i in range(1, occurrences):
angle = angle_step * i
# Create rotation around axis through center
rotation = App.Rotation(axis, angle)
# Calculate new position
# Translate to origin, rotate, translate back
base_pos = base_placement.Base
relative_pos = base_pos - center
rotated_pos = rotation.multVec(relative_pos)
new_pos = rotated_pos + center
# Combine rotations
new_rotation = rotation.multiply(base_placement.Rotation)
new_placement = App.Placement(new_pos, new_rotation)
if use_links:
if comp.TypeId == "App::Link":
link_target = comp.LinkedObject
else:
link_target = comp
new_obj = doc.addObject("App::Link", f"{comp.Label}_Polar{i}")
new_obj.LinkedObject = link_target
new_obj.Placement = new_placement
else:
new_obj = doc.copyObject(comp, False)
new_obj.Label = f"{comp.Label}_Polar{i}"
new_obj.Placement = new_placement
if assembly and hasattr(assembly, "addObject"):
assembly.addObject(new_obj)
created_objects.append(new_obj)
if hide_original and hasattr(comp, "ViewObject"):
comp.ViewObject.Visibility = False
doc.commitTransaction()
doc.recompute()
App.Console.PrintMessage(
f"Created {len(created_objects)} polar pattern instances\n"
)
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to create pattern: {e}\n")
return False
return True
def reject(self):
"""Cancel the operation."""
Gui.Selection.removeObserver(self.observer)
return True
def _find_parent_assembly(self, obj):
"""Find the parent assembly of an object."""
doc = obj.Document
for candidate in doc.Objects:
if candidate.TypeId == "Assembly::AssemblyObject":
if hasattr(candidate, "Group"):
if obj in candidate.Group:
return candidate
for member in candidate.Group:
if hasattr(member, "Group") and obj in member.Group:
return candidate
return None
def getStandardButtons(self):
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
class AssemblyPolarPatternCommand:
"""Command to create a polar pattern of assembly components."""
def GetResources(self):
return {
"Pixmap": get_icon("assembly_polar_pattern"),
"MenuText": "Assembly Polar Pattern",
"ToolTip": "Create a polar (circular) pattern of assembly components",
}
def IsActive(self):
return App.ActiveDocument is not None
def Activated(self):
panel = AssemblyPolarPatternTaskPanel()
Gui.Control.showDialog(panel)
# =============================================================================
# Register Commands
# =============================================================================
Gui.addCommand("ZTools_AssemblyLinearPattern", AssemblyLinearPatternCommand())
Gui.addCommand("ZTools_AssemblyPolarPattern", AssemblyPolarPatternCommand())

View File

@@ -0,0 +1,880 @@
# ztools/commands/datum_commands.py
# GUI commands and task panel for datum creation
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtCore, QtGui
class SelectionItem:
"""Represents a selected geometry item."""
def __init__(self, obj, subname, shape=None):
self.obj = obj
self.subname = subname
self.shape = shape
self.geo_type = self._determine_type()
def _determine_type(self):
"""Determine the geometry type of this selection."""
if self.shape is None:
# Try to get shape from object
if hasattr(self.obj, "Shape"):
if self.subname and self.subname.startswith("Face"):
try:
self.shape = self.obj.Shape.getElement(self.subname)
except Exception:
pass
elif self.subname and self.subname.startswith("Edge"):
try:
self.shape = self.obj.Shape.getElement(self.subname)
except Exception:
pass
elif self.subname and self.subname.startswith("Vertex"):
try:
self.shape = self.obj.Shape.getElement(self.subname)
except Exception:
pass
if self.shape is None:
# Check if it's a datum plane object
type_id = getattr(self.obj, "TypeId", "")
if "Plane" in type_id or (
hasattr(self.obj, "Shape")
and self.obj.Shape.Faces
and self.obj.Shape.Faces[0].Surface.isPlanar()
):
return "plane"
return "unknown"
if isinstance(self.shape, Part.Face):
# Check if it's a cylindrical face
if isinstance(self.shape.Surface, Part.Cylinder):
return "cylinder"
elif self.shape.Surface.isPlanar():
return "face"
return "face"
elif isinstance(self.shape, Part.Edge):
# Check if it's a circular edge
if isinstance(self.shape.Curve, (Part.Circle, Part.ArcOfCircle)):
return "circle"
elif isinstance(self.shape.Curve, Part.Line):
return "edge"
return "edge"
elif isinstance(self.shape, Part.Vertex):
return "vertex"
return "unknown"
@property
def display_name(self):
"""Get display name for UI."""
if self.subname:
return f"{self.obj.Label}.{self.subname}"
return self.obj.Label
@property
def type_icon(self):
"""Get icon character for geometry type."""
icons = {
"face": "",
"plane": "",
"cylinder": "",
"edge": "",
"circle": "",
"vertex": "",
"unknown": "?",
}
return icons.get(self.geo_type, "?")
class DatumCreatorTaskPanel:
"""Unified task panel for creating datum planes, axes, and points.
Features a selection table where users can add/remove geometry items.
The datum type is automatically detected based on selection contents.
"""
# Mode definitions: (display_name, mode_id, required_types, datum_category)
# required_types is a tuple describing what selection is needed
MODES = [
# Planes
("Offset from Face", "offset_face", ("face",), "plane"),
("Offset from Plane", "offset_plane", ("plane",), "plane"),
("Midplane (2 Faces)", "midplane", ("face", "face"), "plane"),
("3 Points", "3_points", ("vertex", "vertex", "vertex"), "plane"),
("Normal to Edge", "normal_edge", ("edge",), "plane"),
("Angled from Face", "angled", ("face", "edge"), "plane"),
("Tangent to Cylinder", "tangent_cyl", ("cylinder",), "plane"),
# Axes
("Axis from 2 Points", "axis_2pt", ("vertex", "vertex"), "axis"),
("Axis from Edge", "axis_edge", ("edge",), "axis"),
("Axis at Cylinder Center", "axis_cyl", ("cylinder",), "axis"),
("Axis at Plane Intersection", "axis_intersect", ("plane", "plane"), "axis"),
# Points
("Point at Vertex", "point_vertex", ("vertex",), "point"),
("Point at XYZ", "point_xyz", (), "point"),
("Point on Edge", "point_edge", ("edge",), "point"),
("Point at Face Center", "point_face", ("face",), "point"),
("Point at Circle Center", "point_circle", ("circle",), "point"),
]
def __init__(self):
self.form = QtGui.QWidget()
self.form.setWindowTitle("ZTools Datum Creator")
self.selection_list = [] # List of SelectionItem
self.setup_ui()
self.setup_selection_observer()
self.update_mode_from_selection()
def setup_ui(self):
layout = QtGui.QVBoxLayout(self.form)
layout.setSpacing(8)
# Selection table section
sel_group = QtGui.QGroupBox("Selection")
sel_layout = QtGui.QVBoxLayout(sel_group)
# Selection table
self.sel_table = QtGui.QTableWidget()
self.sel_table.setColumnCount(3)
self.sel_table.setHorizontalHeaderLabels(["", "Element", ""])
header = self.sel_table.horizontalHeader()
header.setStretchLastSection(False)
header.setSectionResizeMode(0, QtGui.QHeaderView.Fixed)
header.setSectionResizeMode(1, QtGui.QHeaderView.Stretch)
header.setSectionResizeMode(2, QtGui.QHeaderView.Fixed)
header.resizeSection(0, 28)
header.resizeSection(2, 28)
self.sel_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self.sel_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
self.sel_table.setMaximumHeight(150)
self.sel_table.verticalHeader().setVisible(False)
sel_layout.addWidget(self.sel_table)
# Selection buttons
sel_btn_layout = QtGui.QHBoxLayout()
self.add_sel_btn = QtGui.QPushButton("Add Selected")
self.add_sel_btn.clicked.connect(self.add_current_selection)
self.remove_sel_btn = QtGui.QPushButton("Remove")
self.remove_sel_btn.clicked.connect(self.remove_selected_row)
self.clear_sel_btn = QtGui.QPushButton("Clear All")
self.clear_sel_btn.clicked.connect(self.clear_selection)
sel_btn_layout.addWidget(self.add_sel_btn)
sel_btn_layout.addWidget(self.remove_sel_btn)
sel_btn_layout.addWidget(self.clear_sel_btn)
sel_layout.addLayout(sel_btn_layout)
layout.addWidget(sel_group)
# Detected mode display
mode_group = QtGui.QGroupBox("Datum Type")
mode_layout = QtGui.QVBoxLayout(mode_group)
self.mode_label = QtGui.QLabel("Select geometry to auto-detect mode")
self.mode_label.setStyleSheet("font-weight: bold; color: #888;")
mode_layout.addWidget(self.mode_label)
# Manual mode override
override_layout = QtGui.QHBoxLayout()
override_layout.addWidget(QtGui.QLabel("Override:"))
self.mode_combo = QtGui.QComboBox()
self.mode_combo.addItem("(Auto-detect)", None)
for display_name, mode_id, _, category in self.MODES:
self.mode_combo.addItem(f"[{category[0].upper()}] {display_name}", mode_id)
self.mode_combo.currentIndexChanged.connect(self.on_mode_override_changed)
override_layout.addWidget(self.mode_combo)
mode_layout.addLayout(override_layout)
layout.addWidget(mode_group)
# Parameters section
self.params_group = QtGui.QGroupBox("Parameters")
self.params_layout = QtGui.QFormLayout(self.params_group)
# Offset spinner
self.offset_spin = QtGui.QDoubleSpinBox()
self.offset_spin.setRange(-10000, 10000)
self.offset_spin.setValue(10)
self.offset_spin.setSuffix(" mm")
# Angle spinner
self.angle_spin = QtGui.QDoubleSpinBox()
self.angle_spin.setRange(-360, 360)
self.angle_spin.setValue(45)
self.angle_spin.setSuffix(" °")
# Parameter spinner (0-1)
self.param_spin = QtGui.QDoubleSpinBox()
self.param_spin.setRange(0, 1)
self.param_spin.setValue(0.5)
self.param_spin.setSingleStep(0.1)
# XYZ coordinates
self.x_spin = QtGui.QDoubleSpinBox()
self.x_spin.setRange(-10000, 10000)
self.x_spin.setSuffix(" mm")
self.y_spin = QtGui.QDoubleSpinBox()
self.y_spin.setRange(-10000, 10000)
self.y_spin.setSuffix(" mm")
self.z_spin = QtGui.QDoubleSpinBox()
self.z_spin.setRange(-10000, 10000)
self.z_spin.setSuffix(" mm")
layout.addWidget(self.params_group)
# Options section
options_group = QtGui.QGroupBox("Options")
options_layout = QtGui.QVBoxLayout(options_group)
self.link_spreadsheet_cb = QtGui.QCheckBox("Link to Spreadsheet")
options_layout.addWidget(self.link_spreadsheet_cb)
self.use_body_cb = QtGui.QCheckBox("Add to Active Body")
self.use_body_cb.setChecked(True)
options_layout.addWidget(self.use_body_cb)
# Custom name
name_layout = QtGui.QHBoxLayout()
self.custom_name_cb = QtGui.QCheckBox("Custom Name:")
self.custom_name_edit = QtGui.QLineEdit()
self.custom_name_edit.setEnabled(False)
self.custom_name_cb.toggled.connect(self.custom_name_edit.setEnabled)
name_layout.addWidget(self.custom_name_cb)
name_layout.addWidget(self.custom_name_edit)
options_layout.addLayout(name_layout)
layout.addWidget(options_group)
# Initial state
self.update_params_ui(None)
def setup_selection_observer(self):
"""Setup selection observer to track user selections."""
class SelectionObserver:
def __init__(self, panel):
self.panel = panel
def addSelection(self, doc, obj, sub, pos):
self.panel.on_freecad_selection_changed()
def removeSelection(self, doc, obj, sub):
self.panel.on_freecad_selection_changed()
def clearSelection(self, doc):
self.panel.on_freecad_selection_changed()
self.observer = SelectionObserver(self)
Gui.Selection.addObserver(self.observer)
def on_freecad_selection_changed(self):
"""Called when FreeCAD selection changes - update add button state."""
sel = Gui.Selection.getSelectionEx()
has_sel = len(sel) > 0
self.add_sel_btn.setEnabled(has_sel)
def add_current_selection(self):
"""Add current FreeCAD selection to the selection table."""
sel = Gui.Selection.getSelectionEx()
for s in sel:
obj = s.Object
if s.SubElementNames:
for i, sub in enumerate(s.SubElementNames):
# Get the shape if available
shape = None
if i < len(s.SubObjects):
shape = s.SubObjects[i]
item = SelectionItem(obj, sub, shape)
self._add_selection_item(item)
else:
# Object selected without sub-element
item = SelectionItem(obj, "", None)
self._add_selection_item(item)
self.refresh_selection_table()
self.update_mode_from_selection()
def _add_selection_item(self, item):
"""Add item to selection list if not already present."""
# Check for duplicates
for existing in self.selection_list:
if existing.obj == item.obj and existing.subname == item.subname:
return # Already in list
self.selection_list.append(item)
def remove_selected_row(self):
"""Remove selected row from selection table."""
rows = self.sel_table.selectionModel().selectedRows()
if rows:
# Remove in reverse order to maintain indices
for row in sorted([r.row() for r in rows], reverse=True):
if row < len(self.selection_list):
del self.selection_list[row]
self.refresh_selection_table()
self.update_mode_from_selection()
def clear_selection(self):
"""Clear all items from selection table."""
self.selection_list.clear()
self.refresh_selection_table()
self.update_mode_from_selection()
def refresh_selection_table(self):
"""Refresh the selection table display."""
self.sel_table.setRowCount(len(self.selection_list))
for i, item in enumerate(self.selection_list):
# Type icon
type_item = QtGui.QTableWidgetItem(item.type_icon)
type_item.setTextAlignment(QtCore.Qt.AlignCenter)
type_item.setToolTip(item.geo_type)
self.sel_table.setItem(i, 0, type_item)
# Element name
name_item = QtGui.QTableWidgetItem(item.display_name)
self.sel_table.setItem(i, 1, name_item)
# Remove button
remove_btn = QtGui.QPushButton("")
remove_btn.setFixedSize(24, 24)
remove_btn.clicked.connect(lambda checked, row=i: self._remove_row(row))
self.sel_table.setCellWidget(i, 2, remove_btn)
def _remove_row(self, row):
"""Remove a specific row."""
if row < len(self.selection_list):
del self.selection_list[row]
self.refresh_selection_table()
self.update_mode_from_selection()
def get_selection_types(self):
"""Get tuple of geometry types in current selection."""
return tuple(item.geo_type for item in self.selection_list)
def update_mode_from_selection(self):
"""Auto-detect the best mode based on current selection."""
if self.mode_combo.currentIndex() > 0:
# Manual override is active
mode_id = self.mode_combo.currentData()
self._set_detected_mode(mode_id)
return
sel_types = self.get_selection_types()
if not sel_types:
self.mode_label.setText("Select geometry to auto-detect mode")
self.mode_label.setStyleSheet("font-weight: bold; color: #888;")
self.update_params_ui(None)
return
# Find matching modes
best_match = None
best_score = -1
for display_name, mode_id, required_types, category in self.MODES:
if not required_types:
continue # Skip modes with no requirements (like XYZ point)
score = self._match_score(sel_types, required_types)
if score > best_score:
best_score = score
best_match = (display_name, mode_id, category)
if best_match and best_score > 0:
display_name, mode_id, category = best_match
cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"}
color = cat_colors.get(category, "#cdd6f4")
self.mode_label.setText(f"{display_name}")
self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};")
self.update_params_ui(mode_id)
else:
self.mode_label.setText("No matching mode for selection")
self.mode_label.setStyleSheet("font-weight: bold; color: #f38ba8;")
self.update_params_ui(None)
def _match_score(self, sel_types, required_types):
"""
Calculate how well selection matches required types.
Returns score >= 0, higher is better. 0 means no match.
"""
if len(sel_types) < len(required_types):
return 0 # Not enough items
# Check if we can satisfy all requirements
sel_list = list(sel_types)
matched = 0
for req in required_types:
# Try to find a matching type in selection
found = False
for i, sel in enumerate(sel_list):
if self._type_matches(sel, req):
sel_list.pop(i)
matched += 1
found = True
break
if not found:
return 0 # Can't satisfy this requirement
# Score based on how exact the match is
# Exact match (same count) scores higher
if len(sel_types) == len(required_types):
return 100 + matched
else:
return matched
def _type_matches(self, sel_type, req_type):
"""Check if a selected type matches a required type."""
if sel_type == req_type:
return True
# Face can match cylinder (cylinder is a special face)
if req_type == "face" and sel_type in ("face", "cylinder"):
return True
# Edge can match circle (circle is a special edge)
if req_type == "edge" and sel_type in ("edge", "circle"):
return True
return False
def on_mode_override_changed(self, index):
"""Handle manual mode override selection."""
if index == 0:
# Auto-detect
self.update_mode_from_selection()
else:
mode_id = self.mode_combo.currentData()
self._set_detected_mode(mode_id)
def _set_detected_mode(self, mode_id):
"""Set the mode and update UI."""
for display_name, mid, _, category in self.MODES:
if mid == mode_id:
cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"}
color = cat_colors.get(category, "#cdd6f4")
self.mode_label.setText(f"{display_name}")
self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};")
self.update_params_ui(mode_id)
return
def _clear_params_layout(self):
"""Remove all rows from params_layout without deleting the widgets.
QFormLayout.removeRow() destroys the widgets it owns. Instead we
detach every item from the layout (which relinquishes ownership)
and hide the widgets so they can be re-added later.
"""
while self.params_layout.count():
item = self.params_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
widget.setParent(None)
def update_params_ui(self, mode_id):
"""Update parameters UI based on mode."""
# Clear existing params without destroying widgets
self._clear_params_layout()
if mode_id is None:
self.params_group.setVisible(False)
return
self.params_group.setVisible(True)
if mode_id in ("offset_face", "offset_plane"):
self.offset_spin.show()
self.params_layout.addRow("Offset:", self.offset_spin)
elif mode_id == "angled":
self.angle_spin.show()
self.params_layout.addRow("Angle:", self.angle_spin)
elif mode_id == "normal_edge":
self.param_spin.show()
self.params_layout.addRow("Position (0-1):", self.param_spin)
elif mode_id == "tangent_cyl":
self.angle_spin.show()
self.params_layout.addRow("Angle:", self.angle_spin)
elif mode_id == "point_xyz":
self.x_spin.show()
self.y_spin.show()
self.z_spin.show()
self.params_layout.addRow("X:", self.x_spin)
self.params_layout.addRow("Y:", self.y_spin)
self.params_layout.addRow("Z:", self.z_spin)
elif mode_id == "point_edge":
self.param_spin.show()
self.params_layout.addRow("Position (0-1):", self.param_spin)
else:
# No parameters needed
self.params_group.setVisible(False)
def get_current_mode(self):
"""Get the currently active mode ID."""
if self.mode_combo.currentIndex() > 0:
return self.mode_combo.currentData()
# Auto-detected mode
sel_types = self.get_selection_types()
if not sel_types:
return None
best_match = None
best_score = -1
for _, mode_id, required_types, _ in self.MODES:
if not required_types:
continue
score = self._match_score(sel_types, required_types)
if score > best_score:
best_score = score
best_match = mode_id
return best_match if best_score > 0 else None
def get_body(self):
"""Get active body if checkbox is checked."""
if not self.use_body_cb.isChecked():
return None
if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument:
active_view = Gui.ActiveDocument.ActiveView
if hasattr(active_view, "getActiveObject"):
body = active_view.getActiveObject("pdbody")
if body:
return body
doc = App.ActiveDocument
for obj in doc.Objects:
if obj.TypeId == "PartDesign::Body":
return obj
return None
def get_name(self):
"""Get custom name or None for auto-naming."""
if self.custom_name_cb.isChecked() and self.custom_name_edit.text():
return self.custom_name_edit.text()
return None
def get_items_by_type(self, *geo_types):
"""Get selection items matching given geometry types."""
results = []
for item in self.selection_list:
if item.geo_type in geo_types:
results.append(item)
return results
def create_datum(self):
"""Create the datum based on current settings."""
from ztools.datums import core
mode = self.get_current_mode()
if mode is None:
raise ValueError("No valid mode detected. Add geometry to the selection.")
body = self.get_body()
name = self.get_name()
link_ss = self.link_spreadsheet_cb.isChecked()
# Planes
if mode == "offset_face":
items = self.get_items_by_type("face", "cylinder")
if not items:
raise ValueError("Select a face")
item = items[0]
face = item.shape if item.shape else item.obj.Shape.Faces[0]
core.plane_offset_from_face(
face,
self.offset_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
source_object=item.obj,
source_subname=item.subname,
)
elif mode == "offset_plane":
items = self.get_items_by_type("plane")
if not items:
raise ValueError("Select a datum plane")
core.plane_offset_from_plane(
items[0].obj,
self.offset_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
)
elif mode == "midplane":
items = self.get_items_by_type("face", "cylinder")
if len(items) < 2:
raise ValueError("Select 2 faces")
face1 = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
face2 = items[1].shape if items[1].shape else items[1].obj.Shape.Faces[0]
core.plane_midplane(
face1,
face2,
name=name,
body=body,
source_object1=items[0].obj,
source_subname1=items[0].subname,
source_object2=items[1].obj,
source_subname2=items[1].subname,
)
elif mode == "3_points":
items = self.get_items_by_type("vertex")
if len(items) < 3:
raise ValueError("Select 3 vertices")
v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0]
v3 = items[2].shape if items[2].shape else items[2].obj.Shape.Vertexes[0]
core.plane_from_3_points(
v1.Point,
v2.Point,
v3.Point,
name=name,
body=body,
source_refs=[
(items[0].obj, items[0].subname),
(items[1].obj, items[1].subname),
(items[2].obj, items[2].subname),
],
)
elif mode == "normal_edge":
items = self.get_items_by_type("edge", "circle")
if not items:
raise ValueError("Select an edge")
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
core.plane_normal_to_edge(
edge,
parameter=self.param_spin.value(),
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "angled":
faces = self.get_items_by_type("face", "cylinder")
edges = self.get_items_by_type("edge", "circle")
if not faces or not edges:
raise ValueError("Select a face and an edge")
face = faces[0].shape if faces[0].shape else faces[0].obj.Shape.Faces[0]
edge = edges[0].shape if edges[0].shape else edges[0].obj.Shape.Edges[0]
core.plane_angled(
face,
edge,
self.angle_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
source_face_obj=faces[0].obj,
source_face_sub=faces[0].subname,
source_edge_obj=edges[0].obj,
source_edge_sub=edges[0].subname,
)
elif mode == "tangent_cyl":
items = self.get_items_by_type("cylinder")
if not items:
raise ValueError("Select a cylindrical face")
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
core.plane_tangent_to_cylinder(
face,
angle=self.angle_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
source_object=items[0].obj,
source_subname=items[0].subname,
)
# Axes
elif mode == "axis_2pt":
items = self.get_items_by_type("vertex")
if len(items) < 2:
raise ValueError("Select 2 vertices")
v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0]
core.axis_from_2_points(
v1.Point,
v2.Point,
name=name,
body=body,
source_refs=[
(items[0].obj, items[0].subname),
(items[1].obj, items[1].subname),
],
)
elif mode == "axis_edge":
items = self.get_items_by_type("edge")
if not items:
raise ValueError("Select a linear edge")
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
core.axis_from_edge(
edge,
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "axis_cyl":
items = self.get_items_by_type("cylinder")
if not items:
raise ValueError("Select a cylindrical face")
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
core.axis_cylinder_center(
face,
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "axis_intersect":
items = self.get_items_by_type("plane")
if len(items) < 2:
raise ValueError("Select 2 datum planes")
core.axis_intersection_planes(
items[0].obj,
items[1].obj,
name=name,
body=body,
source_object1=items[0].obj,
source_subname1="",
source_object2=items[1].obj,
source_subname2="",
)
# Points
elif mode == "point_vertex":
items = self.get_items_by_type("vertex")
if not items:
raise ValueError("Select a vertex")
vert = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0]
core.point_at_vertex(
vert,
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "point_xyz":
core.point_at_coordinates(
self.x_spin.value(),
self.y_spin.value(),
self.z_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
)
elif mode == "point_edge":
items = self.get_items_by_type("edge", "circle")
if not items:
raise ValueError("Select an edge")
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
core.point_on_edge(
edge,
parameter=self.param_spin.value(),
name=name,
body=body,
link_spreadsheet=link_ss,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "point_face":
items = self.get_items_by_type("face", "cylinder")
if not items:
raise ValueError("Select a face")
face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
core.point_center_of_face(
face,
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
elif mode == "point_circle":
items = self.get_items_by_type("circle")
if not items:
raise ValueError("Select a circular edge")
edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0]
core.point_center_of_circle(
edge,
name=name,
body=body,
source_object=items[0].obj,
source_subname=items[0].subname,
)
else:
raise ValueError(f"Unknown mode: {mode}")
def accept(self):
"""Called when OK is clicked. Creates the datum."""
Gui.Selection.removeObserver(self.observer)
try:
self.create_datum()
App.Console.PrintMessage("ZTools: Datum created successfully\n")
except Exception as e:
App.Console.PrintError(f"ZTools: Failed to create datum: {e}\n")
QtGui.QMessageBox.warning(self.form, "Error", str(e))
return True
def reject(self):
"""Called when Cancel is clicked."""
Gui.Selection.removeObserver(self.observer)
return True
def getStandardButtons(self):
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
class ZTools_DatumCreator:
"""Command to open datum creator task panel."""
def GetResources(self):
from ztools.resources.icons import get_icon
return {
"Pixmap": get_icon("datum_creator"),
"MenuText": "Datum Creator",
"ToolTip": "Create datum planes, axes, and points with advanced options",
}
def Activated(self):
panel = DatumCreatorTaskPanel()
Gui.Control.showDialog(panel)
def IsActive(self):
return App.ActiveDocument is not None
class ZTools_DatumManager:
"""Command to open datum manager panel."""
def GetResources(self):
from ztools.resources.icons import get_icon
return {
"Pixmap": get_icon("datum_manager"),
"MenuText": "Datum Manager",
"ToolTip": "List, toggle visibility, and rename datums in document",
}
def Activated(self):
# TODO: Implement datum manager panel
App.Console.PrintMessage("ZTools: Datum Manager - Coming soon\n")
def IsActive(self):
return App.ActiveDocument is not None
# Register commands
Gui.addCommand("ZTools_DatumCreator", ZTools_DatumCreator())
Gui.addCommand("ZTools_DatumManager", ZTools_DatumManager())

View File

@@ -0,0 +1,487 @@
# ztools/commands/datum_viewprovider.py
# Custom ViewProvider for ZTools datum objects
import json
import math
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtCore, QtGui
class ZToolsDatumViewProvider:
"""
Custom ViewProvider for ZTools datum objects.
Features:
- Overrides double-click to open ZTools editor instead of vanilla attachment
- Hides attachment properties from property editor
- Provides custom icons based on datum type
"""
_is_ztools = True # Marker to identify ZTools ViewProviders
def __init__(self, vobj):
"""Initialize and attach to ViewObject."""
vobj.Proxy = self
self.Object = vobj.Object if vobj else None
def attach(self, vobj):
"""Called when ViewProvider is attached to object."""
self.Object = vobj.Object
self._hide_attachment_props(vobj)
def _hide_attachment_props(self, vobj):
"""Hide FreeCAD attachment properties using persistent property status."""
if not vobj or not vobj.Object:
return
obj = vobj.Object
attachment_props = [
"MapMode",
"MapPathParameter",
"MapReversed",
"AttachmentOffset",
"AttachmentSupport",
"Support",
]
for prop in attachment_props:
try:
if hasattr(obj, prop):
obj.setPropertyStatus(prop, "Hidden")
except Exception:
pass
def updateData(self, obj, prop):
"""Called when data properties change."""
pass
def onChanged(self, vobj, prop):
"""Called when view properties change."""
# Re-hide attachment properties if they become visible
if prop in ("MapMode", "Support"):
self._hide_attachment_props(vobj)
def doubleClicked(self, vobj):
"""
Handle double-click - open ZTools datum editor.
Returns True if handled, False to let FreeCAD handle it.
"""
if Gui.Control.activeDialog():
# Task panel already open
return False
# Check if this is a ZTools datum
obj = vobj.Object
if not hasattr(obj, "ZTools_Type"):
# Not a ZTools datum, let FreeCAD handle it
return False
# Open ZTools editor
panel = DatumEditTaskPanel(obj)
Gui.Control.showDialog(panel)
return True
def setEdit(self, vobj, mode=0):
"""
Called when entering edit mode.
Mode 0 = default edit, Mode 1 = transform
"""
if mode == 0:
obj = vobj.Object
if hasattr(obj, "ZTools_Type"):
panel = DatumEditTaskPanel(obj)
Gui.Control.showDialog(panel)
return True
return False
def unsetEdit(self, vobj, mode=0):
"""Called when exiting edit mode."""
return False
def getIcon(self):
"""Return icon for tree view based on datum type."""
from ztools.resources.icons import get_icon
if not self.Object:
return get_icon("datum_creator")
ztools_type = getattr(self.Object, "ZTools_Type", "")
# Map ZTools type to icon
icon_map = {
"offset_from_face": "plane_offset",
"offset_from_plane": "plane_offset",
"midplane": "plane_midplane",
"3_points": "plane_3pt",
"normal_to_edge": "plane_normal",
"angled": "plane_angled",
"tangent_cylinder": "plane_tangent",
"2_points": "axis_2pt",
"from_edge": "axis_edge",
"cylinder_center": "axis_cyl",
"plane_intersection": "axis_intersect",
"vertex": "point_vertex",
"coordinates": "point_xyz",
"on_edge": "point_edge",
"face_center": "point_face",
"circle_center": "point_circle",
}
icon_name = icon_map.get(ztools_type, "datum_creator")
return get_icon(icon_name)
def __getstate__(self):
"""Serialization - don't save proxy state."""
return None
def __setstate__(self, state):
"""Deserialization."""
return None
def _resolve_source_refs(datum_obj):
"""Parse ZTools_SourceRefs and resolve to (object, subname, shape) tuples."""
refs_json = getattr(datum_obj, "ZTools_SourceRefs", "[]")
try:
refs = json.loads(refs_json)
except json.JSONDecodeError:
return []
doc = datum_obj.Document
resolved = []
for ref in refs:
obj = doc.getObject(ref.get("object", ""))
sub = ref.get("subname", "")
shape = obj.getSubObject(sub) if obj and sub else None
resolved.append((obj, sub, shape))
return resolved
class DatumEditTaskPanel:
"""
Task panel for editing existing ZTools datum objects.
Allows modification of:
- Offset distance (for offset-type datums)
- Angle (for angled/tangent datums)
- Parameter position (for edge-based datums)
- Source references (future)
"""
def __init__(self, datum_obj):
self.datum_obj = datum_obj
self.form = QtGui.QWidget()
self.form.setWindowTitle(f"Edit {datum_obj.Label}")
self.original_placement = datum_obj.Placement.copy()
self.setup_ui()
self.load_current_values()
def setup_ui(self):
"""Create the edit panel UI."""
layout = QtGui.QVBoxLayout(self.form)
layout.setSpacing(8)
# Header with datum info
info_group = QtGui.QGroupBox("Datum Info")
info_layout = QtGui.QFormLayout(info_group)
self.name_edit = QtGui.QLineEdit(self.datum_obj.Label)
info_layout.addRow("Name:", self.name_edit)
ztools_type = getattr(self.datum_obj, "ZTools_Type", "unknown")
type_label = QtGui.QLabel(self._format_type_name(ztools_type))
type_label.setStyleSheet("color: #cba6f7; font-weight: bold;")
info_layout.addRow("Type:", type_label)
layout.addWidget(info_group)
# Parameters group
self.params_group = QtGui.QGroupBox("Parameters")
self.params_layout = QtGui.QFormLayout(self.params_group)
# Offset spinner
self.offset_spin = QtGui.QDoubleSpinBox()
self.offset_spin.setRange(-10000, 10000)
self.offset_spin.setSuffix(" mm")
self.offset_spin.valueChanged.connect(self.on_param_changed)
# Angle spinner
self.angle_spin = QtGui.QDoubleSpinBox()
self.angle_spin.setRange(-360, 360)
self.angle_spin.setSuffix(" °")
self.angle_spin.valueChanged.connect(self.on_param_changed)
# Parameter spinner (0-1)
self.param_spin = QtGui.QDoubleSpinBox()
self.param_spin.setRange(0, 1)
self.param_spin.setSingleStep(0.1)
self.param_spin.valueChanged.connect(self.on_param_changed)
# XYZ spinners for point coordinates
self.x_spin = QtGui.QDoubleSpinBox()
self.x_spin.setRange(-10000, 10000)
self.x_spin.setSuffix(" mm")
self.x_spin.valueChanged.connect(self.on_param_changed)
self.y_spin = QtGui.QDoubleSpinBox()
self.y_spin.setRange(-10000, 10000)
self.y_spin.setSuffix(" mm")
self.y_spin.valueChanged.connect(self.on_param_changed)
self.z_spin = QtGui.QDoubleSpinBox()
self.z_spin.setRange(-10000, 10000)
self.z_spin.setSuffix(" mm")
self.z_spin.valueChanged.connect(self.on_param_changed)
layout.addWidget(self.params_group)
# Source references (read-only for now)
refs_group = QtGui.QGroupBox("Source References")
refs_layout = QtGui.QVBoxLayout(refs_group)
self.refs_list = QtGui.QListWidget()
self.refs_list.setMaximumHeight(80)
refs_layout.addWidget(self.refs_list)
layout.addWidget(refs_group)
# Placement info (read-only)
placement_group = QtGui.QGroupBox("Current Placement")
placement_layout = QtGui.QFormLayout(placement_group)
pos = self.datum_obj.Placement.Base
self.pos_label = QtGui.QLabel(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
placement_layout.addRow("Position:", self.pos_label)
layout.addWidget(placement_group)
layout.addStretch()
def _format_type_name(self, ztools_type):
"""Format ZTools type string for display."""
type_names = {
"offset_from_face": "Offset from Face",
"offset_from_plane": "Offset from Plane",
"midplane": "Midplane",
"3_points": "3 Points",
"normal_to_edge": "Normal to Edge",
"angled": "Angled from Face",
"tangent_cylinder": "Tangent to Cylinder",
"2_points": "2 Points",
"from_edge": "From Edge",
"cylinder_center": "Cylinder Center",
"plane_intersection": "Plane Intersection",
"vertex": "At Vertex",
"coordinates": "XYZ Coordinates",
"on_edge": "On Edge",
"face_center": "Face Center",
"circle_center": "Circle Center",
}
return type_names.get(ztools_type, ztools_type)
def load_current_values(self):
"""Load current values from datum object."""
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
refs_json = getattr(self.datum_obj, "ZTools_SourceRefs", "[]")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
try:
refs = json.loads(refs_json)
except json.JSONDecodeError:
refs = []
# Clear params layout
while self.params_layout.rowCount() > 0:
self.params_layout.removeRow(0)
# Add appropriate parameter controls based on type
if ztools_type in ("offset_from_face", "offset_from_plane"):
distance = params.get("distance", 10)
self.offset_spin.setValue(distance)
self.params_layout.addRow("Offset:", self.offset_spin)
elif ztools_type == "angled":
angle = params.get("angle", 45)
self.angle_spin.setValue(angle)
self.params_layout.addRow("Angle:", self.angle_spin)
elif ztools_type == "tangent_cylinder":
angle = params.get("angle", 0)
self.angle_spin.setValue(angle)
self.params_layout.addRow("Angle:", self.angle_spin)
elif ztools_type in ("normal_to_edge", "on_edge"):
parameter = params.get("parameter", 0.5)
self.param_spin.setValue(parameter)
self.params_layout.addRow("Position (0-1):", self.param_spin)
elif ztools_type == "coordinates":
x = params.get("x", 0)
y = params.get("y", 0)
z = params.get("z", 0)
self.x_spin.setValue(x)
self.y_spin.setValue(y)
self.z_spin.setValue(z)
self.params_layout.addRow("X:", self.x_spin)
self.params_layout.addRow("Y:", self.y_spin)
self.params_layout.addRow("Z:", self.z_spin)
else:
# No editable parameters
no_params_label = QtGui.QLabel("No editable parameters")
no_params_label.setStyleSheet("color: #888;")
self.params_layout.addRow(no_params_label)
# Load source references
self.refs_list.clear()
for ref in refs:
obj_name = ref.get("object", "?")
subname = ref.get("subname", "")
if subname:
self.refs_list.addItem(f"{obj_name}.{subname}")
else:
self.refs_list.addItem(obj_name)
if not refs:
self.refs_list.addItem("(no references)")
def _has_attachment(self):
"""Check if datum uses vanilla AttachExtension (MapMode != Deactivated)."""
return (
hasattr(self.datum_obj, "MapMode")
and self.datum_obj.MapMode != "Deactivated"
)
def on_param_changed(self):
"""Handle parameter value changes - update datum in real-time.
For datums with active AttachExtension, writes to AttachmentOffset or
MapPathParameter — the C++ engine recalculates placement automatically.
For manual datums, updates Placement directly.
"""
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
# For coordinate-based points, update placement directly
if ztools_type == "coordinates":
new_pos = App.Vector(
self.x_spin.value(), self.y_spin.value(), self.z_spin.value()
)
self.datum_obj.Placement.Base = new_pos
self._update_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z})
elif ztools_type in ("offset_from_face", "offset_from_plane", "midplane"):
distance = self.offset_spin.value()
if self._has_attachment():
new_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
self.datum_obj.AttachmentOffset = new_offset
self._update_params({"distance": distance})
elif ztools_type == "angled":
angle = self.angle_spin.value()
if self._has_attachment():
refs = _resolve_source_refs(self.datum_obj)
if len(refs) >= 2 and refs[0][2] and refs[1][2]:
face_normal = refs[0][2].normalAt(0, 0)
edge_shape = refs[1][2]
edge_dir = (
edge_shape.Vertexes[-1].Point - edge_shape.Vertexes[0].Point
).normalize()
face_rot = App.Rotation(App.Vector(0, 0, 1), face_normal)
local_edge_dir = face_rot.inverted().multVec(edge_dir)
angle_rot = App.Rotation(local_edge_dir, angle)
self.datum_obj.AttachmentOffset = App.Placement(
App.Vector(0, 0, 0), angle_rot
)
self._update_params({"angle": angle})
elif ztools_type == "tangent_cylinder":
angle = self.angle_spin.value()
if self._has_attachment():
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
vertex_angle = params.get("vertex_angle", 0.0)
offset_rot = App.Rotation(App.Vector(0, 0, 1), angle - vertex_angle)
self.datum_obj.AttachmentOffset = App.Placement(
App.Vector(0, 0, 0), offset_rot
)
else:
refs = _resolve_source_refs(self.datum_obj)
if refs and refs[0][2]:
face = refs[0][2]
if isinstance(face.Surface, Part.Cylinder):
cyl = face.Surface
axis = cyl.Axis
center = cyl.Center
radius = cyl.Radius
rad = math.radians(angle)
if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99:
local_x = axis.cross(App.Vector(1, 0, 0)).normalize()
else:
local_x = axis.cross(App.Vector(0, 1, 0)).normalize()
local_y = axis.cross(local_x)
radial = local_x * math.cos(rad) + local_y * math.sin(rad)
tangent_point = center + radial * radius
rot = App.Rotation(App.Vector(0, 0, 1), radial)
self.datum_obj.Placement = App.Placement(tangent_point, rot)
self._update_params({"angle": angle})
elif ztools_type in ("normal_to_edge", "on_edge"):
parameter = self.param_spin.value()
if self._has_attachment() and hasattr(self.datum_obj, "MapPathParameter"):
self.datum_obj.MapPathParameter = parameter
self._update_params({"parameter": parameter})
# Update position display
pos = self.datum_obj.Placement.Base
self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
App.ActiveDocument.recompute()
def _update_params(self, new_values):
"""Update stored parameters with new values."""
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
params.update(new_values)
# Re-serialize (handle vectors)
serializable = {}
for k, v in params.items():
if hasattr(v, "x") and hasattr(v, "y") and hasattr(v, "z"):
serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
else:
serializable[k] = v
self.datum_obj.ZTools_Params = json.dumps(serializable)
def accept(self):
"""Handle OK button - apply changes."""
# Update label if changed
new_label = self.name_edit.text().strip()
if new_label and new_label != self.datum_obj.Label:
self.datum_obj.Label = new_label
App.ActiveDocument.recompute()
return True
def reject(self):
"""Handle Cancel button - restore original placement."""
self.datum_obj.Placement = self.original_placement
App.ActiveDocument.recompute()
return True
def getStandardButtons(self):
"""Return dialog buttons."""
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel

View File

@@ -0,0 +1,206 @@
# ztools/commands/pattern_commands.py
# Rotated Linear Pattern command
# Creates a linear pattern with incremental rotation for each instance
import FreeCAD as App
import FreeCADGui as Gui
import Part
from ztools.resources.icons import get_icon
class RotatedLinearPatternFeature:
"""Feature object for rotated linear pattern."""
def __init__(self, obj):
obj.Proxy = self
obj.addProperty(
"App::PropertyLink", "Source", "Base", "Source object to pattern"
)
obj.addProperty(
"App::PropertyVector",
"Direction",
"Pattern",
"Direction of the linear pattern",
)
obj.addProperty(
"App::PropertyDistance", "Length", "Pattern", "Total length of the pattern"
)
obj.addProperty(
"App::PropertyInteger",
"Occurrences",
"Pattern",
"Number of occurrences (including original)",
)
obj.addProperty(
"App::PropertyVector",
"RotationAxis",
"Rotation",
"Axis of rotation for each instance",
)
obj.addProperty(
"App::PropertyAngle",
"RotationAngle",
"Rotation",
"Rotation angle increment per instance",
)
obj.addProperty(
"App::PropertyVector",
"RotationCenter",
"Rotation",
"Center point for rotation (relative to each instance)",
)
obj.addProperty(
"App::PropertyBool",
"CumulativeRotation",
"Rotation",
"If true, rotation accumulates with each instance",
)
# Set defaults
obj.Direction = App.Vector(1, 0, 0)
obj.Length = 100.0
obj.Occurrences = 3
obj.RotationAxis = App.Vector(0, 0, 1)
obj.RotationAngle = 15.0
obj.RotationCenter = App.Vector(0, 0, 0)
obj.CumulativeRotation = True
# Store metadata for ztools tracking
obj.addProperty(
"App::PropertyString",
"ZTools_Type",
"ZTools",
"ZTools feature type",
)
obj.ZTools_Type = "RotatedLinearPattern"
def execute(self, obj):
"""Recompute the feature."""
if not obj.Source or not hasattr(obj.Source, "Shape"):
return
source_shape = obj.Source.Shape
if source_shape.isNull():
return
occurrences = max(1, obj.Occurrences)
if occurrences == 1:
obj.Shape = source_shape.copy()
return
# Calculate spacing
direction = App.Vector(obj.Direction)
if direction.Length < 1e-6:
direction = App.Vector(1, 0, 0)
direction.normalize()
spacing = float(obj.Length) / (occurrences - 1) if occurrences > 1 else 0
shapes = []
for i in range(occurrences):
# Create translation
offset = direction * spacing * i
translated = source_shape.copy()
translated.translate(offset)
# Apply rotation
if abs(float(obj.RotationAngle)) > 1e-6:
if obj.CumulativeRotation:
angle = float(obj.RotationAngle) * i
else:
angle = float(obj.RotationAngle)
# Rotation center is relative to the translated position
center = App.Vector(obj.RotationCenter) + offset
axis = App.Vector(obj.RotationAxis)
if axis.Length < 1e-6:
axis = App.Vector(0, 0, 1)
axis.normalize()
translated.rotate(center, axis, angle)
shapes.append(translated)
if shapes:
obj.Shape = Part.makeCompound(shapes)
def onChanged(self, obj, prop):
"""Handle property changes."""
pass
class RotatedLinearPatternViewProvider:
"""View provider for rotated linear pattern."""
def __init__(self, vobj):
vobj.Proxy = self
def attach(self, vobj):
self.Object = vobj.Object
def updateData(self, obj, prop):
pass
def onChanged(self, vobj, prop):
pass
def getIcon(self):
return get_icon("rotated_pattern")
def __getstate__(self):
return None
def __setstate__(self, state):
return None
class RotatedLinearPatternCommand:
"""Command to create a rotated linear pattern."""
def GetResources(self):
return {
"Pixmap": get_icon("rotated_pattern"),
"MenuText": "Rotated Linear Pattern",
"ToolTip": "Create a linear pattern with rotation for each instance",
}
def IsActive(self):
"""Command is active when there's a document and selection."""
if App.ActiveDocument is None:
return False
sel = Gui.Selection.getSelection()
return len(sel) == 1
def Activated(self):
"""Execute the command."""
sel = Gui.Selection.getSelection()
if not sel:
App.Console.PrintError("Please select an object first\n")
return
source = sel[0]
# Create the feature
doc = App.ActiveDocument
obj = doc.addObject("Part::FeaturePython", "RotatedLinearPattern")
RotatedLinearPatternFeature(obj)
RotatedLinearPatternViewProvider(obj.ViewObject)
obj.Source = source
obj.Label = f"RotatedPattern_{source.Label}"
# Hide source object
if hasattr(source, "ViewObject"):
source.ViewObject.Visibility = False
doc.recompute()
App.Console.PrintMessage(
f"Created rotated linear pattern from {source.Label}\n"
)
# Register the command
Gui.addCommand("ZTools_RotatedLinearPattern", RotatedLinearPatternCommand())

View File

@@ -0,0 +1,601 @@
# ztools/commands/pocket_commands.py
# Enhanced Pocket feature with "Flip side to cut" option
#
# This provides an enhanced pocket workflow that includes the ability to
# cut material OUTSIDE the sketch profile rather than inside (like SOLIDWORKS
# "Flip side to cut" feature).
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtCore, QtGui
class EnhancedPocketTaskPanel:
"""Task panel for creating enhanced pocket features with flip option."""
# Pocket type modes matching FreeCAD's PartDesign::Pocket
POCKET_TYPES = [
("Dimension", 0),
("Through All", 1),
("To First", 2),
("Up To Face", 3),
("Two Dimensions", 4),
]
def __init__(self, sketch=None):
self.form = QtGui.QWidget()
self.form.setWindowTitle("ztools Enhanced Pocket")
self.sketch = sketch
self.selected_face = None
self.setup_ui()
self.setup_selection_observer()
# If sketch provided, show it in selection
if self.sketch:
self.update_sketch_display()
def setup_ui(self):
layout = QtGui.QVBoxLayout(self.form)
# Sketch selection display
sketch_group = QtGui.QGroupBox("Sketch")
sketch_layout = QtGui.QVBoxLayout(sketch_group)
self.sketch_label = QtGui.QLabel("No sketch selected")
self.sketch_label.setWordWrap(True)
sketch_layout.addWidget(self.sketch_label)
layout.addWidget(sketch_group)
# Type selection
type_group = QtGui.QGroupBox("Type")
type_layout = QtGui.QFormLayout(type_group)
self.type_combo = QtGui.QComboBox()
for label, _ in self.POCKET_TYPES:
self.type_combo.addItem(label)
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
type_layout.addRow("Type:", self.type_combo)
layout.addWidget(type_group)
# Dimensions group
self.dim_group = QtGui.QGroupBox("Dimensions")
self.dim_layout = QtGui.QFormLayout(self.dim_group)
# Length input
self.length_spin = QtGui.QDoubleSpinBox()
self.length_spin.setRange(0.001, 10000)
self.length_spin.setValue(10.0)
self.length_spin.setSuffix(" mm")
self.length_spin.setDecimals(3)
self.dim_layout.addRow("Length:", self.length_spin)
# Length2 input (for Two Dimensions mode)
self.length2_spin = QtGui.QDoubleSpinBox()
self.length2_spin.setRange(0.001, 10000)
self.length2_spin.setValue(10.0)
self.length2_spin.setSuffix(" mm")
self.length2_spin.setDecimals(3)
self.length2_label = QtGui.QLabel("Length 2:")
# Hidden by default
self.length2_spin.setVisible(False)
self.length2_label.setVisible(False)
self.dim_layout.addRow(self.length2_label, self.length2_spin)
layout.addWidget(self.dim_group)
# Up To Face selection (hidden by default)
self.face_group = QtGui.QGroupBox("Up To Face")
face_layout = QtGui.QVBoxLayout(self.face_group)
self.face_label = QtGui.QLabel("Select a face...")
self.face_label.setWordWrap(True)
face_layout.addWidget(self.face_label)
self.face_group.setVisible(False)
layout.addWidget(self.face_group)
# Direction options
dir_group = QtGui.QGroupBox("Direction")
dir_layout = QtGui.QVBoxLayout(dir_group)
self.reversed_cb = QtGui.QCheckBox("Reversed")
self.reversed_cb.setToolTip("Reverse the pocket direction")
dir_layout.addWidget(self.reversed_cb)
self.symmetric_cb = QtGui.QCheckBox("Symmetric to plane")
self.symmetric_cb.setToolTip(
"Extend pocket equally on both sides of sketch plane"
)
self.symmetric_cb.toggled.connect(self.on_symmetric_changed)
dir_layout.addWidget(self.symmetric_cb)
layout.addWidget(dir_group)
# FLIP SIDE TO CUT - The main new feature
flip_group = QtGui.QGroupBox("Flip Side to Cut")
flip_layout = QtGui.QVBoxLayout(flip_group)
self.flipped_cb = QtGui.QCheckBox("Cut outside profile (keep inside)")
self.flipped_cb.setToolTip(
"Instead of removing material inside the sketch profile,\n"
"remove material OUTSIDE the profile.\n\n"
"This keeps only the material covered by the sketch,\n"
"similar to SOLIDWORKS 'Flip side to cut' option."
)
flip_layout.addWidget(self.flipped_cb)
# Info label
flip_info = QtGui.QLabel(
"<i>When enabled, material outside the sketch profile is removed,\n"
"leaving only the material inside the sketch boundary.</i>"
)
flip_info.setWordWrap(True)
flip_info.setStyleSheet("color: gray; font-size: 10px;")
flip_layout.addWidget(flip_info)
layout.addWidget(flip_group)
# Taper angle (optional)
taper_group = QtGui.QGroupBox("Taper")
taper_layout = QtGui.QFormLayout(taper_group)
self.taper_spin = QtGui.QDoubleSpinBox()
self.taper_spin.setRange(-89.99, 89.99)
self.taper_spin.setValue(0.0)
self.taper_spin.setSuffix(" °")
self.taper_spin.setDecimals(2)
taper_layout.addRow("Taper Angle:", self.taper_spin)
layout.addWidget(taper_group)
# Create button
self.create_btn = QtGui.QPushButton("Create Pocket")
self.create_btn.clicked.connect(self.on_create)
layout.addWidget(self.create_btn)
layout.addStretch()
def setup_selection_observer(self):
"""Setup selection observer to track user selections."""
class SelectionObserver:
def __init__(self, panel):
self.panel = panel
def addSelection(self, doc, obj, sub, pos):
self.panel.on_selection_changed()
def removeSelection(self, doc, obj, sub):
self.panel.on_selection_changed()
def clearSelection(self, doc):
self.panel.on_selection_changed()
self.observer = SelectionObserver(self)
Gui.Selection.addObserver(self.observer)
def on_selection_changed(self):
"""Handle selection changes."""
sel = Gui.Selection.getSelectionEx()
for s in sel:
obj = s.Object
# Check if it's a sketch (for sketch selection)
if obj.TypeId == "Sketcher::SketchObject" and not self.sketch:
self.sketch = obj
self.update_sketch_display()
# Check for face selection (for Up To Face mode)
if s.SubElementNames:
for sub in s.SubElementNames:
if sub.startswith("Face"):
shape = obj.Shape.getElement(sub)
if isinstance(shape, Part.Face):
self.selected_face = (obj, sub)
self.face_label.setText(f"Face: {obj.Name}.{sub}")
def update_sketch_display(self):
"""Update sketch label."""
if self.sketch:
self.sketch_label.setText(f"Sketch: {self.sketch.Label}")
else:
self.sketch_label.setText("No sketch selected")
def on_type_changed(self, index):
"""Update UI based on pocket type."""
pocket_type = self.POCKET_TYPES[index][1]
# Show/hide dimension inputs based on type
show_length = pocket_type in [0, 4] # Dimension or Two Dimensions
self.dim_group.setVisible(show_length or pocket_type == 4)
self.length_spin.setVisible(show_length)
# Two Dimensions mode
show_length2 = pocket_type == 4
self.length2_spin.setVisible(show_length2)
self.length2_label.setVisible(show_length2)
# Up To Face mode
self.face_group.setVisible(pocket_type == 3)
def on_symmetric_changed(self, checked):
"""Handle symmetric checkbox change."""
if checked:
self.reversed_cb.setEnabled(False)
self.reversed_cb.setChecked(False)
else:
self.reversed_cb.setEnabled(True)
def get_body(self):
"""Get the active PartDesign body."""
if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument:
active_view = Gui.ActiveDocument.ActiveView
if hasattr(active_view, "getActiveObject"):
body = active_view.getActiveObject("pdbody")
if body:
return body
# Fallback: find body containing the sketch
if self.sketch:
for obj in App.ActiveDocument.Objects:
if obj.TypeId == "PartDesign::Body":
if self.sketch in obj.Group:
return obj
return None
def on_create(self):
"""Create the pocket feature."""
if not self.sketch:
QtGui.QMessageBox.warning(
self.form, "Error", "Please select a sketch first."
)
return
body = self.get_body()
if not body:
QtGui.QMessageBox.warning(
self.form, "Error", "No active body found. Please activate a body."
)
return
try:
App.ActiveDocument.openTransaction("Create Enhanced Pocket")
flipped = self.flipped_cb.isChecked()
if flipped:
self.create_flipped_pocket(body)
else:
self.create_standard_pocket(body)
App.ActiveDocument.commitTransaction()
App.ActiveDocument.recompute()
App.Console.PrintMessage("Enhanced Pocket created successfully\n")
except Exception as e:
App.ActiveDocument.abortTransaction()
App.Console.PrintError(f"Failed to create pocket: {e}\n")
QtGui.QMessageBox.critical(self.form, "Error", str(e))
def create_standard_pocket(self, body):
"""Create a standard PartDesign Pocket."""
pocket = body.newObject("PartDesign::Pocket", "Pocket")
pocket.Profile = self.sketch
# Set type
pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1]
pocket.Type = pocket_type
# Set dimensions
pocket.Length = self.length_spin.value()
if pocket_type == 4: # Two Dimensions
pocket.Length2 = self.length2_spin.value()
# Set direction options
pocket.Reversed = self.reversed_cb.isChecked()
pocket.Midplane = self.symmetric_cb.isChecked()
# Set taper
if abs(self.taper_spin.value()) > 0.001:
pocket.TaperAngle = self.taper_spin.value()
# Up To Face
if pocket_type == 3 and self.selected_face:
obj, sub = self.selected_face
pocket.UpToFace = (obj, [sub])
# Hide sketch
self.sketch.ViewObject.Visibility = False
# Add metadata
pocket.addProperty(
"App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type"
)
pocket.ZTools_Type = "EnhancedPocket"
def create_flipped_pocket(self, body):
"""Create a flipped pocket (cut outside profile).
This uses Boolean Common operation: keeps only the intersection
of the body with the extruded profile.
"""
# Get current body shape (the Tip)
tip = body.Tip
if not tip or not hasattr(tip, "Shape"):
raise ValueError("Body has no valid tip shape")
base_shape = tip.Shape.copy()
# Get sketch profile
sketch_shape = self.sketch.Shape
if not sketch_shape.Wires:
raise ValueError("Sketch has no closed profile")
# Create face from sketch wires
wires = sketch_shape.Wires
if len(wires) == 0:
raise ValueError("Sketch has no wires")
# Create a face from the outer wire
# For multiple wires, the first is outer, rest are holes
face = Part.Face(wires[0])
if len(wires) > 1:
# Handle holes in the profile
face = Part.Face(wires)
# Get extrusion direction (sketch normal)
sketch_placement = self.sketch.Placement
normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1))
if self.reversed_cb.isChecked():
normal = normal.negative()
# Calculate extrusion length/direction
pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1]
if pocket_type == 0: # Dimension
length = self.length_spin.value()
if self.symmetric_cb.isChecked():
# Symmetric: extrude half in each direction
half_length = length / 2
tool_solid = face.extrude(normal * half_length)
tool_solid2 = face.extrude(normal.negative() * half_length)
tool_solid = tool_solid.fuse(tool_solid2)
else:
tool_solid = face.extrude(normal * length)
elif pocket_type == 1: # Through All
# Use a large value based on bounding box
bbox = base_shape.BoundBox
diagonal = bbox.DiagonalLength
length = diagonal * 2
if self.symmetric_cb.isChecked():
tool_solid = face.extrude(normal * length)
tool_solid2 = face.extrude(normal.negative() * length)
tool_solid = tool_solid.fuse(tool_solid2)
else:
tool_solid = face.extrude(normal * length)
elif pocket_type == 4: # Two Dimensions
length1 = self.length_spin.value()
length2 = self.length2_spin.value()
tool_solid = face.extrude(normal * length1)
tool_solid2 = face.extrude(normal.negative() * length2)
tool_solid = tool_solid.fuse(tool_solid2)
else:
# For other types, fall back to Through All behavior
bbox = base_shape.BoundBox
length = bbox.DiagonalLength * 2
tool_solid = face.extrude(normal * length)
# Apply taper if specified
# Note: Taper with flipped pocket is complex, skip for now
if abs(self.taper_spin.value()) > 0.001:
App.Console.PrintWarning(
"Taper angle is not supported with Flip Side to Cut. Ignoring.\n"
)
# Boolean Common: keep only intersection
result_shape = base_shape.common(tool_solid)
if result_shape.isNull() or result_shape.Volume < 1e-6:
raise ValueError(
"Flip pocket resulted in empty shape. "
"Make sure the sketch profile intersects with the body."
)
# Create a FeaturePython object to hold the result
feature = body.newObject("PartDesign::FeaturePython", "FlippedPocket")
# Set up the feature
FlippedPocketFeature(feature, self.sketch, result_shape)
FlippedPocketViewProvider(feature.ViewObject)
# Store parameters as properties
feature.addProperty("App::PropertyDistance", "Length", "Pocket", "Pocket depth")
feature.Length = self.length_spin.value()
feature.addProperty(
"App::PropertyBool", "Reversed", "Pocket", "Reverse direction"
)
feature.Reversed = self.reversed_cb.isChecked()
feature.addProperty(
"App::PropertyBool", "Symmetric", "Pocket", "Symmetric to plane"
)
feature.Symmetric = self.symmetric_cb.isChecked()
feature.addProperty(
"App::PropertyInteger", "PocketType", "Pocket", "Pocket type"
)
feature.PocketType = pocket_type
# Hide sketch
self.sketch.ViewObject.Visibility = False
def accept(self):
"""Called when OK is clicked."""
Gui.Selection.removeObserver(self.observer)
return True
def reject(self):
"""Called when Cancel is clicked."""
Gui.Selection.removeObserver(self.observer)
return True
def getStandardButtons(self):
return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
class FlippedPocketFeature:
"""Feature object for flipped pocket (cuts outside profile)."""
def __init__(self, obj, sketch, initial_shape):
obj.Proxy = self
self.sketch = sketch
# Store reference to sketch
obj.addProperty("App::PropertyLink", "Profile", "Base", "Sketch profile")
obj.Profile = sketch
# ZTools metadata
obj.addProperty(
"App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type"
)
obj.ZTools_Type = "FlippedPocket"
# Set initial shape
obj.Shape = initial_shape
def execute(self, obj):
"""Recompute the flipped pocket."""
if not obj.Profile:
return
# Get the base feature (previous feature in the body)
body = obj.getParentGeoFeatureGroup()
if not body:
return
# Find the feature before this one
base_feature = None
group = body.Group
for i, feat in enumerate(group):
if feat == obj and i > 0:
base_feature = group[i - 1]
break
if not base_feature or not hasattr(base_feature, "Shape"):
return
base_shape = base_feature.Shape.copy()
sketch = obj.Profile
# Get sketch profile
sketch_shape = sketch.Shape
if not sketch_shape.Wires:
return
wires = sketch_shape.Wires
face = Part.Face(wires[0])
if len(wires) > 1:
face = Part.Face(wires)
# Get direction
sketch_placement = sketch.Placement
normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1))
if hasattr(obj, "Reversed") and obj.Reversed:
normal = normal.negative()
# Get length
length = obj.Length.Value if hasattr(obj, "Length") else 10.0
symmetric = obj.Symmetric if hasattr(obj, "Symmetric") else False
pocket_type = obj.PocketType if hasattr(obj, "PocketType") else 0
# Create tool solid
if pocket_type == 1: # Through All
bbox = base_shape.BoundBox
length = bbox.DiagonalLength * 2
if symmetric:
half = length / 2
tool_solid = face.extrude(normal * half)
tool_solid2 = face.extrude(normal.negative() * half)
tool_solid = tool_solid.fuse(tool_solid2)
else:
tool_solid = face.extrude(normal * length)
# Boolean Common
result_shape = base_shape.common(tool_solid)
obj.Shape = result_shape
def onChanged(self, obj, prop):
"""Handle property changes."""
pass
class FlippedPocketViewProvider:
"""View provider for flipped pocket."""
def __init__(self, vobj):
vobj.Proxy = self
def attach(self, vobj):
self.Object = vobj.Object
def updateData(self, obj, prop):
pass
def onChanged(self, vobj, prop):
pass
def getIcon(self):
from ztools.resources.icons import get_icon
return get_icon("pocket_flipped")
def __getstate__(self):
return None
def __setstate__(self, state):
return None
class ZTools_EnhancedPocket:
"""Command to create enhanced pocket with flip option."""
def GetResources(self):
from ztools.resources.icons import get_icon
return {
"Pixmap": get_icon("pocket_enhanced"),
"MenuText": "Enhanced Pocket",
"ToolTip": (
"Create a pocket with additional options including\n"
"'Flip side to cut' - removes material outside the sketch profile"
),
}
def Activated(self):
# Check if a sketch is selected
sketch = None
sel = Gui.Selection.getSelection()
for obj in sel:
if obj.TypeId == "Sketcher::SketchObject":
sketch = obj
break
panel = EnhancedPocketTaskPanel(sketch)
Gui.Control.showDialog(panel)
def IsActive(self):
return App.ActiveDocument is not None
# Register the command
Gui.addCommand("ZTools_EnhancedPocket", ZTools_EnhancedPocket())

View File

@@ -0,0 +1,567 @@
# ztools/commands/spreadsheet_commands.py
# Enhanced spreadsheet formatting tools for FreeCAD
# Provides quick formatting toolbar for cell styling
import FreeCAD as App
import FreeCADGui as Gui
from PySide import QtCore, QtGui
from ztools.resources.icons import get_icon
# =============================================================================
# Helper Functions
# =============================================================================
def get_active_spreadsheet():
"""Get the currently active spreadsheet object and its view.
Returns:
tuple: (sheet_object, sheet_view) or (None, None)
"""
doc = App.ActiveDocument
if not doc:
return None, None
# Get MDI area and active subwindow
main_window = Gui.getMainWindow()
mdi = main_window.centralWidget()
if not mdi:
return None, None
subwindow = mdi.activeSubWindow()
if not subwindow:
return None, None
# Get widget from subwindow
widget = subwindow.widget()
if not widget:
return None, None
# Check if it's a spreadsheet view by looking for the table view
# FreeCAD's spreadsheet view contains a QTableView
table_view = None
if hasattr(widget, "findChild"):
table_view = widget.findChild(QtGui.QTableView)
if not table_view:
# Try if widget itself is the table view
if isinstance(widget, QtGui.QTableView):
table_view = widget
else:
return None, None
# Get the spreadsheet object from window title
# Window title format varies: "Spreadsheet" or "Spreadsheet - DocName"
title = subwindow.windowTitle()
sheet_name = title.split(" - ")[0].split(" : ")[0].strip()
# Try to find the sheet object
sheet = doc.getObject(sheet_name)
if sheet and sheet.TypeId == "Spreadsheet::Sheet":
return sheet, table_view
# Fallback: search for any spreadsheet object
for obj in doc.Objects:
if obj.TypeId == "Spreadsheet::Sheet":
return obj, table_view
return None, None
def get_selected_cells():
"""Get list of selected cell addresses from active spreadsheet.
Returns:
tuple: (sheet_object, list_of_cell_addresses) or (None, [])
"""
sheet, table_view = get_active_spreadsheet()
if not sheet or not table_view:
return None, []
# Get selection model
selection_model = table_view.selectionModel()
if not selection_model:
return sheet, []
indexes = selection_model.selectedIndexes()
if not indexes:
return sheet, []
cells = []
for idx in indexes:
col = idx.column()
row = idx.row()
# Convert to cell address (A1 notation)
# Handle columns beyond Z (AA, AB, etc.)
col_str = ""
temp_col = col
while temp_col >= 0:
col_str = chr(65 + (temp_col % 26)) + col_str
temp_col = temp_col // 26 - 1
cell_addr = f"{col_str}{row + 1}"
cells.append(cell_addr)
return sheet, cells
def column_to_index(col_str):
"""Convert column string (A, B, ..., Z, AA, AB, ...) to index."""
result = 0
for char in col_str:
result = result * 26 + (ord(char) - ord("A") + 1)
return result - 1
# =============================================================================
# Style Commands (Bold, Italic, Underline)
# =============================================================================
class ZTools_SpreadsheetStyleBold:
"""Toggle bold style on selected cells."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_bold"),
"MenuText": "Bold",
"ToolTip": "Toggle bold style on selected cells (Ctrl+B)",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Toggle Bold")
try:
for cell in cells:
current = sheet.getStyle(cell) or ""
styles = set(s.strip() for s in current.split("|") if s.strip())
if "bold" in styles:
styles.discard("bold")
else:
styles.add("bold")
new_style = "|".join(sorted(styles)) if styles else ""
sheet.setStyle(cell, new_style)
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to toggle bold: {e}\n")
class ZTools_SpreadsheetStyleItalic:
"""Toggle italic style on selected cells."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_italic"),
"MenuText": "Italic",
"ToolTip": "Toggle italic style on selected cells (Ctrl+I)",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Toggle Italic")
try:
for cell in cells:
current = sheet.getStyle(cell) or ""
styles = set(s.strip() for s in current.split("|") if s.strip())
if "italic" in styles:
styles.discard("italic")
else:
styles.add("italic")
new_style = "|".join(sorted(styles)) if styles else ""
sheet.setStyle(cell, new_style)
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to toggle italic: {e}\n")
class ZTools_SpreadsheetStyleUnderline:
"""Toggle underline style on selected cells."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_underline"),
"MenuText": "Underline",
"ToolTip": "Toggle underline style on selected cells (Ctrl+U)",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Toggle Underline")
try:
for cell in cells:
current = sheet.getStyle(cell) or ""
styles = set(s.strip() for s in current.split("|") if s.strip())
if "underline" in styles:
styles.discard("underline")
else:
styles.add("underline")
new_style = "|".join(sorted(styles)) if styles else ""
sheet.setStyle(cell, new_style)
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to toggle underline: {e}\n")
# =============================================================================
# Alignment Commands
# =============================================================================
class ZTools_SpreadsheetAlignLeft:
"""Align cell content to the left."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_align_left"),
"MenuText": "Align Left",
"ToolTip": "Align selected cells to the left",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Align Left")
try:
for cell in cells:
sheet.setAlignment(cell, "left")
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to align left: {e}\n")
class ZTools_SpreadsheetAlignCenter:
"""Align cell content to the center."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_align_center"),
"MenuText": "Align Center",
"ToolTip": "Align selected cells to the center",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Align Center")
try:
for cell in cells:
sheet.setAlignment(cell, "center")
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to align center: {e}\n")
class ZTools_SpreadsheetAlignRight:
"""Align cell content to the right."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_align_right"),
"MenuText": "Align Right",
"ToolTip": "Align selected cells to the right",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Align Right")
try:
for cell in cells:
sheet.setAlignment(cell, "right")
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to align right: {e}\n")
# =============================================================================
# Color Commands
# =============================================================================
class ZTools_SpreadsheetBgColor:
"""Set background color of selected cells."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_bg_color"),
"MenuText": "Background Color",
"ToolTip": "Set background color of selected cells",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
# Show color picker dialog
color = QtGui.QColorDialog.getColor(
QtCore.Qt.white, Gui.getMainWindow(), "Select Background Color"
)
if not color.isValid():
return
doc = App.ActiveDocument
doc.openTransaction("Set Background Color")
try:
# FreeCAD expects RGB as tuple of floats 0-1
rgb = (
color.redF(),
color.greenF(),
color.blueF(),
)
for cell in cells:
sheet.setBackground(cell, rgb)
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to set background color: {e}\n")
class ZTools_SpreadsheetTextColor:
"""Set text color of selected cells."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_text_color"),
"MenuText": "Text Color",
"ToolTip": "Set text color of selected cells",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
# Show color picker dialog with default white for dark theme
initial_color = QtGui.QColor(205, 214, 244) # Catppuccin Mocha text color
color = QtGui.QColorDialog.getColor(
initial_color, Gui.getMainWindow(), "Select Text Color"
)
if not color.isValid():
return
doc = App.ActiveDocument
doc.openTransaction("Set Text Color")
try:
# FreeCAD expects RGB as tuple of floats 0-1
rgb = (
color.redF(),
color.greenF(),
color.blueF(),
)
for cell in cells:
sheet.setForeground(cell, rgb)
doc.commitTransaction()
doc.recompute()
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to set text color: {e}\n")
# =============================================================================
# Utility Commands
# =============================================================================
class ZTools_SpreadsheetQuickAlias:
"""Create alias from cell content."""
def GetResources(self):
return {
"Pixmap": get_icon("spreadsheet_quick_alias"),
"MenuText": "Quick Alias",
"ToolTip": "Create alias for selected cell based on adjacent label cell",
}
def IsActive(self):
sheet, cells = get_selected_cells()
return sheet is not None and len(cells) > 0
def Activated(self):
sheet, cells = get_selected_cells()
if not sheet or not cells:
App.Console.PrintWarning("No cells selected\n")
return
doc = App.ActiveDocument
doc.openTransaction("Quick Alias")
try:
aliases_created = 0
for cell in cells:
# Parse cell address
import re
match = re.match(r"([A-Z]+)(\d+)", cell)
if not match:
continue
col_str = match.group(1)
row = int(match.group(2))
# Get content of cell to the left (label cell)
col_idx = column_to_index(col_str)
if col_idx > 0:
# Convert back to column string
label_col_idx = col_idx - 1
label_col_str = ""
temp = label_col_idx
while temp >= 0:
label_col_str = chr(65 + (temp % 26)) + label_col_str
temp = temp // 26 - 1
label_cell = f"{label_col_str}{row}"
label_content = sheet.getContents(label_cell)
if label_content:
# Clean the label to make a valid alias
# Must be alphanumeric + underscore, start with letter
alias = "".join(
c if c.isalnum() or c == "_" else "_" for c in label_content
)
# Ensure it starts with a letter
if alias and not alias[0].isalpha():
alias = "var_" + alias
# Truncate if too long
alias = alias[:30]
if alias:
try:
sheet.setAlias(cell, alias)
aliases_created += 1
except Exception as alias_err:
App.Console.PrintWarning(
f"Could not set alias '{alias}' for {cell}: {alias_err}\n"
)
doc.commitTransaction()
doc.recompute()
if aliases_created > 0:
App.Console.PrintMessage(f"Created {aliases_created} alias(es)\n")
else:
App.Console.PrintWarning(
"No aliases created. Select value cells with labels to the left.\n"
)
except Exception as e:
doc.abortTransaction()
App.Console.PrintError(f"Failed to create aliases: {e}\n")
# =============================================================================
# Register Commands
# =============================================================================
Gui.addCommand("ZTools_SpreadsheetStyleBold", ZTools_SpreadsheetStyleBold())
Gui.addCommand("ZTools_SpreadsheetStyleItalic", ZTools_SpreadsheetStyleItalic())
Gui.addCommand("ZTools_SpreadsheetStyleUnderline", ZTools_SpreadsheetStyleUnderline())
Gui.addCommand("ZTools_SpreadsheetAlignLeft", ZTools_SpreadsheetAlignLeft())
Gui.addCommand("ZTools_SpreadsheetAlignCenter", ZTools_SpreadsheetAlignCenter())
Gui.addCommand("ZTools_SpreadsheetAlignRight", ZTools_SpreadsheetAlignRight())
Gui.addCommand("ZTools_SpreadsheetBgColor", ZTools_SpreadsheetBgColor())
Gui.addCommand("ZTools_SpreadsheetTextColor", ZTools_SpreadsheetTextColor())
Gui.addCommand("ZTools_SpreadsheetQuickAlias", ZTools_SpreadsheetQuickAlias())

View File

@@ -0,0 +1,41 @@
# ztools/datums - Datum creation tools
from .core import (
axis_cylinder_center,
# Axes
axis_from_2_points,
axis_from_edge,
axis_intersection_planes,
plane_angled,
plane_from_3_points,
plane_midplane,
plane_normal_to_edge,
# Planes
plane_offset_from_face,
plane_offset_from_plane,
plane_tangent_to_cylinder,
point_at_coordinates,
# Points
point_at_vertex,
point_center_of_circle,
point_center_of_face,
point_on_edge,
)
__all__ = [
"plane_offset_from_face",
"plane_offset_from_plane",
"plane_midplane",
"plane_from_3_points",
"plane_normal_to_edge",
"plane_angled",
"plane_tangent_to_cylinder",
"axis_from_2_points",
"axis_from_edge",
"axis_cylinder_center",
"axis_intersection_planes",
"point_at_vertex",
"point_at_coordinates",
"point_on_edge",
"point_center_of_face",
"point_center_of_circle",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
# ztools/resources - Icons and assets
from .icons import MOCHA, get_icon, save_icons_to_disk
__all__ = [
"get_icon",
"save_icons_to_disk",
"MOCHA",
]

View File

@@ -0,0 +1,513 @@
# ztools/resources/icons.py
# Catppuccin Mocha themed icons for ztools
# Catppuccin Mocha Palette — sourced from kindred-addon-sdk
from kindred_sdk.theme import get_theme_tokens
MOCHA = get_theme_tokens()
def _svg_to_base64(svg_content: str) -> str:
"""Convert SVG string to base64 data URI for FreeCAD."""
import base64
encoded = base64.b64encode(svg_content.encode("utf-8")).decode("utf-8")
return f"data:image/svg+xml;base64,{encoded}"
# =============================================================================
# SVG Icon Definitions
# =============================================================================
# Workbench main icon - stylized "Z"
ICON_WORKBENCH_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<path d="M8 10 L24 10 L10 22 L24 22" stroke="{MOCHA["mauve"]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="24" cy="10" r="2" fill="{MOCHA["teal"]}"/>
</svg>'''
# Datum Creator icon - plane with plus
ICON_DATUM_CREATOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Plane representation -->
<path d="M6 20 L16 8 L26 20 L16 26 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Plus sign -->
<circle cx="24" cy="8" r="6" fill="{MOCHA["green"]}"/>
<path d="M24 5 L24 11 M21 8 L27 8" stroke="{MOCHA["base"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# Datum Manager icon - stacked planes with list
ICON_DATUM_MANAGER_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Stacked planes -->
<path d="M4 18 L12 12 L20 18 L12 22 Z" fill="{MOCHA["blue"]}" fill-opacity="0.5" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
<path d="M4 14 L12 8 L20 14 L12 18 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.5" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
<!-- List lines -->
<line x1="22" y1="10" x2="28" y2="10" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="16" x2="28" y2="16" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="22" x2="28" y2="22" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# Plane Offset icon
ICON_PLANE_OFFSET_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Base plane -->
<path d="M4 22 L16 14 L28 22 L16 28 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
<!-- Offset plane -->
<path d="M4 14 L16 6 L28 14 L16 20 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Offset arrow -->
<path d="M16 24 L16 18" stroke="{MOCHA["peach"]}" stroke-width="2" stroke-linecap="round"/>
<path d="M14 20 L16 17 L18 20" stroke="{MOCHA["peach"]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>'''
# Plane Midplane icon
ICON_PLANE_MIDPLANE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Top plane -->
<path d="M4 10 L16 4 L28 10 L16 14 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
<!-- Middle plane (result) -->
<path d="M4 16 L16 10 L28 16 L16 20 Z" fill="{MOCHA["green"]}" fill-opacity="0.7" stroke="{MOCHA["teal"]}" stroke-width="1.5"/>
<!-- Bottom plane -->
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
</svg>'''
# Plane 3 Points icon
ICON_PLANE_3PT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Plane -->
<path d="M4 20 L16 8 L28 18 L14 28 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Three points -->
<circle cx="8" cy="20" r="3" fill="{MOCHA["peach"]}"/>
<circle cx="16" cy="10" r="3" fill="{MOCHA["peach"]}"/>
<circle cx="24" cy="18" r="3" fill="{MOCHA["peach"]}"/>
</svg>'''
# Plane Normal to Edge icon
ICON_PLANE_NORMAL_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Edge/curve -->
<path d="M6 26 Q16 6 26 16" stroke="{MOCHA["yellow"]}" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Plane perpendicular -->
<path d="M12 8 L20 12 L20 24 L12 20 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Point on curve -->
<circle cx="16" cy="16" r="2.5" fill="{MOCHA["peach"]}"/>
</svg>'''
# Plane Angled icon
ICON_PLANE_ANGLED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Base plane -->
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="{MOCHA["overlay1"]}" stroke="{MOCHA["overlay2"]}" stroke-width="1"/>
<!-- Angled plane -->
<path d="M8 10 L20 6 L24 18 L12 22 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.7" stroke="{MOCHA["lavender"]}" stroke-width="1.5"/>
<!-- Angle arc -->
<path d="M14 20 Q18 18 18 14" stroke="{MOCHA["peach"]}" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>'''
# Plane Tangent icon
ICON_PLANE_TANGENT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Cylinder outline -->
<ellipse cx="12" cy="16" rx="6" ry="10" fill="none" stroke="{MOCHA["yellow"]}" stroke-width="2"/>
<!-- Tangent plane -->
<path d="M18 6 L28 10 L28 26 L18 22 Z" fill="{MOCHA["blue"]}" fill-opacity="0.7" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Tangent point -->
<circle cx="18" cy="16" r="2.5" fill="{MOCHA["peach"]}"/>
</svg>'''
# Axis 2 Points icon
ICON_AXIS_2PT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Axis line -->
<line x1="6" y1="26" x2="26" y2="6" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
<!-- End points -->
<circle cx="6" cy="26" r="3" fill="{MOCHA["peach"]}"/>
<circle cx="26" cy="6" r="3" fill="{MOCHA["peach"]}"/>
</svg>'''
# Axis from Edge icon
ICON_AXIS_EDGE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Box edge representation -->
<path d="M8 24 L8 12 L20 8 L20 20 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<!-- Selected edge highlighted -->
<line x1="8" y1="24" x2="8" y2="12" stroke="{MOCHA["yellow"]}" stroke-width="3" stroke-linecap="round"/>
<!-- Resulting axis -->
<line x1="8" y1="28" x2="8" y2="4" stroke="{MOCHA["red"]}" stroke-width="2" stroke-dasharray="4,2"/>
</svg>'''
# Axis Cylinder Center icon
ICON_AXIS_CYL_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Cylinder -->
<ellipse cx="16" cy="8" rx="8" ry="3" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<path d="M8 8 L8 24" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<path d="M24 8 L24 24" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<ellipse cx="16" cy="24" rx="8" ry="3" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<!-- Center axis -->
<line x1="16" y1="4" x2="16" y2="28" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="16" cy="16" r="2" fill="{MOCHA["peach"]}"/>
</svg>'''
# Axis Intersection icon
ICON_AXIS_INTERSECT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- First plane -->
<path d="M4 12 L16 6 L28 12 L16 18 Z" fill="{MOCHA["blue"]}" fill-opacity="0.5" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
<!-- Second plane -->
<path d="M4 20 L16 14 L28 20 L16 26 Z" fill="{MOCHA["mauve"]}" fill-opacity="0.5" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
<!-- Intersection axis -->
<line x1="4" y1="16" x2="28" y2="16" stroke="{MOCHA["red"]}" stroke-width="2.5" stroke-linecap="round"/>
</svg>'''
# Point at Vertex icon
ICON_POINT_VERTEX_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Wireframe box corner -->
<path d="M10 20 L10 10 L20 6" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
<path d="M10 20 L20 16 L20 6" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
<path d="M10 20 L4 24" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none"/>
<!-- Vertex point -->
<circle cx="10" cy="20" r="4" fill="{MOCHA["green"]}"/>
<circle cx="10" cy="20" r="2" fill="{MOCHA["teal"]}"/>
</svg>'''
# Point XYZ icon
ICON_POINT_XYZ_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Coordinate axes -->
<line x1="6" y1="24" x2="26" y2="24" stroke="{MOCHA["red"]}" stroke-width="1.5"/>
<line x1="6" y1="24" x2="6" y2="6" stroke="{MOCHA["green"]}" stroke-width="1.5"/>
<line x1="6" y1="24" x2="16" y2="28" stroke="{MOCHA["blue"]}" stroke-width="1.5"/>
<!-- Point -->
<circle cx="18" cy="12" r="4" fill="{MOCHA["peach"]}"/>
<!-- Projection lines -->
<line x1="18" y1="12" x2="18" y2="24" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
<line x1="18" y1="12" x2="6" y2="12" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
</svg>'''
# Point on Edge icon
ICON_POINT_EDGE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Edge/curve -->
<path d="M6 24 Q16 4 26 20" stroke="{MOCHA["yellow"]}" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Point on edge -->
<circle cx="14" cy="12" r="4" fill="{MOCHA["green"]}"/>
<!-- Parameter indicator -->
<text x="20" y="10" font-family="monospace" font-size="8" fill="{MOCHA["text"]}">t</text>
</svg>'''
# Point Face Center icon
ICON_POINT_FACE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Face -->
<path d="M6 20 L16 10 L26 20 L16 26 Z" fill="{MOCHA["blue"]}" fill-opacity="0.6" stroke="{MOCHA["sapphire"]}" stroke-width="1.5"/>
<!-- Center point -->
<circle cx="16" cy="19" r="4" fill="{MOCHA["green"]}"/>
<circle cx="16" cy="19" r="2" fill="{MOCHA["teal"]}"/>
</svg>'''
# Point Circle Center icon
ICON_POINT_CIRCLE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Circle -->
<circle cx="16" cy="16" r="10" fill="none" stroke="{MOCHA["yellow"]}" stroke-width="2.5"/>
<!-- Center point -->
<circle cx="16" cy="16" r="4" fill="{MOCHA["green"]}"/>
<circle cx="16" cy="16" r="2" fill="{MOCHA["teal"]}"/>
<!-- Radius line -->
<line x1="16" y1="16" x2="26" y2="16" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="2,2"/>
</svg>'''
# Rotated Linear Pattern icon - objects along line with rotation
ICON_ROTATED_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Direction line -->
<line x1="4" y1="24" x2="28" y2="24" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="3,2"/>
<!-- First cube (original) -->
<rect x="4" y="16" width="6" height="6" fill="{MOCHA["blue"]}" stroke="{MOCHA["sapphire"]}" stroke-width="1"/>
<!-- Second cube (rotated 15deg) -->
<g transform="translate(14,19) rotate(-15)">
<rect x="-3" y="-3" width="6" height="6" fill="{MOCHA["mauve"]}" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
</g>
<!-- Third cube (rotated 30deg) -->
<g transform="translate(24,19) rotate(-30)">
<rect x="-3" y="-3" width="6" height="6" fill="{MOCHA["pink"]}" stroke="{MOCHA["flamingo"]}" stroke-width="1"/>
</g>
<!-- Rotation arrow -->
<path d="M24 8 A5 5 0 0 1 19 13" stroke="{MOCHA["peach"]}" stroke-width="1.5" fill="none"/>
<path d="M18 11 L19 13 L21 12" stroke="{MOCHA["peach"]}" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
</svg>'''
# Enhanced Pocket icon - pocket with plus/settings indicator
ICON_POCKET_ENHANCED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- 3D block with pocket cutout -->
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<path d="M6 10 L16 14 L26 10" stroke="{MOCHA["overlay1"]}" stroke-width="1" fill="none"/>
<path d="M16 14 L16 26" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<!-- Pocket depression -->
<path d="M10 12 L16 10 L22 12 L22 18 L16 20 L10 18 Z" fill="{MOCHA["base"]}" stroke="{MOCHA["mauve"]}" stroke-width="1.5"/>
<!-- Down arrow indicating cut -->
<path d="M16 13 L16 17" stroke="{MOCHA["red"]}" stroke-width="2" stroke-linecap="round"/>
<path d="M14 15 L16 18 L18 15" stroke="{MOCHA["red"]}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>'''
# Flipped Pocket icon - pocket cutting outside the profile
ICON_POCKET_FLIPPED_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Outer material removed (dark) -->
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="{MOCHA["base"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<path d="M6 10 L16 14 L26 10" stroke="{MOCHA["overlay0"]}" stroke-width="1" fill="none"/>
<path d="M16 14 L16 26" stroke="{MOCHA["overlay0"]}" stroke-width="1"/>
<!-- Inner remaining material (raised) -->
<path d="M10 20 L10 12 L16 10 L22 12 L22 20 L16 22 Z" fill="{MOCHA["surface2"]}" stroke="{MOCHA["teal"]}" stroke-width="1.5"/>
<path d="M10 12 L16 14 L22 12" stroke="{MOCHA["overlay1"]}" stroke-width="1" fill="none"/>
<path d="M16 14 L16 22" stroke="{MOCHA["overlay1"]}" stroke-width="1"/>
<!-- Flip arrows -->
<path d="M4 16 L8 14 L8 18 Z" fill="{MOCHA["peach"]}"/>
<path d="M28 16 L24 14 L24 18 Z" fill="{MOCHA["peach"]}"/>
</svg>'''
# Assembly Linear Pattern icon - components along a line
ICON_ASSEMBLY_LINEAR_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Direction arrow -->
<line x1="4" y1="16" x2="26" y2="16" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" stroke-dasharray="3,2"/>
<path d="M24 13 L28 16 L24 19" stroke="{MOCHA["overlay1"]}" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Component 1 (original) -->
<rect x="4" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
<circle cx="7" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
<!-- Component 2 -->
<rect x="13" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
<circle cx="16" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
<!-- Component 3 -->
<rect x="22" y="10" width="6" height="6" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
<circle cx="25" cy="13" r="1.5" fill="{MOCHA["teal"]}"/>
<!-- Count indicator -->
<text x="16" y="26" font-family="monospace" font-size="7" fill="{MOCHA["text"]}" text-anchor="middle">1-2-3</text>
</svg>'''
# Assembly Polar Pattern icon - components around a center
ICON_ASSEMBLY_POLAR_PATTERN_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Center axis indicator -->
<circle cx="16" cy="16" r="2" fill="{MOCHA["peach"]}"/>
<circle cx="16" cy="16" r="10" fill="none" stroke="{MOCHA["overlay1"]}" stroke-width="1" stroke-dasharray="3,2"/>
<!-- Rotation arrow -->
<path d="M23 8 A9 9 0 0 1 26 14" stroke="{MOCHA["green"]}" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M25 11 L26 14 L23 14" stroke="{MOCHA["green"]}" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
<!-- Component 1 (top) -->
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["sapphire"]}" stroke="{MOCHA["blue"]}" stroke-width="1"/>
<!-- Component 2 (right) -->
<g transform="translate(16,16) rotate(72) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["mauve"]}" stroke="{MOCHA["lavender"]}" stroke-width="1"/>
</g>
<!-- Component 3 (bottom-right) -->
<g transform="translate(16,16) rotate(144) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["pink"]}" stroke="{MOCHA["flamingo"]}" stroke-width="1"/>
</g>
<!-- Component 4 (bottom-left) -->
<g transform="translate(16,16) rotate(216) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["peach"]}" stroke="{MOCHA["maroon"]}" stroke-width="1"/>
</g>
<!-- Component 5 (left) -->
<g transform="translate(16,16) rotate(288) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="{MOCHA["yellow"]}" stroke="{MOCHA["peach"]}" stroke-width="1"/>
</g>
</svg>'''
# =============================================================================
# Spreadsheet Icons
# =============================================================================
# Bold text icon
ICON_SPREADSHEET_BOLD_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<text x="16" y="23" font-family="sans-serif" font-size="18" font-weight="bold" fill="{MOCHA["text"]}" text-anchor="middle">B</text>
</svg>'''
# Italic text icon
ICON_SPREADSHEET_ITALIC_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<text x="16" y="23" font-family="serif" font-size="18" font-style="italic" fill="{MOCHA["text"]}" text-anchor="middle">I</text>
</svg>'''
# Underline text icon
ICON_SPREADSHEET_UNDERLINE_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<text x="16" y="20" font-family="sans-serif" font-size="16" fill="{MOCHA["text"]}" text-anchor="middle">U</text>
<line x1="10" y1="24" x2="22" y2="24" stroke="{MOCHA["text"]}" stroke-width="2"/>
</svg>'''
# Align left icon
ICON_SPREADSHEET_ALIGN_LEFT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="14" x2="20" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="19" x2="24" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="24" x2="16" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# Align center icon
ICON_SPREADSHEET_ALIGN_CENTER_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="14" x2="22" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="19" x2="24" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="24" x2="20" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# Align right icon
ICON_SPREADSHEET_ALIGN_RIGHT_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="14" x2="26" y2="14" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="19" x2="26" y2="19" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="24" x2="26" y2="24" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# Background color icon (paint bucket)
ICON_SPREADSHEET_BG_COLOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Paint bucket -->
<path d="M10 12 L16 6 L22 12 L22 20 C22 22 20 24 16 24 C12 24 10 22 10 20 Z" fill="{MOCHA["yellow"]}" stroke="{MOCHA["peach"]}" stroke-width="1.5"/>
<!-- Handle -->
<path d="M16 6 L16 3" stroke="{MOCHA["overlay1"]}" stroke-width="2" stroke-linecap="round"/>
<!-- Paint drip -->
<ellipse cx="25" cy="22" rx="2" ry="3" fill="{MOCHA["yellow"]}"/>
</svg>'''
# Text color icon (A with color bar)
ICON_SPREADSHEET_TEXT_COLOR_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Letter A -->
<text x="16" y="20" font-family="sans-serif" font-size="16" font-weight="bold" fill="{MOCHA["text"]}" text-anchor="middle">A</text>
<!-- Color bar -->
<rect x="8" y="24" width="16" height="3" rx="1" fill="{MOCHA["red"]}"/>
</svg>'''
# Quick alias icon (tag/label)
ICON_SPREADSHEET_QUICK_ALIAS_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Tag shape -->
<path d="M6 10 L18 10 L24 16 L18 22 L6 22 Z" fill="{MOCHA["teal"]}" stroke="{MOCHA["green"]}" stroke-width="1.5"/>
<!-- Tag hole -->
<circle cx="10" cy="16" r="2" fill="{MOCHA["surface0"]}"/>
<!-- Equals sign (alias) -->
<line x1="20" y1="12" x2="26" y2="12" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="17" x2="26" y2="17" stroke="{MOCHA["text"]}" stroke-width="2" stroke-linecap="round"/>
</svg>'''
# =============================================================================
# Icon Registry - Base64 encoded for FreeCAD
# =============================================================================
def get_icon(name: str) -> str:
"""Get icon file path by name.
Returns the path to an SVG icon file. If the file doesn't exist,
it will be created from the embedded SVG definitions.
"""
import os
# Map of short names to SVG content
icons = {
"workbench": ICON_WORKBENCH_SVG,
"datum_creator": ICON_DATUM_CREATOR_SVG,
"datum_manager": ICON_DATUM_MANAGER_SVG,
"plane_offset": ICON_PLANE_OFFSET_SVG,
"plane_midplane": ICON_PLANE_MIDPLANE_SVG,
"plane_3pt": ICON_PLANE_3PT_SVG,
"plane_normal": ICON_PLANE_NORMAL_SVG,
"plane_angled": ICON_PLANE_ANGLED_SVG,
"plane_tangent": ICON_PLANE_TANGENT_SVG,
"axis_2pt": ICON_AXIS_2PT_SVG,
"axis_edge": ICON_AXIS_EDGE_SVG,
"axis_cyl": ICON_AXIS_CYL_SVG,
"axis_intersect": ICON_AXIS_INTERSECT_SVG,
"point_vertex": ICON_POINT_VERTEX_SVG,
"point_xyz": ICON_POINT_XYZ_SVG,
"point_edge": ICON_POINT_EDGE_SVG,
"point_face": ICON_POINT_FACE_SVG,
"point_circle": ICON_POINT_CIRCLE_SVG,
"rotated_pattern": ICON_ROTATED_PATTERN_SVG,
"pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
"pocket_flipped": ICON_POCKET_FLIPPED_SVG,
"assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
"assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
"spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
"spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
"spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
"spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
"spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
"spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
"spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
"spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
"spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
}
if name not in icons:
return ""
# Get the icons directory path (relative to this file)
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
icon_path = os.path.join(icons_dir, f"ztools_{name}.svg")
# If the icon file doesn't exist, create it
if not os.path.exists(icon_path):
os.makedirs(icons_dir, exist_ok=True)
with open(icon_path, "w") as f:
f.write(icons[name])
return icon_path
def save_icons_to_disk(directory: str):
"""Save all icons as SVG files to a directory."""
import os
os.makedirs(directory, exist_ok=True)
icons = {
"ztools_workbench": ICON_WORKBENCH_SVG,
"ztools_datum_creator": ICON_DATUM_CREATOR_SVG,
"ztools_datum_manager": ICON_DATUM_MANAGER_SVG,
"ztools_plane_offset": ICON_PLANE_OFFSET_SVG,
"ztools_plane_midplane": ICON_PLANE_MIDPLANE_SVG,
"ztools_plane_3pt": ICON_PLANE_3PT_SVG,
"ztools_plane_normal": ICON_PLANE_NORMAL_SVG,
"ztools_plane_angled": ICON_PLANE_ANGLED_SVG,
"ztools_plane_tangent": ICON_PLANE_TANGENT_SVG,
"ztools_axis_2pt": ICON_AXIS_2PT_SVG,
"ztools_axis_edge": ICON_AXIS_EDGE_SVG,
"ztools_axis_cyl": ICON_AXIS_CYL_SVG,
"ztools_axis_intersect": ICON_AXIS_INTERSECT_SVG,
"ztools_point_vertex": ICON_POINT_VERTEX_SVG,
"ztools_point_xyz": ICON_POINT_XYZ_SVG,
"ztools_point_edge": ICON_POINT_EDGE_SVG,
"ztools_point_face": ICON_POINT_FACE_SVG,
"ztools_point_circle": ICON_POINT_CIRCLE_SVG,
"ztools_rotated_pattern": ICON_ROTATED_PATTERN_SVG,
"ztools_pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
"ztools_pocket_flipped": ICON_POCKET_FLIPPED_SVG,
"ztools_assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
"ztools_assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
"ztools_spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
"ztools_spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
"ztools_spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
"ztools_spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
"ztools_spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
"ztools_spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
"ztools_spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
"ztools_spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
"ztools_spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
}
for name, svg in icons.items():
filepath = os.path.join(directory, f"{name}.svg")
with open(filepath, "w") as f:
f.write(svg)
print(f"Saved: {filepath}")

View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Direction arrow -->
<line x1="4" y1="16" x2="26" y2="16" stroke="#7f849c" stroke-width="1.5" stroke-dasharray="3,2"/>
<path d="M24 13 L28 16 L24 19" stroke="#7f849c" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Component 1 (original) -->
<rect x="4" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
<circle cx="7" cy="13" r="1.5" fill="#94e2d5"/>
<!-- Component 2 -->
<rect x="13" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
<circle cx="16" cy="13" r="1.5" fill="#94e2d5"/>
<!-- Component 3 -->
<rect x="22" y="10" width="6" height="6" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
<circle cx="25" cy="13" r="1.5" fill="#94e2d5"/>
<!-- Count indicator -->
<text x="16" y="26" font-family="monospace" font-size="7" fill="#cdd6f4" text-anchor="middle">1-2-3</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Center axis indicator -->
<circle cx="16" cy="16" r="2" fill="#fab387"/>
<circle cx="16" cy="16" r="10" fill="none" stroke="#7f849c" stroke-width="1" stroke-dasharray="3,2"/>
<!-- Rotation arrow -->
<path d="M23 8 A9 9 0 0 1 26 14" stroke="#a6e3a1" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M25 11 L26 14 L23 14" stroke="#a6e3a1" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
<!-- Component 1 (top) -->
<rect x="13" y="4" width="6" height="5" rx="1" fill="#74c7ec" stroke="#89b4fa" stroke-width="1"/>
<!-- Component 2 (right) -->
<g transform="translate(16,16) rotate(72) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="#cba6f7" stroke="#b4befe" stroke-width="1"/>
</g>
<!-- Component 3 (bottom-right) -->
<g transform="translate(16,16) rotate(144) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="#f5c2e7" stroke="#f2cdcd" stroke-width="1"/>
</g>
<!-- Component 4 (bottom-left) -->
<g transform="translate(16,16) rotate(216) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="#fab387" stroke="#eba0ac" stroke-width="1"/>
</g>
<!-- Component 5 (left) -->
<g transform="translate(16,16) rotate(288) translate(-16,-16)">
<rect x="13" y="4" width="6" height="5" rx="1" fill="#f9e2af" stroke="#fab387" stroke-width="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Axis line -->
<line x1="6" y1="26" x2="26" y2="6" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
<!-- End points -->
<circle cx="6" cy="26" r="3" fill="#fab387"/>
<circle cx="26" cy="6" r="3" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Cylinder -->
<ellipse cx="16" cy="8" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
<path d="M8 8 L8 24" stroke="#7f849c" stroke-width="1"/>
<path d="M24 8 L24 24" stroke="#7f849c" stroke-width="1"/>
<ellipse cx="16" cy="24" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
<!-- Center axis -->
<line x1="16" y1="4" x2="16" y2="28" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="16" cy="16" r="2" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Box edge representation -->
<path d="M8 24 L8 12 L20 8 L20 20 Z" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
<!-- Selected edge highlighted -->
<line x1="8" y1="24" x2="8" y2="12" stroke="#f9e2af" stroke-width="3" stroke-linecap="round"/>
<!-- Resulting axis -->
<line x1="8" y1="28" x2="8" y2="4" stroke="#f38ba8" stroke-width="2" stroke-dasharray="4,2"/>
</svg>

After

Width:  |  Height:  |  Size: 515 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- First plane -->
<path d="M4 12 L16 6 L28 12 L16 18 Z" fill="#89b4fa" fill-opacity="0.5" stroke="#74c7ec" stroke-width="1"/>
<!-- Second plane -->
<path d="M4 20 L16 14 L28 20 L16 26 Z" fill="#cba6f7" fill-opacity="0.5" stroke="#b4befe" stroke-width="1"/>
<!-- Intersection axis -->
<line x1="4" y1="16" x2="28" y2="16" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Plane representation -->
<path d="M6 20 L16 8 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Plus sign -->
<circle cx="24" cy="8" r="6" fill="#a6e3a1"/>
<path d="M24 5 L24 11 M21 8 L27 8" stroke="#1e1e2e" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Stacked planes -->
<path d="M4 18 L12 12 L20 18 L12 22 Z" fill="#89b4fa" fill-opacity="0.5" stroke="#74c7ec" stroke-width="1"/>
<path d="M4 14 L12 8 L20 14 L12 18 Z" fill="#cba6f7" fill-opacity="0.5" stroke="#b4befe" stroke-width="1"/>
<!-- List lines -->
<line x1="22" y1="10" x2="28" y2="10" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="16" x2="28" y2="16" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="22" x2="28" y2="22" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 700 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Plane -->
<path d="M4 20 L16 8 L28 18 L14 28 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Three points -->
<circle cx="8" cy="20" r="3" fill="#fab387"/>
<circle cx="16" cy="10" r="3" fill="#fab387"/>
<circle cx="24" cy="18" r="3" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Base plane -->
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
<!-- Angled plane -->
<path d="M8 10 L20 6 L24 18 L12 22 Z" fill="#cba6f7" fill-opacity="0.7" stroke="#b4befe" stroke-width="1.5"/>
<!-- Angle arc -->
<path d="M14 20 Q18 18 18 14" stroke="#fab387" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Top plane -->
<path d="M4 10 L16 4 L28 10 L16 14 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
<!-- Middle plane (result) -->
<path d="M4 16 L16 10 L28 16 L16 20 Z" fill="#a6e3a1" fill-opacity="0.7" stroke="#94e2d5" stroke-width="1.5"/>
<!-- Bottom plane -->
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Edge/curve -->
<path d="M6 26 Q16 6 26 16" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Plane perpendicular -->
<path d="M12 8 L20 12 L20 24 L12 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Point on curve -->
<circle cx="16" cy="16" r="2.5" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 480 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Base plane -->
<path d="M4 22 L16 14 L28 22 L16 28 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
<!-- Offset plane -->
<path d="M4 14 L16 6 L28 14 L16 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Offset arrow -->
<path d="M16 24 L16 18" stroke="#fab387" stroke-width="2" stroke-linecap="round"/>
<path d="M14 20 L16 17 L18 20" stroke="#fab387" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 621 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Cylinder outline -->
<ellipse cx="12" cy="16" rx="6" ry="10" fill="none" stroke="#f9e2af" stroke-width="2"/>
<!-- Tangent plane -->
<path d="M18 6 L28 10 L28 26 L18 22 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Tangent point -->
<circle cx="18" cy="16" r="2.5" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- 3D block with pocket cutout -->
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
<path d="M6 10 L16 14 L26 10" stroke="#7f849c" stroke-width="1" fill="none"/>
<path d="M16 14 L16 26" stroke="#7f849c" stroke-width="1"/>
<!-- Pocket depression -->
<path d="M10 12 L16 10 L22 12 L22 18 L16 20 L10 18 Z" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Down arrow indicating cut -->
<path d="M16 13 L16 17" stroke="#f38ba8" stroke-width="2" stroke-linecap="round"/>
<path d="M14 15 L16 18 L18 15" stroke="#f38ba8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 807 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Outer material removed (dark) -->
<path d="M6 22 L6 10 L16 6 L26 10 L26 22 L16 26 Z" fill="#1e1e2e" stroke="#7f849c" stroke-width="1"/>
<path d="M6 10 L16 14 L26 10" stroke="#6c7086" stroke-width="1" fill="none"/>
<path d="M16 14 L16 26" stroke="#6c7086" stroke-width="1"/>
<!-- Inner remaining material (raised) -->
<path d="M10 20 L10 12 L16 10 L22 12 L22 20 L16 22 Z" fill="#585b70" stroke="#94e2d5" stroke-width="1.5"/>
<path d="M10 12 L16 14 L22 12" stroke="#7f849c" stroke-width="1" fill="none"/>
<path d="M16 14 L16 22" stroke="#7f849c" stroke-width="1"/>
<!-- Flip arrows -->
<path d="M4 16 L8 14 L8 18 Z" fill="#fab387"/>
<path d="M28 16 L24 14 L24 18 Z" fill="#fab387"/>
</svg>

After

Width:  |  Height:  |  Size: 842 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Circle -->
<circle cx="16" cy="16" r="10" fill="none" stroke="#f9e2af" stroke-width="2.5"/>
<!-- Center point -->
<circle cx="16" cy="16" r="4" fill="#a6e3a1"/>
<circle cx="16" cy="16" r="2" fill="#94e2d5"/>
<!-- Radius line -->
<line x1="16" y1="16" x2="26" y2="16" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Edge/curve -->
<path d="M6 24 Q16 4 26 20" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Point on edge -->
<circle cx="14" cy="12" r="4" fill="#a6e3a1"/>
<!-- Parameter indicator -->
<text x="20" y="10" font-family="monospace" font-size="8" fill="#cdd6f4">t</text>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Face -->
<path d="M6 20 L16 10 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
<!-- Center point -->
<circle cx="16" cy="19" r="4" fill="#a6e3a1"/>
<circle cx="16" cy="19" r="2" fill="#94e2d5"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Wireframe box corner -->
<path d="M10 20 L10 10 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
<path d="M10 20 L20 16 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
<path d="M10 20 L4 24" stroke="#7f849c" stroke-width="1.5" fill="none"/>
<!-- Vertex point -->
<circle cx="10" cy="20" r="4" fill="#a6e3a1"/>
<circle cx="10" cy="20" r="2" fill="#94e2d5"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Coordinate axes -->
<line x1="6" y1="24" x2="26" y2="24" stroke="#f38ba8" stroke-width="1.5"/>
<line x1="6" y1="24" x2="6" y2="6" stroke="#a6e3a1" stroke-width="1.5"/>
<line x1="6" y1="24" x2="16" y2="28" stroke="#89b4fa" stroke-width="1.5"/>
<!-- Point -->
<circle cx="18" cy="12" r="4" fill="#fab387"/>
<!-- Projection lines -->
<line x1="18" y1="12" x2="18" y2="24" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
<line x1="18" y1="12" x2="6" y2="12" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Direction line -->
<line x1="4" y1="24" x2="28" y2="24" stroke="#7f849c" stroke-width="1" stroke-dasharray="3,2"/>
<!-- First cube (original) -->
<rect x="4" y="16" width="6" height="6" fill="#89b4fa" stroke="#74c7ec" stroke-width="1"/>
<!-- Second cube (rotated 15deg) -->
<g transform="translate(14,19) rotate(-15)">
<rect x="-3" y="-3" width="6" height="6" fill="#cba6f7" stroke="#b4befe" stroke-width="1"/>
</g>
<!-- Third cube (rotated 30deg) -->
<g transform="translate(24,19) rotate(-30)">
<rect x="-3" y="-3" width="6" height="6" fill="#f5c2e7" stroke="#f2cdcd" stroke-width="1"/>
</g>
<!-- Rotation arrow -->
<path d="M24 8 A5 5 0 0 1 19 13" stroke="#fab387" stroke-width="1.5" fill="none"/>
<path d="M18 11 L19 13 L21 12" stroke="#fab387" stroke-width="1.5" fill="none" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 980 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="10" y1="14" x2="22" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="19" x2="24" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="24" x2="20" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="14" x2="20" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="19" x2="24" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="24" x2="16" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<line x1="6" y1="9" x2="26" y2="9" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="14" x2="26" y2="14" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="19" x2="26" y2="19" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="16" y1="24" x2="26" y2="24" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Paint bucket -->
<path d="M10 12 L16 6 L22 12 L22 20 C22 22 20 24 16 24 C12 24 10 22 10 20 Z" fill="#f9e2af" stroke="#fab387" stroke-width="1.5"/>
<!-- Handle -->
<path d="M16 6 L16 3" stroke="#7f849c" stroke-width="2" stroke-linecap="round"/>
<!-- Paint drip -->
<ellipse cx="25" cy="22" rx="2" ry="3" fill="#f9e2af"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<text x="16" y="23" font-family="sans-serif" font-size="18" font-weight="bold" fill="#cdd6f4" text-anchor="middle">B</text>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<text x="16" y="23" font-family="serif" font-size="18" font-style="italic" fill="#cdd6f4" text-anchor="middle">I</text>
</svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Tag shape -->
<path d="M6 10 L18 10 L24 16 L18 22 L6 22 Z" fill="#94e2d5" stroke="#a6e3a1" stroke-width="1.5"/>
<!-- Tag hole -->
<circle cx="10" cy="16" r="2" fill="#313244"/>
<!-- Equals sign (alias) -->
<line x1="20" y1="12" x2="26" y2="12" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="17" x2="26" y2="17" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Letter A -->
<text x="16" y="20" font-family="sans-serif" font-size="16" font-weight="bold" fill="#cdd6f4" text-anchor="middle">A</text>
<!-- Color bar -->
<rect x="8" y="24" width="16" height="3" rx="1" fill="#f38ba8"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<text x="16" y="20" font-family="sans-serif" font-size="16" fill="#cdd6f4" text-anchor="middle">U</text>
<line x1="10" y1="24" x2="22" y2="24" stroke="#cdd6f4" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Paint palette -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Color dots on palette -->
<circle cx="9" cy="16" r="2" fill="#f38ba8"/>
<circle cx="14" cy="13" r="2" fill="#f9e2af"/>
<circle cx="19" cy="16" r="2" fill="#a6e3a1"/>
<circle cx="14" cy="21" r="2" fill="#89b4fa"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- Check mark -->
<circle cx="24" cy="8" r="5" fill="#a6e3a1"/>
<path d="M21.5 8 L23 9.5 L26.5 6" stroke="#11111b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 767 B

View File

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

After

Width:  |  Height:  |  Size: 796 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Paint palette (dimmed) -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#6c7086" stroke-width="1.5"/>
<!-- Color dots (dimmed) -->
<circle cx="9" cy="16" r="2" fill="#7f849c"/>
<circle cx="14" cy="13" r="2" fill="#7f849c"/>
<circle cx="19" cy="16" r="2" fill="#7f849c"/>
<circle cx="14" cy="21" r="2" fill="#7f849c"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- X mark -->
<circle cx="24" cy="8" r="5" fill="#f38ba8"/>
<path d="M22 6 L26 10 M26 6 L22 10" stroke="#11111b" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 748 B

View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<!-- Paint palette -->
<ellipse cx="14" cy="18" rx="10" ry="8" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
<!-- Color dots -->
<circle cx="9" cy="16" r="2" fill="#f38ba8"/>
<circle cx="14" cy="13" r="2" fill="#f9e2af"/>
<circle cx="19" cy="16" r="2" fill="#a6e3a1"/>
<circle cx="14" cy="21" r="2" fill="#89b4fa"/>
<!-- Thumb hole -->
<ellipse cx="10" cy="20" rx="2" ry="1.5" fill="#313244"/>
<!-- Toggle arrows -->
<path d="M22 5 A4 4 0 0 1 26 9" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<path d="M25 5 L22 5 L22 8" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M28 11 A4 4 0 0 1 24 7" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<path d="M25 11 L28 11 L28 8" stroke="#94e2d5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
<path d="M8 10 L24 10 L10 22 L24 22" stroke="#cba6f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="24" cy="10" r="2" fill="#94e2d5"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B