From 50c5c13f72e238fe8033ab349d266ba76298a499 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:13:09 +0200 Subject: [PATCH] ui.camera: read, set and replay the viewport camera (#1302) A control-channel method that reads the orbit camera, sets any of target/yaw/pitch/roll/distance, and fits listed occurrences (or everything) keeping the direction. Its reply passed back sets the same view again; a refused field changes nothing. --- crates/ui-egui/src/control.rs | 173 +++++++++++++++++++++++++ crates/ui-egui/src/dialogs_assembly.rs | 27 ++-- crates/ui-egui/src/lib.rs | 9 ++ docs/control-protocol.md | 1 + 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 81e5562c..6d269103 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -333,6 +333,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> } ok(json!(app.cam)) } + "ui.camera" => wrap(camera(app, p)), "ui.start" => { let Some(id) = s("command") else { return err("missing `command`") }; app.start(id); @@ -693,6 +694,96 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> } } +/// `ui.camera`: the viewport's orbit camera (`target`, `yaw`, `pitch`, `roll` in radians, +/// `distance` in mm, `fov`, `up`), or a new one. A field left out keeps its value, so a reply +/// passed back sets the same view again. `fit`: frame the listed occurrences (ids or names; +/// `root`: the bodies outside every component), or everything (`true`), keeping the direction; +/// `false` frames nothing. +/// Nothing changes when a field is refused. +fn camera(app: &mut SolveApp, p: &Value) -> Result { + const FIELDS: [&str; 8] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up", "fit"]; + let o = match p { + Value::Null => serde_json::Map::new(), + Value::Object(o) => o.clone(), + _ => return Err("params must be an object".into()), + }; + if let Some(k) = o.keys().find(|k| !FIELDS.contains(&k.as_str())) { + return Err(format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", "))); + } + let mut cam = app.cam; + let sets = o.keys().any(|k| k != "fit"); + if sets { + let mut v = serde_json::to_value(app.cam).map_err(|e| e.to_string())?; + for (k, x) in o.iter().filter(|(k, _)| *k != "fit") { + v[k] = x.clone(); + } + cam = serde_json::from_value(v).map_err(|e| format!("bad camera: {e}"))?; + // The distance cap is far past any Fit (so every reply replays) and still finite in f32. + if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 { + return Err("the camera must be finite, with a distance up to 1e15 mm and a pitch within ±π/2".into()); + } + // Straight up or down keeps a heading, as the standard views do. + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + cam.pitch = cam.pitch.clamp(-lim, lim); + // Perspective and the up axis are settings the camera follows, not its own. + if cam.fov != app.cam.fov { + return Err("`fov` follows the perspective setting (ui.set {perspective})".into()); + } + if cam.up != app.cam.up { + return Err("`up` follows the up axis preference (ui.prefs {up_axis})".into()); + } + } + let fit = match o.get("fit") { + None | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) if !crate::render_workspace::active(app) && crate::viewport::view_bounds(app).is_empty() => { + return Err("nothing is shown to fit".into()); + } + Some(Value::Bool(true)) => Some(None), + Some(Value::Array(list)) if !list.is_empty() && list.len() <= 10_000 => { + let occs = list.iter().map(|v| occurrence(&app.session, v)).collect::, String>>()?; + let b = crate::dialogs_assembly::occurrence_bounds(&app.session, &occs); + if b.is_empty() { + return Err("those occurrences show nothing to fit".into()); + } + Some(Some(b)) + } + Some(_) => return Err("`fit` must be true, false or a list of occurrences".into()), + }; + // A read leaves a view change that is animating alone. + if !sets && fit.is_none() { + return serde_json::to_value(app.cam).map_err(|e| e.to_string()); + } + app.cancel_view_animation(); + app.viewport.orbit_snap = None; + app.viewport.orbit_free = None; + app.cam = cam; + match fit { + Some(None) => app.fit_view(), + Some(Some(b)) => app.fit_bounds(b), + None => {} + } + serde_json::to_value(app.cam).map_err(|e| e.to_string()) +} + +/// An occurrence by id or name (or a component's name: its first occurrence); 0 is the root. +fn occurrence(s: &solidcraft_engine::Session, v: &Value) -> Result { + let key = match v { + Value::Number(n) => n.to_string(), + Value::String(x) => x.clone(), + _ => return Err("an occurrence must be an id or a name".into()), + }; + if key == "0" || key.eq_ignore_ascii_case("root") { + return Ok(0); + } + s.doc + .occurrences + .iter() + .find(|o| o.id.to_string() == key || o.name == key) + .map(|o| o.id) + .or_else(|| s.doc.find_component(&key).and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id)) + .ok_or_else(|| format!("no occurrence `{key}`")) +} + /// Save a screenshot PNG. pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { let [w, h] = image.size; @@ -1078,4 +1169,86 @@ mod tests { assert!(bad["error"].as_str().is_some_and(|e| e.contains("allow_paths")), "{bad}"); let _ = std::fs::remove_dir_all(&base); } + + /// `ui.camera` reads the camera and sets it; its reply, passed back, sets the same view + /// again. Fields left out keep their values, `fit` frames occurrences, and a refused field + /// changes nothing. + #[test] + fn ui_camera_reads_sets_and_replays_the_view() { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 100)] { + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("component.create", &json!({ "name": name })).unwrap(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + let mut h = crate::scenario::Harness::new(); + h.app = SolveApp::new(s, Services::default()); + h.app.viewport.no_pixels = true; + h.frames(2); + let before = h.app.cam; + // Reading during an animated view change leaves it running (no frames run between). + fn raw(h: &mut crate::scenario::Harness, method: &str, params: Value) -> Value { + let (req, _rx) = ControlRequest::new(method, params); + match handle(&mut h.app, &h.ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => Value::Null, + } + } + raw(&mut h, "ui.view", json!({"view": "top", "animate": true})); + let r = raw(&mut h, "ui.camera", Value::Null); + assert!(r["ok"] == true && h.app.cam_anim.is_some(), "{r}"); + // Setting it, even to where it is now, ends the animation there. + let yaw = h.app.cam.yaw; + let r = raw(&mut h, "ui.camera", json!({ "yaw": yaw })); + assert!(r["ok"] == true && h.app.cam_anim.is_none(), "{r}"); + h.app.cam = before; + let mut cam = |p: Value| { + let r = h.call("ui.camera", p); + assert_eq!(r["ok"], true, "{r}"); + (r["result"].clone(), h.app.cam) + }; + let (start, now) = cam(json!({})); + assert_eq!(start, json!(now)); + let (set, _) = cam(json!({"target": [5, 6, 7], "yaw": 0.5, "pitch": -0.25, "distance": 300})); + assert_eq!( + (set["target"].clone(), set["yaw"].clone(), set["pitch"].clone(), set["distance"].clone()), + (json!([5.0, 6.0, 7.0]), json!(0.5), json!(-0.25), json!(300.0)) + ); + assert_eq!(set["roll"], start["roll"], "a field left out keeps its value"); + let (top, _) = cam(json!({"pitch": std::f64::consts::FRAC_PI_2})); + assert!(top["pitch"].as_f64().unwrap() < std::f64::consts::FRAC_PI_2, "straight down keeps a heading: {top}"); + let (again, now) = cam(set.clone()); + assert_eq!((again, json!(now)), (set.clone(), set.clone()), "the reply sets the same view again"); + let (fit, _) = cam(json!({"fit": ["B"]})); + let t: Vec = fit["target"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + // Framed as Fit frames (the viewport's panels shift the target a little): on B, not A. + assert!((100.0..120.0).contains(&t[0]) && (0.0..20.0).contains(&t[1]) && (0.0..10.0).contains(&t[2]), "{fit}"); + assert_eq!(fit["yaw"], set["yaw"], "fit keeps the direction"); + let fov = h.app.cam.fov + 0.25; + for bad in [ + json!({"zoom": 2}), + json!({"distance": -1}), + json!({"distance": 1e300}), + json!({"pitch": 2}), + json!({"yaw": "x"}), + json!({"fov": fov}), + json!({"up": "y"}), + json!({"yaw": 1, "fit": ["Nope"]}), + json!({"fit": []}), + json!({"fit": 1}), + json!([1, 2]), + ] { + let before = h.app.cam; + let r = h.call("ui.camera", bad.clone()); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.cam, before, "{bad}"); + } + // Nothing shown: nothing to fit. + let mut empty = crate::scenario::Harness::new(); + empty.app.viewport.no_pixels = true; + let before = empty.app.cam; + let r = empty.call("ui.camera", json!({"yaw": 1, "fit": true})); + assert!(r["ok"] == false && empty.app.cam == before, "{r}"); + } } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 2504a0c0..f63327ea 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -223,17 +223,7 @@ pub fn animate_joint(app: &SolveApp, id: u64) -> Option { pub fn find_joint(app: &mut SolveApp, id: u64) { let s = &app.session; let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return }; - let occs = [j.a.occurrence, j.b.occurrence]; - let model = s.model.state(); - let world = s.world_state(); - let mut bb = Aabb3::default(); - for (name, _, path, _) in s.doc.body_frames(&model) { - // The root (occurrence 0) holds the bodies outside every occurrence. - let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) }); - if on && let Some(b) = world.body(&name) { - bb = bb.union(&b.mesh().bounds()); - } - } + let bb = occurrence_bounds(s, &[j.a.occurrence, j.b.occurrence]); if bb.is_empty() { return; } @@ -242,6 +232,21 @@ pub fn find_joint(app: &mut SolveApp, id: u64) { app.animate_to(cam); } +/// The box around the bodies of occurrences (and of the occurrences inside them), where they +/// are placed; occurrence 0 is the root, holding the bodies outside every occurrence. +pub fn occurrence_bounds(s: &Session, occs: &[u64]) -> Aabb3 { + let model = s.model.state(); + let world = s.world_state(); + let mut bb = Aabb3::default(); + for (name, _, path, _) in s.doc.body_frames(&model) { + let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) }); + if on && let Some(b) = world.body(&name) { + bb = bb.union(&b.mesh().bounds()); + } + } + bb +} + pub fn drive_joint(app: &SolveApp, id: u64) -> Option { let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?; Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![])) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ee5bfe71..40679563 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -946,6 +946,15 @@ impl SolveApp { self.cam = self.fitted(self.cam); } + /// Frame `bounds` at once, keeping the view direction (programmatic use). + pub fn fit_bounds(&mut self, bounds: solidcraft_engine::geom::Aabb3) { + self.cancel_view_animation(); + self.viewport.orbit_snap = None; + self.viewport.orbit_free = None; + self.pending_fit = Some((self.cam, bounds, false)); + self.cam = self.fitted_bounds(self.cam, &bounds); + } + /// `cam` moved to frame the whole model. pub fn fitted(&self, mut cam: Camera) -> Camera { let b = viewport::view_bounds(self); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 114dbe21..58fd63dd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -104,6 +104,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.inspect` | — | UI state, pending file picker (`file_picker_busy`; compatibility alias `file_dialog_open`, no path), what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | +| `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. |