| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| "The FreeCAD Arch Vector Rendering Module" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import math |
|
|
| import FreeCAD |
| import ArchCommands |
| import Draft |
| import DraftVecUtils |
| import DraftGeomUtils |
| import Part |
|
|
| from draftutils import params |
|
|
| MAXLOOP = 10 |
|
|
| |
| |
|
|
| 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" |
| |
| 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 |
| |
|
|
| 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" |
| |
| 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) |
| |
| for e in edges: |
| v = e.Vertexes[0].Point |
| |
| v = self.wp.get_local_coords(v) |
| verts.append(v) |
| verts.append(verts[0]) |
| if len(verts) > 2: |
| |
| 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: |
| |
| vnorm = self.wp.get_local_coords(norm) |
| if vnorm.getAngle(sh.normalAt(0, 0)) > 1: |
| sh.reverse() |
| |
| 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:]) |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| for v in face1[0].Vertexes: |
| if self.isInside(v, face2): |
| return True |
|
|
| |
| 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" |
|
|
| |
|
|
| if not face1: |
| if DEBUG: |
| print("Warning, undefined face!") |
| return 31 |
| elif not face2: |
| if DEBUG: |
| print("Warning, undefined face!") |
| return 32 |
|
|
| |
| |
| |
|
|
| b1 = face1[0].BoundBox |
| b2 = face2[0].BoundBox |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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: |
| |
| faces.remove(f1) |
| faces.append(f1) |
| notfoundstack += 1 |
| else: |
| |
| faces.remove(f1) |
| sfaces.insert(p, f1) |
| notfoundstack = 0 |
| else: |
| |
| |
| 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: |
| |
| 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 = ( |
| '<g stroke="#000000" stroke-width="' |
| + str(linewidth) |
| + '" style="stroke-width:' |
| + str(linewidth) |
| ) |
| svg += ';stroke-miterlimit:1;stroke-linejoin:round;stroke-dasharray:none;">\n' |
| for f in self.faces: |
| if f: |
| fill = self.getFill(f[1]) |
| svg += " <path " |
| svg += 'd="' |
| for w in f[0].Wires: |
| svg += self.getPathData(w) |
| svg += '" style="fill:' + fill + ';fill-rule: evenodd;"/>\n' |
| svg += "</g>\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 = ( |
| '<g stroke="#000000" stroke-width="' |
| + str(linewidth) |
| + '" style="stroke-width:' |
| + str(linewidth) |
| ) |
| svg += ';stroke-miterlimit:1;stroke-linejoin:round;stroke-dasharray:none;">\n' |
| for f in self.sections: |
| if f: |
| if fillpattern: |
| if "#" in fillpattern: |
| fill = fillpattern |
| else: |
| fill = "url(#" + fillpattern + ")" |
| else: |
| fill = "none" |
| svg += "<path " |
| svg += 'd="' |
| for w in f[0].Wires: |
| |
| svg += self.getPathData(w) |
| svg += '" style="fill:' + fill + ';fill-rule: evenodd;"/>\n' |
| svg += "</g>\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 = ( |
| '<g stroke="#000000" stroke-width="' |
| + str(linewidth) |
| + '" style="stroke-width:' |
| + str(linewidth) |
| ) |
| svg += ( |
| ';stroke-miterlimit:1;stroke-linejoin:round;stroke-dasharray:0.09,0.05;fill:none;">\n' |
| ) |
| for e in self.hiddenEdges: |
| svg += "<path " |
| svg += 'd="' |
| svg += self.getPathData(e) |
| svg += '"/>\n' |
| svg += "</g>\n" |
| return svg |
|
|