diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 90a2e2b..c3e8834 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -85,6 +85,76 @@ fn render_dim(p: &Value, key: &str, default: usize) -> Result { } } +/// The keys that make `ui.select` change the viewport's selection (without them it selects a +/// definition in the browser, `{kind, name}`). +pub const VIEWPORT_SELECT: [&str; 10] = ["filter", "box_mode", "mode", "clear", "at", "box", "geometry", "nodes", "elements", "faces"]; + +/// The viewport's selection: the filter, the items, their set members and the hover. +pub fn selection_json(app: &ModelApp) -> Value { + let s = &app.pick.selection; + json!({ + "filter": app.pick.filter, + "box_mode": app.pick.box_mode, + "count": s.len(), + "capped": s.capped(), + "summary": s.summary(), + "items": s.items().iter().map(crate::selection::Item::to_json).collect::>(), + "params": s.params(), + "hover": app.pick.hover.as_ref().map(crate::selection::Item::to_json), + }) +} + +/// The window points in `v`, a list of `n` numbers (two per point). +fn points(v: &Value, key: &str, what: &str) -> Result<[f32; N], String> { + let bad = || format!("`{key}` must be {what} in window points"); + let list = v.as_array().filter(|a| a.len() == N).ok_or_else(bad)?; + let mut out = [0.0; N]; + for (slot, x) in out.iter_mut().zip(list) { + *slot = x.as_f64().filter(|x| x.is_finite() && x.abs() <= 1e6).ok_or_else(bad)? as f32; + } + Ok(out) +} + +/// `ui.select` on the viewport: set the filter and box mode, then pick by name and label +/// (`geometry`, `nodes`, `elements`, `faces`), at a point (`at`) or in a box (`box`), changing +/// the selection by `mode`; `clear` empties it first. +fn select(app: &mut ModelApp, p: &Value) -> Result { + use crate::selection::{Filter, Mode, items_from_params}; + if let Some(f) = p.get("filter") { + app.pick.filter = serde_json::from_value::(f.clone()) + .map_err(|_| format!("`filter` must be one of {}", Filter::ALL.map(|f| json!(f).as_str().unwrap_or_default().to_string()).join(", ")))?; + } + if let Some(on) = p.get("box_mode") { + app.pick.box_mode = on.as_bool().ok_or("`box_mode` must be true or false")?; + } + let mode = match p.get("mode") { + None => Mode::Replace, + Some(m) => serde_json::from_value::(m.clone()).map_err(|_| "`mode` must be replace, add, remove or toggle".to_string())?, + }; + let at = p.get("at").map(|v| points::<2>(v, "at", "a point [x, y]")).transpose()?; + let corners = p.get("box").map(|v| points::<4>(v, "box", "[x0, y0, x1, y1]")).transpose()?; + if corners.is_some() && !app.pick.filter.boxes() { + return Err(format!("box selection picks nodes, element faces or elements, not {}", app.pick.filter.label().to_lowercase())); + } + if (at.is_some() || corners.is_some()) && app.viewport.rect.is_none() { + return Err("the viewport has not been drawn yet".into()); + } + let items = items_from_params(p)?; + if p.get("clear").and_then(Value::as_bool) == Some(true) { + app.clear_selection(); + } + if !items.is_empty() { + app.select(items, mode); + } + if let Some([x, y]) = at { + app.select_at(egui::pos2(x, y), mode); + } + if let Some([x0, y0, x1, y1]) = corners { + app.select_box(egui::pos2(x0, y0), egui::pos2(x1, y1), mode); + } + Ok(selection_json(app)) +} + pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -131,6 +201,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> "version": crate::VERSION, })), "ui.toolbar" => ok(crate::toolbar::describe(app)), + "ui.select" if p.get("kind").is_none() && VIEWPORT_SELECT.iter().any(|k| p.get(*k).is_some()) => wrap(select(app, p)), + "ui.selection" => ok(selection_json(app)), "ui.select" => { // Select a definition as a browser click does (its properties show on the right). let kind = p.get("kind").cloned().and_then(|k| serde_json::from_value::(k).ok()); @@ -209,6 +281,12 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> if let (Some(d), Some(params)) = (app.dialog.as_mut(), p.get("params")) { d.text = serde_json::to_string_pretty(params).unwrap_or_default(); } + // The form's Target row: the viewport's selection into the parameters. + if b("use_selection") + && let Err(e) = app.use_selection_in_dialog() + { + return err(e); + } if b("run") { return wrap(app.run_dialog()); } @@ -454,6 +532,122 @@ mod tests { assert_eq!(call(&mut app, "no.method", json!({}))["ok"], false); } + /// One frame of the whole window (1400 × 900 points), with the control channel's synthetic + /// input, as the desktop app runs it. + fn frame(app: &mut ModelApp, ctx: &egui::Context) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + app.raw_input_hook(&mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + + fn call_in(app: &mut ModelApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + match handle(app, ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!({"screenshot": true}), + } + } + + /// The cantilever's free end (x = 100), looked at from +x: picked by a simulated click and + /// by `ui.select`, made into a set through the set form's Target row, and resolved. + #[test] + fn the_cantilevers_end_is_picked_and_made_a_set_over_the_control_channel() { + let mut app = ModelApp::new(Session::default()); + app.open_sample("cantilever"); + let ctx = egui::Context::default(); + assert_eq!(call_in(&mut app, &ctx, "ui.view", json!({"view": "right"}))["ok"], true); + app.fit_view(); + frame(&mut app, &ctx); + let viewport = app.viewport.rect.unwrap(); + // A click on the end cell at y ∈ [0, 5], z ∈ [5, 10]: element 1 + 19 + 0·20 + 1·40, face 4. + let at = crate::viewport::to_screen(&app, fieldcraft_render::Vec3::new(100.0, 2.5, 7.5)).unwrap(); + assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"filter": "element_faces"}))["result"]["count"], 0); + call_in(&mut app, &ctx, "ui.click", json!({"x": at.x, "y": at.y})); + for _ in 0..4 { + frame(&mut app, &ctx); + } + let r = call_in(&mut app, &ctx, "ui.selection", json!({})); + assert_eq!(r["result"]["items"], json!([{"element": 60, "face": 4}]), "{r}"); + // What is under the pointer is the hover. + assert_eq!(r["result"]["hover"], json!({"element": 60, "face": 4})); + // The same pick by `ui.select {at}`, added to with Cmd (toggle) removes it again. + let r = call_in(&mut app, &ctx, "ui.select", json!({"at": [at.x, at.y], "mode": "toggle"})); + assert_eq!(r["result"]["count"], 0, "{r}"); + // A box over the whole viewport holds the end's eight element faces (the rest are hidden). + let corners = [viewport.left(), viewport.top(), viewport.right(), viewport.bottom()]; + let r = call_in(&mut app, &ctx, "ui.select", json!({"box": corners})); + assert_eq!(r["result"]["count"], 8, "{r}"); + assert!(r["result"]["items"].as_array().unwrap().iter().all(|f| f["face"] == 4)); + // The set form's Target row takes them; the set resolves to the end's faces and nodes. + call_in(&mut app, &ctx, "ui.start", json!({"command": "model.set.create"})); + let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "end"}, "use_selection": true, "run": true})); + assert_eq!(r["ok"], true, "{r}"); + let r = call_in(&mut app, &ctx, "engine.execute", json!({"command": "model.set.resolve", "params": {"name": "end"}})); + assert_eq!((r["result"]["faces"]["count"].as_u64(), r["result"]["nodes"]["count"].as_u64()), (Some(8), Some(15)), "{r}"); + // A boundary condition's Target row makes a set of its own and names it. + call_in(&mut app, &ctx, "ui.start", json!({"command": "model.boundary_condition.create"})); + let r = call_in(&mut app, &ctx, "ui.dialog", json!({"params": {"name": "Hold", "type": "fixed"}, "use_selection": true, "run": true})); + assert_eq!(r["ok"], true, "{r}"); + let bc = serde_json::to_value(app.session.model.boundary_conditions().get("Hold").unwrap()).unwrap(); + assert_eq!(bc["target"], json!({"set": "Hold-set"}), "{bc}"); + assert_eq!(app.session.model.resolve_set("Hold-set").unwrap().faces.len(), 8); + // Nodes in the box: the end's 3 × 5, none of those behind it. + let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": "nodes", "box": corners})); + assert_eq!(r["result"]["count"], 15, "{r}"); + // Escape clears the selection. + call_in(&mut app, &ctx, "ui.key", json!({"key": "Escape"})); + frame(&mut app, &ctx); + assert_eq!(call_in(&mut app, &ctx, "ui.selection", json!({}))["result"]["count"], 0); + } + + #[test] + fn hostile_selections_are_refused_or_capped() { + let mut app = ModelApp::new(Session::default()); + let ctx = egui::Context::default(); + // Before the viewport is drawn, a point has nothing to pick on. + assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"at": [10, 10]}))["ok"], false); + // An empty scene picks nothing anywhere. + frame(&mut app, &ctx); + for filter in ["faces", "edges", "vertices", "bodies", "nodes", "element_faces", "elements"] { + let r = call_in(&mut app, &ctx, "ui.select", json!({"filter": filter, "at": [700, 450]})); + assert_eq!(r["result"]["count"], 0, "{filter}: {r}"); + } + for bad in [ + json!({"filter": "atoms"}), + json!({"mode": "xor", "nodes": [1]}), + json!({"at": [1e300, 5]}), + json!({"at": "here"}), + json!({"at": [1, 2, 3]}), + json!({"box": [0, 0, 1]}), + json!({"box": [0, f64::MAX, 1, 1]}), + json!({"filter": "faces", "box": [0, 0, 100, 100]}), + json!({"nodes": [0]}), + json!({"faces": [{"element": 1}]}), + json!({"geometry": [{"body": "B", "face": ""}]}), + json!({"elements": vec![1; crate::selection::MAX_SELECTION + 1]}), + ] { + let r = call_in(&mut app, &ctx, "ui.select", bad.clone()); + assert_eq!(r["ok"], false, "{bad}: {r}"); + } + // A huge selection is capped and says so. + let all: Vec = (1..=crate::selection::MAX_SELECTION as u32).collect(); + call_in(&mut app, &ctx, "ui.select", json!({"nodes": all})); + let r = call_in(&mut app, &ctx, "ui.select", json!({"elements": [1, 2], "mode": "add"})); + assert_eq!((r["result"]["count"].as_u64(), r["result"]["capped"].as_bool()), (Some(crate::selection::MAX_SELECTION as u64), Some(true))); + // The form's Target row: no form, a command without a target. + assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false); + call_in(&mut app, &ctx, "ui.start", json!({"command": "mesh.node.add"})); + assert_eq!(call_in(&mut app, &ctx, "ui.dialog", json!({"use_selection": true}))["ok"], false); + // `ui.select` with a definition still selects in the browser; `clear` empties the viewport's. + assert_eq!(call_in(&mut app, &ctx, "ui.select", json!({"clear": true}))["result"]["count"], 0); + frame(&mut app, &ctx); + } + #[test] fn the_command_form_is_driven_after_ui_start() { let mut app = ModelApp::new(Session::default()); @@ -533,6 +727,7 @@ mod tests { "ui.toolbar", "ui.prompt", "ui.select", + "ui.selection", "ui.fullscreen", ] { for p in &hostile { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c82963e..792a480 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -41,29 +41,76 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | +| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `busy` (the background job: a file opening, a solve), `prompt` (`{"unsaved": path}`, `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) | | `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form | | `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 `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.dialog` | `params?` (JSON object, fills the form), `run?`, `cancel?` | drives the open command form; `run` returns the command's result and closes the form on success | -| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; a click on a results plot probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | +| `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `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 | +| `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` | | -| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits, a right or middle drag (or Shift + left) pans | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits (with box selection on, it selects the visible nodes, element faces or elements in the box), a right or middle drag (or Shift + left) pans | | `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point | -| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model | +| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model; `Escape` clears the selection (and then cancels an open form) | | `ui.text` | `text` | typed text (into the focused field) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) | | `ui.resize` | `width`, `height` | window inner size in points | | `ui.fullscreen` | `on?` (default true) | into or out of full screen; on macOS the title bar's left group moves into the traffic lights' room while there are none | | `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; waits for a presented frame; when none comes within 8 seconds (a hidden window, a locked screen) it answers with `ui.capture`'s offscreen picture (`"offscreen": true`). Without `path`: `fieldcraft-shot.png` in the temporary folder | | `ui.capture` | `path?`, `width?`, `height?` (points; default the window's), `scale?` (pixels per point, 0.5–4; default the window's) | PNG of the whole window (title bar, ribbon, open menus and search, panels, forms and the viewport) drawn offscreen, without fade-in animations: the UI pass runs in a separate egui context and is rasterised on the CPU, so it works with the window hidden, minimised or the screen locked. At most 8192 pixels a side and 16 megapixels. Without `path`: `fieldcraft-shot.png` in the temporary folder | -| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera and view settings (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error | +| `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera, view settings and selection highlight (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error | | `app.quit` | — | closes the window | +## Selection + +The viewport's selection belongs to the window, not the model: there is no engine command for +it, and nothing about it is saved or journalled. What is selected is kept in the model's +persistent terms, so it reads as a set definition: geometry by name (`{"body", "face"}`, or +`edge`, `vertex`, or the body alone; a face picked on the mesh is named through the mesh's +geometry tags, so it stays selected through a remesh) and the mesh by label (`nodes`, +`elements`, element `faces` as `{"element", "face"}`). Labels the model no longer has drop out +after an edit. + +`ui.select` on the viewport takes, in this order: + +- `filter`: what a pick selects: `faces`, `edges`, `vertices`, `bodies` (geometry), `nodes`, + `element_faces`, `elements` (the mesh). The selection bar at the top of the viewport shows it. +- `box_mode`: `true` or `false` turns box selection on or off (a left drag draws the box). +- `mode`: how the items change the selection: `replace` (default), `add`, `remove`, `toggle`. +- `clear: true`: empty the selection first. +- `geometry`, `nodes`, `elements`, `faces`: items by name and label, as `model.set.create` + takes them (at most 100 000 in each list; labels from 1). +- `at`: `[x, y]` in window points: pick there as a click does, with the filter (nothing under + the point selects nothing; with `replace` it clears the selection). +- `box`: `[x0, y0, x1, y1]` in window points: the visible nodes, element faces or elements + inside the box (other filters refuse a box). Faces in front hide what is behind them. + +A selection holds at most 100 000 items; past that it is cut and `capped` is true. Picking +runs on the CPU against the triangles the viewport draws (on the GPU too), with a watertight +ray–triangle test: the nearest face wins, nodes, vertices and edges are the nearest on screen +within a few pixels that are not hidden, and ties go to the lower index, so the same pick +gives the same answer every time. + +Command forms that apply to part of the model have a Target row with Use selection +(`ui.dialog {"use_selection": true}`): `model.set.create` and `model.set.edit` take the selection +as members; `model.boundary_condition.*`, `model.load.*`, `model.section.*` and +`model.coupling.*` get a set made from it (`model.set.create`, named after the definition: +`Clamp-set`, then `Clamp-set-2`…) in `target: {"set"}` or `set`. A right click in the viewport +offers Create set from selection, which opens `model.set.create`'s form filled in. + +```text +{"id":1,"method":"ui.view","params":{"view":"right"}} +{"id":2,"method":"ui.select","params":{"filter":"element_faces","box":[300,120,1100,700]}} +→ {"id":2,"ok":true,"result":{"count":8,"params":{"faces":[{"element":20,"face":4},…]},…}} +{"id":3,"method":"ui.start","params":{"command":"model.set.create"}} +{"id":4,"method":"ui.dialog","params":{"params":{"name":"end"},"use_selection":true,"run":true}} +``` + ## Files User clicks (title bar File menu, toolbar entries, Cmd/Ctrl+O, S, Shift+S), drops on the window and