2 Commits

Author SHA1 Message Date
forbes
3298d1c6dc feat: appearance mode toggle UI and observer enhancements
- Grouped toolbar dropdown (ZTools_AppearanceMode) replaces two separate buttons
- Set Category command (ZTools_SetCategory) with popup menu for batch tagging
- Category changes wrapped in undo transactions
- Status bar indicator showing current mode, clickable to toggle
- Debounced recompute handling (Shape property changes batched at 100ms)
- Observer re-applies colors after recompute to prevent reset

Closes #21
2026-02-07 13:32:23 -06:00
forbes
7b1e76c791 feat: appearance mode system with engineering color presets
Add configurable appearance mode system with two modes:
- Realistic: material passthrough (restores original ShapeColor)
- Engineering: colors parts by KindredCategory using Catppuccin Mocha palette

KindredCategory enum property (custom_body, fastener, structural, electrical,
seal_gasket, bearing_bushing, spring_compliant, moving_part) is added lazily
to Part::Feature objects when Engineering mode is activated.

Includes:
- AppearanceMode base class with apply/reset/apply_to_object interface
- AppearanceManager singleton with document observer for auto-coloring
- Palette loaded from JSON config (theme-overridable)
- View > Appearance Mode menu and toolbar integration
- Preference persistence at Kindred/AppearanceMode

Closes #20
2026-02-07 11:01:28 -06:00
15 changed files with 1014 additions and 244 deletions

View File

@@ -3,7 +3,7 @@
<name>ZTools</name>
<description>Velocity-focused CAD tools injected into PartDesign, Assembly, and Spreadsheet contexts, plus Catppuccin Mocha theme.</description>
<description>Extended PartDesign workbench with velocity-focused tools, advanced datum creation, and Catppuccin Mocha theme.</description>
<version>0.1.0</version>

View File

