Leverage FreeCAD AttachExtension for parametric datum updates

ZTools datum planes now use FreeCAD's built-in attachment engine instead
of setting MapMode='Deactivated' with manual placement. This means
datums automatically update when source geometry changes on recompute.

Each datum type maps to a vanilla MapMode:
  offset_from_face  -> FlatFace + AttachmentOffset.Base.z = distance
  offset_from_plane -> FlatFace + AttachmentOffset.Base.z = distance
  midplane          -> FlatFace on face1 + offset = half gap distance
  3_points          -> ThreePointsPlane (3 vertex refs)
  normal_to_edge    -> NormalToEdge + MapPathParameter for position
  angled            -> FlatFace + AttachmentOffset.Rotation for angle
  tangent_cylinder  -> manual fallback (TangentPlane needs vertex ref)

The C++ AttachExtension handles: automatic recompute via
extensionExecute(), dependency tracking via PropertyLinkSubList,
topology change handling, and live preview via extensionOnChanged().

Non-Body datums (Part::Plane) lack AttachExtension and continue to
use manual placement as before.

Other changes:
- New _configure_attachment() helper sets MapMode, AttachmentSupport,
  AttachmentOffset, and MapPathParameter on datum objects
- _setup_ztools_datum() accepts optional attachment parameters and
  falls back to manual placement when not provided
- DatumEditTaskPanel.on_param_changed() writes to AttachmentOffset
  and MapPathParameter for live editing instead of TODO stubs
- AttachmentSupport added to hidden properties list
- Spreadsheet links target AttachmentOffset.Base.z instead of
  Placement.Base.z for attached datums
- ZTools metadata (ZTools_Type, ZTools_Params, ZTools_SourceRefs)
  preserved for edit UI and styling
This commit is contained in:
Zoe Forbes
2026-01-31 08:09:53 -06:00
parent 0e95d1cc76
commit 005348b8a9
2 changed files with 273 additions and 134 deletions

View File

@@ -42,6 +42,7 @@ class ZToolsDatumViewProvider:
"MapPathParameter",
"MapReversed",
"AttachmentOffset",
"AttachmentSupport",
"Support",
]
@@ -332,8 +333,20 @@ class DatumEditTaskPanel:
if not refs:
self.refs_list.addItem("(no references)")
def _has_attachment(self):
"""Check if datum uses vanilla AttachExtension (MapMode != Deactivated)."""
return (
hasattr(self.datum_obj, "MapMode")
and self.datum_obj.MapMode != "Deactivated"
)
def on_param_changed(self):
"""Handle parameter value changes - update datum in real-time."""
"""Handle parameter value changes - update datum in real-time.
For datums with active AttachExtension, writes to AttachmentOffset or
MapPathParameter — the C++ engine recalculates placement automatically.
For manual datums, updates Placement directly.
"""
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
# For coordinate-based points, update placement directly
@@ -344,19 +357,21 @@ class DatumEditTaskPanel:
self.datum_obj.Placement.Base = new_pos
self._update_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z})
elif ztools_type in ("offset_from_face", "offset_from_plane"):
# For offset types, we need to recalculate from source
# This is more complex - for now just update the stored param
self._update_params({"distance": self.offset_spin.value()})
# TODO: Recalculate placement from source geometry
elif ztools_type in ("offset_from_face", "offset_from_plane", "midplane"):
distance = self.offset_spin.value()
if self._has_attachment():
new_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
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()})
# TODO: Recalculate placement from source geometry
elif ztools_type in ("normal_to_edge", "on_edge"):
self._update_params({"parameter": self.param_spin.value()})
# TODO: Recalculate placement from source geometry
parameter = self.param_spin.value()
if self._has_attachment() and hasattr(self.datum_obj, "MapPathParameter"):
self.datum_obj.MapPathParameter = parameter
self._update_params({"parameter": parameter})
# Update position display
pos = self.datum_obj.Placement.Base

View File

