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.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:13:09 -04:00
1 parent 7195e323bf
commit 50c5c13f72
4 files changed
+199 -11

No files matched your search

+173
View File
@@ -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<Value, String> {
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::<Result<Vec<u64>, 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<u64, String> {
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<f64> = 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}");
}
}
+16 -11
View File
@@ -223,17 +223,7 @@ pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
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<Dialog> {
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![]))
+9
View File
@@ -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);
+1
View File
@@ -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. |