예제 #1
0
 def test_preMultiply(self):
     temp_matrix = Matrix()
     temp_matrix.setByTranslation(Vector(10,10,10))
     temp_matrix2 = Matrix()
     temp_matrix2.setByScaleFactor(0.5)
     temp_matrix.preMultiply(temp_matrix2)
     numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,5],[0,0.5,0,5],[0,0,0.5,5],[0,0,0,1]]))
예제 #2
0
 def test_preMultiply(self):
     temp_matrix = Matrix()
     temp_matrix.setByTranslation(Vector(10,10,10))
     temp_matrix2 = Matrix()
     temp_matrix2.setByScaleFactor(0.5)
     temp_matrix.preMultiply(temp_matrix2)
     numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,5],[0,0.5,0,5],[0,0,0.5,5],[0,0,0,1]]))
예제 #3
0
    def run(self):
        loading_message = Message(i18n_catalog.i18nc("Loading mesh message, {0} is file name", "Loading {0}").format(self._filename), lifetime = 0, dismissable = False)
        loading_message.setProgress(-1)
        loading_message.show()

        mesh = self._handler.read(self._filename, self._device)

        # Scale down to maximum bounds size if that is available
        if hasattr(Application.getInstance().getController().getScene(), "_maximum_bounds"):
            max_bounds = Application.getInstance().getController().getScene()._maximum_bounds
            bbox = mesh.getExtents()

            if max_bounds.width < bbox.width or max_bounds.height < bbox.height or max_bounds.depth < bbox.depth:
                largest_dimension = max(bbox.width, bbox.height, bbox.depth)

                scale_factor = 1.0
                if largest_dimension == bbox.width:
                    scale_factor = max_bounds.width / bbox.width
                elif largest_dimension == bbox.height:
                    scale_factor = max_bounds.height / bbox.height
                else:
                    scale_factor = max_bounds.depth / bbox.depth

                matrix = Matrix()
                matrix.setByScaleFactor(scale_factor)
                mesh = mesh.getTransformed(matrix)

        self.setResult(mesh)

        loading_message.hide()
        result_message = Message(i18n_catalog.i18nc("Finished loading mesh message, {0} is file name", "Loaded {0}").format(self._filename))
        result_message.show()
예제 #4
0
    def test_setByScaleFactor(self):
        matrix = Matrix()
        matrix.setByScaleFactor(0.5)
        numpy.testing.assert_array_almost_equal(
            matrix.getData(),
            numpy.array([[0.5, 0, 0, 0], [0, 0.5, 0, 0], [0, 0, 0.5, 0],
                         [0, 0, 0, 1]]))

        assert matrix.getScale() == Vector(0.5, 0.5, 0.5)
예제 #5
0
class TestMatrix(unittest.TestCase):
    def setUp(self):
        self._matrix = Matrix()
        # Called before the first testfunction is executed
        pass

    def tearDown(self):
        # Called after the last testfunction was executed
        pass

    def test_setByQuaternion(self):
        pass
    
    def test_multiply(self):
        temp_matrix = Matrix()
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix2 = Matrix()
        temp_matrix2.setByScaleFactor(0.5)
        temp_matrix.multiply(temp_matrix2)
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,10],[0,0.5,0,10],[0,0,0.5,10],[0,0,0,1]]))
    
    def test_preMultiply(self):
        temp_matrix = Matrix()
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix2 = Matrix()
        temp_matrix2.setByScaleFactor(0.5)
        temp_matrix.preMultiply(temp_matrix2)
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,5],[0,0.5,0,5],[0,0,0.5,5],[0,0,0,1]]))

    def test_setByScaleFactor(self):
        self._matrix.setByScaleFactor(0.5)
        numpy.testing.assert_array_almost_equal(self._matrix.getData(), numpy.array([[0.5,0,0,0],[0,0.5,0,0],[0,0,0.5,0],[0,0,0,1]]))

    def test_setByRotation(self):
        pass

    def test_setByTranslation(self):
        self._matrix.setByTranslation(Vector(0,1,0))
        numpy.testing.assert_array_almost_equal(self._matrix.getData(), numpy.array([[1,0,0,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]]))

    def test_setToIdentity(self):
        pass

    def test_getData(self):
        pass

    def test_transposed(self):
        temp_matrix = Matrix()  
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix = temp_matrix.getTransposed()
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[10,10,10,1]]))

    def test_dot(self):
        pass
예제 #6
0
 def test_preMultiplyCopy(self):
     temp_matrix = Matrix()
     temp_matrix.setByTranslation(Vector(10, 10, 10))
     temp_matrix2 = Matrix()
     temp_matrix2.setByScaleFactor(0.5)
     result = temp_matrix.preMultiply(temp_matrix2, copy=True)
     assert result != temp_matrix
     numpy.testing.assert_array_almost_equal(
         result.getData(),
         numpy.array([[0.5, 0, 0, 5], [0, 0.5, 0, 5], [0, 0, 0.5, 5],
                      [0, 0, 0, 1]]))
예제 #7
0
class TestMatrix(unittest.TestCase):
    def setUp(self):
        self._matrix = Matrix()
        # Called before the first testfunction is executed
        pass

    def tearDown(self):
        # Called after the last testfunction was executed
        pass

    def test_setByQuaternion(self):
        pass
    
    def test_multiply(self):
        temp_matrix = Matrix()
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix2 = Matrix()
        temp_matrix2.setByScaleFactor(0.5)
        temp_matrix.multiply(temp_matrix2)
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,10],[0,0.5,0,10],[0,0,0.5,10],[0,0,0,1]]))
    
    def test_preMultiply(self):
        temp_matrix = Matrix()
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix2 = Matrix()
        temp_matrix2.setByScaleFactor(0.5)
        temp_matrix.preMultiply(temp_matrix2)
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[0.5,0,0,5],[0,0.5,0,5],[0,0,0.5,5],[0,0,0,1]]))

    def test_setByScaleFactor(self):
        self._matrix.setByScaleFactor(0.5)
        numpy.testing.assert_array_almost_equal(self._matrix.getData(), numpy.array([[0.5,0,0,0],[0,0.5,0,0],[0,0,0.5,0],[0,0,0,1]]))

    def test_setByRotation(self):
        pass

    def test_setByTranslation(self):
        self._matrix.setByTranslation(Vector(0,1,0))
        numpy.testing.assert_array_almost_equal(self._matrix.getData(), numpy.array([[1,0,0,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]]))

    def test_setToIdentity(self):
        pass

    def test_getData(self):
        pass

    def test_transposed(self):
        temp_matrix = Matrix()  
        temp_matrix.setByTranslation(Vector(10,10,10))
        temp_matrix = temp_matrix.getTransposed()
        numpy.testing.assert_array_almost_equal(temp_matrix.getData(), numpy.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[10,10,10,1]]))

    def test_dot(self):
        pass
예제 #8
0
 def setUp(self):
     # Called before the first testfunction is executed
     self._scene = Scene()
     self._scene_object = SceneNode()
     self._scene_object2 = SceneNode()
     self._scene_object.addChild(self._scene_object2)
     self._scene.getRoot().addChild(self._scene_object)
     temp_matrix = Matrix()
     temp_matrix.setByTranslation(Vector(10,10,10))
     self._scene_object2.setLocalTransformation(deepcopy(temp_matrix))
     temp_matrix.setByScaleFactor(0.5)
     self._scene_object.setLocalTransformation(temp_matrix)
예제 #9
0
 def setUp(self):
     # Called before the first testfunction is executed
     self._scene = Scene()
     self._scene_object = SceneNode()
     self._scene_object2 = SceneNode()
     self._scene_object.addChild(self._scene_object2)
     self._scene.getRoot().addChild(self._scene_object)
     temp_matrix = Matrix()
     temp_matrix.setByTranslation(Vector(10, 10, 10))
     self._scene_object2.setLocalTransformation(deepcopy(temp_matrix))
     temp_matrix.setByScaleFactor(0.5)
     self._scene_object.setLocalTransformation(temp_matrix)
예제 #10
0
class X3DReader(MeshReader):
    def __init__(self) -> None:
        super().__init__()
        self._supported_extensions = [".x3d"]
        self._namespaces = {}

    # Main entry point
    # Reads the file, returns a SceneNode (possibly with nested ones), or None
    def _read(self, file_name):
        try:
            self.defs = {}
            self.shapes = []

            tree = ET.parse(file_name)
            xml_root = tree.getroot()

            if xml_root.tag != "X3D":
                return None

            scale = 1000  # Default X3D unit it one meter, while Cura's is one millimeters
            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
                xml_scene = xml_root[1]
            else:
                xml_scene = xml_root[0]

            if xml_scene.tag != "Scene":
                return None

            self.transform = Matrix()
            self.transform.setByScaleFactor(scale)
            self.index_base = 0

            # Traverse the scene tree, populate the shapes list
            self.processChildNodes(xml_scene)

            if self.shapes:
                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)
                mesh_data = builder.build()

                # Manually try and get the extents of the mesh_data. This should prevent nasty NaN issues from
                # leaving the reader.
                mesh_data.getExtents()

                node = SceneNode()
                node.setMeshData(mesh_data)
                node.setSelectable(True)
                node.setName(file_name)

            else:
                return None

        except Exception:
            Logger.logException("e", "Exception in X3D reader")
            return None

        return node

    # ------------------------- XML tree traversal

    def processNode(self, xml_node):
        xml_node = self.resolveDefUse(xml_node)
        if xml_node is None:
            return

        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(xml_node[0])
        elif tag == "Transform":
            self.processTransform(xml_node)
        elif tag == "Shape":
            self.processShape(xml_node)

    def processShape(self, xml_node):
        # Find the geometry and the appearance inside the Shape
        geometry = appearance = None
        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:
            try:
                self.verts = self.faces = []  # Safeguard
                self.geometry_importers[geometry.tag](self, geometry)
                m = self.transform.getData()
                verts = m.dot(self.verts)[:3].transpose()

                self.shapes.append(
                    Shape(verts, self.faces, self.index_base, geometry.tag))
                self.index_base += len(verts)

            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
    # In X3DOM, when both DEF and USE are in the same node, DEF is ignored.
    # 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:
            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
        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
        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 got_center:
            T.translate(center)
        if rot[0] != 0:
            T.rotateByAxis(*rot)
        if scale.x != 1 or scale.y != 1 or scale.z != 1:
            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 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 self.verts and self.faces arrays, the caller will do the rest

    # Primitives

    def processGeometryBox(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 processGeometrySphere(self, node):
        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)

        self.reserveFaceAndVertexCount(ns * (nr * 2 - 2), 2 + (nr - 1) * ns)

        # +y and -y poles
        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):
                self.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):
            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
        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
                self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg)

    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)

        d = height / 2
        angle = 2 * pi / n

        self.reserveFaceAndVertexCount(
            (n if side else 0) + (n - 2 if bottom else 0), n + 1)

        # Vertex 0 is the apex, vertices 1..n are the bottom
        self.addVertex(0, d, 0)
        for i in range(n):
            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):
                self.addTri(1 + (i + 1) % n, 0, 1 + i)
        if bottom:
            for i in range(2, n):
                self.addTri(1, i, i + 1)

    def processGeometryCylinder(self, node):
        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

        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)
            self.addVertex(rs, hh, rc)
            self.addVertex(rs, -hh, rc)

        if side:
            for i in range(n):
                ni = (i + 1) % n
                self.addQuad(ni * 2 + 1, ni * 2, i * 2, i * 2 + 1)

        for i in range(2, nn - 3, 2):
            if top:
                self.addTri(0, i, i + 2)
            if bottom:
                self.addTri(1, i + 1, i + 3)

    # Semi-primitives

    def processGeometryElevationGrid(self, node):
        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

        self.reserveFaceAndVertexCount(2 * (nx - 1) * (nz - 1), nx * nz)

        for z in range(nz):
            for x in range(nx):
                self.addVertex(x * dx, height[z * nx + x], z * dz)

        for z in range(1, nz):
            for x in range(1, nx):
                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 processGeometryExtrusion(self, node):
        ccw = readBoolean(node, "ccw", 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)
        if orient:
            # 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])))
                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

        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 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
        # 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
            if len(spine) < 2:
                return Vector(0, 0, 1)
            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

        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 spine_closed:
                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.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
                    )  # Tested against X3DOM, the result matches, still not sure :(

            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

            sptv3 = numpy.array(spt.getData()[:3])
            for cpt in cross:
                v = sptv3 + m.dot(cpt)
                self.addVertex(*v)

        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,
        # 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):
                self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc,
                                 (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c,
                                 ccw)

        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 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 vertex count

    def startCoordMesh(self, node, num_faces):
        ccw = readBoolean(node, "ccw", True)
        self.readVertices(node)  # This will allocate and fill the vertex array
        if hasattr(num_faces, "__call__"):
            num_faces = num_faces(self.getVertexCount())
        self.reserveFaceCount(num_faces)

        return ccw

    def processGeometryIndexedTriangleSet(self, node):
        index = readIntArray(node, "index", [])
        num_faces = len(index) // 3
        ccw = int(self.startCoordMesh(node, num_faces))

        for i in range(0, num_faces * 3, 3):
            self.addTri(index[i + 1 - ccw], index[i + ccw], index[i + 2])

    def processGeometryIndexedTriangleStripSet(self, node):
        strips = readIndex(node, "index")
        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.addTri(strip[i + 1 - sccw], strip[i + sccw], strip[i + 2])
                sccw = 1 - sccw

    def processGeometryIndexedTriangleFanSet(self, node):
        fans = readIndex(node, "index")
        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.addTri(fan[0], fan[i + 1 - ccw], fan[i + ccw])

    def processGeometryTriangleSet(self, node):
        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 = 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.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 = 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.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, self.getVertexCount(), 4):
            self.addQuadFlip(i, i + 1, i + 2, i + 3, ccw)

    def processGeometryIndexedQuadSet(self, node):
        index = readIntArray(node, "index", [])
        num_quads = len(index) // 4
        ccw = self.startCoordMesh(node, num_quads * 2)

        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 processGeometryDisk2D(self, node):
        innerRadius = readFloat(node, "innerRadius", 0)
        outerRadius = readFloat(node, "outerRadius", 1)
        n = readInt(node, "subdivision", DEFAULT_SUBDIV)

        angle = 2 * pi / 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)
            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 processGeometryRectangle2D(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 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)]
        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 processGeometryIndexedFaceSet(self, node):
        faces = readIndex(node, "coordIndex")
        ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces]))

        for face in faces:
            if len(face) == 3:
                self.addTriFlip(face[0], face[1], face[2], ccw)
            elif len(face) > 3:
                self.addFace(face, ccw)

    geometry_importers = {
        "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, fills the verts array.
    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:
                        # allow the list of float values in 'point' attribute to
                        # be separated by commas or whitespace as per spec of
                        # XML encoding of X3D
                        # Ref  ISO/IEC 19776-1:2015 : Section 5.1.2
                        co = [
                            float(x) for vec in pt.split(',')
                            for x in vec.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
                        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 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

    def getVertexCount(self):
        return self.verts.shape[1]

    def addVertex(self, x, y, 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
    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
        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)

        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
        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(n):
                inext = (i + 1) % n
                iprev = (i + n - 1) % n
                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(n):
                            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 + n - 1) % n]],
                            indices[vi[i_min]], indices[vi[(i_min + 1) % n]],
                            ccw)
            vi.pop(i_min)
            n -= 1
        self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw)
