View changes animate: view cube faces, edges and corners, Home and Fit move the camera over 500 ms (quaternion slerp, ease in-out); mouse navigation cancels; control channel snaps unless animate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
3e9042b6de
commit
cd993e35a3
6 files changed
+345
-24
No files matched your search
@@ -6,7 +6,7 @@
|
||||
//! - `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.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit}`
|
||||
//! - `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}`, `ui.scroll {x, y, delta}`: real
|
||||
//! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}`
|
||||
@@ -110,7 +110,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
}
|
||||
"ui.view" => {
|
||||
match s("view").unwrap_or("home") {
|
||||
let v = s("view").unwrap_or("home");
|
||||
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.cam_anim = None;
|
||||
match v {
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
app.cam.set_view(StandardView::Iso);
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::sync::mpsc::Receiver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_engine::render::{Camera, CameraAnim, StandardView};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
|
||||
@@ -72,6 +72,10 @@ pub struct SolveApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub cam: Camera,
|
||||
/// A view change in progress (view cube, Home, Fit); any mouse navigation cancels it.
|
||||
pub cam_anim: Option<CameraAnim>,
|
||||
/// Seconds since start, as of this frame.
|
||||
pub now: f64,
|
||||
pub services: Services,
|
||||
pub viewport: viewport::ViewportState,
|
||||
pub tool: Option<tools::Tool>,
|
||||
@@ -95,6 +99,8 @@ impl SolveApp {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
cam: Camera::default(),
|
||||
cam_anim: None,
|
||||
now: 0.0,
|
||||
services,
|
||||
viewport: viewport::ViewportState::default(),
|
||||
tool: None,
|
||||
@@ -175,13 +181,62 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame the model at once (programmatic use; the toolbar animates).
|
||||
pub fn fit_view(&mut self) {
|
||||
self.cam_anim = None;
|
||||
self.cam = self.fitted(self.cam);
|
||||
}
|
||||
|
||||
/// `cam` moved to frame the whole model.
|
||||
pub fn fitted(&self, mut cam: Camera) -> Camera {
|
||||
let b = solvecraft_engine::view::bounds(&self.session);
|
||||
if b.is_empty() {
|
||||
self.cam.target = solvecraft_engine::geom::Vec3::ZERO;
|
||||
self.cam.distance = 150.0;
|
||||
cam.target = solvecraft_engine::geom::Vec3::ZERO;
|
||||
cam.distance = 150.0;
|
||||
} else {
|
||||
self.cam.fit(&b);
|
||||
cam.fit(&b);
|
||||
}
|
||||
cam
|
||||
}
|
||||
|
||||
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
|
||||
pub fn animate_to(&mut self, to: Camera) {
|
||||
if !to.is_valid() {
|
||||
return;
|
||||
}
|
||||
self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now));
|
||||
}
|
||||
|
||||
/// Animated view changes for the view cube and navigation bar.
|
||||
pub fn animate_view(&mut self, v: &str) {
|
||||
let mut to = self.cam;
|
||||
match v {
|
||||
"fit" => to = self.fitted(to),
|
||||
"home" => {
|
||||
to.set_view(StandardView::Iso);
|
||||
to = self.fitted(to);
|
||||
}
|
||||
v => match StandardView::parse(v) {
|
||||
Some(sv) => to.set_view(sv),
|
||||
None => return,
|
||||
},
|
||||
}
|
||||
self.animate_to(to);
|
||||
}
|
||||
|
||||
/// Stop any view animation where it is (the user took over the camera).
|
||||
pub fn cancel_view_animation(&mut self) {
|
||||
self.cam_anim = None;
|
||||
}
|
||||
|
||||
fn step_view_animation(&mut self, ctx: &egui::Context) {
|
||||
let Some(anim) = self.cam_anim else { return };
|
||||
let (cam, done) = anim.sample(self.now);
|
||||
self.cam = if cam.is_valid() { cam } else { anim.to };
|
||||
if done {
|
||||
self.cam_anim = None;
|
||||
} else {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +250,8 @@ impl SolveApp {
|
||||
self.fit_view();
|
||||
self.fitted = true;
|
||||
}
|
||||
self.now = ctx.input(|i| i.time);
|
||||
self.step_view_animation(ctx);
|
||||
self.drain_control(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -379,7 +379,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
i.pointer.delta(),
|
||||
)
|
||||
});
|
||||
let inside = hover.is_some_and(|p| rect.contains(p));
|
||||
// The view cube handles its own clicks; the model underneath must not see them.
|
||||
let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0));
|
||||
let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p));
|
||||
app.viewport.mouse = hover.filter(|_| inside);
|
||||
|
||||
// ---- navigation ----
|
||||
@@ -393,6 +395,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let den = d.dot(n);
|
||||
if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target }
|
||||
});
|
||||
app.cancel_view_animation();
|
||||
app.cam.zoom_at(f, anchor);
|
||||
}
|
||||
let dragging = resp.dragged() || (inside && (middle || secondary_down));
|
||||
@@ -407,6 +410,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mode.is_some() {
|
||||
app.cancel_view_animation();
|
||||
}
|
||||
match mode {
|
||||
Some(NavMode::Orbit) => app.cam.orbit(dx, dy),
|
||||
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
|
||||
@@ -707,12 +713,13 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
(Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom),
|
||||
];
|
||||
let painter = ui.painter_at(rect);
|
||||
let mut clicked: Option<StandardView> = None;
|
||||
// Clicked view direction (target → eye): a face, or an edge/corner zone of a face.
|
||||
let mut clicked: Option<Vec3> = None;
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut order: Vec<usize> = (0..6).collect();
|
||||
order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b)));
|
||||
for i in order {
|
||||
let (n, label, v) = faces[i];
|
||||
let (n, label, _) = faces[i];
|
||||
if n.dot(b) <= 1e-3 {
|
||||
continue;
|
||||
}
|
||||
@@ -727,14 +734,31 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let pts: Vec<Pos2> = corners.iter().map(|p| to2(*p).0).collect();
|
||||
let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts));
|
||||
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
||||
let fill = if poly_hover { t.accent_soft } else { Color32::from_gray((236.0 * shade) as u8) };
|
||||
painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::new(1.0, Color32::from_rgb(140, 148, 160))));
|
||||
painter.add(Shape::convex_polygon(
|
||||
pts.clone(),
|
||||
Color32::from_gray((236.0 * shade) as u8),
|
||||
Stroke::new(1.0, Color32::from_rgb(140, 148, 160)),
|
||||
));
|
||||
// Hover zone: the outer band of a face picks the edge or corner it borders.
|
||||
let zone = hover.filter(|_| poly_hover).and_then(|h| cube_zone(&pts, h));
|
||||
if let Some((zi, zj)) = zone {
|
||||
let lo = |k: i32| match k {
|
||||
-1 => (-1.0, -1.0 + 2.0 * CUBE_BAND),
|
||||
0 => (-1.0 + 2.0 * CUBE_BAND, 1.0 - 2.0 * CUBE_BAND),
|
||||
_ => (1.0 - 2.0 * CUBE_BAND, 1.0),
|
||||
};
|
||||
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));
|
||||
}
|
||||
let center = to2(n).0;
|
||||
if n.dot(b) > 0.35 {
|
||||
painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text);
|
||||
}
|
||||
if poly_hover && ui.input(|i| i.pointer.primary_clicked()) {
|
||||
clicked = Some(v);
|
||||
if let Some((zi, zj)) = zone
|
||||
&& ui.input(|i| i.pointer.primary_clicked())
|
||||
{
|
||||
clicked = Some(n + e1 * zi as f64 + e2 * zj as f64);
|
||||
}
|
||||
}
|
||||
// Axis triad at the cube's corner.
|
||||
@@ -748,14 +772,45 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
|
||||
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { Color32::WHITE }, t.accent);
|
||||
if hr.on_hover_text("Home view").clicked() {
|
||||
app.cam.set_view(StandardView::Iso);
|
||||
app.fit_view();
|
||||
app.animate_view("home");
|
||||
}
|
||||
if let Some(v) = clicked {
|
||||
app.cam.set_view(v);
|
||||
if let Some(dir) = clicked {
|
||||
let to = app.cam.looking_from(dir);
|
||||
app.animate_to(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the edge/corner band of a view cube face, as a fraction of the face.
|
||||
const CUBE_BAND: f64 = 0.2;
|
||||
|
||||
/// Which of the 3×3 zones of a projected cube face (corners in order −−, +−, ++, −+) the point
|
||||
/// is over: (−1|0|1, −1|0|1).
|
||||
pub(crate) fn cube_zone(pts: &[Pos2], p: Pos2) -> Option<(i32, i32)> {
|
||||
let [c0, c1, _, c3] = pts else { return None };
|
||||
let (ux, uy) = ((c1.x - c0.x) as f64, (c1.y - c0.y) as f64);
|
||||
let (vx, vy) = ((c3.x - c0.x) as f64, (c3.y - c0.y) as f64);
|
||||
let det = ux * vy - uy * vx;
|
||||
if det.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (px, py) = ((p.x - c0.x) as f64, (p.y - c0.y) as f64);
|
||||
let a = (px * vy - py * vx) / det;
|
||||
let b = (ux * py - uy * px) / det;
|
||||
if !(-1e-6..=1.0 + 1e-6).contains(&a) || !(-1e-6..=1.0 + 1e-6).contains(&b) {
|
||||
return None;
|
||||
}
|
||||
let k = |t: f64| {
|
||||
if t < CUBE_BAND {
|
||||
-1
|
||||
} else if t > 1.0 - CUBE_BAND {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
Some((k(a), k(b)))
|
||||
}
|
||||
|
||||
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
|
||||
let n = poly.len();
|
||||
let mut inside = false;
|
||||
@@ -808,14 +863,27 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
"orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav),
|
||||
"pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav),
|
||||
"zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav),
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
app.cam.set_view(StandardView::Iso);
|
||||
app.fit_view();
|
||||
}
|
||||
"fit" => app.animate_view("fit"),
|
||||
"home" => app.animate_view("home"),
|
||||
"perspective" => app.ui.perspective = !app.ui.perspective,
|
||||
_ => app.ui.show_grid = !app.ui.show_grid,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn view_cube_zones() {
|
||||
// A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down).
|
||||
let pts = [pos2(0.0, 100.0), pos2(100.0, 100.0), pos2(100.0, 0.0), pos2(0.0, 0.0)];
|
||||
assert_eq!(cube_zone(&pts, pos2(50.0, 50.0)), Some((0, 0)));
|
||||
assert_eq!(cube_zone(&pts, pos2(5.0, 95.0)), Some((-1, -1)));
|
||||
assert_eq!(cube_zone(&pts, pos2(95.0, 50.0)), Some((1, 0)));
|
||||
assert_eq!(cube_zone(&pts, pos2(50.0, 5.0)), Some((0, 1)));
|
||||
assert_eq!(cube_zone(&pts, pos2(150.0, 50.0)), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user