# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2012 Yorik van Havre * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD 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 * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** "The FreeCAD Arch Vector Rendering Module" ## @package ArchVRM # \ingroup ARCH # \brief The Arch Vector Rendering Module # # This module provides the Renderer Class, that allows one to # produce SVG renderings of projected shapes, with filled faces. # It is used by the "Solid" mode of Arch views in TechDraw and Drawing, # and is called from ArchSectionPlane code. import math import FreeCAD import ArchCommands import Draft import DraftVecUtils import DraftGeomUtils import Part from draftutils import params MAXLOOP = 10 # the max number of loop before abort # WARNING: in this module, faces are lists whose first item is the actual OCC face, the # other items being additional information such as color, etc. DEBUG = params.get_param_arch("ShowVRMDebug") class Renderer: "A renderer object" def __init__(self, wp=None): """ Creates a renderer with a default Draft WorkingPlane Use like this: import ArchVRM p = ArchVRM.Renderer() p.add(App.ActiveDocument.ActiveObject) p.sort() p.buildDummy() """ self.reset() if wp: self.wp = wp else: import WorkingPlane self.wp = WorkingPlane.PlaneBase() if DEBUG: print("Renderer initialized on " + str(self.wp)) def __str__(self): return "Arch Renderer: " + str(len(self.faces)) + " faces projected on " + str(self.wp) def reset(self): "removes all faces from this renderer" self.objects = [] self.shapes = [] self.faces = [] self.resetFlags() def resetFlags(self): "resets all flags of this renderer" self.oriented = False self.trimmed = False self.sorted = False self.iscut = False self.joined = False self.sections = [] self.hiddenEdges = [] def setWorkingPlane(self, wp): "sets a Draft WorkingPlane or Placement for this renderer" if isinstance(wp, FreeCAD.Placement): self.wp.align_to_placement(wp) else: self.wp = wp if DEBUG: print("Renderer set on " + str(self.wp)) def addFaces(self, faces, color=(0.9, 0.9, 0.9, 1.0)): "add individual faces to this renderer, optionally with a color" if DEBUG: print( "adding ", len(faces), " faces. Warning, these will get lost if using cut() or join()", ) for f in faces: self.faces.append([f, color]) self.resetFlags() def addObjects(self, objs): "add objects to this renderer" for o in objs: if o.isDerivedFrom("Part::Feature"): self.objects.append(o) color = o.ViewObject.ShapeColor if o.Shape.Faces: self.shapes.append([o.Shape, color]) for f in o.Shape.Faces: self.faces.append([f, color]) self.resetFlags() if DEBUG: print("adding ", len(self.objects), " objects, ", len(self.faces), " faces") def addShapes(self, shapes, color=(0.9, 0.9, 0.9, 1.0)): "add shapes to this renderer, optionally with a color. Warning, these will get lost if using join()" if DEBUG: print("adding ", len(shapes), " shapes") for s in shapes: if s.Faces: self.shapes.append([s, color]) for f in s.Faces: self.faces.append([f, color]) self.resetFlags() def info(self): "Prints info about the contents of this renderer" r = str(self) + "\n" r += "oriented: " + str(self.oriented) + "\n" r += "trimmed: " + str(self.trimmed) + "\n" r += "sorted: " + str(self.sorted) + "\n" r += "contains " + str(len(self.faces)) + " faces\n" for i in range(len(self.faces)): r += " face " + str(i) + " : center " + str(self.faces[i][0].CenterOfMass) r += " : normal " + str(self.faces[i][0].normalAt(0, 0)) r += ", " + str(len(self.faces[i][0].Vertexes)) + " verts" r += ", color: " + self.getFill(self.faces[i][1]) + "\n" return r def addLabels(self): "Add labels on the model to identify faces" c = 0 for f in self.faces: l = FreeCAD.ActiveDocument.addObject("App::AnnotationLabel", "facelabel") l.BasePosition = f[0].CenterOfMass l.LabelText = str(c) c += 1 def isVisible(self, face): "returns True if the given face points in the view direction" normal = face[0].normalAt(0, 0) if DEBUG: print( "checking face normal ", normal, " against ", self.wp.axis, " : ", math.degrees(normal.getAngle(self.wp.axis)), ) if normal.getAngle(self.wp.axis) < math.pi / 2: return True return False def reorient(self): "reorients the faces on the WP" # print("VRM: start reorient") if not self.faces: return self.faces = [self.projectFace(f) for f in self.faces] if self.sections: self.sections = [self.projectFace(f) for f in self.sections] if self.hiddenEdges: self.hiddenEdges = [self.projectEdge(e) for e in self.hiddenEdges] self.oriented = True # print("VRM: end reorient") def removeHidden(self): "removes faces pointing outwards" if not self.faces: return faces = [] for f in self.faces: if self.isVisible(f): faces.append(f) if DEBUG: print(len(self.faces) - len(faces), " faces removed, ", len(faces), " faces retained") self.faces = faces self.trimmed = True def projectFace(self, face): "projects a single face on the WP" # print("VRM: projectFace start: ",len(face[0].Vertexes)," verts, ",len(face[0].Edges)," edges") wires = [] if not face[0].Wires: if DEBUG: print("Error: Unable to project face on the WP") return None norm = face[0].normalAt(0, 0) for w in face[0].Wires: verts = [] edges = Part.__sortEdges__(w.Edges) # print(len(edges)," edges after sorting") for e in edges: v = e.Vertexes[0].Point # print(v) v = self.wp.get_local_coords(v) verts.append(v) verts.append(verts[0]) if len(verts) > 2: # print("new wire with ",len(verts)) wires.append(Part.makePolygon(verts)) try: sh = ArchCommands.makeFace(wires) except Exception: if DEBUG: print("Error: Unable to project face on the WP") return None else: # restoring flipped normals vnorm = self.wp.get_local_coords(norm) if vnorm.getAngle(sh.normalAt(0, 0)) > 1: sh.reverse() # print("VRM: projectFace end: ",len(sh.Vertexes)," verts") return [sh] + face[1:] def projectEdge(self, edge): "projects a single edge on the WP" if len(edge.Vertexes) > 1: v1 = self.wp.get_local_coords(edge.Vertexes[0].Point) v2 = self.wp.get_local_coords(edge.Vertexes[-1].Point) return Part.LineSegment(v1, v2).toShape() return edge def flattenFace(self, face): "Returns a face where all vertices have Z = 0" wires = [] for w in face[0].Wires: verts = [] edges = Part.__sortEdges__(w.Edges) for e in edges: v = e.Vertexes[0].Point verts.append(FreeCAD.Vector(v.x, v.y, 0)) verts.append(verts[0]) wires.append(Part.makePolygon(verts)) try: sh = Part.Face(wires) except Part.OCCError: if DEBUG: print("Error: Unable to flatten face") return None else: return [sh] + face[1:] def cut(self, cutplane, hidden=False): "Cuts through the shapes with a given cut plane and builds section faces" if DEBUG: print("\n\n======> Starting cut\n\n") if self.iscut: return if not self.shapes: if DEBUG: print("No objects to make sections") else: fill = (1.0, 1.0, 1.0, 1.0) shps = [] for sh in self.shapes: shps.append(sh[0]) cutface, cutvolume, invcutvolume = ArchCommands.getCutVolume(cutplane, shps) if cutface and cutvolume: shapes = [] faces = [] sections = [] for sh in self.shapes: for sol in sh[0].Solids: c = sol.cut(cutvolume) shapes.append([c] + sh[1:]) for f in c.Faces: faces.append([f] + sh[1:]) # print("iscoplanar:",f.Vertexes[0].Point,f.normalAt(0,0),cutface.Vertexes[0].Point,cutface.normalAt(0,0)) if DraftGeomUtils.isCoplanar([f, cutface]): print("COPLANAR") sections.append([f, fill]) if hidden: c = sol.cut(invcutvolume) self.hiddenEdges.extend(c.Edges) self.shapes = shapes self.faces = faces self.sections = sections if DEBUG: print( "Built ", len(self.sections), " sections, ", len(self.faces), " faces retained", ) self.iscut = True self.oriented = False self.trimmed = False self.sorted = False self.joined = False if DEBUG: print("\n\n======> Finished cut\n\n") def isInside(self, vert, face): "Returns True if the vert is inside the face in Z projection" if not face: return False # http://paulbourke.net/geometry/insidepoly/ count = 0 p = self.wp.get_local_coords(vert.Point) for e in face[0].Edges: p1 = e.Vertexes[0].Point p2 = e.Vertexes[-1].Point if p.y > min(p1.y, p2.y): if p.y <= max(p1.y, p2.y): if p.x <= max(p1.x, p2.x): if p1.y != p2.y: xinters = (p.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) + p1.x if (p1.x == p2.x) or (p.x <= xinters): count += 1 if count % 2 == 0: return False else: return True def zOverlaps(self, face1, face2): "Checks if face1 overlaps face2 in Z direction" face1 = self.flattenFace(face1) face2 = self.flattenFace(face2) if (not face1) or (not face2): return False # first we check if one of the verts is inside the other face for v in face1[0].Vertexes: if self.isInside(v, face2): return True # even so, faces can still overlap if their edges cross each other for e1 in face1[0].Edges: for e2 in face2[0].Edges: if DraftGeomUtils.findIntersection(e1, e2): return True return False def compare(self, face1, face2): "zsorts two faces. Returns 1 if face1 is closer, 2 if face2 is closer, 0 otherwise" # print(face1,face2) if not face1: if DEBUG: print("Warning, undefined face!") return 31 elif not face2: if DEBUG: print("Warning, undefined face!") return 32 # theory from # http://www.siggraph.org/education/materials/HyperGraph/scanline/visibility/painter.htm # and practical application http://vrm.ao2.it/ (blender vector renderer) b1 = face1[0].BoundBox b2 = face2[0].BoundBox # test 1: if faces don't overlap, no comparison possible if DEBUG: print("doing test 1") if b1.XMax < b2.XMin: return 0 if b1.XMin > b2.XMax: return 0 if b1.YMax < b2.YMin: return 0 if b1.YMin > b2.YMax: return 0 if DEBUG: print("failed, faces bboxes are not distinct") # test 2: if Z bounds don't overlap, it's easy to know the closest if DEBUG: print("doing test 2") if b1.ZMax < b2.ZMin: return 2 if b2.ZMax < b1.ZMin: return 1 if DEBUG: print("failed, faces Z are not distinct") # test 3: all verts of face1 are in front or behind the plane of face2 if DEBUG: print("doing test 3") norm = face2[0].normalAt(0, 0) behind = 0 front = 0 for v in face1[0].Vertexes: dv = v.Point.sub(face2[0].Vertexes[0].Point) dv = DraftVecUtils.project(dv, norm) if DraftVecUtils.isNull(dv): behind += 1 front += 1 else: if dv.getAngle(norm) > 1: behind += 1 else: front += 1 if DEBUG: print("front: ", front, " behind: ", behind) if behind == len(face1[0].Vertexes): return 2 elif front == len(face1[0].Vertexes): return 1 if DEBUG: print("failed, cannot say if face 1 is in front or behind") # test 4: all verts of face2 are in front or behind the plane of face1 if DEBUG: print("doing test 4") norm = face1[0].normalAt(0, 0) behind = 0 front = 0 for v in face2[0].Vertexes: dv = v.Point.sub(face1[0].Vertexes[0].Point) dv = DraftVecUtils.project(dv, norm) if DraftVecUtils.isNull(dv): behind += 1 front += 1 else: if dv.getAngle(norm) > 1: behind += 1 else: front += 1 if DEBUG: print("front: ", front, " behind: ", behind) if behind == len(face2[0].Vertexes): return 1 elif front == len(face2[0].Vertexes): return 2 if DEBUG: print("failed, cannot say if face 2 is in front or behind") # test 5: see if faces projections don't overlap, vertexwise if DEBUG: print("doing test 5") if not self.zOverlaps(face1, face2): return 0 elif not self.zOverlaps(face2, face1): return 0 if DEBUG: print("failed, faces are overlapping") if DEBUG: print("Houston, all tests passed, and still no results") return 0 def join(self, otype): "joins the objects of same type" import Part walls = [] structs = [] objs = [] for o in obj.Source.Objects: t = Draft.getType(o) if t == "Wall": walls.append(o) elif t == "Structure": structs.append(o) else: objs.append(o) for g in [walls, structs]: if g: print("group:", g) col = g[0].ViewObject.DiffuseColor[0] s = g[0].Shape for o in g[1:]: try: fs = s.fuse(o.Shape) fs = fs.removeSplitter() except Part.OCCError: print("shape fusion failed") objs.append([o.Shape, o.ViewObject.DiffuseColor[0]]) else: s = fs objs.append([s, col]) def findPosition(self, f1, faces): "Finds the position of a face in a list of faces" l = None h = None for f2 in faces: if DEBUG: print( "comparing face", str(self.faces.index(f1)), " with face", str(self.faces.index(f2)), ) r = self.compare(f1, f2) if r == 1: l = faces.index(f2) elif r == 2: if h is None: h = faces.index(f2) else: if faces.index(f2) < h: h = faces.index(f2) if l is not None: return l + 1 elif h is not None: return h else: return None def sort(self): "projects a shape on the WP" if DEBUG: print("\n\n======> Starting sort\n\n") if len(self.faces) <= 1: return if not self.trimmed: self.removeHidden() if DEBUG: print("Done hidden face removal") if len(self.faces) == 1: return if not self.oriented: self.reorient() if DEBUG: print("Done reorientation") faces = self.faces[:] if DEBUG: print("sorting ", len(self.faces), " faces") sfaces = [] loopcount = 0 notfoundstack = 0 while faces: if DEBUG: print("loop ", loopcount) f1 = faces[0] if sfaces and (notfoundstack < len(faces)): if DEBUG: print("using ordered stack, notfound = ", notfoundstack) p = self.findPosition(f1, sfaces) if p is None: # no position found, we move the face to the end of the pile faces.remove(f1) faces.append(f1) notfoundstack += 1 else: # position found, we insert it faces.remove(f1) sfaces.insert(p, f1) notfoundstack = 0 else: # either there is no stack, or no more face can be compared # find a root, 2 faces that can be compared if DEBUG: print("using unordered stack, notfound = ", notfoundstack) for f2 in faces[1:]: if DEBUG: print( "comparing face", str(self.faces.index(f1)), " with face", str(self.faces.index(f2)), ) r = self.compare(f1, f2) print("comparison result:", r) if r == 1: faces.remove(f2) sfaces.append(f2) faces.remove(f1) sfaces.append(f1) notfoundstack = 0 break elif r == 2: faces.remove(f1) sfaces.append(f1) faces.remove(f2) sfaces.append(f2) notfoundstack = 0 break elif r == 31: if f1 in faces: faces.remove(f1) elif r == 32: if f2 in faces: faces.remove(f2) else: # nothing found, move the face to the end of the pile if f1 in faces: faces.remove(f1) faces.append(f1) loopcount += 1 if loopcount == MAXLOOP * len(self.faces): if DEBUG: print("Too many loops, aborting.") break if DEBUG: print( "done Z sorting. ", len(sfaces), " faces retained, ", len(self.faces) - len(sfaces), " faces lost.", ) self.faces = sfaces self.sorted = True if DEBUG: print("\n\n======> Finished sort\n\n") def buildDummy(self): "Builds a dummy object with faces spaced on the Z axis, for visual check" z = 0 if not self.sorted: self.sort() faces = [] for f in self.faces[:]: ff = self.flattenFace(f)[0] ff.translate(FreeCAD.Vector(0, 0, z)) faces.append(ff) z += 1 if faces: c = Part.makeCompound(faces) Part.show(c) def getFill(self, fill): "Returns a SVG fill value" r = str(hex(int(fill[0] * 255)))[2:].zfill(2) g = str(hex(int(fill[1] * 255)))[2:].zfill(2) b = str(hex(int(fill[2] * 255)))[2:].zfill(2) col = "#" + r + g + b return col def getPathData(self, w): "Returns a SVG path data string from a 2D wire" def tostr(val): return str(round(val, Draft.precision())) edges = Part.__sortEdges__(w.Edges) v = edges[0].Vertexes[0].Point svg = "M " + tostr(v.x) + " " + tostr(v.y) + " " for e in edges: if (DraftGeomUtils.geomType(e) == "Line") or ( DraftGeomUtils.geomType(e) == "BSplineCurve" ): v = e.Vertexes[-1].Point svg += "L " + tostr(v.x) + " " + tostr(v.y) + " " elif DraftGeomUtils.geomType(e) == "Circle": r = e.Curve.Radius v = e.Vertexes[-1].Point svg += "A " + tostr(r) + " " + tostr(r) + " 0 0 1 " + tostr(v.x) + " " svg += tostr(v.y) + " " if len(edges) > 1: svg += "Z " return svg def getViewSVG(self, linewidth=0.01): "Returns a SVG fragment from viewed faces" if DEBUG: print("Printing ", len(self.faces), " faces") if not self.sorted: self.sort() svg = ( '\n' for f in self.faces: if f: fill = self.getFill(f[1]) svg += " \n' svg += "\n" return svg def getSectionSVG(self, linewidth=0.02, fillpattern=None): "Returns a SVG fragment from cut faces" if DEBUG: print("Printing ", len(self.sections), " sections") if not self.oriented: self.reorient() svg = ( '\n' for f in self.sections: if f: if fillpattern: if "#" in fillpattern: # color fill = fillpattern else: fill = "url(#" + fillpattern + ")" # pattern name else: fill = "none" # none svg += "\n' svg += "\n" return svg def getHiddenSVG(self, linewidth=0.02): "Returns a SVG fragment from cut geometry" if DEBUG: print("Printing ", len(self.sections), " hidden faces") if not self.oriented: self.reorient() svg = ( '\n' ) for e in self.hiddenEdges: svg += "