From bfd7883c87922093afeda51b673f879808046008 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 8 Aug 2016 10:50:38 -0400 Subject: [PATCH 01/22] Initial support for the X3D scene file format (see http://www.web3d.org/). For now, just mesh import. Supports all geometry nodes in Rendering, Geometry3D, CAD geometry modules. No support for materials, color, or textures. Normals in the file (if any) are ignored. Light sources, viewpoints, sensors, world, shaders, scripts also ignored. --- plugins/X3DReader/X3DReader.py | 792 +++++++++++++++++++++++++++++++++ plugins/X3DReader/__init__.py | 26 ++ 2 files changed, 818 insertions(+) create mode 100644 plugins/X3DReader/X3DReader.py create mode 100644 plugins/X3DReader/__init__.py diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py new file mode 100644 index 0000000000..3b5af06d6e --- /dev/null +++ b/plugins/X3DReader/X3DReader.py @@ -0,0 +1,792 @@ +# Seva Alekseyev with National Institutes of Health, 2016 + +from UM.Mesh.MeshReader import MeshReader +from UM.Mesh.MeshBuilder import MeshBuilder +from UM.Logger import Logger +from UM.Math.Matrix import Matrix +from UM.Math.Vector import Vector +from UM.Scene.SceneNode import SceneNode +from UM.Scene.GroupDecorator import GroupDecorator +from UM.Job import Job +from math import pi, sin, cos, sqrt +import numpy + +EPSILON = 0.000001 # So very crude. :( + +try: + import xml.etree.cElementTree as ET +except ImportError: + import xml.etree.ElementTree as ET + + +DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders + +class X3DReader(MeshReader): + def __init__(self): + super().__init__() + self._supported_extensions = [".x3d"] + self._namespaces = {} + self.defs = {} + + def read(self, file_name): + try: + self.sceneNodes = [] + self.fileName = file_name + self.transform = Matrix() + self.transform.setByScaleVector(Vector(1000, 1000, 1000)) + + tree = ET.parse(file_name) + self.processNode(tree.getroot()) + + if len(self.sceneNodes) > 1: + theScene = SceneNode() + group_decorator = GroupDecorator() + theScene.addDecorator(group_decorator) + for node in self.sceneNodes: + theScene.addChild(node) + elif len(self.sceneNodes) == 1: + theScene = self.sceneNodes[0] + else: # No shapes read :( + return None + theScene.setName(file_name) + except Exception as e: + Logger.log("e", "exception occured in x3d reader: %s", e) + + try: + boundingBox = theScene.getBoundingBox() + boundingBox.isValid() + except: + return None + + return theScene + + # ------------------------- XML tree traversal + + def processNode(self, xmlNode): + xmlNode = self.resolveDefUse(xmlNode) + if xmlNode is None: + return + + tag = xmlNode.tag + if tag in ("X3D", "Scene", "Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): + self.processChildNodes(xmlNode) + elif tag == "LOD": + self.processNode(xmlNode[0]) + elif tag == "Transform": + self.processTransform(xmlNode) + elif tag == "Shape": + self.processShape(xmlNode) + + + def processShape(self, xmlNode): + # Find the geometry and the appearance inside the Shape + geometry = appearance = None + for subNode in xmlNode: + if subNode.tag == "Appearance" and not appearance: + appearance = self.resolveDefUse(subNode) + elif subNode.tag in self.geometryImporters and not geometry: + geometry = self.resolveDefUse(subNode) + + # TODO: appearance is completely ignored. At least apply the material color... + if not geometry is None: + try: + bui = MeshBuilder() + self.geometryImporters[geometry.tag](self, geometry, bui) + + bui.calculateNormals() + bui.setFileName(self.fileName) + + sceneNode = SceneNode() + if "DEF" in geometry.attrib: + sceneNode.setName(geometry.tag + "#" + geometry.attrib["DEF"]) + else: + sceneNode.setName(geometry.tag) + + sceneNode.setMeshData(bui.build().getTransformed(self.transform)) + sceneNode.setSelectable(True) + self.sceneNodes.append(sceneNode) + + except Exception as e: + Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) + + # Returns the referenced node if the node has USE, the same node otherwise. + # May return None is USE points at a nonexistent node + # In X3DOM, when both DEF and USE are in the same node, DEF is ignored. + # Big caveat: XML node objects may evaluate to boolean False!!! + def resolveDefUse(self, node): + USE = node.attrib.get("USE") + if USE: + return self.defs.get(USE, None) + + DEF = node.attrib.get("DEF") + if DEF: + self.defs[DEF] = node + return node + + def processChildNodes(self, node): + for c in node: + self.processNode(c) + Job.yieldThread() + + # Since this is a grouping node, will recurse down the tree. + # According to the spec, the final transform matrix is: + # T * C * R * SR * S * -SR * -C + # Where SR corresponds to the rotation matrix to scaleOrientation + # C and SR are rather exotic. S, slightly less so. + def processTransform(self, node): + rot = readRotation(node, "rotation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + trans = readVector(node, "translation", (0, 0, 0)) # Vector + scale = readVector(node, "scale", (1, 1, 1)) # Vector + center = readVector(node, "center", (0, 0, 0)) # Vector + scaleOrient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + + # Store the previous transform; in Cura, the default matrix multiplication is in place + prev = Matrix(self.transform.getData()) # It's deep copy, I've checked + + # The rest of transform manipulation will be applied in place + gotCenter = (center.x != 0 or center.y != 0 or center.z != 0) + + T = self.transform + if trans.x != 0 or trans.y != 0 or trans.z !=0: + T.translate(trans) + if gotCenter: + T.translate(center) + if rot[0] != 0: + T.rotateByAxis(*rot) + if scale.x != 1 or scale.y != 1 or scale.z != 1: + gotScaleOrient = scaleOrient[0] != 0 + if gotScaleOrient: + T.rotateByAxis(*scaleOrient) + # No scale by vector in place operation in UM + S = Matrix() + S.setByScaleVector(scale) + T.multiply(S) + if gotScaleOrient: + T.rotateByAxis(-scaleOrient[0], scaleOrient[1]) + if gotCenter: + T.translate(-center) + + self.processChildNodes(node) + self.transform = prev + + # ------------------------- Geometry importers + # They are supposed to fill the MeshBuilder object with vertices and faces, the caller will do the rest + + # Primitives + + def geomBox(self, node, bui): + size = readFloatArray(node, "size", [2, 2, 2]) + bui.addCube(size[0], size[1], size[2]) + + # The sphere is subdivided into nr rings and ns segments + def geomSphere(self, node, bui): + r = readFloat(node, "radius", 0.5) + subdiv = readIntArray(node, 'subdivision', None) + if subdiv: + if len(subdiv) == 1: + nr = ns = subdiv[0] + else: + (nr, ns) = subdiv + else: + nr = ns = DEFAULT_SUBDIV + + + lau = pi / nr # Unit angle of latitude (rings) for the given tesselation + lou = 2 * pi / ns # Unit angle of longitude (segments) + + bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) + + + # +y and -y poles + bui.addVertex(0, r, 0) + bui.addVertex(0, -r, 0) + + # The non-polar vertices go from x=0, negative z plane counterclockwise - + # to -x, to +z, to +x, back to -z + for ring in range(1, nr): + for seg in range(ns): + bui.addVertex(-r*sin(lou * seg) * sin(lau * ring), + r*cos(lau * ring), + -r*cos(lou * seg) * sin(lau * ring)) + + vb = 2 + (nr - 2) * ns # First vertex index for the bottom cap + + # Faces go in order: top cap, sides, bottom cap. + # Sides go by ring then by segment. + + # Caps + # Top cap face vertices go in order: down right up + # (starting from +y pole) + # Bottom cap goes: up left down (starting from -y pole) + for seg in range(ns): + addTri(bui, 0, seg + 2, (seg + 1) % ns + 2) + addTri(bui, 1, vb + (seg + 1) % ns, vb + seg) + + # Sides + # Side face vertices go in order: down right upleft, downright up left + for ring in range(nr - 2): + tvb = 2 + ring * ns + # First vertex index for the top edge of the ring + bvb = tvb + ns + # First vertex index for the bottom edge of the ring + for seg in range(ns): + nseg = (seg + 1) % ns + addQuad(bui, tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) + + def geomCone(self, node, bui): + r = readFloat(node, "bottomRadius", 1) + height = readFloat(node, "height", 2) + bottom = readBoolean(node, "bottom", True) + side = readBoolean(node, "side", True) + n = readInt(node, 'subdivision', DEFAULT_SUBDIV) + + d = height / 2 + angle = 2 * pi / n + + bui.reserveFaceAndVertexCount((n if side else 0) + (n-1 if bottom else 0), n+1) + + bui.addVertex(0, d, 0) + for i in range(n): + bui.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) + + # Side face vertices go: up down right + if side: + for i in range(n): + addTri(bui, 1 + (i + 1) % n, 0, 1 + i) + if bottom: + for i in range(2, n): + addTri(bui, 1, i, i+1) + + def geomCylinder(self, node, bui): + r = readFloat(node, "radius", 1) + height = readFloat(node, "height", 2) + bottom = readBoolean(node, "bottom", True) + side = readBoolean(node, "side", True) + top = readBoolean(node, "top", True) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) + + nn = n * 2 + angle = 2 * pi / n + hh = height/2 + + bui.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) + + # The seam is at x=0, z=-r, vertices go ccw - + # to pos x, to neg z, to neg x, back to neg z + for i in range(n): + rs = -r * sin(angle * i) + rc = -r * cos(angle * i) + bui.addVertex(rs, hh, rc) + bui.addVertex(rs, -hh, rc) + + if side: + for i in range(n): + ni = (i + 1) % n + addQuad(bui, ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) + + for i in range(2, nn-3, 2): + if top: + addTri(bui, 0, i, i+2) + if bottom: + addTri(bui, 1, i+1, i+3) + +# Semi-primitives + + def geomElevationGrid(self, node, bui): + dx = readFloat(node, "xSpacing", 1) + dz = readFloat(node, "zSpacing", 1) + nx = readInt(node, "xDimension", 0) + nz = readInt(node, "zDimension", 0) + height = readFloatArray(node, "height", False) + ccw = readBoolean(node, "ccw", True) + + if nx <= 0 or nz <= 0 or len(height) < nx*nz: + return # That's weird, the wording of the standard suggests grids with zero quads are somehow valid + + bui.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) + + for z in range(nz): + for x in range(nx): + bui.addVertex(x * dx, height[z*nx + x], z * dz) + + for z in range(1, nz): + for x in range(1, nx): + addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) + addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) + + def geomExtrusion(self, node, bui): + ccw = readBoolean(node, "ccw", True) + beginCap = readBoolean(node, "beginCap", True) + endCap = readBoolean(node, "endCap", True) + cross = readFloatArray(node, "crossSection", (1, 1, 1, -1, -1, -1, -1, 1, 1, 1)) + cross = [(cross[i], cross[i+1]) for i in range(0, len(cross), 2)] + spine = readFloatArray(node, "spine", (0, 0, 0, 0, 1, 0)) + spine = [(spine[i], spine[i+1], spine[i+2]) for i in range(0, len(spine), 3)] + orient = readFloatArray(node, 'orientation', None) + if orient: + orient = [toNumpyRotation(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + scale = readFloatArray(node, "scale", None) + if scale: + scale = [numpy.array(((scale[i], 0, 0), (0, 1, 0), (0, 0, scale[i+1]))) + if scale[i] != 1 or scale[i+1] != 1 else None for i in range(0, len(scale), 2)] + + + # Special treatment for the closed spine and cross section. + # Let's save some memory by not creating identical but distinct vertices; + # later we'll introduce conditional logic to link the last vertex with + # the first one where necessary. + crossClosed = cross[0] == cross[-1] + if crossClosed: + cross = cross[:-1] + nc = len(cross) + cross = [numpy.array((c[0], 0, c[1])) for c in cross] + ncf = nc if crossClosed else nc - 1 + # Face count along the cross; for closed cross, it's the same as the + # respective vertex count + + spineClosed = spine[0] == spine[-1] + if spineClosed: + spine = spine[:-1] + ns = len(spine) + spine = [Vector(*s) for s in spine] + nsf = ns if spineClosed else ns - 1 + + # This will be used for fallback, where the current spine point joins + # two collinear spine segments. No need to recheck the case of the + # closed spine/last-to-first point juncture; if there's an angle there, + # it would kick in on the first iteration of the main loop by spine. + def findFirstAngleNormal(): + for i in range(1, ns - 1): + spt = spine[i] + z = (spine[i + 1] - spt).cross(spine[i - 1] - spt) + if z.length() > EPSILON: + return z + # All the spines are collinear. Fallback to the rotated source + # XZ plane. + # TODO: handle the situation where the first two spine points match + v = spine[1] - spine[0] + orig_y = Vector(0, 1, 0) + orig_z = Vector(0, 0, 1) + if v.cross(orig_y).length() > EPSILON: + # Spine at angle with global y - rotate the z accordingly + a = v.cross(orig_y) # Axis of rotation to get to the Z + (x, y, z) = a.normalized().getData() + s = a.length()/v.length() + c = sqrt(1-s*s) + t = 1-c + m = numpy.array(( + (x * x * t + c, x * y * t + z*s, x * z * t - y * s), + (x * y * t - z*s, y * y * t + c, y * z * t + x * s), + (x * z * t + y * s, y * z * t - x * s, z * z * t + c))) + orig_z = Vector(*m.dot(orig_z.getData())) + return orig_z + + bui.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + + z = None + for i, spt in enumerate(spine): + if (i > 0 and i < ns - 1) or spineClosed: + snext = spine[(i + 1) % ns] + sprev = spine[(i - 1 + ns) % ns] + y = snext - sprev + vnext = snext - spt + vprev = sprev - spt + try_z = vnext.cross(vprev) + # Might be zero, then all kinds of fallback + if try_z.length() > EPSILON: + if z is not None and try_z.dot(z) < 0: + try_z = -try_z + z = try_z + elif not z: # No z, and no previous z. + # Look ahead, see if there's at least one point where + # spines are not collinear. + z = findFirstAngleNormal() + elif i == 0: # And non-crossed + snext = spine[i + 1] + y = snext - spt + z = findFirstAngleNormal() + else: # last point and not crossed + sprev = spine[i - 1] + y = spt - sprev + # If there's more than one point in the spine, z is already set. + # One point in the spline is an error anyway. + + z = z.normalized() + y = y.normalized() + x = y.cross(z) # Already normalized + m = numpy.array((x.getData(), y.getData(), z.getData())) + + # Columns are the unit vectors for the xz plane for the cross-section + if orient: + mrot = orient[i] if len(orient) > 1 else orient[0] + if not mrot is None: + m = m.dot(mrot) # Not sure about this. Counterexample??? + + if scale: + mscale = scale[i] if len(scale) > 1 else scale[0] + if not mscale is None: + m = m.dot(mscale) + + # First the cross-section 2-vector is scaled, + # then rotated (which may make it a 3-vector), + # then applied to the xz plane unit vectors + + for cpt in cross: + v = numpy.array(spt.getData()[:3]) + m.dot(cpt) + bui.addVertex(*v) + # Could've done this with a single 4x4 matrix... Oh well + + if beginCap: + addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) + + # Order of edges in the face: forward along cross, forward along spine, + # backward along cross, backward along spine, flipped if now ccw. + # This order is assumed later in the texture coordinate assignment; + # please don't change without syncing. + + for s in range(ns - 1): + for c in range(ncf): + addQuadFlip(bui, s * nc + c, s * nc + (c + 1) % nc, + (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) + + if spineClosed: + # The faces between the last and the first spine points + b = (ns - 1) * nc + for c in range(ncf): + addQuadFlip(bui, b + c, b + (c + 1) % nc, + (c + 1) % nc, c, ccw) + + if endCap: + addFace(bui, [(ns - 1) * nc + x for x in range(0, nc)], ccw) + +# Triangle meshes + + # Helper for numerous nodes with a Coordinate subnode holding vertices + # That all triangle meshes and IndexedFaceSet + # nFaces can be a function, in case the face count is a function of coord + def startCoordMesh(self, node, bui, nFaces): + ccw = readBoolean(node, "ccw", True) + coord = self.readVertices(node) + if hasattr(nFaces, '__call__'): + nFaces = nFaces(coord) + bui.reserveFaceAndVertexCount(nFaces, len(coord)) + for pt in coord: + bui.addVertex(*pt) + + return ccw + + + def geomIndexedTriangleSet(self, node, bui): + index = readIntArray(node, "index", []) + nFaces = len(index) // 3 + ccw = self.startCoordMesh(node, bui, nFaces) + + for i in range(0, nFaces*3, 3): + addTriFlip(bui, index[i], index[i+1], index[i+2], ccw) + + def geomIndexedTriangleStripSet(self, node, bui): + strips = readIndex(node, "index") + ccw = self.startCoordMesh(node, bui, sum([len(strip) - 2 for strip in strips])) + + for strip in strips: + sccw = ccw # Running CCW value, reset for each strip + for i in range(len(strip) - 2): + addTriFlip(bui, strip[i], strip[i+1], strip[i+2], sccw) + sccw = not sccw + + def geomIndexedTriangleFanSet(self, node, bui): + fans = readIndex(node, "index") + ccw = self.startCoordMesh(node, bui, sum([len(fan) - 2 for fan in fans])) + + for fan in fans: + for i in range(1, len(fan) - 1): + addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) + + + def geomTriangleSet(self, node, bui): + ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) + for i in range(0, len(bui.getVertices()), 3): + addTriFlip(bui, i, i+1, i+2, ccw) + + def geomTriangleStripSet(self, node, bui): + strips = readIntArray(node, "stripCount", []) + ccw = self.startCoordMesh(node, bui, sum([n-2 for n in strips])) + + vb = 0 + for n in strips: + sccw = ccw + for i in range(n-2): + addTriFlip(bui, vb+i, vb+i+1, vb+i+2, sccw) + sccw = not sccw + vb += n + + def geomTriangleFanSet(self, node, bui): + fans = readIntArray(node, "fanCount", []) + ccw = self.startCoordMesh(node, bui, sum([n-2 for n in fans])) + + vb = 0 + for n in fans: + for i in range(1, n-1): + addTriFlip(bui, vb, vb+i, vb+i+1, ccw) + vb += n + + # Quad geometries from the CAD module, might be relevant for printing + + def geomQuadSet(self, node, bui): + ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 4) + for i in range(0, len(bui.getVertices()), 4): + addQuadFlip(bui, i, i+1, i+2, i+4, ccw) + + def geomIndexedQuadSet(self, node, bui): + index = readIntArray(node, "index", []) + nFaces = len(index) // 4 + ccw = self.startCoordMesh(node, bui, nFaces) + + for i in range(0, nFaces*4, 4): + addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) + + + # General purpose polygon mesh + + def geomIndexedFaceSet(self, node, bui): + faces = readIndex(node, "coordIndex") + ccw = self.startCoordMesh(node, bui, sum([len(face) - 2 for face in faces])) + + for face in faces: + if len(face) == 3: + addTriFlip(bui, face[0], face[1], face[2], ccw) + elif len(face) > 3: + addFace(bui, face, ccw) + + geometryImporters = { + 'IndexedFaceSet': geomIndexedFaceSet, + 'IndexedTriangleSet': geomIndexedTriangleSet, + 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, + 'IndexedTriangleFanSet': geomIndexedTriangleFanSet, + 'TriangleSet': geomTriangleSet, + 'TriangleStripSet': geomTriangleStripSet, + 'TriangleFanSet': geomTriangleFanSet, + 'QuadSet': geomQuadSet, + 'IndexedQuadSet': geomIndexedQuadSet, + 'ElevationGrid': geomElevationGrid, + 'Extrusion': geomExtrusion, + 'Sphere': geomSphere, + 'Box': geomBox, + 'Cylinder': geomCylinder, + 'Cone': geomCone + } + + # Parses the Coordinate.@point field + def readVertices(self, node): + for c in node: + if c.tag == "Coordinate": + c = self.resolveDefUse(c) + if not c is None: + pt = c.attrib.get("point") + if pt: + co = [float(x) for x in pt.split()] + # Group by three + return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] + return [] + +# ------------------------------------------------------------ +# X3D field parsers +# ------------------------------------------------------------ +def readFloatArray(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return [float(x) for x in s.split()] + +def readIntArray(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return [int(x, 0) for x in s.split()] + +def readFloat(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return float(s) + +def readInt(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return int(s, 0) + +def readBoolean(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return s.lower() == "true" + +def readVector(node, attr, default): + v = readFloatArray(node, attr, default) + return Vector(v[0], v[1], v[2]) + +def readRotation(node, attr, default): + v = readFloatArray(node, attr, default) + return (v[3], Vector(v[0], v[1], v[2])) + +# Returns the -1-separated runs +def readIndex(node, attr): + v = readIntArray(node, attr, []) + chunks = [] + chunk = [] + for i in range(len(v)): + if v[i] == -1: + if chunk: + chunks.append(chunk) + chunk = [] + else: + chunk.append(v[i]) + if chunk: + chunks.append(chunk) + return chunks + +# Mesh builder helpers + +def addTri(bui, a, b, c): + bui._indices[bui._face_count, 0] = a + bui._indices[bui._face_count, 1] = b + bui._indices[bui._face_count, 2] = c + bui._face_count += 1 + +def addTriFlip(bui, a, b, c, ccw): + if ccw: + addTri(bui, a, b, c) + else: + addTri(bui, b, a, c) + +# Needs to be convex, but not necessaily planar +# Assumed ccw, cut along the ac diagonal +def addQuad(bui, a, b, c, d): + addTri(bui, a, b, c) + addTri(bui, c, d, a) + +def addQuadFlip(bui, a, b, c, d, ccw): + if ccw: + addTri(bui, a, b, c) + addTri(bui, c, d, a) + else: + addTri(bui, a, c, b) + addTri(bui, c, a, d) + + +# Arbitrary polygon triangulation. +# Doesn't assume convexity and doesn't check the "convex" flag in the file. +# Works by the "cutting of ears" algorithm: +# - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle +# - Remove the triangle at that vertex +# - Repeat until done +# Note that n is the count of vertices in the face, but the `face` array is one element bigger, with nth element same as the 0th one +# Vertex coordinates are supposed to be already in the mesh builder object +def addFace(bui, indices, ccw): + # Resolve indices to coordinates for faster math + n = len(indices) + verts = bui.getVertices() + face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + + # Need a normal to the plane so that we can know which vertices form inner angles + normal = findOuterNormal(face) + + if not normal: # Couldn't find an outer edge, non-planar polygon maybe? + return + + # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done + m = len(face) + vi = [i for i in range(m)] # We'll be using this to kick vertices from the face + while m > 3: + maxCos = EPSILON # We don't want to check anything on Pi angles + iMin = 0 # max cos corresponds to min angle + for i in range(m): + inext = (i + 1) % m + iprev = (i + m - 1) % m + v = face[vi[i]] + next = face[vi[inext]] - v + prev = face[vi[iprev]] - v + nextXprev = next.cross(prev) + if nextXprev.dot(normal) > EPSILON: # If it's an inner angle + cos = next.dot(prev) / (next.length() * prev.length()) + if cos > maxCos: + # Check if there are vertices inside the triangle + noPointsInside = True + for j in range(m): + if j != i and j != iprev and j != inext: + vx = face[vi[j]] - v + if pointInsideTriangle(vx, next, prev, nextXprev): + noPointsInside = False + break + + if noPointsInside: + maxCos = cos + iMin = i + + addTriFlip(bui, indices[vi[(iMin + m - 1) % m]], indices[vi[iMin]], indices[vi[(iMin + 1) % m]], ccw) + vi.pop(iMin) + m -= 1 + addTriFlip(bui, indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) + + +# Given a face as a sequence of vectors, returns a normal to the polygon place that forms a right triple +# with a vector along the polygon sequence and a vector backwards +def findOuterNormal(face): + n = len(face) + for i in range(n): + for j in range(i+1, n): + edge = face[j] - face[i] + if edge.length() > EPSILON: + edge = edge.normalized() + prevRejection = Vector() + isOuter = True + for k in range(n): + if k != i and k != j: + pt = face[k] - face[i] + pte = pt.dot(edge) + rejection = pt - edge*pte + if rejection.dot(prevRejection) < -EPSILON: # points on both sides of the edge - not an outer one + isOuter = False + break + elif rejection.length() > prevRejection.length(): # Pick a greater rejection for numeric stability + prevRejection = rejection + + if isOuter: # Found an outer edge, prevRejection is the rejection inside the face. Generate a normal. + return edge.cross(prevRejection) + + return False + +# Assumes the vectors are either parallel or antiparallel and the denominator is nonzero. +# No error handling. +# For stability, taking the ration between the biggest coordinates would be better; none of that, either. +def ratio(a, b): + if b.x > EPSILON: + return a.x / b.x + elif b.y > EPSILON: + return a.y / b.y + else: + return a.z / b.z + +def pointInsideTriangle(vx, next, prev, nextXprev): + vxXprev = vx.cross(prev) + r = ratio(vxXprev, nextXprev) + if r < 0: + return False; + vxXnext = vx.cross(next); + s = -ratio(vxXnext, nextXprev) + return s > 0 and (s + r) < 1 + +def toNumpyRotation(rot): + (x, y, z) = rot[:3] + a = rot[3] + s = sin(a) + c = cos(a) + t = 1-c + return numpy.array(( + (x * x * t + c, x * y * t - z*s, x * z * t + y * s), + (x * y * t + z*s, y * y * t + c, y * z * t - x * s), + (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + + + \ No newline at end of file diff --git a/plugins/X3DReader/__init__.py b/plugins/X3DReader/__init__.py new file mode 100644 index 0000000000..84922f627f --- /dev/null +++ b/plugins/X3DReader/__init__.py @@ -0,0 +1,26 @@ +# Seva Alekseyev with National Institutes of Health, 2016 + +from . import X3DReader + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +def getMetaData(): + return { + "plugin": { + "name": catalog.i18nc("@label", "X3D Reader"), + "author": "Seva Alekseyev", + "version": "0.5", + "description": catalog.i18nc("@info:whatsthis", "Provides support for reading X3D files."), + "api": 3 + }, + "mesh_reader": [ + { + "extension": "x3d", + "description": catalog.i18nc("@item:inlistbox", "X3D File") + } + ] + } + +def register(app): + return { "mesh_reader": X3DReader.X3DReader() } From d32c300ee7bdf45a767282daf0ae64351240f751 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 8 Aug 2016 14:11:30 -0400 Subject: [PATCH 02/22] Support for explicit length unit override in X3D header --- plugins/X3DReader/X3DReader.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 3b5af06d6e..43f528fb82 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -32,11 +32,31 @@ class X3DReader(MeshReader): try: self.sceneNodes = [] self.fileName = file_name - self.transform = Matrix() - self.transform.setByScaleVector(Vector(1000, 1000, 1000)) tree = ET.parse(file_name) - self.processNode(tree.getroot()) + root = tree.getroot() + + if root.tag != "X3D": + return None + + scale = 1000 # Default X3D unit it one meter, while Cura's is one mm + if root[0].tag == "head": + for headNode in root[0]: + if headNode.tag == "unit" and headNode.attrib.get("category") == "length": + scale *= float.parse(headNode.attrib["conversionFactor"]) + break + scene = root[1] + else: + scene = root[0] + + if scene.tag != "Scene": + return None + + self.transform = Matrix() + self.transform.setByScaleVector(Vector(scale, scale, scale)) + + # This will populate the sceneNodes array + self.processChildNodes(scene) if len(self.sceneNodes) > 1: theScene = SceneNode() @@ -68,7 +88,7 @@ class X3DReader(MeshReader): return tag = xmlNode.tag - if tag in ("X3D", "Scene", "Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): + if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): self.processChildNodes(xmlNode) elif tag == "LOD": self.processNode(xmlNode[0]) From 315b4a46b75000d0be0cadff0e1ad7e5e539069d Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Tue, 9 Aug 2016 11:46:41 -0400 Subject: [PATCH 03/22] Bug fix --- plugins/X3DReader/X3DReader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 43f528fb82..8311df0ebe 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -43,7 +43,7 @@ class X3DReader(MeshReader): if root[0].tag == "head": for headNode in root[0]: if headNode.tag == "unit" and headNode.attrib.get("category") == "length": - scale *= float.parse(headNode.attrib["conversionFactor"]) + scale *= float(headNode.attrib["conversionFactor"]) break scene = root[1] else: From 5ff3130260d6b41d4433b468a20ac4fd0d404f98 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Tue, 9 Aug 2016 14:10:06 -0400 Subject: [PATCH 04/22] Fix for the Extrusion node, the trickiest geometry of them all --- plugins/X3DReader/X3DReader.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 8311df0ebe..1302abe448 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -26,10 +26,12 @@ class X3DReader(MeshReader): super().__init__() self._supported_extensions = [".x3d"] self._namespaces = {} - self.defs = {} - + + # Main entry point + # Reads the file, returns a SceneNode (possibly with nested ones), or None def read(self, file_name): try: + self.defs = {} self.sceneNodes = [] self.fileName = file_name @@ -39,7 +41,7 @@ class X3DReader(MeshReader): if root.tag != "X3D": return None - scale = 1000 # Default X3D unit it one meter, while Cura's is one mm + scale = 1000 # Default X3D unit it one meter, while Cura's is one millimeters if root[0].tag == "head": for headNode in root[0]: if headNode.tag == "unit" and headNode.attrib.get("category") == "length": @@ -53,9 +55,9 @@ class X3DReader(MeshReader): return None self.transform = Matrix() - self.transform.setByScaleVector(Vector(scale, scale, scale)) + self.transform.setByScaleFactor(scale) - # This will populate the sceneNodes array + # Traverse the scene tree, populate the sceneNodes array self.processChildNodes(scene) if len(self.sceneNodes) > 1: @@ -132,7 +134,8 @@ class X3DReader(MeshReader): # Returns the referenced node if the node has USE, the same node otherwise. # May return None is USE points at a nonexistent node # In X3DOM, when both DEF and USE are in the same node, DEF is ignored. - # Big caveat: XML node objects may evaluate to boolean False!!! + # Big caveat: XML element objects may evaluate to boolean False!!! + # Don't ever use "if node:", use "if not node is None:" instead def resolveDefUse(self, node): USE = node.attrib.get("USE") if USE: @@ -210,12 +213,10 @@ class X3DReader(MeshReader): else: nr = ns = DEFAULT_SUBDIV - lau = pi / nr # Unit angle of latitude (rings) for the given tesselation lou = 2 * pi / ns # Unit angle of longitude (segments) bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) - # +y and -y poles bui.addVertex(0, r, 0) @@ -434,13 +435,13 @@ class X3DReader(MeshReader): z = z.normalized() y = y.normalized() x = y.cross(z) # Already normalized - m = numpy.array((x.getData(), y.getData(), z.getData())) + m = numpy.array(((x.x, y.x, z.x), (x.y, y.y, z.y), (x.z, y.z, z.z))) # Columns are the unit vectors for the xz plane for the cross-section if orient: mrot = orient[i] if len(orient) > 1 else orient[0] if not mrot is None: - m = m.dot(mrot) # Not sure about this. Counterexample??? + m = m.dot(mrot) # Tested against X3DOM, the result matches, still not sure :( if scale: mscale = scale[i] if len(scale) > 1 else scale[0] @@ -451,10 +452,10 @@ class X3DReader(MeshReader): # then rotated (which may make it a 3-vector), # then applied to the xz plane unit vectors + sptv3 = numpy.array(spt.getData()[:3]) for cpt in cross: - v = numpy.array(spt.getData()[:3]) + m.dot(cpt) + v = sptv3 + m.dot(cpt) bui.addVertex(*v) - # Could've done this with a single 4x4 matrix... Oh well if beginCap: addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) @@ -521,7 +522,6 @@ class X3DReader(MeshReader): for fan in fans: for i in range(1, len(fan) - 1): addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node, bui): ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) From 28a0fe9531f62f4d7349eb32d913a17125dfaffb Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Wed, 10 Aug 2016 10:47:49 -0400 Subject: [PATCH 05/22] Fixes for quad geometries --- plugins/X3DReader/X3DReader.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 1302abe448..ba798e0e96 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -553,18 +553,17 @@ class X3DReader(MeshReader): # Quad geometries from the CAD module, might be relevant for printing def geomQuadSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 4) + ccw = self.startCoordMesh(node, bui, lambda coord: 2*(len(coord) // 4)) for i in range(0, len(bui.getVertices()), 4): - addQuadFlip(bui, i, i+1, i+2, i+4, ccw) + addQuadFlip(bui, i, i+1, i+2, i+3, ccw) def geomIndexedQuadSet(self, node, bui): index = readIntArray(node, "index", []) - nFaces = len(index) // 4 - ccw = self.startCoordMesh(node, bui, nFaces) + nQuads = len(index) // 4 + ccw = self.startCoordMesh(node, bui, nQuads*2) - for i in range(0, nFaces*4, 4): + for i in range(0, nQuads*4, 4): addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) - # General purpose polygon mesh From 0279916ae4ece940c2332351464be5ed39b668f8 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Thu, 11 Aug 2016 10:08:25 -0400 Subject: [PATCH 06/22] In X3D import, setSelectable(true) for the whole scene --- plugins/X3DReader/X3DReader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index ba798e0e96..72fba5d1fe 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -66,6 +66,7 @@ class X3DReader(MeshReader): theScene.addDecorator(group_decorator) for node in self.sceneNodes: theScene.addChild(node) + theScene.setSelectable(True) elif len(self.sceneNodes) == 1: theScene = self.sceneNodes[0] else: # No shapes read :( @@ -700,7 +701,6 @@ def addQuadFlip(bui, a, b, c, d, ccw): # - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle # - Remove the triangle at that vertex # - Repeat until done -# Note that n is the count of vertices in the face, but the `face` array is one element bigger, with nth element same as the 0th one # Vertex coordinates are supposed to be already in the mesh builder object def addFace(bui, indices, ccw): # Resolve indices to coordinates for faster math From 4bad60f94421570adac05156004b4b222bef496d Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Thu, 11 Aug 2016 11:17:48 -0400 Subject: [PATCH 07/22] Slicing logic now index-aware, correctly processes scenes with vertex reuse --- plugins/CuraEngineBackend/StartSliceJob.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index fc9fd05b44..62a027b110 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -148,7 +148,12 @@ class StartSliceJob(Job): obj = group_message.addRepeatedMessage("objects") obj.id = id(object) - verts = numpy.array(mesh_data.getVertices()) + verts = mesh_data.getVertices() + indices = mesh_data.getIndices() + if not indices is None: + verts = numpy.array([verts[vert_index] for face in indices for vert_index in face]) + else: + verts = numpy.array(verts) # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. verts[:, [1, 2]] = verts[:, [2, 1]] From e4bac94189865299047fd4cd1543f8b0c71377bc Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Fri, 12 Aug 2016 15:15:56 -0400 Subject: [PATCH 08/22] Merging all shapes into a single mesh during X3D loading --- plugins/X3DReader/X3DReader.py | 544 +++++++++++++++++++-------------- 1 file changed, 318 insertions(+), 226 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 72fba5d1fe..8a5822d9ed 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -11,6 +11,7 @@ from UM.Job import Job from math import pi, sin, cos, sqrt import numpy + EPSILON = 0.000001 # So very crude. :( try: @@ -18,9 +19,19 @@ try: except ImportError: import xml.etree.ElementTree as ET +# TODO: preserve the structure of scenes that contain several objects +# Use CADPart, for example, to distinguish between separate objects -DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +class Shape: + def __init__(self, v, f, ib, n): + self.verts = v + self.faces = f + # Those are here for debugging purposes only + self.index_base = ib + self.name = n + class X3DReader(MeshReader): def __init__(self): super().__init__() @@ -32,102 +43,97 @@ class X3DReader(MeshReader): def read(self, file_name): try: self.defs = {} - self.sceneNodes = [] - self.fileName = file_name + self.shapes = [] tree = ET.parse(file_name) - root = tree.getroot() + xml_root = tree.getroot() - if root.tag != "X3D": + if xml_root.tag != "X3D": return None scale = 1000 # Default X3D unit it one meter, while Cura's is one millimeters - if root[0].tag == "head": - for headNode in root[0]: - if headNode.tag == "unit" and headNode.attrib.get("category") == "length": - scale *= float(headNode.attrib["conversionFactor"]) + if xml_root[0].tag == "head": + for head_node in xml_root[0]: + if head_node.tag == "unit" and head_node.attrib.get("category") == "length": + scale *= float(head_node.attrib["conversionFactor"]) break - scene = root[1] + xml_scene = xml_root[1] else: - scene = root[0] + xml_scene = xml_root[0] - if scene.tag != "Scene": + if xml_scene.tag != "Scene": return None self.transform = Matrix() self.transform.setByScaleFactor(scale) + self.index_base = 0 - # Traverse the scene tree, populate the sceneNodes array - self.processChildNodes(scene) + # Traverse the scene tree, populate the shapes list + self.processChildNodes(xml_scene) - if len(self.sceneNodes) > 1: - theScene = SceneNode() - group_decorator = GroupDecorator() - theScene.addDecorator(group_decorator) - for node in self.sceneNodes: - theScene.addChild(node) - theScene.setSelectable(True) - elif len(self.sceneNodes) == 1: - theScene = self.sceneNodes[0] - else: # No shapes read :( + if self.shapes: + bui = MeshBuilder() + bui.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) + bui.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) + bui.calculateNormals() + bui.setFileName(file_name) + + scene = SceneNode() + scene.setMeshData(bui.build().getTransformed(Matrix())) + scene.setSelectable(True) + scene.setName(file_name) + else: return None - theScene.setName(file_name) + except Exception as e: Logger.log("e", "exception occured in x3d reader: %s", e) try: - boundingBox = theScene.getBoundingBox() + boundingBox = scene.getBoundingBox() boundingBox.isValid() except: return None - return theScene + return scene # ------------------------- XML tree traversal - def processNode(self, xmlNode): - xmlNode = self.resolveDefUse(xmlNode) - if xmlNode is None: + def processNode(self, xml_node): + xml_node = self.resolveDefUse(xml_node) + if xml_node is None: return - tag = xmlNode.tag - if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): - self.processChildNodes(xmlNode) + tag = xml_node.tag + if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "Collision"): + self.processChildNodes(xml_node) + if tag == "CADPart": + self.processTransform(xml_node) # TODO: split the parts elif tag == "LOD": - self.processNode(xmlNode[0]) + self.processNode(xml_node[0]) elif tag == "Transform": - self.processTransform(xmlNode) + self.processTransform(xml_node) elif tag == "Shape": - self.processShape(xmlNode) + self.processShape(xml_node) - def processShape(self, xmlNode): + def processShape(self, xml_node): # Find the geometry and the appearance inside the Shape geometry = appearance = None - for subNode in xmlNode: + for subNode in xml_node: if subNode.tag == "Appearance" and not appearance: appearance = self.resolveDefUse(subNode) - elif subNode.tag in self.geometryImporters and not geometry: + elif subNode.tag in self.geometry_importers and not geometry: geometry = self.resolveDefUse(subNode) # TODO: appearance is completely ignored. At least apply the material color... if not geometry is None: try: - bui = MeshBuilder() - self.geometryImporters[geometry.tag](self, geometry, bui) - - bui.calculateNormals() - bui.setFileName(self.fileName) - - sceneNode = SceneNode() - if "DEF" in geometry.attrib: - sceneNode.setName(geometry.tag + "#" + geometry.attrib["DEF"]) - else: - sceneNode.setName(geometry.tag) - - sceneNode.setMeshData(bui.build().getTransformed(self.transform)) - sceneNode.setSelectable(True) - self.sceneNodes.append(sceneNode) + self.verts = self.faces = [] # Safeguard + self.geometry_importers[geometry.tag](self, geometry) + m = self.transform.getData() + verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) + self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) + self.index_base += len(verts) except Exception as e: Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) @@ -198,12 +204,33 @@ class X3DReader(MeshReader): # Primitives - def geomBox(self, node, bui): - size = readFloatArray(node, "size", [2, 2, 2]) - bui.addCube(size[0], size[1], size[2]) + def geomBox(self, node): + (dx, dy, dz) = readFloatArray(node, "size", [2, 2, 2]) + dx /= 2 + dy /= 2 + dz /= 2 + self.reserveFaceAndVertexCount(12, 8) + + # xz plane at +y, ccw + self.addVertex(dx, dy, dz) + self.addVertex(-dx, dy, dz) + self.addVertex(-dx, dy, -dz) + self.addVertex(dx, dy, -dz) + # xz plane at -y + self.addVertex(dx, -dy, dz) + self.addVertex(-dx, -dy, dz) + self.addVertex(-dx, -dy, -dz) + self.addVertex(dx, -dy, -dz) + + self.addQuad(0, 1, 2, 3) # +y + self.addQuad(4, 0, 3, 7) # +x + self.addQuad(7, 3, 2, 6) # -z + self.addQuad(6, 2, 1, 5) # -x + self.addQuad(5, 1, 0, 4) # +z + self.addQuad(7, 6, 5, 4) # -y # The sphere is subdivided into nr rings and ns segments - def geomSphere(self, node, bui): + def geomSphere(self, node): r = readFloat(node, "radius", 0.5) subdiv = readIntArray(node, 'subdivision', None) if subdiv: @@ -217,17 +244,17 @@ class X3DReader(MeshReader): lau = pi / nr # Unit angle of latitude (rings) for the given tesselation lou = 2 * pi / ns # Unit angle of longitude (segments) - bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) + self.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr - 1)*ns) # +y and -y poles - bui.addVertex(0, r, 0) - bui.addVertex(0, -r, 0) + self.addVertex(0, r, 0) + self.addVertex(0, -r, 0) # The non-polar vertices go from x=0, negative z plane counterclockwise - # to -x, to +z, to +x, back to -z for ring in range(1, nr): for seg in range(ns): - bui.addVertex(-r*sin(lou * seg) * sin(lau * ring), + self.addVertex(-r*sin(lou * seg) * sin(lau * ring), r*cos(lau * ring), -r*cos(lou * seg) * sin(lau * ring)) @@ -241,8 +268,8 @@ class X3DReader(MeshReader): # (starting from +y pole) # Bottom cap goes: up left down (starting from -y pole) for seg in range(ns): - addTri(bui, 0, seg + 2, (seg + 1) % ns + 2) - addTri(bui, 1, vb + (seg + 1) % ns, vb + seg) + self.addTri(0, seg + 2, (seg + 1) % ns + 2) + self.addTri(1, vb + (seg + 1) % ns, vb + seg) # Sides # Side face vertices go in order: down right upleft, downright up left @@ -253,9 +280,9 @@ class X3DReader(MeshReader): # First vertex index for the bottom edge of the ring for seg in range(ns): nseg = (seg + 1) % ns - addQuad(bui, tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) + self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) - def geomCone(self, node, bui): + def geomCone(self, node): r = readFloat(node, "bottomRadius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -265,21 +292,22 @@ class X3DReader(MeshReader): d = height / 2 angle = 2 * pi / n - bui.reserveFaceAndVertexCount((n if side else 0) + (n-1 if bottom else 0), n+1) + self.reserveFaceAndVertexCount((n if side else 0) + (n-2 if bottom else 0), n+1) - bui.addVertex(0, d, 0) + # Vertex 0 is the apex, vertices 1..n are the bottom + self.addVertex(0, d, 0) for i in range(n): - bui.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) + self.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) # Side face vertices go: up down right if side: for i in range(n): - addTri(bui, 1 + (i + 1) % n, 0, 1 + i) + self.addTri(1 + (i + 1) % n, 0, 1 + i) if bottom: for i in range(2, n): - addTri(bui, 1, i, i+1) + self.addTri(1, i, i+1) - def geomCylinder(self, node, bui): + def geomCylinder(self, node): r = readFloat(node, "radius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -291,30 +319,30 @@ class X3DReader(MeshReader): angle = 2 * pi / n hh = height/2 - bui.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) + self.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) # The seam is at x=0, z=-r, vertices go ccw - # to pos x, to neg z, to neg x, back to neg z for i in range(n): rs = -r * sin(angle * i) rc = -r * cos(angle * i) - bui.addVertex(rs, hh, rc) - bui.addVertex(rs, -hh, rc) + self.addVertex(rs, hh, rc) + self.addVertex(rs, -hh, rc) if side: for i in range(n): ni = (i + 1) % n - addQuad(bui, ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) + self.addQuad(ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) for i in range(2, nn-3, 2): if top: - addTri(bui, 0, i, i+2) + self.addTri(0, i, i+2) if bottom: - addTri(bui, 1, i+1, i+3) + self.addTri(1, i+1, i+3) -# Semi-primitives + # Semi-primitives - def geomElevationGrid(self, node, bui): + def geomElevationGrid(self, node): dx = readFloat(node, "xSpacing", 1) dz = readFloat(node, "zSpacing", 1) nx = readInt(node, "xDimension", 0) @@ -325,18 +353,18 @@ class X3DReader(MeshReader): if nx <= 0 or nz <= 0 or len(height) < nx*nz: return # That's weird, the wording of the standard suggests grids with zero quads are somehow valid - bui.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) + self.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) for z in range(nz): for x in range(nx): - bui.addVertex(x * dx, height[z*nx + x], z * dz) + self.addVertex(x * dx, height[z*nx + x], z * dz) for z in range(1, nz): for x in range(1, nx): - addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) - addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) + self.addTriFlip((z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) + self.addTriFlip((z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) - def geomExtrusion(self, node, bui): + def geomExtrusion(self, node): ccw = readBoolean(node, "ccw", True) beginCap = readBoolean(node, "beginCap", True) endCap = readBoolean(node, "endCap", True) @@ -403,7 +431,7 @@ class X3DReader(MeshReader): orig_z = Vector(*m.dot(orig_z.getData())) return orig_z - bui.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) z = None for i, spt in enumerate(spine): @@ -456,10 +484,10 @@ class X3DReader(MeshReader): sptv3 = numpy.array(spt.getData()[:3]) for cpt in cross: v = sptv3 + m.dot(cpt) - bui.addVertex(*v) + self.addVertex(*v) if beginCap: - addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) + self.addFace([x for x in range(nc - 1, -1, -1)], ccw) # Order of edges in the face: forward along cross, forward along spine, # backward along cross, backward along spine, flipped if now ccw. @@ -468,117 +496,167 @@ class X3DReader(MeshReader): for s in range(ns - 1): for c in range(ncf): - addQuadFlip(bui, s * nc + c, s * nc + (c + 1) % nc, + self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc, (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) if spineClosed: # The faces between the last and the first spine points b = (ns - 1) * nc for c in range(ncf): - addQuadFlip(bui, b + c, b + (c + 1) % nc, + self.addQuadFlip(b + c, b + (c + 1) % nc, (c + 1) % nc, c, ccw) if endCap: - addFace(bui, [(ns - 1) * nc + x for x in range(0, nc)], ccw) + self.addFace([(ns - 1) * nc + x for x in range(0, nc)], ccw) # Triangle meshes # Helper for numerous nodes with a Coordinate subnode holding vertices # That all triangle meshes and IndexedFaceSet - # nFaces can be a function, in case the face count is a function of coord - def startCoordMesh(self, node, bui, nFaces): + # num_faces can be a function, in case the face count is a function of coord + def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) coord = self.readVertices(node) - if hasattr(nFaces, '__call__'): - nFaces = nFaces(coord) - bui.reserveFaceAndVertexCount(nFaces, len(coord)) + if hasattr(num_faces, '__call__'): + num_faces = num_faces(coord) + self.reserveFaceAndVertexCount(num_faces, len(coord)) for pt in coord: - bui.addVertex(*pt) + self.addVertex(*pt) return ccw - def geomIndexedTriangleSet(self, node, bui): + def geomIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) - nFaces = len(index) // 3 - ccw = self.startCoordMesh(node, bui, nFaces) + num_faces = len(index) // 3 + ccw = self.startCoordMesh(node, num_faces) - for i in range(0, nFaces*3, 3): - addTriFlip(bui, index[i], index[i+1], index[i+2], ccw) + for i in range(0, num_faces*3, 3): + self.addTriFlip(index[i], index[i+1], index[i+2], ccw) - def geomIndexedTriangleStripSet(self, node, bui): + def geomIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") - ccw = self.startCoordMesh(node, bui, sum([len(strip) - 2 for strip in strips])) + ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) for strip in strips: sccw = ccw # Running CCW value, reset for each strip for i in range(len(strip) - 2): - addTriFlip(bui, strip[i], strip[i+1], strip[i+2], sccw) + self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) sccw = not sccw - def geomIndexedTriangleFanSet(self, node, bui): + def geomIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") - ccw = self.startCoordMesh(node, bui, sum([len(fan) - 2 for fan in fans])) + ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) for fan in fans: for i in range(1, len(fan) - 1): - addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) + self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) - for i in range(0, len(bui.getVertices()), 3): - addTriFlip(bui, i, i+1, i+2, ccw) + def geomTriangleSet(self, node): + ccw = self.startCoordMesh(node, lambda coord: len(coord) // 3) + for i in range(0, len(self.verts), 3): + self.addTriFlip(i, i+1, i+2, ccw) - def geomTriangleStripSet(self, node, bui): + def geomTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) - ccw = self.startCoordMesh(node, bui, sum([n-2 for n in strips])) + ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) vb = 0 for n in strips: sccw = ccw for i in range(n-2): - addTriFlip(bui, vb+i, vb+i+1, vb+i+2, sccw) + self.addTriFlip(vb+i, vb+i+1, vb+i+2, sccw) sccw = not sccw vb += n - def geomTriangleFanSet(self, node, bui): + def geomTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) - ccw = self.startCoordMesh(node, bui, sum([n-2 for n in fans])) + ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) vb = 0 for n in fans: for i in range(1, n-1): - addTriFlip(bui, vb, vb+i, vb+i+1, ccw) + self.addTriFlip(vb, vb+i, vb+i+1, ccw) vb += n # Quad geometries from the CAD module, might be relevant for printing - def geomQuadSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: 2*(len(coord) // 4)) - for i in range(0, len(bui.getVertices()), 4): - addQuadFlip(bui, i, i+1, i+2, i+3, ccw) + def geomQuadSet(self, node): + ccw = self.startCoordMesh(node, lambda coord: 2*(len(coord) // 4)) + for i in range(0, len(self.verts), 4): + self.addQuadFlip(i, i+1, i+2, i+3, ccw) - def geomIndexedQuadSet(self, node, bui): + def geomIndexedQuadSet(self, node): index = readIntArray(node, "index", []) nQuads = len(index) // 4 - ccw = self.startCoordMesh(node, bui, nQuads*2) + ccw = self.startCoordMesh(node, nQuads*2) for i in range(0, nQuads*4, 4): - addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) + self.addQuadFlip(index[i], index[i+1], index[i+2], index[i+3], ccw) + + # 2D polygon geometries + # Won't work for now, since Cura expects every mesh to have a nontrivial convex hull + # The only way around that is merging meshes. + + def geomDisk2D(self, node): + innerRadius = readFloat(node, "innerRadius", 0) + outerRadius = readFloat(node, "outerRadius", 1) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) + + angle = 2 * pi / n + + if innerRadius: + self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) + + for i in range(n): + s = sin(angle * i) + c = cos(angle * i) + self.addVertex(outerRadius*c, outerRadius*s, 0) + if innerRadius: + self.addVertex(innerRadius*c, innerRadius*s, 0) + ni = (i+1) % n + self.addQuad(2*i, 2*ni, 2*ni+1, 2*i+1) + + if not innerRadius: + for i in range(2, n): + self.addTri(0, i-1, i) + + def geomRectangle2D(self, node): + (x, y) = readFloatArray(node, "size", (2, 2)) + self.reserveFaceAndVertexCount(2, 4) + self.addVertex(-x/2, -y/2, 0) + self.addVertex(x/2, -y/2, 0) + self.addVertex(x/2, y/2, 0) + self.addVertex(-x/2, y/2, 0) + self.addQuad(0, 1, 2, 3) + + def geomTriangleSet2D(self, node): + verts = readFloatArray(node, "vertices", ()) + num_faces = len(verts) // 6; + verts = [(verts[i], verts[i+1], 0) for i in range(0, 6 * num_faces, 2)] + self.reserveFaceAndVertexCount(num_faces, num_faces * 3) + for vert in verts: + self.addVertex(*vert) + + # The front face is on the +Z side, so CCW is a variable + for i in range(0, num_faces*3, 3): + a = Vector(*verts[i+2]) - Vector(*verts[i]) + b = Vector(*verts[i+1]) - Vector(*verts[i]) + self.addTriFlip(i, i+1, i+2, a.x*b.y > a.y*b.x) # General purpose polygon mesh - def geomIndexedFaceSet(self, node, bui): + def geomIndexedFaceSet(self, node): faces = readIndex(node, "coordIndex") - ccw = self.startCoordMesh(node, bui, sum([len(face) - 2 for face in faces])) + ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces])) for face in faces: if len(face) == 3: - addTriFlip(bui, face[0], face[1], face[2], ccw) + self.addTriFlip(face[0], face[1], face[2], ccw) elif len(face) > 3: - addFace(bui, face, ccw) + self.addFace(face, ccw) - geometryImporters = { + geometry_importers = { 'IndexedFaceSet': geomIndexedFaceSet, 'IndexedTriangleSet': geomIndexedTriangleSet, 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, @@ -588,6 +666,9 @@ class X3DReader(MeshReader): 'TriangleFanSet': geomTriangleFanSet, 'QuadSet': geomQuadSet, 'IndexedQuadSet': geomIndexedQuadSet, + 'TriangleSet2D': geomTriangleSet2D, + 'Rectangle2D': geomRectangle2D, + 'Disk2D': geomDisk2D, 'ElevationGrid': geomElevationGrid, 'Extrusion': geomExtrusion, 'Sphere': geomSphere, @@ -609,6 +690,103 @@ class X3DReader(MeshReader): return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] return [] + # Mesh builder helpers + + def reserveFaceAndVertexCount(self, num_faces, num_verts): + # Unlike the Cura MeshBuilder, we use 4-vectors here for easier transform + self.verts = numpy.array([(0,0,0,1) for i in range(num_verts)], dtype=numpy.float32) + self.faces = numpy.zeros((num_faces, 3), dtype=numpy.int32) + self.num_faces = 0 + self.num_verts = 0 + + def addVertex(self, x, y, z): + self.verts[self.num_verts, 0] = x + self.verts[self.num_verts, 1] = y + self.verts[self.num_verts, 2] = z + self.num_verts += 1 + + # Indices are 0-based for this shape, but they won't be zero-based in the merged mesh + def addTri(self, a, b, c): + self.faces[self.num_faces, 0] = self.index_base + a + self.faces[self.num_faces, 1] = self.index_base + b + self.faces[self.num_faces, 2] = self.index_base + c + self.num_faces += 1 + + def addTriFlip(self, a, b, c, ccw): + if ccw: + self.addTri(a, b, c) + else: + self.addTri(b, a, c) + + # Needs to be convex, but not necessaily planar + # Assumed ccw, cut along the ac diagonal + def addQuad(self, a, b, c, d): + self.addTri(a, b, c) + self.addTri(c, d, a) + + def addQuadFlip(self, a, b, c, d, ccw): + if ccw: + self.addTri(a, b, c) + self.addTri(c, d, a) + else: + self.addTri(a, c, b) + self.addTri(c, a, d) + + + # Arbitrary polygon triangulation. + # Doesn't assume convexity and doesn't check the "convex" flag in the file. + # Works by the "cutting of ears" algorithm: + # - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle + # - Remove the triangle at that vertex + # - Repeat until done + # Vertex coordinates are supposed to be already set + def addFace(self, indices, ccw): + # Resolve indices to coordinates for faster math + n = len(indices) + verts = self.verts + face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + + # Need a normal to the plane so that we can know which vertices form inner angles + normal = findOuterNormal(face) + + if not normal: # Couldn't find an outer edge, non-planar polygon maybe? + return + + # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done + m = len(face) + vi = [i for i in range(m)] # We'll be using this to kick vertices from the face + while m > 3: + max_cos = EPSILON # We don't want to check anything on Pi angles + i_min = 0 # max cos corresponds to min angle + for i in range(m): + inext = (i + 1) % m + iprev = (i + m - 1) % m + v = face[vi[i]] + next = face[vi[inext]] - v + prev = face[vi[iprev]] - v + nextXprev = next.cross(prev) + if nextXprev.dot(normal) > EPSILON: # If it's an inner angle + cos = next.dot(prev) / (next.length() * prev.length()) + if cos > max_cos: + # Check if there are vertices inside the triangle + no_points_inside = True + for j in range(m): + if j != i and j != iprev and j != inext: + vx = face[vi[j]] - v + if pointInsideTriangle(vx, next, prev, nextXprev): + no_points_inside = False + break + + if no_points_inside: + max_cos = cos + i_min = i + + self.addTriFlip(indices[vi[(i_min + m - 1) % m]], indices[vi[i_min]], indices[vi[(i_min + 1) % m]], ccw) + vi.pop(i_min) + m -= 1 + self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) + + # ------------------------------------------------------------ # X3D field parsers # ------------------------------------------------------------ @@ -665,89 +843,6 @@ def readIndex(node, attr): if chunk: chunks.append(chunk) return chunks - -# Mesh builder helpers - -def addTri(bui, a, b, c): - bui._indices[bui._face_count, 0] = a - bui._indices[bui._face_count, 1] = b - bui._indices[bui._face_count, 2] = c - bui._face_count += 1 - -def addTriFlip(bui, a, b, c, ccw): - if ccw: - addTri(bui, a, b, c) - else: - addTri(bui, b, a, c) - -# Needs to be convex, but not necessaily planar -# Assumed ccw, cut along the ac diagonal -def addQuad(bui, a, b, c, d): - addTri(bui, a, b, c) - addTri(bui, c, d, a) - -def addQuadFlip(bui, a, b, c, d, ccw): - if ccw: - addTri(bui, a, b, c) - addTri(bui, c, d, a) - else: - addTri(bui, a, c, b) - addTri(bui, c, a, d) - - -# Arbitrary polygon triangulation. -# Doesn't assume convexity and doesn't check the "convex" flag in the file. -# Works by the "cutting of ears" algorithm: -# - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle -# - Remove the triangle at that vertex -# - Repeat until done -# Vertex coordinates are supposed to be already in the mesh builder object -def addFace(bui, indices, ccw): - # Resolve indices to coordinates for faster math - n = len(indices) - verts = bui.getVertices() - face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] - - # Need a normal to the plane so that we can know which vertices form inner angles - normal = findOuterNormal(face) - - if not normal: # Couldn't find an outer edge, non-planar polygon maybe? - return - - # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done - m = len(face) - vi = [i for i in range(m)] # We'll be using this to kick vertices from the face - while m > 3: - maxCos = EPSILON # We don't want to check anything on Pi angles - iMin = 0 # max cos corresponds to min angle - for i in range(m): - inext = (i + 1) % m - iprev = (i + m - 1) % m - v = face[vi[i]] - next = face[vi[inext]] - v - prev = face[vi[iprev]] - v - nextXprev = next.cross(prev) - if nextXprev.dot(normal) > EPSILON: # If it's an inner angle - cos = next.dot(prev) / (next.length() * prev.length()) - if cos > maxCos: - # Check if there are vertices inside the triangle - noPointsInside = True - for j in range(m): - if j != i and j != iprev and j != inext: - vx = face[vi[j]] - v - if pointInsideTriangle(vx, next, prev, nextXprev): - noPointsInside = False - break - - if noPointsInside: - maxCos = cos - iMin = i - - addTriFlip(bui, indices[vi[(iMin + m - 1) % m]], indices[vi[iMin]], indices[vi[(iMin + 1) % m]], ccw) - vi.pop(iMin) - m -= 1 - addTriFlip(bui, indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) - # Given a face as a sequence of vectors, returns a normal to the polygon place that forms a right triple # with a vector along the polygon sequence and a vector backwards @@ -758,21 +853,21 @@ def findOuterNormal(face): edge = face[j] - face[i] if edge.length() > EPSILON: edge = edge.normalized() - prevRejection = Vector() - isOuter = True + prev_rejection = Vector() + is_outer = True for k in range(n): if k != i and k != j: pt = face[k] - face[i] pte = pt.dot(edge) rejection = pt - edge*pte - if rejection.dot(prevRejection) < -EPSILON: # points on both sides of the edge - not an outer one - isOuter = False + if rejection.dot(prev_rejection) < -EPSILON: # points on both sides of the edge - not an outer one + is_outer = False break - elif rejection.length() > prevRejection.length(): # Pick a greater rejection for numeric stability - prevRejection = rejection + elif rejection.length() > prev_rejection.length(): # Pick a greater rejection for numeric stability + prev_rejection = rejection - if isOuter: # Found an outer edge, prevRejection is the rejection inside the face. Generate a normal. - return edge.cross(prevRejection) + if is_outer: # Found an outer edge, prev_rejection is the rejection inside the face. Generate a normal. + return edge.cross(prev_rejection) return False @@ -780,9 +875,9 @@ def findOuterNormal(face): # No error handling. # For stability, taking the ration between the biggest coordinates would be better; none of that, either. def ratio(a, b): - if b.x > EPSILON: + if b.x > EPSILON or b.x < -EPSILON: return a.x / b.x - elif b.y > EPSILON: + elif b.y > EPSILON or b.y < -EPSILON: return a.y / b.y else: return a.z / b.z @@ -806,6 +901,3 @@ def toNumpyRotation(rot): (x * x * t + c, x * y * t - z*s, x * z * t + y * s), (x * y * t + z*s, y * y * t + c, y * z * t - x * s), (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) - - - \ No newline at end of file From e7cfd24e9af63dea7ae6ef21a2e8fb9f9a1c5035 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Fri, 12 Aug 2016 16:11:57 -0400 Subject: [PATCH 09/22] Holeless Disk2D fix --- plugins/X3DReader/X3DReader.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 8a5822d9ed..cb74188b6e 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -605,8 +605,7 @@ class X3DReader(MeshReader): angle = 2 * pi / n - if innerRadius: - self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) + self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) for i in range(n): s = sin(angle * i) From eed26d40a2a0c5b52fdb67ea36b6273b1da86a09 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 22 Aug 2016 10:35:59 -0400 Subject: [PATCH 10/22] Code review issues --- plugins/CuraEngineBackend/StartSliceJob.py | 2 +- plugins/X3DReader/X3DReader.py | 218 ++++++++++----------- 2 files changed, 110 insertions(+), 110 deletions(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 62a027b110..f7ceca990d 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -150,7 +150,7 @@ class StartSliceJob(Job): obj.id = id(object) verts = mesh_data.getVertices() indices = mesh_data.getIndices() - if not indices is None: + if indices is not None: verts = numpy.array([verts[vert_index] for face in indices for vert_index in face]) else: verts = numpy.array(verts) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index cb74188b6e..b7df621c41 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -1,4 +1,5 @@ -# Seva Alekseyev with National Institutes of Health, 2016 +# Contributed by Seva Alekseyev with National Institutes of Health, 2016 +# Cura is released under the terms of the AGPLv3 or higher. from UM.Mesh.MeshReader import MeshReader from UM.Mesh.MeshBuilder import MeshBuilder @@ -6,7 +7,6 @@ from UM.Logger import Logger from UM.Math.Matrix import Matrix from UM.Math.Vector import Vector from UM.Scene.SceneNode import SceneNode -from UM.Scene.GroupDecorator import GroupDecorator from UM.Job import Job from math import pi, sin, cos, sqrt import numpy @@ -25,12 +25,12 @@ except ImportError: DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders class Shape: - def __init__(self, v, f, ib, n): - self.verts = v - self.faces = f + def __init__(self, verts, faces, index_base, name): + self.verts = verts + self.faces = faces # Those are here for debugging purposes only - self.index_base = ib - self.name = n + self.index_base = index_base + self.name = name class X3DReader(MeshReader): def __init__(self): @@ -72,26 +72,22 @@ class X3DReader(MeshReader): self.processChildNodes(xml_scene) if self.shapes: - bui = MeshBuilder() - bui.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) - bui.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) - bui.calculateNormals() - bui.setFileName(file_name) + builder = MeshBuilder() + builder.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) + builder.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) + builder.calculateNormals() + builder.setFileName(file_name) scene = SceneNode() - scene.setMeshData(bui.build().getTransformed(Matrix())) + scene.setMeshData(builder.build()) scene.setSelectable(True) scene.setName(file_name) + scene.getBoundingBox() else: return None - except Exception as e: - Logger.log("e", "exception occured in x3d reader: %s", e) - - try: - boundingBox = scene.getBoundingBox() - boundingBox.isValid() - except: + except Exception: + Logger.logException("e", "Exception in X3D reader") return None return scene @@ -119,11 +115,11 @@ class X3DReader(MeshReader): def processShape(self, xml_node): # Find the geometry and the appearance inside the Shape geometry = appearance = None - for subNode in xml_node: - if subNode.tag == "Appearance" and not appearance: - appearance = self.resolveDefUse(subNode) - elif subNode.tag in self.geometry_importers and not geometry: - geometry = self.resolveDefUse(subNode) + for sub_node in xml_node: + if sub_node.tag == "Appearance" and not appearance: + appearance = self.resolveDefUse(sub_node) + elif sub_node.tag in self.geometry_importers and not geometry: + geometry = self.resolveDefUse(sub_node) # TODO: appearance is completely ignored. At least apply the material color... if not geometry is None: @@ -131,12 +127,13 @@ class X3DReader(MeshReader): self.verts = self.faces = [] # Safeguard self.geometry_importers[geometry.tag](self, geometry) m = self.transform.getData() + # TODO: can this be done with one dot() call? verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) self.index_base += len(verts) - except Exception as e: - Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) + except Exception: + Logger.logException("e", "Exception in X3D reader while reading %s", geometry.tag) # Returns the referenced node if the node has USE, the same node otherwise. # May return None is USE points at a nonexistent node @@ -168,43 +165,43 @@ class X3DReader(MeshReader): trans = readVector(node, "translation", (0, 0, 0)) # Vector scale = readVector(node, "scale", (1, 1, 1)) # Vector center = readVector(node, "center", (0, 0, 0)) # Vector - scaleOrient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + scale_orient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple # Store the previous transform; in Cura, the default matrix multiplication is in place prev = Matrix(self.transform.getData()) # It's deep copy, I've checked # The rest of transform manipulation will be applied in place - gotCenter = (center.x != 0 or center.y != 0 or center.z != 0) + got_center = (center.x != 0 or center.y != 0 or center.z != 0) T = self.transform if trans.x != 0 or trans.y != 0 or trans.z !=0: T.translate(trans) - if gotCenter: + if got_center: T.translate(center) if rot[0] != 0: T.rotateByAxis(*rot) if scale.x != 1 or scale.y != 1 or scale.z != 1: - gotScaleOrient = scaleOrient[0] != 0 - if gotScaleOrient: - T.rotateByAxis(*scaleOrient) + got_scale_orient = scale_orient[0] != 0 + if got_scale_orient: + T.rotateByAxis(*scale_orient) # No scale by vector in place operation in UM S = Matrix() S.setByScaleVector(scale) T.multiply(S) - if gotScaleOrient: - T.rotateByAxis(-scaleOrient[0], scaleOrient[1]) - if gotCenter: + if got_scale_orient: + T.rotateByAxis(-scale_orient[0], scale_orient[1]) + if got_center: T.translate(-center) self.processChildNodes(node) self.transform = prev # ------------------------- Geometry importers - # They are supposed to fill the MeshBuilder object with vertices and faces, the caller will do the rest + # They are supposed to fill the self.verts and self.faces arrays, the caller will do the rest # Primitives - def geomBox(self, node): + def processGeometryBox(self, node): (dx, dy, dz) = readFloatArray(node, "size", [2, 2, 2]) dx /= 2 dy /= 2 @@ -230,9 +227,9 @@ class X3DReader(MeshReader): self.addQuad(7, 6, 5, 4) # -y # The sphere is subdivided into nr rings and ns segments - def geomSphere(self, node): + def processGeometrySphere(self, node): r = readFloat(node, "radius", 0.5) - subdiv = readIntArray(node, 'subdivision', None) + subdiv = readIntArray(node, "subdivision", None) if subdiv: if len(subdiv) == 1: nr = ns = subdiv[0] @@ -282,12 +279,12 @@ class X3DReader(MeshReader): nseg = (seg + 1) % ns self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) - def geomCone(self, node): + def processGeometryCone(self, node): r = readFloat(node, "bottomRadius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) side = readBoolean(node, "side", True) - n = readInt(node, 'subdivision', DEFAULT_SUBDIV) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) d = height / 2 angle = 2 * pi / n @@ -307,7 +304,7 @@ class X3DReader(MeshReader): for i in range(2, n): self.addTri(1, i, i+1) - def geomCylinder(self, node): + def processGeometryCylinder(self, node): r = readFloat(node, "radius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -342,7 +339,7 @@ class X3DReader(MeshReader): # Semi-primitives - def geomElevationGrid(self, node): + def processGeometryElevationGrid(self, node): dx = readFloat(node, "xSpacing", 1) dz = readFloat(node, "zSpacing", 1) nx = readInt(node, "xDimension", 0) @@ -364,17 +361,30 @@ class X3DReader(MeshReader): self.addTriFlip((z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) self.addTriFlip((z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) - def geomExtrusion(self, node): + def processGeometryExtrusion(self, node): ccw = readBoolean(node, "ccw", True) - beginCap = readBoolean(node, "beginCap", True) - endCap = readBoolean(node, "endCap", True) + begin_cap = readBoolean(node, "beginCap", True) + end_cap = readBoolean(node, "endCap", True) cross = readFloatArray(node, "crossSection", (1, 1, 1, -1, -1, -1, -1, 1, 1, 1)) cross = [(cross[i], cross[i+1]) for i in range(0, len(cross), 2)] spine = readFloatArray(node, "spine", (0, 0, 0, 0, 1, 0)) spine = [(spine[i], spine[i+1], spine[i+2]) for i in range(0, len(spine), 3)] - orient = readFloatArray(node, 'orientation', None) + orient = readFloatArray(node, "orientation", None) if orient: - orient = [toNumpyRotation(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + # This converts X3D's axis/angle rotation to a 3x3 numpy matrix + def toRotationMatrix(rot): + (x, y, z) = rot[:3] + a = rot[3] + s = sin(a) + c = cos(a) + t = 1-c + return numpy.array(( + (x * x * t + c, x * y * t - z*s, x * z * t + y * s), + (x * y * t + z*s, y * y * t + c, y * z * t - x * s), + (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + + orient = [toRotationMatrix(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + scale = readFloatArray(node, "scale", None) if scale: scale = [numpy.array(((scale[i], 0, 0), (0, 1, 0), (0, 0, scale[i+1]))) @@ -394,12 +404,12 @@ class X3DReader(MeshReader): # Face count along the cross; for closed cross, it's the same as the # respective vertex count - spineClosed = spine[0] == spine[-1] - if spineClosed: + spine_closed = spine[0] == spine[-1] + if spine_closed: spine = spine[:-1] ns = len(spine) spine = [Vector(*s) for s in spine] - nsf = ns if spineClosed else ns - 1 + nsf = ns if spine_closed else ns - 1 # This will be used for fallback, where the current spine point joins # two collinear spine segments. No need to recheck the case of the @@ -431,11 +441,11 @@ class X3DReader(MeshReader): orig_z = Vector(*m.dot(orig_z.getData())) return orig_z - self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if begin_cap else 0) + (nc - 2 if end_cap else 0), ns*nc) z = None for i, spt in enumerate(spine): - if (i > 0 and i < ns - 1) or spineClosed: + if (i > 0 and i < ns - 1) or spine_closed: snext = spine[(i + 1) % ns] sprev = spine[(i - 1 + ns) % ns] y = snext - sprev @@ -486,7 +496,7 @@ class X3DReader(MeshReader): v = sptv3 + m.dot(cpt) self.addVertex(*v) - if beginCap: + if begin_cap: self.addFace([x for x in range(nc - 1, -1, -1)], ccw) # Order of edges in the face: forward along cross, forward along spine, @@ -499,26 +509,26 @@ class X3DReader(MeshReader): self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc, (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) - if spineClosed: + if spine_closed: # The faces between the last and the first spine points b = (ns - 1) * nc for c in range(ncf): self.addQuadFlip(b + c, b + (c + 1) % nc, (c + 1) % nc, c, ccw) - if endCap: + if end_cap: self.addFace([(ns - 1) * nc + x for x in range(0, nc)], ccw) # Triangle meshes # Helper for numerous nodes with a Coordinate subnode holding vertices # That all triangle meshes and IndexedFaceSet - # num_faces can be a function, in case the face count is a function of coord + # num_faces can be a function, in case the face count is a function of vertex count def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) coord = self.readVertices(node) - if hasattr(num_faces, '__call__'): - num_faces = num_faces(coord) + if hasattr(num_faces, "__call__"): + num_faces = num_faces(len(coord)) self.reserveFaceAndVertexCount(num_faces, len(coord)) for pt in coord: self.addVertex(*pt) @@ -526,7 +536,7 @@ class X3DReader(MeshReader): return ccw - def geomIndexedTriangleSet(self, node): + def processGeometryIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) num_faces = len(index) // 3 ccw = self.startCoordMesh(node, num_faces) @@ -534,7 +544,7 @@ class X3DReader(MeshReader): for i in range(0, num_faces*3, 3): self.addTriFlip(index[i], index[i+1], index[i+2], ccw) - def geomIndexedTriangleStripSet(self, node): + def processGeometryIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) @@ -544,7 +554,7 @@ class X3DReader(MeshReader): self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) sccw = not sccw - def geomIndexedTriangleFanSet(self, node): + def processGeometryIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) @@ -552,12 +562,12 @@ class X3DReader(MeshReader): for i in range(1, len(fan) - 1): self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node): - ccw = self.startCoordMesh(node, lambda coord: len(coord) // 3) + def processGeometryTriangleSet(self, node): + ccw = self.startCoordMesh(node, lambda num_vert: num_vert // 3) for i in range(0, len(self.verts), 3): self.addTriFlip(i, i+1, i+2, ccw) - def geomTriangleStripSet(self, node): + def processGeometryTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) @@ -569,7 +579,7 @@ class X3DReader(MeshReader): sccw = not sccw vb += n - def geomTriangleFanSet(self, node): + def processGeometryTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) @@ -581,24 +591,24 @@ class X3DReader(MeshReader): # Quad geometries from the CAD module, might be relevant for printing - def geomQuadSet(self, node): - ccw = self.startCoordMesh(node, lambda coord: 2*(len(coord) // 4)) + def processGeometryQuadSet(self, node): + ccw = self.startCoordMesh(node, lambda num_vert: 2*(num_vert // 4)) for i in range(0, len(self.verts), 4): self.addQuadFlip(i, i+1, i+2, i+3, ccw) - def geomIndexedQuadSet(self, node): + def processGeometryIndexedQuadSet(self, node): index = readIntArray(node, "index", []) - nQuads = len(index) // 4 - ccw = self.startCoordMesh(node, nQuads*2) + num_quads = len(index) // 4 + ccw = self.startCoordMesh(node, num_quads*2) - for i in range(0, nQuads*4, 4): + for i in range(0, num_quads*4, 4): self.addQuadFlip(index[i], index[i+1], index[i+2], index[i+3], ccw) # 2D polygon geometries # Won't work for now, since Cura expects every mesh to have a nontrivial convex hull # The only way around that is merging meshes. - def geomDisk2D(self, node): + def processGeometryDisk2D(self, node): innerRadius = readFloat(node, "innerRadius", 0) outerRadius = readFloat(node, "outerRadius", 1) n = readInt(node, "subdivision", DEFAULT_SUBDIV) @@ -620,7 +630,7 @@ class X3DReader(MeshReader): for i in range(2, n): self.addTri(0, i-1, i) - def geomRectangle2D(self, node): + def processGeometryRectangle2D(self, node): (x, y) = readFloatArray(node, "size", (2, 2)) self.reserveFaceAndVertexCount(2, 4) self.addVertex(-x/2, -y/2, 0) @@ -629,7 +639,7 @@ class X3DReader(MeshReader): self.addVertex(-x/2, y/2, 0) self.addQuad(0, 1, 2, 3) - def geomTriangleSet2D(self, node): + def processGeometryTriangleSet2D(self, node): verts = readFloatArray(node, "vertices", ()) num_faces = len(verts) // 6; verts = [(verts[i], verts[i+1], 0) for i in range(0, 6 * num_faces, 2)] @@ -645,7 +655,7 @@ class X3DReader(MeshReader): # General purpose polygon mesh - def geomIndexedFaceSet(self, node): + def processGeometryIndexedFaceSet(self, node): faces = readIndex(node, "coordIndex") ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces])) @@ -656,24 +666,24 @@ class X3DReader(MeshReader): self.addFace(face, ccw) geometry_importers = { - 'IndexedFaceSet': geomIndexedFaceSet, - 'IndexedTriangleSet': geomIndexedTriangleSet, - 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, - 'IndexedTriangleFanSet': geomIndexedTriangleFanSet, - 'TriangleSet': geomTriangleSet, - 'TriangleStripSet': geomTriangleStripSet, - 'TriangleFanSet': geomTriangleFanSet, - 'QuadSet': geomQuadSet, - 'IndexedQuadSet': geomIndexedQuadSet, - 'TriangleSet2D': geomTriangleSet2D, - 'Rectangle2D': geomRectangle2D, - 'Disk2D': geomDisk2D, - 'ElevationGrid': geomElevationGrid, - 'Extrusion': geomExtrusion, - 'Sphere': geomSphere, - 'Box': geomBox, - 'Cylinder': geomCylinder, - 'Cone': geomCone + "IndexedFaceSet": processGeometryIndexedFaceSet, + "IndexedTriangleSet": processGeometryIndexedTriangleSet, + "IndexedTriangleStripSet": processGeometryIndexedTriangleStripSet, + "IndexedTriangleFanSet": processGeometryIndexedTriangleFanSet, + "TriangleSet": processGeometryTriangleSet, + "TriangleStripSet": processGeometryTriangleStripSet, + "TriangleFanSet": processGeometryTriangleFanSet, + "QuadSet": processGeometryQuadSet, + "IndexedQuadSet": processGeometryIndexedQuadSet, + "TriangleSet2D": processGeometryTriangleSet2D, + "Rectangle2D": processGeometryRectangle2D, + "Disk2D": processGeometryDisk2D, + "ElevationGrid": processGeometryElevationGrid, + "Extrusion": processGeometryExtrusion, + "Sphere": processGeometrySphere, + "Box": processGeometryBox, + "Cylinder": processGeometryCylinder, + "Cone": processGeometryCone } # Parses the Coordinate.@point field @@ -869,10 +879,10 @@ def findOuterNormal(face): return edge.cross(prev_rejection) return False - -# Assumes the vectors are either parallel or antiparallel and the denominator is nonzero. + +# Given two *collinear* vectors a and b, returns the coefficient that takes b to a. # No error handling. -# For stability, taking the ration between the biggest coordinates would be better; none of that, either. +# For stability, taking the ration between the biggest coordinates would be better... def ratio(a, b): if b.x > EPSILON or b.x < -EPSILON: return a.x / b.x @@ -889,14 +899,4 @@ def pointInsideTriangle(vx, next, prev, nextXprev): vxXnext = vx.cross(next); s = -ratio(vxXnext, nextXprev) return s > 0 and (s + r) < 1 - -def toNumpyRotation(rot): - (x, y, z) = rot[:3] - a = rot[3] - s = sin(a) - c = cos(a) - t = 1-c - return numpy.array(( - (x * x * t + c, x * y * t - z*s, x * z * t + y * s), - (x * y * t + z*s, y * y * t + c, y * z * t - x * s), - (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + From 44cf044395d6130bb9fdc57bd67d46c3fcd156d2 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 22 Aug 2016 14:57:28 -0400 Subject: [PATCH 11/22] X3D loading performance: vertex organization for faster transforms --- plugins/X3DReader/X3DReader.py | 105 ++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index b7df621c41..42efbe84ef 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -11,9 +11,6 @@ from UM.Job import Job from math import pi, sin, cos, sqrt import numpy - -EPSILON = 0.000001 # So very crude. :( - try: import xml.etree.cElementTree as ET except ImportError: @@ -23,8 +20,12 @@ except ImportError: # Use CADPart, for example, to distinguish between separate objects DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +EPSILON = 0.000001 class Shape: + + # Expects verts in MeshBuilder-ready format, as a n by 3 mdarray + # with vertices stored in rows def __init__(self, verts, faces, index_base, name): self.verts = verts self.faces = faces @@ -62,7 +63,7 @@ class X3DReader(MeshReader): xml_scene = xml_root[0] if xml_scene.tag != "Scene": - return None + return None self.transform = Matrix() self.transform.setByScaleFactor(scale) @@ -127,8 +128,8 @@ class X3DReader(MeshReader): self.verts = self.faces = [] # Safeguard self.geometry_importers[geometry.tag](self, geometry) m = self.transform.getData() - # TODO: can this be done with one dot() call? - verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) + verts = m.dot(self.verts)[:3].transpose() + self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) self.index_base += len(verts) @@ -526,12 +527,10 @@ class X3DReader(MeshReader): # num_faces can be a function, in case the face count is a function of vertex count def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) - coord = self.readVertices(node) + self.readVertices(node) # This will allocate and fill the vertex array if hasattr(num_faces, "__call__"): - num_faces = num_faces(len(coord)) - self.reserveFaceAndVertexCount(num_faces, len(coord)) - for pt in coord: - self.addVertex(*pt) + num_faces = num_faces(self.getVertexCount()) + self.reserveFaceCount(num_faces) return ccw @@ -539,61 +538,61 @@ class X3DReader(MeshReader): def processGeometryIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) num_faces = len(index) // 3 - ccw = self.startCoordMesh(node, num_faces) + ccw = int(self.startCoordMesh(node, num_faces)) for i in range(0, num_faces*3, 3): - self.addTriFlip(index[i], index[i+1], index[i+2], ccw) + self.addTri(index[i + 1 - ccw], index[i + ccw], index[i+2]) def processGeometryIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") - ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) + ccw = int(self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips]))) for strip in strips: sccw = ccw # Running CCW value, reset for each strip for i in range(len(strip) - 2): - self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) - sccw = not sccw + self.addTri(strip[i + 1 - sccw], strip[i + sccw], strip[i+2]) + sccw = 1 - sccw def processGeometryIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") - ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) + ccw = int(self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans]))) for fan in fans: for i in range(1, len(fan) - 1): - self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) + self.addTri(fan[0], fan[i + 1 - ccw], fan[i + ccw]) def processGeometryTriangleSet(self, node): - ccw = self.startCoordMesh(node, lambda num_vert: num_vert // 3) - for i in range(0, len(self.verts), 3): - self.addTriFlip(i, i+1, i+2, ccw) + ccw = int(self.startCoordMesh(node, lambda num_vert: num_vert // 3)) + for i in range(0, self.getVertexCount(), 3): + self.addTri(i + 1 - ccw, i + ccw, i+2) def processGeometryTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) - ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) + ccw = int(self.startCoordMesh(node, sum([n-2 for n in strips]))) vb = 0 for n in strips: sccw = ccw for i in range(n-2): - self.addTriFlip(vb+i, vb+i+1, vb+i+2, sccw) - sccw = not sccw + self.addTri(vb + i + 1 - sccw, vb + i + sccw, vb + i + 2) + sccw = 1 - sccw vb += n def processGeometryTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) - ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) + ccw = int(self.startCoordMesh(node, sum([n-2 for n in fans]))) vb = 0 for n in fans: for i in range(1, n-1): - self.addTriFlip(vb, vb+i, vb+i+1, ccw) + self.addTri(vb, vb + i + 1 - ccw, vb + i + ccw) vb += n # Quad geometries from the CAD module, might be relevant for printing def processGeometryQuadSet(self, node): ccw = self.startCoordMesh(node, lambda num_vert: 2*(num_vert // 4)) - for i in range(0, len(self.verts), 4): + for i in range(0, self.getVertexCount(), 4): self.addQuadFlip(i, i+1, i+2, i+3, ccw) def processGeometryIndexedQuadSet(self, node): @@ -686,7 +685,7 @@ class X3DReader(MeshReader): "Cone": processGeometryCone } - # Parses the Coordinate.@point field + # Parses the Coordinate.@point field, fills the verts array. def readVertices(self, node): for c in node: if c.tag == "Coordinate": @@ -695,23 +694,33 @@ class X3DReader(MeshReader): pt = c.attrib.get("point") if pt: co = [float(x) for x in pt.split()] + num_verts = len(co) // 3 + self.verts = numpy.empty((4, num_verts), dtype=numpy.float32) + self.verts[3,:] = numpy.ones((num_verts), dtype=numpy.float32) # Group by three - return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] - return [] + for i in range(num_verts): + self.verts[:3,i] = co[3*i:3*i+3] # Mesh builder helpers def reserveFaceAndVertexCount(self, num_faces, num_verts): - # Unlike the Cura MeshBuilder, we use 4-vectors here for easier transform - self.verts = numpy.array([(0,0,0,1) for i in range(num_verts)], dtype=numpy.float32) + # Unlike the Cura MeshBuilder, we use 4-vectors stored as columns for easier transform + self.verts = numpy.zeros((4, num_verts), dtype=numpy.float32) + self.verts[3,:] = numpy.ones((num_verts), dtype=numpy.float32) + self.num_verts = 0 + self.reserveFaceCount(num_faces) + + def reserveFaceCount(self, num_faces): self.faces = numpy.zeros((num_faces, 3), dtype=numpy.int32) self.num_faces = 0 - self.num_verts = 0 + + def getVertexCount(self): + return self.verts.shape[1] def addVertex(self, x, y, z): - self.verts[self.num_verts, 0] = x - self.verts[self.num_verts, 1] = y - self.verts[self.num_verts, 2] = z + self.verts[0, self.num_verts] = x + self.verts[1, self.num_verts] = y + self.verts[2, self.num_verts] = z self.num_verts += 1 # Indices are 0-based for this shape, but they won't be zero-based in the merged mesh @@ -751,9 +760,7 @@ class X3DReader(MeshReader): # Vertex coordinates are supposed to be already set def addFace(self, indices, ccw): # Resolve indices to coordinates for faster math - n = len(indices) - verts = self.verts - face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + face = [Vector(data=self.verts[0:3, i]) for i in indices] # Need a normal to the plane so that we can know which vertices form inner angles normal = findOuterNormal(face) @@ -762,14 +769,14 @@ class X3DReader(MeshReader): return # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done - m = len(face) - vi = [i for i in range(m)] # We'll be using this to kick vertices from the face - while m > 3: + n = len(face) + vi = [i for i in range(n)] # We'll be using this to kick vertices from the face + while n > 3: max_cos = EPSILON # We don't want to check anything on Pi angles i_min = 0 # max cos corresponds to min angle - for i in range(m): - inext = (i + 1) % m - iprev = (i + m - 1) % m + for i in range(n): + inext = (i + 1) % n + iprev = (i + n - 1) % n v = face[vi[i]] next = face[vi[inext]] - v prev = face[vi[iprev]] - v @@ -779,7 +786,7 @@ class X3DReader(MeshReader): if cos > max_cos: # Check if there are vertices inside the triangle no_points_inside = True - for j in range(m): + for j in range(n): if j != i and j != iprev and j != inext: vx = face[vi[j]] - v if pointInsideTriangle(vx, next, prev, nextXprev): @@ -790,9 +797,9 @@ class X3DReader(MeshReader): max_cos = cos i_min = i - self.addTriFlip(indices[vi[(i_min + m - 1) % m]], indices[vi[i_min]], indices[vi[(i_min + 1) % m]], ccw) + self.addTriFlip(indices[vi[(i_min + n - 1) % n]], indices[vi[i_min]], indices[vi[(i_min + 1) % n]], ccw) vi.pop(i_min) - m -= 1 + n -= 1 self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) @@ -895,7 +902,7 @@ def pointInsideTriangle(vx, next, prev, nextXprev): vxXprev = vx.cross(prev) r = ratio(vxXprev, nextXprev) if r < 0: - return False; + return False vxXnext = vx.cross(next); s = -ratio(vxXnext, nextXprev) return s > 0 and (s + r) < 1 From e4049296d97458ba2e3e4b168d3cb6b4acb92820 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 8 Aug 2016 10:50:38 -0400 Subject: [PATCH 12/22] Initial support for the X3D scene file format (see http://www.web3d.org/). For now, just mesh import. Supports all geometry nodes in Rendering, Geometry3D, CAD geometry modules. No support for materials, color, or textures. Normals in the file (if any) are ignored. Light sources, viewpoints, sensors, world, shaders, scripts also ignored. --- plugins/X3DReader/X3DReader.py | 792 +++++++++++++++++++++++++++++++++ plugins/X3DReader/__init__.py | 26 ++ 2 files changed, 818 insertions(+) create mode 100644 plugins/X3DReader/X3DReader.py create mode 100644 plugins/X3DReader/__init__.py diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py new file mode 100644 index 0000000000..3b5af06d6e --- /dev/null +++ b/plugins/X3DReader/X3DReader.py @@ -0,0 +1,792 @@ +# Seva Alekseyev with National Institutes of Health, 2016 + +from UM.Mesh.MeshReader import MeshReader +from UM.Mesh.MeshBuilder import MeshBuilder +from UM.Logger import Logger +from UM.Math.Matrix import Matrix +from UM.Math.Vector import Vector +from UM.Scene.SceneNode import SceneNode +from UM.Scene.GroupDecorator import GroupDecorator +from UM.Job import Job +from math import pi, sin, cos, sqrt +import numpy + +EPSILON = 0.000001 # So very crude. :( + +try: + import xml.etree.cElementTree as ET +except ImportError: + import xml.etree.ElementTree as ET + + +DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders + +class X3DReader(MeshReader): + def __init__(self): + super().__init__() + self._supported_extensions = [".x3d"] + self._namespaces = {} + self.defs = {} + + def read(self, file_name): + try: + self.sceneNodes = [] + self.fileName = file_name + self.transform = Matrix() + self.transform.setByScaleVector(Vector(1000, 1000, 1000)) + + tree = ET.parse(file_name) + self.processNode(tree.getroot()) + + if len(self.sceneNodes) > 1: + theScene = SceneNode() + group_decorator = GroupDecorator() + theScene.addDecorator(group_decorator) + for node in self.sceneNodes: + theScene.addChild(node) + elif len(self.sceneNodes) == 1: + theScene = self.sceneNodes[0] + else: # No shapes read :( + return None + theScene.setName(file_name) + except Exception as e: + Logger.log("e", "exception occured in x3d reader: %s", e) + + try: + boundingBox = theScene.getBoundingBox() + boundingBox.isValid() + except: + return None + + return theScene + + # ------------------------- XML tree traversal + + def processNode(self, xmlNode): + xmlNode = self.resolveDefUse(xmlNode) + if xmlNode is None: + return + + tag = xmlNode.tag + if tag in ("X3D", "Scene", "Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): + self.processChildNodes(xmlNode) + elif tag == "LOD": + self.processNode(xmlNode[0]) + elif tag == "Transform": + self.processTransform(xmlNode) + elif tag == "Shape": + self.processShape(xmlNode) + + + def processShape(self, xmlNode): + # Find the geometry and the appearance inside the Shape + geometry = appearance = None + for subNode in xmlNode: + if subNode.tag == "Appearance" and not appearance: + appearance = self.resolveDefUse(subNode) + elif subNode.tag in self.geometryImporters and not geometry: + geometry = self.resolveDefUse(subNode) + + # TODO: appearance is completely ignored. At least apply the material color... + if not geometry is None: + try: + bui = MeshBuilder() + self.geometryImporters[geometry.tag](self, geometry, bui) + + bui.calculateNormals() + bui.setFileName(self.fileName) + + sceneNode = SceneNode() + if "DEF" in geometry.attrib: + sceneNode.setName(geometry.tag + "#" + geometry.attrib["DEF"]) + else: + sceneNode.setName(geometry.tag) + + sceneNode.setMeshData(bui.build().getTransformed(self.transform)) + sceneNode.setSelectable(True) + self.sceneNodes.append(sceneNode) + + except Exception as e: + Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) + + # Returns the referenced node if the node has USE, the same node otherwise. + # May return None is USE points at a nonexistent node + # In X3DOM, when both DEF and USE are in the same node, DEF is ignored. + # Big caveat: XML node objects may evaluate to boolean False!!! + def resolveDefUse(self, node): + USE = node.attrib.get("USE") + if USE: + return self.defs.get(USE, None) + + DEF = node.attrib.get("DEF") + if DEF: + self.defs[DEF] = node + return node + + def processChildNodes(self, node): + for c in node: + self.processNode(c) + Job.yieldThread() + + # Since this is a grouping node, will recurse down the tree. + # According to the spec, the final transform matrix is: + # T * C * R * SR * S * -SR * -C + # Where SR corresponds to the rotation matrix to scaleOrientation + # C and SR are rather exotic. S, slightly less so. + def processTransform(self, node): + rot = readRotation(node, "rotation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + trans = readVector(node, "translation", (0, 0, 0)) # Vector + scale = readVector(node, "scale", (1, 1, 1)) # Vector + center = readVector(node, "center", (0, 0, 0)) # Vector + scaleOrient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + + # Store the previous transform; in Cura, the default matrix multiplication is in place + prev = Matrix(self.transform.getData()) # It's deep copy, I've checked + + # The rest of transform manipulation will be applied in place + gotCenter = (center.x != 0 or center.y != 0 or center.z != 0) + + T = self.transform + if trans.x != 0 or trans.y != 0 or trans.z !=0: + T.translate(trans) + if gotCenter: + T.translate(center) + if rot[0] != 0: + T.rotateByAxis(*rot) + if scale.x != 1 or scale.y != 1 or scale.z != 1: + gotScaleOrient = scaleOrient[0] != 0 + if gotScaleOrient: + T.rotateByAxis(*scaleOrient) + # No scale by vector in place operation in UM + S = Matrix() + S.setByScaleVector(scale) + T.multiply(S) + if gotScaleOrient: + T.rotateByAxis(-scaleOrient[0], scaleOrient[1]) + if gotCenter: + T.translate(-center) + + self.processChildNodes(node) + self.transform = prev + + # ------------------------- Geometry importers + # They are supposed to fill the MeshBuilder object with vertices and faces, the caller will do the rest + + # Primitives + + def geomBox(self, node, bui): + size = readFloatArray(node, "size", [2, 2, 2]) + bui.addCube(size[0], size[1], size[2]) + + # The sphere is subdivided into nr rings and ns segments + def geomSphere(self, node, bui): + r = readFloat(node, "radius", 0.5) + subdiv = readIntArray(node, 'subdivision', None) + if subdiv: + if len(subdiv) == 1: + nr = ns = subdiv[0] + else: + (nr, ns) = subdiv + else: + nr = ns = DEFAULT_SUBDIV + + + lau = pi / nr # Unit angle of latitude (rings) for the given tesselation + lou = 2 * pi / ns # Unit angle of longitude (segments) + + bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) + + + # +y and -y poles + bui.addVertex(0, r, 0) + bui.addVertex(0, -r, 0) + + # The non-polar vertices go from x=0, negative z plane counterclockwise - + # to -x, to +z, to +x, back to -z + for ring in range(1, nr): + for seg in range(ns): + bui.addVertex(-r*sin(lou * seg) * sin(lau * ring), + r*cos(lau * ring), + -r*cos(lou * seg) * sin(lau * ring)) + + vb = 2 + (nr - 2) * ns # First vertex index for the bottom cap + + # Faces go in order: top cap, sides, bottom cap. + # Sides go by ring then by segment. + + # Caps + # Top cap face vertices go in order: down right up + # (starting from +y pole) + # Bottom cap goes: up left down (starting from -y pole) + for seg in range(ns): + addTri(bui, 0, seg + 2, (seg + 1) % ns + 2) + addTri(bui, 1, vb + (seg + 1) % ns, vb + seg) + + # Sides + # Side face vertices go in order: down right upleft, downright up left + for ring in range(nr - 2): + tvb = 2 + ring * ns + # First vertex index for the top edge of the ring + bvb = tvb + ns + # First vertex index for the bottom edge of the ring + for seg in range(ns): + nseg = (seg + 1) % ns + addQuad(bui, tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) + + def geomCone(self, node, bui): + r = readFloat(node, "bottomRadius", 1) + height = readFloat(node, "height", 2) + bottom = readBoolean(node, "bottom", True) + side = readBoolean(node, "side", True) + n = readInt(node, 'subdivision', DEFAULT_SUBDIV) + + d = height / 2 + angle = 2 * pi / n + + bui.reserveFaceAndVertexCount((n if side else 0) + (n-1 if bottom else 0), n+1) + + bui.addVertex(0, d, 0) + for i in range(n): + bui.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) + + # Side face vertices go: up down right + if side: + for i in range(n): + addTri(bui, 1 + (i + 1) % n, 0, 1 + i) + if bottom: + for i in range(2, n): + addTri(bui, 1, i, i+1) + + def geomCylinder(self, node, bui): + r = readFloat(node, "radius", 1) + height = readFloat(node, "height", 2) + bottom = readBoolean(node, "bottom", True) + side = readBoolean(node, "side", True) + top = readBoolean(node, "top", True) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) + + nn = n * 2 + angle = 2 * pi / n + hh = height/2 + + bui.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) + + # The seam is at x=0, z=-r, vertices go ccw - + # to pos x, to neg z, to neg x, back to neg z + for i in range(n): + rs = -r * sin(angle * i) + rc = -r * cos(angle * i) + bui.addVertex(rs, hh, rc) + bui.addVertex(rs, -hh, rc) + + if side: + for i in range(n): + ni = (i + 1) % n + addQuad(bui, ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) + + for i in range(2, nn-3, 2): + if top: + addTri(bui, 0, i, i+2) + if bottom: + addTri(bui, 1, i+1, i+3) + +# Semi-primitives + + def geomElevationGrid(self, node, bui): + dx = readFloat(node, "xSpacing", 1) + dz = readFloat(node, "zSpacing", 1) + nx = readInt(node, "xDimension", 0) + nz = readInt(node, "zDimension", 0) + height = readFloatArray(node, "height", False) + ccw = readBoolean(node, "ccw", True) + + if nx <= 0 or nz <= 0 or len(height) < nx*nz: + return # That's weird, the wording of the standard suggests grids with zero quads are somehow valid + + bui.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) + + for z in range(nz): + for x in range(nx): + bui.addVertex(x * dx, height[z*nx + x], z * dz) + + for z in range(1, nz): + for x in range(1, nx): + addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) + addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) + + def geomExtrusion(self, node, bui): + ccw = readBoolean(node, "ccw", True) + beginCap = readBoolean(node, "beginCap", True) + endCap = readBoolean(node, "endCap", True) + cross = readFloatArray(node, "crossSection", (1, 1, 1, -1, -1, -1, -1, 1, 1, 1)) + cross = [(cross[i], cross[i+1]) for i in range(0, len(cross), 2)] + spine = readFloatArray(node, "spine", (0, 0, 0, 0, 1, 0)) + spine = [(spine[i], spine[i+1], spine[i+2]) for i in range(0, len(spine), 3)] + orient = readFloatArray(node, 'orientation', None) + if orient: + orient = [toNumpyRotation(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + scale = readFloatArray(node, "scale", None) + if scale: + scale = [numpy.array(((scale[i], 0, 0), (0, 1, 0), (0, 0, scale[i+1]))) + if scale[i] != 1 or scale[i+1] != 1 else None for i in range(0, len(scale), 2)] + + + # Special treatment for the closed spine and cross section. + # Let's save some memory by not creating identical but distinct vertices; + # later we'll introduce conditional logic to link the last vertex with + # the first one where necessary. + crossClosed = cross[0] == cross[-1] + if crossClosed: + cross = cross[:-1] + nc = len(cross) + cross = [numpy.array((c[0], 0, c[1])) for c in cross] + ncf = nc if crossClosed else nc - 1 + # Face count along the cross; for closed cross, it's the same as the + # respective vertex count + + spineClosed = spine[0] == spine[-1] + if spineClosed: + spine = spine[:-1] + ns = len(spine) + spine = [Vector(*s) for s in spine] + nsf = ns if spineClosed else ns - 1 + + # This will be used for fallback, where the current spine point joins + # two collinear spine segments. No need to recheck the case of the + # closed spine/last-to-first point juncture; if there's an angle there, + # it would kick in on the first iteration of the main loop by spine. + def findFirstAngleNormal(): + for i in range(1, ns - 1): + spt = spine[i] + z = (spine[i + 1] - spt).cross(spine[i - 1] - spt) + if z.length() > EPSILON: + return z + # All the spines are collinear. Fallback to the rotated source + # XZ plane. + # TODO: handle the situation where the first two spine points match + v = spine[1] - spine[0] + orig_y = Vector(0, 1, 0) + orig_z = Vector(0, 0, 1) + if v.cross(orig_y).length() > EPSILON: + # Spine at angle with global y - rotate the z accordingly + a = v.cross(orig_y) # Axis of rotation to get to the Z + (x, y, z) = a.normalized().getData() + s = a.length()/v.length() + c = sqrt(1-s*s) + t = 1-c + m = numpy.array(( + (x * x * t + c, x * y * t + z*s, x * z * t - y * s), + (x * y * t - z*s, y * y * t + c, y * z * t + x * s), + (x * z * t + y * s, y * z * t - x * s, z * z * t + c))) + orig_z = Vector(*m.dot(orig_z.getData())) + return orig_z + + bui.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + + z = None + for i, spt in enumerate(spine): + if (i > 0 and i < ns - 1) or spineClosed: + snext = spine[(i + 1) % ns] + sprev = spine[(i - 1 + ns) % ns] + y = snext - sprev + vnext = snext - spt + vprev = sprev - spt + try_z = vnext.cross(vprev) + # Might be zero, then all kinds of fallback + if try_z.length() > EPSILON: + if z is not None and try_z.dot(z) < 0: + try_z = -try_z + z = try_z + elif not z: # No z, and no previous z. + # Look ahead, see if there's at least one point where + # spines are not collinear. + z = findFirstAngleNormal() + elif i == 0: # And non-crossed + snext = spine[i + 1] + y = snext - spt + z = findFirstAngleNormal() + else: # last point and not crossed + sprev = spine[i - 1] + y = spt - sprev + # If there's more than one point in the spine, z is already set. + # One point in the spline is an error anyway. + + z = z.normalized() + y = y.normalized() + x = y.cross(z) # Already normalized + m = numpy.array((x.getData(), y.getData(), z.getData())) + + # Columns are the unit vectors for the xz plane for the cross-section + if orient: + mrot = orient[i] if len(orient) > 1 else orient[0] + if not mrot is None: + m = m.dot(mrot) # Not sure about this. Counterexample??? + + if scale: + mscale = scale[i] if len(scale) > 1 else scale[0] + if not mscale is None: + m = m.dot(mscale) + + # First the cross-section 2-vector is scaled, + # then rotated (which may make it a 3-vector), + # then applied to the xz plane unit vectors + + for cpt in cross: + v = numpy.array(spt.getData()[:3]) + m.dot(cpt) + bui.addVertex(*v) + # Could've done this with a single 4x4 matrix... Oh well + + if beginCap: + addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) + + # Order of edges in the face: forward along cross, forward along spine, + # backward along cross, backward along spine, flipped if now ccw. + # This order is assumed later in the texture coordinate assignment; + # please don't change without syncing. + + for s in range(ns - 1): + for c in range(ncf): + addQuadFlip(bui, s * nc + c, s * nc + (c + 1) % nc, + (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) + + if spineClosed: + # The faces between the last and the first spine points + b = (ns - 1) * nc + for c in range(ncf): + addQuadFlip(bui, b + c, b + (c + 1) % nc, + (c + 1) % nc, c, ccw) + + if endCap: + addFace(bui, [(ns - 1) * nc + x for x in range(0, nc)], ccw) + +# Triangle meshes + + # Helper for numerous nodes with a Coordinate subnode holding vertices + # That all triangle meshes and IndexedFaceSet + # nFaces can be a function, in case the face count is a function of coord + def startCoordMesh(self, node, bui, nFaces): + ccw = readBoolean(node, "ccw", True) + coord = self.readVertices(node) + if hasattr(nFaces, '__call__'): + nFaces = nFaces(coord) + bui.reserveFaceAndVertexCount(nFaces, len(coord)) + for pt in coord: + bui.addVertex(*pt) + + return ccw + + + def geomIndexedTriangleSet(self, node, bui): + index = readIntArray(node, "index", []) + nFaces = len(index) // 3 + ccw = self.startCoordMesh(node, bui, nFaces) + + for i in range(0, nFaces*3, 3): + addTriFlip(bui, index[i], index[i+1], index[i+2], ccw) + + def geomIndexedTriangleStripSet(self, node, bui): + strips = readIndex(node, "index") + ccw = self.startCoordMesh(node, bui, sum([len(strip) - 2 for strip in strips])) + + for strip in strips: + sccw = ccw # Running CCW value, reset for each strip + for i in range(len(strip) - 2): + addTriFlip(bui, strip[i], strip[i+1], strip[i+2], sccw) + sccw = not sccw + + def geomIndexedTriangleFanSet(self, node, bui): + fans = readIndex(node, "index") + ccw = self.startCoordMesh(node, bui, sum([len(fan) - 2 for fan in fans])) + + for fan in fans: + for i in range(1, len(fan) - 1): + addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) + + + def geomTriangleSet(self, node, bui): + ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) + for i in range(0, len(bui.getVertices()), 3): + addTriFlip(bui, i, i+1, i+2, ccw) + + def geomTriangleStripSet(self, node, bui): + strips = readIntArray(node, "stripCount", []) + ccw = self.startCoordMesh(node, bui, sum([n-2 for n in strips])) + + vb = 0 + for n in strips: + sccw = ccw + for i in range(n-2): + addTriFlip(bui, vb+i, vb+i+1, vb+i+2, sccw) + sccw = not sccw + vb += n + + def geomTriangleFanSet(self, node, bui): + fans = readIntArray(node, "fanCount", []) + ccw = self.startCoordMesh(node, bui, sum([n-2 for n in fans])) + + vb = 0 + for n in fans: + for i in range(1, n-1): + addTriFlip(bui, vb, vb+i, vb+i+1, ccw) + vb += n + + # Quad geometries from the CAD module, might be relevant for printing + + def geomQuadSet(self, node, bui): + ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 4) + for i in range(0, len(bui.getVertices()), 4): + addQuadFlip(bui, i, i+1, i+2, i+4, ccw) + + def geomIndexedQuadSet(self, node, bui): + index = readIntArray(node, "index", []) + nFaces = len(index) // 4 + ccw = self.startCoordMesh(node, bui, nFaces) + + for i in range(0, nFaces*4, 4): + addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) + + + # General purpose polygon mesh + + def geomIndexedFaceSet(self, node, bui): + faces = readIndex(node, "coordIndex") + ccw = self.startCoordMesh(node, bui, sum([len(face) - 2 for face in faces])) + + for face in faces: + if len(face) == 3: + addTriFlip(bui, face[0], face[1], face[2], ccw) + elif len(face) > 3: + addFace(bui, face, ccw) + + geometryImporters = { + 'IndexedFaceSet': geomIndexedFaceSet, + 'IndexedTriangleSet': geomIndexedTriangleSet, + 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, + 'IndexedTriangleFanSet': geomIndexedTriangleFanSet, + 'TriangleSet': geomTriangleSet, + 'TriangleStripSet': geomTriangleStripSet, + 'TriangleFanSet': geomTriangleFanSet, + 'QuadSet': geomQuadSet, + 'IndexedQuadSet': geomIndexedQuadSet, + 'ElevationGrid': geomElevationGrid, + 'Extrusion': geomExtrusion, + 'Sphere': geomSphere, + 'Box': geomBox, + 'Cylinder': geomCylinder, + 'Cone': geomCone + } + + # Parses the Coordinate.@point field + def readVertices(self, node): + for c in node: + if c.tag == "Coordinate": + c = self.resolveDefUse(c) + if not c is None: + pt = c.attrib.get("point") + if pt: + co = [float(x) for x in pt.split()] + # Group by three + return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] + return [] + +# ------------------------------------------------------------ +# X3D field parsers +# ------------------------------------------------------------ +def readFloatArray(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return [float(x) for x in s.split()] + +def readIntArray(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return [int(x, 0) for x in s.split()] + +def readFloat(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return float(s) + +def readInt(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return int(s, 0) + +def readBoolean(node, attr, default): + s = node.attrib.get(attr) + if not s: + return default + return s.lower() == "true" + +def readVector(node, attr, default): + v = readFloatArray(node, attr, default) + return Vector(v[0], v[1], v[2]) + +def readRotation(node, attr, default): + v = readFloatArray(node, attr, default) + return (v[3], Vector(v[0], v[1], v[2])) + +# Returns the -1-separated runs +def readIndex(node, attr): + v = readIntArray(node, attr, []) + chunks = [] + chunk = [] + for i in range(len(v)): + if v[i] == -1: + if chunk: + chunks.append(chunk) + chunk = [] + else: + chunk.append(v[i]) + if chunk: + chunks.append(chunk) + return chunks + +# Mesh builder helpers + +def addTri(bui, a, b, c): + bui._indices[bui._face_count, 0] = a + bui._indices[bui._face_count, 1] = b + bui._indices[bui._face_count, 2] = c + bui._face_count += 1 + +def addTriFlip(bui, a, b, c, ccw): + if ccw: + addTri(bui, a, b, c) + else: + addTri(bui, b, a, c) + +# Needs to be convex, but not necessaily planar +# Assumed ccw, cut along the ac diagonal +def addQuad(bui, a, b, c, d): + addTri(bui, a, b, c) + addTri(bui, c, d, a) + +def addQuadFlip(bui, a, b, c, d, ccw): + if ccw: + addTri(bui, a, b, c) + addTri(bui, c, d, a) + else: + addTri(bui, a, c, b) + addTri(bui, c, a, d) + + +# Arbitrary polygon triangulation. +# Doesn't assume convexity and doesn't check the "convex" flag in the file. +# Works by the "cutting of ears" algorithm: +# - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle +# - Remove the triangle at that vertex +# - Repeat until done +# Note that n is the count of vertices in the face, but the `face` array is one element bigger, with nth element same as the 0th one +# Vertex coordinates are supposed to be already in the mesh builder object +def addFace(bui, indices, ccw): + # Resolve indices to coordinates for faster math + n = len(indices) + verts = bui.getVertices() + face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + + # Need a normal to the plane so that we can know which vertices form inner angles + normal = findOuterNormal(face) + + if not normal: # Couldn't find an outer edge, non-planar polygon maybe? + return + + # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done + m = len(face) + vi = [i for i in range(m)] # We'll be using this to kick vertices from the face + while m > 3: + maxCos = EPSILON # We don't want to check anything on Pi angles + iMin = 0 # max cos corresponds to min angle + for i in range(m): + inext = (i + 1) % m + iprev = (i + m - 1) % m + v = face[vi[i]] + next = face[vi[inext]] - v + prev = face[vi[iprev]] - v + nextXprev = next.cross(prev) + if nextXprev.dot(normal) > EPSILON: # If it's an inner angle + cos = next.dot(prev) / (next.length() * prev.length()) + if cos > maxCos: + # Check if there are vertices inside the triangle + noPointsInside = True + for j in range(m): + if j != i and j != iprev and j != inext: + vx = face[vi[j]] - v + if pointInsideTriangle(vx, next, prev, nextXprev): + noPointsInside = False + break + + if noPointsInside: + maxCos = cos + iMin = i + + addTriFlip(bui, indices[vi[(iMin + m - 1) % m]], indices[vi[iMin]], indices[vi[(iMin + 1) % m]], ccw) + vi.pop(iMin) + m -= 1 + addTriFlip(bui, indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) + + +# Given a face as a sequence of vectors, returns a normal to the polygon place that forms a right triple +# with a vector along the polygon sequence and a vector backwards +def findOuterNormal(face): + n = len(face) + for i in range(n): + for j in range(i+1, n): + edge = face[j] - face[i] + if edge.length() > EPSILON: + edge = edge.normalized() + prevRejection = Vector() + isOuter = True + for k in range(n): + if k != i and k != j: + pt = face[k] - face[i] + pte = pt.dot(edge) + rejection = pt - edge*pte + if rejection.dot(prevRejection) < -EPSILON: # points on both sides of the edge - not an outer one + isOuter = False + break + elif rejection.length() > prevRejection.length(): # Pick a greater rejection for numeric stability + prevRejection = rejection + + if isOuter: # Found an outer edge, prevRejection is the rejection inside the face. Generate a normal. + return edge.cross(prevRejection) + + return False + +# Assumes the vectors are either parallel or antiparallel and the denominator is nonzero. +# No error handling. +# For stability, taking the ration between the biggest coordinates would be better; none of that, either. +def ratio(a, b): + if b.x > EPSILON: + return a.x / b.x + elif b.y > EPSILON: + return a.y / b.y + else: + return a.z / b.z + +def pointInsideTriangle(vx, next, prev, nextXprev): + vxXprev = vx.cross(prev) + r = ratio(vxXprev, nextXprev) + if r < 0: + return False; + vxXnext = vx.cross(next); + s = -ratio(vxXnext, nextXprev) + return s > 0 and (s + r) < 1 + +def toNumpyRotation(rot): + (x, y, z) = rot[:3] + a = rot[3] + s = sin(a) + c = cos(a) + t = 1-c + return numpy.array(( + (x * x * t + c, x * y * t - z*s, x * z * t + y * s), + (x * y * t + z*s, y * y * t + c, y * z * t - x * s), + (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + + + \ No newline at end of file diff --git a/plugins/X3DReader/__init__.py b/plugins/X3DReader/__init__.py new file mode 100644 index 0000000000..84922f627f --- /dev/null +++ b/plugins/X3DReader/__init__.py @@ -0,0 +1,26 @@ +# Seva Alekseyev with National Institutes of Health, 2016 + +from . import X3DReader + +from UM.i18n import i18nCatalog +catalog = i18nCatalog("cura") + +def getMetaData(): + return { + "plugin": { + "name": catalog.i18nc("@label", "X3D Reader"), + "author": "Seva Alekseyev", + "version": "0.5", + "description": catalog.i18nc("@info:whatsthis", "Provides support for reading X3D files."), + "api": 3 + }, + "mesh_reader": [ + { + "extension": "x3d", + "description": catalog.i18nc("@item:inlistbox", "X3D File") + } + ] + } + +def register(app): + return { "mesh_reader": X3DReader.X3DReader() } From 87bff34eb62e236a11ed48898dc7b96ca72e179c Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 8 Aug 2016 14:11:30 -0400 Subject: [PATCH 13/22] Support for explicit length unit override in X3D header --- plugins/X3DReader/X3DReader.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 3b5af06d6e..43f528fb82 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -32,11 +32,31 @@ class X3DReader(MeshReader): try: self.sceneNodes = [] self.fileName = file_name - self.transform = Matrix() - self.transform.setByScaleVector(Vector(1000, 1000, 1000)) tree = ET.parse(file_name) - self.processNode(tree.getroot()) + root = tree.getroot() + + if root.tag != "X3D": + return None + + scale = 1000 # Default X3D unit it one meter, while Cura's is one mm + if root[0].tag == "head": + for headNode in root[0]: + if headNode.tag == "unit" and headNode.attrib.get("category") == "length": + scale *= float.parse(headNode.attrib["conversionFactor"]) + break + scene = root[1] + else: + scene = root[0] + + if scene.tag != "Scene": + return None + + self.transform = Matrix() + self.transform.setByScaleVector(Vector(scale, scale, scale)) + + # This will populate the sceneNodes array + self.processChildNodes(scene) if len(self.sceneNodes) > 1: theScene = SceneNode() @@ -68,7 +88,7 @@ class X3DReader(MeshReader): return tag = xmlNode.tag - if tag in ("X3D", "Scene", "Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): + if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): self.processChildNodes(xmlNode) elif tag == "LOD": self.processNode(xmlNode[0]) From 43de0e1c068b77a19e09436f8630a58a765abc5f Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Tue, 9 Aug 2016 11:46:41 -0400 Subject: [PATCH 14/22] Bug fix --- plugins/X3DReader/X3DReader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 43f528fb82..8311df0ebe 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -43,7 +43,7 @@ class X3DReader(MeshReader): if root[0].tag == "head": for headNode in root[0]: if headNode.tag == "unit" and headNode.attrib.get("category") == "length": - scale *= float.parse(headNode.attrib["conversionFactor"]) + scale *= float(headNode.attrib["conversionFactor"]) break scene = root[1] else: From db5d23889831a2db95e680d0d36feb011d46f8e5 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Tue, 9 Aug 2016 14:10:06 -0400 Subject: [PATCH 15/22] Fix for the Extrusion node, the trickiest geometry of them all --- plugins/X3DReader/X3DReader.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 8311df0ebe..1302abe448 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -26,10 +26,12 @@ class X3DReader(MeshReader): super().__init__() self._supported_extensions = [".x3d"] self._namespaces = {} - self.defs = {} - + + # Main entry point + # Reads the file, returns a SceneNode (possibly with nested ones), or None def read(self, file_name): try: + self.defs = {} self.sceneNodes = [] self.fileName = file_name @@ -39,7 +41,7 @@ class X3DReader(MeshReader): if root.tag != "X3D": return None - scale = 1000 # Default X3D unit it one meter, while Cura's is one mm + scale = 1000 # Default X3D unit it one meter, while Cura's is one millimeters if root[0].tag == "head": for headNode in root[0]: if headNode.tag == "unit" and headNode.attrib.get("category") == "length": @@ -53,9 +55,9 @@ class X3DReader(MeshReader): return None self.transform = Matrix() - self.transform.setByScaleVector(Vector(scale, scale, scale)) + self.transform.setByScaleFactor(scale) - # This will populate the sceneNodes array + # Traverse the scene tree, populate the sceneNodes array self.processChildNodes(scene) if len(self.sceneNodes) > 1: @@ -132,7 +134,8 @@ class X3DReader(MeshReader): # Returns the referenced node if the node has USE, the same node otherwise. # May return None is USE points at a nonexistent node # In X3DOM, when both DEF and USE are in the same node, DEF is ignored. - # Big caveat: XML node objects may evaluate to boolean False!!! + # Big caveat: XML element objects may evaluate to boolean False!!! + # Don't ever use "if node:", use "if not node is None:" instead def resolveDefUse(self, node): USE = node.attrib.get("USE") if USE: @@ -210,12 +213,10 @@ class X3DReader(MeshReader): else: nr = ns = DEFAULT_SUBDIV - lau = pi / nr # Unit angle of latitude (rings) for the given tesselation lou = 2 * pi / ns # Unit angle of longitude (segments) bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) - # +y and -y poles bui.addVertex(0, r, 0) @@ -434,13 +435,13 @@ class X3DReader(MeshReader): z = z.normalized() y = y.normalized() x = y.cross(z) # Already normalized - m = numpy.array((x.getData(), y.getData(), z.getData())) + m = numpy.array(((x.x, y.x, z.x), (x.y, y.y, z.y), (x.z, y.z, z.z))) # Columns are the unit vectors for the xz plane for the cross-section if orient: mrot = orient[i] if len(orient) > 1 else orient[0] if not mrot is None: - m = m.dot(mrot) # Not sure about this. Counterexample??? + m = m.dot(mrot) # Tested against X3DOM, the result matches, still not sure :( if scale: mscale = scale[i] if len(scale) > 1 else scale[0] @@ -451,10 +452,10 @@ class X3DReader(MeshReader): # then rotated (which may make it a 3-vector), # then applied to the xz plane unit vectors + sptv3 = numpy.array(spt.getData()[:3]) for cpt in cross: - v = numpy.array(spt.getData()[:3]) + m.dot(cpt) + v = sptv3 + m.dot(cpt) bui.addVertex(*v) - # Could've done this with a single 4x4 matrix... Oh well if beginCap: addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) @@ -521,7 +522,6 @@ class X3DReader(MeshReader): for fan in fans: for i in range(1, len(fan) - 1): addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node, bui): ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) From d9e07f66a6588d00f86de2c512c1e92310ed5309 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Wed, 10 Aug 2016 10:47:49 -0400 Subject: [PATCH 16/22] Fixes for quad geometries --- plugins/X3DReader/X3DReader.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 1302abe448..ba798e0e96 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -553,18 +553,17 @@ class X3DReader(MeshReader): # Quad geometries from the CAD module, might be relevant for printing def geomQuadSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 4) + ccw = self.startCoordMesh(node, bui, lambda coord: 2*(len(coord) // 4)) for i in range(0, len(bui.getVertices()), 4): - addQuadFlip(bui, i, i+1, i+2, i+4, ccw) + addQuadFlip(bui, i, i+1, i+2, i+3, ccw) def geomIndexedQuadSet(self, node, bui): index = readIntArray(node, "index", []) - nFaces = len(index) // 4 - ccw = self.startCoordMesh(node, bui, nFaces) + nQuads = len(index) // 4 + ccw = self.startCoordMesh(node, bui, nQuads*2) - for i in range(0, nFaces*4, 4): + for i in range(0, nQuads*4, 4): addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) - # General purpose polygon mesh From 8190b9875e725ee516f95fabcab3474681e8e8bd Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Thu, 11 Aug 2016 10:08:25 -0400 Subject: [PATCH 17/22] In X3D import, setSelectable(true) for the whole scene --- plugins/X3DReader/X3DReader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index ba798e0e96..72fba5d1fe 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -66,6 +66,7 @@ class X3DReader(MeshReader): theScene.addDecorator(group_decorator) for node in self.sceneNodes: theScene.addChild(node) + theScene.setSelectable(True) elif len(self.sceneNodes) == 1: theScene = self.sceneNodes[0] else: # No shapes read :( @@ -700,7 +701,6 @@ def addQuadFlip(bui, a, b, c, d, ccw): # - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle # - Remove the triangle at that vertex # - Repeat until done -# Note that n is the count of vertices in the face, but the `face` array is one element bigger, with nth element same as the 0th one # Vertex coordinates are supposed to be already in the mesh builder object def addFace(bui, indices, ccw): # Resolve indices to coordinates for faster math From 5cb9f979861158629ff8f250d6f32212513b03ad Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Thu, 11 Aug 2016 11:17:48 -0400 Subject: [PATCH 18/22] Slicing logic now index-aware, correctly processes scenes with vertex reuse --- plugins/CuraEngineBackend/StartSliceJob.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index c7cedc92ca..abf1996e72 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -148,7 +148,12 @@ class StartSliceJob(Job): obj = group_message.addRepeatedMessage("objects") obj.id = id(object) - verts = numpy.array(mesh_data.getVertices()) + verts = mesh_data.getVertices() + indices = mesh_data.getIndices() + if not indices is None: + verts = numpy.array([verts[vert_index] for face in indices for vert_index in face]) + else: + verts = numpy.array(verts) # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. verts[:, [1, 2]] = verts[:, [2, 1]] From a27f82e64b160cbfba78dc640477ba7e3e083457 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Fri, 12 Aug 2016 15:15:56 -0400 Subject: [PATCH 19/22] Merging all shapes into a single mesh during X3D loading --- plugins/X3DReader/X3DReader.py | 544 +++++++++++++++++++-------------- 1 file changed, 318 insertions(+), 226 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 72fba5d1fe..8a5822d9ed 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -11,6 +11,7 @@ from UM.Job import Job from math import pi, sin, cos, sqrt import numpy + EPSILON = 0.000001 # So very crude. :( try: @@ -18,9 +19,19 @@ try: except ImportError: import xml.etree.ElementTree as ET +# TODO: preserve the structure of scenes that contain several objects +# Use CADPart, for example, to distinguish between separate objects -DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +class Shape: + def __init__(self, v, f, ib, n): + self.verts = v + self.faces = f + # Those are here for debugging purposes only + self.index_base = ib + self.name = n + class X3DReader(MeshReader): def __init__(self): super().__init__() @@ -32,102 +43,97 @@ class X3DReader(MeshReader): def read(self, file_name): try: self.defs = {} - self.sceneNodes = [] - self.fileName = file_name + self.shapes = [] tree = ET.parse(file_name) - root = tree.getroot() + xml_root = tree.getroot() - if root.tag != "X3D": + if xml_root.tag != "X3D": return None scale = 1000 # Default X3D unit it one meter, while Cura's is one millimeters - if root[0].tag == "head": - for headNode in root[0]: - if headNode.tag == "unit" and headNode.attrib.get("category") == "length": - scale *= float(headNode.attrib["conversionFactor"]) + if xml_root[0].tag == "head": + for head_node in xml_root[0]: + if head_node.tag == "unit" and head_node.attrib.get("category") == "length": + scale *= float(head_node.attrib["conversionFactor"]) break - scene = root[1] + xml_scene = xml_root[1] else: - scene = root[0] + xml_scene = xml_root[0] - if scene.tag != "Scene": + if xml_scene.tag != "Scene": return None self.transform = Matrix() self.transform.setByScaleFactor(scale) + self.index_base = 0 - # Traverse the scene tree, populate the sceneNodes array - self.processChildNodes(scene) + # Traverse the scene tree, populate the shapes list + self.processChildNodes(xml_scene) - if len(self.sceneNodes) > 1: - theScene = SceneNode() - group_decorator = GroupDecorator() - theScene.addDecorator(group_decorator) - for node in self.sceneNodes: - theScene.addChild(node) - theScene.setSelectable(True) - elif len(self.sceneNodes) == 1: - theScene = self.sceneNodes[0] - else: # No shapes read :( + if self.shapes: + bui = MeshBuilder() + bui.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) + bui.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) + bui.calculateNormals() + bui.setFileName(file_name) + + scene = SceneNode() + scene.setMeshData(bui.build().getTransformed(Matrix())) + scene.setSelectable(True) + scene.setName(file_name) + else: return None - theScene.setName(file_name) + except Exception as e: Logger.log("e", "exception occured in x3d reader: %s", e) try: - boundingBox = theScene.getBoundingBox() + boundingBox = scene.getBoundingBox() boundingBox.isValid() except: return None - return theScene + return scene # ------------------------- XML tree traversal - def processNode(self, xmlNode): - xmlNode = self.resolveDefUse(xmlNode) - if xmlNode is None: + def processNode(self, xml_node): + xml_node = self.resolveDefUse(xml_node) + if xml_node is None: return - tag = xmlNode.tag - if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "CADPart", "Collision"): - self.processChildNodes(xmlNode) + tag = xml_node.tag + if tag in ("Group", "StaticGroup", "CADAssembly", "CADFace", "CADLayer", "Collision"): + self.processChildNodes(xml_node) + if tag == "CADPart": + self.processTransform(xml_node) # TODO: split the parts elif tag == "LOD": - self.processNode(xmlNode[0]) + self.processNode(xml_node[0]) elif tag == "Transform": - self.processTransform(xmlNode) + self.processTransform(xml_node) elif tag == "Shape": - self.processShape(xmlNode) + self.processShape(xml_node) - def processShape(self, xmlNode): + def processShape(self, xml_node): # Find the geometry and the appearance inside the Shape geometry = appearance = None - for subNode in xmlNode: + for subNode in xml_node: if subNode.tag == "Appearance" and not appearance: appearance = self.resolveDefUse(subNode) - elif subNode.tag in self.geometryImporters and not geometry: + elif subNode.tag in self.geometry_importers and not geometry: geometry = self.resolveDefUse(subNode) # TODO: appearance is completely ignored. At least apply the material color... if not geometry is None: try: - bui = MeshBuilder() - self.geometryImporters[geometry.tag](self, geometry, bui) - - bui.calculateNormals() - bui.setFileName(self.fileName) - - sceneNode = SceneNode() - if "DEF" in geometry.attrib: - sceneNode.setName(geometry.tag + "#" + geometry.attrib["DEF"]) - else: - sceneNode.setName(geometry.tag) - - sceneNode.setMeshData(bui.build().getTransformed(self.transform)) - sceneNode.setSelectable(True) - self.sceneNodes.append(sceneNode) + self.verts = self.faces = [] # Safeguard + self.geometry_importers[geometry.tag](self, geometry) + m = self.transform.getData() + verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) + self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) + self.index_base += len(verts) except Exception as e: Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) @@ -198,12 +204,33 @@ class X3DReader(MeshReader): # Primitives - def geomBox(self, node, bui): - size = readFloatArray(node, "size", [2, 2, 2]) - bui.addCube(size[0], size[1], size[2]) + def geomBox(self, node): + (dx, dy, dz) = readFloatArray(node, "size", [2, 2, 2]) + dx /= 2 + dy /= 2 + dz /= 2 + self.reserveFaceAndVertexCount(12, 8) + + # xz plane at +y, ccw + self.addVertex(dx, dy, dz) + self.addVertex(-dx, dy, dz) + self.addVertex(-dx, dy, -dz) + self.addVertex(dx, dy, -dz) + # xz plane at -y + self.addVertex(dx, -dy, dz) + self.addVertex(-dx, -dy, dz) + self.addVertex(-dx, -dy, -dz) + self.addVertex(dx, -dy, -dz) + + self.addQuad(0, 1, 2, 3) # +y + self.addQuad(4, 0, 3, 7) # +x + self.addQuad(7, 3, 2, 6) # -z + self.addQuad(6, 2, 1, 5) # -x + self.addQuad(5, 1, 0, 4) # +z + self.addQuad(7, 6, 5, 4) # -y # The sphere is subdivided into nr rings and ns segments - def geomSphere(self, node, bui): + def geomSphere(self, node): r = readFloat(node, "radius", 0.5) subdiv = readIntArray(node, 'subdivision', None) if subdiv: @@ -217,17 +244,17 @@ class X3DReader(MeshReader): lau = pi / nr # Unit angle of latitude (rings) for the given tesselation lou = 2 * pi / ns # Unit angle of longitude (segments) - bui.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr + 1)*ns) + self.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr - 1)*ns) # +y and -y poles - bui.addVertex(0, r, 0) - bui.addVertex(0, -r, 0) + self.addVertex(0, r, 0) + self.addVertex(0, -r, 0) # The non-polar vertices go from x=0, negative z plane counterclockwise - # to -x, to +z, to +x, back to -z for ring in range(1, nr): for seg in range(ns): - bui.addVertex(-r*sin(lou * seg) * sin(lau * ring), + self.addVertex(-r*sin(lou * seg) * sin(lau * ring), r*cos(lau * ring), -r*cos(lou * seg) * sin(lau * ring)) @@ -241,8 +268,8 @@ class X3DReader(MeshReader): # (starting from +y pole) # Bottom cap goes: up left down (starting from -y pole) for seg in range(ns): - addTri(bui, 0, seg + 2, (seg + 1) % ns + 2) - addTri(bui, 1, vb + (seg + 1) % ns, vb + seg) + self.addTri(0, seg + 2, (seg + 1) % ns + 2) + self.addTri(1, vb + (seg + 1) % ns, vb + seg) # Sides # Side face vertices go in order: down right upleft, downright up left @@ -253,9 +280,9 @@ class X3DReader(MeshReader): # First vertex index for the bottom edge of the ring for seg in range(ns): nseg = (seg + 1) % ns - addQuad(bui, tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) + self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) - def geomCone(self, node, bui): + def geomCone(self, node): r = readFloat(node, "bottomRadius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -265,21 +292,22 @@ class X3DReader(MeshReader): d = height / 2 angle = 2 * pi / n - bui.reserveFaceAndVertexCount((n if side else 0) + (n-1 if bottom else 0), n+1) + self.reserveFaceAndVertexCount((n if side else 0) + (n-2 if bottom else 0), n+1) - bui.addVertex(0, d, 0) + # Vertex 0 is the apex, vertices 1..n are the bottom + self.addVertex(0, d, 0) for i in range(n): - bui.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) + self.addVertex(-r * sin(angle * i), -d, -r * cos(angle * i)) # Side face vertices go: up down right if side: for i in range(n): - addTri(bui, 1 + (i + 1) % n, 0, 1 + i) + self.addTri(1 + (i + 1) % n, 0, 1 + i) if bottom: for i in range(2, n): - addTri(bui, 1, i, i+1) + self.addTri(1, i, i+1) - def geomCylinder(self, node, bui): + def geomCylinder(self, node): r = readFloat(node, "radius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -291,30 +319,30 @@ class X3DReader(MeshReader): angle = 2 * pi / n hh = height/2 - bui.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) + self.reserveFaceAndVertexCount((nn if side else 0) + (n - 2 if top else 0) + (n - 2 if bottom else 0), nn) # The seam is at x=0, z=-r, vertices go ccw - # to pos x, to neg z, to neg x, back to neg z for i in range(n): rs = -r * sin(angle * i) rc = -r * cos(angle * i) - bui.addVertex(rs, hh, rc) - bui.addVertex(rs, -hh, rc) + self.addVertex(rs, hh, rc) + self.addVertex(rs, -hh, rc) if side: for i in range(n): ni = (i + 1) % n - addQuad(bui, ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) + self.addQuad(ni * 2 + 1, ni * 2, i * 2, i * 2 + 1) for i in range(2, nn-3, 2): if top: - addTri(bui, 0, i, i+2) + self.addTri(0, i, i+2) if bottom: - addTri(bui, 1, i+1, i+3) + self.addTri(1, i+1, i+3) -# Semi-primitives + # Semi-primitives - def geomElevationGrid(self, node, bui): + def geomElevationGrid(self, node): dx = readFloat(node, "xSpacing", 1) dz = readFloat(node, "zSpacing", 1) nx = readInt(node, "xDimension", 0) @@ -325,18 +353,18 @@ class X3DReader(MeshReader): if nx <= 0 or nz <= 0 or len(height) < nx*nz: return # That's weird, the wording of the standard suggests grids with zero quads are somehow valid - bui.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) + self.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz) for z in range(nz): for x in range(nx): - bui.addVertex(x * dx, height[z*nx + x], z * dz) + self.addVertex(x * dx, height[z*nx + x], z * dz) for z in range(1, nz): for x in range(1, nx): - addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) - addTriFlip(bui, (z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) + self.addTriFlip((z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) + self.addTriFlip((z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) - def geomExtrusion(self, node, bui): + def geomExtrusion(self, node): ccw = readBoolean(node, "ccw", True) beginCap = readBoolean(node, "beginCap", True) endCap = readBoolean(node, "endCap", True) @@ -403,7 +431,7 @@ class X3DReader(MeshReader): orig_z = Vector(*m.dot(orig_z.getData())) return orig_z - bui.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) z = None for i, spt in enumerate(spine): @@ -456,10 +484,10 @@ class X3DReader(MeshReader): sptv3 = numpy.array(spt.getData()[:3]) for cpt in cross: v = sptv3 + m.dot(cpt) - bui.addVertex(*v) + self.addVertex(*v) if beginCap: - addFace(bui, [x for x in range(nc - 1, -1, -1)], ccw) + self.addFace([x for x in range(nc - 1, -1, -1)], ccw) # Order of edges in the face: forward along cross, forward along spine, # backward along cross, backward along spine, flipped if now ccw. @@ -468,117 +496,167 @@ class X3DReader(MeshReader): for s in range(ns - 1): for c in range(ncf): - addQuadFlip(bui, s * nc + c, s * nc + (c + 1) % nc, + self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc, (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) if spineClosed: # The faces between the last and the first spine points b = (ns - 1) * nc for c in range(ncf): - addQuadFlip(bui, b + c, b + (c + 1) % nc, + self.addQuadFlip(b + c, b + (c + 1) % nc, (c + 1) % nc, c, ccw) if endCap: - addFace(bui, [(ns - 1) * nc + x for x in range(0, nc)], ccw) + self.addFace([(ns - 1) * nc + x for x in range(0, nc)], ccw) # Triangle meshes # Helper for numerous nodes with a Coordinate subnode holding vertices # That all triangle meshes and IndexedFaceSet - # nFaces can be a function, in case the face count is a function of coord - def startCoordMesh(self, node, bui, nFaces): + # num_faces can be a function, in case the face count is a function of coord + def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) coord = self.readVertices(node) - if hasattr(nFaces, '__call__'): - nFaces = nFaces(coord) - bui.reserveFaceAndVertexCount(nFaces, len(coord)) + if hasattr(num_faces, '__call__'): + num_faces = num_faces(coord) + self.reserveFaceAndVertexCount(num_faces, len(coord)) for pt in coord: - bui.addVertex(*pt) + self.addVertex(*pt) return ccw - def geomIndexedTriangleSet(self, node, bui): + def geomIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) - nFaces = len(index) // 3 - ccw = self.startCoordMesh(node, bui, nFaces) + num_faces = len(index) // 3 + ccw = self.startCoordMesh(node, num_faces) - for i in range(0, nFaces*3, 3): - addTriFlip(bui, index[i], index[i+1], index[i+2], ccw) + for i in range(0, num_faces*3, 3): + self.addTriFlip(index[i], index[i+1], index[i+2], ccw) - def geomIndexedTriangleStripSet(self, node, bui): + def geomIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") - ccw = self.startCoordMesh(node, bui, sum([len(strip) - 2 for strip in strips])) + ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) for strip in strips: sccw = ccw # Running CCW value, reset for each strip for i in range(len(strip) - 2): - addTriFlip(bui, strip[i], strip[i+1], strip[i+2], sccw) + self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) sccw = not sccw - def geomIndexedTriangleFanSet(self, node, bui): + def geomIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") - ccw = self.startCoordMesh(node, bui, sum([len(fan) - 2 for fan in fans])) + ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) for fan in fans: for i in range(1, len(fan) - 1): - addTriFlip(bui, fan[0], fan[i], fan[i+1], ccw) + self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: len(coord) // 3) - for i in range(0, len(bui.getVertices()), 3): - addTriFlip(bui, i, i+1, i+2, ccw) + def geomTriangleSet(self, node): + ccw = self.startCoordMesh(node, lambda coord: len(coord) // 3) + for i in range(0, len(self.verts), 3): + self.addTriFlip(i, i+1, i+2, ccw) - def geomTriangleStripSet(self, node, bui): + def geomTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) - ccw = self.startCoordMesh(node, bui, sum([n-2 for n in strips])) + ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) vb = 0 for n in strips: sccw = ccw for i in range(n-2): - addTriFlip(bui, vb+i, vb+i+1, vb+i+2, sccw) + self.addTriFlip(vb+i, vb+i+1, vb+i+2, sccw) sccw = not sccw vb += n - def geomTriangleFanSet(self, node, bui): + def geomTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) - ccw = self.startCoordMesh(node, bui, sum([n-2 for n in fans])) + ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) vb = 0 for n in fans: for i in range(1, n-1): - addTriFlip(bui, vb, vb+i, vb+i+1, ccw) + self.addTriFlip(vb, vb+i, vb+i+1, ccw) vb += n # Quad geometries from the CAD module, might be relevant for printing - def geomQuadSet(self, node, bui): - ccw = self.startCoordMesh(node, bui, lambda coord: 2*(len(coord) // 4)) - for i in range(0, len(bui.getVertices()), 4): - addQuadFlip(bui, i, i+1, i+2, i+3, ccw) + def geomQuadSet(self, node): + ccw = self.startCoordMesh(node, lambda coord: 2*(len(coord) // 4)) + for i in range(0, len(self.verts), 4): + self.addQuadFlip(i, i+1, i+2, i+3, ccw) - def geomIndexedQuadSet(self, node, bui): + def geomIndexedQuadSet(self, node): index = readIntArray(node, "index", []) nQuads = len(index) // 4 - ccw = self.startCoordMesh(node, bui, nQuads*2) + ccw = self.startCoordMesh(node, nQuads*2) for i in range(0, nQuads*4, 4): - addQuadFlip(bui, index[i], index[i+1], index[i+2], index[i+3], ccw) + self.addQuadFlip(index[i], index[i+1], index[i+2], index[i+3], ccw) + + # 2D polygon geometries + # Won't work for now, since Cura expects every mesh to have a nontrivial convex hull + # The only way around that is merging meshes. + + def geomDisk2D(self, node): + innerRadius = readFloat(node, "innerRadius", 0) + outerRadius = readFloat(node, "outerRadius", 1) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) + + angle = 2 * pi / n + + if innerRadius: + self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) + + for i in range(n): + s = sin(angle * i) + c = cos(angle * i) + self.addVertex(outerRadius*c, outerRadius*s, 0) + if innerRadius: + self.addVertex(innerRadius*c, innerRadius*s, 0) + ni = (i+1) % n + self.addQuad(2*i, 2*ni, 2*ni+1, 2*i+1) + + if not innerRadius: + for i in range(2, n): + self.addTri(0, i-1, i) + + def geomRectangle2D(self, node): + (x, y) = readFloatArray(node, "size", (2, 2)) + self.reserveFaceAndVertexCount(2, 4) + self.addVertex(-x/2, -y/2, 0) + self.addVertex(x/2, -y/2, 0) + self.addVertex(x/2, y/2, 0) + self.addVertex(-x/2, y/2, 0) + self.addQuad(0, 1, 2, 3) + + def geomTriangleSet2D(self, node): + verts = readFloatArray(node, "vertices", ()) + num_faces = len(verts) // 6; + verts = [(verts[i], verts[i+1], 0) for i in range(0, 6 * num_faces, 2)] + self.reserveFaceAndVertexCount(num_faces, num_faces * 3) + for vert in verts: + self.addVertex(*vert) + + # The front face is on the +Z side, so CCW is a variable + for i in range(0, num_faces*3, 3): + a = Vector(*verts[i+2]) - Vector(*verts[i]) + b = Vector(*verts[i+1]) - Vector(*verts[i]) + self.addTriFlip(i, i+1, i+2, a.x*b.y > a.y*b.x) # General purpose polygon mesh - def geomIndexedFaceSet(self, node, bui): + def geomIndexedFaceSet(self, node): faces = readIndex(node, "coordIndex") - ccw = self.startCoordMesh(node, bui, sum([len(face) - 2 for face in faces])) + ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces])) for face in faces: if len(face) == 3: - addTriFlip(bui, face[0], face[1], face[2], ccw) + self.addTriFlip(face[0], face[1], face[2], ccw) elif len(face) > 3: - addFace(bui, face, ccw) + self.addFace(face, ccw) - geometryImporters = { + geometry_importers = { 'IndexedFaceSet': geomIndexedFaceSet, 'IndexedTriangleSet': geomIndexedTriangleSet, 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, @@ -588,6 +666,9 @@ class X3DReader(MeshReader): 'TriangleFanSet': geomTriangleFanSet, 'QuadSet': geomQuadSet, 'IndexedQuadSet': geomIndexedQuadSet, + 'TriangleSet2D': geomTriangleSet2D, + 'Rectangle2D': geomRectangle2D, + 'Disk2D': geomDisk2D, 'ElevationGrid': geomElevationGrid, 'Extrusion': geomExtrusion, 'Sphere': geomSphere, @@ -609,6 +690,103 @@ class X3DReader(MeshReader): return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] return [] + # Mesh builder helpers + + def reserveFaceAndVertexCount(self, num_faces, num_verts): + # Unlike the Cura MeshBuilder, we use 4-vectors here for easier transform + self.verts = numpy.array([(0,0,0,1) for i in range(num_verts)], dtype=numpy.float32) + self.faces = numpy.zeros((num_faces, 3), dtype=numpy.int32) + self.num_faces = 0 + self.num_verts = 0 + + def addVertex(self, x, y, z): + self.verts[self.num_verts, 0] = x + self.verts[self.num_verts, 1] = y + self.verts[self.num_verts, 2] = z + self.num_verts += 1 + + # Indices are 0-based for this shape, but they won't be zero-based in the merged mesh + def addTri(self, a, b, c): + self.faces[self.num_faces, 0] = self.index_base + a + self.faces[self.num_faces, 1] = self.index_base + b + self.faces[self.num_faces, 2] = self.index_base + c + self.num_faces += 1 + + def addTriFlip(self, a, b, c, ccw): + if ccw: + self.addTri(a, b, c) + else: + self.addTri(b, a, c) + + # Needs to be convex, but not necessaily planar + # Assumed ccw, cut along the ac diagonal + def addQuad(self, a, b, c, d): + self.addTri(a, b, c) + self.addTri(c, d, a) + + def addQuadFlip(self, a, b, c, d, ccw): + if ccw: + self.addTri(a, b, c) + self.addTri(c, d, a) + else: + self.addTri(a, c, b) + self.addTri(c, a, d) + + + # Arbitrary polygon triangulation. + # Doesn't assume convexity and doesn't check the "convex" flag in the file. + # Works by the "cutting of ears" algorithm: + # - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle + # - Remove the triangle at that vertex + # - Repeat until done + # Vertex coordinates are supposed to be already set + def addFace(self, indices, ccw): + # Resolve indices to coordinates for faster math + n = len(indices) + verts = self.verts + face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + + # Need a normal to the plane so that we can know which vertices form inner angles + normal = findOuterNormal(face) + + if not normal: # Couldn't find an outer edge, non-planar polygon maybe? + return + + # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done + m = len(face) + vi = [i for i in range(m)] # We'll be using this to kick vertices from the face + while m > 3: + max_cos = EPSILON # We don't want to check anything on Pi angles + i_min = 0 # max cos corresponds to min angle + for i in range(m): + inext = (i + 1) % m + iprev = (i + m - 1) % m + v = face[vi[i]] + next = face[vi[inext]] - v + prev = face[vi[iprev]] - v + nextXprev = next.cross(prev) + if nextXprev.dot(normal) > EPSILON: # If it's an inner angle + cos = next.dot(prev) / (next.length() * prev.length()) + if cos > max_cos: + # Check if there are vertices inside the triangle + no_points_inside = True + for j in range(m): + if j != i and j != iprev and j != inext: + vx = face[vi[j]] - v + if pointInsideTriangle(vx, next, prev, nextXprev): + no_points_inside = False + break + + if no_points_inside: + max_cos = cos + i_min = i + + self.addTriFlip(indices[vi[(i_min + m - 1) % m]], indices[vi[i_min]], indices[vi[(i_min + 1) % m]], ccw) + vi.pop(i_min) + m -= 1 + self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) + + # ------------------------------------------------------------ # X3D field parsers # ------------------------------------------------------------ @@ -665,89 +843,6 @@ def readIndex(node, attr): if chunk: chunks.append(chunk) return chunks - -# Mesh builder helpers - -def addTri(bui, a, b, c): - bui._indices[bui._face_count, 0] = a - bui._indices[bui._face_count, 1] = b - bui._indices[bui._face_count, 2] = c - bui._face_count += 1 - -def addTriFlip(bui, a, b, c, ccw): - if ccw: - addTri(bui, a, b, c) - else: - addTri(bui, b, a, c) - -# Needs to be convex, but not necessaily planar -# Assumed ccw, cut along the ac diagonal -def addQuad(bui, a, b, c, d): - addTri(bui, a, b, c) - addTri(bui, c, d, a) - -def addQuadFlip(bui, a, b, c, d, ccw): - if ccw: - addTri(bui, a, b, c) - addTri(bui, c, d, a) - else: - addTri(bui, a, c, b) - addTri(bui, c, a, d) - - -# Arbitrary polygon triangulation. -# Doesn't assume convexity and doesn't check the "convex" flag in the file. -# Works by the "cutting of ears" algorithm: -# - Find an outer vertex with the smallest angle and no vertices inside its adjacent triangle -# - Remove the triangle at that vertex -# - Repeat until done -# Vertex coordinates are supposed to be already in the mesh builder object -def addFace(bui, indices, ccw): - # Resolve indices to coordinates for faster math - n = len(indices) - verts = bui.getVertices() - face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] - - # Need a normal to the plane so that we can know which vertices form inner angles - normal = findOuterNormal(face) - - if not normal: # Couldn't find an outer edge, non-planar polygon maybe? - return - - # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done - m = len(face) - vi = [i for i in range(m)] # We'll be using this to kick vertices from the face - while m > 3: - maxCos = EPSILON # We don't want to check anything on Pi angles - iMin = 0 # max cos corresponds to min angle - for i in range(m): - inext = (i + 1) % m - iprev = (i + m - 1) % m - v = face[vi[i]] - next = face[vi[inext]] - v - prev = face[vi[iprev]] - v - nextXprev = next.cross(prev) - if nextXprev.dot(normal) > EPSILON: # If it's an inner angle - cos = next.dot(prev) / (next.length() * prev.length()) - if cos > maxCos: - # Check if there are vertices inside the triangle - noPointsInside = True - for j in range(m): - if j != i and j != iprev and j != inext: - vx = face[vi[j]] - v - if pointInsideTriangle(vx, next, prev, nextXprev): - noPointsInside = False - break - - if noPointsInside: - maxCos = cos - iMin = i - - addTriFlip(bui, indices[vi[(iMin + m - 1) % m]], indices[vi[iMin]], indices[vi[(iMin + 1) % m]], ccw) - vi.pop(iMin) - m -= 1 - addTriFlip(bui, indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) - # Given a face as a sequence of vectors, returns a normal to the polygon place that forms a right triple # with a vector along the polygon sequence and a vector backwards @@ -758,21 +853,21 @@ def findOuterNormal(face): edge = face[j] - face[i] if edge.length() > EPSILON: edge = edge.normalized() - prevRejection = Vector() - isOuter = True + prev_rejection = Vector() + is_outer = True for k in range(n): if k != i and k != j: pt = face[k] - face[i] pte = pt.dot(edge) rejection = pt - edge*pte - if rejection.dot(prevRejection) < -EPSILON: # points on both sides of the edge - not an outer one - isOuter = False + if rejection.dot(prev_rejection) < -EPSILON: # points on both sides of the edge - not an outer one + is_outer = False break - elif rejection.length() > prevRejection.length(): # Pick a greater rejection for numeric stability - prevRejection = rejection + elif rejection.length() > prev_rejection.length(): # Pick a greater rejection for numeric stability + prev_rejection = rejection - if isOuter: # Found an outer edge, prevRejection is the rejection inside the face. Generate a normal. - return edge.cross(prevRejection) + if is_outer: # Found an outer edge, prev_rejection is the rejection inside the face. Generate a normal. + return edge.cross(prev_rejection) return False @@ -780,9 +875,9 @@ def findOuterNormal(face): # No error handling. # For stability, taking the ration between the biggest coordinates would be better; none of that, either. def ratio(a, b): - if b.x > EPSILON: + if b.x > EPSILON or b.x < -EPSILON: return a.x / b.x - elif b.y > EPSILON: + elif b.y > EPSILON or b.y < -EPSILON: return a.y / b.y else: return a.z / b.z @@ -806,6 +901,3 @@ def toNumpyRotation(rot): (x * x * t + c, x * y * t - z*s, x * z * t + y * s), (x * y * t + z*s, y * y * t + c, y * z * t - x * s), (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) - - - \ No newline at end of file From 13ead1e4de4a1247b3319dc45494f66c69ed1868 Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Fri, 12 Aug 2016 16:11:57 -0400 Subject: [PATCH 20/22] Holeless Disk2D fix --- plugins/X3DReader/X3DReader.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index 8a5822d9ed..cb74188b6e 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -605,8 +605,7 @@ class X3DReader(MeshReader): angle = 2 * pi / n - if innerRadius: - self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) + self.reserveFaceAndVertexCount(n*4 if innerRadius else n-2, n*2 if innerRadius else n) for i in range(n): s = sin(angle * i) From a43efcde056e238d8eb9e723e5837808165d559f Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 22 Aug 2016 10:35:59 -0400 Subject: [PATCH 21/22] Code review issues --- plugins/CuraEngineBackend/StartSliceJob.py | 2 +- plugins/X3DReader/X3DReader.py | 218 ++++++++++----------- 2 files changed, 110 insertions(+), 110 deletions(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index abf1996e72..38bed421c0 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -150,7 +150,7 @@ class StartSliceJob(Job): obj.id = id(object) verts = mesh_data.getVertices() indices = mesh_data.getIndices() - if not indices is None: + if indices is not None: verts = numpy.array([verts[vert_index] for face in indices for vert_index in face]) else: verts = numpy.array(verts) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index cb74188b6e..b7df621c41 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -1,4 +1,5 @@ -# Seva Alekseyev with National Institutes of Health, 2016 +# Contributed by Seva Alekseyev with National Institutes of Health, 2016 +# Cura is released under the terms of the AGPLv3 or higher. from UM.Mesh.MeshReader import MeshReader from UM.Mesh.MeshBuilder import MeshBuilder @@ -6,7 +7,6 @@ from UM.Logger import Logger from UM.Math.Matrix import Matrix from UM.Math.Vector import Vector from UM.Scene.SceneNode import SceneNode -from UM.Scene.GroupDecorator import GroupDecorator from UM.Job import Job from math import pi, sin, cos, sqrt import numpy @@ -25,12 +25,12 @@ except ImportError: DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders class Shape: - def __init__(self, v, f, ib, n): - self.verts = v - self.faces = f + def __init__(self, verts, faces, index_base, name): + self.verts = verts + self.faces = faces # Those are here for debugging purposes only - self.index_base = ib - self.name = n + self.index_base = index_base + self.name = name class X3DReader(MeshReader): def __init__(self): @@ -72,26 +72,22 @@ class X3DReader(MeshReader): self.processChildNodes(xml_scene) if self.shapes: - bui = MeshBuilder() - bui.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) - bui.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) - bui.calculateNormals() - bui.setFileName(file_name) + builder = MeshBuilder() + builder.setVertices(numpy.concatenate([shape.verts for shape in self.shapes])) + builder.setIndices(numpy.concatenate([shape.faces for shape in self.shapes])) + builder.calculateNormals() + builder.setFileName(file_name) scene = SceneNode() - scene.setMeshData(bui.build().getTransformed(Matrix())) + scene.setMeshData(builder.build()) scene.setSelectable(True) scene.setName(file_name) + scene.getBoundingBox() else: return None - except Exception as e: - Logger.log("e", "exception occured in x3d reader: %s", e) - - try: - boundingBox = scene.getBoundingBox() - boundingBox.isValid() - except: + except Exception: + Logger.logException("e", "Exception in X3D reader") return None return scene @@ -119,11 +115,11 @@ class X3DReader(MeshReader): def processShape(self, xml_node): # Find the geometry and the appearance inside the Shape geometry = appearance = None - for subNode in xml_node: - if subNode.tag == "Appearance" and not appearance: - appearance = self.resolveDefUse(subNode) - elif subNode.tag in self.geometry_importers and not geometry: - geometry = self.resolveDefUse(subNode) + for sub_node in xml_node: + if sub_node.tag == "Appearance" and not appearance: + appearance = self.resolveDefUse(sub_node) + elif sub_node.tag in self.geometry_importers and not geometry: + geometry = self.resolveDefUse(sub_node) # TODO: appearance is completely ignored. At least apply the material color... if not geometry is None: @@ -131,12 +127,13 @@ class X3DReader(MeshReader): self.verts = self.faces = [] # Safeguard self.geometry_importers[geometry.tag](self, geometry) m = self.transform.getData() + # TODO: can this be done with one dot() call? verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) self.index_base += len(verts) - except Exception as e: - Logger.log("e", "exception occured in x3d reader while reading %s: %s", geometry.tag, e) + except Exception: + Logger.logException("e", "Exception in X3D reader while reading %s", geometry.tag) # Returns the referenced node if the node has USE, the same node otherwise. # May return None is USE points at a nonexistent node @@ -168,43 +165,43 @@ class X3DReader(MeshReader): trans = readVector(node, "translation", (0, 0, 0)) # Vector scale = readVector(node, "scale", (1, 1, 1)) # Vector center = readVector(node, "center", (0, 0, 0)) # Vector - scaleOrient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple + scale_orient = readRotation(node, "scaleOrientation", (0, 0, 1, 0)) # (angle, axisVactor) tuple # Store the previous transform; in Cura, the default matrix multiplication is in place prev = Matrix(self.transform.getData()) # It's deep copy, I've checked # The rest of transform manipulation will be applied in place - gotCenter = (center.x != 0 or center.y != 0 or center.z != 0) + got_center = (center.x != 0 or center.y != 0 or center.z != 0) T = self.transform if trans.x != 0 or trans.y != 0 or trans.z !=0: T.translate(trans) - if gotCenter: + if got_center: T.translate(center) if rot[0] != 0: T.rotateByAxis(*rot) if scale.x != 1 or scale.y != 1 or scale.z != 1: - gotScaleOrient = scaleOrient[0] != 0 - if gotScaleOrient: - T.rotateByAxis(*scaleOrient) + got_scale_orient = scale_orient[0] != 0 + if got_scale_orient: + T.rotateByAxis(*scale_orient) # No scale by vector in place operation in UM S = Matrix() S.setByScaleVector(scale) T.multiply(S) - if gotScaleOrient: - T.rotateByAxis(-scaleOrient[0], scaleOrient[1]) - if gotCenter: + if got_scale_orient: + T.rotateByAxis(-scale_orient[0], scale_orient[1]) + if got_center: T.translate(-center) self.processChildNodes(node) self.transform = prev # ------------------------- Geometry importers - # They are supposed to fill the MeshBuilder object with vertices and faces, the caller will do the rest + # They are supposed to fill the self.verts and self.faces arrays, the caller will do the rest # Primitives - def geomBox(self, node): + def processGeometryBox(self, node): (dx, dy, dz) = readFloatArray(node, "size", [2, 2, 2]) dx /= 2 dy /= 2 @@ -230,9 +227,9 @@ class X3DReader(MeshReader): self.addQuad(7, 6, 5, 4) # -y # The sphere is subdivided into nr rings and ns segments - def geomSphere(self, node): + def processGeometrySphere(self, node): r = readFloat(node, "radius", 0.5) - subdiv = readIntArray(node, 'subdivision', None) + subdiv = readIntArray(node, "subdivision", None) if subdiv: if len(subdiv) == 1: nr = ns = subdiv[0] @@ -282,12 +279,12 @@ class X3DReader(MeshReader): nseg = (seg + 1) % ns self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg) - def geomCone(self, node): + def processGeometryCone(self, node): r = readFloat(node, "bottomRadius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) side = readBoolean(node, "side", True) - n = readInt(node, 'subdivision', DEFAULT_SUBDIV) + n = readInt(node, "subdivision", DEFAULT_SUBDIV) d = height / 2 angle = 2 * pi / n @@ -307,7 +304,7 @@ class X3DReader(MeshReader): for i in range(2, n): self.addTri(1, i, i+1) - def geomCylinder(self, node): + def processGeometryCylinder(self, node): r = readFloat(node, "radius", 1) height = readFloat(node, "height", 2) bottom = readBoolean(node, "bottom", True) @@ -342,7 +339,7 @@ class X3DReader(MeshReader): # Semi-primitives - def geomElevationGrid(self, node): + def processGeometryElevationGrid(self, node): dx = readFloat(node, "xSpacing", 1) dz = readFloat(node, "zSpacing", 1) nx = readInt(node, "xDimension", 0) @@ -364,17 +361,30 @@ class X3DReader(MeshReader): self.addTriFlip((z - 1)*nx + x - 1, z*nx + x, (z - 1)*nx + x, ccw) self.addTriFlip((z - 1)*nx + x - 1, z*nx + x - 1, z*nx + x, ccw) - def geomExtrusion(self, node): + def processGeometryExtrusion(self, node): ccw = readBoolean(node, "ccw", True) - beginCap = readBoolean(node, "beginCap", True) - endCap = readBoolean(node, "endCap", True) + begin_cap = readBoolean(node, "beginCap", True) + end_cap = readBoolean(node, "endCap", True) cross = readFloatArray(node, "crossSection", (1, 1, 1, -1, -1, -1, -1, 1, 1, 1)) cross = [(cross[i], cross[i+1]) for i in range(0, len(cross), 2)] spine = readFloatArray(node, "spine", (0, 0, 0, 0, 1, 0)) spine = [(spine[i], spine[i+1], spine[i+2]) for i in range(0, len(spine), 3)] - orient = readFloatArray(node, 'orientation', None) + orient = readFloatArray(node, "orientation", None) if orient: - orient = [toNumpyRotation(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + # This converts X3D's axis/angle rotation to a 3x3 numpy matrix + def toRotationMatrix(rot): + (x, y, z) = rot[:3] + a = rot[3] + s = sin(a) + c = cos(a) + t = 1-c + return numpy.array(( + (x * x * t + c, x * y * t - z*s, x * z * t + y * s), + (x * y * t + z*s, y * y * t + c, y * z * t - x * s), + (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + + orient = [toRotationMatrix(orient[i:i+4]) if orient[i+3] != 0 else None for i in range(0, len(orient), 4)] + scale = readFloatArray(node, "scale", None) if scale: scale = [numpy.array(((scale[i], 0, 0), (0, 1, 0), (0, 0, scale[i+1]))) @@ -394,12 +404,12 @@ class X3DReader(MeshReader): # Face count along the cross; for closed cross, it's the same as the # respective vertex count - spineClosed = spine[0] == spine[-1] - if spineClosed: + spine_closed = spine[0] == spine[-1] + if spine_closed: spine = spine[:-1] ns = len(spine) spine = [Vector(*s) for s in spine] - nsf = ns if spineClosed else ns - 1 + nsf = ns if spine_closed else ns - 1 # This will be used for fallback, where the current spine point joins # two collinear spine segments. No need to recheck the case of the @@ -431,11 +441,11 @@ class X3DReader(MeshReader): orig_z = Vector(*m.dot(orig_z.getData())) return orig_z - self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if beginCap else 0) + (nc - 2 if endCap else 0), ns*nc) + self.reserveFaceAndVertexCount(2*nsf*ncf + (nc - 2 if begin_cap else 0) + (nc - 2 if end_cap else 0), ns*nc) z = None for i, spt in enumerate(spine): - if (i > 0 and i < ns - 1) or spineClosed: + if (i > 0 and i < ns - 1) or spine_closed: snext = spine[(i + 1) % ns] sprev = spine[(i - 1 + ns) % ns] y = snext - sprev @@ -486,7 +496,7 @@ class X3DReader(MeshReader): v = sptv3 + m.dot(cpt) self.addVertex(*v) - if beginCap: + if begin_cap: self.addFace([x for x in range(nc - 1, -1, -1)], ccw) # Order of edges in the face: forward along cross, forward along spine, @@ -499,26 +509,26 @@ class X3DReader(MeshReader): self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc, (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw) - if spineClosed: + if spine_closed: # The faces between the last and the first spine points b = (ns - 1) * nc for c in range(ncf): self.addQuadFlip(b + c, b + (c + 1) % nc, (c + 1) % nc, c, ccw) - if endCap: + if end_cap: self.addFace([(ns - 1) * nc + x for x in range(0, nc)], ccw) # Triangle meshes # Helper for numerous nodes with a Coordinate subnode holding vertices # That all triangle meshes and IndexedFaceSet - # num_faces can be a function, in case the face count is a function of coord + # num_faces can be a function, in case the face count is a function of vertex count def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) coord = self.readVertices(node) - if hasattr(num_faces, '__call__'): - num_faces = num_faces(coord) + if hasattr(num_faces, "__call__"): + num_faces = num_faces(len(coord)) self.reserveFaceAndVertexCount(num_faces, len(coord)) for pt in coord: self.addVertex(*pt) @@ -526,7 +536,7 @@ class X3DReader(MeshReader): return ccw - def geomIndexedTriangleSet(self, node): + def processGeometryIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) num_faces = len(index) // 3 ccw = self.startCoordMesh(node, num_faces) @@ -534,7 +544,7 @@ class X3DReader(MeshReader): for i in range(0, num_faces*3, 3): self.addTriFlip(index[i], index[i+1], index[i+2], ccw) - def geomIndexedTriangleStripSet(self, node): + def processGeometryIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) @@ -544,7 +554,7 @@ class X3DReader(MeshReader): self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) sccw = not sccw - def geomIndexedTriangleFanSet(self, node): + def processGeometryIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) @@ -552,12 +562,12 @@ class X3DReader(MeshReader): for i in range(1, len(fan) - 1): self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) - def geomTriangleSet(self, node): - ccw = self.startCoordMesh(node, lambda coord: len(coord) // 3) + def processGeometryTriangleSet(self, node): + ccw = self.startCoordMesh(node, lambda num_vert: num_vert // 3) for i in range(0, len(self.verts), 3): self.addTriFlip(i, i+1, i+2, ccw) - def geomTriangleStripSet(self, node): + def processGeometryTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) @@ -569,7 +579,7 @@ class X3DReader(MeshReader): sccw = not sccw vb += n - def geomTriangleFanSet(self, node): + def processGeometryTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) @@ -581,24 +591,24 @@ class X3DReader(MeshReader): # Quad geometries from the CAD module, might be relevant for printing - def geomQuadSet(self, node): - ccw = self.startCoordMesh(node, lambda coord: 2*(len(coord) // 4)) + def processGeometryQuadSet(self, node): + ccw = self.startCoordMesh(node, lambda num_vert: 2*(num_vert // 4)) for i in range(0, len(self.verts), 4): self.addQuadFlip(i, i+1, i+2, i+3, ccw) - def geomIndexedQuadSet(self, node): + def processGeometryIndexedQuadSet(self, node): index = readIntArray(node, "index", []) - nQuads = len(index) // 4 - ccw = self.startCoordMesh(node, nQuads*2) + num_quads = len(index) // 4 + ccw = self.startCoordMesh(node, num_quads*2) - for i in range(0, nQuads*4, 4): + for i in range(0, num_quads*4, 4): self.addQuadFlip(index[i], index[i+1], index[i+2], index[i+3], ccw) # 2D polygon geometries # Won't work for now, since Cura expects every mesh to have a nontrivial convex hull # The only way around that is merging meshes. - def geomDisk2D(self, node): + def processGeometryDisk2D(self, node): innerRadius = readFloat(node, "innerRadius", 0) outerRadius = readFloat(node, "outerRadius", 1) n = readInt(node, "subdivision", DEFAULT_SUBDIV) @@ -620,7 +630,7 @@ class X3DReader(MeshReader): for i in range(2, n): self.addTri(0, i-1, i) - def geomRectangle2D(self, node): + def processGeometryRectangle2D(self, node): (x, y) = readFloatArray(node, "size", (2, 2)) self.reserveFaceAndVertexCount(2, 4) self.addVertex(-x/2, -y/2, 0) @@ -629,7 +639,7 @@ class X3DReader(MeshReader): self.addVertex(-x/2, y/2, 0) self.addQuad(0, 1, 2, 3) - def geomTriangleSet2D(self, node): + def processGeometryTriangleSet2D(self, node): verts = readFloatArray(node, "vertices", ()) num_faces = len(verts) // 6; verts = [(verts[i], verts[i+1], 0) for i in range(0, 6 * num_faces, 2)] @@ -645,7 +655,7 @@ class X3DReader(MeshReader): # General purpose polygon mesh - def geomIndexedFaceSet(self, node): + def processGeometryIndexedFaceSet(self, node): faces = readIndex(node, "coordIndex") ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces])) @@ -656,24 +666,24 @@ class X3DReader(MeshReader): self.addFace(face, ccw) geometry_importers = { - 'IndexedFaceSet': geomIndexedFaceSet, - 'IndexedTriangleSet': geomIndexedTriangleSet, - 'IndexedTriangleStripSet': geomIndexedTriangleStripSet, - 'IndexedTriangleFanSet': geomIndexedTriangleFanSet, - 'TriangleSet': geomTriangleSet, - 'TriangleStripSet': geomTriangleStripSet, - 'TriangleFanSet': geomTriangleFanSet, - 'QuadSet': geomQuadSet, - 'IndexedQuadSet': geomIndexedQuadSet, - 'TriangleSet2D': geomTriangleSet2D, - 'Rectangle2D': geomRectangle2D, - 'Disk2D': geomDisk2D, - 'ElevationGrid': geomElevationGrid, - 'Extrusion': geomExtrusion, - 'Sphere': geomSphere, - 'Box': geomBox, - 'Cylinder': geomCylinder, - 'Cone': geomCone + "IndexedFaceSet": processGeometryIndexedFaceSet, + "IndexedTriangleSet": processGeometryIndexedTriangleSet, + "IndexedTriangleStripSet": processGeometryIndexedTriangleStripSet, + "IndexedTriangleFanSet": processGeometryIndexedTriangleFanSet, + "TriangleSet": processGeometryTriangleSet, + "TriangleStripSet": processGeometryTriangleStripSet, + "TriangleFanSet": processGeometryTriangleFanSet, + "QuadSet": processGeometryQuadSet, + "IndexedQuadSet": processGeometryIndexedQuadSet, + "TriangleSet2D": processGeometryTriangleSet2D, + "Rectangle2D": processGeometryRectangle2D, + "Disk2D": processGeometryDisk2D, + "ElevationGrid": processGeometryElevationGrid, + "Extrusion": processGeometryExtrusion, + "Sphere": processGeometrySphere, + "Box": processGeometryBox, + "Cylinder": processGeometryCylinder, + "Cone": processGeometryCone } # Parses the Coordinate.@point field @@ -869,10 +879,10 @@ def findOuterNormal(face): return edge.cross(prev_rejection) return False - -# Assumes the vectors are either parallel or antiparallel and the denominator is nonzero. + +# Given two *collinear* vectors a and b, returns the coefficient that takes b to a. # No error handling. -# For stability, taking the ration between the biggest coordinates would be better; none of that, either. +# For stability, taking the ration between the biggest coordinates would be better... def ratio(a, b): if b.x > EPSILON or b.x < -EPSILON: return a.x / b.x @@ -889,14 +899,4 @@ def pointInsideTriangle(vx, next, prev, nextXprev): vxXnext = vx.cross(next); s = -ratio(vxXnext, nextXprev) return s > 0 and (s + r) < 1 - -def toNumpyRotation(rot): - (x, y, z) = rot[:3] - a = rot[3] - s = sin(a) - c = cos(a) - t = 1-c - return numpy.array(( - (x * x * t + c, x * y * t - z*s, x * z * t + y * s), - (x * y * t + z*s, y * y * t + c, y * z * t - x * s), - (x * z * t - y * s, y * z * t + x * s, z * z * t + c))) + From 8ba9ff53e54ccabcd314687d3f1dcea1b310179b Mon Sep 17 00:00:00 2001 From: Seva Alekseyev Date: Mon, 22 Aug 2016 14:57:28 -0400 Subject: [PATCH 22/22] X3D loading performance: vertex organization for faster transforms --- plugins/X3DReader/X3DReader.py | 105 ++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/plugins/X3DReader/X3DReader.py b/plugins/X3DReader/X3DReader.py index b7df621c41..42efbe84ef 100644 --- a/plugins/X3DReader/X3DReader.py +++ b/plugins/X3DReader/X3DReader.py @@ -11,9 +11,6 @@ from UM.Job import Job from math import pi, sin, cos, sqrt import numpy - -EPSILON = 0.000001 # So very crude. :( - try: import xml.etree.cElementTree as ET except ImportError: @@ -23,8 +20,12 @@ except ImportError: # Use CADPart, for example, to distinguish between separate objects DEFAULT_SUBDIV = 16 # Default subdivision factor for spheres, cones, and cylinders +EPSILON = 0.000001 class Shape: + + # Expects verts in MeshBuilder-ready format, as a n by 3 mdarray + # with vertices stored in rows def __init__(self, verts, faces, index_base, name): self.verts = verts self.faces = faces @@ -62,7 +63,7 @@ class X3DReader(MeshReader): xml_scene = xml_root[0] if xml_scene.tag != "Scene": - return None + return None self.transform = Matrix() self.transform.setByScaleFactor(scale) @@ -127,8 +128,8 @@ class X3DReader(MeshReader): self.verts = self.faces = [] # Safeguard self.geometry_importers[geometry.tag](self, geometry) m = self.transform.getData() - # TODO: can this be done with one dot() call? - verts = numpy.array([m.dot(vert)[:3] for vert in self.verts]) + verts = m.dot(self.verts)[:3].transpose() + self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag)) self.index_base += len(verts) @@ -526,12 +527,10 @@ class X3DReader(MeshReader): # num_faces can be a function, in case the face count is a function of vertex count def startCoordMesh(self, node, num_faces): ccw = readBoolean(node, "ccw", True) - coord = self.readVertices(node) + self.readVertices(node) # This will allocate and fill the vertex array if hasattr(num_faces, "__call__"): - num_faces = num_faces(len(coord)) - self.reserveFaceAndVertexCount(num_faces, len(coord)) - for pt in coord: - self.addVertex(*pt) + num_faces = num_faces(self.getVertexCount()) + self.reserveFaceCount(num_faces) return ccw @@ -539,61 +538,61 @@ class X3DReader(MeshReader): def processGeometryIndexedTriangleSet(self, node): index = readIntArray(node, "index", []) num_faces = len(index) // 3 - ccw = self.startCoordMesh(node, num_faces) + ccw = int(self.startCoordMesh(node, num_faces)) for i in range(0, num_faces*3, 3): - self.addTriFlip(index[i], index[i+1], index[i+2], ccw) + self.addTri(index[i + 1 - ccw], index[i + ccw], index[i+2]) def processGeometryIndexedTriangleStripSet(self, node): strips = readIndex(node, "index") - ccw = self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips])) + ccw = int(self.startCoordMesh(node, sum([len(strip) - 2 for strip in strips]))) for strip in strips: sccw = ccw # Running CCW value, reset for each strip for i in range(len(strip) - 2): - self.addTriFlip(strip[i], strip[i+1], strip[i+2], sccw) - sccw = not sccw + self.addTri(strip[i + 1 - sccw], strip[i + sccw], strip[i+2]) + sccw = 1 - sccw def processGeometryIndexedTriangleFanSet(self, node): fans = readIndex(node, "index") - ccw = self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans])) + ccw = int(self.startCoordMesh(node, sum([len(fan) - 2 for fan in fans]))) for fan in fans: for i in range(1, len(fan) - 1): - self.addTriFlip(fan[0], fan[i], fan[i+1], ccw) + self.addTri(fan[0], fan[i + 1 - ccw], fan[i + ccw]) def processGeometryTriangleSet(self, node): - ccw = self.startCoordMesh(node, lambda num_vert: num_vert // 3) - for i in range(0, len(self.verts), 3): - self.addTriFlip(i, i+1, i+2, ccw) + ccw = int(self.startCoordMesh(node, lambda num_vert: num_vert // 3)) + for i in range(0, self.getVertexCount(), 3): + self.addTri(i + 1 - ccw, i + ccw, i+2) def processGeometryTriangleStripSet(self, node): strips = readIntArray(node, "stripCount", []) - ccw = self.startCoordMesh(node, sum([n-2 for n in strips])) + ccw = int(self.startCoordMesh(node, sum([n-2 for n in strips]))) vb = 0 for n in strips: sccw = ccw for i in range(n-2): - self.addTriFlip(vb+i, vb+i+1, vb+i+2, sccw) - sccw = not sccw + self.addTri(vb + i + 1 - sccw, vb + i + sccw, vb + i + 2) + sccw = 1 - sccw vb += n def processGeometryTriangleFanSet(self, node): fans = readIntArray(node, "fanCount", []) - ccw = self.startCoordMesh(node, sum([n-2 for n in fans])) + ccw = int(self.startCoordMesh(node, sum([n-2 for n in fans]))) vb = 0 for n in fans: for i in range(1, n-1): - self.addTriFlip(vb, vb+i, vb+i+1, ccw) + self.addTri(vb, vb + i + 1 - ccw, vb + i + ccw) vb += n # Quad geometries from the CAD module, might be relevant for printing def processGeometryQuadSet(self, node): ccw = self.startCoordMesh(node, lambda num_vert: 2*(num_vert // 4)) - for i in range(0, len(self.verts), 4): + for i in range(0, self.getVertexCount(), 4): self.addQuadFlip(i, i+1, i+2, i+3, ccw) def processGeometryIndexedQuadSet(self, node): @@ -686,7 +685,7 @@ class X3DReader(MeshReader): "Cone": processGeometryCone } - # Parses the Coordinate.@point field + # Parses the Coordinate.@point field, fills the verts array. def readVertices(self, node): for c in node: if c.tag == "Coordinate": @@ -695,23 +694,33 @@ class X3DReader(MeshReader): pt = c.attrib.get("point") if pt: co = [float(x) for x in pt.split()] + num_verts = len(co) // 3 + self.verts = numpy.empty((4, num_verts), dtype=numpy.float32) + self.verts[3,:] = numpy.ones((num_verts), dtype=numpy.float32) # Group by three - return [(co[i], co[i+1], co[i+2]) for i in range(0, (len(co) // 3)*3, 3)] - return [] + for i in range(num_verts): + self.verts[:3,i] = co[3*i:3*i+3] # Mesh builder helpers def reserveFaceAndVertexCount(self, num_faces, num_verts): - # Unlike the Cura MeshBuilder, we use 4-vectors here for easier transform - self.verts = numpy.array([(0,0,0,1) for i in range(num_verts)], dtype=numpy.float32) + # Unlike the Cura MeshBuilder, we use 4-vectors stored as columns for easier transform + self.verts = numpy.zeros((4, num_verts), dtype=numpy.float32) + self.verts[3,:] = numpy.ones((num_verts), dtype=numpy.float32) + self.num_verts = 0 + self.reserveFaceCount(num_faces) + + def reserveFaceCount(self, num_faces): self.faces = numpy.zeros((num_faces, 3), dtype=numpy.int32) self.num_faces = 0 - self.num_verts = 0 + + def getVertexCount(self): + return self.verts.shape[1] def addVertex(self, x, y, z): - self.verts[self.num_verts, 0] = x - self.verts[self.num_verts, 1] = y - self.verts[self.num_verts, 2] = z + self.verts[0, self.num_verts] = x + self.verts[1, self.num_verts] = y + self.verts[2, self.num_verts] = z self.num_verts += 1 # Indices are 0-based for this shape, but they won't be zero-based in the merged mesh @@ -751,9 +760,7 @@ class X3DReader(MeshReader): # Vertex coordinates are supposed to be already set def addFace(self, indices, ccw): # Resolve indices to coordinates for faster math - n = len(indices) - verts = self.verts - face = [Vector(verts[i, 0], verts[i, 1], verts[i, 2]) for i in indices] + face = [Vector(data=self.verts[0:3, i]) for i in indices] # Need a normal to the plane so that we can know which vertices form inner angles normal = findOuterNormal(face) @@ -762,14 +769,14 @@ class X3DReader(MeshReader): return # Find the vertex with the smallest inner angle and no points inside, cut off. Repeat until done - m = len(face) - vi = [i for i in range(m)] # We'll be using this to kick vertices from the face - while m > 3: + n = len(face) + vi = [i for i in range(n)] # We'll be using this to kick vertices from the face + while n > 3: max_cos = EPSILON # We don't want to check anything on Pi angles i_min = 0 # max cos corresponds to min angle - for i in range(m): - inext = (i + 1) % m - iprev = (i + m - 1) % m + for i in range(n): + inext = (i + 1) % n + iprev = (i + n - 1) % n v = face[vi[i]] next = face[vi[inext]] - v prev = face[vi[iprev]] - v @@ -779,7 +786,7 @@ class X3DReader(MeshReader): if cos > max_cos: # Check if there are vertices inside the triangle no_points_inside = True - for j in range(m): + for j in range(n): if j != i and j != iprev and j != inext: vx = face[vi[j]] - v if pointInsideTriangle(vx, next, prev, nextXprev): @@ -790,9 +797,9 @@ class X3DReader(MeshReader): max_cos = cos i_min = i - self.addTriFlip(indices[vi[(i_min + m - 1) % m]], indices[vi[i_min]], indices[vi[(i_min + 1) % m]], ccw) + self.addTriFlip(indices[vi[(i_min + n - 1) % n]], indices[vi[i_min]], indices[vi[(i_min + 1) % n]], ccw) vi.pop(i_min) - m -= 1 + n -= 1 self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw) @@ -895,7 +902,7 @@ def pointInsideTriangle(vx, next, prev, nextXprev): vxXprev = vx.cross(prev) r = ratio(vxXprev, nextXprev) if r < 0: - return False; + return False vxXnext = vx.cross(next); s = -ratio(vxXnext, nextXprev) return s > 0 and (s + r) < 1