Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Maillage

Fil rouge du tutoriel : une chape percée — plaque rectangulaire terminée par un demi-disque, trouée en son centre.

Plaque de 30 cm × 10 cm, demi-disque de rayon 5 cm, trou de rayon 3,5 cm centré sur le demi-disque, épaisseur 2 cm. La pièce est plane dans XZ et son épaisseur est portée par Y : la géométrie est en Coords(3) dès le départ, il n’y a donc rien à relever au moment de passer au volume.

Deux familles de mailleurs coexistent :

on donne…le mailleur…topologie
non structuréune taille de maille cibleplace ses propres nœuds à l’intérieurquelconque
structuréun nombre d’élémentsbalaie une ligne sur une autregrille

Le script complet est tutoriel/maillage.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier — sauf le petit utilitaire de tracé, défini en tête du script et présenté en fin de page.

Géométrie

L’espace de coordonnées

# FR — Un espace de coordonnées 3D, où tous les mailleurs déposent leurs nœuds.
# EN — One 3-D coordinate space, where every mesher drops its nodes.
coords = pc.Coords(3)

pyrucast.Coords est le magasin commun de tous les nœuds du script. Tous les mailleurs y déposent leurs nœuds, ce qui garantit que deux maillages construits côte à côte partagent bien leurs nœuds communs — c’est ce qui rend possible, plus bas, de raccorder une couronne à une grille sans maillage non conforme.

L’argument 3 est la dimension de l’espace : la pièce est plane, mais on la décrit d’emblée en 3D pour n’avoir rien à relever au moment de l’extruder.

Les cotes

# FR — Paramètres géométriques / EN — Geometrical parameters
LENGTH, HEIGHT = 0.30, 0.10  # m
HOLE_RADIUS = 0.035  # m
THICKNESS = 0.02  # m

Toutes les dimensions sont en mètres et nommées une seule fois : elles servent aux points guides, aux rayons du trou, à l’épaisseur d’extrusion, et les pages de Thermique linéaire les réimportent telles quelles pour placer leurs conditions aux limites.

Points guides

On pose les quelques points qui définissent la pièce — p1/p2/p4/p5 sont les coins du rectangle, p6 le centre du demi-disque et du trou, p3 la pointe.

# FR — Points guides : les coins, le centre du demi-disque et du trou, la pointe.
# EN — Guide points: the corners, the half-disc and hole centre, the tip.
p1 = coords.add_node([0.0, 0.0, 0.0])
p2 = coords.add_node([LENGTH, 0.0, 0.0])
p3 = coords.add_node([LENGTH + HEIGHT / 2.0, 0.0, HEIGHT / 2.0])
p4 = coords.add_node([LENGTH, 0.0, HEIGHT])
p5 = coords.add_node([0.0, 0.0, HEIGHT])
p6 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])

Ce sont les seuls nœuds saisis à la main de tout le script : tous les autres sortent d’un mailleur.

Contour fermé

Bord par bord

Le contour se maille bord par bord, chacun avec son mailleur dédié : line pour les côtés droits, arc pour le demi-disque.

def border_plate_with_hole() -> pc.Mesh:
    # FR — Le contour se maille bord par bord : `line` droit, `arc` courbe.
    # EN — The contour is meshed edge by edge: `line` straight, `arc` curved.
    l12 = pc.mesh.line(p1, p2, 30)
    c23 = pc.mesh.arc(p2, p6, p3, 8)
    c34 = pc.mesh.arc(p3, p6, p4, 8)
    l45 = pc.mesh.line(p4, p5, 30)
    l51 = pc.mesh.line(p5, p1, 10)
    cex = l12 | c23 | c34 | l45 | l51

Le nombre d’éléments par bord est décidé ici, et seulement ici. triangulate_surface respecte le contour qu’on lui donne : il ne redécoupe jamais un segment du bord. La finesse du contour est donc un choix de l’utilisateur, indépendant de la taille de maille demandée pour l’intérieur.

mesh.consolidate est obligatoire

    # FR — `|` garde un sous-maillage par bord ; `consolidate` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate` fuses them.
    cex = pc.mesh.consolidate(cex)

L’union | réunit les cinq bords dans un même maillage, mais chacun y garde son propre sous-maillage SEG2. Or triangulate_surface exige qu’une boucle fermée tienne dans un seul sous-maillage. pyrucast.mesh.consolidate les fusionne sans toucher à la connectivité.

Le trou, et le contour complet

    # FR — Le trou : cercle centré sur p6, de normale −Y, en 32 segments.
    # EN — The hole: a circle centred on p6, with normal −Y, in 32 segments.
    cin = pc.mesh.circle(p6, [0, -1, 0], HOLE_RADIUS, 32)

    # FR — Deux sous-maillages : le contour extérieur, puis le trou.
    # EN — Two submeshes: the outer contour, then the hole.
    border = cex | cin
    show(border, "Contour de la plaque", "maillage-contour.svg")
    return border