예제 #11
0
파일: X3DReader.py 프로젝트: cederom/Cura
class X3DReader(MeshReader):
    def __init__(self):
        super().__init__()
        self._supported_extensions = [".x3d"]
        self._namespaces = {}
    
    # Main entry point
    # Reads the file, returns a SceneNode (possibly with nested ones), or None
    def read(self, file_name):
        try:
            self.defs = {}
            self.shapes = []
            
            tree = ET.parse(file_name)
            xml_root = tree.getroot()
            
            if xml_root.tag != "X3D":
                return None

            scale = 1000 # Default X3D unit it one meter, while Cura's is one millimeters            
            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 
                xml_scene = xml_root[1]
            else:
                xml_scene = xml_root[0]
                
            if xml_scene.tag != "Scene":
                return None
            
            self.transform = Matrix()
            self.transform.setByScaleFactor(scale)
            self.index_base = 0
            
            # Traverse the scene tree, populate the shapes list
            self.processChildNodes(xml_scene)
            
            if self.shapes:
                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)
                mesh_data = builder.build()

                # Manually try and get the extents of the mesh_data. This should prevent nasty NaN issues from
                # leaving the reader.
                mesh_data.getExtents()

                node = SceneNode()
                node.setMeshData(mesh_data)
                node.setSelectable(True)
                node.setName(file_name)

            else:
                return None
            
        except Exception:
            Logger.logException("e", "Exception in X3D reader")
            return None

        return node
    
    # ------------------------- XML tree traversal
  
    def processNode(self, xml_node):
        xml_node =  self.resolveDefUse(xml_node)
        if xml_node is None:
            return
        
        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(xml_node[0])
        elif tag == "Transform":
            self.processTransform(xml_node)
        elif tag == "Shape":
            self.processShape(xml_node)
            
            
    def processShape(self, xml_node):
        # Find the geometry and the appearance inside the Shape
        geometry = appearance = None
        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:
            try:
                self.verts = self.faces = [] # Safeguard 
                self.geometry_importers[geometry.tag](self, geometry)
                m = self.transform.getData()
                verts = m.dot(self.verts)[:3].transpose()
                
                self.shapes.append(Shape(verts, self.faces, self.index_base, geometry.tag))
                self.index_base += len(verts)
                
            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
    # In X3DOM, when both DEF and USE are in the same node, DEF is ignored.
    # 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:
            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
        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
        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 got_center:
            T.translate(center)
        if rot[0] != 0:
            T.rotateByAxis(*rot)
        if scale.x != 1 or scale.y != 1 or scale.z != 1:
            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 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 self.verts and self.faces arrays, the caller will do the rest
    
    # Primitives

    def processGeometryBox(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 processGeometrySphere(self, node):
        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)
        
        self.reserveFaceAndVertexCount(ns*(nr*2 - 2), 2 + (nr - 1)*ns)
        
        # +y and -y poles
        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):
                self.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):
            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
        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
                self.addQuad(tvb + seg, bvb + seg, bvb + nseg, tvb + nseg)
        
    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)
        
        d = height / 2
        angle = 2 * pi / n
    
        self.reserveFaceAndVertexCount((n if side else 0) + (n-2 if bottom else 0), n+1)
    
        # Vertex 0 is the apex, vertices 1..n are the bottom
        self.addVertex(0, d, 0)
        for i in range(n):
            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):
                self.addTri(1 + (i + 1) % n, 0, 1 + i)
        if bottom:
            for i in range(2, n):
                self.addTri(1, i, i+1)
    
    def processGeometryCylinder(self, node):
        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
        
        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)
            self.addVertex(rs, hh, rc)
            self.addVertex(rs, -hh, rc)
        
        if side:
            for i in range(n):
                ni = (i + 1) % n
                self.addQuad(ni * 2 + 1, ni * 2, i * 2, i * 2 + 1)
            
        for i in range(2, nn-3, 2):
            if top:
                self.addTri(0, i, i+2)
            if bottom:
                self.addTri(1, i+1, i+3)
    
    # Semi-primitives

    def processGeometryElevationGrid(self, node):
        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
        
        self.reserveFaceAndVertexCount(2*(nx-1)*(nz-1), nx*nz)
        
        for z in range(nz):
            for x in range(nx):
                self.addVertex(x * dx, height[z*nx + x], z * dz)
                
        for z in range(1, nz):
            for x in range(1, nx):
                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 processGeometryExtrusion(self, node):
        ccw = readBoolean(node, "ccw", 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)
        if orient:
            # 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])))
                     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
    
        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 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
        # 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
            if len(spine) < 2:
                return Vector(0, 0, 1)
            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
    
        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 spine_closed:
                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.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)  # Tested against X3DOM, the result matches, still not sure :(
                    
            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
                    
            sptv3 = numpy.array(spt.getData()[:3])
            for cpt in cross:
                v = sptv3 + m.dot(cpt)
                self.addVertex(*v)
    
        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,
        # 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):
                self.addQuadFlip(s * nc + c, s * nc + (c + 1) % nc,
                    (s + 1) * nc + (c + 1) % nc, (s + 1) * nc + c, ccw)
    
        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 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 vertex count 
    def startCoordMesh(self, node, num_faces):
        ccw = readBoolean(node, "ccw", True)
        self.readVertices(node) # This will allocate and fill the vertex array
        if hasattr(num_faces, "__call__"):
            num_faces = num_faces(self.getVertexCount())
        self.reserveFaceCount(num_faces)
            
        return ccw
        

    def processGeometryIndexedTriangleSet(self, node):
        index = readIntArray(node, "index", [])
        num_faces = len(index) // 3
        ccw = int(self.startCoordMesh(node, num_faces))
        
        for i in range(0, num_faces*3, 3):
            self.addTri(index[i + 1 - ccw], index[i + ccw], index[i+2])
    
    def processGeometryIndexedTriangleStripSet(self, node):
        strips = readIndex(node, "index")
        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.addTri(strip[i + 1 - sccw], strip[i + sccw], strip[i+2])
                sccw = 1 - sccw
    
    def processGeometryIndexedTriangleFanSet(self, node):
        fans = readIndex(node, "index")
        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.addTri(fan[0], fan[i + 1 - ccw], fan[i + ccw])
   
    def processGeometryTriangleSet(self, node):
        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 = 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.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 = 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.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, self.getVertexCount(), 4):
            self.addQuadFlip(i, i+1, i+2, i+3, ccw)
            
    def processGeometryIndexedQuadSet(self, node):
        index = readIntArray(node, "index", [])
        num_quads = len(index) // 4
        ccw = self.startCoordMesh(node, num_quads*2)
        
        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 processGeometryDisk2D(self, node):
        innerRadius = readFloat(node, "innerRadius", 0)
        outerRadius = readFloat(node, "outerRadius", 1)
        n = readInt(node, "subdivision", DEFAULT_SUBDIV)
        
        angle = 2 * pi / 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)
            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 processGeometryRectangle2D(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 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)]
        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 processGeometryIndexedFaceSet(self, node):
        faces = readIndex(node, "coordIndex")
        ccw = self.startCoordMesh(node, sum([len(face) - 2 for face in faces]))
            
        for face in faces:
            if len(face) == 3:
                self.addTriFlip(face[0], face[1], face[2], ccw)
            elif len(face) > 3:
                self.addFace(face, ccw)
                
    geometry_importers = {
        "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, fills the verts array.
    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()]
                        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
                        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 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
        
    def getVertexCount(self):
        return self.verts.shape[1]
        
    def addVertex(self, x, y, 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
    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
        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)
            
        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
        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(n):
                inext = (i + 1) % n
                iprev = (i + n - 1) % n
                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(n):
                            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 + n - 1) % n]], indices[vi[i_min]], indices[vi[(i_min + 1) % n]], ccw)
            vi.pop(i_min)
            n -= 1
        self.addTriFlip(indices[vi[0]], indices[vi[1]], indices[vi[2]], ccw)
