diff --git a/ztools/InitGui.py b/ztools/InitGui.py index 2f1fd52..6877f32 100644 --- a/ztools/InitGui.py +++ b/ztools/InitGui.py @@ -67,6 +67,7 @@ class ZToolsWorkbench(Gui.Workbench): pass from ztools.commands import ( + appearance_commands, assembly_pattern_commands, datum_commands, pattern_commands, @@ -253,6 +254,18 @@ class ZToolsWorkbench(Gui.Workbench): "ZTools_SpreadsheetQuickAlias", ] + # ===================================================================== + # ZTools Appearance Mode Tools + # ===================================================================== + self.ztools_appearance_tools = [ + "ZTools_AppearanceMode", + "ZTools_SetCategory", + ] + self.ztools_appearance_menu_tools = [ + "ZTools_AppearanceRealistic", + "ZTools_AppearanceEngineering", + ] + # ===================================================================== # Append Toolbars # ===================================================================== @@ -275,6 +288,7 @@ class ZToolsWorkbench(Gui.Workbench): self.appendToolbar("ztools Assembly", self.ztools_assembly_tools) self.appendToolbar("Spreadsheet", self.spreadsheet_tools) self.appendToolbar("ztools Spreadsheet", self.ztools_spreadsheet_tools) + self.appendToolbar("ztools Appearance", self.ztools_appearance_tools) # ===================================================================== # Append Menus @@ -297,13 +311,19 @@ class ZToolsWorkbench(Gui.Workbench): self.appendMenu(["Assembly", "Management"], self.assembly_management_tools) self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools) self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools) + self.appendMenu( + ["View", "Appearance Mode"], + self.ztools_appearance_menu_tools + ["ZTools_SetCategory"], + ) self.appendMenu( "ztools", self.ztools_datum_tools + self.ztools_pattern_tools + self.ztools_pocket_tools + self.ztools_assembly_tools - + self.ztools_spreadsheet_tools, + + self.ztools_spreadsheet_tools + + self.ztools_appearance_menu_tools + + ["ZTools_SetCategory"], ) # Register the PartDesign manipulator now that commands exist. @@ -324,11 +344,24 @@ class ZToolsWorkbench(Gui.Workbench): except Exception as e: App.Console.PrintWarning(f"Could not apply spreadsheet colors: {e}\n") + # Activate appearance mode system + try: + from ztools.appearance import get_manager + + get_manager().activate() + except Exception as e: + App.Console.PrintWarning(f"Could not activate appearance system: {e}\n") + App.Console.PrintMessage("ztools workbench activated\n") def Deactivated(self): """Called when workbench is deactivated.""" - pass + try: + from ztools.appearance import get_manager + + get_manager().deactivate() + except Exception: + pass def GetClassName(self): return "Gui::PythonWorkbench" diff --git a/ztools/ztools/appearance/__init__.py b/ztools/ztools/appearance/__init__.py new file mode 100644 index 0000000..1fb10f3 --- /dev/null +++ b/ztools/ztools/appearance/__init__.py @@ -0,0 +1,4 @@ +# ztools/appearance — Appearance mode system +from .manager import get_manager + +__all__ = ["get_manager"] diff --git a/ztools/ztools/appearance/engineering.py b/ztools/ztools/appearance/engineering.py new file mode 100644 index 0000000..7bebb54 --- /dev/null +++ b/ztools/ztools/appearance/engineering.py @@ -0,0 +1,79 @@ +# ztools/appearance/engineering.py +# Engineering mode — colors parts by KindredCategory using palette + +import FreeCAD as App + +from .mode import AppearanceMode +from .palette import load_palette + +KINDRED_CATEGORIES = [ + "custom_body", + "fastener", + "structural", + "electrical", + "seal_gasket", + "bearing_bushing", + "spring_compliant", + "moving_part", +] + +DEFAULT_CATEGORY = "custom_body" + + +def _is_eligible(obj) -> bool: + """Check if an object should be processed by appearance modes.""" + return ( + hasattr(obj, "isDerivedFrom") + and obj.isDerivedFrom("Part::Feature") + and hasattr(obj, "ViewObject") + and obj.ViewObject is not None + and hasattr(obj.ViewObject, "ShapeColor") + ) + + +def ensure_category_property(obj) -> None: + """Add KindredCategory enum property to an object if it doesn't exist.""" + if not hasattr(obj, "KindredCategory"): + obj.addProperty( + "App::PropertyEnumeration", + "KindredCategory", + "Kindred", + "Part category for engineering appearance mode", + ) + obj.KindredCategory = KINDRED_CATEGORIES + obj.KindredCategory = DEFAULT_CATEGORY + + +class EngineeringMode(AppearanceMode): + """Colors parts by their KindredCategory using the active palette.""" + + name = "engineering" + + def apply(self, doc) -> None: + for obj in doc.Objects: + if _is_eligible(obj): + self.apply_to_object(obj) + + def reset(self, doc) -> None: + from . import get_manager + + manager = get_manager() + for obj in doc.Objects: + if _is_eligible(obj): + manager.restore_original(obj) + + def apply_to_object(self, obj) -> None: + if not _is_eligible(obj): + return + + from . import get_manager + + manager = get_manager() + manager.save_original(obj) + + ensure_category_property(obj) + category = obj.KindredCategory if obj.KindredCategory else DEFAULT_CATEGORY + + palette = load_palette() + color = palette.get(category, palette[DEFAULT_CATEGORY]) + obj.ViewObject.ShapeColor = color diff --git a/ztools/ztools/appearance/manager.py b/ztools/ztools/appearance/manager.py new file mode 100644 index 0000000..5099950 --- /dev/null +++ b/ztools/ztools/appearance/manager.py @@ -0,0 +1,198 @@ +# ztools/appearance/manager.py +# Appearance mode manager and document observer + +import FreeCAD as App + +from .engineering import EngineeringMode, _is_eligible +from .realistic import RealisticMode + +_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/Kindred" +_PREF_KEY = "AppearanceMode" + +_MODES = { + "realistic": RealisticMode, + "engineering": EngineeringMode, +} + +_RECOLOR_DEBOUNCE_MS = 100 + + +class _AppearanceObserver: + """Document observer that auto-applies appearance mode to new/changed objects.""" + + def __init__(self, manager: "AppearanceManager"): + self._manager = manager + self._pending_recolor: set = set() # object names awaiting recolor + self._debounce_scheduled = False + + def slotCreatedObject(self, obj) -> None: + if self._manager.active_mode.name != "engineering": + return + if not (hasattr(obj, "isDerivedFrom") and obj.isDerivedFrom("Part::Feature")): + return + # Defer to allow ViewObject to be fully initialized + try: + from PySide.QtCore import QTimer + + QTimer.singleShot(0, lambda o=obj: self._apply_deferred(o)) + except ImportError: + self._apply_deferred(obj) + + def slotChangedObject(self, obj, prop: str) -> None: + if self._manager.active_mode.name != "engineering": + return + if not _is_eligible(obj): + return + + if prop == "KindredCategory": + # Immediate recolor on category change + self._manager.active_mode.apply_to_object(obj) + elif prop == "Shape": + # Shape changed (recompute) — debounce to batch multiple updates + self._pending_recolor.add((obj.Document.Name, obj.Name)) + self._schedule_flush() + + def _schedule_flush(self) -> None: + if self._debounce_scheduled: + return + self._debounce_scheduled = True + try: + from PySide.QtCore import QTimer + + QTimer.singleShot(_RECOLOR_DEBOUNCE_MS, self._flush_recolor) + except ImportError: + self._flush_recolor() + + def _flush_recolor(self) -> None: + self._debounce_scheduled = False + pending = list(self._pending_recolor) + self._pending_recolor.clear() + for doc_name, obj_name in pending: + doc = App.getDocument(doc_name) + if doc is None: + continue + obj = doc.getObject(obj_name) + if obj is not None and _is_eligible(obj): + self._manager.active_mode.apply_to_object(obj) + + def _apply_deferred(self, obj) -> None: + if _is_eligible(obj): + self._manager.active_mode.apply_to_object(obj) + + +class AppearanceManager: + """Singleton manager coordinating appearance mode switching.""" + + def __init__(self): + self._active_mode = RealisticMode() + self._originals: dict = {} # {doc_name: {obj_name: (ShapeColor, Transparency)}} + self._observer = None + + @property + def active_mode(self): + return self._active_mode + + def set_mode(self, mode_name: str) -> None: + """Switch appearance mode, applying to all open documents.""" + if mode_name == self._active_mode.name: + return + + mode_cls = _MODES.get(mode_name) + if mode_cls is None: + App.Console.error(f"ztools: unknown appearance mode '{mode_name}'\n") + return + + # Reset current mode, then apply new one + for doc in App.listDocuments().values(): + self._active_mode.reset(doc) + + self._active_mode = mode_cls() + + for doc in App.listDocuments().values(): + self._active_mode.apply(doc) + + # Persist preference + App.ParamGet(_PREF_GROUP).SetString(_PREF_KEY, mode_name) + + # Update status bar + try: + from .ui import show_status_bar + + show_status_bar(mode_name) + except Exception: + pass + + App.Console.log(f"ztools: appearance mode set to '{mode_name}'\n") + + def save_original(self, obj) -> None: + """Snapshot ShapeColor and Transparency (save-once semantics).""" + doc_name = obj.Document.Name + obj_name = obj.Name + doc_cache = self._originals.setdefault(doc_name, {}) + if obj_name in doc_cache: + return # Already saved + vo = obj.ViewObject + doc_cache[obj_name] = ( + tuple(vo.ShapeColor), + vo.Transparency if hasattr(vo, "Transparency") else 0, + ) + + def restore_original(self, obj) -> None: + """Restore saved ShapeColor and Transparency, removing cache entry.""" + doc_name = obj.Document.Name + obj_name = obj.Name + doc_cache = self._originals.get(doc_name, {}) + saved = doc_cache.pop(obj_name, None) + if saved is None: + return + shape_color, transparency = saved + vo = obj.ViewObject + vo.ShapeColor = shape_color + if hasattr(vo, "Transparency"): + vo.Transparency = transparency + + def activate(self) -> None: + """Start observer and apply persisted mode. Called from Workbench.Activated().""" + if self._observer is None: + self._observer = _AppearanceObserver(self) + App.addDocumentObserver(self._observer) + + # Apply persisted mode + mode_name = App.ParamGet(_PREF_GROUP).GetString(_PREF_KEY, "realistic") + if mode_name != self._active_mode.name: + self._active_mode = _MODES.get(mode_name, RealisticMode)() + for doc in App.listDocuments().values(): + self._active_mode.apply(doc) + + # Show status bar + try: + from .ui import show_status_bar + + show_status_bar(self._active_mode.name) + except Exception: + pass + + def deactivate(self) -> None: + """Stop observer and remove status bar. Called from Workbench.Deactivated().""" + if self._observer is not None: + App.removeDocumentObserver(self._observer) + self._observer = None + + try: + from .ui import hide_status_bar + + hide_status_bar() + except Exception: + pass + + +# Module-level singleton +_manager = None + + +def get_manager() -> AppearanceManager: + """Get the global AppearanceManager singleton.""" + global _manager + if _manager is None: + _manager = AppearanceManager() + return _manager diff --git a/ztools/ztools/appearance/mode.py b/ztools/ztools/appearance/mode.py new file mode 100644 index 0000000..ccd76cc --- /dev/null +++ b/ztools/ztools/appearance/mode.py @@ -0,0 +1,22 @@ +# ztools/appearance/mode.py +# Abstract base class for appearance modes + +from abc import ABC, abstractmethod + + +class AppearanceMode(ABC): + """Base class for viewport appearance modes.""" + + name: str = "" + + @abstractmethod + def apply(self, doc) -> None: + """Apply this mode to all eligible objects in the document.""" + + @abstractmethod + def reset(self, doc) -> None: + """Undo this mode's visual changes on all objects in the document.""" + + @abstractmethod + def apply_to_object(self, obj) -> None: + """Apply this mode to a single object.""" diff --git a/ztools/ztools/appearance/palette.py b/ztools/ztools/appearance/palette.py new file mode 100644 index 0000000..7710909 --- /dev/null +++ b/ztools/ztools/appearance/palette.py @@ -0,0 +1,36 @@ +# ztools/appearance/palette.py +# Palette loading for appearance modes + +import json +import os +from typing import Dict, Tuple + +_PALETTES_DIR = os.path.join(os.path.dirname(__file__), "palettes") +_cache: Dict[str, Dict[str, Tuple[float, float, float]]] = {} + + +def hex_to_rgb(hex_str: str) -> Tuple[float, float, float]: + """Convert hex color string to RGB tuple with 0.0-1.0 floats.""" + h = hex_str.lstrip("#") + return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0) + + +def load_palette( + name: str = "catppuccin_mocha", +) -> Dict[str, Tuple[float, float, float]]: + """Load a palette by name, returning {category: (r, g, b)} with 0.0-1.0 floats. + + Palettes are JSON files in the palettes/ directory. Results are cached. + """ + if name in _cache: + return _cache[name] + + path = os.path.join(_PALETTES_DIR, f"{name}.json") + with open(path, "r") as f: + data = json.load(f) + + palette = { + cat: hex_to_rgb(hex_color) for cat, hex_color in data["categories"].items() + } + _cache[name] = palette + return palette diff --git a/ztools/ztools/appearance/palettes/catppuccin_mocha.json b/ztools/ztools/appearance/palettes/catppuccin_mocha.json new file mode 100644 index 0000000..f7eeebe --- /dev/null +++ b/ztools/ztools/appearance/palettes/catppuccin_mocha.json @@ -0,0 +1,13 @@ +{ + "name": "Catppuccin Mocha", + "categories": { + "custom_body": "#fab387", + "fastener": "#a6e3a1", + "structural": "#89b4fa", + "electrical": "#f9e2af", + "seal_gasket": "#cba6f7", + "bearing_bushing": "#74c7ec", + "spring_compliant": "#f2cdcd", + "moving_part": "#f38ba8" + } +} diff --git a/ztools/ztools/appearance/realistic.py b/ztools/ztools/appearance/realistic.py new file mode 100644 index 0000000..c12aeea --- /dev/null +++ b/ztools/ztools/appearance/realistic.py @@ -0,0 +1,35 @@ +# ztools/appearance/realistic.py +# Realistic mode — restores original material appearances + +from .mode import AppearanceMode + + +def _is_eligible(obj) -> bool: + """Check if an object should be processed by appearance modes.""" + return ( + hasattr(obj, "isDerivedFrom") + and obj.isDerivedFrom("Part::Feature") + and hasattr(obj, "ViewObject") + and obj.ViewObject is not None + and hasattr(obj.ViewObject, "ShapeColor") + ) + + +class RealisticMode(AppearanceMode): + """Material passthrough — restores original ShapeColor and Transparency.""" + + name = "realistic" + + def apply(self, doc) -> None: + from . import get_manager + + manager = get_manager() + for obj in doc.Objects: + if _is_eligible(obj): + manager.restore_original(obj) + + def reset(self, doc) -> None: + pass # Nothing to undo — realistic is the baseline + + def apply_to_object(self, obj) -> None: + pass # No-op — material passthrough diff --git a/ztools/ztools/appearance/ui.py b/ztools/ztools/appearance/ui.py new file mode 100644 index 0000000..91b9be1 --- /dev/null +++ b/ztools/ztools/appearance/ui.py @@ -0,0 +1,72 @@ +# ztools/appearance/ui.py +# Status bar widget for appearance mode indicator + +import FreeCADGui as Gui +from PySide import QtCore, QtGui + +_MODE_LABELS = { + "realistic": "Realistic", + "engineering": "Engineering", +} + +_MODE_COLORS = { + "realistic": "#7f849c", # overlay1 + "engineering": "#fab387", # peach +} + + +class AppearanceStatusBar(QtGui.QLabel): + """Status bar label showing the current appearance mode. Clickable to toggle.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setCursor(QtCore.Qt.PointingHandCursor) + self.setToolTip("Click to toggle appearance mode") + self.setStyleSheet("padding: 2px 6px;") + self._current_mode = "realistic" + self._update_display() + + def set_mode(self, mode_name: str) -> None: + self._current_mode = mode_name + self._update_display() + + def _update_display(self): + label = _MODE_LABELS.get(self._current_mode, self._current_mode) + color = _MODE_COLORS.get(self._current_mode, "#cdd6f4") + self.setText(f" {label}") + + def mousePressEvent(self, event): + from . import get_manager + + manager = get_manager() + next_mode = "engineering" if self._current_mode == "realistic" else "realistic" + manager.set_mode(next_mode) + super().mousePressEvent(event) + + +_widget = None + + +def show_status_bar(mode_name: str) -> None: + """Add or update the appearance mode status bar widget.""" + global _widget + mw = Gui.getMainWindow() + if mw is None: + return + status_bar = mw.statusBar() + if _widget is None: + _widget = AppearanceStatusBar(status_bar) + status_bar.addPermanentWidget(_widget) + _widget.set_mode(mode_name) + _widget.show() + + +def hide_status_bar() -> None: + """Remove the appearance mode status bar widget.""" + global _widget + if _widget is not None: + mw = Gui.getMainWindow() + if mw is not None: + mw.statusBar().removeWidget(_widget) + _widget.deleteLater() + _widget = None diff --git a/ztools/ztools/commands/__init__.py b/ztools/ztools/commands/__init__.py index 8a1b211..48ef4ae 100644 --- a/ztools/ztools/commands/__init__.py +++ b/ztools/ztools/commands/__init__.py @@ -1,5 +1,6 @@ # ztools/commands - GUI commands from . import ( + appearance_commands, assembly_pattern_commands, datum_commands, datum_viewprovider, @@ -9,6 +10,7 @@ from . import ( ) __all__ = [ + "appearance_commands", "datum_commands", "datum_viewprovider", "pattern_commands", diff --git a/ztools/ztools/commands/appearance_commands.py b/ztools/ztools/commands/appearance_commands.py new file mode 100644 index 0000000..8d44c21 --- /dev/null +++ b/ztools/ztools/commands/appearance_commands.py @@ -0,0 +1,127 @@ +# ztools/commands/appearance_commands.py +# Commands for switching appearance modes and setting part categories + +import FreeCAD as App +import FreeCADGui as Gui + +from ztools.appearance import get_manager +from ztools.appearance.engineering import KINDRED_CATEGORIES, ensure_category_property +from ztools.resources.icons import get_icon + + +class ZTools_AppearanceRealistic: + """Switch to Realistic appearance mode (material passthrough).""" + + def GetResources(self): + return { + "Pixmap": get_icon("appearance_realistic"), + "MenuText": "Realistic", + "ToolTip": "Show parts with their original material appearance", + } + + def Activated(self): + get_manager().set_mode("realistic") + + def IsActive(self): + return App.ActiveDocument is not None + + +class ZTools_AppearanceEngineering: + """Switch to Engineering appearance mode (category-based coloring).""" + + def GetResources(self): + return { + "Pixmap": get_icon("appearance_engineering"), + "MenuText": "Engineering", + "ToolTip": "Color parts by KindredCategory using engineering palette", + } + + def Activated(self): + get_manager().set_mode("engineering") + + def IsActive(self): + return App.ActiveDocument is not None + + +class ZTools_AppearanceMode: + """Grouped dropdown for appearance mode switching.""" + + def GetCommands(self): + return ("ZTools_AppearanceRealistic", "ZTools_AppearanceEngineering") + + def GetDefaultCommand(self): + return 0 + + def GetResources(self): + return { + "Pixmap": get_icon("appearance_engineering"), + "MenuText": "Appearance Mode", + "ToolTip": "Switch viewport appearance mode", + } + + def IsActive(self): + return App.ActiveDocument is not None + + +class ZTools_SetCategory: + """Set KindredCategory on selected parts via popup menu.""" + + def GetResources(self): + return { + "Pixmap": get_icon("set_category"), + "MenuText": "Set Category", + "ToolTip": "Set KindredCategory on selected parts", + } + + def Activated(self): + from PySide import QtGui + + sel = Gui.Selection.getSelection() + eligible = [ + obj + for obj in sel + if hasattr(obj, "isDerivedFrom") and obj.isDerivedFrom("Part::Feature") + ] + if not eligible: + return + + # Build popup menu with category choices + menu = QtGui.QMenu() + labels = { + "custom_body": "Custom Body", + "fastener": "Fastener", + "structural": "Structural", + "electrical": "Electrical", + "seal_gasket": "Seal/Gasket", + "bearing_bushing": "Bearing/Bushing", + "spring_compliant": "Spring/Compliant", + "moving_part": "Moving Part", + } + for cat in KINDRED_CATEGORIES: + action = menu.addAction(labels.get(cat, cat)) + action.setData(cat) + + chosen = menu.exec_(QtGui.QCursor.pos()) + if chosen is None: + return + + category = chosen.data() + doc = eligible[0].Document + doc.openTransaction("Set KindredCategory") + try: + for obj in eligible: + ensure_category_property(obj) + obj.KindredCategory = category + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + def IsActive(self): + return App.ActiveDocument is not None and bool(Gui.Selection.getSelection()) + + +Gui.addCommand("ZTools_AppearanceRealistic", ZTools_AppearanceRealistic()) +Gui.addCommand("ZTools_AppearanceEngineering", ZTools_AppearanceEngineering()) +Gui.addCommand("ZTools_AppearanceMode", ZTools_AppearanceMode()) +Gui.addCommand("ZTools_SetCategory", ZTools_SetCategory()) diff --git a/ztools/ztools/resources/icons.py b/ztools/ztools/resources/icons.py index c62db92..c011ad8 100644 --- a/ztools/ztools/resources/icons.py +++ b/ztools/ztools/resources/icons.py @@ -425,6 +425,50 @@ ICON_SPREADSHEET_QUICK_ALIAS_SVG = f'''''' +# ============================================================================= +# Appearance Mode Icons +# ============================================================================= + +# Realistic mode icon - eye with material sphere +ICON_APPEARANCE_REALISTIC_SVG = f''' + + + + + + +''' + +# Engineering mode icon - eye with colored segments +ICON_APPEARANCE_ENGINEERING_SVG = f''' + + + + + + + + + + + + + +''' + +# Set Category icon - tag with color swatches +ICON_SET_CATEGORY_SVG = f''' + + + + + + + + + +''' + # ============================================================================= # Icon Registry - Base64 encoded for FreeCAD # ============================================================================= @@ -472,6 +516,9 @@ def get_icon(name: str) -> str: "spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG, "spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG, "spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG, + "appearance_realistic": ICON_APPEARANCE_REALISTIC_SVG, + "appearance_engineering": ICON_APPEARANCE_ENGINEERING_SVG, + "set_category": ICON_SET_CATEGORY_SVG, } if name not in icons: @@ -529,6 +576,9 @@ def save_icons_to_disk(directory: str): "ztools_spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG, "ztools_spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG, "ztools_spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG, + "ztools_appearance_realistic": ICON_APPEARANCE_REALISTIC_SVG, + "ztools_appearance_engineering": ICON_APPEARANCE_ENGINEERING_SVG, + "ztools_set_category": ICON_SET_CATEGORY_SVG, } for name, svg in icons.items():