Files
create/mods/datums/datums/edit_panel.py
forbes-0023 e3de2c0e71
All checks were successful
Build and Test / build (pull_request) Successful in 29m22s
feat(datums): add unified datum creator addon
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)
2026-03-02 12:15:18 -06:00

335 lines
13 KiB
Python

"""Datum edit task panel.
Opened when double-clicking a datum that has ``Datums_Type`` metadata.
Allows real-time parameter editing with live preview.
"""
import json
import math
import FreeCAD as App
import Part
from PySide6 import QtWidgets
from datums.core import META_PREFIX
def _resolve_source_refs(datum_obj):
"""Parse Datums_SourceRefs and resolve to (object, subname, shape) tuples."""
refs_json = getattr(datum_obj, f"{META_PREFIX}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
_TYPE_DISPLAY_NAMES = {
"offset_from_face": "Offset from Face",
"offset_from_plane": "Offset from Plane",
"midplane": "Midplane",
"3_points": "3 Points",
"normal_to_edge": "Normal to Edge",
"angled": "Angled from Face",
"tangent_cylinder": "Tangent to Cylinder",
"2_points": "2 Points",
"from_edge": "From Edge",
"cylinder_center": "Cylinder Center",
"plane_intersection": "Plane Intersection",
"vertex": "At Vertex",
"coordinates": "XYZ Coordinates",
"on_edge": "On Edge",
"face_center": "Face Center",
"circle_center": "Circle Center",
}
class DatumEditTaskPanel:
"""Task panel for editing existing datum objects with Datums_Type metadata."""
def __init__(self, datum_obj):
self.datum_obj = datum_obj
self.form = QtWidgets.QWidget()
self.form.setWindowTitle(f"Edit {datum_obj.Label}")
self.original_placement = datum_obj.Placement.copy()
self.original_offset = (
datum_obj.AttachmentOffset.copy() if hasattr(datum_obj, "AttachmentOffset") else None
)
self.original_path_param = (
datum_obj.MapPathParameter if hasattr(datum_obj, "MapPathParameter") else None
)
self._setup_ui()
self._load_values()
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _setup_ui(self):
layout = QtWidgets.QVBoxLayout(self.form)
layout.setSpacing(8)
# Info
info_group = QtWidgets.QGroupBox("Datum Info")
info_layout = QtWidgets.QFormLayout(info_group)
self.name_edit = QtWidgets.QLineEdit(self.datum_obj.Label)
info_layout.addRow("Name:", self.name_edit)
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "unknown")
type_label = QtWidgets.QLabel(
_TYPE_DISPLAY_NAMES.get(dtype, dtype),
)
type_label.setStyleSheet("color: #cba6f7; font-weight: bold;")
info_layout.addRow("Type:", type_label)
layout.addWidget(info_group)
# Parameters
self.params_group = QtWidgets.QGroupBox("Parameters")
self.params_layout = QtWidgets.QFormLayout(self.params_group)
self.offset_spin = QtWidgets.QDoubleSpinBox()
self.offset_spin.setRange(-10000, 10000)
self.offset_spin.setSuffix(" mm")
self.offset_spin.valueChanged.connect(self._on_param_changed)
self.angle_spin = QtWidgets.QDoubleSpinBox()
self.angle_spin.setRange(-360, 360)
self.angle_spin.setSuffix(" \u00b0")
self.angle_spin.valueChanged.connect(self._on_param_changed)
self.param_spin = QtWidgets.QDoubleSpinBox()
self.param_spin.setRange(0, 1)
self.param_spin.setSingleStep(0.1)
self.param_spin.valueChanged.connect(self._on_param_changed)
self.x_spin = QtWidgets.QDoubleSpinBox()
self.x_spin.setRange(-10000, 10000)
self.x_spin.setSuffix(" mm")
self.x_spin.valueChanged.connect(self._on_param_changed)
self.y_spin = QtWidgets.QDoubleSpinBox()
self.y_spin.setRange(-10000, 10000)
self.y_spin.setSuffix(" mm")
self.y_spin.valueChanged.connect(self._on_param_changed)
self.z_spin = QtWidgets.QDoubleSpinBox()
self.z_spin.setRange(-10000, 10000)
self.z_spin.setSuffix(" mm")
self.z_spin.valueChanged.connect(self._on_param_changed)
layout.addWidget(self.params_group)
# Source references (read-only)
refs_group = QtWidgets.QGroupBox("Source References")
refs_layout = QtWidgets.QVBoxLayout(refs_group)
self.refs_list = QtWidgets.QListWidget()
self.refs_list.setMaximumHeight(80)
refs_layout.addWidget(self.refs_list)
layout.addWidget(refs_group)
# Placement readout
placement_group = QtWidgets.QGroupBox("Current Placement")
placement_layout = QtWidgets.QFormLayout(placement_group)
pos = self.datum_obj.Placement.Base
self.pos_label = QtWidgets.QLabel(
f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})",
)
placement_layout.addRow("Position:", self.pos_label)
layout.addWidget(placement_group)
layout.addStretch()
def _load_values(self):
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "")
params_json = getattr(self.datum_obj, f"{META_PREFIX}Params", "{}")
refs_json = getattr(self.datum_obj, f"{META_PREFIX}SourceRefs", "[]")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
try:
refs = json.loads(refs_json)
except json.JSONDecodeError:
refs = []
# Clear
while self.params_layout.rowCount() > 0:
self.params_layout.removeRow(0)
if dtype in ("offset_from_face", "offset_from_plane"):
self.offset_spin.setValue(params.get("distance", 10))
self.params_layout.addRow("Offset:", self.offset_spin)
elif dtype == "midplane":
self.offset_spin.setValue(params.get("half_distance", 0))
self.params_layout.addRow("Half-distance:", self.offset_spin)
elif dtype == "angled":
self.angle_spin.setValue(params.get("angle", 45))
self.params_layout.addRow("Angle:", self.angle_spin)
elif dtype == "tangent_cylinder":
self.angle_spin.setValue(params.get("angle", 0))
self.params_layout.addRow("Angle:", self.angle_spin)
elif dtype in ("normal_to_edge", "on_edge"):
self.param_spin.setValue(params.get("parameter", 0.5))
self.params_layout.addRow("Position (0-1):", self.param_spin)
elif dtype == "coordinates":
self.x_spin.setValue(params.get("x", 0))
self.y_spin.setValue(params.get("y", 0))
self.z_spin.setValue(params.get("z", 0))
self.params_layout.addRow("X:", self.x_spin)
self.params_layout.addRow("Y:", self.y_spin)
self.params_layout.addRow("Z:", self.z_spin)
else:
lbl = QtWidgets.QLabel("No editable parameters")
lbl.setStyleSheet("color: #888;")
self.params_layout.addRow(lbl)
# Source refs
self.refs_list.clear()
for ref in refs:
obj_name = ref.get("object", "?")
subname = ref.get("subname", "")
text = f"{obj_name}.{subname}" if subname else obj_name
self.refs_list.addItem(text)
if not refs:
self.refs_list.addItem("(no references)")
# ------------------------------------------------------------------
# Live parameter updates
# ------------------------------------------------------------------
def _has_attachment(self):
return hasattr(self.datum_obj, "MapMode") and self.datum_obj.MapMode != "Deactivated"
def _on_param_changed(self):
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "")
if dtype == "coordinates":
new_pos = App.Vector(
self.x_spin.value(),
self.y_spin.value(),
self.z_spin.value(),
)
self.datum_obj.Placement.Base = new_pos
self._store_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z})
elif dtype in ("offset_from_face", "offset_from_plane", "midplane"):
distance = self.offset_spin.value()
if self._has_attachment():
self.datum_obj.AttachmentOffset = App.Placement(
App.Vector(0, 0, distance),
App.Rotation(),
)
key = "half_distance" if dtype == "midplane" else "distance"
self._store_params({key: distance})
elif dtype == "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._store_params({"angle": angle})
elif dtype == "tangent_cylinder":
angle = self.angle_spin.value()
if self._has_attachment():
params_json = getattr(self.datum_obj, f"{META_PREFIX}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_dir = cyl.Axis
center = cyl.Center
radius = cyl.Radius
rad = math.radians(angle)
if abs(axis_dir.dot(App.Vector(1, 0, 0))) < 0.99:
lx = axis_dir.cross(App.Vector(1, 0, 0)).normalize()
else:
lx = axis_dir.cross(App.Vector(0, 1, 0)).normalize()
ly = axis_dir.cross(lx)
radial = lx * math.cos(rad) + ly * math.sin(rad)
tp = center + radial * radius
rot = App.Rotation(App.Vector(0, 0, 1), radial)
self.datum_obj.Placement = App.Placement(tp, rot)
self._store_params({"angle": angle})
elif dtype in ("normal_to_edge", "on_edge"):
parameter = self.param_spin.value()
if self._has_attachment() and hasattr(self.datum_obj, "MapPathParameter"):
self.datum_obj.MapPathParameter = parameter
self._store_params({"parameter": parameter})
# Refresh readout
pos = self.datum_obj.Placement.Base
self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
App.ActiveDocument.recompute()
def _store_params(self, new_values):
params_json = getattr(self.datum_obj, f"{META_PREFIX}Params", "{}")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
params.update(new_values)
serializable = {}
for k, v in params.items():
if hasattr(v, "x") and hasattr(v, "y") and hasattr(v, "z"):
serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
else:
serializable[k] = v
setattr(self.datum_obj, f"{META_PREFIX}Params", json.dumps(serializable))
# ------------------------------------------------------------------
# Task panel protocol
# ------------------------------------------------------------------
def accept(self):
new_label = self.name_edit.text().strip()
if new_label and new_label != self.datum_obj.Label:
self.datum_obj.Label = new_label
App.ActiveDocument.recompute()
return True
def reject(self):
self.datum_obj.Placement = self.original_placement
if self.original_offset is not None and hasattr(self.datum_obj, "AttachmentOffset"):
self.datum_obj.AttachmentOffset = self.original_offset
if self.original_path_param is not None and hasattr(self.datum_obj, "MapPathParameter"):
self.datum_obj.MapPathParameter = self.original_path_param
App.ActiveDocument.recompute()
return True
def getStandardButtons(self):
return QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel