View state: hide, show and isolate viewport items, saved with the model

view.hide, view.show, view.isolate and view.show_all change which
bodies, surfaces, datums, boundary conditions, loads, reference points
and couplings the viewport draws. The hidden items are view state: the
manifest's `view` keeps them, a change marks the model unsaved, but it
is neither journalled nor undone and leaves results current. Items that
go away are forgotten. Headless pictures leave hidden items out; the
glyph builders take a filter. Tools > View > Show All claims the
commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 23:51:01 -04:00
1 parent d9824de4a5
commit d7ec7f486a
17 files changed
+431 -25

No files matched your search

+1
View File
@@ -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.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.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/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.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/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 | | 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 |
+1
View File
@@ -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}]}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>view.show_all</title>
<g id="Layer_1" data-name="Layer 1">
<path 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="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.4 13 C9.4 11.564 10.564 10.4 12 10.4 C13.436 10.4 14.6 11.564 14.6 13 C14.6 14.436 13.436 15.6 12 15.6 C10.564 15.6 9.4 14.436 9.4 13 Z" fill="#4f9ce8" stroke="#4f9ce8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 2.5 L12 5 M5.5 4.5 L7 6.5 M18.5 4.5 L17 6.5" fill="none" stroke="#f0b44a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.
+9
View File
@@ -580,6 +580,15 @@ pub static CATALOGUE: &[Capability] = &[
"Open a model autosaved by an app that crashed or closed with unsaved changes", "Open a model autosaved by an app that crashed or closed with unsaved changes",
&["file.recovery.list", "file.recovery.discard"], &["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.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"), ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
]; ];
+12 -3
View File
@@ -190,7 +190,7 @@ impl Session {
design: self.sources.design.as_ref().map(stored).transpose()?, design: self.sources.design.as_ref().map(stored).transpose()?,
meshes: self.sources.meshes.iter().map(stored).collect::<fieldcraft_io::Result<_>>()?, meshes: self.sources.meshes.iter().map(stored).collect::<fieldcraft_io::Result<_>>()?,
}; };
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 /// 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)); surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source); sources.meshes.push(source);
} }
let session = let session = Session {
Session { model: file.model, design, surfaces, sources, journal: file.journal, revision: next_revision(), ..Session::default() }; model: file.model,
design,
surfaces,
sources,
journal: file.journal,
revision: next_revision(),
saved_view: file.view.clone(),
view: file.view,
..Session::default()
};
(session, warnings) (session, warnings)
} }
+24
View File
@@ -1392,6 +1392,30 @@ pub const DATUM_POINT: &[Field] = &[
.req() .req()
.def(r#"{"type": "coordinates"}"#), .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] = &[ pub const DATUM_DELETE: &[Field] = &[
reference("name", "Datum", C::Datum).req().help("The Datum to act on, by name."), 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."), flag("cascade", "Delete its partitions").help("Also delete the partitions that cut by it; without it, a datum in use is refused."),
+5
View File
@@ -24,6 +24,7 @@ pub(crate) mod seeds;
pub(crate) mod solve; pub(crate) mod solve;
mod symmetry; mod symmetry;
mod tags; mod tags;
pub(crate) mod view;
use crate::schema::ProblemKind; use crate::schema::ProblemKind;
use fieldcraft_model::MAX_LABEL; use fieldcraft_model::MAX_LABEL;
@@ -47,6 +48,10 @@ pub static ALL: &[CommandSpec] = &[
datum::DATUM_AXIS_EDIT, datum::DATUM_AXIS_EDIT,
datum::DATUM_POINT_EDIT, datum::DATUM_POINT_EDIT,
datum::DATUM_DELETE, datum::DATUM_DELETE,
view::HIDE,
view::SHOW,
view::ISOLATE,
view::SHOW_ALL,
datum::PARTITION, datum::PARTITION,
datum::PARTITION_DELETE, datum::PARTITION_DELETE,
mesh::NODE_ADD, mesh::NODE_ADD,
+219
View File
@@ -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<String> {
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<Item> = 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<Value> {
let items: Vec<Item> = 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::<String>())));
}
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<Item> = 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<Item> = 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::<Vec<_>>(), [&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);
}
}
+9 -1
View File
@@ -238,6 +238,11 @@ pub struct Session {
/// Identifies the open document: it changes when another model, sample or design is opened /// 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`]). /// in place of the current one, never with an edit (see [`Session::opened`]).
opened: u64, 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 { impl Session {
@@ -275,6 +280,8 @@ impl Session {
self.journal.push((id.to_string(), params.clone())); self.journal.push((id.to_string(), params.clone()));
self.revision = next_revision(); self.revision = next_revision();
self.record_undo(before); self.record_undo(before);
// An item deleted (or a body cut into cells) is no longer hidden.
cmd::view::forget_missing(self);
} }
} }
result result
@@ -315,7 +322,7 @@ impl Session {
/// Whether the session has changes its file does not hold. Undoing back to the saved state /// Whether the session has changes its file does not hold. Undoing back to the saved state
/// makes it clean again. /// makes it clean again.
pub fn is_dirty(&self) -> bool { 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; /// 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) { pub(crate) fn mark_saved(&mut self, path: String) {
self.path = Some(path); self.path = Some(path);
self.saved_revision = Some(self.revision); self.saved_revision = Some(self.revision);
self.saved_view = self.view.clone();
} }
} }
+4 -4
View File
@@ -121,14 +121,14 @@ pub struct Shown {
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) { pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
let mut d = DisplayMesh::from_model(&s.model); let mut d = DisplayMesh::from_model(&s.model);
let mut failed = Vec::new(); 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() }))); let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh { match mesh {
Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face), Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face),
Err(e) => failed.push(format!("{}: {e}", body.name)), 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.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face);
} }
(d, failed) (d, failed)
@@ -180,13 +180,13 @@ impl Picture {
fn picture(s: &Session, p: &Palette) -> Picture { fn picture(s: &Session, p: &Palette) -> Picture {
let (base, failed) = display(s); let (base, failed) = display(s);
let glyphs = Glyphs::from_model(&s.model); let glyphs = s.glyphs();
let mut shown = Shown { failed, ..Shown::default() }; 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 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 Some((view, result)) = plot else {
let mut scene = model_scene(&base, p); let mut scene = model_scene(&base, p);
let mut bounds = base.bounds; 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 }; return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs };
}; };
shown.step = Some(view.step.clone()); shown.step = Some(view.step.clone());
+81 -6
View File
@@ -5,8 +5,8 @@
//! //!
//! - `fieldcraft.json`: the manifest. The format tag (`fieldcraft/1`), the program that wrote //! - `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, //! 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 //! each a path and the SHA-256 of its content, opened again when the model is), the view state
//! and SHA-256 of every other part. //! ([`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 //! - `model.json`: the model definition (unit system, sets, materials, sections, reference
//! points, couplings, boundary conditions, loads, steps) as pretty-printed JSON with a stable //! 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. //! key order, so two versions of a model diff line by line.
@@ -28,7 +28,7 @@ pub mod mesh;
pub mod recovery; pub mod recovery;
pub mod roots; pub mod roots;
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
use fieldcraft_model::Model; use fieldcraft_model::Model;
@@ -176,6 +176,75 @@ pub struct Geometry {
pub meshes: Vec<SourceFile>, pub meshes: Vec<SourceFile>,
} }
/// 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<Item>,
}
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. /// One journal entry: a command id and its parameters.
pub type JournalEntry = (String, Value); pub type JournalEntry = (String, Value);
@@ -185,6 +254,7 @@ pub struct ModelFile {
pub model: Model, pub model: Model,
pub geometry: Geometry, pub geometry: Geometry,
pub journal: Vec<JournalEntry>, pub journal: Vec<JournalEntry>,
pub view: View,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -194,6 +264,8 @@ struct Manifest {
generator: String, generator: String,
#[serde(default)] #[serde(default)]
geometry: Geometry, geometry: Geometry,
#[serde(default, skip_serializing_if = "View::is_empty")]
view: View,
parts: BTreeMap<String, Part>, parts: BTreeMap<String, Part>,
} }
@@ -235,11 +307,12 @@ fn journal_text(journal: &[JournalEntry]) -> Result<Vec<u8>> {
} }
/// The bytes of a `.fieldcraft` file holding `model`, built on `geometry` by the commands in /// The bytes of a `.fieldcraft` file holding `model`, built on `geometry` by the commands in
/// `journal`. The same arguments always give the same bytes. /// `journal`, shown with `view`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Result<Vec<u8>> { pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry], view: &View) -> Result<Vec<u8>> {
if geometry.meshes.len() > MAX_MESH_SOURCES { if geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); 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))?; let mut model_json = serde_json::to_vec_pretty(&model.without_mesh()).map_err(|e| json_error(MODEL, e))?;
model_json.push(b'\n'); model_json.push(b'\n');
let mesh = mesh::encode(model)?; let mesh = mesh::encode(model)?;
@@ -249,6 +322,7 @@ pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Re
format: FORMAT.into(), format: FORMAT.into(),
generator: format!("FieldCraft {}", env!("CARGO_PKG_VERSION")), generator: format!("FieldCraft {}", env!("CARGO_PKG_VERSION")),
geometry: geometry.clone(), 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(), 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))?; 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<ModelFile> {
if manifest.geometry.meshes.len() > MAX_MESH_SOURCES { if manifest.geometry.meshes.len() > MAX_MESH_SOURCES {
return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes"))); return Err(Error::Limit(format!("a model refers to at most {MAX_MESH_SOURCES} imported meshes")));
} }
manifest.view.check()?;
if manifest.parts.len() != 3 { if manifest.parts.len() != 3 {
return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}")); return invalid(format!("`{MANIFEST}` must list exactly the parts {MODEL}, {MESH} and {JOURNAL}"));
} }
@@ -347,7 +422,7 @@ pub fn read(bytes: &[u8]) -> Result<ModelFile> {
mesh::decode_into(&mut model, &mesh_bin)?; mesh::decode_into(&mut model, &mesh_bin)?;
drop(mesh_bin); drop(mesh_bin);
let journal = read_journal(&journal_json)?; 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<Vec<JournalEntry>> { fn read_journal(bytes: &[u8]) -> Result<Vec<JournalEntry>> {
+25 -3
View File
@@ -2,7 +2,7 @@
use std::io::{Cursor, Read, Write}; 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 fieldcraft_model::{ElementKind, Entity, GeometryRef, Model, UnitSystem};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -36,11 +36,12 @@ fn sample() -> ModelFile {
}; };
let journal = 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"}))]; 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<u8> { fn bytes_of(f: &ModelFile) -> Vec<u8> {
write(&f.model, &f.geometry, &f.journal).unwrap() write(&f.model, &f.geometry, &f.journal, &f.view).unwrap()
} }
/// The package's parts, by name. /// 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(); mesh::decode_into(&mut back, &good).unwrap();
assert_eq!(back, f.model); 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}");
}
+23 -4
View File
@@ -722,6 +722,16 @@ impl Glyphs {
Glyphs { owners: b.owners, groups: b.groups, marks: b.marks, unit } 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<bool> = 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 /// The model's own symbol unit (a fortieth of its bounding box diagonal): the size symbols
/// are drawn at when no camera gives one. /// are drawn at when no camera gives one.
pub fn model_unit(&self) -> f64 { 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 /// 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 /// (a line through the model) and points (a small cross), each with its name; returns the box
/// around them. /// around them. Only the datums `shown` takes are drawn.
pub fn datums(model: &Model, bounds: &Aabb3, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 { 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 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 (centre, diagonal) = if bounds.is_empty() { (Vec3::ZERO, 1.0) } else { (bounds.center(), bounds.diagonal().max(1e-9)) };
let reach = DATUM_REACH * diagonal; let reach = DATUM_REACH * diagonal;
for (name, datum) in model.datums() { for (name, datum) in model.datums().iter().filter(|(n, _)| shown(n)) {
match *datum { match *datum {
fieldcraft_model::Datum::Plane { point, normal } => { fieldcraft_model::Datum::Plane { point, normal } => {
let (p, n) = (Vec3::from(point), Vec3::from(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)); let (s, _) = drawn_at(&g, &m, &view(GlyphShow::ALL, u));
assert!(s.tris.is_empty() && s.on_top.is_empty()); assert!(s.tris.is_empty() && s.on_top.is_empty());
assert_eq!(s.lines.len(), 4 * 3 * 6); 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)); 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). // Tip on the node, each triangle along its axis (two crossed bases per axis).
let b = bases(&s, Vec3::ZERO); let b = bases(&s, Vec3::ZERO);
@@ -1510,7 +1525,7 @@ mod tests {
bounds.add(Vec3::ZERO); bounds.add(Vec3::ZERO);
bounds.add(Vec3::new(10.0, 10.0, 10.0)); bounds.add(Vec3::new(10.0, 10.0, 10.0));
let mut scene = Scene::default(); 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, // A square with its hatch (4 + 2 × 3 lines), the axis line and the point's three arms,
// each crossing at the point. // each crossing at the point.
assert_eq!(scene.lines.len(), 14); assert_eq!(scene.lines.len(), 14);
@@ -1522,5 +1537,9 @@ mod tests {
// The plane's lines lie on x = 5, centred on the model. // 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!(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()); 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));
} }
} }
+1 -1
View File
@@ -84,7 +84,7 @@ pub fn symbols(
/// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them. /// 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 { 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`. /// The scene for `display` drawn in `style`.
+2 -1
View File
@@ -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, 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. 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 ## 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 — 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 | 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` | | 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 | Undo — Undo the last command that changed the model | ready | `edit.undo` |
| History | Redo — Redo the command undone last | ready | `edit.redo` | | History | Redo — Redo the command undone last | ready | `edit.redo` |
+6 -2
View File
@@ -12,8 +12,8 @@ unzip tool reads them: `unzip -p bracket.fieldcraft model.json`):
| Part | Content | | 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. | | `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 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. | | `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). | | `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. | | `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 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. 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` ## `mesh.bin`
Little-endian: Little-endian: