9 Commits

Author SHA1 Message Date
forbes
08e439b9ca refactor: remove theme from ztools, move to base distribution
- Delete CatppuccinMocha preference pack (redundant with KindredCreate)
- Delete theme.py (generate_stylesheet never called; apply_spreadsheet_colors
  was a no-op due to SetUnsigned vs GetASCII type mismatch)
- Remove preferencepack declaration from package.xml
- Remove apply_spreadsheet_colors call from InitGui.py Activated()
- Clean up resources/__init__.py exports

Spreadsheet colors are now set via KindredCreate.cfg preference pack
using FCText entries that match the C++ GetASCII() reader.

Part of #278
2026-02-19 14:50:15 -06:00
4e01fdf68b Merge pull request 'refactor: migrate to kindred-addon-sdk for theme tokens' (#2) from feat/migrate-to-sdk into main
Reviewed-on: #2
2026-02-17 17:01:20 +00:00
55be41e697 refactor: migrate to kindred-addon-sdk for theme tokens (#250)
Replace hardcoded MOCHA dict with kindred_sdk.get_theme_tokens().
Add sdk dependency to package.xml <kindred> element.
2026-02-17 08:59:39 -06:00
29ca89e533 feat: add <kindred> element to package.xml
Declares min_create_version=0.1.0, load_priority=50, pure_python=true,
and documents partdesign.body/feature context injection.
2026-02-16 14:03:27 -06:00
forbes
ef16ecbaa2 fix: viewport background gradient — Overlay0/Mantle 3-color gradient (#188)
Change viewport background from 2-color Base→Crust gradient to
3-color Overlay0→Mantle→Overlay0 gradient for better visual depth.
2026-02-14 13:03:37 -06:00
forbes
2bf969c62a fix: use TangentPlane MapMode for tangent-to-cylinder datums (#58)
plane_tangent_to_cylinder() now derives a vertex from the cylinder
face's edges and uses TangentPlane MapMode with AttachmentOffset to
encode the angular position. This makes tangent datums parametric —
they auto-update when cylinder geometry changes.

Add _find_cylinder_vertex() and _vertex_angle_on_cylinder() helpers.
Store vertex_angle in ZTools_Params for the edit panel to compute
AttachmentOffset updates. Falls back to manual placement when no
vertex can be resolved (non-Body datums).
2026-02-08 18:53:13 -06:00
forbes
04f9df75cb fix: update AttachmentOffset during angled datum editing (#66)
on_param_changed() now recomputes AttachmentOffset.Rotation for angled
datums and recalculates Placement for tangent_cylinder datums when the
angle spinner changes. Previously only ZTools_Params was updated,
leaving the visual representation unchanged until a manual recompute.

Add _resolve_source_refs() helper to parse ZTools_SourceRefs and
resolve stored object/subname pairs to actual shapes for the rotation
and placement math.
2026-02-08 18:42:28 -06:00
forbes
2132c4e64c fix: use append instead of fragile insert chain in PartDesign menu (#57)
Replace the chained insert operations in modifyMenuBar() with independent
append operations. The old approach anchored on PartDesign_Boolean and
chained each subsequent command off the previous insertion — a single
missing anchor caused a cascade failure.

The new approach uses append with PartDesign_Body as the parent locator.
Each operation is independent, so a failure in one does not affect the
others.
2026-02-08 18:07:18 -06:00
forbes
12e332240a fix: register commands and manipulator at module scope (#52)
Move command imports and PartDesign manipulator installation from
ZToolsWorkbench.Initialize() to module scope. This ensures commands
are registered and the manipulator is available before any workbench
activates, fixing the case where PartDesign activates before ZTools
and ztools buttons never appear.
2026-02-08 17:57:54 -06:00
20 changed files with 183 additions and 3579 deletions

View File

@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<FCParameters>
<FCParamGroup Name="Root">
<FCParamGroup Name="BaseApp">
<FCParamGroup Name="Preferences">
<FCParamGroup Name="Editor">
<FCUInt Name="Text" Value="3453416703"/>
<FCUInt Name="Bookmark" Value="3032415999"/>
<FCUInt Name="Breakpoint" Value="4086016255"/>
<FCUInt Name="Keyword" Value="3416717311"/>
<FCUInt Name="Comment" Value="2139095295"/>
<FCUInt Name="Block comment" Value="2139095295"/>
<FCUInt Name="Number" Value="4206069759"/>
<FCUInt Name="String" Value="2799935999"/>
<FCUInt Name="Character" Value="4073902335"/>
<FCUInt Name="Class name" Value="2310339327"/>
<FCUInt Name="Define name" Value="2310339327"/>
<FCUInt Name="Operator" Value="2312199935"/>
<FCUInt Name="Python output" Value="2796290303"/>
<FCUInt Name="Python error" Value="4086016255"/>
<FCUInt Name="Current line highlight" Value="1162304255"/>
</FCParamGroup>
<FCParamGroup Name="OutputWindow">
<FCUInt Name="colorText" Value="3453416703"/>
<FCUInt Name="colorLogging" Value="2497893887"/>
<FCUInt Name="colorWarning" Value="4192382975"/>
<FCUInt Name="colorError" Value="4086016255"/>
</FCParamGroup>
<FCParamGroup Name="View">
<FCUInt Name="BackgroundColor" Value="505294591"/>
<FCUInt Name="BackgroundColor2" Value="286333951"/>
<FCUInt Name="BackgroundColor3" Value="404235775"/>
<FCUInt Name="BackgroundColor4" Value="825378047"/>
<FCBool Name="Simple" Value="0"/>
<FCBool Name="Gradient" Value="1"/>
<FCBool Name="UseBackgroundColorMid" Value="0"/>
<FCUInt Name="HighlightColor" Value="3416717311"/>
<FCUInt Name="SelectionColor" Value="3032415999"/>
<FCUInt Name="PreselectColor" Value="2497893887"/>
<FCUInt Name="DefaultShapeColor" Value="1482387711"/>
<FCBool Name="RandomColor" Value="0"/>
<FCUInt Name="DefaultShapeLineColor" Value="2470768383"/>
<FCUInt Name="DefaultShapeVertexColor" Value="2470768383"/>
<FCUInt Name="BoundingBoxColor" Value="1819509759"/>
<FCUInt Name="AnnotationTextColor" Value="3453416703"/>
<FCUInt Name="SketchEdgeColor" Value="3453416703"/>
<FCUInt Name="SketchVertexColor" Value="3453416703"/>
<FCUInt Name="EditedEdgeColor" Value="3416717311"/>
<FCUInt Name="EditedVertexColor" Value="4123402495"/>
<FCUInt Name="ConstructionColor" Value="4206069759"/>
<FCUInt Name="ExternalColor" Value="4192382975"/>
<FCUInt Name="FullyConstrainedColor" Value="2799935999"/>
<FCUInt Name="InternalAlignedGeoColor" Value="1959907071"/>
<FCUInt Name="FullyConstraintElementColor" Value="2799935999"/>
<FCUInt Name="FullyConstraintConstructionElementColor" Value="2497893887"/>
<FCUInt Name="FullyConstraintInternalAlignmentColor" Value="2312199935"/>
<FCUInt Name="FullyConstraintConstructionPointColor" Value="2799935999"/>
<FCUInt Name="ConstrainedIcoColor" Value="2310339327"/>
<FCUInt Name="NonDrivingConstrDimColor" Value="2139095295"/>
<FCUInt Name="ConstrainedDimColor" Value="3416717311"/>
<FCUInt Name="ExprBasedConstrDimColor" Value="4206069759"/>
<FCUInt Name="DeactivatedConstrDimColor" Value="1819509759"/>
<FCUInt Name="CursorTextColor" Value="3453416703"/>
<FCUInt Name="CursorCrosshairColor" Value="3416717311"/>
<FCUInt Name="CreateLineColor" Value="2799935999"/>
<FCUInt Name="ShadowLightColor" Value="2470768128"/>
<FCUInt Name="ShadowGroundColor" Value="286333952"/>
<FCUInt Name="HiddenLineColor" Value="825378047"/>
<FCUInt Name="HiddenLineFaceColor" Value="505294591"/>
<FCUInt Name="HiddenLineBackground" Value="505294591"/>
<FCBool Name="EnableBacklight" Value="1"/>
<FCUInt Name="BacklightColor" Value="1162304255"/>
<FCFloat Name="BacklightIntensity" Value="0.30"/>
</FCParamGroup>
<FCParamGroup Name="TreeView">
<FCUInt Name="TreeEditColor" Value="3416717311"/>
<FCUInt Name="TreeActiveColor" Value="2799935999"/>
</FCParamGroup>
<FCParamGroup Name="MainWindow">
<FCText Name="StyleSheet">CatppuccinMocha.qss</FCText>
</FCParamGroup>
<FCParamGroup Name="Mod">
<FCParamGroup Name="Start">
<FCUInt Name="BackgroundColor1" Value="404235775"/>
<FCUInt Name="BackgroundTextColor" Value="3453416703"/>
<FCUInt Name="PageColor" Value="505294591"/>
<FCUInt Name="PageTextColor" Value="3453416703"/>
<FCUInt Name="BoxColor" Value="825378047"/>
<FCUInt Name="LinkColor" Value="2310339327"/>
<FCUInt Name="BackgroundColor2" Value="286333951"/>
</FCParamGroup>
<FCParamGroup Name="Part">
<FCUInt Name="VertexColor" Value="3032415999"/>
<FCUInt Name="EdgeColor" Value="2310339327"/>
</FCParamGroup>
<FCParamGroup Name="PartDesign">
<FCUInt Name="DefaultDatumColor" Value="3416717311"/>
</FCParamGroup>
<FCParamGroup Name="Draft">
<FCUInt Name="snapcolor" Value="2799935999"/>
</FCParamGroup>
<FCParamGroup Name="Sketcher">
<FCUInt Name="GridLineColor" Value="1162304255"/>
</FCParamGroup>
</FCParamGroup>
</FCParamGroup>
</FCParamGroup>
</FCParamGroup>
</FCParameters>

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<name>ZTools</name>
<description>Extended PartDesign workbench with velocity-focused tools, advanced datum creation, and Catppuccin Mocha theme.</description>
<description>Extended PartDesign workbench with velocity-focused tools and advanced datum creation.</description>
<version>0.1.0</version>
@@ -17,16 +17,20 @@
<classname>ZToolsWorkbench</classname>
<subdirectory>./ztools</subdirectory>
</workbench>
<preferencepack>
<name>CatppuccinMocha</name>
<description>Catppuccin Mocha dark theme - soothing pastel colors for the high-spirited</description>
<subdirectory>./CatppuccinMocha</subdirectory>
<tag>color</tag>
<tag>dark</tag>
<tag>catppuccin</tag>
<tag>mocha</tag>
<tag>theme</tag>
</preferencepack>
</content>
<!-- Kindred Create extensions -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>50</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
<contexts>
<context id="partdesign.body" action="inject"/>
<context id="partdesign.feature" action="inject"/>
</contexts>
</kindred>
</package>

View File

@@ -66,14 +66,8 @@ class ZToolsWorkbench(Gui.Workbench):
except Exception:
pass
from ztools.commands import (
appearance_commands,
assembly_pattern_commands,
datum_commands,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
# Command imports moved to module scope (after Gui.addWorkbench) so they
# are available before Initialize() runs. See end of file.
# =====================================================================
# PartDesign Structure Tools
@@ -254,18 +248,6 @@ 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
# =====================================================================
@@ -288,7 +270,6 @@ 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
@@ -311,57 +292,24 @@ 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_appearance_menu_tools
+ ["ZTools_SetCategory"],
+ self.ztools_spreadsheet_tools,
)
# 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
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
@@ -370,11 +318,24 @@ class ZToolsWorkbench(Gui.Workbench):
Gui.addWorkbench(ZToolsWorkbench())
# ---------------------------------------------------------------------------
# Eager command registration
# ---------------------------------------------------------------------------
# Import command modules at module scope so Gui.addCommand() calls run before
# any workbench activates. This ensures the PartDesign manipulator can
# reference them regardless of workbench activation order (#52).
from ztools.commands import (
assembly_pattern_commands,
datum_commands,
pattern_commands,
pocket_commands,
spreadsheet_commands,
)
# ---------------------------------------------------------------------------
# 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 _ZToolsPartDesignManipulator:
@@ -396,24 +357,11 @@ class _ZToolsPartDesignManipulator:
def modifyMenuBar(self):
return [
{
"insert": "ZTools_DatumCreator",
"menuItem": "PartDesign_Boolean",
"after": "",
},
{
"insert": "ZTools_DatumManager",
"menuItem": "ZTools_DatumCreator",
"after": "",
},
{
"insert": "ZTools_EnhancedPocket",
"menuItem": "ZTools_DatumManager",
"after": "",
},
{
"insert": "ZTools_RotatedLinearPattern",
"menuItem": "ZTools_EnhancedPocket",
"after": "",
},
{"append": "ZTools_DatumCreator", "menuItem": "PartDesign_Body"},
{"append": "ZTools_DatumManager", "menuItem": "PartDesign_Body"},
{"append": "ZTools_EnhancedPocket", "menuItem": "PartDesign_Body"},
{"append": "ZTools_RotatedLinearPattern", "menuItem": "PartDesign_Body"},
]
Gui.addWorkbenchManipulator(_ZToolsPartDesignManipulator())

View File

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

View File

@@ -1,79 +0,0 @@
# 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

@@ -1,198 +0,0 @@
# 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

@@ -1,22 +0,0 @@
# 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

@@ -1,36 +0,0 @@
# 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

@@ -1,13 +0,0 @@
{
"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

@@ -1,35 +0,0 @@
# 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

@@ -1,72 +0,0 @@
# 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,6 +1,5 @@
# ztools/commands - GUI commands
from . import (
appearance_commands,
assembly_pattern_commands,
datum_commands,
datum_viewprovider,
@@ -10,7 +9,6 @@ from . import (
)
__all__ = [
"appearance_commands",
"datum_commands",
"datum_viewprovider",
"pattern_commands",

View File

@@ -1,127 +0,0 @@
# 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,6 +2,7 @@
# Custom ViewProvider for ZTools datum objects
import json
import math
import FreeCAD as App
import FreeCADGui as Gui
@@ -141,6 +142,23 @@ 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.
@@ -364,8 +382,57 @@ class DatumEditTaskPanel:
self.datum_obj.AttachmentOffset = new_offset
self._update_params({"distance": distance})
elif ztools_type in ("angled", "tangent_cylinder"):
self._update_params({"angle": self.angle_spin.value()})
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 ("normal_to_edge", "on_edge"):
parameter = self.param_spin.value()

View File

@@ -903,6 +903,41 @@ 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,
@@ -966,16 +1001,41 @@ def plane_tangent_to_cylinder(
if body:
plane = body.newObject("PartDesign::Plane", name)
# 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,
# 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
)
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

@@ -1,10 +1,8 @@
# ztools/resources - Icons and assets
from .icons import MOCHA, get_icon, save_icons_to_disk
from .theme import get_stylesheet
__all__ = [
"get_icon",
"save_icons_to_disk",
"MOCHA",
"get_stylesheet",
]

View File

@@ -1,35 +1,10 @@
# ztools/resources/icons.py
# Catppuccin Mocha themed icons for ztools
# Catppuccin Mocha Palette
MOCHA = {
"rosewater": "#f5e0dc",
"flamingo": "#f2cdcd",
"pink": "#f5c2e7",
"mauve": "#cba6f7",
"red": "#f38ba8",
"maroon": "#eba0ac",
"peach": "#fab387",
"yellow": "#f9e2af",
"green": "#a6e3a1",
"teal": "#94e2d5",
"sky": "#89dceb",
"sapphire": "#74c7ec",
"blue": "#89b4fa",
"lavender": "#b4befe",
"text": "#cdd6f4",
"subtext1": "#bac2de",
"subtext0": "#a6adc8",
"overlay2": "#9399b2",
"overlay1": "#7f849c",
"overlay0": "#6c7086",
"surface2": "#585b70",
"surface1": "#45475a",
"surface0": "#313244",
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b",
}
# Catppuccin Mocha Palette — sourced from kindred-addon-sdk
from kindred_sdk.theme import get_theme_tokens
MOCHA = get_theme_tokens()
def _svg_to_base64(svg_content: str) -> str:
@@ -425,50 +400,6 @@ 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
# =============================================================================
@@ -516,9 +447,6 @@ 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:
@@ -576,9 +504,6 @@ 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():

File diff suppressed because it is too large Load Diff