Le trou tient en une commande : un cercle centré sur p6, de normale −Y (donc dans le plan de la pièce), en 32 segments. Le contour renvoyé compte ainsi deux sous-maillages : le contour extérieur, puis le trou.

Maillage non structuré : triangulation

triangulate_surface remplit l’intérieur par triangulation de Delaunay contrainte, raffinée à la taille cible (raffinement de Ruppert).

L’orientation des boucles décide de la matière

def unstructured_mesh(
    border: pc.Mesh,
) -> tuple[pc.Mesh, pc.Mesh, pc.Mesh, pc.Mesh]:
    # FR — Les deux boucles tournent dans le même sens : deux domaines pleins.
    # EN — Both loops wind the same way: two filled-in domains.
    plate_two_domains = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(
        plate_two_domains,
        "Deux boucles CCW : le disque est rempli",
        "maillage-deux-domaines.svg",
    )

Le point à retenir est l’orientation des boucles. Le mailleur la lit pour savoir ce qui est matière et ce qui ne l’est pas :

  • une boucle antihoraire (CCW) est le bord extérieur d’un domaine ;
  • une boucle horaire (CW) est un trou, contenu dans une boucle extérieure ;
  • plusieurs boucles CCW disjointes maillent plusieurs domaines indépendants.

Ici circle produit une boucle qui tourne dans le même sens que le contour extérieur. Telle quelle, elle n’est donc pas lue comme un trou mais comme un second domaine, et le disque est rempli :

invert fait du disque un vrai trou

    # FR — `invert` retourne la boucle du trou : le mailleur y voit un trou.
    # EN — `invert` flips the hole loop: the mesher then sees a genuine hole.
    border = border[:1] | pc.mesh.invert(border[1:])
    plate = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(plate, "Plaque non structurée (TRI3)", "maillage-non-structure.svg")
    print(f"unstructured   : {plate.element_types()}, {plate.cell_count} mailles")

invert retourne la boucle du trou — le sous-maillage 1 du contour, d’où le border[:1] | invert(border[1:]) — et le mailleur y voit alors un vrai trou :

size n’est qu’une taille cible : le raffinement insère ses propres nœuds à l’intérieur jusqu’à l’approcher, sans jamais toucher au bord.

Du surfacique au volumique

Quatre opérateurs enchaînés font passer de la surface au volume tétraédrique.

extrude — balayage sur l’épaisseur

    # FR — L'extrusion balaie la surface sur l'épaisseur : un TRI3 donne un PENTA6.
    # EN — Extrusion sweeps the surface through the thickness: TRI3 gives PENTA6.
    volume_extruded = pc.mesh.extrude(plate, [0, THICKNESS, 0], 2)
    show(
        volume_extruded,
        "Volume extrudé (toutes arêtes)",
        "maillage-volume-aretes.svg",
        wireframe=True,
    )
    show(
        volume_extruded,
        "Volume extrudé (faces cachées)",
        "maillage-volume-extrude.svg",
    )

L’extrusion balaie la surface le long d’un vecteur, en un nombre de couches donné. Le type d’élément suit : SEG2 → QUA4, TRI3 → PENTA6, QUA4 → HEX8.

En wireframe=True toutes les arêtes sont tracées, y compris celles de l’intérieur : on voit le maillage traverser la pièce. Le même volume, faces cachées, ne montre que la peau :

skin — la peau, découpée en faces

    # FR — `skin` extrait la peau et la découpe en faces, coupées au-delà de 85°.
    # EN — `skin` extracts the boundary and splits it into faces, cut beyond 85°.
    skin = pc.mesh.skin(volume_extruded, angle_deg=85)
    for i, face in enumerate(skin):
        face.face_color = PALETTE[i % len(PALETTE)]
    show(
        skin[1:-1],
        "Enveloppe QUA4 (faces planes)",
        "maillage-enveloppe-qua4.svg",
        wireframe=True,
    )

skin extrait les facettes du bord du volume et les regroupe par face : deux facettes voisines restent dans la même face tant que leurs normales diffèrent de moins de l’angle donné (85° ici). Chaque facette garde son type : les faces haute et basse de ce volume de prismes sont en TRI3, les chants en QUA4. On obtient cinq sous-maillages — dessous, chant du trou, chant extérieur (le demi-disque compris : sa courbure ne dépasse jamais 85° d’une facette à la suivante), face gauche, dessus — donc colorables et sélectionnables indépendamment, ce qui sert directement à poser les conditions aux limites.

