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.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 |
+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",
&["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"),
];
+12 -3
View File
@@ -190,7 +190,7 @@ impl Session {
design: self.sources.design.as_ref().map(stored).transpose()?,
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
@@ -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)
}
+24
View File
@@ -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."),
+5
View File
@@ -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,
+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
/// 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();
}
}
+4 -4
View File
@@ -121,14 +121,14 @@ pub struct Shown {
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
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());
+81 -6
View File
@@ -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<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.
pub type JournalEntry = (String, Value);
@@ -185,6 +254,7 @@ pub struct ModelFile {
pub model: Model,
pub geometry: Geometry,
pub journal: Vec<JournalEntry>,
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<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
/// `journal`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry]) -> Result<Vec<u8>> {
/// `journal`, shown with `view`. The same arguments always give the same bytes.
pub fn write(model: &Model, geometry: &Geometry, journal: &[JournalEntry], view: &View) -> Result<Vec<u8>> {
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<ModelFile> {
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<ModelFile> {
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<Vec<JournalEntry>> {
+25 -3
View File
@@ -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<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.
@@ -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}");
}
+23 -4
View File
@@ -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<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
/// 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));
}
}
+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.
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`.
+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,
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` |
+6 -2
View File
@@ -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: