Validate sourced physical material property cards and elastic stability (#934)
Adds the read-only material.properties.validate command: schema_version 1 engineering material cards in SI units are checked for numerical consistency and elastic stability, with derived isotropic moduli, missing conditions and sources needing review. Nothing is assigned or saved. Thanks @0xlillith.
This commit is contained in:
1 parent
9ca7d307a4
commit
01f2c78cd1
11 files changed
+707
-5
No files matched your search
@@ -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 `<file>.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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -956,5 +956,6 @@
|
||||
"Letter Spacing": "文字間隔",
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります"
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
|
||||
"cmd.material_properties_validate":"物理材料データを検証"
|
||||
}
|
||||
@@ -956,5 +956,6 @@
|
||||
"Path": "路径",
|
||||
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
|
||||
"ui.analysis": "分析",
|
||||
"ui.edit_section_analysis": "编辑截面分析"
|
||||
"ui.edit_section_analysis": "编辑截面分析",
|
||||
"cmd.material_properties_validate":"验证物理材料数据"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Source>,
|
||||
pub properties: Vec<Property>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub reference: Option<String>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
/// kg water / kg dry material; unlike wet-basis fraction, may exceed one.
|
||||
pub moisture_dry_basis_kg_kg: Option<f64>,
|
||||
pub strain_rate_per_s: Option<f64>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub isotropic_moduli: Option<IsotropicModuli>,
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
pub source_review_required: Vec<String>,
|
||||
pub analysis_solver_available: bool,
|
||||
pub rendering_properties_available: bool,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> 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<Option<IsotropicModuli>> {
|
||||
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<Option<IsotropicModuli>> {
|
||||
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<Validation> {
|
||||
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<Validation> {
|
||||
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;
|
||||
@@ -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::<MaterialCard>(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());
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+3
-2
@@ -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 <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`). **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).
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user