(La figure ne montre que les faces intermédiaires, skin[1:-1] — les trois chants en QUA4 —, pour voir à travers la pièce.)

convert + invert — préparer l’enveloppe

    # FR — `convert` coupe chaque QUA4 en deux TRI3, `invert` sort les normales.
    # EN — `convert` splits each QUA4 into two TRI3, `invert` turns normals out.
    skin = pc.mesh.invert(pc.mesh.convert(skin, "TRI3"))
    show(
        skin[1:-1],
        "Enveloppe TRI3",
        "maillage-enveloppe-tri3.svg",
        wireframe=True,
    )

triangulate_volume n’accepte qu’une enveloppe TRI3 fermée dont les normales sortent de la matière. convert coupe chaque QUA4 en deux TRI3 sans ajouter le moindre nœud ; invert retourne l’ensemble dans le bon sens.

triangulate_volume — remplissage TET4

    # FR — Remplissage TET4 ; le mailleur peut redécouper l'enveloppe donnée.
    # EN — TET4 filling; the mesher may re-cut the envelope it was handed.
    volume_tetra = pc.mesh.triangulate_volume(skin, size=0.01, allow_surface_nodes=True)

C’est le compagnon 3D de triangulate_surface : il remplit l’intérieur de l’enveloppe de tétraèdres.

allow_surface_nodes=True autorise le mailleur à redécouper l’enveloppe là où il ne sait pas la respecter telle quelle. La forme est conservée — chaque nœud ajouté est posé sur l’arête ou la facette qu’il divise — mais la peau du résultat ne coïncide plus maille pour maille avec celle qu’on a fournie. Sans cette autorisation, une telle enveloppe serait refusée plutôt que mal maillée.

Les nœuds ajoutés, un sous-maillage de plus

    # FR — Les nœuds ajoutés forment un second sous-maillage POI1, ici en rouge.
    # EN — The added nodes form a second POI1 submesh, shown here in red.
    volume_tetra[0].face_color = (0, 0, 0)
    for marqueur in volume_tetra[1:]:
        marqueur.face_color = (255, 0, 0)
    show(
        volume_tetra,
        "Volume non structuré triangulé (TET4)",
        "maillage-volume-tetra.svg",
        wireframe=True,
    )

Le résultat porte un sous-maillage de plus. Quand des nœuds ont dû être ajoutés, triangulate_volume prévient sur stderr et les nomme : le maillage renvoyé contient un second sous-maillage, de POI1, à côté des TET4. element_types() vaut donc ['TET4', 'POI1'] et non ['TET4']. Tout ce qui parcourt les sous-maillages ou compte des mailles doit prendre le TET4 seul. Sur la figure ci-dessus, les tétraèdres sont en noir et ce sont ces nœuds ajoutés (3 sur cette pièce) que l’on voit en rouge.

Maillage structuré : grille et couronne

Un nombre d’éléments, plus une taille

def structured_mesh(plot: bool = True) -> tuple[pc.Mesh, pc.Mesh]:
    # FR — Plus de taille de maille : un nombre d'éléments par direction.
    # EN — No element size any more: an element count per direction.
    n15 = 10  # FR — éléments sur la hauteur / EN — elements through the height
    n12 = 20  # FR — éléments sur la longueur / EN — elements along the length

En structuré on ne donne plus une taille de maille mais un nombre d’éléments par direction. La pièce est traitée en deux morceaux : une grille rectangulaire à gauche, une couronne autour du trou à droite.

Les éléments sont quadratiques. Chaque arête porte un nœud en son milieu, en plus de ses deux extrémités : SEG3 pour les lignes, QUA8 pour les surfaces, HEX20 pour le volume. Sur une arête courbe, ce nœud milieu est sur la courbe : le trou et le demi-disque sont suivis exactement, et non par des cordes.

La grille, par balayage du bord gauche

    # FR — La grille : le bord gauche balayé par translation. En SEG3, chaque
    #      segment porte un nœud en son milieu, et un SEG3 balayé donne un QUA8.
    # EN — The grid: the left edge swept by translation. In SEG3 every segment
    #      carries a node at its middle, and a swept SEG3 gives a QUA8.
    l15 = pc.mesh.line(p1, p5, n15, "SEG3")
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les scripts de thermique importent le volume, sans figures.
    # EN — `plot=False`: the thermal scripts import the volume, without figures.
    if plot:
        show(sr1, "Grille structurée (QUA8)", "maillage-grille.svg")

Balayer plutôt que remplir. extrude(ligne, vecteur, n) balaie une ligne par translation ; un SEG3 balayé donne un QUA8, d’où une grille régulière n12 × n15. Le degré se choisit une fois, sur la ligne de départ ("SEG3") : les mailleurs qui suivent le gardent. La grille s’arrête à x13, l’abscisse où commence le demi-disque.

