diff --git a/ROADMAP.md b/ROADMAP.md index 4d12ff1f..beb4ae34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/assets/locales/de.json b/assets/locales/de.json index ee8ba70b..a53803ec 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index fd5a9dc6..591e8e13 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 4d79b510..f35b8333 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -957,5 +957,6 @@ "Line Spacing": "行間", "Path": "パス", "sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります", - "cmd.material_properties_validate":"物理材料データを検証" + "cmd.material_properties_validate":"物理材料データを検証", + "cmd.material_properties_directional":"方向別弾性率" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 4eaf82c2..9224618b 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "면 목록", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 1b2073e8..7be6c5f1 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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":"方向弹性模量" } diff --git a/crates/doc/src/physical_material.rs b/crates/doc/src/physical_material.rs index b5f496b4..26aee591 100644 --- a/crates/doc/src/physical_material.rs +++ b/crates/doc/src/physical_material.rs @@ -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 { + 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 { 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 { } } 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, + 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 { + 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::().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::() + (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)] diff --git a/crates/doc/src/physical_material_tests.rs b/crates/doc/src/physical_material_tests.rs index 0b99a76c..70dc3588 100644 --- a/crates/doc/src/physical_material_tests.rs +++ b/crates/doc/src/physical_material_tests.rs @@ -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::() - 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(); diff --git a/crates/engine/src/cmd/material_properties.rs b/crates/engine/src/cmd/material_properties.rs index 0e69aa9b..7a34c47d 100644 --- a/crates/engine/src/cmd/material_properties.rs +++ b/crates/engine/src/cmd/material_properties.rs @@ -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 { + 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 { 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())); diff --git a/docs/features.md b/docs/features.md index f5df4b7c..4a004542 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 ''`, 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 ''`, 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) diff --git a/docs/physical-material-cards.md b/docs/physical-material-cards.md index f9294972..1b3dfb3a 100644 --- a/docs/physical-material-cards.md +++ b/docs/physical-material-cards.md @@ -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.