forked from 1b-agent/fieldcraft-upstream
Browser and viewport: item menu, per-item eyes, Delete key with confirmation
Right-click on any browser item (body, surface, datum, partition, set, material, section, step, boundary condition, load, reference point, coupling, contact, fluid domain) opens its menu: Edit…, Hide/Show, Isolate, Show All, Active in step (boundary conditions and loads) and Delete…. A right click on a datum or a partition's cut face in the viewport opens the same menu. Each item the viewport draws on its own has an eye; hidden items are neither drawn nor picked. Datums and partitions are selectable with properties. Delete… and the Delete key ask first (the prompt lists what uses the item: a datum's partitions are deleted with it, other definitions in use are refused); ui.prompt answers it. Every entry runs the same command as the ribbon and forms. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
d7ec7f486a
commit
08f669acad
12 files changed
+646
-78
No files matched your search
@@ -18,6 +18,7 @@ pub mod cmd;
|
||||
|
||||
pub use cmd::analytic_surfaces;
|
||||
pub use cmd::replace::{MESHERS, Replacement, replacement};
|
||||
pub use fieldcraft_io::{Item as ViewItem, ItemKind};
|
||||
pub mod history;
|
||||
pub mod progress;
|
||||
pub mod requirement;
|
||||
|
||||
@@ -186,7 +186,13 @@ fn picture(s: &Session, p: &Palette) -> Picture {
|
||||
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, &|n| s.shows(fieldcraft_io::ItemKind::Datum, n), &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());
|
||||
|
||||
@@ -1148,7 +1148,8 @@ mod tests {
|
||||
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));
|
||||
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());
|
||||
|
||||
+153
-56
@@ -2,8 +2,11 @@
|
||||
//! partitions) and Mesh with eye toggles, then the model's
|
||||
//! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions)
|
||||
//! with counts (Loads & BCs and Interactions with eye toggles for their viewport symbols), and
|
||||
//! the solved steps' results. Clicking a definition selects it (its properties show on the
|
||||
//! right), a double click opens its edit form, and clicking a result step shows it.
|
||||
//! the solved steps' results. Clicking a definition, datum or partition selects it (its
|
||||
//! properties show on the right), a double click opens its edit form, and clicking a result step
|
||||
//! shows it. Each item the viewport draws on its own (bodies, surfaces, datums, boundary
|
||||
//! conditions, loads, reference points, couplings) has its own eye, and a right click on an item
|
||||
//! opens its menu ([`crate::menu`]: edit, hide, isolate, active in step, delete).
|
||||
//!
|
||||
//! The tree also shows where the model is in the analysis workflow
|
||||
//! ([`fieldcraft_engine::progress`]): each top-level node that is a setup stage carries a mark at
|
||||
@@ -24,6 +27,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::icons::{self, Look};
|
||||
use crate::menu::{self, Action, Target};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The browser's width.
|
||||
@@ -161,9 +165,27 @@ pub enum Def {
|
||||
Coupling,
|
||||
Contact,
|
||||
FluidDomain,
|
||||
/// A datum plane, axis or point.
|
||||
Datum,
|
||||
Partition,
|
||||
}
|
||||
|
||||
impl Def {
|
||||
pub const ALL: [Def; 12] = [
|
||||
Def::Set,
|
||||
Def::Material,
|
||||
Def::Section,
|
||||
Def::Step,
|
||||
Def::BoundaryCondition,
|
||||
Def::Load,
|
||||
Def::ReferencePoint,
|
||||
Def::Coupling,
|
||||
Def::Contact,
|
||||
Def::FluidDomain,
|
||||
Def::Datum,
|
||||
Def::Partition,
|
||||
];
|
||||
|
||||
/// The kind of setup symbols a definition of this kind draws, if any.
|
||||
pub fn symbols(self) -> Option<fieldcraft_render::glyphs::Definition> {
|
||||
use fieldcraft_render::glyphs::Definition;
|
||||
@@ -174,7 +196,7 @@ impl Def {
|
||||
Def::Coupling => Definition::Coupling,
|
||||
Def::Contact => Definition::Contact,
|
||||
Def::Step => Definition::Step,
|
||||
Def::Set | Def::Material | Def::Section | Def::FluidDomain => return None,
|
||||
Def::Set | Def::Material | Def::Section | Def::FluidDomain | Def::Datum | Def::Partition => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,6 +213,8 @@ impl Def {
|
||||
Def::Coupling => Collection::Coupling,
|
||||
Def::Contact => Collection::Contact,
|
||||
Def::FluidDomain => Collection::FluidDomain,
|
||||
Def::Datum => Collection::Datum,
|
||||
Def::Partition => Collection::Partition,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +231,8 @@ impl Def {
|
||||
Def::Coupling => "coupling",
|
||||
Def::Contact => "contact",
|
||||
Def::FluidDomain => "fluid_domain",
|
||||
Def::Datum => "datum",
|
||||
Def::Partition => "partition",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +248,8 @@ impl Def {
|
||||
Def::Coupling => "Coupling",
|
||||
Def::Contact => "Contact",
|
||||
Def::FluidDomain => "Fluid Domain",
|
||||
Def::Datum => "Datum",
|
||||
Def::Partition => "Partition",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,19 +266,75 @@ impl Def {
|
||||
Def::Coupling => "model.coupling.create",
|
||||
Def::Contact => "interaction.contact.pair",
|
||||
Def::FluidDomain => "model.fluid_domain.create",
|
||||
Def::Datum => "geometry.datum",
|
||||
Def::Partition => "geometry.partition",
|
||||
}
|
||||
}
|
||||
|
||||
/// The command of this kind with `verb` (`create`, `edit`, `delete`); contacts are
|
||||
/// interactions, created as pairs or general contact.
|
||||
/// interactions, created as pairs or general contact; datums and partitions are geometry
|
||||
/// (a datum's edit command depends on its kind: [`Def::edit`]).
|
||||
pub fn command(self, verb: &str) -> String {
|
||||
match (self, verb) {
|
||||
(Def::Contact, "create") => "interaction.contact.pair".into(),
|
||||
(Def::Contact, _) => format!("interaction.contact.{verb}"),
|
||||
(Def::Datum | Def::Partition, "create") => self.icon().into(),
|
||||
(Def::Datum | Def::Partition, _) => format!("geometry.{}.{verb}", self.stem()),
|
||||
_ => format!("model.{}.{verb}", self.stem()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command that edits definition `name` (without building its parameters); `None` when
|
||||
/// it is gone or cannot be edited.
|
||||
pub fn edit_command(self, m: &Model, name: &str) -> Option<String> {
|
||||
match self {
|
||||
Def::Partition => None,
|
||||
Def::Datum => m.datum(name).map(|d| fieldcraft_engine::cmd::datum_edit_params(name, d).0.to_string()),
|
||||
_ => self.exists(m, name).then(|| self.command("edit")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command that edits definition `name` and its parameters as it is (its edit form,
|
||||
/// filled in); `None` when it is gone or cannot be edited (a partition).
|
||||
pub fn edit(self, m: &Model, name: &str) -> Option<(String, Value)> {
|
||||
match self {
|
||||
Def::Partition => None,
|
||||
Def::Datum => m.datum(name).map(|d| {
|
||||
let (command, params) = fieldcraft_engine::cmd::datum_edit_params(name, d);
|
||||
(command.to_string(), params)
|
||||
}),
|
||||
_ => definition(m, self, name).map(|v| (self.command("edit"), v)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport item it is, for the kinds the viewport draws one by one (and can hide).
|
||||
pub fn item_kind(self) -> Option<fieldcraft_engine::ItemKind> {
|
||||
use fieldcraft_engine::ItemKind as K;
|
||||
match self {
|
||||
Def::BoundaryCondition => Some(K::BoundaryCondition),
|
||||
Def::Load => Some(K::Load),
|
||||
Def::ReferencePoint => Some(K::ReferencePoint),
|
||||
Def::Coupling => Some(K::Coupling),
|
||||
Def::Datum => Some(K::Datum),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The definitions that refer to `name`, as `kind \`name\`` strings: what refuses its
|
||||
/// deletion (a datum's partitions are deleted with it instead, when confirmed).
|
||||
pub fn users(self, m: &Model, name: &str) -> Vec<String> {
|
||||
match self {
|
||||
Def::Set => m.set_users(name),
|
||||
Def::Material => m.material_users(name),
|
||||
Def::BoundaryCondition => m.boundary_condition_users(name),
|
||||
Def::Load => m.load_users(name),
|
||||
Def::ReferencePoint => m.reference_point_users(name),
|
||||
Def::FluidDomain => m.fluid_domain_users(name),
|
||||
Def::Datum => m.datum_users(name),
|
||||
Def::Section | Def::Step | Def::Coupling | Def::Contact | Def::Partition => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many definitions of this kind the model has.
|
||||
pub fn count(self, m: &Model) -> usize {
|
||||
match self {
|
||||
@@ -264,6 +348,8 @@ impl Def {
|
||||
Def::Coupling => m.couplings().len(),
|
||||
Def::Contact => m.contacts().len(),
|
||||
Def::FluidDomain => m.fluid_domains().len(),
|
||||
Def::Datum => m.datums().len(),
|
||||
Def::Partition => m.partitions().len(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +366,8 @@ impl Def {
|
||||
Def::Coupling => m.couplings().contains_key(name),
|
||||
Def::Contact => m.contacts().contains_key(name),
|
||||
Def::FluidDomain => m.fluid_domain(name).is_some(),
|
||||
Def::Datum => m.datum(name).is_some(),
|
||||
Def::Partition => m.partitions().iter().any(|p| p.name == name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +384,8 @@ impl Def {
|
||||
Def::Coupling => m.couplings().keys().cloned().collect(),
|
||||
Def::Contact => m.contacts().keys().cloned().collect(),
|
||||
Def::FluidDomain => m.fluid_domains().keys().cloned().collect(),
|
||||
Def::Datum => m.datums().keys().cloned().collect(),
|
||||
Def::Partition => m.partitions().iter().map(|p| p.name.clone()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,6 +403,8 @@ pub fn definition(m: &Model, kind: Def, name: &str) -> Option<Value> {
|
||||
Def::Coupling => serde_json::to_value(m.couplings().get(name)?),
|
||||
Def::Contact => serde_json::to_value(m.contacts().get(name)?),
|
||||
Def::FluidDomain => serde_json::to_value(m.fluid_domain(name)?),
|
||||
Def::Datum => serde_json::to_value(m.datum(name)?),
|
||||
Def::Partition => serde_json::to_value(m.partitions().iter().find(|p| p.name == name)?),
|
||||
}
|
||||
.ok()?;
|
||||
let mut v = if v.is_object() { v } else { json!({"value": v}) };
|
||||
@@ -351,6 +443,8 @@ struct RowResp {
|
||||
double: bool,
|
||||
fold: bool,
|
||||
eye: bool,
|
||||
/// The row's response, for its context menu (`None` when the row was not drawn).
|
||||
response: Option<egui::Response>,
|
||||
}
|
||||
|
||||
fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
@@ -446,9 +540,39 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, row.label));
|
||||
out.clicked = resp.clicked() && click_on == 0;
|
||||
out.double = resp.double_clicked() && click_on == 0;
|
||||
out.response = Some(resp);
|
||||
out
|
||||
}
|
||||
|
||||
/// A row for one item: its eye when the viewport draws it on its own, selection by a click and
|
||||
/// its edit form by a double click (definitions, datums, partitions), and its menu by a right
|
||||
/// click.
|
||||
fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, target: Target, acts: &mut Vec<Act>) {
|
||||
if let Some(item) = target.item() {
|
||||
row.eye = Some(app.session.shows(item.kind, &item.name));
|
||||
}
|
||||
if let Target::Def(kind, name) = &target {
|
||||
row.selected = app.selected.as_ref().is_some_and(|(k, n)| k == kind && n == name);
|
||||
}
|
||||
let r = draw_row(app, ui, id, &row);
|
||||
if let Some(resp) = &r.response {
|
||||
resp.context_menu(|ui| {
|
||||
if let Some(a) = menu::show(app, ui, &target) {
|
||||
acts.push(Act::Menu(target.clone(), a));
|
||||
}
|
||||
});
|
||||
}
|
||||
if r.eye {
|
||||
acts.push(Act::Menu(target, if row.eye == Some(true) { Action::Hide } else { Action::Show }));
|
||||
} else if let Target::Def(kind, name) = target {
|
||||
if r.double {
|
||||
acts.push(Act::Edit(kind, name));
|
||||
} else if r.clicked {
|
||||
acts.push(Act::Select(kind, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ✓ done (success), → next (accent), ⚠ blocked (muted); nothing for the rest.
|
||||
fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) {
|
||||
let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height());
|
||||
@@ -513,6 +637,8 @@ enum Act {
|
||||
ShowResult(String),
|
||||
/// A ghost row's command.
|
||||
Start(&'static str),
|
||||
/// An item menu's entry, or an item's eye.
|
||||
Menu(Target, Action),
|
||||
}
|
||||
|
||||
/// A seed in a few words: `8 exact`, `2.5 min, bias 4 end`.
|
||||
@@ -557,11 +683,12 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
app.selected = if app.selected.as_ref() == Some(&(kind, name.clone())) { None } else { Some((kind, name)) };
|
||||
}
|
||||
Act::Edit(kind, name) => {
|
||||
if let Some(v) = definition(&app.session.model, kind, &name) {
|
||||
if let Some((command, params)) = kind.edit(&app.session.model, &name) {
|
||||
app.selected = Some((kind, name));
|
||||
app.open_form(&kind.command("edit"), v);
|
||||
app.open_form(&command, params);
|
||||
}
|
||||
}
|
||||
Act::Menu(target, action) => menu::apply(app, target, action),
|
||||
Act::ShowResult(step) => {
|
||||
// Errors show in the status bar.
|
||||
let _ = app.run("results.deformed", json!({"step": step}));
|
||||
@@ -641,40 +768,29 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
&Row { depth: 2, icon: Some("geometry.open"), label: &label, note: Some(format!("{bodies} bodies")), ..Default::default() },
|
||||
);
|
||||
for (i, b) in d.bodies.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("fc_row_body", i)),
|
||||
&Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() },
|
||||
);
|
||||
let row = Row { depth: 3, icon: Some("geometry.bodies"), label: &b.name, ..Default::default() };
|
||||
item_row(app, ui, egui::Id::new(("fc_row_body", i)), row, Target::Body(b.name.clone()), acts);
|
||||
}
|
||||
}
|
||||
for (i, s) in surfaces.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("fc_row_surface", i)),
|
||||
&Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() },
|
||||
);
|
||||
let row = Row { depth: 2, icon: Some("geometry.import.mesh"), label: s, ..Default::default() };
|
||||
item_row(app, ui, egui::Id::new(("fc_row_surface", i)), row, Target::Surface(s.clone()), acts);
|
||||
}
|
||||
// Datum planes and axes, then the partitions in the order they cut.
|
||||
// Datum planes, axes and points, then the partitions in the order they cut.
|
||||
let datums: Vec<(String, &'static str)> = app.session.model.datums().iter().map(|(n, d)| (n.clone(), d.kind())).collect();
|
||||
for (i, (n, kind)) in datums.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("fc_row_datum", i)),
|
||||
&Row { depth: 2, icon: Some("geometry.datum"), label: n, note: Some((*kind).into()), ..Default::default() },
|
||||
);
|
||||
let icon = match *kind {
|
||||
"axis" => "geometry.datum.axis",
|
||||
"point" => "geometry.datum.point",
|
||||
_ => "geometry.datum",
|
||||
};
|
||||
let row = Row { depth: 2, icon: Some(icon), label: n, note: Some((*kind).into()), ..Default::default() };
|
||||
item_row(app, ui, egui::Id::new(("fc_row_datum", i)), row, Target::Def(Def::Datum, n.clone()), acts);
|
||||
}
|
||||
let partitions: Vec<(String, String)> = app.session.model.partitions().iter().map(|p| (p.name.clone(), p.datum.clone())).collect();
|
||||
for (i, (n, datum)) in partitions.iter().enumerate() {
|
||||
draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("fc_row_partition", i)),
|
||||
&Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() },
|
||||
);
|
||||
let row = Row { depth: 2, icon: Some("geometry.partition"), label: n, note: Some(format!("by {datum}")), ..Default::default() };
|
||||
item_row(app, ui, egui::Id::new(("fc_row_partition", i)), row, Target::Def(Def::Partition, n.clone()), acts);
|
||||
}
|
||||
}
|
||||
ghost_rows(app, ui, &stages, "geometry", acts);
|
||||
@@ -808,18 +924,9 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
}
|
||||
if open(app, key) {
|
||||
for (kind, name) in items {
|
||||
let selected = app.selected.as_ref() == Some(&(kind, name.clone()));
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
egui::Id::new(("fc_row_def", kind.stem(), &name)),
|
||||
&Row { depth: 2, icon: Some(kind.icon()), label: &name, selected, ..Default::default() },
|
||||
);
|
||||
if r.double {
|
||||
acts.push(Act::Edit(kind, name));
|
||||
} else if r.clicked {
|
||||
acts.push(Act::Select(kind, name));
|
||||
}
|
||||
let id = egui::Id::new(("fc_row_def", kind.stem(), &name));
|
||||
let row = Row { depth: 2, icon: Some(kind.icon()), label: &name, ..Default::default() };
|
||||
item_row(app, ui, id, row, Target::Def(kind, name.clone()), acts);
|
||||
}
|
||||
}
|
||||
ghost_rows(app, ui, &stages, key, acts);
|
||||
@@ -947,19 +1054,9 @@ mod tests {
|
||||
assert_eq!(steel["name"], "Steel");
|
||||
assert!(steel.get("elastic").is_some(), "{steel}");
|
||||
assert!(definition(m, Def::Load, "nope").is_none());
|
||||
for kind in [
|
||||
Def::Set,
|
||||
Def::Material,
|
||||
Def::Section,
|
||||
Def::Step,
|
||||
Def::BoundaryCondition,
|
||||
Def::Load,
|
||||
Def::ReferencePoint,
|
||||
Def::Coupling,
|
||||
Def::Contact,
|
||||
Def::FluidDomain,
|
||||
] {
|
||||
for verb in ["create", "edit", "delete"] {
|
||||
for kind in Def::ALL {
|
||||
let verbs: &[&str] = if kind == Def::Partition { &["create", "delete"] } else { &["create", "edit", "delete"] };
|
||||
for verb in verbs {
|
||||
assert!(fieldcraft_engine::find_command(&kind.command(verb)).is_some(), "{kind:?} {verb}");
|
||||
}
|
||||
assert!(fieldcraft_engine::catalogue::find(kind.icon()).is_some());
|
||||
|
||||
@@ -253,16 +253,19 @@ fn properties(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens, kind: Def, name
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let (mut edit, mut delete) = (false, false);
|
||||
let editing = kind.edit_command(&app.session.model, name);
|
||||
ui.horizontal(|ui| {
|
||||
edit = ui.button("Edit…").on_hover_text(kind.command("edit")).clicked();
|
||||
delete = ui.button("Delete").on_hover_text(kind.command("delete")).clicked();
|
||||
if let Some(command) = &editing {
|
||||
edit = ui.button("Edit…").on_hover_text(command).clicked();
|
||||
}
|
||||
delete = ui.button("Delete…").on_hover_text(format!("{} (Delete key)", kind.command("delete"))).clicked();
|
||||
});
|
||||
if deselect {
|
||||
app.selected = None;
|
||||
} else if edit && let Some(v) = browser::definition(&app.session.model, kind, name) {
|
||||
app.open_form(&kind.command("edit"), v);
|
||||
} else if delete && app.run(&kind.command("delete"), json!({"name": name})).is_ok() {
|
||||
app.selected = None;
|
||||
} else if edit && let Some((command, params)) = kind.edit(&app.session.model, name) {
|
||||
app.open_form(&command, params);
|
||||
} else if delete {
|
||||
app.ask_delete(kind, name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
crate::files::Prompt::Samples => json!("samples"),
|
||||
crate::files::Prompt::Design(p) => json!({"design": p}),
|
||||
crate::files::Prompt::ReplaceMesh(r) => json!({"replace_mesh": r.question, "warnings": r.warnings}),
|
||||
crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}),
|
||||
}),
|
||||
"recent": app.recent.paths,
|
||||
"version": crate::VERSION,
|
||||
@@ -303,6 +304,35 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
ok(json!(app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error}))))
|
||||
}
|
||||
"ui.item" => {
|
||||
// An item menu's entry, as a right click on the item's browser row and a click on the
|
||||
// entry do (crate::menu).
|
||||
let kind = s("kind");
|
||||
let name = s("name").unwrap_or_default().to_string();
|
||||
let target = match kind {
|
||||
Some("body") => crate::menu::Target::Body(name),
|
||||
Some("surface") => crate::menu::Target::Surface(name),
|
||||
Some(k) => match serde_json::from_value::<crate::browser::Def>(json!(k)) {
|
||||
Ok(def) => crate::menu::Target::Def(def, name),
|
||||
Err(_) => return err(format!("unknown item kind `{}`", k.chars().take(40).collect::<String>())),
|
||||
},
|
||||
None => return err("missing `kind`"),
|
||||
};
|
||||
let action = match (s("action"), s("step")) {
|
||||
(Some("edit"), _) => crate::menu::Action::Edit,
|
||||
(Some("hide"), _) => crate::menu::Action::Hide,
|
||||
(Some("show"), _) => crate::menu::Action::Show,
|
||||
(Some("isolate"), _) => crate::menu::Action::Isolate,
|
||||
(Some("show_all"), _) => crate::menu::Action::ShowAll,
|
||||
(Some("delete"), _) => crate::menu::Action::Delete,
|
||||
(Some(a @ ("activate" | "deactivate")), Some(step)) => {
|
||||
crate::menu::Action::Active { step: step.to_string(), active: a == "activate" }
|
||||
}
|
||||
(a, _) => return err(format!("`action` {a:?}: edit, hide, show, isolate, show_all, delete, or activate/deactivate with `step`")),
|
||||
};
|
||||
crate::menu::apply(app, target, action);
|
||||
ok(json!({"status": app.status, "prompt": app.prompt.is_some(), "dialog": app.dialog.as_ref().map(|d| d.command)}))
|
||||
}
|
||||
"ui.prompt" => {
|
||||
let Some(prompt) = app.prompt.clone() else { return err("no prompt is open") };
|
||||
match (prompt, s("answer")) {
|
||||
@@ -328,6 +358,10 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
app.answer_replace_mesh(a == "replace");
|
||||
ok(json!({"busy": app.busy()}))
|
||||
}
|
||||
(crate::files::Prompt::Delete(_), Some(a @ ("delete" | "cancel"))) => {
|
||||
app.answer_delete(a == "delete");
|
||||
ok(json!({"status": app.status}))
|
||||
}
|
||||
(crate::files::Prompt::Samples, Some("open")) => match s("name") {
|
||||
Some(name) => {
|
||||
app.prompt = None;
|
||||
@@ -348,7 +382,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
None => err("missing `id` (from file.recovery.list)"),
|
||||
},
|
||||
(_, a) => err(format!(
|
||||
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; recover: recover with id, close; samples: open with name, close)"
|
||||
"`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -853,6 +887,7 @@ mod tests {
|
||||
"ui.capture",
|
||||
"ui.toolbar",
|
||||
"ui.prompt",
|
||||
"ui.item",
|
||||
"ui.select",
|
||||
"ui.selection",
|
||||
"ui.fullscreen",
|
||||
|
||||
@@ -272,6 +272,9 @@ pub enum Prompt {
|
||||
/// A design (its path) has none of the bodies the model's mesh and sets are on: start a new
|
||||
/// model with it, or keep the model. The opened session waits in [`ModelApp::held`].
|
||||
Design(String),
|
||||
/// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and
|
||||
/// the deletion refused or (a datum's partitions) done with it.
|
||||
Delete(crate::menu::DeleteAsk),
|
||||
}
|
||||
|
||||
/// The answer to [`Prompt::Design`].
|
||||
@@ -871,6 +874,32 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
|
||||
app.answer_design(a);
|
||||
}
|
||||
}
|
||||
Some(Prompt::Delete(ask)) => {
|
||||
let mut answer = None;
|
||||
crate::frame::window(ctx, "Delete", crate::frame::Width::Normal).id(egui::Id::new("fc_delete")).show(ctx, |ui| {
|
||||
ui.label(ask.question());
|
||||
if ask.allowed() && !ask.users.is_empty() {
|
||||
ui.label(egui::RichText::new("They are deleted too; Undo brings everything back.").color(t.text_dim));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ask.allowed() && ui.button("Delete").clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui.button(if ask.allowed() { "Cancel" } else { "Close" }).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(false);
|
||||
} else if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
answer = Some(ask.allowed());
|
||||
}
|
||||
if let Some(delete) = answer {
|
||||
app.answer_delete(delete);
|
||||
}
|
||||
}
|
||||
Some(Prompt::ReplaceMesh(r)) => {
|
||||
let mut answer = None;
|
||||
crate::frame::window(ctx, "Replace mesh", crate::frame::Width::Normal).id(egui::Id::new("fc_replace_mesh")).show(ctx, |ui| {
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod frame;
|
||||
pub mod framing;
|
||||
pub mod gpu;
|
||||
pub mod icons;
|
||||
pub mod menu;
|
||||
pub mod navigation;
|
||||
pub mod palette;
|
||||
pub mod reply;
|
||||
@@ -276,6 +277,8 @@ pub struct Viewport {
|
||||
pub drag: Option<NavMode>,
|
||||
/// The point the current orbit turns about (drawn as a marker while orbiting).
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
/// The item the open context menu was opened on (a datum or a partition), if any.
|
||||
pub menu_target: Option<menu::Target>,
|
||||
}
|
||||
|
||||
/// A probed node: its values as lines of text, for the step, mode and frame they were read from.
|
||||
@@ -518,7 +521,7 @@ impl ModelApp {
|
||||
/// label (the engine's message names its key, which agents need).
|
||||
fn run_form(&mut self, id: &str, params: Value, form: bool) -> Result<Value, Failure> {
|
||||
self.ensure_idle().map_err(|message| Failure { message, refused: None })?;
|
||||
let revision = self.session.revision();
|
||||
let (revision, view) = (self.session.revision(), self.session.view.clone());
|
||||
let label = command_label(id);
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| match e {
|
||||
EngineError::Param(p) => {
|
||||
@@ -529,7 +532,8 @@ impl ModelApp {
|
||||
e => Failure { message: e.to_string(), refused: None },
|
||||
});
|
||||
let changed = self.session.revision() != revision;
|
||||
if changed {
|
||||
// A view change (hide, show) draws the model again, as an edit does.
|
||||
if changed || self.session.view != view {
|
||||
self.model_changed();
|
||||
}
|
||||
// The results display commands reply with what is shown, which the viewport shows.
|
||||
@@ -835,13 +839,14 @@ impl ModelApp {
|
||||
}
|
||||
}
|
||||
self.pick.names.clear();
|
||||
for (names, mesh) in &self.bodies.1 {
|
||||
// Hidden bodies and surfaces are neither drawn nor picked.
|
||||
for (names, mesh) in self.bodies.1.iter().filter(|(n, _)| self.session.shows(fieldcraft_engine::ItemKind::Body, &n.name)) {
|
||||
d.add_curves(&mesh.edges);
|
||||
d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face);
|
||||
self.pick.names.push(names.clone());
|
||||
}
|
||||
// An imported surface's faces are named by their tags.
|
||||
for s in &self.session.surfaces {
|
||||
for s in self.session.surfaces.iter().filter(|s| self.session.shows(fieldcraft_engine::ItemKind::Surface, &s.name)) {
|
||||
d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face);
|
||||
self.pick.names.push(selection::BodyNames {
|
||||
name: s.name.clone(),
|
||||
@@ -851,7 +856,7 @@ impl ModelApp {
|
||||
}
|
||||
let was_empty = self.display.1.bounds.is_empty();
|
||||
self.display = (Some(self.revision), d);
|
||||
self.glyphs = Glyphs::from_model(&self.session.model);
|
||||
self.glyphs = self.session.glyphs();
|
||||
if was_empty && !self.display.1.bounds.is_empty() {
|
||||
self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Fit);
|
||||
}
|
||||
@@ -915,7 +920,7 @@ impl ModelApp {
|
||||
};
|
||||
let mut datums = Aabb3::EMPTY;
|
||||
if show.geometry && self.plot.1.is_none() {
|
||||
datums = scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t);
|
||||
datums = scene::datums(&mut s, &self.session, &self.display.1.bounds, &t);
|
||||
scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
|
||||
}
|
||||
self.scene = (Some(key), s, datums);
|
||||
@@ -1337,8 +1342,8 @@ impl ModelApp {
|
||||
}
|
||||
|
||||
/// Undo (Ctrl/Cmd+Z), redo (Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y), open (Ctrl/Cmd+O), save
|
||||
/// (Ctrl/Cmd+S) and save as (Ctrl/Cmd+Shift+S), unless a text field has the keyboard (it
|
||||
/// undoes its own typing).
|
||||
/// (Ctrl/Cmd+S), save as (Ctrl/Cmd+Shift+S) and delete the browser's selection (Delete),
|
||||
/// unless a text field has the keyboard (it undoes its own typing).
|
||||
fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
if ctx.memory(|m| m.focused().is_some()) {
|
||||
return;
|
||||
@@ -1374,6 +1379,14 @@ impl ModelApp {
|
||||
if open {
|
||||
self.open_dialog(None);
|
||||
}
|
||||
// Delete (or Backspace) deletes the definition selected in the browser, after asking.
|
||||
if self.prompt.is_none()
|
||||
&& let Some((kind, name)) = self.selected.clone()
|
||||
&& ctx
|
||||
.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
|
||||
{
|
||||
self.ask_delete(kind, &name);
|
||||
}
|
||||
// The command search: S (as in SolidCraft) or Cmd/Ctrl+K.
|
||||
let search = ctx.input_mut(|i| {
|
||||
i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::K)) || i.consume_key(egui::Modifiers::NONE, egui::Key::S)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! The item menu: what a right click on a browser row offers, and on a datum or a partition's cut
|
||||
//! face in the viewport. Edit… opens the item's edit form, Hide/Show and Isolate change the view
|
||||
//! (`view.*`), Active in step switches a boundary condition or load on or off per step
|
||||
//! (`model.step.activate`), and Delete… asks first ([`crate::files::Prompt::Delete`]): it lists
|
||||
//! the definitions that use the item, and refuses, or for a datum deletes its partitions with it.
|
||||
//! Every entry runs the same command as the ribbon, the forms and scripts.
|
||||
|
||||
use fieldcraft_engine::{ItemKind, ViewItem};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ModelApp;
|
||||
use crate::browser::Def;
|
||||
|
||||
/// What the menu is for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A body of the open design.
|
||||
Body(String),
|
||||
/// An imported surface.
|
||||
Surface(String),
|
||||
/// A model definition, datum or partition.
|
||||
Def(Def, String),
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// The viewport item it is, when the viewport draws it on its own.
|
||||
pub fn item(&self) -> Option<ViewItem> {
|
||||
let (kind, name) = match self {
|
||||
Target::Body(n) => (ItemKind::Body, n),
|
||||
Target::Surface(n) => (ItemKind::Surface, n),
|
||||
Target::Def(def, n) => (def.item_kind()?, n),
|
||||
};
|
||||
Some(ViewItem { kind, name: name.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu entry chosen.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Edit,
|
||||
Hide,
|
||||
Show,
|
||||
Isolate,
|
||||
ShowAll,
|
||||
/// Switch a boundary condition or load on or off in a step.
|
||||
Active {
|
||||
step: String,
|
||||
active: bool,
|
||||
},
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Whether `target` is active in each step (boundary conditions and loads; empty for the rest).
|
||||
fn activity(app: &ModelApp, target: &Target) -> Vec<(String, bool)> {
|
||||
let steps = app.session.model.steps();
|
||||
match target {
|
||||
Target::Def(Def::BoundaryCondition, n) => steps.iter().map(|s| (s.name.clone(), s.boundary_conditions.contains(n))).collect(),
|
||||
Target::Def(Def::Load, n) => steps.iter().map(|s| (s.name.clone(), s.loads.contains(n))).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the menu's entries for `target`; the entry clicked.
|
||||
pub fn show(app: &ModelApp, ui: &mut egui::Ui, target: &Target) -> Option<Action> {
|
||||
let mut chosen = None;
|
||||
let mut entry = |ui: &mut egui::Ui, enabled: bool, label: &str, tip: &str, action: Action| {
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).on_hover_text(tip).clicked() {
|
||||
chosen = Some(action);
|
||||
}
|
||||
};
|
||||
if let Target::Def(def, name) = target {
|
||||
let edit = def.edit_command(&app.session.model, name);
|
||||
let tip = edit.clone().unwrap_or_else(|| "Not editable: delete it and make it again".to_string());
|
||||
entry(ui, edit.is_some(), "Edit…", &tip, Action::Edit);
|
||||
}
|
||||
if let Some(item) = target.item() {
|
||||
if app.session.shows(item.kind, &item.name) {
|
||||
entry(ui, true, "Hide", "view.hide", Action::Hide);
|
||||
} else {
|
||||
entry(ui, true, "Show", "view.show", Action::Show);
|
||||
}
|
||||
entry(ui, true, "Isolate", "view.isolate: show only this", Action::Isolate);
|
||||
}
|
||||
if !app.session.view.is_empty() {
|
||||
entry(ui, true, "Show All", "view.show_all", Action::ShowAll);
|
||||
}
|
||||
let steps = activity(app, target);
|
||||
if !steps.is_empty() {
|
||||
ui.menu_button("Active in step", |ui| {
|
||||
for (step, on) in steps {
|
||||
let mut checked = on;
|
||||
if ui.checkbox(&mut checked, &step).on_hover_text("model.step.activate").changed() {
|
||||
chosen = Some(Action::Active { step, active: checked });
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Target::Def(def, _) = target {
|
||||
ui.separator();
|
||||
let command = def.command("delete");
|
||||
if ui.button("Delete…").on_hover_text(format!("{command} (Delete key)")).clicked() {
|
||||
chosen = Some(Action::Delete);
|
||||
}
|
||||
}
|
||||
if chosen.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Do what the menu entry chosen asks.
|
||||
pub fn apply(app: &mut ModelApp, target: Target, action: Action) {
|
||||
let items = |t: &Target| json!({"items": t.item().into_iter().collect::<Vec<_>>()});
|
||||
// Errors show in the status bar.
|
||||
let _ = match action {
|
||||
Action::Edit => {
|
||||
if let Target::Def(def, name) = target
|
||||
&& let Some((command, params)) = def.edit(&app.session.model, &name)
|
||||
{
|
||||
app.selected = Some((def, name));
|
||||
app.open_form(&command, params);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Action::Hide => app.run("view.hide", items(&target)),
|
||||
Action::Show => app.run("view.show", items(&target)),
|
||||
Action::Isolate => app.run("view.isolate", items(&target)),
|
||||
Action::ShowAll => app.run("view.show_all", json!({})),
|
||||
Action::Active { step, active } => match target {
|
||||
Target::Def(def @ (Def::BoundaryCondition | Def::Load), name) => {
|
||||
let key = if def == Def::Load { "loads" } else { "boundary_conditions" };
|
||||
app.run("model.step.activate", json!({"step": step, key: [name], "active": active}))
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
},
|
||||
Action::Delete => {
|
||||
if let Target::Def(def, name) = target {
|
||||
app.ask_delete(def, &name);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The question a deletion asks ([`crate::files::Prompt::Delete`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeleteAsk {
|
||||
pub kind: Def,
|
||||
pub name: String,
|
||||
/// The definitions that use it.
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
impl DeleteAsk {
|
||||
/// Whether deleting goes ahead with its users: a datum's partitions are deleted with it; any
|
||||
/// other definition in use is refused.
|
||||
pub fn cascades(&self) -> bool {
|
||||
self.kind == Def::Datum
|
||||
}
|
||||
|
||||
/// Whether it can be deleted at all.
|
||||
pub fn allowed(&self) -> bool {
|
||||
self.users.is_empty() || self.cascades()
|
||||
}
|
||||
|
||||
/// The question, in words.
|
||||
pub fn question(&self) -> String {
|
||||
let what = format!("{} `{}`", self.kind.label().to_lowercase(), self.name);
|
||||
match (self.users.is_empty(), self.cascades()) {
|
||||
(true, _) => format!("Delete {what}?"),
|
||||
(false, true) => format!("Delete {what} and what uses it: {}?", self.users.join(", ")),
|
||||
(false, false) => format!("{what} is used by {}: change or delete those first.", self.users.join(", ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelApp {
|
||||
/// Ask before deleting definition `name` of `kind` (the item menu's Delete…, the Delete key).
|
||||
pub fn ask_delete(&mut self, kind: Def, name: &str) {
|
||||
if !kind.exists(&self.session.model, name) {
|
||||
return;
|
||||
}
|
||||
let users = kind.users(&self.session.model, name);
|
||||
self.prompt = Some(crate::files::Prompt::Delete(DeleteAsk { kind, name: name.to_string(), users }));
|
||||
}
|
||||
|
||||
/// Answer [`crate::files::Prompt::Delete`]: delete (with its partitions, for a datum in use)
|
||||
/// or leave it.
|
||||
pub fn answer_delete(&mut self, delete: bool) {
|
||||
let Some(crate::files::Prompt::Delete(ask)) = self.prompt.clone() else { return };
|
||||
self.prompt = None;
|
||||
if !delete || !ask.allowed() {
|
||||
return;
|
||||
}
|
||||
let mut params = json!({"name": ask.name});
|
||||
if ask.cascades() && !ask.users.is_empty() {
|
||||
params["cascade"] = json!(true);
|
||||
}
|
||||
if self.run(&ask.kind.command("delete"), params).is_ok() && self.selected.as_ref() == Some(&(ask.kind, ask.name)) {
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_render::StandardView;
|
||||
|
||||
use super::*;
|
||||
use crate::viewport;
|
||||
|
||||
/// The plate design open in an app with a datum plane `Mid` cutting it (partition `Half`)
|
||||
/// and a datum point `P`, framed in an 800 × 600 viewport.
|
||||
fn plate() -> (ModelApp, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-menu-{}-{:?}", std::process::id(), std::thread::current().id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let design = dir.join("plate.solidcraft");
|
||||
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap();
|
||||
app.run("geometry.datum", json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap();
|
||||
app.run("geometry.partition", json!({"name": "Half", "datum": "Mid"})).unwrap();
|
||||
app.run("geometry.datum.point", json!({"name": "P", "by": {"type": "coordinates", "point": [0, 0, 0]}})).unwrap();
|
||||
app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0)));
|
||||
app.set_view(StandardView::Iso);
|
||||
app.fit_view();
|
||||
(app, dir)
|
||||
}
|
||||
|
||||
/// A screen point on datum `name`'s drawn lines.
|
||||
fn on_datum(app: &mut ModelApp, name: &str) -> egui::Pos2 {
|
||||
let bounds = app.display().bounds;
|
||||
let mut s = fieldcraft_render::Scene::default();
|
||||
fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, &crate::theme::Tokens::dark().glyph_colors(), &mut s);
|
||||
let l = s.lines[0];
|
||||
viewport::to_screen(app, (l.a + l.b) * 0.5).unwrap()
|
||||
}
|
||||
|
||||
/// The menu's entries run the commands the ribbon, forms and scripts run; hiding is view
|
||||
/// state (nothing journalled) and a hidden item is not picked.
|
||||
#[test]
|
||||
fn menu_entries_run_the_same_commands_and_hidden_items_are_not_picked() {
|
||||
let (mut app, dir) = plate();
|
||||
let mid = Target::Def(Def::Datum, "Mid".into());
|
||||
let at = on_datum(&mut app, "Mid");
|
||||
assert_eq!(viewport::menu_target(&mut app, at), Some(mid.clone()));
|
||||
let journal = app.session.journal.len();
|
||||
apply(&mut app, mid.clone(), Action::Hide);
|
||||
assert!(!app.session.shows(ItemKind::Datum, "Mid") && app.session.is_dirty());
|
||||
assert_eq!(app.session.journal.len(), journal);
|
||||
assert_ne!(viewport::menu_target(&mut app, at), Some(mid.clone()), "a hidden datum is not picked");
|
||||
apply(&mut app, mid.clone(), Action::Show);
|
||||
assert!(app.session.shows(ItemKind::Datum, "Mid"));
|
||||
|
||||
// A hidden body is neither drawn nor picked.
|
||||
let cell = app.session.design.as_ref().unwrap().bodies[0].name.clone();
|
||||
let centre = app.session.design.as_ref().unwrap().bodies[0].measure().unwrap().centroid;
|
||||
let top = viewport::to_screen(&app, fieldcraft_render::Vec3::new(centre[0], centre[1], 10.0)).unwrap();
|
||||
assert!(app.pick_at(top).is_some());
|
||||
apply(&mut app, Target::Body(cell.clone()), Action::Isolate);
|
||||
assert!(app.session.shows(ItemKind::Body, &cell) && !app.session.shows(ItemKind::Datum, "P"));
|
||||
apply(&mut app, Target::Body(cell.clone()), Action::Hide);
|
||||
app.display();
|
||||
assert!(app.pick.names.iter().all(|n| n.name != cell));
|
||||
assert!(app.pick_at(top).is_none_or(|i| !i.describe().contains(cell.as_str())));
|
||||
apply(&mut app, Target::Body(cell), Action::ShowAll);
|
||||
assert!(app.session.view.is_empty());
|
||||
|
||||
// Edit… opens the datum's edit form filled in; running it is the edit command.
|
||||
apply(&mut app, Target::Def(Def::Datum, "P".into()), Action::Edit);
|
||||
let d = app.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.command, "geometry.datum.point.edit");
|
||||
assert!(d.text.contains("[0.0,0.0,0.0]"), "{}", d.text);
|
||||
d.text = json!({"name": "P", "by": {"type": "coordinates", "point": [1, 0, 0]}}).to_string();
|
||||
app.run_dialog().unwrap();
|
||||
assert_eq!(app.session.journal.last().map(|(c, _)| c.as_str()), Some("geometry.datum.point.edit"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Delete asks first: a datum a partition uses names it and deletes both on confirmation;
|
||||
/// a definition in use elsewhere is refused.
|
||||
#[test]
|
||||
fn deleting_lists_what_uses_it_and_cascades_or_refuses() {
|
||||
let (mut app, dir) = plate();
|
||||
apply(&mut app, Target::Def(Def::Datum, "Mid".into()), Action::Delete);
|
||||
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!("{:?}", app.prompt) };
|
||||
assert_eq!(ask.users, ["partition `Half`"]);
|
||||
assert!(ask.allowed() && ask.question().contains("partition `Half`"));
|
||||
// Cancel changes nothing; Delete deletes the datum and its partition in one step.
|
||||
app.answer_delete(false);
|
||||
assert!(app.prompt.is_none() && app.session.model.datum("Mid").is_some());
|
||||
app.selected = Some((Def::Datum, "Mid".into()));
|
||||
app.ask_delete(Def::Datum, "Mid");
|
||||
app.answer_delete(true);
|
||||
assert_eq!(app.session.journal.last(), Some(&("geometry.datum.delete".to_string(), json!({"name": "Mid", "cascade": true}))));
|
||||
assert!(app.session.model.partitions().is_empty() && app.selected.is_none());
|
||||
|
||||
// A material a section uses is refused: no Delete.
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.open_sample("cantilever");
|
||||
app.ask_delete(Def::Material, "Steel");
|
||||
let Some(crate::files::Prompt::Delete(ask)) = app.prompt.clone() else { panic!() };
|
||||
assert!(!ask.allowed() && !ask.users.is_empty());
|
||||
let journal = app.session.journal.len();
|
||||
app.answer_delete(true);
|
||||
assert!(app.session.model.materials().contains_key("Steel"));
|
||||
assert_eq!(app.session.journal.len(), journal);
|
||||
// Active in step switches a boundary condition per step.
|
||||
let bc = app.session.model.boundary_conditions().keys().next().unwrap().clone();
|
||||
let step = app.session.model.steps()[0].name.clone();
|
||||
apply(&mut app, Target::Def(Def::BoundaryCondition, bc.clone()), Action::Active { step: step.clone(), active: false });
|
||||
assert!(!app.session.model.step(&step).unwrap().boundary_conditions.contains(&bc));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,10 @@ pub fn symbols(
|
||||
glyphs.draw(pos, &view, s)
|
||||
}
|
||||
|
||||
/// 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, &|_| true, &t.glyph_colors(), s)
|
||||
/// Draw the datums the session's view shows around `bounds` into `s`; returns the box around them.
|
||||
pub fn datums(s: &mut Scene, session: &fieldcraft_engine::Session, bounds: &Aabb3, t: &Tokens) -> Aabb3 {
|
||||
let shown = |name: &str| session.shows(fieldcraft_engine::ItemKind::Datum, name);
|
||||
fieldcraft_render::glyphs::datums(&session.model, bounds, &shown, &t.glyph_colors(), s)
|
||||
}
|
||||
|
||||
/// The scene for `display` drawn in `style`.
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
//! click selects it (Shift adds, Cmd/Ctrl adds or removes, a click on nothing clears), Escape
|
||||
//! clears. The selection bar (top centre) chooses what is picked and turns on box selection,
|
||||
//! where a left drag selects the visible nodes, element faces or elements inside the box. A
|
||||
//! right click offers a set made from the selection.
|
||||
//! right click offers a set made from the selection, and on a datum or a partition's cut face
|
||||
//! that item's menu ([`crate::menu`]), as its browser row does.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
|
||||
use fieldcraft_render::{StandardView, Vec3};
|
||||
|
||||
use crate::browser::Def;
|
||||
use crate::gpu::{GpuScene, ViewportCallback};
|
||||
use crate::icons::{self, Look};
|
||||
use crate::menu::{self, Target};
|
||||
use crate::navigation::{self, NavButton};
|
||||
use crate::selection::{Filter, Mode};
|
||||
use crate::theme::Tokens;
|
||||
@@ -179,8 +182,69 @@ fn hover_label(app: &ModelApp, painter: &egui::Painter, t: &Tokens) {
|
||||
}
|
||||
|
||||
/// The right-click menu: a set from the selection, clearing it, and the filter.
|
||||
/// Most screen distance (points) from a datum's drawn lines at which a right click is on it.
|
||||
const DATUM_REACH: f32 = 6.0;
|
||||
|
||||
/// The datum drawn under window point `at`: the nearest shown datum whose lines pass within
|
||||
/// [`DATUM_REACH`].
|
||||
fn datum_at(app: &mut ModelApp, at: Pos2) -> Option<String> {
|
||||
if app.plot().is_some() || !app.ui.show_geometry {
|
||||
return None;
|
||||
}
|
||||
let bounds = app.display().bounds;
|
||||
let colors = Tokens::get(app.ui.dark).glyph_colors();
|
||||
let names: Vec<String> =
|
||||
app.session.model.datums().keys().filter(|n| app.session.shows(fieldcraft_engine::ItemKind::Datum, n)).cloned().collect();
|
||||
let mut best: Option<(f32, String)> = None;
|
||||
for name in names {
|
||||
let mut drawn = fieldcraft_render::Scene::default();
|
||||
fieldcraft_render::glyphs::datums(&app.session.model, &bounds, &|n| n == name, &colors, &mut drawn);
|
||||
for l in &drawn.lines {
|
||||
let (Some(a), Some(b)) = (to_screen(app, l.a), to_screen(app, l.b)) else { continue };
|
||||
let xy = |p: Pos2| [f64::from(p.x), f64::from(p.y)];
|
||||
let d = fieldcraft_render::pick::segment_distance(xy(at), xy(a), xy(b)).0 as f32;
|
||||
if d <= DATUM_REACH && best.as_ref().is_none_or(|(e, _)| d < *e) {
|
||||
best = Some((d, name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// The partition whose cut face is under window point `at` (`partition:<name>:a|b`).
|
||||
fn partition_at(app: &mut ModelApp, at: Pos2) -> Option<String> {
|
||||
let rect = app.viewport.rect.filter(|r| r.contains(at))?;
|
||||
let proj = app.projector()?;
|
||||
let local = [f64::from(at.x - rect.left()), f64::from(at.y - rect.top())];
|
||||
let item = app.with_picker(|p| p.pick(&proj, local, Filter::Faces))?;
|
||||
let crate::selection::Item::Geometry(g) = item else { return None };
|
||||
let name = g.entity.name()?.strip_prefix("partition:")?.split(':').next()?.to_string();
|
||||
app.session.model.partitions().iter().any(|p| p.name == name).then_some(name)
|
||||
}
|
||||
|
||||
/// What a right click at `at` offers its item menu for: a datum drawn there, else a partition
|
||||
/// whose cut face is there.
|
||||
pub(crate) fn menu_target(app: &mut ModelApp, at: Pos2) -> Option<Target> {
|
||||
if let Some(name) = datum_at(app, at) {
|
||||
return Some(Target::Def(Def::Datum, name));
|
||||
}
|
||||
partition_at(app, at).map(|name| Target::Def(Def::Partition, name))
|
||||
}
|
||||
|
||||
fn context_menu(app: &mut ModelApp, response: &egui::Response) {
|
||||
if response.secondary_clicked() {
|
||||
app.viewport.menu_target = response.interact_pointer_pos().and_then(|at| menu_target(app, at));
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
if let Some(target) = app.viewport.menu_target.clone() {
|
||||
let Target::Def(def, name) = &target else { return };
|
||||
ui.label(egui::RichText::new(format!("{} {name}", def.label())).strong());
|
||||
if let Some(action) = menu::show(app, ui, &target) {
|
||||
menu::apply(app, target, action);
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
let has = !app.pick.selection.is_empty();
|
||||
if ui
|
||||
.add_enabled(has, egui::Button::new("Create set from selection…"))
|
||||
|
||||
@@ -50,7 +50,8 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
| `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel |
|
||||
| `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply |
|
||||
| `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) |
|
||||
| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) |
|
||||
| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close` | answers the window's question (`ui.inspect`'s `prompt`) |
|
||||
| `ui.item` | `kind` (`body`, `surface`, or a definition kind as `ui.select` takes it, `datum`, `partition`), `name`, `action`: `edit`, `hide`, `show`, `isolate`, `show_all`, `delete`, or `activate`/`deactivate` with `step` | an item menu's entry, as a right click on the item and a click on the entry: runs the same command (`delete` opens the delete prompt, answered with `ui.prompt`) |
|
||||
| `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `submit?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` |
|
||||
| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) |
|
||||
| `ui.move` | `x`, `y` | |
|
||||
|
||||
Reference in new issue
Block a user