@@ -1,109 +1,387 @@
# ztools Command Provider for Kindred Create
# Injects ZTools commands into context toolbars via EditingContextResolver.
# No longer a standalone workbench — commands appear in the appropriate
# editing context (PartDesign body/feature, Assembly, Spreadsheet).
# ztools Workbench for FreeCAD 1.0+
# Extended PartDesign replacement with velocity-focused tools
import FreeCAD as App
import FreeCADGui as Gui
def _ensure_workbenches_loaded():
"""Activate dependent workbenches so their commands are registered."""
wb_list = Gui.listWorkbenches()
for wb_name in (
"PartDesignWorkbench",
"SketcherWorkbench",
"AssemblyWorkbench",
"SpreadsheetWorkbench",
):
if wb_name in wb_list:
try:
Gui.activateWorkbench(wb_name)
except Exception as e:
App.Console.PrintWarning(f"ztools: could not init {wb_name}: {e}\n")
class ZToolsWorkbench(Gui.Workbench):
"""Extended PartDesign workbench with velocity-focused tools."""
MenuText = "ztools"
ToolTip = "Extended PartDesign replacement for faster CAD workflows"
def _register():
"""Import ZTools commands and inject them into context toolbars."""
_ensure_workbenches_loaded()
# Catppuccin Mocha themed icon
Icon = """
/* XPM */
static char * ztools_xpm[] = {
"16 16 5 1",
" c None",
". c #313244",
"+ c #cba6f7",
"@ c #94e2d5",
"# c #45475a",
" ",
" ############ ",
" #..........# ",
" #.++++++++.# ",
" #.+......+.# ",
" #.....+++..# ",
" #....++....# ",
" #...++.....# ",
" #..++......# ",
" #.++.......# ",
" #.++++++++@# ",
" #..........# ",
" ############ ",
" ",
" ",
" "};
"""
# Import all ZTools command modules (registers Gui commands)
from ztools.commands import ( # noqa: F401
assembly_pattern_commands,
datum_commands,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
def Initialize(self):
"""Called on workbench first activation."""
# Load PartDesign and Sketcher workbenches to register their commands
# We need to actually activate them briefly to ensure commands are registered
# Activate dependent workbenches so their commands are registered.
# Use activateWorkbench() instead of calling Initialize() directly,
# since direct calls skip the C++ __Workbench__ injection step.
# Wrap each individually so one failure doesn't block the rest.
wb_list = Gui.listWorkbenches()
current_wb = Gui.activeWorkbench()
for wb_name in (
"PartDesignWorkbench",
"SketcherWorkbench",
"AssemblyWorkbench",
"SpreadsheetWorkbench",
):
if wb_name in wb_list:
try:
Gui.activateWorkbench(wb_name)
except Exception as e:
App.Console.PrintWarning(f"Could not initialize {wb_name}: {e}\n")
# Restore ztools as the active workbench
try:
Gui.activateWorkbench("ZToolsWorkbench")
except Exception:
pass
# Apply Catppuccin Mocha Spreadsheet colors
try:
from ztools.resources.theme import apply_spreadsheet_colors
apply_spreadsheet_colors()
except Exception as e:
App.Console.PrintWarning(f"ztools: could not apply spreadsheet colors: {e}\n")
# Register WorkbenchManipulator for injecting into native workbench toolbars
Gui.addWorkbenchManipulator(_ZToolsManipulator())
# Inject ZTools commands into editing context toolbars.
# These calls append commands to the named toolbar within the given context,
# so when the EditingContextResolver activates a context the injected
# commands appear alongside the native ones.
try:
Gui.injectEditingCommands(
"partdesign.body",
"Part Design Helper Features",
["ZTools_DatumCreator", "ZTools_DatumManager"],
from ztools.commands import (
appearance_commands,
assembly_pattern_commands,
datum_commands,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
Gui.injectEditingCommands(
"partdesign.feature",
"Part Design Modeling Features",
["ZTools_EnhancedPocket"],
)
Gui.injectEditingCommands(
"partdesign.feature",
"Part Design Transformation Features",
["ZTools_RotatedLinearPattern"],
)
Gui.injectEditingCommands(
"assembly.edit",
"Assembly Management",
["ZTools_AssemblyLinearPattern", "ZTools_AssemblyPolarPattern"],
)
Gui.injectEditingCommands(
"spreadsheet",
"Spreadsheet",
[
"ZTools_SpreadsheetStyleBold",
"ZTools_SpreadsheetStyleItalic",
"ZTools_SpreadsheetStyleUnderline",
"ZTools_SpreadsheetAlignLeft",
"ZTools_SpreadsheetAlignCenter",
"ZTools_SpreadsheetAlignRight",
"ZTools_SpreadsheetBgColor",
"ZTools_SpreadsheetTextColor",
"ZTools_SpreadsheetQuickAlias",
],
)
except Exception as e:
App.Console.PrintWarning(f"ztools: could not inject context commands: {e}\n")
App.Console.PrintMessage("ztools commands registered\n")
# =====================================================================
# PartDesign Structure Tools
# =====================================================================
self.structure_tools = [
"PartDesign_Body",
"PartDesign_NewSketch",
]
# =====================================================================
# PartDesign Reference Geometry (Datums)
# =====================================================================
self.partdesign_datum_tools = [
"PartDesign_Plane",
"PartDesign_Line",
"PartDesign_Point",
"PartDesign_CoordinateSystem",
"PartDesign_ShapeBinder",
"PartDesign_SubShapeBinder",
"PartDesign_Clone",
]
# =====================================================================
# PartDesign Additive Features
# =====================================================================
self.additive_tools = [
"PartDesign_Pad",
"PartDesign_Revolution",
"PartDesign_AdditiveLoft",
"PartDesign_AdditivePipe",
"PartDesign_AdditiveHelix",
]
# =====================================================================
# PartDesign Additive Primitives (compound command with dropdown)
# =====================================================================
self.additive_primitives = [
"PartDesign_CompPrimitiveAdditive",
]
# =====================================================================
# PartDesign Subtractive Features
# =====================================================================
self.subtractive_tools = [
"PartDesign_Pocket",
"PartDesign_Hole",
"PartDesign_Groove",
"PartDesign_SubtractiveLoft",
"PartDesign_SubtractivePipe",
"PartDesign_SubtractiveHelix",
]
# =====================================================================
# PartDesign Subtractive Primitives (compound command with dropdown)
# =====================================================================
self.subtractive_primitives = [
"PartDesign_CompPrimitiveSubtractive",
]
# =====================================================================
# PartDesign Transformation Features (Patterns)
# =====================================================================
self.transformation_tools = [
"PartDesign_Mirrored",
"PartDesign_LinearPattern",
"PartDesign_PolarPattern",
"PartDesign_MultiTransform",
]
# =====================================================================
# PartDesign Dress-Up Features
# =====================================================================
self.dressup_tools = [
"PartDesign_Fillet",
"PartDesign_Chamfer",
"PartDesign_Draft",
"PartDesign_Thickness",
]
# =====================================================================
# PartDesign Boolean Operations
# =====================================================================
self.boolean_tools = [
"PartDesign_Boolean",
]
# =====================================================================
# Sketcher Tools (commonly used with PartDesign)
# =====================================================================
self.sketcher_tools = [
"Sketcher_NewSketch",
"Sketcher_EditSketch",
"Sketcher_MapSketch",
"Sketcher_ValidateSketch",
]
# =====================================================================
# ZTools Custom Tools
# =====================================================================
self.ztools_datum_tools = [
"ZTools_DatumCreator",
"ZTools_DatumManager",
]
self.ztools_pattern_tools = [
"ZTools_RotatedLinearPattern",
]
self.ztools_pocket_tools = [
"ZTools_EnhancedPocket",
]
# =====================================================================
# Assembly Workbench Tools (FreeCAD 1.0+)
# =====================================================================
self.assembly_structure_tools = [
"Assembly_CreateAssembly",
"Assembly_InsertLink",
"Assembly_InsertNewPart",
]
self.assembly_joint_tools = [
"Assembly_CreateJointFixed",
"Assembly_CreateJointRevolute",
"Assembly_CreateJointCylindrical",
"Assembly_CreateJointSlider",
"Assembly_CreateJointBall",
"Assembly_CreateJointDistance",
"Assembly_CreateJointParallel",
"Assembly_CreateJointPerpendicular",
"Assembly_CreateJointAngle",
"Assembly_CreateJointRackPinion",
"Assembly_CreateJointScrew",
"Assembly_CreateJointGears",
"Assembly_CreateJointBelt",
]
self.assembly_management_tools = [
"Assembly_ToggleGrounded",
"Assembly_SolveAssembly",
"Assembly_CreateView",
"Assembly_CreateBom",
"Assembly_ExportASMT",
]
# =====================================================================
# ZTools Assembly Pattern Tools
# =====================================================================
self.ztools_assembly_tools = [
"ZTools_AssemblyLinearPattern",
"ZTools_AssemblyPolarPattern",
]
# =====================================================================
# Spreadsheet Workbench Tools
# =====================================================================
self.spreadsheet_tools = [
"Spreadsheet_CreateSheet",
"Spreadsheet_Import",
"Spreadsheet_Export",
"Spreadsheet_SetAlias",
"Spreadsheet_MergeCells",
"Spreadsheet_SplitCell",
]
# =====================================================================
# ZTools Spreadsheet Formatting Tools
# =====================================================================
self.ztools_spreadsheet_tools = [
"ZTools_SpreadsheetStyleBold",
"ZTools_SpreadsheetStyleItalic",
"ZTools_SpreadsheetStyleUnderline",
"ZTools_SpreadsheetAlignLeft",
"ZTools_SpreadsheetAlignCenter",
"ZTools_SpreadsheetAlignRight",
"ZTools_SpreadsheetBgColor",
"ZTools_SpreadsheetTextColor",
"ZTools_SpreadsheetQuickAlias",
]
# =====================================================================
# ZTools Appearance Mode Tools
# =====================================================================
self.ztools_appearance_tools = [
"ZTools_AppearanceMode",
"ZTools_SetCategory",
]
self.ztools_appearance_menu_tools = [
"ZTools_AppearanceRealistic",
"ZTools_AppearanceEngineering",
]
# =====================================================================
# Append Toolbars
# =====================================================================
self.appendToolbar("Structure", self.structure_tools)
self.appendToolbar("Sketcher", self.sketcher_tools)
self.appendToolbar("Datums", self.partdesign_datum_tools)
self.appendToolbar("Additive", self.additive_tools + self.additive_primitives)
self.appendToolbar(
"Subtractive", self.subtractive_tools + self.subtractive_primitives
)
self.appendToolbar("Transformations", self.transformation_tools)
self.appendToolbar("Dress-Up", self.dressup_tools)
self.appendToolbar("Boolean", self.boolean_tools)
self.appendToolbar("Assembly", self.assembly_structure_tools)
self.appendToolbar("Assembly Joints", self.assembly_joint_tools)
self.appendToolbar("Assembly Management", self.assembly_management_tools)
self.appendToolbar("ztools Datums", self.ztools_datum_tools)
self.appendToolbar("ztools Patterns", self.ztools_pattern_tools)
self.appendToolbar("ztools Features", self.ztools_pocket_tools)
self.appendToolbar("ztools Assembly", self.ztools_assembly_tools)
self.appendToolbar("Spreadsheet", self.spreadsheet_tools)
self.appendToolbar("ztools Spreadsheet", self.ztools_spreadsheet_tools)
self.appendToolbar("ztools Appearance", self.ztools_appearance_tools)
# =====================================================================
# Append Menus
# =====================================================================
self.appendMenu("Structure", self.structure_tools)
self.appendMenu("Sketch", self.sketcher_tools)
self.appendMenu(["PartDesign", "Datums"], self.partdesign_datum_tools)
self.appendMenu(
["PartDesign", "Additive"], self.additive_tools + self.additive_primitives
)
self.appendMenu(
["PartDesign", "Subtractive"],
self.subtractive_tools + self.subtractive_primitives,
)
self.appendMenu(["PartDesign", "Transformations"], self.transformation_tools)
self.appendMenu(["PartDesign", "Dress-Up"], self.dressup_tools)
self.appendMenu(["PartDesign", "Boolean"], self.boolean_tools)
self.appendMenu(["Assembly", "Structure"], self.assembly_structure_tools)
self.appendMenu(["Assembly", "Joints"], self.assembly_joint_tools)
self.appendMenu(["Assembly", "Management"], self.assembly_management_tools)
self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools)
self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools)
self.appendMenu(
["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_appearance_menu_tools
+ ["ZTools_SetCategory"],
)
# Register the PartDesign manipulator now that commands exist.
# Guard so it only registers once even if Initialize is called again.
if not getattr(ZToolsWorkbench, "_manipulator_installed", False):
ZToolsWorkbench._manipulator_installed = True
Gui.addWorkbenchManipulator(_ZToolsPartDesignManipulator())
App.Console.PrintMessage("ztools workbench initialized\n")
def Activated(self):
"""Called when workbench is activated."""
# Apply Catppuccin Mocha colors to Spreadsheet preferences
try:
from ztools.resources.theme import apply_spreadsheet_colors
apply_spreadsheet_colors()
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."""
try:
from ztools.appearance import get_manager
get_manager().deactivate()
except Exception:
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(ZToolsWorkbench())
# ---------------------------------------------------------------------------
# WorkbenchManipulator: inject ZTools commands into native workbench toolbars
# WorkbenchManipulator: inject ZTools commands into PartDesign workbench
# ---------------------------------------------------------------------------
# Registered in ZToolsWorkbench.Initialize() after commands are imported,
# so the commands exist before the manipulator references them.
class _ZToolsManipulator:
"""Adds ZTools commands to PartDesign, Assembly, and Spreadsheet toolbars."""
class _ZToolsPartDesignManipulator:
"""Adds ZTools commands to PartDesign toolbars and menus."""
def modifyToolBars(self):
return [
# PartDesign
{"append": "ZTools_DatumCreator", "toolBar": "Part Design Helper Features"},
{"append": "ZTools_DatumManager", "toolBar": "Part Design Helper Features"},
{
@@ -114,19 +392,6 @@ class _ZToolsManipulator:
"append": "ZTools_RotatedLinearPattern",
"toolBar": "Part Design Transformation Features",
},
# Assembly
{"append": "ZTools_AssemblyLinearPattern", "toolBar": "Assembly"},
{"append": "ZTools_AssemblyPolarPattern", "toolBar": "Assembly"},
# Spreadsheet
{"append": "ZTools_SpreadsheetStyleBold", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetStyleItalic", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetStyleUnderline", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetAlignLeft", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetAlignCenter", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetAlignRight", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetBgColor", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetTextColor", "toolBar": "Spreadsheet"},
{"append": "ZTools_SpreadsheetQuickAlias", "toolBar": "Spreadsheet"},
]
def modifyMenuBar(self):
@@ -152,9 +417,3 @@ class _ZToolsManipulator:
"after": "",
},
]
# Deferred registration — wait for dependent workbenches to finish loading
from PySide.QtCore import QTimer # noqa: E402
QTimer.singleShot(2000, _register)

View File

@@ -0,0 +1,4 @@
# ztools/appearance — Appearance mode system
from .manager import get_manager
__all__ = ["get_manager"]

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

@@ -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"
}
}

View File

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

View File

@@ -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"<span style='color:{color};'>&#9679;</span> {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

View File

@@ -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",

View File

@@ -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())

View File

@@ -2,7 +2,6 @@
# Custom ViewProvider for ZTools datum objects
import json
import math
import FreeCAD as App
import FreeCADGui as Gui
@@ -142,23 +141,6 @@ class ZToolsDatumViewProvider:
return None
def _resolve_source_refs(datum_obj):
"""Parse ZTools_SourceRefs and resolve to (object, subname, shape) tuples."""
refs_json = getattr(datum_obj, "ZTools_SourceRefs", "[]")
try:
refs = json.loads(refs_json)
except json.JSONDecodeError:
return []
doc = datum_obj.Document
resolved = []
for ref in refs:
obj = doc.getObject(ref.get("object", ""))
sub = ref.get("subname", "")
shape = obj.getSubObject(sub) if obj and sub else None
resolved.append((obj, sub, shape))
return resolved
class DatumEditTaskPanel:
"""
Task panel for editing existing ZTools datum objects.
@@ -382,57 +364,8 @@ class DatumEditTaskPanel:
self.datum_obj.AttachmentOffset = new_offset
self._update_params({"distance": distance})
elif ztools_type == "angled":
angle = self.angle_spin.value()
if self._has_attachment():
refs = _resolve_source_refs(self.datum_obj)
if len(refs) >= 2 and refs[0][2] and refs[1][2]:
face_normal = refs[0][2].normalAt(0, 0)
edge_shape = refs[1][2]
edge_dir = (
edge_shape.Vertexes[-1].Point - edge_shape.Vertexes[0].Point
).normalize()
face_rot = App.Rotation(App.Vector(0, 0, 1), face_normal)
local_edge_dir = face_rot.inverted().multVec(edge_dir)
angle_rot = App.Rotation(local_edge_dir, angle)
self.datum_obj.AttachmentOffset = App.Placement(
App.Vector(0, 0, 0), angle_rot
)
self._update_params({"angle": angle})
elif ztools_type == "tangent_cylinder":
angle = self.angle_spin.value()
if self._has_attachment():
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
vertex_angle = params.get("vertex_angle", 0.0)
offset_rot = App.Rotation(App.Vector(0, 0, 1), angle - vertex_angle)
self.datum_obj.AttachmentOffset = App.Placement(
App.Vector(0, 0, 0), offset_rot
)
else:
refs = _resolve_source_refs(self.datum_obj)
if refs and refs[0][2]:
face = refs[0][2]
if isinstance(face.Surface, Part.Cylinder):
cyl = face.Surface
axis = cyl.Axis
center = cyl.Center
radius = cyl.Radius
rad = math.radians(angle)
if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99:
local_x = axis.cross(App.Vector(1, 0, 0)).normalize()
else:
local_x = axis.cross(App.Vector(0, 1, 0)).normalize()
local_y = axis.cross(local_x)
radial = local_x * math.cos(rad) + local_y * math.sin(rad)
tangent_point = center + radial * radius
rot = App.Rotation(App.Vector(0, 0, 1), radial)
self.datum_obj.Placement = App.Placement(tangent_point, rot)
self._update_params({"angle": angle})
elif ztools_type in ("angled", "tangent_cylinder"):
self._update_params({"angle": self.angle_spin.value()})
elif ztools_type in ("normal_to_edge", "on_edge"):
parameter = self.param_spin.value()

View File

@@ -903,41 +903,6 @@ def plane_angled(
return plane
def _find_cylinder_vertex(obj, face_subname):
"""Find a vertex subname from a cylindrical face's edges."""
face = obj.getSubObject(face_subname)
if not face or not face.Edges:
return None
edge = face.Edges[0]
if not edge.Vertexes:
return None
vertex_point = edge.Vertexes[0].Point
for i, v in enumerate(obj.Shape.Vertexes, 1):
if v.Point.isEqual(vertex_point, 1e-6):
return f"Vertex{i}"
return None
def _vertex_angle_on_cylinder(obj, vertex_sub, cylinder):
"""Compute the angular position of a vertex on a cylinder surface."""
vertex = obj.getSubObject(vertex_sub)
if not vertex:
return 0.0
point = vertex.Point
relative = point - cylinder.Center
axis = cylinder.Axis
radial = relative - axis * relative.dot(axis)
if radial.Length < 1e-10:
return 0.0
radial.normalize()
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)
return math.degrees(math.atan2(radial.dot(local_y), radial.dot(local_x)))
def plane_tangent_to_cylinder(
face: Part.Face,
angle: float = 0,
@@ -1001,41 +966,16 @@ def plane_tangent_to_cylinder(
if body:
plane = body.newObject("PartDesign::Plane", name)
# TangentPlane MapMode needs (face, vertex). Derive a vertex from
# the cylinder face's edges and encode the angular offset.
vertex_sub = (
_find_cylinder_vertex(source_object, source_subname)
if source_object and source_subname
else None
# TangentPlane mode needs (face, vertex). Without a vertex reference,
# fall back to manual placement.
_setup_ztools_datum(
plane,
placement,
"tangent_cylinder",
{"angle": angle, "radius": radius},
source_refs,
is_plane=True,
)
if vertex_sub:
vertex_angle = _vertex_angle_on_cylinder(source_object, vertex_sub, cyl)
offset_angle = angle - vertex_angle
offset_rot = App.Rotation(App.Vector(0, 0, 1), offset_angle)
att_offset = App.Placement(App.Vector(0, 0, 0), offset_rot)
_setup_ztools_datum(
plane,
placement,
"tangent_cylinder",
{"angle": angle, "radius": radius, "vertex_angle": vertex_angle},
source_refs,
is_plane=True,
map_mode="TangentPlane",
support=[
(source_object, source_subname),
(source_object, vertex_sub),
],
offset=att_offset,
)
else:
_setup_ztools_datum(
plane,
placement,
"tangent_cylinder",
{"angle": angle, "radius": radius},
source_refs,
is_plane=True,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50

View File

@@ -425,6 +425,50 @@ ICON_SPREADSHEET_QUICK_ALIAS_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" v
</svg>'''
# =============================================================================
# Appearance Mode Icons
# =============================================================================
# Realistic mode icon - eye with material sphere
ICON_APPEARANCE_REALISTIC_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Eye shape -->
<path d="M4 16 Q16 6 28 16 Q16 26 4 16 Z" fill="none" stroke="{MOCHA["text"]}" stroke-width="1.5"/>
<!-- Material sphere with gradient-like shading -->
<circle cx="16" cy="16" r="5" fill="{MOCHA["overlay1"]}"/>
<circle cx="14" cy="14" r="2" fill="{MOCHA["overlay2"]}" opacity="0.6"/>
</svg>'''
# Engineering mode icon - eye with colored segments
ICON_APPEARANCE_ENGINEERING_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Eye shape -->
<path d="M4 16 Q16 6 28 16 Q16 26 4 16 Z" fill="none" stroke="{MOCHA["text"]}" stroke-width="1.5"/>
<!-- Segmented iris showing category colors -->
<path d="M16 11 L19 14 L19 18 L16 21 L13 18 L13 14 Z" fill="{MOCHA["peach"]}"/>
<path d="M16 11 L19 14 L16 16 Z" fill="{MOCHA["green"]}"/>
<path d="M19 14 L19 18 L16 16 Z" fill="{MOCHA["blue"]}"/>
<path d="M19 18 L16 21 L16 16 Z" fill="{MOCHA["yellow"]}"/>
<path d="M16 21 L13 18 L16 16 Z" fill="{MOCHA["mauve"]}"/>
<path d="M13 18 L13 14 L16 16 Z" fill="{MOCHA["sapphire"]}"/>
<path d="M13 14 L16 11 L16 16 Z" fill="{MOCHA["red"]}"/>
<!-- Pupil -->
<circle cx="16" cy="16" r="2" fill="{MOCHA["crust"]}"/>
</svg>'''
# Set Category icon - tag with color swatches
ICON_SET_CATEGORY_SVG = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="{MOCHA["surface0"]}"/>
<!-- Tag shape -->
<path d="M5 8 L18 8 L25 16 L18 24 L5 24 Z" fill="{MOCHA["surface1"]}" stroke="{MOCHA["overlay1"]}" stroke-width="1.5"/>
<circle cx="10" cy="16" r="2" fill="{MOCHA["surface0"]}"/>
<!-- Color dots representing categories -->
<circle cx="22" cy="8" r="3" fill="{MOCHA["peach"]}"/>
<circle cx="28" cy="8" r="3" fill="{MOCHA["green"]}"/>
<circle cx="22" cy="14" r="3" fill="{MOCHA["blue"]}"/>
<circle cx="28" cy="14" r="3" fill="{MOCHA["yellow"]}"/>
</svg>'''
# =============================================================================
# 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():