diff --git a/assets/locales/de.json b/assets/locales/de.json index ca1e3278..fd416b1f 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -85,6 +85,28 @@ "ui.clear_recent": "Zuletzt verwendet leeren", "ui.clear_the_selection": "Auswahl aufheben", "ui.close": "Schließen", + "ui.material_review.title": "Physikalische Materialdaten prüfen", + "ui.material_review.scope": "Nur Kandidat: nicht zugewiesen oder gespeichert. Numerische Prüfung ist keine technische Zertifizierung.", + "ui.material_review.input": "Materialkarte mit Schema 1 einfügen (JSON)", + "ui.material_review.review": "Karte prüfen", + "ui.material_review.limit": "Die Karte überschreitet die Eingabegrenze von 256 KiB.", + "ui.material_review.unknown": "Unbekannt", + "ui.material_review.properties": "Eigenschaften", + "ui.material_review.conditions": "Angegebene Bedingungen", + "ui.material_review.source": "Quelle", + "ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.", + "ui.material_review.derived": "Abgeleitete isotrope Moduln", + "ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.", + "ui.material_review.density": "Dichte", + "ui.material_review.elasticity": "Elastizität", + "ui.material_review.tensile_strength": "Zugfestigkeit", + "ui.material_review.compressive_strength": "Druckfestigkeit", + "ui.material_review.yield_strength": "Streckgrenze", + "ui.material_review.shear_strength": "Scherfestigkeit", + "ui.material_review.thermal_conductivity": "Wärmeleitfähigkeit", + "ui.material_review.thermal_expansion": "Wärmeausdehnung", + "ui.material_review.specific_heat": "Spezifische Wärmekapazität", + "ui.material_review.moisture_unit": "kg Wasser / kg Trockenmaterial", "ui.coil": "Schraube", "ui.coincident": "Koinzident", "ui.collapse": "Einklappen", diff --git a/assets/locales/en.json b/assets/locales/en.json index 8fa712e0..84f2bcdc 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -370,6 +370,28 @@ "ui.click_selects_whole_bodies": "Click selects whole bodies", "ui.click_then_press_key": "Click, then press the new key", "ui.close": "Close", + "ui.material_review.title": "Review Physical Material Data", + "ui.material_review.scope": "Candidate only: not assigned or saved. Numerical validation is not engineering certification.", + "ui.material_review.input": "Paste a schema-1 material card (JSON)", + "ui.material_review.review": "Review card", + "ui.material_review.limit": "The card exceeds the 256 KiB input limit.", + "ui.material_review.unknown": "Unknown", + "ui.material_review.properties": "Properties", + "ui.material_review.conditions": "Stated conditions", + "ui.material_review.source": "Source", + "ui.material_review.axes": "Material-local axes; no body orientation is assigned.", + "ui.material_review.derived": "Derived isotropic moduli", + "ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.", + "ui.material_review.density": "Density", + "ui.material_review.elasticity": "Elasticity", + "ui.material_review.tensile_strength": "Tensile strength", + "ui.material_review.compressive_strength": "Compressive strength", + "ui.material_review.yield_strength": "Yield strength", + "ui.material_review.shear_strength": "Shear strength", + "ui.material_review.thermal_conductivity": "Thermal conductivity", + "ui.material_review.thermal_expansion": "Thermal expansion", + "ui.material_review.specific_heat": "Specific heat", + "ui.material_review.moisture_unit": "kg water / kg dry material", "ui.coil": "Coil", "ui.coincident": "Coincident", "ui.collapse": "Collapse", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index e5eaf321..bc82cf21 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -370,6 +370,28 @@ "ui.click_selects_whole_bodies": "Un clic sélectionne des corps entiers", "ui.click_then_press_key": "Cliquer, puis appuyer sur la nouvelle touche", "ui.close": "Fermer", + "ui.material_review.title": "Examiner les données physiques du matériau", + "ui.material_review.scope": "Candidat uniquement : ni attribué ni enregistré. La validation numérique ne constitue pas une certification technique.", + "ui.material_review.input": "Coller une fiche matériau de schéma 1 (JSON)", + "ui.material_review.review": "Examiner la fiche", + "ui.material_review.limit": "La fiche dépasse la limite de saisie de 256 Kio.", + "ui.material_review.unknown": "Inconnu", + "ui.material_review.properties": "Propriétés", + "ui.material_review.conditions": "Conditions déclarées", + "ui.material_review.source": "Source", + "ui.material_review.axes": "Axes locaux au matériau ; aucune orientation du corps attribuée.", + "ui.material_review.derived": "Modules isotropes dérivés", + "ui.material_review.claims": "Les sources et licences sont des déclarations non vérifiées. Les valeurs absentes restent inconnues.", + "ui.material_review.density": "Masse volumique", + "ui.material_review.elasticity": "Élasticité", + "ui.material_review.tensile_strength": "Résistance à la traction", + "ui.material_review.compressive_strength": "Résistance à la compression", + "ui.material_review.yield_strength": "Limite élastique", + "ui.material_review.shear_strength": "Résistance au cisaillement", + "ui.material_review.thermal_conductivity": "Conductivité thermique", + "ui.material_review.thermal_expansion": "Dilatation thermique", + "ui.material_review.specific_heat": "Capacité thermique massique", + "ui.material_review.moisture_unit": "kg eau / kg matériau sec", "ui.coil": "Hélice", "ui.coincident": "Coïncidence", "ui.collapse": "Réduire", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index aaabd4eb..ec75bb8d 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -370,6 +370,28 @@ "ui.click_selects_whole_bodies": "クリックでボディ全体を選択", "ui.click_then_press_key": "クリックした後、割り当てるキーを押してください", "ui.close": "閉じる", + "ui.material_review.title": "物理材料データを確認", + "ui.material_review.scope": "候補のみ:割り当てや保存は行いません。数値検証は工学的認証ではありません。", + "ui.material_review.input": "スキーマ1の材料カードを貼り付け(JSON)", + "ui.material_review.review": "カードを確認", + "ui.material_review.limit": "カードが256 KiBの入力上限を超えています。", + "ui.material_review.unknown": "不明", + "ui.material_review.properties": "特性", + "ui.material_review.conditions": "記載された条件", + "ui.material_review.source": "出典", + "ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。", + "ui.material_review.derived": "導出された等方性弾性率", + "ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。", + "ui.material_review.density": "密度", + "ui.material_review.elasticity": "弾性", + "ui.material_review.tensile_strength": "引張強度", + "ui.material_review.compressive_strength": "圧縮強度", + "ui.material_review.yield_strength": "降伏強度", + "ui.material_review.shear_strength": "せん断強度", + "ui.material_review.thermal_conductivity": "熱伝導率", + "ui.material_review.thermal_expansion": "熱膨張", + "ui.material_review.specific_heat": "比熱", + "ui.material_review.moisture_unit": "kg 水 / kg 乾燥材料", "ui.coil": "コイル", "ui.coincident": "一致", "ui.collapse": "折りたたむ", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index ccc557e2..3472c7c7 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -85,6 +85,28 @@ "ui.clear_recent": "최근 항목 지우기", "ui.clear_the_selection": "선택 해제", "ui.close": "닫기", + "ui.material_review.title": "물리적 재료 데이터 검토", + "ui.material_review.scope": "후보만 검토합니다. 지정하거나 저장하지 않습니다. 수치 검증은 공학적 인증이 아닙니다.", + "ui.material_review.input": "스키마 1 재료 카드 붙여넣기 (JSON)", + "ui.material_review.review": "카드 검토", + "ui.material_review.limit": "카드가 256 KiB 입력 한도를 초과합니다.", + "ui.material_review.unknown": "알 수 없음", + "ui.material_review.properties": "물성", + "ui.material_review.conditions": "명시된 조건", + "ui.material_review.source": "출처", + "ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.", + "ui.material_review.derived": "유도된 등방성 탄성 계수", + "ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.", + "ui.material_review.density": "밀도", + "ui.material_review.elasticity": "탄성", + "ui.material_review.tensile_strength": "인장 강도", + "ui.material_review.compressive_strength": "압축 강도", + "ui.material_review.yield_strength": "항복 강도", + "ui.material_review.shear_strength": "전단 강도", + "ui.material_review.thermal_conductivity": "열전도율", + "ui.material_review.thermal_expansion": "열팽창", + "ui.material_review.specific_heat": "비열", + "ui.material_review.moisture_unit": "kg 물 / kg 건조 재료", "ui.coil": "코일", "ui.coincident": "일치", "ui.collapse": "접기", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 970cec92..9d2e46b8 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -345,6 +345,28 @@ "ui.click_selects_whole_bodies": "Щелчок выделяет тела целиком", "ui.click_then_press_key": "Щёлкните, затем нажмите новую клавишу", "ui.close": "Закрыть", + "ui.material_review.title": "Проверка физических данных материала", + "ui.material_review.scope": "Только кандидат: без назначения и сохранения. Численная проверка не является инженерной сертификацией.", + "ui.material_review.input": "Вставьте карточку материала по схеме 1 (JSON)", + "ui.material_review.review": "Проверить карточку", + "ui.material_review.limit": "Карточка превышает предел ввода 256 КиБ.", + "ui.material_review.unknown": "Неизвестно", + "ui.material_review.properties": "Свойства", + "ui.material_review.conditions": "Указанные условия", + "ui.material_review.source": "Источник", + "ui.material_review.axes": "Локальные оси материала; ориентация тела не назначается.", + "ui.material_review.derived": "Вычисленные изотропные модули", + "ui.material_review.claims": "Источники и лицензии указаны без проверки. Отсутствующие значения остаются неизвестными.", + "ui.material_review.density": "Плотность", + "ui.material_review.elasticity": "Упругость", + "ui.material_review.tensile_strength": "Прочность при растяжении", + "ui.material_review.compressive_strength": "Прочность при сжатии", + "ui.material_review.yield_strength": "Предел текучести", + "ui.material_review.shear_strength": "Прочность при сдвиге", + "ui.material_review.thermal_conductivity": "Теплопроводность", + "ui.material_review.thermal_expansion": "Тепловое расширение", + "ui.material_review.specific_heat": "Удельная теплоёмкость", + "ui.material_review.moisture_unit": "кг воды / кг сухого материала", "ui.coil": "Спираль", "ui.coincident": "Совпадение", "ui.collapse": "Свернуть", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index c8037568..1842a89c 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -349,6 +349,28 @@ "ui.click_selects_whole_bodies": "Клацання виділяє тіла цілком", "ui.click_then_press_key": "Клацніть, а потім натисніть нову клавішу", "ui.close": "Закрити", + "ui.material_review.title": "Перевірка фізичних даних матеріалу", + "ui.material_review.scope": "Лише кандидат: без призначення та збереження. Числова перевірка не є інженерною сертифікацією.", + "ui.material_review.input": "Вставте картку матеріалу за схемою 1 (JSON)", + "ui.material_review.review": "Перевірити картку", + "ui.material_review.limit": "Картка перевищує межу введення 256 КіБ.", + "ui.material_review.unknown": "Невідомо", + "ui.material_review.properties": "Властивості", + "ui.material_review.conditions": "Зазначені умови", + "ui.material_review.source": "Джерело", + "ui.material_review.axes": "Локальні осі матеріалу; орієнтація тіла не призначається.", + "ui.material_review.derived": "Обчислені ізотропні модулі", + "ui.material_review.claims": "Джерела та ліцензії зазначені без перевірки. Відсутні значення залишаються невідомими.", + "ui.material_review.density": "Густина", + "ui.material_review.elasticity": "Пружність", + "ui.material_review.tensile_strength": "Міцність на розтяг", + "ui.material_review.compressive_strength": "Міцність на стиск", + "ui.material_review.yield_strength": "Межа плинності", + "ui.material_review.shear_strength": "Міцність на зсув", + "ui.material_review.thermal_conductivity": "Теплопровідність", + "ui.material_review.thermal_expansion": "Теплове розширення", + "ui.material_review.specific_heat": "Питома теплоємність", + "ui.material_review.moisture_unit": "кг води / кг сухого матеріалу", "ui.coil": "Спіраль", "ui.coincident": "Співпадання", "ui.collapse": "Згорнути", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 70fe1d76..d5d626c0 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -369,6 +369,28 @@ "ui.click_selects_whole_bodies": "单击选择整个实体", "ui.click_then_press_key": "单击,然后按新按键", "ui.close": "关闭", + "ui.material_review.title": "检查物理材料数据", + "ui.material_review.scope": "仅检查候选数据:不指定或保存。数值验证不等于工程认证。", + "ui.material_review.input": "粘贴架构版本1的材料卡(JSON)", + "ui.material_review.review": "检查材料卡", + "ui.material_review.limit": "材料卡超过256 KiB输入限制。", + "ui.material_review.unknown": "未知", + "ui.material_review.properties": "属性", + "ui.material_review.conditions": "声明的条件", + "ui.material_review.source": "来源", + "ui.material_review.axes": "材料局部坐标轴;不指定实体方向。", + "ui.material_review.derived": "推导的各向同性模量", + "ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。", + "ui.material_review.density": "密度", + "ui.material_review.elasticity": "弹性", + "ui.material_review.tensile_strength": "抗拉强度", + "ui.material_review.compressive_strength": "抗压强度", + "ui.material_review.yield_strength": "屈服强度", + "ui.material_review.shear_strength": "抗剪强度", + "ui.material_review.thermal_conductivity": "导热系数", + "ui.material_review.thermal_expansion": "热膨胀", + "ui.material_review.specific_heat": "比热容", + "ui.material_review.moisture_unit": "kg 水 / kg 干燥材料", "ui.coil": "螺旋", "ui.coincident": "重合", "ui.collapse": "折叠", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 431da63f..c8e3aaa4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,7 @@ pub mod inference; pub mod install; pub mod issue_report; pub mod keymap; +mod material_card_review; #[cfg(test)] mod menu_tests; #[cfg(test)] @@ -273,6 +274,7 @@ pub struct SolveApp { pub params: Option, /// The Design Properties window, when open. pub design_properties: Option, + material_card_review: Option, pub palette: palette::Palette, /// Context menus, rename box and Properties window. pub menu: context_menu::MenuState, @@ -391,6 +393,7 @@ impl SolveApp { dialog: None, params: None, design_properties: None, + material_card_review: None, palette: palette::Palette::default(), menu: context_menu::MenuState::default(), tree: browser::TreeState::default(), @@ -667,6 +670,11 @@ impl SolveApp { self.set_status(format!("{label}: {why}"), true); return; } + if id == "material.properties.validate" { + self.material_card_review = Some(material_card_review::ReviewWindow::default()); + self.home.open = false; + return; + } // Another command ends Edit Text (its dialog is not a feature edit to keep). sketch_text_move::cancel_drag(self); if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { @@ -1315,6 +1323,7 @@ impl SolveApp { self.resolve_fit(ui.ctx()); params_dialog::show(self, ui.ctx()); design_properties::show(self, ui.ctx()); + material_card_review::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); stand_up::show(self, ui.ctx()); prefs::show_reset_notice(self, ui.ctx()); diff --git a/crates/ui-egui/src/material_card_review.rs b/crates/ui-egui/src/material_card_review.rs new file mode 100644 index 00000000..c23f69f1 --- /dev/null +++ b/crates/ui-egui/src/material_card_review.rs @@ -0,0 +1,322 @@ +//! Candidate review through the existing validator, independent of assigned-card persistence. +use egui::RichText; +use serde_json::{Value, json}; +use solidcraft_engine::doc::physical_material::{Family, MaterialCard, PropertyData, admit_card}; + +use crate::{SolveApp, i18n::tr, theme::Tokens}; + +const MAX_BYTES: usize = 256 * 1024; +const KINDS: [&str; 9] = [ + "density", + "elasticity", + "tensile_strength", + "compressive_strength", + "yield_strength", + "shear_strength", + "thermal_conductivity", + "thermal_expansion", + "specific_heat", +]; + +#[derive(Default)] +pub struct ReviewWindow { + input: String, + reviewed: Option<(MaterialCard, Value)>, + error: Option, +} + +impl ReviewWindow { + fn review(&mut self, app: &mut SolveApp) { + self.reviewed = None; + let result = (|| { + if self.input.len() > MAX_BYTES { + return Err(tr("ui.material_review.limit").to_string()); + } + let value: Value = serde_json::from_str(&self.input).map_err(|e| e.to_string())?; + let report = app.run("material.properties.validate", json!({"card":value}))?; + let card = admit_card(&value).map_err(|e| e.to_string())?; + Ok((card, report)) + })(); + match result { + Ok(reviewed) => { + self.reviewed = Some(reviewed); + self.error = None; + } + Err(error) => self.error = Some(error), + } + } +} + +/// Open only through ui.start; executing the validator never opens a window. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut window) = app.material_card_review.take() else { return }; + let mut open = true; + let mut close = false; + crate::frame::window(ctx, tr("ui.material_review.title"), crate::frame::Width::Wide) + .id(egui::Id::new("sc_material_card_review")) + .open(&mut open) + .show(ctx, |ui| { + ui.label(tr("ui.material_review.scope")); + egui::ScrollArea::vertical().max_height((ctx.content_rect().height() - 180.0).max(100.0)).show(ui, |ui| { + ui.label(tr("ui.material_review.input")); + let response = ui.add( + egui::TextEdit::multiline(&mut window.input) + .id(egui::Id::new("sc_material_card_input")) + .desired_width(f32::INFINITY) + .desired_rows(5) + .char_limit(MAX_BYTES), + ); + if response.changed() { + window.reviewed = None; + window.error = None; + } + if ui.button(tr("ui.material_review.review")).clicked() { + window.review(app); + } + if let Some(error) = &window.error { + ui.colored_label(Tokens::get().error, error); + } + if let Some((card, report)) = &window.reviewed { + show_card(ui, card, report); + } + }); + ui.separator(); + close = ui.button(tr("ui.close")).clicked(); + }); + if open && !close { + app.material_card_review = Some(window); + } +} + +fn value(ui: &mut egui::Ui, field: &str, value: Option, unit: &str) { + ui.horizontal_wrapped(|ui| { + ui.monospace(field); + ui.label(value.unwrap_or_else(|| tr("ui.material_review.unknown").to_string())); + ui.label(unit); + }); +} + +fn numbers(ui: &mut egui::Ui, field: &str, values: &[f64], unit: &str) { + value(ui, field, Some(values.iter().map(|v| format!("{v:e}")).collect::>().join(", ")), unit); +} + +fn show_card(ui: &mut egui::Ui, card: &MaterialCard, report: &Value) { + ui.separator(); + ui.heading(&card.designation); + // Schema identifiers are inert metadata, not translated source claims or assigned body axes. + let family = serde_json::to_value(card.family).unwrap_or(Value::Null); + value(ui, "family", family.as_str().map(str::to_string), ""); + ui.strong(tr("ui.material_review.properties")); + for kind in KINDS { + let label = format!("ui.material_review.{kind}"); + let Some(property) = card.properties.iter().find(|p| p.data.kind() == kind) else { + ui.label(format!("{}: {}", tr(&label), tr("ui.material_review.unknown"))); + continue; + }; + egui::CollapsingHeader::new(tr(&label)).id_salt(kind).default_open(true).show(ui, |ui| { + show_data(ui, &property.data); + if let Some(axes) = serde_json::to_value(&property.data) + .ok() + .and_then(|d| d.get("axes").or_else(|| d.get("model").and_then(|m| m.get("axes"))).and_then(Value::as_str).map(str::to_string)) + { + let axes = if axes == "wood_lrt" { "wood_lrt (1=L, 2=R, 3=T)".to_string() } else { axes }; + value(ui, "axes", Some(axes), ""); + ui.label(tr("ui.material_review.axes")); + } + ui.strong(tr("ui.material_review.conditions")); + let c = &property.conditions; + value(ui, "temperature_k", c.temperature_k.map(|v| v.to_string()), "K"); + value(ui, "state", c.state.clone(), ""); + if card.family == Family::Wood || c.moisture_dry_basis_kg_kg.is_some() { + value(ui, "moisture_dry_basis_kg_kg", c.moisture_dry_basis_kg_kg.map(|v| v.to_string()), tr("ui.material_review.moisture_unit")); + } + if card.family == Family::Polymer || c.strain_rate_per_s.is_some() { + value(ui, "strain_rate_per_s", c.strain_rate_per_s.map(|v| v.to_string()), "s⁻¹"); + } + ui.strong(tr("ui.material_review.source")); + if let Some(source) = card.sources.iter().find(|s| s.id == property.source_id) { + ui.label(&source.title); + value(ui, "source_id", Some(source.id.clone()), ""); + value(ui, "kind", serde_json::to_value(source.kind).ok().and_then(|v| v.as_str().map(str::to_string)), ""); + value(ui, "license", source.license.clone(), ""); + value(ui, "reference", source.reference.clone(), ""); + } + }); + } + if let Some(moduli) = report.get("validation").and_then(|v| v.get("isotropic_moduli")).filter(|v| v.is_object()) { + ui.strong(tr("ui.material_review.derived")); + for field in ["shear_pa", "bulk_pa"] { + value(ui, field, moduli.get(field).and_then(Value::as_f64).map(|v| format!("{v:e}")), "Pa"); + } + } + ui.label(RichText::new(tr("ui.material_review.claims")).small().color(Tokens::get().text_dim)); +} + +fn show_data(ui: &mut egui::Ui, data: &PropertyData) { + match data { + PropertyData::Density { kg_m3 } => numbers(ui, "density", &[*kg_m3], "kg/m³"), + PropertyData::Elasticity { model } => match model { + solidcraft_engine::doc::physical_material::Elasticity::Isotropic { young_modulus_pa, poisson_ratio } => { + numbers(ui, "E", &[*young_modulus_pa], "Pa"); + numbers(ui, "ν", &[*poisson_ratio], ""); + } + solidcraft_engine::doc::physical_material::Elasticity::Orthotropic { young_moduli_pa, shear_moduli_pa, poisson_ratios, .. } => { + numbers(ui, "E1, E2, E3", young_moduli_pa, "Pa"); + numbers(ui, "G12, G23, G31", shear_moduli_pa, "Pa"); + numbers(ui, "ν12, ν23, ν31", poisson_ratios, ""); + } + }, + PropertyData::TensileStrength { pa } | PropertyData::CompressiveStrength { pa } | PropertyData::YieldStrength { pa } => { + numbers(ui, "σ", &[*pa], "Pa"); + } + PropertyData::ShearStrength { pa } => numbers(ui, "τ", &[*pa], "Pa"), + PropertyData::ThermalConductivity { w_m_k, .. } => numbers(ui, "k1, k2, k3", w_m_k, "W/(m·K)"), + PropertyData::ThermalExpansion { per_k, .. } => numbers(ui, "α1, α2, α3", per_k, "K⁻¹"), + PropertyData::SpecificHeat { j_kg_k } => numbers(ui, "c", &[*j_kg_k], "J/(kg·K)"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::Harness; + use std::sync::Arc; + + fn candidate() -> Value { + json!({"schema_version":1,"designation":"Original synthetic candidate","family":"wood", + "sources":[{"id":"example","title":"Original illustrative source","kind":"illustrative", + "license":"Unverified licence claim","reference":"https://invalid.example/not-fetched"}], + "properties":[{"source_id":"example","data":{"kind":"elasticity","model":{ + "symmetry":"isotropic","young_modulus_pa":12e9,"poisson_ratio":0.25}}, + "conditions":{"temperature_k":293.15,"moisture_dry_basis_kg_kg":0.,"state":"synthetic specimen"}}]}) + } + + #[test] + fn clicked_candidate_review_and_close_preserve_design_history_and_unknowns() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + let doc = h.app.session.doc.clone(); + let before = ( + h.app.session.revision, + h.app.session.model.is_stale(), + h.app.session.undo.len(), + h.app.session.redo.len(), + h.app.session.selection.clone(), + ); + h.step(&json!({"start":"material.properties.validate"})).unwrap(); + h.app.material_card_review.as_mut().unwrap().input = candidate().to_string(); + h.frames(2); + h.step(&json!({"widget":"Review card"})).unwrap(); + let (card, report) = h.app.material_card_review.as_ref().unwrap().reviewed.as_ref().unwrap(); + assert_eq!(card.designation, "Original synthetic candidate"); + assert_eq!(card.properties.len(), 1, "missing values were not filled with zeros"); + assert_eq!(card.sources[0].title, "Original illustrative source"); + assert_eq!(card.properties[0].conditions.moisture_dry_basis_kg_kg, Some(0.)); + assert_eq!(report["validation"]["isotropic_moduli"], json!({"shear_pa":4.8e9,"bulk_pa":8e9})); + assert_eq!(report["validation"]["source_review_required"], json!(["example"])); + assert!(h.widget("Original synthetic candidate").is_some()); + assert!(h.widget("Density: Unknown").is_some()); + assert!(h.widget("Pa").is_some()); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("sc_material_card_input"))); + h.step(&json!({"text":" "})).unwrap(); + assert!(h.app.material_card_review.as_ref().unwrap().reviewed.is_none(), "editing invalidates the old report"); + h.step(&json!({"widget":"Close"})).unwrap(); + assert!(h.app.material_card_review.is_none()); + assert!(Arc::ptr_eq(&doc, &h.app.session.doc)); + assert_eq!( + before, + ( + h.app.session.revision, + h.app.session.model.is_stale(), + h.app.session.undo.len(), + h.app.session.redo.len(), + h.app.session.selection.clone() + ) + ); + } + + #[test] + fn invalid_and_oversized_candidates_never_keep_a_previous_review() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let doc = app.session.doc.clone(); + let mut window = ReviewWindow { input: candidate().to_string(), ..Default::default() }; + window.review(&mut app); + assert!(window.reviewed.is_some()); + for input in ["[".into(), "{\"schema_version\":2}".into(), "é".repeat(MAX_BYTES / 2 + 1)] { + window.input = input; + window.review(&mut app); + assert!(window.reviewed.is_none()); + assert!(window.error.is_some()); + } + assert!(window.error.as_deref().unwrap().contains("256 KiB")); + assert!(Arc::ptr_eq(&doc, &app.session.doc)); + assert!(app.session.undo.is_empty() && app.session.redo.is_empty()); + assert!(!app.session.is_dirty()); + } + + #[test] + fn every_property_variant_renders_its_declared_si_unit_and_inert_source() { + let fixtures = [ + (json!({"kind":"density","kg_m3":600.}), "kg/m³"), + ( + json!({"kind":"elasticity","model":{"symmetry":"orthotropic","axes":"wood_lrt", + "young_moduli_pa":[12e9,3e9,1e9],"shear_moduli_pa":[2e9,1e9,0.5e9],"poisson_ratios":[0.,0.,0.]}}), + "Pa", + ), + (json!({"kind":"tensile_strength","pa":1e8}), "Pa"), + (json!({"kind":"compressive_strength","pa":1e8}), "Pa"), + (json!({"kind":"yield_strength","pa":1e8}), "Pa"), + (json!({"kind":"shear_strength","pa":1e8}), "Pa"), + (json!({"kind":"thermal_conductivity","w_m_k":[1.,2.,3.],"axes":"material123"}), "W/(m·K)"), + (json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}), "K⁻¹"), + (json!({"kind":"specific_heat","j_kg_k":500.}), "J/(kg·K)"), + ]; + for (data, unit) in fixtures { + let mut h = Harness::new(); + h.app.start("material.properties.validate"); + let mut card = candidate(); + card["properties"][0]["data"] = data; + h.app.material_card_review.as_mut().unwrap().input = card.to_string(); + h.frames(2); + h.step(&json!({"widget":"Review card"})).unwrap(); + assert!(h.app.material_card_review.as_ref().unwrap().error.is_none(), "{card}"); + assert!(h.widget(unit).is_some(), "expected {unit} for {card}"); + assert!(h.widget("Original illustrative source").is_some(), "source is displayed as literal text"); + } + } + + #[test] + fn executing_json_validation_does_not_open_the_inspector() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.run("material.properties.validate", json!({"card":candidate()})).unwrap(); + assert!(app.material_card_review.is_none()); + } + + #[test] + fn reviewer_messages_exist_in_every_locale() { + let keys = [ + "title", + "scope", + "input", + "review", + "limit", + "unknown", + "properties", + "conditions", + "source", + "axes", + "derived", + "claims", + "moisture_unit", + ]; + for catalog in crate::i18n::LOCALES { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/locales").join(format!("{}.json", catalog.id)); + let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + for key in keys.into_iter().chain(KINDS) { + let key = format!("ui.material_review.{key}"); + assert!(messages.get(&key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()), "{}: {key}", catalog.id); + } + } + } +} diff --git a/docs/physical-material-cards.md b/docs/physical-material-cards.md index b7682866..bb4818a2 100644 --- a/docs/physical-material-cards.md +++ b/docs/physical-material-cards.md @@ -13,6 +13,26 @@ advanced rendering. The result reports `analysis_solver_available: false` and ## Contract (standalone card schema 1) +### Candidate review window + +Search for **Validate Physical Material Data** in the command palette (or use +`ui.start` with `material.properties.validate`) to open the candidate reviewer. +Paste a schema-1 card object and choose **Review card**. The existing engine +validator checks it; the window shows known properties in SI units, unknown +properties, per-property conditions and source claims as inert text. Material-local +axes do not assign a frame to a body. Isotropic G/K come from the engine report. + +Editing the input clears the previous review. Invalid input shows its refusal +without retaining a stale valid result. Input is capped at 262,144 characters and +256 KiB before JSON parsing; the validator's tighter structural and text budgets +still apply. The window does not read files, fetch references, assign properties, +replace density, modify history or save the candidate. Close discards it. Calling +the engine's JSON command still returns its report without opening a dialog. + +This main-based review unit is independent of assigned-card persistence, local +file import and the design-owned inspector follow-up in #1094. No dataset or +rendering capability is supplied by reviewing a candidate. + 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`,