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 de la formation : une chape percée — plaque rectangulaire terminée par un demi-disque, trouée en son centre. C’est l’équivalent pyrucast de la pièce « structure avec un trou » de la formation Cast3M originale.

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 formation/maillage.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier.

Géométrie

L’espace de coordonnées

# FR — Un espace de coordonnées 3D, seul objet mutable de tout le script.
# EN — One 3-D coordinate space, the script's only mutable object.
coords = pc.Coords(3)

pyrucast.Coords est le seul objet mutable 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 chapitres suivants 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_mesh` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate_mesh` 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 planes

    # FR — `skin` extrait la peau et la découpe en faces planes, à 85° près.
    # EN — `skin` extracts the boundary and splits it into flat faces, at 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 plane : deux facettes voisines restent dans la même face tant que leurs normales diffèrent de moins de l’angle donné (85° ici). On obtient un sous-maillage par face — dessus, dessous, chant du trou, chant extérieur — donc colorable et sélectionnable indépendamment, ce qui sert directement à poser les conditions aux limites.

(La figure ne montre que les faces intermédiaires, skin[1:-1], 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.

La grille, par balayage du bord gauche

    # FR — La grille : le bord gauche balayé par translation, un SEG2 → un QUA4.
    # EN — The grid: the left edge swept by translation, a SEG2 → a QUA4.
    l15 = pc.mesh.line(p1, p5, n15)
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les chapitres suivants importent le volume, sans figures.
    # EN — `plot=False`: later chapters import the volume, without any figure.
    if plot:
        show(sr1, "Grille structurée (QUA4)", "maillage-grille.svg")

Balayer plutôt que remplir. extrude(ligne, vecteur, n) balaie une ligne par translation ; un SEG2 balayé donne un QUA4, d’où une grille régulière n12 × n15. La grille s’arrête à x13, l’abscisse où commence le demi-disque.

C’est ici qu’apparaît le premier if plot: : les chapitres suivants 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 — The grid's right edge is not rebuilt: it is extracted.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.5) / 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, une sélection sur la coordonnée X (field.positions + field.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. 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.
    # EN — Its two ends, the lowest and the highest in Z.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.5 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.5) / 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) : 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.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 segments.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15)
        | pc.mesh.arc(p3, p6, p4, n15)
        | pc.mesh.line(p4, p12, int(n15 / 2))
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2))
    )
    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 — consolidés en une seule boucle fermée, comme pour le contour non structuré.

La boucle intérieure, alignée sur elle

    # FR — Boucle intérieure : le trou en quatre quarts, 40 segments aussi.
    # EN — Inner loop: the hole as four quarters, 40 segments as well.
    p14 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 - HOLE_RADIUS])
    p15 = coords.add_node([LENGTH + HOLE_RADIUS, 0.0, HEIGHT / 2.0])
    p16 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 + HOLE_RADIUS])
    p17 = coords.add_node([LENGTH - HOLE_RADIUS, 0.0, HEIGHT / 2.0])
    cin = (
        pc.mesh.arc(p14, p6, p15, n15)
        | pc.mesh.arc(p15, p6, p16, n15)
        | pc.mesh.arc(p16, p6, p17, n15)
        | pc.mesh.arc(p17, p6, p14, n15)
    )
    cin = pc.mesh.consolidate(cin)

    # 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 : elles doivent donc avoir le même nombre de segments. D’où le trou découpé en quatre quarts de 10 segments, soit 40 également — et les quatre points de départ p14…p17 posés explicitement pour que les deux découpages s’alignent.

La figure montre les deux boucles seules, l’extérieure en bleu et l’intérieure en rouge : 40 segments de chaque côté, et les découpages en vis-à-vis. C’est exactement ce que sweep demande.

