diff --git a/ROADMAP.md b/ROADMAP.md index f101971c..4d12ff1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,6 +59,14 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | | M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +## Physical material data + +- Physical material card validation (#923/#922) provides typed SI mechanical and + thermal data, per-property sources/conditions and isotropic/orthotropic stability + checks through read-only `material.properties.validate`. It does not assign or + persist properties, provide a solver or enable rendering; see + [the contract and staged follow-ups](docs/physical-material-cards.md). + ## Library foundations (#785, #786, #787) - Proposed phases cover offline bundled/project/user packs, physical materials and diff --git a/assets/locales/en.json b/assets/locales/en.json index 7bf21bd0..fd5a9dc6 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -956,5 +956,6 @@ "Letter Spacing": "Letter Spacing", "Line Spacing": "Line Spacing", "Path": "Path", - "sketch_text.on_path": "The text follows its path: the curve places it" + "sketch_text.on_path": "The text follows its path: the curve places it", + "cmd.material_properties_validate":"Validate Physical Material Data" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index af8acbe5..4d79b510 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -956,5 +956,6 @@ "Letter Spacing": "文字間隔", "Line Spacing": "行間", "Path": "パス", - "sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります" + "sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります", + "cmd.material_properties_validate":"物理材料データを検証" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 813b6f74..1b2073e8 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -956,5 +956,6 @@ "Path": "路径", "sketch_text.on_path": "文字沿路径排列:由曲线放置", "ui.analysis": "分析", - "ui.edit_section_analysis": "编辑截面分析" + "ui.edit_section_analysis": "编辑截面分析", + "cmd.material_properties_validate":"验证物理材料数据" } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index eafc1dd3..6af31183 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -30,6 +30,7 @@ pub mod motion; mod name_lookup; pub mod naming; mod params; +pub mod physical_material; mod piece_names; mod profile_refs; pub mod progress; diff --git a/crates/doc/src/physical_material.rs b/crates/doc/src/physical_material.rs new file mode 100644 index 00000000..b5f496b4 --- /dev/null +++ b/crates/doc/src/physical_material.rs @@ -0,0 +1,353 @@ +//! Candidate engineering data, separate from assigned density and visual appearance. +//! Validation establishes numerical consistency, not certification or solver support. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +use crate::{DocError, Result}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MaterialCard { + pub schema_version: u32, + pub designation: String, + pub family: Family, + pub sources: Vec, + pub properties: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Family { + Wood, + Polymer, + Glass, + Metal, + Other, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Source { + pub id: String, + pub title: String, + pub kind: SourceKind, + /// A caller's claim, never an approval to redistribute or proof of authenticity. + pub license: Option, + pub reference: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + UserSupplied, + Measurement, + PublicReference, + Illustrative, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Property { + pub data: PropertyData, + pub source_id: String, + #[serde(default)] + pub conditions: Conditions, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Conditions { + pub temperature_k: Option, + /// kg water / kg dry material; unlike wet-basis fraction, may exceed one. + pub moisture_dry_basis_kg_kg: Option, + pub strain_rate_per_s: Option, + pub state: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MaterialAxes { + Material123, + WoodLrt, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PropertyData { + Density { kg_m3: f64 }, + Elasticity { model: Elasticity }, + TensileStrength { pa: f64 }, + CompressiveStrength { pa: f64 }, + YieldStrength { pa: f64 }, + ShearStrength { pa: f64 }, + ThermalConductivity { w_m_k: [f64; 3], axes: MaterialAxes }, + ThermalExpansion { per_k: [f64; 3], axes: MaterialAxes }, + SpecificHeat { j_kg_k: f64 }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "symmetry", rename_all = "snake_case", deny_unknown_fields)] +pub enum Elasticity { + Isotropic { + young_modulus_pa: f64, + poisson_ratio: f64, + }, + /// E1,E2,E3; G12,G23,G31; nu12,nu23,nu31, with nu_ij = -eps_j/eps_i + /// under uniaxial stress in i. Reciprocal ratios follow nu_ij/Ei = nu_ji/Ej. + Orthotropic { + axes: MaterialAxes, + young_moduli_pa: [f64; 3], + shear_moduli_pa: [f64; 3], + poisson_ratios: [f64; 3], + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct IsotropicModuli { + pub shear_pa: f64, + pub bulk_pa: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Validation { + pub schema_version: u32, + pub property_kinds: Vec, + pub isotropic_moduli: Option, + pub unspecified_conditions: Vec, + pub source_review_required: Vec, + pub analysis_solver_available: bool, + pub rendering_properties_available: bool, +} + +fn invalid(message: impl Into) -> DocError { + DocError::Invalid(message.into()) +} +fn text(value: &str, limit: usize, field: &str) -> Result<()> { + if value.trim().is_empty() || value.len() > limit || value.chars().any(char::is_control) { + return Err(invalid(format!("{field}: expected nonempty text of at most {limit} UTF-8 bytes without controls"))); + } + Ok(()) +} +fn number(value: f64, low: f64, high: f64, field: &str) -> Result<()> { + if !value.is_finite() || value < low || value > high { + return Err(invalid(format!("{field}: expected a finite value in [{low}, {high}]"))); + } + Ok(()) +} +fn positive(value: f64, high: f64, field: &str) -> Result<()> { + number(value, 0., high, field)?; + if value == 0. { + return Err(invalid(format!("{field}: must be positive"))); + } + Ok(()) +} + +impl Elasticity { + pub fn validate(&self) -> Result> { + match self { + Self::Isotropic { young_modulus_pa: e, poisson_ratio: nu } => { + positive(*e, 1e15, "young_modulus_pa")?; + if !e.recip().is_finite() { + return Err(invalid("young_modulus_pa produces unrepresentable compliance")); + } + if !nu.is_finite() || *nu <= -1. || *nu >= 0.5 { + return Err(invalid("poisson_ratio: isotropic stability requires -1 < nu < 0.5")); + } + let result = IsotropicModuli { shear_pa: e / (2. * (1. + nu)), bulk_pa: e / (3. * (1. - 2. * nu)) }; + if !result.shear_pa.is_finite() + || !result.bulk_pa.is_finite() + || result.shear_pa <= 0. + || result.bulk_pa <= 0. + || !result.shear_pa.recip().is_finite() + || !result.bulk_pa.recip().is_finite() + { + return Err(invalid("elastic moduli exceed numeric limits")); + } + Ok(Some(result)) + } + Self::Orthotropic { young_moduli_pa: e, shear_moduli_pa: g, poisson_ratios: nu, .. } => { + for value in e.iter().chain(g) { + positive(*value, 1e15, "orthotropic modulus_pa")?; + if !value.recip().is_finite() { + return Err(invalid("orthotropic modulus produces unrepresentable compliance")); + } + } + for value in nu { + number(*value, -1e6, 1e6, "orthotropic poisson_ratio")?; + } + // Diagonal scaling of the reciprocal, symmetric normal compliance + // gives unit diagonal and these off-diagonal entries. Sylvester's + // criterion proves positive definiteness without dimensional cutoffs. + let [e1, e2, e3] = *e; + let [n12, n23, n31] = *nu; + let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())]; + let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c; + if ![a, b, c, determinant].iter().all(|v| v.is_finite()) || 1. - a * a <= 0. || determinant <= 0. { + return Err(invalid("orthotropic compliance must be finite and positive definite")); + } + Ok(None) + } + } + } +} + +impl PropertyData { + pub fn kind(&self) -> &'static str { + match self { + Self::Density { .. } => "density", + Self::Elasticity { .. } => "elasticity", + Self::TensileStrength { .. } => "tensile_strength", + Self::CompressiveStrength { .. } => "compressive_strength", + Self::YieldStrength { .. } => "yield_strength", + Self::ShearStrength { .. } => "shear_strength", + Self::ThermalConductivity { .. } => "thermal_conductivity", + Self::ThermalExpansion { .. } => "thermal_expansion", + Self::SpecificHeat { .. } => "specific_heat", + } + } + fn validate(&self) -> Result> { + match self { + Self::Density { kg_m3 } => positive(*kg_m3, 1e6, "density.kg_m3")?, + Self::Elasticity { model } => return model.validate(), + Self::TensileStrength { pa } | Self::CompressiveStrength { pa } | Self::YieldStrength { pa } | Self::ShearStrength { pa } => { + positive(*pa, 1e15, self.kind())? + } + Self::ThermalConductivity { w_m_k, .. } => { + for k in w_m_k { + positive(*k, 1e9, "thermal_conductivity.w_m_k")?; + } + } + Self::ThermalExpansion { per_k, .. } => { + for a in per_k { + number(*a, -1., 1., "thermal_expansion.per_k")?; + } + } + Self::SpecificHeat { j_kg_k } => positive(*j_kg_k, 1e9, "specific_heat.j_kg_k")?, + } + Ok(None) + } +} + +impl MaterialCard { + pub fn validate(&self) -> Result { + if self.schema_version != 1 { + return Err(invalid("unsupported material card schema_version (expected 1)")); + } + text(&self.designation, 256, "designation")?; + if self.sources.len() > 32 || self.properties.len() > 16 { + return Err(invalid("material card exceeds 32 sources or 16 properties")); + } + let mut sources = BTreeSet::new(); + for source in &self.sources { + text(&source.id, 128, "source.id")?; + text(&source.title, 256, "source.title")?; + if let Some(value) = &source.license { + text(value, 512, "source.license")?; + } + if let Some(value) = &source.reference { + text(value, 2048, "source.reference")?; + } + if !sources.insert(source.id.clone()) { + return Err(invalid("duplicate source id")); + } + } + let mut kinds = BTreeSet::new(); + let mut used_sources = BTreeSet::new(); + let mut unspecified = Vec::new(); + let mut isotropic_moduli = None; + for property in &self.properties { + let kind = property.data.kind(); + if !kinds.insert(kind.to_string()) { + return Err(invalid(format!("duplicate property kind: {kind}"))); + } + if !sources.contains(&property.source_id) { + return Err(invalid(format!("{kind}: unknown source_id"))); + } + used_sources.insert(property.source_id.clone()); + let conditions = &property.conditions; + if let Some(t) = conditions.temperature_k { + positive(t, 20000., "temperature_k")?; + } + if let Some(m) = conditions.moisture_dry_basis_kg_kg { + number(m, 0., 1e6, "moisture_dry_basis_kg_kg")?; + } + if let Some(r) = conditions.strain_rate_per_s { + positive(r, 1e12, "strain_rate_per_s")?; + } + if let Some(state) = &conditions.state { + text(state, 1024, "conditions.state")?; + } + if conditions.temperature_k.is_none() + || conditions.state.is_none() + || (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none()) + || (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none()) + { + unspecified.push(kind.to_string()); + } + if let Some(moduli) = property.data.validate()? { + isotropic_moduli = Some(moduli); + } + } + Ok(Validation { + schema_version: 1, + property_kinds: kinds.into_iter().collect(), + isotropic_moduli, + unspecified_conditions: unspecified, + source_review_required: used_sources.into_iter().collect(), + analysis_solver_available: false, + rendering_properties_available: false, + }) + } +} + +/// 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 { + let mut pending = vec![(value, 0usize)]; + let (mut nodes, mut bytes) = (0usize, 0usize); + while let Some((value, depth)) = pending.pop() { + nodes += 1; + bytes += 32; + if nodes > 2048 || depth > 12 { + return Err(invalid("material card exceeds JSON node/depth budget")); + } + match value { + Value::String(s) => { + if s.len() > 256 * 1024 { + return Err(invalid("material card string exceeds admission budget")); + } + bytes += s.len(); + } + Value::Array(a) => { + if a.len() > 32 { + return Err(invalid("material card array exceeds 32 entries")); + } + pending.extend(a.iter().map(|v| (v, depth + 1))); + } + Value::Object(o) => { + if o.len() > 32 { + return Err(invalid("material card object exceeds 32 fields")); + } + for (key, v) in o { + if key.len() > 256 * 1024 { + return Err(invalid("material card key exceeds admission budget")); + } + bytes += key.len(); + pending.push((v, depth + 1)); + } + } + _ => {} + } + if bytes > 256 * 1024 { + return Err(invalid("material card exceeds 256 KiB admission budget")); + } + } + let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?; + card.validate() +} + +#[cfg(test)] +#[path = "physical_material_tests.rs"] +mod tests; diff --git a/crates/doc/src/physical_material_tests.rs b/crates/doc/src/physical_material_tests.rs new file mode 100644 index 00000000..0b99a76c --- /dev/null +++ b/crates/doc/src/physical_material_tests.rs @@ -0,0 +1,115 @@ +use super::*; +use serde_json::json; + +fn card(data: Value) -> Value { + json!({"schema_version":1,"designation":"Original synthetic fixture","family":"wood", + "sources":[{"id":"test","title":"Analytic synthetic data, not a material specification","kind":"illustrative","license":"MIT OR Apache-2.0"}], + "properties":[{"data":data,"source_id":"test"}]}) +} +fn isotropic(e: f64, nu: f64) -> Value { + json!({"kind":"elasticity","model":{"symmetry":"isotropic","young_modulus_pa":e,"poisson_ratio":nu}}) +} +fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value { + json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt", + "young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}}) +} + +#[test] +fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() { + let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap(); + assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 4.8e9, bulk_pa: 8e9 })); + let r = validate_card(&card(isotropic(12e9, -0.5))).unwrap(); + assert_eq!(r.isotropic_moduli, Some(IsotropicModuli { shear_pa: 12e9, bulk_pa: 2e9 })); + for nu in [-2., -1., 0.5, 0.6] { + assert!(validate_card(&card(isotropic(12e9, nu))).is_err()); + } + for e in [0., -1., 1e16] { + assert!(validate_card(&card(isotropic(e, 0.25))).is_err()); + } + assert!(Elasticity::Isotropic { young_modulus_pa: f64::NAN, poisson_ratio: 0.25 }.validate().is_err()); + assert!(Elasticity::Isotropic { young_modulus_pa: 1e-309, poisson_ratio: 0.25 }.validate().is_err()); +} + +#[test] +fn orthotropy_uses_reciprocity_and_full_positive_definiteness() { + // With equal E and nu=.25 this is the same normal compliance as isotropy. + assert!(validate_card(&card(orthotropic([12e9; 3], [0.25; 3]))).is_ok()); + // A general orthotropic ratio may exceed .5: reciprocal nu21 is .08 here. + assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [0.8, 0., 0.]))).is_ok()); + // Every 2x2 principal minor is positive; the full determinant is negative. + assert!(validate_card(&card(orthotropic([12e9; 3], [0.6; 3]))).is_err()); + assert!(validate_card(&card(orthotropic([10e9, 1e9, 1e9], [4., 0., 0.]))).is_err()); + assert!(validate_card(&card(orthotropic([0., 1e9, 1e9], [0.; 3]))).is_err()); + let mut bad = card(orthotropic([12e9; 3], [0.25; 3])); + bad["properties"][0]["data"]["model"]["shear_moduli_pa"] = json!([4e9, 0., 4e9]); + assert!(validate_card(&bad).is_err()); + // Scaling all moduli changes units of compliance, not its stability verdict. + assert!(validate_card(&card(orthotropic([12.; 3], [0.25; 3]))).is_ok()); + assert!(validate_card(&card(orthotropic([1e-300, 1e15, 1.], [0.; 3]))).is_ok()); + assert!(validate_card(&card(orthotropic([1e-309, 1., 1.], [0.; 3]))).is_err()); +} + +#[test] +fn conditions_missing_values_and_source_claims_remain_explicit() { + let mut value = card(json!({"kind":"density","kg_m3":600.})); + let r = validate_card(&value).unwrap(); + assert_eq!(r.unspecified_conditions, vec!["density"]); + assert_eq!(r.source_review_required, vec!["test"]); // Even an explicit license is unverified. + assert!(!r.analysis_solver_available && !r.rendering_properties_available); + assert!(r.isotropic_moduli.is_none()); + value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"moisture_dry_basis_kg_kg":1.5,"state":"synthetic wet specimen"}); + assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty()); + value["family"] = json!("polymer"); + value["properties"][0]["conditions"]["moisture_dry_basis_kg_kg"] = json!(150.); + value["properties"][0]["conditions"]["strain_rate_per_s"] = json!(1.); + assert!(validate_card(&value).unwrap().unspecified_conditions.is_empty()); + value["properties"] = json!([]); + let r = validate_card(&value).unwrap(); + assert!(r.property_kinds.is_empty() && r.isotropic_moduli.is_none()); + let parsed: MaterialCard = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::from_value::(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed); +} + +#[test] +fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() { + assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok()); + assert!(validate_card(&card(json!({"kind":"thermal_conductivity","w_m_k":[1.,0.,1.],"axes":"material123"}))).is_err()); + assert!(validate_card(&card(json!({"kind":"specific_heat","j_kg_k":500.}))).is_ok()); + for kind in ["tensile_strength", "compressive_strength", "yield_strength", "shear_strength"] { + assert!(validate_card(&card(json!({"kind":kind,"pa":1e8}))).is_ok()); + assert!(validate_card(&card(json!({"kind":kind,"pa":-1.}))).is_err()); + } +} + +#[test] +fn hostile_units_sources_fields_and_budgets_are_rejected() { + let valid = card(isotropic(12e9, 0.25)); + let mut bad = valid.clone(); + bad["properties"][0]["data"]["model"]["young_modulus_mpa"] = json!(12.); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["properties"][0]["source_id"] = json!("unknown"); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["properties"] = json!([valid["properties"][0], valid["properties"][0]]); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["sources"] = json!([valid["sources"][0], valid["sources"][0]]); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["schema_version"] = json!(2); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["designation"] = json!("x".repeat(257)); + assert!(validate_card(&bad).is_err()); + let mut bad = valid.clone(); + bad["properties"][0]["conditions"] = json!({"moisture_dry_basis_kg_kg":-1.}); + assert!(validate_card(&bad).is_err()); + assert!(validate_card(&json!(vec![Value::Null; 33])).is_err()); + assert!(validate_card(&json!("x".repeat(256 * 1024 + 1))).is_err()); + let mut deep = Value::Null; + for _ in 0..14 { + deep = json!([deep]); + } + assert!(validate_card(&deep).is_err()); +} diff --git a/crates/engine/src/cmd/material_properties.rs b/crates/engine/src/cmd/material_properties.rs new file mode 100644 index 00000000..0e69aa9b --- /dev/null +++ b/crates/engine/src/cmd/material_properties.rs @@ -0,0 +1,92 @@ +//! Read-only validation of candidate engineering data; no assignment or simulation. +use super::CommandSpec; +use crate::params::bad; +use crate::{Result, Session}; +use serde_json::{Value, json}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate) + .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")]; + +fn validate(_: &mut Session, params: &Value) -> Result { + let object = params + .as_object() + .filter(|o| o.len() == 1 && o.contains_key("card")) + .ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?; + let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?; + let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?; + Ok(json!({"valid":true,"validation":result})) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn command_reports_analytic_moduli_and_refuses_unstable_data() { + let mut s = Session::default(); + let mut card = json!({"schema_version":1,"designation":"Synthetic elastic fixture","family":"other", + "sources":[{"id":"fixture","title":"Original analytic fixture","kind":"illustrative"}], + "properties":[{"source_id":"fixture","data":{"kind":"elasticity","model":{ + "symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}}]}); + let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap(); + assert_eq!(result["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9})); + assert_eq!(result["validation"]["source_review_required"], json!(["fixture"])); + assert_eq!(result["validation"]["rendering_properties_available"], false); + card["properties"][0]["data"]["model"]["poisson_ratio"] = json!(0.6); + assert!(s.execute("material.properties.validate", &json!({"card":card})).is_err()); + } + #[test] + fn validation_preserves_document_history_selection_and_evaluation() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length":10,"width":20,"height":5})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = s.doc.clone(); + let undo = s.undo.clone(); + let redo = s.redo.clone(); + let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale(), s.camera, s.selection.clone()); + let card = json!({"schema_version":1,"designation":"Synthetic empty card","family":"other","sources":[],"properties":[]}); + let result = s.execute("material.properties.validate", &json!({"card":card})).unwrap(); + assert_eq!(result["validation"]["analysis_solver_available"], false); + for p in [json!({}), json!({"card":card,"assign":true}), json!({"card":{"schema_version":9}})] { + assert!(s.execute("material.properties.validate", &p).is_err()); + } + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale(), s.camera, s.selection.clone())); + for (a, b) in [(&undo, &s.undo), (&redo, &s.redo)] { + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert!(Arc::ptr_eq(&a.doc, &b.doc)); + } + } + } + + #[test] + fn validation_does_not_settle_live_geometry() { + let mut s = Session::default(); + for (id, p) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})), + ("sketch.dimension", json!({"entities":["l1"],"value":20})), + ("sketch.dimension", json!({"entities":["l2"],"value":15})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance":5})), + ("sketch.edit", json!({"sketch":"Sketch1"})), + ("sketch.drag", json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})), + ] { + s.execute(id, &p).unwrap(); + } + assert!(s.model.is_stale()); + let doc = s.doc.clone(); + let before = (s.revision, s.evaluations, s.active_sketch, s.undo.len(), s.redo.len()); + 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()); + 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/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f8b9ed7e..dd39bb56 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ mod config; mod file; mod inspect; pub(crate) mod joints; +mod material_properties; mod materials; mod measure_sel; mod mesh; @@ -248,6 +249,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(file::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); v.extend(materials::COMMANDS.iter()); + v.extend(material_properties::COMMANDS.iter()); v.extend(section::COMMANDS.iter()); v.extend(view_navigation::COMMANDS.iter()); v.extend(diagnostics::COMMANDS.iter()); diff --git a/docs/features.md b/docs/features.md index 242d104a..f5df4b7c 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`). **343 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`). **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). ## 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.*` (5) +## `material.*` (6) | Command | Id | Toolbar | |---|---|---| @@ -441,6 +441,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | New Physical Material | `material.create` | – | | Edit Physical Material | `material.edit` | – | | Delete Physical Material | `material.delete` | – | +| Validate Physical Material Data | `material.properties.validate` | – | ## `mesh.*` (1) diff --git a/docs/physical-material-cards.md b/docs/physical-material-cards.md new file mode 100644 index 00000000..f9294972 --- /dev/null +++ b/docs/physical-material-cards.md @@ -0,0 +1,127 @@ +# Physical material data cards + +`material.properties.validate {card}` checks a caller-supplied candidate record, +without assigning it, saving it in the design, reading files, fetching sources or +changing history, selection, camera or live geometry. This is phase 1 of #923/#922, +building towards #785. Existing `material.create/edit` and their density/appearance +behavior are unchanged. The design format is unchanged. + +Validation means numerical and structural consistency. It does not authenticate +sources, approve redistribution, certify design values, provide a solver or enable +advanced rendering. The result reports `analysis_solver_available: false` and +`rendering_properties_available: false`. Missing properties remain unknown. + +## Contract (standalone card schema 1) + +A card has `schema_version: 1`, `designation`, `family` (`wood`, `polymer`, `glass`, +`metal`, `other`), `sources` and `properties`. Source IDs are exact, case-sensitive +local references. Each source has `id`, `title`, `kind` (`user_supplied`, +`measurement`, `public_reference`, `illustrative`), optional `license` and +`reference` strings. These strings are metadata supplied by the caller; even a +declared open license remains in `source_review_required`. Nothing is downloaded. + +Every property has `data`, `source_id` and optional `conditions`. Conditions are +`temperature_k`, `moisture_dry_basis_kg_kg`, `strain_rate_per_s` and a free-text +`state` (grade, temper, processing or specimen condition). Conditions belong to +the property, not an assumed global room temperature. An elastic block represents +constants measured under the same conditions and source; mixed-condition curves +and independently sourced constants need a later representation. + +| `data.kind` | Explicit SI fields | +|---|---| +| `density` | `kg_m3` | +| `elasticity` | `model`, described below | +| `tensile_strength`, `compressive_strength`, `yield_strength`, `shear_strength` | `pa` | +| `thermal_conductivity` | `w_m_k: [k1,k2,k3]`, `axes` | +| `thermal_expansion` | `per_k: [a1,a2,a3]`, `axes` | +| `specific_heat` | `j_kg_k` | + +Directional values use `axes: material123` (material-local 1/2/3) or `wood_lrt` +(longitudinal/radial/tangential). These labels do not assign a frame to a body or +infer mechanical orientation from texture rotation. Equal directional values can +describe an isotropic thermal property. Thermal expansion may be zero or negative. + +An isotropic elastic model has `symmetry: isotropic`, `young_modulus_pa` and +`poisson_ratio`. Stability requires E > 0 and -1 < nu < 0.5. Valid auxetic values +are accepted. The result derives G = E/[2(1+nu)] and K = E/[3(1-2nu)], in Pa. + +An orthotropic model has `symmetry: orthotropic`, `axes`, +`young_moduli_pa: [E1,E2,E3]`, `shear_moduli_pa: [G12,G23,G31]` and +`poisson_ratios: [nu12,nu23,nu31]`. Here nu_ij = -eps_j/eps_i under stress along i; +reciprocity supplies nu_ij/Ei = nu_ji/Ej. The normal compliance is symmetric with +diagonal 1/Ei and off-diagonal -nu_ij/Ei. Diagonal scaling produces unit diagonal; +Sylvester's criterion checks its positive definiteness. All three shear moduli +must be positive. General orthotropic Poisson ratios are not capped at 0.5. +Moduli whose reciprocals cannot be represented finitely are rejected; the +isotropic derived response and its reciprocals must also remain finite/positive. + +Moisture is kg water / kg dry material and can exceed one. It is not a wet-mass +fraction. Unspecified temperature/state, wood moisture or polymer strain rate are +reported in `unspecified_conditions`; they are not guessed or replaced by zero. +The family label does not certify that a chosen constitutive model fits a material. +Scalar strengths are individual measurements, not a three-dimensional failure +envelope. Typed strength directions and test methods remain a separate extension. + +## Admission limits + +Before cloning/deserializing JSON: at most 2,048 nodes, depth 12, 32 array entries +or object fields and a 256 KiB conservative accounting budget (32 bytes per node +plus string/key bytes). The typed record allows 32 sources and 16 properties; +duplicate source IDs or property kinds and unknown fields are rejected. + +Text limits in UTF-8 bytes: designation/title 256, source ID 128, license 512, +reference 2,048 and state 1,024; controls and blank text are rejected. All numeric +values must be finite. Positive-value upper admission limits are density 1e6 +kg/m³, moduli/strengths 1e15 Pa, conductivity 1e9 W/(m K), heat capacity 1e9 +J/(kg K), temperature 20,000 K and strain rate 1e12 /s. Expansion is within ±1 /K, +dry-basis moisture within [0,1e6] (also accommodating highly hydrated polymers), +orthotropic Poisson ratios within ±1e6 in addition +to the compliance stability test. These are generous technical admission limits, +not measured applicability ranges or a conditioning guarantee for a future solver. + +## Original synthetic example + +These deliberately invented constants demonstrate validation, not a real grade: + +```json +{ + "card": { + "schema_version": 1, + "designation": "Synthetic elastic example — not design data", + "family": "other", + "sources": [{ + "id": "example", + "title": "Original synthetic example", + "kind": "illustrative", + "license": "MIT OR Apache-2.0" + }], + "properties": [{ + "data": { + "kind": "elasticity", + "model": {"symmetry": "isotropic", "young_modulus_pa": 12000000000, "poisson_ratio": 0.25} + }, + "source_id": "example" + }] + } +} +``` + +The result has G = 4.8e9 Pa, K = 8e9 Pa, unspecified conditions and source review +still required. No assigned mass, saved material or appearance changes. + +## Next phases and references + +Persistence/library integration needs a separately reviewed document migration, +atomic edits, undo/redo and offline reopening, coordinated with #863. Curves, +uncertainty, hardness scales, viscoelasticity and failure criteria remain later work. +Visual PBR/texture/environment consumption uses the renderer contract requested +in #813, with #909/#837 input utilities; accepting physical data advertises none +of those capabilities. + +The [USDA Wood Handbook](https://research.fs.usda.gov/fpl/wood-handbook), including +[chapter 4's dry-mass moisture definition](https://research.fs.usda.gov/download/treesearch/62243.pdf), is a public +scientific reference for moisture, mechanical behavior and the distinction between +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.