# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2009, 2010 Yorik van Havre * # * Copyright (c) 2009, 2010 Ken Cline * # * Copyright (c) 2020 Eliud Cabrera Castillo * # * Copyright (c) 2025 The 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 * # * * # *************************************************************************** """Provides functions to upgrade objects by different methods. See also the `downgrade` function. """ ## @package downgrade # \ingroup draftfunctions # \brief Provides functions to upgrade objects by different methods. import math import re import lazy_loader.lazy_loader as lz import FreeCAD as App from draftfunctions import draftify from draftgeoutils.geometry import is_straight_line from draftmake import make_block from draftmake import make_wire from draftutils import gui_utils from draftutils import params from draftutils import utils from draftutils.groups import is_group from draftutils.messages import _msg from draftutils.translate import translate # Delay import of module until first use because it is heavy Part = lz.LazyLoader("Part", globals(), "Part") DraftGeomUtils = lz.LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils") Arch = lz.LazyLoader("Arch", globals(), "Arch") _DEBUG = False ## \addtogroup draftfunctions # @{ def upgrade(objects, delete=False, force=None): """Upgrade the given objects. This is a counterpart to `downgrade`. Parameters ---------- objects: Part::Feature or list A single object to upgrade or a list containing various such objects. delete: bool, optional It defaults to `False`. If it is `True`, the old objects are deleted, and only the resulting object is kept. force: str, optional It defaults to `None`. Its value can be used to force a certain method of upgrading. It can be any of: `'makeCompound'`, `'closeGroupWires'`, `'makeSolid'`, `'closeWire'`, `'turnToParts'`, `'makeFusion'`, `'makeShell'`, `'makeFaces'`, `'draftify'`, `'joinFaces'`, `'makeSketchFace'`, `'makeWires'`. Returns ------- tuple A tuple containing two lists, a list of new objects and a list of objects to be deleted. See Also -------- downgrade """ # definitions of actions to perform def makeCompound(objects): """Return a compound object made from the given objects.""" newobj = make_block.make_block(objects) format(objects[0], [newobj]) add_to_parent(objects[0], [newobj]) add_list.append(newobj) return True def closeGroupWires(groups): """Close every open wire in the given groups.""" result = False for grp in groups: if any([closeWire(obj) for obj in grp.Group]): result = True return result def makeSolid(obj): """Turn an object into a solid, if possible.""" if obj.Shape.Solids: return False try: solid = Part.makeSolid(obj.Shape) except Part.OCCError: return False if not solid.isClosed(): return False newobj = doc.addObject("Part::Feature", "Solid") newobj.Shape = solid format(obj, [newobj]) add_to_parent(obj, [newobj]) add_list.append(newobj) delete_list.append(obj) return True def closeWire(obj): """Close a wire object, if possible.""" if obj.Shape.Faces: return False if len(obj.Shape.Wires) != 1: return False if len(obj.Shape.Edges) == 1: return False if is_straight_line(obj.Shape): return False if utils.get_type(obj) == "Wire": obj.Closed = True return True wire = obj.Shape.Wires[0] if wire.isClosed(): return False verts = wire.OrderedVertexes p0 = verts[0].Point p1 = verts[-1].Point edges = wire.Edges edges.append(Part.LineSegment(p1, p0).toShape()) wire = Part.Wire(Part.__sortEdges__(edges)) newobj = doc.addObject("Part::Feature", "Wire") newobj.Shape = wire format(obj, [newobj]) add_to_parent(obj, [newobj]) add_list.append(newobj) delete_list.append(obj) return True def turnToParts(meshes): """Turn given meshes to parts.""" result = False for mesh in meshes: shp = Arch.getShapeFromMesh(mesh.Mesh) if shp: newobj = doc.addObject("Part::Feature", shp.ShapeType) newobj.Shape = shp format(mesh, [newobj]) add_to_parent(mesh, [newobj]) add_list.append(newobj) delete_list.append(mesh) result = True return result def makeFusion(objects): """Make a Draft or Part fusion between 2 given objects.""" newobj = doc.addObject("Part::Fuse", "Fusion") newobj.Base = objects[0] newobj.Tool = objects[1] format(objects[0], [newobj]) add_to_parent(objects[0], [newobj]) add_list.append(newobj) return True def makeShell(objects): """Make a shell or compound with the given objects.""" faces = [] done_list = [] for obj in objects: if obj.Shape.Faces: faces.extend(obj.Shape.Faces) done_list.append(obj) if not faces: return None shp = Part.makeShell(faces) if shp.isNull(): return None newobj = doc.addObject("Part::Feature", shp.ShapeType) newobj.Shape = shp # Format before applying diffuse color: format(done_list[0], [newobj]) add_to_parent(done_list[0], [newobj]) add_list.append(newobj) delete_list.extend(done_list) if App.GuiUp and params.get_param("preserveFaceColor"): # Must happen after add_to_parent for correct CenterOfMass. colors = gui_utils.get_diffuse_color(done_list) if len(faces) != len(colors): newobj.ViewObject.DiffuseColor = [colors[0]] else: # The ordering of shp.Faces may be different. Since we cannot # compare via hashCode(), we have to iterate and use different # criteria to find the correct color. old_data = [] for face, color in zip(faces, colors): old_data.append([face.Area, face.CenterOfMass, color]) new_colors = [] for new_face in shp.Faces: new_area = new_face.Area new_cen = new_face.CenterOfMass for old_area, old_cen, old_color in old_data: if math.isclose(new_area, old_area, abs_tol=1e-7) and new_cen.isEqual( old_cen, 1e-7 ): new_colors.append(old_color) break newobj.ViewObject.DiffuseColor = new_colors if params.get_param("preserveFaceNames"): firstName = done_list[0].Label nameNoTrailNumbers = re.sub(r"\d+$", "", firstName) newobj.Label = "{} {}".format(newobj.Label, nameNoTrailNumbers) return newobj def joinFaces(objects): """Make one big face from the given objects, if possible.""" faces = [] done_list = [] for obj in objects: if obj.Shape.Faces: faces.extend(obj.Shape.Faces) done_list.append(obj) if not faces: return False if not DraftGeomUtils.is_coplanar(faces, 1e-3): return False fuse_face = faces.pop(0) for face in faces: fuse_face = fuse_face.fuse(face) face = DraftGeomUtils.concatenate(fuse_face) # check if concatenate failed if face.isEqual(fuse_face): return False # several coplanar and non-curved faces, they can become a Draft Wire if len(face.Wires) == 1 and not DraftGeomUtils.hasCurves(face): newobj = make_wire.make_wire(face.Wires[0], closed=True, face=True) # if not possible, we do a non-parametric union else: newobj = doc.addObject("Part::Feature", "Union") newobj.Shape = face format(done_list[0], [newobj]) add_to_parent(done_list[0], [newobj]) add_list.append(newobj) delete_list.extend(done_list) return True def makeSketchFace(obj): """Make a face from a sketch.""" face = Part.makeFace(obj.Shape.Wires, "Part::FaceMakerBullseye") if not face: return False newobj = doc.addObject("Part::Feature", "Face") newobj.Shape = face format(obj, [newobj]) add_to_parent(obj, [newobj]) add_list.append(newobj) delete_list.append(obj) return True def makeFaces(objects): """Make a face from every closed wire in the given objects.""" result = False for obj in objects: new_list = [] for wire in obj.Shape.Wires: try: face = Part.Face(wire) except Part.OCCError: continue newobj = doc.addObject("Part::Feature", "Face") newobj.Shape = face new_list.append(newobj) if not new_list: continue format(obj, new_list) add_to_parent(obj, new_list) add_list.extend(new_list) delete_list.append(obj) result = True return result def makeWires(objects): """Join edges in the given objects into wires.""" edges = [] done_list = [] for obj in objects: if obj.Shape.Edges: edges.extend(obj.Shape.Edges) done_list.append(obj) if not edges: return False try: sorted_edges = Part.sortEdges(edges) if _DEBUG: for cluster in sorted_edges: for edge in cluster: print("Curve: {}".format(edge.Curve)) print( "first: {}, last: {}".format( edge.Vertexes[0].Point, edge.Vertexes[-1].Point ) ) wires = [Part.Wire(cluster) for cluster in sorted_edges] except Part.OCCError: return False if len(objects) > 1 and len(wires) == len(objects): # we still have the same number of objects, we actually didn't join anything! return False new_list = [] for wire in wires: newobj = doc.addObject("Part::Feature", "Wire") newobj.Shape = wire new_list.append(newobj) # We don't know which wire came from which obj, we format them the same: format(done_list[0], new_list) add_to_parent(done_list[0], new_list) add_list.extend(new_list) delete_list.extend(done_list) return True def _draftify(obj): """Wrapper for draftify.""" new_list = draftify.draftify(obj, delete=False) if not new_list: return False if not isinstance(new_list, list): new_list = [new_list] format(obj, new_list) add_to_parent(obj, new_list) add_list.extend(new_list) delete_list.append(obj) return True # helper functions (same as in downgrade.py) def get_parent(obj): # Problem with obj.getParent(): # https://github.com/FreeCAD/FreeCAD/issues/19600 parent = obj.getParentGroup() if parent is not None: return parent return obj.getParentGeoFeatureGroup() def can_be_deleted(obj): if not obj.InList: return True for other in obj.InList: if is_group(other): continue if other.TypeId == "App::Part": continue return False return True def delete_object(obj): if utils.is_deleted(obj): return parent = get_parent(obj) if parent is not None and parent.TypeId == "PartDesign::Body": obj = parent if not can_be_deleted(obj): # Make obj invisible instead: obj.Visibility = False return if obj.TypeId == "PartDesign::Body": obj.removeObjectsFromDocument() doc.removeObject(obj.Name) def add_to_parent(obj, new_list): parent = get_parent(obj) if parent is None: if doc.getObject("Draft_Construction"): # This cludge is required because the make_* commands may # put new objects in the construction group. constr_group = doc.getObject("Draft_Construction") for newobj in new_list: constr_group.removeObject(newobj) return if parent.TypeId == "PartDesign::Body": # We don't add to a PD Body. We process its placement and # add to its parent instead. for newobj in new_list: newobj.Placement = parent.Placement.multiply(newobj.Placement) add_to_parent(parent, new_list) return for newobj in new_list: # Using addObject is different from just changing the Group property. # With addObject the object will be added to the parent group, but if # that is a normal group, also to that group's parent GeoFeatureGroup, # if available. parent.addObject(newobj) def format(obj, new_list): for newobj in new_list: gui_utils.format_object(newobj, obj, ignore_construction=True) doc = App.ActiveDocument add_list = [] delete_list = [] result = False if not isinstance(objects, list): objects = [objects] if not objects: return add_list, delete_list # analyzing objects faces = [] wires = [] openwires = [] facewires = [] edges = [] loneedges = [] groups = [] meshes = [] parts = [] for obj in objects: if obj.TypeId == "App::DocumentObjectGroup": groups.append(obj) elif hasattr(obj, "Shape"): parts.append(obj) faces.extend(obj.Shape.Faces) wires.extend(obj.Shape.Wires) edges.extend(obj.Shape.Edges) for face in obj.Shape.Faces: facewires.extend(face.Wires) wirededges = [] for wire in obj.Shape.Wires: if len(wire.Edges) > 1: for edge in wire.Edges: wirededges.append(edge.hashCode()) if not wire.isClosed(): openwires.append(wire) for edge in obj.Shape.Edges: if not edge.hashCode() in wirededges and not edge.isClosed(): loneedges.append(edge) elif obj.isDerivedFrom("Mesh::Feature"): meshes.append(obj) objects = parts if _DEBUG: print("objects: {}, edges: {}".format(objects, edges)) print("wires: {}, openwires: {}".format(wires, openwires)) print("faces: {}".format(faces)) print("groups: {}".format(groups)) print("facewires: {}, loneedges: {}".format(facewires, loneedges)) if not (groups or objects or meshes): result = False elif force: if force == "closeGroupWires": result = closeGroupWires(groups) elif force == "turnToParts": result = turnToParts(meshes) else: # functions that work on a single object: single_funcs = { "closeWire": closeWire, "draftify": _draftify, "makeSketchFace": makeSketchFace, "makeSolid": makeSolid, } # functions that work on multiple objects: multi_funcs = { "joinFaces": joinFaces, "makeCompound": makeCompound, "makeFaces": makeFaces, "makeFusion": makeFusion, "makeShell": makeShell, "makeWires": makeWires, } if force in single_funcs: result = any([single_funcs[force](obj) for obj in objects]) elif force in multi_funcs: result = multi_funcs[force](objects) else: _msg(translate("draft", "Upgrade: Unknown force method:") + " " + force) result = False # if we have a group: close each wire inside elif groups: result = closeGroupWires(groups) if result: _msg(translate("draft", "Found groups: closing open wires inside")) # if we have meshes, we try to turn them into shapes elif meshes: result = turnToParts(meshes) if result: _msg(translate("draft", "Found meshes: turning them into Part shapes")) else: # checking faces coplanarity # The precision needed in Part.makeFace is 1e-7. Here we use a # higher value to let that function throw the exception when # joinFaces is called if the precision is insufficient. if faces: faces_coplanarity = DraftGeomUtils.is_coplanar(faces, 1e-3) parent = get_parent(objects[0]) same_parent = True same_parent_type = getattr(parent, "TypeId", "") # "" for global space. if len(objects) > 1: for obj in objects[1:]: if get_parent(obj) != parent: same_parent = False same_parent_type = None break # we have only faces if faces and len(facewires) == len(wires) and not openwires and not loneedges: # we have one shell: we try to make a solid # this also handles PD Bodies and PD features with solids (result will be False) if len(objects) == 1 and len(faces) > 3 and not faces_coplanarity: result = makeSolid(objects[0]) if result: _msg(translate("draft", "Found 1 solidifiable object: solidifying it")) # we have exactly 2 objects: we fuse them elif ( len(objects) == 2 and not faces_coplanarity and same_parent and same_parent_type != "PartDesign::Body" ): result = makeFusion(objects) if result: _msg(translate("draft", "Found 2 objects: fusing them")) # we have many separate faces: we try to make a shell or compound elif ( len(objects) > 1 and len(faces) > 1 and same_parent and same_parent_type != "PartDesign::Body" ): result = makeShell(objects) if result: _msg( translate( "draft", "Found several objects: creating a " + result.Shape.ShapeType ) ) # we have faces: we try to join them if they are coplanar elif len(objects) == 1 and len(faces) > 1 and faces_coplanarity: result = joinFaces(objects) if result: _msg( translate( "draft", "Found object with several coplanar faces: refining them" ) ) # only one object: if not parametric, we "draftify" it elif ( len(objects) == 1 and not objects[0].isDerivedFrom("Part::Part2DObjectPython") and not utils.get_type(objects[0]) in ["BezCurve", "BSpline", "Wire"] ): result = _draftify(objects[0]) if result: _msg( translate( "draft", "Found 1 non-parametric object: replacing it with a Draft object", ) ) # in the following cases there are no faces elif not faces: # we have only closed wires if wires and not openwires and not loneedges: # we have a sketch: extract a face if len(objects) == 1 and objects[0].isDerivedFrom("Sketcher::SketchObject"): result = makeSketchFace(objects[0]) if result: _msg( translate( "draft", "Found 1 closed sketch object: creating a face from it" ) ) # only closed wires else: result = makeFaces(objects) if result: _msg(translate("draft", "Found closed wires: creating faces")) # wires or edges: we try to join them elif len(objects) > 1 and len(edges) > 1 and same_parent: result = makeWires(objects) if result: _msg(translate("draft", "Found several wires or edges: wiring them")) else: result = makeCompound(objects) if result: _msg( translate( "draft", "Found several non-treatable objects: creating compound" ) ) # special case, we have only one open wire. We close it, unless it has only 1 edge! elif len(objects) == 1 and len(openwires) == 1: result = closeWire(objects[0]) if result: _msg(translate("draft", "Found 1 open wire: closing it")) # only one object: if not parametric, we "draftify" it elif ( len(objects) == 1 and len(edges) == 1 and not objects[0].isDerivedFrom("Part::Part2DObjectPython") and not utils.get_type(objects[0]) in ["BezCurve", "BSpline", "Wire"] ): edge_type = DraftGeomUtils.geomType(objects[0].Shape.Edges[0]) # currently only support Line and Circle if edge_type in ("Line", "Circle"): result = _draftify(objects[0]) if result: _msg( translate( "draft", "Found 1 non-parametric object: replacing it with a Draft object", ) ) # only points, no edges elif not edges and len(objects) > 1: result = makeCompound(objects) if result: _msg(translate("draft", "Found points: creating compound")) # all other cases, if more than 1 object, make a compound elif len(objects) > 1: result = makeCompound(objects) if result: _msg(translate("draft", "Found several non-treatable objects: creating compound")) # no result has been obtained if not result: _msg(translate("draft", "Unable to upgrade these objects")) if delete: for obj in delete_list: delete_object(obj) gui_utils.select(add_list) return add_list, delete_list ## @}