diff --git a/ztools/InitGui.py b/ztools/InitGui.py
index fc6b910..6877f32 100644
--- a/ztools/InitGui.py
+++ b/ztools/InitGui.py
@@ -258,6 +258,10 @@ class ZToolsWorkbench(Gui.Workbench):
# ZTools Appearance Mode Tools
# =====================================================================
self.ztools_appearance_tools = [
+ "ZTools_AppearanceMode",
+ "ZTools_SetCategory",
+ ]
+ self.ztools_appearance_menu_tools = [
"ZTools_AppearanceRealistic",
"ZTools_AppearanceEngineering",
]
@@ -307,7 +311,10 @@ 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_tools)
+ self.appendMenu(
+ ["View", "Appearance Mode"],
+ self.ztools_appearance_menu_tools + ["ZTools_SetCategory"],
+ )
self.appendMenu(
"ztools",
self.ztools_datum_tools
@@ -315,7 +322,8 @@ class ZToolsWorkbench(Gui.Workbench):
+ self.ztools_pocket_tools
+ self.ztools_assembly_tools
+ self.ztools_spreadsheet_tools
- + self.ztools_appearance_tools,
+ + self.ztools_appearance_menu_tools
+ + ["ZTools_SetCategory"],
)
# Register the PartDesign manipulator now that commands exist.
diff --git a/ztools/ztools/appearance/manager.py b/ztools/ztools/appearance/manager.py
index cce6154..5099950 100644
--- a/ztools/ztools/appearance/manager.py
+++ b/ztools/ztools/appearance/manager.py
@@ -14,12 +14,16 @@ _MODES = {
"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":
@@ -35,12 +39,41 @@ class _AppearanceObserver:
self._apply_deferred(obj)
def slotChangedObject(self, obj, prop: str) -> None:
- if prop != "KindredCategory":
- return
if self._manager.active_mode.name != "engineering":
return
- if _is_eligible(obj):
+ 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):
@@ -78,8 +111,17 @@ class AppearanceManager:
for doc in App.listDocuments().values():
self._active_mode.apply(doc)
- # Persist
+ # 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:
@@ -122,12 +164,27 @@ class AppearanceManager:
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. Called from Workbench.Deactivated()."""
+ """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
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/appearance_commands.py b/ztools/ztools/commands/appearance_commands.py
index 227b7b8..8d44c21 100644
--- a/ztools/ztools/commands/appearance_commands.py
+++ b/ztools/ztools/commands/appearance_commands.py
@@ -1,10 +1,11 @@
# ztools/commands/appearance_commands.py
-# Commands for switching appearance modes
+# 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
@@ -42,5 +43,85 @@ class ZTools_AppearanceEngineering:
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 1d68b3b..c011ad8 100644
--- a/ztools/ztools/resources/icons.py
+++ b/ztools/ztools/resources/icons.py
@@ -456,6 +456,19 @@ ICON_APPEARANCE_ENGINEERING_SVG = f'''
'''
+# Set Category icon - tag with color swatches
+ICON_SET_CATEGORY_SVG = f''''''
+
# =============================================================================
# Icon Registry - Base64 encoded for FreeCAD
# =============================================================================
@@ -505,6 +518,7 @@ def get_icon(name: str) -> str:
"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:
@@ -564,6 +578,7 @@ def save_icons_to_disk(directory: str):
"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():