diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index e203c14..ccbf518 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -8,7 +8,10 @@ use crate::schema::*; // ---- Shared pieces ---- /// `{"set": …}` or `{"reference_point": …}`. -const TARGET_OPTIONS: &[Field] = &[reference("set", "Set", C::Set), reference("reference_point", "Reference point", C::ReferencePoint)]; +const TARGET_OPTIONS: &[Field] = &[ + reference("set", "Set", C::Set).help("A named set of nodes, elements or faces."), + reference("reference_point", "Reference point", C::ReferencePoint).help("A reference point: a single node that stands for a rigid connection."), +]; const fn target(key: &'static str, label: &'static str) -> Field { either(key, label, TARGET_OPTIONS) } @@ -16,7 +19,7 @@ const fn target(key: &'static str, label: &'static str) -> Field { /// The name of an existing definition, for delete commands. macro_rules! existing { ($label:literal, $c:expr) => { - &[reference("name", $label, $c).req()] + &[reference("name", $label, $c).req().help(concat!("The ", $label, " to act on, by name."))] }; } @@ -43,17 +46,18 @@ pub const SET: &[Field] = &[ "geometry", "Geometry", &[ - reference("body", "Body", C::Body).req(), - text("face", "Face").hint("persistent face name"), - text("edge", "Edge"), - text("vertex", "Vertex"), + reference("body", "Body", C::Body).help("A body of the open design.").req(), + text("face", "Face").help("A persistent face name of the body (e.g. F3); the whole body when empty.").hint("persistent face name"), + text("edge", "Edge").help("A persistent edge name of the body."), + text("vertex", "Vertex").help("A persistent vertex name of the body."), ], ) + .help("Bodies of the design, or their named faces, edges or vertices. Kept by name, so the set survives remeshing.") .hint("whole bodies, or their faces, edges or vertices"), - json("nodes", "Nodes").hint("[labels]"), - json("elements", "Elements").hint("[labels]"), - json("faces", "Element faces").hint("[{\"element\", \"face\"}]"), - json("edges", "Element edges").hint("[{\"element\", \"edge\"}]"), + json("nodes", "Nodes").help("Mesh node labels, as a JSON list.").hint("[labels]"), + json("elements", "Elements").help("Mesh element labels, as a JSON list.").hint("[labels]"), + json("faces", "Element faces").help("Element faces, as a JSON list of element label and local face number.").hint("[{\"element\", \"face\"}]"), + json("edges", "Element edges").help("Element edges, as a JSON list of element label and local edge number.").hint("[{\"element\", \"edge\"}]"), ]; const ELASTIC: Field = variants( @@ -63,19 +67,29 @@ const ELASTIC: Field = variants( variant( "isotropic", "Isotropic", - &[num("young", "Young's modulus", D::Stress).req(), range("poisson", "Poisson's ratio", D::None, -1.0, 0.5).req()], + &[ + num("young", "Young's modulus", D::Stress) + .help("Stiffness in tension: stress over elastic strain. Steel ≈ 210 GPa (210000 MPa), aluminium ≈ 70 GPa, rubber a few MPa.") + .req(), + range("poisson", "Poisson's ratio", D::None, -1.0, 0.5) + .help("Lateral contraction over axial stretch, between -1 and 0.5. Metals ≈ 0.3, rubber close to 0.5.") + .req(), + ], ), variant( "engineering_constants", "Orthotropic", &[ - vector("young", "E1, E2, E3", D::Stress, &["E1", "E2", "E3"]).req(), - vector("poisson", "ν12, ν13, ν23", D::None, &["ν12", "ν13", "ν23"]).req(), - vector("shear", "G12, G13, G23", D::Stress, &["G12", "G13", "G23"]).req(), + vector("young", "E1, E2, E3", D::Stress, &["E1", "E2", "E3"]).help("Young's moduli along the three material axes.").req(), + vector("poisson", "ν12, ν13, ν23", D::None, &["ν12", "ν13", "ν23"]) + .help("Poisson's ratios between the material axes (contraction along the second axis for a stretch along the first).") + .req(), + vector("shear", "G12, G13, G23", D::Stress, &["G12", "G13", "G23"]).help("Shear moduli in the three material planes.").req(), ], ), ], ) +.help("How the material deforms elastically: the same in every direction (isotropic) or per material axis (orthotropic).") .def(r#"{"type": "isotropic"}"#); const HARDENING: &[(&str, &str)] = &[("isotropic", "Isotropic"), ("kinematic", "Kinematic")]; @@ -88,9 +102,9 @@ const PLASTIC: Field = variants( "bilinear", "Bilinear", &[ - positive("yield_stress", "Yield stress", D::Stress).req(), - num("hardening_modulus", "Hardening modulus", D::Stress).def("0"), - choice("hardening", "Hardening", HARDENING).def("\"isotropic\""), + positive("yield_stress", "Yield stress", D::Stress).help("The initial yield stress: where elastic behaviour ends and plastic flow starts. 6061-T6 aluminium ≈ 276 MPa, mild steel ≈ 250 MPa.").req(), + num("hardening_modulus", "Hardening modulus", D::Stress).help("Slope of stress against plastic strain after yield. 0 is perfectly plastic; metals are often 1–5 % of Young's modulus (e.g. 2000 MPa for steel).").def("0"), + choice("hardening", "Hardening", HARDENING).help("Isotropic: the yield surface grows equally in every direction. Kinematic: it moves with the loading, so reversed loading yields earlier (Bauschinger effect; for cyclic loads).").def("\"isotropic\""), ], ), variant( @@ -99,28 +113,28 @@ const PLASTIC: Field = variants( &[rows( "points", "Hardening curve", - &[positive("strain", "Plastic strain", D::None).req(), positive("stress", "Stress", D::Stress).req()], - ) + &[positive("strain", "Plastic strain", D::None).help("Equivalent plastic strain of the point; the first point is 0.").req(), positive("stress", "Stress", D::Stress).help("Yield stress at that plastic strain.").req()], + ).help("Points of yield stress against plastic strain, plastic strain rising from 0 at the initial yield.") .req()], ), variant( "johnson_cook", "Johnson–Cook", &[ - positive("a", "A", D::Stress).req(), - num("b", "B", D::Stress).req(), - num("n", "n", D::None).req(), - num("c", "C", D::None).def("0"), - num("m", "m", D::None).def("1"), - positive("reference_strain_rate", "Reference strain rate", D::None).def("1").hint("per time unit"), - num("room_temperature", "Room temperature", D::Temperature).req(), - num("melt_temperature", "Melt temperature", D::Temperature).req(), - positive("specific_heat", "Specific heat", D::SpecificHeat).hint("isothermal when empty"), - range("taylor_quinney", "Taylor–Quinney", D::None, 0.0, 1.0).def("0.9"), + positive("a", "A", D::Stress).help("Johnson–Cook initial yield stress at the reference strain rate and room temperature. Steel ≈ 300–800 MPa.").req(), + num("b", "B", D::Stress).help("Johnson–Cook hardening coefficient: the stress added by plastic strain.").req(), + num("n", "n", D::None).help("Johnson–Cook hardening exponent, typically 0.1–0.6.").req(), + num("c", "C", D::None).help("Johnson–Cook strain-rate coefficient, typically 0.01–0.03; 0 ignores the strain rate.").def("0"), + num("m", "m", D::None).help("Johnson–Cook thermal softening exponent, typically about 1.").def("1"), + positive("reference_strain_rate", "Reference strain rate", D::None).help("The plastic strain rate at which A, B and n were measured, per time unit (often 1/s).").def("1").hint("per time unit"), + num("room_temperature", "Room temperature", D::Temperature).help("The temperature at which A, B and n hold; no thermal softening below it.").req(), + num("melt_temperature", "Melt temperature", D::Temperature).help("The temperature at which strength drops to zero. Steel ≈ 1800 K, aluminium ≈ 900 K.").req(), + positive("specific_heat", "Specific heat", D::SpecificHeat).help("Heat that raises a unit mass by one degree, for heating by plastic work. Steel ≈ 4.6e8 mm²/(s²·K) in mm-t-s.").hint("isothermal when empty"), + range("taylor_quinney", "Taylor–Quinney", D::None, 0.0, 1.0).help("Fraction of plastic work turned into heat, 0 to 1; usually 0.9.").def("0.9"), ], ), ], -); +).help("Permanent deformation once the stress passes the yield stress: bilinear, a table of points, or Johnson–Cook."); const DAMAGE: Field = variants( "damage", @@ -129,90 +143,145 @@ const DAMAGE: Field = variants( "johnson_cook", "Johnson–Cook", &[ - num("d1", "D1", D::None).req(), - num("d2", "D2", D::None).req(), - num("d3", "D3", D::None).req(), - num("d4", "D4", D::None), - num("d5", "D5", D::None), + num("d1", "D1", D::None).help("Johnson–Cook failure strain constant. Typical metals: −0.8 to 0.2.").req(), + num("d2", "D2", D::None).help("Johnson–Cook failure strain constant multiplying the stress-triaxiality exponential.").req(), + num("d3", "D3", D::None).help("Johnson–Cook stress-triaxiality exponent constant, usually negative.").req(), + num("d4", "D4", D::None).help("Johnson–Cook strain-rate constant; 0 when empty."), + num("d5", "D5", D::None).help("Johnson–Cook temperature constant; 0 when empty."), ], )], -); -const EROSION: Field = group("erosion", "Erosion", &[positive("plastic_strain", "Plastic strain", D::None).req()]); +) +.help("When the material fails: Johnson–Cook failure strain built from D1–D5."); +const EROSION: Field = group( + "erosion", + "Erosion", + &[positive("plastic_strain", "Plastic strain", D::None) + .help("Equivalent plastic strain at which an element is removed, e.g. 0.3–1 for ductile metals.") + .req()], +) +.help("Removes an element from an explicit analysis once it reaches the plastic strain given."); const HYPERELASTIC: Field = variants( "hyperelastic", "Hyperelasticity", &[ variant("neo_hookean", "Neo-Hookean", &[]), - variant("mooney_rivlin", "Mooney–Rivlin", &[num("c01", "C01", D::Stress).req()]), - variant("yeoh", "Yeoh", &[num("c20", "C20", D::Stress).req(), num("c30", "C30", D::Stress).req()]), + variant( + "mooney_rivlin", + "Mooney–Rivlin", + &[num("c01", "C01", D::Stress).help("Mooney–Rivlin second constant; C10 comes from the isotropic shear modulus.").req()], + ), + variant( + "yeoh", + "Yeoh", + &[ + num("c20", "C20", D::Stress).help("Yeoh second-order constant.").req(), + num("c30", "C30", D::Stress).help("Yeoh third-order constant.").req(), + ], + ), ], ) +.help("A large-strain rubber model (nonlinear steps), built on the isotropic constants.") .hint("large strain in nonlinear steps, from the isotropic constants"); const THERMAL: Field = group( "thermal", "Thermal", &[ - json("conductivity", "Conductivity").hint("k or [k1, k2, k3]"), - positive("specific_heat", "Specific heat", D::SpecificHeat), - json("expansion", "Expansion").hint("α or [α1, α2, α3], 1/K"), - num("reference_temperature", "Reference temperature", D::Temperature).def("0"), + json("conductivity", "Conductivity") + .help("Thermal conductivity, one value or one per axis. Steel ≈ 50 W/(m·K), i.e. 50 in mm-t-s-N.") + .hint("k or [k1, k2, k3]"), + positive("specific_heat", "Specific heat", D::SpecificHeat) + .help("Heat that raises a unit mass by one degree. Steel ≈ 460 J/(kg·K), 4.6e8 in mm-t-s."), + json("expansion", "Expansion") + .help("Thermal expansion coefficient, one value or one per axis, in 1/K. Steel ≈ 1.2e-5, aluminium ≈ 2.3e-5.") + .hint("α or [α1, α2, α3], 1/K"), + num("reference_temperature", "Reference temperature", D::Temperature).help("The temperature at which there is no thermal strain.").def("0"), ], -); +) +.help("Heat conduction, specific heat and thermal expansion, for heat-transfer and thermal-stress steps."); pub const MATERIAL: &[Field] = &[ NAME, ELASTIC, - positive("density", "Density", D::Density), + positive("density", "Density", D::Density) + .help("Mass per volume; needed for dynamics, frequencies and gravity. Steel ≈ 7.85e-9 t/mm³ (7850 kg/m³), aluminium ≈ 2.7e-9 t/mm³."), PLASTIC, HYPERELASTIC, DAMAGE, EROSION, THERMAL, - variants("fluid", "Fluid", &[variant("newtonian", "Newtonian", &[positive("viscosity", "Viscosity", D::Viscosity).req()])]) - .hint("a fluid needs a density"), + variants( + "fluid", + "Fluid", + &[variant( + "newtonian", + "Newtonian", + &[positive("viscosity", "Viscosity", D::Viscosity).help("Dynamic viscosity. Water ≈ 1e-3 Pa·s (1e-9 MPa·s), air ≈ 1.8e-5 Pa·s.").req()], + )], + ) + .help("Makes the material a fluid for a lattice Boltzmann flow analysis.") + .hint("a fluid needs a density"), ]; -pub const MATERIAL_PLASTICITY: &[Field] = &[reference("name", "Material", C::Material).req(), PLASTIC.req().def(r#"{"type": "bilinear"}"#)]; -pub const MATERIAL_DAMAGE: &[Field] = - &[reference("name", "Material", C::Material).req(), DAMAGE.def(r#"{"type": "johnson_cook"}"#).hint("give damage, erosion or both"), EROSION]; +pub const MATERIAL_PLASTICITY: &[Field] = + &[reference("name", "Material", C::Material).help("The material to change.").req(), PLASTIC.req().def(r#"{"type": "bilinear"}"#)]; +pub const MATERIAL_DAMAGE: &[Field] = &[ + reference("name", "Material", C::Material).help("The material to change.").req(), + DAMAGE.def(r#"{"type": "johnson_cook"}"#).hint("give damage, erosion or both"), + EROSION, +]; const PROFILE: Field = variants( "profile", "Profile", &[ - variant("rectangle", "Rectangle", &[positive("width", "Width", D::Length).req(), positive("height", "Height", D::Length).req()]), - variant("circle", "Circle", &[positive("radius", "Radius", D::Length).req()]), - variant("pipe", "Pipe", &[positive("radius", "Radius", D::Length).req(), positive("thickness", "Wall", D::Length).req()]), + variant( + "rectangle", + "Rectangle", + &[ + positive("width", "Width", D::Length).help("Cross-section width, along the section's first axis.").req(), + positive("height", "Height", D::Length).help("Cross-section height, along the section's second axis.").req(), + ], + ), + variant("circle", "Circle", &[positive("radius", "Radius", D::Length).help("Outer radius of the cross-section.").req()]), + variant( + "pipe", + "Pipe", + &[ + positive("radius", "Radius", D::Length).help("Outer radius of the pipe.").req(), + positive("thickness", "Wall", D::Length).help("Wall thickness of the pipe.").req(), + ], + ), variant( "i", "I-beam", &[ - positive("height", "Height", D::Length).req(), - positive("width", "Width", D::Length).req(), - positive("web", "Web", D::Length).req(), - positive("flange", "Flange", D::Length).req(), + positive("height", "Height", D::Length).help("Overall height of the I-beam.").req(), + positive("width", "Width", D::Length).help("Flange width.").req(), + positive("web", "Web", D::Length).help("Web thickness.").req(), + positive("flange", "Flange", D::Length).help("Flange thickness.").req(), ], ), variant( "box", "Box", &[ - positive("width", "Width", D::Length).req(), - positive("height", "Height", D::Length).req(), - positive("thickness", "Wall", D::Length).req(), + positive("width", "Width", D::Length).help("Outer width of the box.").req(), + positive("height", "Height", D::Length).help("Outer height of the box.").req(), + positive("thickness", "Wall", D::Length).help("Wall thickness of the box.").req(), ], ), ], ) +.help("The beam's cross-section shape.") .req() .def(r#"{"type": "rectangle"}"#); pub const SECTION: &[Field] = &[ NAME, - reference("set", "Region", C::Set).req(), - reference("material", "Material", C::Material).req(), + reference("set", "Region", C::Set).help("The set of elements the section applies to.").req(), + reference("material", "Material", C::Material).help("The material of the section.").req(), variants( "kind", "Kind", @@ -222,60 +291,104 @@ pub const SECTION: &[Field] = &[ "shell", "Shell", &[ - positive("thickness", "Thickness", D::Length).req(), - range("offset", "Offset", D::None, -0.5, 0.5).def("0").hint("fraction of the thickness"), - int("points", "Integration points", 1, 15).def("5"), + positive("thickness", "Thickness", D::Length).help("Shell thickness.").req(), + range("offset", "Offset", D::None, -0.5, 0.5) + .help("Where the reference surface sits, as a fraction of the thickness: 0 mid-surface, ±0.5 top or bottom.") + .def("0") + .hint("fraction of the thickness"), + int("points", "Integration points", 1, 15) + .help("Points through the thickness. 5 is usual; more for plasticity through the thickness.") + .def("5"), ], ), - variant("beam", "Beam", &[PROFILE, xyz("orientation", "Orientation", D::None).req().def("[0, 0, 1]")]), - variant("rod", "Rod", &[positive("area", "Area", D::Area).req()]), - variant("plane_stress", "Plane stress", &[positive("thickness", "Thickness", D::Length).req().def("1")]), - variant("plane_strain", "Plane strain", &[positive("thickness", "Thickness", D::Length).def("1")]), + variant( + "beam", + "Beam", + &[ + PROFILE, + xyz("orientation", "Orientation", D::None) + .help("A direction that sets the beam section's first axis (not along the beam).") + .req() + .def("[0, 0, 1]"), + ], + ), + variant("rod", "Rod", &[positive("area", "Area", D::Area).help("Cross-section area of the rod.").req()]), + variant( + "plane_stress", + "Plane stress", + &[positive("thickness", "Thickness", D::Length).help("Out-of-plane thickness of the plane-stress elements.").req().def("1")], + ), + variant( + "plane_strain", + "Plane strain", + &[positive("thickness", "Thickness", D::Length).help("Out-of-plane thickness of the plane-strain slice.").def("1")], + ), variant("axisymmetric", "Axisymmetric", &[]), ], ) + .help("What the elements are: solids, shells, beams, rods or 2D elements.") .req() .def(r#"{"type": "solid"}"#), group( "formulation", "Formulation", &[ - choice("integration", "Integration", &[("full", "Full"), ("reduced", "Reduced")]).def("\"full\""), - flag("incompatible_modes", "Incompatible modes").def("false"), - flag("hybrid", "Hybrid").def("false"), + choice("integration", "Integration", &[("full", "Full"), ("reduced", "Reduced")]) + .help("Full: every integration point. Reduced: fewer points, cheaper and less stiff, but may need hourglass control.") + .def("\"full\""), + flag("incompatible_modes", "Incompatible modes") + .help("Extra bending modes for linear hexahedra, so a coarse hex8 mesh bends without locking.") + .def("false"), + flag("hybrid", "Hybrid").help("Pressure as an extra unknown, for nearly incompressible materials such as rubber.").def("false"), ], - ), + ) + .help("How the solid elements are integrated; the defaults suit most models."), ]; pub const REFERENCE_POINT: &[Field] = &[ NAME, - xyz("xyz", "Position", D::Length).req().def("[0, 0, 0]"), - int("label", "Node label", 0, u32::MAX as i64).hint("0: the highest free label"), + xyz("xyz", "Position", D::Length).help("The point's coordinates.").req().def("[0, 0, 0]"), + int("label", "Node label", 0, u32::MAX as i64).help("The node label written to decks.").hint("0: the highest free label"), ]; -const DOFS: Field = json("dofs", "Degrees of freedom").hint("[1, …, 6], default all"); +const DOFS: Field = json("dofs", "Degrees of freedom") + .help("Which of the six DOFs to couple (1–3 translations, 4–6 rotations); all when empty.") + .hint("[1, …, 6], default all"); pub const COUPLING: &[Field] = &[ NAME, - reference("reference_point", "Reference point", C::ReferencePoint).req(), - reference("set", "Region", C::Set).req(), - choice("kind", "Kind", &[("kinematic", "Kinematic"), ("distributing", "Distributing")]).req().def("\"kinematic\""), + reference("reference_point", "Reference point", C::ReferencePoint).help("The reference point the region follows.").req(), + reference("set", "Region", C::Set).help("The nodes or faces coupled to the reference point.").req(), + choice("kind", "Kind", &[("kinematic", "Kinematic"), ("distributing", "Distributing")]) + .help("Kinematic: the region moves rigidly with the point. Distributing: forces spread over the region, which stays free to deform.") + .req() + .def("\"kinematic\""), DOFS, ]; pub const MASS: &[Field] = &[ NAME, TARGET, - positive("mass", "Mass", D::Mass).hint("per node; [mx, my, mz] in JSON"), - vector("inertia", "Inertia", D::Inertia, &["I11", "I22", "I33", "I12", "I13", "I23"]).hint("about the node; needs a reference point"), + positive("mass", "Mass", D::Mass).help("A lumped mass at each node of the target.").hint("per node; [mx, my, mz] in JSON"), + vector("inertia", "Inertia", D::Inertia, &["I11", "I22", "I33", "I12", "I13", "I23"]) + .help("Rotary inertia about the reference point: three moments and three products.") + .hint("about the node; needs a reference point"), ]; -const SPRING_FIRST: Field = target("first", "First end").req(); -const SPRING_SECOND: Field = target("second", "Second end").hint("ground when empty"); -const SPRING_DIRECTION: Field = - either("direction", "Direction", &[int("dof", "DOF", 1, 6), xyz("vector", "Vector", D::None), unit("line", "Along the ends")]) - .req() - .def(r#"{"dof": 1}"#); +const SPRING_FIRST: Field = target("first", "First end").help("Where the spring starts: a set or a reference point.").req(); +const SPRING_SECOND: Field = target("second", "Second end").help("Where the spring ends; ground when empty.").hint("ground when empty"); +const SPRING_DIRECTION: Field = either( + "direction", + "Direction", + &[ + int("dof", "DOF", 1, 6).help("1–3 translation along X, Y, Z; 4–6 rotation about them."), + xyz("vector", "Vector", D::None).help("A direction vector; its length does not matter."), + unit("line", "Along the ends").help("Along the line from the first end to the second."), + ], +) +.help("The direction the spring acts in: one DOF, a vector, or the line between its ends.") +.req() +.def(r#"{"dof": 1}"#); pub const SPRING: &[Field] = &[ NAME, @@ -283,16 +396,35 @@ pub const SPRING: &[Field] = &[ "", "Type", &[ - variant("spring", "Spring", &[SPRING_FIRST, SPRING_SECOND, SPRING_DIRECTION, positive("stiffness", "Stiffness", D::Stiffness).req()]), - variant("dashpot", "Dashpot", &[SPRING_FIRST, SPRING_SECOND, SPRING_DIRECTION, positive("coefficient", "Coefficient", D::Damping).req()]), + variant( + "spring", + "Spring", + &[ + SPRING_FIRST, + SPRING_SECOND, + SPRING_DIRECTION, + positive("stiffness", "Stiffness", D::Stiffness).help("Force per unit stretch. For a 10 N/mm spring enter 10 in mm-t-s-N.").req(), + ], + ), + variant( + "dashpot", + "Dashpot", + &[ + SPRING_FIRST, + SPRING_SECOND, + SPRING_DIRECTION, + positive("coefficient", "Coefficient", D::Damping).help("Damping force per unit velocity.").req(), + ], + ), ], ) + .help("What kind of connector: a spring (stiffness) or a dashpot (damping).") .req() .def("\"spring\""), ]; /// The target of a boundary condition, force, moment or temperature. -const TARGET: Field = target("target", "Target").req(); +const TARGET: Field = target("target", "Target").help("Where it applies: a set or a reference point.").req(); pub const BOUNDARY_CONDITION: &[Field] = &[ NAME, @@ -307,21 +439,35 @@ pub const BOUNDARY_CONDITION: &[Field] = &[ &[ TARGET, free_vector("values", "Values", D::Length, &["u1", "u2", "u3", "ur1", "ur2", "ur3"]) + .help("Displacements u1–u3 along X, Y, Z and rotations ur1–ur3; empty entries stay free.") .req() .def("[0, 0, 0, null, null, null]") .hint("empty: free"), ], ), - variant("symmetry", "Symmetry", &[TARGET, choice("plane", "Plane normal", &[("x", "X"), ("y", "Y"), ("z", "Z")]).req().def("\"x\"")]), - variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).req()]), + variant( + "symmetry", + "Symmetry", + &[ + TARGET, + choice("plane", "Plane normal", &[("x", "X"), ("y", "Y"), ("z", "Z")]) + .help("The axis normal to the symmetry plane: its translation and the two rotations about the other axes are held.") + .req() + .def("\"x\""), + ], + ), + variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).help("The prescribed temperature.").req()]), ], ) + .help("What is held: everything fixed, given displacements, a symmetry plane or a temperature.") .req() .def("\"fixed\""), ]; -const DISTRIBUTION: Field = choice("distribution", "Distribution", &[("total", "Total"), ("per_node", "Per node")]).def("\"total\""); -const LOAD_SET: Field = reference("set", "Region", C::Set).req(); +const DISTRIBUTION: Field = choice("distribution", "Distribution", &[("total", "Total"), ("per_node", "Per node")]) + .help("Total: the value is split over the target's nodes. Per node: every node gets the full value.") + .def("\"total\""); +const LOAD_SET: Field = reference("set", "Region", C::Set).help("The faces or elements the load acts on.").req(); pub const LOAD: &[Field] = &[ NAME, @@ -329,53 +475,107 @@ pub const LOAD: &[Field] = &[ "", "Type", &[ - variant("force", "Force", &[TARGET, xyz("vector", "Force", D::Force).req(), DISTRIBUTION]), - variant("moment", "Moment", &[TARGET, xyz("vector", "Moment", D::Moment).req(), DISTRIBUTION]), - variant("pressure", "Pressure", &[LOAD_SET, num("magnitude", "Pressure", D::Stress).req().hint("positive pushes in")]), - variant("traction", "Traction", &[LOAD_SET, xyz("vector", "Traction", D::Stress).req()]), + variant( + "force", + "Force", + &[TARGET, xyz("vector", "Force", D::Force).help("The force, X, Y and Z components in the global axes.").req(), DISTRIBUTION], + ), + variant( + "moment", + "Moment", + &[TARGET, xyz("vector", "Moment", D::Moment).help("The moment, about the X, Y and Z axes.").req(), DISTRIBUTION], + ), + variant( + "pressure", + "Pressure", + &[ + LOAD_SET, + num("magnitude", "Pressure", D::Stress) + .help("Pressure normal to the faces; positive pushes into the surface. 1 MPa = 1 N/mm².") + .req() + .hint("positive pushes in"), + ], + ), + variant( + "traction", + "Traction", + &[LOAD_SET, xyz("vector", "Traction", D::Stress).help("Force per area on the faces, X, Y and Z components.").req()], + ), variant( "gravity", "Gravity", - &[xyz("acceleration", "Acceleration", D::Acceleration).req(), reference("set", "Region", C::Set).hint("the whole model")], + &[ + xyz("acceleration", "Acceleration", D::Acceleration) + .help("Gravity or other acceleration. Earth gravity downward is [0, 0, -9810] mm/s² in mm-t-s.") + .req(), + reference("set", "Region", C::Set).help("Where gravity acts; the whole model when empty.").hint("the whole model"), + ], + ), + variant( + "body_force", + "Body force", + &[LOAD_SET, xyz("vector", "Force per volume", D::ForcePerVolume).help("A body force per unit volume, X, Y and Z components.").req()], ), - variant("body_force", "Body force", &[LOAD_SET, xyz("vector", "Force per volume", D::ForcePerVolume).req()]), variant( "centrifugal", "Centrifugal", &[ - num("angular_velocity", "Angular velocity", D::AngularVelocity).req(), - xyz("point", "Point on axis", D::Length).req().def("[0, 0, 0]"), - xyz("axis", "Axis", D::None).req().def("[0, 0, 1]"), - reference("set", "Region", C::Set).hint("the whole model"), + num("angular_velocity", "Angular velocity", D::AngularVelocity) + .help("Spin speed in radians per time unit. 3000 rpm ≈ 314 rad/s.") + .req(), + xyz("point", "Point on axis", D::Length).help("A point on the axis of rotation.").req().def("[0, 0, 0]"), + xyz("axis", "Axis", D::None).help("Direction of the axis of rotation.").req().def("[0, 0, 1]"), + reference("set", "Region", C::Set).help("Where the centrifugal load acts; the whole model when empty.").hint("the whole model"), ], ), - variant("surface_flux", "Surface heat flux", &[LOAD_SET, num("flux", "Flux", D::HeatFlux).req()]), + variant( + "surface_flux", + "Surface heat flux", + &[LOAD_SET, num("flux", "Flux", D::HeatFlux).help("Heat flow per area into the surface.").req()], + ), variant( "convection", "Convection", - &[LOAD_SET, positive("film", "Film coefficient", D::FilmCoefficient).req(), num("sink", "Sink temperature", D::Temperature).req()], + &[ + LOAD_SET, + positive("film", "Film coefficient", D::FilmCoefficient) + .help("Convective heat transfer coefficient. Still air ≈ 5–25 W/(m²·K), water 500–10000.") + .req(), + num("sink", "Sink temperature", D::Temperature).help("The temperature of the surrounding fluid.").req(), + ], ), - variant("heat_source", "Heat source", &[LOAD_SET, num("power", "Power per volume", D::None).req()]), - variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).req()]), + variant( + "heat_source", + "Heat source", + &[LOAD_SET, num("power", "Power per volume", D::None).help("Heat generated per unit volume.").req()], + ), + variant("temperature", "Temperature", &[TARGET, num("value", "Temperature", D::Temperature).help("The prescribed temperature.").req()]), ], ) + .help("What kind of load: forces, pressures, gravity, heat and more.") .req() .def("\"force\""), ]; const FACE_CONDITION: &[Variant] = &[ variant("wall", "Wall", &[]), - variant("moving_wall", "Moving wall", &[xyz("velocity", "Velocity", D::Velocity).req()]), + variant("moving_wall", "Moving wall", &[xyz("velocity", "Velocity", D::Velocity).help("The velocity of the moving wall.").req()]), variant("slip", "Slip", &[]), variant( "velocity", "Velocity inlet", &[ - xyz("velocity", "Velocity", D::Velocity).req(), - choice("profile", "Profile", &[("uniform", "Uniform"), ("parabolic", "Parabolic")]).def("\"uniform\""), + xyz("velocity", "Velocity", D::Velocity).help("Inflow velocity.").req(), + choice("profile", "Profile", &[("uniform", "Uniform"), ("parabolic", "Parabolic")]) + .help("Uniform across the inlet, or parabolic (fully developed flow).") + .def("\"uniform\""), ], ), - variant("pressure", "Pressure outlet", &[num("pressure", "Pressure", D::Stress).req().def("0")]), + variant( + "pressure", + "Pressure outlet", + &[num("pressure", "Pressure", D::Stress).help("Pressure at the outlet, relative to the domain's reference.").req().def("0")], + ), variant("periodic", "Periodic", &[]), ]; @@ -385,33 +585,44 @@ const fn face(key: &'static str, label: &'static str) -> Field { pub const FLUID_DOMAIN: &[Field] = &[ NAME, - reference("material", "Fluid", C::Material).req(), - xyz("min", "Box min", D::Length).req(), - xyz("max", "Box max", D::Length).req(), - positive("spacing", "Cell size", D::Length).req(), + reference("material", "Fluid", C::Material).help("The fluid material: needs a density and a viscosity.").req(), + xyz("min", "Box min", D::Length).help("The box corner with the smallest coordinates.").req(), + xyz("max", "Box max", D::Length).help("The box corner with the largest coordinates.").req(), + positive("spacing", "Cell size", D::Length) + .help("Lattice cell edge length. Smaller is more accurate and costs as the cube of the refinement.") + .req(), group( "faces", "Box faces", &[ - face("x_min", "X min"), - face("x_max", "X max"), - face("y_min", "Y min"), - face("y_max", "Y max"), - face("z_min", "Z min"), - face("z_max", "Z max"), + face("x_min", "X min").help("Condition on the box face at the smallest X."), + face("x_max", "X max").help("Condition on the box face at the largest X."), + face("y_min", "Y min").help("Condition on the box face at the smallest Y."), + face("y_max", "Y max").help("Condition on the box face at the largest Y."), + face("z_min", "Z min").help("Condition on the box face at the smallest Z."), + face("z_max", "Z max").help("Condition on the box face at the largest Z."), ], - ), - references("obstacles", "Obstacles", C::Body), - json("surfaces", "Obstacle faces").hint("[{\"face\", \"condition\"}]; walls by default"), - xyz("initial_velocity", "Initial velocity", D::Velocity), - xyz("acceleration", "Acceleration", D::Acceleration), - positive("tolerance", "Obstacle tolerance", D::Length).hint("a tenth of the cell size"), + ) + .help("The condition on each face of the box."), + references("obstacles", "Obstacles", C::Body).help("Bodies of the design the flow goes around."), + json("surfaces", "Obstacle faces") + .help("Conditions on single obstacle faces, as JSON; walls by default.") + .hint("[{\"face\", \"condition\"}]; walls by default"), + xyz("initial_velocity", "Initial velocity", D::Velocity).help("The fluid's velocity at the start."), + xyz("acceleration", "Acceleration", D::Acceleration).help("A body acceleration on the fluid, such as gravity."), + positive("tolerance", "Obstacle tolerance", D::Length) + .help("How close a cell must be to an obstacle surface to count as on it.") + .hint("a tenth of the cell size"), ]; pub const FLUID_BOUNDARY: &[Field] = &[ - reference("domain", "Domain", C::FluidDomain).req(), - json("at", "Boundary").req().def("\"x_min\"").hint("\"x_min\" … \"z_max\", or {\"body\", \"face\"}"), - variants("condition", "Condition", FACE_CONDITION).req().def(r#"{"type": "velocity"}"#), + reference("domain", "Domain", C::FluidDomain).help("The fluid domain to change.").req(), + json("at", "Boundary") + .help("A box face (x_min … z_max) or an obstacle face, as JSON.") + .req() + .def("\"x_min\"") + .hint("\"x_min\" … \"z_max\", or {\"body\", \"face\"}"), + variants("condition", "Condition", FACE_CONDITION).help("The flow condition on that boundary.").req().def(r#"{"type": "velocity"}"#), ]; const PROCEDURE: Field = variants( @@ -423,94 +634,144 @@ const PROCEDURE: Field = variants( "nonlinear_static", "Static, nonlinear", &[ - flag("nonlinear_geometry", "Nonlinear geometry").def("true"), - range("initial_increment", "Initial increment", D::None, 0.0, 1.0).def("0.1"), - range("min_increment", "Min increment", D::None, 0.0, 1.0).def("1e-5"), - range("max_increment", "Max increment", D::None, 0.0, 1.0).def("1.0"), - int("max_increments", "Max increments", 1, 1_000_000).def("100"), - int("max_iterations", "Iterations per increment", 1, 1000).def("16"), + flag("nonlinear_geometry", "Nonlinear geometry") + .help("Account for large displacements and rotations. Needed for buckling-like and large-deflection problems.") + .def("true"), + range("initial_increment", "Initial increment", D::None, 0.0, 1.0) + .help("First increment, as a fraction of the step (0.1 = 10 %).") + .def("0.1"), + range("min_increment", "Min increment", D::None, 0.0, 1.0).help("Smallest increment allowed before the step gives up.").def("1e-5"), + range("max_increment", "Max increment", D::None, 0.0, 1.0).help("Largest increment, as a fraction of the step.").def("1.0"), + int("max_increments", "Max increments", 1, 1_000_000).help("The most increments the step may take.").def("100"), + int("max_iterations", "Iterations per increment", 1, 1000).help("Newton iterations before an increment is cut back.").def("16"), group( "tolerances", "Tolerances", &[ - positive("force", "Force", D::None).def("1e-6"), - positive("displacement", "Displacement", D::None).def("1e-5"), - positive("energy", "Energy", D::None).def("1e-10"), + positive("force", "Force", D::None).help("Residual force, relative to the applied forces.").def("1e-6"), + positive("displacement", "Displacement", D::None) + .help("Displacement correction, relative to the increment's displacement.") + .def("1e-5"), + positive("energy", "Energy", D::None).help("Work of the residual, relative to the increment's work.").def("1e-10"), ], - ), - flag("line_search", "Line search").def("false"), - json("load_path", "Load path").hint("[[fraction, factor], …]; a ramp when empty"), + ) + .help("Convergence tolerances; the defaults suit most models."), + flag("line_search", "Line search") + .help("Scale each Newton correction to reduce the residual; helps with hard convergence.") + .def("false"), + json("load_path", "Load path") + .help("How the load factor varies through the step, as JSON pairs of step fraction and factor; a linear ramp when empty.") + .hint("[[fraction, factor], …]; a ramp when empty"), group( "imperfection", "Imperfection", &[ - reference("step", "Buckling step", C::Step).req(), - int("mode", "Mode", 1, 100_000).req().def("1"), - num("scale", "Largest component", D::Length).req(), + reference("step", "Buckling step", C::Step).help("The solved buckling step whose mode is used.").req(), + int("mode", "Mode", 1, 100_000).help("Which buckling mode, from 1.").req().def("1"), + num("scale", "Largest component", D::Length) + .help("The largest displacement of the scaled mode, e.g. 1 % of the thickness.") + .req(), ], - ), + ) + .help("Seed a buckling mode shape into the geometry, for post-buckling analysis."), group( "arc_length", "Arc length", &[ - choice("constraint", "Constraint", &[("cylindrical", "Cylindrical"), ("spherical", "Spherical")]).def("\"cylindrical\""), - positive("max_load_factor", "Max load factor", D::None).def("1"), - json("displacement_limit", "Displacement limit").hint("{\"node\": 1, \"direction\": 3, \"displacement\": -10}"), + choice("constraint", "Constraint", &[("cylindrical", "Cylindrical"), ("spherical", "Spherical")]) + .help("Cylindrical: displacements only. Spherical: displacements and load factor.") + .def("\"cylindrical\""), + positive("max_load_factor", "Max load factor", D::None).help("Stop when the load factor reaches this.").def("1"), + json("displacement_limit", "Displacement limit") + .help("Stop when a node's displacement reaches a value, as JSON.") + .hint("{\"node\": 1, \"direction\": 3, \"displacement\": -10}"), ], - ), + ) + .help("Follow the load-displacement path through limit points (snap-through), with the load factor as an unknown."), + ], + ), + variant( + "buckle", + "Buckling", + &[ + int("modes", "Modes", 1, 100_000).help("How many buckling modes to find.").req().def("4"), + num("shift", "Shift", D::None).help("Look for eigenvalues near this value; 0 finds the lowest."), ], ), - variant("buckle", "Buckling", &[int("modes", "Modes", 1, 100_000).req().def("4"), num("shift", "Shift", D::None)]), variant( "frequency", "Frequency", &[ - int("modes", "Modes", 1, 10_000).req().def("10"), - positive("min_hz", "Min frequency", D::Hertz), - positive("max_hz", "Max frequency", D::Hertz), + int("modes", "Modes", 1, 10_000).help("How many natural frequencies to find.").req().def("10"), + positive("min_hz", "Min frequency", D::Hertz).help("Lowest frequency of interest."), + positive("max_hz", "Max frequency", D::Hertz).help("Highest frequency of interest."), ], ), variant( "explicit_dynamic", "Dynamic, explicit", &[ - positive("end_time", "End time", D::Time).req(), - range("dt_scale", "Time step scale", D::None, 0.0, 1.0).def("0.9"), - int("max_increments", "Max increments", 1, 10_000_000_000).def("10000000"), - positive("output_interval", "Field output every", D::Time), - positive("history_interval", "History every", D::Time), - reference("history_nodes", "History nodes", C::Set), + positive("end_time", "End time", D::Time).help("Duration of the step. An impact is typically a few milliseconds.").req(), + range("dt_scale", "Time step scale", D::None, 0.0, 1.0) + .help("Fraction of the stable time step used, 0 to 1; 0.9 is usual.") + .def("0.9"), + int("max_increments", "Max increments", 1, 10_000_000_000).help("The most time increments the step may take.").def("10000000"), + positive("output_interval", "Field output every", D::Time) + .help("Time between saved result frames; ten frames over the step when empty."), + positive("history_interval", "History every", D::Time).help("Time between history samples."), + reference("history_nodes", "History nodes", C::Set).help("The nodes whose displacements are recorded over time."), rows( "initial_velocities", "Initial velocities", - &[reference("set", "Set", C::Set).req(), xyz("velocity", "Velocity", D::Velocity).req()], - ), + &[ + reference("set", "Set", C::Set).help("The set given the velocity.").req(), + xyz("velocity", "Velocity", D::Velocity).help("Its starting velocity.").req(), + ], + ) + .help("Starting velocities of sets, for impacts and drops."), rows( "rigid_walls", "Rigid walls", &[ - reference("set", "Set", C::Set).req(), - xyz("point", "Point", D::Length).req(), - xyz("normal", "Normal", D::None).req(), + reference("set", "Set", C::Set).help("The nodes the wall stops.").req(), + xyz("point", "Point", D::Length).help("A point on the wall.").req(), + xyz("normal", "Normal", D::None).help("The wall's normal, pointing to the side the nodes stay on.").req(), FRICTION, ], - ), + ) + .help("Flat walls the set's nodes cannot pass."), group( "hourglass", "Hourglass control", &[ - choice("type", "Type", &[("viscous", "Viscous"), ("stiffness", "Stiffness")]).def("\"viscous\""), - positive("coefficient", "Coefficient", D::None), + choice("type", "Type", &[("viscous", "Viscous"), ("stiffness", "Stiffness")]) + .help("Viscous: damping, for high-speed events. Stiffness: springs, for slower ones.") + .def("\"viscous\""), + positive("coefficient", "Coefficient", D::None).help("Strength of the hourglass control, typically 0.03–0.15."), ], - ), - group("bulk_viscosity", "Bulk viscosity", &[positive("linear", "Linear", D::None), positive("quadratic", "Quadratic", D::None)]), - flag("nonlinear_geometry", "Nonlinear geometry").def("true"), - choice("stress_rate", "Stress rate", &[("jaumann", "Jaumann"), ("green_naghdi", "Green–Naghdi")]), + ) + .help("Suppresses zero-energy modes of reduced-integration elements."), + group( + "bulk_viscosity", + "Bulk viscosity", + &[ + positive("linear", "Linear", D::None).help("Linear bulk viscosity coefficient, typically 0.06."), + positive("quadratic", "Quadratic", D::None).help("Quadratic bulk viscosity coefficient, typically 1.2."), + ], + ) + .help("Damps shock-wave ringing."), + flag("nonlinear_geometry", "Nonlinear geometry").help("Account for large displacements and rotations.").def("true"), + choice("stress_rate", "Stress rate", &[("jaumann", "Jaumann"), ("green_naghdi", "Green–Naghdi")]) + .help("How stresses rotate with the material: Jaumann or Green–Naghdi."), group( "mass_scaling", "Mass scaling", - &[positive("target_increment", "Target increment", D::Time).req(), reference("set", "Set", C::Set)], - ), + &[ + positive("target_increment", "Target increment", D::Time).help("The time step mass scaling aims for.").req(), + reference("set", "Set", C::Set).help("Where mass is added; the whole model when empty."), + ], + ) + .help("Adds mass to small elements so the time step reaches the target; check the added mass stays small."), ], ), variant( @@ -520,12 +781,13 @@ const PROCEDURE: Field = variants( "transient", "Transient", &[ - positive("end_time", "End time", D::Time).req(), - positive("increment", "Increment", D::Time).req(), - range("theta", "θ", D::None, 0.0, 1.0), - num("initial_temperature", "Initial temperature", D::Temperature), + positive("end_time", "End time", D::Time).help("Duration of the transient.").req(), + positive("increment", "Increment", D::Time).help("Time step of the transient.").req(), + range("theta", "θ", D::None, 0.0, 1.0).help("Time integration: 0.5 Crank–Nicolson, 1 backward Euler (default, most stable)."), + num("initial_temperature", "Initial temperature", D::Temperature).help("Temperature at the start of the transient."), ], ) + .help("Solve over time instead of for the steady state.") .hint("steady state when off")], ), variant("coupled_temperature_displacement", "Coupled temperature–displacement", &[]), @@ -533,31 +795,43 @@ const PROCEDURE: Field = variants( "fluid", "Fluid (lattice Boltzmann)", &[ - reference("domain", "Domain", C::FluidDomain).req(), - positive("end_time", "End time", D::Time).hint("or Steps"), - int("steps", "Steps", 1, 1_000_000_000_000), - positive("time_step", "Time step", D::Time).hint("chosen for Mach 0.087"), - int("frames", "Frames", 1, 100_000).def("10"), - positive("magic", "TRT Λ", D::None).hint("0.1875"), + reference("domain", "Domain", C::FluidDomain).help("The fluid domain to solve.").req(), + positive("end_time", "End time", D::Time).help("Simulated flow time, or give Steps.").hint("or Steps"), + int("steps", "Steps", 1, 1_000_000_000_000).help("Lattice time steps, instead of End time."), + positive("time_step", "Time step", D::Time) + .help("The lattice time step; chosen for a Mach number of 0.087 when empty.") + .hint("chosen for Mach 0.087"), + int("frames", "Frames", 1, 100_000).help("How many result frames to keep.").def("10"), + positive("magic", "TRT Λ", D::None).help("The two-relaxation-time magic parameter; 0.1875 is usual.").hint("0.1875"), ], ), ], -); +) +.help("What the step solves: linear or nonlinear static, buckling, frequencies, explicit dynamics, heat transfer or flow."); -const OUTPUT: Field = json("output", "Output").hint("[\"displacement\", \"stress\", …]; by procedure when empty"); -const STEP_BCS: Field = references("boundary_conditions", "Boundary conditions", C::BoundaryCondition); -const STEP_LOADS: Field = references("loads", "Loads", C::Load); +const OUTPUT: Field = json("output", "Output") + .help("Which results to keep, as JSON; by procedure when empty.") + .hint("[\"displacement\", \"stress\", …]; by procedure when empty"); +const STEP_BCS: Field = + references("boundary_conditions", "Boundary conditions", C::BoundaryCondition).help("The boundary conditions active in the step."); +const STEP_LOADS: Field = references("loads", "Loads", C::Load).help("The loads active in the step."); -pub const STEP_CREATE: &[Field] = - &[NAME, PROCEDURE.req().def(r#"{"type": "static"}"#), OUTPUT, STEP_BCS, STEP_LOADS, reference("before", "Insert before", C::Step).hint("last")]; +pub const STEP_CREATE: &[Field] = &[ + NAME, + PROCEDURE.req().def(r#"{"type": "static"}"#), + OUTPUT, + STEP_BCS, + STEP_LOADS, + reference("before", "Insert before", C::Step).help("The step the new one goes before; last when empty.").hint("last"), +]; -pub const STEP_EDIT: &[Field] = &[reference("name", "Step", C::Step).req(), PROCEDURE, OUTPUT, STEP_BCS, STEP_LOADS]; +pub const STEP_EDIT: &[Field] = &[reference("name", "Step", C::Step).help("The step to change.").req(), PROCEDURE, OUTPUT, STEP_BCS, STEP_LOADS]; pub const STEP_ACTIVATE: &[Field] = &[ - reference("step", "Step", C::Step).req(), - references("boundary_conditions", "Boundary conditions", C::BoundaryCondition), - references("loads", "Loads", C::Load), - flag("active", "Active").def("true"), + reference("step", "Step", C::Step).help("The step to change.").req(), + references("boundary_conditions", "Boundary conditions", C::BoundaryCondition).help("The boundary conditions to switch on or off in the step."), + references("loads", "Loads", C::Load).help("The loads to switch on or off in the step."), + flag("active", "Active").help("On: they act in the step. Off: they are switched off from it on.").def("true"), ]; pub const UNITS: &[Field] = &[choice( @@ -571,7 +845,7 @@ pub const UNITS: &[Field] = &[choice( ("in-lbf-s-psi", "in, lbf·s²/in, s, lbf, psi"), ("unspecified", "Unspecified"), ], -) +).help("The units the model's numbers are in. FieldCraft never converts: enter every value in this system. Designs from SolidCraft are in mm, so mm-t-s-N-MPa fits them.") .req() .def("\"mm-t-s-N-MPa\"")]; @@ -581,35 +855,63 @@ const FRICTION: Field = group( "friction", "Friction", &[ - positive("static", "Static μ", D::None).hint("0"), - positive("kinetic", "Kinetic μ", D::None).hint("the static μ"), - positive("decay", "Decay", D::None).hint("0; per slip rate"), + positive("static", "Static μ", D::None) + .help("Friction coefficient at rest. Steel on steel ≈ 0.15–0.8, rubber on concrete ≈ 0.6–1.") + .hint("0"), + positive("kinetic", "Kinetic μ", D::None) + .help("Friction coefficient while sliding, usually a little lower than the static.") + .hint("the static μ"), + positive("decay", "Decay", D::None).help("How fast friction falls from static to kinetic with slip rate.").hint("0; per slip rate"), ], ) +.help("Coulomb friction between the surfaces; frictionless when off.") .hint("frictionless"); -const PENALTY: Field = range("penalty_scale", "Penalty scale", D::None, 0.0, 1.0).def("1"); -const PENETRATION: Field = - choice("initial_penetration", "Initial penetration", &[("adjust", "Adjust nodes"), ("refuse", "Refuse")]).def("\"adjust\""); +const PENALTY: Field = range("penalty_scale", "Penalty scale", D::None, 0.0, 1.0) + .help("Scales the contact stiffness. Lower softens contact and helps convergence, but lets surfaces interpenetrate more.") + .def("1"); +const PENETRATION: Field = choice("initial_penetration", "Initial penetration", &[("adjust", "Adjust nodes"), ("refuse", "Refuse")]) + .help("Adjust: move secondary nodes onto the main surface at the start. Refuse: report overlaps as errors.") + .def("\"adjust\""); pub const CONTACT_PAIR: &[Field] = &[ NAME, - reference("main", "Main surface", C::Set).req(), - reference("secondary", "Secondary surface", C::Set).req(), + reference("main", "Main surface", C::Set).help("The stiffer or coarser surface.").req(), + reference("secondary", "Secondary surface", C::Set).help("The finer surface; its nodes are kept out of the main surface.").req(), + FRICTION, + PENALTY, + PENETRATION, +]; +pub const CONTACT_GENERAL: &[Field] = &[ + NAME, + reference("surface", "Surface", C::Set).help("The faces that may touch each other; every exterior face when empty.").hint("every exterior face"), FRICTION, PENALTY, PENETRATION, ]; -pub const CONTACT_GENERAL: &[Field] = &[NAME, reference("surface", "Surface", C::Set).hint("every exterior face"), FRICTION, PENALTY, PENETRATION]; pub const CONTACT_EDIT: &[Field] = &[ - reference("name", "Contact", C::Contact).req(), + reference("name", "Contact", C::Contact).help("The contact to change.").req(), variants( "surfaces", "Surfaces", &[ - variant("pair", "Pair", &[reference("main", "Main", C::Set).req(), reference("secondary", "Secondary", C::Set).req()]), - variant("general", "General", &[reference("surface", "Surface", C::Set).hint("every exterior face")]), + variant( + "pair", + "Pair", + &[ + reference("main", "Main", C::Set).help("The stiffer or coarser surface.").req(), + reference("secondary", "Secondary", C::Set).help("The finer surface.").req(), + ], + ), + variant( + "general", + "General", + &[reference("surface", "Surface", C::Set) + .help("The faces that may touch; every exterior face when empty.") + .hint("every exterior face")], + ), ], ) + .help("A pair of surfaces, or general contact among many faces.") .req(), FRICTION, PENALTY, @@ -617,19 +919,23 @@ pub const CONTACT_EDIT: &[Field] = &[ ]; pub const TIE: &[Field] = &[ NAME, - reference("main", "Main surface", C::Set).req(), - reference("secondary", "Secondary surface", C::Set).req(), - positive("position_tolerance", "Position tolerance", D::Length).hint("half the main element's thickness"), + reference("main", "Main surface", C::Set).help("The surface the secondary is tied to; usually the coarser mesh.").req(), + reference("secondary", "Secondary surface", C::Set).help("The surface whose nodes are tied to the main surface.").req(), + positive("position_tolerance", "Position tolerance", D::Length) + .help("Secondary nodes farther than this from the main surface are left untied.") + .hint("half the main element's thickness"), ]; pub const TIE_EDIT: &[Field] = &[ - reference("name", "Tie", C::Tie).req(), - reference("main", "Main surface", C::Set).req(), - reference("secondary", "Secondary surface", C::Set).req(), - positive("position_tolerance", "Position tolerance", D::Length).hint("half the main element's thickness"), + reference("name", "Tie", C::Tie).help("The tie to change.").req(), + reference("main", "Main surface", C::Set).help("The surface the secondary is tied to; usually the coarser mesh.").req(), + reference("secondary", "Secondary surface", C::Set).help("The surface whose nodes are tied to the main surface.").req(), + positive("position_tolerance", "Position tolerance", D::Length) + .help("Secondary nodes farther than this from the main surface are left untied.") + .hint("half the main element's thickness"), ]; pub const CONTACT_FIND: &[Field] = &[ - positive("tolerance", "Tolerance", D::Length).hint("a tenth of the mean face size"), - flag("create", "Create pairs").def("false"), + positive("tolerance", "Tolerance", D::Length).help("Faces closer than this count as touching.").hint("a tenth of the mean face size"), + flag("create", "Create pairs").help("Also create a contact for each touching pair found.").def("false"), FRICTION, PENALTY, PENETRATION, @@ -637,34 +943,51 @@ pub const CONTACT_FIND: &[Field] = &[ // ---- Meshing ---- -const BODY: Field = reference("body", "Body", C::Body).hint("every body of the design"); -const SURFACE: Field = int("surface", "Imported surface", 0, 1_000_000).hint("instead of a body"); -const TOLERANCE: Field = positive("tolerance", "Chordal tolerance", D::Millimetre).hint("1e-3 of the diagonal"); -const WELD: Field = range("weld", "Weld", D::None, 0.0, 1e-3).hint("1e-9 of the diagonal"); -const WELD_DISTANCE: Field = positive("weld_distance", "Weld distance", D::Millimetre).hint("the surface's own"); -const FEATURE_ANGLE: Field = range("feature_angle", "Feature angle", D::Degree, 0.0, 180.0).hint("45"); +const BODY: Field = + reference("body", "Body", C::Body).help("The body to mesh; every body of the design when empty.").hint("every body of the design"); +const SURFACE: Field = + int("surface", "Imported surface", 0, 1_000_000).help("An imported surface mesh to mesh instead of a design body.").hint("instead of a body"); +const TOLERANCE: Field = positive("tolerance", "Chordal tolerance", D::Millimetre) + .help("Largest distance between the tessellation and the true curved surface. Smaller follows curves closer, with more elements.") + .hint("1e-3 of the diagonal"); +const WELD: Field = range("weld", "Weld", D::None, 0.0, 1e-3) + .help("Points closer than this fraction of the diagonal are merged when the surface is assembled.") + .hint("1e-9 of the diagonal"); +const WELD_DISTANCE: Field = positive("weld_distance", "Weld distance", D::Millimetre) + .help("Points closer than this are merged when the surface is assembled.") + .hint("the surface's own"); +const FEATURE_ANGLE: Field = range("feature_angle", "Feature angle", D::Degree, 0.0, 180.0) + .help("Edges where faces meet at more than this angle are kept as sharp features.") + .hint("45"); pub const TET: &[Field] = &[ BODY, SURFACE, - positive("size", "Element size", D::Millimetre).hint("1/20 of the diagonal"), - choice("order", "Order", &[("linear", "Linear (tet4)"), ("quadratic", "Quadratic (tet10)")]).def("\"linear\""), + positive("size", "Element size", D::Millimetre) + .help("Target element edge length. Several elements through the thinnest wall are needed for accurate stresses.") + .hint("1/20 of the diagonal"), + choice("order", "Order", &[("linear", "Linear (tet4)"), ("quadratic", "Quadratic (tet10)")]) + .help("Linear (tet4) is fast but stiff in bending; quadratic (tet10) is far more accurate for stress.") + .def("\"linear\""), TOLERANCE, - flag("quality", "Improve quality").def("true"), - range("quality_target", "Quality target", D::Degree, 5.0, 60.0).hint("40"), - range("curvature_angle", "Curvature angle", D::Degree, 0.0, 90.0).hint("22.5"), - range("grading", "Grading", D::None, 0.05, 2.0).hint("0.3"), - int("max_passes", "Improvement passes", 0, 200).hint("20"), - int("max_points", "Max points", 1, 20_000_000).hint("1000000"), - int("max_steiner", "Max Steiner points", 0, 10_000_000).hint("50000"), - int("max_tets", "Max tetrahedra", 1, 50_000_000).hint("5000000"), + flag("quality", "Improve quality").help("Smooth and swap elements after meshing to remove slivers.").def("true"), + range("quality_target", "Quality target", D::Degree, 5.0, 60.0).help("The smallest dihedral angle quality improvement aims for.").hint("40"), + range("curvature_angle", "Curvature angle", D::Degree, 0.0, 90.0) + .help("Angle an element may span on a curved surface; smaller refines curved regions.") + .hint("22.5"), + range("grading", "Grading", D::None, 0.05, 2.0).help("How fast element size may grow from small to large; smaller is smoother.").hint("0.3"), + int("max_passes", "Improvement passes", 0, 200).help("The most quality-improvement passes.").hint("20"), + int("max_points", "Max points", 1, 20_000_000).help("Cap on the points the mesher creates.").hint("1000000"), + int("max_steiner", "Max Steiner points", 0, 10_000_000).help("Cap on the points added to recover the surface.").hint("50000"), + int("max_tets", "Max tetrahedra", 1, 50_000_000).help("Cap on the tetrahedra created.").hint("5000000"), WELD, WELD_DISTANCE, FEATURE_ANGLE, ]; -const HEX_SIZE: Field = positive("size", "Element size", D::Millimetre).hint("1/25 of the diagonal"); -const HEX_ORDER: Field = int("order", "Order", 1, 2).def("1").hint("1: hex8, 2: hex20"); +const HEX_SIZE: Field = positive("size", "Element size", D::Millimetre).help("Target element edge length.").hint("1/25 of the diagonal"); +const HEX_ORDER: Field = + int("order", "Order", 1, 2).help("1: linear hex8. 2: quadratic hex20, more accurate in bending.").def("1").hint("1: hex8, 2: hex20"); pub const HEX_GENERATE: &[Field] = &[ BODY, @@ -676,6 +999,7 @@ pub const HEX_GENERATE: &[Field] = &[ "Method", &[("auto", "Automatic"), ("swept", "Any sweep"), ("extrude", "Extrude"), ("sweep", "Sweep"), ("revolve", "Revolve"), ("mapped", "Mapped")], ) + .help("How to build the hexes: automatic, or a sweep, extrusion, revolution or mapped mesh.") .def("\"auto\""), TOLERANCE, WELD, @@ -688,7 +1012,9 @@ pub const HEX_SWEEP: &[Field] = &[ SURFACE, HEX_SIZE, HEX_ORDER, - choice("method", "Method", &[("swept", "Any sweep"), ("extrude", "Extrude"), ("sweep", "Sweep"), ("revolve", "Revolve")]).def("\"swept\""), + choice("method", "Method", &[("swept", "Any sweep"), ("extrude", "Extrude"), ("sweep", "Sweep"), ("revolve", "Revolve")]) + .help("How to sweep: any sweep, extrusion, sweep along a path or revolution.") + .def("\"swept\""), TOLERANCE, WELD, WELD_DISTANCE, @@ -697,30 +1023,47 @@ pub const HEX_SWEEP: &[Field] = &[ pub const HEX_MAPPED: &[Field] = &[BODY, SURFACE, HEX_SIZE, HEX_ORDER, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE]; -pub const HEX_BLOCKS: &[Field] = - &[references("bodies", "Cells", C::Body).hint("every body of the design"), HEX_SIZE, HEX_ORDER, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE]; +pub const HEX_BLOCKS: &[Field] = &[ + references("bodies", "Cells", C::Body) + .help("The cells (bodies) to mesh as blocks; every body of the design when empty.") + .hint("every body of the design"), + HEX_SIZE, + HEX_ORDER, + TOLERANCE, + WELD, + WELD_DISTANCE, + FEATURE_ANGLE, +]; pub const CELLS: &[Field] = &[ - json("methods", "Methods").hint(r#"{"cell": "auto | hex | swept | extrude | sweep | revolve | mapped | tet"}"#), + json("methods", "Methods") + .help("The meshing method per cell, as JSON.") + .hint(r#"{"cell": "auto | hex | swept | extrude | sweep | revolve | mapped | tet"}"#), choice("default", "Other cells", &[("auto", "Auto (hex, else tet)"), ("hex", "Hex"), ("swept", "Swept"), ("mapped", "Mapped"), ("tet", "Tet")]) + .help("The method for cells not listed in Methods.") .def("\"auto\""), - references("bodies", "Cells", C::Body).hint("every body of the design"), + references("bodies", "Cells", C::Body).help("The cells (bodies) to mesh; every body of the design when empty.").hint("every body of the design"), HEX_SIZE, HEX_ORDER, TOLERANCE, ]; pub const MIRROR: &[Field] = &[ - json("plane", "Plane").req().hint(r#""Datum" or {"point": [x, y, z], "normal": [nx, ny, nz]}"#), - references("bodies", "Bodies", C::Body).hint("every meshed body"), - json("as", "Images").hint(r#"{"body": "image body"}; default: the design's body at the reflected place"#), + json("plane", "Plane") + .help("The mirror plane: a datum plane's name, or a point and normal as JSON.") + .req() + .hint(r#""Datum" or {"point": [x, y, z], "normal": [nx, ny, nz]}"#), + references("bodies", "Bodies", C::Body).help("The meshed bodies to mirror; every meshed body when empty.").hint("every meshed body"), + json("as", "Images") + .help("Names for the mirrored bodies, as JSON.") + .hint(r#"{"body": "image body"}; default: the design's body at the reflected place"#), ]; pub const SYMMETRY: &[Field] = &[ NAME, - reference("datum", "Datum plane", C::DatumPlane).req(), - reference("set", "Set", C::Set).hint("the faces of the design on the plane"), - references("bodies", "Bodies", C::Body).hint("every body"), + reference("datum", "Datum plane", C::DatumPlane).help("The symmetry plane.").req(), + reference("set", "Set", C::Set).help("The faces on the plane; found from the design when empty.").hint("the faces of the design on the plane"), + references("bodies", "Bodies", C::Body).help("The bodies cut by the symmetry plane; every body when empty.").hint("every body"), ]; pub const HEX_OCTREE: &[Field] = &[ @@ -728,17 +1071,17 @@ pub const HEX_OCTREE: &[Field] = &[ SURFACE, HEX_SIZE, TOLERANCE, - int("max_depth", "Refinement levels", 0, 5).hint("3"), - int("max_elements", "Max elements", 1, 10_000_000), - int("attempts", "Attempts", 1, 8).hint("4"), - int("max_sweeps", "Optimiser sweeps", 0, 10_000).hint("300"), + int("max_depth", "Refinement levels", 0, 5).help("How many times the octree may refine near the surface.").hint("3"), + int("max_elements", "Max elements", 1, 10_000_000).help("Cap on the elements created."), + int("attempts", "Attempts", 1, 8).help("How many times to retry with adjusted settings.").hint("4"), + int("max_sweeps", "Optimiser sweeps", 0, 10_000).help("The most smoothing sweeps.").hint("300"), WELD, WELD_DISTANCE, FEATURE_ANGLE, ]; pub const BLOCK: &[Field] = &[ - text("body", "Body").req().def("\"Block\""), + text("body", "Body").help("Name of the new block body.").req().def("\"Block\""), choice( "kind", "Element", @@ -754,39 +1097,54 @@ pub const BLOCK: &[Field] = &[ ("tri6", "tri6 (2D)"), ], ) + .help("The element type of the block.") .req() .def("\"hex8\""), - json("corners", "Corners").req().hint("[[x, y, z] × 8] (2D: × 4 at z = 0)"), - json("divisions", "Divisions").req().hint("[n12, n14, n15] (2D: [n12, n14])"), - json("bias", "Bias").hint("[1, 1, 1]"), - json("edges", "Curved and named edges"), - json("faces", "Face names").hint("{\"1\": \"bottom\"}"), - json("vertices", "Corner names"), - text("face", "2D face name").hint("area"), - positive("merge", "Merge distance", D::Length).hint("1e-6 of the diagonal"), + json("corners", "Corners").help("The eight corner points (four for 2D), as JSON.").req().hint("[[x, y, z] × 8] (2D: × 4 at z = 0)"), + json("divisions", "Divisions").help("Elements along each edge direction, as JSON.").req().hint("[n12, n14, n15] (2D: [n12, n14])"), + json("bias", "Bias").help("Element size ratio along each direction, as JSON; 1 is uniform.").hint("[1, 1, 1]"), + json("edges", "Curved and named edges").help("Edge curves and names, as JSON."), + json("faces", "Face names").help("Names of the block's faces, as JSON.").hint("{\"1\": \"bottom\"}"), + json("vertices", "Corner names").help("Names of the block's corners, as JSON."), + text("face", "2D face name").help("Name of a 2D block's face.").hint("area"), + positive("merge", "Merge distance", D::Length).help("Nodes closer than this merge with existing nodes.").hint("1e-6 of the diagonal"), ]; pub const PLANAR: &[Field] = &[ - positive("size", "Element size", D::Length).req(), - choice("kind", "Element", &[("quad4", "quad4"), ("quad8", "quad8"), ("tri3", "tri3"), ("tri6", "tri6")]).def("\"quad4\""), + positive("size", "Element size", D::Length).help("Target element edge length.").req(), + choice("kind", "Element", &[("quad4", "quad4"), ("quad8", "quad8"), ("tri3", "tri3"), ("tri6", "tri6")]) + .help("The 2D element type.") + .def("\"quad4\""), BODY, - text("face", "Face").hint("a planar face of the body"), + text("face", "Face").help("A planar face of the body to mesh.").hint("a planar face of the body"), TOLERANCE, - json("polygon", "Polygon").hint("[[x, y], …] instead of a face"), - json("holes", "Holes"), - text("name", "Body name").hint("Plane"), - int("max_elements", "Max elements", 1, 4_000_000), + json("polygon", "Polygon").help("An outline to mesh instead of a face, as JSON points.").hint("[[x, y], …] instead of a face"), + json("holes", "Holes").help("Holes in the polygon, as JSON point lists."), + text("name", "Body name").help("Name of the new 2D body.").hint("Plane"), + int("max_elements", "Max elements", 1, 4_000_000).help("Cap on the elements created."), ]; -pub const QUALITY: &[Field] = &[json("elements", "Elements").hint("[labels]; all when empty"), int("bins", "Histogram bins", 1, 1000).def("10")]; +pub const QUALITY: &[Field] = &[ + json("elements", "Elements").help("The elements to measure, as JSON labels; all when empty.").hint("[labels]; all when empty"), + int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"), +]; -pub const SURFACE_CHECK: &[Field] = - &[reference("body", "Body", C::Body).req().hint("or an imported surface"), SURFACE, TOLERANCE, WELD, WELD_DISTANCE, FEATURE_ANGLE]; +pub const SURFACE_CHECK: &[Field] = &[ + reference("body", "Body", C::Body).help("The body whose surface to check.").req().hint("or an imported surface"), + SURFACE, + TOLERANCE, + WELD, + WELD_DISTANCE, + FEATURE_ANGLE, +]; -pub const NODE_ADD: &[Field] = &[int("label", "Label", 1, u32::MAX as i64).req(), xyz("xyz", "Position", D::Length).req().def("[0, 0, 0]")]; -pub const LABEL: &[Field] = &[int("label", "Label", 1, u32::MAX as i64).req()]; +pub const NODE_ADD: &[Field] = &[ + int("label", "Label", 1, u32::MAX as i64).help("The node label.").req(), + xyz("xyz", "Position", D::Length).help("The node's coordinates.").req().def("[0, 0, 0]"), +]; +pub const LABEL: &[Field] = &[int("label", "Label", 1, u32::MAX as i64).help("The label of the node or element.").req()]; pub const ELEMENT_ADD: &[Field] = &[ - int("label", "Label", 1, u32::MAX as i64).req(), + int("label", "Label", 1, u32::MAX as i64).help("The element label.").req(), choice( "kind", "Kind", @@ -804,44 +1162,66 @@ pub const ELEMENT_ADD: &[Field] = &[ ("hex20", "hex20"), ], ) + .help("The element type.") .req() .def("\"hex8\""), - json("nodes", "Nodes").req().hint("[labels]"), + json("nodes", "Nodes").help("The element's node labels in order, as JSON.").req().hint("[labels]"), ]; pub const SEED_EDGE: &[Field] = &[ - json("edges", "Edges").req().hint("[{\"body\", \"edge\"}, …] — Use selection with Select: Edges"), - int("count", "Count", 1, 100_000).hint("or an element size"), - positive("size", "Element size", D::Length).hint("or a count"), + json("edges", "Edges") + .help("The edges to seed, as JSON; easiest with Use selection.") + .req() + .hint("[{\"body\", \"edge\"}, …] — Use selection with Select: Edges"), + int("count", "Count", 1, 100_000).help("Number of elements along each edge.").hint("or an element size"), + positive("size", "Element size", D::Length).help("Element length along the edge, instead of a count.").hint("or a count"), group( "bias", "Bias", &[ - range("ratio", "Ratio", D::None, 1.0, 1e4).req().def("1"), - choice("toward", "Small toward", &[("start", "Start"), ("end", "End"), ("both", "Both ends")]).req().def("\"end\""), + range("ratio", "Ratio", D::None, 1.0, 1e4).help("Largest element over smallest; 1 is uniform.").req().def("1"), + choice("toward", "Small toward", &[("start", "Start"), ("end", "End"), ("both", "Both ends")]) + .help("Which end gets the small elements.") + .req() + .def("\"end\""), ], - ), - choice("constraint", "Constraint", &[("exact", "Exact"), ("min", "At least"), ("free", "Free (a goal)")]).def("\"exact\""), + ) + .help("Grade the elements along the edge."), + choice("constraint", "Constraint", &[("exact", "Exact"), ("min", "At least"), ("free", "Free (a goal)")]) + .help("Exact: the mesher must match the count. At least: it may add more. Free: a goal only.") + .def("\"exact\""), ]; -pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").hint("every seed")]; +pub const SEED_CLEAR: &[Field] = &[json("edges", "Edges").help("The edges to clear, as JSON; every seed when empty.").hint("every seed")]; -pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").req().hint("[[x, y, z], …]")]; +pub const DELAUNAY_POINTS: &[Field] = &[json("points", "Points").help("The points to triangulate, as JSON.").req().hint("[[x, y, z], …]")]; pub const TAG: &[Field] = &[ - group("geometry", "Geometry", &[reference("body", "Body", C::Body).req(), text("face", "Face"), text("edge", "Edge"), text("vertex", "Vertex")]) - .req(), - json("elements", "Elements"), - json("faces", "Element faces"), - json("edges", "Element edges"), - json("nodes", "Nodes"), + group( + "geometry", + "Geometry", + &[ + reference("body", "Body", C::Body).help("A body of the open design.").req(), + text("face", "Face").help("A persistent face name."), + text("edge", "Edge").help("A persistent edge name."), + text("vertex", "Vertex").help("A persistent vertex name."), + ], + ) + .help("The geometry the mesh entities are tagged with.") + .req(), + json("elements", "Elements").help("Element labels, as JSON."), + json("faces", "Element faces").help("Element faces, as JSON."), + json("edges", "Element edges").help("Element edges, as JSON."), + json("nodes", "Nodes").help("Node labels, as JSON."), ]; // ---- Geometry and files ---- pub const OPEN_DESIGN: &[Field] = &[ - path("path", "Design", false).req(), - choice("model", "Model", &[("keep", "Keep the current model"), ("new", "Start a new model")]).def("\"keep\""), + path("path", "Design", false).help("The SolidCraft design file to open.").req(), + choice("model", "Model", &[("keep", "Keep the current model"), ("new", "Start a new model")]) + .help("Keep the current model with the new geometry, or start a new one.") + .def("\"keep\""), ]; const POINT: Dim = D::Millimetre; pub const DATUM_PLANE: &[Field] = &[ @@ -854,18 +1234,32 @@ pub const DATUM_PLANE: &[Field] = &[ "offset", "Principal plane", &[ - choice("plane", "Plane", &[("yz", "YZ (normal X)"), ("zx", "ZX (normal Y)"), ("xy", "XY (normal Z)")]).req(), - num("offset", "Offset", POINT).def("0"), + choice("plane", "Plane", &[("yz", "YZ (normal X)"), ("zx", "ZX (normal Y)"), ("xy", "XY (normal Z)")]) + .help("The principal plane.") + .req(), + num("offset", "Offset", POINT).help("Distance along the plane's normal.").def("0"), ], ), variant( "three_points", "Three points", - &[xyz("a", "Point A", POINT).req(), xyz("b", "Point B", POINT).req(), xyz("c", "Point C", POINT).req()], + &[ + xyz("a", "Point A", POINT).help("First point.").req(), + xyz("b", "Point B", POINT).help("Second point.").req(), + xyz("c", "Point C", POINT).help("Third point.").req(), + ], + ), + variant( + "point_normal", + "Point and normal", + &[ + xyz("point", "Point", POINT).help("A point on the plane.").req(), + xyz("normal", "Normal", D::None).help("The plane's normal direction.").req(), + ], ), - variant("point_normal", "Point and normal", &[xyz("point", "Point", POINT).req(), xyz("normal", "Normal", D::None).req()]), ], ) + .help("How the plane is defined.") .req() .def(r#"{"type": "offset"}"#), ]; @@ -878,27 +1272,56 @@ pub const DATUM_AXIS: &[Field] = &[ variant( "principal", "Principal axis", - &[choice("axis", "Axis", &[("x", "X"), ("y", "Y"), ("z", "Z")]).req(), xyz("through", "Through", POINT).hint("the origin")], + &[ + choice("axis", "Axis", &[("x", "X"), ("y", "Y"), ("z", "Z")]).help("The principal direction.").req(), + xyz("through", "Through", POINT).help("A point the axis passes through; the origin when empty.").hint("the origin"), + ], + ), + variant( + "two_points", + "Two points", + &[ + xyz("a", "Point A", POINT).help("First point on the axis.").req(), + xyz("b", "Point B", POINT).help("Second point on the axis.").req(), + ], + ), + variant( + "point_direction", + "Point and direction", + &[ + xyz("point", "Point", POINT).help("A point on the axis.").req(), + xyz("direction", "Direction", D::None).help("The axis direction.").req(), + ], ), - variant("two_points", "Two points", &[xyz("a", "Point A", POINT).req(), xyz("b", "Point B", POINT).req()]), - variant("point_direction", "Point and direction", &[xyz("point", "Point", POINT).req(), xyz("direction", "Direction", D::None).req()]), ], ) + .help("How the axis is defined.") .req() .def(r#"{"type": "principal"}"#), ]; pub const DATUM_DELETE: &[Field] = existing!("Datum", C::Datum); -pub const PARTITION: &[Field] = - &[NAME, reference("datum", "Datum plane", C::DatumPlane).req(), references("bodies", "Bodies", C::Body).hint("every body the plane divides")]; +pub const PARTITION: &[Field] = &[ + NAME, + reference("datum", "Datum plane", C::DatumPlane).help("The plane that divides the bodies.").req(), + references("bodies", "Bodies", C::Body).help("The bodies to divide; every body the plane cuts when empty.").hint("every body the plane divides"), +]; pub const PARTITION_DELETE: &[Field] = existing!("Partition", C::Partition); -pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).req(), TOLERANCE]; -pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).req()]; -pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).req()]; -pub const FILE_SAVE: &[Field] = &[path("path", "File", true).hint("the model's file")]; -pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).req()]; -pub const RECOVERY_LIST: &[Field] = &[path("dir", "Folder", false).hint("the recovery folder")]; -pub const RECOVER: &[Field] = &[text("id", "Autosave").req(), path("dir", "Folder", false).hint("the recovery folder")]; -pub const RECOVERY_DISCARD: &[Field] = &[text("id", "Autosave"), flag("all", "All"), path("dir", "Folder", false).hint("the recovery folder")]; +pub const TESSELLATE: &[Field] = &[reference("body", "Body", C::Body).help("The body to tessellate.").req(), TOLERANCE]; +pub const IMPORT_MESH: &[Field] = &[path("path", "3MF file", false).help("A 3MF surface mesh with named faces.").req()]; +pub const FILE_OPEN: &[Field] = &[path("path", "Model", false).help("The FieldCraft model file to open.").req()]; +pub const FILE_SAVE: &[Field] = &[path("path", "File", true).help("Where to save; the model's own file when empty.").hint("the model's file")]; +pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).help("The file to save to.").req()]; +pub const RECOVERY_LIST: &[Field] = + &[path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder")]; +pub const RECOVER: &[Field] = &[ + text("id", "Autosave").help("The autosave to recover.").req(), + path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder"), +]; +pub const RECOVERY_DISCARD: &[Field] = &[ + text("id", "Autosave").help("The autosave to discard."), + flag("all", "All").help("Discard every autosave."), + path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder"), +]; pub const SAMPLE: &[Field] = &[choice( "name", "Sample", @@ -915,71 +1338,86 @@ pub const SAMPLE: &[Field] = &[choice( ("crank-mirror", "Mirrored crank arm"), ], ) +.help("The sample model to open.") .req() .def("\"cantilever\"")]; -pub const COUNT: &[Field] = &[int("count", "Count", 1, 1_000_000).def("1")]; +pub const COUNT: &[Field] = &[int("count", "Count", 1, 1_000_000).help("How many steps.").def("1")]; pub const DECK: &[Field] = &[ choice("format", "Format", &[("abaqus", "Abaqus input (.inp)"), ("nastran", "Nastran bulk data (.bdf)"), ("lsdyna", "LS-DYNA keyword (.k)")]) + .help("The solver deck format.") .req() .def("\"abaqus\""), - path("path", "File", true).req(), - references("steps", "Steps", C::Step).hint("all"), + path("path", "File", true).help("Where to write the deck.").req(), + references("steps", "Steps", C::Step).help("The steps to write; all when empty.").hint("all"), ]; // ---- Solving ---- -const STEP: Field = reference("step", "Step", C::Step).hint("the only one"); -const SOLVED: Field = reference("step", "Step", C::SolvedStep).hint("the step shown"); -const MAX_DOFS: Field = int("max_dofs", "Max DOFs", 1, 6_000_000).hint("6000000"); -const MAX_FACTOR: Field = int("max_factor_entries", "Max factor entries", 1, 400_000_000).hint("400000000"); -const THREADS: Field = int("threads", "Threads", 1, 256).hint("the session's"); +const STEP: Field = reference("step", "Step", C::Step).help("The step to solve; the only one when empty.").hint("the only one"); +const SOLVED: Field = reference("step", "Step", C::SolvedStep).help("The solved step; the step shown when empty.").hint("the step shown"); +const MAX_DOFS: Field = int("max_dofs", "Max DOFs", 1, 6_000_000).help("Cap on the degrees of freedom solved.").hint("6000000"); +const MAX_FACTOR: Field = + int("max_factor_entries", "Max factor entries", 1, 400_000_000).help("Cap on the factorised matrix size, which bounds memory.").hint("400000000"); +const THREADS: Field = int("threads", "Threads", 1, 256).help("Threads to use; the session's setting when empty.").hint("the session's"); pub const SOLVE_STATIC: &[Field] = &[STEP, MAX_DOFS, MAX_FACTOR, THREADS]; -pub const SOLVE_NONLINEAR: &[Field] = - &[STEP, MAX_DOFS, MAX_FACTOR, int("max_newton_iterations", "Max Newton iterations", 1, 100_000).hint("100000"), THREADS]; -pub const SOLVE_FREQUENCY: &[Field] = &[ +pub const SOLVE_NONLINEAR: &[Field] = &[ STEP, - choice("mass", "Mass matrix", &[("consistent", "Consistent"), ("lumped", "Lumped")]).def("\"consistent\""), - num("shift", "Shift", D::None).hint("0"), MAX_DOFS, MAX_FACTOR, - int("max_iterations", "Max Lanczos vectors", 1, 3000).hint("3000"), + int("max_newton_iterations", "Max Newton iterations", 1, 100_000).help("Cap on the Newton iterations over the whole step.").hint("100000"), THREADS, ]; -pub const SOLVE_BUCKLE: &[Field] = &[STEP, MAX_DOFS, MAX_FACTOR, int("max_iterations", "Max Lanczos vectors", 1, 3000).hint("3000"), THREADS]; +pub const SOLVE_FREQUENCY: &[Field] = &[ + STEP, + choice("mass", "Mass matrix", &[("consistent", "Consistent"), ("lumped", "Lumped")]) + .help("Consistent is more accurate; lumped is cheaper.") + .def("\"consistent\""), + num("shift", "Shift", D::None).help("Look for frequencies near this eigenvalue; 0 finds the lowest.").hint("0"), + MAX_DOFS, + MAX_FACTOR, + int("max_iterations", "Max Lanczos vectors", 1, 3000).help("Cap on the Lanczos vectors.").hint("3000"), + THREADS, +]; +pub const SOLVE_BUCKLE: &[Field] = + &[STEP, MAX_DOFS, MAX_FACTOR, int("max_iterations", "Max Lanczos vectors", 1, 3000).help("Cap on the Lanczos vectors.").hint("3000"), THREADS]; pub const SOLVE_EXPLICIT: &[Field] = &[ STEP, MAX_DOFS, - int("max_increments", "Max increments", 1, 1_000_000_000).hint("1000000000"), - int("max_output_values", "Max output values", 1, 50_000_000).hint("50000000"), - int("max_contact_faces", "Max contact faces", 1, 10_000_000).hint("10000000"), + int("max_increments", "Max increments", 1, 1_000_000_000).help("Cap on the time increments.").hint("1000000000"), + int("max_output_values", "Max output values", 1, 50_000_000).help("Cap on the result values kept.").hint("50000000"), + int("max_contact_faces", "Max contact faces", 1, 10_000_000).help("Cap on the faces in contact search.").hint("10000000"), THREADS, ]; -pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000)]; +pub const SOLVE_HISTORY: &[Field] = &[SOLVED.req(), int("every", "Keep one in", 1, 1_000_000_000).help("Keep one history sample in this many.")]; pub const SOLVE_FLUID: &[Field] = &[ STEP, - int("max_cells", "Max cells", 1, 16_000_000), - int("max_steps", "Max steps", 1, 10_000_000), - positive("max_cell_updates", "Max cell updates", D::None), - positive("max_frame_bytes", "Max frame bytes", D::None), + int("max_cells", "Max cells", 1, 16_000_000).help("Cap on the lattice cells."), + int("max_steps", "Max steps", 1, 10_000_000).help("Cap on the lattice time steps."), + positive("max_cell_updates", "Max cell updates", D::None).help("Cap on cells × steps."), + positive("max_frame_bytes", "Max frame bytes", D::None).help("Cap on the memory for result frames."), THREADS, ]; pub const SOLVE_PROBE: &[Field] = &[ SOLVED.req(), - json("nodes", "Nodes").hint("[labels]"), - reference("set", "Set", C::Set), - json("at", "Points").hint("[[x, y, z], …]: nearest nodes"), - int("mode", "Mode", 1, 1_000_000), - int("frame", "Frame", 0, 1_000_000_000), - flag("elements", "Element results"), - json("points", "Fluid points").hint("[[x, y, z], …]"), - json("forces", "Fluid walls").hint("[\"x_min\", \"F2:top\"]"), + json("nodes", "Nodes").help("Node labels to read, as JSON.").hint("[labels]"), + reference("set", "Set", C::Set).help("A set whose nodes to read."), + json("at", "Points").help("Points to read at the nearest nodes, as JSON.").hint("[[x, y, z], …]: nearest nodes"), + int("mode", "Mode", 1, 1_000_000).help("The mode to read, for frequency and buckling steps."), + int("frame", "Frame", 0, 1_000_000_000).help("The frame to read, for explicit and transient steps."), + flag("elements", "Element results").help("Also read element results (stresses) around the nodes."), + json("points", "Fluid points").help("Points in the fluid to read, as JSON.").hint("[[x, y, z], …]"), + json("forces", "Fluid walls").help("Walls whose fluid forces to sum, as JSON.").hint("[\"x_min\", \"F2:top\"]"), ]; // ---- Results ---- -pub const DEFORMED: &[Field] = &[SOLVED, flag("show", "Show").def("true"), json("scale", "Scale").def("\"auto\"").hint("\"auto\" or a factor")]; +pub const DEFORMED: &[Field] = &[ + SOLVED, + flag("show", "Show").help("Show the deformed shape.").def("true"), + json("scale", "Scale").help("Displacement magnification: auto, or a factor (1 is true scale).").def("\"auto\"").hint("\"auto\" or a factor"), +]; pub const CONTOUR: &[Field] = &[ SOLVED, choice( @@ -1008,17 +1446,29 @@ pub const CONTOUR: &[Field] = &[ ("none", "None"), ], ) + .help("The result shown as colours.") .req() .def("\"von_mises\""), - int("bands", "Bands", 2, 24).def("12"), + int("bands", "Bands", 2, 24).help("Number of colour bands.").def("12"), +]; +pub const MODES: &[Field] = &[SOLVED, int("mode", "Mode", 1, 1_000_000).help("The mode shape shown.").def("1")]; +pub const ANIMATE: &[Field] = &[flag("on", "Animate").help("Play the deformation or the frames.").def("true")]; +pub const FRAME: &[Field] = &[ + SOLVED, + int("frame", "Frame", 0, 1_000_000_000).help("The frame shown."), + positive("time", "Time", D::Time).help("Show the frame nearest this time; the last when empty.").hint("the last frame"), +]; +pub const REACTIONS: &[Field] = &[ + SOLVED, + references("sets", "Sets", C::Set) + .help("The sets whose reaction forces to sum; the step's boundary-condition sets when empty.") + .hint("the step's BC sets"), + xyz("about", "About point", D::Length).help("The point moments are taken about."), ]; -pub const MODES: &[Field] = &[SOLVED, int("mode", "Mode", 1, 1_000_000).def("1")]; -pub const ANIMATE: &[Field] = &[flag("on", "Animate").def("true")]; -pub const FRAME: &[Field] = &[SOLVED, int("frame", "Frame", 0, 1_000_000_000), positive("time", "Time", D::Time).hint("the last frame")]; -pub const REACTIONS: &[Field] = &[SOLVED, references("sets", "Sets", C::Set).hint("the step's BC sets"), xyz("about", "About point", D::Length)]; #[cfg(test)] mod tests { + use fieldcraft_model::UnitSystem; use serde_json::{Value, json}; use super::*; @@ -1105,6 +1555,40 @@ mod tests { } } + /// Every field of every form says what it means, and every number with a dimension shows + /// its unit, or its dimension when the model has no unit system. + #[test] + fn every_field_has_help_and_a_unit() { + fn walk(id: &str, fields: &[Field], missing: &mut Vec) { + for f in fields { + if f.help.trim().is_empty() { + missing.push(format!("{id}: {} ({}) has no help", f.label, f.key)); + } + if f.dim().is_some_and(|d| d != D::None) { + for system in [UnitSystem::Unspecified, UnitSystem::MmTonneS, UnitSystem::MKgS] { + if f.unit_label(system).is_none_or(|u| u.is_empty()) { + missing.push(format!("{id}: {} shows no unit in {system:?}", f.label)); + } + } + } + match f.kind { + Kind::Group(g) | Kind::Rows { fields: g, .. } | Kind::Either(g) => walk(id, g, missing), + Kind::Variants { variants, .. } => variants.iter().for_each(|v| walk(id, v.fields, missing)), + _ => {} + } + } + } + let mut missing = Vec::new(); + for c in crate::commands() { + walk(c.id, c.form.unwrap_or_default(), &mut missing); + } + assert!(missing.is_empty(), "{missing:#?}"); + let stress = positive("s", "S", D::Stress); + assert_eq!(stress.unit_label(UnitSystem::Unspecified).as_deref(), Some("[stress]")); + assert_eq!(stress.unit_label(UnitSystem::MmTonneS).as_deref(), Some("MPa")); + assert!(stress.help_text(UnitSystem::Unspecified).contains(UNITS_HINT)); + } + /// Every default is JSON; every variant field's default names a variant. #[test] fn defaults_parse() { diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index f500314..bc60f1f 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -33,6 +33,9 @@ pub struct Field { pub default: Option<&'static str>, /// What an empty field means ("every body", "1/25 of the diagonal"), or a short note. pub hint: &'static str, + /// What the field means, in a sentence or two, with a typical value: the form's tooltip and + /// the JSON Schema description. The unit is added from the field's dimension ([`Field::help_text`]). + pub help: &'static str, } /// What a field holds. @@ -213,6 +216,43 @@ pub enum Dim { } impl Dim { + /// The dimension's name ("stress", "length"); `None` for [`Dim::None`]. + pub fn name(self) -> Option<&'static str> { + Some(match self { + Dim::None => return None, + Dim::Length | Dim::Millimetre => "length", + Dim::Area => "area", + Dim::Stress => "stress", + Dim::Density => "density", + Dim::Force => "force", + Dim::Moment => "moment", + Dim::Mass => "mass", + Dim::Inertia => "rotary inertia", + Dim::Time => "time", + Dim::Velocity => "velocity", + Dim::Acceleration => "acceleration", + Dim::AngularVelocity => "angular velocity", + Dim::Stiffness => "stiffness", + Dim::Damping => "damping", + Dim::Viscosity => "viscosity", + Dim::ForcePerVolume => "force per volume", + Dim::Power => "power", + Dim::HeatFlux => "heat flux", + Dim::FilmCoefficient => "film coefficient", + Dim::Conductivity => "conductivity", + Dim::SpecificHeat => "specific heat", + Dim::Expansion => "expansion", + Dim::Temperature => "temperature", + Dim::Degree => "angle", + Dim::Hertz => "frequency", + }) + } + + /// Whether its unit follows the model's unit system (a fixed unit such as degrees does not). + pub fn follows_system(self) -> bool { + self.name().is_some() && self.unit(UnitSystem::Unspecified).is_none() + } + /// Its unit in `system`, or `None` (no unit, or the system is unspecified). pub fn unit(self, system: UnitSystem) -> Option { let fixed = match self { @@ -266,7 +306,7 @@ impl Dim { // ---- Building fields (const, so schemas are static tables) ---- const fn field(key: &'static str, label: &'static str, kind: Kind) -> Field { - Field { key, label, kind, required: false, default: None, hint: "" } + Field { key, label, kind, required: false, default: None, hint: "", help: "" } } impl Field { @@ -282,8 +322,52 @@ impl Field { pub const fn hint(self, hint: &'static str) -> Field { Field { hint, ..self } } + /// What it means ([`Field::help`]). + pub const fn help(self, help: &'static str) -> Field { + Field { help, ..self } + } + + /// Its physical dimension, for a number or a vector of numbers. + pub fn dim(&self) -> Option { + match self.kind { + Kind::Number { dim, .. } | Kind::Vector { dim, .. } => Some(dim), + _ => None, + } + } + + /// What its value is measured in, as a form shows it: the unit in `system` ("MPa"), or, with + /// no unit system, the dimension in brackets ("[stress]"). `None` for a number without a unit. + pub fn unit_label(&self, system: UnitSystem) -> Option { + let dim = self.dim()?; + dim.unit(system).or_else(|| dim.name().map(|n| format!("[{n}]"))) + } + + /// The help as the form's tooltip shows it: [`Field::help`] and the unit (or, with no unit system, the dimension and where to set the system). + pub fn help_text(&self, system: UnitSystem) -> String { + let mut out = self.help.to_string(); + let mut line = |s: String| { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&s); + }; + if let Some(dim) = self.dim() { + match (dim.unit(system), dim.name()) { + (Some(u), _) => line(format!("Unit: {u}.")), + (None, Some(n)) => { + let hint = if system == UnitSystem::Unspecified { format!(" {UNITS_HINT}") } else { String::new() }; + line(format!("Unit: a {n} in the model's units.{hint}")) + } + (None, None) => {} + } + } + out + } } +/// What a form says when the model has no unit system. +pub const UNITS_HINT: &str = "Set a unit system: Properties › Units."; + pub const fn num(key: &'static str, label: &'static str, dim: Dim) -> Field { field(key, label, Kind::Number { dim, min: None, max: None }) } @@ -347,7 +431,7 @@ pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [F } /// The `name` of a new or edited definition. -pub const NAME: Field = text("name", "Name").req(); +pub const NAME: Field = text("name", "Name").req().help("A name for it, unique among its kind; other definitions refer to it by this name."); impl Kind { /// The variant of `tag`, for [`Kind::Variants`]. @@ -831,8 +915,14 @@ fn field_schema(f: &Field, depth: usize) -> Value { _ => json!({}), }; s["title"] = json!(f.label); - if !f.hint.is_empty() { - s["description"] = json!(f.hint); + let description = match (f.help, f.hint) { + ("", "") => None, + (help, "") => Some(help.to_string()), + ("", hint) => Some(hint.to_string()), + (help, hint) => Some(format!("{help} Empty or default: {hint}.")), + }; + if let Some(d) = description { + s["description"] = json!(d); } if let Some(d) = parse_default(f) { s["default"] = d;