feat: add smooth interactive axis view and diagonal snapping
This commit is contained in:
1 parent
6cbcf5c823
commit
43a02625a2
24 files changed
+673
-520
No files matched your search
+2
-2
@@ -10,7 +10,7 @@ third-party material under an open licence. Screenshots of Autodesk software are
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
|
||||
## Files
|
||||
@@ -46,7 +46,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `view_cube_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the 14-target cube | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes.
|
||||
|
||||
@@ -61,6 +61,13 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/11"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## View navigation
|
||||
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
|
||||
@@ -469,7 +469,7 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Value), String> {
|
||||
if let Some(v) = str_arg(a, "view") {
|
||||
b.call("ui.view", json!({"view": v}))?;
|
||||
// Turning to a standard view also frames the model (like a view cube click).
|
||||
// Turning to a standard view also frames the model (like a axis view click).
|
||||
if v != "fit" && v != "home" {
|
||||
b.call("ui.view", json!({"view": "fit"}))?;
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
// Corners and edges of the view cube, and a fit (new target and distance).
|
||||
// Corners and edges of the axis view, and a fit (new target and distance).
|
||||
cams.push(base.looking_from(Vec3::new(1.0, -1.0, 1.0)));
|
||||
cams.push(base.looking_from(Vec3::new(-1.0, 0.0, 1.0)));
|
||||
cams.push(base.looking_from(Vec3::new(0.0, 0.0, 1.0)));
|
||||
|
||||
@@ -190,6 +190,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
"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}`"));
|
||||
@@ -343,12 +345,6 @@ 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") {
|
||||
|
||||
@@ -1263,7 +1263,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||||
}
|
||||
|
||||
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
|
||||
/// the view cube, sized to its content.
|
||||
/// the axis view, sized to its content.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
@@ -1272,7 +1272,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
update_fill_cells(app, &mut d);
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE);
|
||||
let mut keep = true;
|
||||
let mut ok = false;
|
||||
let mut applied = false;
|
||||
|
||||
@@ -79,6 +79,8 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
app.tool = None;
|
||||
app.params = None;
|
||||
app.cam_anim = None;
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
app.viewport.hover = None;
|
||||
app.menu.props = None;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! A thin shell over [`solvecraft_engine::Session`]: panels read engine state and act through
|
||||
//! commands ([`SolveApp::run`]). Nothing here owns design data. The layout follows the familiar
|
||||
//! parametric-CAD desktop: application bar, a toolbar of workspace tabs and panels, the browser
|
||||
//! on the left, the 3D viewport with a view cube and navigation bar, the command dialog on the
|
||||
//! on the left, the 3D viewport with an axis view and navigation bar, the command dialog on the
|
||||
//! right of the viewport, the command palette and the timeline at the bottom.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -129,7 +129,7 @@ pub struct UiState {
|
||||
pub visual_style: u8,
|
||||
/// A soft shadow on the ground under the model (seen from above).
|
||||
pub ground_shadow: bool,
|
||||
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
|
||||
/// The world's up axis (Z or Y; #3): the camera, axis view, home view and ground follow it.
|
||||
pub up_axis: solvecraft_engine::render::UpAxis,
|
||||
/// Sketches whose closed profiles are not shaded.
|
||||
pub hidden_profiles: Vec<u64>,
|
||||
@@ -180,7 +180,7 @@ 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.
|
||||
/// A view change in progress (axis view, Home, Fit); any mouse navigation cancels it.
|
||||
pub cam_anim: Option<CameraAnim>,
|
||||
/// Seconds since start, as of this frame.
|
||||
pub now: f64,
|
||||
@@ -736,6 +736,8 @@ impl SolveApp {
|
||||
|
||||
pub fn fit_view(&mut self) {
|
||||
self.cam_anim = None;
|
||||
self.viewport.orbit_snap = None;
|
||||
self.viewport.orbit_free = None;
|
||||
self.cam = self.fitted(self.cam);
|
||||
}
|
||||
|
||||
@@ -753,6 +755,8 @@ impl SolveApp {
|
||||
|
||||
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
|
||||
pub fn animate_to(&mut self, to: Camera) {
|
||||
self.viewport.orbit_snap = None;
|
||||
self.viewport.orbit_free = None;
|
||||
if !to.is_valid() {
|
||||
return;
|
||||
}
|
||||
@@ -770,7 +774,7 @@ impl SolveApp {
|
||||
self.cam_anim = Some(anim);
|
||||
}
|
||||
|
||||
/// Animated view changes for the view cube and navigation bar.
|
||||
/// Animated view changes for the axis view and navigation bar.
|
||||
pub fn animate_view(&mut self, v: &str) {
|
||||
let mut to = self.cam;
|
||||
match v {
|
||||
|
||||
@@ -52,7 +52,7 @@ pub struct Prefs {
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
|
||||
@@ -554,7 +554,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
@@ -569,7 +569,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate view changes");
|
||||
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
|
||||
ui.checkbox(&mut app.preferences.look_at_anim, "Axis view, Look At, Home and Fit");
|
||||
ui.end_row();
|
||||
ui.label("Animation speed");
|
||||
ui.add_enabled(
|
||||
@@ -585,7 +585,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
|
||||
note(ui, "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.");
|
||||
ui.label("Offer to stand up Y-up files");
|
||||
ui.checkbox(&mut app.preferences.offer_stand_up, "")
|
||||
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
|
||||
|
||||
@@ -82,7 +82,7 @@ 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).
|
||||
/// A number about this frame's drawing, by name ("axis_view_highlights": the axis view's highlighted tips).
|
||||
pub fn publish_count(name: &str, v: f64) {
|
||||
COUNTS.with(|h| h.borrow_mut().push((name.to_string(), v)));
|
||||
}
|
||||
@@ -213,7 +213,7 @@ impl Harness {
|
||||
pub fn settle(&mut self) {
|
||||
for _ in 0..2000 {
|
||||
// Queued input used up and the view no longer moving.
|
||||
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() {
|
||||
if self.app.synthetic.is_empty() && self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
|
||||
break;
|
||||
}
|
||||
self.frame();
|
||||
@@ -314,14 +314,14 @@ impl Harness {
|
||||
}
|
||||
}
|
||||
|
||||
/// Animate the view to `v` (as the view cube does), sampling the camera every frame: no
|
||||
/// Animate the view to `v` (as the axis view 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() {
|
||||
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.is_none() {
|
||||
return Err(format!("view_anim {v}: no animation started"));
|
||||
}
|
||||
let mut worst = (0.0f64, 0usize);
|
||||
@@ -334,7 +334,7 @@ impl Harness {
|
||||
worst = (d, i);
|
||||
}
|
||||
prev = now;
|
||||
if self.app.cam_anim.is_none() {
|
||||
if self.app.cam_anim.is_none() && self.app.viewport.orbit_snap.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));
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
};
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE);
|
||||
let frame = egui::Frame::window(&ctx.global_style())
|
||||
.fill(t.dialog_bg)
|
||||
.stroke(Stroke::new(1.0, t.dialog_border))
|
||||
|
||||
@@ -103,9 +103,6 @@ pub struct Tokens {
|
||||
pub glyph_text: Color32,
|
||||
/// Chips drawn over the viewport (status, navigation bar, dimension labels).
|
||||
pub overlay: Color32,
|
||||
/// View cube face (shaded by orientation) and edges.
|
||||
pub cube_face: Color32,
|
||||
pub cube_edge: Color32,
|
||||
/// Rubber band of the shape being drawn.
|
||||
pub rubber_band: Color32,
|
||||
/// Light text on the application bar.
|
||||
@@ -191,8 +188,6 @@ impl Tokens {
|
||||
glyph_edge: Color32::from_rgb(90, 150, 90),
|
||||
glyph_text: Color32::from_rgb(40, 110, 40),
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
cube_face: Color32::from_rgb(236, 236, 236),
|
||||
cube_edge: Color32::from_rgb(140, 148, 160),
|
||||
rubber_band: Color32::from_rgb(30, 120, 230),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
timeline_item: Color32::WHITE,
|
||||
@@ -247,8 +242,6 @@ impl Tokens {
|
||||
t.glyph_edge = Color32::from_rgb(96, 170, 110);
|
||||
t.glyph_text = Color32::from_rgb(150, 220, 160);
|
||||
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
|
||||
t.cube_face = Color32::from_rgb(196, 200, 207);
|
||||
t.cube_edge = Color32::from_rgb(100, 108, 122);
|
||||
t.rubber_band = Color32::from_rgb(92, 160, 255);
|
||||
t.timeline_item = Color32::from_rgb(52, 58, 70);
|
||||
t.timeline_item_error = Color32::from_rgb(110, 50, 54);
|
||||
|
||||
+369
-401
@@ -1,5 +1,5 @@
|
||||
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view
|
||||
//! cube and the navigation bar.
|
||||
//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the axis
|
||||
//! view and the navigation bar.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -55,8 +55,12 @@ pub struct ViewportState {
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
/// Continuous orbit: the view turns about the vertical until the next click or key.
|
||||
pub spin: bool,
|
||||
/// A primary press on the cube: the pointer position used by its last orbit step.
|
||||
pub cube_drag: Option<Pos2>,
|
||||
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
|
||||
pub axis_drag: Option<Pos2>,
|
||||
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
|
||||
pub orbit_free: Option<Camera>,
|
||||
/// Spring toward the chosen orientation, including after the drag ends.
|
||||
pub(crate) orbit_snap: Option<OrbitSnap>,
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
@@ -983,8 +987,8 @@ fn section_clip(app: &SolveApp) -> [f32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Space kept free at the top right of the viewport for the view cube (docked dialogs start below).
|
||||
pub const VIEW_CUBE_CLEARANCE: f32 = 168.0;
|
||||
/// Space kept free at the top right of the viewport for the axis view (docked dialogs start below).
|
||||
pub const AXIS_VIEW_CLEARANCE: f32 = 168.0;
|
||||
|
||||
/// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond).
|
||||
const AXIS_LEN: f64 = 1.8;
|
||||
@@ -1442,10 +1446,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
// The right button belongs to the marking menu while it is open.
|
||||
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
|
||||
// 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));
|
||||
// The axis view handles its own clicks; the model underneath must not see them.
|
||||
let axes = axis_rect(rect);
|
||||
// Floating windows and menus own input over the model, including wheel navigation.
|
||||
let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.contains(p));
|
||||
let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p));
|
||||
app.viewport.mouse = hover.filter(|_| inside);
|
||||
// Ctrl or Alt held: drawing and dragging don't snap.
|
||||
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
|
||||
@@ -1453,6 +1457,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// ---- navigation ----
|
||||
let h = rect.height().max(1.0) as f64;
|
||||
if inside && scroll != 0.0 {
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||||
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||||
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||||
@@ -1470,26 +1476,37 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.cam.zoom_at(f, anchor);
|
||||
}
|
||||
let dragging = resp.dragged() || (inside && (middle || secondary_down));
|
||||
if dragging && delta != egui::Vec2::ZERO {
|
||||
let (dx, dy) = (delta.x as f64, delta.y as f64);
|
||||
let mode = if middle || secondary_down {
|
||||
let mode = if dragging && app.viewport.axis_drag.is_none() {
|
||||
if middle || secondary_down {
|
||||
crate::prefs::nav_mode(app, middle, secondary_down, mods)
|
||||
} else if primary_down && app.viewport.cube_drag.is_none() {
|
||||
} else if primary_down {
|
||||
app.viewport.nav
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mode.is_some() {
|
||||
app.cancel_view_animation();
|
||||
}
|
||||
match mode {
|
||||
Some(NavMode::Orbit) => {
|
||||
let press = ui.input(|i| i.pointer.press_origin());
|
||||
crate::prefs::orbit(app, rect, dx, dy, press);
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if mode == Some(NavMode::Orbit) {
|
||||
let press = ui.input(|i| i.pointer.press_origin());
|
||||
app.cancel_view_animation();
|
||||
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, mods.shift, true);
|
||||
} else {
|
||||
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
|
||||
app.viewport.orbit_snap = None;
|
||||
}
|
||||
if app.viewport.axis_drag.is_none() {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
if delta != egui::Vec2::ZERO {
|
||||
if mode.is_some() {
|
||||
app.cancel_view_animation();
|
||||
}
|
||||
match mode {
|
||||
Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h),
|
||||
Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None),
|
||||
_ => {}
|
||||
}
|
||||
Some(NavMode::Pan) => app.cam.pan(dx, dy, h),
|
||||
Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if !dragging {
|
||||
@@ -1513,10 +1530,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
if !app.cam.is_valid() {
|
||||
app.viewport.orbit_free = None;
|
||||
app.viewport.orbit_snap = None;
|
||||
app.cam = Camera::default();
|
||||
app.fit_view();
|
||||
}
|
||||
|
||||
step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt)));
|
||||
if app.viewport.orbit_snap.is_some() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let proj = projection(app, rect);
|
||||
// Suppress hover for the whole navigation gesture, including frames between pointer
|
||||
// events. Testing delta made the highlight reappear whenever a held drag paused.
|
||||
@@ -1525,8 +1548,9 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|| middle
|
||||
|| secondary_down
|
||||
|| (app.viewport.nav.is_some() && primary_down)
|
||||
|| app.viewport.cube_drag.is_some()
|
||||
|| app.viewport.axis_drag.is_some()
|
||||
|| app.viewport.spin
|
||||
|| app.viewport.orbit_snap.is_some()
|
||||
|| app.cam_anim.is_some();
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
@@ -1762,7 +1786,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if resp.dragged_by(egui::PointerButton::Primary)
|
||||
&& let (Some(a), Some(b)) = (origin, hover)
|
||||
&& rect.contains(a)
|
||||
&& !cube.contains(a)
|
||||
&& !axes.contains(a)
|
||||
{
|
||||
app.viewport.boxsel = Some(BoxSel::from_drag(a, b));
|
||||
}
|
||||
@@ -1807,7 +1831,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::canvas::show(app, ui, &painter, &proj);
|
||||
crate::sketch_tools::show(app, ui, &painter, &proj);
|
||||
crate::sketch_dims::show(app, ui, &proj);
|
||||
view_cube(app, ui, rect);
|
||||
axis_view(app, ui, rect);
|
||||
nav_bar(app, ui, rect);
|
||||
// Status chip.
|
||||
if let Some((msg, at, err)) = app.status.clone() {
|
||||
@@ -2153,117 +2177,220 @@ 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, _) = 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)
|
||||
/// Compact orientation gizmo, drawn and hit-tested in world coordinates.
|
||||
const AXIS_LENGTH: f64 = 38.0;
|
||||
const AXIS_RADIUS: f32 = 11.0;
|
||||
|
||||
fn axis_center(rect: Rect) -> Pos2 {
|
||||
pos2(rect.right() - 68.0, rect.top() + 68.0)
|
||||
}
|
||||
|
||||
/// Half a view cube face, in pixels.
|
||||
const CUBE_SCALE: f64 = 32.0;
|
||||
fn axis_rect(rect: Rect) -> Rect {
|
||||
Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0))
|
||||
}
|
||||
|
||||
/// The view cube (top right): a cube with chamfered corners. Its 14 targets are the 6 faces
|
||||
/// (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) {
|
||||
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
|
||||
fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> {
|
||||
let (r, u, b) = cam.basis();
|
||||
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
|
||||
let c = axis_center(rect);
|
||||
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
|
||||
.into_iter()
|
||||
.map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b)))
|
||||
.collect();
|
||||
tips.sort_by(|a, b| a.3.total_cmp(&b.3));
|
||||
tips
|
||||
}
|
||||
|
||||
/// Dragging a primary press on the cube orbits about the view centre. A stationary press
|
||||
/// remains a click; a floating window over the cube owns its input.
|
||||
fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool {
|
||||
let (pressed, down, decided, origin, pos) = ui.input(|i| {
|
||||
fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option<usize> {
|
||||
tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Six principal views, twelve edge diagonals and eight isometric corner directions.
|
||||
fn snap_directions() -> impl Iterator<Item = Vec3> {
|
||||
[-1.0, 0.0, 1.0].into_iter().flat_map(|x| {
|
||||
[-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Snap to the nearest standard or diagonal direction in the user's up-axis frame.
|
||||
fn snapped_axis_camera(cam: Camera) -> Camera {
|
||||
let b = cam.to_frame(cam.back());
|
||||
let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z);
|
||||
let mut to = cam.looking_from(cam.from_frame(dir));
|
||||
if dir.x == 0.0 && dir.y == 0.0 {
|
||||
to.yaw = nearest_quarter_turn(cam.yaw);
|
||||
}
|
||||
to
|
||||
}
|
||||
|
||||
pub(crate) struct OrbitSnap {
|
||||
target: Camera,
|
||||
yaw_velocity: f64,
|
||||
pitch_velocity: f64,
|
||||
pivot: Option<(Vec3, Pos2)>,
|
||||
}
|
||||
|
||||
/// Pointer motion accumulates independently of the spring preview, so many small deltas
|
||||
/// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker.
|
||||
fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Pos2>, snap: bool, around_cursor: bool) {
|
||||
let displayed = app.cam;
|
||||
if let Some(free) = app.viewport.orbit_free.take() {
|
||||
app.cam = free;
|
||||
}
|
||||
if around_cursor {
|
||||
crate::prefs::orbit(app, rect, dx, dy, press);
|
||||
} else {
|
||||
app.cam.orbit(dx, dy);
|
||||
}
|
||||
let free = app.cam;
|
||||
app.viewport.orbit_free = Some(free);
|
||||
if snap || !around_cursor || app.viewport.orbit_snap.is_some() {
|
||||
let mut target = if snap { snapped_axis_camera(free) } else { free };
|
||||
if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() {
|
||||
let b = free.back();
|
||||
if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) {
|
||||
target.yaw = previous.target.yaw;
|
||||
target.pitch = previous.target.pitch;
|
||||
}
|
||||
}
|
||||
let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| {
|
||||
app.cam = displayed;
|
||||
projection(app, rect).to_screen(p).map(|at| (p, at))
|
||||
});
|
||||
let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot });
|
||||
spring.target = target;
|
||||
spring.pivot = pivot;
|
||||
app.cam = displayed;
|
||||
}
|
||||
}
|
||||
|
||||
/// A lightly damped spring: mostly settled in about a third of a second, with a small
|
||||
/// elastic finish. Bounded substeps keep the behavior consistent across frame rates.
|
||||
fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||||
let Some(mut spring) = app.viewport.orbit_snap.take() else { return };
|
||||
let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 };
|
||||
let steps = (dt * 240.0).ceil().max(1.0) as usize;
|
||||
let h = dt / steps as f64;
|
||||
let omega = 16.0;
|
||||
let damping = 0.82;
|
||||
let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
for _ in 0..steps.min(12) {
|
||||
spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h;
|
||||
spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h;
|
||||
app.cam.yaw += spring.yaw_velocity * h;
|
||||
app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2);
|
||||
}
|
||||
app.cam.target = spring.target.target;
|
||||
let done = yaw_error(app.cam.yaw).abs() < 1e-5
|
||||
&& (spring.target.pitch - app.cam.pitch).abs() < 1e-5
|
||||
&& spring.yaw_velocity.abs() < 1e-4
|
||||
&& spring.pitch_velocity.abs() < 1e-4;
|
||||
if done {
|
||||
app.cam = spring.target;
|
||||
}
|
||||
if let Some((pivot, at)) = spring.pivot
|
||||
&& let Some(after) = projection(app, rect).to_screen(pivot)
|
||||
{
|
||||
app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0)));
|
||||
}
|
||||
if !done {
|
||||
app.viewport.orbit_snap = Some(spring);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press
|
||||
/// on a tip chooses its view. Floating windows own their input over the gizmo.
|
||||
fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
|
||||
let (pressed, down, decided, origin, pos, shift) = ui.input(|i| {
|
||||
let p = &i.pointer;
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos())
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
|
||||
});
|
||||
if pressed && ui.rect_contains_pointer(rect) {
|
||||
let shapes = cube_projected(targets, cube_basis(&app.cam), c);
|
||||
app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some());
|
||||
app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p));
|
||||
if app.viewport.axis_drag.is_some() {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
}
|
||||
if !down {
|
||||
app.viewport.cube_drag = None;
|
||||
if !down && app.viewport.axis_drag.take().is_some() {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false };
|
||||
let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false };
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// Include the movement that crossed the drag threshold, so the orbit follows the pointer.
|
||||
if let Some(p) = pos
|
||||
&& p != last
|
||||
{
|
||||
if let Some(p) = pos {
|
||||
app.cancel_view_animation();
|
||||
app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y));
|
||||
app.viewport.cube_drag = Some(p);
|
||||
orbit_drag(app, rect, f64::from(p.x - last.x), f64::from(p.y - last.y), None, shift, false);
|
||||
app.viewport.axis_drag = Some(p);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
fn axis_view(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 targets = cube_targets();
|
||||
let orbiting = view_cube_orbit(app, ui, rect, &targets, c);
|
||||
let (r, u, b) = cube_basis(&app.cam);
|
||||
let (wr, wu, _) = app.cam.basis();
|
||||
let painter = ui.painter_at(rect);
|
||||
let c = axis_center(rect);
|
||||
let orbiting = axis_view_orbit(app, ui, rect);
|
||||
let tips = axis_tips(&app.cam, rect);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
|
||||
let shapes = cube_projected(&targets, (r, u, b), c);
|
||||
let hovered = if orbiting { None } else { 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.
|
||||
// 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)] {
|
||||
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)));
|
||||
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
|
||||
let painter = ui.painter_at(rect);
|
||||
if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) {
|
||||
painter.circle_filled(c, 55.0, t.overlay);
|
||||
}
|
||||
let labels: Vec<String> = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect();
|
||||
let size = cube_label_size(ui.ctx(), &labels);
|
||||
// Back to front: the faces and facets turned toward the camera.
|
||||
for (i, pts) in shapes.iter().rev() {
|
||||
let tg = &targets[*i];
|
||||
let n = tg.normal();
|
||||
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
||||
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
|
||||
painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||||
if hovered == Some(*i) {
|
||||
painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent)));
|
||||
}
|
||||
let centre = cube_centroid(pts);
|
||||
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
|
||||
// Fades out as the face turns edge-on.
|
||||
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
|
||||
if !tg.corner && ink > 0.05 {
|
||||
let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink);
|
||||
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color));
|
||||
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
|
||||
let col = if d.x != 0.0 {
|
||||
colors::AXIS_X
|
||||
} else if d.y != 0.0 {
|
||||
colors::AXIS_Y
|
||||
} else {
|
||||
colors::AXIS_Z
|
||||
};
|
||||
let col = Color32::from_rgb(col.0, col.1, col.2);
|
||||
let positive = !name.starts_with('-');
|
||||
let ink = if *depth < -0.01 { col.gamma_multiply(0.55) } else { col };
|
||||
painter.line_segment([c, *at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
|
||||
painter.circle_filled(*at, AXIS_RADIUS, if hovered == Some(i) { t.accent_soft } else { t.overlay });
|
||||
painter.circle_stroke(
|
||||
*at,
|
||||
AXIS_RADIUS,
|
||||
Stroke::new(if hovered == Some(i) { 2.0 } else { 1.0 }, if hovered == Some(i) { t.accent } else { ink }),
|
||||
);
|
||||
painter.text(*at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink);
|
||||
crate::scenario::publish_handle(&format!("axis_view:{name}"), *at);
|
||||
}
|
||||
crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
if let Some(i) = hovered
|
||||
&& let Some((name, d, _, _)) = tips.get(i)
|
||||
{
|
||||
crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c));
|
||||
ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover())
|
||||
.on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase()));
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||
let frame = app.cam.to_frame(*d);
|
||||
let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d };
|
||||
let mut to = app.cam.looking_from(dir);
|
||||
if frame.z.abs() > 0.5 {
|
||||
to.yaw = nearest_quarter_turn(app.cam.yaw);
|
||||
}
|
||||
if app.preferences.look_at_anim {
|
||||
app.cancel_view_animation();
|
||||
app.viewport.orbit_free = None;
|
||||
app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None });
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
app.animate_to(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
if let Some(i) = hovered {
|
||||
crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c));
|
||||
}
|
||||
// Clicked: the view from the face's or facet's direction (target → eye).
|
||||
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
|
||||
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
|
||||
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
|
||||
let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0));
|
||||
let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click());
|
||||
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
|
||||
if hr.on_hover_text("Home view").clicked() {
|
||||
app.animate_view("home");
|
||||
}
|
||||
if let Some(dir) = clicked {
|
||||
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
|
||||
// Keep the nearest square heading when looking straight down or up.
|
||||
if dir.x == 0.0 && dir.y == 0.0 {
|
||||
to.yaw = nearest_quarter_turn(app.cam.yaw);
|
||||
}
|
||||
app.animate_to(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest square heading. At a halfway tie, prefer the heading nearer the standard
|
||||
/// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom.
|
||||
/// At a halfway tie, prefer the square heading nearer the standard plan view.
|
||||
fn nearest_quarter_turn(yaw: f64) -> f64 {
|
||||
if !yaw.is_finite() {
|
||||
return 0.0;
|
||||
@@ -2282,168 +2409,6 @@ fn nearest_quarter_turn(yaw: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
|
||||
const CUBE_CHAMFER: f64 = 0.38;
|
||||
|
||||
/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline
|
||||
/// (cube units).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CubeTarget {
|
||||
pub name: String,
|
||||
pub dir: Vec3,
|
||||
pub corner: bool,
|
||||
pub outline: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl CubeTarget {
|
||||
fn normal(&self) -> Vec3 {
|
||||
self.dir.normalized().unwrap_or(Vec3::Z)
|
||||
}
|
||||
}
|
||||
|
||||
/// The 14 targets: 6 octagonal faces and 8 triangular corner facets.
|
||||
pub(crate) fn cube_targets() -> Vec<CubeTarget> {
|
||||
let k = CUBE_CHAMFER;
|
||||
let mut out = Vec::new();
|
||||
for (n, name) in [
|
||||
(Vec3::new(0.0, -1.0, 0.0), "front"),
|
||||
(Vec3::new(0.0, 1.0, 0.0), "back"),
|
||||
(Vec3::new(1.0, 0.0, 0.0), "right"),
|
||||
(Vec3::new(-1.0, 0.0, 0.0), "left"),
|
||||
(Vec3::Z, "top"),
|
||||
(Vec3::new(0.0, 0.0, -1.0), "bottom"),
|
||||
] {
|
||||
let (e1, e2) = cube_face_axes(n);
|
||||
let uv =
|
||||
[(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)];
|
||||
out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() });
|
||||
}
|
||||
for sz in [1.0, -1.0] {
|
||||
for sy in [-1.0, 1.0] {
|
||||
for sx in [1.0, -1.0] {
|
||||
let corner = Vec3::new(sx, sy, sz);
|
||||
let name = format!(
|
||||
"{}-{}-{}",
|
||||
if sz > 0.0 { "top" } else { "bottom" },
|
||||
if sy < 0.0 { "front" } else { "back" },
|
||||
if sx > 0.0 { "right" } else { "left" }
|
||||
);
|
||||
let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)];
|
||||
out.push(CubeTarget { name, dir: corner, corner: true, outline });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's
|
||||
/// centre `c`.
|
||||
fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
|
||||
pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32)
|
||||
}
|
||||
|
||||
/// A face's text directions (right, up): read from outside the cube, and upright in the view
|
||||
/// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom).
|
||||
fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
|
||||
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
|
||||
}
|
||||
|
||||
/// How much of a face the widest face label spans.
|
||||
const CUBE_LABEL_WIDTH: f32 = 0.9;
|
||||
|
||||
/// The face labels' font size: one size for all, the widest of `labels` spanning
|
||||
/// `CUBE_LABEL_WIDTH` of a face.
|
||||
fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 {
|
||||
let probe = 10.0;
|
||||
let widest = labels
|
||||
.iter()
|
||||
.map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x))
|
||||
.fold(0.0, f32::max);
|
||||
if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe }
|
||||
}
|
||||
|
||||
/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's
|
||||
/// projection, so it turns and foreshortens with the face.
|
||||
fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
|
||||
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY));
|
||||
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
|
||||
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let at = (-galley.size() / 2.0).to_pos2();
|
||||
tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh);
|
||||
let (right, up) = cube_label_axes(n);
|
||||
for v in &mut mesh.vertices {
|
||||
let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE);
|
||||
v.pos = cube_to_screen(ru, c, p);
|
||||
}
|
||||
Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
|
||||
/// the cube's centre `c`.
|
||||
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
|
||||
let (r, u, b) = rub;
|
||||
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
|
||||
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
|
||||
.collect();
|
||||
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
|
||||
v.into_iter().map(|(i, _, p)| (i, p)).collect()
|
||||
}
|
||||
|
||||
/// The target under `p`. The faces and facets tile the cube, so a point on an edge between two
|
||||
/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels.
|
||||
pub(crate) fn cube_hit(shapes: &[(usize, Vec<Pos2>)], p: Pos2) -> Option<usize> {
|
||||
if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) {
|
||||
return Some(*i);
|
||||
}
|
||||
shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 {
|
||||
(0..pts.len())
|
||||
.map(|i| {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
let ab = b - a;
|
||||
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
p.distance(a + ab * t)
|
||||
})
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
fn cube_centroid(pts: &[Pos2]) -> Pos2 {
|
||||
let n = pts.len().max(1) as f32;
|
||||
pos2(pts.iter().map(|p| p.x).sum::<f32>() / n, pts.iter().map(|p| p.y).sum::<f32>() / n)
|
||||
}
|
||||
|
||||
/// The in-face axes of a view cube face.
|
||||
fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) {
|
||||
if n.z.abs() > 0.5 {
|
||||
(Vec3::X, Vec3::Y)
|
||||
} else if n.x.abs() > 0.5 {
|
||||
(Vec3::Y, Vec3::Z)
|
||||
} else {
|
||||
(Vec3::X, Vec3::Z)
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
|
||||
let n = poly.len();
|
||||
let mut inside = false;
|
||||
for i in 0..n {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % n]);
|
||||
if (a.y > p.y) != (b.y > p.y) {
|
||||
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
|
||||
if p.x < x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Navigation bar (bottom centre).
|
||||
/// Turn the view to face the selected planar face or plane, else the active sketch.
|
||||
pub fn look_at_selection(app: &mut SolveApp) {
|
||||
@@ -2465,7 +2430,7 @@ pub fn look_at_selection(app: &mut SolveApp) {
|
||||
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
let items: [(&str, &str); 10] = [
|
||||
("orbit", "Orbit (drag; or Shift+middle / right drag)"),
|
||||
("orbit", "Orbit (right drag; hold Shift to snap)"),
|
||||
("spin", "Continuous orbit (stops at the next click or key)"),
|
||||
("pan", "Pan (drag; or middle drag)"),
|
||||
("zoom", "Zoom (drag; or wheel)"),
|
||||
@@ -2945,44 +2910,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_dialog_blocks_view_cube_highlights_and_clicks() {
|
||||
fn floating_dialog_blocks_axis_view_highlights_and_clicks() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width": 800, "height": 700}));
|
||||
let p = crate::scenario::handle_at("cube:top").unwrap();
|
||||
let p = crate::scenario::handle_at("axis_view:z").unwrap();
|
||||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
|
||||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
|
||||
h.call("ui.prefs", json!({"open": "Navigation"}));
|
||||
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
|
||||
assert!(window.contains(p), "the floating dialog must cover the cube target");
|
||||
assert!(window.contains(p), "the floating dialog must cover the axis target");
|
||||
let camera = serde_json::to_value(h.app.cam).unwrap();
|
||||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
|
||||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0));
|
||||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view");
|
||||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8}));
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the cube");
|
||||
assert!(h.app.viewport.cube_drag.is_none());
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo");
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
|
||||
assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0));
|
||||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_drag_owns_navigation_and_keeps_the_view_centre() {
|
||||
fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() {
|
||||
for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.view", json!({"view": "front"}));
|
||||
h.app.viewport.nav = nav;
|
||||
let before = h.app.cam;
|
||||
let p = crate::scenario::handle_at("cube:front").unwrap();
|
||||
let p = crate::scenario::handle_at("axis_view:-y").unwrap();
|
||||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8}));
|
||||
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: cube drag must orbit exactly once");
|
||||
assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan");
|
||||
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once");
|
||||
assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan");
|
||||
assert_eq!(h.app.cam.distance, before.distance);
|
||||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||||
assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture");
|
||||
assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture");
|
||||
assert!(h.app.viewport.boxsel.is_none());
|
||||
}
|
||||
}
|
||||
@@ -3018,69 +2983,8 @@ mod tests {
|
||||
assert!(!build_highlight(&app).hidden.is_empty());
|
||||
}
|
||||
|
||||
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
|
||||
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
cam.basis()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_has_14_targets_each_hit_where_drawn() {
|
||||
let targets = cube_targets();
|
||||
assert_eq!(targets.len(), 14);
|
||||
assert_eq!(targets.iter().filter(|t| t.corner).count(), 8);
|
||||
let c = pos2(100.0, 100.0);
|
||||
for (i, tg) in targets.iter().enumerate() {
|
||||
// Seen from a little off its own direction, its middle hits it.
|
||||
let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c);
|
||||
let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1;
|
||||
assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name);
|
||||
}
|
||||
// From an iso view: 3 faces and the 4 facets turned toward it.
|
||||
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c);
|
||||
assert_eq!(shapes.len(), 3 + 4);
|
||||
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
|
||||
}
|
||||
|
||||
/// From any direction, including faces turned nearly edge-on, everything drawn stays within
|
||||
/// the cube.
|
||||
#[test]
|
||||
fn view_cube_faces_never_spike_past_the_cube() {
|
||||
let targets = cube_targets();
|
||||
let c = pos2(100.0, 100.0);
|
||||
// The cube's circumradius (corner at √3) plus the stroke and feathering.
|
||||
let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0;
|
||||
let mut worst = 0.0f32;
|
||||
for dir in orbit_directions() {
|
||||
for (_, pts) in cube_projected(&targets, basis_from(dir), c) {
|
||||
for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_labels_read_upright_in_their_own_view() {
|
||||
for tg in cube_targets().iter().filter(|t| !t.corner) {
|
||||
// The standard face view (TOP and BOTTOM with yaw 0).
|
||||
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
|
||||
if tg.dir.z.abs() > 0.5 {
|
||||
cam.yaw = 0.0;
|
||||
}
|
||||
let (r, u, _) = cam.basis();
|
||||
let (right, up) = cube_label_axes(tg.dir);
|
||||
assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u));
|
||||
}
|
||||
}
|
||||
|
||||
fn face_labels(targets: &[CubeTarget]) -> Vec<String> {
|
||||
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_top_bottom_choose_nearest_square_heading() {
|
||||
fn axis_view_top_bottom_choose_nearest_square_heading() {
|
||||
for (from, to) in [
|
||||
(0.0, 0.0),
|
||||
(30.0, 0.0),
|
||||
@@ -3105,72 +3009,136 @@ mod tests {
|
||||
assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0);
|
||||
}
|
||||
|
||||
/// The widest label spans 90% of its face, looking straight at it.
|
||||
#[test]
|
||||
fn view_cube_widest_label_spans_most_of_its_face() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut span = 0.0f32;
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets()));
|
||||
let front = Vec3::new(0.0, -1.0, 0.0);
|
||||
let (r, u, _) = basis_from(front);
|
||||
let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]);
|
||||
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
|
||||
span = (hi - lo) / (2.0 * CUBE_SCALE as f32);
|
||||
});
|
||||
assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face");
|
||||
fn shift_orbit_accumulates_and_returns_to_free_rotation() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
let before = h.app.cam;
|
||||
for _ in 0..100 {
|
||||
orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false);
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||||
}
|
||||
for _ in 0..120 {
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||||
}
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||||
assert_eq!(h.app.cam.target, before.target);
|
||||
assert_eq!(h.app.cam.distance, before.distance);
|
||||
orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false);
|
||||
h.settle();
|
||||
assert!((h.app.cam.yaw - 1.6).abs() < 1e-6);
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
}
|
||||
|
||||
/// From any direction, each label is printed within its face.
|
||||
#[test]
|
||||
fn view_cube_labels_stay_on_their_faces() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let targets = cube_targets();
|
||||
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
|
||||
let c = pos2(100.0, 100.0);
|
||||
for dir in orbit_directions().step_by(7) {
|
||||
let (r, u, b) = basis_from(dir);
|
||||
for (i, pts) in cube_projected(&targets, (r, u, b), c) {
|
||||
let tg = &targets[i];
|
||||
if tg.corner {
|
||||
continue;
|
||||
}
|
||||
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) {
|
||||
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
|
||||
worst = worst.max(outside);
|
||||
drawn += 1;
|
||||
}
|
||||
}
|
||||
fn axis_snap_selects_all_directions_in_both_up_frames() {
|
||||
assert_eq!(snap_directions().count(), 26);
|
||||
for up in [solvecraft_engine::render::UpAxis::Z, solvecraft_engine::render::UpAxis::Y] {
|
||||
for d in snap_directions() {
|
||||
let mut cam = Camera { up, ..Camera::default() };
|
||||
cam = cam.looking_from(cam.from_frame(d + Vec3::new(0.05, 0.03, 0.02)));
|
||||
let to = snapped_axis_camera(cam);
|
||||
assert!(to.back().dist(cam.from_frame(d)) < 1e-6);
|
||||
assert_eq!(to.target, cam.target);
|
||||
assert_eq!(to.distance, cam.distance);
|
||||
assert_eq!(to.fov, cam.fov);
|
||||
}
|
||||
});
|
||||
assert!(drawn > 10_000, "only {drawn} label vertices drawn");
|
||||
assert!(worst <= 0.5, "a label vertex {worst} px outside its face");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_edges_are_not_targets() {
|
||||
let targets = cube_targets();
|
||||
let c = pos2(100.0, 100.0);
|
||||
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c);
|
||||
let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9));
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let name = |i: Option<usize>| i.map(|i| targets[i].name.clone()).unwrap_or_default();
|
||||
// On the top-front edge, its middle and near its ends: a neighbouring face (or facet).
|
||||
for x in [-0.5, 0.0, 0.5] {
|
||||
let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0))));
|
||||
assert!(hit == "top" || hit == "front", "edge point {x}: {hit}");
|
||||
fn camera_resets_cancel_pending_axis_motion() {
|
||||
for reset in 0..3 {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false);
|
||||
assert!(h.app.viewport.orbit_snap.is_some());
|
||||
match reset {
|
||||
0 => h.app.fit_view(),
|
||||
1 => h.app.set_up_axis(solvecraft_engine::render::UpAxis::Y),
|
||||
_ => crate::documents::new_design(&mut h.app),
|
||||
}
|
||||
let before = h.app.cam;
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||||
assert_eq!(h.app.cam.target, before.target);
|
||||
assert!(h.app.viewport.orbit_snap.is_none());
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
}
|
||||
let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0))));
|
||||
assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}");
|
||||
// Every direction a click turns to is a face's or a corner's: none along an edge.
|
||||
for tg in &targets {
|
||||
let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count();
|
||||
assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() {
|
||||
for fps in [30, 60, 120] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false);
|
||||
assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera");
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
|
||||
assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25);
|
||||
for _ in 0..fps * 2 {
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps));
|
||||
assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small");
|
||||
}
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6);
|
||||
assert!(h.app.viewport.orbit_snap.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.orbit_cursor = true;
|
||||
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
|
||||
h.app.fit_view();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
let at = rect.center() + vec2(50.0, -20.0);
|
||||
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), false, true);
|
||||
let pivot = h.app.viewport.orbit_pivot.unwrap();
|
||||
let before = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
for _ in 0..20 {
|
||||
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||||
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
assert!(after.distance(before) < 2.0);
|
||||
}
|
||||
h.app.viewport.orbit_free = None;
|
||||
h.app.viewport.orbit_pivot = None;
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
|
||||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||||
assert_ne!(h.app.cam.target, before.target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_view_click_again_flips_and_drag_snaps() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"ui.view","params":{"view":"home"}},
|
||||
{"click":{"handle":"axis_view:z"}},
|
||||
{"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}},
|
||||
{"click":{"handle":"axis_view:z"}},
|
||||
{"expect":{"camera":{"pitch_deg":-90,"yaw_deg":0}}},
|
||||
{"call":"ui.view","params":{"view":"front"}}
|
||||
]))
|
||||
.unwrap();
|
||||
let c = axis_center(h.app.viewport.rect.unwrap());
|
||||
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40}));
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
assert!(h.app.viewport.orbit_free.is_none());
|
||||
assert!(h.app.viewport.boxsel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_axes_span_the_model_and_points_show() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"note": "Viewport right-drag orbits freely; holding Shift snaps after accumulated motion."
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "front"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.drag",
|
||||
"params": {
|
||||
"x0": 800,
|
||||
"y0": 500,
|
||||
"x1": 848,
|
||||
"y1": 500,
|
||||
"steps": 8,
|
||||
"button": "right"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 22,
|
||||
"pitch_deg": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "front"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.drag",
|
||||
"params": {
|
||||
"x0": 800,
|
||||
"y0": 500,
|
||||
"x1": 920,
|
||||
"y1": 500,
|
||||
"steps": 40,
|
||||
"shift": true,
|
||||
"button": "right"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 45,
|
||||
"pitch_deg": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
[
|
||||
{
|
||||
"note": "Signed axis tips own one highlight at a time and choose principal views."
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "home"
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "axis_view:z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "axis_view_highlights",
|
||||
"value": 1
|
||||
},
|
||||
"shown_handles": [
|
||||
"axis_view_hover:z"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 0,
|
||||
"pitch_deg": 90
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:-y"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 0,
|
||||
"pitch_deg": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [
|
||||
800,
|
||||
500
|
||||
]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "axis_view_highlights",
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
[
|
||||
{
|
||||
"note": "Top/bottom axis clicks preserve the nearest square heading in each up frame."
|
||||
},
|
||||
{
|
||||
"camera": {
|
||||
"yaw": -2.356194490192345,
|
||||
"pitch": 0.6154797086703874
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": -90,
|
||||
"pitch_deg": 90
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"camera": {
|
||||
"yaw": 2.9670597283903604,
|
||||
"pitch": -0.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:-z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 180,
|
||||
"pitch_deg": -90
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.prefs",
|
||||
"params": {
|
||||
"up_axis": "y"
|
||||
}
|
||||
},
|
||||
{
|
||||
"camera": {
|
||||
"yaw": -1.3962634015954636,
|
||||
"pitch": 0.6
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:y"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": -90,
|
||||
"pitch_deg": 90,
|
||||
"back": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
[
|
||||
{
|
||||
"note": "Axis labels are world X/Y/Z; with Y up, Y chooses top and Z chooses front."
|
||||
},
|
||||
{
|
||||
"call": "ui.prefs",
|
||||
"params": {
|
||||
"up_axis": "y"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "home"
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:y"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 0,
|
||||
"pitch_deg": 90,
|
||||
"back": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "axis_view:z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"camera": {
|
||||
"yaw_deg": 0,
|
||||
"pitch_deg": 0,
|
||||
"back": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
[
|
||||
{"note": "The view cube is a cube with chamfered corners. Its targets are the 6 faces and the 8 corner facets; hover lights exactly the one under the pointer (edges are not targets: a point on an edge goes to a neighbour), and a click turns the view to it."},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"move": {"cube": [0, 0, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top"]}},
|
||||
{"move": {"cube": [0.3, -1, 0.2]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:front"]}},
|
||||
{"note": "On the top-front edge: one target, the top or the front face."},
|
||||
{"move": {"cube": [0, -1, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}}},
|
||||
{"move": {"cube": [0.873, -0.873, 0.873]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front-right"]}},
|
||||
{"click": {"handle": "cube:top-front-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 35.26}}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top-back-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
|
||||
{"move": [800, 500]},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 0}}}
|
||||
]
|
||||
@@ -1,10 +0,0 @@
|
||||
[
|
||||
{"note": "A cube drag rotates by the whole pointer movement and releasing it does not click the pressed face."},
|
||||
{"call": "ui.view", "params": {"view": "front"}},
|
||||
{"drag": [{"cube": [0, -1, 0]}, {"cube": [1.5, -1, 0]}]},
|
||||
{"expect": {"camera": {"yaw_deg": 22, "pitch_deg": 0}}},
|
||||
{"note": "A stationary click still selects a face."},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
[
|
||||
{"note": "TOP and BOTTOM keep the nearest square heading, with home ties toward yaw zero. Clicking side faces still selects their standard view."},
|
||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 90}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top-back-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 0, 1]}}},
|
||||
{"shot": "minimal-turn-top-z"},
|
||||
{"camera": {"yaw": 2.9670597283903604, "pitch": -0.6}},
|
||||
{"click": {"handle": "cube:bottom"}},
|
||||
{"expect": {"camera": {"yaw_deg": 180, "pitch_deg": -90, "back": [0, 0, -1]}}},
|
||||
{"camera": {"yaw": 1.3962634015954636, "pitch": 0.6}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": 90}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}},
|
||||
{"note": "The same heading choice is measured in the Y-up camera frame."},
|
||||
{"call": "ui.prefs", "params": {"up_axis": "y"}},
|
||||
{"camera": {"yaw": -1.3962634015954636, "pitch": 0.6}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 1, 0]}}},
|
||||
{"shot": "minimal-turn-top-y"},
|
||||
{"camera": {"yaw": 1.3962634015954636, "pitch": -0.6}},
|
||||
{"click": {"handle": "cube:bottom"}},
|
||||
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": -90, "back": [0, -1, 0]}}}
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{"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.prefs", "params": {"up_axis": "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]}}}
|
||||
]
|
||||
@@ -13,11 +13,13 @@
|
||||
hides an item, a component's radio button makes it the active component, and right-clicking any
|
||||
row gives that item's menu. Rows drag into groups and other components.
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in
|
||||
Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar
|
||||
change the view: a cube face turns to look straight at it (TOP and BOTTOM keep the nearest
|
||||
quarter-turn heading), a chamfered corner to that corner's
|
||||
iso view, and the house goes home. Drag the cube to orbit around the view centre.
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
preset in Preferences switches to SolidWorks or Inventor style). Hold Shift during an orbit
|
||||
to snap to the nearest standard or diagonal view; the default Shift+middle gesture also snaps.
|
||||
The axis view shows the world's X/Y/Z directions in SolveCraft's colors. Click a signed
|
||||
axis tip to look along that axis; click the aligned tip again to look from the opposite side.
|
||||
Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom
|
||||
keep the nearest quarter-turn heading. The house and navigation bar's Home button go home.
|
||||
Right-click for the marking menu: eight commands around the cursor, plus a
|
||||
menu for whatever is under it.
|
||||
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
|
||||
@@ -30,3 +32,6 @@
|
||||
Preferences (File › Preferences) hold the theme, the units of new designs, the mouse preset, the
|
||||
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
|
||||
Shortcuts changes them.
|
||||
|
||||
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
|
||||
lightly damped spring transition and a small boundary margin to prevent flicker.
|
||||
@@ -49,7 +49,7 @@ rolls the numbering back with the design), so they can go straight into later co
|
||||
| `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) |
|
||||
| `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input |
|
||||
| `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) |
|
||||
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>`, panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles |
|
||||
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>`, panel labels `panel:<tab>:<panel>`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; orientation tips `axis_view:x`, `axis_view:-x` (and y/z) |
|
||||
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) |
|
||||
| `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |
|
||||
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | |
|
||||
@@ -83,3 +83,11 @@ channel; see [mcp.md](mcp.md).
|
||||
{"id":4,"method":"engine.execute","params":{"command":"solid.fillet","params":{"edges":[[0,0,10]],"radius":3}}}
|
||||
{"id":5,"method":"ui.screenshot","params":{"path":"/tmp/part.png"}}
|
||||
```
|
||||
|
||||
Hold Shift during an orbit gesture to snap to the nearest standard or diagonal view. This applies to
|
||||
right-drag, the Orbit navigation tool, and dragging the axis view. Under the default preset,
|
||||
Shift+middle also orbits with snapping; middle alone pans. Axis-view tips use world coordinates,
|
||||
regardless of the up-axis preference. Clicking an aligned tip again flips to its opposite.
|
||||
|
||||
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
|
||||
lightly damped spring transition and a small boundary margin to prevent flicker.
|
||||
Reference in new issue
Block a user