forked from 1b-agent/fieldcraft-upstream
ui: the Bodies list by name with sizes, faces and edges; journal deletions and edits badged, tooltips in words (#345, #359)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
de8cdd0b03
commit
27af5e19b7
2 files changed
+182
-10
No files matched your search
@@ -193,6 +193,8 @@ enum Act {
|
||||
Start(&'static str),
|
||||
Tab(Tab),
|
||||
Rerun,
|
||||
/// Pick a design entity in the viewport (a body, face or edge from the Bodies list).
|
||||
Pick(fieldcraft_model::GeometryRef),
|
||||
}
|
||||
|
||||
/// The reply's section of the context panel.
|
||||
@@ -216,6 +218,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
match reply.command.as_str() {
|
||||
"model.check" => diagnostics(app, ui, t, &reply.value, &mut acts),
|
||||
"model.activity" => activity(ui, t, &reply.value),
|
||||
"geometry.bodies" => bodies(ui, t, &reply.value, &mut acts),
|
||||
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
|
||||
}
|
||||
for a in acts {
|
||||
@@ -229,6 +232,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
Act::Rerun => {
|
||||
let _ = app.run(&reply.command, reply.params.clone());
|
||||
}
|
||||
Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +299,107 @@ fn diagnostics(app: &ModelApp, ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces or edges listed under a body at most.
|
||||
const MAX_ENTITIES: usize = 500;
|
||||
|
||||
/// Geometry › Bodies: one entry per body, its name first, then volume, area, face and edge counts
|
||||
/// and its size; a click on the name picks the body, and its faces and edges unfold, by
|
||||
/// persistent name (edges with their lengths), each picked by a click: the names the seed and set
|
||||
/// forms ask for. Design geometry is in millimetres.
|
||||
fn bodies(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
|
||||
let q = crate::describe::quantity;
|
||||
let pick = |body: &str, entity: fieldcraft_model::Entity| fieldcraft_model::GeometryRef { body: body.to_string(), entity };
|
||||
for (i, b) in v.as_array().into_iter().flatten().enumerate() {
|
||||
let name = b["name"].as_str().unwrap_or_default();
|
||||
let faces = b["faces"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
let edges = b["edges"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
let corner = |k: usize| -> Option<[f64; 3]> { serde_json::from_value(b["bbox"][k].clone()).ok() };
|
||||
let size =
|
||||
corner(0).zip(corner(1)).map(|(lo, hi)| format!("{} × {} × {} mm", q(sig(hi[0] - lo[0])), q(sig(hi[1] - lo[1])), q(sig(hi[2] - lo[2]))));
|
||||
let id = egui::Id::new(("fc_reply_body", i));
|
||||
if ui.link(RichText::new(name).strong()).on_hover_text("Pick this body in the viewport").clicked() {
|
||||
acts.push(Act::Pick(pick(name, fieldcraft_model::Entity::Body)));
|
||||
}
|
||||
egui::Grid::new(id.with("grid")).num_columns(2).spacing(vec2(10.0, 2.0)).show(ui, |ui| {
|
||||
let mut row = |k: &str, text: String| {
|
||||
ui.label(RichText::new(k).color(t.text_dim));
|
||||
ui.label(text);
|
||||
ui.end_row();
|
||||
};
|
||||
if let Some(v) = b["volume"].as_f64() {
|
||||
row("Volume", format!("{} mm³", q(sig(v))));
|
||||
}
|
||||
if let Some(a) = b["area"].as_f64() {
|
||||
row("Area", format!("{} mm²", q(sig(a))));
|
||||
}
|
||||
row("Faces, edges", format!("{}, {}", faces.len(), edges.len()));
|
||||
if let Some(size) = size {
|
||||
row("Size", size);
|
||||
}
|
||||
});
|
||||
body_list(ui, t, id.with("faces"), "Faces", faces, &|f| (f["name"].as_str().unwrap_or_default().to_string(), None), name, acts);
|
||||
body_list(
|
||||
ui,
|
||||
t,
|
||||
id.with("edges"),
|
||||
"Edges",
|
||||
edges,
|
||||
&|e| (e["name"].as_str().unwrap_or_default().to_string(), e["length"].as_f64()),
|
||||
name,
|
||||
acts,
|
||||
);
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
|
||||
/// A body's faces or edges, folded: each name picks it in the viewport (edges with their length).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn body_list(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
id: egui::Id,
|
||||
title: &str,
|
||||
items: &[Value],
|
||||
read: &dyn Fn(&Value) -> (String, Option<f64>),
|
||||
body: &str,
|
||||
acts: &mut Vec<Act>,
|
||||
) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
egui::CollapsingHeader::new(RichText::new(format!("{title} ({})", items.len())).size(12.0)).id_salt(id).show(ui, |ui| {
|
||||
for item in items.iter().take(MAX_ENTITIES) {
|
||||
let (name, length) = read(item);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.link(&name).on_hover_text("Pick it in the viewport").clicked() {
|
||||
let entity =
|
||||
if title == "Edges" { fieldcraft_model::Entity::Edge(name.clone()) } else { fieldcraft_model::Entity::Face(name.clone()) };
|
||||
acts.push(Act::Pick(fieldcraft_model::GeometryRef { body: body.to_string(), entity }));
|
||||
}
|
||||
if let Some(l) = length {
|
||||
ui.label(RichText::new(format!("{} mm", crate::describe::quantity(sig(l)))).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
}
|
||||
more_than(ui, t, items.len(), MAX_ENTITIES);
|
||||
});
|
||||
}
|
||||
|
||||
/// `v` to 3 significant figures.
|
||||
fn sig(v: f64) -> f64 {
|
||||
if v == 0.0 || !v.is_finite() {
|
||||
return v;
|
||||
}
|
||||
let scale = 10f64.powi(2 - v.abs().log10().floor() as i32);
|
||||
(v * scale).round() / scale
|
||||
}
|
||||
|
||||
fn more_than(ui: &mut egui::Ui, t: &Tokens, n: usize, max: usize) {
|
||||
if n > max {
|
||||
ui.label(RichText::new(format!("… {} more", n - max)).small().color(t.text_dim));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Step Table: each boundary condition and load against the steps, ticked where active.
|
||||
fn activity(ui: &mut egui::Ui, t: &Tokens, v: &Value) {
|
||||
let steps: Vec<&str> = v["steps"].as_array().into_iter().flatten().filter_map(|s| s["name"].as_str()).collect();
|
||||
|
||||
@@ -82,15 +82,63 @@ impl ModelApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for an item's tooltip: the command, how many, and its first parameters.
|
||||
fn tip(item: &Item, reachable: bool) -> String {
|
||||
let label = crate::command_label(&item.command);
|
||||
let mut s = if item.count() > 1 { format!("{label} × {}", item.count()) } else { label.to_string() };
|
||||
let params = item.params.to_string();
|
||||
if params != "{}" {
|
||||
let short: String = params.chars().take(160).collect();
|
||||
s.push_str(&format!("\n{}{short}{}", if item.count() > 1 { "first: " } else { "" }, if params.chars().count() > 160 { "…" } else { "" }));
|
||||
/// What an item's commands did, in words: "Delete reference point RP1", "Add Node × 3", "Open
|
||||
/// Sample cantilever". Never the parameters' JSON.
|
||||
fn describe(item: &Item) -> String {
|
||||
let name = item.params.get("name").and_then(Value::as_str);
|
||||
let def = crate::browser::Def::ALL
|
||||
.into_iter()
|
||||
.find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == item.command).map(|verb| (kind, verb)));
|
||||
let text = match (def, name) {
|
||||
(Some((kind, verb)), Some(name)) => {
|
||||
let did = match verb {
|
||||
"create" => "Add",
|
||||
"edit" => "Edit",
|
||||
_ => "Delete",
|
||||
};
|
||||
format!("{did} {} {name}", kind.label().to_lowercase())
|
||||
}
|
||||
(_, Some(name)) => format!("{} {name}", crate::command_label(&item.command)),
|
||||
_ => crate::command_label(&item.command).to_string(),
|
||||
};
|
||||
if item.count() > 1 { format!("{text} × {}", item.count()) } else { text }
|
||||
}
|
||||
|
||||
/// What a command does to the item its icon shows, when it is not making it: deleting or changing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Badge {
|
||||
Delete,
|
||||
Edit,
|
||||
}
|
||||
|
||||
fn badge(command: &str) -> Option<Badge> {
|
||||
let verb = command.rsplit('.').next()?;
|
||||
match verb {
|
||||
"delete" | "clear" => Some(Badge::Delete),
|
||||
"edit" => Some(Badge::Edit),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The badge at an item's top-left corner: a minus on the error colour for a deletion, a pencil
|
||||
/// stroke on the accent for an edit.
|
||||
fn paint_badge(p: &egui::Painter, item: Rect, b: Badge, t: &Tokens) {
|
||||
let c = item.left_top() + vec2(5.0, 5.0);
|
||||
let (fill, white) = (if b == Badge::Delete { t.error } else { t.accent }, Stroke::new(1.4, Color32::WHITE));
|
||||
p.circle(c, 5.0, fill, Stroke::new(1.0, t.timeline_item));
|
||||
match b {
|
||||
Badge::Delete => {
|
||||
p.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], white);
|
||||
}
|
||||
Badge::Edit => {
|
||||
p.line_segment([c + vec2(-2.2, 2.2), c + vec2(2.2, -2.2)], white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for an item's tooltip: what it did, and whether a click reaches it.
|
||||
fn tip(item: &Item, reachable: bool) -> String {
|
||||
let mut s = describe(item);
|
||||
s.push_str(if reachable { "\nClick to roll the model to here" } else { "\nOlder than the undo history" });
|
||||
s
|
||||
}
|
||||
@@ -191,14 +239,17 @@ pub fn timeline(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
let icon = fieldcraft_engine::catalogue::entry_of(&item.command).map_or("ui.views", |c| c.id);
|
||||
let tint = if undone || !reachable { Color32::from_white_alpha(110) } else { Color32::WHITE };
|
||||
icons::paint_tinted(app, ui, icon, br.shrink(4.0), Look::of(!undone), tint);
|
||||
if let Some(b) = badge(&item.command) {
|
||||
paint_badge(&p, br, b, &t);
|
||||
}
|
||||
if item.count() > 1 {
|
||||
let text = p.layout_no_wrap(format!("{}", item.count()), FontId::proportional(9.0), t.text);
|
||||
let b = Rect::from_min_size(br.right_bottom() - text.size() - vec2(3.0, 1.0), text.size() + vec2(3.0, 1.0));
|
||||
p.rect_filled(b, 3.0, t.panel_header);
|
||||
p.galley(b.min + vec2(1.5, 0.5), text, t.text);
|
||||
}
|
||||
let label = crate::command_label(&item.command);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, label));
|
||||
let label = describe(item);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, reachable, &label));
|
||||
if resp
|
||||
.on_hover_ui(|ui| {
|
||||
ui.label(tip(item, reachable));
|
||||
@@ -252,4 +303,20 @@ mod tests {
|
||||
app.roll_to(100);
|
||||
assert_eq!(app.session.journal.len(), 4, "clamped to what can be redone");
|
||||
}
|
||||
|
||||
/// A deletion or an edit reads apart from the creation it undoes (#359): its own badge, and
|
||||
/// a tooltip in words.
|
||||
#[test]
|
||||
fn deletions_and_edits_read_apart_from_creations() {
|
||||
let item = |command: &str, params: Value| Item { command: command.into(), start: 0, end: 1, params };
|
||||
let create = item("model.reference_point.create", json!({"name": "RP1", "xyz": [0, 0, 0]}));
|
||||
let delete = item("model.reference_point.delete", json!({"name": "RP1"}));
|
||||
assert_eq!((badge(&create.command), badge(&delete.command)), (None, Some(Badge::Delete)));
|
||||
assert_eq!(badge("model.load.edit"), Some(Badge::Edit));
|
||||
assert_eq!(describe(&create), "Add reference point RP1");
|
||||
assert_eq!(describe(&delete), "Delete reference point RP1");
|
||||
assert!(!tip(&delete, true).contains('{'));
|
||||
let nodes = Item { end: 3, ..item("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})) };
|
||||
assert_eq!(describe(&nodes), "Add Node × 3");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user