C’est ici qu’apparaît le premier if plot: : les scripts de thermique importent structured_mesh pour calculer sur son volume et l’appellent avec plot=False, ce qui rend le maillage sans retracer aucune des figures de cette page.

Un bord se récupère, il ne se refabrique pas

    # FR — Le bord droit de la grille ne se refabrique pas : il s'extrait, en SEG3.
    # EN — The grid's right edge is not rebuilt: it is extracted, in SEG3.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.25) / n12
    )
    l1213 = pc.mesh.elements_on(border_sr1, right_nodes, strict=True)

Pour raccorder la couronne à la grille, il faut le bord droit de la grille. Le reconstruire avec line donnerait une ligne jumelle ne partageant aucun nœud avec la grille, donc un maillage non conforme et une pièce en deux morceaux. On l’extrait : border donne le contour de la grille — en SEG3, puisque la grille est en QUA8 —, une sélection sur la coordonnée X (pyrucast.node_field.positions + pyrucast.mesh.select) garde les nœuds de la dernière colonne, et elements_on(..., strict=True) remonte aux segments dont tous les nœuds y sont. Le seuil est à un quart de maille du bord : les nœuds milieux des segments voisins, à une demi-maille, restent dehors. Aucun indice n’est écrit à la main.

Ses deux extrémités, par coordonnée

    # FR — Ses deux extrémités, la plus basse et la plus haute en Z : à un quart
    #      de maille près, car un nœud milieu est à une demi-maille du bout.
    # EN — Its two ends, the lowest and the highest in Z: within a quarter of a
    #      cell, since a mid-node sits half a cell from the end.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.25 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.25) / n15 * HEIGHT
    ).node(0, 0, 0)

Même méthode pour les deux points de raccord de la couronne, repérés par leur coordonnée Z (le plus bas et le plus haut), à un quart de maille près : le nœud milieu du premier segment, à une demi-maille du bout, n’est pas pris. La sélection rend un maillage POI1 d’un seul nœud, dont node(0, 0, 0) extrait le point.

La boucle extérieure de la couronne

    # FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments SEG3.
    #      Sur les arcs, le nœud milieu de chaque segment est sur le cercle.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 SEG3 segments. On the
    #      arcs, each segment's mid-node lies on the circle.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15, "SEG3")
        | pc.mesh.arc(p3, p6, p4, n15, "SEG3")
        | pc.mesh.line(p4, p12, int(n15 / 2), "SEG3")
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2), "SEG3")
    )
    cext = pc.mesh.consolidate(cext)

Elle réunit le demi-disque, les deux tronçons de bord restants et le bord droit de la grille — 10 + 10 + 5 + 10 + 5 = 40 segments SEG3 — consolidés en une seule boucle fermée, comme pour le contour non structuré. Sur les arcs, arc(..., "SEG3") pose le nœud milieu de chaque segment à mi-angle, sur le cercle.

La boucle intérieure, projetée sur le trou

    # FR — Boucle intérieure : la boucle extérieure projetée sur le trou, chaque
    #      nœud poussé le long du rayon issu de p6 — un nœud du trou en face de
    #      chaque nœud de la boucle extérieure.
    # EN — Inner loop: the outer loop projected onto the hole, each node
    #      pushed along the ray from p6 — one hole node facing each node of the
    #      outer loop.
    cin = pc.mesh.project_on_sphere(cext, p6.position(), HOLE_RADIUS)

    # FR — Les deux boucles, l'une bleue et l'autre rouge : découpages alignés.
    # EN — Both loops, one blue and one red: their cuttings line up.
    if plot:
        cext.unit().face_color, cin.unit().face_color = PALETTE[2], PALETTE[3]
        show(cext | cin, "Les deux boucles de la couronne", "maillage-boucles.svg")

Les deux boucles doivent se correspondre. sweep relie les nœuds de la première boucle à ceux de la seconde, une paire à la fois : il en faut autant de part et d’autre, et en vis-à-vis. On obtient les deux d’un coup en projetant la boucle extérieure sur le trou : pyrucast.mesh.project_on_sphere pousse chaque nœud le long du rayon issu de p6 jusqu’au rayon du trou — dans le plan de la pièce, la sphère est le cercle du trou. Chaque nœud du trou est en face du nœud extérieur dont il vient, nœuds milieux compris, et la boucle projetée est fermée comme l’originale.

La figure montre les deux boucles seules, l’extérieure en bleu et l’intérieure en rouge : 40 segments de chaque côté, chaque nœud intérieur sur le rayon de son vis-à-vis. Le découpage du trou n’est pas régulier : il suit celui de la boucle extérieure. C’est exactement ce que sweep demande.

