Impression 3D pseudo 5 axes : Différence entre versions

De Wiki LOGre
Aller à : navigation, rechercher
m (Code)
m (juillet 2019)
 
(11 révisions intermédiaires par le même utilisateur non affichées)
Ligne 12 : Ligne 12 :
  
 
[[Fichier:BB-8_orange-panel.png|300px]]
 
[[Fichier:BB-8_orange-panel.png|300px]]
 +
 +
Ces panneaux sont des portions de sphère. Si on les imprime de manière classique, il faudra beaucoup de support. De plus, il y aura un effet d'escalier de plus en plus marqué vers le sommet ; cet effet pourrait être atténué en utilisant une hauteur de couche variable, au détriment du temps d'impression. Dans tous les cas, les couches resteront très visibles.
  
 
== Étude ==
 
== Étude ==
 
Ces panneaux sont des portions de sphère. Si on les imprime de manière classique, il faudra beaucoup de support. De plus, il y aura un effet d'escalier de plus en plus marqué vers le sommet ; cet effet pourrait être atténué en utilisant une hauteur de couche variable, au détriment du temps d'impression. Dans tous les cas, les couches resteront très visibles.
 
  
 
Une solution est d'utiliser une imprimante 5 axes, et d'imprimer ''le long de la sphère'' : les couches ne seraient alors plus visibles, puisque la surface du haut correspondrait à celle d'une pièce plane classique.
 
Une solution est d'utiliser une imprimante 5 axes, et d'imprimer ''le long de la sphère'' : les couches ne seraient alors plus visibles, puisque la surface du haut correspondrait à celle d'une pièce plane classique.
Ligne 27 : Ligne 27 :
 
Il reste ensuite à transformer les coordonnées X/Y/Z en coordonnées X/Y/Z/A/B (où A et B sont les 2 axes supplémentaires, les rotations de la tête ou du plateau), de telle sorte que la tête se déplace bien sur la sphère plutôt que le plan.
 
Il reste ensuite à transformer les coordonnées X/Y/Z en coordonnées X/Y/Z/A/B (où A et B sont les 2 axes supplémentaires, les rotations de la tête ou du plateau), de telle sorte que la tête se déplace bien sur la sphère plutôt que le plan.
  
== Transformation sphère -> plan ==
+
== Transformations ==
  
TODO.
+
=== Sphère -> plan ===
  
Cette partie semble bien fonctionner. Voici ce que ça donne sur une petite portion de sphère :
+
Cette partie consiste à ''mapper'' le STL de la portion de sphère dans le plan, comme si on écrasait la pièce - molle - sur la table : elle s'étire donc dans le plan X/Y.
  
[[Fichier:Sphere.png|300px]]
+
=== Plan -> sphère ===
[[Fichier:Sphere_mapped.png|325px]]
 
 
 
== Transformation plan -> sphère ==
 
  
 
Une fois la pièce ''mappée'' sur un plan, puis tranchée de manière classique, il faut donc générer les coordonnées X/Y/Z/A/B de la machine 5 axes à partir des coordonnées X/Y/Z.
 
Une fois la pièce ''mappée'' sur un plan, puis tranchée de manière classique, il faut donc générer les coordonnées X/Y/Z/A/B de la machine 5 axes à partir des coordonnées X/Y/Z.
Ligne 43 : Ligne 40 :
 
* ''remapper'' les déplacements plans sur la sphère ;
 
* ''remapper'' les déplacements plans sur la sphère ;
 
* décomposer les grands mouvements en petits segments pour d'une part gérer la non-linéarité des nouveaux axes (rotations du plateau), et d'autre part parcourir la sphère et éviter les collisions.
 
* décomposer les grands mouvements en petits segments pour d'une part gérer la non-linéarité des nouveaux axes (rotations du plateau), et d'autre part parcourir la sphère et éviter les collisions.
 +
 +
== LogBook ==
 +
 +
=== Juillet 2019 ===
 +
 +
* premier jet du code Python pour la transformation du STL (sphère vers plan) et de la transformation inverse du G-Code (plan vers sphère), avec segmentation
 +
* première impression de test -> pas si pire ! Note : le support en étoile a été généré/imprimé indépendemment.
 +
* il y a visiblement un souci de ''mapping/de-mapping'', car la longueur d'un segment sur la pièce ''mappée'' est plus long que le segment correspondant sur la pièce ''dé-mappée'' :o/
 +
 +
[[Fichier:Sphere.png|300px]]
 +
[[Fichier:Sphere_mapped.png|325px]]
 +
[[Fichier:Sphere_unmapped_printed_1.png|300px]]
  
 
== Code ==
 
== Code ==
  
=== Sphère vers plan ===
+
=== Sphère -> plan ===
  
 
Voici le code Python permettant de transformer un fichier STL (ascii ou binaire).
 
Voici le code Python permettant de transformer un fichier STL (ascii ou binaire).
Ligne 280 : Ligne 289 :
 
</syntaxhighlight>
 
</syntaxhighlight>
  
=== Plan vers sphère ===
+
=== Plan -> sphère ===
  
 
Voici un premier jet du code Python permettant de faire la transformation inverse, et gérer la cinématique de déplacement le long de la sphère (qui pourra à terme être implémentée dans le firmware).
 
Voici un premier jet du code Python permettant de faire la transformation inverse, et gérer la cinématique de déplacement le long de la sphère (qui pourra à terme être implémentée dans le firmware).
Ligne 382 : Ligne 391 :
 
             'F': 0
 
             'F': 0
 
         }
 
         }
        self._originalMoveLength = 0
 
        self._newMoveLength = 0.
 
  
 
     def parse(self, path):
 
     def parse(self, path):

Version actuelle datée du 13 juillet 2019 à 12:57

Langue : Français  • English

Projet réalisé par Fma38.

En cours.

Présentation

Le but de ce projet est de pouvoir imprimer les panneaux du robot BB-8 de manière optimale.

BB-8 orange-panel.png

Ces panneaux sont des portions de sphère. Si on les imprime de manière classique, il faudra beaucoup de support. De plus, il y aura un effet d'escalier de plus en plus marqué vers le sommet ; cet effet pourrait être atténué en utilisant une hauteur de couche variable, au détriment du temps d'impression. Dans tous les cas, les couches resteront très visibles.

Étude

Une solution est d'utiliser une imprimante 5 axes, et d'imprimer le long de la sphère : les couches ne seraient alors plus visibles, puisque la surface du haut correspondrait à celle d'une pièce plane classique.

Si l'imprimante n'est pas très difficile à réaliser (!), il en va tout autrement du trancheur. À ce jour, seules quelques sociétés ont une solution de ce type, propriétaire, bien sûr.

Mais, au contraire d'une pièce quelconque, ici, on connaît parfaitement la géométrie - simple - de la pièce : une sphère. Il suffirait donc que le trancheur parcoure la pièce selon cette sphère, plutôt que le plan XY. Mais même cette modification n'est pas triviale, et bien au delà de mes compétences. Et je doute d'arriver à motiver un développeur de trancheur d'implémenter ça.

Heureusement, il y a une autre solution, beaucoup plus accessible, qui est de mapper la pièce sphérique sur le plan ! Cela se fait très simplement avec un script Python (cf ci-dessous). Une fois la pièce plane, n'importe quel trancheur de base fait l'affaire : il pensera parcourir la pièce suivant un plan, alors qu'il suit en fait une sphère ;o)

Il reste ensuite à transformer les coordonnées X/Y/Z en coordonnées X/Y/Z/A/B (où A et B sont les 2 axes supplémentaires, les rotations de la tête ou du plateau), de telle sorte que la tête se déplace bien sur la sphère plutôt que le plan.

Transformations

Sphère -> plan

Cette partie consiste à mapper le STL de la portion de sphère dans le plan, comme si on écrasait la pièce - molle - sur la table : elle s'étire donc dans le plan X/Y.

Plan -> sphère

Une fois la pièce mappée sur un plan, puis tranchée de manière classique, il faut donc générer les coordonnées X/Y/Z/A/B de la machine 5 axes à partir des coordonnées X/Y/Z.

Cette partie est plus complexe, car il faut :

  • remapper les déplacements plans sur la sphère ;
  • décomposer les grands mouvements en petits segments pour d'une part gérer la non-linéarité des nouveaux axes (rotations du plateau), et d'autre part parcourir la sphère et éviter les collisions.

LogBook

Juillet 2019

  • premier jet du code Python pour la transformation du STL (sphère vers plan) et de la transformation inverse du G-Code (plan vers sphère), avec segmentation
  • première impression de test -> pas si pire ! Note : le support en étoile a été généré/imprimé indépendemment.
  • il y a visiblement un souci de mapping/de-mapping, car la longueur d'un segment sur la pièce mappée est plus long que le segment correspondant sur la pièce dé-mappée :o/

