diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cc770aa..66fc893 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -143,6 +143,7 @@ is committed and listed here with its exports. | assets/icons/ui.sun.svg, assets/icons/ui.sun.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.sun.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/ui.views.svg, assets/icons/ui.views.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.views.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/ui.zoom.svg, assets/icons/ui.zoom.vectorcraft | FieldCraft contributors | Original interface icon (navigation bar, title bar, browser) drawn with VectorCraft from `assets/icons/src/ui.zoom.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/view.show_all.svg, assets/icons/view.show_all.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/view.show_all.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/app-icon/icon.vectorcraft, assets/app-icon/icon.svg, assets/app-icon/icon.png | FieldCraft contributors | Original application icon (a hexahedral-meshed cube with a banded colour fringe on its faces on a rounded-square backdrop), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs); editable source, SVG and 1024 × 1024 PNG export | MIT OR Apache-2.0 | | assets/app-icon/icon-small.vectorcraft, assets/app-icon/icon-small.svg, assets/app-icon/icon-small.png | FieldCraft contributors | Original simplified application icon for 16–32 px (the fringe cube alone; no mesh or floor grid), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | | assets/app-icon/layers/01-floor.vectorcraft, assets/app-icon/FieldCraft.icon/Assets/01-floor.svg | FieldCraft contributors | Original isometric floor grid with a radial fade (Default appearance), drawn with VectorCraft by `cargo xtask app-icon` (xtask/src/app_icon.rs) | MIT OR Apache-2.0 | diff --git a/assets/icons/src/view.show_all.json b/assets/icons/src/view.show_all.json new file mode 100644 index 0000000..563b7da --- /dev/null +++ b/assets/icons/src/view.show_all.json @@ -0,0 +1 @@ +{"shapes": [{"d": "M2.5 13 C5.5 7.5 18.5 7.5 21.5 13 C18.5 18.5 5.5 18.5 2.5 13 Z", "fill": "soft", "stroke": "line"}, {"circle": [12, 13, 2.6], "fill": "accent", "stroke": "accent", "width": 1}, {"d": "M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5", "stroke": "warm", "width": 1.5}]} diff --git a/assets/icons/view.show_all.svg b/assets/icons/view.show_all.svg new file mode 100644 index 0000000..64ce61f --- /dev/null +++ b/assets/icons/view.show_all.svg @@ -0,0 +1,9 @@ + + + view.show_all + + + + + + diff --git a/assets/icons/view.show_all.vectorcraft b/assets/icons/view.show_all.vectorcraft new file mode 100644 index 0000000..cdafda4 Binary files /dev/null and b/assets/icons/view.show_all.vectorcraft differ diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index a40d04f..3b22a32 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -580,6 +580,15 @@ pub static CATALOGUE: &[Capability] = &[ "Open a model autosaved by an app that crashed or closed with unsaved changes", &["file.recovery.list", "file.recovery.discard"], ), + // Hide, show and isolate act on the browser's items, from their eyes and item menus. + covering( + "view.show_all", + "Show All", + Tools, + "View", + "Show every item hidden in the viewport (hide, show and isolate items from the browser)", + &["view.hide", "view.show", "view.isolate"], + ), ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"), ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"), ]; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index eeda4be..0558a49 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -190,7 +190,7 @@ impl Session { design: self.sources.design.as_ref().map(stored).transpose()?, meshes: self.sources.meshes.iter().map(stored).collect::>()?, }; - Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal)?) + Ok(fieldcraft_io::write(&self.model, &geometry, &self.journal, &self.view)?) } /// A session holding what a file holds, its geometry opened again from the paths it names @@ -220,8 +220,17 @@ impl Session { surfaces.extend(opened.into_iter().flatten().map(Arc::new)); sources.meshes.push(source); } - let session = - Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() }; + let session = Session { + model: file.model, + design, + surfaces, + sources, + journal: file.journal, + revision: next_revision(), + saved_view: file.view.clone(), + view: file.view, + ..Session::default() + }; (session, warnings) } diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 2f63789..6cb5dcd 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1392,6 +1392,30 @@ pub const DATUM_POINT: &[Field] = &[ .req() .def(r#"{"type": "coordinates"}"#), ]; +pub const VIEW_ITEMS: &[Field] = &[rows( + "items", + "Items", + &[ + choice( + "kind", + "Kind", + &[ + ("body", "Body"), + ("surface", "Surface"), + ("datum", "Datum"), + ("boundary_condition", "Boundary condition"), + ("load", "Load"), + ("reference_point", "Reference point"), + ("coupling", "Coupling"), + ], + ) + .help("What kind of item it is.") + .req(), + text("name", "Name").help("The item's name.").req(), + ], +) +.help("The viewport items.") +.req()]; pub const DATUM_DELETE: &[Field] = &[ reference("name", "Datum", C::Datum).req().help("The Datum to act on, by name."), flag("cascade", "Delete its partitions").help("Also delete the partitions that cut by it; without it, a datum in use is refused."), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 28a1def..3b32799 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ pub(crate) mod seeds; pub(crate) mod solve; mod symmetry; mod tags; +pub(crate) mod view; use crate::schema::ProblemKind; use fieldcraft_model::MAX_LABEL; @@ -47,6 +48,10 @@ pub static ALL: &[CommandSpec] = &[ datum::DATUM_AXIS_EDIT, datum::DATUM_POINT_EDIT, datum::DATUM_DELETE, + view::HIDE, + view::SHOW, + view::ISOLATE, + view::SHOW_ALL, datum::PARTITION, datum::PARTITION_DELETE, mesh::NODE_ADD, diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs new file mode 100644 index 0000000..287e882 --- /dev/null +++ b/crates/engine/src/cmd/view.rs @@ -0,0 +1,219 @@ +//! What the viewport hides: `view.hide`, `view.show`, `view.isolate` and `view.show_all`. +//! +//! The hidden items (bodies, imported surfaces, datums, boundary conditions, loads, reference +//! points, couplings) are view state ([`fieldcraft_io::View`]): saved with the model and marking +//! it unsaved, but not a definition, so they are neither journalled nor undone and they leave +//! results current. A hidden item is not drawn, so it cannot be picked either. An item that goes +//! away (deleted, or a body cut into cells) is forgotten. + +use std::collections::BTreeSet; + +use fieldcraft_io::{Item, ItemKind, MAX_HIDDEN}; +use serde_json::Value; + +use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; + +pub const HIDE: CommandSpec = CommandSpec { + id: "view.hide", + label: "Hide", + params: concat!( + r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#, + " — hides the items in the viewport (and from picking); view state, saved with the model" + ), + form: Some(super::forms::VIEW_ITEMS), + kind: CommandKind::Session, + requires: &[], + run: |s, p| change(s, p, Change::Hide), +}; + +pub const SHOW: CommandSpec = CommandSpec { + id: "view.show", + label: "Show", + params: concat!( + r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#, + " — shows hidden items again" + ), + form: Some(super::forms::VIEW_ITEMS), + kind: CommandKind::Session, + requires: &[], + run: |s, p| change(s, p, Change::Show), +}; + +pub const ISOLATE: CommandSpec = CommandSpec { + id: "view.isolate", + label: "Isolate", + params: concat!( + r#"{"items": [{"kind": "body" | "surface" | "datum" | "boundary_condition" | "load" | "reference_point" | "coupling", "name": "Sym"}]}"#, + " — shows only these items: every other one is hidden (view.show_all brings them back)" + ), + form: Some(super::forms::VIEW_ITEMS), + kind: CommandKind::Session, + requires: &[], + run: |s, p| change(s, p, Change::Isolate), +}; + +pub const SHOW_ALL: CommandSpec = CommandSpec { + id: "view.show_all", + label: "Show All", + params: "{}", + form: Some(&[]), + kind: CommandKind::Session, + requires: &[], + run: |s, _| { + s.view.hidden.clear(); + Ok(Value::Null) + }, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Change { + Hide, + Show, + Isolate, +} + +/// Every item of `kind` the session has, by name. +pub(crate) fn names(s: &Session, kind: ItemKind) -> Vec { + let m = &s.model; + match kind { + ItemKind::Body => s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(), + ItemKind::Surface => s.surfaces.iter().map(|x| x.name.clone()).collect(), + ItemKind::Datum => m.datums().keys().cloned().collect(), + ItemKind::BoundaryCondition => m.boundary_conditions().keys().cloned().collect(), + ItemKind::Load => m.loads().keys().cloned().collect(), + ItemKind::ReferencePoint => m.reference_points().keys().cloned().collect(), + ItemKind::Coupling => m.couplings().keys().cloned().collect(), + } +} + +fn exists(s: &Session, item: &Item) -> bool { + let m = &s.model; + match item.kind { + ItemKind::Body => s.design.as_ref().is_some_and(|d| d.body(&item.name).is_some()), + ItemKind::Surface => s.surfaces.iter().any(|x| x.name == item.name), + ItemKind::Datum => m.datum(&item.name).is_some(), + ItemKind::BoundaryCondition => m.boundary_conditions().contains_key(&item.name), + ItemKind::Load => m.loads().contains_key(&item.name), + ItemKind::ReferencePoint => m.reference_points().contains_key(&item.name), + ItemKind::Coupling => m.couplings().contains_key(&item.name), + } +} + +/// Forget hidden items the session no longer has. +pub(crate) fn forget_missing(s: &mut Session) { + if s.view.hidden.is_empty() { + return; + } + let gone: Vec = s.view.hidden.iter().filter(|i| !exists(s, i)).cloned().collect(); + for i in gone { + s.view.hidden.remove(&i); + } +} + +impl Session { + /// The analysis setup's symbols, without the definitions the view hides. + pub fn glyphs(&self) -> fieldcraft_render::glyphs::Glyphs { + use fieldcraft_render::glyphs::Definition; + fieldcraft_render::glyphs::Glyphs::from_model_hiding(&self.model, &|definition, name| { + let kind = match definition { + Definition::BoundaryCondition => ItemKind::BoundaryCondition, + Definition::Load => ItemKind::Load, + Definition::ReferencePoint => ItemKind::ReferencePoint, + Definition::Coupling => ItemKind::Coupling, + Definition::Contact | Definition::Tie | Definition::Step => return false, + }; + self.view.is_hidden(kind, name) + }) + } + + /// Whether the view shows item `name` of `kind`. + pub fn shows(&self, kind: ItemKind, name: &str) -> bool { + !self.view.is_hidden(kind, name) + } +} + +fn change(s: &mut Session, p: &Value, how: Change) -> Result { + let items: Vec = super::req(p, "items")?; + if items.len() > MAX_HIDDEN { + return Err(EngineError::Params(format!("at most {MAX_HIDDEN} items"))); + } + if let Some(i) = items.iter().find(|i| !exists(s, i)) { + let kind = serde_json::to_value(i.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(EngineError::Params(format!("no {} `{}`", kind.replace('_', " "), i.name.chars().take(100).collect::()))); + } + match how { + Change::Hide => s.view.hidden.extend(items), + Change::Show => { + for i in &items { + s.view.hidden.remove(i); + } + } + Change::Isolate => { + let keep: BTreeSet = items.into_iter().collect(); + let all = ItemKind::ALL.into_iter().flat_map(|kind| names(s, kind).into_iter().map(move |name| Item { kind, name })); + let rest: BTreeSet = all.filter(|i| !keep.contains(i)).take(MAX_HIDDEN).collect(); + s.view.hidden = rest; + } + } + Ok(Value::Null) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::cmd::datum::tests::plate; + + fn item(kind: ItemKind, name: &str) -> Value { + json!({"kind": kind, "name": name}) + } + + #[test] + fn hiding_is_saved_view_state_not_an_edit() { + let (mut s, dir) = plate(); + s.execute("geometry.datum", &json!({"name": "Sym", "by": {"type": "offset", "plane": "zx"}})).unwrap(); + s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "coordinates", "point": [1, 2, 3]}})).unwrap(); + let file = dir.join("hide.fieldcraft"); + s.execute("file.save_as", &json!({"path": file.to_str().unwrap()})).unwrap(); + let (journal, revision) = (s.journal.len(), s.revision()); + assert!(!s.is_dirty()); + + s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap(); + assert_eq!(s.view.hidden.iter().collect::>(), [&Item { kind: ItemKind::Datum, name: "Sym".into() }]); + // Unsaved, but no edit: nothing journalled, the revision (and so any result) unchanged. + assert!(s.is_dirty()); + assert_eq!((s.journal.len(), s.revision()), (journal, revision)); + // Showing it again is back to the saved state. + s.execute("view.show", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap(); + assert!(!s.is_dirty()); + + // Hidden state survives save and open. + s.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "Sym")]})).unwrap(); + s.execute("file.save", &json!({})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap(); + assert!(t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P")); + assert!(!t.is_dirty()); + + // Isolate hides every other item; show_all brings them back. + let body = names(&t, ItemKind::Body).remove(0); + t.execute("view.isolate", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap(); + assert!(t.view.is_hidden(ItemKind::Body, &body) && t.view.is_hidden(ItemKind::Datum, "Sym") && !t.view.is_hidden(ItemKind::Datum, "P")); + t.execute("view.show_all", &json!({})).unwrap(); + assert!(t.view.is_empty()); + + // An item deleted is forgotten. + t.execute("view.hide", &json!({"items": [item(ItemKind::Datum, "P")]})).unwrap(); + t.execute("geometry.datum.delete", &json!({"name": "P"})).unwrap(); + assert!(t.view.is_empty()); + + // Unknown items and hostile parameters change nothing. + for p in [json!({}), json!({"items": "x"}), json!({"items": [item(ItemKind::Load, "Nope")]}), json!({"items": [{"kind": "x", "name": "A"}]})] + { + assert!(t.execute("view.hide", &p).is_err(), "{p}"); + } + assert!(t.view.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 866d138..e2569c8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -238,6 +238,11 @@ pub struct Session { /// Identifies the open document: it changes when another model, sample or design is opened /// in place of the current one, never with an edit (see [`Session::opened`]). opened: u64, + /// What the viewport hides (`view.*`): saved with the model, but no edit of it, so it is + /// neither journalled nor undone, and it leaves results current. + pub view: fieldcraft_io::View, + /// The view as last saved (or opened): a change to it is unsaved too. + saved_view: fieldcraft_io::View, } impl Session { @@ -275,6 +280,8 @@ impl Session { self.journal.push((id.to_string(), params.clone())); self.revision = next_revision(); self.record_undo(before); + // An item deleted (or a body cut into cells) is no longer hidden. + cmd::view::forget_missing(self); } } result @@ -315,7 +322,7 @@ impl Session { /// Whether the session has changes its file does not hold. Undoing back to the saved state /// makes it clean again. pub fn is_dirty(&self) -> bool { - self.saved_revision.unwrap_or(0) != self.revision + self.saved_revision.unwrap_or(0) != self.revision || self.view != self.saved_view } /// The stored result of a step, if it was solved in the current state (an edit makes it stale; @@ -356,6 +363,7 @@ impl Session { pub(crate) fn mark_saved(&mut self, path: String) { self.path = Some(path); self.saved_revision = Some(self.revision); + self.saved_view = self.view.clone(); } } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index f18883d..36bb3cf 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -121,14 +121,14 @@ pub struct Shown { pub fn display(s: &Session) -> (DisplayMesh, Vec) { let mut d = DisplayMesh::from_model(&s.model); let mut failed = Vec::new(); - for body in s.design.iter().flat_map(|d| &d.bodies) { + for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) { let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() }))); match mesh { Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face), Err(e) => failed.push(format!("{}: {e}", body.name)), } } - for surface in &s.surfaces { + for surface in s.surfaces.iter().filter(|x| s.shows(fieldcraft_io::ItemKind::Surface, &x.name)) { d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face); } (d, failed) @@ -180,13 +180,13 @@ impl Picture { fn picture(s: &Session, p: &Palette) -> Picture { let (base, failed) = display(s); - let glyphs = Glyphs::from_model(&s.model); + let glyphs = s.glyphs(); let mut shown = Shown { failed, ..Shown::default() }; let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r))); let Some((view, result)) = plot else { let mut scene = model_scene(&base, p); let mut bounds = base.bounds; - bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &p.glyphs, &mut scene)); + bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &|n| s.shows(fieldcraft_io::ItemKind::Datum, n), &p.glyphs, &mut scene)); return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs }; }; shown.step = Some(view.step.clone()); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 03063f6..149ec8f 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -5,8 +5,8 @@ //! //! - `fieldcraft.json`: the manifest. The format tag (`fieldcraft/1`), the program that wrote //! it, the geometry the model is built on (the SolidCraft design and the imported 3MF meshes, -//! each a path and the SHA-256 of its content, opened again when the model is), and the size -//! and SHA-256 of every other part. +//! each a path and the SHA-256 of its content, opened again when the model is), the view state +//! ([`View`]: what the viewport hides), and the size and SHA-256 of every other part. //! - `model.json`: the model definition (unit system, sets, materials, sections, reference //! points, couplings, boundary conditions, loads, steps) as pretty-printed JSON with a stable //! key order, so two versions of a model diff line by line. @@ -28,7 +28,7 @@ pub mod mesh; pub mod recovery; pub mod roots; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io::{Cursor, Read, Write}; use fieldcraft_model::Model; @@ -176,6 +176,75 @@ pub struct Geometry { pub meshes: Vec, } +/// Most items a view hides. +pub const MAX_HIDDEN: usize = 100_000; + +/// Longest name of a hidden item. +const MAX_HIDDEN_NAME: usize = 1024; + +/// The kinds of items the viewport draws one by one, and so can hide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + /// A body of the open design (or a cell of a partitioned one). + Body, + /// An imported surface. + Surface, + Datum, + BoundaryCondition, + Load, + ReferencePoint, + Coupling, +} + +impl ItemKind { + pub const ALL: [ItemKind; 7] = [ + ItemKind::Body, + ItemKind::Surface, + ItemKind::Datum, + ItemKind::BoundaryCondition, + ItemKind::Load, + ItemKind::ReferencePoint, + ItemKind::Coupling, + ]; +} + +/// An item of the viewport, by kind and name. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Item { + pub kind: ItemKind, + pub name: String, +} + +/// View state saved with a model (not part of its definition): the items the viewport hides. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct View { + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub hidden: BTreeSet, +} + +impl View { + pub fn is_empty(&self) -> bool { + self.hidden.is_empty() + } + + pub fn is_hidden(&self, kind: ItemKind, name: &str) -> bool { + self.hidden.contains(&Item { kind, name: name.to_string() }) + } + + fn check(&self) -> Result<()> { + if self.hidden.len() > MAX_HIDDEN { + return Err(Error::Limit(format!("a view hides at most {MAX_HIDDEN} items"))); + } + if self.hidden.iter().any(|i| i.name.is_empty() || i.name.len() > MAX_HIDDEN_NAME) { + return invalid(format!("{MANIFEST}: a hidden item's name is 1 to {MAX_HIDDEN_NAME} bytes")); + } + Ok(()) + } +} + /// One journal entry: a command id and its parameters. pub type JournalEntry = (String, Value); @@ -185,6 +254,7 @@ pub struct ModelFile { pub model: Model, pub geometry: Geometry, pub journal: Vec, + pub view: View, } #[derive(Serialize, Deserialize)] @@ -194,6 +264,8 @@ struct Manifest { generator: String, #[serde(default)] geometry: Geometry, + #[serde(default, skip_serializing_if = "View::is_empty")] + view: View, parts: BTreeMap, } @@ -235,11 +307,12 @@ fn journal_text(journal: &[JournalEntry]) -> Result> { } /// The bytes of a `.fieldcraft` file holding `model`, built on `geometry` by the commands in -/// `journal`. The same arguments always give the same bytes. -pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Result> { +/// `journal`, shown with `view`. The same arguments always give the same bytes. +pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry], view: &View) -> Result> { if geometry.meshes.len() > MAX_MESH_SOURCES { return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); } + view.check()?; let mut model_json = serde_json::to_vec_pretty(&model.without_mesh()).map_err(|e| json_error(MODEL, e))?; model_json.push(b'\n'); let mesh = mesh::encode(model)?; @@ -249,6 +322,7 @@ pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Re format: FORMAT.into(), generator: format!("FieldCraft {}", env!("CARGO_PKG_VERSION")), geometry: geometry.clone(), + view: view.clone(), parts: parts.iter().map(|(name, bytes)| (name.to_string(), Part { bytes: bytes.len() as u64, sha256: sha256_hex(bytes) })).collect(), }; let mut manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|e| json_error(MANIFEST, e))?; @@ -323,6 +397,7 @@ pub fn read(bytes: &[u8]) -> Result { if manifest.geometry.meshes.len() > MAX_MESH_SOURCES { return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); } + manifest.view.check()?; if manifest.parts.len() != 3 { return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}")); } @@ -347,7 +422,7 @@ pub fn read(bytes: &[u8]) -> Result { mesh::decode_into(&mut model, &mesh_bin)?; drop(mesh_bin); let journal = read_journal(&journal_json)?; - Ok(ModelFile { model, geometry: manifest.geometry, journal }) + Ok(ModelFile { model, geometry: manifest.geometry, journal, view: manifest.view }) } fn read_journal(bytes: &[u8]) -> Result> { diff --git a/crates/io/tests/file.rs b/crates/io/tests/file.rs index 2bee7ab..e1e137d 100644 --- a/crates/io/tests/file.rs +++ b/crates/io/tests/file.rs @@ -2,7 +2,7 @@ use std::io::{Cursor, Read, Write}; -use fieldcraft_io::{Error, Geometry, ModelFile, SourceFile, mesh, read, write}; +use fieldcraft_io::{Error, Geometry, Item, ItemKind, ModelFile, SourceFile, View, mesh, read, write}; use fieldcraft_model::{ElementKind, Entity, GeometryRef, Model, UnitSystem}; use serde_json::{Value, json}; @@ -36,11 +36,12 @@ fn sample() -> ModelFile { }; let journal = vec![("mesh.node.add".to_string(), json!({"label": 1, "xyz": [0, 0, 0]})), ("model.units".to_string(), json!({"system": "mm-t-s-N-MPa"}))]; - ModelFile { model: m, geometry, journal } + let view = View { hidden: [Item { kind: ItemKind::Datum, name: "Sym".into() }, Item { kind: ItemKind::Body, name: "Body1".into() }].into() }; + ModelFile { model: m, geometry, journal, view } } fn bytes_of(f: &ModelFile) -> Vec { - write(&f.model, &f.geometry, &f.journal).unwrap() + write(&f.model, &f.geometry, &f.journal, &f.view).unwrap() } /// The package's parts, by name. @@ -248,3 +249,24 @@ fn mesh_part_rejects_what_the_model_rejects() { mesh::decode_into(&mut back, &good).unwrap(); assert_eq!(back, f.model); } + +/// The view's hidden items come back as written; a hostile hidden list is refused. +#[test] +fn the_view_state_round_trips_and_is_checked() { + let f = sample(); + let back = read(&bytes_of(&f)).unwrap(); + assert_eq!(back.view, f.view); + assert!(back.view.is_hidden(ItemKind::Datum, "Sym") && !back.view.is_hidden(ItemKind::Load, "Sym")); + let bytes = bytes_of(&f); + for bad in [json!([{"kind": "body", "name": ""}]), json!([{"kind": "nope", "name": "A"}]), json!("x")] { + let mut ps = parts(&bytes); + let mut manifest: Value = serde_json::from_slice(&ps[0].1).unwrap(); + manifest["view"]["hidden"] = bad.clone(); + ps[0].1 = serde_json::to_vec(&manifest).unwrap(); + assert!(read(&package(&ps)).is_err(), "{bad}"); + } + let empty = View::default(); + let plain = write(&f.model, &f.geometry, &f.journal, &empty).unwrap(); + let manifest: Value = serde_json::from_slice(&parts(&plain)[0].1).unwrap(); + assert!(manifest.get("view").is_none(), "an empty view writes nothing: {manifest}"); +} diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index 03ce18e..9a443f1 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -722,6 +722,16 @@ impl Glyphs { Glyphs { owners: b.owners, groups: b.groups, marks: b.marks, unit } } + /// [`Glyphs::from_model`] without the definitions `hidden` names (the view's hidden items). + pub fn from_model_hiding(model: &Model, hidden: &dyn Fn(Definition, &str) -> bool) -> Glyphs { + let mut g = Glyphs::from_model(model); + let gone: Vec = g.owners.iter().map(|(d, n)| hidden(*d, n)).collect(); + let kept = |owner: usize| !gone.get(owner).copied().unwrap_or(false); + g.groups.retain(|x| kept(x.owner)); + g.marks.retain(|(owner, _)| kept(*owner)); + g + } + /// The model's own symbol unit (a fortieth of its bounding box diagonal): the size symbols /// are drawn at when no camera gives one. pub fn model_unit(&self) -> f64 { @@ -848,12 +858,12 @@ const DATUM_HATCH: usize = 4; /// Draw the model's datum planes (an outlined, hatched square around the model, `bounds`), axes /// (a line through the model) and points (a small cross), each with its name; returns the box -/// around them. -pub fn datums(model: &Model, bounds: &Aabb3, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 { +/// around them. Only the datums `shown` takes are drawn. +pub fn datums(model: &Model, bounds: &Aabb3, shown: &dyn Fn(&str) -> bool, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 { let mut d = Draw { scene, bounds: Aabb3::EMPTY, color: colors.datum, width: OUTLINE_WIDTH, on_top: false }; let (centre, diagonal) = if bounds.is_empty() { (Vec3::ZERO, 1.0) } else { (bounds.center(), bounds.diagonal().max(1e-9)) }; let reach = DATUM_REACH * diagonal; - for (name, datum) in model.datums() { + for (name, datum) in model.datums().iter().filter(|(n, _)| shown(n)) { match *datum { fieldcraft_model::Datum::Plane { point, normal } => { let (p, n) = (Vec3::from(point), Vec3::from(normal)); @@ -1137,6 +1147,11 @@ mod tests { let (s, _) = drawn_at(&g, &m, &view(GlyphShow::ALL, u)); assert!(s.tris.is_empty() && s.on_top.is_empty()); assert_eq!(s.lines.len(), 4 * 3 * 6); + // A hidden definition draws nothing; hiding another leaves it. + let (hidden, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, n| d == Definition::BoundaryCondition && n == "Clamp"), &m, &view(GlyphShow::ALL, u)); + assert!(hidden.lines.is_empty()); + let (other, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, _| d == Definition::Load), &m, &view(GlyphShow::ALL, u)); + assert_eq!(other.lines.len(), s.lines.len()); assert!(s.lines.iter().all(|l| l.color == COLORS.constraint && l.width == LINE_WIDTH)); // Tip on the node, each triangle along its axis (two crossed bases per axis). let b = bases(&s, Vec3::ZERO); @@ -1510,7 +1525,7 @@ mod tests { bounds.add(Vec3::ZERO); bounds.add(Vec3::new(10.0, 10.0, 10.0)); let mut scene = Scene::default(); - let drawn = datums(&m, &bounds, &COLORS, &mut scene); + let drawn = datums(&m, &bounds, &|_| true, &COLORS, &mut scene); // A square with its hatch (4 + 2 × 3 lines), the axis line and the point's three arms, // each crossing at the point. assert_eq!(scene.lines.len(), 14); @@ -1522,5 +1537,9 @@ mod tests { // The plane's lines lie on x = 5, centred on the model. assert!(scene.lines.iter().skip(1).take(10).all(|l| (l.a.x - 5.0).abs() < 1e-12 && (l.b.x - 5.0).abs() < 1e-12)); assert!(drawn.diagonal() > bounds.diagonal()); + // A datum left out draws nothing. + let mut only = Scene::default(); + datums(&m, &bounds, &|n| n == "Ax", &COLORS, &mut only); + assert_eq!((only.lines.len(), only.labels.len()), (1, 1)); } } diff --git a/crates/ui-egui/src/scene.rs b/crates/ui-egui/src/scene.rs index 730a6b1..b612e7f 100644 --- a/crates/ui-egui/src/scene.rs +++ b/crates/ui-egui/src/scene.rs @@ -84,7 +84,7 @@ pub fn symbols( /// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them. pub fn datums(s: &mut Scene, model: &fieldcraft_model::Model, bounds: &Aabb3, t: &Tokens) -> Aabb3 { - fieldcraft_render::glyphs::datums(model, bounds, &t.glyph_colors(), s) + fieldcraft_render::glyphs::datums(model, bounds, &|_| true, &t.glyph_colors(), s) } /// The scene for `display` drawn in `style`. diff --git a/docs/features.md b/docs/features.md index 0a74288..006b6aa 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, the control channel and AI agents over MCP (docs/mcp.md) run the same commands. -64 ready, 41 coming soon. +65 ready, 41 coming soon. ## Geometry @@ -160,5 +160,6 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | File | Save — Save the model to its .fieldcraft file | ready | `file.save` | | File | Save As — Save the model to a new .fieldcraft file | ready | `file.save_as` | | File | Recover — Open a model autosaved by an app that crashed or closed with unsaved changes | ready | `file.recover`, `file.recovery.list`, `file.recovery.discard` | +| View | Show All — Show every item hidden in the viewport (hide, show and isolate items from the browser) | ready | `view.show_all`, (`view.hide`), (`view.show`), (`view.isolate`) | | History | Undo — Undo the last command that changed the model | ready | `edit.undo` | | History | Redo — Redo the command undone last | ready | `edit.redo` | diff --git a/docs/formats/fieldcraft.md b/docs/formats/fieldcraft.md index 3f6e932..91d4309 100644 --- a/docs/formats/fieldcraft.md +++ b/docs/formats/fieldcraft.md @@ -12,8 +12,8 @@ unzip tool reads them: `unzip -p bracket.fieldcraft model.json`): | Part | Content | |---|---| -| `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.5.8"`), `geometry`, and `parts`: the byte size and SHA-256 of each other part. | -| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads, steps, datum planes and axes, and the partitions that cut the geometry (in order; applied again when the design is opened). Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. | +| `fieldcraft.json` | Manifest: `format` (`"fieldcraft/1"`), `generator` (`"FieldCraft 0.5.8"`), `geometry`, `view` (left out when empty), and `parts`: the byte size and SHA-256 of each other part. | +| `model.json` | The model definition: unit system, sets, materials, sections, reference points, couplings, boundary conditions, loads, steps, datum planes, axes and points, and the partitions that cut the geometry (in order; applied again when the design is opened). Pretty-printed JSON with a fixed key order (maps sorted by name), so a change shows as a line diff. Its form is the model's own JSON form, which commands take as parameters. | | `mesh.bin` | Nodes, elements and geometry tags, binary (below). | | `journal.json` | The commands that built the model, `[{"command": id, "params": {...}}, ...]`, one per line. It is a script `fieldcraft-cli run` replays. | @@ -40,6 +40,10 @@ is reported as a warning and the model still opens; sets bind to geometry names, brings the mesh in line with a changed design. A geometry file that could not be opened keeps its reference, so saving again does not lose it. +`view` is view state, not part of the definition: the items the viewport hides (`view.hide`, +`view.isolate`), as `{"hidden": [{"kind": "datum", "name": "Sym"}, …]}` with `kind` one of +`body`, `surface`, `datum`, `boundary_condition`, `load`, `reference_point`, `coupling`. + ## `mesh.bin` Little-endian: