From 005348b8a94a46934b4d013fcfb93f6399de8c63 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 31 Jan 2026 08:09:53 -0600 Subject: [PATCH] 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 --- ztools/ztools/commands/datum_viewprovider.py | 33 +- ztools/ztools/datums/core.py | 374 ++++++++++++------- 2 files changed, 273 insertions(+), 134 deletions(-) diff --git a/ztools/ztools/commands/datum_viewprovider.py b/ztools/ztools/commands/datum_viewprovider.py index 2170da2..d7656b5 100644 --- a/ztools/ztools/commands/datum_viewprovider.py +++ b/ztools/ztools/commands/datum_viewprovider.py @@ -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 diff --git a/ztools/ztools/datums/core.py b/ztools/ztools/datums/core.py index 031c10e..2b754ef 100644 --- a/ztools/ztools/datums/core.py +++ b/ztools/ztools/datums/core.py @@ -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