예제 #12
0
    def test_setByScaleFactor(self):
        matrix = Matrix()
        matrix.setByScaleFactor(0.5)
        numpy.testing.assert_array_almost_equal(matrix.getData(), numpy.array([[0.5,0,0,0],[0,0.5,0,0],[0,0,0.5,0],[0,0,0,1]]))

        assert matrix.getScale() == Vector(0.5, 0.5, 0.5)
예제 #13
0
    def setNode(self, node):
        super().setNode(node)

        aabb = node.getBoundingBox()
        if not aabb.isValid():
            return

        if not self._body:
            self._body = ode.Body(self._world)
            self._body.setMaxAngularSpeed(0)
            mass = ode.Mass()
            mass.setBox(5.0, Helpers.toODE(aabb.width),
                        Helpers.toODE(aabb.height), Helpers.toODE(aabb.depth))
            self._body.setMass(mass)

        if not self._geom:
            if node.getMeshData():
                scale_matrix = Matrix()
                scale_matrix.setByScaleFactor(1.01)
                mesh = node.getMeshData().getTransformed(scale_matrix)

                self._trimesh = ode.TriMeshData()

                debug_builder = MeshBuilder()

                vertices = mesh.getVertices()
                indices = mesh.getConvexHull().simplices

                _fixWindingOrder(vertices, indices, debug_builder)

                self._trimesh.build(vertices / Helpers.ScaleFactor, indices)

                self._geom = ode.GeomTriMesh(self._trimesh, self._space)

                mb = MeshBuilder()

                for i in range(self._geom.getTriangleCount()):
                    tri = self._geom.getTriangle(i)

                    v0 = Helpers.fromODE(tri[0])
                    v1 = Helpers.fromODE(tri[1])
                    v2 = Helpers.fromODE(tri[2])

                    mb.addFace(v0=v0,
                               v1=v1,
                               v2=v2,
                               color=Color(1.0, 0.0, 0.0, 0.5))

                chn = SceneNode(node)
                chn.setMeshData(mb.build())

                def _renderConvexHull(renderer):
                    renderer.queueNode(chn, transparent=True)
                    return True

                chn.render = _renderConvexHull

                n = SceneNode(node)
                n.setMeshData(debug_builder.build())

                def _renderNormals(renderer):
                    renderer.queueNode(n, mode=1, overlay=True)
                    return True

                n.render = _renderNormals
            else:
                self._geom = ode.GeomBox(self._space,
                                         lengths=(Helpers.toODE(aabb.width),
                                                  Helpers.toODE(aabb.height),
                                                  Helpers.toODE(aabb.depth)))

            self._geom.setBody(self._body)

        self._body.setPosition(Helpers.toODE(node.getWorldPosition()))

        node.transformationChanged.connect(self._onTransformationChanged)
