diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4ba5cdc --- /dev/null +++ b/PLAN.md @@ -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 diff --git a/ztools/ztools/commands/__init__.py b/ztools/ztools/commands/__init__.py index 6bd2534..70d804a 100644 --- a/ztools/ztools/commands/__init__.py +++ b/ztools/ztools/commands/__init__.py @@ -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", +] diff --git a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc index 0eccf2d..17471f6 100644 Binary files a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc and b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc index 365ca66..6cf134f 100644 Binary files a/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc and b/ztools/ztools/commands/__pycache__/datum_commands.cpython-312.pyc differ diff --git a/ztools/ztools/commands/__pycache__/datum_viewprovider.cpython-312.pyc b/ztools/ztools/commands/__pycache__/datum_viewprovider.cpython-312.pyc new file mode 100644 index 0000000..cacfd4b Binary files /dev/null and b/ztools/ztools/commands/__pycache__/datum_viewprovider.cpython-312.pyc differ diff --git a/ztools/ztools/commands/datum_commands.py b/ztools/ztools/commands/datum_commands.py index 9eecbd2..2eb942d 100644 --- a/ztools/ztools/commands/datum_commands.py +++ b/ztools/ztools/commands/datum_commands.py @@ -7,153 +7,227 @@ import Part from PySide import QtCore, QtGui +class SelectionItem: + """Represents a selected geometry item.""" + + 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: + # Try to get shape from object + if hasattr(self.obj, "Shape"): + if self.subname and self.subname.startswith("Face"): + try: + self.shape = self.obj.Shape.getElement(self.subname) + except Exception: + pass + elif self.subname and self.subname.startswith("Edge"): + try: + self.shape = self.obj.Shape.getElement(self.subname) + except Exception: + pass + elif self.subname and self.subname.startswith("Vertex"): + try: + self.shape = self.obj.Shape.getElement(self.subname) + except Exception: + pass + + if self.shape is None: + # Check if it's a datum plane object + 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): + # Check if it's a cylindrical face + if isinstance(self.shape.Surface, Part.Cylinder): + return "cylinder" + elif self.shape.Surface.isPlanar(): + return "face" + return "face" + elif isinstance(self.shape, Part.Edge): + # Check if it's a circular edge + if isinstance(self.shape.Curve, (Part.Circle, Part.ArcOfCircle)): + return "circle" + elif isinstance(self.shape.Curve, Part.Line): + return "edge" + return "edge" + elif isinstance(self.shape, Part.Vertex): + return "vertex" + + return "unknown" + + @property + def display_name(self): + """Get display name for UI.""" + if self.subname: + return f"{self.obj.Label}.{self.subname}" + return self.obj.Label + + @property + def type_icon(self): + """Get icon character for geometry type.""" + icons = { + "face": "▢", + "plane": "▣", + "cylinder": "◎", + "edge": "―", + "circle": "○", + "vertex": "•", + "unknown": "?", + } + return icons.get(self.geo_type, "?") + + class DatumCreatorTaskPanel: - """Unified task panel for creating datum planes, axes, and points.""" + """Unified task panel for creating datum planes, axes, and points. - PLANE_MODES = [ - ("Offset from Face", "offset_face"), - ("Midplane (2 Faces)", "midplane"), - ("3 Points", "3_points"), - ("Normal to Edge", "normal_edge"), - ("Angled from Face", "angled"), - ("Tangent to Cylinder", "tangent_cyl"), - ] + Features a selection table where users can add/remove geometry items. + The datum type is automatically detected based on selection contents. + """ - AXIS_MODES = [ - ("2 Points", "axis_2pt"), - ("From Edge", "axis_edge"), - ("Cylinder Center", "axis_cyl"), - ("Plane Intersection", "axis_intersect"), - ] - - POINT_MODES = [ - ("At Vertex", "point_vertex"), - ("XYZ Coordinates", "point_xyz"), - ("On Edge", "point_edge"), - ("Face Center", "point_face"), - ("Circle Center", "point_circle"), + # Mode definitions: (display_name, mode_id, required_types, datum_category) + # required_types is a tuple describing what selection is needed + 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"), ] def __init__(self): self.form = QtGui.QWidget() - self.form.setWindowTitle("ztools Datum Creator") + self.form.setWindowTitle("ZTools Datum Creator") + self.selection_list = [] # List of SelectionItem self.setup_ui() - self.selection_callback = None - self.selected_items = [] self.setup_selection_observer() + self.update_mode_from_selection() def setup_ui(self): layout = QtGui.QVBoxLayout(self.form) + layout.setSpacing(8) - # Datum type tabs - self.tabs = QtGui.QTabWidget() - layout.addWidget(self.tabs) + # Selection table section + sel_group = QtGui.QGroupBox("Selection") + sel_layout = QtGui.QVBoxLayout(sel_group) - # Plane tab - plane_widget = QtGui.QWidget() - plane_layout = QtGui.QVBoxLayout(plane_widget) + # Selection table + self.sel_table = QtGui.QTableWidget() + self.sel_table.setColumnCount(3) + self.sel_table.setHorizontalHeaderLabels(["Type", "Element", ""]) + self.sel_table.horizontalHeader().setStretchLastSection(False) + self.sel_table.horizontalHeader().setSectionResizeMode( + 0, QtGui.QHeaderView.ResizeToContents + ) + self.sel_table.horizontalHeader().setSectionResizeMode( + 1, QtGui.QHeaderView.Stretch + ) + self.sel_table.horizontalHeader().setSectionResizeMode( + 2, QtGui.QHeaderView.ResizeToContents + ) + self.sel_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows) + self.sel_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + self.sel_table.setMaximumHeight(150) + self.sel_table.verticalHeader().setVisible(False) + sel_layout.addWidget(self.sel_table) - self.plane_mode = QtGui.QComboBox() - for label, _ in self.PLANE_MODES: - self.plane_mode.addItem(label) - self.plane_mode.currentIndexChanged.connect(self.on_plane_mode_changed) - plane_layout.addWidget(QtGui.QLabel("Mode:")) - plane_layout.addWidget(self.plane_mode) + # Selection buttons + sel_btn_layout = QtGui.QHBoxLayout() + self.add_sel_btn = QtGui.QPushButton("Add Selected") + self.add_sel_btn.clicked.connect(self.add_current_selection) + self.remove_sel_btn = QtGui.QPushButton("Remove") + self.remove_sel_btn.clicked.connect(self.remove_selected_row) + self.clear_sel_btn = QtGui.QPushButton("Clear All") + self.clear_sel_btn.clicked.connect(self.clear_selection) + sel_btn_layout.addWidget(self.add_sel_btn) + sel_btn_layout.addWidget(self.remove_sel_btn) + sel_btn_layout.addWidget(self.clear_sel_btn) + sel_layout.addLayout(sel_btn_layout) - # Plane parameters group - self.plane_params = QtGui.QGroupBox("Parameters") - self.plane_params_layout = QtGui.QFormLayout(self.plane_params) + layout.addWidget(sel_group) - self.plane_offset_spin = QtGui.QDoubleSpinBox() - self.plane_offset_spin.setRange(-10000, 10000) - self.plane_offset_spin.setValue(10) - self.plane_offset_spin.setSuffix(" mm") + # Detected mode display + mode_group = QtGui.QGroupBox("Datum Type") + mode_layout = QtGui.QVBoxLayout(mode_group) - self.plane_angle_spin = QtGui.QDoubleSpinBox() - self.plane_angle_spin.setRange(-360, 360) - self.plane_angle_spin.setValue(45) - self.plane_angle_spin.setSuffix(" °") + self.mode_label = QtGui.QLabel("Select geometry to auto-detect mode") + self.mode_label.setStyleSheet("font-weight: bold; color: #888;") + mode_layout.addWidget(self.mode_label) - self.plane_param_spin = QtGui.QDoubleSpinBox() - self.plane_param_spin.setRange(0, 1) - self.plane_param_spin.setValue(0.5) - self.plane_param_spin.setSingleStep(0.1) + # Manual mode override + override_layout = QtGui.QHBoxLayout() + override_layout.addWidget(QtGui.QLabel("Override:")) + self.mode_combo = QtGui.QComboBox() + self.mode_combo.addItem("(Auto-detect)", None) + for display_name, mode_id, _, category in self.MODES: + self.mode_combo.addItem(f"[{category[0].upper()}] {display_name}", mode_id) + self.mode_combo.currentIndexChanged.connect(self.on_mode_override_changed) + override_layout.addWidget(self.mode_combo) + mode_layout.addLayout(override_layout) - plane_layout.addWidget(self.plane_params) + layout.addWidget(mode_group) - # Plane selection display - self.plane_selection_label = QtGui.QLabel("Selection: None") - self.plane_selection_label.setWordWrap(True) - plane_layout.addWidget(self.plane_selection_label) + # Parameters section + self.params_group = QtGui.QGroupBox("Parameters") + self.params_layout = QtGui.QFormLayout(self.params_group) - self.tabs.addTab(plane_widget, "Planes") + # Offset spinner + self.offset_spin = QtGui.QDoubleSpinBox() + self.offset_spin.setRange(-10000, 10000) + self.offset_spin.setValue(10) + self.offset_spin.setSuffix(" mm") - # Axis tab - axis_widget = QtGui.QWidget() - axis_layout = QtGui.QVBoxLayout(axis_widget) + # Angle spinner + self.angle_spin = QtGui.QDoubleSpinBox() + self.angle_spin.setRange(-360, 360) + self.angle_spin.setValue(45) + self.angle_spin.setSuffix(" °") - self.axis_mode = QtGui.QComboBox() - for label, _ in self.AXIS_MODES: - self.axis_mode.addItem(label) - self.axis_mode.currentIndexChanged.connect(self.on_axis_mode_changed) - axis_layout.addWidget(QtGui.QLabel("Mode:")) - axis_layout.addWidget(self.axis_mode) + # Parameter spinner (0-1) + self.param_spin = QtGui.QDoubleSpinBox() + self.param_spin.setRange(0, 1) + self.param_spin.setValue(0.5) + self.param_spin.setSingleStep(0.1) - self.axis_selection_label = QtGui.QLabel("Selection: None") - self.axis_selection_label.setWordWrap(True) - axis_layout.addWidget(self.axis_selection_label) + # XYZ coordinates + self.x_spin = QtGui.QDoubleSpinBox() + self.x_spin.setRange(-10000, 10000) + self.x_spin.setSuffix(" mm") + self.y_spin = QtGui.QDoubleSpinBox() + self.y_spin.setRange(-10000, 10000) + self.y_spin.setSuffix(" mm") + self.z_spin = QtGui.QDoubleSpinBox() + self.z_spin.setRange(-10000, 10000) + self.z_spin.setSuffix(" mm") - axis_layout.addStretch() - self.tabs.addTab(axis_widget, "Axes") + layout.addWidget(self.params_group) - # Point tab - point_widget = QtGui.QWidget() - point_layout = QtGui.QVBoxLayout(point_widget) - - self.point_mode = QtGui.QComboBox() - for label, _ in self.POINT_MODES: - self.point_mode.addItem(label) - self.point_mode.currentIndexChanged.connect(self.on_point_mode_changed) - point_layout.addWidget(QtGui.QLabel("Mode:")) - point_layout.addWidget(self.point_mode) - - # Point XYZ inputs - self.point_xyz_group = QtGui.QGroupBox("Coordinates") - xyz_layout = QtGui.QFormLayout(self.point_xyz_group) - - self.point_x_spin = QtGui.QDoubleSpinBox() - self.point_x_spin.setRange(-10000, 10000) - self.point_x_spin.setSuffix(" mm") - - self.point_y_spin = QtGui.QDoubleSpinBox() - self.point_y_spin.setRange(-10000, 10000) - self.point_y_spin.setSuffix(" mm") - - self.point_z_spin = QtGui.QDoubleSpinBox() - self.point_z_spin.setRange(-10000, 10000) - self.point_z_spin.setSuffix(" mm") - - xyz_layout.addRow("X:", self.point_x_spin) - xyz_layout.addRow("Y:", self.point_y_spin) - xyz_layout.addRow("Z:", self.point_z_spin) - - self.point_xyz_group.setVisible(False) - point_layout.addWidget(self.point_xyz_group) - - # Point parameter (for edge) - self.point_param_spin = QtGui.QDoubleSpinBox() - self.point_param_spin.setRange(0, 1) - self.point_param_spin.setValue(0.5) - self.point_param_spin.setSingleStep(0.1) - - self.point_selection_label = QtGui.QLabel("Selection: None") - self.point_selection_label.setWordWrap(True) - point_layout.addWidget(self.point_selection_label) - - point_layout.addStretch() - self.tabs.addTab(point_widget, "Points") - - # Common options + # Options section options_group = QtGui.QGroupBox("Options") options_layout = QtGui.QVBoxLayout(options_group) @@ -176,14 +250,8 @@ class DatumCreatorTaskPanel: layout.addWidget(options_group) - # Create button - self.create_btn = QtGui.QPushButton("Create Datum") - self.create_btn.clicked.connect(self.on_create) - layout.addWidget(self.create_btn) - - # Initialize UI state - self.on_plane_mode_changed(0) - self.tabs.currentChanged.connect(self.on_tab_changed) + # Initial state + self.update_params_ui(None) def setup_selection_observer(self): """Setup selection observer to track user selections.""" @@ -193,75 +261,260 @@ class DatumCreatorTaskPanel: self.panel = panel def addSelection(self, doc, obj, sub, pos): - self.panel.on_selection_changed() + self.panel.on_freecad_selection_changed() def removeSelection(self, doc, obj, sub): - self.panel.on_selection_changed() + self.panel.on_freecad_selection_changed() def clearSelection(self, doc): - self.panel.on_selection_changed() + self.panel.on_freecad_selection_changed() self.observer = SelectionObserver(self) Gui.Selection.addObserver(self.observer) - def on_selection_changed(self): - """Update UI when selection changes.""" + def on_freecad_selection_changed(self): + """Called when FreeCAD selection changes - update add button state.""" sel = Gui.Selection.getSelectionEx() - self.selected_items = sel + has_sel = len(sel) > 0 + self.add_sel_btn.setEnabled(has_sel) - # Build selection description - desc = [] + def add_current_selection(self): + """Add current FreeCAD selection to the selection table.""" + sel = Gui.Selection.getSelectionEx() for s in sel: + obj = s.Object if s.SubElementNames: - for sub in s.SubElementNames: - desc.append(f"{s.ObjectName}.{sub}") + for i, sub in enumerate(s.SubElementNames): + # Get the shape if available + shape = None + if i < len(s.SubObjects): + shape = s.SubObjects[i] + item = SelectionItem(obj, sub, shape) + self._add_selection_item(item) else: - desc.append(s.ObjectName) + # Object selected without sub-element + item = SelectionItem(obj, "", None) + self._add_selection_item(item) - text = ", ".join(desc) if desc else "None" + self.refresh_selection_table() + self.update_mode_from_selection() - # Update appropriate label - tab = self.tabs.currentIndex() - if tab == 0: - self.plane_selection_label.setText(f"Selection: {text}") - elif tab == 1: - self.axis_selection_label.setText(f"Selection: {text}") - elif tab == 2: - self.point_selection_label.setText(f"Selection: {text}") + def _add_selection_item(self, item): + """Add item to selection list if not already present.""" + # Check for duplicates + for existing in self.selection_list: + if existing.obj == item.obj and existing.subname == item.subname: + return # Already in list + self.selection_list.append(item) - def on_tab_changed(self, index): - self.on_selection_changed() + def remove_selected_row(self): + """Remove selected row from selection table.""" + rows = self.sel_table.selectionModel().selectedRows() + if rows: + # Remove in reverse order to maintain indices + for row in sorted([r.row() for r in rows], reverse=True): + if row < len(self.selection_list): + del self.selection_list[row] + self.refresh_selection_table() + self.update_mode_from_selection() - def on_plane_mode_changed(self, index): - """Update plane parameter UI based on mode.""" + def clear_selection(self): + """Clear all items from selection table.""" + self.selection_list.clear() + self.refresh_selection_table() + self.update_mode_from_selection() + + def refresh_selection_table(self): + """Refresh the selection table display.""" + self.sel_table.setRowCount(len(self.selection_list)) + for i, item in enumerate(self.selection_list): + # Type icon + type_item = QtGui.QTableWidgetItem(item.type_icon) + type_item.setTextAlignment(QtCore.Qt.AlignCenter) + type_item.setToolTip(item.geo_type) + self.sel_table.setItem(i, 0, type_item) + + # Element name + name_item = QtGui.QTableWidgetItem(item.display_name) + self.sel_table.setItem(i, 1, name_item) + + # Remove button + remove_btn = QtGui.QPushButton("✕") + remove_btn.setMaximumWidth(30) + remove_btn.clicked.connect(lambda checked, row=i: self._remove_row(row)) + self.sel_table.setCellWidget(i, 2, remove_btn) + + def _remove_row(self, row): + """Remove a specific row.""" + if row < len(self.selection_list): + del self.selection_list[row] + self.refresh_selection_table() + self.update_mode_from_selection() + + def get_selection_types(self): + """Get tuple of geometry types in current selection.""" + return tuple(item.geo_type for item in self.selection_list) + + def update_mode_from_selection(self): + """Auto-detect the best mode based on current selection.""" + if self.mode_combo.currentIndex() > 0: + # Manual override is active + mode_id = self.mode_combo.currentData() + self._set_detected_mode(mode_id) + return + + sel_types = self.get_selection_types() + + if not sel_types: + self.mode_label.setText("Select geometry to auto-detect mode") + self.mode_label.setStyleSheet("font-weight: bold; color: #888;") + self.update_params_ui(None) + return + + # Find matching modes + best_match = None + best_score = -1 + + for display_name, mode_id, required_types, category in self.MODES: + if not required_types: + continue # Skip modes with no requirements (like XYZ point) + + score = self._match_score(sel_types, required_types) + if score > best_score: + best_score = score + best_match = (display_name, mode_id, category) + + if best_match and best_score > 0: + display_name, mode_id, category = best_match + cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"} + color = cat_colors.get(category, "#cdd6f4") + self.mode_label.setText(f"{display_name}") + self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};") + self.update_params_ui(mode_id) + else: + self.mode_label.setText("No matching mode for selection") + self.mode_label.setStyleSheet("font-weight: bold; color: #f38ba8;") + self.update_params_ui(None) + + def _match_score(self, sel_types, required_types): + """ + Calculate how well selection matches required types. + Returns score >= 0, higher is better. 0 means no match. + """ + if len(sel_types) < len(required_types): + return 0 # Not enough items + + # Check if we can satisfy all requirements + sel_list = list(sel_types) + matched = 0 + for req in required_types: + # Try to find a matching type in selection + found = False + for i, sel in enumerate(sel_list): + if self._type_matches(sel, req): + sel_list.pop(i) + matched += 1 + found = True + break + if not found: + return 0 # Can't satisfy this requirement + + # Score based on how exact the match is + # Exact match (same count) scores higher + if len(sel_types) == len(required_types): + return 100 + matched + else: + return matched + + def _type_matches(self, sel_type, req_type): + """Check if a selected type matches a required type.""" + if sel_type == req_type: + return True + # Face can match cylinder (cylinder is a special face) + if req_type == "face" and sel_type in ("face", "cylinder"): + return True + # Edge can match circle (circle is a special edge) + if req_type == "edge" and sel_type in ("edge", "circle"): + return True + return False + + def on_mode_override_changed(self, index): + """Handle manual mode override selection.""" + if index == 0: + # Auto-detect + self.update_mode_from_selection() + else: + mode_id = self.mode_combo.currentData() + self._set_detected_mode(mode_id) + + def _set_detected_mode(self, mode_id): + """Set the mode and update UI.""" + for display_name, mid, _, category in self.MODES: + if mid == mode_id: + cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"} + color = cat_colors.get(category, "#cdd6f4") + self.mode_label.setText(f"{display_name}") + self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};") + self.update_params_ui(mode_id) + return + + def update_params_ui(self, mode_id): + """Update parameters UI based on mode.""" # Clear existing params - while self.plane_params_layout.rowCount() > 0: - self.plane_params_layout.removeRow(0) + while self.params_layout.rowCount() > 0: + self.params_layout.removeRow(0) - mode = self.PLANE_MODES[index][1] + if mode_id is None: + self.params_group.setVisible(False) + return - if mode == "offset_face": - self.plane_params_layout.addRow("Offset:", self.plane_offset_spin) - elif mode == "angled": - self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) - elif mode == "normal_edge": - self.plane_params_layout.addRow("Position (0-1):", self.plane_param_spin) - elif mode == "tangent_cyl": - self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) + self.params_group.setVisible(True) - def on_axis_mode_changed(self, index): - pass # Axes don't have extra parameters currently + if mode_id in ("offset_face", "offset_plane"): + self.params_layout.addRow("Offset:", self.offset_spin) + elif mode_id == "angled": + self.params_layout.addRow("Angle:", self.angle_spin) + elif mode_id == "normal_edge": + self.params_layout.addRow("Position (0-1):", self.param_spin) + elif mode_id == "tangent_cyl": + self.params_layout.addRow("Angle:", self.angle_spin) + elif mode_id == "point_xyz": + self.params_layout.addRow("X:", self.x_spin) + self.params_layout.addRow("Y:", self.y_spin) + self.params_layout.addRow("Z:", self.z_spin) + elif mode_id == "point_edge": + self.params_layout.addRow("Position (0-1):", self.param_spin) + else: + # No parameters needed + self.params_group.setVisible(False) - def on_point_mode_changed(self, index): - mode = self.POINT_MODES[index][1] - self.point_xyz_group.setVisible(mode == "point_xyz") + def get_current_mode(self): + """Get the currently active mode ID.""" + if self.mode_combo.currentIndex() > 0: + return self.mode_combo.currentData() + + # Auto-detected mode + sel_types = self.get_selection_types() + if not sel_types: + return None + + best_match = None + best_score = -1 + for _, mode_id, required_types, _ in self.MODES: + if not required_types: + continue + score = self._match_score(sel_types, required_types) + if score > best_score: + best_score = score + best_match = mode_id + + return best_match if best_score > 0 else None def get_body(self): """Get active body if checkbox is checked.""" if not self.use_body_cb.isChecked(): return None - # Try to get active body if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument: active_view = Gui.ActiveDocument.ActiveView if hasattr(active_view, "getActiveObject"): @@ -269,7 +522,6 @@ class DatumCreatorTaskPanel: if body: return body - # Fallback: find a body in document doc = App.ActiveDocument for obj in doc.Objects: if obj.TypeId == "PartDesign::Body": @@ -283,131 +535,79 @@ class DatumCreatorTaskPanel: return self.custom_name_edit.text() return None - def get_selected_geometry(self, geo_type): - """Extract geometry of specified type from selection. - - Returns: - List of tuples: (shape, source_object, subname) - """ + def get_items_by_type(self, *geo_types): + """Get selection items matching given geometry types.""" results = [] - for sel in self.selected_items: - obj = sel.Object - if not hasattr(obj, "Shape"): - continue - - if sel.SubElementNames: - for sub in sel.SubElementNames: - # Only process valid sub-element names (Face#, Edge#, Vertex#) - # Skip invalid names like "Plane" from datum objects - if not ( - sub.startswith("Face") - or sub.startswith("Edge") - or sub.startswith("Vertex") - ): - # Try to use the whole object's shape instead - shape = obj.Shape - if geo_type == "face" and shape.Faces: - # Use the first face of the object (e.g., datum plane) - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) - continue - - try: - shape = obj.Shape.getElement(sub) - if geo_type == "face" and isinstance(shape, Part.Face): - results.append((shape, obj, sub)) - elif geo_type == "edge" and isinstance(shape, Part.Edge): - results.append((shape, obj, sub)) - elif geo_type == "vertex" and isinstance(shape, Part.Vertex): - results.append((shape, obj, sub)) - except Exception: - # If getElement fails, try to use the whole shape - shape = obj.Shape - if geo_type == "face" and shape.Faces: - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) - else: - # No sub-element selected, use the whole object's shape - shape = obj.Shape - if geo_type == "face" and shape.Faces: - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) + for item in self.selection_list: + if item.geo_type in geo_types: + results.append(item) return results - def on_create(self): + def create_datum(self): """Create the datum based on current settings.""" from ztools.datums import core - tab = self.tabs.currentIndex() + mode = self.get_current_mode() + if mode is None: + raise ValueError("No valid mode detected. Add geometry to the selection.") + body = self.get_body() name = self.get_name() link_ss = self.link_spreadsheet_cb.isChecked() - try: - if tab == 0: # Planes - self.create_plane(core, body, name, link_ss) - elif tab == 1: # Axes - self.create_axis(core, body, name) - elif tab == 2: # Points - self.create_point(core, body, name, link_ss) - - App.Console.PrintMessage("Datum created successfully\n") - - except Exception as e: - App.Console.PrintError(f"Failed to create datum: {e}\n") - QtGui.QMessageBox.warning(self.form, "Error", str(e)) - - def create_plane(self, core, body, name, link_ss): - mode = self.PLANE_MODES[self.plane_mode.currentIndex()][1] - + # Planes if mode == "offset_face": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("face", "cylinder") + if not items: raise ValueError("Select a face") - face, src_obj, src_sub = faces[0] + item = items[0] + face = item.shape if item.shape else item.obj.Shape.Faces[0] core.plane_offset_from_face( face, - self.plane_offset_spin.value(), + self.offset_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + source_object=item.obj, + source_subname=item.subname, + ) + + elif mode == "offset_plane": + items = self.get_items_by_type("plane") + if not items: + raise ValueError("Select a datum plane") + core.plane_offset_from_plane( + items[0].obj, + self.offset_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_object=src_obj, - source_subname=src_sub, ) elif mode == "midplane": - faces = self.get_selected_geometry("face") - if len(faces) < 2: + items = self.get_items_by_type("face", "cylinder") + if len(items) < 2: raise ValueError("Select 2 faces") - face1, src_obj1, src_sub1 = faces[0] - face2, src_obj2, src_sub2 = faces[1] + face1 = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] + face2 = items[1].shape if items[1].shape else items[1].obj.Shape.Faces[0] core.plane_midplane( face1, face2, name=name, body=body, - source_object1=src_obj1, - source_subname1=src_sub1, - source_object2=src_obj2, - source_subname2=src_sub2, + source_object1=items[0].obj, + source_subname1=items[0].subname, + source_object2=items[1].obj, + source_subname2=items[1].subname, ) elif mode == "3_points": - verts = self.get_selected_geometry("vertex") - if len(verts) < 3: + items = self.get_items_by_type("vertex") + if len(items) < 3: raise ValueError("Select 3 vertices") - v1, src_obj1, src_sub1 = verts[0] - v2, src_obj2, src_sub2 = verts[1] - v3, src_obj3, src_sub3 = verts[2] + v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] + v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0] + v3 = items[2].shape if items[2].shape else items[2].obj.Shape.Vertexes[0] core.plane_from_3_points( v1.Point, v2.Point, @@ -415,185 +615,197 @@ class DatumCreatorTaskPanel: name=name, body=body, source_refs=[ - (src_obj1, src_sub1), - (src_obj2, src_sub2), - (src_obj3, src_sub3), + (items[0].obj, items[0].subname), + (items[1].obj, items[1].subname), + (items[2].obj, items[2].subname), ], ) elif mode == "normal_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge", "circle") + if not items: raise ValueError("Select an edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.plane_normal_to_edge( edge, - parameter=self.plane_param_spin.value(), + parameter=self.param_spin.value(), name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "angled": - faces = self.get_selected_geometry("face") - edges = self.get_selected_geometry("edge") + faces = self.get_items_by_type("face", "cylinder") + edges = self.get_items_by_type("edge", "circle") if not faces or not edges: raise ValueError("Select a face and an edge") - face, face_obj, face_sub = faces[0] - edge, edge_obj, edge_sub = edges[0] + face = faces[0].shape if faces[0].shape else faces[0].obj.Shape.Faces[0] + edge = edges[0].shape if edges[0].shape else edges[0].obj.Shape.Edges[0] core.plane_angled( face, edge, - self.plane_angle_spin.value(), + self.angle_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_face_obj=face_obj, - source_face_sub=face_sub, - source_edge_obj=edge_obj, - source_edge_sub=edge_sub, + source_face_obj=faces[0].obj, + source_face_sub=faces[0].subname, + source_edge_obj=edges[0].obj, + source_edge_sub=edges[0].subname, ) elif mode == "tangent_cyl": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("cylinder") + if not items: raise ValueError("Select a cylindrical face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.plane_tangent_to_cylinder( face, - angle=self.plane_angle_spin.value(), + angle=self.angle_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) - def create_axis(self, core, body, name): - mode = self.AXIS_MODES[self.axis_mode.currentIndex()][1] - - if mode == "axis_2pt": - verts = self.get_selected_geometry("vertex") - if len(verts) < 2: + # Axes + elif mode == "axis_2pt": + items = self.get_items_by_type("vertex") + if len(items) < 2: raise ValueError("Select 2 vertices") - v1, obj1, sub1 = verts[0] - v2, obj2, sub2 = verts[1] + v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] + v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0] core.axis_from_2_points( v1.Point, v2.Point, name=name, body=body, - source_refs=[(obj1, sub1), (obj2, sub2)], + source_refs=[ + (items[0].obj, items[0].subname), + (items[1].obj, items[1].subname), + ], ) elif mode == "axis_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge") + if not items: raise ValueError("Select a linear edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.axis_from_edge( edge, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "axis_cyl": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("cylinder") + if not items: raise ValueError("Select a cylindrical face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.axis_cylinder_center( face, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "axis_intersect": - # Need 2 plane objects selected - if len(self.selected_items) < 2: + items = self.get_items_by_type("plane") + if len(items) < 2: raise ValueError("Select 2 datum planes") core.axis_intersection_planes( - self.selected_items[0].Object, - self.selected_items[1].Object, + items[0].obj, + items[1].obj, name=name, body=body, + source_object1=items[0].obj, + source_subname1="", + source_object2=items[1].obj, + source_subname2="", ) - def create_point(self, core, body, name, link_ss): - mode = self.POINT_MODES[self.point_mode.currentIndex()][1] - - if mode == "point_vertex": - verts = self.get_selected_geometry("vertex") - if not verts: + # Points + elif mode == "point_vertex": + items = self.get_items_by_type("vertex") + if not items: raise ValueError("Select a vertex") - vert, src_obj, src_sub = verts[0] + vert = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] core.point_at_vertex( vert, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_xyz": core.point_at_coordinates( - self.point_x_spin.value(), - self.point_y_spin.value(), - self.point_z_spin.value(), + self.x_spin.value(), + self.y_spin.value(), + self.z_spin.value(), name=name, body=body, link_spreadsheet=link_ss, ) elif mode == "point_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge", "circle") + if not items: raise ValueError("Select an edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.point_on_edge( edge, - parameter=self.point_param_spin.value(), + parameter=self.param_spin.value(), name=name, - source_object=src_obj, - source_subname=src_sub, body=body, link_spreadsheet=link_ss, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_face": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("face", "cylinder") + if not items: raise ValueError("Select a face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.point_center_of_face( face, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_circle": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("circle") + if not items: raise ValueError("Select a circular edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.point_center_of_circle( edge, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) + else: + raise ValueError(f"Unknown mode: {mode}") + def accept(self): - """Called when OK is clicked.""" + """Called when OK is clicked. Creates the datum.""" Gui.Selection.removeObserver(self.observer) + try: + self.create_datum() + App.Console.PrintMessage("ZTools: Datum created successfully\n") + except Exception as e: + App.Console.PrintError(f"ZTools: Failed to create datum: {e}\n") + QtGui.QMessageBox.warning(self.form, "Error", str(e)) return True def reject(self): @@ -602,7 +814,7 @@ class DatumCreatorTaskPanel: return True def getStandardButtons(self): - return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel + return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel) class ZTools_DatumCreator: @@ -639,7 +851,7 @@ class ZTools_DatumManager: def Activated(self): # TODO: Implement datum manager panel - App.Console.PrintMessage("Datum Manager - Coming soon\n") + App.Console.PrintMessage("ZTools: Datum Manager - Coming soon\n") def IsActive(self): return App.ActiveDocument is not None diff --git a/ztools/ztools/commands/datum_viewprovider.py b/ztools/ztools/commands/datum_viewprovider.py new file mode 100644 index 0000000..913ed80 --- /dev/null +++ b/ztools/ztools/commands/datum_viewprovider.py @@ -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) diff --git a/ztools/ztools/datums/__init__.py b/ztools/ztools/datums/__init__.py index fcbfac7..e9755d3 100644 --- a/ztools/ztools/datums/__init__.py +++ b/ztools/ztools/datums/__init__.py @@ -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", diff --git a/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc index 8461c8a..dfeaa09 100644 Binary files a/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc and b/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc differ diff --git a/ztools/ztools/datums/__pycache__/core.cpython-312.pyc b/ztools/ztools/datums/__pycache__/core.cpython-312.pyc index f818041..cabe2e8 100644 Binary files a/ztools/ztools/datums/__pycache__/core.cpython-312.pyc and b/ztools/ztools/datums/__pycache__/core.cpython-312.pyc differ diff --git a/ztools/ztools/datums/core.py b/ztools/ztools/datums/core.py index dd42814..dcaba08 100644 --- a/ztools/ztools/datums/core.py +++ b/ztools/ztools/datums/core.py @@ -1,8 +1,9 @@ # ztools/datums/core.py -# Core datum creation functions +# Core datum creation functions with custom ZTools attachment system +import json import math -from typing import List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union import FreeCAD as App import Part @@ -10,6 +11,10 @@ import Part # Metadata property prefix ZTOOLS_META_PREFIX = "ZTools_" +# ZTools default colors (Catppuccin Mocha palette) +ZTOOLS_PLANE_COLOR = (0.796, 0.651, 0.969) # Mauve #cba6f7 as RGB 0-1 +ZTOOLS_PLANE_TRANSPARENCY = 70 # 70% transparent + def _get_next_index(doc: App.Document, prefix: str) -> int: """Get next available index for auto-naming.""" @@ -26,47 +31,198 @@ def _get_next_index(doc: App.Document, prefix: str) -> int: return max(indices, default=0) + 1 -def _setup_datum_attachment(obj, support, map_mode: str, offset: App.Placement = None): +# ============================================================================= +# ZTOOLS CUSTOM ATTACHMENT SYSTEM +# ============================================================================= +# +# FreeCAD's vanilla attachment system has reliability issues. ZTools uses its +# own attachment approach: +# +# 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 +# +# This gives us full control over datum positioning while maintaining +# the ability to update datums when source geometry changes (future feature). +# ============================================================================= + + +def _style_ztools_plane(obj): """ - Set up a PartDesign datum object with proper attachment. + Apply ZTools default styling to a datum plane. + Makes the plane transparent purple (Catppuccin Mocha mauve). + """ + if hasattr(obj, "ViewObject") and obj.ViewObject is not None: + vo = obj.ViewObject + # Set shape color to mauve purple + if hasattr(vo, "ShapeColor"): + vo.ShapeColor = ZTOOLS_PLANE_COLOR + # Set transparency (0 = opaque, 100 = fully transparent) + if hasattr(vo, "Transparency"): + vo.Transparency = ZTOOLS_PLANE_TRANSPARENCY + # Also set line color for edges + if hasattr(vo, "LineColor"): + vo.LineColor = ZTOOLS_PLANE_COLOR + + +def _hide_attachment_properties(obj): + """ + Hide FreeCAD's vanilla attachment properties from the property editor. + This prevents user confusion since ZTools uses its own attachment system. + + Editor modes: + 0 = Normal (visible, editable) + 1 = Read-only + 2 = Hidden + """ + if not hasattr(obj, "ViewObject") or obj.ViewObject is None: + return + + vo = obj.ViewObject + + # Hide attachment-related properties + attachment_props = [ + "MapMode", + "MapPathParameter", + "MapReversed", + "AttachmentOffset", + "Support", + ] + + for prop in attachment_props: + try: + if hasattr(obj, prop): + vo.setEditorMode(prop, 2) # 2 = Hidden + except Exception: + pass # Property might not exist on all datum types + + +def _setup_ztools_viewprovider(obj): + """ + Set up a custom ViewProvider proxy for ZTools datums. + This enables custom double-click behavior to open ZTools editor. + """ + if not hasattr(obj, "ViewObject") or obj.ViewObject is None: + return + + vo = obj.ViewObject + + # Only set up if not already a ZTools ViewProvider + if hasattr(vo, "Proxy") and vo.Proxy is not None: + if hasattr(vo.Proxy, "_is_ztools"): + return + + # Import here to avoid circular imports + from ztools.commands.datum_viewprovider import ZToolsDatumViewProvider + + ZToolsDatumViewProvider(vo) + + +def _setup_ztools_datum( + obj, + placement: App.Placement, + datum_type: str, + params: Dict[str, Any], + source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None, + is_plane: bool = False, +): + """ + Set up a ZTools datum with custom attachment system. Args: obj: The datum object (PartDesign::Plane, Line, or Point) - support: Attachment support - list of tuples [(object, 'SubElement'), ...] - map_mode: Attachment mode string (e.g., 'FlatFace', 'ObjectXY', 'Translate') - offset: Optional offset from the attachment point - """ - if hasattr(obj, "Support"): - obj.Support = support - - if hasattr(obj, "MapMode"): - obj.MapMode = map_mode - - if offset and hasattr(obj, "MapPathParameter"): - obj.AttachmentOffset = offset - - -def _setup_datum_placement(obj, placement: App.Placement): - """ - Set up a PartDesign datum object with placement only (no attachment). - Use this when we can't determine a proper attachment reference. - - For PartDesign datums (Plane, Line, Point), we need to either: - 1. Set up proper attachment (Support + MapMode), or - 2. Explicitly set MapMode to 'Deactivated' to indicate we're using placement only - - This function sets MapMode to 'Deactivated' and applies the placement. + placement: Calculated placement for the datum + 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 """ + # Disable FreeCAD's attachment system if hasattr(obj, "MapMode"): obj.MapMode = "Deactivated" - - # Clear any attachment support if hasattr(obj, "Support"): obj.Support = None - # Apply placement + # Apply calculated placement obj.Placement = placement + # Store ZTools metadata + _add_ztools_metadata(obj, datum_type, params, source_refs) + + # Apply plane styling if this is a plane + if is_plane: + _style_ztools_plane(obj) + + # Hide vanilla attachment properties from property editor + _hide_attachment_properties(obj) + + # Set up custom ViewProvider for edit behavior + _setup_ztools_viewprovider(obj) + + +def _add_ztools_metadata( + obj, + datum_type: str, + params: Dict[str, Any], + source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None, +): + """Store ZTools metadata in object properties.""" + # Add ZTools_Type property + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Type", + "ZTools", + "Datum creation method", + ) + + # Add ZTools_Params property + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Params", + "ZTools", + "Creation parameters (JSON)", + ) + + # Add ZTools_SourceRefs property for storing references + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}SourceRefs", + "ZTools", + "Source geometry references (JSON)", + ) + + setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type) + + # Convert vectors/placements to serializable format + serializable_params = {} + for k, v in params.items(): + if isinstance(v, App.Vector): + serializable_params[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z} + elif isinstance(v, App.Placement): + serializable_params[k] = { + "_type": "Placement", + "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z}, + "rotation": list(v.Rotation.Q), + } + else: + serializable_params[k] = v + + setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params)) + + # Store source references + if source_refs: + ref_data = [] + for src_obj, subname in source_refs: + if src_obj: + ref_data.append({"object": src_obj.Name, "subname": subname or ""}) + setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", json.dumps(ref_data)) + else: + setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", "[]") + def _get_subname_from_shape(parent_obj, shape): """ @@ -127,44 +283,6 @@ def _find_shape_owner(doc, shape): return None, None -def _add_ztools_metadata(obj, datum_type: str, params: dict): - """Store ztools metadata in object properties.""" - # Add custom properties for ztools tracking - if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"): - obj.addProperty( - "App::PropertyString", - f"{ZTOOLS_META_PREFIX}Type", - "ztools", - "Datum creation method", - ) - if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"): - obj.addProperty( - "App::PropertyString", - f"{ZTOOLS_META_PREFIX}Params", - "ztools", - "Creation parameters (JSON)", - ) - - setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type) - - import json - - # Convert vectors/placements to serializable format - serializable_params = {} - for k, v in params.items(): - if isinstance(v, App.Vector): - serializable_params[k] = {"x": v.x, "y": v.y, "z": v.z} - elif isinstance(v, App.Placement): - serializable_params[k] = { - "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z}, - "rotation": list(v.Rotation.Q), - } - else: - serializable_params[k] = v - - setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params)) - - def _link_to_spreadsheet( doc: App.Document, obj, param_name: str, value: float, alias: str ): @@ -211,8 +329,8 @@ def plane_offset_from_face( name: Optional custom name body: Optional body to add plane to (None = document level) link_spreadsheet: Create spreadsheet alias for distance - source_object: The object containing the face (for attachment) - source_subname: The sub-element name like 'Face1' (for attachment) + source_object: The object containing the face (for reference tracking) + source_subname: The sub-element name like 'Face1' (for reference tracking) Returns: Created datum plane object @@ -236,46 +354,123 @@ def plane_offset_from_face( idx = _get_next_index(doc, "ZPlane_Offset") name = f"ZPlane_Offset_{idx:03d}" - # Create plane + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(base, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Try to use proper attachment if we have the source reference - if source_object and source_subname: - # Use FlatFace mode with offset - _setup_datum_attachment( - plane, [(source_object, source_subname)], "FlatFace" - ) - # Set the offset along the normal - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, distance), App.Rotation() - ) - else: - # Fallback to placement-only mode - _setup_datum_placement(plane, App.Placement(base, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - # Center the plane visually (for Part::Plane) - plane.Placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) + # Adjust placement for Part::Plane (centered differently) + placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) - # Store metadata - _add_ztools_metadata( + # 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, ) # Spreadsheet link - if link_spreadsheet and not body: - # Part::Plane doesn't have Offset property, would need expression on Placement - pass - elif link_spreadsheet and body: + if link_spreadsheet and body: alias = f"{name}_offset" - _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias) + _link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias) + + doc.recompute() + return plane + + +def plane_offset_from_plane( + source_plane: App.DocumentObject, + distance: float, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, +) -> App.DocumentObject: + """ + Create datum plane offset from another datum plane. + + Args: + source_plane: Source datum plane object (PartDesign::Plane or Part::Plane) + distance: Offset distance in mm (positive = along normal) + name: Optional custom name + body: Optional body to add plane to (None = document level) + link_spreadsheet: Create spreadsheet alias for distance + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + # Get the plane's shape and extract normal/position + if not hasattr(source_plane, "Shape"): + raise ValueError("Source must be a plane object with a Shape") + + shape = source_plane.Shape + if not shape.Faces: + raise ValueError("Source plane has no faces") + + face = shape.Faces[0] + if not face.Surface.isPlanar(): + raise ValueError("Source must be a planar object") + + # Get normal from the plane's face + uv = face.Surface.parameter(face.CenterOfMass) + normal = face.normalAt(uv[0], uv[1]) + + # Get the plane's center position + center = face.CenterOfMass + + # Calculate offset position + base = center + normal * distance + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Offset") + name = f"ZPlane_Offset_{idx:03d}" + + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(base, rot) + + # Create plane + if body: + plane = body.newObject("PartDesign::Plane", name) + 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, + ) + + # Spreadsheet link + if link_spreadsheet and body: + alias = f"{name}_offset" + _link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias) doc.recompute() return plane @@ -331,22 +526,33 @@ def plane_midplane( idx = _get_next_index(doc, "ZPlane_Mid") name = f"ZPlane_Mid_{idx:03d}" - # Create plane + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), n1) + placement = App.Placement(mid, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - # No direct "midplane" attachment mode exists in FreeCAD - # Use placement-only mode with calculated midpoint - _setup_datum_placement(plane, App.Placement(mid, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot) + placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( - plane, "midplane", {"center1": c1, "center2": c2, "midpoint": mid} + # 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, ) doc.recompute() @@ -391,28 +597,27 @@ def plane_from_3_points( idx = _get_next_index(doc, "ZPlane_3Pt") name = f"ZPlane_3Pt_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(center, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use ThreePointPlane attachment if we have references - if source_refs and len(source_refs) >= 3: - _setup_datum_attachment(plane, source_refs[:3], "ThreePointPlane") - else: - _setup_datum_placement(plane, App.Placement(center, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( - center - rot.multVec(App.Vector(25, 25, 0)), rot - ) + placement = App.Placement(center - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # 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, ) doc.recompute() @@ -454,30 +659,26 @@ def plane_normal_to_edge( # Plane normal = edge tangent rot = App.Rotation(App.Vector(0, 0, 1), tangent) + placement = App.Placement(point, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use NormalToPath attachment if we have a reference - if source_object and source_subname: - _setup_datum_attachment( - plane, [(source_object, source_subname)], "NormalToPath" - ) - # Set parameter along path - if hasattr(plane, "MapPathParameter"): - plane.MapPathParameter = parameter - else: - _setup_datum_placement(plane, App.Placement(point, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot) + placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # 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, ) doc.recompute() @@ -540,32 +741,29 @@ def plane_angled( idx = _get_next_index(doc, "ZPlane_Angled") name = f"ZPlane_Angled_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), new_normal) + placement = App.Placement(edge_mid, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use FlatFace on the reference face with rotation offset - if source_face_obj and source_face_sub: - _setup_datum_attachment( - plane, [(source_face_obj, source_face_sub)], "FlatFace" - ) - # Apply rotation as attachment offset - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, 0), App.Rotation(App.Vector(1, 0, 0), angle) - ) - else: - _setup_datum_placement(plane, App.Placement(edge_mid, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( - edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot - ) + placement = App.Placement(edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # 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, @@ -573,13 +771,14 @@ def plane_angled( "original_normal": face_normal, "new_normal": new_normal, }, + source_refs if source_refs else None, + is_plane=True, ) if link_spreadsheet and body: alias = f"{name}_angle" - _link_to_spreadsheet( - doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias - ) + # For ZTools system, we'd need custom expression handling + # _link_to_spreadsheet(doc, plane, "...", angle, alias) doc.recompute() return plane @@ -639,40 +838,32 @@ def plane_tangent_to_cylinder( idx = _get_next_index(doc, "ZPlane_Tangent") name = f"ZPlane_Tangent_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), plane_normal) + placement = App.Placement(tangent_point, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use Tangent attachment mode for cylindrical face - if source_object and source_subname: - _setup_datum_attachment(plane, [(source_object, source_subname)], "Tangent") - # Set rotation angle via attachment offset - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, 0), App.Rotation(App.Vector(0, 0, 1), angle) - ) - else: - _setup_datum_placement(plane, App.Placement(tangent_point, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( + placement = App.Placement( tangent_point - rot.multVec(App.Vector(25, 25, 0)), rot ) - _add_ztools_metadata( + # 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, ) - if link_spreadsheet and body: - alias = f"{name}_angle" - _link_to_spreadsheet( - doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias - ) - doc.recompute() return plane @@ -715,22 +906,25 @@ def axis_from_2_points( idx = _get_next_index(doc, "ZAxis_2Pt") name = f"ZAxis_2Pt_{idx:03d}" + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - - # Use TwoPointLine attachment if we have references - if source_refs and len(source_refs) >= 2: - _setup_datum_attachment(axis, source_refs[:2], "TwoPointLine") - else: - rot = App.Rotation(App.Vector(0, 0, 1), direction) - _setup_datum_placement(axis, App.Placement(midpoint, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( - axis, "2_points", {"p1": p1, "p2": p2, "direction": direction, "length": length} + # Set up with ZTools attachment system + _setup_ztools_datum( + axis, + placement, + "2_points", + {"p1": p1, "p2": p2, "direction": direction, "length": length}, + source_refs, ) doc.recompute() @@ -770,22 +964,26 @@ def axis_from_edge( direction = (p2 - p1).normalize() midpoint = (p1 + p2) * 0.5 + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - - # Use ObjectXY attachment for edge - if source_object and source_subname: - _setup_datum_attachment(axis, [(source_object, source_subname)], "ObjectXY") - else: - rot = App.Rotation(App.Vector(0, 0, 1), direction) - _setup_datum_placement(axis, App.Placement(midpoint, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( - axis, "from_edge", {"p1": p1, "p2": p2, "direction": direction} + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + axis, + placement, + "from_edge", + {"p1": p1, "p2": p2, "direction": direction}, + source_refs, ) doc.recompute() @@ -806,8 +1004,8 @@ def axis_cylinder_center( face: Cylindrical face name: Optional custom name body: Optional body to add axis to - source_object: Object that owns the face (for attachment) - source_subname: Sub-element name like 'Face1' (for attachment) + source_object: Object that owns the face (for reference tracking) + source_subname: Sub-element name like 'Face1' (for reference tracking) Returns: Created datum axis object @@ -822,8 +1020,6 @@ def axis_cylinder_center( axis_dir = cyl.Axis # Get cylinder extent from face bounds - bbox = face.BoundBox - # Project to axis p1 = center + axis_dir * (-50) # Arbitrary length p2 = center + axis_dir * 50 @@ -831,24 +1027,26 @@ def axis_cylinder_center( idx = _get_next_index(doc, "ZAxis_Cyl") name = f"ZAxis_Cyl_{idx:03d}" + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), axis_dir) + placement = App.Placement(center, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - if source_object and source_subname: - # Use 'ObjectZ' to align axis with cylindrical face's axis - support = [(source_object, source_subname)] - _setup_datum_attachment(axis, support, "ObjectZ") - else: - rot = App.Rotation(App.Vector(0, 0, 1), axis_dir) - _setup_datum_placement(axis, App.Placement(center, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( axis, + placement, "cylinder_center", {"center": center, "direction": axis_dir, "radius": cyl.Radius}, + source_refs, ) doc.recompute() @@ -872,10 +1070,10 @@ def axis_intersection_planes( plane1, plane2: Two non-parallel planes name: Optional custom name body: Optional body to add axis to - source_object1: Object that owns plane1 (for attachment) - source_subname1: Sub-element name for plane1 (for attachment) - source_object2: Object that owns plane2 (for attachment) - source_subname2: Sub-element name for plane2 (for attachment) + source_object1: Object that owns plane1 (for reference tracking) + source_subname1: Sub-element name for plane1 (for reference tracking) + source_object2: Object that owns plane2 (for reference tracking) + source_subname2: Sub-element name for plane2 (for reference tracking) Returns: Created datum axis object @@ -894,32 +1092,42 @@ def axis_intersection_planes( edge = common.Edges[0] p1 = edge.valueAt(edge.FirstParameter) p2 = edge.valueAt(edge.LastParameter) + direction = (p2 - p1).normalize() + midpoint = (p1 + p2) * 0.5 if name is None: idx = _get_next_index(doc, "ZAxis_Intersect") name = f"ZAxis_Intersect_{idx:03d}" - if ( - body - and source_object1 - and source_subname1 - and source_object2 - and source_subname2 - ): - # Create axis with TwoFace attachment (intersection of two planes) - axis = body.newObject("PartDesign::Line", name) - support = [(source_object1, source_subname1), (source_object2, source_subname2)] - _setup_datum_attachment(axis, support, "TwoFace") + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) - _add_ztools_metadata( - axis, - "plane_intersection", - {"point1": p1, "point2": p2}, - ) - doc.recompute() - return axis + # Create axis + if body: + axis = body.newObject("PartDesign::Line", name) else: - return axis_from_2_points(p1, p2, name, body) + axis = doc.addObject("Part::Line", name) + axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z + axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z + + # 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( + axis, + placement, + "plane_intersection", + {"point1": p1, "point2": p2, "direction": direction}, + source_refs if source_refs else None, + ) + + doc.recompute() + return axis # ============================================================================= @@ -941,8 +1149,8 @@ def point_at_vertex( vertex: Source vertex name: Optional custom name body: Optional body to add point to - source_object: Object that owns the vertex (for attachment) - source_subname: Sub-element name like 'Vertex1' (for attachment) + source_object: Object that owns the vertex (for reference tracking) + source_subname: Sub-element name like 'Vertex1' (for reference tracking) Returns: Created datum point object @@ -954,19 +1162,25 @@ def point_at_vertex( idx = _get_next_index(doc, "ZPoint_Vtx") name = f"ZPoint_Vtx_{idx:03d}" + # Calculate placement + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - if source_object and source_subname: - # Use 'Vertex' attachment mode to attach to the vertex - support = [(source_object, source_subname)] - _setup_datum_attachment(point, support, "Vertex") - else: - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "vertex", {"position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "vertex", + {"position": pos}, + source_refs, + ) doc.recompute() return point @@ -998,16 +1212,24 @@ def point_at_coordinates( idx = _get_next_index(doc, "ZPoint_XYZ") name = f"ZPoint_XYZ_{idx:03d}" + pos = App.Vector(x, y, z) + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement( - point, App.Placement(App.Vector(x, y, z), App.Rotation()) - ) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = x, y, z - _add_ztools_metadata(point, "coordinates", {"x": x, "y": y, "z": z}) + # Set up with ZTools attachment system (no source refs for explicit coordinates) + _setup_ztools_datum( + point, + placement, + "coordinates", + {"x": x, "y": y, "z": z}, + None, + ) if link_spreadsheet and not body: _link_to_spreadsheet(doc, point, "X", x, f"{name}_X") @@ -1024,6 +1246,8 @@ def point_on_edge( name: Optional[str] = None, body: Optional[App.DocumentObject] = None, link_spreadsheet: bool = False, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point on edge at parameter. @@ -1034,6 +1258,8 @@ def point_on_edge( name: Optional custom name body: Optional body to add point to link_spreadsheet: Create spreadsheet alias for parameter + source_object: Object that owns the edge (for reference tracking) + source_subname: Sub-element name like 'Edge1' (for reference tracking) Returns: Created datum point object @@ -1047,14 +1273,24 @@ def point_on_edge( idx = _get_next_index(doc, "ZPoint_Edge") name = f"ZPoint_Edge_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "on_edge", {"parameter": parameter, "position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "on_edge", + {"parameter": parameter, "position": pos}, + source_refs, + ) doc.recompute() return point @@ -1064,6 +1300,8 @@ def point_center_of_face( face: Part.Face, name: Optional[str] = None, body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point at center of mass of face. @@ -1072,6 +1310,8 @@ def point_center_of_face( face: Source face name: Optional custom name body: Optional body to add point to + source_object: Object that owns the face (for reference tracking) + source_subname: Sub-element name like 'Face1' (for reference tracking) Returns: Created datum point object @@ -1083,14 +1323,24 @@ def point_center_of_face( idx = _get_next_index(doc, "ZPoint_FaceCenter") name = f"ZPoint_FaceCenter_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "face_center", {"position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "face_center", + {"position": pos}, + source_refs, + ) doc.recompute() return point @@ -1100,6 +1350,8 @@ def point_center_of_circle( edge: Part.Edge, name: Optional[str] = None, body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point at center of circular edge. @@ -1108,6 +1360,8 @@ def point_center_of_circle( edge: Circular edge (circle or arc) name: Optional custom name body: Optional body to add point to + source_object: Object that owns the edge (for reference tracking) + source_subname: Sub-element name like 'Edge1' (for reference tracking) Returns: Created datum point object @@ -1123,15 +1377,23 @@ def point_center_of_circle( idx = _get_next_index(doc, "ZPoint_CircleCenter") name = f"ZPoint_CircleCenter_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata( - point, "circle_center", {"position": pos, "radius": edge.Curve.Radius} + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "circle_center", + {"position": pos, "radius": edge.Curve.Radius}, + source_refs, ) doc.recompute()