Sphere.png Sphere mapped.png Sphere unmapped printed 1.png

Code

Sphère -> plan

Voici le code Python permettant de transformer un fichier STL (ascii ou binaire).

Attention :

  • la portion de sphère doit être posée sur le plan Z=0, orientée vers le bas, et centrée sur l'axe Z
  • il faut donner :
    • le rayon intérieur de la sphère (RADIUS_INT)
    • la position verticale du centre de la sphère sur l'axe Z (SPHERE_ORIGIN_Z)
# -*- coding: utf-8 -*-

"""
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
or see:

  http://www.gnu.org/licenses/gpl.html

author: Frédéric Mantegazza
copyright: (C) 2019 Frédéric Mantegazza
"""

from __future__ import division

import re
import sys
import math
import struct
import os.path

import numpy

RADIUS_INT = 60.  # mm
SPHERE_ORIGIN_Z = -55.  # mm


class Vector(list):
    def __str__(self):
        return "%.5f %.5f %.5f" % (self[0], self[1], self[2])


class Vertex(list):
    def __str__(self):
        return "%.5f %.5f %.5f" % (self[0], self[1], self[2])

    def mapToPlan(self, radiusInt, sphereOriginZ):
        """
        """
        L = math.sqrt(self[0] ** 2 + self[1] ** 2 + (self[2] - sphereOriginZ) ** 2)
        alpha = math.atan2(math.sqrt(self[0] ** 2 + self[1] ** 2), self[2] - sphereOriginZ)
        beta = math.atan2(self[1], self[0])

        x = alpha * L * math.cos(beta)
        y = alpha * L * math.sin(beta)
        z = L - radiusInt

        return Vertex([x, y, z])


class Facet(object):
    def __init__(self, normal, vertices):
        if len(vertices) != 3:
            raise ValueError("A facet must have 3 vertices")

        self._normal = normal
        self._vertices = vertices

    @property
    def normal(self):
        return self._normal

    @property
    def vertices(self):
        return self._vertices

    def __str__(self):
        s = "  facet normal %s\n" % self._normal
        s += "    outer loop\n"
        for vertex in self._vertices:
            s += "      vertex %s\n" % vertex
        s += "    endloop\n"
        s += "  endfacet"

        return s


class Solid(object):
    def __init__(self, name="Onshape"):
        self._name = name
        self._facets = []

    def __str__(self):
        s = "solid %s\n" % self._name
        for facet in self._facets:
            s += "%s\n" % facet
        s += "endsolid %s\n" % self._name

        return s

    @property
    def name(self):
        return self._name

    @property
    def facets(self):
        return self._facets

    def addFacet(self, facet):
        self._facets.append(facet)

    def mapToPlan(self):
        newSolid = Solid("%s_mapped" % self._name)

        for i, facet in enumerate(self._facets):
            newVertices = []
            for vertex in facet.vertices:
                newVertex = vertex.mapToPlan(RADIUS_INT, SPHERE_ORIGIN_Z)
                newVertices.append(newVertex)

            # Compute new normal
            vector1 = [b-a for a, b in zip(newVertices[0], newVertices[1])]
            vector2 = [b-a for a, b in zip(newVertices[0], newVertices[2])]
            newNormal  = numpy.cross(vector1, vector2)
            newNormal /= numpy.sqrt(newNormal.dot(newNormal))

            newFacet = Facet(Vector(newNormal), newVertices)
            newSolid.addFacet(newFacet)

        return newSolid


def parseAscii(inputFile):
    inputFile.seek(0)
    inputStr = inputFile.read()

    # Iter over 'solid' (should be only one)
    for solidStr in re.findall(r"solid\s(.*?)endsolid", inputStr, re.S):
        solidName = re.match(r"^(.*)$", solidStr, re.M).group(0)
        print "Processing object '%s'..." % solidName

        solid = Solid(solidName)

        # Iter over 'facet normal'
        for facetNormalStr in re.findall(r"facet\snormal\s(.*?)endfacet", solidStr, re.S):
            coords = [float(coord) for coord in facetNormalStr.split('\n')[0].split()]
            normal = Vector(coords)

            # Iter over 'outer loop' (should be only one)
            for outerLoopStr in re.findall(r"outer\sloop(.*?)endloop", facetNormalStr, re.S):
                vertices = []

                # Iter over 'vertex'
                for vertexStr in re.findall(r"vertex\s(.*)$", outerLoopStr, re.M):
                    coords = [float(coord) for coord in vertexStr.split()]
                    vertex = Vertex(coords)
                    vertices.append(vertex)

            facet = Facet(normal, vertices)
            solid.addFacet(facet)

    return solid