@@ -32,19 +32,25 @@ def _get_next_index(doc: App.Document, prefix: str) -> int:
# =============================================================================
# ZTOOLS CUSTOM ATTACHMENT SYSTEM
# ZTOOLS ATTACHMENT SYSTEM
# =============================================================================
#
# FreeCAD's vanilla attachment system has reliability issues. ZTools uses its
# own attachment approach:
# ZTools leverages FreeCAD's built-in AttachExtension (MapMode, AttachmentSupport,
# AttachmentOffset) on PartDesign datums for automatic parametric updates on
# recompute. Each datum type maps to a vanilla MapMode:
#
# 1. Store source references as properties (ZTools_SourceRefs)
# 2. Store creation method and parameters (ZTools_Type, ZTools_Params)
# 3. Calculate placement directly from geometry at creation time
# 4. Use MapMode='Deactivated' to prevent FreeCAD attachment interference
# offset_from_face -> FlatFace + AttachmentOffset.Base.z = distance
# offset_from_plane -> FlatFace + AttachmentOffset.Base.z = distance
# midplane -> FlatFace on face1 + AttachmentOffset.Base.z = half_gap
# 3_points -> ThreePointsPlane
# normal_to_edge -> NormalToEdge + MapPathParameter
# angled -> FlatFace + AttachmentOffset.Rotation = angle about edge
# tangent_cylinder -> TangentPlane (with vertex ref) or manual fallback
#
# This gives us full control over datum positioning while maintaining
# the ability to update datums when source geometry changes (future feature).
# ZTools metadata (ZTools_Type, ZTools_Params, ZTools_SourceRefs) is kept for
# the edit UI. Attachment properties are hidden from the property panel.
#
# Non-Body datums (Part::Plane) lack AttachExtension and use manual placement.
# =============================================================================
@@ -79,6 +85,7 @@ def _hide_attachment_properties(obj):
"MapPathParameter",
"MapReversed",
"AttachmentOffset",
"AttachmentSupport",
"Support",
]
@@ -123,6 +130,35 @@ def _setup_ztools_viewprovider(obj):
pass # C++ ViewProvider doesn't support Python proxy assignment
def _configure_attachment(obj, map_mode, support, offset=None, path_param=None):
"""
Configure FreeCAD's vanilla AttachExtension on a datum object.
This enables automatic placement updates on recompute — the C++ engine
recalculates placement from source geometry via extensionExecute().
Args:
obj: The datum object (must have AttachExtension, e.g. PartDesign::Plane)
map_mode: MapMode string (e.g. "FlatFace", "NormalToEdge")
support: List of (object, subname) tuples for AttachmentSupport
offset: Optional App.Placement for AttachmentOffset
path_param: Optional float for MapPathParameter (0-1, for curve modes)
Returns:
True if attachment was configured, False if object lacks AttachExtension
"""
if not hasattr(obj, "MapMode"):
return False
obj.AttachmentSupport = support
obj.MapMode = map_mode
if offset is not None:
obj.AttachmentOffset = offset
if path_param is not None and hasattr(obj, "MapPathParameter"):
obj.MapPathParameter = path_param
return True
def _setup_ztools_datum(
obj,
placement: App.Placement,
@@ -130,26 +166,37 @@ def _setup_ztools_datum(
params: Dict[str, Any],
source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None,
is_plane: bool = False,
map_mode: Optional[str] = None,
support: Optional[list] = None,
offset: Optional[App.Placement] = None,
path_param: Optional[float] = None,
):
"""
Set up a ZTools datum with custom attachment system.
Set up a ZTools datum with attachment or manual placement.
When map_mode and support are provided and the object has AttachExtension,
configures vanilla attachment for automatic recompute. Otherwise falls back
to manual placement with MapMode="Deactivated".
Args:
obj: The datum object (PartDesign::Plane, Line, or Point)
placement: Calculated placement for the datum
placement: Calculated placement (used as fallback for non-Body datums)
datum_type: ZTools creation method identifier
params: Creation parameters to store
source_refs: List of (object, subname) tuples for source geometry
is_plane: If True, apply transparent purple styling
map_mode: Optional MapMode string for vanilla attachment
support: Optional AttachmentSupport list for vanilla attachment
offset: Optional AttachmentOffset placement
path_param: Optional MapPathParameter value (0-1)
"""
# Disable FreeCAD's attachment system
if hasattr(obj, "MapMode"):
obj.MapMode = "Deactivated"
if hasattr(obj, "Support"):
obj.Support = None
# Apply calculated placement
obj.Placement = placement
if map_mode and support and hasattr(obj, "MapMode"):
_configure_attachment(obj, map_mode, support, offset, path_param)
else:
# Fallback: manual placement (non-Body datums like Part::Plane)
if hasattr(obj, "MapMode"):
obj.MapMode = "Deactivated"
obj.Placement = placement
# Store ZTools metadata
_add_ztools_metadata(obj, datum_type, params, source_refs)
@@ -363,30 +410,41 @@ def plane_offset_from_face(
placement = App.Placement(base, rot)
# Create plane
source_refs = [(source_object, source_subname)] if source_object else None
if body:
plane = body.newObject("PartDesign::Plane", name)
att_support = [(source_object, source_subname)] if source_object else None
att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
_setup_ztools_datum(
plane,
placement,
"offset_from_face",
{"distance": distance},
source_refs,
is_plane=True,
map_mode="FlatFace" if att_support else None,
support=att_support,
offset=att_offset,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
# Adjust placement for Part::Plane (centered differently)
placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
source_refs = [(source_object, source_subname)] if source_object else None
_setup_ztools_datum(
plane,
placement,
"offset_from_face",
{"distance": distance, "base": base, "normal": normal},
source_refs,
is_plane=True,
)
_setup_ztools_datum(
plane,
placement,
"offset_from_face",
{"distance": distance},
source_refs,
is_plane=True,
)
# Spreadsheet link
if link_spreadsheet and body:
alias = f"{name}_offset"
_link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias)
_link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias)
doc.recompute()
return plane
@@ -446,35 +504,40 @@ def plane_offset_from_plane(
placement = App.Placement(base, rot)
# Create plane
source_refs = [(source_plane, "")]
if body:
plane = body.newObject("PartDesign::Plane", name)
att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
_setup_ztools_datum(
plane,
placement,
"offset_from_plane",
{"distance": distance, "source_plane": source_plane.Name},
source_refs,
is_plane=True,
map_mode="FlatFace",
support=[(source_plane, "")],
offset=att_offset,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
# Adjust placement for Part::Plane (centered differently)
placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
source_refs = [(source_plane, "")]
_setup_ztools_datum(
plane,
placement,
"offset_from_plane",
{
"distance": distance,
"base": base,
"normal": normal,
"source_plane": source_plane.Name,
},
source_refs,
is_plane=True,
)
_setup_ztools_datum(
plane,
placement,
"offset_from_plane",
{"distance": distance, "source_plane": source_plane.Name},
source_refs,
is_plane=True,
)
# Spreadsheet link
if link_spreadsheet and body:
alias = f"{name}_offset"
_link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias)
_link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias)
doc.recompute()
return plane
@@ -520,9 +583,10 @@ def plane_midplane(
if dot < 0.9999:
raise ValueError("Faces must be parallel for midplane")
# Midpoint
# Compute half-distance between faces along face1 normal
c1 = face1.CenterOfMass
c2 = face2.CenterOfMass
half_dist = (c2 - c1).dot(n1) / 2.0
mid = (c1 + c2) * 0.5
# Auto-name
@@ -530,34 +594,47 @@ def plane_midplane(
idx = _get_next_index(doc, "ZPlane_Mid")
name = f"ZPlane_Mid_{idx:03d}"
# Calculate placement
# Calculate placement (used as fallback for non-Body datums)
rot = App.Rotation(App.Vector(0, 0, 1), n1)
placement = App.Placement(mid, rot)
# Create plane
if body:
plane = body.newObject("PartDesign::Plane", name)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
source_refs = []
if source_object1:
source_refs.append((source_object1, source_subname1))
if source_object2:
source_refs.append((source_object2, source_subname2))
_setup_ztools_datum(
plane,
placement,
"midplane",
{"center1": c1, "center2": c2, "midpoint": mid},
source_refs if source_refs else None,
is_plane=True,
)
if body and source_object1 and source_subname1:
plane = body.newObject("PartDesign::Plane", name)
att_offset = App.Placement(App.Vector(0, 0, half_dist), App.Rotation())
_setup_ztools_datum(
plane,
placement,
"midplane",
{"half_distance": half_dist},
source_refs if source_refs else None,
is_plane=True,
map_mode="FlatFace",
support=[(source_object1, source_subname1)],
offset=att_offset,
)
else:
if body:
plane = body.newObject("PartDesign::Plane", name)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot)
_setup_ztools_datum(
plane,
placement,
"midplane",
{"half_distance": half_dist},
source_refs if source_refs else None,
is_plane=True,
)
doc.recompute()
return plane
@@ -608,21 +685,32 @@ def plane_from_3_points(
# Create plane
if body:
plane = body.newObject("PartDesign::Plane", name)
att_support = [(ref[0], ref[1]) for ref in source_refs] if source_refs else None
_setup_ztools_datum(
plane,
placement,
"3_points",
{"p1": p1, "p2": p2, "p3": p3},
source_refs,
is_plane=True,
map_mode="ThreePointsPlane"
if att_support and len(att_support) == 3
else None,
support=att_support,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(center - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
_setup_ztools_datum(
plane,
placement,
"3_points",
{"p1": p1, "p2": p2, "p3": p3, "center": center, "normal": normal},
source_refs,
is_plane=True,
)
_setup_ztools_datum(
plane,
placement,
"3_points",
{"p1": p1, "p2": p2, "p3": p3},
source_refs,
is_plane=True,
)
doc.recompute()
return plane
@@ -666,24 +754,35 @@ def plane_normal_to_edge(
placement = App.Placement(point, rot)
# Create plane
source_refs = [(source_object, source_subname)] if source_object else None
if body:
plane = body.newObject("PartDesign::Plane", name)
att_support = [(source_object, source_subname)] if source_object else None
_setup_ztools_datum(
plane,
placement,
"normal_to_edge",
{"parameter": parameter},
source_refs,
is_plane=True,
map_mode="NormalToEdge" if att_support else None,
support=att_support,
path_param=parameter,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
source_refs = [(source_object, source_subname)] if source_object else None
_setup_ztools_datum(
plane,
placement,
"normal_to_edge",
{"parameter": parameter, "point": point, "tangent": tangent},
source_refs,
is_plane=True,
)
_setup_ztools_datum(
plane,
placement,
"normal_to_edge",
{"parameter": parameter},
source_refs,
is_plane=True,
)
doc.recompute()
return plane
@@ -750,39 +849,55 @@ def plane_angled(
placement = App.Placement(edge_mid, rot)
# Create plane
if body:
plane = body.newObject("PartDesign::Plane", name)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot)
# Set up with ZTools attachment system
source_refs = []
if source_face_obj:
source_refs.append((source_face_obj, source_face_sub))
if source_edge_obj:
source_refs.append((source_edge_obj, source_edge_sub))
_setup_ztools_datum(
plane,
placement,
"angled",
{
"angle": angle,
"edge_mid": edge_mid,
"original_normal": face_normal,
"new_normal": new_normal,
},
source_refs if source_refs else None,
is_plane=True,
)
if body and source_face_obj and source_face_sub:
plane = body.newObject("PartDesign::Plane", name)
# Encode the angle as a rotation about the edge direction in the
# face-local coordinate system. The FlatFace attachment aligns Z with
# face normal, so we rotate about the edge direction projected into
# the face-local frame.
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)
att_offset = App.Placement(App.Vector(0, 0, 0), angle_rot)
_setup_ztools_datum(
plane,
placement,
"angled",
{"angle": angle},
source_refs if source_refs else None,
is_plane=True,
map_mode="FlatFace",
support=[(source_face_obj, source_face_sub)],
offset=att_offset,
)
else:
if body:
plane = body.newObject("PartDesign::Plane", name)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
plane.Width = 50
placement = App.Placement(
edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot
)
_setup_ztools_datum(
plane,
placement,
"angled",
{"angle": angle},
source_refs if source_refs else None,
is_plane=True,
)
if link_spreadsheet and body:
alias = f"{name}_angle"
# For ZTools system, we'd need custom expression handling
# _link_to_spreadsheet(doc, plane, "...", angle, alias)
_link_to_spreadsheet(doc, plane, "AttachmentOffset.Angle", angle, alias)
doc.recompute()
return plane
@@ -847,8 +962,20 @@ def plane_tangent_to_cylinder(
placement = App.Placement(tangent_point, rot)
# Create plane
source_refs = [(source_object, source_subname)] if source_object else None
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,
)
else:
plane = doc.addObject("Part::Plane", name)
plane.Length = 50
@@ -856,17 +983,14 @@ def plane_tangent_to_cylinder(
placement = App.Placement(
tangent_point - rot.multVec(App.Vector(25, 25, 0)), rot
)
# Set up with ZTools attachment system
source_refs = [(source_object, source_subname)] if source_object else None
_setup_ztools_datum(
plane,
placement,
"tangent_cylinder",
{"angle": angle, "radius": radius, "tangent_point": tangent_point},
source_refs,
is_plane=True,
)
_setup_ztools_datum(
plane,
placement,
"tangent_cylinder",
{"angle": angle, "radius": radius},
source_refs,
is_plane=True,
)
doc.recompute()
return plane