QA targets for the newest UX reports (pending until their owners land): s37 browser chevrons on every node kind (all toggle; no Joints folder yet), s38 view animations to Top/Bottom end without a jump (the last frame turns 22.5 degrees today), s39 view cube edge/corner hover lights 2/3 patches, s40 toolbar Pin/Remove/reorder survive a restart and the workspace switcher spans the toolbar; ui_layout tests: every command dialog docked right at 260-380 px (or 480-560 for tables; the exploded view is 635), other windows fit on screen. Harness: published rects and counts, browser rows/fold arrows as handles, camera and view_anim steps, ui.at cube points, settings carried over a close restart, an adhoc scenario runner
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
1e8fc25976
commit
c45f3173fa
11 files changed
+831
-9
No files matched your search
@@ -281,6 +281,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
|
||||
out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked();
|
||||
crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center());
|
||||
}
|
||||
x += 14.0;
|
||||
// Eye.
|
||||
@@ -322,6 +323,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
badge(ui.painter(), br, b, t.text_dim);
|
||||
bx += 15.0;
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
|
||||
out.resp = Some(resp.clone());
|
||||
out.clicked = resp.clicked();
|
||||
out.double = resp.double_clicked();
|
||||
|
||||
@@ -264,6 +264,12 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
};
|
||||
}
|
||||
let Some(rect) = app.viewport.rect else { return err("no viewport") };
|
||||
if let Some(c) = p.get("cube") {
|
||||
// A point on the view cube, in cube units (faces at ±1).
|
||||
let n = |i: usize| c.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let q = crate::viewport::cube_point(app, rect, solvecraft_engine::geom::Vec3::new(n(0), n(1), n(2)));
|
||||
return ok(json!([q.x, q.y]));
|
||||
}
|
||||
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") {
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
//! folder (Save pickers save there)
|
||||
//! - `{"shot": "name"}`: a screenshot when run in a window; headless, a render of the model
|
||||
//! into `$SOLVECRAFT_SCENARIO_SHOTS` when that is set; `{"debug": 1}`
|
||||
//! prints the UI state
|
||||
//! prints the UI state (`"widgets"`: also every widget's text; `"handles"`: every published
|
||||
//! handle, browser rows as `row:<label>` and their fold arrows as `fold:<label>`)
|
||||
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
|
||||
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
|
||||
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
|
||||
//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`])
|
||||
//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" |
|
||||
@@ -40,26 +43,47 @@ thread_local! {
|
||||
/// Drag handles drawn this frame (name, screen point): manipulators publish theirs so
|
||||
/// scenarios (and the control channel's `ui.at {handle}`) can grab them.
|
||||
static HANDLES: RefCell<Vec<(String, egui::Pos2)>> = const { RefCell::new(Vec::new()) };
|
||||
/// Painted parts with no widget of their own (the toolbar, the workspace switcher…): rects
|
||||
/// and counts by name, for checks.
|
||||
static RECTS: RefCell<Vec<(String, egui::Rect)>> = const { RefCell::new(Vec::new()) };
|
||||
static COUNTS: RefCell<Vec<(String, f64)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// A manipulator's handle drawn this frame at `at` (screen), by name: "arrow" (a dialog's value
|
||||
/// arrow), "triad_x"… Call it from the code that draws the handle, every frame it shows.
|
||||
pub fn publish_handle(name: &str, at: egui::Pos2) {
|
||||
HANDLES.with(|h| {
|
||||
let mut h = h.borrow_mut();
|
||||
h.retain(|(n, _)| n != name);
|
||||
h.push((name.to_string(), at));
|
||||
});
|
||||
// The latest wins (lookups search from the end), so publishing is cheap for browser rows.
|
||||
HANDLES.with(|h| h.borrow_mut().push((name.to_string(), at)));
|
||||
}
|
||||
|
||||
/// Where a handle was drawn last frame.
|
||||
pub fn handle_at(name: &str) -> Option<egui::Pos2> {
|
||||
HANDLES.with(|h| h.borrow().iter().find(|(n, _)| n == name).map(|x| x.1))
|
||||
HANDLES.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
|
||||
}
|
||||
|
||||
/// Forget last frame's handles (called as a frame starts).
|
||||
/// A painted part's rect this frame, by name ("toolbar", "workspace_switcher").
|
||||
pub fn publish_rect(name: &str, r: egui::Rect) {
|
||||
RECTS.with(|h| h.borrow_mut().push((name.to_string(), r)));
|
||||
}
|
||||
|
||||
pub fn rect_of(name: &str) -> Option<egui::Rect> {
|
||||
RECTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
|
||||
}
|
||||
|
||||
/// A number about this frame's drawing, by name ("cube_highlights": the view cube's lit patches).
|
||||
pub fn publish_count(name: &str, v: f64) {
|
||||
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
|
||||
}
|
||||
|
||||
pub fn count_of(name: &str) -> Option<f64> {
|
||||
COUNTS.with(|h| h.borrow().iter().rfind(|(n, _)| n == name).map(|x| x.1))
|
||||
}
|
||||
|
||||
/// Forget last frame's handles, rects and counts (called as a frame starts).
|
||||
pub fn clear_handles() {
|
||||
HANDLES.with(|h| h.borrow_mut().clear());
|
||||
RECTS.with(|h| h.borrow_mut().clear());
|
||||
COUNTS.with(|h| h.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// A fresh folder for one scenario's files.
|
||||
@@ -232,6 +256,36 @@ impl Harness {
|
||||
}
|
||||
}
|
||||
|
||||
/// Animate the view to `v` (as the view cube does), sampling the camera every frame: no
|
||||
/// frame may turn the view (its right or back vector) by more than `max_deg`, the last
|
||||
/// included, so the move ends without a jump.
|
||||
fn view_anim(&mut self, v: &str, max_deg: f64) -> Result<(), String> {
|
||||
let basis = |c: &solvecraft_engine::render::Camera| c.basis();
|
||||
let mut prev = basis(&self.app.cam);
|
||||
self.app.animate_view(v);
|
||||
if self.app.cam_anim.is_none() {
|
||||
return Err(format!("view_anim {v}: no animation started"));
|
||||
}
|
||||
let mut worst = (0.0f64, 0usize);
|
||||
for i in 0..240 {
|
||||
self.frame();
|
||||
let now = basis(&self.app.cam);
|
||||
let ang = |a: solvecraft_engine::geom::Vec3, b: solvecraft_engine::geom::Vec3| a.dot(b).clamp(-1.0, 1.0).acos().to_degrees();
|
||||
let d = ang(prev.0, now.0).max(ang(prev.2, now.2));
|
||||
if d > worst.0 {
|
||||
worst = (d, i);
|
||||
}
|
||||
prev = now;
|
||||
if self.app.cam_anim.is_none() {
|
||||
if worst.0 > max_deg {
|
||||
return Err(format!("view_anim {v}: frame {} of {} turns the view {:.1}° (limit {max_deg}°)", worst.1 + 1, i + 1, worst.0));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("view_anim {v}: still animating after 240 frames"))
|
||||
}
|
||||
|
||||
/// Run one step.
|
||||
pub fn step(&mut self, s: &Value) -> Result<(), String> {
|
||||
let flag = |k: &str| s.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -279,6 +333,19 @@ impl Harness {
|
||||
self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700}));
|
||||
}
|
||||
return Ok(());
|
||||
} else if let Some(c) = s.get("camera") {
|
||||
// Put the camera at an orbit: {"yaw": rad, "pitch": rad}.
|
||||
if let Some(y) = c.get("yaw").and_then(Value::as_f64) {
|
||||
self.app.cam.yaw = y;
|
||||
}
|
||||
if let Some(p) = c.get("pitch").and_then(Value::as_f64) {
|
||||
self.app.cam.pitch = p;
|
||||
}
|
||||
self.app.cam_anim = None;
|
||||
self.frames(2);
|
||||
return Ok(());
|
||||
} else if let Some(v) = s.get("view_anim").and_then(Value::as_str) {
|
||||
return self.view_anim(v, s.get("max_deg").and_then(Value::as_f64).unwrap_or(10.0));
|
||||
} else if s.get("autosave").is_some() {
|
||||
self.start_autosave();
|
||||
return Ok(());
|
||||
@@ -288,6 +355,9 @@ impl Harness {
|
||||
let mut old = std::mem::replace(&mut self.app, SolveApp::new(Session::default(), Services::default()));
|
||||
if how == "close" {
|
||||
crate::recovery_ui::close(&mut old);
|
||||
// The desktop saves its settings on close and loads them on the next start.
|
||||
let prefs = old.prefs();
|
||||
self.app.load_prefs(&prefs);
|
||||
}
|
||||
drop(old);
|
||||
self.ctx = egui::Context::default();
|
||||
@@ -330,6 +400,10 @@ impl Harness {
|
||||
.collect();
|
||||
eprintln!("widgets: {texts:?}");
|
||||
}
|
||||
if s["debug"] == json!("handles") {
|
||||
let names: Vec<String> = HANDLES.with(|h| h.borrow().iter().map(|(n, p)| format!("{n} @{:.0},{:.0}", p.x, p.y)).collect());
|
||||
eprintln!("handles: {names:?}");
|
||||
}
|
||||
return Ok(());
|
||||
} else if let Some(e) = s.get("until") {
|
||||
// Background work (a sample being built, a preview): frames until the checks pass.
|
||||
@@ -417,6 +491,71 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("errors: {bad:?}"));
|
||||
}
|
||||
}
|
||||
"shown_handles" | "hidden_handles" => {
|
||||
// Published handles ("fold:Bodies", "row:Body1") drawn (or not) this frame.
|
||||
for n in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
if handle_at(n).is_some() != (k == "shown_handles") {
|
||||
return Err(format!("{n}: want {}", if k == "shown_handles" { "shown" } else { "hidden" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"handle_order" => {
|
||||
// Published handles left to right in this order.
|
||||
let xs: Vec<(String, Option<f32>)> =
|
||||
v.as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), handle_at(n).map(|p| p.x))).collect();
|
||||
if xs.iter().any(|x| x.1.is_none()) || xs.windows(2).any(|w| w[0].1 >= w[1].1) {
|
||||
return Err(format!("handle order: got {xs:?}"));
|
||||
}
|
||||
}
|
||||
"count" => {
|
||||
// {"count": {"name": n, "value": x}}: a published count.
|
||||
let n = v["name"].as_str().unwrap_or_default();
|
||||
let got = count_of(n);
|
||||
if got != v["value"].as_f64() {
|
||||
return Err(format!("count {n}: got {got:?}, want {}", v["value"]));
|
||||
}
|
||||
}
|
||||
"spans" => {
|
||||
// {"spans": {"inner": a, "outer": b, "axis": "y", "tol": px}}: published rect a
|
||||
// covers b's extent along the axis (within tol).
|
||||
let (a, b) = (v["inner"].as_str().unwrap_or_default(), v["outer"].as_str().unwrap_or_default());
|
||||
let (ra, rb) = (rect_of(a).ok_or(format!("no rect {a}"))?, rect_of(b).ok_or(format!("no rect {b}"))?);
|
||||
let tol = v["tol"].as_f64().unwrap_or(4.0) as f32;
|
||||
let (lo, hi) = if v["axis"] == "x" { (ra.x_range(), rb.x_range()) } else { (ra.y_range(), rb.y_range()) };
|
||||
if (lo.min - hi.min).abs() > tol || (lo.max - hi.max).abs() > tol {
|
||||
return Err(format!("{a} {lo:?} does not span {b} {hi:?} (±{tol})"));
|
||||
}
|
||||
}
|
||||
"dialog_docked" => {
|
||||
// The command dialog: docked at the viewport's right, its width in [min, max].
|
||||
let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).ok_or("no command dialog")?;
|
||||
let vp = h.app.viewport.rect.unwrap_or(egui::Rect::NOTHING);
|
||||
let (lo, hi) = (v["min"].as_f64().unwrap_or(260.0) as f32, v["max"].as_f64().unwrap_or(380.0) as f32);
|
||||
if r.width() < lo - 0.5 || r.width() > hi + 0.5 {
|
||||
return Err(format!("dialog width {:.0}, want {lo}..{hi}", r.width()));
|
||||
}
|
||||
if (r.right() - vp.right()).abs() > 2.0 || r.top() < vp.top() || r.bottom() > vp.bottom() + 1.0 {
|
||||
return Err(format!("dialog {r:?} is not docked right inside the viewport {vp:?}"));
|
||||
}
|
||||
}
|
||||
"windows_fit" => {
|
||||
// Every open window lies on screen and is at most `max_width` wide.
|
||||
let screen = h.ctx.content_rect();
|
||||
let max_w = v["max_width"].as_f64().unwrap_or(720.0) as f32;
|
||||
let rects: Vec<(egui::Id, egui::Rect)> = h.ctx.memory(|m| {
|
||||
m.areas()
|
||||
.visible_layer_ids()
|
||||
.into_iter()
|
||||
.filter(|l| l.order == egui::Order::Middle)
|
||||
.filter_map(|l| m.area_rect(l.id).map(|r| (l.id, r)))
|
||||
.collect()
|
||||
});
|
||||
for (id, r) in rects {
|
||||
if r.width() > max_w || !screen.expand(1.0).contains_rect(r) {
|
||||
return Err(format!("window {id:?} at {r:?} (width {:.0}) does not fit {screen:?} at ≤{max_w}", r.width()));
|
||||
}
|
||||
}
|
||||
}
|
||||
"dialog" => {
|
||||
let d = &ui["dialog"];
|
||||
let ok = match v {
|
||||
|
||||
@@ -243,6 +243,8 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let tabs_y = r.top() + 2.0;
|
||||
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
|
||||
painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
crate::scenario::publish_rect("toolbar", r);
|
||||
crate::scenario::publish_rect("workspace_switcher", ws);
|
||||
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
|
||||
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
|
||||
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
|
||||
@@ -1771,11 +1771,21 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a point in view-cube units (faces at ±1) is drawn.
|
||||
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
|
||||
let (r, u, _) = app.cam.basis();
|
||||
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
|
||||
}
|
||||
|
||||
/// Half a view cube face, in pixels.
|
||||
const CUBE_SCALE: f64 = 32.0;
|
||||
|
||||
/// The view cube (top right): click a face for a standard view; the house resets.
|
||||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
|
||||
let s = 32.0f64;
|
||||
let s = CUBE_SCALE;
|
||||
let mut lit = 0;
|
||||
let (r, u, b) = app.cam.basis();
|
||||
let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) };
|
||||
let faces: [(Vec3, &str, StandardView); 6] = [
|
||||
@@ -1820,6 +1830,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ((a0, a1), (b0, b1)) = (lo(zi), lo(zj));
|
||||
let zp: Vec<Pos2> = [(a0, b0), (a1, b0), (a1, b1), (a0, b1)].iter().map(|(x, y)| to2(n + e1 * *x + e2 * *y).0).collect();
|
||||
painter.add(Shape::convex_polygon(zp, t.accent_soft, Stroke::NONE));
|
||||
lit += 1;
|
||||
}
|
||||
let center = to2(n).0;
|
||||
if n.dot(b) > 0.35 {
|
||||
@@ -1831,6 +1842,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
clicked = Some(n + e1 * zi as f64 + e2 * zj as f64);
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(lit));
|
||||
// Axis triad at the cube's corner.
|
||||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
|
||||
@@ -72,4 +72,22 @@ macro_rules! pending {
|
||||
|
||||
pending!(
|
||||
s31_sketch_on_face_projects: "user bug: a sketch on a face projects its loops as reference (solvecraft-sketch)",
|
||||
s37_browser_chevrons: "every chevron works; the browser has no Joints folder (solvecraft-shell)",
|
||||
s38_view_anim_no_jump: "the last frame of an animation to Top turns the view 22.5 degrees (solvecraft-ui)",
|
||||
s39_view_cube_hover: "view cube edge and corner hover light 1 patch, Fusion lights 2 and 3 (solvecraft-ui)",
|
||||
s40_toolbar_customize_persists: "toolbar Pin/Remove/reorder (toolbar:<id> handles, ui.menu toolbar_command) and a full-height workspace switcher (solvecraft-shell)",
|
||||
);
|
||||
|
||||
/// A scenario file named by `SOLVECRAFT_SCENARIO_FILE` (for trying one out):
|
||||
/// `SOLVECRAFT_SCENARIO_FILE=x.json cargo test --test scenarios adhoc -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn adhoc() {
|
||||
let Ok(path) = std::env::var("SOLVECRAFT_SCENARIO_FILE") else { return };
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let steps: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
let mut h = Harness::new();
|
||||
if let Err(e) = h.run(&steps) {
|
||||
panic!("{path}: {e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
[
|
||||
{
|
||||
"note": "Browser chevrons expand and collapse every kind of node (folders, components, groups, the origin, named views, document settings). Model: two components with a body each, a joint, a sketch, an offset plane, a named view."
|
||||
},
|
||||
{
|
||||
"start": "FusionCreateNewComponentCommand"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"start": "SketchCreate"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "ShapeRectangleTwoPoint"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
20,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"start": "SketchStop"
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "Extrude"
|
||||
},
|
||||
{
|
||||
"text": "10"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"call": "ui.menu",
|
||||
"params": {
|
||||
"x": 100,
|
||||
"y": 170,
|
||||
"target": {
|
||||
"type": "component",
|
||||
"id": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.menuPick",
|
||||
"params": {
|
||||
"item": "Activate"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "FusionCreateNewComponentCommand"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"start": "SketchCreate"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "XZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "ShapeRectangleTwoPoint"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
30,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"sketch": [
|
||||
50,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"start": "SketchStop"
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "Extrude"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
40,
|
||||
0,
|
||||
5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"text": "20"
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 2,
|
||||
"errors": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "JointAssembleCmdNew"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
10,
|
||||
10,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
40,
|
||||
0,
|
||||
5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget": "Rigid"
|
||||
},
|
||||
{
|
||||
"widget": "Revolute"
|
||||
},
|
||||
{
|
||||
"widget": "OK"
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "ConstructionPlaneOffsetFromPlaneCommand",
|
||||
"params": {
|
||||
"base": "XY",
|
||||
"offset": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"call": "ui.menu",
|
||||
"params": {
|
||||
"x": 100,
|
||||
"y": 300,
|
||||
"target": {
|
||||
"type": "named_views"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.menuPick",
|
||||
"params": {
|
||||
"item": "New Named View"
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Each kind of node: its chevron hides its children, and shows them again."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Document Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Units: mm"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Document Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Units: mm"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Document Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Units: mm"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Named View1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Named Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Named View1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Named Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Named View1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:XY"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:XY"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:XY"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Body2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Bodies"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Body2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Bodies"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Body2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Plane1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Construction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Plane1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Construction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Plane1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Component2:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Component2:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Component1:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Component1:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Origin"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Untitled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"row:Origin"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Untitled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"row:Origin"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The joints folder lists the joint (Fusion's Joints folder in the root component)."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"fold:Joints"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Joints"
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "fold:Joints"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": [
|
||||
"fold:Joints"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{"note": "Animating to Top or Bottom from iso, front or any orbit turns the view smoothly to the end: no frame (the last included) turns it more than 10 degrees."},
|
||||
{"start": "PrimitiveBox"},
|
||||
{"key": "Escape"},
|
||||
{"view_anim": "top"},
|
||||
{"view_anim": "home"},
|
||||
{"view_anim": "bottom"},
|
||||
{"call": "ui.view", "params": {"view": "front"}},
|
||||
{"view_anim": "top"},
|
||||
{"call": "ui.view", "params": {"view": "front"}},
|
||||
{"view_anim": "bottom"},
|
||||
{"camera": {"yaw": 2.3, "pitch": 0.35}},
|
||||
{"view_anim": "top"},
|
||||
{"camera": {"yaw": -1.1, "pitch": -0.4}},
|
||||
{"view_anim": "top"},
|
||||
{"camera": {"yaw": 2.3, "pitch": 0.35}},
|
||||
{"view_anim": "bottom"},
|
||||
{"camera": {"yaw": 0.4, "pitch": 1.2}},
|
||||
{"view_anim": "front"}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"note": "View cube hover from the home view: a face lights one patch, an edge the 2 patches on its faces, a corner the 3 around it (Fusion)."},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"move": {"cube": [0, 0, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}}},
|
||||
{"move": {"cube": [0, -0.9, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 2}}},
|
||||
{"move": {"cube": [1, -0.9, 0]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 2}}},
|
||||
{"move": {"cube": [0.9, -0.9, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 3}}},
|
||||
{"move": {"cube": [0.9, -1, 0.9]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 3}}},
|
||||
{"move": [800, 500]},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 0}}}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{"note": "Toolbar customizing like Fusion survives a restart: Pin to Toolbar, Remove from Toolbar, drag to reorder. Toolbar buttons publish handles `toolbar:<command id>`. The workspace switcher spans the toolbar's height."},
|
||||
{"expect": {"spans": {"inner": "workspace_switcher", "outer": "toolbar", "axis": "y", "tol": 6}}},
|
||||
{"expect": {"shown_handles": ["toolbar:Extrude", "toolbar:Revolve"], "hidden_handles": ["toolbar:FusionShellBodyCommand"]}},
|
||||
{"call": "ui.menu", "params": {"x": 400, "y": 80, "target": {"type": "toolbar_command", "id": "FusionShellBodyCommand"}}},
|
||||
{"call": "ui.menuPick", "params": {"item": "Pin to Toolbar"}},
|
||||
{"expect": {"shown_handles": ["toolbar:FusionShellBodyCommand"]}},
|
||||
{"call": "ui.menu", "params": {"x": 400, "y": 80, "target": {"type": "toolbar_command", "id": "Revolve"}}},
|
||||
{"call": "ui.menuPick", "params": {"item": "Remove from Toolbar"}},
|
||||
{"expect": {"hidden_handles": ["toolbar:Revolve"]}},
|
||||
{"drag": [{"handle": "toolbar:FusionShellBodyCommand"}, {"handle": "toolbar:Extrude"}], "steps": 12},
|
||||
{"expect": {"handle_order": ["toolbar:FusionShellBodyCommand", "toolbar:Extrude"]}},
|
||||
{"restart": "close"},
|
||||
{"expect": {"shown_handles": ["toolbar:FusionShellBodyCommand"], "hidden_handles": ["toolbar:Revolve"]}},
|
||||
{"expect": {"handle_order": ["toolbar:FusionShellBodyCommand", "toolbar:Extrude"]}},
|
||||
{"expect": {"spans": {"inner": "workspace_switcher", "outer": "toolbar", "axis": "y", "tol": 6}}}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Layout checks over every toolbar command, headless (see `solvecraft_ui_egui::scenario`):
|
||||
//! each command's dialog opens docked at the viewport's right within its width limits, and
|
||||
//! every window it opens fits on screen.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_ui_egui::scenario::{Harness, check};
|
||||
|
||||
/// A box body (its sketch finished) to give feature dialogs something to work on.
|
||||
fn harness() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
for (id, p) in [
|
||||
("SketchCreate", json!({"plane": "XY"})),
|
||||
("ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})),
|
||||
("SketchStop", json!({})),
|
||||
("Extrude", json!({"distance": 10})),
|
||||
] {
|
||||
h.call("engine.execute", json!({"command": id, "params": p}));
|
||||
}
|
||||
h.frames(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "pending: the exploded view dialog opens 635 px wide (solvecraft-ui)"]
|
||||
fn every_dialog_opens_docked_within_its_width_limits() {
|
||||
let mut bad = Vec::new();
|
||||
let mut seen = 0;
|
||||
for spec in solvecraft_engine::command_specs() {
|
||||
if spec.tab.is_empty() || spec.tab == "SKETCH" {
|
||||
continue;
|
||||
}
|
||||
let mut h = harness();
|
||||
h.call("ui.start", json!({"command": spec.id}));
|
||||
h.frames(4);
|
||||
if h.app.dialog.is_some() {
|
||||
seen += 1;
|
||||
// Command dialogs are 260–380 px (plus their 1 px border), the few with tables
|
||||
// (motion study, configurations) 480–560.
|
||||
let w = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).map_or(0.0, |r| r.width());
|
||||
let limits = if w > 400.0 { json!({"min": 480, "max": 562}) } else { json!({"min": 260, "max": 382}) };
|
||||
if let Err(e) = check(&mut h, &json!({ "dialog_docked": limits })) {
|
||||
bad.push(format!("{}: {e}", spec.id));
|
||||
}
|
||||
}
|
||||
if let Err(e) = check(&mut h, &json!({"windows_fit": {"max_width": 900}})) {
|
||||
bad.push(format!("{}: {e}", spec.id));
|
||||
}
|
||||
}
|
||||
assert!(seen > 20, "only {seen} commands opened a dialog");
|
||||
assert!(bad.is_empty(), "{} problems:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
|
||||
/// Windows that are not command dialogs: each fits on screen.
|
||||
#[test]
|
||||
fn other_windows_fit_on_screen() {
|
||||
let mut bad = Vec::new();
|
||||
let opens: [(&str, Value); 3] = [("ui.help", json!({"item": "about"})), ("ui.key", json!({"key": "S"})), ("ui.prefs", json!({"open": true}))];
|
||||
for (m, p) in opens {
|
||||
let mut h = harness();
|
||||
h.call(m, p.clone());
|
||||
h.frames(4);
|
||||
if let Err(e) = check(&mut h, &json!({"windows_fit": {"max_width": 900}})) {
|
||||
bad.push(format!("{m} {p}: {e}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("\n"));
|
||||
}
|
||||
Reference in new issue
Block a user