sweep, puis l’union

    # FR — `sweep` relie les deux boucles par 3 couches de QUA8 ; deux boucles
    #      fermées donnent un anneau fermé, sans couture doublée.
    # EN — `sweep` links both loops with 3 layers of QUA8; two closed loops
    #      give a closed ring, with no doubled seam.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA8)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    #      La couronne tourne dans l'autre sens que la grille : `orient` met
    #      toutes les mailles dans le sens de la première, avant l'extrusion.
    # EN — Grid and ring share the right edge's nodes: `|` is enough. The
    #      ring turns the other way from the grid: `orient` turns every cell
    #      the way the first one does, before the extrusion.
    grid = pc.mesh.orient(sr1 | sh1)
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count} mailles")
        show(grid, "Plaque structurée (QUA8)", "maillage-structure.svg")

sweep(a, b, n) balaie une ligne sur une autre, en n couches : entre deux boucles fermées, c’est le moyen d’obtenir un maillage structuré propre autour d’un trou. Les 40 paires de segments donnent ici 40 × 3 = 120 QUA8. Entre deux lignes SEG3, sweep construit directement des QUA8 et reprend les nœuds milieux des deux boucles : ceux des arcs, sur le cercle.

Deux boucles fermées donnent un anneau fermé. Là où chaque boucle se referme, le rayon qui les relie est partagé par la première et la dernière maille de chaque couche, sans nœud en double : la couronne n’est pas fendue le long de ce rayon.

Grille et couronne partageant les nœuds du bord droit l1213, l’union | suffit ensuite à en faire un maillage conforme — 200 + 120 = 320 QUA8.

Une surface extrudée garde son sens de rotation. La couronne, balayée de la boucle extérieure vers la boucle intérieure, tourne en sens inverse de la grille. Extrudée telle quelle, elle donnerait des hexaèdres retournés, dont les faces pointeraient vers l’intérieur de la matière. Une conduction n’y verrait rien ; une pression ou un contact, qui lisent la normale des faces, s’y inverseraient. pyrucast.mesh.orient met toutes les mailles dans le sens de la première, ici celui de la grille :

Le volume HEX20

    # FR — Même extrusion qu'en non structuré, mais un QUA8 donne un HEX20.
    # EN — Same extrusion as in the unstructured case, but a QUA8 gives an HEX20.
    volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
    if plot:
        show(
            volume_structure,
            "Volume structuré (HEX20)",
            "maillage-volume-structure.svg",
        )
    return grid, volume_structure

La même extrusion que pour le non structuré donne le volume — mais un QUA8 balayé donne un HEX20, l’hexaèdre quadratique à 20 nœuds : 640 éléments et 4 000 nœuds, dont les nœuds milieux des arêtes.

C’est ce volume que reprennent les pages de Thermique linéaire. Importer la fonction plutôt que recopier sa géométrie n’est pas un raffinement de style — deux maillages construits à l’identique dans deux scripts porteraient des nœuds distincts, et toute condition posée sur l’un serait sans effet sur l’autre.

Visualiser et exporter

Tout passe par la méthode plot(...) de Mesh/SubMesh :

plaque.plot(save="plaque.svg")  # export without a window

# Fenêtre interactive (souris) — seulement s'il y a un écran, sinon `plot`
# raises: neither DISPLAY nor WAYLAND_DISPLAY is set.
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
    plaque.plot(save=None)

save=None ouvre une fenêtre interactive ; save="....png" ou "....svg" exporte sans fenêtre. La version installée par pip install pyrucast comprend les deux ; une version compilée à la main ne les a qu’avec les features viz-interactive (fenêtre et export) ou viz (export seul) — voir Visualisation pour le détail (caméra, colormaps, coloration par champ).

Le script réunit les deux dans une petite fonction, pour que chaque tracé s’ouvre dans une fenêtre quand on lance le script, et devienne une figure de cette page quand on produit les figures du livre :

# FR — Répertoire des figures : défini → export SVG, absent → fenêtre interactive.
# EN — Figure directory: set → SVG export, unset → interactive window.
OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")

# FR — Vue commune à toutes les figures : azimut, élévation, échelle.
# EN — View shared by every figure: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

# FR — Une couleur par face plane de la peau (recyclée s'il y en a plus).
# EN — One colour per flat skin face (recycled if there are more of them).
PALETTE = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)]


