forked from 1b-agent/fieldcraft-upstream
engine: results.export.vtu writes a solved step for ParaView
Static, frequency (per mode), nonlinear and explicit (per frame, with a .pvd collection) and heat results; fluid steps are refused. Wedges keep the model's node order: VTK 9's wedge has the same orientation. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
439dfc678f
commit
f9a0282acc
12 files changed
+468
-38
No files matched your search
@@ -92,6 +92,7 @@ is committed and listed here with its exports.
|
|||||||
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
|
| assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
| assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<title>results.export.vtu</title>
|
||||||
|
<g id="Layer_1" data-name="Layer 1">
|
||||||
|
<path d="M4 3 L12 3 L16 7 L16 21 L4 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 3 L12 7 L16 7" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 17 L7 14 M10 17 L10 11 M13 17 L13 13" fill="none" stroke="#4f9ce8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 837 B |
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"shapes": [{"d": "M4 3 L12 3 L16 7 L16 21 L4 21 Z", "fill": "soft", "stroke": "line"}, {"d": "M12 3 L12 7 L16 7", "stroke": "line"}, {"d": "M7 17 L7 14 M10 17 L10 11 M13 17 L13 13", "stroke": "accent", "width": 2}, {"d": "M15 15 L21 15 M18.19 16.53 L21 15 L18.19 13.47", "stroke": "accent", "width": 1.5}]}
|
||||||
@@ -618,6 +618,7 @@ pub static CATALOGUE: &[Capability] = &[
|
|||||||
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
|
soon("results.cut", "Cut", Results, "Plots", "M9", "Section cuts through the model"),
|
||||||
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
|
soon("results.display_group", "Display Group", Results, "Plots", "M9", "Show and hide parts of the model"),
|
||||||
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
|
soon("results.streamlines", "Streamlines", Results, "Plots", "M12", "Streamlines and flow fields"),
|
||||||
|
ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"),
|
||||||
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
soon("results.read", "Read Results", Results, "Files", "M9", "Read results in public formats"),
|
||||||
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
soon("results.convergence", "Convergence", Results, "Files", "M9", "Mesh convergence studies"),
|
||||||
// ---- Tools ----
|
// ---- Tools ----
|
||||||
|
|||||||
@@ -1838,6 +1838,17 @@ pub const EXPORT_CSV: &[Field] = &[
|
|||||||
int("mode", "Mode", 1, 1_000_000).help("Probe: the mode to read, for frequency and buckling steps."),
|
int("mode", "Mode", 1, 1_000_000).help("Probe: the mode to read, for frequency and buckling steps."),
|
||||||
int("frame", "Frame", 0, 1_000_000_000).help("Probe: the frame to read, for explicit and transient steps."),
|
int("frame", "Frame", 0, 1_000_000_000).help("Probe: the frame to read, for explicit and transient steps."),
|
||||||
];
|
];
|
||||||
|
pub const EXPORT_VTU: &[Field] = &[
|
||||||
|
path("path", "File", true).help("A .vtu file for one state, or a .pvd collection for every frame or mode.").req(),
|
||||||
|
SOLVED,
|
||||||
|
int("mode", "Mode", 1, 1_000_000).help("Frequency steps: the mode to write; every mode in a .pvd when empty.").hint("all (.pvd) or 1"),
|
||||||
|
choice("frames", "Frames", &[("all", "Every frame"), ("last", "The last frame")])
|
||||||
|
.help("A .pvd series: every frame or only the last.")
|
||||||
|
.def("\"all\""),
|
||||||
|
choice("encoding", "Encoding", &[("ascii", "Text"), ("binary", "Binary (base64)")])
|
||||||
|
.help("Text is readable; binary is smaller and exact.")
|
||||||
|
.def("\"ascii\""),
|
||||||
|
];
|
||||||
pub const REACTIONS: &[Field] = &[
|
pub const REACTIONS: &[Field] = &[
|
||||||
SOLVED,
|
SOLVED,
|
||||||
references("sets", "Sets", C::Set)
|
references("sets", "Sets", C::Set)
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ pub static ALL: &[CommandSpec] = &[
|
|||||||
results::FRAME,
|
results::FRAME,
|
||||||
results::REACTIONS,
|
results::REACTIONS,
|
||||||
results::EXPORT_CSV,
|
results::EXPORT_CSV,
|
||||||
|
results::EXPORT_VTU,
|
||||||
results::CLEAR,
|
results::CLEAR,
|
||||||
diagnostics::CRASH_REPORTS,
|
diagnostics::CRASH_REPORTS,
|
||||||
diagnostics::CRASH_REPORT,
|
diagnostics::CRASH_REPORT,
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ pub const REACTIONS: CommandSpec = CommandSpec {
|
|||||||
run: reactions,
|
run: reactions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const EXPORT_VTU: CommandSpec = CommandSpec {
|
||||||
|
id: "results.export.vtu",
|
||||||
|
label: "Export VTU",
|
||||||
|
params: r#"{"step": "Load", "path": "out.vtu" | "out.pvd", "mode": 1, "frames": "all|last", "encoding": "ascii|binary"} — writes a solved step as VTK XML unstructured grids for ParaView: the mesh (points in node label order, cells in element label order, arrays `node_label` and `element_label`), the step's nodal fields as point data and per-element fields as cell data. Static: displacement, rotation (with shells or beams), reaction, stress (6 components in VTK order XX, YY, ZZ, XY, YZ, XZ), von_mises, temperature (coupled steps); nonlinear static: displacement per frame, the final fields on the last; frequency: mode_shape, with mode, frequency and eigenvalue as field data; explicit: displacement and velocity per frame, cell stress, plastic_strain, damage, eroded; heat: temperature per frame, heat_flux on the last. Point array `has_result` is 0 at nodes the step has no values for (written as 0, as are values that are not finite). A `.vtu` path writes one state (the last frame, or `mode`, default 1); a `.pvd` path writes every frame or mode (`frames: "last"` only the last) as `out_0000.vtu`, … beside it and the collection listing each with its time (load factor, mode number). `encoding` ascii (default) or binary (inline base64). Fluid steps are not supported yet. Replies {files, bytes, points, cells}"#,
|
||||||
|
form: Some(super::forms::EXPORT_VTU),
|
||||||
|
kind: CommandKind::Session,
|
||||||
|
requires: &[Requirement::Result],
|
||||||
|
run: export_vtu,
|
||||||
|
};
|
||||||
|
|
||||||
pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
pub const EXPORT_CSV: CommandSpec = CommandSpec {
|
||||||
id: "results.export.csv",
|
id: "results.export.csv",
|
||||||
label: "Export Results (CSV)",
|
label: "Export Results (CSV)",
|
||||||
@@ -379,6 +389,251 @@ fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
|
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most files one `results.export.vtu` writes.
|
||||||
|
pub const MAX_VTU_FILES: usize = 10_000;
|
||||||
|
/// Most bytes one `results.export.vtu` writes, over all its files.
|
||||||
|
pub const MAX_VTU_BYTES: u64 = 8 << 30;
|
||||||
|
|
||||||
|
/// One state of a result written as a `.vtu`: the end of a linear step, a mode (from 1) or a
|
||||||
|
/// frame (from 0).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
enum State {
|
||||||
|
Final,
|
||||||
|
Mode(usize),
|
||||||
|
Frame(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_vtu(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
use fieldcraft_io::vtk::{self, Encoding};
|
||||||
|
let step = step_for(s, p, Want::Any)?;
|
||||||
|
let mode: Option<usize> = opt(p, "mode")?;
|
||||||
|
let last_only = match opt::<String>(p, "frames")?.as_deref() {
|
||||||
|
None | Some("all") => false,
|
||||||
|
Some("last") => true,
|
||||||
|
Some(_) => return Err(EngineError::invalid("frames", "must be \"all\" or \"last\"")),
|
||||||
|
};
|
||||||
|
let encoding = match opt::<String>(p, "encoding")?.as_deref() {
|
||||||
|
None | Some("ascii") => Encoding::Ascii,
|
||||||
|
Some("binary") => Encoding::Base64,
|
||||||
|
Some(_) => return Err(EngineError::invalid("encoding", "must be \"ascii\" or \"binary\"")),
|
||||||
|
};
|
||||||
|
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
||||||
|
let target = std::path::Path::new(&path);
|
||||||
|
let series = match target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() {
|
||||||
|
Some("vtu") => false,
|
||||||
|
Some("pvd") => true,
|
||||||
|
_ => return Err(EngineError::Params("`path` must end in .vtu (one file) or .pvd (a series of .vtu files)".into())),
|
||||||
|
};
|
||||||
|
let r = match s.latest_result(&step) {
|
||||||
|
Some((_, false)) => return Err(EngineError::Params(format!("the model changed since step `{step}` was solved; solve it again"))),
|
||||||
|
Some((r, true)) => std::sync::Arc::clone(r),
|
||||||
|
None => return Err(EngineError::Params(format!("step `{step}` has no result; solve it first"))),
|
||||||
|
};
|
||||||
|
let frames = |n: usize, what: &str| -> Result<Vec<State>> {
|
||||||
|
let last = n.checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no {what}")))?;
|
||||||
|
Ok(if series && !last_only { (0..n).map(State::Frame).collect() } else { vec![State::Frame(last)] })
|
||||||
|
};
|
||||||
|
if mode.is_some() && !matches!(r.kind(), Kind::Frequency(_)) {
|
||||||
|
return Err(EngineError::invalid("mode", "is for frequency and buckling steps"));
|
||||||
|
}
|
||||||
|
let states: Vec<State> = match r.kind() {
|
||||||
|
Kind::Fluid(_) => return Err(EngineError::Params("fluid results are per cell; not supported yet".into())),
|
||||||
|
Kind::Static(st) => match &st.nonlinear {
|
||||||
|
Some(h) if !h.frames.is_empty() => frames(h.frames.len(), "frames")?,
|
||||||
|
_ => vec![State::Final],
|
||||||
|
},
|
||||||
|
Kind::Frequency(f) => match mode {
|
||||||
|
Some(m) if m == 0 || m > f.modes.len() => {
|
||||||
|
return Err(EngineError::invalid("mode", format!("step `{step}` has modes 1 to {}", f.modes.len())));
|
||||||
|
}
|
||||||
|
Some(m) => vec![State::Mode(m)],
|
||||||
|
None if series && !last_only => (1..=f.modes.len()).map(State::Mode).collect(),
|
||||||
|
None if f.modes.is_empty() => return Err(EngineError::Params(format!("step `{step}` has no modes"))),
|
||||||
|
None => vec![State::Mode(1)],
|
||||||
|
},
|
||||||
|
Kind::Explicit(e) => frames(e.frames.len(), "field frames")?,
|
||||||
|
Kind::Heat(h) => frames(h.frames.len(), "temperatures")?,
|
||||||
|
};
|
||||||
|
if states.len() > MAX_VTU_FILES {
|
||||||
|
return Err(EngineError::Params(format!("step `{step}` has {} frames; at most {MAX_VTU_FILES} files are written", states.len())));
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = &s.model;
|
||||||
|
let (nodes, elements) = vtk::points_and_cells(model);
|
||||||
|
let build = |state: State| -> Result<(f64, String)> {
|
||||||
|
let (time, fields, data) = vtu_fields(&r, state, &nodes, &elements);
|
||||||
|
let data: Vec<(&str, f64)> = data.iter().map(|(n, v)| (*n, *v)).collect();
|
||||||
|
Ok((time, vtk::write_vtu(model, &fields, &data, encoding)?))
|
||||||
|
};
|
||||||
|
// The last state has the most fields: its size times the count bounds the output before
|
||||||
|
// anything is written.
|
||||||
|
let Some(&last) = states.last() else { return Err(EngineError::Params(format!("step `{step}` has nothing to write"))) };
|
||||||
|
let sample = build(last)?;
|
||||||
|
let estimate = (sample.1.len() as u64).saturating_mul(states.len() as u64);
|
||||||
|
if estimate > MAX_VTU_BYTES {
|
||||||
|
return Err(EngineError::Params(format!(
|
||||||
|
"the export would write about {estimate} bytes; at most {MAX_VTU_BYTES} (8 GiB) are written: choose `frames: \"last\"` or a single .vtu"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut written: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
let mut bytes = 0usize;
|
||||||
|
let result = (|| -> Result<Vec<String>> {
|
||||||
|
if !series {
|
||||||
|
fieldcraft_io::fs::write_atomic(target, sample.1.as_bytes(), false)?;
|
||||||
|
written.push(target.to_path_buf());
|
||||||
|
bytes += sample.1.len();
|
||||||
|
return Ok(vec![path.clone()]);
|
||||||
|
}
|
||||||
|
let stem = target.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
|
let dir = target.parent().unwrap_or(std::path::Path::new(""));
|
||||||
|
let mut entries: Vec<(f64, String)> = Vec::with_capacity(states.len());
|
||||||
|
let mut files = Vec::with_capacity(states.len() + 1);
|
||||||
|
for (k, &state) in states.iter().enumerate() {
|
||||||
|
let name = format!("{stem}_{k:04}.vtu");
|
||||||
|
let file = dir.join(&name);
|
||||||
|
let built = if state == last { None } else { Some(build(state)?) };
|
||||||
|
let (time, text) = built.as_ref().map_or((sample.0, sample.1.as_str()), |(t, x)| (*t, x.as_str()));
|
||||||
|
fieldcraft_io::fs::write_atomic(&file, text.as_bytes(), false)?;
|
||||||
|
written.push(file.clone());
|
||||||
|
bytes += text.len();
|
||||||
|
entries.push((time, name));
|
||||||
|
files.push(file.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
let list: Vec<(f64, &str)> = entries.iter().map(|(t, n)| (*t, n.as_str())).collect();
|
||||||
|
let collection = vtk::pvd(&list);
|
||||||
|
fieldcraft_io::fs::write_atomic(target, collection.as_bytes(), false)?;
|
||||||
|
bytes += collection.len();
|
||||||
|
files.push(path.clone());
|
||||||
|
Ok(files)
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(files) => Ok(json!({"files": files, "bytes": bytes, "points": nodes.len(), "cells": elements.len()})),
|
||||||
|
Err(e) => {
|
||||||
|
// Leave nothing half written behind.
|
||||||
|
for f in &written {
|
||||||
|
let _ = std::fs::remove_file(f);
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stress (xx, yy, zz, xy, xz, yz) in VTK's symmetric tensor order (XX, YY, ZZ, XY, YZ, XZ).
|
||||||
|
fn vtk_tensor(t: &[f64; 6]) -> [f64; 6] {
|
||||||
|
[t[0], t[1], t[2], t[3], t[5], t[4]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values given per label (`labels` sorted, as a result's nodes and elements are) spread over
|
||||||
|
/// `order` (the points' or cells' labels): `N` components each, 0 where a label has none.
|
||||||
|
fn spread<const N: usize>(order: &[u32], labels: &[u32], values: &[[f64; N]]) -> Vec<f64> {
|
||||||
|
let mut out = vec![0.0; order.len() * N];
|
||||||
|
for (label, v) in labels.iter().zip(values) {
|
||||||
|
if let Ok(i) = order.binary_search(label)
|
||||||
|
&& let Some(slot) = out.get_mut(i * N..(i + 1) * N)
|
||||||
|
{
|
||||||
|
slot.copy_from_slice(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalars(values: &[f64]) -> Vec<[f64; 1]> {
|
||||||
|
values.iter().map(|v| [*v]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fields of one state of a result, its time (load factor, mode number) and its field data.
|
||||||
|
fn vtu_fields(
|
||||||
|
r: &crate::StepResult,
|
||||||
|
state: State,
|
||||||
|
nodes: &[u32],
|
||||||
|
elements: &[u32],
|
||||||
|
) -> (f64, Vec<fieldcraft_io::vtk::Field>, Vec<(&'static str, f64)>) {
|
||||||
|
use fieldcraft_io::vtk::{Field, Location};
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut add = |name: &str, location: Location, components: usize, values: Vec<f64>, integer: bool| {
|
||||||
|
fields.push(Field { name: name.into(), location, components, values, integer });
|
||||||
|
};
|
||||||
|
let on = |labels: &[u32]| -> Vec<f64> { nodes.iter().map(|n| if labels.binary_search(n).is_ok() { 1.0 } else { 0.0 }).collect() };
|
||||||
|
add("has_result", Location::Point, 1, on(r.nodes()), true);
|
||||||
|
let point = Location::Point;
|
||||||
|
let cell = Location::Cell;
|
||||||
|
let time = match (r.kind(), state) {
|
||||||
|
(Kind::Static(st), _) => {
|
||||||
|
let frame = match (state, &st.nonlinear) {
|
||||||
|
(State::Frame(k), Some(h)) => h.frames.get(k).map(|f| (f.load_factor, f.displacement.as_slice(), k + 1 == h.frames.len())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let (time, displacement, last) = frame.unwrap_or((0.0, st.displacement.as_slice(), true));
|
||||||
|
add("displacement", point, 3, spread(nodes, &st.nodes, displacement), false);
|
||||||
|
if last {
|
||||||
|
if !st.shells.is_empty() || !st.beams.is_empty() {
|
||||||
|
add("rotation", point, 3, spread(nodes, &st.nodes, &st.rotation), false);
|
||||||
|
}
|
||||||
|
add("reaction", point, 3, spread(nodes, &st.nodes, &st.reaction), false);
|
||||||
|
if !st.stress.is_empty() {
|
||||||
|
let stress: Vec<[f64; 6]> = st.stress.iter().map(vtk_tensor).collect();
|
||||||
|
add("stress", point, 6, spread(nodes, &st.nodes, &stress), false);
|
||||||
|
add("von_mises", point, 1, spread(nodes, &st.nodes, &scalars(&st.von_mises)), false);
|
||||||
|
}
|
||||||
|
if let Some(t) = &st.temperature {
|
||||||
|
add("temperature", point, 1, spread(nodes, &st.nodes, &scalars(t)), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if st.nonlinear.is_some() {
|
||||||
|
data.push(("load_factor", time));
|
||||||
|
}
|
||||||
|
time
|
||||||
|
}
|
||||||
|
(Kind::Frequency(f), State::Mode(m)) => {
|
||||||
|
if let Some(mode) = m.checked_sub(1).and_then(|k| f.modes.get(k)) {
|
||||||
|
add("mode_shape", point, 3, spread(nodes, &f.nodes, &mode.shape), false);
|
||||||
|
data.extend([("mode", m as f64), ("frequency", mode.frequency), ("eigenvalue", mode.eigenvalue)]);
|
||||||
|
}
|
||||||
|
m as f64
|
||||||
|
}
|
||||||
|
(Kind::Explicit(e), State::Frame(k)) => {
|
||||||
|
let Some(f) = e.frames.get(k) else { return (0.0, fields, data) };
|
||||||
|
if !f.displacement.is_empty() {
|
||||||
|
add("displacement", point, 3, spread(nodes, &e.nodes, &f.displacement), false);
|
||||||
|
}
|
||||||
|
if !f.velocity.is_empty() {
|
||||||
|
add("velocity", point, 3, spread(nodes, &e.nodes, &f.velocity), false);
|
||||||
|
}
|
||||||
|
if !f.stress.is_empty() {
|
||||||
|
let stress: Vec<[f64; 6]> = f.stress.iter().map(vtk_tensor).collect();
|
||||||
|
add("stress", cell, 6, spread(elements, &e.elements, &stress), false);
|
||||||
|
}
|
||||||
|
if !f.plastic_strain.is_empty() {
|
||||||
|
add("plastic_strain", cell, 1, spread(elements, &e.elements, &scalars(&f.plastic_strain)), false);
|
||||||
|
}
|
||||||
|
if !f.damage.is_empty() {
|
||||||
|
add("damage", cell, 1, spread(elements, &e.elements, &scalars(&f.damage)), false);
|
||||||
|
}
|
||||||
|
if !f.failed.is_empty() {
|
||||||
|
let eroded: Vec<[f64; 1]> = f.failed.iter().map(|&b| [if b { 1.0 } else { 0.0 }]).collect();
|
||||||
|
add("eroded", cell, 1, spread(elements, &e.elements, &eroded), true);
|
||||||
|
}
|
||||||
|
data.push(("time", f.time));
|
||||||
|
f.time
|
||||||
|
}
|
||||||
|
(Kind::Heat(h), State::Frame(k)) => {
|
||||||
|
let time = h.times.get(k).copied().unwrap_or(0.0);
|
||||||
|
if let Some(t) = h.frames.get(k) {
|
||||||
|
add("temperature", point, 1, spread(nodes, &h.nodes, &scalars(t)), false);
|
||||||
|
}
|
||||||
|
if k + 1 == h.frames.len() && !h.flux.is_empty() {
|
||||||
|
add("heat_flux", point, 3, spread(nodes, &h.nodes, &h.flux), false);
|
||||||
|
}
|
||||||
|
data.push(("time", time));
|
||||||
|
time
|
||||||
|
}
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
(time, fields, data)
|
||||||
|
}
|
||||||
|
|
||||||
fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
|
fn export_csv(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let what: String = super::req(p, "what")?;
|
let what: String = super::req(p, "what")?;
|
||||||
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
let path = super::file_param(s, p, "path", fieldcraft_io::roots::Access::Write)?;
|
||||||
@@ -549,6 +804,164 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::cmd::solve::tests::cantilever;
|
use crate::cmd::solve::tests::cantilever;
|
||||||
|
|
||||||
|
/// The numbers of the first `DataArray` called `name` in a written `.vtu`.
|
||||||
|
fn vtu_array(xml: &str, name: &str) -> Vec<f64> {
|
||||||
|
let at = xml.find(&format!("Name=\"{name}\"")).unwrap_or_else(|| panic!("no array {name}"));
|
||||||
|
let start = at + xml[at..].find('>').unwrap() + 1;
|
||||||
|
let end = start + xml[start..].find("</DataArray>").unwrap();
|
||||||
|
xml[start..end].split_whitespace().map(|t| t.parse().unwrap()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scratch(name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("fieldcraft-vtu-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_vtu_writes_a_static_step_and_its_modes() {
|
||||||
|
let dir = scratch("static");
|
||||||
|
let out = dir.join("out.vtu").to_string_lossy().into_owned();
|
||||||
|
let mut s = Session::default();
|
||||||
|
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
|
||||||
|
let e = s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("Needs a solved step"), "{e}");
|
||||||
|
s.execute("solve.static", &json!({"step": "Load"})).unwrap();
|
||||||
|
let reply = s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap();
|
||||||
|
let xml = std::fs::read_to_string(&out).unwrap();
|
||||||
|
assert_eq!(reply["points"], s.model.nodes().len());
|
||||||
|
assert_eq!(reply["cells"], s.model.elements().len());
|
||||||
|
assert_eq!(reply["bytes"], xml.len());
|
||||||
|
assert!(xml.contains(&format!("NumberOfPoints=\"{}\"", s.model.nodes().len())));
|
||||||
|
|
||||||
|
// The tip node's displacement is the probe's, at the node's point.
|
||||||
|
let labels = vtu_array(&xml, "node_label");
|
||||||
|
let u = vtu_array(&xml, "displacement");
|
||||||
|
let r = s.result("Load").unwrap().clone();
|
||||||
|
let Kind::Static(st) = r.kind() else { panic!() };
|
||||||
|
let (k, tip) = st.displacement.iter().enumerate().max_by(|a, b| a.1[2].abs().total_cmp(&b.1[2].abs())).unwrap();
|
||||||
|
let tip_label = st.nodes[k];
|
||||||
|
let probe = s.execute("solve.probe", &json!({"step": "Load", "nodes": [tip_label]})).unwrap();
|
||||||
|
let i = labels.iter().position(|&l| l == f64::from(tip_label)).unwrap();
|
||||||
|
assert_eq!(&u[3 * i..3 * i + 3], tip.as_slice());
|
||||||
|
assert_eq!(
|
||||||
|
probe["nodes"][0]["displacement"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()),
|
||||||
|
Some(tip.to_vec()),
|
||||||
|
"{probe}"
|
||||||
|
);
|
||||||
|
// Stress components in VTK order: XY at 3, YZ at 4, XZ at 5.
|
||||||
|
let stress = vtu_array(&xml, "stress");
|
||||||
|
let t = st.stress[k];
|
||||||
|
assert_eq!(&stress[6 * i..6 * i + 6], [t[0], t[1], t[2], t[3], t[5], t[4]].as_slice());
|
||||||
|
assert_eq!(vtu_array(&xml, "has_result").iter().filter(|&&h| h == 1.0).count(), st.nodes.len());
|
||||||
|
// Deterministic.
|
||||||
|
s.execute("results.export.vtu", &json!({"step": "Load", "path": out})).unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&out).unwrap(), xml);
|
||||||
|
|
||||||
|
// Modes: one file per mode with its frequency, and a collection.
|
||||||
|
s.execute("solve.frequency", &json!({"step": "Modes"})).unwrap();
|
||||||
|
let pvd = dir.join("modes.pvd").to_string_lossy().into_owned();
|
||||||
|
let reply = s.execute("results.export.vtu", &json!({"step": "Modes", "path": pvd})).unwrap();
|
||||||
|
let files = reply["files"].as_array().unwrap();
|
||||||
|
assert_eq!(files.len(), 7, "six modes and the collection: {reply}");
|
||||||
|
let mode2 = std::fs::read_to_string(dir.join("modes_0001.vtu")).unwrap();
|
||||||
|
assert_eq!(vtu_array(&mode2, "mode"), [2.0]);
|
||||||
|
let collection = std::fs::read_to_string(&pvd).unwrap();
|
||||||
|
assert!(collection.contains("timestep=\"2.0\" part=\"0\" file=\"modes_0001.vtu\""), "{collection}");
|
||||||
|
let one = dir.join("mode3.vtu").to_string_lossy().into_owned();
|
||||||
|
s.execute("results.export.vtu", &json!({"step": "Modes", "path": one, "mode": 3, "encoding": "binary"})).unwrap();
|
||||||
|
assert!(std::fs::read_to_string(&one).unwrap().contains("format=\"binary\""));
|
||||||
|
|
||||||
|
// Refusals write nothing.
|
||||||
|
let none = dir.join("none.vtu").to_string_lossy().into_owned();
|
||||||
|
for (p, why) in [
|
||||||
|
(json!({"step": "Modes", "path": none, "mode": 0}), "modes 1 to 6"),
|
||||||
|
(json!({"step": "Modes", "path": none, "mode": 1_000_000_000}), "modes 1 to 6"),
|
||||||
|
(json!({"step": "Load", "path": none, "mode": 1}), "frequency"),
|
||||||
|
(json!({"step": "Load", "path": dir.join("x.vtk").to_string_lossy()}), ".vtu"),
|
||||||
|
(json!({"step": "Load", "path": none, "frames": 3}), "frames"),
|
||||||
|
(json!({"step": "Load", "path": none, "encoding": "hex"}), "encoding"),
|
||||||
|
(json!({"step": "Nope", "path": none}), "has no result"),
|
||||||
|
] {
|
||||||
|
let e = s.execute("results.export.vtu", &p).unwrap_err().to_string();
|
||||||
|
assert!(e.contains(why), "{p}: {e}");
|
||||||
|
}
|
||||||
|
s.execute("mesh.node.add", &json!({"label": 9999, "xyz": [50, 50, 50]})).unwrap();
|
||||||
|
let e = s.execute("results.export.vtu", &json!({"step": "Load", "path": none})).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("the model changed since step `Load` was solved"), "{e}");
|
||||||
|
assert!(!dir.join("none.vtu").exists() && !dir.join("x.vtk").exists());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_vtu_writes_nonlinear_frames_and_heat() {
|
||||||
|
let dir = scratch("nl-heat");
|
||||||
|
let mut s = Session::default();
|
||||||
|
cantilever(&mut s);
|
||||||
|
s.run_script(&json!([
|
||||||
|
{"command": "model.section.edit", "params": {"name": "bar", "set": "all", "material": "Mat", "kind": {"type": "solid"}, "formulation": {}}},
|
||||||
|
{"command": "model.step.create", "params": {"name": "Large", "procedure": {"type": "nonlinear_static", "initial_increment": 0.25},
|
||||||
|
"boundary_conditions": ["clamp"], "loads": ["push"]}},
|
||||||
|
{"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}},
|
||||||
|
{"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "root"}, "value": 100}},
|
||||||
|
{"command": "model.set.create", "params": {"name": "skin", "faces": (1..=6).map(|f| json!({"element": 10, "face": f})).collect::<Vec<_>>()}},
|
||||||
|
{"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.5, "sink": 20}},
|
||||||
|
{"command": "model.step.create", "params": {"name": "Therm", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["Heater"], "loads": ["Air"]}},
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
s.execute("solve.nonlinear", &json!({"step": "Large"})).unwrap();
|
||||||
|
s.execute("solve.heat", &json!({"step": "Therm"})).unwrap();
|
||||||
|
|
||||||
|
let pvd = dir.join("large.pvd").to_string_lossy().into_owned();
|
||||||
|
let reply = s.execute("results.export.vtu", &json!({"step": "Large", "path": pvd})).unwrap();
|
||||||
|
let Kind::Static(st) = s.result("Large").unwrap().kind() else { panic!() };
|
||||||
|
let h = st.nonlinear.as_ref().unwrap();
|
||||||
|
assert_eq!(reply["files"].as_array().unwrap().len(), h.frames.len() + 1);
|
||||||
|
let first = std::fs::read_to_string(dir.join("large_0000.vtu")).unwrap();
|
||||||
|
let last = std::fs::read_to_string(dir.join(format!("large_{:04}.vtu", h.frames.len() - 1))).unwrap();
|
||||||
|
assert!(first.contains("Name=\"displacement\"") && !first.contains("Name=\"stress\""), "final stresses on the last frame only");
|
||||||
|
assert!(last.contains("Name=\"stress\"") && last.contains("Name=\"von_mises\""));
|
||||||
|
assert_eq!(vtu_array(&last, "load_factor"), [h.frames.last().unwrap().load_factor]);
|
||||||
|
|
||||||
|
let heat = dir.join("heat.vtu").to_string_lossy().into_owned();
|
||||||
|
s.execute("results.export.vtu", &json!({"step": "Therm", "path": heat})).unwrap();
|
||||||
|
let xml = std::fs::read_to_string(&heat).unwrap();
|
||||||
|
let t = vtu_array(&xml, "temperature");
|
||||||
|
assert!(t.contains(&100.0), "the heater's temperature");
|
||||||
|
assert_eq!(vtu_array(&xml, "heat_flux").len(), 3 * s.model.nodes().len());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_vtu_writes_explicit_frames_as_a_series() {
|
||||||
|
let dir = scratch("impact");
|
||||||
|
let mut s = Session::default();
|
||||||
|
s.execute("file.sample", &json!({"name": "impact"})).unwrap();
|
||||||
|
s.execute("solve.explicit", &json!({})).unwrap();
|
||||||
|
let pvd = dir.join("impact.pvd").to_string_lossy().into_owned();
|
||||||
|
let reply = s.execute("results.export.vtu", &json!({"path": pvd})).unwrap();
|
||||||
|
let Kind::Explicit(e) = s.result("Impact").unwrap().kind() else { panic!() };
|
||||||
|
let frames = e.frames.len();
|
||||||
|
assert!(frames > 2);
|
||||||
|
assert_eq!(reply["files"].as_array().unwrap().len(), frames + 1);
|
||||||
|
let collection = std::fs::read_to_string(&pvd).unwrap();
|
||||||
|
let times: Vec<f64> =
|
||||||
|
collection.match_indices("timestep=\"").map(|(i, m)| collection[i + m.len()..].split('"').next().unwrap().parse().unwrap()).collect();
|
||||||
|
assert_eq!(times.len(), frames);
|
||||||
|
assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}");
|
||||||
|
let last = std::fs::read_to_string(dir.join(format!("impact_{:04}.vtu", frames - 1))).unwrap();
|
||||||
|
let cell = &last[last.find("<CellData>").unwrap()..last.find("</CellData>").unwrap()];
|
||||||
|
assert!(cell.contains("Name=\"stress\" NumberOfComponents=\"6\""), "{cell}");
|
||||||
|
// A .vtu takes the last frame; `frames: "last"` makes a one-entry series.
|
||||||
|
let one = dir.join("last.vtu").to_string_lossy().into_owned();
|
||||||
|
s.execute("results.export.vtu", &json!({"path": one})).unwrap();
|
||||||
|
assert_eq!(std::fs::read_to_string(&one).unwrap(), last);
|
||||||
|
let lastp = dir.join("last.pvd").to_string_lossy().into_owned();
|
||||||
|
assert_eq!(s.execute("results.export.vtu", &json!({"path": lastp, "frames": "last"})).unwrap()["files"].as_array().unwrap().len(), 2);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
fn solved() -> Session {
|
fn solved() -> Session {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
cantilever(&mut s);
|
cantilever(&mut s);
|
||||||
|
|||||||
@@ -545,6 +545,12 @@ mod tests {
|
|||||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
|
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": vec!["x".repeat(1000); 2000]}),
|
||||||
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
|
json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": {}}),
|
||||||
json!({"path": 7}),
|
json!({"path": 7}),
|
||||||
|
json!({"path": "/no/such/dir/out.vtu", "step": "Nope"}),
|
||||||
|
json!({"path": "/no/such/dir/out.vtu", "mode": 0}),
|
||||||
|
json!({"path": "/no/such/dir/out.pvd", "mode": 1_000_000_000}),
|
||||||
|
json!({"path": "/no/such/dir/out.pvd", "frames": 3, "encoding": []}),
|
||||||
|
json!({"path": "x\u{0}.vtu"}),
|
||||||
|
json!({"path": format!("/no/such/{}.vtu", "x".repeat(100_000))}),
|
||||||
json!({"path": "x\u{0}.stl"}),
|
json!({"path": "x\u{0}.stl"}),
|
||||||
json!({"path": "/no/such/dir/skin.stl", "set": 7}),
|
json!({"path": "/no/such/dir/skin.stl", "set": 7}),
|
||||||
json!({"path": "/no/such/dir/skin.stl", "set": "x".repeat(100_000)}),
|
json!({"path": "/no/such/dir/skin.stl", "set": "x".repeat(100_000)}),
|
||||||
|
|||||||
+20
-34
@@ -9,9 +9,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Points are the model's nodes in label order and cells its elements in label order; the point
|
//! Points are the model's nodes in label order and cells its elements in label order; the point
|
||||||
//! array `node_label` and the cell array `element_label` keep the labels. Element node order is
|
//! array `node_label` and the cell array `element_label` keep the labels. Element node order is
|
||||||
//! the model's ([`ElementKind`]), which is VTK's for every kind but the wedge: a FieldCraft
|
//! the model's ([`ElementKind`]), which is VTK's for every kind. That holds for the wedge too:
|
||||||
//! wedge's triangle 1-2-3 has its right-hand normal towards 4-5-6, a VTK wedge's base 0-1-2
|
//! VTK's wedge has the parametric coordinates (0,0,0), (1,0,0), (0,1,0) for points 0-2 and the
|
||||||
//! points away from 3-4-5, so wedges are written as 0, 2, 1, 3, 5, 4.
|
//! same at ζ = 1 for 3-5, so the base 0-1-2's right-hand normal points to 3-4-5, as a FieldCraft
|
||||||
|
//! wedge's 1-2-3 points to 4-5-6 (VTK 9 computes a positive volume for it and lists its outward
|
||||||
|
//! base face as 0-2-1).
|
||||||
//!
|
//!
|
||||||
//! Data arrays are written as text (`ascii`, numbers in the shortest form that reads back to the
|
//! Data arrays are written as text (`ascii`, numbers in the shortest form that reads back to the
|
||||||
//! same `f64`) or inline base64 (`binary`, each array prefixed with its byte count as a `UInt64`,
|
//! same `f64`) or inline base64 (`binary`, each array prefixed with its byte count as a `UInt64`,
|
||||||
@@ -41,15 +43,6 @@ pub fn cell_type(kind: ElementKind) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The positions in an element's node list in the order VTK lists the cell's points.
|
|
||||||
pub fn vtk_order(kind: ElementKind) -> &'static [usize] {
|
|
||||||
const IDENTITY: [usize; 20] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
|
||||||
match kind {
|
|
||||||
ElementKind::Wedge6 => &[0, 2, 1, 3, 5, 4],
|
|
||||||
k => IDENTITY.get(..k.node_count()).unwrap_or(&[]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a field's values are: one tuple per point (node) or per cell (element).
|
/// Where a field's values are: one tuple per point (node) or per cell (element).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Location {
|
pub enum Location {
|
||||||
@@ -106,8 +99,7 @@ pub fn write_vtu(model: &Model, fields: &[Field], field_data: &[(&str, f64)], en
|
|||||||
let mut offsets: Vec<i64> = Vec::with_capacity(elements.len());
|
let mut offsets: Vec<i64> = Vec::with_capacity(elements.len());
|
||||||
let mut types: Vec<u8> = Vec::with_capacity(elements.len());
|
let mut types: Vec<u8> = Vec::with_capacity(elements.len());
|
||||||
for e in model.elements().values() {
|
for e in model.elements().values() {
|
||||||
for &i in vtk_order(e.kind) {
|
for &label in &e.nodes {
|
||||||
let label = e.nodes.get(i).copied().ok_or_else(|| Error::Invalid(format!("a {} lists too few nodes", e.kind.name())))?;
|
|
||||||
connectivity.push(index(label)? as i64);
|
connectivity.push(index(label)? as i64);
|
||||||
}
|
}
|
||||||
offsets.push(connectivity.len() as i64);
|
offsets.push(connectivity.len() as i64);
|
||||||
@@ -438,8 +430,7 @@ mod tests {
|
|||||||
let labels = numbers(array(&xml, "node_label").1);
|
let labels = numbers(array(&xml, "node_label").1);
|
||||||
let written: Vec<u32> = numbers(array(&xml, "connectivity").1).iter().map(|&i| labels[i as usize] as u32).collect();
|
let written: Vec<u32> = numbers(array(&xml, "connectivity").1).iter().map(|&i| labels[i as usize] as u32).collect();
|
||||||
let e = m.element(7).unwrap();
|
let e = m.element(7).unwrap();
|
||||||
let expected: Vec<u32> = vtk_order(kind).iter().map(|&i| e.nodes[i]).collect();
|
assert_eq!(written, e.nodes, "{kind:?}");
|
||||||
assert_eq!(written, expected, "{kind:?}");
|
|
||||||
}
|
}
|
||||||
let ids: Vec<u8> = ElementKind::ALL.iter().map(|&k| cell_type(k)).collect();
|
let ids: Vec<u8> = ElementKind::ALL.iter().map(|&k| cell_type(k)).collect();
|
||||||
// rod2, rod3, beam2, tri3, tri6, quad4, quad8, tet4, tet10, wedge6, hex8, hex20
|
// rod2, rod3, beam2, tri3, tri6, quad4, quad8, tet4, tet10, wedge6, hex8, hex20
|
||||||
@@ -466,27 +457,25 @@ mod tests {
|
|||||||
[0, 1, 2].map(|k| p.iter().map(|q| q[k]).sum::<f64>() / n)
|
[0, 1, 2].map(|k| p.iter().map(|q| q[k]).sum::<f64>() / n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The VTK document's convention for each 3D cell: the base face's right-hand normal points
|
/// VTK's convention for each 3D cell, from its parametric coordinates: the base face's
|
||||||
/// towards the rest of the cell for the tetrahedron and the hexahedron, and away from it
|
/// right-hand normal points to the rest of the cell (tetrahedron 0-1-2 to 3, hexahedron
|
||||||
/// (out of the cell) for the wedge. The signed measure is positive when the written cell
|
/// 0-1-2-3 to 4-7, wedge 0-1-2 to 3-5). The signed measure is positive when the written cell
|
||||||
/// follows it.
|
/// follows it; VTK 9.7's vtkCellSizeFilter and vtkMeshQuality agree (a unit wedge written
|
||||||
|
/// this way measures +0.5, reordered 0-2-1-3-5-4 it measures -0.5).
|
||||||
#[test]
|
#[test]
|
||||||
fn solid_cells_have_positive_volume_in_vtk_convention() {
|
fn solid_cells_have_positive_volume_in_vtk_convention() {
|
||||||
for (kind, base, rest, towards) in [
|
for (kind, base, rest) in [
|
||||||
(ElementKind::Tet4, 3, 3..4, true),
|
(ElementKind::Tet4, 3, 3..4),
|
||||||
(ElementKind::Tet10, 3, 3..4, true),
|
(ElementKind::Tet10, 3, 3..4),
|
||||||
(ElementKind::Hex8, 4, 4..8, true),
|
(ElementKind::Hex8, 4, 4..8),
|
||||||
(ElementKind::Hex20, 4, 4..8, true),
|
(ElementKind::Hex20, 4, 4..8),
|
||||||
(ElementKind::Wedge6, 3, 3..6, false),
|
(ElementKind::Wedge6, 3, 3..6),
|
||||||
] {
|
] {
|
||||||
let p = cell_points(&write_vtu(&one(kind), &[], &[], Encoding::Ascii).unwrap());
|
let p = cell_points(&write_vtu(&one(kind), &[], &[], Encoding::Ascii).unwrap());
|
||||||
let b = &p[..base];
|
let b = &p[..base];
|
||||||
let n = cross(sub(b[1], b[0]), sub(b[2], b[0]));
|
let n = cross(sub(b[1], b[0]), sub(b[2], b[0]));
|
||||||
let d = dot(n, sub(centre(&p[rest]), centre(b)));
|
assert!(dot(n, sub(centre(&p[rest]), centre(b))) > 0.0, "{kind:?}");
|
||||||
assert!(if towards { d > 0.0 } else { d < 0.0 }, "{kind:?}: {d}");
|
|
||||||
}
|
}
|
||||||
// Without the wedge's reorder the base would point the wrong way.
|
|
||||||
assert_eq!(vtk_order(ElementKind::Wedge6), [0, 2, 1, 3, 5, 4]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -496,10 +485,7 @@ mod tests {
|
|||||||
(ElementKind::Tri6, &[(0, 1), (1, 2), (2, 0)]),
|
(ElementKind::Tri6, &[(0, 1), (1, 2), (2, 0)]),
|
||||||
(ElementKind::Quad8, &[(0, 1), (1, 2), (2, 3), (3, 0)]),
|
(ElementKind::Quad8, &[(0, 1), (1, 2), (2, 3), (3, 0)]),
|
||||||
(ElementKind::Tet10, &[(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)]),
|
(ElementKind::Tet10, &[(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)]),
|
||||||
(
|
(ElementKind::Hex20, &[(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]),
|
||||||
ElementKind::Hex20,
|
|
||||||
&[(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)],
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
for (kind, list) in edges {
|
for (kind, list) in edges {
|
||||||
let p = cell_points(&write_vtu(&one(kind), &[], &[], Encoding::Ascii).unwrap());
|
let p = cell_points(&write_vtu(&one(kind), &[], &[], Encoding::Ascii).unwrap());
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ pub struct Node {
|
|||||||
/// - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; hex20's mid-side nodes 9–12 lie on the
|
/// - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; hex20's mid-side nodes 9–12 lie on the
|
||||||
/// edges of 1-2-3-4, 13–16 on those of 5-6-7-8, and 17–20 on edges 1-5, 2-6, 3-7, 4-8.
|
/// edges of 1-2-3-4, 13–16 on those of 5-6-7-8, and 17–20 on edges 1-5, 2-6, 3-7, 4-8.
|
||||||
///
|
///
|
||||||
/// This is the Abaqus order, and VTK's for every kind but wedge6: a VTK wedge's base 0-1-2 has its
|
/// This is the Abaqus (and VTK) order; a VTK wedge too has its base 0-1-2 normal pointing to
|
||||||
/// right-hand normal pointing away from 3-4-5, so the VTK writer lists a wedge as 1-3-2-4-6-5.
|
/// 3-4-5 (its parametric coordinates put 0-1-2 at ζ = 0 counter-clockwise seen from ζ = 1). Formats that order nodes differently, such as Nastran's
|
||||||
/// Formats that order nodes differently, such as Nastran's
|
|
||||||
/// CHEXA with 20 nodes (vertical edges before the top face's edges), are reordered by their deck
|
/// CHEXA with 20 nodes (vertical edges before the top face's edges), are reordered by their deck
|
||||||
/// readers and writers; the model never stores another order.
|
/// readers and writers; the model never stores another order.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
|
|||||||
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
|
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
|
||||||
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
||||||
|
|
||||||
74 ready, 39 coming soon.
|
75 ready, 39 coming soon.
|
||||||
|
|
||||||
## Geometry
|
## Geometry
|
||||||
|
|
||||||
@@ -153,6 +153,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
|
|||||||
| Plots | Cut — Section cuts through the model | coming soon (M9) | |
|
| Plots | Cut — Section cuts through the model | coming soon (M9) | |
|
||||||
| Plots | Display Group — Show and hide parts of the model | coming soon (M9) | |
|
| Plots | Display Group — Show and hide parts of the model | coming soon (M9) | |
|
||||||
| Plots | Streamlines — Streamlines and flow fields | coming soon (M12) | |
|
| Plots | Streamlines — Streamlines and flow fields | coming soon (M12) | |
|
||||||
|
| Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` |
|
||||||
| Files | Read Results — Read results in public formats | coming soon (M9) | |
|
| Files | Read Results — Read results in public formats | coming soon (M9) | |
|
||||||
| Files | Convergence — Mesh convergence studies | coming soon (M9) | |
|
| Files | Convergence — Mesh convergence studies | coming soon (M9) | |
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user