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'''