def show(mesh: pc.Mesh, title: str, file: str, wireframe: bool = False) -> None:
    """FR — Trace `mesh` : fenêtre interactive, ou SVG si `OUT` est défini.

    EN — Plot `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        wireframe=wireframe,
        save=os.path.join(OUT, file) if OUT else None,
    )


L’attribut face_color d’un sous-maillage fixe sa couleur de tracé, ce qui sert à distinguer les faces d’une peau ou à faire ressortir un groupe de nœuds.

Les figures SVG de ce tutoriel (img/*.svg) sont pré-générées à partir des scripts tutoriel/*.py et commitées avec le livre — mdbook build ne connaît pas Python, elles ne sont donc pas régénérées automatiquement. Après modification d’un script, régénérer avant de committer :

script/generate-tutoriel-figures.sh

Script complet

Une fois les explications retirées, toute la page tient en un script — du premier point guide au volume structuré :

"""Tutoriel débutant — 1. Maillage. / Beginner tutorial — 1. Meshing.

FR — Construit la pièce qui sert de fil rouge : une **chape percée**, plaque
rectangulaire terminée par un demi-disque et trouée en son centre, maillée de
quatre façons successives — contour, surface, volume, puis la même pièce en
**structuré**.

FR — Cotes : 0.30 × 0.10 m, demi-disque de rayon 0.05 m, trou de rayon
0.035 m, épaisseur 0.02 m. La pièce est plane dans **XZ** et son épaisseur est
portée par **Y** : la géométrie est donc 3D (`Coords(3)`) dès le départ, ce qui
évite d'avoir à la relever au moment de passer au volume.

FR — Deux familles de mailleurs sont comparées : le **non structuré**
(`pyrucast.mesh.triangulate_surface`), où l'on donne un contour fermé et une
**taille de maille** cible, et le **structuré** (`pyrucast.mesh.extrude`,
`pyrucast.mesh.sweep`), où l'on impose un **nombre d'éléments** et où le
maillage a la topologie d'une grille. Le structuré est **quadratique**
(SEG3, QUA8, HEX20) : chaque arête porte un nœud milieu, posé sur la courbe
quand l'arête est courbe. Le détail pas à pas est dans le livre, page
« Maillage ».

EN — Builds the part used as the guiding thread: a **pierced lug**, a
rectangular plate capped by a half-disc and holed at its centre, meshed in four
successive ways — contour, surface, volume, then the same part **structured**.

EN — Dimensions: 0.30 × 0.10 m, half-disc of radius 0.05 m, hole of radius
0.035 m, thickness 0.02 m. The part lies in the **XZ** plane and its thickness
runs along **Y**: the geometry is 3-D (`Coords(3)`) from the start, so nothing
has to be lifted when moving on to the volume.

EN — Two families of meshers are compared: **unstructured**
(`pyrucast.mesh.triangulate_surface`), where you hand in a closed contour and
a target **element size**, and **structured** (`pyrucast.mesh.extrude`,
`pyrucast.mesh.sweep`), where you impose an **element count** and the mesh
has grid topology. The structured mesh is **quadratic** (SEG3, QUA8,
HEX20): every edge carries a mid-node, on the curve when the edge is curved.
The step-by-step walkthrough lives in the book's meshing page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/maillage.py

    # Figures du livre / book figures (book/src/tutoriel/img/) :
    # PYRUCAST_TUTORIEL_IMG_DIR=book/src/tutoriel/img python tutoriel/maillage.py
