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 cible | place ses propres nœuds à l’intérieur | quelconque |
| structuré | un nombre d’éléments | balaie une ligne sur une autre | grille |
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_volumeprévient surstderret les nomme : le maillage renvoyé contient un second sous-maillage, dePOI1, à côté desTET4.element_types()vaut donc['TET4', 'POI1']et non['TET4']. Tout ce qui parcourt les sous-maillages ou compte des mailles doit prendre leTET4seul. 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.