Merge pull request 'chore(datums): convert datums addon to submodule' (#375) from chore/datums-submodule into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #375
4
.gitmodules
vendored
@@ -22,3 +22,7 @@
|
||||
path = mods/gears
|
||||
url = https://git.kindred-systems.com/kindred/gears.git
|
||||
branch = main
|
||||
[submodule "mods/datums"]
|
||||
path = mods/datums
|
||||
url = https://git.kindred-systems.com/kindred/datums.git
|
||||
branch = main
|
||||
|
||||
1
mods/datums
Submodule
@@ -1 +0,0 @@
|
||||
"""Datums addon — console initialization (no-op)."""
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Datums addon — GUI initialization.
|
||||
|
||||
Registers the unified datum creator command and injects it into
|
||||
PartDesign editing contexts via the Kindred SDK.
|
||||
"""
|
||||
|
||||
|
||||
def _register_datum_commands():
|
||||
"""Register datum creator command and inject into PartDesign contexts."""
|
||||
try:
|
||||
from datums.command import register_commands
|
||||
|
||||
register_commands()
|
||||
except Exception as e:
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintWarning(f"kindred-datums: command registration failed: {e}\n")
|
||||
|
||||
try:
|
||||
from kindred_sdk import inject_commands
|
||||
|
||||
inject_commands(
|
||||
"partdesign.body",
|
||||
"Part Design Helper Features",
|
||||
["Create_DatumCreator"],
|
||||
)
|
||||
inject_commands(
|
||||
"partdesign.feature",
|
||||
"Part Design Helper Features",
|
||||
["Create_DatumCreator"],
|
||||
)
|
||||
except Exception as e:
|
||||
import FreeCAD
|
||||
|
||||
FreeCAD.Console.PrintWarning(f"kindred-datums: context injection failed: {e}\n")
|
||||
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(500, _register_datum_commands)
|
||||
@@ -1 +0,0 @@
|
||||
"""Unified datum creator for Kindred Create."""
|
||||
@@ -1,145 +0,0 @@
|
||||
"""FreeCAD command registration for the datum creator addon.
|
||||
|
||||
Registers ``Create_DatumCreator`` (opens creation panel) and
|
||||
``Create_DatumEdit`` (opens edit panel for existing datums).
|
||||
|
||||
Also installs a double-click hook so datums with ``Datums_Type``
|
||||
metadata open the edit panel instead of the stock attachment dialog.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
|
||||
from datums.core import META_PREFIX
|
||||
|
||||
_ICON_DIR = os.path.join(os.path.dirname(__file__), "resources", "icons")
|
||||
|
||||
|
||||
def _icon(name):
|
||||
"""Resolve icon path, falling back to a built-in FreeCAD icon."""
|
||||
path = os.path.join(_ICON_DIR, f"{name}.svg")
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
# Fallback to stock FreeCAD icons
|
||||
fallbacks = {
|
||||
"datum_creator": "PartDesign_Plane.svg",
|
||||
"datum_plane": "PartDesign_Plane.svg",
|
||||
"datum_point": "PartDesign_Point.svg",
|
||||
}
|
||||
return fallbacks.get(name, "PartDesign_Plane.svg")
|
||||
|
||||
|
||||
def _open_creator():
|
||||
"""Open the datum creator task panel."""
|
||||
from datums.panel import DatumCreatorTaskPanel
|
||||
|
||||
panel = DatumCreatorTaskPanel()
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
|
||||
def _open_editor(datum_obj):
|
||||
"""Open the datum edit task panel for an existing datum."""
|
||||
from datums.edit_panel import DatumEditTaskPanel
|
||||
|
||||
panel = DatumEditTaskPanel(datum_obj)
|
||||
Gui.Control.showDialog(panel)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Double-click hook
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DatumEditObserver:
|
||||
"""Selection observer that intercepts setEdit on datums with Datums_Type.
|
||||
|
||||
FreeCAD's PartDesign datum ViewProviders are pure C++ and don't support
|
||||
Python proxies, so we can't override doubleClicked() directly. Instead
|
||||
we install a global observer that watches for ``openCommand("Edit")``
|
||||
or we override via the ViewProvider's ``setEdit`` if possible.
|
||||
|
||||
Pragmatic approach: we monkey-patch ``Gui.ActiveDocument.setEdit`` to
|
||||
intercept datums with our metadata. If that's not available, the user
|
||||
can invoke Create_DatumEdit manually.
|
||||
"""
|
||||
|
||||
_installed = False
|
||||
|
||||
@classmethod
|
||||
def install(cls):
|
||||
if cls._installed:
|
||||
return
|
||||
try:
|
||||
Gui.Selection.addObserver(cls())
|
||||
cls._installed = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setPreselection(self, doc, obj, sub):
|
||||
pass
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
pass
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
pass
|
||||
|
||||
def clearSelection(self, doc):
|
||||
pass
|
||||
|
||||
|
||||
def register_commands():
|
||||
"""Register datum commands with FreeCAD."""
|
||||
from kindred_sdk import register_command
|
||||
|
||||
register_command(
|
||||
"Create_DatumCreator",
|
||||
activated=_open_creator,
|
||||
resources={
|
||||
"Pixmap": _icon("datum_creator"),
|
||||
"MenuText": "Datum Creator",
|
||||
"ToolTip": "Create datum planes, axes, and points with smart detection",
|
||||
},
|
||||
is_active=lambda: App.ActiveDocument is not None,
|
||||
)
|
||||
|
||||
register_command(
|
||||
"Create_DatumEdit",
|
||||
activated=_try_edit_selected,
|
||||
resources={
|
||||
"Pixmap": _icon("datum_creator"),
|
||||
"MenuText": "Edit Datum",
|
||||
"ToolTip": "Edit parameters of an existing datum",
|
||||
},
|
||||
is_active=_has_editable_datum_selected,
|
||||
)
|
||||
|
||||
_DatumEditObserver.install()
|
||||
|
||||
|
||||
def _has_editable_datum_selected():
|
||||
"""Check if a datum with Datums_Type is selected."""
|
||||
if not App.ActiveDocument:
|
||||
return False
|
||||
sel = Gui.Selection.getSelection()
|
||||
if not sel:
|
||||
return False
|
||||
return hasattr(sel[0], f"{META_PREFIX}Type")
|
||||
|
||||
|
||||
def _try_edit_selected():
|
||||
"""Open editor for the selected datum if it has Datums_Type."""
|
||||
sel = Gui.Selection.getSelection()
|
||||
if not sel:
|
||||
App.Console.PrintWarning("Datums: No object selected\n")
|
||||
return
|
||||
obj = sel[0]
|
||||
if not hasattr(obj, f"{META_PREFIX}Type"):
|
||||
App.Console.PrintWarning("Datums: Selected object is not a datums-created datum\n")
|
||||
return
|
||||
if Gui.Control.activeDialog():
|
||||
App.Console.PrintWarning("Datums: A task panel is already open\n")
|
||||
return
|
||||
_open_editor(obj)
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Selection auto-detection system for the unified datum creator.
|
||||
|
||||
Provides geometry type classification and mode matching to automatically
|
||||
determine the best datum creation mode from user selection.
|
||||
"""
|
||||
|
||||
import Part
|
||||
|
||||
|
||||
class SelectionItem:
|
||||
"""Wraps a selected geometry element with auto-detected type."""
|
||||
|
||||
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 and hasattr(self.obj, "Shape"):
|
||||
for prefix in ("Face", "Edge", "Vertex"):
|
||||
if self.subname and self.subname.startswith(prefix):
|
||||
try:
|
||||
self.shape = self.obj.Shape.getElement(self.subname)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if self.shape is None:
|
||||
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):
|
||||
if isinstance(self.shape.Surface, Part.Cylinder):
|
||||
return "cylinder"
|
||||
if self.shape.Surface.isPlanar():
|
||||
return "face"
|
||||
return "face"
|
||||
elif isinstance(self.shape, Part.Edge):
|
||||
if isinstance(self.shape.Curve, (Part.Circle, Part.ArcOfCircle)):
|
||||
return "circle"
|
||||
return "edge"
|
||||
elif isinstance(self.shape, Part.Vertex):
|
||||
return "vertex"
|
||||
|
||||
return "unknown"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.subname:
|
||||
return f"{self.obj.Label}.{self.subname}"
|
||||
return self.obj.Label
|
||||
|
||||
@property
|
||||
def type_icon(self):
|
||||
icons = {
|
||||
"face": "\u25a2",
|
||||
"plane": "\u25a3",
|
||||
"cylinder": "\u25ce",
|
||||
"edge": "\u2015",
|
||||
"circle": "\u25cb",
|
||||
"vertex": "\u2022",
|
||||
"unknown": "?",
|
||||
}
|
||||
return icons.get(self.geo_type, "?")
|
||||
|
||||
|
||||
# Mode definitions: (display_name, mode_id, required_types, datum_category)
|
||||
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"),
|
||||
)
|
||||
|
||||
# Category colors (Catppuccin Mocha)
|
||||
CATEGORY_COLORS = {
|
||||
"plane": "#cba6f7", # mauve
|
||||
"axis": "#94e2d5", # teal
|
||||
"point": "#f9e2af", # yellow
|
||||
}
|
||||
|
||||
|
||||
def _type_matches(sel_type, req_type):
|
||||
"""Check if a selected type satisfies a required type."""
|
||||
if sel_type == req_type:
|
||||
return True
|
||||
if req_type == "face" and sel_type in ("face", "cylinder"):
|
||||
return True
|
||||
if req_type == "edge" and sel_type in ("edge", "circle"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _match_score(sel_types, required_types):
|
||||
"""Score how well selection matches required types. 0 = no match."""
|
||||
if len(sel_types) < len(required_types):
|
||||
return 0
|
||||
|
||||
remaining = list(sel_types)
|
||||
matched = 0
|
||||
for req in required_types:
|
||||
found = False
|
||||
for i, sel in enumerate(remaining):
|
||||
if _type_matches(sel, req):
|
||||
remaining.pop(i)
|
||||
matched += 1
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return 0
|
||||
|
||||
# Exact count match scores higher
|
||||
if len(sel_types) == len(required_types):
|
||||
return 100 + matched
|
||||
return matched
|
||||
|
||||
|
||||
def match_mode(selection_items):
|
||||
"""Auto-detect the best creation mode from selection items.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
selection_items : list[SelectionItem]
|
||||
Current selection items.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple or None
|
||||
``(display_name, mode_id, category)`` for best match, or None.
|
||||
"""
|
||||
sel_types = tuple(item.geo_type for item in selection_items)
|
||||
if not sel_types:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
best_score = -1
|
||||
|
||||
for display_name, mode_id, required_types, category in MODES:
|
||||
if not required_types:
|
||||
continue
|
||||
score = _match_score(sel_types, required_types)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = (display_name, mode_id, category)
|
||||
|
||||
return best_match if best_score > 0 else None
|
||||
@@ -1,334 +0,0 @@
|
||||
"""Datum edit task panel.
|
||||
|
||||
Opened when double-clicking a datum that has ``Datums_Type`` metadata.
|
||||
Allows real-time parameter editing with live preview.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import FreeCAD as App
|
||||
import Part
|
||||
from PySide6 import QtWidgets
|
||||
|
||||
from datums.core import META_PREFIX
|
||||
|
||||
|
||||
def _resolve_source_refs(datum_obj):
|
||||
"""Parse Datums_SourceRefs and resolve to (object, subname, shape) tuples."""
|
||||
refs_json = getattr(datum_obj, f"{META_PREFIX}SourceRefs", "[]")
|
||||
try:
|
||||
refs = json.loads(refs_json)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
doc = datum_obj.Document
|
||||
resolved = []
|
||||
for ref in refs:
|
||||
obj = doc.getObject(ref.get("object", ""))
|
||||
sub = ref.get("subname", "")
|
||||
shape = obj.getSubObject(sub) if obj and sub else None
|
||||
resolved.append((obj, sub, shape))
|
||||
return resolved
|
||||
|
||||
|
||||
_TYPE_DISPLAY_NAMES = {
|
||||
"offset_from_face": "Offset from Face",
|
||||
"offset_from_plane": "Offset from Plane",
|
||||
"midplane": "Midplane",
|
||||
"3_points": "3 Points",
|
||||
"normal_to_edge": "Normal to Edge",
|
||||
"angled": "Angled from Face",
|
||||
"tangent_cylinder": "Tangent to Cylinder",
|
||||
"2_points": "2 Points",
|
||||
"from_edge": "From Edge",
|
||||
"cylinder_center": "Cylinder Center",
|
||||
"plane_intersection": "Plane Intersection",
|
||||
"vertex": "At Vertex",
|
||||
"coordinates": "XYZ Coordinates",
|
||||
"on_edge": "On Edge",
|
||||
"face_center": "Face Center",
|
||||
"circle_center": "Circle Center",
|
||||
}
|
||||
|
||||
|
||||
class DatumEditTaskPanel:
|
||||
"""Task panel for editing existing datum objects with Datums_Type metadata."""
|
||||
|
||||
def __init__(self, datum_obj):
|
||||
self.datum_obj = datum_obj
|
||||
self.form = QtWidgets.QWidget()
|
||||
self.form.setWindowTitle(f"Edit {datum_obj.Label}")
|
||||
self.original_placement = datum_obj.Placement.copy()
|
||||
self.original_offset = (
|
||||
datum_obj.AttachmentOffset.copy() if hasattr(datum_obj, "AttachmentOffset") else None
|
||||
)
|
||||
self.original_path_param = (
|
||||
datum_obj.MapPathParameter if hasattr(datum_obj, "MapPathParameter") else None
|
||||
)
|
||||
self._setup_ui()
|
||||
self._load_values()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QtWidgets.QVBoxLayout(self.form)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# Info
|
||||
info_group = QtWidgets.QGroupBox("Datum Info")
|
||||
info_layout = QtWidgets.QFormLayout(info_group)
|
||||
|
||||
self.name_edit = QtWidgets.QLineEdit(self.datum_obj.Label)
|
||||
info_layout.addRow("Name:", self.name_edit)
|
||||
|
||||
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "unknown")
|
||||
type_label = QtWidgets.QLabel(
|
||||
_TYPE_DISPLAY_NAMES.get(dtype, dtype),
|
||||
)
|
||||
type_label.setStyleSheet("color: #cba6f7; font-weight: bold;")
|
||||
info_layout.addRow("Type:", type_label)
|
||||
layout.addWidget(info_group)
|
||||
|
||||
# Parameters
|
||||
self.params_group = QtWidgets.QGroupBox("Parameters")
|
||||
self.params_layout = QtWidgets.QFormLayout(self.params_group)
|
||||
|
||||
self.offset_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.offset_spin.setRange(-10000, 10000)
|
||||
self.offset_spin.setSuffix(" mm")
|
||||
self.offset_spin.valueChanged.connect(self._on_param_changed)
|
||||
|
||||
self.angle_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(-360, 360)
|
||||
self.angle_spin.setSuffix(" \u00b0")
|
||||
self.angle_spin.valueChanged.connect(self._on_param_changed)
|
||||
|
||||
self.param_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.param_spin.setRange(0, 1)
|
||||
self.param_spin.setSingleStep(0.1)
|
||||
self.param_spin.valueChanged.connect(self._on_param_changed)
|
||||
|
||||
self.x_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.x_spin.setRange(-10000, 10000)
|
||||
self.x_spin.setSuffix(" mm")
|
||||
self.x_spin.valueChanged.connect(self._on_param_changed)
|
||||
self.y_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.y_spin.setRange(-10000, 10000)
|
||||
self.y_spin.setSuffix(" mm")
|
||||
self.y_spin.valueChanged.connect(self._on_param_changed)
|
||||
self.z_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.z_spin.setRange(-10000, 10000)
|
||||
self.z_spin.setSuffix(" mm")
|
||||
self.z_spin.valueChanged.connect(self._on_param_changed)
|
||||
|
||||
layout.addWidget(self.params_group)
|
||||
|
||||
# Source references (read-only)
|
||||
refs_group = QtWidgets.QGroupBox("Source References")
|
||||
refs_layout = QtWidgets.QVBoxLayout(refs_group)
|
||||
self.refs_list = QtWidgets.QListWidget()
|
||||
self.refs_list.setMaximumHeight(80)
|
||||
refs_layout.addWidget(self.refs_list)
|
||||
layout.addWidget(refs_group)
|
||||
|
||||
# Placement readout
|
||||
placement_group = QtWidgets.QGroupBox("Current Placement")
|
||||
placement_layout = QtWidgets.QFormLayout(placement_group)
|
||||
pos = self.datum_obj.Placement.Base
|
||||
self.pos_label = QtWidgets.QLabel(
|
||||
f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})",
|
||||
)
|
||||
placement_layout.addRow("Position:", self.pos_label)
|
||||
layout.addWidget(placement_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def _load_values(self):
|
||||
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "")
|
||||
params_json = getattr(self.datum_obj, f"{META_PREFIX}Params", "{}")
|
||||
refs_json = getattr(self.datum_obj, f"{META_PREFIX}SourceRefs", "[]")
|
||||
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
try:
|
||||
refs = json.loads(refs_json)
|
||||
except json.JSONDecodeError:
|
||||
refs = []
|
||||
|
||||
# Clear
|
||||
while self.params_layout.rowCount() > 0:
|
||||
self.params_layout.removeRow(0)
|
||||
|
||||
if dtype in ("offset_from_face", "offset_from_plane"):
|
||||
self.offset_spin.setValue(params.get("distance", 10))
|
||||
self.params_layout.addRow("Offset:", self.offset_spin)
|
||||
elif dtype == "midplane":
|
||||
self.offset_spin.setValue(params.get("half_distance", 0))
|
||||
self.params_layout.addRow("Half-distance:", self.offset_spin)
|
||||
elif dtype == "angled":
|
||||
self.angle_spin.setValue(params.get("angle", 45))
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif dtype == "tangent_cylinder":
|
||||
self.angle_spin.setValue(params.get("angle", 0))
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif dtype in ("normal_to_edge", "on_edge"):
|
||||
self.param_spin.setValue(params.get("parameter", 0.5))
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
elif dtype == "coordinates":
|
||||
self.x_spin.setValue(params.get("x", 0))
|
||||
self.y_spin.setValue(params.get("y", 0))
|
||||
self.z_spin.setValue(params.get("z", 0))
|
||||
self.params_layout.addRow("X:", self.x_spin)
|
||||
self.params_layout.addRow("Y:", self.y_spin)
|
||||
self.params_layout.addRow("Z:", self.z_spin)
|
||||
else:
|
||||
lbl = QtWidgets.QLabel("No editable parameters")
|
||||
lbl.setStyleSheet("color: #888;")
|
||||
self.params_layout.addRow(lbl)
|
||||
|
||||
# Source refs
|
||||
self.refs_list.clear()
|
||||
for ref in refs:
|
||||
obj_name = ref.get("object", "?")
|
||||
subname = ref.get("subname", "")
|
||||
text = f"{obj_name}.{subname}" if subname else obj_name
|
||||
self.refs_list.addItem(text)
|
||||
if not refs:
|
||||
self.refs_list.addItem("(no references)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Live parameter updates
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _has_attachment(self):
|
||||
return hasattr(self.datum_obj, "MapMode") and self.datum_obj.MapMode != "Deactivated"
|
||||
|
||||
def _on_param_changed(self):
|
||||
dtype = getattr(self.datum_obj, f"{META_PREFIX}Type", "")
|
||||
|
||||
if dtype == "coordinates":
|
||||
new_pos = App.Vector(
|
||||
self.x_spin.value(),
|
||||
self.y_spin.value(),
|
||||
self.z_spin.value(),
|
||||
)
|
||||
self.datum_obj.Placement.Base = new_pos
|
||||
self._store_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z})
|
||||
|
||||
elif dtype in ("offset_from_face", "offset_from_plane", "midplane"):
|
||||
distance = self.offset_spin.value()
|
||||
if self._has_attachment():
|
||||
self.datum_obj.AttachmentOffset = App.Placement(
|
||||
App.Vector(0, 0, distance),
|
||||
App.Rotation(),
|
||||
)
|
||||
key = "half_distance" if dtype == "midplane" else "distance"
|
||||
self._store_params({key: distance})
|
||||
|
||||
elif dtype == "angled":
|
||||
angle = self.angle_spin.value()
|
||||
if self._has_attachment():
|
||||
refs = _resolve_source_refs(self.datum_obj)
|
||||
if len(refs) >= 2 and refs[0][2] and refs[1][2]:
|
||||
face_normal = refs[0][2].normalAt(0, 0)
|
||||
edge_shape = refs[1][2]
|
||||
edge_dir = (
|
||||
edge_shape.Vertexes[-1].Point - edge_shape.Vertexes[0].Point
|
||||
).normalize()
|
||||
face_rot = App.Rotation(App.Vector(0, 0, 1), face_normal)
|
||||
local_edge_dir = face_rot.inverted().multVec(edge_dir)
|
||||
angle_rot = App.Rotation(local_edge_dir, angle)
|
||||
self.datum_obj.AttachmentOffset = App.Placement(
|
||||
App.Vector(0, 0, 0),
|
||||
angle_rot,
|
||||
)
|
||||
self._store_params({"angle": angle})
|
||||
|
||||
elif dtype == "tangent_cylinder":
|
||||
angle = self.angle_spin.value()
|
||||
if self._has_attachment():
|
||||
params_json = getattr(self.datum_obj, f"{META_PREFIX}Params", "{}")
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
vertex_angle = params.get("vertex_angle", 0.0)
|
||||
offset_rot = App.Rotation(App.Vector(0, 0, 1), angle - vertex_angle)
|
||||
self.datum_obj.AttachmentOffset = App.Placement(
|
||||
App.Vector(0, 0, 0),
|
||||
offset_rot,
|
||||
)
|
||||
else:
|
||||
refs = _resolve_source_refs(self.datum_obj)
|
||||
if refs and refs[0][2]:
|
||||
face = refs[0][2]
|
||||
if isinstance(face.Surface, Part.Cylinder):
|
||||
cyl = face.Surface
|
||||
axis_dir = cyl.Axis
|
||||
center = cyl.Center
|
||||
radius = cyl.Radius
|
||||
rad = math.radians(angle)
|
||||
if abs(axis_dir.dot(App.Vector(1, 0, 0))) < 0.99:
|
||||
lx = axis_dir.cross(App.Vector(1, 0, 0)).normalize()
|
||||
else:
|
||||
lx = axis_dir.cross(App.Vector(0, 1, 0)).normalize()
|
||||
ly = axis_dir.cross(lx)
|
||||
radial = lx * math.cos(rad) + ly * math.sin(rad)
|
||||
tp = center + radial * radius
|
||||
rot = App.Rotation(App.Vector(0, 0, 1), radial)
|
||||
self.datum_obj.Placement = App.Placement(tp, rot)
|
||||
self._store_params({"angle": angle})
|
||||
|
||||
elif dtype in ("normal_to_edge", "on_edge"):
|
||||
parameter = self.param_spin.value()
|
||||
if self._has_attachment() and hasattr(self.datum_obj, "MapPathParameter"):
|
||||
self.datum_obj.MapPathParameter = parameter
|
||||
self._store_params({"parameter": parameter})
|
||||
|
||||
# Refresh readout
|
||||
pos = self.datum_obj.Placement.Base
|
||||
self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})")
|
||||
App.ActiveDocument.recompute()
|
||||
|
||||
def _store_params(self, new_values):
|
||||
params_json = getattr(self.datum_obj, f"{META_PREFIX}Params", "{}")
|
||||
try:
|
||||
params = json.loads(params_json)
|
||||
except json.JSONDecodeError:
|
||||
params = {}
|
||||
params.update(new_values)
|
||||
|
||||
serializable = {}
|
||||
for k, v in params.items():
|
||||
if hasattr(v, "x") and hasattr(v, "y") and hasattr(v, "z"):
|
||||
serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
|
||||
else:
|
||||
serializable[k] = v
|
||||
setattr(self.datum_obj, f"{META_PREFIX}Params", json.dumps(serializable))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task panel protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def accept(self):
|
||||
new_label = self.name_edit.text().strip()
|
||||
if new_label and new_label != self.datum_obj.Label:
|
||||
self.datum_obj.Label = new_label
|
||||
App.ActiveDocument.recompute()
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
self.datum_obj.Placement = self.original_placement
|
||||
if self.original_offset is not None and hasattr(self.datum_obj, "AttachmentOffset"):
|
||||
self.datum_obj.AttachmentOffset = self.original_offset
|
||||
if self.original_path_param is not None and hasattr(self.datum_obj, "MapPathParameter"):
|
||||
self.datum_obj.MapPathParameter = self.original_path_param
|
||||
App.ActiveDocument.recompute()
|
||||
return True
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
|
||||
@@ -1,605 +0,0 @@
|
||||
"""Datum Creator task panel.
|
||||
|
||||
Provides the main creation UI: selection table, auto-detected mode,
|
||||
mode override combo, dynamic parameter spinboxes, and OK/Cancel.
|
||||
"""
|
||||
|
||||
import FreeCAD as App
|
||||
import FreeCADGui as Gui
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from datums import core
|
||||
from datums.detection import (
|
||||
CATEGORY_COLORS,
|
||||
MODES,
|
||||
SelectionItem,
|
||||
match_mode,
|
||||
)
|
||||
|
||||
|
||||
class DatumCreatorTaskPanel:
|
||||
"""Unified task panel for creating datum planes, axes, and points."""
|
||||
|
||||
def __init__(self):
|
||||
self.form = QtWidgets.QWidget()
|
||||
self.form.setWindowTitle("Datum Creator")
|
||||
self.selection_list = []
|
||||
self._setup_ui()
|
||||
self._setup_selection_observer()
|
||||
|
||||
# Auto-capture any existing selection
|
||||
sel = Gui.Selection.getSelectionEx()
|
||||
if sel:
|
||||
self._capture_selection(sel)
|
||||
|
||||
self._update_mode_from_selection()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI construction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QtWidgets.QVBoxLayout(self.form)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# --- Selection table ---
|
||||
sel_group = QtWidgets.QGroupBox("Selection")
|
||||
sel_layout = QtWidgets.QVBoxLayout(sel_group)
|
||||
|
||||
self.sel_table = QtWidgets.QTableWidget()
|
||||
self.sel_table.setColumnCount(3)
|
||||
self.sel_table.setHorizontalHeaderLabels(["", "Element", ""])
|
||||
header = self.sel_table.horizontalHeader()
|
||||
header.setStretchLastSection(False)
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed)
|
||||
header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(2, QtWidgets.QHeaderView.Fixed)
|
||||
header.resizeSection(0, 28)
|
||||
header.resizeSection(2, 28)
|
||||
self.sel_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||
self.sel_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
||||
self.sel_table.setMaximumHeight(150)
|
||||
self.sel_table.verticalHeader().setVisible(False)
|
||||
sel_layout.addWidget(self.sel_table)
|
||||
|
||||
btn_row = QtWidgets.QHBoxLayout()
|
||||
self.add_sel_btn = QtWidgets.QPushButton("Add Selected")
|
||||
self.add_sel_btn.clicked.connect(self._on_add_selection)
|
||||
self.remove_sel_btn = QtWidgets.QPushButton("Remove")
|
||||
self.remove_sel_btn.clicked.connect(self._on_remove_row)
|
||||
self.clear_sel_btn = QtWidgets.QPushButton("Clear All")
|
||||
self.clear_sel_btn.clicked.connect(self._on_clear)
|
||||
btn_row.addWidget(self.add_sel_btn)
|
||||
btn_row.addWidget(self.remove_sel_btn)
|
||||
btn_row.addWidget(self.clear_sel_btn)
|
||||
sel_layout.addLayout(btn_row)
|
||||
layout.addWidget(sel_group)
|
||||
|
||||
# --- Detected mode ---
|
||||
mode_group = QtWidgets.QGroupBox("Datum Type")
|
||||
mode_layout = QtWidgets.QVBoxLayout(mode_group)
|
||||
|
||||
self.mode_label = QtWidgets.QLabel("Select geometry to auto-detect mode")
|
||||
self.mode_label.setStyleSheet("font-weight: bold; color: #888;")
|
||||
mode_layout.addWidget(self.mode_label)
|
||||
|
||||
override_row = QtWidgets.QHBoxLayout()
|
||||
override_row.addWidget(QtWidgets.QLabel("Override:"))
|
||||
self.mode_combo = QtWidgets.QComboBox()
|
||||
self.mode_combo.addItem("(Auto-detect)", None)
|
||||
for display_name, mode_id, _, category in MODES:
|
||||
self.mode_combo.addItem(
|
||||
f"[{category[0].upper()}] {display_name}",
|
||||
mode_id,
|
||||
)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_override)
|
||||
override_row.addWidget(self.mode_combo)
|
||||
mode_layout.addLayout(override_row)
|
||||
layout.addWidget(mode_group)
|
||||
|
||||
# --- Parameters ---
|
||||
self.params_group = QtWidgets.QGroupBox("Parameters")
|
||||
self.params_layout = QtWidgets.QFormLayout(self.params_group)
|
||||
|
||||
self.offset_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.offset_spin.setRange(-10000, 10000)
|
||||
self.offset_spin.setValue(10)
|
||||
self.offset_spin.setSuffix(" mm")
|
||||
|
||||
self.angle_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(-360, 360)
|
||||
self.angle_spin.setValue(45)
|
||||
self.angle_spin.setSuffix(" \u00b0")
|
||||
|
||||
self.param_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.param_spin.setRange(0, 1)
|
||||
self.param_spin.setValue(0.5)
|
||||
self.param_spin.setSingleStep(0.1)
|
||||
|
||||
self.x_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.x_spin.setRange(-10000, 10000)
|
||||
self.x_spin.setSuffix(" mm")
|
||||
self.y_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.y_spin.setRange(-10000, 10000)
|
||||
self.y_spin.setSuffix(" mm")
|
||||
self.z_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.z_spin.setRange(-10000, 10000)
|
||||
self.z_spin.setSuffix(" mm")
|
||||
|
||||
layout.addWidget(self.params_group)
|
||||
|
||||
# --- Options ---
|
||||
options_group = QtWidgets.QGroupBox("Options")
|
||||
options_layout = QtWidgets.QVBoxLayout(options_group)
|
||||
|
||||
self.link_spreadsheet_cb = QtWidgets.QCheckBox("Link to Spreadsheet")
|
||||
options_layout.addWidget(self.link_spreadsheet_cb)
|
||||
|
||||
name_row = QtWidgets.QHBoxLayout()
|
||||
self.custom_name_cb = QtWidgets.QCheckBox("Custom Name:")
|
||||
self.custom_name_edit = QtWidgets.QLineEdit()
|
||||
self.custom_name_edit.setEnabled(False)
|
||||
self.custom_name_cb.toggled.connect(self.custom_name_edit.setEnabled)
|
||||
name_row.addWidget(self.custom_name_cb)
|
||||
name_row.addWidget(self.custom_name_edit)
|
||||
options_layout.addLayout(name_row)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
self._update_params_ui(None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Selection observer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_selection_observer(self):
|
||||
class _Observer:
|
||||
def __init__(self, panel):
|
||||
self.panel = panel
|
||||
|
||||
def addSelection(self, doc, obj, sub, pos):
|
||||
self.panel._on_freecad_sel_changed()
|
||||
|
||||
def removeSelection(self, doc, obj, sub):
|
||||
self.panel._on_freecad_sel_changed()
|
||||
|
||||
def clearSelection(self, doc):
|
||||
self.panel._on_freecad_sel_changed()
|
||||
|
||||
self._observer = _Observer(self)
|
||||
Gui.Selection.addObserver(self._observer)
|
||||
|
||||
def _on_freecad_sel_changed(self):
|
||||
self.add_sel_btn.setEnabled(bool(Gui.Selection.getSelectionEx()))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Selection table management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _capture_selection(self, sel_ex):
|
||||
"""Add SelectionEx items to the internal list."""
|
||||
for s in sel_ex:
|
||||
obj = s.Object
|
||||
if s.SubElementNames:
|
||||
for i, sub in enumerate(s.SubElementNames):
|
||||
shape = s.SubObjects[i] if i < len(s.SubObjects) else None
|
||||
item = SelectionItem(obj, sub, shape)
|
||||
self._add_item(item)
|
||||
else:
|
||||
self._add_item(SelectionItem(obj, "", None))
|
||||
|
||||
def _add_item(self, item):
|
||||
for existing in self.selection_list:
|
||||
if existing.obj == item.obj and existing.subname == item.subname:
|
||||
return
|
||||
self.selection_list.append(item)
|
||||
|
||||
def _on_add_selection(self):
|
||||
self._capture_selection(Gui.Selection.getSelectionEx())
|
||||
self._refresh_table()
|
||||
self._update_mode_from_selection()
|
||||
|
||||
def _on_remove_row(self):
|
||||
rows = self.sel_table.selectionModel().selectedRows()
|
||||
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_table()
|
||||
self._update_mode_from_selection()
|
||||
|
||||
def _on_clear(self):
|
||||
self.selection_list.clear()
|
||||
self._refresh_table()
|
||||
self._update_mode_from_selection()
|
||||
|
||||
def _refresh_table(self):
|
||||
self.sel_table.setRowCount(len(self.selection_list))
|
||||
for i, item in enumerate(self.selection_list):
|
||||
type_item = QtWidgets.QTableWidgetItem(item.type_icon)
|
||||
type_item.setTextAlignment(QtCore.Qt.AlignCenter)
|
||||
type_item.setToolTip(item.geo_type)
|
||||
self.sel_table.setItem(i, 0, type_item)
|
||||
|
||||
self.sel_table.setItem(
|
||||
i,
|
||||
1,
|
||||
QtWidgets.QTableWidgetItem(item.display_name),
|
||||
)
|
||||
|
||||
btn = QtWidgets.QPushButton("\u2715")
|
||||
btn.setFixedSize(24, 24)
|
||||
btn.clicked.connect(lambda checked, r=i: self._remove_at(r))
|
||||
self.sel_table.setCellWidget(i, 2, btn)
|
||||
|
||||
def _remove_at(self, row):
|
||||
if row < len(self.selection_list):
|
||||
del self.selection_list[row]
|
||||
self._refresh_table()
|
||||
self._update_mode_from_selection()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mode detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_mode_from_selection(self):
|
||||
if self.mode_combo.currentIndex() > 0:
|
||||
mode_id = self.mode_combo.currentData()
|
||||
self._apply_mode(mode_id)
|
||||
return
|
||||
|
||||
if not self.selection_list:
|
||||
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
|
||||
|
||||
result = match_mode(self.selection_list)
|
||||
if result:
|
||||
display_name, mode_id, category = result
|
||||
color = CATEGORY_COLORS.get(category, "#cdd6f4")
|
||||
self.mode_label.setText(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 _on_mode_override(self, index):
|
||||
if index == 0:
|
||||
self._update_mode_from_selection()
|
||||
else:
|
||||
self._apply_mode(self.mode_combo.currentData())
|
||||
|
||||
def _apply_mode(self, mode_id):
|
||||
for display_name, mid, _, category in MODES:
|
||||
if mid == mode_id:
|
||||
color = CATEGORY_COLORS.get(category, "#cdd6f4")
|
||||
self.mode_label.setText(display_name)
|
||||
self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};")
|
||||
self._update_params_ui(mode_id)
|
||||
return
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dynamic parameter UI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clear_params_layout(self):
|
||||
while self.params_layout.count():
|
||||
item = self.params_layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.hide()
|
||||
w.setParent(None)
|
||||
|
||||
def _update_params_ui(self, mode_id):
|
||||
self._clear_params_layout()
|
||||
|
||||
if mode_id is None:
|
||||
self.params_group.setVisible(False)
|
||||
return
|
||||
|
||||
self.params_group.setVisible(True)
|
||||
|
||||
if mode_id in ("offset_face", "offset_plane"):
|
||||
self.offset_spin.show()
|
||||
self.params_layout.addRow("Offset:", self.offset_spin)
|
||||
elif mode_id == "midplane":
|
||||
self.params_group.setVisible(False)
|
||||
elif mode_id == "angled":
|
||||
self.angle_spin.show()
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif mode_id == "normal_edge":
|
||||
self.param_spin.show()
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
elif mode_id == "tangent_cyl":
|
||||
self.angle_spin.show()
|
||||
self.params_layout.addRow("Angle:", self.angle_spin)
|
||||
elif mode_id == "point_xyz":
|
||||
self.x_spin.show()
|
||||
self.y_spin.show()
|
||||
self.z_spin.show()
|
||||
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.param_spin.show()
|
||||
self.params_layout.addRow("Position (0-1):", self.param_spin)
|
||||
else:
|
||||
self.params_group.setVisible(False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Getters for create_datum
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_current_mode(self):
|
||||
if self.mode_combo.currentIndex() > 0:
|
||||
return self.mode_combo.currentData()
|
||||
result = match_mode(self.selection_list)
|
||||
return result[1] if result else None
|
||||
|
||||
def _get_name(self):
|
||||
if self.custom_name_cb.isChecked() and self.custom_name_edit.text():
|
||||
return self.custom_name_edit.text()
|
||||
return None
|
||||
|
||||
def _items_by_type(self, *geo_types):
|
||||
return [it for it in self.selection_list if it.geo_type in geo_types]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Creation dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_datum(self):
|
||||
mode = self._get_current_mode()
|
||||
if mode is None:
|
||||
raise ValueError("No valid mode detected. Add geometry to the selection.")
|
||||
|
||||
name = self._get_name()
|
||||
link_ss = self.link_spreadsheet_cb.isChecked()
|
||||
|
||||
# --- Planes ---
|
||||
if mode == "offset_face":
|
||||
items = self._items_by_type("face", "cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a face")
|
||||
it = items[0]
|
||||
face = it.shape if it.shape else it.obj.Shape.Faces[0]
|
||||
core.plane_offset_from_face(
|
||||
face,
|
||||
self.offset_spin.value(),
|
||||
name=name,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "offset_plane":
|
||||
items = self._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,
|
||||
link_spreadsheet=link_ss,
|
||||
)
|
||||
|
||||
elif mode == "midplane":
|
||||
items = self._items_by_type("face", "cylinder")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 faces")
|
||||
f1 = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0]
|
||||
f2 = items[1].shape if items[1].shape else items[1].obj.Shape.Faces[0]
|
||||
core.plane_midplane(
|
||||
f1,
|
||||
f2,
|
||||
name=name,
|
||||
source_object1=items[0].obj,
|
||||
source_subname1=items[0].subname,
|
||||
source_object2=items[1].obj,
|
||||
source_subname2=items[1].subname,
|
||||
)
|
||||
|
||||
elif mode == "3_points":
|
||||
items = self._items_by_type("vertex")
|
||||
if len(items) < 3:
|
||||
raise ValueError("Select 3 vertices")
|
||||
verts = [it.shape if it.shape else it.obj.Shape.Vertexes[0] for it in items[:3]]
|
||||
core.plane_from_3_points(
|
||||
verts[0].Point,
|
||||
verts[1].Point,
|
||||
verts[2].Point,
|
||||
name=name,
|
||||
source_refs=[(it.obj, it.subname) for it in items[:3]],
|
||||
)
|
||||
|
||||
elif mode == "normal_edge":
|
||||
items = self._items_by_type("edge", "circle")
|
||||
if not items:
|
||||
raise ValueError("Select an edge")
|
||||
it = items[0]
|
||||
edge = it.shape if it.shape else it.obj.Shape.Edges[0]
|
||||
core.plane_normal_to_edge(
|
||||
edge,
|
||||
parameter=self.param_spin.value(),
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "angled":
|
||||
faces = self._items_by_type("face", "cylinder")
|
||||
edges = self._items_by_type("edge", "circle")
|
||||
if not faces or not edges:
|
||||
raise ValueError("Select a face and an edge")
|
||||
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.angle_spin.value(),
|
||||
name=name,
|
||||
link_spreadsheet=link_ss,
|
||||
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":
|
||||
items = self._items_by_type("cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a cylindrical face")
|
||||
it = items[0]
|
||||
face = it.shape if it.shape else it.obj.Shape.Faces[0]
|
||||
core.plane_tangent_to_cylinder(
|
||||
face,
|
||||
angle=self.angle_spin.value(),
|
||||
name=name,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
# --- Axes ---
|
||||
elif mode == "axis_2pt":
|
||||
items = self._items_by_type("vertex")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 vertices")
|
||||
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,
|
||||
source_refs=[(items[0].obj, items[0].subname), (items[1].obj, items[1].subname)],
|
||||
)
|
||||
|
||||
elif mode == "axis_edge":
|
||||
items = self._items_by_type("edge")
|
||||
if not items:
|
||||
raise ValueError("Select a linear edge")
|
||||
it = items[0]
|
||||
edge = it.shape if it.shape else it.obj.Shape.Edges[0]
|
||||
core.axis_from_edge(
|
||||
edge,
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "axis_cyl":
|
||||
items = self._items_by_type("cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a cylindrical face")
|
||||
it = items[0]
|
||||
face = it.shape if it.shape else it.obj.Shape.Faces[0]
|
||||
core.axis_cylinder_center(
|
||||
face,
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "axis_intersect":
|
||||
items = self._items_by_type("plane")
|
||||
if len(items) < 2:
|
||||
raise ValueError("Select 2 datum planes")
|
||||
core.axis_intersection_planes(
|
||||
items[0].obj,
|
||||
items[1].obj,
|
||||
name=name,
|
||||
source_object1=items[0].obj,
|
||||
source_subname1="",
|
||||
source_object2=items[1].obj,
|
||||
source_subname2="",
|
||||
)
|
||||
|
||||
# --- Points ---
|
||||
elif mode == "point_vertex":
|
||||
items = self._items_by_type("vertex")
|
||||
if not items:
|
||||
raise ValueError("Select a vertex")
|
||||
it = items[0]
|
||||
vert = it.shape if it.shape else it.obj.Shape.Vertexes[0]
|
||||
core.point_at_vertex(
|
||||
vert,
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "point_xyz":
|
||||
core.point_at_coordinates(
|
||||
self.x_spin.value(),
|
||||
self.y_spin.value(),
|
||||
self.z_spin.value(),
|
||||
name=name,
|
||||
link_spreadsheet=link_ss,
|
||||
)
|
||||
|
||||
elif mode == "point_edge":
|
||||
items = self._items_by_type("edge", "circle")
|
||||
if not items:
|
||||
raise ValueError("Select an edge")
|
||||
it = items[0]
|
||||
edge = it.shape if it.shape else it.obj.Shape.Edges[0]
|
||||
core.point_on_edge(
|
||||
edge,
|
||||
parameter=self.param_spin.value(),
|
||||
name=name,
|
||||
link_spreadsheet=link_ss,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "point_face":
|
||||
items = self._items_by_type("face", "cylinder")
|
||||
if not items:
|
||||
raise ValueError("Select a face")
|
||||
it = items[0]
|
||||
face = it.shape if it.shape else it.obj.Shape.Faces[0]
|
||||
core.point_center_of_face(
|
||||
face,
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
elif mode == "point_circle":
|
||||
items = self._items_by_type("circle")
|
||||
if not items:
|
||||
raise ValueError("Select a circular edge")
|
||||
it = items[0]
|
||||
edge = it.shape if it.shape else it.obj.Shape.Edges[0]
|
||||
core.point_center_of_circle(
|
||||
edge,
|
||||
name=name,
|
||||
source_object=it.obj,
|
||||
source_subname=it.subname,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task panel protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def accept(self):
|
||||
Gui.Selection.removeObserver(self._observer)
|
||||
try:
|
||||
self._create_datum()
|
||||
App.Console.PrintMessage("Datums: Datum created successfully\n")
|
||||
except Exception as e:
|
||||
App.Console.PrintError(f"Datums: Failed to create datum: {e}\n")
|
||||
QtWidgets.QMessageBox.warning(self.form, "Error", str(e))
|
||||
return True
|
||||
|
||||
def reject(self):
|
||||
Gui.Selection.removeObserver(self._observer)
|
||||
return True
|
||||
|
||||
def getStandardButtons(self):
|
||||
return QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Axis line -->
|
||||
<line x1="6" y1="26" x2="26" y2="6" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<!-- End points -->
|
||||
<circle cx="6" cy="26" r="3" fill="#fab387"/>
|
||||
<circle cx="26" cy="6" r="3" fill="#fab387"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 372 B |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Cylinder -->
|
||||
<ellipse cx="16" cy="8" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M8 8 L8 24" stroke="#7f849c" stroke-width="1"/>
|
||||
<path d="M24 8 L24 24" stroke="#7f849c" stroke-width="1"/>
|
||||
<ellipse cx="16" cy="24" rx="8" ry="3" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Center axis -->
|
||||
<line x1="16" y1="4" x2="16" y2="28" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="2" fill="#fab387"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Box edge representation -->
|
||||
<path d="M8 24 L8 12 L20 8 L20 20 Z" fill="#585b70" stroke="#7f849c" stroke-width="1"/>
|
||||
<!-- Selected edge highlighted -->
|
||||
<line x1="8" y1="24" x2="8" y2="12" stroke="#f9e2af" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Resulting axis -->
|
||||
<line x1="8" y1="28" x2="8" y2="4" stroke="#f38ba8" stroke-width="2" stroke-dasharray="4,2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 515 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- First plane -->
|
||||
<path d="M4 12 L16 6 L28 12 L16 18 Z" fill="#89b4fa" fill-opacity="0.5" stroke="#74c7ec" stroke-width="1"/>
|
||||
<!-- Second plane -->
|
||||
<path d="M4 20 L16 14 L28 20 L16 26 Z" fill="#cba6f7" fill-opacity="0.5" stroke="#b4befe" stroke-width="1"/>
|
||||
<!-- Intersection axis -->
|
||||
<line x1="4" y1="16" x2="28" y2="16" stroke="#f38ba8" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 531 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Plane representation -->
|
||||
<path d="M6 20 L16 8 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Plus sign -->
|
||||
<circle cx="24" cy="8" r="6" fill="#a6e3a1"/>
|
||||
<path d="M24 5 L24 11 M21 8 L27 8" stroke="#1e1e2e" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 443 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Plane -->
|
||||
<path d="M4 20 L16 8 L28 18 L14 28 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Three points -->
|
||||
<circle cx="8" cy="20" r="3" fill="#fab387"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#fab387"/>
|
||||
<circle cx="24" cy="18" r="3" fill="#fab387"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 433 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Angled plane -->
|
||||
<path d="M8 10 L20 6 L24 18 L12 22 Z" fill="#cba6f7" fill-opacity="0.7" stroke="#b4befe" stroke-width="1.5"/>
|
||||
<!-- Angle arc -->
|
||||
<path d="M14 20 Q18 18 18 14" stroke="#fab387" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 508 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Top plane -->
|
||||
<path d="M4 10 L16 4 L28 10 L16 14 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Middle plane (result) -->
|
||||
<path d="M4 16 L16 10 L28 16 L16 20 Z" fill="#a6e3a1" fill-opacity="0.7" stroke="#94e2d5" stroke-width="1.5"/>
|
||||
<!-- Bottom plane -->
|
||||
<path d="M4 22 L16 16 L28 22 L16 26 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 508 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 26 Q16 6 26 16" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Plane perpendicular -->
|
||||
<path d="M12 8 L20 12 L20 24 L12 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Point on curve -->
|
||||
<circle cx="16" cy="16" r="2.5" fill="#fab387"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 480 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Base plane -->
|
||||
<path d="M4 22 L16 14 L28 22 L16 28 Z" fill="#7f849c" stroke="#9399b2" stroke-width="1"/>
|
||||
<!-- Offset plane -->
|
||||
<path d="M4 14 L16 6 L28 14 L16 20 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Offset arrow -->
|
||||
<path d="M16 24 L16 18" stroke="#fab387" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M14 20 L16 17 L18 20" stroke="#fab387" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 621 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Cylinder outline -->
|
||||
<ellipse cx="12" cy="16" rx="6" ry="10" fill="none" stroke="#f9e2af" stroke-width="2"/>
|
||||
<!-- Tangent plane -->
|
||||
<path d="M18 6 L28 10 L28 26 L18 22 Z" fill="#89b4fa" fill-opacity="0.7" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Tangent point -->
|
||||
<circle cx="18" cy="16" r="2.5" fill="#fab387"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Circle -->
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="#f9e2af" stroke-width="2.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="16" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="16" cy="16" r="2" fill="#94e2d5"/>
|
||||
<!-- Radius line -->
|
||||
<line x1="16" y1="16" x2="26" y2="16" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 479 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Edge/curve -->
|
||||
<path d="M6 24 Q16 4 26 20" stroke="#f9e2af" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Point on edge -->
|
||||
<circle cx="14" cy="12" r="4" fill="#a6e3a1"/>
|
||||
<!-- Parameter indicator -->
|
||||
<text x="20" y="10" font-family="monospace" font-size="8" fill="#cdd6f4">t</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 448 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Face -->
|
||||
<path d="M6 20 L16 10 L26 20 L16 26 Z" fill="#89b4fa" fill-opacity="0.6" stroke="#74c7ec" stroke-width="1.5"/>
|
||||
<!-- Center point -->
|
||||
<circle cx="16" cy="19" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="16" cy="19" r="2" fill="#94e2d5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Wireframe box corner -->
|
||||
<path d="M10 20 L10 10 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L20 16 L20 6" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<path d="M10 20 L4 24" stroke="#7f849c" stroke-width="1.5" fill="none"/>
|
||||
<!-- Vertex point -->
|
||||
<circle cx="10" cy="20" r="4" fill="#a6e3a1"/>
|
||||
<circle cx="10" cy="20" r="2" fill="#94e2d5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 527 B |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="2" y="2" width="28" height="28" rx="4" fill="#313244"/>
|
||||
<!-- Coordinate axes -->
|
||||
<line x1="6" y1="24" x2="26" y2="24" stroke="#f38ba8" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="6" y2="6" stroke="#a6e3a1" stroke-width="1.5"/>
|
||||
<line x1="6" y1="24" x2="16" y2="28" stroke="#89b4fa" stroke-width="1.5"/>
|
||||
<!-- Point -->
|
||||
<circle cx="18" cy="12" r="4" fill="#fab387"/>
|
||||
<!-- Projection lines -->
|
||||
<line x1="18" y1="12" x2="18" y2="24" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
<line x1="18" y1="12" x2="6" y2="12" stroke="#7f849c" stroke-width="1" stroke-dasharray="2,2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 681 B |
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
|
||||
<name>datums</name>
|
||||
<description>Unified datum creator for Kindred Create</description>
|
||||
<version>0.1.0</version>
|
||||
<maintainer email="development@kindred-systems.com">Kindred Systems</maintainer>
|
||||
<license file="LICENSE">LGPL-2.1-or-later</license>
|
||||
<url type="repository">https://git.kindred-systems.com/kindred/create</url>
|
||||
|
||||
<content>
|
||||
<workbench>
|
||||
<classname>DatumCommandProvider</classname>
|
||||
<subdirectory>datums</subdirectory>
|
||||
</workbench>
|
||||
</content>
|
||||
|
||||
<kindred>
|
||||
<min_create_version>0.1.0</min_create_version>
|
||||
<load_priority>45</load_priority>
|
||||
<dependencies>
|
||||
<dependency>sdk</dependency>
|
||||
</dependencies>
|
||||
</kindred>
|
||||
</package>
|
||||