def parseBinary(inputFile):

    # Skip header
    inputFile.seek(80)

    nbFacets = struct.unpack("<I", inputFile.read(4))[0]
    print "found %d facets" % nbFacets

    solid = Solid("Onshape")

    # Iterate over facets
    for i in range(nbFacets):

        # Get normal
        coords = struct.unpack("<fff", inputFile.read(3*4))
        normal = Vector(coords)

        vertices = []

        # Iterate over vertices
        for j in range(3):
            coords = struct.unpack("<fff", inputFile.read(3*4))
            vertex = Vertex(coords)
            vertices.append(vertex)

        facet = Facet(normal, vertices)
        solid.addFacet(facet)

        # Skip byte count
        inputFile.seek(2, 1)

    return solid


def main():
    inputFileName = sys.argv[1]
    inputFile = file(inputFileName)
    if inputFile.read(5) == "solid":
        print "ascii file"
        solid = parseAscii(inputFile)
    else:
        print "binary file"
        solid = parseBinary(inputFile)
    inputFile.close()

    outputFileName = "%s_mapped%s%s" % (os.path.splitext(inputFileName)[0], os.path.extsep, "stl")
    outputFile = file(outputFileName, "w")
    newSolid = solid.mapToPlan()
    outputFile.write(str(newSolid))
    outputFile.close()

    print "%s saved" % outputFileName


if __name__ == "__main__":
    main()

Plan -> sphère

Voici un premier jet du code Python permettant de faire la transformation inverse, et gérer la cinématique de déplacement le long de la sphère (qui pourra à terme être implémentée dans le firmware).

Attention :

  • il faut donner :
    • le rayon intérieur de la sphère (RADIUS_INT)
    • la position verticale du centre de la sphère sur l'axe Z (SPHERE_ORIGIN_Z)
    • donner le centre de rotation du plateau (BED_ROTATION_ORIGIN_Z) - non géré dans cette version
# -*- coding: utf-8 -*-

"""
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
or see:

  http://www.gnu.org/licenses/gpl.html

author: Frédéric Mantegazza
copyright: (C) 2019 Frédéric Mantegazza
"""

from __future__ import division

import sys
import math

import numpy

RADIUS_INT = 60.  # mm
SPHERE_ORIGIN_Z = -55.  # mm
BED_ROTATION_ORIGIN_Z = 0.  # mm

MAX_MOVE_LENGTH = 1  # mm


def mapToSphere(coords):
    """
    """

    # Common values
    L = coords['Z'] + RADIUS_INT
    alpha = math.sqrt(coords['X'] ** 2 + coords['Y'] ** 2) / L
    beta = math.atan2(coords['Y'], coords['X'])

    Lz = L * math.cos(alpha)
    Lu = L * math.sin(alpha)

    # Translations
    x = Lu * math.cos(beta)
    y = Lu * math.sin(beta)
    z = Lz + SPHERE_ORIGIN_Z  # + coords['Z']
    if z < 0.2:
        z = 0.2

    # Rotations
    # Note: these are not Euler rotations! This is for a real 6 axis kinematic, like a 6 axis delta printer.
    #       If using a tilting bed with stacked rotations, we need to implement Euler rotations
    U = numpy.array([0., 0., Lz])
    Va = numpy.array([x,  0., Lz])
    Ul = numpy.sqrt(U.dot(U))  # U length
    a = math.acos(U.dot(Va) / (Ul * numpy.sqrt(Va.dot(Va))))
    Vb = numpy.array([0., y,  Lz])
    b = math.acos(U.dot(Vb) / (Ul * numpy.sqrt(Vb.dot(Vb))))

    # Compute offsets due to rotation
    # TODO
    dx = 0.
    dy = 0.
    dz = 0.

    return {'X': x+dx, 'Y': y+dy, 'Z': z+dz, 'A': math.degrees(a), 'B': math.degrees(b)}


