1419 lines
74 KiB
Rust
1419 lines
74 KiB
Rust
//! Programmatic control of the running app (agents, tests).
|
||
//!
|
||
//! Methods (JSON lines over the host's transport, `{"id", "method", "params"}`):
|
||
//! - `engine.execute {command, params}`: run any command with JSON parameters (no dialogs)
|
||
//! - `engine.script {commands: [...]}`: run a command script
|
||
//! - `engine.batch {commands: [...], rollback?}`: the steps in one request, each as
|
||
//! `engine.execute` runs it; stops at the first failure and goes back to the checkpoint
|
||
//! (per-step results, `failed`, `undone`). Refused (`engine.batch unavailable: …`) while the
|
||
//! agent cursor is shown or when a step loads a file
|
||
//! - `engine.commands`: every command with tab, panel, params and enablement
|
||
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
|
||
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
|
||
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation;
|
||
//! `hold` keeps the button down that many frames before moving);
|
||
//! `ui.selection` (selection, dialog inputs, hover, drawn sketch dimensions);
|
||
//! `ui.sketchToScreen {points: [[x,y]…]}` (screen points of active-sketch coordinates); `ui.editFeature {feature}` (edit dialog)
|
||
//! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate)
|
||
//! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs)
|
||
//! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`: real
|
||
//! pointer input in screen points; `ui.scroll {x, y, delta}`: mouse-wheel input in
|
||
//! points (zooms); `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?, grid?, axes?}`:
|
||
//! headless CPU render of the model with the current camera, in the window's theme, with the
|
||
//! section the viewport shows (no window needed); the ground grid and the origin's axes as the
|
||
//! window shows them, unless `grid` or `axes` says
|
||
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
|
||
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
|
||
//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label;
|
||
//! `ui.rename {text?, commit?}` finishes the rename box
|
||
//! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do
|
||
//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists)
|
||
//! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears)
|
||
//! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics
|
||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_around, msaa,
|
||
//! length_decimals, angle_decimals); returns them all
|
||
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
|
||
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
|
||
//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks
|
||
//! and replies once the command has run (`"animate": false` skips that one call)
|
||
//! - `ui.resize {width, height}`, `app.quit`
|
||
|
||
use std::sync::mpsc::Sender;
|
||
|
||
use serde_json::{Value, json};
|
||
use solidcraft_engine::io::vfs;
|
||
use solidcraft_engine::render::StandardView;
|
||
|
||
use crate::SolveApp;
|
||
|
||
pub type ControlResponse = Value;
|
||
|
||
pub struct ControlRequest {
|
||
pub method: String,
|
||
pub params: Value,
|
||
pub reply: Sender<ControlResponse>,
|
||
}
|
||
|
||
impl ControlRequest {
|
||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
(Self { method: method.into(), params, reply: tx }, rx)
|
||
}
|
||
}
|
||
|
||
pub enum Outcome {
|
||
Done(Value),
|
||
Screenshot { path: Option<String> },
|
||
}
|
||
|
||
fn ok(v: Value) -> Outcome {
|
||
Outcome::Done(json!({"ok": true, "result": v}))
|
||
}
|
||
fn err(e: impl std::fmt::Display) -> Outcome {
|
||
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
|
||
}
|
||
fn wrap(r: Result<Value, String>) -> Outcome {
|
||
match r {
|
||
Ok(v) => ok(v),
|
||
Err(e) => err(e),
|
||
}
|
||
}
|
||
|
||
fn key_from(name: &str) -> Option<egui::Key> {
|
||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||
"enter" | "return" => Some(egui::Key::Enter),
|
||
"esc" | "escape" => Some(egui::Key::Escape),
|
||
"delete" => Some(egui::Key::Delete),
|
||
"space" => Some(egui::Key::Space),
|
||
"tab" => Some(egui::Key::Tab),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// Run one command as `engine.execute` does: through `app.run`, then the first-body fit and the
|
||
/// camera the command asked for.
|
||
fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result<Value, String> {
|
||
let r = app.run(id, params);
|
||
if r.is_ok()
|
||
&& id != "view.navigate"
|
||
&& app.session.active_sketch.is_none()
|
||
&& app.session.world_state().bodies.len() == 1
|
||
&& app.session.undo.len() <= 2
|
||
{
|
||
app.fit_view();
|
||
}
|
||
// After the first-body fit, so a camera the command asked for (`view.apply`) wins.
|
||
app.follow_view_request(false);
|
||
r
|
||
}
|
||
|
||
/// A batch step's command id.
|
||
fn command(step: &Value) -> Option<&str> {
|
||
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)
|
||
}
|
||
|
||
/// Most steps in one `engine.batch` (as in the MCP `batch` tool).
|
||
const MAX_BATCH: usize = 10_000;
|
||
|
||
/// Commands that load a file: through `engine.execute` they go to the app's file loader
|
||
/// (`file_load::control`: off the window thread, with progress and Cancel), which a batch would
|
||
/// bypass.
|
||
const LOADS_A_FILE: [&str; 3] = ["doc.open", "file.insert_step", "file.insert_mesh"];
|
||
|
||
/// `engine.batch {commands, rollback?}`: the steps in one request, so in one frame (#743). Each
|
||
/// step runs as `engine.execute` runs it; the first failure stops the batch and, with
|
||
/// `rollback` (the default), the design goes back to its checkpoint. Step failures are part of
|
||
/// the reply; refusals (`engine.batch unavailable: …`) come before anything runs, so the client
|
||
/// can send the steps one by one instead.
|
||
fn batch(app: &mut SolveApp, p: &Value) -> Outcome {
|
||
let Some(steps) = p.get("commands").and_then(Value::as_array).filter(|s| s.len() <= MAX_BATCH) else {
|
||
return err(format!("engine.batch: commands must be an array of at most {MAX_BATCH} steps"));
|
||
};
|
||
// The agent cursor acts out each `engine.execute` on its own.
|
||
if app.ui.agent_cursor.show {
|
||
return err("engine.batch unavailable: the agent cursor is shown");
|
||
}
|
||
if let Some((i, c)) = steps.iter().enumerate().find_map(|(i, step)| command(step).filter(|c| LOADS_A_FILE.contains(c)).map(|c| (i, c))) {
|
||
return err(format!("engine.batch unavailable: step {i} ({c}) loads a file"));
|
||
}
|
||
let rollback = p.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
||
let checkpoint = if rollback { app.run("edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) } else { None };
|
||
let mut results = Vec::with_capacity(steps.len());
|
||
for (i, step) in steps.iter().enumerate() {
|
||
let r = match command(step) {
|
||
Some(id) => execute(app, id, step.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))),
|
||
None => Err("missing `command`".to_string()),
|
||
};
|
||
match r {
|
||
Ok(v) => results.push(v),
|
||
Err(e) => {
|
||
let restored = checkpoint.is_some_and(|id| app.run("edit.restore_checkpoint", json!({"checkpoint": id})).is_ok());
|
||
let failed = json!({"index": i, "command": command(step), "error": e});
|
||
return ok(json!({"completed": i, "results": results, "failed": failed, "undone": if restored { i } else { 0 }}));
|
||
}
|
||
}
|
||
}
|
||
if let Some(id) = checkpoint {
|
||
let _ = app.run("edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||
}
|
||
ok(json!({"completed": results.len(), "results": results, "undone": 0}))
|
||
}
|
||
|
||
/// A request from an MCP server carries the folders its agent may use (`allow_paths`); every
|
||
/// file the request reaches must lie inside them (docs/mcp.md). `None`: it names none.
|
||
pub fn request_scope(app: &SolveApp, params: &Value) -> Result<Option<vfs::PathScope>, String> {
|
||
let Some(allowed) = params.get("allow_paths") else { return Ok(None) };
|
||
let Some(roots) = allowed.as_array().and_then(|a| a.iter().map(|r| r.as_str().map(std::path::PathBuf::from)).collect::<Option<Vec<_>>>()) else {
|
||
return Err("`allow_paths` must be a list of folders".into());
|
||
};
|
||
// Plus the app's own recovery folder and, when it is a safe one, the open design's folder.
|
||
let roots = roots.into_iter().chain(solidcraft_engine::recovery::default_dir());
|
||
Ok(Some(vfs::PathScope::new(roots).with_design(app.session.path.as_deref())))
|
||
}
|
||
|
||
/// What a picture of the view shows: the model as the window shows it (theme and colour vision
|
||
/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's
|
||
/// Slice), with the grid and the origin's axes as `guides` says.
|
||
pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::Scene {
|
||
solidcraft_engine::view::scene_with_palette_and_guides(
|
||
&app.session,
|
||
&app.cam,
|
||
crate::viewport::section_plane(app),
|
||
solidcraft_engine::view::world_curve_parts,
|
||
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
|
||
guides,
|
||
)
|
||
}
|
||
|
||
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
|
||
fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides {
|
||
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
|
||
}
|
||
|
||
/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's
|
||
/// pipelines (the renderer the app installs).
|
||
pub(crate) fn view_rgba(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||
solidcraft_engine::capture::rgba(&view_scene(app, window_guides(app)), &app.cam, w, h).map(|p| p.rgba)
|
||
}
|
||
|
||
/// [`view_rgba`] as PNG.
|
||
pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||
let rgba = view_rgba(app, w, h)?;
|
||
solidcraft_engine::render::png(w as usize, h as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())
|
||
}
|
||
|
||
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||
let scope = match request_scope(app, &req.params) {
|
||
Ok(Some(scope)) => scope,
|
||
Ok(None) => return dispatch(app, ctx, &req.method, &req.params),
|
||
Err(e) => return err(e),
|
||
};
|
||
let mut p = req.params.clone();
|
||
if let Some(o) = p.as_object_mut() {
|
||
o.remove("allow_paths");
|
||
}
|
||
vfs::scoped(Some(scope), || dispatch(app, ctx, &req.method, &p))
|
||
}
|
||
|
||
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
|
||
crate::render_workspace::sync_backend(app);
|
||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||
match method {
|
||
"ui.workspace" => {
|
||
if let Some(workspace) = s("workspace") {
|
||
if !matches!(workspace, "design" | "render") {
|
||
return err("workspace must be design or render");
|
||
}
|
||
if let Err(e) = crate::render_workspace::enter(app, workspace == "render") {
|
||
return err(e);
|
||
}
|
||
}
|
||
ok(json!({"workspace":if crate::render_workspace::active(app) {"render"} else {"design"}}))
|
||
}
|
||
"engine.execute" | "command" => {
|
||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||
wrap(execute(app, id, p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}))))
|
||
}
|
||
"engine.batch" => batch(app, p),
|
||
"ui.agent_cursor" => {
|
||
let s = &mut app.ui.agent_cursor;
|
||
if let Some(v) = p.get("show").and_then(Value::as_bool) {
|
||
s.show = v;
|
||
}
|
||
if let Some(v) = p.get("follow_camera").and_then(Value::as_bool) {
|
||
s.follow_camera = v;
|
||
}
|
||
if let Some(v) = p.get("speed") {
|
||
match crate::agent_cursor::Speed::from_value(v) {
|
||
Some(sp) => s.speed = sp,
|
||
None => return err("speed must be a number from 1 to 5, or \"instant\""),
|
||
}
|
||
}
|
||
ok(json!(app.ui.agent_cursor))
|
||
}
|
||
"engine.script" => {
|
||
let r = app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string());
|
||
app.follow_view_request(false);
|
||
wrap(r)
|
||
}
|
||
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
|
||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||
"ui.inspect" => ok(json!({
|
||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||
"visibility": app.session.visibility,
|
||
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||
"camera": app.cam,
|
||
"tool": app.tool.as_ref().map(|t| t.cmd),
|
||
"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")),
|
||
"status": app.status.as_ref().map(|(text, _, error)| json!({"text": text, "error": error})),
|
||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
|
||
"frame_ms": app.frame_ms,
|
||
"file_busy": app.file_load.busy(),
|
||
"file_picker_busy": app.file_picker_busy(),
|
||
"file_dialog_open": app.file_picker_busy(),
|
||
"build_ms": app.viewport.build_ms,
|
||
"display_lod": app.viewport.lod.lock().map(|l| serde_json::to_value(&l.stats).unwrap_or_default()).unwrap_or_default(),
|
||
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
|
||
"home": app.home.open,
|
||
"recent_thumbnails_ready": app.home.ready_recents(),
|
||
"documents": app.docs.count(),
|
||
"quit_requested": app.quit_requested,
|
||
"quitting": app.docs.quitting.is_some(),
|
||
})),
|
||
"ui.file" => {
|
||
let Some(path) = s("path") else { return err("path is required") };
|
||
if p.get("insert").and_then(Value::as_bool).unwrap_or(false) {
|
||
app.insert_path_async(path);
|
||
} else {
|
||
app.open_path_async(path);
|
||
}
|
||
ok(json!({"busy": app.file_load.busy()}))
|
||
}
|
||
"ui.documents" => ok(crate::documents::control(app, p)),
|
||
"ui.home" => {
|
||
match p.get("open").and_then(Value::as_bool) {
|
||
Some(true) => {
|
||
let rev = app.session.revision;
|
||
app.home.show(rev);
|
||
}
|
||
Some(false) => app.home.open = false,
|
||
None => {}
|
||
}
|
||
if let Some(i) = p.get("sample").and_then(Value::as_u64) {
|
||
crate::home::open_sample(app, i as usize);
|
||
}
|
||
ok(json!({"open": app.home.open, "recent": app.home.recent}))
|
||
}
|
||
"ui.set" => {
|
||
// What is hidden lives in the session (`browser.visibility`); `ui.set` still sets it.
|
||
const VIS: [(&str, &str); 5] = [
|
||
("hiddenBodies", "hidden_bodies"),
|
||
("hiddenOrigin", "hidden_origin"),
|
||
("hiddenSketches", "hidden_sketches"),
|
||
("shownSketches", "shown_sketches"),
|
||
("hiddenJoints", "hidden_joints"),
|
||
];
|
||
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
|
||
let mut vis = serde_json::to_value(&app.session.visibility).unwrap_or(json!({}));
|
||
if let (Some(o), Some(vo), Some(src)) = (cur.as_object_mut(), vis.as_object_mut(), p.as_object()) {
|
||
for (k, v) in src {
|
||
match VIS.iter().find(|(camel, _)| camel == k) {
|
||
Some((_, snake)) => vo.insert(snake.to_string(), v.clone()),
|
||
None => o.insert(k.clone(), v.clone()),
|
||
};
|
||
}
|
||
}
|
||
match (serde_json::from_value::<crate::UiState>(cur), serde_json::from_value::<solidcraft_engine::Visibility>(vis)) {
|
||
(Ok(u), Ok(v)) => {
|
||
if (u.tab == "RENDER") != crate::render_workspace::active(app)
|
||
&& let Err(e) = crate::render_workspace::enter(app, u.tab == "RENDER")
|
||
{
|
||
return err(e);
|
||
}
|
||
app.ui = u;
|
||
app.session.visibility = v;
|
||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||
}
|
||
(Err(e), _) | (_, Err(e)) => err(e),
|
||
}
|
||
}
|
||
"ui.view" => {
|
||
let v = s("view").unwrap_or("home");
|
||
app.viewport.orbit_snap = None;
|
||
app.viewport.orbit_free = None;
|
||
if p.get("animate").and_then(Value::as_bool).unwrap_or(false) {
|
||
if v != "fit" && v != "home" && StandardView::parse(v).is_none() {
|
||
return err(format!("unknown view `{v}`"));
|
||
}
|
||
app.animate_view(v);
|
||
return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) }));
|
||
}
|
||
app.cancel_view_animation();
|
||
match v {
|
||
"fit" => app.fit_view(),
|
||
"home" => {
|
||
app.cam.set_view(StandardView::Iso);
|
||
app.fit_view();
|
||
}
|
||
v => match StandardView::parse(v) {
|
||
Some(sv) => app.cam.set_view(sv),
|
||
None => return err(format!("unknown view `{v}`")),
|
||
},
|
||
}
|
||
ok(json!(app.cam))
|
||
}
|
||
"ui.camera" => wrap(camera(app, p)),
|
||
"ui.start" => {
|
||
let Some(id) = s("command") else { return err("missing `command`") };
|
||
// `params`: what the dialog opens on. Refused: nothing starts, or the dialog closes.
|
||
let params = p.get("params").filter(|v| !v.is_null());
|
||
if params.is_some() && id != "joint.drive" {
|
||
return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values)"));
|
||
}
|
||
app.start(id);
|
||
if let Some(params) = params {
|
||
if app.tree.capture_prompt.is_some() {
|
||
return err("Drive Joints waits for the Capture Position question (ui.capture)");
|
||
}
|
||
let Some(crate::dialogs::Kind::Assembly(k)) = app.dialog.as_mut().map(|d| &mut d.kind) else {
|
||
let why = app.status.as_ref().filter(|s| s.2).map(|s| s.0.clone());
|
||
return err(why.unwrap_or_else(|| "Drive Joints did not open".into()));
|
||
};
|
||
if let Err(e) = crate::dialogs_assembly::preset(&app.session, k, params) {
|
||
crate::dialogs::cancel(app);
|
||
return err(e);
|
||
}
|
||
}
|
||
ok(json!({"tool": app.tool.as_ref().map(|t| t.cmd), "dialog": app.dialog.as_ref().map(|d| format!("{d:?}"))}))
|
||
}
|
||
"ui.move" | "ui.click" => {
|
||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||
if x.abs() > 1e6 || y.abs() > 1e6 {
|
||
return err("x/y must be within ±1000000 screen points");
|
||
}
|
||
let pos = egui::pos2(x as f32, y as f32);
|
||
let modifiers = if method == "ui.move" {
|
||
egui::Modifiers { shift: b("shift"), alt: b("alt"), ctrl: b("ctrl"), command: b("ctrl") || b("cmd"), mac_cmd: b("cmd") }
|
||
} else {
|
||
egui::Modifiers::NONE
|
||
};
|
||
app.synthetic.push(egui::Event::ModifiersChanged(modifiers));
|
||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||
if method == "ui.click" {
|
||
let button = match s("button") {
|
||
Some("right") => egui::PointerButton::Secondary,
|
||
Some("middle") => egui::PointerButton::Middle,
|
||
_ => egui::PointerButton::Primary,
|
||
};
|
||
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
|
||
// `double: true` sends two clicks in one frame, which egui reads as a double-click.
|
||
for _ in 0..if b("double") { 2 } else { 1 } {
|
||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers });
|
||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers });
|
||
}
|
||
}
|
||
ok(Value::Null)
|
||
}
|
||
"ui.drag" => {
|
||
let (Some(x0), Some(y0), Some(x1), Some(y1)) = (f("x0"), f("y0"), f("x1"), f("y1")) else { return err("missing x0/y0/x1/y1") };
|
||
let button = match s("button") {
|
||
Some("right") => egui::PointerButton::Secondary,
|
||
Some("middle") => egui::PointerButton::Middle,
|
||
_ => egui::PointerButton::Primary,
|
||
};
|
||
let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), alt: b("alt"), ..Default::default() };
|
||
let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32));
|
||
// Positioning precedes the press by a frame: it is not a modifier-only gesture.
|
||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
|
||
// `hold`: frames to keep the button down before moving (press-and-hold).
|
||
for _ in 0..f("hold").unwrap_or(0.0).clamp(0.0, 600.0) as usize {
|
||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||
}
|
||
let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize;
|
||
for k in 1..=steps {
|
||
app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32)));
|
||
}
|
||
// `hold_end`: frames to keep the button down at the end (to look at a drag's snap).
|
||
for _ in 0..f("hold_end").unwrap_or(0.0).clamp(0.0, 600.0) as usize {
|
||
app.synthetic.push(egui::Event::PointerMoved(z));
|
||
}
|
||
app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers });
|
||
ok(Value::Null)
|
||
}
|
||
"ui.editFeature" => {
|
||
let key = match p.get("feature") {
|
||
Some(Value::Number(n)) => n.to_string(),
|
||
Some(Value::String(x)) => x.clone(),
|
||
_ => return err("`feature` must be an id or name"),
|
||
};
|
||
let Some(id) = app.session.doc.find_feature(&key).map(|f| f.id) else { return err(format!("no feature `{key}`")) };
|
||
app.edit_feature(id);
|
||
ok(json!({"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")), "marker": app.session.doc.marker}))
|
||
}
|
||
"ui.selection" => ok(json!({
|
||
"selection": app.session.selection,
|
||
"dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::<Vec<_>>()),
|
||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||
"dimensions": crate::dim_view::drawn().into_iter().map(|(id, p)| json!({"id": id, "x": p.x, "y": p.y})).collect::<Vec<_>>(),
|
||
"dimension_selected": crate::dim_view::selected(app),
|
||
"dimension_editing": crate::dim_view::editing(),
|
||
})),
|
||
"ui.triad" => {
|
||
// Screen points of the 3D move triad's handles (x, y, z arrows and the plane square).
|
||
let Some(rect) = app.viewport.rect else { return err("no viewport") };
|
||
let proj = crate::viewport::projection(app, rect);
|
||
ok(json!(
|
||
crate::sketch3d::handle_points(app, &proj).into_iter().map(|(n, p)| json!({"handle": n, "x": p.x, "y": p.y})).collect::<Vec<_>>()
|
||
))
|
||
}
|
||
"ui.dialogInput" => {
|
||
// What clicking a selection input's chip does: make it the one picks go to.
|
||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
match app.dialog.as_mut() {
|
||
Some(d) if i < d.inputs.len() => {
|
||
d.active = i;
|
||
ok(json!({"label": d.inputs.get(i).map(|x| x.label)}))
|
||
}
|
||
_ => err("no dialog input with that index"),
|
||
}
|
||
}
|
||
"ui.handles" => {
|
||
// The handles drawn last frame (toolbar buttons `toolbar:<id>`, panels, browser rows…).
|
||
let list = crate::scenario::handles(s("prefix").unwrap_or(""));
|
||
ok(json!(list.into_iter().map(|(n, p)| json!({"name": n, "x": p.x, "y": p.y})).collect::<Vec<_>>()))
|
||
}
|
||
"ui.at" => {
|
||
if let Some(path) = p.get("occurrence").and_then(Value::as_array) {
|
||
let ids: Option<Vec<u64>> = path.iter().map(Value::as_u64).collect();
|
||
let Some(ids) = ids else { return err("occurrence path must contain integer ids") };
|
||
let key = ids.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
|
||
let control = s("control").unwrap_or("row");
|
||
return match crate::scenario::handle_at(&format!("{control}:occurrence:{key}")) {
|
||
Some(q) => ok(json!([q.x, q.y])),
|
||
None => err("occurrence row or control is not shown"),
|
||
};
|
||
}
|
||
// A scenario point: {world: [x,y,z]} | {sketch: [x,y]} | {plane: "XY"} (the middle of an
|
||
// origin plane's square) → screen point.
|
||
if let Some(h) = p.get("handle").and_then(Value::as_str) {
|
||
return match crate::scenario::handle_at(h) {
|
||
Some(q) => ok(json!([q.x, q.y])),
|
||
None => err(format!("handle {h} is not shown")),
|
||
};
|
||
}
|
||
// A browser row by its label: the row itself, or its fold arrow.
|
||
for (k, chevron) in [("browser", false), ("chevron", true)] {
|
||
if let Some(label) = p.get(k).and_then(Value::as_str) {
|
||
let rows = crate::browser::drawn_rows();
|
||
let found = rows
|
||
.iter()
|
||
.find(|r| r.0 == label)
|
||
.and_then(|r| if chevron { r.2.map(|f| f.center()) } else { Some(r.1.left_center() + egui::vec2(120.0, 0.0)) });
|
||
return match found {
|
||
Some(q) => ok(json!([q.x, q.y])),
|
||
None => err(format!("no browser row `{label}`{} on screen", if chevron { " with a fold arrow" } else { "" })),
|
||
};
|
||
}
|
||
}
|
||
if let Some(d) = p.get("dimension").and_then(Value::as_str) {
|
||
return match crate::dim_view::text_at(d) {
|
||
Some(q) => ok(json!([q.x, q.y])),
|
||
None => err(format!("dimension {d} is not shown")),
|
||
};
|
||
}
|
||
let Some(rect) = app.viewport.rect else { return err("no viewport") };
|
||
let proj = crate::viewport::projection(app, rect);
|
||
let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||
let world = if let Some(w) = p.get("world") {
|
||
Some(solidcraft_engine::geom::Vec3::new(num(w, 0), num(w, 1), num(w, 2)))
|
||
} else if let Some(s) = p.get("sketch") {
|
||
let st = app.session.world_state();
|
||
app.session
|
||
.active_sketch
|
||
.and_then(|id| st.sketch(id))
|
||
.map(|ss| ss.plane.to_world(solidcraft_engine::geom::Vec2::new(num(s, 0), num(s, 1))))
|
||
} else if let Some(n) = p.get("plane").and_then(Value::as_str) {
|
||
crate::viewport::origin_plane_target(app, &proj, n)
|
||
} else if let Some(n) = p.get("axis").and_then(Value::as_str) {
|
||
// Along an origin axis, on its solid part.
|
||
let size = crate::selection::origin_size(app.cam.half_height());
|
||
crate::viewport::origin_axes(app).into_iter().find(|(name, _)| *name == n).map(|(_, d)| d * (size * 0.8))
|
||
} else if let Some(n) = p.get("gizmo").and_then(Value::as_str) {
|
||
// A handle of the move triad ("x", "xy", "rz", "ball"), moved `by` [x,y,z] mm or
|
||
// turned `turn` degrees (rings).
|
||
let by = p.get("by").map(|b| solidcraft_engine::geom::Vec3::new(num(b, 0), num(b, 1), num(b, 2))).unwrap_or_default();
|
||
crate::gizmo::handle_point(n, by, p.get("turn").and_then(Value::as_f64).unwrap_or(0.0))
|
||
} else {
|
||
None
|
||
};
|
||
match world.and_then(|w| proj.to_screen(w)) {
|
||
Some(q) => ok(json!([q.x, q.y])),
|
||
None => err(format!("not on screen: {}", p)),
|
||
}
|
||
}
|
||
"ui.worldToScreen" => {
|
||
let Some(rect) = app.viewport.rect else { return err("no viewport") };
|
||
let proj = crate::viewport::projection(app, rect);
|
||
let pts: Vec<Value> = p
|
||
.get("points")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.take(1000)
|
||
.map(|v| {
|
||
let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||
proj.to_screen(solidcraft_engine::geom::Vec3::new(c(0), c(1), c(2))).map_or(Value::Null, |q| json!([q.x, q.y]))
|
||
})
|
||
.collect();
|
||
ok(json!(pts))
|
||
}
|
||
"ui.sketchToScreen" => {
|
||
// Screen points of active-sketch coordinates (for driving sketch interaction).
|
||
let st = app.session.world_state();
|
||
let (Some(rect), Some(ss)) = (app.viewport.rect, app.session.active_sketch.and_then(|s| st.sketch(s))) else {
|
||
return err("no active sketch in a drawn viewport");
|
||
};
|
||
let proj = crate::viewport::projection(app, rect);
|
||
let pts: Vec<Value> = p
|
||
.get("points")
|
||
.and_then(Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.take(1000)
|
||
.map(|v| {
|
||
let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||
let q = solidcraft_engine::geom::Vec2::new(c(0), c(1));
|
||
proj.to_screen(ss.plane.to_world(q)).map_or(Value::Null, |p| json!([p.x, p.y]))
|
||
})
|
||
.collect();
|
||
ok(json!(pts))
|
||
}
|
||
"ui.scroll" => {
|
||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32)));
|
||
// Mouse-wheel semantics (lines): zooms regardless of the trackpad pan
|
||
// preference, so scripts and agents keep working. Trackpads report points.
|
||
let speed = ctx.options(|o| o.input_options.line_scroll_speed).max(1.0);
|
||
let lines = f("delta").unwrap_or(-100.0) / f64::from(speed);
|
||
app.synthetic.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
delta: egui::vec2(0.0, lines as f32),
|
||
modifiers: Default::default(),
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
ok(Value::Null)
|
||
}
|
||
"ui.key" => {
|
||
let Some(key) = s("key").and_then(key_from) else { return err("unknown key") };
|
||
let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() };
|
||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
||
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
||
ok(Value::Null)
|
||
}
|
||
"ui.text" => {
|
||
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
||
ok(Value::Null)
|
||
}
|
||
"ui.menu" => {
|
||
if b("close") {
|
||
crate::context_menu::close(app);
|
||
return ok(json!({"open": false}));
|
||
}
|
||
if let (Some(x), Some(y)) = (f("x"), f("y")) {
|
||
let target = match p.get("target") {
|
||
Some(v) => match serde_json::from_value::<crate::context_menu::Target>(v.clone()) {
|
||
Ok(t) => t,
|
||
Err(e) => return err(e),
|
||
},
|
||
None => crate::context_menu::Target::Viewport,
|
||
};
|
||
crate::context_menu::request_open(app, egui::pos2(x as f32, y as f32), target);
|
||
return ok(json!({"pending": true}));
|
||
}
|
||
ok(crate::context_menu::describe(app))
|
||
}
|
||
"ui.confirm" => match p.get("accept").and_then(Value::as_bool) {
|
||
Some(a) => {
|
||
if crate::delete::confirm(app, a) {
|
||
ok(json!({"accepted": a}))
|
||
} else {
|
||
err("nothing is waiting for a confirmation")
|
||
}
|
||
}
|
||
None => ok(json!({"pending": app.menu.confirm.as_ref().map(|c| c.report.clone())})),
|
||
},
|
||
"ui.shortcut" => {
|
||
let Some(id) = s("command") else { return err("missing `command`") };
|
||
match s("key") {
|
||
Some(k) => match crate::keymap::bind(app, id, k, b("replace")) {
|
||
Ok(()) => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
|
||
Err(e) => err(e),
|
||
},
|
||
None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
|
||
}
|
||
}
|
||
"ui.browser" => ok(json!({
|
||
"occurrences": crate::browser::drawn_occurrences().iter().map(|(path, label, r, open)| json!({"path": path, "label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||
"rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||
"selection": app.session.selection,
|
||
"picked_components": app.tree.picked_components,
|
||
})),
|
||
"ui.capture" => match s("action") {
|
||
Some(a) => match crate::browser::answer_capture(app, a) {
|
||
Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})),
|
||
Err(e) => err(e),
|
||
},
|
||
None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})),
|
||
},
|
||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||
"ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)),
|
||
"ui.help" => match s("item") {
|
||
Some(i @ ("about" | "shortcuts" | "report" | "crashes")) => {
|
||
crate::help::run(app, ctx, i);
|
||
ok(
|
||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||
)
|
||
}
|
||
_ => ok(
|
||
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
|
||
),
|
||
},
|
||
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
|
||
"ui.menuPick" => {
|
||
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
|
||
wrap(crate::context_menu::pick(app, key))
|
||
}
|
||
"ui.rename" => {
|
||
if crate::context_menu::finish_rename(app, s("text"), p.get("commit").and_then(Value::as_bool).unwrap_or(true)) {
|
||
ok(json!({"done": true}))
|
||
} else {
|
||
err("no rename in progress")
|
||
}
|
||
}
|
||
"ui.window" => match crate::titlebar::window_action(app, ctx, s("action").unwrap_or("")) {
|
||
Ok(()) => ok(json!({"custom_titlebar": app.custom_titlebar, "maximized": ctx.input(|i| i.viewport().maximized)})),
|
||
Err(e) => err(e),
|
||
},
|
||
"ui.resize" => {
|
||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||
ok(Value::Null)
|
||
}
|
||
"ui.screenshot" => match s("path").map(vfs::check) {
|
||
Some(Err(e)) => err(e),
|
||
_ => Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||
},
|
||
"ui.render" => {
|
||
let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as u32;
|
||
let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as u32;
|
||
// As the window shows it (`view_scene`). The grid and the axes as the window shows
|
||
// them, unless the request says: `grid` and `axes` leave them out or put them in.
|
||
let shown = window_guides(app);
|
||
let guides = solidcraft_engine::view::Guides {
|
||
grid: p.get("grid").and_then(Value::as_bool).unwrap_or(shown.grid),
|
||
axes: p.get("axes").and_then(Value::as_bool).unwrap_or(shown.axes),
|
||
};
|
||
// A path outside the request's folders is refused before anything is drawn.
|
||
if let Some(Err(e)) = s("path").map(vfs::check) {
|
||
return err(e);
|
||
}
|
||
let scene = view_scene(app, guides);
|
||
let png = match solidcraft_engine::capture::png(&scene, &app.cam, w, h) {
|
||
Ok(png) => png,
|
||
Err(e) => return err(format!("render failed: {e}")),
|
||
};
|
||
// What drew it (gpu, gpu-software or cpu), and the warning a CPU render carries.
|
||
let (renderer, warning) = (png.renderer, solidcraft_engine::capture::warning(png.renderer));
|
||
match s("path") {
|
||
Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png.bytes)) {
|
||
Ok(()) => ok(json!({"path": path, "bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
|
||
Err(e) => err(e),
|
||
},
|
||
None => ok(json!({"bytes": png.bytes.len(), "renderer": renderer, "warning": warning})),
|
||
}
|
||
}
|
||
"app.quit" => {
|
||
// `force`: quit without asking about unsaved designs.
|
||
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||
app.quit_requested = true;
|
||
} else {
|
||
crate::documents::request_quit(app);
|
||
}
|
||
if app.quit_requested {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||
}
|
||
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
|
||
}
|
||
other => err(format!("unknown method `{other}`")),
|
||
}
|
||
}
|
||
|
||
/// `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;
|
||
let mut buf = Vec::with_capacity(w * h * 4);
|
||
for c in &image.pixels {
|
||
buf.extend_from_slice(&c.to_array());
|
||
}
|
||
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) };
|
||
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("solidcraft-shot.png").to_string_lossy().to_string());
|
||
match img.save(&path) {
|
||
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
||
Err(e) => json!({"ok": false, "error": e.to_string()}),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
use solidcraft_engine::{Sel, Session};
|
||
|
||
use super::*;
|
||
use crate::Services;
|
||
|
||
#[test]
|
||
fn touchpad_automation_moves_with_modifiers_and_releases_without_buttons() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.nav = "touchpad".into();
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
let before = h.app.cam;
|
||
h.call("ui.move", json!({"x":800,"y":500,"shift":true}));
|
||
h.step(&json!({"move":[830,520],"shift":true})).unwrap();
|
||
assert_ne!(h.app.cam.target, before.target);
|
||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||
h.call("ui.move", json!({"x":830,"y":520,"alt":true}));
|
||
h.step(&json!({"move":[850,530],"alt":true})).unwrap();
|
||
assert!((h.app.cam.yaw - before.yaw - 0.16).abs() < 1e-6);
|
||
let after = h.app.cam;
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
h.frames(3);
|
||
assert_eq!(h.app.cam, after);
|
||
assert!(h.ctx.input(|i| i.modifiers.is_none()));
|
||
assert!(h.app.session.selection.is_empty());
|
||
assert!(h.app.viewport.boxsel.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn touchpad_drag_positioning_releases_previous_motion_modifiers() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app.preferences.nav = "touchpad".into();
|
||
h.call("ui.move", json!({"x":800,"y":500}));
|
||
for modifier in ["shift", "alt"] {
|
||
let mut params = json!({"x":800,"y":500});
|
||
params[modifier] = json!(true);
|
||
h.call("ui.move", params);
|
||
let before = h.app.cam;
|
||
h.call("ui.drag", json!({"x0":900,"y0":500,"x1":930,"y1":520,"shift":true,"steps":2}));
|
||
assert_eq!(h.app.cam, before, "positioning a selection drag must not inherit {modifier} motion");
|
||
assert!(!h.ctx.input(|i| i.pointer.any_down()));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn touchpad_text_prefix_does_not_collapse_queued_pointer_frames() {
|
||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||
let ctx = egui::Context::default();
|
||
let (req, _) = ControlRequest::new("ui.text", json!({"text":"prefix"}));
|
||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
|
||
let (req, _) = ControlRequest::new("ui.move", params);
|
||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||
}
|
||
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
|
||
let mut raw = egui::RawInput::default();
|
||
app.raw_input_hook(&mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
ui.input(|i| {
|
||
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
|
||
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
|
||
});
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
assert!(app.synthetic.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn touchpad_automation_preserves_each_queued_moves_modifiers() {
|
||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||
let ctx = egui::Context::default();
|
||
for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] {
|
||
let (req, _) = ControlRequest::new("ui.move", params);
|
||
assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_)));
|
||
}
|
||
for (p, shift, alt) in [(10.0, true, false), (20.0, false, true), (30.0, false, false)] {
|
||
let mut raw = egui::RawInput::default();
|
||
app.raw_input_hook(&mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
ui.input(|i| {
|
||
assert_eq!(i.pointer.hover_pos(), Some(egui::pos2(p, p)));
|
||
assert_eq!((i.modifiers.shift, i.modifiers.alt), (shift, alt));
|
||
assert!(!i.pointer.any_down());
|
||
});
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
assert!(app.synthetic.is_empty());
|
||
}
|
||
|
||
fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec<u8> {
|
||
let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140}));
|
||
match handle(app, &egui::Context::default(), &req) {
|
||
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
|
||
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
|
||
}
|
||
std::fs::read(path).unwrap()
|
||
}
|
||
|
||
/// `ui.render` draws in the window's theme (#974): its background is the viewport's, light
|
||
/// or dark.
|
||
#[test]
|
||
fn model_render_is_in_the_windows_theme() {
|
||
if !crate::gpu::test_picture_renderer() {
|
||
return;
|
||
}
|
||
let mut s = Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||
let mut app = SolveApp::new(s, Services::default());
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-render-theme-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
for dark in [false, true, false] {
|
||
app.ui.dark = dark;
|
||
let png = render(&mut app, &dir.join(format!("dark-{dark}.png")));
|
||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||
// The top row is the background's top: its most common colour.
|
||
let mut counts = std::collections::HashMap::new();
|
||
for x in 0..img.width() {
|
||
*counts.entry(img.get_pixel(x, 0).0).or_insert(0) += 1;
|
||
}
|
||
let top = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap();
|
||
let want = if dark { crate::theme::Tokens::dark() } else { crate::theme::Tokens::light() }.viewport_top;
|
||
let near = top.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
|
||
assert!(near, "dark {dark}: the render's top row is {top:?}, the window's background {want:?}");
|
||
}
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
/// A model render shows the section the window shows: the open Section Analysis dialog's
|
||
/// preview too, not only a committed `inspect.section`.
|
||
#[test]
|
||
fn model_render_shows_the_section_dialog_preview() {
|
||
if !crate::gpu::test_picture_renderer() {
|
||
return;
|
||
}
|
||
let mut s = Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||
let mut app = SolveApp::new(s, Services::default());
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-render-section-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let whole = render(&mut app, &dir.join("whole.png"));
|
||
app.session.selection = vec![Sel::Plane { name: "XZ".into() }];
|
||
app.start("inspect.section");
|
||
let Some(crate::dialogs::Kind::Section { offset, .. }) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("no section dialog") };
|
||
*offset = "15 mm".into();
|
||
assert!(app.session.section.is_none() && crate::viewport::section_plane(&app).is_some());
|
||
let preview = render(&mut app, &dir.join("preview.png"));
|
||
assert!(whole != preview, "the model render ignores the section the window shows");
|
||
// Committed, it renders the same.
|
||
app.run("inspect.section", json!({"plane": "XZ", "offset": 15})).unwrap();
|
||
app.dialog = None;
|
||
assert!(render(&mut app, &dir.join("committed.png")) == preview, "the committed section renders differently");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// `ui.render` with extra request parameters.
|
||
fn render_with(app: &mut SolveApp, path: &std::path::Path, extra: serde_json::Value) -> Vec<u8> {
|
||
let mut params = json!({"path": path.to_string_lossy(), "width": 200, "height": 140});
|
||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||
params[k.as_str()] = v.clone();
|
||
}
|
||
let (req, _rx) = ControlRequest::new("ui.render", params);
|
||
match handle(app, &egui::Context::default(), &req) {
|
||
Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"),
|
||
Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"),
|
||
}
|
||
std::fs::read(path).unwrap()
|
||
}
|
||
|
||
/// A model render draws the ground grid (it cuts through a part that straddles Z 0) and the
|
||
/// origin's axes as the window does, by the Grid and Origin switches, and `grid` and `axes`
|
||
/// in the request say otherwise for that render.
|
||
#[test]
|
||
fn model_render_follows_the_grid_and_origin_switches_unless_asked() {
|
||
if !crate::gpu::test_picture_renderer() {
|
||
return;
|
||
}
|
||
let mut s = Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]})).unwrap();
|
||
let mut app = SolveApp::new(s, Services::default());
|
||
app.cam.set_view(solidcraft_engine::render::StandardView::Front);
|
||
let dir = std::env::temp_dir().join(format!("solidcraft-render-guides-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("shot.png");
|
||
let shot = |app: &mut SolveApp, extra: serde_json::Value| render_with(app, &path, extra);
|
||
let all = shot(&mut app, json!({}));
|
||
let no_grid = shot(&mut app, json!({"grid": false}));
|
||
let no_axes = shot(&mut app, json!({"axes": false}));
|
||
let bare = shot(&mut app, json!({"grid": false, "axes": false}));
|
||
let pictures = [&all, &no_grid, &no_axes, &bare];
|
||
for (i, a) in pictures.iter().enumerate() {
|
||
for b in &pictures[i + 1..] {
|
||
assert!(a != b, "two of the pictures are the same");
|
||
}
|
||
}
|
||
// With the Grid switch off the render has no grid, and `grid: true` brings it back.
|
||
app.ui.show_grid = false;
|
||
assert!(shot(&mut app, json!({})) == no_grid);
|
||
assert!(shot(&mut app, json!({"grid": true})) == all);
|
||
// With the Origin off too it has neither; `axes: true` puts the axes back.
|
||
app.ui.show_origin = false;
|
||
assert!(shot(&mut app, json!({})) == bare);
|
||
assert!(shot(&mut app, json!({"axes": true})) == no_grid);
|
||
// The Origin and the Grid back on: the first picture again.
|
||
app.ui.show_grid = true;
|
||
app.ui.show_origin = true;
|
||
assert!(shot(&mut app, json!({})) == all);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
fn batch(h: &mut crate::scenario::Harness, params: serde_json::Value) -> serde_json::Value {
|
||
h.call("engine.batch", params)
|
||
}
|
||
|
||
fn inspect(h: &mut crate::scenario::Harness) -> serde_json::Value {
|
||
h.call("document.inspect", json!({}))["result"].clone()
|
||
}
|
||
|
||
/// Timing fields (`ms`) differ from run to run.
|
||
fn without_timings(v: &serde_json::Value) -> serde_json::Value {
|
||
match v {
|
||
serde_json::Value::Object(o) => o.iter().filter(|(k, _)| *k != "ms").map(|(k, v)| (k.clone(), without_timings(v))).collect(),
|
||
serde_json::Value::Array(a) => a.iter().map(without_timings).collect(),
|
||
v => v.clone(),
|
||
}
|
||
}
|
||
|
||
/// #743: a batch is one request through the app's queue, answered within a frame, however
|
||
/// many steps it has.
|
||
#[test]
|
||
fn engine_batch_runs_in_one_frame() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let steps: Vec<_> = (0..40)
|
||
.flat_map(|_| [json!({"command": "sketch.create", "params": {"plane": "XY"}}), json!({"command": "sketch.finish", "params": {}})])
|
||
.collect();
|
||
let (v, frames) = h.call_queued("engine.batch", json!({"commands": steps}));
|
||
assert_eq!(v["ok"], true, "{v}");
|
||
assert_eq!(v["result"]["completed"], 80, "{v}");
|
||
assert_eq!(v["result"]["results"].as_array().map(Vec::len), Some(80));
|
||
assert!(v["result"].get("failed").is_none(), "{v}");
|
||
assert!(frames <= 2, "a batch of 80 steps took {frames} frames");
|
||
assert_eq!(inspect(&mut h)["sketches"].as_array().map(Vec::len), Some(40));
|
||
}
|
||
|
||
#[test]
|
||
fn engine_batch_rolls_back_at_the_first_failure() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
assert_eq!(h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}))["ok"], true);
|
||
let before = inspect(&mut h);
|
||
let v = batch(
|
||
&mut h,
|
||
json!({"commands": [
|
||
{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}},
|
||
{"command": "solid.extrude", "params": {"distance": "bogus +"}},
|
||
{"command": "solid.sphere", "params": {"radius": 3, "center": [80, 0, 0]}},
|
||
]}),
|
||
);
|
||
assert_eq!(v["ok"], true, "{v}");
|
||
let r = &v["result"];
|
||
assert_eq!((r["completed"].clone(), r["failed"]["index"].clone(), r["undone"].clone()), (json!(1), json!(1), json!(1)), "{v}");
|
||
assert_eq!(r["failed"]["command"], "solid.extrude");
|
||
let after = inspect(&mut h);
|
||
assert_eq!(after["undo"], before["undo"], "{after}");
|
||
assert_eq!(after["bodies"], before["bodies"]);
|
||
assert_eq!(without_timings(&after["timeline"]), without_timings(&before["timeline"]));
|
||
}
|
||
|
||
#[test]
|
||
fn engine_batch_without_rollback_keeps_completed_steps() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let v = batch(
|
||
&mut h,
|
||
json!({"rollback": false, "commands": [
|
||
{"command": "solid.sphere", "params": {"radius": 3}},
|
||
{"command": "NoSuchCommand"},
|
||
]}),
|
||
);
|
||
assert_eq!(v["result"]["failed"]["index"], 1, "{v}");
|
||
assert_eq!(v["result"]["undone"], 0, "{v}");
|
||
assert_eq!(inspect(&mut h)["bodies"].as_array().map(Vec::len), Some(1));
|
||
}
|
||
|
||
#[test]
|
||
fn engine_batch_step_without_a_command_fails_that_step() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let v = batch(&mut h, json!({"commands": [{"params": {}}]}));
|
||
assert_eq!(v["result"]["failed"]["index"], 0, "{v}");
|
||
assert!(v["result"]["failed"]["error"].as_str().is_some_and(|e| e.contains("missing `command`")), "{v}");
|
||
}
|
||
|
||
#[test]
|
||
fn engine_batch_is_refused_when_a_step_loads_a_file() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let v = batch(
|
||
&mut h,
|
||
json!({"commands": [
|
||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}},
|
||
{"command": "doc.open", "params": {"path": "elsewhere.solidcraft"}},
|
||
]}),
|
||
);
|
||
assert_eq!(v["ok"], false, "{v}");
|
||
assert_eq!(v["error"], "engine.batch unavailable: step 1 (doc.open) loads a file", "{v}");
|
||
assert_eq!(inspect(&mut h)["bodies"].as_array().map(Vec::len), Some(0), "nothing may run before the refusal");
|
||
}
|
||
|
||
#[test]
|
||
fn engine_batch_is_refused_while_the_agent_cursor_is_shown() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
assert_eq!(h.call("ui.agent_cursor", json!({"show": true}))["ok"], true);
|
||
let v = batch(&mut h, json!({"commands": [{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}]}));
|
||
assert_eq!(v["ok"], false, "{v}");
|
||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("engine.batch unavailable")), "{v}");
|
||
assert_eq!(inspect(&mut h)["bodies"].as_array().map(Vec::len), Some(0));
|
||
}
|
||
|
||
/// Every step of a batch from an MCP server stays inside the agent's folders (#205), as an
|
||
/// `engine.execute` would.
|
||
#[test]
|
||
fn engine_batch_stays_inside_allow_paths() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let base = vfs::temp_dir().join(format!("solidcraft-batch-scope-{}", std::process::id()));
|
||
let (root, out) = (base.join("root"), base.join("out"));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let export = |d: &std::path::Path| json!({"command": "file.export", "params": {"path": d.join("a.step").to_string_lossy()}});
|
||
let v = batch(
|
||
&mut h,
|
||
json!({"allow_paths": [root.to_string_lossy()], "commands": [
|
||
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}},
|
||
export(&root),
|
||
export(&out),
|
||
]}),
|
||
);
|
||
assert_eq!(v["result"]["failed"]["index"], 2, "{v}");
|
||
assert!(v["result"]["failed"]["error"].as_str().is_some_and(|e| e.contains("outside the folders this agent may use")), "{v}");
|
||
assert!(root.join("a.step").exists());
|
||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written outside");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// An MCP server's `doc.open` and imports load off the window thread (`file_load`), where
|
||
/// the agent's folders hold as they do for every other request.
|
||
#[test]
|
||
fn file_loads_stay_inside_allow_paths() {
|
||
let mut h = crate::scenario::Harness::new();
|
||
let base = vfs::temp_dir().join(format!("solidcraft-load-scope-{}", std::process::id()));
|
||
let (root, out) = (base.join("root"), base.join("out"));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let mut s = Session::default();
|
||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
for d in [&root, &out] {
|
||
std::fs::write(d.join("part.solidcraft"), solidcraft_engine::io::write_design(&s.doc)).unwrap();
|
||
}
|
||
s.execute("file.export", &json!({"path": out.join("part.step").to_string_lossy()})).unwrap();
|
||
let load = |command: &str, d: &std::path::Path, file: &str| json!({"command": command, "params": {"path": d.join(file).to_string_lossy()}, "allow_paths": [root.to_string_lossy()]});
|
||
for (command, file) in [("doc.open", "part.solidcraft"), ("file.insert_step", "part.step")] {
|
||
let (v, _) = h.call_queued("engine.execute", load(command, &out, file));
|
||
assert!(v["ok"] == false && v.to_string().contains("outside the folders this agent may use"), "{command}: {v}");
|
||
}
|
||
let (v, _) = h.call_queued("engine.execute", load("doc.open", &root, "part.solidcraft"));
|
||
assert_eq!(v["ok"], true, "{v}");
|
||
let (v, _) = h.call_queued("engine.execute", json!({"command": "doc.open", "params": {"path": "x"}, "allow_paths": "/"}));
|
||
assert!(v["error"].as_str().is_some_and(|e| e.contains("allow_paths")), "{v}");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
/// Each step runs as `engine.execute` runs it: same results, same design.
|
||
#[test]
|
||
fn engine_batch_matches_engine_execute() {
|
||
let steps = [
|
||
json!({"command": "sketch.create", "params": {"plane": "XY"}}),
|
||
json!({"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}),
|
||
json!({"command": "sketch.finish", "params": {}}),
|
||
json!({"command": "solid.extrude", "params": {"distance": 20}}),
|
||
];
|
||
let mut a = crate::scenario::Harness::new();
|
||
let batched = batch(&mut a, json!({"commands": steps}));
|
||
let mut b = crate::scenario::Harness::new();
|
||
let one_by_one: Vec<_> = steps.iter().map(|s| b.call("engine.execute", s.clone())["result"].clone()).collect();
|
||
assert_eq!(without_timings(&batched["result"]["results"]), without_timings(&json!(one_by_one)), "{batched}");
|
||
assert_eq!(without_timings(&inspect(&mut a)["timeline"]), without_timings(&inspect(&mut b)["timeline"]));
|
||
assert_eq!(a.app.cam, b.app.cam, "the first-body fit must happen as for engine.execute");
|
||
}
|
||
|
||
/// A request carrying `allow_paths` (from an MCP server, #205) reaches files only inside
|
||
/// those folders; the same request without it, like the user's own dialogs, is unconfined.
|
||
#[test]
|
||
fn requests_with_allow_paths_stay_inside_them() {
|
||
let mut s = Session::default();
|
||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||
let mut app = SolveApp::new(s, Services::default());
|
||
let base = vfs::temp_dir().join(format!("solidcraft-control-scope-{}", std::process::id()));
|
||
let (root, out) = (base.join("root"), base.join("out"));
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
std::fs::create_dir_all(&out).unwrap();
|
||
let at = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().to_string();
|
||
let mut call = |method: &str, mut params: Value, scoped: bool| {
|
||
if scoped {
|
||
params["allow_paths"] = json!([root.to_string_lossy()]);
|
||
}
|
||
let (req, _rx) = ControlRequest::new(method, params);
|
||
match handle(&mut app, &egui::Context::default(), &req) {
|
||
Outcome::Done(v) => v,
|
||
Outcome::Screenshot { path } => json!({"ok": true, "screenshot": path}),
|
||
}
|
||
};
|
||
let export = |p: String| json!({"command": "file.export", "params": {"path": p}});
|
||
assert_eq!(call("engine.execute", export(at(&root, "a.step")), true)["ok"], true);
|
||
if crate::gpu::test_picture_renderer() {
|
||
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
|
||
}
|
||
assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true);
|
||
for (method, params) in [
|
||
("engine.execute", export(at(&out, "a.step"))),
|
||
("engine.execute", export(at(&root, "../out/a.step"))),
|
||
("engine.script", json!({"commands": [{"command": "file.export", "params": {"path": at(&out, "a.step")}}]})),
|
||
("ui.render", json!({"path": at(&out, "a.png"), "width": 32, "height": 32})),
|
||
("ui.screenshot", json!({"path": at(&out, "b.png")})),
|
||
] {
|
||
let v = call(method, params, true);
|
||
assert!(v["ok"] == false && v.to_string().contains("outside the folders this agent may use"), "{method}: {v}");
|
||
}
|
||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written outside");
|
||
assert_eq!(call("engine.execute", export(at(&out, "a.step")), false)["ok"], true, "without allow_paths: unconfined");
|
||
let bad = call("engine.execute", json!({"command": "file.new", "allow_paths": "/"}), false);
|
||
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.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();
|
||
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}");
|
||
}
|
||
|
||
/// `ui.start` params set what a dialog opens on: Drive Joints on a joint (by name or id) at
|
||
/// the given values, driving nothing yet. A param it doesn't take is refused and closes it.
|
||
#[test]
|
||
fn ui_start_params_open_drive_joints_where_asked() {
|
||
let mut s = Session::default();
|
||
for (name, x) in [("A", 0), ("B", 30), ("C", 60)] {
|
||
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();
|
||
s.execute("occurrence.ground", &json!({"occurrence": "A:1", "grounded": true})).unwrap();
|
||
let at = |o: &str, x: i32| json!({"occurrence": o, "point": [x, 10, 5], "z": [1, 0, 0]});
|
||
s.execute("joint.create", &json!({"type": "revolute", "a": at("A", 25), "b": at("B", 25), "flip": true, "name": "Hinge"})).unwrap();
|
||
s.execute("joint.create", &json!({"type": "slider", "a": at("B", 55), "b": at("C", 55), "flip": true, "name": "Rail"})).unwrap();
|
||
let mut h = crate::scenario::Harness::new();
|
||
h.app = SolveApp::new(s, Services::default());
|
||
let before = h.app.session.doc.clone();
|
||
let drive = |h: &crate::scenario::Harness| match h.app.dialog.as_ref().map(|d| &d.kind) {
|
||
Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Drive { joint, values, .. })) => {
|
||
(h.app.session.doc.assembly.joints[*joint].name.clone(), values.clone())
|
||
}
|
||
other => panic!("{other:?}"),
|
||
};
|
||
let r = h.call("ui.start", json!({"command": "joint.drive", "params": {"joint": "Rail", "values": [12.5]}}));
|
||
assert_eq!(r["ok"], true, "{r}");
|
||
assert_eq!(drive(&h), ("Rail".to_string(), vec!["12.5 mm".to_string()]));
|
||
let hinge = h.app.session.doc.assembly.joints[0].id;
|
||
h.call("ui.start", json!({"command": "joint.drive", "params": {"joint": hinge, "values": ["45 deg"]}}));
|
||
assert_eq!(drive(&h), ("Hinge".to_string(), vec!["45 deg".to_string()]));
|
||
assert_eq!(h.app.session.doc, before, "nothing is driven until OK");
|
||
for bad in [json!({"joint": "Nope"}), json!({"joint": "Rail", "value": 3}), json!({"values": [1, 2]}), json!({"values": [f64::MAX]})] {
|
||
let r = h.call("ui.start", json!({"command": "joint.drive", "params": bad}));
|
||
assert_eq!(r["ok"], false, "{bad}: {r}");
|
||
assert!(h.app.dialog.is_none(), "{bad}");
|
||
}
|
||
for command in ["solid.extrude", "sketch.create", "inspect.zebra"] {
|
||
let r = h.call("ui.start", json!({"command": command, "params": {"distance": 5}}));
|
||
assert!(r["ok"] == false && r["error"].as_str().unwrap().contains("takes no ui.start params"), "{r}");
|
||
assert!(h.app.dialog.is_none() && h.app.tool.is_none() && h.app.session.analysis.is_none(), "{command} started nothing");
|
||
}
|
||
}
|
||
}
|