···455455 });
456456 assert!(
457457 !bound_in_sketch,
458458- "{action:?} must ship unbound; SolidWorks stock has no default for it"
458458+ "{action:?} must ship unbound with no stock default"
459459 );
460460 });
461461 }
···11-use bone_types::{AxisAngle, Camera3, OrbitState, Result};
11+use bone_types::{AxisAngle, Camera3, OrbitState, Result, ZoomFactor};
2233use crate::camera::ViewportExtent;
44use crate::camera3::{
55 ViewportPoint, arcball_rotation, orbit_about_point, pan_pixels, roll_about_view,
66+ zoom_about_pixel,
67};
7899+const ZOOM_DRAG_PER_PIXEL: f64 = 1.0075;
1010+811#[derive(Copy, Clone, Debug, PartialEq, Eq)]
912pub struct DragModifiers {
1313+ ctrl: bool,
1014 shift: bool,
1115 alt: bool,
1216}
13171418impl DragModifiers {
1519 pub const NONE: Self = Self {
2020+ ctrl: false,
1621 shift: false,
1722 alt: false,
1823 };
19242025 #[must_use]
2626+ pub const fn with_ctrl(self) -> Self {
2727+ Self {
2828+ ctrl: true,
2929+ shift: self.shift,
3030+ alt: self.alt,
3131+ }
3232+ }
3333+3434+ #[must_use]
2135 pub const fn with_shift(self) -> Self {
2236 Self {
3737+ ctrl: self.ctrl,
2338 shift: true,
2439 alt: self.alt,
2540 }
···2843 #[must_use]
2944 pub const fn with_alt(self) -> Self {
3045 Self {
4646+ ctrl: self.ctrl,
3147 shift: self.shift,
3248 alt: true,
3349 }
···35513652 #[must_use]
3753 pub const fn gesture(self) -> NavGesture {
3838- if self.shift {
5454+ if self.ctrl {
3955 NavGesture::Pan
5656+ } else if self.shift {
5757+ NavGesture::Zoom
4058 } else if self.alt {
4159 NavGesture::Roll
4260 } else {
···5068 Orbit,
5169 Pan,
5270 Roll,
7171+ Zoom,
5372}
54735574#[derive(Copy, Clone, Debug, PartialEq)]
···104123 return Ok(camera);
105124 };
106125 let next = match drag.gesture {
107107- NavGesture::Orbit => {
108108- let delta = arcball_rotation(camera, extent, cursor, drag.last)?;
109109- let oriented = orbit_about_point(camera, camera.target(), delta)?;
110110- self.orbit = self.orbit.rotated(delta);
111111- oriented
112112- }
126126+ NavGesture::Orbit => self.orbit_step(camera, extent, cursor, drag.last)?,
113127 NavGesture::Pan => pan_pixels(camera, extent, drag.last, cursor)?,
114128 NavGesture::Roll => roll_about_view(camera, extent, cursor, drag.last)?,
129129+ NavGesture::Zoom => {
130130+ let factor = ZoomFactor::new(ZOOM_DRAG_PER_PIXEL.powf(drag.last.y() - cursor.y()))?;
131131+ zoom_about_pixel(camera, extent, cursor, factor)?
132132+ }
115133 };
116134 self.drag = Some(Drag {
117135 gesture: drag.gesture,
···119137 });
120138 Ok(next)
121139 }
140140+141141+ pub fn orbit_pixels(
142142+ &mut self,
143143+ camera: Camera3,
144144+ extent: ViewportExtent,
145145+ dx: f64,
146146+ dy: f64,
147147+ ) -> Result<Camera3> {
148148+ let cx = f64::from(extent.width().value()) * 0.5;
149149+ let cy = f64::from(extent.height().value()) * 0.5;
150150+ let from = ViewportPoint::new(cx, cy)?;
151151+ let to = ViewportPoint::new(cx + dx, cy + dy)?;
152152+ self.orbit_step(camera, extent, to, from)
153153+ }
154154+155155+ fn orbit_step(
156156+ &mut self,
157157+ camera: Camera3,
158158+ extent: ViewportExtent,
159159+ from: ViewportPoint,
160160+ to: ViewportPoint,
161161+ ) -> Result<Camera3> {
162162+ let delta = arcball_rotation(camera, extent, from, to)?;
163163+ let oriented = orbit_about_point(camera, camera.target(), delta)?;
164164+ self.orbit = self.orbit.rotated(delta);
165165+ Ok(oriented)
166166+ }
122167}
123168124169impl Default for ViewportNavigator {
···170215 #[test]
171216 fn modifiers_select_the_gesture() {
172217 assert_eq!(DragModifiers::NONE.gesture(), NavGesture::Orbit);
173173- assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Pan);
218218+ assert_eq!(DragModifiers::NONE.with_ctrl().gesture(), NavGesture::Pan);
219219+ assert_eq!(DragModifiers::NONE.with_shift().gesture(), NavGesture::Zoom);
174220 assert_eq!(DragModifiers::NONE.with_alt().gesture(), NavGesture::Roll);
175221 }
176222177223 #[test]
178178- fn shift_takes_precedence_over_alt() {
224224+ fn ctrl_outranks_shift_outranks_alt() {
179225 assert_eq!(
180180- DragModifiers::NONE.with_shift().with_alt().gesture(),
226226+ DragModifiers::NONE.with_ctrl().with_shift().gesture(),
181227 NavGesture::Pan,
182182- "holding both shift and alt resolves to pan, never roll"
228228+ "ctrl pans even when shift is also held"
183229 );
184230 assert_eq!(
185185- DragModifiers::NONE.with_alt().with_shift().gesture(),
231231+ DragModifiers::NONE.with_shift().with_alt().gesture(),
232232+ NavGesture::Zoom,
233233+ "shift zooms even when alt is also held"
234234+ );
235235+ assert_eq!(
236236+ DragModifiers::NONE.with_alt().with_ctrl().gesture(),
186237 NavGesture::Pan,
187238 "the precedence is independent of the order the modifiers were set"
188239 );
···270321 "a roll keeps the eye and target fixed"
271322 );
272323 assert_ne!(rolled.up(), camera().up(), "a roll reorients the up vector");
324324+ }
325325+326326+ #[test]
327327+ fn scroll_orbit_holds_the_target_and_accumulates_rotation() {
328328+ let mut nav = ViewportNavigator::new();
329329+ let Ok(orbited) = nav.orbit_pixels(camera(), extent(), 60.0, 0.0) else {
330330+ panic!("a scroll delta orbits the camera");
331331+ };
332332+ assert!(
333333+ close(orbited.target(), camera().target(), 1e-9),
334334+ "a scroll orbit pivots on the target"
335335+ );
336336+ assert_ne!(
337337+ orbited.eye(),
338338+ camera().eye(),
339339+ "a horizontal scroll orbit moves the eye"
340340+ );
341341+ let rotated = nav.orbit_rotation().angle().get::<uom::si::angle::radian>();
342342+ assert!(
343343+ rotated.abs() > 1e-3,
344344+ "a scroll orbit accumulates rotation like a drag: {rotated}"
345345+ );
346346+ }
347347+348348+ #[test]
349349+ fn zoom_drag_down_enlarges_the_orthographic_view() {
350350+ use bone_types::ProjectionKind;
351351+ let mut nav = ViewportNavigator::new();
352352+ nav.begin_drag(NavGesture::Zoom, vp(128.0, 100.0));
353353+ let Ok(zoomed) = nav.drag_to(vp(128.0, 160.0), camera(), extent()) else {
354354+ panic!("a zoom drag transforms the camera");
355355+ };
356356+ let ProjectionKind::Orthographic { half_height } = zoomed.projection().kind() else {
357357+ panic!("the seed camera is orthographic");
358358+ };
359359+ assert!(
360360+ half_height.get::<uom::si::length::millimeter>() > 2.0,
361361+ "dragging the zoom gesture downward zooms out, growing the half height"
362362+ );
273363 }
274364275365 #[test]
+15-9
crates/bone-types/src/lib.rs
···112112 pub struct BrepLoopId;
113113}
114114115115-impl SketchId {
116116- /// Encodes this id as an opaque `u64`, stable for the same slotmap slot+version
117117- /// within a single process. Use only for deterministic widget-key derivation; the
118118- /// representation is not portable across builds or persisted artifacts.
119119- #[must_use]
120120- pub fn as_u64(self) -> u64 {
121121- use slotmap::Key;
122122- self.data().as_ffi()
123123- }
115115+macro_rules! impl_as_u64 {
116116+ ($($key:ty),+ $(,)?) => {
117117+ $(impl $key {
118118+ /// Encodes this id as an opaque `u64`, stable for the same slotmap slot+version
119119+ /// within a single process. Use only for deterministic widget-key derivation; the
120120+ /// representation is not portable across builds or persisted artifacts.
121121+ #[must_use]
122122+ pub fn as_u64(self) -> u64 {
123123+ use slotmap::Key;
124124+ self.data().as_ffi()
125125+ }
126126+ })+
127127+ };
124128}
129129+130130+impl_as_u64!(SketchId, ExtrudeId);
125131126132#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
127133pub struct Tolerance(f64);