From 6deb42453964d728bf260ce35b3a3a05fc4ebf1a Mon Sep 17 00:00:00 2001 From: Roy-043 <70520633+Roy-043@users.noreply.github.com> Date: Tue, 7 Jan 2025 10:23:33 +0100 Subject: [PATCH] Draft: Closed corners for extruded Facebinders (#18901) * Draft: props_changed_placement_only should ignore material props The new material related properties (Density, Volume and Mass) must be ignored by the `props_changed_placement_only` function. Without this moving a Draft_Point will fail for example. * Draft: Closed corners for extruded Facebinders Fixes #13816. The `makeOffsetShape` method that creates the extruded shape is quite picky. For example, it will work for a pyramidal shell (4 triangles) with a square floorplan, but not if the floorplan is slightly rectangular. To get closed corners the `Sew` property of the Facebinder must be set to `True`. If extruding does not work properly, the code will retry with `Sew` disabled. There is also some code that tries to convert flat B-spline faces created between the main offset faces into planar faces. In some cases that code will fail (the results of `makeOffsetShape` can already contain errors). If that is the case the original shape created by `makeOffsetShape` is used. * Rebase to restore base.py --- src/Mod/Draft/draftobjects/facebinder.py | 153 ++++++++++++++++------- 1 file changed, 107 insertions(+), 46 deletions(-) diff --git a/src/Mod/Draft/draftobjects/facebinder.py b/src/Mod/Draft/draftobjects/facebinder.py index 957160d214..f3fb93a533 100644 --- a/src/Mod/Draft/draftobjects/facebinder.py +++ b/src/Mod/Draft/draftobjects/facebinder.py @@ -2,7 +2,7 @@ # * Copyright (c) 2009, 2010 Yorik van Havre * # * Copyright (c) 2009, 2010 Ken Cline * # * Copyright (c) 2020 FreeCAD Developers * -# * Copyright (c) 2023 FreeCAD Project Association * +# * Copyright (c) 2023-2025 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) * @@ -31,8 +31,11 @@ from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD as App +from draftgeoutils import geometry from draftobjects.base import DraftObject from draftutils import gui_utils +from draftutils.messages import _err, _msg, _wrn +from draftutils.translate import translate class Facebinder(DraftObject): @@ -71,6 +74,7 @@ class Facebinder(DraftObject): return if not obj.Faces: + self._report_face_error(obj) return import Part @@ -81,65 +85,122 @@ class Facebinder(DraftObject): if "Face" in sub: face = Part.getShape(sel[0], sub, needSubElement=True, retType=0) faces.append(face) - except Exception: - print("Draft: error building facebinder") + except Part.OCCError: + self._report_face_error(obj) return if not faces: + self._report_face_error(obj) return - offset_val = obj.Offset.Value if hasattr(obj, "Offset") else 0 - extrusion_val = obj.Extrusion.Value if hasattr(obj, "Extrusion") else 0 - + obj_sew = getattr(obj, "Sew", True) try: - if offset_val: - offsets = [] - for face in faces: - if face.Surface.isPlanar(): - norm = face.normalAt(0, 0) - dist = norm.multiply(offset_val) - face.translate(dist) - offsets.append(face) - else: - offset = face.makeOffsetShape(offset_val, 1e-7) - offsets.extend(offset.Faces) - faces = offsets - - shp = faces.pop() - if faces: - shp = shp.fuse(faces) - area = shp.Area # take area after offsetting and fusing, but before extruding - - if extrusion_val: - extrusions = [] - for face in shp.Faces: - if face.Surface.isPlanar(): - extrusion = face.extrude(face.normalAt(0, 0).multiply(extrusion_val)) - extrusions.append(extrusion) - else: - extrusion = face.makeOffsetShape(extrusion_val, 1e-7, fill=True) - extrusions.extend(extrusion.Solids) - shp = extrusions.pop() - if extrusions: - shp = shp.fuse(extrusions) - - if len(shp.Faces) > 1: - if getattr(obj, "Sew", True): - shp.sewShape() - if getattr(obj, "RemoveSplitter", True): - shp = shp.removeSplitter() - + shp, area = self._build_shape(obj, faces, sew=obj_sew) except Exception: - print("Draft: error building facebinder") - return + if not obj_sew: + self._report_build_error(obj) + return + self._report_sew_error(obj) + try: + shp, area = self._build_shape(obj, faces, sew=False) + except Exception: + self._report_build_error(obj) + return + if not shp.isValid(): + if not obj_sew: + self._report_build_error(obj) + return + self._report_sew_error(obj) + try: + shp, area = self._build_shape(obj, faces, sew=False) + except Exception: + self._report_build_error(obj) + return - if shp.__class__.__name__ == "Compound": + if shp.ShapeType == "Compound": obj.Shape = shp else: obj.Shape = Part.Compound([shp]) # nest in compound to ensure default Placement obj.Area = area self.props_changed_clear() + def _report_build_error(self, obj): + _err(obj.Label + ": " + translate("draft", "Unable to build Facebinder")) + + def _report_face_error(self, obj): + _wrn(obj.Label + ": " + translate("draft", "No valid faces for Facebinder")) + + def _report_sew_error(self, obj): + _wrn(obj.Label + ": " + translate("draft", "Unable to build Facebinder, resuming with Sew disabled")) + + def _build_shape(self, obj, faces, sew=False): + """returns the built shape and the area of the offset faces""" + import Part + offs_val = getattr(obj, "Offset", 0) + extr_val = getattr(obj, "Extrusion", 0) + + shp = Part.Compound(faces) + # Sew before offsetting to ensure corners stay connected: + if sew: + shp.sewShape() + if shp.ShapeType != "Compound": + shp = Part.Compound([shp]) + + if offs_val: + offsets = [] + for sub in shp.SubShapes: + offsets.append(sub.makeOffsetShape(offs_val, 1e-7, join=2)) + shp = Part.Compound(offsets) + + area = shp.Area # take area after offsetting original faces, but before extruding + + if extr_val: + extrudes = [] + for sub in shp.SubShapes: + ext = sub.makeOffsetShape(extr_val, 1e-7, inter=True, join=2, fill=True) + extrudes.append(self._convert_to_planar(obj, ext)) + shp = Part.Compound(extrudes) + + subs = shp.SubShapes + shp = subs.pop() + if subs: + shp = shp.fuse(subs) + + if len(shp.Faces) > 1: + if getattr(obj, "RemoveSplitter", True): + shp = shp.removeSplitter() + + return shp, area + + def _convert_to_planar(self, obj, shp): + """convert flat B-spline faces to planar faces if possible""" + import Part + faces = [] + for face in shp.Faces: + if face.Surface.TypeId == "Part::GeomPlane": + faces.append(face) + elif not geometry.is_planar(face): + faces.append(face) + else: + edges = [] + for edge in face.Edges: + if edge.Curve.TypeId == "Part::GeomLine" or geometry.is_straight_line(edge): + verts = edge.Vertexes + edges.append(Part.makeLine(verts[0].Point, verts[1].Point)) + else: + edges.append(edge) + wires = [Part.Wire(x) for x in Part.sortEdges(edges)] + face = Part.makeFace(wires, "Part::FaceMakerCheese") + face.fix(1e-7, 0, 1) + faces.append(face) + solid = Part.makeSolid(Part.makeShell(faces)) + if solid.isValid(): + return solid + _msg(obj.Label + ": " + translate("draft", + "Converting flat B-spline faces of Facebinder to planar faces failed" + )) + return shp + def onChanged(self, obj, prop): self.props_changed_store(prop)