forked from 1b-agent/fieldcraft-upstream
mesh.block: mapped hex, tet and wedge blocks with curved edges; solve.probe at points
A structured mesh of a six-sided block from its corners, with circular and elliptical edges (Gordon-Hall transfinite interpolation), grading, named faces, edges and vertices as geometry tags, and joining to the existing mesh face to face (refused otherwise). solve.probe takes "at" points and reports the nearest node with a result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
15a9ac9157
commit
a3cb6d749b
7 files changed
+1022
-10
No files matched your search
@@ -187,7 +187,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
soon("mesh.untangle", "Untangle", Mesh, "Improve", "M6", "Repair inverted elements"),
|
||||
soon("mesh.layers", "Layers", Mesh, "Improve", "M6", "Boundary padding and layers through thin walls"),
|
||||
ready_more("mesh.node.add", "Node", Mesh, "Edit", "Add a node", &["mesh.node.remove"]),
|
||||
ready_more("mesh.element.add", "Element", Mesh, "Edit", "Add an element on existing nodes", &["mesh.element.remove"]),
|
||||
ready_more(
|
||||
"mesh.element.add",
|
||||
"Element",
|
||||
Mesh,
|
||||
"Edit",
|
||||
"Add an element on existing nodes, or a mapped block of them",
|
||||
&["mesh.element.remove", "mesh.block"],
|
||||
),
|
||||
soon("mesh.node.merge", "Merge Nodes", Mesh, "Edit", "M6", "Merge coincident nodes"),
|
||||
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
|
||||
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! `mesh.block`: a structured mesh of one six-sided block from its corners and curved edges
|
||||
//! ([`modelcraft_mesh::block`]), joined to the mesh already there and tagged as a body with named
|
||||
//! faces, edges and vertices, so sets by geometry select it.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use modelcraft_mesh::block::{Block, BlockMesh, Curve, block};
|
||||
use modelcraft_model::{ElementKind, Entity, GeometryRef, MAX_LABEL, Model};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const BLOCK: CommandSpec = CommandSpec {
|
||||
id: "mesh.block",
|
||||
label: "Mesh Block",
|
||||
params: r#"{"body": "Plate", "kind": "hex8|hex20|tet4|tet10|wedge6", "corners": [[x, y, z] × 8] (hex8 order: 1-2-3-4 counter-clockwise seen from 5-6-7-8), "divisions": [n12, n14, n15] (elements along 1→2, 1→4, 1→5), "bias": [1, 1, 1] (optional: last over first element size along each direction), "edges": [{"corners": [1, 2], "arc": {"center": [x, y, z]}} | {"corners": [2, 3], "ellipse": {"center": [x, y, z], "axes": [[a], [b]] (perpendicular)}} | {"corners": [5, 6], "name": "tip_edge"}] (optional: curved edges, the shorter arc between the corners, and edge names), "faces": {"1": "bottom", "4": "tip"} (optional: names of block faces, numbered as hex8 faces: 1 = 1-2-3-4, 2 = 5-6-7-8, 3 = 1-2-6-5, 4 = 2-3-7-6, 5 = 3-4-8-7, 6 = 4-1-5-8), "vertices": {"1": "A"} (optional: names of corners), "merge": distance (optional, default 1e-6 of the block's diagonal; 0 never joins)} — adds a mapped mesh of the block (transfinite interpolation of its edges; quadratic nodes on the curves), uses existing nodes within `merge` of new ones so blocks that share a face join (the shared face must have the same elements on both sides, or nothing changes), and tags the elements with the body and element faces, edges and nodes with the named faces, edges and vertices"#,
|
||||
kind: CommandKind::Model,
|
||||
run: block_mesh,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Params {
|
||||
body: String,
|
||||
kind: String,
|
||||
corners: [[f64; 3]; 8],
|
||||
divisions: [usize; 3],
|
||||
#[serde(default = "uniform")]
|
||||
bias: [f64; 3],
|
||||
#[serde(default)]
|
||||
edges: Vec<EdgeParams>,
|
||||
#[serde(default)]
|
||||
faces: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
vertices: BTreeMap<String, String>,
|
||||
merge: Option<f64>,
|
||||
}
|
||||
|
||||
fn uniform() -> [f64; 3] {
|
||||
[1.0; 3]
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EdgeParams {
|
||||
corners: [usize; 2],
|
||||
name: Option<String>,
|
||||
arc: Option<ArcParams>,
|
||||
ellipse: Option<EllipseParams>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ArcParams {
|
||||
center: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EllipseParams {
|
||||
center: [f64; 3],
|
||||
axes: [[f64; 3]; 2],
|
||||
}
|
||||
|
||||
/// A 1-based number in `1..=n` given as a JSON object key, 0-based.
|
||||
fn numbered(key: &str, n: usize, what: &str) -> Result<usize> {
|
||||
key.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|k| (1..=n).contains(k))
|
||||
.map(|k| k - 1)
|
||||
.ok_or_else(|| EngineError::Params(format!("{what} `{key}` must be a number from 1 to {n}")))
|
||||
}
|
||||
|
||||
fn block_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p: Params = serde_json::from_value(p.clone()).map_err(|e| EngineError::Params(format!("mesh.block: {e}")))?;
|
||||
let kind = ElementKind::from_name(&p.kind).ok_or_else(|| EngineError::Params(format!("unknown element kind `{}`", p.kind)))?;
|
||||
let mut curves = Vec::new();
|
||||
let mut edge_names = Vec::new();
|
||||
for e in &p.edges {
|
||||
let pair = [0, 1].map(|i| e.corners[i].wrapping_sub(1));
|
||||
let index =
|
||||
Block::edge_index(pair).ok_or_else(|| EngineError::Params(format!("corners {:?} are not the two ends of a block edge", e.corners)))?;
|
||||
let curve = match (&e.arc, &e.ellipse) {
|
||||
(Some(a), None) => Some(Curve::Arc { center: a.center }),
|
||||
(None, Some(el)) => Some(Curve::Ellipse { center: el.center, axes: el.axes }),
|
||||
(None, None) => None,
|
||||
(Some(_), Some(_)) => return Err(EngineError::Params(format!("edge {:?} is an arc or an ellipse, not both", e.corners))),
|
||||
};
|
||||
if let Some(c) = curve {
|
||||
curves.push((pair, c));
|
||||
}
|
||||
if let Some(n) = &e.name {
|
||||
edge_names.push((index, n.clone()));
|
||||
}
|
||||
if curve.is_none() && e.name.is_none() {
|
||||
return Err(EngineError::Params(format!("edge {:?} gives neither a curve nor a name", e.corners)));
|
||||
}
|
||||
}
|
||||
let faces: Vec<(usize, String)> = p.faces.iter().map(|(k, n)| Ok((numbered(k, 6, "face")?, n.clone()))).collect::<Result<_>>()?;
|
||||
let vertices: Vec<(usize, String)> = p.vertices.iter().map(|(k, n)| Ok((numbered(k, 8, "vertex")?, n.clone()))).collect::<Result<_>>()?;
|
||||
// Check every name before meshing.
|
||||
let body = GeometryRef::body(p.body.clone())?;
|
||||
let face_refs: Vec<(usize, GeometryRef)> =
|
||||
faces.iter().map(|(k, n)| Ok((*k, GeometryRef::face(p.body.clone(), n.clone())?))).collect::<Result<_>>()?;
|
||||
let edge_refs: Vec<(usize, GeometryRef)> =
|
||||
edge_names.iter().map(|(k, n)| Ok((*k, GeometryRef::new(p.body.clone(), Entity::Edge(n.clone()))?))).collect::<Result<_>>()?;
|
||||
let vertex_refs: Vec<(usize, GeometryRef)> =
|
||||
vertices.iter().map(|(k, n)| Ok((*k, GeometryRef::new(p.body.clone(), Entity::Vertex(n.clone()))?))).collect::<Result<_>>()?;
|
||||
|
||||
let mesh = block(&Block { kind, corners: p.corners, divisions: p.divisions, bias: p.bias, curves })?;
|
||||
let diagonal = {
|
||||
let lo = (0..3).map(|d| p.corners.iter().map(|c| c[d]).fold(f64::INFINITY, f64::min));
|
||||
let hi = (0..3).map(|d| p.corners.iter().map(|c| c[d]).fold(f64::NEG_INFINITY, f64::max));
|
||||
lo.zip(hi).map(|(l, h)| (h - l) * (h - l)).sum::<f64>().sqrt()
|
||||
};
|
||||
let merge = p.merge.unwrap_or(1e-6 * diagonal);
|
||||
if !(merge.is_finite() && merge >= 0.0 && merge <= 1e-2 * diagonal) {
|
||||
return Err(EngineError::Params(format!("`merge` must be from 0 to 1/100 of the block's diagonal ({diagonal})")));
|
||||
}
|
||||
|
||||
let next = |last: Option<u32>| last.map_or(Some(1), |l| l.checked_add(1));
|
||||
let first_node = next(s.model.nodes().keys().next_back().copied());
|
||||
let first_element = next(s.model.elements().keys().next_back().copied());
|
||||
let fits = |first: Option<u32>, count: usize| first.filter(|&f| u64::from(f) + count as u64 <= u64::from(MAX_LABEL) + 1);
|
||||
let (Some(first_node), Some(first_element)) = (fits(first_node, mesh.points.len()), fits(first_element, mesh.elements.len())) else {
|
||||
return Err(EngineError::Params(format!("the block needs labels beyond {MAX_LABEL}")));
|
||||
};
|
||||
|
||||
// Each block point: an existing node within `merge`, or a new label.
|
||||
let existing = joined(&s.model, &mesh.points, merge)?;
|
||||
let mut labels = Vec::with_capacity(mesh.points.len());
|
||||
let mut added = 0u32;
|
||||
for (i, xyz) in mesh.points.iter().enumerate() {
|
||||
match existing.get(i).copied().flatten() {
|
||||
Some(label) => labels.push(label),
|
||||
None => {
|
||||
let label = first_node + added;
|
||||
s.model.set_node(label, *xyz)?;
|
||||
labels.push(label);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let merged: BTreeSet<u32> = existing.iter().flatten().copied().collect();
|
||||
let elements: Vec<Vec<u32>> = mesh.elements.iter().map(|e| e.iter().map(|&i| labels.get(i as usize).copied().unwrap_or(0)).collect()).collect();
|
||||
if !merged.is_empty() {
|
||||
check_conforming(&s.model, kind, &mesh, &elements, &merged)?;
|
||||
}
|
||||
|
||||
let body_tag = s.model.geometry_tag(&body)?;
|
||||
for (i, nodes) in elements.into_iter().enumerate() {
|
||||
let label = first_element + i as u32;
|
||||
s.model.set_element(label, kind, nodes)?;
|
||||
s.model.tag_element(label, body_tag)?;
|
||||
}
|
||||
for (k, r) in &face_refs {
|
||||
let t = s.model.geometry_tag(r)?;
|
||||
for &(e, f, bf) in &mesh.faces {
|
||||
if usize::from(bf) == *k {
|
||||
s.model.tag_element_face(first_element + e, f, t)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, r) in &edge_refs {
|
||||
let t = s.model.geometry_tag(r)?;
|
||||
for &(e, edge, be) in &mesh.edges {
|
||||
if usize::from(be) == *k {
|
||||
s.model.tag_element_edge(first_element + e, edge, t)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, r) in &vertex_refs {
|
||||
let t = s.model.geometry_tag(r)?;
|
||||
let node = mesh.corners.get(*k).and_then(|&i| labels.get(i as usize)).copied().unwrap_or(0);
|
||||
s.model.tag_node(node, t)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"body": p.body,
|
||||
"kind": kind.name(),
|
||||
"elements": mesh.elements.len(),
|
||||
"first_element": first_element,
|
||||
"nodes": added,
|
||||
"first_node": first_node,
|
||||
"joined": merged.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// For each block point, the existing node within `merge` of it (the nearest; the lowest label
|
||||
/// on a tie). Two points may not join the same node.
|
||||
fn joined(model: &Model, points: &[[f64; 3]], merge: f64) -> Result<Vec<Option<u32>>> {
|
||||
if merge == 0.0 || model.nodes().is_empty() {
|
||||
return Ok(vec![None; points.len()]);
|
||||
}
|
||||
let cell = |x: f64| (x / merge).floor() as i64;
|
||||
let mut grid: BTreeMap<[i64; 3], Vec<(u32, [f64; 3])>> = BTreeMap::new();
|
||||
for (&label, node) in model.nodes() {
|
||||
grid.entry(node.xyz.map(cell)).or_default().push((label, node.xyz));
|
||||
}
|
||||
let mut out = Vec::with_capacity(points.len());
|
||||
let mut taken: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
let c = p.map(cell);
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
for &(label, xyz) in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() {
|
||||
let d = ((xyz[0] - p[0]).powi(2) + (xyz[1] - p[1]).powi(2) + (xyz[2] - p[2]).powi(2)).sqrt();
|
||||
if d <= merge && best.is_none_or(|(bd, bl)| d < bd || (d == bd && label < bl)) {
|
||||
best = Some((d, label));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, label)) = best
|
||||
&& let Some(other) = taken.insert(label, i)
|
||||
{
|
||||
return Err(EngineError::Params(format!(
|
||||
"block points {other} and {i} both lie within `merge` of node {label}: lower `merge` or refine the block"
|
||||
)));
|
||||
}
|
||||
out.push(best.map(|(_, l)| l));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Faces as sorted node lists.
|
||||
fn key(nodes: impl Iterator<Item = u32>) -> Vec<u32> {
|
||||
let mut k: Vec<u32> = nodes.collect();
|
||||
k.sort_unstable();
|
||||
k
|
||||
}
|
||||
|
||||
/// Where the block joins existing nodes, its boundary faces and the existing element faces on
|
||||
/// those nodes must be the same faces: no hanging nodes, no mixed linear and quadratic sides.
|
||||
/// A block joins along whole block faces and edges: when every corner of a block face (both
|
||||
/// ends of a block edge) lands on an existing node, every node on it must.
|
||||
fn check_conforming(model: &Model, kind: ElementKind, mesh: &BlockMesh, elements: &[Vec<u32>], merged: &BTreeSet<u32>) -> Result<()> {
|
||||
// Labels of the block's corners: the corner point's label in any element that uses it.
|
||||
let mut point_label: BTreeMap<u32, u32> = BTreeMap::new();
|
||||
for (e, points) in mesh.elements.iter().enumerate() {
|
||||
for (k, &pt) in points.iter().enumerate() {
|
||||
if let Some(&l) = elements.get(e).and_then(|n| n.get(k)) {
|
||||
point_label.insert(pt, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
let joined = |pt: u32| point_label.get(&pt).is_some_and(|l| merged.contains(l));
|
||||
let hex_faces = ElementKind::Hex8.faces();
|
||||
let pieces = |list: &[(u32, u8, u8)], which: usize, local: fn(ElementKind, usize) -> Option<&'static [usize]>| -> Vec<u32> {
|
||||
list.iter()
|
||||
.filter(|t| usize::from(t.2) == which)
|
||||
.flat_map(|&(e, k, _)| {
|
||||
let nodes = elements.get(e as usize);
|
||||
local(kind, usize::from(k)).unwrap_or(&[]).iter().filter_map(move |&i| nodes.and_then(|n| n.get(i)).copied())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (bf, corners) in hex_faces.iter().enumerate() {
|
||||
if corners.iter().all(|&c| mesh.corners.get(c).is_some_and(|&pt| joined(pt))) {
|
||||
let nodes = pieces(&mesh.faces, bf, |k, f| k.faces().get(f.checked_sub(1)?).copied());
|
||||
if nodes.iter().any(|n| !merged.contains(n)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"block face {} lies on existing nodes at its corners but not at every node: the block must meet the mesh face to face (same divisions and element kind)",
|
||||
bf + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (be, [a, b]) in modelcraft_mesh::block::EDGES.iter().enumerate() {
|
||||
if [a, b].iter().all(|&&c| mesh.corners.get(c).is_some_and(|&pt| joined(pt))) {
|
||||
let nodes = pieces(&mesh.edges, be, |k, f| k.edges().get(f.checked_sub(1)?).copied());
|
||||
if nodes.iter().any(|n| !merged.contains(n)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"the block edge from corner {} to {} ends on existing nodes but not every node on it does: the block must meet the mesh with the same divisions",
|
||||
a + 1,
|
||||
b + 1
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_faces: BTreeSet<Vec<u32>> = mesh
|
||||
.faces
|
||||
.iter()
|
||||
.filter_map(|&(e, f, _)| {
|
||||
let nodes = elements.get(e as usize)?;
|
||||
let local = kind.faces().get(usize::from(f).checked_sub(1)?)?;
|
||||
Some(key(local.iter().filter_map(|&i| nodes.get(i).copied())))
|
||||
})
|
||||
.collect();
|
||||
let mut old_faces = BTreeSet::new();
|
||||
for (label, e) in model.elements() {
|
||||
if !e.nodes.iter().any(|n| merged.contains(n)) {
|
||||
continue;
|
||||
}
|
||||
for local in e.kind.faces() {
|
||||
let face = key(local.iter().filter_map(|&i| e.nodes.get(i).copied()));
|
||||
if face.iter().all(|n| merged.contains(n)) {
|
||||
if !new_faces.contains(&face) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"element {label} has a face on the block that no block element shares: the block must meet the mesh face to face (same divisions and element kind)"
|
||||
)));
|
||||
}
|
||||
old_faces.insert(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = new_faces.iter().find(|f| f.iter().all(|n| merged.contains(n)) && !old_faces.contains(*f)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"the block face on nodes {f:?} lies on existing nodes but matches no element face there: the block must meet the mesh face to face (same divisions and element kind)"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn cube(x0: f64) -> serde_json::Value {
|
||||
json!([[x0, 0, 0], [x0 + 1.0, 0, 0], [x0 + 1.0, 1, 0], [x0, 1, 0], [x0, 0, 1], [x0 + 1.0, 0, 1], [x0 + 1.0, 1, 1], [x0, 1, 1]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_join_face_to_face_and_name_their_geometry() {
|
||||
let mut s = Session::default();
|
||||
let r = s
|
||||
.execute(
|
||||
"mesh.block",
|
||||
&json!({"body": "Bar", "kind": "hex20", "corners": cube(0.0), "divisions": [2, 2, 2], "faces": {"6": "root"}, "vertices": {"7": "corner"}, "edges": [{"corners": [5, 8], "name": "top_root"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["joined"].clone()), (json!(8), json!(81), json!(0)));
|
||||
let r = s
|
||||
.execute("mesh.block", &json!({"body": "Bar", "kind": "hex20", "corners": cube(1.0), "divisions": [2, 2, 2], "faces": {"4": "tip"}}))
|
||||
.unwrap();
|
||||
assert_eq!((r["first_element"].clone(), r["nodes"].clone(), r["joined"].clone()), (json!(9), json!(81 - 21), json!(21)));
|
||||
for (set, geometry, nodes) in [
|
||||
("root", json!({"body": "Bar", "face": "root"}), 21),
|
||||
("tip", json!({"body": "Bar", "face": "tip"}), 21),
|
||||
("edge", json!({"body": "Bar", "edge": "top_root"}), 5),
|
||||
("corner", json!({"body": "Bar", "vertex": "corner"}), 1),
|
||||
] {
|
||||
s.execute("model.set.create", &json!({"name": set, "geometry": [geometry]})).unwrap();
|
||||
let r = s.execute("model.set.resolve", &json!({"name": set})).unwrap();
|
||||
assert_eq!(r["nodes"]["count"], nodes, "{set}: {r}");
|
||||
}
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!((q["valid"].clone(), q["volume"].as_f64().map(|v| (v - 2.0).abs() < 1e-12)), (json!(16), Some(true)));
|
||||
|
||||
// A third block that meets the second with other divisions or another kind changes nothing.
|
||||
let model = s.model.clone();
|
||||
for bad in [
|
||||
json!({"body": "Bar", "kind": "hex20", "corners": cube(2.0), "divisions": [2, 3, 2]}),
|
||||
json!({"body": "Bar", "kind": "hex20", "corners": cube(2.0), "divisions": [1, 1, 1]}),
|
||||
json!({"body": "Bar", "kind": "hex8", "corners": cube(2.0), "divisions": [4, 4, 4]}),
|
||||
json!({"body": "Bar", "kind": "tet10", "corners": cube(2.0), "divisions": [2, 2, 2]}),
|
||||
] {
|
||||
assert!(s.execute("mesh.block", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.model, model);
|
||||
}
|
||||
// A finer block first, then a coarser one against it: caught from the other side.
|
||||
let mut t = Session::default();
|
||||
t.execute("mesh.block", &json!({"body": "A", "kind": "hex8", "corners": cube(0.0), "divisions": [2, 2, 2]})).unwrap();
|
||||
assert!(t.execute("mesh.block", &json!({"body": "B", "kind": "hex8", "corners": cube(1.0), "divisions": [1, 1, 1]})).is_err());
|
||||
assert!(t.execute("mesh.block", &json!({"body": "B", "kind": "hex8", "corners": cube(1.0), "divisions": [1, 1, 1], "merge": 0})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_edges_and_hostile_parameters() {
|
||||
let mut s = Session::default();
|
||||
let corners = json!([[1, 0, 0], [2, 0, 0], [0, 2, 0], [0, 1, 0], [1, 0, 1], [2, 0, 1], [0, 2, 1], [0, 1, 1]]);
|
||||
let edges = json!([
|
||||
{"corners": [2, 3], "arc": {"center": [0, 0, 0]}},
|
||||
{"corners": [6, 7], "arc": {"center": [0, 0, 1]}},
|
||||
{"corners": [1, 4], "ellipse": {"center": [0, 0, 0], "axes": [[1, 0, 0], [0, 1, 0]]}},
|
||||
{"corners": [5, 8], "arc": {"center": [0, 0, 1]}, "name": "inner_top"}
|
||||
]);
|
||||
let r =
|
||||
s.execute("mesh.block", &json!({"body": "Ring", "kind": "tet10", "corners": corners, "divisions": [2, 4, 1], "edges": edges})).unwrap();
|
||||
assert_eq!(r["elements"], 48);
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
let exact = std::f64::consts::PI * 3.0 / 4.0;
|
||||
assert!((q["volume"].as_f64().unwrap() - exact).abs() < 1e-3 * exact, "{q}");
|
||||
|
||||
let journal = s.journal.len();
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1]}),
|
||||
json!({"body": "B", "kind": "quad4", "corners": cube(5.0), "divisions": [1, 1, 1]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "faces": {"7": "x"}}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "faces": {"one": "x"}}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "vertices": {"0": "x"}}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "edges": [{"corners": [1, 7], "name": "x"}]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "edges": [{"corners": [0, 1], "name": "x"}]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "edges": [{"corners": [1, 2]}]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "merge": -1}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "merge": 1}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1e9, 1e9, 1e9]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(1e300), "divisions": [1, 1, 1]}),
|
||||
json!({"body": "", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1]}),
|
||||
json!({"body": "B", "kind": "hex8", "corners": cube(5.0), "divisions": [1, 1, 1], "colour": "red"}),
|
||||
] {
|
||||
assert!(s.execute("mesh.block", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The command registry. Each area has its own module; [`ALL`] lists every command.
|
||||
|
||||
mod block;
|
||||
pub(crate) mod deck;
|
||||
pub(crate) mod define;
|
||||
mod edit;
|
||||
@@ -32,6 +33,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
mesh::TET_GENERATE,
|
||||
block::BLOCK,
|
||||
tags::TAG,
|
||||
tags::TAG_CLEAR,
|
||||
model::UNITS,
|
||||
|
||||
@@ -54,7 +54,7 @@ pub const FLUID: CommandSpec = CommandSpec {
|
||||
pub const PROBE: CommandSpec = CommandSpec {
|
||||
id: "solve.probe",
|
||||
label: "Probe Results",
|
||||
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
params: r#"{"step": "Load", "nodes": [1, 2], "mode": 1, "frame": 0} — the stored result at nodes: displacement, reaction, stress and von Mises (static), the shape of mode `mode` (frequency, from 1), or displacement and velocity in field frame `frame` (explicit, from 0; the last by default) at `nodes` (or at the nodes nearest to points: "at": [[x, y, z], ...], lowest label on a tie, each entry then giving its `xyz` and `distance`) and, with `elements` (explicit), each element's stress, von Mises stress, strain, equivalent plastic strain, damage, temperature and whether it has eroded; a coupled reference point's label gives its six displacements (translations, rotations) and reactions (forces, moments). Fluid steps: {"step": "Flow", "points": [[x, y, z]], "frame": 10 (default the last)} gives velocity and pressure in the cells at the points; {"step": "Flow", "forces": ["x_min", "F2:top", "Body1/F2:top"]} gives those walls' force histories"#,
|
||||
kind: CommandKind::Model,
|
||||
run: probe,
|
||||
};
|
||||
@@ -345,15 +345,55 @@ fn probe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format!("step `{step}` has no result; solve it first")
|
||||
})
|
||||
})?;
|
||||
let result = match result.as_ref() {
|
||||
StepResult::Explicit(r) => {
|
||||
let list = |key| if p.get(key).is_some() { labels(p, key, MAX_PROBE) } else { Ok(Vec::new()) };
|
||||
return probe_frame(r, &step, &list("nodes")?, &list("elements")?, p);
|
||||
if let StepResult::Fluid(r) = result.as_ref() {
|
||||
return probe_fluid(r, p);
|
||||
}
|
||||
let (nodes, located) = match (p.get("nodes"), p.get("at")) {
|
||||
(Some(_), None) => (labels(p, "nodes", MAX_PROBE)?, None),
|
||||
(None, Some(_)) => {
|
||||
let at = points(p, "at", MAX_PROBE)?;
|
||||
let found = nearest_nodes(&s.model, result.nodes(), &at)?;
|
||||
(found.iter().map(|(l, _)| *l).collect(), Some(found))
|
||||
}
|
||||
StepResult::Fluid(r) => return probe_fluid(r, p),
|
||||
StepResult::Implicit(r) => r,
|
||||
(None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) => (Vec::new(), None),
|
||||
_ => return Err(EngineError::Params("give either `nodes` or `at`".into())),
|
||||
};
|
||||
let nodes = labels(p, "nodes", MAX_PROBE)?;
|
||||
let mut out = match result.as_ref() {
|
||||
StepResult::Explicit(r) => {
|
||||
let elements = if p.get("elements").is_some() { labels(p, "elements", MAX_PROBE)? } else { Vec::new() };
|
||||
probe_frame(r, &step, &nodes, &elements, p)?
|
||||
}
|
||||
StepResult::Implicit(r) => probe_implicit(r, &step, &nodes, p)?,
|
||||
StepResult::Fluid(r) => probe_fluid(r, p)?,
|
||||
};
|
||||
if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) {
|
||||
for (entry, (label, distance)) in entries.iter_mut().zip(found) {
|
||||
if let Some(obj) = entry.as_object_mut() {
|
||||
obj.insert("xyz".into(), json!(s.model.node(label).map(|n| n.xyz)));
|
||||
obj.insert("distance".into(), json!(distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The node with a result nearest to each point (lowest label on a tie), and its distance.
|
||||
fn nearest_nodes(model: &modelcraft_model::Model, with_result: &[u32], at: &[[f64; 3]]) -> Result<Vec<(u32, f64)>> {
|
||||
at.iter()
|
||||
.map(|p| {
|
||||
with_result
|
||||
.iter()
|
||||
.filter_map(|&l| {
|
||||
model.node(l).map(|n| (l, ((n.xyz[0] - p[0]).powi(2) + (n.xyz[1] - p[1]).powi(2) + (n.xyz[2] - p[2]).powi(2)).sqrt()))
|
||||
})
|
||||
.fold(None, |best: Option<(u32, f64)>, (l, d)| if best.is_none_or(|(_, bd)| d < bd) { Some((l, d)) } else { best })
|
||||
.ok_or_else(|| EngineError::Params("the result has no nodes to probe".into()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The result of an implicit step at nodes.
|
||||
fn probe_implicit(result: &ImplicitResult, step: &str, nodes: &[u32], p: &Value) -> Result<Value> {
|
||||
let locate = |label: u32| {
|
||||
result
|
||||
.locate(label)
|
||||
@@ -507,6 +547,15 @@ pub(crate) mod tests {
|
||||
let probe = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [11, 1]})).unwrap();
|
||||
assert!(probe["nodes"][0]["displacement"][2].as_f64().unwrap() < -3.8, "{probe}");
|
||||
assert!(probe["nodes"][1]["reaction"][2].as_f64().unwrap() != 0.0, "{probe}");
|
||||
// By location: the nearest node, with where it is.
|
||||
let at = s.execute("solve.probe", &json!({"step": "Bend", "at": [[10.1, -0.2, 0.1], [0, 0, 0]]})).unwrap();
|
||||
assert_eq!((at["nodes"][0]["node"].clone(), at["nodes"][1]["node"].clone()), (json!(11), json!(1)), "{at}");
|
||||
assert_eq!(at["nodes"][0]["displacement"], probe["nodes"][0]["displacement"]);
|
||||
assert_eq!(at["nodes"][0]["xyz"], json!([10.0, 0.0, 0.0]));
|
||||
assert!((at["nodes"][0]["distance"].as_f64().unwrap() - 0.06f64.sqrt()).abs() < 1e-12);
|
||||
for bad in [json!({"step": "Bend"}), json!({"step": "Bend", "nodes": [1], "at": [[0, 0, 0]]}), json!({"step": "Bend", "at": [[0, 0]]})] {
|
||||
assert!(s.execute("solve.probe", &bad).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
let f = s.execute("solve.frequency", &json!({"mass": "lumped"})).unwrap();
|
||||
assert_eq!(f["modes"].as_array().unwrap().len(), 4, "{f}");
|
||||
|
||||
Reference in new issue
Block a user