diff --git a/mods/datums/Init.py b/mods/datums/Init.py
new file mode 100644
index 0000000000..247f54dc1b
--- /dev/null
+++ b/mods/datums/Init.py
@@ -0,0 +1 @@
+"""Datums addon — console initialization (no-op)."""
diff --git a/mods/datums/InitGui.py b/mods/datums/InitGui.py
new file mode 100644
index 0000000000..08693fcfef
--- /dev/null
+++ b/mods/datums/InitGui.py
@@ -0,0 +1,40 @@
+"""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)
diff --git a/mods/datums/datums/__init__.py b/mods/datums/datums/__init__.py
new file mode 100644
index 0000000000..54b1f6a61d
--- /dev/null
+++ b/mods/datums/datums/__init__.py
@@ -0,0 +1 @@
+"""Unified datum creator for Kindred Create."""
diff --git a/mods/datums/datums/command.py b/mods/datums/datums/command.py
new file mode 100644
index 0000000000..8b7adf8757
--- /dev/null
+++ b/mods/datums/datums/command.py
@@ -0,0 +1,145 @@
+"""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)
diff --git a/mods/datums/datums/core.py b/mods/datums/datums/core.py
new file mode 100644
index 0000000000..4156e03c71
--- /dev/null
+++ b/mods/datums/datums/core.py
@@ -0,0 +1,1061 @@
+"""Core datum creation functions with attachment system.
+
+Each function creates the appropriate PartDesign::Plane, PartDesign::Line,
+or PartDesign::Point in the active Body, then configures Part::AttachExtension
+properties for parametric updates on recompute.
+
+Metadata is stored as ``Datums_Type``, ``Datums_Params``, ``Datums_SourceRefs``
+properties on each datum object for the edit panel.
+"""
+
+import json
+import math
+from typing import Any, Dict, List, Optional, Tuple
+
+import FreeCAD as App
+import Part
+
+# Metadata property prefix
+META_PREFIX = "Datums_"
+
+# Default plane styling (Catppuccin Mocha mauve)
+_DEFAULT_PLANE_COLOR = (0.796, 0.651, 0.969) # #cba6f7
+_DEFAULT_PLANE_TRANSPARENCY = 70
+
+
+def _get_plane_color():
+ """Get plane color from SDK theme or use default."""
+ try:
+ from kindred_sdk import get_theme_tokens
+
+ tokens = get_theme_tokens()
+ hex_color = tokens.get("mauve", "#cba6f7")
+ h = hex_color.lstrip("#")
+ return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
+ except Exception:
+ return _DEFAULT_PLANE_COLOR
+
+
+def _get_next_index(doc, prefix):
+ """Get next available index for auto-naming."""
+ existing = [obj.Name for obj in doc.Objects if obj.Name.startswith(prefix)]
+ if not existing:
+ return 1
+ indices = []
+ for name in existing:
+ try:
+ idx = int(name.replace(prefix, "").lstrip("_").split("_")[0])
+ indices.append(idx)
+ except ValueError:
+ continue
+ return max(indices, default=0) + 1
+
+
+# ---------------------------------------------------------------------------
+# Attachment helpers
+# ---------------------------------------------------------------------------
+
+
+def _configure_attachment(obj, map_mode, support, offset=None, path_param=None):
+ """Configure FreeCAD's vanilla AttachExtension on a datum object.
+
+ Returns True if attachment was configured, False if unavailable.
+ """
+ if not hasattr(obj, "MapMode"):
+ return False
+ obj.AttachmentSupport = support
+ obj.MapMode = map_mode
+ if offset is not None:
+ obj.AttachmentOffset = offset
+ if path_param is not None and hasattr(obj, "MapPathParameter"):
+ obj.MapPathParameter = path_param
+ return True
+
+
+def _style_plane(obj):
+ """Apply Catppuccin mauve styling to a datum plane."""
+ if hasattr(obj, "ViewObject") and obj.ViewObject is not None:
+ vo = obj.ViewObject
+ color = _get_plane_color()
+ if hasattr(vo, "ShapeColor"):
+ vo.ShapeColor = color
+ if hasattr(vo, "Transparency"):
+ vo.Transparency = _DEFAULT_PLANE_TRANSPARENCY
+ if hasattr(vo, "LineColor"):
+ vo.LineColor = color
+
+
+def _hide_attachment_properties(obj):
+ """Hide vanilla attachment properties from the property editor."""
+ for prop in (
+ "MapMode",
+ "MapPathParameter",
+ "MapReversed",
+ "AttachmentOffset",
+ "AttachmentSupport",
+ "Support",
+ ):
+ try:
+ if hasattr(obj, prop):
+ obj.setPropertyStatus(prop, "Hidden")
+ except Exception:
+ pass
+
+
+def _add_metadata(obj, datum_type, params, source_refs=None):
+ """Store datum metadata in object properties."""
+ for suffix, doc_str in (
+ ("Type", "Datum creation method"),
+ ("Params", "Creation parameters (JSON)"),
+ ("SourceRefs", "Source geometry references (JSON)"),
+ ):
+ prop_name = f"{META_PREFIX}{suffix}"
+ if not hasattr(obj, prop_name):
+ obj.addProperty(
+ "App::PropertyString",
+ prop_name,
+ "Datums",
+ doc_str,
+ )
+
+ setattr(obj, f"{META_PREFIX}Type", datum_type)
+
+ # Serialize params — convert vectors/placements to dicts
+ serializable = {}
+ for k, v in params.items():
+ if isinstance(v, App.Vector):
+ serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z}
+ elif isinstance(v, App.Placement):
+ serializable[k] = {
+ "_type": "Placement",
+ "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z},
+ "rotation": list(v.Rotation.Q),
+ }
+ else:
+ serializable[k] = v
+ setattr(obj, f"{META_PREFIX}Params", json.dumps(serializable))
+
+ # Source refs
+ if source_refs:
+ ref_data = [{"object": src.Name, "subname": sub or ""} for src, sub in source_refs if src]
+ setattr(obj, f"{META_PREFIX}SourceRefs", json.dumps(ref_data))
+ else:
+ setattr(obj, f"{META_PREFIX}SourceRefs", "[]")
+
+
+def _setup_datum(
+ obj,
+ placement,
+ datum_type,
+ params,
+ source_refs=None,
+ is_plane=False,
+ map_mode=None,
+ support=None,
+ offset=None,
+ path_param=None,
+):
+ """Set up a datum with attachment or manual placement."""
+ if map_mode and support and hasattr(obj, "MapMode"):
+ _configure_attachment(obj, map_mode, support, offset, path_param)
+ else:
+ if hasattr(obj, "MapMode"):
+ obj.MapMode = "Deactivated"
+ obj.Placement = placement
+
+ _add_metadata(obj, datum_type, params, source_refs)
+ if is_plane:
+ _style_plane(obj)
+ _hide_attachment_properties(obj)
+
+
+def _get_subname_from_shape(parent_obj, shape):
+ """Find the sub-element name for a shape within a parent object."""
+ if not hasattr(parent_obj, "Shape"):
+ return None
+ parent_shape = parent_obj.Shape
+ if isinstance(shape, Part.Face):
+ for i, face in enumerate(parent_shape.Faces, 1):
+ if face.isSame(shape):
+ return f"Face{i}"
+ elif isinstance(shape, Part.Edge):
+ for i, edge in enumerate(parent_shape.Edges, 1):
+ if edge.isSame(shape):
+ return f"Edge{i}"
+ elif isinstance(shape, Part.Vertex):
+ for i, vertex in enumerate(parent_shape.Vertexes, 1):
+ if vertex.isSame(shape):
+ return f"Vertex{i}"
+ return None
+
+
+def _find_shape_owner(doc, shape):
+ """Find the object that owns a given shape."""
+ for obj in doc.Objects:
+ if not hasattr(obj, "Shape"):
+ continue
+ subname = _get_subname_from_shape(obj, shape)
+ if subname:
+ return obj, subname
+ return None, None
+
+
+def _link_to_spreadsheet(doc, obj, param_name, value, alias):
+ """Optionally link a parameter to spreadsheet."""
+ sheet = doc.getObject("Spreadsheet")
+ if not sheet:
+ sheet = doc.addObject("Spreadsheet::Sheet", "Spreadsheet")
+ row = 1
+ while sheet.getContents(f"A{row}"):
+ row += 1
+ sheet.set(f"A{row}", alias)
+ sheet.set(f"B{row}", f"{value} mm")
+ sheet.setAlias(f"B{row}", alias)
+ obj.setExpression(param_name, f"Spreadsheet.{alias}")
+ return alias
+
+
+def _get_active_body():
+ """Get the active PartDesign body, or raise."""
+ import FreeCADGui as Gui
+
+ if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument:
+ view = Gui.ActiveDocument.ActiveView
+ if hasattr(view, "getActiveObject"):
+ body = view.getActiveObject("pdbody")
+ if body:
+ return body
+ raise RuntimeError("No active PartDesign body. Select a body first.")
+
+
+# ==========================================================================
+# DATUM PLANES
+# ==========================================================================
+
+
+def plane_offset_from_face(
+ face,
+ distance,
+ *,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum plane offset from a planar face."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not face.Surface.isPlanar():
+ raise ValueError("Face must be planar for offset plane")
+
+ if source_object is None or source_subname is None:
+ source_object, source_subname = _find_shape_owner(doc, face)
+
+ uv = face.Surface.parameter(face.CenterOfMass)
+ normal = face.normalAt(uv[0], uv[1])
+ base = face.CenterOfMass + normal * distance
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Offset")
+ name = f"DPlane_Offset_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(base, rot)
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(source_object, source_subname)] if source_object else None
+ att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
+ _setup_datum(
+ plane,
+ placement,
+ "offset_from_face",
+ {"distance": distance},
+ source_refs,
+ is_plane=True,
+ map_mode="FlatFace" if att_support else None,
+ support=att_support,
+ offset=att_offset,
+ )
+
+ if link_spreadsheet:
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, f"{name}_offset")
+
+ doc.recompute()
+ return plane
+
+
+def plane_offset_from_plane(
+ source_plane,
+ distance,
+ *,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+):
+ """Create datum plane offset from another datum plane."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not hasattr(source_plane, "Shape") or not source_plane.Shape.Faces:
+ raise ValueError("Source must be a plane object with a Shape")
+
+ face = source_plane.Shape.Faces[0]
+ if not face.Surface.isPlanar():
+ raise ValueError("Source must be a planar object")
+
+ uv = face.Surface.parameter(face.CenterOfMass)
+ normal = face.normalAt(uv[0], uv[1])
+ base = face.CenterOfMass + normal * distance
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Offset")
+ name = f"DPlane_Offset_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(base, rot)
+ source_refs = [(source_plane, "")]
+
+ plane = body.newObject("PartDesign::Plane", name)
+ att_offset = App.Placement(App.Vector(0, 0, distance), App.Rotation())
+ _setup_datum(
+ plane,
+ placement,
+ "offset_from_plane",
+ {"distance": distance, "source_plane": source_plane.Name},
+ source_refs,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_plane, "")],
+ offset=att_offset,
+ )
+
+ if link_spreadsheet:
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, f"{name}_offset")
+
+ doc.recompute()
+ return plane
+
+
+def plane_midplane(
+ face1,
+ face2,
+ *,
+ name=None,
+ body=None,
+ source_object1=None,
+ source_subname1=None,
+ source_object2=None,
+ source_subname2=None,
+):
+ """Create datum plane midway between two parallel faces."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not (face1.Surface.isPlanar() and face2.Surface.isPlanar()):
+ raise ValueError("Both faces must be planar")
+
+ uv1 = face1.Surface.parameter(face1.CenterOfMass)
+ uv2 = face2.Surface.parameter(face2.CenterOfMass)
+ n1 = face1.normalAt(uv1[0], uv1[1])
+ n2 = face2.normalAt(uv2[0], uv2[1])
+
+ if abs(n1.dot(n2)) < 0.9999:
+ raise ValueError("Faces must be parallel for midplane")
+
+ c1, c2 = face1.CenterOfMass, face2.CenterOfMass
+ half_dist = (c2 - c1).dot(n1) / 2.0
+ mid = (c1 + c2) * 0.5
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Mid")
+ name = f"DPlane_Mid_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), n1)
+ placement = App.Placement(mid, rot)
+
+ source_refs = []
+ if source_object1:
+ source_refs.append((source_object1, source_subname1))
+ if source_object2:
+ source_refs.append((source_object2, source_subname2))
+
+ plane = body.newObject("PartDesign::Plane", name)
+ if source_object1 and source_subname1:
+ att_offset = App.Placement(App.Vector(0, 0, half_dist), App.Rotation())
+ _setup_datum(
+ plane,
+ placement,
+ "midplane",
+ {"half_distance": half_dist},
+ source_refs or None,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_object1, source_subname1)],
+ offset=att_offset,
+ )
+ else:
+ _setup_datum(
+ plane,
+ placement,
+ "midplane",
+ {"half_distance": half_dist},
+ source_refs or None,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_from_3_points(
+ p1,
+ p2,
+ p3,
+ *,
+ name=None,
+ body=None,
+ source_refs=None,
+):
+ """Create datum plane from 3 non-collinear points."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ normal = (p2 - p1).cross(p3 - p1)
+ if normal.Length < 1e-6:
+ raise ValueError("Points are collinear, cannot define plane")
+ normal.normalize()
+ center = (p1 + p2 + p3) / 3
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_3Pt")
+ name = f"DPlane_3Pt_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), normal)
+ placement = App.Placement(center, rot)
+
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(ref[0], ref[1]) for ref in source_refs] if source_refs else None
+ _setup_datum(
+ plane,
+ placement,
+ "3_points",
+ {"p1": p1, "p2": p2, "p3": p3},
+ source_refs,
+ is_plane=True,
+ map_mode="ThreePointsPlane" if att_support and len(att_support) == 3 else None,
+ support=att_support,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_normal_to_edge(
+ edge,
+ *,
+ parameter=0.5,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum plane normal to edge at parameter location (0-1)."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter)
+ point = edge.valueAt(param)
+ tangent = edge.tangentAt(param)
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Normal")
+ name = f"DPlane_Normal_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), tangent)
+ placement = App.Placement(point, rot)
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ plane = body.newObject("PartDesign::Plane", name)
+ att_support = [(source_object, source_subname)] if source_object else None
+ _setup_datum(
+ plane,
+ placement,
+ "normal_to_edge",
+ {"parameter": parameter},
+ source_refs,
+ is_plane=True,
+ map_mode="NormalToEdge" if att_support else None,
+ support=att_support,
+ path_param=parameter,
+ )
+
+ doc.recompute()
+ return plane
+
+
+def plane_angled(
+ face,
+ edge,
+ angle,
+ *,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+ source_face_obj=None,
+ source_face_sub=None,
+ source_edge_obj=None,
+ source_edge_sub=None,
+):
+ """Create datum plane at angle to face, rotating about edge."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not face.Surface.isPlanar():
+ raise ValueError("Face must be planar")
+
+ uv = face.Surface.parameter(face.CenterOfMass)
+ face_normal = face.normalAt(uv[0], uv[1])
+
+ edge_dir = (
+ edge.Curve.Direction
+ if hasattr(edge.Curve, "Direction")
+ else (edge.valueAt(edge.LastParameter) - edge.valueAt(edge.FirstParameter)).normalize()
+ )
+ edge_mid = edge.valueAt((edge.FirstParameter + edge.LastParameter) / 2)
+
+ rot_axis = App.Rotation(edge_dir, angle)
+ new_normal = rot_axis.multVec(face_normal)
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Angled")
+ name = f"DPlane_Angled_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), new_normal)
+ placement = App.Placement(edge_mid, rot)
+
+ 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))
+
+ plane = body.newObject("PartDesign::Plane", name)
+ if source_face_obj and source_face_sub:
+ face_rot = App.Rotation(App.Vector(0, 0, 1), face_normal)
+ local_edge_dir = face_rot.inverted().multVec(edge_dir)
+ angle_rot = App.Rotation(local_edge_dir, angle)
+ att_offset = App.Placement(App.Vector(0, 0, 0), angle_rot)
+ _setup_datum(
+ plane,
+ placement,
+ "angled",
+ {"angle": angle},
+ source_refs or None,
+ is_plane=True,
+ map_mode="FlatFace",
+ support=[(source_face_obj, source_face_sub)],
+ offset=att_offset,
+ )
+ else:
+ _setup_datum(
+ plane,
+ placement,
+ "angled",
+ {"angle": angle},
+ source_refs or None,
+ is_plane=True,
+ )
+
+ if link_spreadsheet:
+ _link_to_spreadsheet(doc, plane, "AttachmentOffset.Angle", angle, f"{name}_angle")
+
+ doc.recompute()
+ return plane
+
+
+def _find_cylinder_vertex(obj, face_subname):
+ """Find a vertex subname from a cylindrical face's edges."""
+ face = obj.getSubObject(face_subname)
+ if not face or not face.Edges:
+ return None
+ edge = face.Edges[0]
+ if not edge.Vertexes:
+ return None
+ vertex_point = edge.Vertexes[0].Point
+ for i, v in enumerate(obj.Shape.Vertexes, 1):
+ if v.Point.isEqual(vertex_point, 1e-6):
+ return f"Vertex{i}"
+ return None
+
+
+def _vertex_angle_on_cylinder(obj, vertex_sub, cylinder):
+ """Compute the angular position of a vertex on a cylinder surface."""
+ vertex = obj.getSubObject(vertex_sub)
+ if not vertex:
+ return 0.0
+ relative = vertex.Point - cylinder.Center
+ axis = cylinder.Axis
+ radial = relative - axis * relative.dot(axis)
+ if radial.Length < 1e-10:
+ return 0.0
+ radial.normalize()
+ if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99:
+ local_x = axis.cross(App.Vector(1, 0, 0)).normalize()
+ else:
+ local_x = axis.cross(App.Vector(0, 1, 0)).normalize()
+ local_y = axis.cross(local_x)
+ return math.degrees(math.atan2(radial.dot(local_y), radial.dot(local_x)))
+
+
+def plane_tangent_to_cylinder(
+ face,
+ *,
+ angle=0,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum plane tangent to cylindrical face at angle."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not isinstance(face.Surface, Part.Cylinder):
+ raise ValueError("Face must be cylindrical")
+
+ cyl = face.Surface
+ axis = cyl.Axis
+ center = cyl.Center
+ radius = cyl.Radius
+
+ rad = math.radians(angle)
+ if abs(axis.dot(App.Vector(1, 0, 0))) < 0.99:
+ local_x = axis.cross(App.Vector(1, 0, 0)).normalize()
+ else:
+ local_x = axis.cross(App.Vector(0, 1, 0)).normalize()
+ local_y = axis.cross(local_x)
+
+ radial = local_x * math.cos(rad) + local_y * math.sin(rad)
+ tangent_point = center + radial * radius
+
+ if name is None:
+ idx = _get_next_index(doc, "DPlane_Tangent")
+ name = f"DPlane_Tangent_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), radial)
+ placement = App.Placement(tangent_point, rot)
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ plane = body.newObject("PartDesign::Plane", name)
+ vertex_sub = (
+ _find_cylinder_vertex(source_object, source_subname)
+ if source_object and source_subname
+ else None
+ )
+ if vertex_sub:
+ vertex_angle = _vertex_angle_on_cylinder(source_object, vertex_sub, cyl)
+ offset_angle = angle - vertex_angle
+ offset_rot = App.Rotation(App.Vector(0, 0, 1), offset_angle)
+ att_offset = App.Placement(App.Vector(0, 0, 0), offset_rot)
+ _setup_datum(
+ plane,
+ placement,
+ "tangent_cylinder",
+ {"angle": angle, "radius": radius, "vertex_angle": vertex_angle},
+ source_refs,
+ is_plane=True,
+ map_mode="TangentPlane",
+ support=[(source_object, source_subname), (source_object, vertex_sub)],
+ offset=att_offset,
+ )
+ else:
+ _setup_datum(
+ plane,
+ placement,
+ "tangent_cylinder",
+ {"angle": angle, "radius": radius},
+ source_refs,
+ is_plane=True,
+ )
+
+ doc.recompute()
+ return plane
+
+
+# ==========================================================================
+# DATUM AXES
+# ==========================================================================
+
+
+def axis_from_2_points(
+ p1,
+ p2,
+ *,
+ name=None,
+ body=None,
+ source_refs=None,
+):
+ """Create datum axis from two points."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ direction = p2 - p1
+ if direction.Length < 1e-6:
+ raise ValueError("Points must be distinct")
+ length = direction.Length
+ direction.normalize()
+ midpoint = (p1 + p2) * 0.5
+
+ if name is None:
+ idx = _get_next_index(doc, "DAxis_2Pt")
+ name = f"DAxis_2Pt_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+
+ axis = body.newObject("PartDesign::Line", name)
+ _setup_datum(
+ axis,
+ placement,
+ "2_points",
+ {"p1": p1, "p2": p2, "direction": direction, "length": length},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_from_edge(
+ edge,
+ *,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum axis from linear edge."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not isinstance(edge.Curve, Part.Line):
+ raise ValueError("Edge must be linear")
+
+ if name is None:
+ idx = _get_next_index(doc, "DAxis_Edge")
+ name = f"DAxis_Edge_{idx:03d}"
+
+ p1 = edge.valueAt(edge.FirstParameter)
+ p2 = edge.valueAt(edge.LastParameter)
+ direction = (p2 - p1).normalize()
+ midpoint = (p1 + p2) * 0.5
+
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ axis = body.newObject("PartDesign::Line", name)
+ _setup_datum(
+ axis,
+ placement,
+ "from_edge",
+ {"p1": p1, "p2": p2, "direction": direction},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_cylinder_center(
+ face,
+ *,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum axis at center of cylindrical face."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not isinstance(face.Surface, Part.Cylinder):
+ raise ValueError("Face must be cylindrical")
+
+ cyl = face.Surface
+ center = cyl.Center
+ axis_dir = cyl.Axis
+
+ if name is None:
+ idx = _get_next_index(doc, "DAxis_Cyl")
+ name = f"DAxis_Cyl_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), axis_dir)
+ placement = App.Placement(center, rot)
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ axis = body.newObject("PartDesign::Line", name)
+ _setup_datum(
+ axis,
+ placement,
+ "cylinder_center",
+ {"center": center, "direction": axis_dir, "radius": cyl.Radius},
+ source_refs,
+ )
+
+ doc.recompute()
+ return axis
+
+
+def axis_intersection_planes(
+ plane1,
+ plane2,
+ *,
+ name=None,
+ body=None,
+ source_object1=None,
+ source_subname1=None,
+ source_object2=None,
+ source_subname2=None,
+):
+ """Create datum axis at intersection of two planes."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ shape1 = plane1.Shape if hasattr(plane1, "Shape") else plane1
+ shape2 = plane2.Shape if hasattr(plane2, "Shape") else plane2
+
+ common = shape1.common(shape2)
+ if not common.Edges:
+ raise ValueError("Planes do not intersect or are parallel")
+
+ 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, "DAxis_Intersect")
+ name = f"DAxis_Intersect_{idx:03d}"
+
+ rot = App.Rotation(App.Vector(0, 0, 1), direction)
+ placement = App.Placement(midpoint, rot)
+
+ source_refs = []
+ if source_object1:
+ source_refs.append((source_object1, source_subname1))
+ if source_object2:
+ source_refs.append((source_object2, source_subname2))
+
+ axis = body.newObject("PartDesign::Line", name)
+ _setup_datum(
+ axis,
+ placement,
+ "plane_intersection",
+ {"point1": p1, "point2": p2, "direction": direction},
+ source_refs or None,
+ )
+
+ doc.recompute()
+ return axis
+
+
+# ==========================================================================
+# DATUM POINTS
+# ==========================================================================
+
+
+def point_at_vertex(
+ vertex,
+ *,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum point at vertex location."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ pos = vertex.Point
+
+ if name is None:
+ idx = _get_next_index(doc, "DPoint_Vtx")
+ name = f"DPoint_Vtx_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ point = body.newObject("PartDesign::Point", name)
+ _setup_datum(
+ point,
+ placement,
+ "vertex",
+ {"position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_at_coordinates(
+ x,
+ y,
+ z,
+ *,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+):
+ """Create datum point at XYZ coordinates."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if name is None:
+ idx = _get_next_index(doc, "DPoint_XYZ")
+ name = f"DPoint_XYZ_{idx:03d}"
+
+ pos = App.Vector(x, y, z)
+ placement = App.Placement(pos, App.Rotation())
+
+ point = body.newObject("PartDesign::Point", name)
+ _setup_datum(
+ point,
+ placement,
+ "coordinates",
+ {"x": x, "y": y, "z": z},
+ None,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_on_edge(
+ edge,
+ *,
+ parameter=0.5,
+ name=None,
+ body=None,
+ link_spreadsheet=False,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum point on edge at parameter (0-1)."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ param = edge.FirstParameter + parameter * (edge.LastParameter - edge.FirstParameter)
+ pos = edge.valueAt(param)
+
+ if name is None:
+ idx = _get_next_index(doc, "DPoint_Edge")
+ name = f"DPoint_Edge_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ point = body.newObject("PartDesign::Point", name)
+ _setup_datum(
+ point,
+ placement,
+ "on_edge",
+ {"parameter": parameter, "position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_center_of_face(
+ face,
+ *,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum point at center of mass of face."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ pos = face.CenterOfMass
+
+ if name is None:
+ idx = _get_next_index(doc, "DPoint_FaceCenter")
+ name = f"DPoint_FaceCenter_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ point = body.newObject("PartDesign::Point", name)
+ _setup_datum(
+ point,
+ placement,
+ "face_center",
+ {"position": pos},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
+
+
+def point_center_of_circle(
+ edge,
+ *,
+ name=None,
+ body=None,
+ source_object=None,
+ source_subname=None,
+):
+ """Create datum point at center of circular edge."""
+ doc = App.ActiveDocument
+ if body is None:
+ body = _get_active_body()
+
+ if not isinstance(edge.Curve, (Part.Circle, Part.ArcOfCircle)):
+ raise ValueError("Edge must be circular")
+
+ pos = edge.Curve.Center
+
+ if name is None:
+ idx = _get_next_index(doc, "DPoint_CircleCenter")
+ name = f"DPoint_CircleCenter_{idx:03d}"
+
+ placement = App.Placement(pos, App.Rotation())
+ source_refs = [(source_object, source_subname)] if source_object else None
+
+ point = body.newObject("PartDesign::Point", name)
+ _setup_datum(
+ point,
+ placement,
+ "circle_center",
+ {"position": pos, "radius": edge.Curve.Radius},
+ source_refs,
+ )
+
+ doc.recompute()
+ return point
diff --git a/mods/datums/datums/detection.py b/mods/datums/datums/detection.py
new file mode 100644
index 0000000000..3b96e648ae
--- /dev/null
+++ b/mods/datums/datums/detection.py
@@ -0,0 +1,169 @@
+"""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
diff --git a/mods/datums/datums/edit_panel.py b/mods/datums/datums/edit_panel.py
new file mode 100644
index 0000000000..4936f72498
--- /dev/null
+++ b/mods/datums/datums/edit_panel.py
@@ -0,0 +1,334 @@
+"""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
diff --git a/mods/datums/datums/panel.py b/mods/datums/datums/panel.py
new file mode 100644
index 0000000000..af185d273b
--- /dev/null
+++ b/mods/datums/datums/panel.py
@@ -0,0 +1,605 @@
+"""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
diff --git a/mods/datums/datums/resources/icons/axis_2pt.svg b/mods/datums/datums/resources/icons/axis_2pt.svg
new file mode 100644
index 0000000000..439f092fc1
--- /dev/null
+++ b/mods/datums/datums/resources/icons/axis_2pt.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/axis_cyl.svg b/mods/datums/datums/resources/icons/axis_cyl.svg
new file mode 100644
index 0000000000..0e723e7dab
--- /dev/null
+++ b/mods/datums/datums/resources/icons/axis_cyl.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/axis_edge.svg b/mods/datums/datums/resources/icons/axis_edge.svg
new file mode 100644
index 0000000000..ab54592d17
--- /dev/null
+++ b/mods/datums/datums/resources/icons/axis_edge.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/axis_intersect.svg b/mods/datums/datums/resources/icons/axis_intersect.svg
new file mode 100644
index 0000000000..4069f734ae
--- /dev/null
+++ b/mods/datums/datums/resources/icons/axis_intersect.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/datum_creator.svg b/mods/datums/datums/resources/icons/datum_creator.svg
new file mode 100644
index 0000000000..de02931b3c
--- /dev/null
+++ b/mods/datums/datums/resources/icons/datum_creator.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_3pt.svg b/mods/datums/datums/resources/icons/plane_3pt.svg
new file mode 100644
index 0000000000..4ed7efb1e9
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_3pt.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_angled.svg b/mods/datums/datums/resources/icons/plane_angled.svg
new file mode 100644
index 0000000000..6790fcdfa4
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_angled.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_midplane.svg b/mods/datums/datums/resources/icons/plane_midplane.svg
new file mode 100644
index 0000000000..bdd5ea96ae
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_midplane.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_normal.svg b/mods/datums/datums/resources/icons/plane_normal.svg
new file mode 100644
index 0000000000..e564b74c80
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_normal.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_offset.svg b/mods/datums/datums/resources/icons/plane_offset.svg
new file mode 100644
index 0000000000..7f1f35bcda
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_offset.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/plane_tangent.svg b/mods/datums/datums/resources/icons/plane_tangent.svg
new file mode 100644
index 0000000000..b683e2f3ae
--- /dev/null
+++ b/mods/datums/datums/resources/icons/plane_tangent.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/point_circle.svg b/mods/datums/datums/resources/icons/point_circle.svg
new file mode 100644
index 0000000000..b05da8bc34
--- /dev/null
+++ b/mods/datums/datums/resources/icons/point_circle.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/point_edge.svg b/mods/datums/datums/resources/icons/point_edge.svg
new file mode 100644
index 0000000000..7032701305
--- /dev/null
+++ b/mods/datums/datums/resources/icons/point_edge.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/point_face.svg b/mods/datums/datums/resources/icons/point_face.svg
new file mode 100644
index 0000000000..887201af3d
--- /dev/null
+++ b/mods/datums/datums/resources/icons/point_face.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/point_vertex.svg b/mods/datums/datums/resources/icons/point_vertex.svg
new file mode 100644
index 0000000000..4db1731423
--- /dev/null
+++ b/mods/datums/datums/resources/icons/point_vertex.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/mods/datums/datums/resources/icons/point_xyz.svg b/mods/datums/datums/resources/icons/point_xyz.svg
new file mode 100644
index 0000000000..37a355a363
--- /dev/null
+++ b/mods/datums/datums/resources/icons/point_xyz.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/mods/datums/package.xml b/mods/datums/package.xml
new file mode 100644
index 0000000000..50e484f4ad
--- /dev/null
+++ b/mods/datums/package.xml
@@ -0,0 +1,24 @@
+
+
+ datums
+ Unified datum creator for Kindred Create
+ 0.1.0
+ Kindred Systems
+ LGPL-2.1-or-later
+ https://git.kindred-systems.com/kindred/create
+
+
+
+ DatumCommandProvider
+ datums
+
+
+
+
+ 0.1.0
+ 45
+
+ sdk
+
+
+
diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt
index 021bef1bba..4d773a572d 100644
--- a/src/Mod/Create/CMakeLists.txt
+++ b/src/Mod/Create/CMakeLists.txt
@@ -85,3 +85,39 @@ install(
DESTINATION
mods/solver
)
+
+# Install Gears addon
+install(
+ DIRECTORY
+ ${CMAKE_SOURCE_DIR}/mods/gears/freecad
+ DESTINATION
+ mods/gears
+)
+install(
+ DIRECTORY
+ ${CMAKE_SOURCE_DIR}/mods/gears/pygears
+ DESTINATION
+ mods/gears
+)
+install(
+ FILES
+ ${CMAKE_SOURCE_DIR}/mods/gears/package.xml
+ DESTINATION
+ mods/gears
+)
+
+# Install Datums addon
+install(
+ DIRECTORY
+ ${CMAKE_SOURCE_DIR}/mods/datums/datums
+ DESTINATION
+ mods/datums
+)
+install(
+ FILES
+ ${CMAKE_SOURCE_DIR}/mods/datums/package.xml
+ ${CMAKE_SOURCE_DIR}/mods/datums/Init.py
+ ${CMAKE_SOURCE_DIR}/mods/datums/InitGui.py
+ DESTINATION
+ mods/datums
+)