Improve datum behaviour

This commit is contained in:
Zoe Forbes
2026-01-24 23:24:39 -06:00
parent 981b15804e
commit a66dac7afc
11 changed files with 1675 additions and 591 deletions

198
PLAN.md Normal file
View File

@@ -0,0 +1,198 @@
# ZTools Development Plan
## Current Status: v0.1.0 (70% complete)
### What's Working
- Workbench registration with 10 toolbars and menus
- All 15 datum creation functions with custom ZTools attachment system
- Datum Creator GUI (task panel with Planes/Axes/Points tabs)
- OK button creates datum, Cancel dismisses without creating
- Rotated Linear Pattern feature (complete)
- Icon system (21+ Catppuccin-themed SVGs)
- Metadata storage system (ZTools_Type, ZTools_Params, ZTools_SourceRefs)
- Spreadsheet linking for parametric control
### Recent Changes (2026-01-24)
- Replaced FreeCAD's vanilla attachment system with custom ZTools attachment
- All datums now use `MapMode='Deactivated'` with calculated placements
- Source references stored in `ZTools_SourceRefs` property for future update capability
- Fixed all 3 point functions (`point_on_edge`, `point_center_of_face`, `point_center_of_circle`) to accept source parameters
- Removed redundant "Create Datum" button - OK now creates the datum
- Task panel properly cleans up selection observer on close
---
## ZTools Attachment System
FreeCAD's vanilla attachment system has reliability issues. ZTools uses a custom approach:
1. **Calculate placement directly** from source geometry at creation time
2. **Store source references** in `ZTools_SourceRefs` property (JSON)
3. **Use `MapMode='Deactivated'`** to prevent FreeCAD attachment interference
4. **Store creation parameters** in `ZTools_Params` for potential recalculation
This gives full control over datum positioning while maintaining the ability to update datums when source geometry changes (future feature).
### Metadata Properties
All ZTools datums have these custom properties:
- `ZTools_Type`: Creation method identifier (e.g., "offset_from_face", "midplane")
- `ZTools_Params`: JSON-encoded creation parameters
- `ZTools_SourceRefs`: JSON-encoded list of source geometry references
---
## Phase 1: Complete (Datum Tools)
All datum creation functions now work:
### Planes (6 modes)
- Offset from Face
- Midplane (2 Faces)
- 3 Points
- Normal to Edge
- Angled from Face
- Tangent to Cylinder
### Axes (4 modes)
- 2 Points
- From Edge
- Cylinder Center
- Plane Intersection
### Points (5 modes)
- At Vertex
- XYZ Coordinates
- On Edge (with parameter)
- Face Center
- Circle Center
---
## Phase 2: Complete Enhanced Pocket
### 2.1 Wire Up Pocket Execution (pocket_commands.py)
The EnhancedPocketTaskPanel has complete UI but no execute logic.
Required implementation:
1. Get selected sketch from user
2. Create PartDesign::Pocket with selected type
3. Apply "Flip Side to Cut" by:
- Reversing the pocket direction, OR
- Using a boolean cut approach with inverted profile
4. Handle all pocket types: Dimension, Through All, To First, Up To Face, Two Dimensions
### 2.2 Register Pocket Command
Add to InitGui.py toolbar if not already present.
---
## Phase 3: Datum Manager
### 3.1 Implement DatumManagerTaskPanel
Replace the stub in datum_commands.py with functional panel:
Features:
- List all datum objects (planes, axes, points) in document
- Filter by type (ZTools-created vs native)
- Toggle visibility (eye icon per item)
- Rename datums inline
- Delete selected datums
- Jump to datum in model tree
UI Layout:
```
+----------------------------------+
| Filter: [All v] [ZTools only ☐] |
+----------------------------------+
| ☑ ZPlane_Offset_001 [👁] [🗑] |
| ☑ ZPlane_Mid_001 [👁] [🗑] |
| ☐ ZAxis_Cyl_001 [👁] [🗑] |
+----------------------------------+
| [Rename] [Show All] [Hide All] |
+----------------------------------+
```
---
## Phase 4: Additional Features (Future)
### 4.1 Module 2 Completion: Enhanced Pad
- Multi-body support
- Draft angles on pad
- Lip/groove profiles
### 4.2 Module 3: Body Operations
- Split body at plane
- Combine bodies
- Shell improvements
### 4.3 Module 4: Pattern Tools
- Curve-driven pattern (sweep instances along spline)
- Fill pattern (populate region with instances)
- Pattern with variable spacing
### 4.4 Datum Update Feature
- Use stored `ZTools_SourceRefs` to recalculate datum positions
- Handle topology changes gracefully
- Option to "freeze" datums (disconnect from sources)
---
## File Reference
| File | Purpose | Lines |
|------|---------|-------|
| `ztools/ztools/datums/core.py` | Datum creation functions | ~750 |
| `ztools/ztools/commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
| `ztools/ztools/commands/pocket_commands.py` | Enhanced Pocket GUI | ~600 |
| `ztools/ztools/commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
| `ztools/InitGui.py` | Workbench registration | ~200 |
| `ztools/ztools/resources/icons.py` | SVG icon definitions | ~400 |
---
## Testing Checklist
### Phase 1 Tests (Datum Tools)
- [ ] Create plane offset from face
- [ ] Create midplane between 2 faces
- [ ] Create plane from 3 points
- [ ] Create plane normal to edge at various parameters
- [ ] Create angled plane from face about edge
- [ ] Create plane tangent to cylinder
- [ ] Create axis from 2 points
- [ ] Create axis from edge
- [ ] Create axis at cylinder center
- [ ] Create axis at plane intersection
- [ ] Create point at vertex
- [ ] Create point at XYZ coordinates
- [ ] Create point on edge at parameter 0.0, 0.5, 1.0
- [ ] Create point at face center (planar and cylindrical)
- [ ] Create point at circle center (full circle and arc)
- [ ] Verify ZTools_Type, ZTools_Params, ZTools_SourceRefs properties exist
- [ ] Verify no "deactivated attachment mode" warnings in console
### Phase 2 Tests (Enhanced Pocket)
- [ ] Create pocket with Dimension type
- [ ] Create pocket with Through All
- [ ] Create pocket with Flip Side to Cut enabled
- [ ] Verify pocket respects taper angle
### Phase 3 Tests (Datum Manager)
- [ ] Datum Manager lists all datums
- [ ] Visibility toggle works
- [ ] Rename persists after recompute
- [ ] Delete removes datum cleanly
---
## Notes
- FreeCAD 1.0+ required (TNP mitigation assumed)
- ZTools uses custom attachment system (not FreeCAD's vanilla attachment)
- Catppuccin Mocha theme is bundled as preference pack
- LGPL-3.0-or-later license

View File

@@ -1,4 +1,9 @@
# ztools/commands - GUI commands
from . import datum_commands, pattern_commands, pocket_commands
from . import datum_commands, datum_viewprovider, pattern_commands, pocket_commands
__all__ = ["datum_commands", "pattern_commands", "pocket_commands"]
__all__ = [
"datum_commands",
"datum_viewprovider",
"pattern_commands",
"pocket_commands",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,405 @@
# ztools/commands/datum_viewprovider.py
# Custom ViewProvider for ZTools datum objects
import json
import FreeCAD as App
import FreeCADGui as Gui
import Part
from PySide import QtCore, QtGui
class ZToolsDatumViewProvider:
"""
Custom ViewProvider for ZTools datum objects.
Features:
- Overrides double-click to open ZTools editor instead of vanilla attachment
- Hides attachment properties from property editor
- Provides custom icons based on datum type
"""
_is_ztools = True # Marker to identify ZTools ViewProviders
def __init__(self, vobj):
"""Initialize and attach to ViewObject."""
vobj.Proxy = self
self.Object = vobj.Object if vobj else None
def attach(self, vobj):
"""Called when ViewProvider is attached to object."""
self.Object = vobj.Object
self._hide_attachment_props(vobj)
def _hide_attachment_props(self, vobj):
"""Hide FreeCAD attachment properties."""
if not vobj or not vobj.Object:
return
obj = vobj.Object
attachment_props = [
"MapMode",
"MapPathParameter",
"MapReversed",
"AttachmentOffset",
"Support",
]
for prop in attachment_props:
try:
if hasattr(obj, prop):
vobj.setEditorMode(prop, 2) # 2 = Hidden
except Exception:
pass
def updateData(self, obj, prop):
"""Called when data properties change."""
pass
def onChanged(self, vobj, prop):
"""Called when view properties change."""
# Re-hide attachment properties if they become visible
if prop in ("MapMode", "Support"):
self._hide_attachment_props(vobj)
def doubleClicked(self, vobj):
"""
Handle double-click - open ZTools datum editor.
Returns True if handled, False to let FreeCAD handle it.
"""
if Gui.Control.activeDialog():
# Task panel already open
return False
# Check if this is a ZTools datum
obj = vobj.Object
if not hasattr(obj, "ZTools_Type"):
# Not a ZTools datum, let FreeCAD handle it
return False
# Open ZTools editor
panel = DatumEditTaskPanel(obj)
Gui.Control.showDialog(panel)
return True
def setEdit(self, vobj, mode=0):
"""
Called when entering edit mode.
Mode 0 = default edit, Mode 1 = transform
"""
if mode == 0:
obj = vobj.Object
if hasattr(obj, "ZTools_Type"):
panel = DatumEditTaskPanel(obj)
Gui.Control.showDialog(panel)
return True
return False
def unsetEdit(self, vobj, mode=0):
"""Called when exiting edit mode."""
return False
def getIcon(self):
"""Return icon for tree view based on datum type."""
from ztools.resources.icons import get_icon
if not self.Object:
return get_icon("datum_creator")
ztools_type = getattr(self.Object, "ZTools_Type", "")
# Map ZTools type to icon
icon_map = {
"offset_from_face": "plane_offset",
"offset_from_plane": "plane_offset",
"midplane": "plane_midplane",
"3_points": "plane_3pt",
"normal_to_edge": "plane_normal",
"angled": "plane_angled",
"tangent_cylinder": "plane_tangent",
"2_points": "axis_2pt",
"from_edge": "axis_edge",
"cylinder_center": "axis_cyl",
"plane_intersection": "axis_intersect",
"vertex": "point_vertex",
"coordinates": "point_xyz",
"on_edge": "point_edge",
"face_center": "point_face",
"circle_center": "point_circle",
}
icon_name = icon_map.get(ztools_type, "datum_creator")
return get_icon(icon_name)
def __getstate__(self):
"""Serialization - don't save proxy state."""
return None
def __setstate__(self, state):
"""Deserialization."""
return None
class DatumEditTaskPanel:
"""
Task panel for editing existing ZTools datum objects.
Allows modification of:
- Offset distance (for offset-type datums)
- Angle (for angled/tangent datums)
- Parameter position (for edge-based datums)
- Source references (future)
"""
def __init__(self, datum_obj):
self.datum_obj = datum_obj
self.form = QtGui.QWidget()
self.form.setWindowTitle(f"Edit {datum_obj.Label}")
self.original_placement = datum_obj.Placement.copy()
self.setup_ui()
self.load_current_values()
def setup_ui(self):
"""Create the edit panel UI."""
layout = QtGui.QVBoxLayout(self.form)
layout.setSpacing(8)
# Header with datum info
info_group = QtGui.QGroupBox("Datum Info")
info_layout = QtGui.QFormLayout(info_group)
self.name_edit = QtGui.QLineEdit(self.datum_obj.Label)
info_layout.addRow("Name:", self.name_edit)
ztools_type = getattr(self.datum_obj, "ZTools_Type", "unknown")
type_label = QtGui.QLabel(self._format_type_name(ztools_type))
type_label.setStyleSheet("color: #cba6f7; font-weight: bold;")
info_layout.addRow("Type:", type_label)
layout.addWidget(info_group)
# Parameters group
self.params_group = QtGui.QGroupBox("Parameters")
self.params_layout = QtGui.QFormLayout(self.params_group)
# Offset spinner
self.offset_spin = QtGui.QDoubleSpinBox()
self.offset_spin.setRange(-10000, 10000)
self.offset_spin.setSuffix(" mm")
self.offset_spin.valueChanged.connect(self.on_param_changed)
# Angle spinner
self.angle_spin = QtGui.QDoubleSpinBox()
self.angle_spin.setRange(-360, 360)
self.angle_spin.setSuffix(" °")
self.angle_spin.valueChanged.connect(self.on_param_changed)
# Parameter spinner (0-1)
self.param_spin = QtGui.QDoubleSpinBox()
self.param_spin.setRange(0, 1)
self.param_spin.setSingleStep(0.1)
self.param_spin.valueChanged.connect(self.on_param_changed)
# XYZ spinners for point coordinates
self.x_spin = QtGui.QDoubleSpinBox()
self.x_spin.setRange(-10000, 10000)
self.x_spin.setSuffix(" mm")
self.x_spin.valueChanged.connect(self.on_param_changed)
self.y_spin = QtGui.QDoubleSpinBox()
self.y_spin.setRange(-10000, 10000)
self.y_spin.setSuffix(" mm")
self.y_spin.valueChanged.connect(self.on_param_changed)
self.z_spin = QtGui.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 for now)
refs_group = QtGui.QGroupBox("Source References")
refs_layout = QtGui.QVBoxLayout(refs_group)
self.refs_list = QtGui.QListWidget()
self.refs_list.setMaximumHeight(80)
refs_layout.addWidget(self.refs_list)
layout.addWidget(refs_group)
# Placement info (read-only)
placement_group = QtGui.QGroupBox("Current Placement")
placement_layout = QtGui.QFormLayout(placement_group)
pos = self.datum_obj.Placement.Base
self.pos_label = QtGui.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 _format_type_name(self, ztools_type):
"""Format ZTools type string for display."""
type_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",
}
return type_names.get(ztools_type, ztools_type)
def load_current_values(self):
"""Load current values from datum object."""
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
refs_json = getattr(self.datum_obj, "ZTools_SourceRefs", "[]")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
try:
refs = json.loads(refs_json)
except json.JSONDecodeError:
refs = []
# Clear params layout
while self.params_layout.rowCount() > 0:
self.params_layout.removeRow(0)
# Add appropriate parameter controls based on type
if ztools_type in ("offset_from_face", "offset_from_plane"):
distance = params.get("distance", 10)
self.offset_spin.setValue(distance)
self.params_layout.addRow("Offset:", self.offset_spin)
elif ztools_type == "angled":
angle = params.get("angle", 45)
self.angle_spin.setValue(angle)
self.params_layout.addRow("Angle:", self.angle_spin)
elif ztools_type == "tangent_cylinder":
angle = params.get("angle", 0)
self.angle_spin.setValue(angle)
self.params_layout.addRow("Angle:", self.angle_spin)
elif ztools_type in ("normal_to_edge", "on_edge"):
parameter = params.get("parameter", 0.5)
self.param_spin.setValue(parameter)
self.params_layout.addRow("Position (0-1):", self.param_spin)
elif ztools_type == "coordinates":
x = params.get("x", 0)
y = params.get("y", 0)
z = params.get("z", 0)
self.x_spin.setValue(x)
self.y_spin.setValue(y)
self.z_spin.setValue(z)
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:
# No editable parameters
no_params_label = QtGui.QLabel("No editable parameters")
no_params_label.setStyleSheet("color: #888;")
self.params_layout.addRow(no_params_label)
# Load source references
self.refs_list.clear()
for ref in refs:
obj_name = ref.get("object", "?")
subname = ref.get("subname", "")
if subname:
self.refs_list.addItem(f"{obj_name}.{subname}")
else:
self.refs_list.addItem(obj_name)
if not refs:
self.refs_list.addItem("(no references)")
def on_param_changed(self):
"""Handle parameter value changes - update datum in real-time."""
ztools_type = getattr(self.datum_obj, "ZTools_Type", "")
# For coordinate-based points, update placement directly
if ztools_type == "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._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 ("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
# Update position display
pos = self.datum_obj.Placement.Base
self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
App.ActiveDocument.recompute()
def _update_params(self, new_values):
"""Update stored parameters with new values."""
params_json = getattr(self.datum_obj, "ZTools_Params", "{}")
try:
params = json.loads(params_json)
except json.JSONDecodeError:
params = {}
params.update(new_values)
# Re-serialize (handle vectors)
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
self.datum_obj.ZTools_Params = json.dumps(serializable)
def accept(self):
"""Handle OK button - apply changes."""
# Update label if changed
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):
"""Handle Cancel button - restore original placement."""
self.datum_obj.Placement = self.original_placement
App.ActiveDocument.recompute()
return True
def getStandardButtons(self):
"""Return dialog buttons."""
return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel)

View File

@@ -1,27 +1,29 @@
# ztools/datums - Datum creation tools
from .core import (
# Planes
plane_offset_from_face,
plane_midplane,
plane_from_3_points,
plane_normal_to_edge,
plane_angled,
plane_tangent_to_cylinder,
axis_cylinder_center,
# Axes
axis_from_2_points,
axis_from_edge,
axis_cylinder_center,
axis_intersection_planes,
plane_angled,
plane_from_3_points,
plane_midplane,
plane_normal_to_edge,
# Planes
plane_offset_from_face,
plane_offset_from_plane,
plane_tangent_to_cylinder,
point_at_coordinates,
# Points
point_at_vertex,
point_at_coordinates,
point_on_edge,
point_center_of_face,
point_center_of_circle,
point_center_of_face,
point_on_edge,
)
__all__ = [
"plane_offset_from_face",
"plane_offset_from_plane",
"plane_midplane",
"plane_from_3_points",
"plane_normal_to_edge",

File diff suppressed because it is too large Load Diff