forked from 1b-agent/fieldcraft-upstream
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:
1 parent
d9824de4a5
commit
d7ec7f486a
17 files changed
+431
-25
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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}]}
|
||||
@@ -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.
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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` |
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user