ui: review candidate physical material cards without assignment (#1344)

Adds a read-only review window to Validate Physical Material Data: paste a schema-1 card, see its known properties in SI units, unknown ones marked, with stated conditions and source claims. Nothing is assigned or saved.

Co-authored-by: 0xlillith <[email protected]>
This commit is contained in:
seluneand0xlillith authored and GitHub committed 2026-10-15 13:55:30 -04:00
1 parent be985f136d
commit a47e82ba94
11 files changed
+527

No files matched your search

+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "折りたたむ",
+22
View File
@@ -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": "접기",
+22
View File
@@ -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": "Свернуть",
+22
View File
@@ -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": "Згорнути",
+22
View File
@@ -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": "折叠",
+9
View File
@@ -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<params_dialog::ParamsDialog>,
/// The Design Properties window, when open.
pub design_properties: Option<design_properties::DesignProperties>,
material_card_review: Option<material_card_review::ReviewWindow>,
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());
+322
View File
@@ -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<String>,
}
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<String>, 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::<Vec<_>>().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 <b>source</b>","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 <b>source</b>");
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 <b>source</b>").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);
}
}
}
}
+20
View File
@@ -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`,