예제 #14
0
    def checkQueuedNodes(self) -> None:
        global_container_stack = self._application.getGlobalContainerStack()
        if global_container_stack:
            disallowed_edge = self._application.getBuildVolume().getEdgeDisallowedSize() + 2  # Allow for some rounding errors
            max_x_coordinate = (global_container_stack.getProperty("machine_width", "value") / 2) - disallowed_edge
            max_y_coordinate = (global_container_stack.getProperty("machine_depth", "value") / 2) - disallowed_edge

        for node in self._node_queue:
            mesh_data = node.getMeshData()
            if not mesh_data:
                continue
            file_name = mesh_data.getFileName()

            if self._preferences.getValue("meshtools/randomise_location_on_load") and global_container_stack != None:
                if file_name and os.path.splitext(file_name)[1].lower() == ".3mf": # don't randomise project files
                    continue

                node_bounds = node.getBoundingBox()
                position = self._randomLocation(node_bounds, max_x_coordinate, max_y_coordinate)
                node.setPosition(position)

            if (
                self._preferences.getValue("meshtools/check_models_on_load") or
                self._preferences.getValue("meshtools/fix_normals_on_load") or
                self._preferences.getValue("meshtools/model_unit_factor") != 1
            ):

                tri_node = self._toTriMesh(mesh_data)

            if self._preferences.getValue("meshtools/model_unit_factor") != 1:
                if file_name and os.path.splitext(file_name)[1].lower() not in [".stl", ".obj", ".ply"]:
                    # only resize models that don't have an intrinsic unit set
                    continue

                scale_matrix = Matrix()
                scale_matrix.setByScaleFactor(float(self._preferences.getValue("meshtools/model_unit_factor")))
                tri_node.apply_transform(scale_matrix.getData())

                self._replaceSceneNode(node, [tri_node])

            if self._preferences.getValue("meshtools/check_models_on_load") and not tri_node.is_watertight:
                if not file_name:
                    file_name = catalog.i18nc("@text Print job name", "Untitled")
                base_name = os.path.basename(file_name)

                if file_name in self._mesh_not_watertight_messages:
                    self._mesh_not_watertight_messages[file_name].hide()

                message = Message(title=catalog.i18nc("@info:title", "Mesh Tools"))
                body = catalog.i18nc("@info:status", "Model %s is not watertight, and may not print properly.") % base_name

                # XRayView may not be available if the plugin has been disabled
                active_view = self._controller.getActiveView()
                if active_view and "XRayView" in self._controller.getAllViews() and active_view.getPluginId() != "XRayView":
                    body += " " + catalog.i18nc("@info:status", "Check X-Ray View and repair the model before printing it.")
                    message.addAction("X-Ray", catalog.i18nc("@action:button", "Show X-Ray View"), "", "")
                    message.actionTriggered.connect(self._showXRayView)
                else:
                    body += " " +catalog.i18nc("@info:status", "Repair the model before printing it.")

                message.setText(body)
                message.show()

                self._mesh_not_watertight_messages[file_name] = message

            if self._preferences.getValue("meshtools/fix_normals_on_load") and tri_node.is_watertight:
                tri_node.fix_normals()
                self._replaceSceneNode(node, [tri_node])

        self._node_queue = []