sweep, puis l’union

    # FR — `sweep` relie les deux boucles par 3 couches de QUA4.
    # EN — `sweep` links both loops with 3 layers of QUA4.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA4)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    # EN — Grid and ring share the right edge's nodes: `|` is enough.
    grid = sr1 | sh1
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count()} mailles")
        show(grid, "Plaque structurée (QUA4)", "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 nœuds donnent ici 40 × 3 = 120 QUA4.

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

Le volume HEX8

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

La même extrusion que pour le non structuré donne le volume — mais un QUA4 balayé donne un HEX8, le meilleur élément pour le calcul :

C’est ce volume que reprennent les chapitres suivants. 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

Une seule méthode, plot(...), sur Mesh/SubMesh — l’équivalent de TRAC :

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 (nécessite la feature Cargo viz-interactive) ; save="....png" ou "....svg" exporte sans fenêtre (feature viz) — voir Visualisation pour le détail (caméra, colormaps, coloration par champ).

Le script réunit les deux dans un petit helper, pour que chaque tracé soit interactif à l’exécution et devienne une figure de cette page en mode batch :

# 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_FORMATION_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 cette formation (img/*.svg) sont pré-générées à partir des scripts formation/*.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-formation-figures.sh

Script complet

Une fois les explications retirées, tout le chapitre tient en une page — du premier point guide au volume structuré :

"""Formation débutant — 1. Maillage. / Beginner training — 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 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 step-by-step walkthrough lives in the book's meshing
page.

Lancement / Running ::

    maturin develop --release
    python formation/maillage.py

    # Figures du livre / book figures (book/src/formation/img/) :
    # PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/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_FORMATION_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, seul objet mutable de tout le script.
# EN — One 3-D coordinate space, the script's only mutable object.
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_mesh` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate_mesh` 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 planes, à 85° près.
    # EN — `skin` extracts the boundary and splits it into flat faces, at 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, un SEG2 → un QUA4.
    # EN — The grid: the left edge swept by translation, a SEG2 → a QUA4.
    l15 = pc.mesh.line(p1, p5, n15)
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les chapitres suivants importent le volume, sans figures.
    # EN — `plot=False`: later chapters import the volume, without any figure.
    if plot:
        show(sr1, "Grille structurée (QUA4)", "maillage-grille.svg")

    # FR — Le bord droit de la grille ne se refabrique pas : il s'extrait.
    # EN — The grid's right edge is not rebuilt: it is extracted.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.5) / 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.
    # EN — Its two ends, the lowest and the highest in Z.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.5 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.5) / n15 * HEIGHT
    ).node(0, 0, 0)

    # FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 segments.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15)
        | pc.mesh.arc(p3, p6, p4, n15)
        | pc.mesh.line(p4, p12, int(n15 / 2))
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2))
    )
    cext = pc.mesh.consolidate(cext)

    # FR — Boucle intérieure : le trou en quatre quarts, 40 segments aussi.
    # EN — Inner loop: the hole as four quarters, 40 segments as well.
    p14 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 - HOLE_RADIUS])
    p15 = coords.add_node([LENGTH + HOLE_RADIUS, 0.0, HEIGHT / 2.0])
    p16 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 + HOLE_RADIUS])
    p17 = coords.add_node([LENGTH - HOLE_RADIUS, 0.0, HEIGHT / 2.0])
    cin = (
        pc.mesh.arc(p14, p6, p15, n15)
        | pc.mesh.arc(p15, p6, p16, n15)
        | pc.mesh.arc(p16, p6, p17, n15)
        | pc.mesh.arc(p17, p6, p14, n15)
    )
    cin = pc.mesh.consolidate(cin)

    # 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 QUA4.
    # EN — `sweep` links both loops with 3 layers of QUA4.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA4)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    # EN — Grid and ring share the right edge's nodes: `|` is enough.
    grid = sr1 | sh1
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count()} mailles")
        show(grid, "Plaque structurée (QUA4)", "maillage-structure.svg")

    # FR — Même extrusion qu'en non structuré, mais un QUA4 donne un HEX8.
    # EN — Same extrusion as in the unstructured case, but a QUA4 gives an HEX8.
    volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
    if plot:
        show(
            volume_structure,
            "Volume structuré (HEX8)",
            "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 : Calcul thermique, qui reprend le volume HEX8 structuré de cette page et y pose des conditions aux limites.