class GcodeConverter(object):
    """
    """
    def __init__(self):
        """
        """
        self._numLine = 0
        self._line = ""

        self._currentPos = {
            'X': 0.,
            'Y': 0.,
            'Z': 0.,
            'F': 0
        }

    def parse(self, path):
        """
        """

        # Read the gcode file
        with open(path, 'r') as f:
            self._numLine = 0

            # Parse lines
            for line in f:
                self._line = line.rstrip()
                self._parseLine()
                self._numLine += 1

    def _parseLine(self):
        """
        """

        # Strip comments
        bits = self._line.split(';', 1)
        try:
            comment = bits[1]
        except IndexError:
            comment = ""

        # Extract and clean command
        command = bits[0].strip()

        # TODO strip logical line number and checksum

        # Code is first word, then args
        comm = command.split(None, 1)
        code = comm[0] if (len(comm) > 0) else None
        args = comm[1] if (len(comm) > 1) else None

        if code:
            if hasattr(self, "_parse%s" % code):
                getattr(self, "_parse%s" % code)(args)
            else:
                print self._line
        else:
            print self._line

    def _parseArgs(self, args):
        """
        """
        argsDict = {}
        bits = args.split()
        for bit in bits:
            letter = bit[0]
            coord = float(bit[1:])
            argsDict[letter] = coord

        return argsDict

    def _parseG0(self, args):
        """ G0: Rapid move

        Treated as G1.
        """
        self.parseG1(args, code="G0")

    def _parseG1(self, args, code="G1"):
        """ G1: Controlled move

        Implemented for Slic3R / Prusaslicer. May need some adjustements for others slicers.
        Only support absolute coordinates and relative extrusion distance.

        Valid moves are:
            - G1 F...
            - G1 Z... F...
            - G1 X... Y...
            - G1 X... Y... E...
            - G1 X... Y... F...
        """
        args = self._parseArgs(args)
        try:
            args['Z'] += Z_SHIFT
        except KeyError:
            pass
        target = dict(self._currentPos)  # local copy
        target.update(args)

        # Compute move vector
        currentPos_ = numpy.array([self._currentPos['X'],
                                   self._currentPos['Y'],
                                   self._currentPos['Z']])
        target_ = numpy.array([target['X'],
                               target['Y'],
                               target['Z']])
        moveVector = target_ - currentPos_

        moveLength = numpy.sqrt(moveVector.dot(moveVector))

        # If segment is too long, split in shorter ones, in order to move along the sphere
        if moveLength > MAX_MOVE_LENGTH:
            self._originalMoveLength += moveLength
            nbSteps = int(moveLength / MAX_MOVE_LENGTH + 1)
            for subStep in range(1, nbSteps+1):
                subCoords = currentPos_ + subStep / nbSteps * moveVector
                newCoords = mapToSphere(dict(X=subCoords[0], Y=subCoords[1], Z=subCoords[2]))
                if subStep == 1:
                    newVector = newVector0 = numpy.array([newCoords['X'], newCoords['Y'], newCoords['Z']])
                else:
                    newVector = numpy.array([newCoords['X'], newCoords['Y'], newCoords['Z']]) - newVector0
                    newVector0 = numpy.array([newCoords['X'], newCoords['Y'], newCoords['Z']])

                line = "%s X%.2f Y%.2f Z%.2f F%0.1f" % (code,
                                                        newCoords['X'], newCoords['Y'], newCoords['Z'],
                                                        target['F'])

                if 'E' in args.keys():
                    extrusion =  args['E'] / nbSteps
                    line += " E%.6f" % extrusion

                print line

        else:
            newCoords = mapToSphere(dict(X=target['X'], Y=target['Y'], Z=target['Z']))
            line = "%s X%.2f Y%.2f Z%.2f F%.1f" % (code,
                                                   newCoords['X'], newCoords['Y'], newCoords['Z'],
                                                   target['F'])
            if 'E' in args.keys():
                line += " E%.5f" % args['E']

            print line

        for key in self._currentPos.keys():
            try:
                self._currentPos[key] = args[key]
            except KeyError:
                pass

    def _parseG91(self, args):
        """ G91: Set to Relative Positioning
        """
        self._error("Relative coordinates not supported")

    def _parseG92(self, args):
        """ G92: Set current Position
        """
        self._error("G92 not supported")

    def _parseM82(self, args):
        """ M82: Set extrusion to absolute distance
        """
        self._error("Absolute extrusion distance not supported")

    def _error(self, msg):
        """
        """
        raise Exception("ERROR::Line %d: %s (text: '%s')" % (self._numLine, msg, self._line))

def main():
    """
    """
    converter = GcodeConverter()
    converter.parse(sys.argv[1])


if __name__ == "__main__":
    main()