Visualisation
La visualisation des maillages est optionnelle : elle est gardée par des features Cargo et n’est donc compilée que sur demande.
| Feature Cargo | Apport | Dépendances ajoutées |
|---|---|---|
| (aucune) | rien — bibliothèque de calcul pure | — |
viz | export PNG + SVG (rendu CPU) | plotters |
viz-interactive | + fenêtre interactive (souris) | plotters, winit, softbuffer |
viz-interactive implique viz. Pour un environnement sans serveur d’affichage (CI headless, conteneur), on s’arrête à viz : tout l’export image fonctionne.
Installé depuis PyPI ? Les wheels publiées compilent
vizetviz-interactive; la sdist, sur laquellepipretombe quand aucune wheel ne correspond à la plateforme, ne compile que le cœur de calcul —mesh.plot()n’y existe pas.pyrucast.__features__dit ce que porte l’installation en cours, et « Ce que porte chaque distribution » détaille les deux cas.
# Bibliothèque de calcul pure (par défaut).
cargo build
# Avec export PNG/SVG.
cargo build --features viz
# Avec fenêtre interactive.
cargo build --features viz-interactive
# Côté Python (pour les tests pytest) : les features passées en ligne de
# commande *remplacent* celles du pyproject, il faut donc redonner
# `extension-module` en plus de `viz`.
maturin develop --features extension-module,viz
Modèle de point de vue
La caméra est décrite par une structure View, située sur une sphère orientée autour d’un point cible :
yaw: azimut en degrés (rotation autour de l’axe Z monde) ;pitch: élévation en degrés (au-dessus du plan XY monde) ;scale:1.0= la bounding-box remplit l’image ;>1zoom,<1dézoom ;target: point regardé.None⇒ le centre de la bounding-box de l’objet visualisé ;revolve: sur une géométrie axisymétrique uniquement, balaie la section méridienne pour tracer le corps de révolution (voir plus bas).None(défaut) ⇒ la section plane.
Préréglages disponibles :
#[test]
fn les_vues_predefinies() {
let _ = View::front(); // yaw=0, pitch=0 : caméra en +X
let _ = View::side(); // yaw=90, pitch=0 : caméra en +Y
let _ = View::top(); // yaw=0, pitch=90 : vue du dessus
let _ = View::iso(); // yaw=45, pitch≈35.26 : isométrique
let _ = View::default(); // = iso()
}
Convention : yaw = pitch = 0 place la caméra en +X, regard vers l’origine, axe Z vers le haut. Le repère écran qui en résulte est (Y, Z).
Une seule fonction plot
Toute la sortie passe par la même méthode plot(view, save) exposée sur SubMesh et Mesh :
save | Effet | Compilation nécessaire |
|---|---|---|
None | ouvre une fenêtre interactive (souris : rotation au glisser, molette : zoom) | viz-interactive |
Some(path) avec extension .png | écrit un PNG | viz |
Some(path) avec extension .svg | écrit un SVG vectoriel | viz |
Some(path) avec extension .svgz | écrit le même SVG, gzippé | viz |
Tout autre extension est rejetée avec une erreur explicite. Le format vectoriel est ce qui rend ce socle particulièrement utile pour les figures de rapport : on conserve un trait propre quel que soit le zoom.
.svgz : quand une étude sort des figures par centaines
.svgz n’est pas un autre rendu, c’est le .svg compressé : le dézipper rend le fichier octet pour octet. Il pèse environ le dixième, parce qu’un maillage produit un balisage très répétitif, et se lit nativement dans un navigateur ou dans Inkscape.
Il est fait pour s’accumuler sur un disque, pas pour être publié. Sur le web le gain est nul : les serveurs compressent déjà le .svg à la volée, si bien qu’un .svgz ne change pas un octet transféré — et il rendrait binaires des fichiers que git suit très bien en texte. C’est pourquoi les figures de ce livre restent en .svg.
mesh.plot(save="piece.svg") # to version, to publish
mesh.plot(save="piece.svgz") # to stack by the hundred
Le .svg lui-même est déjà allégé à l’écriture : le générateur de SVG répète le style complet sur chaque balise, et pyrucast retire ce que le format sait hériter — un dessin identique au pixel près, pour environ la moitié des octets.
Dans la fenêtre interactive uniquement, le point de vue courant view=(yaw, pitch, scale) s’affiche en permanence en haut à droite — le même ordre que le tuple accepté par view=, pour recopier tel quel l’angle atteint à la souris/molette dans un appel plot() ultérieur.
Exemple Rust :
#[test]
fn exporter_un_sous_maillage_en_svg() -> Result<()> {
let (dossier, coords, _) = scene()?;
let a = Node::create_in(coords.clone(), &[0.0, 0.0, 0.0])?;
let b = Node::create_in(coords.clone(), &[1.0, 0.0, 0.0])?;
let c = Node::create_in(coords.clone(), &[0.0, 1.0, 0.0])?;
let mut sm = SubMesh::new(coords, ElementType::TRI3);
sm.add_cell(&[a.id(), b.id(), c.id()])?;
// Export vectoriel.
sm.plot(View::iso(), Some(&dossier.join("triangle.svg")))?;
// Fenêtre interactive (feature `viz-interactive`).
// sm.plot(View::default(), None).unwrap();
Ok(())
}
Côté Python, l’API miroir prend des tuples :
import pyrucast
coords = pyrucast.Coords(3)
a = coords.add_node([0.0, 0.0, 0.0])
b = coords.add_node([1.0, 0.0, 0.0])
c = coords.add_node([0.0, 1.0, 0.0])
mesh = pyrucast.Mesh(coords, "TRI3")
mesh.unit().add_cell([a, b, c])
# (yaw, pitch, scale) ; save=None ouvre la fenêtre interactive.
mesh.plot(view=(45.0, 35.264, 1.0), save="triangle.svg")
Nom de figure / de fenêtre (title)
Toutes les méthodes plot(...) — Mesh, SubMesh, NodeField, ElementField — acceptent un argument nommé optionnel title, qui sert de nom de figure :
- en export fichier (PNG/SVG), il est gravé centré en bas de l’image, dans une bande réservée sous le tracé ;
- en fenêtre interactive (
save=None), il devient le titre de la fenêtre (barre de titre de l’OS).
title=None (défaut) : aucune légende en bas et titre de fenêtre par défaut (pyrucast). Une chaîne vide vaut None.
mesh.plot(
save="piece.svg", title="cantilever beam"
) # caption centred at the SVG's bottom
mesh.plot(save="t.svg", field=t_field, title="temperature") # combines with field
# mesh.plot(title="ma pièce") # nomme la fenêtre interactive (bloquant)
Pour les courbes d’
Evolution/SubEvolution, letitleexistant reste la légende en haut du graphe (voir plus bas) ; il n’est pas repris en bas.
Couleur de face par SubMesh
Chaque SubMesh porte une propriété face_color (type RgbColor, format (r, g, b) sur 8 bits) utilisée par la couche viz pour remplir les facettes. Cette donnée n’a aucun effet sur les calculs ; elle est simplement persistée avec le maillage et consommée par plot. Couleur par défaut : un bleu clair (180, 200, 230).
Côté Rust :
#[test]
fn chaque_zone_porte_sa_couleur() -> Result<()> {
let (_, coords, maillage) = scene()?;
let mut sm = SubMesh::new(coords, ElementType::TRI3);
sm.set_face_color(RgbColor::new(220, 60, 60));
assert_eq!(sm.face_color(), RgbColor::new(220, 60, 60));
// The same colour for **every** zone of a mesh, without a loop: the method
// returns the mesh, so it chains.
let bleu = RgbColor::new(60, 60, 220);
assert_eq!(maillage.set_face_color(bleu).cell_count(), 1);
assert!(maillage.iter().all(|z| z.read().face_color() == bleu));
Ok(())
}
Côté Python :
sm = pyrucast.Mesh(coords, "TRI3")[0] # view of the single submesh
sm.face_color = (220, 60, 60)
assert sm.face_color == (220, 60, 60)
# The same colour for **every** zone of a mesh, without a loop: the method
# returns the mesh, so it chains.
piece = pyrucast.Mesh(coords, "TRI3").set_face_color((60, 60, 220))
assert all(zone.face_color == (60, 60, 220) for zone in piece)
Quand on appelle Mesh.plot, chaque sous-maillage est rendu avec sa propre face_color, ce qui permet de distinguer visuellement des composants regroupés dans un même maillage (par exemple : peau / cœur / interfaces).
Pour peindre toutes les zones d’un coup, Mesh.set_face_color((r, g, b)) pose la même couleur sur chacune et rend le maillage — les mêmes zones, pas des copies —, ce qui permet de l’enchaîner : pyrucast.mesh.circle(...).set_face_color((220, 60, 60)).plot(). La couleur étant une donnée de tracé, un maillage scellé l’accepte : le sceau fige la connectivité, pas la façon de la dessiner.
Coloration par un champ — NodeField ou ElementField
plot accepte un argument optionnel field — un NodeField ou un ElementField, interchangeables — qui remplace la couleur uniforme par une couleur tirée d’une colormap appliquée aux valeurs du champ.
Le rendu raisonne par élément : pour dessiner un élément, il lui faut des valeurs à ses nœuds, propres à cet élément.
NodeField: les valeurs nodales sont lues directement (champ continu par construction ; un nœud absent du support prend la moyenne des nœuds présents).ElementField: les valeurs vivent aux points de Gauss. Les valeurs nodales du tracé viennent d’un moindre carré local à l’élément (fit de l’interpolant Lagrange aux valeurs de Gauss de cet élément). Aucune moyenne entre éléments voisins : les discontinuités inter-éléments — flux, contraintes — restent visibles, c’est une information physique. Avec un seul point de Gauss, le fit dégénère en couleur constante par élément.
Affichage commun aux deux types :
- Composante affichée : la première composante du champ par défaut ; on en choisit une autre via
component="<nom>". - Échelle : linéaire entre le minimum et le maximum observés sur le maillage rendu, sauf si on fixe les bornes (voir Bornes).
- Colorbar : une barre verticale graduée est dessinée sur le bord droit de l’image (bas = borne basse, haut = borne haute), avec le même dégradé que les cellules.
- Bandeau : en haut de l’image, le nom de la composante affichée et l’intervalle
[min, max].
Rendu interpolé (smooth)
Par défaut (smooth=4), la couleur suit les fonctions de forme à l’intérieur de chaque élément : chaque maille est sous-découpée en sous-triangles (TRI3 → n², QUA4 → 2n²) dont la géométrie et la valeur sont évaluées par N_i(ξ) — y compris le gauchissement bilinéaire des QUA4/HEX8. Le filaire noir n’est tracé que sur les arêtes d’origine des éléments. smooth=0 revient à une couleur plate par cellule (moyenne des valeurs nodales) ; monter smooth lisse davantage au prix de n² polygones par élément.
Le sous-découpage est purement graphique et interne à chaque élément : les sous-sommets d’une arête partagée sont évalués séparément de chaque côté, donc les discontinuités d’un ElementField traversent le rendu interpolé sans être gommées.
Tracé d’un champ seul
element_field.plot(...)fonctionne sans maillage : chaque zone retrouve son sous-maillage via son sous-espace EF (partagé, pas copié).node_field.plot(...)trace un nuage de points colorés : son support POI1 ne porte pas de connectivité, aucune surface ne peut être inférée — pour des surfaces, passer parmesh.plot(field=...)avec le maillage d’origine.
Échelles de couleur (cmap)
Cinq colormaps sont disponibles, sélectionnées par leur nom (insensible à la casse). Un nom inconnu lève une erreur listant les noms acceptés.
cmap | Dégradé | Usage |
|---|---|---|
"viridis" (défaut) | violet → bleu → vert → jaune | usage général ; perceptuellement uniforme, lisible en niveaux de gris et pour les daltoniens |
"coolwarm" | bleu → blanc → rouge | données signées centrées sur 0 (le blanc marque le milieu de l’échelle) |
"hot" | noir → rouge → jaune → blanc | rendu thermique |
"gray" | noir → blanc | impression N&B, superposition |
"jet" | bleu → vert → rouge | ancien défaut, conservé |
Bornes de l’échelle (vmin / vmax)
Par défaut l’échelle couvre le min/max des valeurs par cellule. On peut fixer l’une ou l’autre borne (ou les deux) — celle laissée à None continue de suivre les données. Utile pour comparer plusieurs figures sur une échelle commune, ou pour centrer une colormap divergente (vmin = -vmax avec "coolwarm").
Côté Rust, l’argument scale (ColorScale) regroupe colormap et bornes :
#[test]
fn tracer_un_champ_avec_son_echelle() -> Result<()> {
let (dossier, _, mesh) = scene()?;
let poi1_h = mesh::to_poi1(&mesh)?.get(0)?;
// A displacement field with 2 components "UX" / "UY" on a POI1. `FieldArg`
// takes the **aggregate**: a lone zone is lifted by `NodeField::from_sub`.
let sub = SubNodeField::from_poi1(&poi1_h, vec!["UX".into(), "UY".into()])?;
// ... remplissage ...
let u = NodeField::from_sub(sub);
// Échelle auto, viridis, première composante, rendu interpolé niveau 4.
mesh.plot_with_field(
View::default(),
Some(&dossier.join("ux.svg")),
FieldArg::Node(&u),
None,
ColorScale::default(),
4,
None, // titre
)?;
// Component "UY", coolwarm colormap, bounds fixed at [-1, 1], flat.
let scale = ColorScale {
cmap: Colormap::CoolWarm,
vmin: Some(-1.0),
vmax: Some(1.0),
};
mesh.plot_with_field(
View::default(),
Some(&dossier.join("uy.svg")),
FieldArg::Node(&u),
Some("UY"),
scale,
0,
None, // titre
)?;
Ok(())
}
Côté Python, cmap, vmin et vmax sont des arguments nommés de plot :
# Default component, viridis, automatic scale.
mesh.plot(save="t.svg", field=t_field)
# Composante "UY", colormap "coolwarm", bornes fixées.
mesh.plot(
save="uy.svg",
field=u_field,
component="UY",
cmap="coolwarm",
vmin=-1.0,
vmax=1.0,
)
# Ceiling only set: the floor follows the data's minimum.
mesh.plot(save="t.svg", field=t_field, vmax=100.0)
# Field at the Gauss points: strictly the same call.
mesh.plot(save="flux.svg", field=flux_field)
Bouton de sélection dans la fenêtre interactive
En mode interactif (viz-interactive), un bouton cliquable apparaît au sommet de la fenêtre, affichant la composante actuelle et son intervalle. Deux manières équivalentes d’en changer :
- Clic sur le bouton — cycle dans l’ordre des composantes du champ ;
- Touche
Tab— même effet, sans toucher à la souris.
La caméra (rotation à la souris, molette, axes affichés via A) continue de fonctionner exactement comme en plot classique ; seul un clic sur le bouton est intercepté, les clics ailleurs lancent une rotation comme d’habitude.
Tracé d’une évolution
L’objet Evolution / SubEvolution expose plot(...), qui s’adapte au type de valeur tabulée :
- évolution de scalaires → une courbe X-Y : l’abscisse est la variable, l’ordonnée la valeur. Un agrégat à plusieurs zones trace une ligne par zone avec légende ; chaque échantillon tabulé est marqué d’un point. Les libellés se règlent par
x_label/y_label/title. Les arguments de champ (mesh,component,cmap, …) sont sans effet ici. - évolution de champs → le champ est rendu comme par
mesh.plot(field=...), pour une valeur tabulée à la fois. La géométrie suit la même règle que le tracé d’un champ seul : champ par éléments → reconstruit son maillage via le support EF ; champ aux nœuds → nuage de points par défaut, ou surface si on passemesh=<maillage>.
import pyrucast as pc
# Courbe scalaire (variable → valeur).
e = pc.Evolution([(0.0, 10.0), (1.0, 20.0), (2.0, 5.0)])
e.plot(save="courbe.svg", x_label="temps", y_label="T", title="évolution de T")
# Evolution of a field at the nodes: one whole NodeField per time step.
ev = pc.Evolution([(0.0, champ_t0), (1.0, champ_t1), (2.0, champ_t2)])
ev.plot(save="frame.png", frame=2) # one tabulated value (default: the last)
ev.plot(save="frame_surf.png", mesh=maillage) # surface rendering on a supplied mesh
Slider de valeur tabulée (fenêtre interactive)
En mode interactif (viz-interactive, save=None), une évolution de champs ouvre la fenêtre avec un slider dessiné en bas, qui choisit quelle valeur tabulée est affichée (le libellé indique frame k/n x=…) :
- glisser le curseur du slider à la souris ;
- touches
←/→pour reculer / avancer d’un pas tabulé.
Le bouton de composante (clic / Tab) et la caméra (rotation, molette, axes via A) fonctionnent comme d’habitude ; un clic sur le slider est intercepté, ailleurs c’est une rotation. Le slider ne choisit que parmi les valeurs tabulées — il n’interpole pas entre elles (pour une valeur intermédiaire, voir interpolate sur la page Évolution).
Toutes les sous-évolutions d’un agrégat tracé doivent partager la même grille d’abscisses (un index de frame global l’exige) ; sinon
plotlève une erreur.
Types d’éléments rendus
Tous les types d’éléments sont rendus, chacun converti en une primitive géométrique :
| Type | Primitive | Rendu |
|---|---|---|
POI1 | point | un point coloré |
SEG2 | segment | une arête |
TRI3 | face | triangle plein + contour noir |
QUA4 | face | quadrangle plein + contour noir |
TET4 | faces triangulaires | la peau du volume (facettes de bord) |
HEX8 | faces quadrangulaires | la peau du volume (facettes de bord) |
Mesh.plot parcourt tous ses sous-maillages et dessine chacun selon son type. L’ajout d’un éventuel nouveau type d’élément se fera sans changement d’API, en étendant le match de submesh_primitives dans src/viz/mesh_draw.rs.
Maillages volumiques pleins
Pour un sous-maillage volumique (TET4 / HEX8), seules les facettes de bord sont dessinées : une facette partagée par deux éléments est intérieure au solide, jamais visible, et donc supprimée (une facette est de bord quand elle n’apparaît que dans un seul élément). Cela rend un maillage volumique plein comme une surface fermée opaque au lieu d’un enchevêtrement de toutes les facettes internes, et divise à peu près par deux le nombre de primitives.
Les facettes sont tracées opaques : combinées au tri en profondeur de l’algorithme du peintre, elles réalisent l’élimination des faces cachées (une facette proche recouvre intégralement celles derrière elle), si bien qu’on ne voit que la peau tournée vers la caméra. La suppression des faces intérieures s’applique aussi bien au tracé géométrique qu’à la coloration par un champ (plate et interpolée), de sorte qu’un champ sur un maillage volumique colore correctement sa surface externe.
Peau opaque ou fil de fer
Pour le tracé d’un maillage seul (sans champ), deux styles sont disponibles :
| Style | Rendu |
|---|---|
Surface (défaut) | la peau externe opaque ; l’intérieur est masqué |
Wireframe | toutes les arêtes en fil de fer (y compris les arêtes intérieures des volumes), sans remplissage — un tracé transparent |
Le fil de fer trace chaque arête distincte (les arêtes partagées par plusieurs cellules ne sont dessinées qu’une fois) dans la face_color du sous-maillage, donc les composants d’un Mesh restent distinguables. Ce choix n’a pas de sens pour la coloration par un champ (un champ peint toujours les faces) : le combiner avec field lève une erreur.
Côté Rust, le style est un argument [MeshStyle] passé à plot_styled :
#[test]
fn peau_opaque_ou_fil_de_fer() -> Result<()> {
let (dossier, _, mesh) = scene()?;
// Peau opaque (équivalent de plot).
mesh.plot_styled(
View::iso(),
Some(&dossier.join("solide.svg")),
MeshStyle::Surface,
None, // titre
)?;
// Wireframe: every edge.
mesh.plot_styled(
View::iso(),
Some(&dossier.join("fil.svg")),
MeshStyle::Wireframe,
None, // titre
)?;
Ok(())
}
Côté Python, c’est l’argument booléen wireframe de plot :
mesh.plot(save="solide.svg") # peau opaque (défaut)
mesh.plot(save="fil.svg", wireframe=True) # fil de fer
# Pointless with a field: raises ValueError.
# mesh.plot(save="x.svg", field=t_field, wireframe=True)
Axisymétrie : section méridienne ou corps de révolution
Un maillage bâti sur des coordonnées axisymétriques est le demi-plan méridien (r, z) d’un corps de révolution : tracé tel quel, il se lit comme une section plane 2-D — ce qui est fidèle à l’objet calculé, mais peu parlant pour montrer la pièce.
L’option revolve balaie cette section autour de l’axe r = 0 et dessine le solide qu’elle décrit. Rien n’est recalculé : le balayage a lieu sur les primitives de rendu, juste avant la projection, donc il s’applique de la même façon au maillage seul, au fil de fer, à la coloration par un champ (plate et interpolée) et aux évolutions.
| Argument | Défaut | Effet |
|---|---|---|
revolve | False | True ⇒ trace le corps de révolution au lieu de la section |
revolve_angle | 360.0 | angle balayé en degrés, dans ]0, 360] |
Un angle partiel ouvre la pièce et dessine la section méridienne — et le champ qui la colore — aux deux extrémités du balayage, comme une coupe.
import pyrucast
coords = pyrucast.Coords.axisymmetric() # (r, z), r ≥ 0
section = [coords.add_node(p) for p in ([1.0, 0.0], [2.0, 0.0], [1.0, 1.0])]
mesh = pyrucast.Mesh(coords, "TRI3")
mesh.unit().add_cell(section)
# … computation, then a temperature field at the section's nodes:
t_field = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes(section), ["T"])
mesh.plot(save="section.svg") # la section plane (défaut)
mesh.plot(save="piece.svg", revolve=True) # le corps de révolution complet
mesh.plot(save="coupe.svg", revolve=True, revolve_angle=270.0) # opened to 270°
mesh.plot(save="t3d.svg", field=t_field, revolve=True) # field on the body
Côté Rust, c’est le champ revolve de la View, portant un [Revolve] :
#[test]
fn le_corps_de_revolution_se_demande_dans_la_vue() -> Result<()> {
// The mesh must be **axisymmetric**: it is its frame that gives
// l'axe autour duquel le balayage tourne.
let (dossier, mesh) = section_axisymetrique()?;
let vue = View {
revolve: Some(Revolve::full()),
..View::iso()
};
mesh.plot(vue, Some(&dossier.join("piece.svg")))?;
// Partial sweep, or angular fineness picked by hand.
let _ = Revolve::new(270.0).unwrap(); // un secteur par 10°
let _ = Revolve::with_sectors(360.0, 72).unwrap(); // silhouette plus lisse
Ok(())
}
Demander revolve sur une géométrie non axisymétrique est une erreur : l’abscisse n’y est pas un rayon, le balayage n’aurait aucun sens.
Ce qui est dessiné
Seul ce qui est visible est émis, l’algorithme du peintre faisant le reste :
- une face balaie un anneau de matière. Seules les arêtes de bord de la section engendrent une surface latérale : une arête partagée par deux cellules reste enfouie dans la matière. C’est le pendant exact de la suppression des facettes intérieures des maillages volumiques ;
- le contour d’élément du rendu interpolé suit la même règle, si bien que le quadrillage du maillage reste tracé sur la surface balayée ;
- segments et points sont répétés à chaque station angulaire, et les cercles décrits par leurs extrémités sont ajoutés : c’est le fil de fer (resp. le nuage de nœuds) du maillage balayé ;
- une arête posée sur l’axe (
r = 0) ne balaie rien ; une arête qui le touche par une extrémité balaie un cône (triangles au lieu de quadrangles).
Bascule dans la fenêtre interactive
En mode interactif, sur une géométrie axisymétrique uniquement :
- un bouton en haut à gauche indique l’état courant (
2D section/3D 360deg) et bascule au clic ; - touche
R— même effet, sans toucher à la souris.
La caméra se recentre à chaque bascule : le corps balayé est centré sur l’axe, la section ne l’est pas, sans quoi la pièce sortirait du cadre.
Export vers ParaView (export_vtk)
Pour les maillages industriels — ou simplement pour exploiter les filtres de
ParaView — export_vtk écrit un fichier VTK legacy
(UNSTRUCTURED_GRID) que ParaView lit nativement. C’est l’opérateur
d’export (src/ops/export), pendant « écriture » du lecteur read_gmsh.
import pyrucast
# Géométrie seule.
pyrucast.export.export_vtk(mesh, "maillage.vtk")
# Geometry + field at the nodes (POINT_DATA).
pyrucast.export.export_vtk(mesh, "solution.vtk", field=temperature)
# Geometry + field at the Gauss points (CELL_DATA): one value per cell = the
# intra-element mean of the cell's Gauss points.
pyrucast.export.export_vtk(mesh, "contraintes.vtk", field=stresses)
- Chaque sous-maillage est écrit ; les types d’éléments se traduisent un pour
un (
POI1→VERTEX,SEG2→LINE,TRI3→TRIANGLE,QUA4→QUAD,TET4→TETRA,PYRA5→PYRAMID,PENTA6→WEDGE,HEX8→HEXAHEDRON, et leurs variantes quadratiques) et l’ordre local des nœuds coïncide déjà avec celui de VTK : la connectivité est copiée telle quelle. - Une
Coords2-D est complétée en 3-D avecz = 0. - Un
NodeFielddonne un tableauSCALARSpar composante aux points (valeur nodale,0là où le champ n’est pas défini) ; unElementFielddonne un tableau par composante aux cellules. La valeur par cellule est la moyenne des points de Gauss de cette cellule (moyenne intra-élément uniquement — les discontinuités inter-éléments restent visibles). Le champ aux éléments doit couvrir toutes les cellules du maillage : il provient d’un espace bâti sur ce maillage.
L’écrivain VTK n’est qu’un formateur : la mise à plat (quels points, quelles
cellules dans quel ordre, quelles valeurs) vient de to_arrays, la sortie que
partagent tous les échanges (voir
to_arrays). Les
fichiers ASCII produits sont identiques, octet pour octet, à ceux de la
première version.
Binaire
binary=True écrit les mêmes sections, les nombres en binaire brut
gros-boutiste comme l’exige le format legacy : fichier bien plus petit,
lecture bien plus rapide pour un gros maillage.
# Same file, numbers written raw (big-endian): smaller, faster to read.
pyrucast.export.export_vtk(mesh, "solution_bin.vtk", field=t1, binary=True)
Séries temporelles
Passée une Evolution de champs, export_vtk écrit une série : un fichier
par valeur tabulée, nom_0000.vtk, nom_0001.vtk…, et un index
nom.vtk.series (JSON) qui donne à chacun son temps, l’abscisse de
l’évolution. ParaView ouvre l’index comme un seul jeu de données muni d’un
curseur de temps. Le maillage n’est mis à plat qu’une fois pour tous les pas.
# An Evolution of fields: one file per time, plus an index for ParaView.
chauffe = pyrucast.Evolution([(0.0, t0), (30.0, t1)])
pyrucast.export.export_vtk(mesh, "chauffe.vtk.series", field=chauffe, binary=True)
# → chauffe_0000.vtk, chauffe_0001.vtk and chauffe.vtk.series:
# open the .series in ParaView, the time slider plays the two steps.
# The steps themselves, as whole fields:
print(chauffe.shared_abscissas(), len(chauffe.frames())) # [0.0, 30.0] 2
Côté Rust : ops::export::write_vtk_mesh, write_vtk_node_field,
write_vtk_element_field (avec un VtkEncoding), write_vtk_series, et les
variantes vtk_*_string qui rendent le texte ASCII sans toucher au disque.
Limites actuelles et évolutions possibles
- VTK legacy uniquement. Pas de
.vtu(XML) ni de compression. Évolution : un back-end.vtu, recommandé par ParaView. - Composantes en scalaires séparés. Chaque composante donne un tableau
SCALARSdistinct ; pas de regroupement enVECTORS/TENSORS. Un déplacement(ux, uy, uz)sort en trois scalaires plutôt qu’en un champ vectoriel directement « warpable » dans ParaView. CELL_DATA= moyenne des points de Gauss. VTK ne connaît pas de donnée au point d’intégration. Pour garder les points de Gauss, passer par MED (to_medcoupling,ON_GAUSS_PT).- Un champ par fichier. Plusieurs champs simultanés dans un même fichier restent à faire.
Échanger avec gmsh et Salome (to_gmsh, to_medcoupling)
Les deux sens des échanges passent par le même format à plat. Pour la lecture,
voir from_gmsh
et from_medcoupling.
to_gmsh pousse maillages et champs dans la session gmsh en cours, comme un
nouveau modèle : chaque clé devient un groupe physique, chaque champ une vue —
NodeData pour un champ aux nœuds, ElementData (moyenne par maille) pour un
champ aux éléments, un pas par valeur d’une Evolution. gmsh dessine 1, 3 ou
9 composantes : un champ d’un autre nombre donne une vue par composante.
# A new gmsh model: the meshes as physical groups, the fields as views.
pyrucast.export.to_gmsh({"piece": piece}, {"T": temperature}, model_name="calcul")
gmsh.write("calcul.msh") # or gmsh.fltk.run() to look at it
gmsh.view.write(gmsh.view.getTags()[0], "temperature.pos")
# from_gmsh reads the views back: a NodeData view is a NodeField.
_, champs = pyrucast.mesh.from_gmsh(pyrucast.Coords(dim=3))
print(sorted(champs)) # ['T']
to_medcoupling construit un medcoupling.MEDFileData, qu’on écrit avec sa
propre méthode write : chaque clé devient un groupe MED (un maillage POI1, un
groupe de nœuds), un champ aux nœuds ON_NODES, un champ aux éléments
ON_GAUSS_PT avec sa règle déclarée dans l’élément de référence MED
(gauss=False : ON_CELLS, la moyenne par maille), un profil quand le champ
ne couvre pas tout un niveau, une Evolution un pas de temps par valeur.
# Each key becomes a MED group; an Evolution gives one time step per value.
chauffe = pyrucast.Evolution([(0.0, froid), (60.0, temperature)])
donnees = pyrucast.export.to_medcoupling(
{"plaque": plaque, "bas": bas},
{"T": chauffe, "sxx": contraintes}, # sxx: ON_GAUSS_PT
mesh_name="plaque",
)
donnees.write("plaque.med", 2) # a medcoupling object: its own writer
Limite de medcoupling pour
TRI6,PENTA15etHEX20aux points de Gauss. Les éléments de référence par défaut de medcoupling ne suivent pas, pour ces trois types, sa propre connectivité MED (leTRI6répète un sommet, les deux autres rangent leurs nœuds milieux autrement), et son localisateur de points de Gauss n’en accepte pas d’autres. pyrucast déclare l’élément cohérent avec la connectivité écrite : le fichier est juste et se relit dans pyrucast, maisgetLocalizationOfDiscr()de medcoupling le refuse. Les douze autres types sont localisés exactement par medcoupling.
Ni gmsh ni medcoupling ne sont des dépendances : import pyrucast ne charge
aucun des deux, ils ne sont importés qu’à l’appel de ces fonctions.
Notes techniques
- Le rendu utilise l’algorithme du peintre : projection 3D → 2D, tri des triangles par profondeur moyenne (du plus lointain au plus proche), puis dessin des facettes pleines opaques suivies des arêtes noires en superposition. L’opacité assure l’élimination des faces cachées (les facettes proches recouvrent les lointaines) ; c’est ce qui fait qu’un solide 3D se lit comme un solide et non comme une coque transparente. Coût :
O(n log n)à chaque rafraîchissement, raisonnable jusqu’à quelques milliers de cellules. Pour des maillages plus lourds ou un post-traitement avancé, exporter vers ParaView avecexport_vtk(voir ci-dessus). - Limite connue de l’algorithme du peintre : pour un solide fortement non convexe, le tri par profondeur moyenne peut mal ordonner deux facettes qui se chevauchent en profondeur. C’est inhérent à la méthode ; un z-buffer par pixel le corrigerait, au prix d’un rendu non vectoriel.
- Le balayage axisymétrique (
revolve) multiplie le nombre de primitives par le nombre de secteurs, mais seulement sur le bord de la section (les arêtes intérieures ne balaient rien) : le coût reste proportionnel au périmètre, pas à la surface. Une section partielle ajoute en plus une copie de la section à chaque extrémité. - L’export reste portable Linux ↔ Windows : tout le rendu se fait en CPU, sans pilote GPU. Le binaire
viz-interactivenécessite en revanche un serveur d’affichage (X11, Wayland ou Windows) à l’exécution — ce qui est attendu pour une fenêtre interactive. - Le mode interactif est confiné à
src/viz/window.rs; il est entièrement encapsulé derrière la featureviz-interactiveet ne s’invite pas dans la couche de calcul.