"""

import os

import pyrucast as pc

# FR — Répertoire des figures : défini → export SVG, absent → fenêtre interactive.
# EN — Figure directory: set → SVG export, unset → interactive window.
OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")

# FR — Vue commune à toutes les figures : azimut, élévation, échelle.
# EN — View shared by every figure: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

# FR — Une couleur par face plane de la peau (recyclée s'il y en a plus).
# EN — One colour per flat skin face (recycled if there are more of them).
PALETTE = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)]


def show(mesh: pc.Mesh, title: str, file: str, wireframe: bool = False) -> None:
    """FR — Trace `mesh` : fenêtre interactive, ou SVG si `OUT` est défini.

    EN — Plot `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        wireframe=wireframe,
        save=os.path.join(OUT, file) if OUT else None,
    )




# ── Géométrie / Geometry ──────────────────────────────────────────────────
# FR — Un espace de coordonnées 3D, où tous les mailleurs déposent leurs nœuds.
# EN — One 3-D coordinate space, where every mesher drops its nodes.
coords = pc.Coords(3)

# FR — Paramètres géométriques / EN — Geometrical parameters
LENGTH, HEIGHT = 0.30, 0.10  # m
HOLE_RADIUS = 0.035  # m
THICKNESS = 0.02  # m

# FR — Points guides : les coins, le centre du demi-disque et du trou, la pointe.
# EN — Guide points: the corners, the half-disc and hole centre, the tip.
p1 = coords.add_node([0.0, 0.0, 0.0])
p2 = coords.add_node([LENGTH, 0.0, 0.0])
p3 = coords.add_node([LENGTH + HEIGHT / 2.0, 0.0, HEIGHT / 2.0])
p4 = coords.add_node([LENGTH, 0.0, HEIGHT])
p5 = coords.add_node([0.0, 0.0, HEIGHT])
p6 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])


# ── Contour fermé / Closed contour ────────────────────────────────────────
def border_plate_with_hole() -> pc.Mesh:
    # FR — Le contour se maille bord par bord : `line` droit, `arc` courbe.
    # EN — The contour is meshed edge by edge: `line` straight, `arc` curved.
    l12 = pc.mesh.line(p1, p2, 30)
    c23 = pc.mesh.arc(p2, p6, p3, 8)
    c34 = pc.mesh.arc(p3, p6, p4, 8)
    l45 = pc.mesh.line(p4, p5, 30)
    l51 = pc.mesh.line(p5, p1, 10)
    cex = l12 | c23 | c34 | l45 | l51

    # FR — `|` garde un sous-maillage par bord ; `consolidate` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate` fuses them.
    cex = pc.mesh.consolidate(cex)

    # FR — Le trou : cercle centré sur p6, de normale −Y, en 32 segments.
    # EN — The hole: a circle centred on p6, with normal −Y, in 32 segments.
    cin = pc.mesh.circle(p6, [0, -1, 0], HOLE_RADIUS, 32)

    # FR — Deux sous-maillages : le contour extérieur, puis le trou.
    # EN — Two submeshes: the outer contour, then the hole.
    border = cex | cin
    show(border, "Contour de la plaque", "maillage-contour.svg")
    return border


# ── Maillage non structuré / Unstructured mesh ────────────────────────────
def unstructured_mesh(
    border: pc.Mesh,
) -> tuple[pc.Mesh, pc.Mesh, pc.Mesh, pc.Mesh]:
    # FR — Les deux boucles tournent dans le même sens : deux domaines pleins.
    # EN — Both loops wind the same way: two filled-in domains.
    plate_two_domains = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(
        plate_two_domains,
        "Deux boucles CCW : le disque est rempli",
        "maillage-deux-domaines.svg",
    )

    # FR — `invert` retourne la boucle du trou : le mailleur y voit un trou.
    # EN — `invert` flips the hole loop: the mesher then sees a genuine hole.
    border = border[:1] | pc.mesh.invert(border[1:])
    plate = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(plate, "Plaque non structurée (TRI3)", "maillage-non-structure.svg")
    print(f"unstructured   : {plate.element_types()}, {plate.cell_count} mailles")

    # FR — L'extrusion balaie la surface sur l'épaisseur : un TRI3 donne un PENTA6.
    # EN — Extrusion sweeps the surface through the thickness: TRI3 gives PENTA6.
    volume_extruded = pc.mesh.extrude(plate, [0, THICKNESS, 0], 2)
    show(
        volume_extruded,
        "Volume extrudé (toutes arêtes)",
        "maillage-volume-aretes.svg",
        wireframe=True,
    )
    show(
        volume_extruded,
        "Volume extrudé (faces cachées)",
        "maillage-volume-extrude.svg",
    )

    # FR — `skin` extrait la peau et la découpe en faces, coupées au-delà de 85°.
    # EN — `skin` extracts the boundary and splits it into faces, cut beyond 85°.
    skin = pc.mesh.skin(volume_extruded, angle_deg=85)
    for i, face in enumerate(skin):
        face.face_color = PALETTE[i % len(PALETTE)]
    show(
        skin[1:-1],
        "Enveloppe QUA4 (faces planes)",
        "maillage-enveloppe-qua4.svg",
        wireframe=True,
    )

    # FR — `convert` coupe chaque QUA4 en deux TRI3, `invert` sort les normales.
    # EN — `convert` splits each QUA4 into two TRI3, `invert` turns normals out.
    skin = pc.mesh.invert(pc.mesh.convert(skin, "TRI3"))
    show(
        skin[1:-1],
        "Enveloppe TRI3",
        "maillage-enveloppe-tri3.svg",
        wireframe=True,
    )

    # FR — Remplissage TET4 ; le mailleur peut redécouper l'enveloppe donnée.
    # EN — TET4 filling; the mesher may re-cut the envelope it was handed.
    volume_tetra = pc.mesh.triangulate_volume(skin, size=0.01, allow_surface_nodes=True)

    # FR — Les nœuds ajoutés forment un second sous-maillage POI1, ici en rouge.
    # EN — The added nodes form a second POI1 submesh, shown here in red.
    volume_tetra[0].face_color = (0, 0, 0)
    for marqueur in volume_tetra[1:]:
        marqueur.face_color = (255, 0, 0)
    show(
        volume_tetra,
        "Volume non structuré triangulé (TET4)",
        "maillage-volume-tetra.svg",
        wireframe=True,
    )
    return plate, volume_extruded, skin, volume_tetra


