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 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 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_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.
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.