From c9a765aa0238fbf4775681a949b35fdcdf73cbdf Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:30:11 -0400 Subject: [PATCH] engine: samples can build their geometry from a SolidCraft script A sample may carry a SolidCraft script (the corpus recipe form) whose design opens before its commands run, and its script sees the design's bodies. fieldcraft-engine now builds fieldcraft-geometry with its scripts feature. The start screen scrolls its sample list, and a test keeps the file.sample form's choices in step with the samples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- apps/fieldcraft/src/main.rs | 3 ++- crates/engine/Cargo.toml | 5 ++--- crates/engine/src/cmd/file.rs | 9 +++++++-- crates/engine/src/sample.rs | 37 +++++++++++++++++++++++++++++++---- crates/ui-egui/src/files.rs | 4 ++++ 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 794842e..1a9981a 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -17,7 +17,8 @@ use fieldcraft_engine::Session; use fieldcraft_engine::autosave::{self, Autosaver}; use fieldcraft_ui_egui::{Filter, ModelApp, Services}; -const USAGE: &str = "usage: fieldcraft [--sample [parts|cantilever|impact]] [--control PORT] [FILE…] (FILE: .fieldcraft, .solidcraft or .3mf)"; +const USAGE: &str = + "usage: fieldcraft [--sample [NAME]] [--control PORT] [FILE…] (NAME: a sample, docs/examples.md; FILE: .fieldcraft, .solidcraft or .3mf)"; const DEPTH_BITS: u8 = 24; /// The window draws its own caption buttons where the OS ones would sit apart from the app bar. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index d970866..0f1cc6a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -10,7 +10,8 @@ workspace = true [dependencies] fieldcraft-model = { workspace = true } -fieldcraft-geometry = { workspace = true } +# Samples build their geometry from SolidCraft scripts (sample.rs). +fieldcraft-geometry = { workspace = true, features = ["scripts"] } fieldcraft-mesh = { workspace = true } fieldcraft-io = { workspace = true } fieldcraft-decks = { workspace = true } @@ -27,5 +28,3 @@ log = { workspace = true } [dev-dependencies] zip = { workspace = true } -# Multi-body designs from SolidCraft scripts in tests. -fieldcraft-geometry = { workspace = true, features = ["scripts"] } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b4fef45..7e216b0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -76,7 +76,7 @@ pub const RECOVERY_DISCARD: CommandSpec = CommandSpec { pub const SAMPLE: CommandSpec = CommandSpec { id: "file.sample", label: "Open Sample", - params: r#"{"name": "parts|cantilever|impact"} — opens a sample model (fieldcraft_engine::sample) in place of the current one, unsaved changes discarded; `cantilever` and `impact` are ready to solve (solve.run)"#, + params: r#"{"name": "cantilever"} — opens a sample model (fieldcraft_engine::sample; the list is in docs/examples.md) in place of the current one, unsaved changes discarded; every sample but `parts` is ready to solve (solve.run)"#, form: Some(super::forms::SAMPLE), kind: CommandKind::Session, run: open_sample, @@ -87,7 +87,12 @@ fn open_sample(s: &mut Session, p: &Value) -> Result { let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::>().join(", "); let sample = crate::sample::find(name).ok_or_else(|| EngineError::Params(format!("no sample `{name}` (samples: {})", names())))?; let mut opened = Session::default(); - opened.run_script(&(sample.script)())?; + if let Some(recipe) = sample.design { + opened.design = Some(Arc::new(open_design(&fieldcraft_geometry::script_design(recipe)?)?)); + } + let bodies = opened.design.as_ref().map_or(&[][..], |d| d.bodies.as_slice()); + let script = (sample.script)(bodies); + opened.run_script(&script)?; // A sample just opened has nothing to save: it opens again the same way. opened.saved_revision = Some(opened.revision); s.replace_with(opened); diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index f9119b8..8e7989d 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -9,7 +9,13 @@ //! [`cantilever`], a model ready to solve: a steel beam clamped at one end with a load on the //! other, a static and a frequency step. [`impact`]: the same beam flying onto a rigid wall, an //! explicit dynamic step. +//! +//! The examples gallery (docs/examples.md), each ready to solve with `solve.run`: +//! [`drop_test`], [`rubber_seal`], [`flow_past`], [`heated_bracket`] and [`bracket_hex`]. Their +//! geometry is a SolidCraft script run when the sample opens ([`Sample::design`]): a corpus +//! recipe (`tests/corpus`) or [`UPRIGHT_BRACKET`]. +use fieldcraft_geometry::Shape; use serde_json::{Value, json}; /// A sample the app offers (Help › Samples, the empty viewport, `file.sample`, `--sample NAME`). @@ -18,23 +24,35 @@ pub struct Sample { pub label: &'static str, /// One line: what it shows. pub about: &'static str, - pub script: fn() -> Value, + /// The SolidCraft script (`{"commands": [...]}`, the corpus recipe form) that builds the + /// sample's geometry, opened as its design before [`Sample::script`] runs. + pub design: Option<&'static str>, + /// The model's commands, given the design's bodies (none without a design). + pub script: fn(&[Shape]) -> Value, } /// Every sample, the default first. pub const SAMPLES: &[Sample] = &[ - Sample { name: "parts", label: "Hex and Tet Parts", about: "A hex8 block beside a tet4 cube: meshes to look at, nothing to solve", script }, + Sample { + name: "parts", + label: "Hex and Tet Parts", + about: "A hex8 block beside a tet4 cube: meshes to look at, nothing to solve", + design: None, + script: |_| script(), + }, Sample { name: "cantilever", label: "Cantilever", about: "A clamped steel beam with an end load: a static and a frequency step, ready to solve", - script: cantilever, + design: None, + script: |_| cantilever(), }, Sample { name: "impact", label: "Bar Impact", about: "The steel beam flying onto a rigid wall: an explicit dynamic step, ready to solve", - script: impact, + design: None, + script: |_| impact(), }, ]; @@ -225,6 +243,17 @@ mod tests { use super::*; use crate::Session; + /// The `file.sample` form offers every sample, by name. + #[test] + fn the_form_lists_every_sample() { + let Some(crate::schema::Kind::Choice(options)) = crate::cmd::forms::SAMPLE.first().map(|f| f.kind) else { panic!("not a choice") }; + let mut offered: Vec<&str> = options.iter().map(|(n, _)| *n).collect(); + let mut names: Vec<&str> = SAMPLES.iter().map(|s| s.name).collect(); + offered.sort_unstable(); + names.sort_unstable(); + assert_eq!(offered, names); + } + #[test] fn the_sample_builds_positive_elements_that_fill_both_parts() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bb9b6eb..42ae3ea 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -740,6 +740,10 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { /// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked. pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) { let width = ui.available_width().clamp(280.0, 420.0); + egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| sample_list(ui, t, open, width)); +} + +fn sample_list(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>, width: f32) { for sample in fieldcraft_engine::sample::SAMPLES { ui.horizontal(|ui| { ui.vertical(|ui| {