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 @@
+
+
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: