From 9dd901649cbd14cc8b9314f68e6ae12d28012b51 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 03:52:24 +0000 Subject: [PATCH] View cube with Y up (#3): the cube is drawn and clicked in the camera's up-axis frame, so TOP is the world's up; TOP and BOTTOM turn to the standard plan views (yaw 0). Scenario view_cube_y_up; the camera check can test the world direction the camera looks from Refs #15, #3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/ui-egui/src/scenario.rs | 11 ++++++++- crates/ui-egui/src/viewport.rs | 23 +++++++++++++++---- .../tests/scenarios/view_cube_y_up.json | 16 +++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/view_cube_y_up.json diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 7d3b596d..87a848bf 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -577,7 +577,8 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } } "camera" => { - // {"camera": {"yaw_deg": y, "pitch_deg": p}}: where the camera looks from (±0.5°), + // {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera + // looks from (yaw and pitch in its up-axis frame, ±0.5°), // once any view animation has finished. for _ in 0..240 { if h.app.cam_anim.is_none() { @@ -590,6 +591,14 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) { return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}")); } + // "back": the world direction from the target to the eye. + if let Some(want) = v["back"].as_array() { + let b = h.app.cam.back(); + let w = |i: usize| want.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN); + if (b.x - w(0)).abs() > 1e-3 || (b.y - w(1)).abs() > 1e-3 || (b.z - w(2)).abs() > 1e-3 { + return Err(format!("camera looks from {b:?}, want {want:?}")); + } + } } "count" => { // {"count": {"name": n, "value": x}}: a published count. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 9ee756f2..83ba11a7 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1879,7 +1879,7 @@ 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(); + let (r, u, _) = cube_basis(&app.cam); pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32) } @@ -1890,20 +1890,29 @@ const CUBE_SCALE: f64 = 32.0; /// (orthographic views) and the 8 corner facets (iso views); the edges between them are not /// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the /// house goes home. +/// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z. +fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) { + let (r, u, b) = cam.basis(); + (cam.to_frame(r), cam.to_frame(u), cam.to_frame(b)) +} + 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 (r, u, b) = app.cam.basis(); + let (r, u, b) = cube_basis(&app.cam); + let (wr, wu, _) = app.cam.basis(); let painter = ui.painter_at(rect); let hover = ui.input(|i| i.pointer.hover_pos()); let targets = cube_targets(); let shapes = cube_projected(&targets, (r, u, b), c); let hovered = hover.and_then(|h| cube_hit(&shapes, h)); // Axis triad from the cube's back corner, drawn first: the cube hides what is behind it. - let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32); + // The corner is in the cube's frame, the axes are the world's. let o = Vec3::new(-1.0, -1.0, -1.0); + let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32); for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { - painter.line_segment([to2(o), to2(o + d * 2.6)], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); + let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32); + painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); } // Back to front: the faces and facets turned toward the camera. for (i, pts) in shapes.iter().rev() { @@ -1934,7 +1943,11 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { app.animate_view("home"); } if let Some(dir) = clicked { - let to = app.cam.looking_from(dir); + let mut to = app.cam.looking_from(app.cam.from_frame(dir)); + // TOP and BOTTOM turn to the standard plan views (front edge at the bottom). + if dir.x == 0.0 && dir.y == 0.0 { + to.yaw = 0.0; + } app.animate_to(to); } } diff --git a/crates/ui-egui/tests/scenarios/view_cube_y_up.json b/crates/ui-egui/tests/scenarios/view_cube_y_up.json new file mode 100644 index 00000000..aa6d1967 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/view_cube_y_up.json @@ -0,0 +1,16 @@ +[ + {"note": "With Y up (#3) the view cube turns with the world: its TOP is world +Y and clicking it looks down −Y in the standard plan orientation; FRONT looks along −Z."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}}, + {"call": "ui.set", "params": {"upAxis": "y"}}, + {"call": "ui.view", "params": {"view": "home"}}, + {"move": {"cube": [0, 0, 1]}}, + {"expect": {"shown_handles": ["cube_hover:top"]}}, + {"click": {"handle": "cube:top"}}, + {"expect": {"camera": {"pitch_deg": 90, "yaw_deg": 0, "back": [0, 1, 0]}}}, + {"call": "ui.view", "params": {"view": "home"}}, + {"click": {"handle": "cube:front"}}, + {"expect": {"camera": {"pitch_deg": 0, "yaw_deg": 0, "back": [0, 0, 1]}}}, + {"call": "ui.view", "params": {"view": "home"}}, + {"click": {"handle": "cube:top-front-right"}}, + {"expect": {"camera": {"pitch_deg": 35.26, "yaw_deg": -45, "back": [0.57735, 0.57735, 0.57735]}}} +]