# *************************************************************************** # * Copyright (c) 2009, 2010 Ken Cline * # * Copyright (c) 2023 FreeCAD Project Association * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program is distributed in the hope that it will be useful, * # * but WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * # * GNU Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** """Provide the working plane code and utilities for the Draft Workbench. This module provides the plane class which provides a virtual working plane in FreeCAD and a couple of utility functions. The working plane is mostly intended to be used in the Draft Workbench to draw 2D objects in various orientations, not only in the standard XY, YZ, and XZ planes. """ ## @package WorkingPlane # \ingroup DRAFT # \brief This module handles the Working Plane and grid of the Draft module. # # This module provides the plane class which provides a virtual working plane # in FreeCAD and a couple of utility functions. import math import lazy_loader.lazy_loader as lz from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import DraftVecUtils from FreeCAD import Vector from draftutils import gui_utils from draftutils import params from draftutils import utils from draftutils.messages import _wrn from draftutils.translate import translate DraftGeomUtils = lz.LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils") Part = lz.LazyLoader("Part", globals(), "Part") FreeCADGui = lz.LazyLoader("FreeCADGui", globals(), "FreeCADGui") __title__ = "FreeCAD Working Plane utility" __author__ = "Ken Cline" __url__ = "https://www.freecad.org" class PlaneBase: """PlaneBase is the base class for the Plane class and the PlaneGui class. Parameters ---------- u: Base.Vector or WorkingPlane.PlaneBase, optional Defaults to Vector(1, 0, 0). If a WP is provided: A copy of the WP is created, all other parameters are then ignored. If a vector is provided: Unit vector for the `u` attribute (+X axis). v: Base.Vector, optional Defaults to Vector(0, 1, 0). Unit vector for the `v` attribute (+Y axis). w: Base.Vector, optional Defaults to Vector(0, 0, 1). Unit vector for the `axis` attribute (+Z axis). pos: Base.Vector, optional Defaults to Vector(0, 0, 0). Vector for the `position` attribute (origin). Note that the u, v and w vectors are not checked for validity. """ def __init__(self, u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1), pos=Vector(0, 0, 0)): if isinstance(u, PlaneBase): self.match(u) return self.u = Vector(u) self.v = Vector(v) self.axis = Vector(w) self.position = Vector(pos) def __repr__(self): text = "Workplane" text += " x=" + str(DraftVecUtils.rounded(self.u)) text += " y=" + str(DraftVecUtils.rounded(self.v)) text += " z=" + str(DraftVecUtils.rounded(self.axis)) text += " pos=" + str(DraftVecUtils.rounded(self.position)) return text def copy(self): """Return a new WP that is a copy of the present object.""" wp = PlaneBase() self.match(source=self, target=wp) return wp def _copy_value(self, val): """Return a copy of a value, primarily required for vectors.""" return val.__class__(val) def match(self, source, target=None): """Match the main properties of two working planes. Parameters ---------- source: WP object WP to copy properties from. target: WP object, optional Defaults to `None`. WP to copy properties to. If `None` the present object is used. """ if target is None: target = self for prop in self._get_prop_list(): setattr(target, prop, self._copy_value(getattr(source, prop))) def get_parameters(self): """Return a data dictionary with the main properties of the WP.""" data = {} for prop in self._get_prop_list(): data[prop] = self._copy_value(getattr(self, prop)) return data def set_parameters(self, data): """Set the main properties of the WP according to a data dictionary.""" for prop in self._get_prop_list(): setattr(self, prop, self._copy_value(data[prop])) def align_to_3_points(self, p1, p2, p3, offset=0): """Align the WP to 3 points with an optional offset. The points must define a plane. Parameters ---------- p1: Base.Vector New WP `position`. p2: Base.Vector Point on the +X axis. (p2 - p1) defines the WP `u` axis. p3: Base.Vector Defines the plane. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True`/`False` `True` if successful. """ return self.align_to_edge_or_wire(Part.makePolygon([p1, p2, p3]), offset) def align_to_edges_vertexes(self, shapes, offset=0): """Align the WP to the endpoints of edges and/or the points of vertexes with an optional offset. The points must define a plane. The first 2 points define the WP `position` and `u` axis. Parameters ---------- shapes: iterable One or more edges and/or vertexes. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True`/`False` `True` if successful. """ points = [vert.Point for shape in shapes for vert in shape.Vertexes] if len(points) < 2: return False return self.align_to_edge_or_wire(Part.makePolygon(points), offset) def align_to_edge_or_wire(self, shape, offset=0): """Align the WP to an edge or wire with an optional offset. The shape must define a plane. If the shape is an edge with a `Center` then that defines the WP `position`. The vector between the center and its start point then defines the WP `u` axis. In other cases the start point of the first edge defines the WP `position` and the 1st derivative at that point the WP `u` axis. Parameters ---------- shape: Part.Edge or Part.Wire Edge or wire. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True`/`False` `True` if successful. """ tol = 1e-7 plane = shape.findPlane() if plane is None: return False self.axis = plane.Axis if shape.ShapeType == "Edge" and hasattr(shape.Curve, "Center"): pos = shape.Curve.Center vec = shape.Vertexes[0].Point - pos if vec.Length > tol: self.u = vec self.u.normalize() self.v = self.axis.cross(self.u) else: self.u, self.v, _ = self._axes_from_rotation(plane.Rotation) elif shape.Edges[0].Length > tol: pos = shape.Vertexes[0].Point self.u = shape.Edges[0].derivative1At(0) self.u.normalize() self.v = self.axis.cross(self.u) else: pos = shape.Vertexes[0].Point self.u, self.v, _ = self._axes_from_rotation(plane.Rotation) self.position = pos + (self.axis * offset) return True def align_to_face(self, shape, offset=0): """Align the WP to a face with an optional offset. The face must be planar. The center of gravity of the face defines the WP `position` and the normal of the face the WP `axis`. The WP `u` and `v` vectors are determined by the DraftGeomUtils.uv_vectors_from_face function. See there. Parameters ---------- shape: Part.Face Face. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True`/`False` `True` if successful. """ if shape.Surface.isPlanar() is False: return False place = DraftGeomUtils.placement_from_face(shape) self.u, self.v, self.axis = self._axes_from_rotation(place.Rotation) self.position = place.Base + (self.axis * offset) return True def align_to_placement(self, place, offset=0): """Align the WP to a placement with an optional offset. Parameters ---------- place: Base.Placement Placement. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True` """ self.u, self.v, self.axis = self._axes_from_rotation(place.Rotation) self.position = place.Base + (self.axis * offset) return True def align_to_point_and_axis(self, point, axis, offset=0, upvec=Vector(1, 0, 0)): """Align the WP to a point and an axis with an optional offset and an optional up-vector. If the axis and up-vector are parallel the FreeCAD.Rotation algorithm will replace the up-vector: Vector(0, 0, 1) is tried first, then Vector(0, 1, 0), and finally Vector(1, 0, 0). Parameters ---------- point: Base.Vector New WP `position`. axis: Base.Vector New WP `axis`. offset: float, optional Defaults to zero. Offset along the WP `axis`. upvec: Base.Vector, optional Defaults to Vector(1, 0, 0). Up-vector. Returns ------- `True` """ tol = 1e-7 if axis.Length < tol: return False if upvec.Length < tol: return False axis = Vector(axis).normalize() upvec = Vector(upvec).normalize() if axis.isEqual(upvec, tol) or axis.isEqual(upvec.negative(), tol): upvec = axis rot = FreeCAD.Rotation(Vector(), upvec, axis, "ZYX") self.u, self.v, _ = self._axes_from_rotation(rot) self.axis = axis self.position = point + (self.axis * offset) return True def align_to_point_and_axis_svg(self, point, axis, offset=0): """Align the WP to a point and an axis with an optional offset. It aligns `u` and `v` based on the magnitude of the components of `axis`. Parameters ---------- point: Base.Vector The new `position` of the plane, adjusted by the `offset`. axis: Base.Vector A vector whose unit vector will be used as the new `axis` of the plane. The magnitudes of the `x`, `y`, `z` components of the axis determine the orientation of `u` and `v` of the plane. offset: float, optional Defaults to zero. A value which will be used to offset the plane in the direction of its `axis`. Returns ------- `True` Cases ----- The `u` and `v` are always calculated the same * `u` is the cross product of the positive or negative of `axis` with a `reference vector`. :: u = [+1|-1] axis.cross(ref_vec) * `v` is `u` rotated 90 degrees around `axis`. Whether the `axis` is positive or negative, and which reference vector is used, depends on the absolute values of the `x`, `y`, `z` components of the `axis` unit vector. #. If `x > y`, and `y > z` The reference vector is +Z :: u = -1 axis.cross(+Z) #. If `y > z`, and `z >= x` The reference vector is +X. :: u = -1 axis.cross(+X) #. If `y >= x`, and `x > z` The reference vector is +Z. :: u = +1 axis.cross(+Z) #. If `x > z`, and `z >= y` The reference vector is +Y. :: u = +1 axis.cross(+Y) #. If `z >= y`, and `y > x` The reference vector is +X. :: u = +1 axis.cross(+X) #. otherwise The reference vector is +Y. :: u = -1 axis.cross(+Y) """ self.axis = Vector(axis).normalize() ref_vec = Vector(0.0, 1.0, 0.0) if ((abs(axis.x) > abs(axis.y)) and (abs(axis.y) > abs(axis.z))): ref_vec = Vector(0.0, 0., 1.0) self.u = axis.negative().cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "Case new" elif ((abs(axis.y) > abs(axis.z)) and (abs(axis.z) >= abs(axis.x))): ref_vec = Vector(1.0, 0.0, 0.0) self.u = axis.negative().cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "Y>Z, View Y" elif ((abs(axis.y) >= abs(axis.x)) and (abs(axis.x) > abs(axis.z))): ref_vec = Vector(0.0, 0., 1.0) self.u = axis.cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "ehem. XY, Case XY" elif ((abs(axis.x) > abs(axis.z)) and (abs(axis.z) >= abs(axis.y))): self.u = axis.cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "X>Z, View X" elif ((abs(axis.z) >= abs(axis.y)) and (abs(axis.y) > abs(axis.x))): ref_vec = Vector(1.0, 0., 0.0) self.u = axis.cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "Y>X, Case YZ" else: self.u = axis.negative().cross(ref_vec) self.u.normalize() self.v = DraftVecUtils.rotate(self.u, math.pi/2, self.axis) # projcase = "else" # spat_vec = self.u.cross(self.v) # spat_res = spat_vec.dot(axis) # Console.PrintMessage(projcase + " spat Prod = " + str(spat_res) + "\n") offsetVector = Vector(axis) offsetVector.multiply(offset) self.position = point.add(offsetVector) return True def get_global_coords(self, point, as_vector=False): """Translate a point or vector from the local (WP) coordinate system to the global coordinate system. Parameters ---------- point: Base.Vector Point. as_vector: bool, optional Defaults to `False`. If `True` treat point as a vector. Returns ------- Base.Vector """ pos = Vector() if as_vector else self.position mtx = FreeCAD.Matrix(self.u, self.v, self.axis, pos) return mtx.multVec(point) def get_local_coords(self, point, as_vector=False): """Translate a point or vector from the global coordinate system to the local (WP) coordinate system. Parameters ---------- point: Base.Vector Point. as_vector: bool, optional Defaults to `False`. If `True` treat point as a vector. Returns ------- Base.Vector """ pos = Vector() if as_vector else self.position mtx = FreeCAD.Matrix(self.u, self.v, self.axis, pos) return mtx.inverse().multVec(point) def get_closest_axis(self, vec): """Return a string indicating the positive or negative WP axis closest to a vector. Parameters ---------- vec: Base.Vector Vector. Returns ------- str `"x"`, `"y"` or `"z"`. """ xyz = list(self.get_local_coords(vec, as_vector=True)) x, y, z = [abs(coord) for coord in xyz] if x >= y and x >= z: return "x" elif y >= x and y >= z: return "y" else: return "z" def get_placement(self): """Return a placement calculated from the WP.""" return FreeCAD.Placement(self.position, FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX")) def is_global(self): """Return `True` if the WP matches the global coordinate system exactly.""" return self.u == Vector(1, 0, 0) \ and self.v == Vector(0, 1, 0) \ and self.axis == Vector(0, 0, 1) \ and self.position == Vector() def is_ortho(self): """Return `True` if all WP axes are parallel to a global axis.""" rot = FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX") ypr = [round(ang, 6) for ang in rot.getYawPitchRoll()] return all([ang%90 == 0 for ang in ypr]) def project_point(self, point, direction=None, force_projection=True): """Project a point onto the WP and return the global coordinates of the projected point. Parameters ---------- point: Base.Vector Point to project. direction: Base.Vector, optional Defaults to `None` in which case the WP `axis` is used. Direction of projection. force_projection: bool, optional Defaults to `True`. See DraftGeomUtils.project_point_on_plane Returns ------- Base.Vector """ return DraftGeomUtils.project_point_on_plane(point, self.position, self.axis, direction, force_projection) def set_to_top(self, offset=0): """Set the WP to the top position with an optional offset.""" self.u = Vector(1, 0, 0) self.v = Vector(0, 1, 0) self.axis = Vector(0, 0, 1) self.position = self.axis * offset def set_to_front(self, offset=0): """Set the WP to the front position with an optional offset.""" self.u = Vector(1, 0, 0) self.v = Vector(0, 0, 1) self.axis = Vector(0, -1, 0) self.position = self.axis * offset def set_to_side(self, offset=0): """Set the WP to the right side position with an optional offset.""" self.u = Vector(0, 1, 0) self.v = Vector(0, 0, 1) self.axis = Vector(1, 0, 0) self.position = self.axis * offset def _axes_from_rotation(self, rot): """Return a tuple with the `u`, `v` and `axis` vectors from a Base.Rotation.""" mtx = rot.toMatrix() return mtx.col(0), mtx.col(1), mtx.col(2) def _axes_from_view_rotation(self, rot): """Return a tuple with the `u`, `v` and `axis` vectors from a Base.Rotation derived from a view. The Yaw, Pitch and Roll angles are rounded if they are near multiples of 45 degrees. """ ypr = [round(ang, 3) for ang in rot.getYawPitchRoll()] if all([ang%45 == 0 for ang in ypr]): rot.setEulerAngles("YawPitchRoll", *ypr) return self._axes_from_rotation(rot) def _get_prop_list(self): return ["u", "v", "axis", "position"] class Plane(PlaneBase): """The old Plane class. Parameters ---------- u: Base.Vector or WorkingPlane.Plane, optional Defaults to Vector(1, 0, 0). If a WP is provided: A copy of the WP is created, all other parameters are then ignored. If a vector is provided: Unit vector for the `u` attribute (+X axis). v: Base.Vector, optional Defaults to Vector(0, 1, 0). Unit vector for the `v` attribute (+Y axis). w: Base.Vector, optional Defaults to Vector(0, 0, 1). Unit vector for the `axis` attribute (+Z axis). pos: Base.Vector, optional Defaults to Vector(0, 0, 0). Vector for the `position` attribute (origin). weak: bool, optional Defaults to `True`. If `True` the WP is in "Auto" mode and will adapt to the current view. Note that the u, v and w vectors are not checked for validity. Other attributes ---------------- stored: None/list Placeholder for a stored state. """ def __init__(self, u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1), pos=Vector(0, 0, 0), weak=True): if isinstance(u, Plane): self.match(u) return super().__init__(u, v, w, pos) self.weak = weak # a placeholder for a stored state self.stored = None def copy(self): """See PlaneBase.copy.""" wp = Plane() self.match(source=self, target=wp) return wp def offsetToPoint(self, point, direction=None): """Return the signed distance from a point to the plane. The direction argument is ignored. The returned value is the negative of the local Z coordinate of the point. """ return -DraftGeomUtils.distance_to_plane(point, self.position, self.axis) def projectPoint(self, point, direction=None, force_projection=True): """See PlaneBase.project_point.""" return super().project_point(point, direction, force_projection) def alignToPointAndAxis(self, point, axis, offset=0, upvec=None): """See PlaneBase.align_to_point_and_axis.""" if upvec is None: upvec = Vector(1, 0, 0) super().align_to_point_and_axis(point, axis, offset, upvec) self.weak = False return True def alignToPointAndAxis_SVG(self, point, axis, offset=0): """See PlaneBase.align_to_point_and_axis_svg.""" super().align_to_point_and_axis_svg(point, axis, offset) self.weak = False return True def alignToCurve(self, shape, offset=0): """Align the WP to a curve. NOT IMPLEMENTED. Parameters ---------- shape: Part.Shape Edge or Wire. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `False` """ return False def alignToEdges(self, shapes, offset=0): """Align the WP to edges with an optional offset. The eges must define a plane. The endpoints of the first edge defines the WP `position` and `u` axis. Parameters ---------- shapes: iterable Two edges. offset: float, optional Defaults to zero. Offset along the WP `axis`. Returns ------- `True`/`False` `True` if successful. """ if super().align_to_edges_vertexes(shapes, offset) is False: return False self.weak = False return True def alignToFace(self, shape, offset=0, parent=None): """Align the WP to a face with an optional offset. The face must be planar. The center of gravity of the face defines the WP `position` and the normal of the face the WP `axis`. The WP `u` and `v` vectors are determined by the DraftGeomUtils.uv_vectors_from_face function. See there. Parameters ---------- shape: Part.Face Face. offset: float, optional Defaults to zero. Offset along the WP `axis`. parent: object Defaults to `None`. The ParentGeoFeatureGroup of the object the face belongs to. Returns ------- `True`/`False` `True` if successful. """ if shape.ShapeType == "Face" and shape.Surface.isPlanar(): place = DraftGeomUtils.placement_from_face(shape) if parent: place = parent.getGlobalPlacement() * place super().align_to_placement(place, offset) self.weak = False return True else: return False def alignTo3Points(self, p1, p2, p3, offset=0): """Align the plane to a temporary face created from three points. Parameters ---------- p1: Base.Vector First point. p2: Base.Vector Second point. p3: Base.Vector Third point. offset: float, optional Defaults to zero. Offset along the WP `axis` Returns ------- `True`/`False` `True` if successful. """ w = Part.makePolygon([p1, p2, p3, p1]) f = Part.Face(w) return self.alignToFace(f, offset) def alignToSelection(self, offset=0): """Align the plane to a selection with an optional offset. The selection must define a plane. Parameter --------- offset: float, optional Defaults to zero. Offset along the WP `axis` Returns ------- `True`/`False` `True` if successful. """ sel_ex = FreeCADGui.Selection.getSelectionEx() if not sel_ex: return False shapes = list() names = list() for obj in sel_ex: # check that the geometric property is a Part.Shape object geom_is_shape = False if isinstance(obj.Object, FreeCAD.GeoFeature): geom = obj.Object.getPropertyOfGeometry() if isinstance(geom, Part.Shape): geom_is_shape = True if not geom_is_shape: _wrn(translate( "draft", "Object without Part.Shape geometry:'{}'".format( obj.ObjectName)) + "\n") return False if geom.isNull(): _wrn(translate( "draft", "Object with null Part.Shape geometry:'{}'".format( obj.ObjectName)) + "\n") return False if obj.HasSubObjects: shapes.extend(obj.SubObjects) names.extend([obj.ObjectName + "." + n for n in obj.SubElementNames]) else: shapes.append(geom) names.append(obj.ObjectName) normal = None for n in range(len(shapes)): if not DraftGeomUtils.is_planar(shapes[n]): _wrn(translate( "draft", "'{}' object is not planar".format(names[n])) + "\n") return False if not normal: normal = DraftGeomUtils.get_normal(shapes[n]) shape_ref = n # test if all shapes are coplanar if normal: for n in range(len(shapes)): if not DraftGeomUtils.are_coplanar(shapes[shape_ref], shapes[n]): _wrn(translate( "draft", "{} and {} aren't coplanar".format( names[shape_ref],names[n])) + "\n") return False else: # suppose all geometries are straight lines or points points = [vertex.Point for shape in shapes for vertex in shape.Vertexes] if len(points) >= 3: poly = Part.makePolygon(points) if not DraftGeomUtils.is_planar(poly): _wrn(translate( "draft", "All Shapes must be coplanar") + "\n") return False normal = DraftGeomUtils.get_normal(poly) else: normal = None if not normal: _wrn(translate( "draft", "Selected Shapes must define a plane") + "\n") return False # set center of mass ctr_mass = Vector(0,0,0) ctr_pts = Vector(0,0,0) mass = 0 for shape in shapes: if hasattr(shape, "CenterOfMass"): ctr_mass += shape.CenterOfMass*shape.Mass mass += shape.Mass else: ctr_pts += shape.Point if mass > 0: ctr_mass /= mass # all shapes are vertexes else: ctr_mass = ctr_pts/len(shapes) super().align_to_point_and_axis(ctr_mass, normal, offset) self.weak = False return True def setup(self, direction=None, point=None, upvec=None, force=False): """Set up the working plane if it exists but is undefined. If `direction` and `point` are present, it calls `align_to_point_and_axis(point, direction, 0, upvec)`. Otherwise, it gets the camera orientation to define a working plane that is perpendicular to the current view, centered at the origin, and with `v` pointing up on the screen. This method only works when the `weak` attribute is `True`. This method also sets `weak` to `True`. This method only works when `FreeCAD.GuiUp` is `True`, that is, when the graphical interface is loaded. Otherwise it fails silently. Parameters ---------- direction: Base.Vector, optional It defaults to `None`. It is the new `axis` of the plane. point: Base.Vector, optional It defaults to `None`. It is the new `position` of the plane. upvec: Base.Vector, optional It defaults to `None`. It is the new `v` orientation of the plane. force: bool If True, it sets the plane even if the plane is not in weak mode """ if self.weak or force: if direction is not None and point is not None: super().align_to_point_and_axis(point, direction, 0, upvec) elif FreeCAD.GuiUp: try: view = gui_utils.get_3d_view() if view is not None: cam = view.getCameraNode() rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue()) self.u, self.v, self.axis = self._axes_from_view_rotation(rot) self.position = Vector() except Exception: pass if force: self.weak = False else: self.weak = True def reset(self): """Reset the WP. Sets the `weak` attribute to `True`. """ self.weak = True def setTop(self): """Set the WP to the top position and updates the GUI.""" super().set_to_top() self.weak = False if FreeCAD.GuiUp: from draftutils.translate import translate if hasattr(FreeCADGui,"Snapper"): FreeCADGui.Snapper.setGrid() if hasattr(FreeCADGui,"draftToolBar"): FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Top")) def setFront(self): """Set the WP to the front position and updates the GUI.""" super().set_to_front() self.weak = False if FreeCAD.GuiUp: from draftutils.translate import translate if hasattr(FreeCADGui,"Snapper"): FreeCADGui.Snapper.setGrid() if hasattr(FreeCADGui,"draftToolBar"): FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Front")) def setSide(self): """Set the WP to the left side position and updates the GUI. Note that set_to_side from the parent class sets the WP to the right side position. Which matches the Side option from Draft_SelectPlane. """ self.u = Vector(0, -1, 0) self.v = Vector(0, 0, 1) self.axis = Vector(-1, 0, 0) self.position = Vector() self.weak = False if FreeCAD.GuiUp: from draftutils.translate import translate if hasattr(FreeCADGui,"Snapper"): FreeCADGui.Snapper.setGrid() if hasattr(FreeCADGui,"draftToolBar"): FreeCADGui.draftToolBar.wplabel.setText(translate("draft", "Side")) def getRotation(self): """Return a placement describing the WP orientation only.""" return FreeCAD.Placement(Vector(), FreeCAD.Rotation(self.u, self.v, self.axis, "ZYX")) def getPlacement(self, rotated=False): """Return a placement calculated from the WP. The rotated argument is ignored.""" return super().get_placement() def getNormal(self): """Return the normal vector (axis) of the WP.""" return self.axis def setFromPlacement(self, place, rebase=False): """Align the WP to a placement. Parameters ---------- place: Base.Placement Placement. rebase: bool, optional Defaults to `False`. If `False` the `position` of the WP is not changed. """ if rebase: super().align_to_placement(place) else: super()._axes_from_rotation(place.Rotation) def inverse(self): """Invert the direction of the plane. It inverts the `u` and `axis` vectors. """ self.u = self.u.negative() self.axis = self.axis.negative() def save(self): """Store the plane attributes. Store `u`, `v`, `axis`, `position` and `weak` in a list in `stored`. """ self.stored = [self.u, self.v, self.axis, self.position, self.weak] def restore(self): """Restore the plane attributes that were saved. Restores the attributes `u`, `v`, `axis`, `position` and `weak` from `stored`, and set `stored` to `None`. """ if self.stored is not None: self.u, self.v, self.axis, self.position, self.weak = self.stored self.stored = None def getLocalCoords(self, point): """Translate a point from the global coordinate system to the local (WP) coordinate system. """ return super().get_local_coords(point) def getGlobalCoords(self, point): """Translate a point from the local (WP) coordinate system to the global coordinate system. """ return super().get_global_coords(point) def getLocalRot(self, vec): """Translate a vector from the global coordinate system to the local (WP) coordinate system. """ return super().get_local_coords(vec, as_vector=True) def getGlobalRot(self, vec): """Translate a vector from the local (WP) coordinate system to the global coordinate system. """ return super().get_global_coords(vec, as_vector=True) def getClosestAxis(self, vec): """Return the closest WP axis to a vector. Parameters ---------- vec: Base.Vector Vector. Returns ------- str `'x'`, `'y'` or `'z'`. """ return super().get_closest_axis(vec) def isGlobal(self): """Return `True` if the WP matches the global coordinate system.""" return super().is_global() def isOrtho(self): """Return `True` if the WP axes are orthogonal to the global axes.""" return super().is_ortho() def getDeviation(self): """Return the angle between the u axis and the horizontal plane. It defines a projection of `u` on the horizontal plane (without a Z component), and then measures the angle between this projection and `u`. It also considers the cross product of the projection and `u` to determine the sign of the angle. Returns ------- float Angle between the `u` vector, and a projected vector on the global horizontal plane. See Also -------- DraftVecUtils.angle """ proj = Vector(self.u.x, self.u.y, 0) if self.u.getAngle(proj) == 0: return 0 else: norm = proj.cross(self.u) return DraftVecUtils.angle(self.u, proj, norm) def getParameters(self): """Return a dictionary with the data which define the plane: `u`, `v`, `axis`, `position`, `weak`. Returns ------- dict dictionary of the form: {"u":x, "v":v, "axis":axis, "position":position, "weak":weak} """ return super().get_parameters() def setFromParameters(self, data): """Set the plane according to data. Parameters ---------- data: dict dictionary of the form: {"u":x, "v":v, "axis":axis, "position":position, "weak":weak} """ super().set_parameters(data) def _get_prop_list(self): return ["u", "v", "axis", "position", "weak"] plane = Plane class PlaneGui(PlaneBase): """The PlaneGui class. The class handles several GUI related aspects of the WP including a history. Parameters ---------- u: Base.Vector or WorkingPlane.Plane, optional Defaults to Vector(1, 0, 0). If a WP is provided: A copy of the WP is created, all other parameters are then ignored. If a vector is provided: Unit vector for the `u` attribute (+X axis). v: Base.Vector, optional Defaults to Vector(0, 1, 0). Unit vector for the `v` attribute (+Y axis). w: Base.Vector, optional Defaults to Vector(0, 0, 1). Unit vector for the `axis` attribute (+Z axis). pos: Base.Vector, optional Defaults to Vector(0, 0, 0). Vector for the `position` attribute (origin). auto: bool, optional Defaults to `True`. If `True` the WP is in "Auto" mode and will adapt to the current view. icon: str, optional Defaults to ":/icons/view-axonometric.svg". Path to the icon for the draftToolBar. label: str, optional Defaults to "Auto". Label for the draftToolBar. tip: str, optional Defaults to "Current working plane: Auto". Tooltip for the draftToolBar. Note that the u, v and w vectors are not checked for validity. Other attributes ---------------- _view: Gui::View3DInventor Reference to a 3D view. _stored: dict Dictionary for a temporary stored state. _history: dict Dictionary that holds up to 10 stored states. """ def __init__(self, u=Vector(1, 0, 0), v=Vector(0, 1, 0), w=Vector(0, 0, 1), pos=Vector(0, 0, 0), auto=True, icon=":/icons/view-axonometric.svg", label=QT_TRANSLATE_NOOP("draft", "Auto"), tip=QT_TRANSLATE_NOOP("draft", "Current working plane:") + " " + QT_TRANSLATE_NOOP("draft", "Auto")): if isinstance(u, PlaneGui): self.match(u) else: super().__init__(u, v, w, pos) self.auto = auto self.icon = icon self.label = label self.tip = tip self._view = None self._stored = {} self._history = {} def copy(self): """Return a new plane that is a copy of the present object.""" wp = PlaneGui() self.match(source=self, target=wp) return wp def _save(self): """Store the WP attributes.""" self._stored = self.get_parameters() def _restore(self): """Restore the WP attributes that were saved.""" if self._stored: self.set_parameters(self._stored) self._stored = {} self._update_all(_hist_add=False) def align_to_selection(self, offset=0, _hist_add=True): """Align the WP to a selection with an optional offset. The selection must define a plane. Parameter --------- offset: float, optional Defaults to zero. Offset along the WP `axis` Returns ------- `True`/`False` `True` if successful. """ if not FreeCAD.GuiUp: return False sels = FreeCADGui.Selection.getSelectionEx("", 0) if not sels: return False objs = [] for sel in sels: for sub in sel.SubElementNames if sel.SubElementNames else [""]: objs.append(Part.getShape(sel.Object, sub, needSubElement=True, retType=1)) if len(objs) != 1: if all([obj[0].isNull() is False and obj[0].ShapeType in ["Edge", "Vertex"] for obj in objs]): ret = self.align_to_edges_vertexes([obj[0] for obj in objs], offset, _hist_add) else: ret = False if ret is False: _wrn(translate("draft", "Selected shapes do not define a plane")) return ret shape, mtx, obj = objs[0] place = FreeCAD.Placement(mtx) typ = utils.get_type(obj) if typ in ["App::Part", "PartDesign::Plane", "Axis", "SectionPlane"]: ret = self.align_to_obj_placement(obj, offset, place, _hist_add) elif typ == "WorkingPlaneProxy": ret = self.align_to_wp_proxy(obj, offset, place, _hist_add) elif typ == "BuildingPart": ret = self.align_to_wp_proxy(obj, offset, place * obj.Placement, _hist_add) elif shape.isNull(): ret = self.align_to_obj_placement(obj, offset, place, _hist_add) elif shape.ShapeType == "Face": ret = self.align_to_face(shape, offset, _hist_add) elif shape.ShapeType == "Edge": ret = self.align_to_edge_or_wire(shape, offset, _hist_add) elif shape.Solids: ret = self.align_to_obj_placement(obj, offset, place, _hist_add) else: ret = self.align_to_edges_vertexes(shape.Vertexes, offset, _hist_add) if ret is False: _wrn(translate("draft", "Selected shapes do not define a plane")) return ret def _handle_custom(self, _hist_add): self.auto = False self.icon = ":/icons/Draft_SelectPlane.svg" self.label = self._get_label(translate("draft", "Custom")) self.tip = self._get_tip(translate("draft", "Custom")) self._update_all(_hist_add) def align_to_3_points(self, p1, p2, p3, offset=0, _hist_add=True): """See PlaneBase.align_to_3_points.""" if super().align_to_3_points(p1, p2, p3, offset) is False: return False self._handle_custom(_hist_add) return True def align_to_edges_vertexes(self, shapes, offset=0, _hist_add=True): """See PlaneBase.align_to_edges_vertexes.""" if super().align_to_edges_vertexes(shapes, offset) is False: return False self._handle_custom(_hist_add) return True def align_to_edge_or_wire(self, shape, offset=0, _hist_add=True): """See PlaneBase.align_to_edge_or_wire.""" if super().align_to_edge_or_wire(shape, offset) is False: return False self._handle_custom(_hist_add) return True def align_to_face(self, shape, offset=0, _hist_add=True): """See PlaneBase.align_to_face.""" if super().align_to_face(shape, offset) is False: return False self._handle_custom(_hist_add) return True def align_to_placement(self, place, offset=0, _hist_add=True): """See PlaneBase.align_to_placement.""" super().align_to_placement(place, offset) self._handle_custom(_hist_add) return True def align_to_point_and_axis(self, point, axis, offset=0, upvec=Vector(1, 0, 0), _hist_add=True): """See PlaneBase.align_to_point_and_axis.""" if super().align_to_point_and_axis(point, axis, offset, upvec) is False: return False self._handle_custom(_hist_add) return True def align_to_obj_placement(self, obj, offset=0, place=None, _hist_add=True): """Align the WP to an object placement with an optional offset. Parameters ---------- obj: App::DocumentObject Object to derive the Placement (if place is `None`), the icon and the label from. offset: float, optional Defaults to zero. Offset along the WP `axis`. place: Base.Placement, optional Defaults to `None`. If `None` the Placement from obj is used. Argument to be used if obj is inside a container. Returns ------- `True`/`False` `True` if successful. """ if hasattr(obj, "Placement") is False: return False if place is None: place = obj.Placement super().align_to_placement(place, offset) self.auto = False if utils.get_type(obj) == "WorkingPlaneProxy": self.icon = ":/icons/Draft_PlaneProxy.svg" elif FreeCAD.GuiUp \ and hasattr(obj, "ViewObject") \ and hasattr(obj.ViewObject, "Proxy") \ and hasattr(obj.ViewObject.Proxy, "getIcon"): self.icon = obj.ViewObject.Proxy.getIcon() else: self.icon = ":/icons/Std_Placement.svg" self.label = self._get_label(obj.Label) self.tip = self._get_tip(obj.Label) self._update_all(_hist_add) return True def align_to_wp_proxy(self, obj, offset=0, place=None, _hist_add=True): """Align the WP to a WPProxy with an optional offset. See align_to_obj_placement. Also handles several WPProxy related features. """ if self.align_to_obj_placement(obj, offset, place, _hist_add) is False: return False if not FreeCAD.GuiUp: return True vobj = obj.ViewObject if hasattr(vobj, "AutoWorkingPlane") \ and vobj.AutoWorkingPlane is True: self.auto = True if hasattr(vobj, "CutView") \ and hasattr(vobj, "AutoCutView") \ and vobj.AutoCutView is True: vobj.CutView = True if hasattr(vobj, "RestoreView") \ and vobj.RestoreView is True \ and hasattr(vobj, "ViewData") \ and len(vobj.ViewData) >= 12: vdat = vobj.ViewData if self._view is not None: try: if len(vdat) == 13 and vdat[12] == 1: camtype = "Perspective" else: camtype = "Orthographic" if self._view.getCameraType() != camtype: self._view.setCameraType(camtype) cam = self._view.getCameraNode() cam.position.setValue([vdat[0], vdat[1], vdat[2]]) cam.orientation.setValue([vdat[3], vdat[4], vdat[5], vdat[6]]) cam.nearDistance.setValue(vdat[7]) cam.farDistance.setValue(vdat[8]) cam.aspectRatio.setValue(vdat[9]) cam.focalDistance.setValue(vdat[10]) if camtype == "Orthographic": cam.height.setValue(vdat[11]) else: cam.heightAngle.setValue(vdat[11]) except Exception: pass if hasattr(vobj, "RestoreState") \ and vobj.RestoreState is True \ and hasattr(vobj, "VisibilityMap") \ and vobj.VisibilityMap: for name, vis in vobj.VisibilityMap.items(): obj = FreeCADGui.ActiveDocument.getObject(name) if obj: obj.Visibility = (vis == "True") return True def auto_align(self): """Align the WP to the current view if self.auto is True.""" if self.auto and self._view is not None: try: cam = self._view.getCameraNode() rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue()) self.u, self.v, self.axis = self._axes_from_view_rotation(rot) self.position = Vector() except Exception: pass def set_to_default(self): """Set the WP to the default from the preferences.""" default_wp = params.get_param("defaultWP") if default_wp == 0: self.set_to_auto() elif default_wp == 1: self.set_to_top() elif default_wp == 2: self.set_to_front() elif default_wp == 3: self.set_to_side() def set_to_auto(self): # Similar to Plane.reset. """Set the WP to auto.""" self.auto = True self.auto_align() self.icon = ":/icons/view-axonometric.svg" self.label = self._get_label(translate("draft", "Auto")) self.tip = self._get_tip(translate("draft", "Auto")) self._update_all() def set_to_top(self, offset=0, center_on_view=False): """Set the WP to the top position with an optional offset. Parameters ---------- offset: float, optional Defaults to zero. Offset along the WP `axis`. center_on_view: bool, optional Defaults to `False`. If `True` the WP `position` is moved along the (offset) WP to the center of the view """ super().set_to_top(offset) if center_on_view: self._center_on_view() self.auto = False self.icon = ":/icons/view-top.svg" self.label = self._get_label(translate("draft", "Top")) self.tip = self._get_tip(translate("draft", "Top")) self._update_all() def set_to_front(self, offset=0, center_on_view=False): """Set the WP to the front position with an optional offset. Parameters ---------- offset: float, optional Defaults to zero. Offset along the WP `axis`. center_on_view: bool, optional Defaults to `False`. If `True` the WP `position` is moved along the (offset) WP to the center of the view """ super().set_to_front(offset) if center_on_view: self._center_on_view() self.auto = False self.icon = ":/icons/view-front.svg" self.label = self._get_label(translate("draft", "Front")) self.tip = self._get_tip(translate("draft", "Front")) self._update_all() def set_to_side(self, offset=0, center_on_view=False): """Set the WP to the right side position with an optional offset. Parameters ---------- offset: float, optional Defaults to zero. Offset along the WP `axis`. center_on_view: bool, optional Defaults to `False`. If `True` the WP `position` is moved along the (offset) WP to the center of the view """ super().set_to_side(offset) if center_on_view: self._center_on_view() self.auto = False self.icon = ":/icons/view-right.svg" self.label = self._get_label(translate("draft", "Side")) self.tip = self._get_tip(translate("draft", "Side")) self._update_all() def set_to_view(self, offset=0, center_on_view=False): """Align the WP to the view with an optional offset. Parameters ---------- offset: float, optional Defaults to zero. Offset along the WP `axis`. center_on_view: bool, optional Defaults to `False`. If `True` the WP `position` is moved along the (offset) WP to the center of the view """ if self._view is not None: try: cam = self._view.getCameraNode() rot = FreeCAD.Rotation(*cam.getField("orientation").getValue().getValue()) self.u, self.v, self.axis = self._axes_from_view_rotation(rot) self.position = self.axis * offset if center_on_view: self._center_on_view() self.auto = False self.icon = ":/icons/Draft_SelectPlane.svg" self.label = self._get_label(translate("draft", "Custom")) self.tip = self._get_tip(translate("draft", "Custom")) self._update_all() except Exception: pass def set_to_position(self, pos): """Set the `position` of the WP.""" self.position = pos label = self.label.rstrip("*") self.label = self._get_label(label) self.tip = self._get_tip(label) self._update_all() def center_on_view(self): """Move the WP `position` along the WP to the center of the view.""" self._center_on_view() label = self.label.rstrip("*") self.label = self._get_label(label) self.tip = self._get_tip(label) self._update_all() def _center_on_view(self): if self._view is not None: try: cam = self._view.getCameraNode() pos = self.project_point(Vector(cam.position.getValue().getValue()), direction=self._view.getViewDirection(), force_projection=False) if pos is not None: self.position = pos except Exception: pass def align_view(self): """Align the view to the WP.""" if self._view is not None: try: default_cam_dist = abs(params.get_param_view("NewDocumentCameraScale")) cam = self._view.getCameraNode() cur_cam_dist = abs(self.get_local_coords(Vector(cam.position.getValue().getValue())).z) cam_dist = max(default_cam_dist, cur_cam_dist) cam.position.setValue(self.position + DraftVecUtils.scaleTo(self.axis, cam_dist)) cam.orientation.setValue(self.get_placement().Rotation.Q) except Exception: pass def _previous(self): idx = self._history["idx"] if idx == 0: _wrn(translate("draft", "No previous working plane")) return idx -= 1 self.set_parameters(self._history["data_list"][idx]) self._history["idx"] = idx self._update_all(_hist_add=False) def _next(self): idx = self._history["idx"] if idx == len(self._history["data_list"]) - 1: _wrn(translate("draft", "No next working plane")) return idx += 1 self.set_parameters(self._history["data_list"][idx]) self._history["idx"] = idx self._update_all(_hist_add=False) def _has_previous(self): return bool(self._history) and self._history["idx"] != 0 def _has_next(self): return bool(self._history) and self._history["idx"] != len(self._history["data_list"]) - 1 def _get_prop_list(self): return ["u", "v", "axis", "position", "auto", "icon", "label", "tip"] def _get_label(self, label): if self.auto or self.position.isEqual(Vector(), 0): return label else: return label + "*" def _get_tip(self, label): tip = translate("draft", "Current working plane:") tip += " " + label if self.auto: return tip tip += "\n" + translate("draft", "Axes:") tip += "\n X = " tip += self._format_vector(self.u) tip += "\n Y = " tip += self._format_vector(self.v) tip += "\n Z = " tip += self._format_vector(self.axis) tip += "\n" + translate("draft", "Position:") tip += "\n X = " tip += self._format_coord(self.position.x) tip += "\n Y = " tip += self._format_coord(self.position.y) tip += "\n Z = " tip += self._format_coord(self.position.z) return tip def _format_coord(self, coord): return FreeCAD.Units.Quantity(coord, FreeCAD.Units.Length).UserString def _format_vector(self, vec): dec = params.get_param("Decimals", path="Units") return f"({vec.x:.{dec}f} {vec.y:.{dec}f} {vec.z:.{dec}f})" def _update_all(self, _hist_add=True): if _hist_add is True: self._update_history() self._update_old_plane() # Must happen before _update_grid. self._update_grid() self._update_gui() def _update_history(self): data = self.get_parameters() if not self._history: self._history = {"idx": 0, "data_list": [data]} return idx = self._history["idx"] if data == self._history["data_list"][idx]: return if self.auto is True and self._history["data_list"][idx]["auto"] is True: return max_len = 10 # Max. length of data_list. self._history["data_list"] = self._history["data_list"][(idx - (max_len - 2)):(idx + 1)] self._history["data_list"].append(data) self._history["idx"] = len(self._history["data_list"]) - 1 def _update_old_plane(self): """ Update the old DraftWorkingPlane for compatibility. The tracker and snapper code currently still depend on it. """ if not hasattr(FreeCAD, "DraftWorkingPlane"): FreeCAD.DraftWorkingPlane = Plane() for prop in ["u", "v", "axis", "position"]: setattr(FreeCAD.DraftWorkingPlane, prop, self._copy_value(getattr(self, prop))) FreeCAD.DraftWorkingPlane.weak = self.auto def _update_grid(self): # Check for draftToolBar because the trackers (grid) depend on it for its colors. if FreeCAD.GuiUp \ and hasattr(FreeCADGui, "draftToolBar") \ and hasattr(FreeCADGui, "Snapper") \ and self._view is not None: FreeCADGui.Snapper.setGrid() FreeCADGui.Snapper.restack() # Required?? def _update_gui(self): if FreeCAD.GuiUp \ and hasattr(FreeCADGui, "draftToolBar") \ and self._view is not None: from PySide import QtGui button = FreeCADGui.draftToolBar.wplabel button.setIcon(QtGui.QIcon(self.icon)) button.setText(self.label) button.setToolTip(self.tip) def get_working_plane(update=True): if not hasattr(FreeCAD, "draft_working_planes"): FreeCAD.draft_working_planes = [[], []] view = gui_utils.get_3d_view() if view is not None and view in FreeCAD.draft_working_planes[0]: i = FreeCAD.draft_working_planes[0].index(view) wp = FreeCAD.draft_working_planes[1][i] if update is False: wp._update_old_plane() # Currently required for tracker and snapper code. return wp wp.auto_align() wp._update_all(_hist_add=False) return wp wp = PlaneGui() if FreeCAD.GuiUp: wp._view = view # Update _view before call to set_to_default, set_to_auto requires a 3D view. wp.set_to_default() if view is not None: FreeCAD.draft_working_planes[0].append(view) FreeCAD.draft_working_planes[1].append(wp) return wp # View observer code to update the Draft Tray: if FreeCAD.GuiUp: from PySide import QtWidgets from draftutils.todo import ToDo def _update_gui(): try: view = gui_utils.get_3d_view() if view is None: return if not hasattr(FreeCAD, "draft_working_planes"): FreeCAD.draft_working_planes = [[], []] if view in FreeCAD.draft_working_planes[0]: i = FreeCAD.draft_working_planes[0].index(view) wp = FreeCAD.draft_working_planes[1][i] wp._update_gui() else: get_working_plane() except Exception: pass def _view_observer_callback(sub_win): if sub_win is None: return view = gui_utils.get_3d_view() if view is None: return if not hasattr(FreeCADGui, "draftToolBar"): return tray = FreeCADGui.draftToolBar.tray if tray is None: return if FreeCADGui.draftToolBar.tray.isVisible() is False: return ToDo.delay(_update_gui, None) _view_observer_active = False def _view_observer_start(): mw = FreeCADGui.getMainWindow() mdi = mw.findChild(QtWidgets.QMdiArea) global _view_observer_active if not _view_observer_active: mdi.subWindowActivated.connect(_view_observer_callback) _view_observer_active = True _view_observer_callback(mdi.activeSubWindow()) # Trigger initial update. def _view_observer_stop(): mw = FreeCADGui.getMainWindow() mdi = mw.findChild(QtWidgets.QMdiArea) global _view_observer_active if _view_observer_active: mdi.subWindowActivated.disconnect(_view_observer_callback) _view_observer_active = False # Compatibility function (v1.0, 2023): def getPlacementFromPoints(points): """Return a placement from a list of 3 or 4 points. The 4th point is no longer used. Calls DraftGeomUtils.placement_from_points(). See there. """ utils.use_instead("DraftGeomUtils.placement_from_points") return DraftGeomUtils.placement_from_points(*points[:3]) # Compatibility function (v1.0, 2023): def getPlacementFromFace(face, rotated=False): """Return a placement from a face. Calls DraftGeomUtils.placement_from_face(). See there. """ utils.use_instead("DraftGeomUtils.placement_from_face") return DraftGeomUtils.placement_from_face(face, rotated=rotated)