control: ui.select on the viewport and ui.selection

ui.select now also drives the viewport's selection: the filter, box
mode, items by name and label as model.set.create takes them, a pick at
a window point or the visible items in a box, changed by a mode
(replace, add, remove, toggle). ui.selection reads it back with its set
members and the hover; ui.dialog takes use_selection (the form's Target
row). Selection stays in the window: no engine command. Documented in
docs/control-protocol.md, with a test that picks the cantilever's free
end by a simulated click and a box, makes sets of it and resolves them,
and hostile-input tests (bad points and boxes, labels, huge lists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 08:16:26 -04:00
1 parent 9e269a9f45
commit f11be61ff3
2 files changed
+248 -6

No files matched your search

+195
View File
@@ -85,6 +85,76 @@ fn render_dim(p: &Value, key: &str, default: usize) -> Result<usize, String> {
}
}
/// 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::<Vec<_>>(),
"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<const N: usize>(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<Value, String> {
use crate::selection::{Filter, Mode, items_from_params};
if let Some(f) = p.get("filter") {
app.pick.filter = serde_json::from_value::<Filter>(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::<Mode>(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::<crate::browser::Def>(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<u32> = (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 {
+53 -6
View File
@@ -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:<action>`, `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:<action>`, `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 `<tab>/<panel>` 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