# ── Maillage structuré / Structured mesh ──────────────────────────────────
def structured_mesh(plot: bool = True) -> tuple[pc.Mesh, pc.Mesh]:
    # FR — Plus de taille de maille : un nombre d'éléments par direction.
    # EN — No element size any more: an element count per direction.
    n15 = 10  # FR — éléments sur la hauteur / EN — elements through the height
    n12 = 20  # FR — éléments sur la longueur / EN — elements along the length

    # FR — La grille : le bord gauche balayé par translation. En SEG3, chaque
    #      segment porte un nœud en son milieu, et un SEG3 balayé donne un QUA8.
    # EN — The grid: the left edge swept by translation. In SEG3 every segment
    #      carries a node at its middle, and a swept SEG3 gives a QUA8.
    l15 = pc.mesh.line(p1, p5, n15, "SEG3")
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les scripts de thermique importent le volume, sans figures.
    # EN — `plot=False`: the thermal scripts import the volume, without figures.
    if plot:
        show(sr1, "Grille structurée (QUA8)", "maillage-grille.svg")

    # FR — Le bord droit de la grille ne se refabrique pas : il s'extrait, en SEG3.
    # EN — The grid's right edge is not rebuilt: it is extracted, in SEG3.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.25) / n12
    )
    l1213 = pc.mesh.elements_on(border_sr1, right_nodes, strict=True)

    # FR — Ses deux extrémités, la plus basse et la plus haute en Z : à un quart
    #      de maille près, car un nœud milieu est à une demi-maille du bout.
    # EN — Its two ends, the lowest and the highest in Z: within a quarter of a
    #      cell, since a mid-node sits half a cell from the end.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.25 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.25) / n15 * HEIGHT
    ).node(0, 0, 0)

    # FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments SEG3.
    #      Sur les arcs, le nœud milieu de chaque segment est sur le cercle.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 SEG3 segments. On the
    #      arcs, each segment's mid-node lies on the circle.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15, "SEG3")
        | pc.mesh.arc(p3, p6, p4, n15, "SEG3")
        | pc.mesh.line(p4, p12, int(n15 / 2), "SEG3")
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2), "SEG3")
    )
    cext = pc.mesh.consolidate(cext)

    # FR — Boucle intérieure : la boucle extérieure projetée sur le trou, chaque
    #      nœud poussé le long du rayon issu de p6 — un nœud du trou en face de
    #      chaque nœud de la boucle extérieure.
    # EN — Inner loop: the outer loop projected onto the hole, each node
    #      pushed along the ray from p6 — one hole node facing each node of the
    #      outer loop.
    cin = pc.mesh.project_on_sphere(cext, p6.position(), HOLE_RADIUS)

    # FR — Les deux boucles, l'une bleue et l'autre rouge : découpages alignés.
    # EN — Both loops, one blue and one red: their cuttings line up.
    if plot:
        cext.unit().face_color, cin.unit().face_color = PALETTE[2], PALETTE[3]
        show(cext | cin, "Les deux boucles de la couronne", "maillage-boucles.svg")

    # FR — `sweep` relie les deux boucles par 3 couches de QUA8 ; deux boucles
    #      fermées donnent un anneau fermé, sans couture doublée.
    # EN — `sweep` links both loops with 3 layers of QUA8; two closed loops
    #      give a closed ring, with no doubled seam.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA8)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    #      La couronne tourne dans l'autre sens que la grille : `orient` met
    #      toutes les mailles dans le sens de la première, avant l'extrusion.
    # EN — Grid and ring share the right edge's nodes: `|` is enough. The
    #      ring turns the other way from the grid: `orient` turns every cell
    #      the way the first one does, before the extrusion.
    grid = pc.mesh.orient(sr1 | sh1)
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count} mailles")
        show(grid, "Plaque structurée (QUA8)", "maillage-structure.svg")

    # FR — Même extrusion qu'en non structuré, mais un QUA8 donne un HEX20.
    # EN — Same extrusion as in the unstructured case, but a QUA8 gives an HEX20.
    volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
    if plot:
        show(
            volume_structure,
            "Volume structuré (HEX20)",
            "maillage-volume-structure.svg",
        )
    return grid, volume_structure


def main() -> None:
    contour = border_plate_with_hole()
    unstructured_mesh(contour)
    structured_mesh()
    if OUT:
        print(f"Figures written in {OUT}/")


if __name__ == "__main__":
    main()

Suite : Thermique linéaire, dont les pages reprennent le volume HEX20 structuré de cette page et y posent des conditions aux limites.