feat: add Windows SpaceNavigator navigation

This commit is contained in:
kenos committed 2026-10-12 05:07:44 +09:00
1 parent 1d3048062d
commit ee1b56cac4
11 files changed
+353 -23

No files matched your search

Generated
+15
View File
@@ -1553,6 +1553,19 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
dependencies = [
"cc",
"cfg-if",
"libc",
"pkg-config",
"windows-sys 0.61.2",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3260,6 +3273,7 @@ version = "0.2.1"
dependencies = [
"eframe",
"egui",
"hidapi",
"log",
"rfd",
"serde_json",
@@ -3357,6 +3371,7 @@ version = "0.2.1"
dependencies = [
"image",
"serde",
"serde_json",
"solvecraft-geom",
]
+5
View File
@@ -92,6 +92,11 @@ cargo xtask ci # fmt, clippy, tests, as
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
On Windows, SolveCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
+23 -1
View File
@@ -1,6 +1,6 @@
Third-party software in SolveCraft
SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 453 crates below.
SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -147,6 +147,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0)
hashbrown 0.17.1 (MIT OR Apache-2.0)
hermit-abi 0.5.3 (MIT OR Apache-2.0)
hex 0.4.3 (MIT OR Apache-2.0)
hidapi 2.6.7 (MIT)
iana-time-zone 0.1.65 (MIT OR Apache-2.0)
iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0)
icu_collections 2.3.0 (Unicode-3.0)
@@ -6019,6 +6020,27 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
hidapi 2.6.7
================================================================================
Copyright 2017 The hidapi-rs Developers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2
================================================================================
+3
View File
@@ -19,5 +19,8 @@ serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+24 -10
View File
@@ -13,36 +13,44 @@
#![forbid(unsafe_code)]
mod control_server;
#[cfg(target_os = "windows")]
mod space_mouse;
use solvecraft_engine::Session;
use solvecraft_ui_egui::{Services, SolveApp};
struct App(SolveApp);
struct App {
solve: SolveApp,
#[cfg(target_os = "windows")]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
}
self.0.logic(ctx);
if self.0.quit_requested {
self.solve.logic(ctx);
#[cfg(target_os = "windows")]
self.space_mouse.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.solve.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.0.prefs());
storage.set_string(PREFS_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
solvecraft_ui_egui::recovery_ui::close(&mut self.0);
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
}
}
@@ -205,7 +213,13 @@ fn main() -> eframe::Result {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
}
Ok(Box::new(App(app)))
#[cfg(target_os = "windows")]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(target_os = "windows")]
space_mouse,
}))
}),
)
}
+206
View File
@@ -0,0 +1,206 @@
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
//! it publishes the latest axes, and the egui thread applies them once per frame.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hidapi::{DeviceInfo, HidApi};
use solvecraft_engine::render::Camera;
use solvecraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy)]
struct Reading {
axes: Axes,
translation_at: Instant,
rotation_at: Instant,
}
impl Reading {
fn new(now: Instant) -> Self {
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
}
fn current(self, now: Instant) -> Axes {
Axes {
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
}
}
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
fn is_3d_mouse(info: &DeviceInfo) -> bool {
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
}
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
reading.translation_at = now;
true
}
Some(2) => {
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.rotation = rotation;
reading.rotation_at = now;
true
}
_ => false,
}
}
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => {
if let Ok(mut reading) = shared.lock()
&& decode_report(&report[..size], &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
std::thread::sleep(RETRY_AFTER);
}
}
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
let worker_ctx = ctx.clone();
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now }
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = axes.translation.map(normalized);
let [rx, ry, rz] = axes.rotation.map(normalized);
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
if !sketch {
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
}
+3
View File
@@ -12,3 +12,6 @@ workspace = true
solvecraft-geom = { workspace = true }
serde = { workspace = true }
image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+45 -10
View File
@@ -224,6 +224,9 @@ pub struct Camera {
pub yaw: f64,
/// Elevation above the XY plane, in (−π/2, π/2].
pub pitch: f64,
/// Rotation about the viewing direction. Zero keeps the world's up axis vertical.
#[serde(default)]
pub roll: f64,
/// Distance from target to eye.
pub distance: f64,
/// Vertical field of view (radians); 0 = orthographic.
@@ -236,7 +239,7 @@ pub struct Camera {
impl Default for Camera {
fn default() -> Self {
let (yaw, pitch) = StandardView::Iso.angles();
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
Camera { target: Vec3::ZERO, yaw, pitch, roll: 0.0, distance: 200.0, fov: 0.0, up: UpAxis::Z }
}
}
@@ -266,7 +269,8 @@ impl Camera {
let b = self.back();
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
(right, up, b)
let (s, c) = self.roll.sin_cos();
(right * c + up * s, up * c - right * s, b)
}
pub fn view(&self) -> Mat4 {
let (r, u, b) = self.basis();
@@ -349,11 +353,18 @@ impl Camera {
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
}
/// Rotate the image about the viewing direction.
pub fn roll_by(&mut self, radians: f64) {
if radians.is_finite() {
self.roll = (self.roll + radians) % std::f64::consts::TAU;
}
}
pub fn set_view(&mut self, v: StandardView) {
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
let (y, p) = v.angles();
self.yaw = y;
self.pitch = p.clamp(-lim, lim);
self.roll = 0.0;
}
/// Frame a bounding box.
pub fn fit(&mut self, b: &Aabb3) {
@@ -376,15 +387,14 @@ impl Camera {
} else {
(-b.x).atan2(-b.y)
};
Camera { yaw, pitch, ..*self }
Camera { yaw, pitch, roll: 0.0, ..*self }
}
/// Orientation as a unit quaternion (camera → world rotation).
pub fn orientation(&self) -> Quat {
let (r, u, b) = self.basis();
Quat::from_basis(r, u, b)
}
/// Blend between two cameras at `s` in [0, 1]: heading (the shorter way), elevation,
/// target, distance and field of view linearly. The ends are returned exactly.
/// Blend between two cameras at `s` in [0, 1]: heading and roll take the shorter way.
pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera {
if !(s > 0.0) {
return *a;
@@ -392,24 +402,29 @@ impl Camera {
if s >= 1.0 {
return *b;
}
// The camera has no roll, so it turns in heading and elevation: both change smoothly
// to the very end (the heading the shorter way round). A slerp of orientations would
// need a roll-free heading at every step, and near straight up or down that heading
// swings fast, so a move to Top spun in its last frames.
// Keep heading and elevation smooth near straight up or down. A quaternion slerp
// swings the heading sharply there when moving to Top.
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let yaw = a.yaw + dy * s;
let dr = (b.roll - a.roll + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let pitch = a.pitch + (b.pitch - a.pitch) * s;
Camera {
target: a.target + (b.target - a.target) * s,
yaw,
pitch,
roll: a.roll + dr * s,
distance: a.distance + (b.distance - a.distance) * s,
fov: a.fov + (b.fov - a.fov) * s,
up: b.up,
}
}
pub fn is_valid(&self) -> bool {
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
self.target.is_finite()
&& self.yaw.is_finite()
&& self.pitch.is_finite()
&& self.roll.is_finite()
&& self.distance.is_finite()
&& self.distance > 0.0
}
}
@@ -444,6 +459,26 @@ mod tests {
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
}
#[test]
fn roll_rotates_the_picture_and_standard_views_level_it() {
let mut c = Camera::default();
c.set_view(StandardView::Front);
let (_, before_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
c.roll_by(std::f64::consts::FRAC_PI_2);
let (after_x, after_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
assert!((before_y - 300.0).abs() < 1e-9);
assert!((after_x - 400.0).abs() < 1e-9 && after_y > 300.0);
let (r, u, b) = c.basis();
assert!(r.dot(u).abs() < 1e-12 && r.dot(b).abs() < 1e-12 && u.dot(b).abs() < 1e-12);
c.set_view(StandardView::Front);
assert_eq!(c.roll, 0.0);
let saved = serde_json::to_value(c).unwrap();
let mut old = saved.as_object().unwrap().clone();
old.remove("roll");
let restored: Camera = serde_json::from_value(serde_json::Value::Object(old)).unwrap();
assert_eq!(restored.roll, 0.0);
}
#[test]
fn view_animation_interpolates() {
let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() };
+9
View File
@@ -863,6 +863,15 @@ impl SolveApp {
self.cam_anim = None;
}
/// Hand the view to an external navigation device without a pending mouse orbit or spin
/// immediately taking it back on the next frame.
pub fn begin_external_navigation(&mut self) {
self.cancel_view_animation();
self.viewport.orbit_snap = None;
self.viewport.orbit_free = None;
self.viewport.spin = false;
}
fn step_view_animation(&mut self, ctx: &egui::Context) {
let Some(anim) = self.cam_anim else { return };
let (cam, done) = anim.sample(self.now);
+18
View File
@@ -39,6 +39,10 @@ pub struct Prefs {
pub zoom_reverse: bool,
/// Orbit around the point under the cursor (else around the view's centre).
pub orbit_cursor: bool,
/// Whether a connected 3D mouse moves the view.
pub space_mouse_enabled: bool,
/// Speed multiplier for 3D mouse navigation.
pub space_mouse_speed: f32,
/// Anti-aliasing samples (applies on restart).
pub msaa: u32,
/// Decimals shown for lengths and angles.
@@ -68,6 +72,8 @@ impl Default for Prefs {
nav: "fusion".into(),
zoom_reverse: false,
orbit_cursor: false,
space_mouse_enabled: true,
space_mouse_speed: 1.0,
msaa: 4,
length_decimals: 2,
angle_decimals: 1,
@@ -175,6 +181,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
p.zoom_reverse = d.zoom_reverse;
p.zoom_to_cursor = d.zoom_to_cursor;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
p.look_at_anim = d.look_at_anim;
p.look_at_speed = d.look_at_speed;
}
@@ -577,6 +585,16 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
ui.label("3D mouse (Windows)");
ui.checkbox(&mut app.preferences.space_mouse_enabled, "Navigate with SpaceNavigator / SpaceMouse");
ui.end_row();
ui.label("3D mouse speed");
ui.add_enabled(
app.preferences.space_mouse_enabled,
egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")),
);
ui.end_row();
note(ui, "Move and rotate the view together; sketches use pan and zoom only.");
}
PAGE_DESIGN => {
ui.label("Default modelling orientation");
+2 -2
View File
@@ -671,7 +671,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
let mut h = std::collections::hash_map::DefaultHasher::new();
pos.x.to_bits().hash(&mut h);
pos.y.to_bits().hash(&mut h);
for v in [app.cam.yaw, app.cam.pitch, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
v.to_bits().hash(&mut h);
}
app.session.revision.hash(&mut h);
@@ -2241,7 +2241,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
}
let mut h = std::collections::hash_map::DefaultHasher::new();
sketch_scene_key(app, proj).hash(&mut h);
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
v.to_bits().hash(&mut h);
}
(rect.width() as u32, rect.height() as u32).hash(&mut h);