forked from 1b-agent/fieldcraft-upstream
WIP (parked): folding robustness, stabilization, mesh.revolve, boot-seal profile
Parked work for #182/#185 (not for merge as is): - contact: held active set with primal-dual updates replaces the inconsistent ENGAGED_BAND tangent after chattering (moves the nl-friction-sliding-blocks fixture by 4.5e-8; not re-blessed) - nonlinear: opt-in viscous stabilization (step 'stabilization', calibrated on the first increment), Abaqus STABILIZE, LS-DYNA refusal - ordering: graph (level-structure) nested dissection, chosen when it fills less: half the fill on the revolved boot, factor time 62 s -> 25 s - engine: mesh.revolve command (2D profile -> hex8/wedge6, tags kept) - engine: sample/boot.rs boot-seal profile, axisymmetric and 3D scripts Scratch still in the code: FC_TRACE/TIMES timing prints, NO_BAND/STAB/ SQUEEZE env hooks in tests, boot_axisymmetric_scratch test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
227c4544ed
commit
98eaede2dd
18 files changed
+1111
-57
No files matched your search
@@ -292,7 +292,7 @@ fn fluid_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub const STEP_CREATE: CommandSpec = CommandSpec {
|
||||
id: "model.step.create",
|
||||
label: "New Step",
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
params: r#"{"name": "Bend", "procedure": {"type": "static"} | {"type": "nonlinear_static", "nonlinear_geometry": true, "initial_increment": 0.1, "min_increment": 1e-5, "max_increment": 1.0 (fractions of the step), "max_increments": 100, "max_iterations": 16 (per increment), "tolerances": {"force": 1e-6, "displacement": 1e-5, "energy": 1e-10}, "line_search": false, "load_path": [[0, 0], [0.5, 1], [1, 0]] (optional: the load factor against the step fraction, piecewise linear from [0, 0] to fraction 1; increments end at its points), "stabilization": 2e-4 (optional: viscous damping of local instabilities, folding and snap-through, as the fraction of the step's work it may dissipate; calibrated on the first increment)} (loads and prescribed displacements are the load factor times their values; it ramps from 0 to 1 without a load path) | {"type": "frequency", "modes": 10, "min_hz": 0, "max_hz": 500} | {"type": "fluid", "domain": "Flow", "end_time": 1.0 | "steps": 1000, "time_step": (default: chosen for Mach 0.087 and τ ≤ 1), "frames": 10, "magic": 0.1875 (the two-relaxation-time Λ)} | {"type": "explicit_dynamic", "end_time": 0.001, "dt_scale": 0.9, "max_increments": 10000000, "output_interval", "history_interval", "history_nodes": "set", "initial_velocities": [{"set", "velocity": [vx, vy, vz]}], "rigid_walls": [{"set", "point", "normal", "friction": {"static": 0.3, "kinetic": 0.2, "decay": 10} (Coulomb; default frictionless)}], "hourglass": {"type": "viscous|stiffness", "coefficient": 0.1}, "bulk_viscosity": {"linear": 0.06, "quadratic": 1.5}, "mass_scaling": {"target_increment": 1e-6, "set": "…" (optional, default every element)} (opt-in: elements below the target increment get the density that reaches it; the added mass is reported)}, "output": ["displacement", "reaction_force", "stress", "strain"] (default by procedure), "boundary_conditions": [names], "loads": [names] (active in this step; nothing carries over between steps), "before": "step" (default: last)}"#,
|
||||
form: Some(super::forms::STEP_CREATE),
|
||||
kind: CommandKind::Model,
|
||||
run: step_create,
|
||||
|
||||
@@ -440,6 +440,7 @@ const PROCEDURE: Field = variants(
|
||||
),
|
||||
flag("line_search", "Line search").def("false"),
|
||||
json("load_path", "Load path").hint("[[fraction, factor], …]; a ramp when empty"),
|
||||
positive("stabilization", "Stabilization", D::None).hint("damping fraction (e.g. 2e-4) for folding and snap-through; none when empty"),
|
||||
group(
|
||||
"imperfection",
|
||||
"Imperfection",
|
||||
@@ -755,6 +756,12 @@ pub const PLANAR: &[Field] = &[
|
||||
int("max_elements", "Max elements", 1, 4_000_000),
|
||||
];
|
||||
|
||||
pub const REVOLVE: &[Field] = &[
|
||||
num("angle", "Angle (°)", D::None).def("360"),
|
||||
int("sectors", "Sectors", 1, 3600).def("36"),
|
||||
num("start", "Start (°)", D::None).def("0"),
|
||||
];
|
||||
|
||||
pub const QUALITY: &[Field] = &[json("elements", "Elements").hint("[labels]; all when empty"), int("bins", "Histogram bins", 1, 1000).def("10")];
|
||||
|
||||
pub const SURFACE_CHECK: &[Field] =
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use mesh::analytic_surfaces;
|
||||
mod model;
|
||||
pub(crate) mod octree;
|
||||
mod results;
|
||||
mod revolve;
|
||||
pub(crate) mod solve;
|
||||
mod tags;
|
||||
|
||||
@@ -40,6 +41,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
mesh::DELAUNAY_POINTS,
|
||||
mesh::SURFACE_CHECK,
|
||||
mesh::PLANAR,
|
||||
revolve::REVOLVE,
|
||||
mesh::TET_GENERATE,
|
||||
octree::HEX_OCTREE,
|
||||
block::BLOCK,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! `mesh.revolve`: a 2D mesh of a profile in the XY plane (x the radius, Y the axis, as
|
||||
//! axisymmetric sections take it) swept about the axis into a solid mesh.
|
||||
//!
|
||||
//! The profile's point (r, y) at angle θ becomes (r cos θ, r sin θ, y): the axis becomes Z (up)
|
||||
//! and the profile's half-plane at θ = `start` the first ring of nodes. Each quad4 becomes a hex8
|
||||
//! per sector and each tri3 a wedge6, the profile's node order reversed so the swept elements are
|
||||
//! positively oriented. The 2D elements and their nodes are replaced (new labels after the
|
||||
//! largest ones). Geometry tags carry over: an element filling face F of body B fills body B, an
|
||||
//! element edge on edge E of body B gives the swept faces F named E of body B, and when the
|
||||
//! sweep is not a whole turn its first and last sides are faces `start` and `end` of the body,
|
||||
//! so sets named by geometry resolve on the solid. A whole turn joins its last ring to the first.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fieldcraft_model::{ElementKind, Entity, GeometryRef};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::mesh::optional_count;
|
||||
use super::optional_number;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const REVOLVE: CommandSpec = CommandSpec {
|
||||
id: "mesh.revolve",
|
||||
label: "Revolve 2D Mesh",
|
||||
params: r#"{"angle": 360 (degrees, up to a whole turn), "sectors": 36 (elements around, 1..=3600), "start": 0 (degrees: where the profile's half-plane lies, from +X towards +Y)} — sweeps the 2D mesh (quad4 and tri3 in the XY plane, x ≥ 0 the radius, Y the axis) about the axis into hex8 and wedge6 with the axis along Z: (r, y) at angle θ becomes (r cos θ, r sin θ, y). Replaces the 2D elements and their nodes; geometry tags carry over (a face's elements fill its body, an edge's element edges sweep to faces of that name, and a partial turn's first and last sides are faces `start` and `end`), so sets named by geometry resolve on the solid"#,
|
||||
form: Some(super::forms::REVOLVE),
|
||||
kind: CommandKind::Model,
|
||||
run: revolve,
|
||||
};
|
||||
|
||||
/// Most sectors of a sweep.
|
||||
const MAX_SECTORS: u64 = 3600;
|
||||
|
||||
/// The hex8 face (1-based) swept by each quad4 edge, and the wedge6 face swept by each tri3
|
||||
/// edge, with the profile's nodes reversed (see [`swept`]); faces 1 and 2 are the first and
|
||||
/// last sides of a sector.
|
||||
const QUAD_EDGE_FACE: [u8; 4] = [6, 5, 4, 3];
|
||||
const TRI_EDGE_FACE: [u8; 3] = [5, 4, 3];
|
||||
|
||||
/// A swept element's nodes: the profile's nodes reversed on ring `k`, then on ring `k + 1`.
|
||||
fn swept(profile: &[u32], ring: impl Fn(u32, usize) -> u32, k: usize) -> Vec<u32> {
|
||||
let reversed: Vec<u32> = profile.first().copied().into_iter().chain(profile.iter().skip(1).rev().copied()).collect();
|
||||
reversed.iter().map(|n| ring(*n, k)).chain(reversed.iter().map(|n| ring(*n, k + 1))).collect()
|
||||
}
|
||||
|
||||
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let angle = optional_number(p, "angle", 360.0)?;
|
||||
if !(angle > 0.0 && angle <= 360.0) {
|
||||
return Err(EngineError::Params(format!("`angle` must be in (0, 360] degrees, got {angle}")));
|
||||
}
|
||||
let start = optional_number(p, "start", 0.0)?;
|
||||
if !start.is_finite() {
|
||||
return Err(EngineError::Params("`start` must be a number of degrees".into()));
|
||||
}
|
||||
let sectors = optional_count(p, "sectors", 36, 1..=MAX_SECTORS)?;
|
||||
let whole = angle >= 360.0;
|
||||
if whole && sectors < 3 {
|
||||
return Err(EngineError::Params("a whole turn takes at least 3 sectors".into()));
|
||||
}
|
||||
// The profile: every element a linear 2D element in the XY plane, off the axis.
|
||||
let profile: Vec<(u32, ElementKind, Vec<u32>)> = s.model.elements().iter().map(|(l, e)| (*l, e.kind, e.nodes.clone())).collect();
|
||||
if profile.is_empty() {
|
||||
return Err(EngineError::Params("there is no 2D mesh to revolve".into()));
|
||||
}
|
||||
if let Some((l, k, _)) = profile.iter().find(|(_, k, _)| !matches!(k, ElementKind::Quad4 | ElementKind::Tri3)) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"not supported yet: revolving {} elements (element {l}): mesh.revolve takes a mesh of quad4 and tri3 elements",
|
||||
k.name()
|
||||
)));
|
||||
}
|
||||
let used: Vec<u32> = {
|
||||
let mut v: Vec<u32> = profile.iter().flat_map(|(_, _, n)| n.iter().copied()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let mut points = Vec::with_capacity(used.len());
|
||||
for n in &used {
|
||||
let xyz = s.model.node(*n).map(|n| n.xyz).ok_or_else(|| EngineError::Params(format!("node {n} is missing")))?;
|
||||
if xyz[2] != 0.0 {
|
||||
return Err(EngineError::Params(format!("node {n} is off the XY plane (z = {})", xyz[2])));
|
||||
}
|
||||
if !(xyz[0] > 0.0) {
|
||||
return Err(EngineError::Params(format!(
|
||||
"not supported yet: revolving a profile that reaches the axis (node {n} at x = {}): its elements would collapse; keep the profile at x > 0",
|
||||
xyz[0]
|
||||
)));
|
||||
}
|
||||
points.push(xyz);
|
||||
}
|
||||
let rings = if whole { sectors } else { sectors + 1 };
|
||||
let count = |a: usize, b: usize| a.checked_mul(b).ok_or_else(|| EngineError::Params("the revolved mesh is too large".into()));
|
||||
let (node_count, element_count) = (count(used.len(), rings)?, count(profile.len(), sectors)?);
|
||||
if element_count > fieldcraft_model::MAX_ELEMENTS {
|
||||
return Err(EngineError::Params(format!("the revolved mesh would have {element_count} elements, more than {}", fieldcraft_model::MAX_ELEMENTS)));
|
||||
}
|
||||
// The tags to carry over, read before the profile is removed.
|
||||
let tags = s.model.classification().clone();
|
||||
let entity = |tag: u32| tags.entity(tag).cloned();
|
||||
let (first_node, first_element) = super::fresh_labels(&s.model, node_count, element_count)?;
|
||||
let index: BTreeMap<u32, usize> = used.iter().enumerate().map(|(i, n)| (*n, i)).collect();
|
||||
let ring = |n: u32, k: usize| -> u32 {
|
||||
let i = index.get(&n).copied().unwrap_or(0);
|
||||
first_node + ((k % rings) * used.len() + i) as u32
|
||||
};
|
||||
let (start_rad, step) = (start.to_radians(), angle.to_radians() / sectors as f64);
|
||||
for k in 0..rings {
|
||||
let (sin, cos) = (start_rad + step * k as f64).sin_cos();
|
||||
for (i, x) in points.iter().enumerate() {
|
||||
s.model.set_node(first_node + (k * used.len() + i) as u32, [x[0] * cos, x[0] * sin, x[1]])?;
|
||||
}
|
||||
}
|
||||
let mut label = first_element;
|
||||
let mut new_tags: Vec<(u32, u8, GeometryRef)> = Vec::new();
|
||||
let mut regions: Vec<(u32, GeometryRef)> = Vec::new();
|
||||
for k in 0..sectors {
|
||||
for (old, kind, nodes) in &profile {
|
||||
let (solid, edge_face) = match kind {
|
||||
ElementKind::Quad4 => (ElementKind::Hex8, &QUAD_EDGE_FACE[..]),
|
||||
_ => (ElementKind::Wedge6, &TRI_EDGE_FACE[..]),
|
||||
};
|
||||
s.model.set_element(label, solid, swept(nodes, ring, k))?;
|
||||
if let Some(t) = tags.elements().get(old) {
|
||||
if let Some(region) = t.region.and_then(entity) {
|
||||
let body = GeometryRef::body(region.body.clone())?;
|
||||
regions.push((label, body.clone()));
|
||||
if !whole && k == 0 {
|
||||
new_tags.push((label, 1, GeometryRef::face(body.body.clone(), "start".to_string())?));
|
||||
}
|
||||
if !whole && k + 1 == sectors {
|
||||
new_tags.push((label, 2, GeometryRef::face(body.body.clone(), "end".to_string())?));
|
||||
}
|
||||
}
|
||||
for (edge, tag) in &t.edges {
|
||||
let (Some(e), Some(face)) = (entity(*tag), edge_face.get(usize::from(*edge).saturating_sub(1))) else { continue };
|
||||
if let Entity::Edge(name) = &e.entity {
|
||||
new_tags.push((label, *face, GeometryRef::face(e.body.clone(), name.clone())?));
|
||||
}
|
||||
}
|
||||
}
|
||||
label += 1;
|
||||
}
|
||||
}
|
||||
// The profile goes; its tags with it.
|
||||
for (old, _, _) in &profile {
|
||||
s.model.remove_element(*old)?;
|
||||
}
|
||||
for n in &used {
|
||||
s.model.remove_node(*n)?;
|
||||
}
|
||||
for (element, body) in regions {
|
||||
let tag = s.model.geometry_tag(&body)?;
|
||||
s.model.tag_element(element, tag)?;
|
||||
}
|
||||
for (element, face, reference) in new_tags {
|
||||
let tag = s.model.geometry_tag(&reference)?;
|
||||
s.model.tag_element_face(element, face, tag)?;
|
||||
}
|
||||
Ok(json!({
|
||||
"nodes": node_count,
|
||||
"elements": element_count,
|
||||
"first_node": first_node,
|
||||
"first_element": first_element,
|
||||
"sectors": sectors,
|
||||
"angle": angle,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A ring of rectangular section (1 ≤ r ≤ 2, 0 ≤ y ≤ 1) as two quads and two triangles,
|
||||
/// revolved: its volume is π (2² − 1²) · 1 = 3π, every element valid, the tags carried.
|
||||
#[test]
|
||||
fn a_revolved_profile_is_a_valid_solid_of_the_right_volume() {
|
||||
for (angle, sectors) in [(360.0f64, 48usize), (90.0, 12)] {
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.planar", &json!({"size": 0.5, "polygon": [[1, 0], [2, 0], [2, 1], [1, 1]], "name": "Ring"})).unwrap();
|
||||
let profile = s.model.elements().len();
|
||||
let r = s.execute("mesh.revolve", &json!({"angle": angle, "sectors": sectors})).unwrap();
|
||||
assert_eq!(r["elements"].as_u64(), Some((profile * sectors) as u64));
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
assert_eq!(q["invalid"]["count"].as_u64(), Some(0), "{q}");
|
||||
let volume: f64 = q["volume"].as_f64().unwrap();
|
||||
// Chords: a polygon of `sectors` sides over the arc.
|
||||
let chord = (angle.to_radians() / sectors as f64).sin() * sectors as f64 / angle.to_radians();
|
||||
let exact = 3.0 * std::f64::consts::PI * angle / 360.0;
|
||||
assert!((volume - exact * chord).abs() < 1e-9 * exact, "{volume} {}", exact * chord);
|
||||
// The bottom edge (e1, y = 0) sweeps to face e1: a set on it holds the z = 0 faces.
|
||||
s.execute("model.set.create", &json!({"name": "bottom", "geometry": [{"body": "Ring", "face": "e1"}]})).unwrap();
|
||||
let set = s.execute("model.set.resolve", &json!({"name": "bottom"})).unwrap();
|
||||
assert_eq!(set["faces"]["count"].as_u64(), Some(2 * sectors as u64), "two element edges along e1: {set}");
|
||||
if angle < 360.0 {
|
||||
s.execute("model.set.create", &json!({"name": "cut", "geometry": [{"body": "Ring", "face": "start"}]})).unwrap();
|
||||
let set = s.execute("model.set.resolve", &json!({"name": "cut"})).unwrap();
|
||||
assert_eq!(set["faces"]["count"].as_u64(), Some(profile as u64), "{set}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_mesh_revolve_refuses() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("mesh.revolve", &json!({})).is_err(), "no mesh");
|
||||
s.execute("mesh.planar", &json!({"size": 0.5, "polygon": [[0, 0], [2, 0], [2, 1], [0, 1]]})).unwrap();
|
||||
let e = s.execute("mesh.revolve", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("axis"), "{e}");
|
||||
let mut s = Session::default();
|
||||
s.execute("mesh.planar", &json!({"size": 0.5, "kind": "quad8", "polygon": [[1, 0], [2, 0], [2, 1], [1, 1]]})).unwrap();
|
||||
assert!(s.execute("mesh.revolve", &json!({})).unwrap_err().to_string().contains("quad8"));
|
||||
for bad in [json!({"angle": 0}), json!({"angle": 400}), json!({"sectors": 0}), json!({"angle": 360, "sectors": 2}), json!({"start": "x"})] {
|
||||
assert!(s.execute("mesh.revolve", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
//! geometry is a SolidCraft script run when the sample opens ([`Sample::design`]): a corpus
|
||||
//! recipe (`tests/corpus`) or [`UPRIGHT_BRACKET`].
|
||||
|
||||
pub mod boot;
|
||||
|
||||
use fieldcraft_geometry::Shape;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -792,4 +794,45 @@ mod tests {
|
||||
let kept = pz(last) / pz(0);
|
||||
assert!(kept > 0.4 && kept < 0.6 && kinetic(last) < kinetic(0), "{} {}", pz(last), kinetic(last));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "scratch"]
|
||||
fn boot_axisymmetric_scratch() {
|
||||
let travel: f64 = std::env::var("TRAVEL").ok().and_then(|v| v.parse().ok()).unwrap_or(30.0);
|
||||
let mut s = Session::default();
|
||||
let script = match std::env::var("SECTORS").ok().and_then(|v| v.parse::<usize>().ok()) {
|
||||
Some(n) => super::boot::solid(travel, n, std::env::var("FULL").is_err()),
|
||||
None => super::boot::axisymmetric(travel),
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
for c in script.as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
eprintln!("built {} elements in {:?}, height {}", s.model.elements().len(), t0.elapsed(), super::boot::height());
|
||||
if let Ok(path) = std::env::var("DUMP_MODEL") {
|
||||
std::fs::write(path, serde_json::to_string(&s.model).unwrap()).unwrap();
|
||||
}
|
||||
if std::env::var("NO_CONTACT").is_ok() {
|
||||
s.execute("interaction.contact.delete", &json!({"name": "Folds"})).unwrap();
|
||||
}
|
||||
let check = s.execute("model.check", &json!({})).unwrap();
|
||||
eprintln!("{check}");
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
eprintln!("quality {}", q.to_string().chars().take(300).collect::<String>());
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("solve.nonlinear", &json!({}));
|
||||
eprintln!("solve {:?}: {}", t0.elapsed(), match &r { Ok(v) => v.to_string().chars().take(3000).collect::<String>(), Err(e) => e.to_string() });
|
||||
if r.is_ok() {
|
||||
let labels: Vec<u32> = s.model.nodes().keys().copied().collect();
|
||||
let mut out = Vec::new();
|
||||
for chunk in labels.chunks(5000) {
|
||||
let p = s.execute("solve.probe", &json!({"step": "Squeeze", "nodes": chunk})).unwrap();
|
||||
for (l, n) in chunk.iter().zip(p["nodes"].as_array().unwrap()) {
|
||||
out.push(json!({"label": l, "xyz": s.model.nodes()[l].xyz, "u": n["displacement"], "p": n["contact"][0]["pressure"]}));
|
||||
}
|
||||
}
|
||||
let elements: Vec<Value> = s.model.elements().iter().map(|(l, e)| json!({"label": l, "nodes": e.nodes})).collect();
|
||||
std::fs::write("/private/tmp/claude-501/fc/boot_state.json", json!({"nodes": out, "elements": elements}).to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! The boot seal: a conical rubber bellows of our own design, its six rounded convolutions
|
||||
//! squeezed axially by a rigid collar until they fold onto each other.
|
||||
//!
|
||||
//! **Profile.** The wall's mid-surface in the (r, y) half-plane, y the axis: a base cylinder
|
||||
//! (r = [`BASE_RADIUS`]), then six V-shaped convolutions zig-zagging out to a crest and back in to
|
||||
//! a root, the crests and roots tapering linearly towards the top, and the top collar, a cylinder
|
||||
//! at the last root's radius. Every corner is rounded with radius [`BEND`]. The wall is the
|
||||
//! mid-surface offset by ± [`WALL`]/2 along its normal: a structured quad mesh, [`LAYERS`]
|
||||
//! elements through the wall and about [`STEP`] mm along it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Mid-surface radius of the base cylinder (mm).
|
||||
pub const BASE_RADIUS: f64 = 27.0;
|
||||
/// Wall thickness (mm).
|
||||
pub const WALL: f64 = 1.8;
|
||||
/// Radius of the rounded corners of the mid-surface (mm).
|
||||
pub const BEND: f64 = 1.6;
|
||||
/// Convolutions, and their pitch along the axis (mm).
|
||||
pub const FOLDS: usize = 6;
|
||||
const PITCH: f64 = 9.5;
|
||||
/// Height of the straight base, and the boot's height (mm).
|
||||
const BASE_LENGTH: f64 = 4.0;
|
||||
pub const HEIGHT: f64 = 70.0;
|
||||
/// Crest and root radii of the first and last convolutions (mm).
|
||||
const CREST: [f64; 2] = [38.0, 26.0];
|
||||
const ROOT: [f64; 2] = [28.0, 17.0];
|
||||
/// Elements through the wall and the target element length along it (mm).
|
||||
pub const LAYERS: usize = 3;
|
||||
pub const STEP: f64 = 0.6;
|
||||
|
||||
/// A piece of the mid-surface: a straight line, or an arc about `center` of `radius` from angle
|
||||
/// `start` through `sweep` (radians, counter-clockwise positive).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Piece {
|
||||
Line { from: [f64; 2], to: [f64; 2] },
|
||||
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn length(&self) -> f64 {
|
||||
match *self {
|
||||
Piece::Line { from, to } => (to[0] - from[0]).hypot(to[1] - from[1]),
|
||||
Piece::Arc { radius, sweep, .. } => radius * sweep.abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point and unit tangent at fraction `s` of the piece.
|
||||
fn at(&self, s: f64) -> ([f64; 2], [f64; 2]) {
|
||||
match *self {
|
||||
Piece::Line { from, to } => {
|
||||
let l = self.length();
|
||||
([from[0] + s * (to[0] - from[0]), from[1] + s * (to[1] - from[1])], [(to[0] - from[0]) / l, (to[1] - from[1]) / l])
|
||||
}
|
||||
Piece::Arc { center, radius, start, sweep } => {
|
||||
let a = start + s * sweep;
|
||||
let (sin, cos) = a.sin_cos();
|
||||
let turn = sweep.signum();
|
||||
([center[0] + radius * cos, center[1] + radius * sin], [-turn * sin, turn * cos])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The crest or root radius of convolution `k` (linear from the first to the last).
|
||||
fn taper(ends: [f64; 2], k: usize) -> f64 {
|
||||
let f = k as f64 / (FOLDS - 1) as f64;
|
||||
ends[0] + f * (ends[1] - ends[0])
|
||||
}
|
||||
|
||||
/// The corners of the mid-surface's polyline, base to collar top.
|
||||
fn corners() -> Vec<[f64; 2]> {
|
||||
let mut out = vec![[BASE_RADIUS, 0.0], [BASE_RADIUS, BASE_LENGTH]];
|
||||
for k in 0..FOLDS {
|
||||
let y = BASE_LENGTH + k as f64 * PITCH;
|
||||
out.push([taper(CREST, k), y + 0.5 * PITCH]);
|
||||
out.push([taper(ROOT, k), y + PITCH]);
|
||||
}
|
||||
let collar = out.last().map_or(0.0, |c| c[0]);
|
||||
out.push([collar, HEIGHT]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The mid-surface, base to collar: the polyline of [`corners`] with each inner corner rounded
|
||||
/// by an arc of radius [`BEND`] tangent to both sides (`None` when a side is too short for its
|
||||
/// arcs).
|
||||
fn mid_surface() -> Option<Vec<Piece>> {
|
||||
let c = corners();
|
||||
let unit = |a: [f64; 2], b: [f64; 2]| {
|
||||
let l = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
[(b[0] - a[0]) / l, (b[1] - a[1]) / l]
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut from = *c.first()?;
|
||||
for w in c.windows(3) {
|
||||
let (a, v, b) = (w[0], w[1], w[2]);
|
||||
let (d1, d2) = (unit(a, v), unit(v, b));
|
||||
let cross = d1[0] * d2[1] - d1[1] * d2[0];
|
||||
let turn = cross.atan2(d1[0] * d2[0] + d1[1] * d2[1]);
|
||||
let tangent = BEND * (0.5 * turn.abs()).tan();
|
||||
let enter = [v[0] - tangent * d1[0], v[1] - tangent * d1[1]];
|
||||
// The side must hold both of its arcs' tangent lengths.
|
||||
if tangent > (v[0] - from[0]).hypot(v[1] - from[1]) {
|
||||
return None;
|
||||
}
|
||||
out.push(Piece::Line { from, to: enter });
|
||||
// The centre on the inside of the turn: left of d1 for a left (counter-clockwise) turn.
|
||||
let side = turn.signum();
|
||||
let center = [enter[0] - side * BEND * d1[1], enter[1] + side * BEND * d1[0]];
|
||||
let start = (enter[1] - center[1]).atan2(enter[0] - center[0]);
|
||||
out.push(Piece::Arc { center, radius: BEND, start, sweep: turn });
|
||||
from = [v[0] + tangent * d2[0], v[1] + tangent * d2[1]];
|
||||
}
|
||||
out.push(Piece::Line { from, to: *c.last()? });
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The mesh of the wall in the (r, y) half-plane: nodes row by row along the mid-surface
|
||||
/// (`LAYERS + 1` per row, from the outer face in), and the number of rows.
|
||||
pub fn wall_nodes() -> (Vec<[f64; 2]>, usize) {
|
||||
let mut rows: Vec<([f64; 2], [f64; 2])> = Vec::new();
|
||||
for piece in mid_surface().unwrap_or_default() {
|
||||
let n = (piece.length() / STEP).round().max(1.0) as usize;
|
||||
let first = if rows.is_empty() { 0 } else { 1 };
|
||||
for i in first..=n {
|
||||
rows.push(piece.at(i as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
let mut nodes = Vec::with_capacity(rows.len() * (LAYERS + 1));
|
||||
for (p, t) in &rows {
|
||||
// The left normal of the tangent: through the wall from the outer face in, so the quads
|
||||
// run counter-clockwise.
|
||||
let n = [-t[1], t[0]];
|
||||
for j in 0..=LAYERS {
|
||||
let d = WALL * (j as f64 / LAYERS as f64 - 0.5);
|
||||
nodes.push([p[0] + d * n[0], p[1] + d * n[1]]);
|
||||
}
|
||||
}
|
||||
(nodes, rows.len())
|
||||
}
|
||||
|
||||
/// The top of the collar (mm): the boot's height.
|
||||
pub fn height() -> f64 {
|
||||
HEIGHT
|
||||
}
|
||||
|
||||
/// The wall's quad4 mesh in the (r, y) = (x, y) plane, tagged as face `wall` of body `Boot`
|
||||
/// with its edges `base`, `collar` (the ends), `outer` and `inner` (the faces of the wall).
|
||||
fn profile(steps: &mut Vec<Value>) {
|
||||
let (nodes, rows) = wall_nodes();
|
||||
let per = LAYERS + 1;
|
||||
let label = |i: usize, j: usize| (i * per + j + 1) as u64;
|
||||
for (k, p) in nodes.iter().enumerate() {
|
||||
steps.push(json!({"command": "mesh.node.add", "params": {"label": k + 1, "xyz": [p[0], p[1], 0.0]}}));
|
||||
}
|
||||
let mut element = 0u64;
|
||||
let mut edges: [Vec<Value>; 4] = Default::default();
|
||||
for i in 0..rows.saturating_sub(1) {
|
||||
for j in 0..LAYERS {
|
||||
element += 1;
|
||||
let quad = [label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1)];
|
||||
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "quad4", "nodes": quad}}));
|
||||
// Edge 1 runs along the outer face (j = 0), 3 along the inner one, 4 and 2 across
|
||||
// the wall at the base and the collar.
|
||||
let mut on = |k: usize, edge: u8| edges[k].push(json!({"element": element, "edge": edge}));
|
||||
if i == 0 {
|
||||
on(0, 4);
|
||||
}
|
||||
if i + 2 == rows {
|
||||
on(1, 2);
|
||||
}
|
||||
if j == 0 {
|
||||
on(2, 1);
|
||||
}
|
||||
if j + 1 == LAYERS {
|
||||
on(3, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
steps.push(json!({"command": "mesh.tag", "params": {"geometry": {"body": "Boot", "face": "wall"}, "elements": (1..=element).collect::<Vec<_>>()}}));
|
||||
for (name, list) in ["base", "collar", "outer", "inner"].iter().zip(edges) {
|
||||
steps.push(json!({"command": "mesh.tag", "params": {"geometry": {"body": "Boot", "edge": name}, "edges": list}}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The rubber, its section, general contact on every exterior face or edge, the clamped base
|
||||
/// and the collar pushed down by `travel` along the axis (`axis`: 1 for Y in 2D, 2 for Z in 3D)
|
||||
/// and held across it (a rigid collar).
|
||||
fn analysis(steps: &mut Vec<Value>, section: Value, entity: &str, axis: usize, travel: f64, extra: &[&str]) {
|
||||
// The elements: the profile's face, or the solid body.
|
||||
let region = if axis == 1 { json!({"body": "Boot", "face": "wall"}) } else { json!({"body": "Boot"}) };
|
||||
let mut push = [json!(0), json!(0), json!(0), Value::Null, Value::Null, Value::Null];
|
||||
if axis == 1 {
|
||||
push[2] = Value::Null;
|
||||
}
|
||||
push[axis] = json!(-travel);
|
||||
let mut held = vec!["Base", "Push"];
|
||||
held.extend_from_slice(extra);
|
||||
steps.extend([
|
||||
json!({"command": "model.set.create", "params": {"name": "boot", "geometry": [region]}}),
|
||||
json!({"command": "model.set.create", "params": {"name": "base", "geometry": [{"body": "Boot", entity: "base"}]}}),
|
||||
json!({"command": "model.set.create", "params": {"name": "collar", "geometry": [{"body": "Boot", entity: "collar"}]}}),
|
||||
json!({"command": "model.material.create", "params": {"name": "Rubber", "elastic": {"type": "isotropic", "young": RUBBER[0], "poisson": RUBBER[1]}, "density": 1.1e-9,
|
||||
"hyperelastic": {"type": "mooney_rivlin", "c01": RUBBER[2]}}}),
|
||||
json!({"command": "model.section.create", "params": {"name": "Boot", "set": "boot", "material": "Rubber", "kind": section, "formulation": {"hybrid": true}}}),
|
||||
json!({"command": "interaction.contact.general", "params": {"name": "Folds"}}),
|
||||
json!({"command": "model.boundary_condition.create", "params": {"name": "Base", "type": "fixed", "target": {"set": "base"}}}),
|
||||
json!({"command": "model.boundary_condition.create", "params": {"name": "Push", "type": "displacement", "target": {"set": "collar"}, "values": push}}),
|
||||
json!({"command": "model.step.create", "params": {"name": "Squeeze", "procedure": {"type": "nonlinear_static", "initial_increment": 0.02, "max_increment": 0.05, "max_increments": 1000, "line_search": true, "stabilization": STABILIZATION}, "boundary_conditions": held}}),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The rubber: Young's modulus (MPa), Poisson's ratio and Mooney–Rivlin C01 (MPa), so
|
||||
/// μ = E / (2 (1 + ν)) = 1.0 MPa, C10 = μ/2 − C01 = 0.4 MPa: a soft generic rubber, nearly
|
||||
/// incompressible (κ/μ ≈ 830).
|
||||
pub const RUBBER: [f64; 3] = [3.0, 0.499, 0.1];
|
||||
|
||||
/// The squeeze's stabilization (the fraction of its work the damping may dissipate).
|
||||
pub const STABILIZATION: f64 = 2e-3;
|
||||
|
||||
/// The axisymmetric boot (hybrid quad4) squeezed by `travel` mm.
|
||||
pub fn axisymmetric(travel: f64) -> Value {
|
||||
let mut steps = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
|
||||
profile(&mut steps);
|
||||
analysis(&mut steps, json!({"type": "axisymmetric"}), "edge", 1, travel, &[]);
|
||||
Value::Array(steps)
|
||||
}
|
||||
|
||||
/// The boot in 3D: the axisymmetric wall mesh revolved about the axis (`mesh.revolve`, Z up)
|
||||
/// into hybrid hex8, `sectors` around the circumference, or around the half x ≥ 0 (from −90° to
|
||||
/// 90°) with the cut held on the symmetry plane x = 0.
|
||||
pub fn solid(travel: f64, sectors: usize, half: bool) -> Value {
|
||||
let mut steps = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
|
||||
profile(&mut steps);
|
||||
let (angle, start) = if half { (180, -90) } else { (360, 0) };
|
||||
steps.push(json!({"command": "mesh.revolve", "params": {"angle": angle, "sectors": sectors, "start": start}}));
|
||||
let mut extra = Vec::new();
|
||||
if half {
|
||||
steps.extend([
|
||||
json!({"command": "model.set.create", "params": {"name": "cut", "geometry": [{"body": "Boot", "face": "start"}, {"body": "Boot", "face": "end"}]}}),
|
||||
json!({"command": "model.boundary_condition.create", "params": {"name": "Symmetry", "type": "symmetry", "target": {"set": "cut"}, "plane": "x"}}),
|
||||
]);
|
||||
extra.push("Symmetry");
|
||||
}
|
||||
analysis(&mut steps, json!({"type": "solid"}), "face", 2, travel, &extra);
|
||||
Value::Array(steps)
|
||||
}
|
||||
Reference in new issue
Block a user