feat: query directional elastic modulus from validated material cards (#998)
* feat: validate physical material cards with sources and conditions * feat: query directional elastic modulus from physical material cards * fix: register physical material validation labels in complete locales * fix: register directional elasticity labels in complete locales * Add German and Korean labels for the material property commands --------- Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
1 parent
377e12a059
commit
52fb5ac0e6
11 files changed
+228
-7
No files matched your search
@@ -67,6 +67,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
|
||||
@@ -495,6 +495,8 @@
|
||||
"cmd.material_delete": "Physisches Material löschen",
|
||||
"cmd.material_edit": "Physisches Material bearbeiten",
|
||||
"cmd.material_list": "Materialien auflisten",
|
||||
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
|
||||
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
|
||||
"cmd.mesh_convert": "Netz konvertieren",
|
||||
"cmd.model_edges": "Kanten auflisten",
|
||||
"cmd.model_faces": "Flächen auflisten",
|
||||
|
||||
@@ -957,5 +957,6 @@
|
||||
"Line Spacing": "Line Spacing",
|
||||
"Path": "Path",
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it",
|
||||
"cmd.material_properties_validate":"Validate Physical Material Data"
|
||||
"cmd.material_properties_validate":"Validate Physical Material Data",
|
||||
"cmd.material_properties_directional":"Directional Elastic Modulus"
|
||||
}
|
||||
@@ -957,5 +957,6 @@
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
|
||||
"cmd.material_properties_validate":"物理材料データを検証"
|
||||
"cmd.material_properties_validate":"物理材料データを検証",
|
||||
"cmd.material_properties_directional":"方向別弾性率"
|
||||
}
|
||||
@@ -495,6 +495,8 @@
|
||||
"cmd.material_delete": "물리 재질 삭제",
|
||||
"cmd.material_edit": "물리 재질 편집",
|
||||
"cmd.material_list": "재질 목록",
|
||||
"cmd.material_properties_directional": "방향별 탄성 계수",
|
||||
"cmd.material_properties_validate": "물리 재질 데이터 검증",
|
||||
"cmd.mesh_convert": "메쉬 변환",
|
||||
"cmd.model_edges": "모서리 목록",
|
||||
"cmd.model_faces": "면 목록",
|
||||
|
||||
@@ -957,5 +957,6 @@
|
||||
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
|
||||
"ui.analysis": "分析",
|
||||
"ui.edit_section_analysis": "编辑截面分析",
|
||||
"cmd.material_properties_validate":"验证物理材料数据"
|
||||
"cmd.material_properties_validate":"验证物理材料数据",
|
||||
"cmd.material_properties_directional":"方向弹性模量"
|
||||
}
|
||||
@@ -305,6 +305,12 @@ impl MaterialCard {
|
||||
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
|
||||
/// bounds are admission limits, not claims about a real material's valid range.
|
||||
pub fn validate_card(value: &Value) -> Result<Validation> {
|
||||
admit_card(value)?.validate()
|
||||
}
|
||||
|
||||
/// Bound and deserialize a candidate before cloning it. Semantic checks belong
|
||||
/// to `validate` or the derived query; this alone does not validate the numbers.
|
||||
pub fn admit_card(value: &Value) -> Result<MaterialCard> {
|
||||
let mut pending = vec![(value, 0usize)];
|
||||
let (mut nodes, mut bytes) = (0usize, 0usize);
|
||||
while let Some((value, depth)) = pending.pop() {
|
||||
@@ -345,7 +351,69 @@ pub fn validate_card(value: &Value) -> Result<Validation> {
|
||||
}
|
||||
}
|
||||
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
|
||||
card.validate()
|
||||
Ok(card)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct DirectionalModulus {
|
||||
pub direction: [f64; 3],
|
||||
pub axes: Option<MaterialAxes>,
|
||||
pub young_modulus_pa: f64,
|
||||
pub validation: Validation,
|
||||
}
|
||||
|
||||
impl MaterialCard {
|
||||
/// Uniaxial small-strain modulus in the supplied material-local direction.
|
||||
/// This is a constitutive query, not a body load case or structural solver.
|
||||
pub fn directional_modulus(&self, direction: [f64; 3]) -> Result<DirectionalModulus> {
|
||||
let validation = self.validate()?;
|
||||
if !direction.iter().all(|x| x.is_finite()) {
|
||||
return Err(invalid("direction: expected three finite components"));
|
||||
}
|
||||
let scale = direction.iter().fold(0.0_f64, |a, x| a.max(x.abs()));
|
||||
if scale == 0. {
|
||||
return Err(invalid("direction: must be nonzero"));
|
||||
}
|
||||
let scaled = direction.map(|x| x / scale);
|
||||
let length = scaled.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
let direction = scaled.map(|x| x / length);
|
||||
let model = self
|
||||
.properties
|
||||
.iter()
|
||||
.find_map(|p| match &p.data {
|
||||
PropertyData::Elasticity { model } => Some(model),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| invalid("elasticity is unspecified"))?;
|
||||
let (axes, modulus) = match model {
|
||||
Elasticity::Isotropic { young_modulus_pa, .. } => (None, *young_modulus_pa),
|
||||
Elasticity::Orthotropic { axes, young_moduli_pa, shear_moduli_pa, poisson_ratios } => {
|
||||
let [x, y, z] = direction;
|
||||
let [e1, e2, e3] = *young_moduli_pa;
|
||||
let [g12, g23, g31] = *shear_moduli_pa;
|
||||
let [n12, n23, n31] = *poisson_ratios;
|
||||
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
|
||||
let minor = 1. - a * a;
|
||||
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
|
||||
// Cholesky of the scaled normal compliance: evaluate the energy
|
||||
// as sums of squares instead of cancelling positive/negative terms.
|
||||
let l22 = minor.sqrt();
|
||||
let l32 = (b - a * c) / l22;
|
||||
let l33 = (determinant / minor).sqrt();
|
||||
let [u, v, w] = [x * x / e1.sqrt(), y * y / e2.sqrt(), z * z / e3.sqrt()];
|
||||
let terms = [u + a * v + c * w, l22 * v + l32 * w, l33 * w];
|
||||
let compliance = terms.iter().map(|t| t * t).sum::<f64>() + (x * y).powi(2) / g12 + (y * z).powi(2) / g23 + (z * x).powi(2) / g31;
|
||||
if !compliance.is_finite() || compliance <= 0. {
|
||||
return Err(invalid("directional compliance is unrepresentable"));
|
||||
}
|
||||
(Some(*axes), compliance.recip())
|
||||
}
|
||||
};
|
||||
if !modulus.is_finite() || modulus <= 0. {
|
||||
return Err(invalid("directional modulus is unrepresentable"));
|
||||
}
|
||||
Ok(DirectionalModulus { direction, axes, young_modulus_pa: modulus, validation })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,6 +14,68 @@ fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
|
||||
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_isotropy_and_principal_axes_match_analytic_values() {
|
||||
let iso = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
let mut equivalent = orthotropic([12e9; 3], [0.25; 3]);
|
||||
equivalent["model"]["shear_moduli_pa"] = json!([4.8e9, 4.8e9, 4.8e9]);
|
||||
let equivalent = admit_card(&card(equivalent)).unwrap();
|
||||
for direction in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 2., 3.], [-1., 2., -3.], [f64::MAX; 3], [1e-320; 3]] {
|
||||
let a = iso.directional_modulus(direction).unwrap();
|
||||
let b = equivalent.directional_modulus(direction).unwrap();
|
||||
assert_eq!(a.young_modulus_pa, 12e9);
|
||||
assert!((b.young_modulus_pa / 12e9 - 1.).abs() < 1e-14);
|
||||
assert_eq!(a.axes, None);
|
||||
assert_eq!(b.axes, Some(MaterialAxes::WoodLrt));
|
||||
assert!((a.direction.iter().map(|x| x * x).sum::<f64>() - 1.).abs() < 1e-14);
|
||||
}
|
||||
let ortho = admit_card(&card(orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]))).unwrap();
|
||||
for (direction, e) in [([1., 0., 0.], 12e9), ([0., 1., 0.], 3e9), ([0., 0., 1.], 1e9)] {
|
||||
assert!((ortho.directional_modulus(direction).unwrap().young_modulus_pa / e - 1.).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_orthotropy_includes_shear_and_reciprocal_coupling() {
|
||||
let mut data = orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]);
|
||||
data["model"]["shear_moduli_pa"] = json!([2e9, 1e9, 0.5e9]);
|
||||
let material = admit_card(&card(data)).unwrap();
|
||||
// For n=(1,1,0)/sqrt(2), 1/E(n)=(S11+S22+2S12+1/G12)/4.
|
||||
let expected = 4. / (1. / 12e9 + 1. / 3e9 - 2. * 0.2 / 12e9 + 1. / 2e9);
|
||||
for d in [[1., 1., 0.], [-2., 2., 0.], [1e200, 1e200, 0.]] {
|
||||
let r = material.directional_modulus(d).unwrap();
|
||||
assert!((r.young_modulus_pa / expected - 1.).abs() < 1e-14);
|
||||
assert_eq!(r.validation.source_review_required, vec!["test"]);
|
||||
assert!(!r.validation.analysis_solver_available);
|
||||
}
|
||||
// Independent direct tensor contraction in a general non-principal direction.
|
||||
let [x, y, z] = [2. / 3., -1. / 3., 2. / 3.];
|
||||
let inverse = x * x * x * x / 12e9
|
||||
+ y * y * y * y / 3e9
|
||||
+ z * z * z * z / 1e9
|
||||
+ x * x * y * y * (1. / 2e9 - 2. * 0.2 / 12e9)
|
||||
+ y * y * z * z * (1. / 1e9 - 2. * 0.1 / 3e9)
|
||||
+ z * z * x * x * (1. / 0.5e9 - 2. * 0.02 / 1e9);
|
||||
let actual = material.directional_modulus([2., -1., 2.]).unwrap();
|
||||
assert!((actual.young_modulus_pa * inverse - 1.).abs() < 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_queries_refuse_unknown_unstable_and_nonfinite_inputs() {
|
||||
let mut material = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
for d in [[0.; 3], [f64::NAN, 0., 1.], [0., f64::INFINITY, 0.]] {
|
||||
assert!(material.directional_modulus(d).is_err());
|
||||
}
|
||||
material.properties.clear();
|
||||
assert!(material.directional_modulus([1., 0., 0.]).is_err());
|
||||
material.properties.push(Property {
|
||||
data: PropertyData::Elasticity { model: Elasticity::Isotropic { young_modulus_pa: 12e9, poisson_ratio: 0.6 } },
|
||||
source_id: "test".into(),
|
||||
conditions: Conditions::default(),
|
||||
});
|
||||
assert!(material.directional_modulus([1., 0., 0.]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
|
||||
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
|
||||
@@ -8,7 +8,28 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.va
|
||||
.icon("material")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md")];
|
||||
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
|
||||
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
|
||||
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
|
||||
|
||||
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "material.properties.directional";
|
||||
let object = params
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 2 && o.contains_key("card") && o.contains_key("direction"))
|
||||
.ok_or_else(|| bad(cmd, "expected {card: ..., direction: [x,y,z]}"))?;
|
||||
let value = object.get("card").ok_or_else(|| bad(cmd, "missing card"))?;
|
||||
let direction = object
|
||||
.get("direction")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|v| v.len() == 3)
|
||||
.ok_or_else(|| bad(cmd, "direction: expected exactly three numeric components"))?;
|
||||
let component = |i| direction.get(i).and_then(Value::as_f64).ok_or_else(|| bad(cmd, "direction: expected numeric components"));
|
||||
let direction = [component(0)?, component(1)?, component(2)?];
|
||||
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
let object = params
|
||||
@@ -25,6 +46,25 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn directional_command_reports_modulus_and_preserves_state_on_refusal() {
|
||||
let mut s = Session::default();
|
||||
let card = json!({"schema_version":1,"designation":"Synthetic","family":"other",
|
||||
"sources":[{"id":"test","title":"Original synthetic fixture","kind":"illustrative"}],
|
||||
"properties":[{"source_id":"test","data":{"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]});
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.camera, s.selection.clone());
|
||||
let response = s.execute("material.properties.directional", &json!({"card":card,"direction":[1,2,3]})).unwrap();
|
||||
assert_eq!(response["young_modulus_pa"], 12e9);
|
||||
assert_eq!(response["validation"]["source_review_required"], json!(["test"]));
|
||||
for direction in [json!([0, 0, 0]), json!([1, 2]), json!([1, 2, 3, 4]), json!([true, 0, 0]), json!(["1", 0, 0]), Value::Null] {
|
||||
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":direction})).is_err());
|
||||
}
|
||||
assert!(s.execute("material.properties.directional", &json!({"card":card,"direction":[1,0,0],"assign":true})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.camera, s.selection.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_reports_analytic_moduli_and_refuses_unstable_data() {
|
||||
let mut s = Session::default();
|
||||
@@ -85,6 +125,11 @@ mod tests {
|
||||
let card = json!({"schema_version":1,"designation":"Synthetic","family":"other","sources":[],"properties":[]});
|
||||
s.execute("material.properties.validate", &json!({"card":card})).unwrap();
|
||||
assert!(s.execute("material.properties.validate", &json!({"card":false})).is_err());
|
||||
let elastic = json!({"schema_version":1,"designation":"Synthetic","family":"other",
|
||||
"sources":[{"id":"test","title":"Original fixture","kind":"illustrative"}],
|
||||
"properties":[{"source_id":"test","data":{"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]});
|
||||
s.execute("material.properties.directional", &json!({"card":elastic,"direction":[1,2,3]})).unwrap();
|
||||
assert!(s.execute("material.properties.directional", &json!({"card":elastic,"direction":[0,0,0]})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.active_sketch, s.undo.len(), s.redo.len()));
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **344 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **345 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (99)
|
||||
|
||||
@@ -432,7 +432,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
|---|---|---|
|
||||
| Delete Face | `face.delete` | SOLID › MODIFY |
|
||||
|
||||
## `material.*` (6)
|
||||
## `material.*` (7)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -442,6 +442,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Edit Physical Material | `material.edit` | – |
|
||||
| Delete Physical Material | `material.delete` | – |
|
||||
| Validate Physical Material Data | `material.properties.validate` | – |
|
||||
| Directional Elastic Modulus | `material.properties.directional` | – |
|
||||
|
||||
## `mesh.*` (1)
|
||||
|
||||
|
||||
@@ -125,3 +125,37 @@ specimen properties and graded design values. No handbook data/table is bundled.
|
||||
The [Khronos PBR material model](https://www.khronos.org/gltf/pbr) is a reference for
|
||||
the separate optical track. No third-party code, engineering dataset or asset is
|
||||
added by this implementation.
|
||||
|
||||
## Directional elasticity query
|
||||
|
||||
`material.properties.directional {card, direction: [x,y,z]}` reports Young's modulus
|
||||
in Pa for a uniaxial, small-strain constitutive test along a material-local direction.
|
||||
It returns the safely normalized direction, declared axes (`null` for isotropy), and
|
||||
the full validation/source/condition report. It neither assigns axes to a body nor
|
||||
predicts a part's displacement, failure, strength or safety factor. No solver or
|
||||
render capability is enabled. Missing elasticity refuses instead of inventing data.
|
||||
|
||||
For orthotropy, let n=(x,y,z) be the unit direction. With engineering shear
|
||||
compliance 1/G and the reciprocal Poisson convention above:
|
||||
|
||||
```
|
||||
1/E(n) = x^4/E1 + y^4/E2 + z^4/E3
|
||||
+ x²y²(1/G12 - 2 nu12/E1)
|
||||
+ y²z²(1/G23 - 2 nu23/E2)
|
||||
+ z²x²(1/G31 - 2 nu31/E3).
|
||||
```
|
||||
|
||||
This follows by applying stress sigma = n tensor n, computing strain with the
|
||||
symmetric compliance, then contracting n with strain and n. The implementation
|
||||
uses a Cholesky sum of squares for the normal contribution, avoiding cancellation
|
||||
between the positive diagonal and negative coupling terms. Shear contributions
|
||||
are also nonnegative. Isotropy returns E directly, independent of orientation.
|
||||
|
||||
Input directions must contain exactly three finite numbers and be nonzero; arbitrary
|
||||
magnitude is allowed. Max-component scaling avoids overflow or underflow while
|
||||
normalizing very large or tiny vectors. Unrepresentable compliance/modulus refuses;
|
||||
this finite-precision check does not establish a condition-number certificate.
|
||||
The card's test conditions still apply; changing direction does not extrapolate
|
||||
temperature, moisture, process, rate, damage or nonlinear behavior. Synthetic analytic
|
||||
tests cover principal axes, the isotropic limit and off-axis shear/coupling terms.
|
||||
No physical dataset or external implementation is included.
|
||||
Reference in new issue
Block a user