feat(datums): add unified datum creator addon
All checks were successful
Build and Test / build (pull_request) Successful in 29m22s

Introduces mods/datums/ — a standalone addon that replaces the three
stock PartDesign datum commands (Plane, Line, Point) with a single
unified Create_DatumCreator command.

Features:
- 16 smart creation modes (7 plane, 4 axis, 5 point)
- Auto-detection engine: selects best mode from geometry selection
- Mode override combo for manual selection
- Dynamic parameter UI (offset, angle, position, XYZ)
- Datums_Type/Params/SourceRefs metadata for edit panel
- Edit panel with real-time parameter updates via AttachExtension
- Catppuccin Mocha themed plane styling via SDK theme tokens
- Injected into partdesign.body and partdesign.feature contexts

Also adds CMake install targets for gears and datums addons.

Ported from archived ztools with key changes:
- Property prefix: ZTools_ -> Datums_
- No document-level datums (Body-only)
- PySide -> PySide6
- SDK integration (register_command, inject_commands, get_theme_tokens)
This commit is contained in:
2026-03-02 12:15:18 -06:00
parent b0621f9731
commit e3de2c0e71
26 changed files with 2565 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
"""Selection auto-detection system for the unified datum creator.
Provides geometry type classification and mode matching to automatically
determine the best datum creation mode from user selection.
"""
import Part
class SelectionItem:
"""Wraps a selected geometry element with auto-detected type."""
def __init__(self, obj, subname, shape=None):
self.obj = obj
self.subname = subname
self.shape = shape
self.geo_type = self._determine_type()
def _determine_type(self):
"""Determine the geometry type of this selection."""
if self.shape is None and hasattr(self.obj, "Shape"):
for prefix in ("Face", "Edge", "Vertex"):
if self.subname and self.subname.startswith(prefix):
try:
self.shape = self.obj.Shape.getElement(self.subname)
except Exception:
pass
break
if self.shape is None:
type_id = getattr(self.obj, "TypeId", "")
if "Plane" in type_id or (
hasattr(self.obj, "Shape")
and self.obj.Shape.Faces
and self.obj.Shape.Faces[0].Surface.isPlanar()
):
return "plane"
return "unknown"
if isinstance(self.shape, Part.Face):
if isinstance(self.shape.Surface, Part.Cylinder):
return "cylinder"
if self.shape.Surface.isPlanar():
return "face"
return "face"
elif isinstance(self.shape, Part.Edge):
if isinstance(self.shape.Curve, (Part.Circle, Part.ArcOfCircle)):
return "circle"
return "edge"
elif isinstance(self.shape, Part.Vertex):
return "vertex"
return "unknown"
@property
def display_name(self):
if self.subname:
return f"{self.obj.Label}.{self.subname}"
return self.obj.Label
@property
def type_icon(self):
icons = {
"face": "\u25a2",
"plane": "\u25a3",
"cylinder": "\u25ce",
"edge": "\u2015",
"circle": "\u25cb",
"vertex": "\u2022",
"unknown": "?",
}
return icons.get(self.geo_type, "?")
# Mode definitions: (display_name, mode_id, required_types, datum_category)
MODES = (
# Planes
("Offset from Face", "offset_face", ("face",), "plane"),
("Offset from Plane", "offset_plane", ("plane",), "plane"),
("Midplane (2 Faces)", "midplane", ("face", "face"), "plane"),
("3 Points", "3_points", ("vertex", "vertex", "vertex"), "plane"),
("Normal to Edge", "normal_edge", ("edge",), "plane"),
("Angled from Face", "angled", ("face", "edge"), "plane"),
("Tangent to Cylinder", "tangent_cyl", ("cylinder",), "plane"),
# Axes
("Axis from 2 Points", "axis_2pt", ("vertex", "vertex"), "axis"),
("Axis from Edge", "axis_edge", ("edge",), "axis"),
("Axis at Cylinder Center", "axis_cyl", ("cylinder",), "axis"),
("Axis at Plane Intersection", "axis_intersect", ("plane", "plane"), "axis"),
# Points
("Point at Vertex", "point_vertex", ("vertex",), "point"),
("Point at XYZ", "point_xyz", (), "point"),
("Point on Edge", "point_edge", ("edge",), "point"),
("Point at Face Center", "point_face", ("face",), "point"),
("Point at Circle Center", "point_circle", ("circle",), "point"),
)
# Category colors (Catppuccin Mocha)
CATEGORY_COLORS = {
"plane": "#cba6f7", # mauve
"axis": "#94e2d5", # teal
"point": "#f9e2af", # yellow
}
def _type_matches(sel_type, req_type):
"""Check if a selected type satisfies a required type."""
if sel_type == req_type:
return True
if req_type == "face" and sel_type in ("face", "cylinder"):
return True
if req_type == "edge" and sel_type in ("edge", "circle"):
return True
return False
def _match_score(sel_types, required_types):
"""Score how well selection matches required types. 0 = no match."""
if len(sel_types) < len(required_types):
return 0
remaining = list(sel_types)
matched = 0
for req in required_types:
found = False
for i, sel in enumerate(remaining):
if _type_matches(sel, req):
remaining.pop(i)
matched += 1
found = True
break
if not found:
return 0
# Exact count match scores higher
if len(sel_types) == len(required_types):
return 100 + matched
return matched
def match_mode(selection_items):
"""Auto-detect the best creation mode from selection items.
Parameters
----------
selection_items : list[SelectionItem]
Current selection items.
Returns
-------
tuple or None
``(display_name, mode_id, category)`` for best match, or None.
"""
sel_types = tuple(item.geo_type for item in selection_items)
if not sel_types:
return None
best_match = None
best_score = -1
for display_name, mode_id, required_types, category in MODES:
if not required_types:
continue
score = _match_score(sel_types, required_types)
if score > best_score:
best_score = score
best_match = (display_name, mode_id, category)
return best_match if best_score > 0 else None