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 <0xlillith@users.noreply.github.com>
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

+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);
}
}
}
}