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 @@ + + + + + + + + t + \ 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 +)