Engine: diagnostics.validation reads and sets the mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

With input and output schemas. The mode is the session's user setting:
kept when another design is opened, started, recovered or imported (each
evaluated under it), and by timeline.compute_all. Linked and extracted
designs are evaluated under it too.
This commit is contained in:
jyviko
2026-10-30 09:36:06 -04:00
committed by jyviko
parent 9acbcf924b
commit 5d80fc5711
8 changed files with 214 additions and 18 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ fn part_design(s: &Session, sc: &Scope, stem: &str, cmd: &str) -> Result<(Sessio
if !source.configs.is_empty() {
notices.push(format!("the configurations stay in this design; {stem} has none"));
}
let part = Session::new(part);
let part = Session::opened_validating(part, None, s.validation());
let failing: Vec<String> = part.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!("{} ({e})", r.name))).take(5).collect();
if !failing.is_empty() {
return Err(bad(cmd, format!("{name} does not build on its own: {}", failing.join("; "))));
+6 -6
View File
@@ -88,8 +88,8 @@ pub fn resolve_design_links(doc: &mut Document, from: Option<&str>) {
/// the design gives their faces (`Feature::link_faces`), with the source's design properties.
type Linked = (String, solidcraft_io::StepFeature, Vec<Vec<String>>, solidcraft_doc::Metadata);
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
/// The solid bodies of the design at `file`, evaluated under `validation`, as an Import feature.
fn linked_bodies(file: &Path, own: Option<&str>, validation: solidcraft_kernel::Validation) -> Result<Linked> {
let shown = file.display().to_string();
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
@@ -101,7 +101,7 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
})?;
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
session_bodies(&Session::opened_at(doc, None, Some(&shown)), &shown)
session_bodies(&Session::opened_at_validating(doc, None, Some(&shown), validation), &shown)
}
/// The solid bodies of `part`, the design saved as `shown`, as an Import feature.
@@ -156,7 +156,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
return super::component::copy_occurrence(s, &json!({"component": component, "parent": parent, "translate": translate}));
}
}
let (design_name, sf, faces, metadata) = linked_bodies(&file, s.path.as_deref())?;
let (design_name, sf, faces, metadata) = linked_bodies(&file, s.path.as_deref(), s.validation())?;
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
let mut create = json!({"name": name, "activate": false});
// Clipboard insertion uses reuse even for the first paste, which must stay movable.
@@ -224,7 +224,7 @@ fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
let old = old.clone();
link_refused(s.path.as_deref(), &link.path).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
let file = resolve(s.path.as_deref(), &link.path);
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref(), s.validation()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
warnings.extend(sf.warnings.iter().cloned());
let same = matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old);
let kept = s.doc.feature(link.feature).map(|f| &f.link_faces);
@@ -305,7 +305,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
// Read/evaluate the candidate before changing anything. Session::execute owns the single
// undo step and restores both the document and evaluated state on any later failure.
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref(), s.validation()).map_err(|e| bad(cmd, e.to_string()))?;
let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) {
(Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step,
_ => true,
+32
View File
@@ -38,6 +38,18 @@ pub static COMMANDS: &[CommandSpec] = &[
inside it; total counts its outermost run only. Recording also starts with SOLIDCRAFT_PROFILE=1, the CLI's \
--profile and the app's preference; off, it costs nothing measurable. Read-only",
),
CommandSpec::new("diagnostics.validation", "Validation", crate::checked!("diagnostics.validation", validation))
.icon("bug_report")
.noundo()
.contract(&VALIDATION_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::diagnostics::ValidationReply>)
.params(
"mode?: each_step | each_feature | off (default: leave it). When SMLib's full check runs on the features computed \
from now on: after every sweep, boolean and blend (each_step), once on the bodies each feature makes or changes \
(each_feature, the default; a body it refuses still fails its feature, with the check's reason), or never (off, \
experimental). Results already there stand until their features are computed again (timeline.compute_all). \
The app's preference of the same name sets it. Returns {mode, previous}",
),
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params(
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
@@ -92,6 +104,26 @@ fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
const PROFILE_CONTRACT: crate::schema::TypedContract<crate::schema::diagnostics::ProfileParams> = crate::schema::TypedContract::new();
const VALIDATION_CONTRACT: crate::schema::TypedContract<crate::schema::diagnostics::ValidationParams> = crate::schema::TypedContract::new();
fn validation(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "diagnostics.validation";
let mode = match p.get("mode") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(solidcraft_kernel::Validation::parse)
.ok_or_else(|| bad(cmd, format!("`mode` {}", crate::schema::diagnostics::VALIDATION_MODES)))?,
),
};
let previous = s.validation();
if let Some(mode) = mode {
s.set_validation(mode);
}
let out = crate::schema::diagnostics::ValidationReply { mode: s.validation().into(), previous: previous.into() };
serde_json::to_value(out).map_err(|e| EngineError::Other(format!("{cmd}: {e}")))
}
/// Rows `diagnostics.profile` returns at most.
const MAX_PROFILE_ROWS: u64 = 10_000;
+2
View File
@@ -134,7 +134,9 @@ fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
let validation = s.validation();
s.model = solidcraft_doc::Model::new();
s.set_validation(validation);
s.refresh();
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"features": s.model.results.len(), "errors": errors}))
+16 -4
View File
@@ -294,6 +294,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(command) = super::drawing_import_command(path) {
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
let mut fresh = Session::new(Document::new(&stem));
fresh.set_validation(s.validation());
let mut out = fresh.execute(command, &json!({"path": path}))?;
fresh.undo.clear();
s.replace_design(fresh);
@@ -307,6 +308,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
// A new, unsaved design holding the file's bodies.
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
let mut fresh = Session::new(Document::new(&stem));
fresh.set_validation(s.validation());
let r = if solidcraft_io::is_step_path(path) {
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
} else {
@@ -335,7 +337,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem;
}
s.replace_design(Session::opened_at(doc, saved.as_ref(), Some(path)));
s.replace_design(Session::opened_at_validating(doc, saved.as_ref(), Some(path), s.validation()));
s.disk = disk;
s.read_only = read_only;
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
@@ -460,9 +462,19 @@ fn write_bytes(
impl Session {
/// Open a design with its file location, resolving component links before history keeps
/// them. Absolute in-memory targets survive Save As, undo, redo and recovery unchanged.
pub fn opened_at(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>, path: Option<&str>) -> Self {
pub fn opened_at(doc: Document, saved: Option<&solidcraft_doc::SavedModel>, path: Option<&str>) -> Self {
Self::opened_at_validating(doc, saved, path, solidcraft_kernel::Validation::default())
}
/// [`Self::opened_at`], its features evaluated under `validation` ([`Session::set_validation`]).
pub fn opened_at_validating(
mut doc: Document,
saved: Option<&solidcraft_doc::SavedModel>,
path: Option<&str>,
validation: solidcraft_kernel::Validation,
) -> Self {
super::component_link::resolve_design_links(&mut doc, path);
let mut session = Self::opened(doc, saved);
let mut session = Self::opened_validating(doc, saved, validation);
session.path = path.map(str::to_owned);
session
}
@@ -815,7 +827,7 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
doc.name = stem;
}
s.replace_design(Session::opened_at(doc, None, entry.path.as_deref()));
s.replace_design(Session::opened_at_validating(doc, None, entry.path.as_deref(), s.validation()));
s.mark_unsaved();
if bool_(p, "discard").unwrap_or(false) {
crate::recovery::remove(&dir, id)?;
+36 -6
View File
@@ -606,7 +606,12 @@ impl Session {
/// `doc`'s evaluation ([`Model::adopt`]); otherwise the timeline is evaluated.
/// Use [`Self::opened_at`] when the document's file location is known.
/// `SOLIDCRAFT_NO_MODEL_CACHE` set always evaluates.
pub fn opened(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>) -> Self {
pub fn opened(doc: Document, saved: Option<&solidcraft_doc::SavedModel>) -> Self {
Self::opened_validating(doc, saved, solidcraft_kernel::Validation::default())
}
/// [`Self::opened`], its features evaluated under `validation` ([`Session::set_validation`]).
pub fn opened_validating(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>, validation: solidcraft_kernel::Validation) -> Self {
// A design's font files are checked like its other paths before it is evaluated.
solidcraft_io::vfs::install_checks();
// The session keeps what is hidden (an undo step of its own); the design file carries it.
@@ -615,11 +620,18 @@ impl Session {
let adopted = saved
.filter(|_| solidcraft_geom::env::var_os("NO_MODEL_CACHE").is_none())
.and_then(|saved| Model::adopt(&doc, saved).map_err(|e| log::info!("the saved model is not used: {e}")).ok());
let model = adopted.unwrap_or_else(|| {
let mut model = Model::new();
model.evaluate(&doc);
model
});
let model = match adopted {
Some(mut model) => {
model.set_validation(validation);
model
}
None => {
let mut model = Model::new();
model.set_validation(validation);
model.evaluate(&doc);
model
}
};
Session {
saved: doc.clone(),
doc,
@@ -690,6 +702,7 @@ impl Session {
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
let auto_project = self.auto_project;
let validation = self.validation();
let metric_tap_drill_major_less_pitch = self.metric_tap_drill_major_less_pitch;
let surface_extrude_symmetric_per_side = self.surface_extrude_symmetric_per_side;
let param_check = self.param_check;
@@ -707,6 +720,9 @@ impl Session {
self.crash_journal = crash_journal;
self.command_check = command_check;
self.auto_project = auto_project;
// The user's, like `auto_project`: the commands that open a design evaluate it under it
// already (`Session::opened_validating`).
self.set_validation(validation);
self.metric_tap_drill_major_less_pitch = metric_tap_drill_major_less_pitch;
self.surface_extrude_symmetric_per_side = surface_extrude_symmetric_per_side;
self.param_check = param_check;
@@ -942,6 +958,20 @@ impl Session {
}
}
/// When SMLib's check runs on the features evaluated: after every sweep, boolean and blend,
/// once on the bodies each feature keeps (the default), or not at all. A user's setting,
/// kept when another design is opened (`diagnostics.validation`, the app's preference).
pub fn validation(&self) -> solidcraft_kernel::Validation {
self.model.validation()
}
/// Evaluate the features computed from now on under `validation` ([`Session::validation`]);
/// the results already there stand until their features are computed again
/// (`timeline.compute_all` computes every one).
pub fn set_validation(&mut self, validation: solidcraft_kernel::Validation) {
self.model.set_validation(validation);
}
pub fn refresh(&mut self) {
self.model.evaluate(&self.doc);
#[cfg(test)]
+61
View File
@@ -142,6 +142,67 @@ impl ProfileReport {
}
}
/// A validation mode by name, with the handler's message for anything else.
fn validation_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<ValidationMode>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(s) => ValidationMode::parse(&s).map(Some).ok_or_else(|| serde::de::Error::custom(VALIDATION_MODES)),
_ => Err(serde::de::Error::custom(VALIDATION_MODES)),
}
}
/// What a `mode` that names no validation mode is told.
pub(crate) const VALIDATION_MODES: &str = "must be each_step, each_feature or off";
/// When SMLib's full check runs while features are computed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ValidationMode {
/// After every sweep, boolean and blend inside a feature: a step whose result fails the
/// check fails the feature there.
EachStep,
/// Once per feature (the default): each body the feature makes or changes is checked before
/// it is kept, and a body the check refuses fails the feature with the check's reason.
EachFeature,
/// Never (experimental): a body the check would refuse is kept.
Off,
}
impl ValidationMode {
fn parse(id: &str) -> Option<ValidationMode> {
solidcraft_kernel::Validation::parse(id).map(ValidationMode::from)
}
}
impl From<solidcraft_kernel::Validation> for ValidationMode {
fn from(v: solidcraft_kernel::Validation) -> Self {
match v {
solidcraft_kernel::Validation::EachStep => ValidationMode::EachStep,
solidcraft_kernel::Validation::EachFeature => ValidationMode::EachFeature,
solidcraft_kernel::Validation::Off => ValidationMode::Off,
}
}
}
/// Read, or set, when SMLib's full check runs on the features computed from now on.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ValidationParams {
/// The mode to compute features under from now on. Default: leave it as it is. The results
/// already there stand until their features are computed again (`timeline.compute_all`).
#[serde(default, deserialize_with = "validation_mode")]
pub mode: Option<ValidationMode>,
}
/// The reply of `diagnostics.validation`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ValidationReply {
/// The mode features are computed under now.
pub mode: ValidationMode,
/// The mode before this call.
pub previous: ValidationMode,
}
/// Tests that switch the process-wide profiler on, off or reset it run one at a time.
#[cfg(test)]
pub(crate) static PROFILE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
+60 -1
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use super::ProfileParams;
use super::{ProfileParams, ValidationParams};
use crate::Session;
use crate::schema::input_schema_for;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
@@ -8,6 +8,7 @@ use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ProfileParams>(), &["enabled", "reset", "top", "trace"]);
assert_keys(input_schema_for::<ValidationParams>(), &["mode"]);
}
#[test]
@@ -18,6 +19,8 @@ fn strict_keeps_todays_errors() {
("diagnostics.profile", json!({"top": -1})),
("diagnostics.profile", json!({"top": 2.5})),
("diagnostics.profile", json!({"trace": 5})),
("diagnostics.validation", json!({"mode": "each step"})),
("diagnostics.validation", json!({"mode": true})),
]);
}
@@ -60,3 +63,59 @@ fn diagnostics_profile_reports_commands_features_and_the_kernel() {
assert_eq!(after["enabled"], json!(false), "{after}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Features are checked once each by default; the command reads and sets the mode, and the mode
/// is the user's: it stays when another design is started and when everything is computed again.
#[test]
fn validation_defaults_to_each_feature_and_round_trips_through_the_command() {
use solidcraft_kernel::Validation;
let mut s = Session::default();
assert_eq!(s.validation(), Validation::EachFeature);
assert_eq!(s.execute("diagnostics.validation", &json!({})).unwrap(), json!({"mode": "each_feature", "previous": "each_feature"}));
let r = s.execute("diagnostics.validation", &json!({"mode": "each_step"})).unwrap();
assert_eq!(r, json!({"mode": "each_step", "previous": "each_feature"}));
assert_eq!(s.validation(), Validation::EachStep);
s.execute("file.new", &json!({})).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("timeline.compute_all", &json!({})).unwrap();
assert_eq!(s.execute("diagnostics.validation", &json!({"mode": "off"})).unwrap()["previous"], json!("each_step"));
assert_eq!(s.validation(), Validation::Off);
// A refused mode changes nothing.
assert!(s.execute("diagnostics.validation", &json!({"mode": "never"})).is_err());
assert_eq!(s.validation(), Validation::Off);
}
/// A feature whose body SMLib's check refuses fails under each_feature as under each_step (once
/// at its end instead of at the step), with the check's reason; off keeps the body. The bar's
/// top edge rounded into the boss's round (kernel case
/// fillets/1207-fillet-bar-top-edge-after-boss-arc-round-r0-5, refused by the check on this
/// SMLib); the same features without the last round build under every mode, to the same volume.
#[test]
fn a_body_the_check_refuses_still_fails_its_feature_once_per_feature() {
use solidcraft_kernel::Validation;
let build = |mode: Validation, last: bool| {
let mut s = Session::default();
s.set_validation(mode);
s.execute("solid.box", &json!({"length": 100, "width": 30, "height": 10, "corner": [0, -15, 0], "body_name": "Arm"})).unwrap();
s.execute("solid.cylinder", &json!({"diameter": 40, "height": 20, "base": [0, 0, 0], "operation": "join", "targets": ["Arm"]})).unwrap();
s.execute("solid.fillet", &json!({"edges": [[16, 12, 10]], "radius": 2})).unwrap();
let r = if last { s.execute("solid.fillet", &json!({"edges": [[60, -15, 10]], "radius": 0.5})).map(|_| ()) } else { Ok(()) };
let volume = s.execute("inspect.measure", &json!({})).ok().and_then(|m| m["total"]["volume_mm3"].as_f64());
(r.map_err(|e| e.to_string()), volume)
};
let each_step = build(Validation::EachStep, false);
for mode in [Validation::EachFeature, Validation::Off] {
let (r, volume) = build(mode, false);
assert!(r.is_ok() && each_step.0.is_ok(), "{mode:?}: {r:?}");
assert!(volume.zip(each_step.1).is_some_and(|(v, w)| (v - w).abs() <= 1e-9 * w), "{mode:?}: {volume:?} vs {:?}", each_step.1);
}
let (step, _) = build(Validation::EachStep, true);
let (feature, _) = build(Validation::EachFeature, true);
assert_eq!(step.is_err(), feature.is_err(), "each_step {step:?}, each_feature {feature:?}");
if let Err(e) = &feature {
assert!(e.contains("SMLib's check refuses the result"), "{e}");
assert!(step.as_ref().err().is_some_and(|e| e.contains("SMLib's check refuses the result")), "{step:?}");
let (off, _) = build(Validation::Off, true);
assert!(off.is_ok(), "off keeps the body the check refuses: {off:?}");
}
}