commit 981b15804e4571d0c6db1697d7f72e34cc606c46 Author: Zoe Forbes Date: Sat Jan 24 15:16:09 2026 -0600 first commit diff --git a/CatppuccinMocha/CatppuccinMocha.cfg b/CatppuccinMocha/CatppuccinMocha.cfg new file mode 100644 index 0000000..fc726cc --- /dev/null +++ b/CatppuccinMocha/CatppuccinMocha.cfg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CatppuccinMocha.qss + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CatppuccinMocha/CatppuccinMocha.qss b/CatppuccinMocha/CatppuccinMocha.qss new file mode 100644 index 0000000..aaf927b --- /dev/null +++ b/CatppuccinMocha/CatppuccinMocha.qss @@ -0,0 +1,1249 @@ + +/* ============================================================================= + Catppuccin Mocha Theme for FreeCAD + Bundled with ztools addon + https://catppuccin.com/ + ============================================================================= */ + +/* ============================================================================= + Global Defaults + ============================================================================= */ + +* { + color: #cdd6f4; + font-family: "Segoe UI", "Ubuntu", "Noto Sans", sans-serif; +} + +QWidget { + background-color: #1e1e2e; + color: #cdd6f4; + selection-background-color: #585b70; + selection-color: #cdd6f4; +} + +/* ============================================================================= + Main Window and MDI Area + ============================================================================= */ + +QMainWindow { + background-color: #181825; +} + +QMainWindow::separator { + background-color: #313244; + width: 4px; + height: 4px; +} + +QMainWindow::separator:hover { + background-color: #cba6f7; +} + +QMdiArea { + background-color: #11111b; +} + +QMdiSubWindow { + background-color: #1e1e2e; + border: 1px solid #45475a; +} + +QMdiSubWindow > QWidget { + background-color: #1e1e2e; +} + +/* ============================================================================= + Menu Bar + ============================================================================= */ + +QMenuBar { + background-color: #181825; + color: #cdd6f4; + border-bottom: 1px solid #313244; + padding: 2px; +} + +QMenuBar::item { + background-color: transparent; + padding: 4px 8px; + border-radius: 4px; +} + +QMenuBar::item:selected { + background-color: #313244; +} + +QMenuBar::item:pressed { + background-color: #45475a; +} + +/* ============================================================================= + Menus + ============================================================================= */ + +QMenu { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 6px; + padding: 4px; +} + +QMenu::item { + padding: 6px 24px 6px 8px; + border-radius: 4px; +} + +QMenu::item:selected { + background-color: #45475a; + color: #cdd6f4; +} + +QMenu::item:disabled { + color: #6c7086; +} + +QMenu::separator { + height: 1px; + background-color: #45475a; + margin: 4px 8px; +} + +QMenu::icon { + margin-left: 8px; +} + +QMenu::indicator { + width: 16px; + height: 16px; + margin-left: 4px; +} + +/* ============================================================================= + Toolbars + ============================================================================= */ + +QToolBar { + background-color: #181825; + border: none; + spacing: 2px; + padding: 2px; +} + +QToolBar::handle { + background-color: #45475a; + width: 8px; + margin: 2px; + border-radius: 2px; +} + +QToolBar::handle:horizontal { + width: 8px; +} + +QToolBar::handle:vertical { + height: 8px; +} + +QToolBar::separator { + background-color: #45475a; + width: 1px; + margin: 4px 2px; +} + +/* ============================================================================= + Tool Buttons (Toolbar icons) + ============================================================================= */ + +QToolButton { + background-color: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 4px; + margin: 1px; +} + +QToolButton:hover { + background-color: #313244; + border: 1px solid #45475a; +} + +QToolButton:pressed { + background-color: #45475a; +} + +QToolButton:checked { + background-color: #45475a; + border: 1px solid #cba6f7; +} + +QToolButton:disabled { + color: #6c7086; +} + +QToolButton[popupMode="1"] { + padding-right: 16px; +} + +QToolButton::menu-button { + border: none; + width: 14px; +} + +QToolButton::menu-arrow { + width: 10px; + height: 10px; +} + +/* ============================================================================= + Push Buttons + ============================================================================= */ + +QPushButton { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 6px; + padding: 6px 16px; + min-height: 20px; +} + +QPushButton:hover { + background-color: #45475a; + border-color: #585b70; +} + +QPushButton:pressed { + background-color: #585b70; +} + +QPushButton:checked { + background-color: #cba6f7; + color: #11111b; + border-color: #cba6f7; +} + +QPushButton:disabled { + background-color: #313244; + color: #6c7086; + border-color: #313244; +} + +QPushButton:default { + border: 2px solid #cba6f7; +} + +/* ============================================================================= + Dock Widgets + ============================================================================= */ + +QDockWidget { + background-color: #1e1e2e; + color: #cdd6f4; + titlebar-close-icon: none; + titlebar-normal-icon: none; +} + +QDockWidget::title { + background-color: #181825; + color: #cdd6f4; + padding: 6px; + border-bottom: 1px solid #313244; +} + +QDockWidget::close-button, +QDockWidget::float-button { + background-color: transparent; + border: none; + padding: 2px; +} + +QDockWidget::close-button:hover, +QDockWidget::float-button:hover { + background-color: #313244; + border-radius: 4px; +} + +/* ============================================================================= + Tab Widgets + ============================================================================= */ + +QTabWidget::pane { + background-color: #1e1e2e; + border: 1px solid #45475a; + border-radius: 4px; + top: -1px; +} + +QTabBar { + background-color: transparent; +} + +QTabBar::tab { + background-color: #313244; + color: #bac2de; + border: 1px solid #45475a; + padding: 6px 12px; + margin-right: 2px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; +} + +QTabBar::tab:selected { + background-color: #1e1e2e; + color: #cdd6f4; + border-bottom-color: #1e1e2e; +} + +QTabBar::tab:hover:!selected { + background-color: #45475a; + color: #cdd6f4; +} + +QTabBar::tab:disabled { + color: #6c7086; +} + +QTabBar::close-button { + margin-left: 4px; +} + +QTabBar::close-button:hover { + background-color: #f38ba8; + border-radius: 2px; +} + +/* ============================================================================= + Scroll Bars + ============================================================================= */ + +QScrollBar:horizontal { + background-color: #181825; + height: 12px; + margin: 0 12px 0 12px; + border-radius: 6px; +} + +QScrollBar:vertical { + background-color: #181825; + width: 12px; + margin: 12px 0 12px 0; + border-radius: 6px; +} + +QScrollBar::handle:horizontal { + background-color: #45475a; + min-width: 20px; + border-radius: 5px; + margin: 1px; +} + +QScrollBar::handle:vertical { + background-color: #45475a; + min-height: 20px; + border-radius: 5px; + margin: 1px; +} + +QScrollBar::handle:horizontal:hover, +QScrollBar::handle:vertical:hover { + background-color: #585b70; +} + +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal, +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + width: 12px; + height: 12px; + background-color: #313244; + border-radius: 6px; +} + +QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover { + background-color: #45475a; +} + +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal, +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical { + background-color: transparent; +} + +/* ============================================================================= + Input Fields + ============================================================================= */ + +QLineEdit { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px 8px; + selection-background-color: #cba6f7; + selection-color: #11111b; +} + +QLineEdit:focus { + border-color: #cba6f7; +} + +QLineEdit:disabled { + background-color: #181825; + color: #6c7086; +} + +QLineEdit:read-only { + background-color: #181825; +} + +QTextEdit, QPlainTextEdit { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + selection-background-color: #cba6f7; + selection-color: #11111b; +} + +QTextEdit:focus, QPlainTextEdit:focus { + border-color: #cba6f7; +} + +/* ============================================================================= + Spin Boxes + ============================================================================= */ + +QSpinBox, QDoubleSpinBox { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px; + padding-right: 20px; +} + +QSpinBox:focus, QDoubleSpinBox:focus { + border-color: #cba6f7; +} + +QSpinBox:disabled, QDoubleSpinBox:disabled { + background-color: #181825; + color: #6c7086; +} + +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 16px; + border-left: 1px solid #45475a; + border-top-right-radius: 4px; + background-color: #45475a; +} + +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 16px; + border-left: 1px solid #45475a; + border-bottom-right-radius: 4px; + background-color: #45475a; +} + +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { + background-color: #585b70; +} + +QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed, +QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { + background-color: #cba6f7; +} + +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + width: 8px; + height: 8px; +} + +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + width: 8px; + height: 8px; +} + +/* ============================================================================= + Combo Boxes + ============================================================================= */ + +QComboBox { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px 8px; + padding-right: 24px; + min-height: 20px; +} + +QComboBox:hover { + border-color: #585b70; +} + +QComboBox:focus { + border-color: #cba6f7; +} + +QComboBox:disabled { + background-color: #181825; + color: #6c7086; +} + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border-left: 1px solid #45475a; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: #45475a; +} + +QComboBox::drop-down:hover { + background-color: #585b70; +} + +QComboBox::down-arrow { + width: 10px; + height: 10px; +} + +QComboBox QAbstractItemView { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + selection-background-color: #45475a; + selection-color: #cdd6f4; + outline: none; +} + +QComboBox QAbstractItemView::item { + padding: 4px 8px; + min-height: 24px; +} + +QComboBox QAbstractItemView::item:hover { + background-color: #45475a; +} + +QComboBox QAbstractItemView::item:selected { + background-color: #585b70; +} + +/* ============================================================================= + Check Boxes + ============================================================================= */ + +QCheckBox { + spacing: 8px; + color: #cdd6f4; +} + +QCheckBox:disabled { + color: #6c7086; +} + +QCheckBox::indicator { + width: 18px; + height: 18px; + border: 2px solid #585b70; + border-radius: 4px; + background-color: #313244; +} + +QCheckBox::indicator:hover { + border-color: #cba6f7; +} + +QCheckBox::indicator:checked { + background-color: #cba6f7; + border-color: #cba6f7; +} + +QCheckBox::indicator:checked:disabled { + background-color: #6c7086; + border-color: #6c7086; +} + +QCheckBox::indicator:disabled { + background-color: #181825; + border-color: #45475a; +} + +/* ============================================================================= + Radio Buttons + ============================================================================= */ + +QRadioButton { + spacing: 8px; + color: #cdd6f4; +} + +QRadioButton:disabled { + color: #6c7086; +} + +QRadioButton::indicator { + width: 18px; + height: 18px; + border: 2px solid #585b70; + border-radius: 9px; + background-color: #313244; +} + +QRadioButton::indicator:hover { + border-color: #cba6f7; +} + +QRadioButton::indicator:checked { + background-color: #cba6f7; + border-color: #cba6f7; +} + +QRadioButton::indicator:checked:disabled { + background-color: #6c7086; + border-color: #6c7086; +} + +QRadioButton::indicator:disabled { + background-color: #181825; + border-color: #45475a; +} + +/* ============================================================================= + Sliders + ============================================================================= */ + +QSlider::groove:horizontal { + height: 6px; + background-color: #45475a; + border-radius: 3px; +} + +QSlider::groove:vertical { + width: 6px; + background-color: #45475a; + border-radius: 3px; +} + +QSlider::handle:horizontal { + width: 16px; + height: 16px; + margin: -5px 0; + background-color: #cba6f7; + border-radius: 8px; +} + +QSlider::handle:vertical { + width: 16px; + height: 16px; + margin: 0 -5px; + background-color: #cba6f7; + border-radius: 8px; +} + +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover { + background-color: #b4befe; +} + +QSlider::handle:horizontal:pressed, +QSlider::handle:vertical:pressed { + background-color: #f5c2e7; +} + +QSlider::sub-page:horizontal { + background-color: #cba6f7; + border-radius: 3px; +} + +QSlider::add-page:vertical { + background-color: #cba6f7; + border-radius: 3px; +} + +/* ============================================================================= + Progress Bars + ============================================================================= */ + +QProgressBar { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + text-align: center; + height: 20px; +} + +QProgressBar::chunk { + background-color: #cba6f7; + border-radius: 3px; +} + +/* ============================================================================= + Group Boxes + ============================================================================= */ + +QGroupBox { + background-color: #1e1e2e; + border: 1px solid #45475a; + border-radius: 6px; + margin-top: 12px; + padding-top: 8px; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + padding: 0 4px; + color: #bac2de; + background-color: #1e1e2e; +} + +/* ============================================================================= + Tree View + ============================================================================= */ + +QTreeView { + background-color: #1e1e2e; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + outline: none; +} + +QTreeView::item { + padding: 4px; + border-radius: 2px; +} + +QTreeView::item:hover { + background-color: #313244; +} + +QTreeView::item:selected { + background-color: #45475a; + color: #cdd6f4; +} + +QTreeView::item:selected:active { + background-color: #585b70; +} + +/* Branch indicators (collapse/expand arrows) - uses FreeCAD built-in light images for dark theme */ +QTreeView::branch { + background: transparent; +} + +QTreeView::branch:has-siblings:!adjoins-item { + border-image: url(qss:images_dark-light/branch_vline_light.svg) 0; +} + +QTreeView::branch:has-siblings:adjoins-item { + border-image: url(qss:images_dark-light/branch_more_light.svg) 0; +} + +QTreeView::branch:!has-children:!has-siblings:adjoins-item { + border-image: url(qss:images_dark-light/branch_end_light.svg) 0; +} + +QTreeView::branch:closed:has-children:has-siblings { + border-image: url(qss:images_dark-light/branch_more_closed_light.svg) 0; +} + +QTreeView::branch:has-children:!has-siblings:closed { + border-image: url(qss:images_dark-light/branch_end_closed_light.svg) 0; +} + +QTreeView::branch:open:has-children:has-siblings { + border-image: url(qss:images_dark-light/branch_more_open_light.svg) 0; +} + +QTreeView::branch:open:has-children:!has-siblings { + border-image: url(qss:images_dark-light/branch_end_open_light.svg) 0; +} + +/* ============================================================================= + List View + ============================================================================= */ + +QListView { + background-color: #1e1e2e; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + outline: none; +} + +QListView::item { + padding: 4px; + border-radius: 2px; +} + +QListView::item:hover { + background-color: #313244; +} + +QListView::item:selected { + background-color: #45475a; + color: #cdd6f4; +} + +/* ============================================================================= + Table View + ============================================================================= */ + +QTableView { + background-color: #1e1e2e; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + gridline-color: #313244; + outline: none; +} + +QTableView::item { + padding: 4px; +} + +QTableView::item:hover { + background-color: #313244; +} + +QTableView::item:selected { + background-color: #45475a; + color: #cdd6f4; +} + +QTableView QTableCornerButton::section { + background-color: #313244; + border: 1px solid #45475a; +} + +/* ============================================================================= + Header Views (for Tables/Trees) + ============================================================================= */ + +QHeaderView { + background-color: #313244; + border: none; +} + +QHeaderView::section { + background-color: #313244; + color: #bac2de; + border: none; + border-right: 1px solid #45475a; + border-bottom: 1px solid #45475a; + padding: 6px 8px; +} + +QHeaderView::section:hover { + background-color: #45475a; + color: #cdd6f4; +} + +QHeaderView::section:checked { + background-color: #45475a; +} + +QHeaderView::down-arrow { + width: 10px; + height: 10px; +} + +QHeaderView::up-arrow { + width: 10px; + height: 10px; +} + +/* ============================================================================= + Splitters + ============================================================================= */ + +QSplitter::handle { + background-color: #313244; +} + +QSplitter::handle:horizontal { + width: 4px; +} + +QSplitter::handle:vertical { + height: 4px; +} + +QSplitter::handle:hover { + background-color: #cba6f7; +} + +/* ============================================================================= + Status Bar + ============================================================================= */ + +QStatusBar { + background-color: #181825; + color: #bac2de; + border-top: 1px solid #313244; +} + +QStatusBar::item { + border: none; +} + +QStatusBar QLabel { + padding: 2px 8px; +} + +/* ============================================================================= + Tooltips + ============================================================================= */ + +QToolTip { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px 8px; +} + +/* ============================================================================= + Labels + ============================================================================= */ + +QLabel { + color: #cdd6f4; + background-color: transparent; +} + +QLabel:disabled { + color: #6c7086; +} + +/* ============================================================================= + Frames + ============================================================================= */ + +QFrame { + border: none; +} + +QFrame[frameShape="4"] { + /* HLine */ + background-color: #45475a; + max-height: 1px; +} + +QFrame[frameShape="5"] { + /* VLine */ + background-color: #45475a; + max-width: 1px; +} + +/* ============================================================================= + Tool Box (Collapsible sections) + ============================================================================= */ + +QToolBox { + background-color: #1e1e2e; + border: 1px solid #45475a; + border-radius: 4px; +} + +QToolBox::tab { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 8px; +} + +QToolBox::tab:selected { + background-color: #45475a; + border-color: #cba6f7; +} + +QToolBox::tab:hover { + background-color: #45475a; +} + +/* ============================================================================= + Dialog Buttons + ============================================================================= */ + +QDialogButtonBox { + button-layout: 0; +} + +/* ============================================================================= + Date/Time Edits + ============================================================================= */ + +QDateEdit, QTimeEdit, QDateTimeEdit { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px; +} + +QDateEdit:focus, QTimeEdit:focus, QDateTimeEdit:focus { + border-color: #cba6f7; +} + +QDateEdit::drop-down, QTimeEdit::drop-down, QDateTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border-left: 1px solid #45475a; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: #45475a; +} + +QCalendarWidget { + background-color: #1e1e2e; +} + +QCalendarWidget QToolButton { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + margin: 2px; +} + +QCalendarWidget QToolButton:hover { + background-color: #45475a; +} + +QCalendarWidget QMenu { + background-color: #313244; +} + +QCalendarWidget QSpinBox { + background-color: #313244; +} + +QCalendarWidget QAbstractItemView { + background-color: #1e1e2e; + selection-background-color: #cba6f7; + selection-color: #11111b; +} + +/* ============================================================================= + Wizard + ============================================================================= */ + +QWizard { + background-color: #1e1e2e; +} + +QWizard QLabel { + color: #cdd6f4; +} + +/* ============================================================================= + FreeCAD Specific Widgets + ============================================================================= */ + +/* Property Editor */ +Gui--PropertyEditor--PropertyEditor { + background-color: #1e1e2e; + color: #cdd6f4; + border: 1px solid #45475a; + qproperty-groupBackground: #313244; + qproperty-groupTextColor: #bac2de; + qproperty-itemBackground: #1e1e2e; +} + +Gui--PropertyEditor--PropertyEditor QLineEdit { + background-color: #313244; + border: 1px solid #45475a; +} + +Gui--PropertyEditor--PropertyEditor QComboBox { + background-color: #313244; +} + +/* Color Button */ +Gui--ColorButton { + background-color: #313244; + border: 1px solid #45475a; + border-radius: 4px; + padding: 2px; +} + +Gui--ColorButton:hover { + border-color: #cba6f7; +} + +/* Workbench Selector */ +Gui--WorkbenchComboBox { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; + padding: 4px 8px; +} + +Gui--WorkbenchComboBox:hover { + border-color: #585b70; +} + +Gui--WorkbenchComboBox::drop-down { + background-color: #45475a; + border-left: 1px solid #45475a; + border-radius: 0 4px 4px 0; +} + +/* Task Panel */ +QSint--ActionGroup { + background-color: #313244; + border: 1px solid #45475a; + border-radius: 6px; +} + +QSint--ActionGroup QToolButton { + background-color: #313244; + color: #cdd6f4; + border: none; + border-radius: 4px; + padding: 6px; +} + +QSint--ActionGroup QToolButton:hover { + background-color: #45475a; +} + +QSint--ActionGroup QFrame { + background-color: #1e1e2e; + border: none; + border-radius: 4px; +} + +/* Input Field */ +Gui--InputField { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 4px; +} + +Gui--InputField:focus { + border-color: #cba6f7; +} + +/* Expression Completer */ +Gui--ExpressionCompleter { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; +} + +/* Spreadsheet */ +SpreadsheetGui--SheetTableView { + background-color: #1e1e2e; + color: #cdd6f4; + gridline-color: #45475a; + selection-background-color: #45475a; + selection-color: #cdd6f4; +} + +SpreadsheetGui--SheetTableView QHeaderView::section { + background-color: #313244; + color: #bac2de; + border: 1px solid #45475a; + padding: 4px; +} + +/* Python Console */ +Gui--PythonConsole { + background-color: #11111b; + color: #cdd6f4; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + selection-background-color: #45475a; +} + +/* Python Editor */ +Gui--PythonEditor { + background-color: #11111b; + color: #cdd6f4; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + selection-background-color: #cba6f7; + selection-color: #11111b; +} + +/* Report View */ +Gui--DockWnd--ReportOutput { + background-color: #11111b; + color: #cdd6f4; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; +} + +/* DAG View */ +Gui--DAG--Model { + background-color: #1e1e2e; +} + +/* ============================================================================= + Sketcher Specific Styles + ============================================================================= */ + +/* Sketcher constraint colors are handled via preferences, not QSS */ + +/* ============================================================================= + Syntax Highlighting Colors (Python Editor) + Note: These are typically set via FreeCAD preferences, but we define them here + for reference and any widgets that support them. + ============================================================================= */ + +/* + Python Editor Syntax Colors (Catppuccin Mocha): + - Comment: #7f849c + - Number: #fab387 + - String: #a6e3a1 + - Keyword: #cba6f7 + - Class/Def name: #89b4fa + - Operator: #89dceb + - Output: #cdd6f4 + - Error: #f38ba8 +*/ + +/* ============================================================================= + Custom Color Accents by Context + ============================================================================= */ + +/* Success states */ +*[state="success"] { + color: #a6e3a1; +} + +/* Warning states */ +*[state="warning"] { + color: #f9e2af; +} + +/* Error states */ +*[state="error"] { + color: #f38ba8; +} + +/* Info states */ +*[state="info"] { + color: #89b4fa; +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2d881a --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# Makefile for ZTools FreeCAD Workbench +# Installs to local flatpak FreeCAD instance + +WORKBENCH_NAME := ZTools +FLATPAK_APP := org.freecad.FreeCAD + +# FreeCAD Mod directory for flatpak +FLATPAK_MOD_DIR := $(HOME)/.var/app/$(FLATPAK_APP)/data/FreeCAD/Mod +INSTALL_DIR := $(FLATPAK_MOD_DIR)/$(WORKBENCH_NAME) + +.PHONY: all install uninstall clean check-flatpak list dev-install dev-uninstall run help + +all: install + +# Check if flatpak FreeCAD is installed +check-flatpak: + @if ! flatpak list | grep -q $(FLATPAK_APP); then \ + echo "Error: FreeCAD flatpak ($(FLATPAK_APP)) is not installed"; \ + exit 1; \ + fi + +# Install the workbench +install: check-flatpak + @echo "Installing $(WORKBENCH_NAME) workbench to flatpak FreeCAD..." + @mkdir -p $(INSTALL_DIR)/ztools/commands + @mkdir -p $(INSTALL_DIR)/ztools/datums + @mkdir -p $(INSTALL_DIR)/ztools/resources/icons + @cp -v package.xml $(INSTALL_DIR)/ + @cp -v ztools/InitGui.py $(INSTALL_DIR)/ + @cp -v ztools/ztools/*.py $(INSTALL_DIR)/ztools/ + @cp -v ztools/ztools/commands/*.py $(INSTALL_DIR)/ztools/commands/ + @cp -v ztools/ztools/datums/*.py $(INSTALL_DIR)/ztools/datums/ + @cp -v ztools/ztools/resources/*.py $(INSTALL_DIR)/ztools/resources/ + @cp -v ztools/ztools/resources/icons/*.svg $(INSTALL_DIR)/ztools/resources/icons/ + @echo "Installation complete!" + @echo "Restart FreeCAD to load the workbench." + +# Uninstall the workbench +uninstall: + @echo "Uninstalling $(WORKBENCH_NAME) workbench..." + @rm -rf $(INSTALL_DIR) + @echo "Uninstallation complete!" + +# Clean build artifacts (if any) +clean: + @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + @find . -type f -name "*.pyc" -delete 2>/dev/null || true + @echo "Cleaned build artifacts." + +# List installed files +list: + @echo "Installed files in $(INSTALL_DIR):" + @find $(INSTALL_DIR) -type f 2>/dev/null || echo "Workbench not installed." + +# Development: install with symlink for live editing +dev-install: check-flatpak + @echo "Installing $(WORKBENCH_NAME) workbench (dev mode with symlink)..." + @mkdir -p $(FLATPAK_MOD_DIR) + @rm -rf $(INSTALL_DIR) + @ln -sfv $(CURDIR) $(INSTALL_DIR) + @echo "Dev installation complete (symlinked)!" + @echo "Changes to source files will be reflected immediately (restart FreeCAD to reload)." + +# Remove dev symlink +dev-uninstall: + @echo "Removing dev symlink..." + @rm -f $(INSTALL_DIR) + @echo "Dev uninstallation complete!" + +# Run FreeCAD flatpak +run: + @echo "Starting FreeCAD flatpak..." + @flatpak run $(FLATPAK_APP) + +# Show help +help: + @echo "ZTools Workbench Makefile" + @echo "" + @echo "Targets:" + @echo " install - Install workbench to flatpak FreeCAD" + @echo " uninstall - Remove workbench from flatpak FreeCAD" + @echo " dev-install - Install as symlink for development" + @echo " dev-uninstall- Remove development symlink" + @echo " clean - Remove Python cache files" + @echo " list - List installed files" + @echo " run - Start FreeCAD flatpak" + @echo " help - Show this help message" + @echo "" + @echo "Install directory: $(INSTALL_DIR)" diff --git a/TODO_ATTACHMENT_WORK.md b/TODO_ATTACHMENT_WORK.md new file mode 100644 index 0000000..0c13a7f --- /dev/null +++ b/TODO_ATTACHMENT_WORK.md @@ -0,0 +1,58 @@ +# Datum Attachment Work - In Progress + +## Context +Implementing proper FreeCAD attachment for datum objects to avoid "deactivated attachment mode" warnings. +The pattern is adding `source_object` and `source_subname` parameters to each datum function and using `_setup_datum_attachment()` with appropriate MapModes. + +## Completed Functions (in core.py) + +### Planes +- `plane_offset_from_face` - MapMode='FlatFace' +- `plane_midplane` - MapMode='TwoFace' +- `plane_from_3_points` - MapMode='ThreePointPlane' +- `plane_normal_to_edge` - MapMode='NormalToPath' +- `plane_angled` - MapMode='FlatFace' with rotation offset +- `plane_tangent_to_cylinder` - MapMode='Tangent' + +### Axes +- `axis_from_2_points` - MapMode='TwoPointLine' +- `axis_from_edge` - MapMode='ObjectXY' +- `axis_cylinder_center` - MapMode='ObjectZ' +- `axis_intersection_planes` - MapMode='TwoFace' + +### Points +- `point_at_vertex` - MapMode='Vertex' + +## Remaining Functions to Update (in core.py) + +- `point_at_coordinates` - No attachment needed (explicit coordinates), but could use 'Translate' mode +- `point_on_edge` - Use MapMode='OnEdge' with MapPathParameter for position +- `point_center_of_face` - Use MapMode='CenterOfCurvature' or similar +- `point_center_of_circle` - Use MapMode='CenterOfCurvature' + +## After core.py Updates + +Update `datum_commands.py` to pass source references to the remaining point functions: +- `create_point_at_vertex` - already done +- `create_point_on_edge` - needs update +- `create_point_center_face` - needs update +- `create_point_center_circle` - needs update + +## Pattern for Updates + +1. Add parameters to function signature: + ```python + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, + ``` + +2. In the body section, use attachment instead of placement: + ```python + if source_object and source_subname: + support = [(source_object, source_subname)] + _setup_datum_attachment(point, support, "MapMode") + else: + _setup_datum_placement(point, App.Placement(...)) + ``` + +3. Update datum_commands.py to extract and pass source references from selection. diff --git a/package.xml b/package.xml new file mode 100644 index 0000000..4f29cd2 --- /dev/null +++ b/package.xml @@ -0,0 +1,32 @@ + + + + ZTools + + Extended PartDesign workbench with velocity-focused tools, advanced datum creation, and Catppuccin Mocha theme. + + 0.1.0 + + 2026-01-24 + + LGPL-3.0-or-later + + + + ZTools + ZToolsWorkbench + ./ztools + + + CatppuccinMocha + Catppuccin Mocha dark theme - soothing pastel colors for the high-spirited + ./CatppuccinMocha + color + dark + catppuccin + mocha + theme + + + + diff --git a/partdesign.md b/partdesign.md new file mode 100644 index 0000000..333dbf9 --- /dev/null +++ b/partdesign.md @@ -0,0 +1,591 @@ +# FreeCAD 1.0.2 PartDesign Workbench Command Reference + +## Overview + +The PartDesign Workbench uses a **feature-based parametric methodology** where a component is represented by a Body container. Features are cumulative—each builds on the result of preceding features. Most features are based on parametric sketches and are either additive (adding material) or subtractive (removing material). + +FreeCAD 1.0 introduced significant improvements including **Topological Naming Problem (TNP) mitigation**, making parametric models more stable when earlier features are modified. + +--- + +## Structure & Containers + +### Body +The fundamental container for PartDesign features. Defines a local coordinate system and contains all features that define a single solid component. + +```python +body = doc.addObject('PartDesign::Body', 'Body') +``` + +**Properties:** +- `Tip` — The feature representing the current state of the body +- `BaseFeature` — Optional external solid to build upon +- `Origin` — Contains reference planes (XY, XZ, YZ) and axes (X, Y, Z) + +### Part Container +Groups multiple Bodies for organization. Not a PartDesign-specific object but commonly used. + +```python +part = doc.addObject('App::Part', 'Part') +``` + +--- + +## Sketch Tools + +| Command | Description | +|---------|-------------| +| **Create Sketch** | Creates a new sketch on a selected face or datum plane | +| **Attach Sketch** | Attaches a sketch to geometry from the active body | +| **Edit Sketch** | Opens selected sketch for editing | +| **Validate Sketch** | Verifies tolerance of points and adjusts them | +| **Check Geometry** | Checks geometry for errors | + +```python +# Create sketch attached to XY plane +sketch = body.newObject('Sketcher::SketchObject', 'Sketch') +sketch.AttachmentSupport = [(body.getObject('Origin').getObject('XY_Plane'), '')] +sketch.MapMode = 'FlatFace' +``` + +--- + +## Reference Geometry (Datums) + +### Datum Plane +Creates a reference plane for sketch attachment or as a mirror/pattern reference. + +```python +plane = body.newObject('PartDesign::Plane', 'DatumPlane') +plane.AttachmentSupport = [(face_reference, '')] +plane.MapMode = 'FlatFace' +plane.Offset = App.Vector(0, 0, 10) # Offset along normal +``` + +### Datum Line +Creates a reference axis for revolutions, grooves, or patterns. + +```python +line = body.newObject('PartDesign::Line', 'DatumLine') +line.AttachmentSupport = [(edge_reference, '')] +line.MapMode = 'ObjectXY' +``` + +### Datum Point +Creates a reference point for geometry attachment. + +```python +point = body.newObject('PartDesign::Point', 'DatumPoint') +point.AttachmentSupport = [(vertex_reference, '')] +``` + +### Local Coordinate System +Creates a local coordinate system (LCS) attached to datum geometry. + +```python +lcs = body.newObject('PartDesign::CoordinateSystem', 'LocalCS') +``` + +### Shape Binder +References geometry from a single parent object. + +```python +binder = body.newObject('PartDesign::ShapeBinder', 'ShapeBinder') +binder.Support = [(external_object, ['Face1'])] +``` + +### SubShapeBinder +References geometry from one or more parent objects (more flexible than ShapeBinder). + +```python +subbinder = body.newObject('PartDesign::SubShapeBinder', 'SubShapeBinder') +subbinder.Support = [(obj1, ['Face1']), (obj2, ['Edge2'])] +``` + +### Clone +Creates a clone of a selected body. + +```python +clone = doc.addObject('PartDesign::FeatureBase', 'Clone') +clone.BaseFeature = source_body +``` + +--- + +## Additive Features (Add Material) + +### Pad +Extrudes a sketch profile to create a solid. + +```python +pad = body.newObject('PartDesign::Pad', 'Pad') +pad.Profile = sketch +pad.Length = 20.0 +pad.Type = 0 # 0=Dimension, 1=UpToLast, 2=UpToFirst, 3=UpToFace, 4=TwoLengths, 5=UpToShape +pad.Reversed = False +pad.Midplane = False +pad.Symmetric = False +pad.Length2 = 10.0 # For TwoLengths type +pad.UseCustomVector = False +pad.Direction = App.Vector(0, 0, 1) +pad.TaperAngle = 0.0 # Draft angle (new in 1.0) +pad.TaperAngle2 = 0.0 +``` + +**Type Options:** +| Value | Mode | Description | +|-------|------|-------------| +| 0 | Dimension | Fixed length | +| 1 | UpToLast | Extends to last face in direction | +| 2 | UpToFirst | Extends to first face encountered | +| 3 | UpToFace | Extends to selected face | +| 4 | TwoLengths | Extends in both directions | +| 5 | UpToShape | Extends to selected shape (new in 1.0) | + +### Revolution +Creates a solid by revolving a sketch around an axis. + +```python +revolution = body.newObject('PartDesign::Revolution', 'Revolution') +revolution.Profile = sketch +revolution.Axis = (body.getObject('Origin').getObject('Z_Axis'), ['']) +revolution.Angle = 360.0 +revolution.Midplane = False +revolution.Reversed = False +``` + +### Additive Loft +Creates a solid by transitioning between two or more sketch profiles. + +```python +loft = body.newObject('PartDesign::AdditiveLoft', 'AdditiveLoft') +loft.Profile = sketch1 +loft.Sections = [sketch2, sketch3] +loft.Ruled = False +loft.Closed = False +``` + +### Additive Pipe (Sweep) +Creates a solid by sweeping a profile along a path. + +```python +pipe = body.newObject('PartDesign::AdditivePipe', 'AdditivePipe') +pipe.Profile = profile_sketch +pipe.Spine = path_sketch # or (object, ['Edge1', 'Edge2']) +pipe.Transition = 0 # 0=Transformed, 1=RightCorner, 2=RoundCorner +pipe.Mode = 0 # 0=Standard, 1=Fixed, 2=Frenet, 3=Auxiliary +pipe.Auxiliary = None # Auxiliary spine for Mode=3 +``` + +### Additive Helix +Creates a solid by sweeping a sketch along a helix. + +```python +helix = body.newObject('PartDesign::AdditiveHelix', 'AdditiveHelix') +helix.Profile = sketch +helix.Axis = (body.getObject('Origin').getObject('Z_Axis'), ['']) +helix.Pitch = 5.0 +helix.Height = 30.0 +helix.Turns = 6.0 +helix.Mode = 0 # 0=pitch-height, 1=pitch-turns, 2=height-turns +helix.LeftHanded = False +helix.Reversed = False +helix.Angle = 0.0 # Taper angle +helix.Growth = 0.0 # Radius growth per turn +``` + +### Additive Primitives +Direct primitive creation without sketches. + +```python +# Box +box = body.newObject('PartDesign::AdditiveBox', 'Box') +box.Length = 10.0 +box.Width = 10.0 +box.Height = 10.0 + +# Cylinder +cyl = body.newObject('PartDesign::AdditiveCylinder', 'Cylinder') +cyl.Radius = 5.0 +cyl.Height = 20.0 +cyl.Angle = 360.0 + +# Sphere +sphere = body.newObject('PartDesign::AdditiveSphere', 'Sphere') +sphere.Radius = 10.0 +sphere.Angle1 = -90.0 +sphere.Angle2 = 90.0 +sphere.Angle3 = 360.0 + +# Cone +cone = body.newObject('PartDesign::AdditiveCone', 'Cone') +cone.Radius1 = 10.0 +cone.Radius2 = 5.0 +cone.Height = 15.0 +cone.Angle = 360.0 + +# Ellipsoid +ellipsoid = body.newObject('PartDesign::AdditiveEllipsoid', 'Ellipsoid') +ellipsoid.Radius1 = 10.0 +ellipsoid.Radius2 = 5.0 +ellipsoid.Radius3 = 8.0 + +# Torus +torus = body.newObject('PartDesign::AdditiveTorus', 'Torus') +torus.Radius1 = 20.0 +torus.Radius2 = 5.0 + +# Prism +prism = body.newObject('PartDesign::AdditivePrism', 'Prism') +prism.Polygon = 6 +prism.Circumradius = 10.0 +prism.Height = 20.0 + +# Wedge +wedge = body.newObject('PartDesign::AdditiveWedge', 'Wedge') +wedge.Xmin = 0.0 +wedge.Xmax = 10.0 +wedge.Ymin = 0.0 +wedge.Ymax = 10.0 +wedge.Zmin = 0.0 +wedge.Zmax = 10.0 +wedge.X2min = 2.0 +wedge.X2max = 8.0 +wedge.Z2min = 2.0 +wedge.Z2max = 8.0 +``` + +--- + +## Subtractive Features (Remove Material) + +### Pocket +Cuts material by extruding a sketch inward. + +```python +pocket = body.newObject('PartDesign::Pocket', 'Pocket') +pocket.Profile = sketch +pocket.Length = 15.0 +pocket.Type = 0 # Same options as Pad, plus 1=ThroughAll +pocket.Reversed = False +pocket.Midplane = False +pocket.Symmetric = False +pocket.TaperAngle = 0.0 +``` + +### Hole +Creates parametric holes with threading options. + +```python +hole = body.newObject('PartDesign::Hole', 'Hole') +hole.Profile = sketch # Sketch with center points +hole.Diameter = 6.0 +hole.Depth = 15.0 +hole.DepthType = 0 # 0=Dimension, 1=ThroughAll +hole.Threaded = True +hole.ThreadType = 0 # 0=None, 1=ISOMetricCoarse, 2=ISOMetricFine, 3=UNC, 4=UNF, 5=NPT, etc. +hole.ThreadSize = 'M6' +hole.ThreadFit = 0 # 0=Standard, 1=Close +hole.ThreadDirection = 0 # 0=Right, 1=Left +hole.HoleCutType = 0 # 0=None, 1=Counterbore, 2=Countersink +hole.HoleCutDiameter = 10.0 +hole.HoleCutDepth = 3.0 +hole.HoleCutCountersinkAngle = 90.0 +hole.DrillPoint = 0 # 0=Flat, 1=Angled +hole.DrillPointAngle = 118.0 +hole.DrillForDepth = False +``` + +**Thread Types:** +- ISO Metric Coarse/Fine +- UNC/UNF (Unified National) +- NPT/NPTF (National Pipe Thread) +- BSW/BSF (British Standard) +- UTS (Unified Thread Standard) + +### Groove +Creates a cut by revolving a sketch around an axis (subtractive revolution). + +```python +groove = body.newObject('PartDesign::Groove', 'Groove') +groove.Profile = sketch +groove.Axis = (body.getObject('Origin').getObject('Z_Axis'), ['']) +groove.Angle = 360.0 +groove.Midplane = False +groove.Reversed = False +``` + +### Subtractive Loft +Cuts by transitioning between profiles. + +```python +subloft = body.newObject('PartDesign::SubtractiveLoft', 'SubtractiveLoft') +subloft.Profile = sketch1 +subloft.Sections = [sketch2] +``` + +### Subtractive Pipe +Cuts by sweeping a profile along a path. + +```python +subpipe = body.newObject('PartDesign::SubtractivePipe', 'SubtractivePipe') +subpipe.Profile = profile_sketch +subpipe.Spine = path_sketch +``` + +### Subtractive Helix +Cuts by sweeping along a helix (e.g., for threads). + +```python +subhelix = body.newObject('PartDesign::SubtractiveHelix', 'SubtractiveHelix') +subhelix.Profile = thread_profile_sketch +subhelix.Axis = (body.getObject('Origin').getObject('Z_Axis'), ['']) +subhelix.Pitch = 1.0 +subhelix.Height = 10.0 +``` + +### Subtractive Primitives +Same primitives as additive, but subtract material: +- `PartDesign::SubtractiveBox` +- `PartDesign::SubtractiveCylinder` +- `PartDesign::SubtractiveSphere` +- `PartDesign::SubtractiveCone` +- `PartDesign::SubtractiveEllipsoid` +- `PartDesign::SubtractiveTorus` +- `PartDesign::SubtractivePrism` +- `PartDesign::SubtractiveWedge` + +--- + +## Transformation Features (Patterns) + +### Mirrored +Creates a mirror copy of features across a plane. + +```python +mirrored = body.newObject('PartDesign::Mirrored', 'Mirrored') +mirrored.Originals = [pad, pocket] +mirrored.MirrorPlane = (body.getObject('Origin').getObject('XZ_Plane'), ['']) +``` + +### Linear Pattern +Creates copies in a linear arrangement. + +```python +linear = body.newObject('PartDesign::LinearPattern', 'LinearPattern') +linear.Originals = [pocket] +linear.Direction = (body.getObject('Origin').getObject('X_Axis'), ['']) +linear.Length = 100.0 +linear.Occurrences = 5 +linear.Mode = 0 # 0=OverallLength, 1=Offset +``` + +### Polar Pattern +Creates copies in a circular arrangement. + +```python +polar = body.newObject('PartDesign::PolarPattern', 'PolarPattern') +polar.Originals = [pocket] +polar.Axis = (body.getObject('Origin').getObject('Z_Axis'), ['']) +polar.Angle = 360.0 +polar.Occurrences = 6 +polar.Mode = 0 # 0=OverallAngle, 1=Offset +``` + +### MultiTransform +Combines multiple transformations (mirrored, linear, polar, scaled). + +```python +multi = body.newObject('PartDesign::MultiTransform', 'MultiTransform') +multi.Originals = [pocket] + +# Add transformations (created within MultiTransform) +# Typically done via GUI or by setting Transformations property +multi.Transformations = [mirrored_transform, linear_transform] +``` + +### Scaled +Scales features (only available within MultiTransform). + +```python +# Only accessible as part of MultiTransform +scaled = body.newObject('PartDesign::Scaled', 'Scaled') +scaled.Factor = 0.5 +scaled.Occurrences = 3 +``` + +--- + +## Dress-Up Features (Edge/Face Treatment) + +### Fillet +Rounds edges with a specified radius. + +```python +fillet = body.newObject('PartDesign::Fillet', 'Fillet') +fillet.Base = (pad, ['Edge1', 'Edge5', 'Edge9']) +fillet.Radius = 2.0 +``` + +### Chamfer +Bevels edges. + +```python +chamfer = body.newObject('PartDesign::Chamfer', 'Chamfer') +chamfer.Base = (pad, ['Edge2', 'Edge6']) +chamfer.ChamferType = 'Equal Distance' # or 'Two Distances' or 'Distance and Angle' +chamfer.Size = 1.5 +chamfer.Size2 = 2.0 # For asymmetric +chamfer.Angle = 45.0 # For 'Distance and Angle' +``` + +### Draft +Applies angular draft to faces (for mold release). + +```python +draft = body.newObject('PartDesign::Draft', 'Draft') +draft.Base = (pad, ['Face2', 'Face4']) +draft.Angle = 3.0 # Degrees +draft.NeutralPlane = (body.getObject('Origin').getObject('XY_Plane'), ['']) +draft.PullDirection = App.Vector(0, 0, 1) +draft.Reversed = False +``` + +### Thickness +Creates a shell by hollowing out a solid, keeping selected faces open. + +```python +thickness = body.newObject('PartDesign::Thickness', 'Thickness') +thickness.Base = (pad, ['Face6']) # Faces to remove (open) +thickness.Value = 2.0 # Wall thickness +thickness.Mode = 0 # 0=Skin, 1=Pipe, 2=RectoVerso +thickness.Join = 0 # 0=Arc, 1=Intersection +thickness.Reversed = False +``` + +--- + +## Boolean Operations + +### Boolean +Imports bodies and applies boolean operations. + +```python +boolean = body.newObject('PartDesign::Boolean', 'Boolean') +boolean.Type = 0 # 0=Fuse, 1=Cut, 2=Common (intersection) +boolean.Bodies = [other_body1, other_body2] +``` + +--- + +## Context Menu Commands + +| Command | Description | +|---------|-------------| +| **Set Tip** | Sets selected feature as the body's current state (tip) | +| **Move object to other body** | Transfers feature to a different body | +| **Move object after other object** | Reorders features in the tree | +| **Appearance** | Sets color and transparency | +| **Color per face** | Assigns different colors to individual faces | + +```python +# Set tip programmatically +body.Tip = pocket + +# Move feature order +doc.moveObject(feature, body, after_feature) +``` + +--- + +## Additional Tools + +### Sprocket +Creates a sprocket profile for chain drives. + +```python +# Available via Gui.runCommand('PartDesign_Sprocket') +``` + +### Involute Gear +Creates an involute gear profile. + +```python +# Available via Gui.runCommand('PartDesign_InvoluteGear') +``` + +--- + +## Common Properties (All Features) + +| Property | Type | Description | +|----------|------|-------------| +| `Label` | String | User-visible name | +| `Placement` | Placement | Position and orientation | +| `BaseFeature` | Link | Feature this builds upon | +| `Shape` | Shape | Resulting geometry | + +--- + +## Expression Binding + +All dimensional properties can be driven by expressions: + +```python +pad.setExpression('Length', 'Spreadsheet.plate_height') +fillet.setExpression('Radius', 'Spreadsheet.fillet_r * 0.5') +hole.setExpression('Diameter', '<>.hole_dia') +``` + +--- + +## Best Practices + +1. **Always work within a Body** — PartDesign features require a body container +2. **Use fully constrained sketches** — Prevents unexpected behavior when parameters change +3. **Leverage datum geometry** — Creates stable references that survive TNP issues +4. **Name constraints** — Enables expression-based parametric design +5. **Use spreadsheets** — Centralizes parameters for easy modification +6. **Set meaningful Labels** — Internal Names are auto-generated; Labels are user-friendly +7. **Check isSolid()** — Before subtractive operations, verify the body has solid geometry + +```python +if not body.isSolid(): + raise ValueError("Body must contain solid geometry for subtractive features") +``` + +--- + +## FreeCAD 1.0 Changes + +| Change | Description | +|--------|-------------| +| **TNP Mitigation** | Topological naming more stable | +| **UpToShape** | New Pad/Pocket type extending to arbitrary shapes | +| **Draft Angle** | Taper angles on Pad/Pocket | +| **Improved Hole** | More thread types, better UI | +| **Assembly Integration** | Native assembly workbench | +| **Arch → BIM** | Workbench rename | +| **Path → CAM** | Workbench rename | + +--- + +## Python Module Access + +```python +import FreeCAD as App +import FreeCADGui as Gui +import Part +import Sketcher +import PartDesign +import PartDesignGui + +# Access feature classes +print(dir(PartDesign)) +# ['Additive', 'AdditiveBox', 'AdditiveCone', 'AdditiveCylinder', ...] +``` + +--- + +*Document version: FreeCAD 1.0.2 / January 2026* +*Reference: FreeCAD Wiki, GitHub FreeCAD-documentation, FreeCAD Forum* diff --git a/ztools/Init.py b/ztools/Init.py new file mode 100644 index 0000000..79204b3 --- /dev/null +++ b/ztools/Init.py @@ -0,0 +1,12 @@ +# ztools Addon Initialization +# This file runs at FreeCAD startup (before GUI) + +import FreeCAD as App + +# The Catppuccin Mocha theme is now provided as a Preference Pack. +# It will be automatically available in: +# Edit > Preferences > General > Preference packs > CatppuccinMocha +# +# No manual installation is required - FreeCAD's addon system handles it. + +App.Console.PrintLog("ztools addon loaded\n") diff --git a/ztools/InitGui.py b/ztools/InitGui.py new file mode 100644 index 0000000..2507c31 --- /dev/null +++ b/ztools/InitGui.py @@ -0,0 +1,233 @@ +# 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 + try: + # Get list of available workbenches + wb_list = Gui.listWorkbenches() + + # Initialize PartDesign workbench if available + if "PartDesignWorkbench" in wb_list: + pd_wb = Gui.getWorkbench("PartDesignWorkbench") + # Call Initialize if not already done + if hasattr(pd_wb, "Initialize"): + pd_wb.Initialize() + + # Initialize Sketcher workbench if available + if "SketcherWorkbench" in wb_list: + sketcher_wb = Gui.getWorkbench("SketcherWorkbench") + if hasattr(sketcher_wb, "Initialize"): + sketcher_wb.Initialize() + + except Exception as e: + App.Console.PrintWarning(f"Could not initialize PartDesign/Sketcher: {e}\n") + + from ztools.commands import datum_commands, pattern_commands, pocket_commands + + # ===================================================================== + # 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", + ] + + # ===================================================================== + # 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("ztools Datums", self.ztools_datum_tools) + self.appendToolbar("ztools Patterns", self.ztools_pattern_tools) + self.appendToolbar("ztools Features", self.ztools_pocket_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( + "ztools", + self.ztools_datum_tools + + self.ztools_pattern_tools + + self.ztools_pocket_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()) diff --git a/ztools/README.md b/ztools/README.md new file mode 100644 index 0000000..b1d9e4a --- /dev/null +++ b/ztools/README.md @@ -0,0 +1,123 @@ +# ztools - Extended PartDesign for FreeCAD + +Velocity-focused CAD tools extending FreeCAD 1.0+ PartDesign workbench. + +## Installation + +### Manual Installation + +1. Copy the `ztools` folder to your FreeCAD Mod directory: + - **Linux**: `~/.local/share/FreeCAD/Mod/ztools/` + - **Windows**: `%APPDATA%\FreeCAD\Mod\ztools\` + - **macOS**: `~/Library/Application Support/FreeCAD/Mod/ztools/` + +2. Restart FreeCAD + +3. Select **ztools** from the workbench dropdown + +### Directory Structure + +``` +ztools/ +├── InitGui.py # Workbench registration +├── ztools/ +│ ├── __init__.py +│ ├── datums/ +│ │ ├── __init__.py +│ │ └── core.py # Datum creation functions +│ └── commands/ +│ ├── __init__.py +│ └── datum_commands.py # GUI commands +├── setup.cfg +└── README.md +``` + +## Module 1: Datum Tools + +### Datum Creator (GUI) + +Unified task panel for creating: + +**Planes** +- Offset from Face +- Midplane (2 parallel faces) +- 3 Points +- Normal to Edge (at parameter) +- Angled from Face (about edge) +- Tangent to Cylinder + +**Axes** +- 2 Points +- From Edge +- Cylinder Center +- Plane Intersection + +**Points** +- At Vertex +- XYZ Coordinates +- On Edge (at parameter) +- Face Center +- Circle Center + +### Options + +- **Link to Spreadsheet**: Creates aliases in Spreadsheet for parametric control +- **Add to Active Body**: Creates PartDesign datums vs Part geometry +- **Custom Name**: Override auto-naming (e.g., `ZPlane_Offset_001`) + +### Python API + +```python +from ztools.datums import ( + plane_offset_from_face, + plane_midplane, + axis_cylinder_center, + point_at_coordinates, +) + +doc = App.ActiveDocument +body = doc.getObject('Body') + +# Offset plane from selected face +face = body.Shape.Faces[0] +plane = plane_offset_from_face(face, 15.0, body=body, link_spreadsheet=True) + +# Midplane between two faces +mid = plane_midplane(face1, face2, name="MidPlane_Custom") + +# Axis at cylinder center +cyl_face = body.Shape.Faces[2] +axis = axis_cylinder_center(cyl_face, body=body) + +# Point at coordinates with spreadsheet link +pt = point_at_coordinates(50, 25, 0, link_spreadsheet=True) +``` + +### Metadata + +All ztools datums store creation metadata in custom properties: + +- `ZTools_Type`: Creation method (e.g., "offset_from_face") +- `ZTools_Params`: JSON-encoded parameters + +Access via: +```python +plane = doc.getObject('ZPlane_Offset_001') +print(plane.ZTools_Type) # "offset_from_face" +print(plane.ZTools_Params) # {"distance": 15.0, ...} +``` + +## Roadmap + +- [ ] **Module 2**: Enhanced Pad/Pocket (multi-body, draft angles, lip/groove) +- [ ] **Module 3**: Body operations (split, combine, shell improvements) +- [ ] **Module 4**: Pattern tools (curve-driven, fill patterns) +- [ ] **Datum Manager**: Panel to list/toggle/rename all datums + +## License + +LGPL-2.1 (same as FreeCAD) + +## Contributing + +Kindred Systems LLC - Kansas City diff --git a/ztools/__pycache__/Init.cpython-313.pyc b/ztools/__pycache__/Init.cpython-313.pyc new file mode 100644 index 0000000..db5e049 Binary files /dev/null and b/ztools/__pycache__/Init.cpython-313.pyc differ diff --git a/ztools/__pycache__/InitGui.cpython-313.pyc b/ztools/__pycache__/InitGui.cpython-313.pyc new file mode 100644 index 0000000..7742236 Binary files /dev/null and b/ztools/__pycache__/InitGui.cpython-313.pyc differ diff --git a/ztools/setup.cfg b/ztools/setup.cfg new file mode 100644 index 0000000..672d152 --- /dev/null +++ b/ztools/setup.cfg @@ -0,0 +1,11 @@ +[metadata] +name = ztools +version = 0.1.0 +description = Extended PartDesign workbench for FreeCAD with velocity-focused tools +author = Kindred Systems LLC +license = LGPL-2.1 +url = https://github.com/kindredsystems/ztools + +[options] +packages = find: +python_requires = >=3.8 diff --git a/ztools/ztools/__init__.py b/ztools/ztools/__init__.py new file mode 100644 index 0000000..d0c14a3 --- /dev/null +++ b/ztools/ztools/__init__.py @@ -0,0 +1,2 @@ +# ztools - Extended PartDesign for FreeCAD +__version__ = "0.1.0" diff --git a/ztools/ztools/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5ece483 Binary files /dev/null and b/ztools/ztools/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/__pycache__/__init__.cpython-313.pyc b/ztools/ztools/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..ae1cf3f Binary files /dev/null and b/ztools/ztools/__pycache__/__init__.cpython-313.pyc differ diff --git a/ztools/ztools/commands/__init__.py b/ztools/ztools/commands/__init__.py new file mode 100644 index 0000000..6bd2534 --- /dev/null +++ b/ztools/ztools/commands/__init__.py @@ -0,0 +1,4 @@ +# ztools/commands - GUI commands +from . import datum_commands, pattern_commands, pocket_commands + +__all__ = ["datum_commands", "pattern_commands", "pocket_commands"] diff --git a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0eccf2d Binary files /dev/null and b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/__init__.cpython-313.pyc b/ztools/ztools/commands/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2210b1b Binary files /dev/null and b/ztools/ztools/commands/__pycache__/__init__.cpython-313.pyc differ diff --git a/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc new file mode 100644 index 0000000..365ca66 Binary files /dev/null and b/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/datum_commands.cpython-313.pyc b/ztools/ztools/commands/__pycache__/datum_commands.cpython-313.pyc new file mode 100644 index 0000000..7b25fcc Binary files /dev/null and b/ztools/ztools/commands/__pycache__/datum_commands.cpython-313.pyc differ diff --git a/ztools/ztools/commands/__pycache__/pattern_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/pattern_commands.cpython-312.pyc new file mode 100644 index 0000000..038077a Binary files /dev/null and b/ztools/ztools/commands/__pycache__/pattern_commands.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/pocket_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/pocket_commands.cpython-312.pyc new file mode 100644 index 0000000..881c2fb Binary files /dev/null and b/ztools/ztools/commands/__pycache__/pocket_commands.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/pocket_commands.cpython-313.pyc b/ztools/ztools/commands/__pycache__/pocket_commands.cpython-313.pyc new file mode 100644 index 0000000..217827b Binary files /dev/null and b/ztools/ztools/commands/__pycache__/pocket_commands.cpython-313.pyc differ diff --git a/ztools/ztools/commands/__pycache__/theme_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/theme_commands.cpython-312.pyc new file mode 100644 index 0000000..63a8882 Binary files /dev/null and b/ztools/ztools/commands/__pycache__/theme_commands.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/theme_commands.cpython-313.pyc b/ztools/ztools/commands/__pycache__/theme_commands.cpython-313.pyc new file mode 100644 index 0000000..70cf07c Binary files /dev/null and b/ztools/ztools/commands/__pycache__/theme_commands.cpython-313.pyc differ diff --git a/ztools/ztools/commands/datum_commands.py b/ztools/ztools/commands/datum_commands.py new file mode 100644 index 0000000..9eecbd2 --- /dev/null +++ b/ztools/ztools/commands/datum_commands.py @@ -0,0 +1,650 @@ +# 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 DatumCreatorTaskPanel: + """Unified task panel for creating datum planes, axes, and points.""" + + PLANE_MODES = [ + ("Offset from Face", "offset_face"), + ("Midplane (2 Faces)", "midplane"), + ("3 Points", "3_points"), + ("Normal to Edge", "normal_edge"), + ("Angled from Face", "angled"), + ("Tangent to Cylinder", "tangent_cyl"), + ] + + AXIS_MODES = [ + ("2 Points", "axis_2pt"), + ("From Edge", "axis_edge"), + ("Cylinder Center", "axis_cyl"), + ("Plane Intersection", "axis_intersect"), + ] + + POINT_MODES = [ + ("At Vertex", "point_vertex"), + ("XYZ Coordinates", "point_xyz"), + ("On Edge", "point_edge"), + ("Face Center", "point_face"), + ("Circle Center", "point_circle"), + ] + + def __init__(self): + self.form = QtGui.QWidget() + self.form.setWindowTitle("ztools Datum Creator") + self.setup_ui() + self.selection_callback = None + self.selected_items = [] + self.setup_selection_observer() + + def setup_ui(self): + layout = QtGui.QVBoxLayout(self.form) + + # Datum type tabs + self.tabs = QtGui.QTabWidget() + layout.addWidget(self.tabs) + + # Plane tab + plane_widget = QtGui.QWidget() + plane_layout = QtGui.QVBoxLayout(plane_widget) + + self.plane_mode = QtGui.QComboBox() + for label, _ in self.PLANE_MODES: + self.plane_mode.addItem(label) + self.plane_mode.currentIndexChanged.connect(self.on_plane_mode_changed) + plane_layout.addWidget(QtGui.QLabel("Mode:")) + plane_layout.addWidget(self.plane_mode) + + # Plane parameters group + self.plane_params = QtGui.QGroupBox("Parameters") + self.plane_params_layout = QtGui.QFormLayout(self.plane_params) + + self.plane_offset_spin = QtGui.QDoubleSpinBox() + self.plane_offset_spin.setRange(-10000, 10000) + self.plane_offset_spin.setValue(10) + self.plane_offset_spin.setSuffix(" mm") + + self.plane_angle_spin = QtGui.QDoubleSpinBox() + self.plane_angle_spin.setRange(-360, 360) + self.plane_angle_spin.setValue(45) + self.plane_angle_spin.setSuffix(" °") + + self.plane_param_spin = QtGui.QDoubleSpinBox() + self.plane_param_spin.setRange(0, 1) + self.plane_param_spin.setValue(0.5) + self.plane_param_spin.setSingleStep(0.1) + + plane_layout.addWidget(self.plane_params) + + # Plane selection display + self.plane_selection_label = QtGui.QLabel("Selection: None") + self.plane_selection_label.setWordWrap(True) + plane_layout.addWidget(self.plane_selection_label) + + self.tabs.addTab(plane_widget, "Planes") + + # Axis tab + axis_widget = QtGui.QWidget() + axis_layout = QtGui.QVBoxLayout(axis_widget) + + self.axis_mode = QtGui.QComboBox() + for label, _ in self.AXIS_MODES: + self.axis_mode.addItem(label) + self.axis_mode.currentIndexChanged.connect(self.on_axis_mode_changed) + axis_layout.addWidget(QtGui.QLabel("Mode:")) + axis_layout.addWidget(self.axis_mode) + + self.axis_selection_label = QtGui.QLabel("Selection: None") + self.axis_selection_label.setWordWrap(True) + axis_layout.addWidget(self.axis_selection_label) + + axis_layout.addStretch() + self.tabs.addTab(axis_widget, "Axes") + + # Point tab + point_widget = QtGui.QWidget() + point_layout = QtGui.QVBoxLayout(point_widget) + + self.point_mode = QtGui.QComboBox() + for label, _ in self.POINT_MODES: + self.point_mode.addItem(label) + self.point_mode.currentIndexChanged.connect(self.on_point_mode_changed) + point_layout.addWidget(QtGui.QLabel("Mode:")) + point_layout.addWidget(self.point_mode) + + # Point XYZ inputs + self.point_xyz_group = QtGui.QGroupBox("Coordinates") + xyz_layout = QtGui.QFormLayout(self.point_xyz_group) + + self.point_x_spin = QtGui.QDoubleSpinBox() + self.point_x_spin.setRange(-10000, 10000) + self.point_x_spin.setSuffix(" mm") + + self.point_y_spin = QtGui.QDoubleSpinBox() + self.point_y_spin.setRange(-10000, 10000) + self.point_y_spin.setSuffix(" mm") + + self.point_z_spin = QtGui.QDoubleSpinBox() + self.point_z_spin.setRange(-10000, 10000) + self.point_z_spin.setSuffix(" mm") + + xyz_layout.addRow("X:", self.point_x_spin) + xyz_layout.addRow("Y:", self.point_y_spin) + xyz_layout.addRow("Z:", self.point_z_spin) + + self.point_xyz_group.setVisible(False) + point_layout.addWidget(self.point_xyz_group) + + # Point parameter (for edge) + self.point_param_spin = QtGui.QDoubleSpinBox() + self.point_param_spin.setRange(0, 1) + self.point_param_spin.setValue(0.5) + self.point_param_spin.setSingleStep(0.1) + + self.point_selection_label = QtGui.QLabel("Selection: None") + self.point_selection_label.setWordWrap(True) + point_layout.addWidget(self.point_selection_label) + + point_layout.addStretch() + self.tabs.addTab(point_widget, "Points") + + # Common options + 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) + + # Create button + self.create_btn = QtGui.QPushButton("Create Datum") + self.create_btn.clicked.connect(self.on_create) + layout.addWidget(self.create_btn) + + # Initialize UI state + self.on_plane_mode_changed(0) + self.tabs.currentChanged.connect(self.on_tab_changed) + + 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): + """Update UI when selection changes.""" + sel = Gui.Selection.getSelectionEx() + self.selected_items = sel + + # Build selection description + desc = [] + for s in sel: + if s.SubElementNames: + for sub in s.SubElementNames: + desc.append(f"{s.ObjectName}.{sub}") + else: + desc.append(s.ObjectName) + + text = ", ".join(desc) if desc else "None" + + # Update appropriate label + tab = self.tabs.currentIndex() + if tab == 0: + self.plane_selection_label.setText(f"Selection: {text}") + elif tab == 1: + self.axis_selection_label.setText(f"Selection: {text}") + elif tab == 2: + self.point_selection_label.setText(f"Selection: {text}") + + def on_tab_changed(self, index): + self.on_selection_changed() + + def on_plane_mode_changed(self, index): + """Update plane parameter UI based on mode.""" + # Clear existing params + while self.plane_params_layout.rowCount() > 0: + self.plane_params_layout.removeRow(0) + + mode = self.PLANE_MODES[index][1] + + if mode == "offset_face": + self.plane_params_layout.addRow("Offset:", self.plane_offset_spin) + elif mode == "angled": + self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) + elif mode == "normal_edge": + self.plane_params_layout.addRow("Position (0-1):", self.plane_param_spin) + elif mode == "tangent_cyl": + self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) + + def on_axis_mode_changed(self, index): + pass # Axes don't have extra parameters currently + + def on_point_mode_changed(self, index): + mode = self.POINT_MODES[index][1] + self.point_xyz_group.setVisible(mode == "point_xyz") + + def get_body(self): + """Get active body if checkbox is checked.""" + if not self.use_body_cb.isChecked(): + return None + + # Try to get active 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 a body in document + 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_selected_geometry(self, geo_type): + """Extract geometry of specified type from selection. + + Returns: + List of tuples: (shape, source_object, subname) + """ + results = [] + for sel in self.selected_items: + obj = sel.Object + if not hasattr(obj, "Shape"): + continue + + if sel.SubElementNames: + for sub in sel.SubElementNames: + # Only process valid sub-element names (Face#, Edge#, Vertex#) + # Skip invalid names like "Plane" from datum objects + if not ( + sub.startswith("Face") + or sub.startswith("Edge") + or sub.startswith("Vertex") + ): + # Try to use the whole object's shape instead + shape = obj.Shape + if geo_type == "face" and shape.Faces: + # Use the first face of the object (e.g., datum plane) + results.append((shape.Faces[0], obj, "Face1")) + elif geo_type == "edge" and shape.Edges: + results.append((shape.Edges[0], obj, "Edge1")) + elif geo_type == "vertex" and shape.Vertexes: + results.append((shape.Vertexes[0], obj, "Vertex1")) + continue + + try: + shape = obj.Shape.getElement(sub) + if geo_type == "face" and isinstance(shape, Part.Face): + results.append((shape, obj, sub)) + elif geo_type == "edge" and isinstance(shape, Part.Edge): + results.append((shape, obj, sub)) + elif geo_type == "vertex" and isinstance(shape, Part.Vertex): + results.append((shape, obj, sub)) + except Exception: + # If getElement fails, try to use the whole shape + shape = obj.Shape + if geo_type == "face" and shape.Faces: + results.append((shape.Faces[0], obj, "Face1")) + elif geo_type == "edge" and shape.Edges: + results.append((shape.Edges[0], obj, "Edge1")) + elif geo_type == "vertex" and shape.Vertexes: + results.append((shape.Vertexes[0], obj, "Vertex1")) + else: + # No sub-element selected, use the whole object's shape + shape = obj.Shape + if geo_type == "face" and shape.Faces: + results.append((shape.Faces[0], obj, "Face1")) + elif geo_type == "edge" and shape.Edges: + results.append((shape.Edges[0], obj, "Edge1")) + elif geo_type == "vertex" and shape.Vertexes: + results.append((shape.Vertexes[0], obj, "Vertex1")) + return results + + def on_create(self): + """Create the datum based on current settings.""" + from ztools.datums import core + + tab = self.tabs.currentIndex() + body = self.get_body() + name = self.get_name() + link_ss = self.link_spreadsheet_cb.isChecked() + + try: + if tab == 0: # Planes + self.create_plane(core, body, name, link_ss) + elif tab == 1: # Axes + self.create_axis(core, body, name) + elif tab == 2: # Points + self.create_point(core, body, name, link_ss) + + App.Console.PrintMessage("Datum created successfully\n") + + except Exception as e: + App.Console.PrintError(f"Failed to create datum: {e}\n") + QtGui.QMessageBox.warning(self.form, "Error", str(e)) + + def create_plane(self, core, body, name, link_ss): + mode = self.PLANE_MODES[self.plane_mode.currentIndex()][1] + + if mode == "offset_face": + faces = self.get_selected_geometry("face") + if not faces: + raise ValueError("Select a face") + face, src_obj, src_sub = faces[0] + core.plane_offset_from_face( + face, + self.plane_offset_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "midplane": + faces = self.get_selected_geometry("face") + if len(faces) < 2: + raise ValueError("Select 2 faces") + face1, src_obj1, src_sub1 = faces[0] + face2, src_obj2, src_sub2 = faces[1] + core.plane_midplane( + face1, + face2, + name=name, + body=body, + source_object1=src_obj1, + source_subname1=src_sub1, + source_object2=src_obj2, + source_subname2=src_sub2, + ) + + elif mode == "3_points": + verts = self.get_selected_geometry("vertex") + if len(verts) < 3: + raise ValueError("Select 3 vertices") + v1, src_obj1, src_sub1 = verts[0] + v2, src_obj2, src_sub2 = verts[1] + v3, src_obj3, src_sub3 = verts[2] + core.plane_from_3_points( + v1.Point, + v2.Point, + v3.Point, + name=name, + body=body, + source_refs=[ + (src_obj1, src_sub1), + (src_obj2, src_sub2), + (src_obj3, src_sub3), + ], + ) + + elif mode == "normal_edge": + edges = self.get_selected_geometry("edge") + if not edges: + raise ValueError("Select an edge") + edge, src_obj, src_sub = edges[0] + core.plane_normal_to_edge( + edge, + parameter=self.plane_param_spin.value(), + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "angled": + faces = self.get_selected_geometry("face") + edges = self.get_selected_geometry("edge") + if not faces or not edges: + raise ValueError("Select a face and an edge") + face, face_obj, face_sub = faces[0] + edge, edge_obj, edge_sub = edges[0] + core.plane_angled( + face, + edge, + self.plane_angle_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + source_face_obj=face_obj, + source_face_sub=face_sub, + source_edge_obj=edge_obj, + source_edge_sub=edge_sub, + ) + + elif mode == "tangent_cyl": + faces = self.get_selected_geometry("face") + if not faces: + raise ValueError("Select a cylindrical face") + face, src_obj, src_sub = faces[0] + core.plane_tangent_to_cylinder( + face, + angle=self.plane_angle_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + source_object=src_obj, + source_subname=src_sub, + ) + + def create_axis(self, core, body, name): + mode = self.AXIS_MODES[self.axis_mode.currentIndex()][1] + + if mode == "axis_2pt": + verts = self.get_selected_geometry("vertex") + if len(verts) < 2: + raise ValueError("Select 2 vertices") + v1, obj1, sub1 = verts[0] + v2, obj2, sub2 = verts[1] + core.axis_from_2_points( + v1.Point, + v2.Point, + name=name, + body=body, + source_refs=[(obj1, sub1), (obj2, sub2)], + ) + + elif mode == "axis_edge": + edges = self.get_selected_geometry("edge") + if not edges: + raise ValueError("Select a linear edge") + edge, src_obj, src_sub = edges[0] + core.axis_from_edge( + edge, + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "axis_cyl": + faces = self.get_selected_geometry("face") + if not faces: + raise ValueError("Select a cylindrical face") + face, src_obj, src_sub = faces[0] + core.axis_cylinder_center( + face, + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "axis_intersect": + # Need 2 plane objects selected + if len(self.selected_items) < 2: + raise ValueError("Select 2 datum planes") + core.axis_intersection_planes( + self.selected_items[0].Object, + self.selected_items[1].Object, + name=name, + body=body, + ) + + def create_point(self, core, body, name, link_ss): + mode = self.POINT_MODES[self.point_mode.currentIndex()][1] + + if mode == "point_vertex": + verts = self.get_selected_geometry("vertex") + if not verts: + raise ValueError("Select a vertex") + vert, src_obj, src_sub = verts[0] + core.point_at_vertex( + vert, + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "point_xyz": + core.point_at_coordinates( + self.point_x_spin.value(), + self.point_y_spin.value(), + self.point_z_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + ) + + elif mode == "point_edge": + edges = self.get_selected_geometry("edge") + if not edges: + raise ValueError("Select an edge") + edge, src_obj, src_sub = edges[0] + core.point_on_edge( + edge, + parameter=self.point_param_spin.value(), + name=name, + source_object=src_obj, + source_subname=src_sub, + body=body, + link_spreadsheet=link_ss, + ) + + elif mode == "point_face": + faces = self.get_selected_geometry("face") + if not faces: + raise ValueError("Select a face") + face, src_obj, src_sub = faces[0] + core.point_center_of_face( + face, + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + elif mode == "point_circle": + edges = self.get_selected_geometry("edge") + if not edges: + raise ValueError("Select a circular edge") + edge, src_obj, src_sub = edges[0] + core.point_center_of_circle( + edge, + name=name, + body=body, + source_object=src_obj, + source_subname=src_sub, + ) + + 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 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("Datum Manager - Coming soon\n") + + def IsActive(self): + return App.ActiveDocument is not None + + +# Register commands +Gui.addCommand("ZTools_DatumCreator", ZTools_DatumCreator()) +Gui.addCommand("ZTools_DatumManager", ZTools_DatumManager()) diff --git a/ztools/ztools/commands/pattern_commands.py b/ztools/ztools/commands/pattern_commands.py new file mode 100644 index 0000000..e94a58e --- /dev/null +++ b/ztools/ztools/commands/pattern_commands.py @@ -0,0 +1,206 @@ +# ztools/commands/pattern_commands.py +# Rotated Linear Pattern command +# Creates a linear pattern with incremental rotation for each instance + +import FreeCAD as App +import FreeCADGui as Gui +import Part + +from ztools.resources.icons import get_icon + + +class RotatedLinearPatternFeature: + """Feature object for rotated linear pattern.""" + + def __init__(self, obj): + obj.Proxy = self + + obj.addProperty( + "App::PropertyLink", "Source", "Base", "Source object to pattern" + ) + obj.addProperty( + "App::PropertyVector", + "Direction", + "Pattern", + "Direction of the linear pattern", + ) + obj.addProperty( + "App::PropertyDistance", "Length", "Pattern", "Total length of the pattern" + ) + obj.addProperty( + "App::PropertyInteger", + "Occurrences", + "Pattern", + "Number of occurrences (including original)", + ) + obj.addProperty( + "App::PropertyVector", + "RotationAxis", + "Rotation", + "Axis of rotation for each instance", + ) + obj.addProperty( + "App::PropertyAngle", + "RotationAngle", + "Rotation", + "Rotation angle increment per instance", + ) + obj.addProperty( + "App::PropertyVector", + "RotationCenter", + "Rotation", + "Center point for rotation (relative to each instance)", + ) + obj.addProperty( + "App::PropertyBool", + "CumulativeRotation", + "Rotation", + "If true, rotation accumulates with each instance", + ) + + # Set defaults + obj.Direction = App.Vector(1, 0, 0) + obj.Length = 100.0 + obj.Occurrences = 3 + obj.RotationAxis = App.Vector(0, 0, 1) + obj.RotationAngle = 15.0 + obj.RotationCenter = App.Vector(0, 0, 0) + obj.CumulativeRotation = True + + # Store metadata for ztools tracking + obj.addProperty( + "App::PropertyString", + "ZTools_Type", + "ZTools", + "ZTools feature type", + ) + obj.ZTools_Type = "RotatedLinearPattern" + + def execute(self, obj): + """Recompute the feature.""" + if not obj.Source or not hasattr(obj.Source, "Shape"): + return + + source_shape = obj.Source.Shape + if source_shape.isNull(): + return + + occurrences = max(1, obj.Occurrences) + if occurrences == 1: + obj.Shape = source_shape.copy() + return + + # Calculate spacing + direction = App.Vector(obj.Direction) + if direction.Length < 1e-6: + direction = App.Vector(1, 0, 0) + direction.normalize() + + spacing = float(obj.Length) / (occurrences - 1) if occurrences > 1 else 0 + + shapes = [] + for i in range(occurrences): + # Create translation + offset = direction * spacing * i + translated = source_shape.copy() + translated.translate(offset) + + # Apply rotation + if abs(float(obj.RotationAngle)) > 1e-6: + if obj.CumulativeRotation: + angle = float(obj.RotationAngle) * i + else: + angle = float(obj.RotationAngle) + + # Rotation center is relative to the translated position + center = App.Vector(obj.RotationCenter) + offset + axis = App.Vector(obj.RotationAxis) + if axis.Length < 1e-6: + axis = App.Vector(0, 0, 1) + axis.normalize() + + translated.rotate(center, axis, angle) + + shapes.append(translated) + + if shapes: + obj.Shape = Part.makeCompound(shapes) + + def onChanged(self, obj, prop): + """Handle property changes.""" + pass + + +class RotatedLinearPatternViewProvider: + """View provider for rotated linear pattern.""" + + def __init__(self, vobj): + vobj.Proxy = self + + def attach(self, vobj): + self.Object = vobj.Object + + def updateData(self, obj, prop): + pass + + def onChanged(self, vobj, prop): + pass + + def getIcon(self): + return get_icon("rotated_pattern") + + def __getstate__(self): + return None + + def __setstate__(self, state): + return None + + +class RotatedLinearPatternCommand: + """Command to create a rotated linear pattern.""" + + def GetResources(self): + return { + "Pixmap": get_icon("rotated_pattern"), + "MenuText": "Rotated Linear Pattern", + "ToolTip": "Create a linear pattern with rotation for each instance", + } + + def IsActive(self): + """Command is active when there's a document and selection.""" + if App.ActiveDocument is None: + return False + sel = Gui.Selection.getSelection() + return len(sel) == 1 + + def Activated(self): + """Execute the command.""" + sel = Gui.Selection.getSelection() + if not sel: + App.Console.PrintError("Please select an object first\n") + return + + source = sel[0] + + # Create the feature + doc = App.ActiveDocument + obj = doc.addObject("Part::FeaturePython", "RotatedLinearPattern") + RotatedLinearPatternFeature(obj) + RotatedLinearPatternViewProvider(obj.ViewObject) + + obj.Source = source + obj.Label = f"RotatedPattern_{source.Label}" + + # Hide source object + if hasattr(source, "ViewObject"): + source.ViewObject.Visibility = False + + doc.recompute() + + App.Console.PrintMessage( + f"Created rotated linear pattern from {source.Label}\n" + ) + + +# Register the command +Gui.addCommand("ZTools_RotatedLinearPattern", RotatedLinearPatternCommand()) diff --git a/ztools/ztools/commands/pocket_commands.py b/ztools/ztools/commands/pocket_commands.py new file mode 100644 index 0000000..286ead0 --- /dev/null +++ b/ztools/ztools/commands/pocket_commands.py @@ -0,0 +1,601 @@ +# ztools/commands/pocket_commands.py +# Enhanced Pocket feature with "Flip side to cut" option +# +# This provides an enhanced pocket workflow that includes the ability to +# cut material OUTSIDE the sketch profile rather than inside (like SOLIDWORKS +# "Flip side to cut" feature). + +import FreeCAD as App +import FreeCADGui as Gui +import Part +from PySide import QtCore, QtGui + + +class EnhancedPocketTaskPanel: + """Task panel for creating enhanced pocket features with flip option.""" + + # Pocket type modes matching FreeCAD's PartDesign::Pocket + POCKET_TYPES = [ + ("Dimension", 0), + ("Through All", 1), + ("To First", 2), + ("Up To Face", 3), + ("Two Dimensions", 4), + ] + + def __init__(self, sketch=None): + self.form = QtGui.QWidget() + self.form.setWindowTitle("ztools Enhanced Pocket") + self.sketch = sketch + self.selected_face = None + self.setup_ui() + self.setup_selection_observer() + + # If sketch provided, show it in selection + if self.sketch: + self.update_sketch_display() + + def setup_ui(self): + layout = QtGui.QVBoxLayout(self.form) + + # Sketch selection display + sketch_group = QtGui.QGroupBox("Sketch") + sketch_layout = QtGui.QVBoxLayout(sketch_group) + self.sketch_label = QtGui.QLabel("No sketch selected") + self.sketch_label.setWordWrap(True) + sketch_layout.addWidget(self.sketch_label) + layout.addWidget(sketch_group) + + # Type selection + type_group = QtGui.QGroupBox("Type") + type_layout = QtGui.QFormLayout(type_group) + + self.type_combo = QtGui.QComboBox() + for label, _ in self.POCKET_TYPES: + self.type_combo.addItem(label) + self.type_combo.currentIndexChanged.connect(self.on_type_changed) + type_layout.addRow("Type:", self.type_combo) + + layout.addWidget(type_group) + + # Dimensions group + self.dim_group = QtGui.QGroupBox("Dimensions") + self.dim_layout = QtGui.QFormLayout(self.dim_group) + + # Length input + self.length_spin = QtGui.QDoubleSpinBox() + self.length_spin.setRange(0.001, 10000) + self.length_spin.setValue(10.0) + self.length_spin.setSuffix(" mm") + self.length_spin.setDecimals(3) + self.dim_layout.addRow("Length:", self.length_spin) + + # Length2 input (for Two Dimensions mode) + self.length2_spin = QtGui.QDoubleSpinBox() + self.length2_spin.setRange(0.001, 10000) + self.length2_spin.setValue(10.0) + self.length2_spin.setSuffix(" mm") + self.length2_spin.setDecimals(3) + self.length2_label = QtGui.QLabel("Length 2:") + # Hidden by default + self.length2_spin.setVisible(False) + self.length2_label.setVisible(False) + self.dim_layout.addRow(self.length2_label, self.length2_spin) + + layout.addWidget(self.dim_group) + + # Up To Face selection (hidden by default) + self.face_group = QtGui.QGroupBox("Up To Face") + face_layout = QtGui.QVBoxLayout(self.face_group) + self.face_label = QtGui.QLabel("Select a face...") + self.face_label.setWordWrap(True) + face_layout.addWidget(self.face_label) + self.face_group.setVisible(False) + layout.addWidget(self.face_group) + + # Direction options + dir_group = QtGui.QGroupBox("Direction") + dir_layout = QtGui.QVBoxLayout(dir_group) + + self.reversed_cb = QtGui.QCheckBox("Reversed") + self.reversed_cb.setToolTip("Reverse the pocket direction") + dir_layout.addWidget(self.reversed_cb) + + self.symmetric_cb = QtGui.QCheckBox("Symmetric to plane") + self.symmetric_cb.setToolTip( + "Extend pocket equally on both sides of sketch plane" + ) + self.symmetric_cb.toggled.connect(self.on_symmetric_changed) + dir_layout.addWidget(self.symmetric_cb) + + layout.addWidget(dir_group) + + # FLIP SIDE TO CUT - The main new feature + flip_group = QtGui.QGroupBox("Flip Side to Cut") + flip_layout = QtGui.QVBoxLayout(flip_group) + + self.flipped_cb = QtGui.QCheckBox("Cut outside profile (keep inside)") + self.flipped_cb.setToolTip( + "Instead of removing material inside the sketch profile,\n" + "remove material OUTSIDE the profile.\n\n" + "This keeps only the material covered by the sketch,\n" + "similar to SOLIDWORKS 'Flip side to cut' option." + ) + flip_layout.addWidget(self.flipped_cb) + + # Info label + flip_info = QtGui.QLabel( + "When enabled, material outside the sketch profile is removed,\n" + "leaving only the material inside the sketch boundary." + ) + flip_info.setWordWrap(True) + flip_info.setStyleSheet("color: gray; font-size: 10px;") + flip_layout.addWidget(flip_info) + + layout.addWidget(flip_group) + + # Taper angle (optional) + taper_group = QtGui.QGroupBox("Taper") + taper_layout = QtGui.QFormLayout(taper_group) + + self.taper_spin = QtGui.QDoubleSpinBox() + self.taper_spin.setRange(-89.99, 89.99) + self.taper_spin.setValue(0.0) + self.taper_spin.setSuffix(" °") + self.taper_spin.setDecimals(2) + taper_layout.addRow("Taper Angle:", self.taper_spin) + + layout.addWidget(taper_group) + + # Create button + self.create_btn = QtGui.QPushButton("Create Pocket") + self.create_btn.clicked.connect(self.on_create) + layout.addWidget(self.create_btn) + + layout.addStretch() + + def setup_selection_observer(self): + """Setup selection observer to track user selections.""" + + class SelectionObserver: + def __init__(self, panel): + self.panel = panel + + def addSelection(self, doc, obj, sub, pos): + self.panel.on_selection_changed() + + def removeSelection(self, doc, obj, sub): + self.panel.on_selection_changed() + + def clearSelection(self, doc): + self.panel.on_selection_changed() + + self.observer = SelectionObserver(self) + Gui.Selection.addObserver(self.observer) + + def on_selection_changed(self): + """Handle selection changes.""" + sel = Gui.Selection.getSelectionEx() + + for s in sel: + obj = s.Object + + # Check if it's a sketch (for sketch selection) + if obj.TypeId == "Sketcher::SketchObject" and not self.sketch: + self.sketch = obj + self.update_sketch_display() + + # Check for face selection (for Up To Face mode) + if s.SubElementNames: + for sub in s.SubElementNames: + if sub.startswith("Face"): + shape = obj.Shape.getElement(sub) + if isinstance(shape, Part.Face): + self.selected_face = (obj, sub) + self.face_label.setText(f"Face: {obj.Name}.{sub}") + + def update_sketch_display(self): + """Update sketch label.""" + if self.sketch: + self.sketch_label.setText(f"Sketch: {self.sketch.Label}") + else: + self.sketch_label.setText("No sketch selected") + + def on_type_changed(self, index): + """Update UI based on pocket type.""" + pocket_type = self.POCKET_TYPES[index][1] + + # Show/hide dimension inputs based on type + show_length = pocket_type in [0, 4] # Dimension or Two Dimensions + self.dim_group.setVisible(show_length or pocket_type == 4) + self.length_spin.setVisible(show_length) + + # Two Dimensions mode + show_length2 = pocket_type == 4 + self.length2_spin.setVisible(show_length2) + self.length2_label.setVisible(show_length2) + + # Up To Face mode + self.face_group.setVisible(pocket_type == 3) + + def on_symmetric_changed(self, checked): + """Handle symmetric checkbox change.""" + if checked: + self.reversed_cb.setEnabled(False) + self.reversed_cb.setChecked(False) + else: + self.reversed_cb.setEnabled(True) + + def get_body(self): + """Get the active PartDesign body.""" + if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument: + active_view = Gui.ActiveDocument.ActiveView + if hasattr(active_view, "getActiveObject"): + body = active_view.getActiveObject("pdbody") + if body: + return body + + # Fallback: find body containing the sketch + if self.sketch: + for obj in App.ActiveDocument.Objects: + if obj.TypeId == "PartDesign::Body": + if self.sketch in obj.Group: + return obj + + return None + + def on_create(self): + """Create the pocket feature.""" + if not self.sketch: + QtGui.QMessageBox.warning( + self.form, "Error", "Please select a sketch first." + ) + return + + body = self.get_body() + if not body: + QtGui.QMessageBox.warning( + self.form, "Error", "No active body found. Please activate a body." + ) + return + + try: + App.ActiveDocument.openTransaction("Create Enhanced Pocket") + + flipped = self.flipped_cb.isChecked() + + if flipped: + self.create_flipped_pocket(body) + else: + self.create_standard_pocket(body) + + App.ActiveDocument.commitTransaction() + App.ActiveDocument.recompute() + + App.Console.PrintMessage("Enhanced Pocket created successfully\n") + + except Exception as e: + App.ActiveDocument.abortTransaction() + App.Console.PrintError(f"Failed to create pocket: {e}\n") + QtGui.QMessageBox.critical(self.form, "Error", str(e)) + + def create_standard_pocket(self, body): + """Create a standard PartDesign Pocket.""" + pocket = body.newObject("PartDesign::Pocket", "Pocket") + pocket.Profile = self.sketch + + # Set type + pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1] + pocket.Type = pocket_type + + # Set dimensions + pocket.Length = self.length_spin.value() + if pocket_type == 4: # Two Dimensions + pocket.Length2 = self.length2_spin.value() + + # Set direction options + pocket.Reversed = self.reversed_cb.isChecked() + pocket.Midplane = self.symmetric_cb.isChecked() + + # Set taper + if abs(self.taper_spin.value()) > 0.001: + pocket.TaperAngle = self.taper_spin.value() + + # Up To Face + if pocket_type == 3 and self.selected_face: + obj, sub = self.selected_face + pocket.UpToFace = (obj, [sub]) + + # Hide sketch + self.sketch.ViewObject.Visibility = False + + # Add metadata + pocket.addProperty( + "App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type" + ) + pocket.ZTools_Type = "EnhancedPocket" + + def create_flipped_pocket(self, body): + """Create a flipped pocket (cut outside profile). + + This uses Boolean Common operation: keeps only the intersection + of the body with the extruded profile. + """ + # Get current body shape (the Tip) + tip = body.Tip + if not tip or not hasattr(tip, "Shape"): + raise ValueError("Body has no valid tip shape") + + base_shape = tip.Shape.copy() + + # Get sketch profile + sketch_shape = self.sketch.Shape + if not sketch_shape.Wires: + raise ValueError("Sketch has no closed profile") + + # Create face from sketch wires + wires = sketch_shape.Wires + if len(wires) == 0: + raise ValueError("Sketch has no wires") + + # Create a face from the outer wire + # For multiple wires, the first is outer, rest are holes + face = Part.Face(wires[0]) + if len(wires) > 1: + # Handle holes in the profile + face = Part.Face(wires) + + # Get extrusion direction (sketch normal) + sketch_placement = self.sketch.Placement + normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1)) + + if self.reversed_cb.isChecked(): + normal = normal.negative() + + # Calculate extrusion length/direction + pocket_type = self.POCKET_TYPES[self.type_combo.currentIndex()][1] + + if pocket_type == 0: # Dimension + length = self.length_spin.value() + if self.symmetric_cb.isChecked(): + # Symmetric: extrude half in each direction + half_length = length / 2 + tool_solid = face.extrude(normal * half_length) + tool_solid2 = face.extrude(normal.negative() * half_length) + tool_solid = tool_solid.fuse(tool_solid2) + else: + tool_solid = face.extrude(normal * length) + + elif pocket_type == 1: # Through All + # Use a large value based on bounding box + bbox = base_shape.BoundBox + diagonal = bbox.DiagonalLength + length = diagonal * 2 + + if self.symmetric_cb.isChecked(): + tool_solid = face.extrude(normal * length) + tool_solid2 = face.extrude(normal.negative() * length) + tool_solid = tool_solid.fuse(tool_solid2) + else: + tool_solid = face.extrude(normal * length) + + elif pocket_type == 4: # Two Dimensions + length1 = self.length_spin.value() + length2 = self.length2_spin.value() + tool_solid = face.extrude(normal * length1) + tool_solid2 = face.extrude(normal.negative() * length2) + tool_solid = tool_solid.fuse(tool_solid2) + + else: + # For other types, fall back to Through All behavior + bbox = base_shape.BoundBox + length = bbox.DiagonalLength * 2 + tool_solid = face.extrude(normal * length) + + # Apply taper if specified + # Note: Taper with flipped pocket is complex, skip for now + if abs(self.taper_spin.value()) > 0.001: + App.Console.PrintWarning( + "Taper angle is not supported with Flip Side to Cut. Ignoring.\n" + ) + + # Boolean Common: keep only intersection + result_shape = base_shape.common(tool_solid) + + if result_shape.isNull() or result_shape.Volume < 1e-6: + raise ValueError( + "Flip pocket resulted in empty shape. " + "Make sure the sketch profile intersects with the body." + ) + + # Create a FeaturePython object to hold the result + feature = body.newObject("PartDesign::FeaturePython", "FlippedPocket") + + # Set up the feature + FlippedPocketFeature(feature, self.sketch, result_shape) + FlippedPocketViewProvider(feature.ViewObject) + + # Store parameters as properties + feature.addProperty("App::PropertyDistance", "Length", "Pocket", "Pocket depth") + feature.Length = self.length_spin.value() + + feature.addProperty( + "App::PropertyBool", "Reversed", "Pocket", "Reverse direction" + ) + feature.Reversed = self.reversed_cb.isChecked() + + feature.addProperty( + "App::PropertyBool", "Symmetric", "Pocket", "Symmetric to plane" + ) + feature.Symmetric = self.symmetric_cb.isChecked() + + feature.addProperty( + "App::PropertyInteger", "PocketType", "Pocket", "Pocket type" + ) + feature.PocketType = pocket_type + + # Hide sketch + self.sketch.ViewObject.Visibility = False + + def accept(self): + """Called when OK is clicked.""" + Gui.Selection.removeObserver(self.observer) + return True + + def reject(self): + """Called when Cancel is clicked.""" + Gui.Selection.removeObserver(self.observer) + return True + + def getStandardButtons(self): + return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel + + +class FlippedPocketFeature: + """Feature object for flipped pocket (cuts outside profile).""" + + def __init__(self, obj, sketch, initial_shape): + obj.Proxy = self + self.sketch = sketch + + # Store reference to sketch + obj.addProperty("App::PropertyLink", "Profile", "Base", "Sketch profile") + obj.Profile = sketch + + # ZTools metadata + obj.addProperty( + "App::PropertyString", "ZTools_Type", "ZTools", "ZTools feature type" + ) + obj.ZTools_Type = "FlippedPocket" + + # Set initial shape + obj.Shape = initial_shape + + def execute(self, obj): + """Recompute the flipped pocket.""" + if not obj.Profile: + return + + # Get the base feature (previous feature in the body) + body = obj.getParentGeoFeatureGroup() + if not body: + return + + # Find the feature before this one + base_feature = None + group = body.Group + for i, feat in enumerate(group): + if feat == obj and i > 0: + base_feature = group[i - 1] + break + + if not base_feature or not hasattr(base_feature, "Shape"): + return + + base_shape = base_feature.Shape.copy() + sketch = obj.Profile + + # Get sketch profile + sketch_shape = sketch.Shape + if not sketch_shape.Wires: + return + + wires = sketch_shape.Wires + face = Part.Face(wires[0]) + if len(wires) > 1: + face = Part.Face(wires) + + # Get direction + sketch_placement = sketch.Placement + normal = sketch_placement.Rotation.multVec(App.Vector(0, 0, 1)) + + if hasattr(obj, "Reversed") and obj.Reversed: + normal = normal.negative() + + # Get length + length = obj.Length.Value if hasattr(obj, "Length") else 10.0 + symmetric = obj.Symmetric if hasattr(obj, "Symmetric") else False + pocket_type = obj.PocketType if hasattr(obj, "PocketType") else 0 + + # Create tool solid + if pocket_type == 1: # Through All + bbox = base_shape.BoundBox + length = bbox.DiagonalLength * 2 + + if symmetric: + half = length / 2 + tool_solid = face.extrude(normal * half) + tool_solid2 = face.extrude(normal.negative() * half) + tool_solid = tool_solid.fuse(tool_solid2) + else: + tool_solid = face.extrude(normal * length) + + # Boolean Common + result_shape = base_shape.common(tool_solid) + obj.Shape = result_shape + + def onChanged(self, obj, prop): + """Handle property changes.""" + pass + + +class FlippedPocketViewProvider: + """View provider for flipped pocket.""" + + def __init__(self, vobj): + vobj.Proxy = self + + def attach(self, vobj): + self.Object = vobj.Object + + def updateData(self, obj, prop): + pass + + def onChanged(self, vobj, prop): + pass + + def getIcon(self): + from ztools.resources.icons import get_icon + + return get_icon("pocket_flipped") + + def __getstate__(self): + return None + + def __setstate__(self, state): + return None + + +class ZTools_EnhancedPocket: + """Command to create enhanced pocket with flip option.""" + + def GetResources(self): + from ztools.resources.icons import get_icon + + return { + "Pixmap": get_icon("pocket_enhanced"), + "MenuText": "Enhanced Pocket", + "ToolTip": ( + "Create a pocket with additional options including\n" + "'Flip side to cut' - removes material outside the sketch profile" + ), + } + + def Activated(self): + # Check if a sketch is selected + sketch = None + sel = Gui.Selection.getSelection() + for obj in sel: + if obj.TypeId == "Sketcher::SketchObject": + sketch = obj + break + + panel = EnhancedPocketTaskPanel(sketch) + Gui.Control.showDialog(panel) + + def IsActive(self): + return App.ActiveDocument is not None + + +# Register the command +Gui.addCommand("ZTools_EnhancedPocket", ZTools_EnhancedPocket()) diff --git a/ztools/ztools/datums/__init__.py b/ztools/ztools/datums/__init__.py new file mode 100644 index 0000000..fcbfac7 --- /dev/null +++ b/ztools/ztools/datums/__init__.py @@ -0,0 +1,39 @@ +# ztools/datums - Datum creation tools +from .core import ( + # Planes + plane_offset_from_face, + plane_midplane, + plane_from_3_points, + plane_normal_to_edge, + plane_angled, + plane_tangent_to_cylinder, + # Axes + axis_from_2_points, + axis_from_edge, + axis_cylinder_center, + axis_intersection_planes, + # Points + point_at_vertex, + point_at_coordinates, + point_on_edge, + point_center_of_face, + point_center_of_circle, +) + +__all__ = [ + "plane_offset_from_face", + "plane_midplane", + "plane_from_3_points", + "plane_normal_to_edge", + "plane_angled", + "plane_tangent_to_cylinder", + "axis_from_2_points", + "axis_from_edge", + "axis_cylinder_center", + "axis_intersection_planes", + "point_at_vertex", + "point_at_coordinates", + "point_on_edge", + "point_center_of_face", + "point_center_of_circle", +] diff --git a/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8461c8a Binary files /dev/null and b/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/datums/__pycache__/core.cpython-312.pyc b/ztools/ztools/datums/__pycache__/core.cpython-312.pyc new file mode 100644 index 0000000..f818041 Binary files /dev/null and b/ztools/ztools/datums/__pycache__/core.cpython-312.pyc differ diff --git a/ztools/ztools/datums/__pycache__/core.cpython-313.pyc b/ztools/ztools/datums/__pycache__/core.cpython-313.pyc new file mode 100644 index 0000000..143cc4b Binary files /dev/null and b/ztools/ztools/datums/__pycache__/core.cpython-313.pyc differ diff --git a/ztools/ztools/datums/core.py b/ztools/ztools/datums/core.py new file mode 100644 index 0000000..dd42814 --- /dev/null +++ b/ztools/ztools/datums/core.py @@ -0,0 +1,1138 @@ +# ztools/datums/core.py +# Core datum creation functions + +import math +from typing import List, Optional, Tuple, Union + +import FreeCAD as App +import Part + +# Metadata property prefix +ZTOOLS_META_PREFIX = "ZTools_" + + +def _get_next_index(doc: App.Document, prefix: str) -> int: + """Get next available index for auto-naming.""" + existing = [obj.Name for obj in doc.Objects if obj.Name.startswith(prefix)] + if not existing: + return 1 + indices = [] + for name in existing: + try: + idx = int(name.replace(prefix, "").lstrip("_").split("_")[0]) + indices.append(idx) + except ValueError: + continue + return max(indices, default=0) + 1 + + +def _setup_datum_attachment(obj, support, map_mode: str, offset: App.Placement = None): + """ + Set up a PartDesign datum object with proper attachment. + + Args: + obj: The datum object (PartDesign::Plane, Line, or Point) + support: Attachment support - list of tuples [(object, 'SubElement'), ...] + map_mode: Attachment mode string (e.g., 'FlatFace', 'ObjectXY', 'Translate') + offset: Optional offset from the attachment point + """ + if hasattr(obj, "Support"): + obj.Support = support + + if hasattr(obj, "MapMode"): + obj.MapMode = map_mode + + if offset and hasattr(obj, "MapPathParameter"): + obj.AttachmentOffset = offset + + +def _setup_datum_placement(obj, placement: App.Placement): + """ + Set up a PartDesign datum object with placement only (no attachment). + Use this when we can't determine a proper attachment reference. + + For PartDesign datums (Plane, Line, Point), we need to either: + 1. Set up proper attachment (Support + MapMode), or + 2. Explicitly set MapMode to 'Deactivated' to indicate we're using placement only + + This function sets MapMode to 'Deactivated' and applies the placement. + """ + if hasattr(obj, "MapMode"): + obj.MapMode = "Deactivated" + + # Clear any attachment support + if hasattr(obj, "Support"): + obj.Support = None + + # Apply placement + obj.Placement = placement + + +def _get_subname_from_shape(parent_obj, shape): + """ + Find the sub-element name (e.g., 'Face1', 'Edge2') for a shape within a parent object. + + Args: + parent_obj: The FreeCAD object containing the shape + shape: The Part.Face, Part.Edge, or Part.Vertex to find + + Returns: + String like 'Face1', 'Edge2', 'Vertex3' or None if not found + """ + if not hasattr(parent_obj, "Shape"): + return None + + parent_shape = parent_obj.Shape + + # Check faces + if isinstance(shape, Part.Face): + for i, face in enumerate(parent_shape.Faces, 1): + if face.isSame(shape): + return f"Face{i}" + + # Check edges + elif isinstance(shape, Part.Edge): + for i, edge in enumerate(parent_shape.Edges, 1): + if edge.isSame(shape): + return f"Edge{i}" + + # Check vertices + elif isinstance(shape, Part.Vertex): + for i, vertex in enumerate(parent_shape.Vertexes, 1): + if vertex.isSame(shape): + return f"Vertex{i}" + + return None + + +def _find_shape_owner(doc, shape): + """ + Find the object that owns a given shape. + + Args: + doc: FreeCAD document + shape: The shape to find the owner of + + Returns: + Tuple of (object, subname) or (None, None) if not found + """ + for obj in doc.Objects: + if not hasattr(obj, "Shape"): + continue + + subname = _get_subname_from_shape(obj, shape) + if subname: + return obj, subname + + return None, None + + +def _add_ztools_metadata(obj, datum_type: str, params: dict): + """Store ztools metadata in object properties.""" + # Add custom properties for ztools tracking + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Type", + "ztools", + "Datum creation method", + ) + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Params", + "ztools", + "Creation parameters (JSON)", + ) + + setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type) + + import json + + # Convert vectors/placements to serializable format + serializable_params = {} + for k, v in params.items(): + if isinstance(v, App.Vector): + serializable_params[k] = {"x": v.x, "y": v.y, "z": v.z} + elif isinstance(v, App.Placement): + serializable_params[k] = { + "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z}, + "rotation": list(v.Rotation.Q), + } + else: + serializable_params[k] = v + + setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params)) + + +def _link_to_spreadsheet( + doc: App.Document, obj, param_name: str, value: float, alias: str +): + """Optionally link a parameter to spreadsheet.""" + sheet = doc.getObject("Spreadsheet") + if not sheet: + sheet = doc.addObject("Spreadsheet::Sheet", "Spreadsheet") + + # Find next empty row + row = 1 + while sheet.getContents(f"A{row}"): + row += 1 + + sheet.set(f"A{row}", alias) + sheet.set(f"B{row}", f"{value} mm") + sheet.setAlias(f"B{row}", alias) + + # Set expression on object + obj.setExpression(param_name, f"Spreadsheet.{alias}") + + return alias + + +# ============================================================================= +# DATUM PLANES +# ============================================================================= + + +def plane_offset_from_face( + face: Part.Face, + distance: float, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum plane offset from a planar face. + + Args: + face: Source face (must be planar) + distance: Offset distance in mm (positive = along normal) + name: Optional custom name + body: Optional body to add plane to (None = document level) + link_spreadsheet: Create spreadsheet alias for distance + source_object: The object containing the face (for attachment) + source_subname: The sub-element name like 'Face1' (for attachment) + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + if not face.Surface.isPlanar(): + raise ValueError("Face must be planar for offset plane") + + # Try to find the source object if not provided + if source_object is None or source_subname is None: + source_object, source_subname = _find_shape_owner(doc, face) + + # Get face center and normal + uv = face.Surface.parameter(face.CenterOfMass) + normal = face.normalAt(uv[0], uv[1]) + base = face.CenterOfMass + normal * distance + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Offset") + name = f"ZPlane_Offset_{idx:03d}" + + # Create plane + rot = App.Rotation(App.Vector(0, 0, 1), normal) + + if body: + plane = body.newObject("PartDesign::Plane", name) + + # Try to use proper attachment if we have the source reference + if source_object and source_subname: + # Use FlatFace mode with offset + _setup_datum_attachment( + plane, [(source_object, source_subname)], "FlatFace" + ) + # Set the offset along the normal + plane.AttachmentOffset = App.Placement( + App.Vector(0, 0, distance), App.Rotation() + ) + else: + # Fallback to placement-only mode + _setup_datum_placement(plane, App.Placement(base, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + # Center the plane visually (for Part::Plane) + plane.Placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) + + # Store metadata + _add_ztools_metadata( + plane, + "offset_from_face", + {"distance": distance, "base": base, "normal": normal}, + ) + + # Spreadsheet link + if link_spreadsheet and not body: + # Part::Plane doesn't have Offset property, would need expression on Placement + pass + elif link_spreadsheet and body: + alias = f"{name}_offset" + _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias) + + doc.recompute() + return plane + + +def plane_midplane( + face1: Part.Face, + face2: Part.Face, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object1: Optional[App.DocumentObject] = None, + source_subname1: Optional[str] = None, + source_object2: Optional[App.DocumentObject] = None, + source_subname2: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum plane midway between two parallel faces. + + Args: + face1: First face + face2: Second face (must be parallel to face1) + name: Optional custom name + body: Optional body to add plane to + source_object1, source_subname1: Reference for face1 + source_object2, source_subname2: Reference for face2 + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + if not (face1.Surface.isPlanar() and face2.Surface.isPlanar()): + raise ValueError("Both faces must be planar") + + # Get normals + uv1 = face1.Surface.parameter(face1.CenterOfMass) + uv2 = face2.Surface.parameter(face2.CenterOfMass) + n1 = face1.normalAt(uv1[0], uv1[1]) + n2 = face2.normalAt(uv2[0], uv2[1]) + + # Check parallel (dot product ~1 or ~-1) + dot = abs(n1.dot(n2)) + if dot < 0.9999: + raise ValueError("Faces must be parallel for midplane") + + # Midpoint + c1 = face1.CenterOfMass + c2 = face2.CenterOfMass + mid = (c1 + c2) * 0.5 + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Mid") + name = f"ZPlane_Mid_{idx:03d}" + + # Create plane + rot = App.Rotation(App.Vector(0, 0, 1), n1) + + if body: + plane = body.newObject("PartDesign::Plane", name) + # No direct "midplane" attachment mode exists in FreeCAD + # Use placement-only mode with calculated midpoint + _setup_datum_placement(plane, App.Placement(mid, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + plane.Placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot) + + _add_ztools_metadata( + plane, "midplane", {"center1": c1, "center2": c2, "midpoint": mid} + ) + + doc.recompute() + return plane + + +def plane_from_3_points( + p1: App.Vector, + p2: App.Vector, + p3: App.Vector, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_refs: Optional[List[Tuple]] = None, +) -> App.DocumentObject: + """ + Create datum plane from 3 points. + + Args: + p1, p2, p3: Three non-collinear points + name: Optional custom name + body: Optional body to add plane to + source_refs: List of (object, subname) tuples for the 3 vertices + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + # Calculate normal + v1 = p2 - p1 + v2 = p3 - p1 + normal = v1.cross(v2) + + if normal.Length < 1e-6: + raise ValueError("Points are collinear, cannot define plane") + + normal.normalize() + center = (p1 + p2 + p3) / 3 + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_3Pt") + name = f"ZPlane_3Pt_{idx:03d}" + + rot = App.Rotation(App.Vector(0, 0, 1), normal) + + if body: + plane = body.newObject("PartDesign::Plane", name) + + # Use ThreePointPlane attachment if we have references + if source_refs and len(source_refs) >= 3: + _setup_datum_attachment(plane, source_refs[:3], "ThreePointPlane") + else: + _setup_datum_placement(plane, App.Placement(center, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + plane.Placement = App.Placement( + center - rot.multVec(App.Vector(25, 25, 0)), rot + ) + + _add_ztools_metadata( + plane, + "3_points", + {"p1": p1, "p2": p2, "p3": p3, "center": center, "normal": normal}, + ) + + doc.recompute() + return plane + + +def plane_normal_to_edge( + edge: Part.Edge, + parameter: float = 0.5, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum plane normal to edge at parameter location. + + Args: + edge: Source edge/curve + parameter: Location along edge (0.0 to 1.0) + name: Optional custom name + body: Optional body to add plane to + source_object, source_subname: Reference for the edge + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + # Get point and tangent at parameter + param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter) + point = edge.valueAt(param) + tangent = edge.tangentAt(param) + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Normal") + name = f"ZPlane_Normal_{idx:03d}" + + # Plane normal = edge tangent + rot = App.Rotation(App.Vector(0, 0, 1), tangent) + + if body: + plane = body.newObject("PartDesign::Plane", name) + + # Use NormalToPath attachment if we have a reference + if source_object and source_subname: + _setup_datum_attachment( + plane, [(source_object, source_subname)], "NormalToPath" + ) + # Set parameter along path + if hasattr(plane, "MapPathParameter"): + plane.MapPathParameter = parameter + else: + _setup_datum_placement(plane, App.Placement(point, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + plane.Placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot) + + _add_ztools_metadata( + plane, + "normal_to_edge", + {"parameter": parameter, "point": point, "tangent": tangent}, + ) + + doc.recompute() + return plane + + +def plane_angled( + face: Part.Face, + edge: Part.Edge, + angle: float, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, + source_face_obj: Optional[App.DocumentObject] = None, + source_face_sub: Optional[str] = None, + source_edge_obj: Optional[App.DocumentObject] = None, + source_edge_sub: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum plane at angle to face, rotating about edge. + + Args: + face: Reference face + edge: Rotation axis (should lie on face) + angle: Rotation angle in degrees + name: Optional custom name + body: Optional body to add plane to + link_spreadsheet: Create spreadsheet alias for angle + source_face_obj, source_face_sub: Reference for the face + source_edge_obj, source_edge_sub: Reference for the edge + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + if not face.Surface.isPlanar(): + raise ValueError("Face must be planar") + + # Get face normal and edge direction + uv = face.Surface.parameter(face.CenterOfMass) + face_normal = face.normalAt(uv[0], uv[1]) + + edge_dir = ( + edge.Curve.Direction + if hasattr(edge.Curve, "Direction") + else ( + edge.valueAt(edge.LastParameter) - edge.valueAt(edge.FirstParameter) + ).normalize() + ) + + edge_mid = edge.valueAt((edge.FirstParameter + edge.LastParameter) / 2) + + # Rotate face normal about edge + rot_axis = App.Rotation(edge_dir, angle) + new_normal = rot_axis.multVec(face_normal) + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Angled") + name = f"ZPlane_Angled_{idx:03d}" + + rot = App.Rotation(App.Vector(0, 0, 1), new_normal) + + if body: + plane = body.newObject("PartDesign::Plane", name) + + # Use FlatFace on the reference face with rotation offset + if source_face_obj and source_face_sub: + _setup_datum_attachment( + plane, [(source_face_obj, source_face_sub)], "FlatFace" + ) + # Apply rotation as attachment offset + plane.AttachmentOffset = App.Placement( + App.Vector(0, 0, 0), App.Rotation(App.Vector(1, 0, 0), angle) + ) + else: + _setup_datum_placement(plane, App.Placement(edge_mid, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + plane.Placement = App.Placement( + edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot + ) + + _add_ztools_metadata( + plane, + "angled", + { + "angle": angle, + "edge_mid": edge_mid, + "original_normal": face_normal, + "new_normal": new_normal, + }, + ) + + if link_spreadsheet and body: + alias = f"{name}_angle" + _link_to_spreadsheet( + doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias + ) + + doc.recompute() + return plane + + +def plane_tangent_to_cylinder( + face: Part.Face, + angle: float = 0, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum plane tangent to cylindrical face at angle. + + Args: + face: Cylindrical face + angle: Angular position in degrees (0 = +X direction) + name: Optional custom name + body: Optional body to add plane to + link_spreadsheet: Create spreadsheet alias for angle + source_object, source_subname: Reference for the cylindrical face + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + if not isinstance(face.Surface, Part.Cylinder): + raise ValueError("Face must be cylindrical") + + cyl = face.Surface + axis = cyl.Axis + center = cyl.Center + radius = cyl.Radius + + # Calculate tangent point at angle + rad = math.radians(angle) + # Local X direction perpendicular to axis + if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99: + local_x = axis.cross(App.Vector(1, 0, 0)).normalize() + else: + local_x = axis.cross(App.Vector(0, 1, 0)).normalize() + local_y = axis.cross(local_x) + + # Point on cylinder surface + radial = local_x * math.cos(rad) + local_y * math.sin(rad) + tangent_point = center + radial * radius + + # Plane normal is radial direction (tangent plane) + plane_normal = radial + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Tangent") + name = f"ZPlane_Tangent_{idx:03d}" + + rot = App.Rotation(App.Vector(0, 0, 1), plane_normal) + + if body: + plane = body.newObject("PartDesign::Plane", name) + + # Use Tangent attachment mode for cylindrical face + if source_object and source_subname: + _setup_datum_attachment(plane, [(source_object, source_subname)], "Tangent") + # Set rotation angle via attachment offset + plane.AttachmentOffset = App.Placement( + App.Vector(0, 0, 0), App.Rotation(App.Vector(0, 0, 1), angle) + ) + else: + _setup_datum_placement(plane, App.Placement(tangent_point, rot)) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + plane.Placement = App.Placement( + tangent_point - rot.multVec(App.Vector(25, 25, 0)), rot + ) + + _add_ztools_metadata( + plane, + "tangent_cylinder", + {"angle": angle, "radius": radius, "tangent_point": tangent_point}, + ) + + if link_spreadsheet and body: + alias = f"{name}_angle" + _link_to_spreadsheet( + doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias + ) + + doc.recompute() + return plane + + +# ============================================================================= +# DATUM AXES +# ============================================================================= + + +def axis_from_2_points( + p1: App.Vector, + p2: App.Vector, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_refs: Optional[List[Tuple]] = None, +) -> App.DocumentObject: + """ + Create datum axis from two points. + + Args: + p1, p2: Two distinct points + name: Optional custom name + body: Optional body to add axis to + source_refs: List of (object, subname) tuples for the vertices + + Returns: + Created datum axis object + """ + doc = App.ActiveDocument + + direction = p2 - p1 + if direction.Length < 1e-6: + raise ValueError("Points must be distinct") + + length = direction.Length + direction.normalize() + midpoint = (p1 + p2) * 0.5 + + if name is None: + idx = _get_next_index(doc, "ZAxis_2Pt") + name = f"ZAxis_2Pt_{idx:03d}" + + if body: + axis = body.newObject("PartDesign::Line", name) + + # Use TwoPointLine attachment if we have references + if source_refs and len(source_refs) >= 2: + _setup_datum_attachment(axis, source_refs[:2], "TwoPointLine") + else: + rot = App.Rotation(App.Vector(0, 0, 1), direction) + _setup_datum_placement(axis, App.Placement(midpoint, rot)) + else: + axis = doc.addObject("Part::Line", name) + axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z + axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z + + _add_ztools_metadata( + axis, "2_points", {"p1": p1, "p2": p2, "direction": direction, "length": length} + ) + + doc.recompute() + return axis + + +def axis_from_edge( + edge: Part.Edge, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum axis from linear edge. + + Args: + edge: Linear edge + name: Optional custom name + body: Optional body to add axis to + source_object, source_subname: Reference for the edge + + Returns: + Created datum axis object + """ + doc = App.ActiveDocument + + if not isinstance(edge.Curve, Part.Line): + raise ValueError("Edge must be linear") + + if name is None: + idx = _get_next_index(doc, "ZAxis_Edge") + name = f"ZAxis_Edge_{idx:03d}" + + p1 = edge.valueAt(edge.FirstParameter) + p2 = edge.valueAt(edge.LastParameter) + direction = (p2 - p1).normalize() + midpoint = (p1 + p2) * 0.5 + + if body: + axis = body.newObject("PartDesign::Line", name) + + # Use ObjectXY attachment for edge + if source_object and source_subname: + _setup_datum_attachment(axis, [(source_object, source_subname)], "ObjectXY") + else: + rot = App.Rotation(App.Vector(0, 0, 1), direction) + _setup_datum_placement(axis, App.Placement(midpoint, rot)) + else: + axis = doc.addObject("Part::Line", name) + axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z + axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z + + _add_ztools_metadata( + axis, "from_edge", {"p1": p1, "p2": p2, "direction": direction} + ) + + doc.recompute() + return axis + + +def axis_cylinder_center( + face: Part.Face, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum axis at center of cylindrical face. + + Args: + face: Cylindrical face + name: Optional custom name + body: Optional body to add axis to + source_object: Object that owns the face (for attachment) + source_subname: Sub-element name like 'Face1' (for attachment) + + Returns: + Created datum axis object + """ + doc = App.ActiveDocument + + if not isinstance(face.Surface, Part.Cylinder): + raise ValueError("Face must be cylindrical") + + cyl = face.Surface + center = cyl.Center + axis_dir = cyl.Axis + + # Get cylinder extent from face bounds + bbox = face.BoundBox + # Project to axis + p1 = center + axis_dir * (-50) # Arbitrary length + p2 = center + axis_dir * 50 + + if name is None: + idx = _get_next_index(doc, "ZAxis_Cyl") + name = f"ZAxis_Cyl_{idx:03d}" + + if body: + axis = body.newObject("PartDesign::Line", name) + if source_object and source_subname: + # Use 'ObjectZ' to align axis with cylindrical face's axis + support = [(source_object, source_subname)] + _setup_datum_attachment(axis, support, "ObjectZ") + else: + rot = App.Rotation(App.Vector(0, 0, 1), axis_dir) + _setup_datum_placement(axis, App.Placement(center, rot)) + else: + axis = doc.addObject("Part::Line", name) + axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z + axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z + + _add_ztools_metadata( + axis, + "cylinder_center", + {"center": center, "direction": axis_dir, "radius": cyl.Radius}, + ) + + doc.recompute() + return axis + + +def axis_intersection_planes( + plane1, + plane2, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object1: Optional[App.DocumentObject] = None, + source_subname1: Optional[str] = None, + source_object2: Optional[App.DocumentObject] = None, + source_subname2: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum axis at intersection of two planes. + + Args: + plane1, plane2: Two non-parallel planes + name: Optional custom name + body: Optional body to add axis to + source_object1: Object that owns plane1 (for attachment) + source_subname1: Sub-element name for plane1 (for attachment) + source_object2: Object that owns plane2 (for attachment) + source_subname2: Sub-element name for plane2 (for attachment) + + Returns: + Created datum axis object + """ + doc = App.ActiveDocument + + # Get plane shapes + shape1 = plane1.Shape if hasattr(plane1, "Shape") else plane1 + shape2 = plane2.Shape if hasattr(plane2, "Shape") else plane2 + + # Find intersection + common = shape1.common(shape2) + if not common.Edges: + raise ValueError("Planes do not intersect or are parallel") + + edge = common.Edges[0] + p1 = edge.valueAt(edge.FirstParameter) + p2 = edge.valueAt(edge.LastParameter) + + if name is None: + idx = _get_next_index(doc, "ZAxis_Intersect") + name = f"ZAxis_Intersect_{idx:03d}" + + if ( + body + and source_object1 + and source_subname1 + and source_object2 + and source_subname2 + ): + # Create axis with TwoFace attachment (intersection of two planes) + axis = body.newObject("PartDesign::Line", name) + support = [(source_object1, source_subname1), (source_object2, source_subname2)] + _setup_datum_attachment(axis, support, "TwoFace") + + _add_ztools_metadata( + axis, + "plane_intersection", + {"point1": p1, "point2": p2}, + ) + doc.recompute() + return axis + else: + return axis_from_2_points(p1, p2, name, body) + + +# ============================================================================= +# DATUM POINTS +# ============================================================================= + + +def point_at_vertex( + vertex: Part.Vertex, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, +) -> App.DocumentObject: + """ + Create datum point at vertex location. + + Args: + vertex: Source vertex + name: Optional custom name + body: Optional body to add point to + source_object: Object that owns the vertex (for attachment) + source_subname: Sub-element name like 'Vertex1' (for attachment) + + Returns: + Created datum point object + """ + doc = App.ActiveDocument + pos = vertex.Point + + if name is None: + idx = _get_next_index(doc, "ZPoint_Vtx") + name = f"ZPoint_Vtx_{idx:03d}" + + if body: + point = body.newObject("PartDesign::Point", name) + if source_object and source_subname: + # Use 'Vertex' attachment mode to attach to the vertex + support = [(source_object, source_subname)] + _setup_datum_attachment(point, support, "Vertex") + else: + _setup_datum_placement(point, App.Placement(pos, App.Rotation())) + else: + point = doc.addObject("Part::Vertex", name) + point.X, point.Y, point.Z = pos.x, pos.y, pos.z + + _add_ztools_metadata(point, "vertex", {"position": pos}) + + doc.recompute() + return point + + +def point_at_coordinates( + x: float, + y: float, + z: float, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, +) -> App.DocumentObject: + """ + Create datum point at XYZ coordinates. + + Args: + x, y, z: Coordinates in mm + name: Optional custom name + body: Optional body to add point to + link_spreadsheet: Create spreadsheet aliases for coordinates + + Returns: + Created datum point object + """ + doc = App.ActiveDocument + + if name is None: + idx = _get_next_index(doc, "ZPoint_XYZ") + name = f"ZPoint_XYZ_{idx:03d}" + + if body: + point = body.newObject("PartDesign::Point", name) + _setup_datum_placement( + point, App.Placement(App.Vector(x, y, z), App.Rotation()) + ) + else: + point = doc.addObject("Part::Vertex", name) + point.X, point.Y, point.Z = x, y, z + + _add_ztools_metadata(point, "coordinates", {"x": x, "y": y, "z": z}) + + if link_spreadsheet and not body: + _link_to_spreadsheet(doc, point, "X", x, f"{name}_X") + _link_to_spreadsheet(doc, point, "Y", y, f"{name}_Y") + _link_to_spreadsheet(doc, point, "Z", z, f"{name}_Z") + + doc.recompute() + return point + + +def point_on_edge( + edge: Part.Edge, + parameter: float = 0.5, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, +) -> App.DocumentObject: + """ + Create datum point on edge at parameter. + + Args: + edge: Source edge + parameter: Location along edge (0.0 to 1.0) + name: Optional custom name + body: Optional body to add point to + link_spreadsheet: Create spreadsheet alias for parameter + + Returns: + Created datum point object + """ + doc = App.ActiveDocument + + param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter) + pos = edge.valueAt(param) + + if name is None: + idx = _get_next_index(doc, "ZPoint_Edge") + name = f"ZPoint_Edge_{idx:03d}" + + if body: + point = body.newObject("PartDesign::Point", name) + _setup_datum_placement(point, App.Placement(pos, App.Rotation())) + else: + point = doc.addObject("Part::Vertex", name) + point.X, point.Y, point.Z = pos.x, pos.y, pos.z + + _add_ztools_metadata(point, "on_edge", {"parameter": parameter, "position": pos}) + + doc.recompute() + return point + + +def point_center_of_face( + face: Part.Face, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, +) -> App.DocumentObject: + """ + Create datum point at center of mass of face. + + Args: + face: Source face + name: Optional custom name + body: Optional body to add point to + + Returns: + Created datum point object + """ + doc = App.ActiveDocument + pos = face.CenterOfMass + + if name is None: + idx = _get_next_index(doc, "ZPoint_FaceCenter") + name = f"ZPoint_FaceCenter_{idx:03d}" + + if body: + point = body.newObject("PartDesign::Point", name) + _setup_datum_placement(point, App.Placement(pos, App.Rotation())) + else: + point = doc.addObject("Part::Vertex", name) + point.X, point.Y, point.Z = pos.x, pos.y, pos.z + + _add_ztools_metadata(point, "face_center", {"position": pos}) + + doc.recompute() + return point + + +def point_center_of_circle( + edge: Part.Edge, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, +) -> App.DocumentObject: + """ + Create datum point at center of circular edge. + + Args: + edge: Circular edge (circle or arc) + name: Optional custom name + body: Optional body to add point to + + Returns: + Created datum point object + """ + doc = App.ActiveDocument + + if not isinstance(edge.Curve, (Part.Circle, Part.ArcOfCircle)): + raise ValueError("Edge must be circular") + + pos = edge.Curve.Center + + if name is None: + idx = _get_next_index(doc, "ZPoint_CircleCenter") + name = f"ZPoint_CircleCenter_{idx:03d}" + + if body: + point = body.newObject("PartDesign::Point", name) + _setup_datum_placement(point, App.Placement(pos, App.Rotation())) + else: + point = doc.addObject("Part::Vertex", name) + point.X, point.Y, point.Z = pos.x, pos.y, pos.z + + _add_ztools_metadata( + point, "circle_center", {"position": pos, "radius": edge.Curve.Radius} + ) + + doc.recompute() + return point diff --git a/ztools/ztools/resources/__init__.py b/ztools/ztools/resources/__init__.py new file mode 100644 index 0000000..eaa5d09 --- /dev/null +++ b/ztools/ztools/resources/__init__.py @@ -0,0 +1,10 @@ +# ztools/resources - Icons and assets +from .icons import MOCHA, get_icon, save_icons_to_disk +from .theme import get_stylesheet + +__all__ = [ + "get_icon", + "save_icons_to_disk", + "MOCHA", + "get_stylesheet", +] diff --git a/ztools/ztools/resources/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/resources/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9811f03 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/resources/__pycache__/__init__.cpython-313.pyc b/ztools/ztools/resources/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..af6e362 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/__init__.cpython-313.pyc differ diff --git a/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc b/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc new file mode 100644 index 0000000..5120d08 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc differ diff --git a/ztools/ztools/resources/__pycache__/icons.cpython-313.pyc b/ztools/ztools/resources/__pycache__/icons.cpython-313.pyc new file mode 100644 index 0000000..27bd1d5 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/icons.cpython-313.pyc differ diff --git a/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc b/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc new file mode 100644 index 0000000..b5f2b30 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc differ diff --git a/ztools/ztools/resources/__pycache__/theme.cpython-313.pyc b/ztools/ztools/resources/__pycache__/theme.cpython-313.pyc new file mode 100644 index 0000000..95c0645 Binary files /dev/null and b/ztools/ztools/resources/__pycache__/theme.cpython-313.pyc differ diff --git a/ztools/ztools/resources/icons.py b/ztools/ztools/resources/icons.py new file mode 100644 index 0000000..cd711fe --- /dev/null +++ b/ztools/ztools/resources/icons.py @@ -0,0 +1,386 @@ +# ztools/resources/icons.py +# Catppuccin Mocha themed icons for ztools + +# Catppuccin Mocha Palette +MOCHA = { + "rosewater": "#f5e0dc", + "flamingo": "#f2cdcd", + "pink": "#f5c2e7", + "mauve": "#cba6f7", + "red": "#f38ba8", + "maroon": "#eba0ac", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "sapphire": "#74c7ec", + "blue": "#89b4fa", + "lavender": "#b4befe", + "text": "#cdd6f4", + "subtext1": "#bac2de", + "subtext0": "#a6adc8", + "overlay2": "#9399b2", + "overlay1": "#7f849c", + "overlay0": "#6c7086", + "surface2": "#585b70", + "surface1": "#45475a", + "surface0": "#313244", + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", +} + + +def _svg_to_base64(svg_content: str) -> str: + """Convert SVG string to base64 data URI for FreeCAD.""" + import base64 + + encoded = base64.b64encode(svg_content.encode("utf-8")).decode("utf-8") + return f"data:image/svg+xml;base64,{encoded}" + + +# ============================================================================= +# SVG Icon Definitions +# ============================================================================= + +# Workbench main icon - stylized "Z" +ICON_WORKBENCH_SVG = f''' + + + +''' + +# Datum Creator icon - plane with plus +ICON_DATUM_CREATOR_SVG = f''' + + + + + + +''' + +# Datum Manager icon - stacked planes with list +ICON_DATUM_MANAGER_SVG = f''' + + + + + + + + +''' + +# Plane Offset icon +ICON_PLANE_OFFSET_SVG = f''' + + + + + + + + +''' + +# Plane Midplane icon +ICON_PLANE_MIDPLANE_SVG = f''' + + + + + + + +''' + +# Plane 3 Points icon +ICON_PLANE_3PT_SVG = f''' + + + + + + + +''' + +# Plane Normal to Edge icon +ICON_PLANE_NORMAL_SVG = f''' + + + + + + + +''' + +# Plane Angled icon +ICON_PLANE_ANGLED_SVG = f''' + + + + + + + +''' + +# Plane Tangent icon +ICON_PLANE_TANGENT_SVG = f''' + + + + + + + +''' + +# Axis 2 Points icon +ICON_AXIS_2PT_SVG = f''' + + + + + + +''' + +# Axis from Edge icon +ICON_AXIS_EDGE_SVG = f''' + + + + + + + +''' + +# Axis Cylinder Center icon +ICON_AXIS_CYL_SVG = f''' + + + + + + + + + +''' + +# Axis Intersection icon +ICON_AXIS_INTERSECT_SVG = f''' + + + + + + + +''' + +# Point at Vertex icon +ICON_POINT_VERTEX_SVG = f''' + + + + + + + + +''' + +# Point XYZ icon +ICON_POINT_XYZ_SVG = f''' + + + + + + + + + + +''' + +# Point on Edge icon +ICON_POINT_EDGE_SVG = f''' + + + + + + + t +''' + +# Point Face Center icon +ICON_POINT_FACE_SVG = f''' + + + + + + +''' + +# Point Circle Center icon +ICON_POINT_CIRCLE_SVG = f''' + + + + + + + + +''' + +# Rotated Linear Pattern icon - objects along line with rotation +ICON_ROTATED_PATTERN_SVG = f''' + + + + + + + + + + + + + + + + +''' + +# Enhanced Pocket icon - pocket with plus/settings indicator +ICON_POCKET_ENHANCED_SVG = f''' + + + + + + + + + + +''' + +# Flipped Pocket icon - pocket cutting outside the profile +ICON_POCKET_FLIPPED_SVG = f''' + + + + + + + + + + + + +''' + + +# ============================================================================= +# 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, + } + + 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, + } + + for name, svg in icons.items(): + filepath = os.path.join(directory, f"{name}.svg") + with open(filepath, "w") as f: + f.write(svg) + print(f"Saved: {filepath}") diff --git a/ztools/ztools/resources/icons/ztools_axis_2pt.svg b/ztools/ztools/resources/icons/ztools_axis_2pt.svg new file mode 100644 index 0000000..439f092 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_axis_2pt.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_axis_cyl.svg b/ztools/ztools/resources/icons/ztools_axis_cyl.svg new file mode 100644 index 0000000..0e723e7 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_axis_cyl.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_axis_edge.svg b/ztools/ztools/resources/icons/ztools_axis_edge.svg new file mode 100644 index 0000000..ab54592 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_axis_edge.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_axis_intersect.svg b/ztools/ztools/resources/icons/ztools_axis_intersect.svg new file mode 100644 index 0000000..4069f73 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_axis_intersect.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_datum_creator.svg b/ztools/ztools/resources/icons/ztools_datum_creator.svg new file mode 100644 index 0000000..de02931 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_datum_creator.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_datum_manager.svg b/ztools/ztools/resources/icons/ztools_datum_manager.svg new file mode 100644 index 0000000..9c80090 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_datum_manager.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_3pt.svg b/ztools/ztools/resources/icons/ztools_plane_3pt.svg new file mode 100644 index 0000000..4ed7efb --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_3pt.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_angled.svg b/ztools/ztools/resources/icons/ztools_plane_angled.svg new file mode 100644 index 0000000..6790fcd --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_angled.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_midplane.svg b/ztools/ztools/resources/icons/ztools_plane_midplane.svg new file mode 100644 index 0000000..bdd5ea9 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_midplane.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_normal.svg b/ztools/ztools/resources/icons/ztools_plane_normal.svg new file mode 100644 index 0000000..e564b74 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_normal.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_offset.svg b/ztools/ztools/resources/icons/ztools_plane_offset.svg new file mode 100644 index 0000000..7f1f35b --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_offset.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_plane_tangent.svg b/ztools/ztools/resources/icons/ztools_plane_tangent.svg new file mode 100644 index 0000000..b683e2f --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_plane_tangent.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg b/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg new file mode 100644 index 0000000..16eb0b8 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_pocket_enhanced.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_pocket_flipped.svg b/ztools/ztools/resources/icons/ztools_pocket_flipped.svg new file mode 100644 index 0000000..6e667a7 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_pocket_flipped.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_point_circle.svg b/ztools/ztools/resources/icons/ztools_point_circle.svg new file mode 100644 index 0000000..b05da8b --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_point_circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_point_edge.svg b/ztools/ztools/resources/icons/ztools_point_edge.svg new file mode 100644 index 0000000..7032701 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_point_edge.svg @@ -0,0 +1,9 @@ + + + + + + + + t + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_point_face.svg b/ztools/ztools/resources/icons/ztools_point_face.svg new file mode 100644 index 0000000..887201a --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_point_face.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_point_vertex.svg b/ztools/ztools/resources/icons/ztools_point_vertex.svg new file mode 100644 index 0000000..4db1731 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_point_vertex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_point_xyz.svg b/ztools/ztools/resources/icons/ztools_point_xyz.svg new file mode 100644 index 0000000..37a355a --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_point_xyz.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_rotated_pattern.svg b/ztools/ztools/resources/icons/ztools_rotated_pattern.svg new file mode 100644 index 0000000..9b75476 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_rotated_pattern.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ztools/ztools/resources/icons/ztools_theme_apply.svg b/ztools/ztools/resources/icons/ztools_theme_apply.svg new file mode 100644 index 0000000..5dfafd7 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_theme_apply.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_theme_export.svg b/ztools/ztools/resources/icons/ztools_theme_export.svg new file mode 100644 index 0000000..a4456e2 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_theme_export.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_theme_remove.svg b/ztools/ztools/resources/icons/ztools_theme_remove.svg new file mode 100644 index 0000000..aaba292 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_theme_remove.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_theme_toggle.svg b/ztools/ztools/resources/icons/ztools_theme_toggle.svg new file mode 100644 index 0000000..2845df5 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_theme_toggle.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/icons/ztools_workbench.svg b/ztools/ztools/resources/icons/ztools_workbench.svg new file mode 100644 index 0000000..e879935 --- /dev/null +++ b/ztools/ztools/resources/icons/ztools_workbench.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ztools/ztools/resources/theme.py b/ztools/ztools/resources/theme.py new file mode 100644 index 0000000..a7c2652 --- /dev/null +++ b/ztools/ztools/resources/theme.py @@ -0,0 +1,1306 @@ +# ztools/resources/theme.py +# Catppuccin Mocha theme for FreeCAD +# +# This module generates a comprehensive Qt stylesheet (QSS) that applies the +# Catppuccin Mocha color palette across the entire FreeCAD interface. +# +# The theme is automatically installed to FreeCAD's Gui/Stylesheets directory +# by Init.py at startup, making it available in: +# Edit > Preferences > General > Stylesheet + +from .icons import MOCHA + +# Convenience aliases for commonly used colors +_base = MOCHA["base"] +_mantle = MOCHA["mantle"] +_crust = MOCHA["crust"] +_surface0 = MOCHA["surface0"] +_surface1 = MOCHA["surface1"] +_surface2 = MOCHA["surface2"] +_overlay0 = MOCHA["overlay0"] +_overlay1 = MOCHA["overlay1"] +_overlay2 = MOCHA["overlay2"] +_subtext0 = MOCHA["subtext0"] +_subtext1 = MOCHA["subtext1"] +_text = MOCHA["text"] +_lavender = MOCHA["lavender"] +_blue = MOCHA["blue"] +_sapphire = MOCHA["sapphire"] +_sky = MOCHA["sky"] +_teal = MOCHA["teal"] +_green = MOCHA["green"] +_yellow = MOCHA["yellow"] +_peach = MOCHA["peach"] +_maroon = MOCHA["maroon"] +_red = MOCHA["red"] +_mauve = MOCHA["mauve"] +_pink = MOCHA["pink"] +_flamingo = MOCHA["flamingo"] +_rosewater = MOCHA["rosewater"] + + +def generate_stylesheet() -> str: + """Generate the complete Catppuccin Mocha QSS stylesheet for FreeCAD. + + Returns: + str: Complete Qt stylesheet string. + """ + return f""" +/* ============================================================================= + Catppuccin Mocha Theme for FreeCAD + Bundled with ztools addon + https://catppuccin.com/ + ============================================================================= */ + +/* ============================================================================= + Global Defaults + ============================================================================= */ + +* {{ + color: {_text}; + font-family: "Segoe UI", "Ubuntu", "Noto Sans", sans-serif; +}} + +QWidget {{ + background-color: {_base}; + color: {_text}; + selection-background-color: {_surface2}; + selection-color: {_text}; +}} + +/* ============================================================================= + Main Window and MDI Area + ============================================================================= */ + +QMainWindow {{ + background-color: {_mantle}; +}} + +QMainWindow::separator {{ + background-color: {_surface0}; + width: 4px; + height: 4px; +}} + +QMainWindow::separator:hover {{ + background-color: {_mauve}; +}} + +QMdiArea {{ + background-color: {_crust}; +}} + +QMdiSubWindow {{ + background-color: {_base}; + border: 1px solid {_surface1}; +}} + +QMdiSubWindow > QWidget {{ + background-color: {_base}; +}} + +/* ============================================================================= + Menu Bar + ============================================================================= */ + +QMenuBar {{ + background-color: {_mantle}; + color: {_text}; + border-bottom: 1px solid {_surface0}; + padding: 2px; +}} + +QMenuBar::item {{ + background-color: transparent; + padding: 4px 8px; + border-radius: 4px; +}} + +QMenuBar::item:selected {{ + background-color: {_surface0}; +}} + +QMenuBar::item:pressed {{ + background-color: {_surface1}; +}} + +/* ============================================================================= + Menus + ============================================================================= */ + +QMenu {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 6px; + padding: 4px; +}} + +QMenu::item {{ + padding: 6px 24px 6px 8px; + border-radius: 4px; +}} + +QMenu::item:selected {{ + background-color: {_surface1}; + color: {_text}; +}} + +QMenu::item:disabled {{ + color: {_overlay0}; +}} + +QMenu::separator {{ + height: 1px; + background-color: {_surface1}; + margin: 4px 8px; +}} + +QMenu::icon {{ + margin-left: 8px; +}} + +QMenu::indicator {{ + width: 16px; + height: 16px; + margin-left: 4px; +}} + +/* ============================================================================= + Toolbars + ============================================================================= */ + +QToolBar {{ + background-color: {_mantle}; + border: none; + spacing: 2px; + padding: 2px; +}} + +QToolBar::handle {{ + background-color: {_surface1}; + width: 8px; + margin: 2px; + border-radius: 2px; +}} + +QToolBar::handle:horizontal {{ + width: 8px; +}} + +QToolBar::handle:vertical {{ + height: 8px; +}} + +QToolBar::separator {{ + background-color: {_surface1}; + width: 1px; + margin: 4px 2px; +}} + +/* ============================================================================= + Tool Buttons (Toolbar icons) + ============================================================================= */ + +QToolButton {{ + background-color: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 4px; + margin: 1px; +}} + +QToolButton:hover {{ + background-color: {_surface0}; + border: 1px solid {_surface1}; +}} + +QToolButton:pressed {{ + background-color: {_surface1}; +}} + +QToolButton:checked {{ + background-color: {_surface1}; + border: 1px solid {_mauve}; +}} + +QToolButton:disabled {{ + color: {_overlay0}; +}} + +QToolButton[popupMode="1"] {{ + padding-right: 16px; +}} + +QToolButton::menu-button {{ + border: none; + width: 14px; +}} + +QToolButton::menu-arrow {{ + width: 10px; + height: 10px; +}} + +/* ============================================================================= + Push Buttons + ============================================================================= */ + +QPushButton {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 6px; + padding: 6px 16px; + min-height: 20px; +}} + +QPushButton:hover {{ + background-color: {_surface1}; + border-color: {_surface2}; +}} + +QPushButton:pressed {{ + background-color: {_surface2}; +}} + +QPushButton:checked {{ + background-color: {_mauve}; + color: {_crust}; + border-color: {_mauve}; +}} + +QPushButton:disabled {{ + background-color: {_surface0}; + color: {_overlay0}; + border-color: {_surface0}; +}} + +QPushButton:default {{ + border: 2px solid {_mauve}; +}} + +/* ============================================================================= + Dock Widgets + ============================================================================= */ + +QDockWidget {{ + background-color: {_base}; + color: {_text}; + titlebar-close-icon: none; + titlebar-normal-icon: none; +}} + +QDockWidget::title {{ + background-color: {_mantle}; + color: {_text}; + padding: 6px; + border-bottom: 1px solid {_surface0}; +}} + +QDockWidget::close-button, +QDockWidget::float-button {{ + background-color: transparent; + border: none; + padding: 2px; +}} + +QDockWidget::close-button:hover, +QDockWidget::float-button:hover {{ + background-color: {_surface0}; + border-radius: 4px; +}} + +/* ============================================================================= + Tab Widgets + ============================================================================= */ + +QTabWidget::pane {{ + background-color: {_base}; + border: 1px solid {_surface1}; + border-radius: 4px; + top: -1px; +}} + +QTabBar {{ + background-color: transparent; +}} + +QTabBar::tab {{ + background-color: {_surface0}; + color: {_subtext1}; + border: 1px solid {_surface1}; + padding: 6px 12px; + margin-right: 2px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; +}} + +QTabBar::tab:selected {{ + background-color: {_base}; + color: {_text}; + border-bottom-color: {_base}; +}} + +QTabBar::tab:hover:!selected {{ + background-color: {_surface1}; + color: {_text}; +}} + +QTabBar::tab:disabled {{ + color: {_overlay0}; +}} + +QTabBar::close-button {{ + margin-left: 4px; +}} + +QTabBar::close-button:hover {{ + background-color: {_red}; + border-radius: 2px; +}} + +/* ============================================================================= + Scroll Bars + ============================================================================= */ + +QScrollBar:horizontal {{ + background-color: {_mantle}; + height: 12px; + margin: 0 12px 0 12px; + border-radius: 6px; +}} + +QScrollBar:vertical {{ + background-color: {_mantle}; + width: 12px; + margin: 12px 0 12px 0; + border-radius: 6px; +}} + +QScrollBar::handle:horizontal {{ + background-color: {_surface1}; + min-width: 20px; + border-radius: 5px; + margin: 1px; +}} + +QScrollBar::handle:vertical {{ + background-color: {_surface1}; + min-height: 20px; + border-radius: 5px; + margin: 1px; +}} + +QScrollBar::handle:horizontal:hover, +QScrollBar::handle:vertical:hover {{ + background-color: {_surface2}; +}} + +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal, +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical {{ + width: 12px; + height: 12px; + background-color: {_surface0}; + border-radius: 6px; +}} + +QScrollBar::add-line:horizontal:hover, +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:vertical:hover, +QScrollBar::sub-line:vertical:hover {{ + background-color: {_surface1}; +}} + +QScrollBar::add-page:horizontal, +QScrollBar::sub-page:horizontal, +QScrollBar::add-page:vertical, +QScrollBar::sub-page:vertical {{ + background-color: transparent; +}} + +/* ============================================================================= + Input Fields + ============================================================================= */ + +QLineEdit {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px 8px; + selection-background-color: {_mauve}; + selection-color: {_crust}; +}} + +QLineEdit:focus {{ + border-color: {_mauve}; +}} + +QLineEdit:disabled {{ + background-color: {_mantle}; + color: {_overlay0}; +}} + +QLineEdit:read-only {{ + background-color: {_mantle}; +}} + +QTextEdit, QPlainTextEdit {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + selection-background-color: {_mauve}; + selection-color: {_crust}; +}} + +QTextEdit:focus, QPlainTextEdit:focus {{ + border-color: {_mauve}; +}} + +/* ============================================================================= + Spin Boxes + ============================================================================= */ + +QSpinBox, QDoubleSpinBox {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px; + padding-right: 20px; +}} + +QSpinBox:focus, QDoubleSpinBox:focus {{ + border-color: {_mauve}; +}} + +QSpinBox:disabled, QDoubleSpinBox:disabled {{ + background-color: {_mantle}; + color: {_overlay0}; +}} + +QSpinBox::up-button, QDoubleSpinBox::up-button {{ + subcontrol-origin: border; + subcontrol-position: top right; + width: 16px; + border-left: 1px solid {_surface1}; + border-top-right-radius: 4px; + background-color: {_surface1}; +}} + +QSpinBox::down-button, QDoubleSpinBox::down-button {{ + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 16px; + border-left: 1px solid {_surface1}; + border-bottom-right-radius: 4px; + background-color: {_surface1}; +}} + +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {{ + background-color: {_surface2}; +}} + +QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed, +QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed {{ + background-color: {_mauve}; +}} + +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{ + width: 8px; + height: 8px; +}} + +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{ + width: 8px; + height: 8px; +}} + +/* ============================================================================= + Combo Boxes + ============================================================================= */ + +QComboBox {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px 8px; + padding-right: 24px; + min-height: 20px; +}} + +QComboBox:hover {{ + border-color: {_surface2}; +}} + +QComboBox:focus {{ + border-color: {_mauve}; +}} + +QComboBox:disabled {{ + background-color: {_mantle}; + color: {_overlay0}; +}} + +QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border-left: 1px solid {_surface1}; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: {_surface1}; +}} + +QComboBox::drop-down:hover {{ + background-color: {_surface2}; +}} + +QComboBox::down-arrow {{ + width: 10px; + height: 10px; +}} + +QComboBox QAbstractItemView {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + selection-background-color: {_surface1}; + selection-color: {_text}; + outline: none; +}} + +QComboBox QAbstractItemView::item {{ + padding: 4px 8px; + min-height: 24px; +}} + +QComboBox QAbstractItemView::item:hover {{ + background-color: {_surface1}; +}} + +QComboBox QAbstractItemView::item:selected {{ + background-color: {_surface2}; +}} + +/* ============================================================================= + Check Boxes + ============================================================================= */ + +QCheckBox {{ + spacing: 8px; + color: {_text}; +}} + +QCheckBox:disabled {{ + color: {_overlay0}; +}} + +QCheckBox::indicator {{ + width: 18px; + height: 18px; + border: 2px solid {_surface2}; + border-radius: 4px; + background-color: {_surface0}; +}} + +QCheckBox::indicator:hover {{ + border-color: {_mauve}; +}} + +QCheckBox::indicator:checked {{ + background-color: {_mauve}; + border-color: {_mauve}; +}} + +QCheckBox::indicator:checked:disabled {{ + background-color: {_overlay0}; + border-color: {_overlay0}; +}} + +QCheckBox::indicator:disabled {{ + background-color: {_mantle}; + border-color: {_surface1}; +}} + +/* ============================================================================= + Radio Buttons + ============================================================================= */ + +QRadioButton {{ + spacing: 8px; + color: {_text}; +}} + +QRadioButton:disabled {{ + color: {_overlay0}; +}} + +QRadioButton::indicator {{ + width: 18px; + height: 18px; + border: 2px solid {_surface2}; + border-radius: 9px; + background-color: {_surface0}; +}} + +QRadioButton::indicator:hover {{ + border-color: {_mauve}; +}} + +QRadioButton::indicator:checked {{ + background-color: {_mauve}; + border-color: {_mauve}; +}} + +QRadioButton::indicator:checked:disabled {{ + background-color: {_overlay0}; + border-color: {_overlay0}; +}} + +QRadioButton::indicator:disabled {{ + background-color: {_mantle}; + border-color: {_surface1}; +}} + +/* ============================================================================= + Sliders + ============================================================================= */ + +QSlider::groove:horizontal {{ + height: 6px; + background-color: {_surface1}; + border-radius: 3px; +}} + +QSlider::groove:vertical {{ + width: 6px; + background-color: {_surface1}; + border-radius: 3px; +}} + +QSlider::handle:horizontal {{ + width: 16px; + height: 16px; + margin: -5px 0; + background-color: {_mauve}; + border-radius: 8px; +}} + +QSlider::handle:vertical {{ + width: 16px; + height: 16px; + margin: 0 -5px; + background-color: {_mauve}; + border-radius: 8px; +}} + +QSlider::handle:horizontal:hover, +QSlider::handle:vertical:hover {{ + background-color: {_lavender}; +}} + +QSlider::handle:horizontal:pressed, +QSlider::handle:vertical:pressed {{ + background-color: {_pink}; +}} + +QSlider::sub-page:horizontal {{ + background-color: {_mauve}; + border-radius: 3px; +}} + +QSlider::add-page:vertical {{ + background-color: {_mauve}; + border-radius: 3px; +}} + +/* ============================================================================= + Progress Bars + ============================================================================= */ + +QProgressBar {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + text-align: center; + height: 20px; +}} + +QProgressBar::chunk {{ + background-color: {_mauve}; + border-radius: 3px; +}} + +/* ============================================================================= + Group Boxes + ============================================================================= */ + +QGroupBox {{ + background-color: {_base}; + border: 1px solid {_surface1}; + border-radius: 6px; + margin-top: 12px; + padding-top: 8px; +}} + +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + padding: 0 4px; + color: {_subtext1}; + background-color: {_base}; +}} + +/* ============================================================================= + Tree View + ============================================================================= */ + +QTreeView {{ + background-color: {_base}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + outline: none; +}} + +QTreeView::item {{ + padding: 4px; + border-radius: 2px; +}} + +QTreeView::item:hover {{ + background-color: {_surface0}; +}} + +QTreeView::item:selected {{ + background-color: {_surface1}; + color: {_text}; +}} + +QTreeView::item:selected:active {{ + background-color: {_surface2}; +}} + +/* Branch indicators (collapse/expand arrows) - uses FreeCAD built-in light images for dark theme */ +QTreeView::branch {{ + background: transparent; +}} + +QTreeView::branch:has-siblings:!adjoins-item {{ + border-image: url(qss:images_dark-light/branch_vline_light.svg) 0; +}} + +QTreeView::branch:has-siblings:adjoins-item {{ + border-image: url(qss:images_dark-light/branch_more_light.svg) 0; +}} + +QTreeView::branch:!has-children:!has-siblings:adjoins-item {{ + border-image: url(qss:images_dark-light/branch_end_light.svg) 0; +}} + +QTreeView::branch:closed:has-children:has-siblings {{ + border-image: url(qss:images_dark-light/branch_more_closed_light.svg) 0; +}} + +QTreeView::branch:has-children:!has-siblings:closed {{ + border-image: url(qss:images_dark-light/branch_end_closed_light.svg) 0; +}} + +QTreeView::branch:open:has-children:has-siblings {{ + border-image: url(qss:images_dark-light/branch_more_open_light.svg) 0; +}} + +QTreeView::branch:open:has-children:!has-siblings {{ + border-image: url(qss:images_dark-light/branch_end_open_light.svg) 0; +}} + +/* ============================================================================= + List View + ============================================================================= */ + +QListView {{ + background-color: {_base}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + outline: none; +}} + +QListView::item {{ + padding: 4px; + border-radius: 2px; +}} + +QListView::item:hover {{ + background-color: {_surface0}; +}} + +QListView::item:selected {{ + background-color: {_surface1}; + color: {_text}; +}} + +/* ============================================================================= + Table View + ============================================================================= */ + +QTableView {{ + background-color: {_base}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + gridline-color: {_surface0}; + outline: none; +}} + +QTableView::item {{ + padding: 4px; +}} + +QTableView::item:hover {{ + background-color: {_surface0}; +}} + +QTableView::item:selected {{ + background-color: {_surface1}; + color: {_text}; +}} + +QTableView QTableCornerButton::section {{ + background-color: {_surface0}; + border: 1px solid {_surface1}; +}} + +/* ============================================================================= + Header Views (for Tables/Trees) + ============================================================================= */ + +QHeaderView {{ + background-color: {_surface0}; + border: none; +}} + +QHeaderView::section {{ + background-color: {_surface0}; + color: {_subtext1}; + border: none; + border-right: 1px solid {_surface1}; + border-bottom: 1px solid {_surface1}; + padding: 6px 8px; +}} + +QHeaderView::section:hover {{ + background-color: {_surface1}; + color: {_text}; +}} + +QHeaderView::section:checked {{ + background-color: {_surface1}; +}} + +QHeaderView::down-arrow {{ + width: 10px; + height: 10px; +}} + +QHeaderView::up-arrow {{ + width: 10px; + height: 10px; +}} + +/* ============================================================================= + Splitters + ============================================================================= */ + +QSplitter::handle {{ + background-color: {_surface0}; +}} + +QSplitter::handle:horizontal {{ + width: 4px; +}} + +QSplitter::handle:vertical {{ + height: 4px; +}} + +QSplitter::handle:hover {{ + background-color: {_mauve}; +}} + +/* ============================================================================= + Status Bar + ============================================================================= */ + +QStatusBar {{ + background-color: {_mantle}; + color: {_subtext1}; + border-top: 1px solid {_surface0}; +}} + +QStatusBar::item {{ + border: none; +}} + +QStatusBar QLabel {{ + padding: 2px 8px; +}} + +/* ============================================================================= + Tooltips + ============================================================================= */ + +QToolTip {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px 8px; +}} + +/* ============================================================================= + Labels + ============================================================================= */ + +QLabel {{ + color: {_text}; + background-color: transparent; +}} + +QLabel:disabled {{ + color: {_overlay0}; +}} + +/* ============================================================================= + Frames + ============================================================================= */ + +QFrame {{ + border: none; +}} + +QFrame[frameShape="4"] {{ + /* HLine */ + background-color: {_surface1}; + max-height: 1px; +}} + +QFrame[frameShape="5"] {{ + /* VLine */ + background-color: {_surface1}; + max-width: 1px; +}} + +/* ============================================================================= + Tool Box (Collapsible sections) + ============================================================================= */ + +QToolBox {{ + background-color: {_base}; + border: 1px solid {_surface1}; + border-radius: 4px; +}} + +QToolBox::tab {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 8px; +}} + +QToolBox::tab:selected {{ + background-color: {_surface1}; + border-color: {_mauve}; +}} + +QToolBox::tab:hover {{ + background-color: {_surface1}; +}} + +/* ============================================================================= + Dialog Buttons + ============================================================================= */ + +QDialogButtonBox {{ + button-layout: 0; +}} + +/* ============================================================================= + Date/Time Edits + ============================================================================= */ + +QDateEdit, QTimeEdit, QDateTimeEdit {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px; +}} + +QDateEdit:focus, QTimeEdit:focus, QDateTimeEdit:focus {{ + border-color: {_mauve}; +}} + +QDateEdit::drop-down, QTimeEdit::drop-down, QDateTimeEdit::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 20px; + border-left: 1px solid {_surface1}; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + background-color: {_surface1}; +}} + +QCalendarWidget {{ + background-color: {_base}; +}} + +QCalendarWidget QToolButton {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + margin: 2px; +}} + +QCalendarWidget QToolButton:hover {{ + background-color: {_surface1}; +}} + +QCalendarWidget QMenu {{ + background-color: {_surface0}; +}} + +QCalendarWidget QSpinBox {{ + background-color: {_surface0}; +}} + +QCalendarWidget QAbstractItemView {{ + background-color: {_base}; + selection-background-color: {_mauve}; + selection-color: {_crust}; +}} + +/* ============================================================================= + Wizard + ============================================================================= */ + +QWizard {{ + background-color: {_base}; +}} + +QWizard QLabel {{ + color: {_text}; +}} + +/* ============================================================================= + FreeCAD Specific Widgets + ============================================================================= */ + +/* Property Editor */ +Gui--PropertyEditor--PropertyEditor {{ + background-color: {_base}; + color: {_text}; + border: 1px solid {_surface1}; + qproperty-groupBackground: {_surface0}; + qproperty-groupTextColor: {_subtext1}; + qproperty-itemBackground: {_base}; +}} + +Gui--PropertyEditor--PropertyEditor QLineEdit {{ + background-color: {_surface0}; + border: 1px solid {_surface1}; +}} + +Gui--PropertyEditor--PropertyEditor QComboBox {{ + background-color: {_surface0}; +}} + +/* Color Button */ +Gui--ColorButton {{ + background-color: {_surface0}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 2px; +}} + +Gui--ColorButton:hover {{ + border-color: {_mauve}; +}} + +/* Workbench Selector */ +Gui--WorkbenchComboBox {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; + padding: 4px 8px; +}} + +Gui--WorkbenchComboBox:hover {{ + border-color: {_surface2}; +}} + +Gui--WorkbenchComboBox::drop-down {{ + background-color: {_surface1}; + border-left: 1px solid {_surface1}; + border-radius: 0 4px 4px 0; +}} + +/* Task Panel */ +QSint--ActionGroup {{ + background-color: {_surface0}; + border: 1px solid {_surface1}; + border-radius: 6px; +}} + +QSint--ActionGroup QToolButton {{ + background-color: {_surface0}; + color: {_text}; + border: none; + border-radius: 4px; + padding: 6px; +}} + +QSint--ActionGroup QToolButton:hover {{ + background-color: {_surface1}; +}} + +QSint--ActionGroup QFrame {{ + background-color: {_base}; + border: none; + border-radius: 4px; +}} + +/* Input Field */ +Gui--InputField {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; + border-radius: 4px; +}} + +Gui--InputField:focus {{ + border-color: {_mauve}; +}} + +/* Expression Completer */ +Gui--ExpressionCompleter {{ + background-color: {_surface0}; + color: {_text}; + border: 1px solid {_surface1}; +}} + +/* Spreadsheet */ +SpreadsheetGui--SheetTableView {{ + background-color: {_base}; + color: {_text}; + gridline-color: {_surface1}; + selection-background-color: {_surface1}; + selection-color: {_text}; +}} + +SpreadsheetGui--SheetTableView QHeaderView::section {{ + background-color: {_surface0}; + color: {_subtext1}; + border: 1px solid {_surface1}; + padding: 4px; +}} + +/* Python Console */ +Gui--PythonConsole {{ + background-color: {_crust}; + color: {_text}; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + selection-background-color: {_surface1}; +}} + +/* Python Editor */ +Gui--PythonEditor {{ + background-color: {_crust}; + color: {_text}; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; + selection-background-color: {_mauve}; + selection-color: {_crust}; +}} + +/* Report View */ +Gui--DockWnd--ReportOutput {{ + background-color: {_crust}; + color: {_text}; + font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace; +}} + +/* DAG View */ +Gui--DAG--Model {{ + background-color: {_base}; +}} + +/* ============================================================================= + Sketcher Specific Styles + ============================================================================= */ + +/* Sketcher constraint colors are handled via preferences, not QSS */ + +/* ============================================================================= + Syntax Highlighting Colors (Python Editor) + Note: These are typically set via FreeCAD preferences, but we define them here + for reference and any widgets that support them. + ============================================================================= */ + +/* + Python Editor Syntax Colors (Catppuccin Mocha): + - Comment: {_overlay1} + - Number: {_peach} + - String: {_green} + - Keyword: {_mauve} + - Class/Def name: {_blue} + - Operator: {_sky} + - Output: {_text} + - Error: {_red} +*/ + +/* ============================================================================= + Custom Color Accents by Context + ============================================================================= */ + +/* Success states */ +*[state="success"] {{ + color: {_green}; +}} + +/* Warning states */ +*[state="warning"] {{ + color: {_yellow}; +}} + +/* Error states */ +*[state="error"] {{ + color: {_red}; +}} + +/* Info states */ +*[state="info"] {{ + color: {_blue}; +}} +""" + + +def get_stylesheet() -> str: + """Get the Catppuccin Mocha stylesheet. + + Returns: + str: Complete QSS stylesheet. + """ + return generate_stylesheet()