mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80af7bbe3e | |||
| 191e983423 | |||
| 7ddf48ca16 | |||
| d2f6848377 |
Generated
+17
-17
@@ -4078,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,11 +4109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4124,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4213,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4267,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.16.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -91,16 +91,8 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
cam.pan(delta.x, delta.y, viewport()?.y);
|
||||
} else if p.get("pivot").is_some() {
|
||||
let pivot = req_vec3(cmd, p, "pivot")?;
|
||||
let size = viewport()?;
|
||||
let before = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
cam.orbit(delta.x, delta.y);
|
||||
let after = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) {
|
||||
// Pan operates at target depth. Perspective screen correction must instead
|
||||
// use the pivot's depth after orbiting; lateral pan leaves that depth fixed.
|
||||
let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 };
|
||||
cam.pan((ax - bx) * scale, (ay - by) * scale, size.y);
|
||||
}
|
||||
viewport()?;
|
||||
cam.orbit_about(delta.x, delta.y, pivot);
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
}
|
||||
|
||||
@@ -353,6 +353,25 @@ impl Camera {
|
||||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||||
}
|
||||
/// Orbit by pixel deltas about `pivot`: the camera turns rigidly about it, so the pivot keeps
|
||||
/// its place in the camera's frame and on screen, in perspective as in parallel views.
|
||||
pub fn orbit_about(&mut self, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let keep = self.frame_coords(pivot);
|
||||
self.orbit(dx, dy);
|
||||
self.place(pivot, keep);
|
||||
}
|
||||
/// Where `p` lies from the target along the camera's right, up and back axes.
|
||||
pub fn frame_coords(&self, p: Vec3) -> Vec3 {
|
||||
let (r, u, b) = self.basis();
|
||||
let d = p - self.target;
|
||||
Vec3::new(d.dot(r), d.dot(u), d.dot(b))
|
||||
}
|
||||
/// Move the target so `p` lies at `coords` (from [`Camera::frame_coords`]) in the camera's
|
||||
/// frame: with the distance unchanged, `p` is where it was on screen.
|
||||
pub fn place(&mut self, p: Vec3, coords: Vec3) {
|
||||
let (r, u, b) = self.basis();
|
||||
self.target = p - (r * coords.x + u * coords.y + b * coords.z);
|
||||
}
|
||||
/// Rotate the image about the viewing direction.
|
||||
pub fn roll_by(&mut self, radians: f64) {
|
||||
if radians.is_finite() {
|
||||
@@ -432,6 +451,24 @@ impl Camera {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Orbiting about a point keeps it on its pixel, with and without perspective and roll.
|
||||
#[test]
|
||||
fn orbit_about_keeps_the_pivot_on_screen() {
|
||||
let pivot = Vec3::new(12.0, -7.0, 9.0);
|
||||
for fov in [0.0, 0.6] {
|
||||
for roll in [0.0, 0.4] {
|
||||
let mut cam = Camera { target: Vec3::new(3.0, 4.0, -2.0), distance: 150.0, yaw: 0.3, pitch: 0.5, roll, fov, ..Camera::default() };
|
||||
let before = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||||
for _ in 0..10 {
|
||||
cam.orbit_about(25.0, -12.0, pivot);
|
||||
}
|
||||
let after = cam.to_screen(pivot, 800.0, 600.0, 100.0).unwrap();
|
||||
assert!((after.0 - before.0).abs() < 1e-6 && (after.1 - before.1).abs() < 1e-6, "fov {fov}, roll {roll}: {before:?} -> {after:?}");
|
||||
assert!((cam.eye() - pivot).len() > 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_view_projects_target_to_centre() {
|
||||
let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() };
|
||||
|
||||
@@ -574,7 +574,8 @@ pub(crate) struct OrbitSnap {
|
||||
pub(crate) target: Camera,
|
||||
pub(crate) yaw_velocity: f64,
|
||||
pub(crate) pitch_velocity: f64,
|
||||
pub(crate) pivot: Option<(Vec3, Pos2)>,
|
||||
/// The pivot and where it lies in the camera's frame ([`Camera::frame_coords`]).
|
||||
pub(crate) pivot: Option<(Vec3, Vec3)>,
|
||||
}
|
||||
|
||||
/// Pointer motion accumulates independently of the spring preview, so many small deltas
|
||||
@@ -610,10 +611,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.and_then(|p| {
|
||||
app.cam = displayed;
|
||||
projection(app, rect).to_screen(p).map(|at| (p, at))
|
||||
});
|
||||
.map(|p| (p, displayed.frame_coords(p)));
|
||||
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;
|
||||
@@ -623,7 +621,7 @@ pub(crate) fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||||
pub(crate) 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;
|
||||
@@ -645,10 +643,8 @@ pub(crate) fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) {
|
||||
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 let Some((pivot, coords)) = spring.pivot {
|
||||
app.cam.place(pivot, coords);
|
||||
}
|
||||
if !done {
|
||||
app.viewport.orbit_snap = Some(spring);
|
||||
@@ -707,18 +703,7 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti
|
||||
app.cam.orbit(dx, dy);
|
||||
return;
|
||||
};
|
||||
orbit_about(app, rect, dx, dy, pivot);
|
||||
}
|
||||
|
||||
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
|
||||
/// correction puts it back where it was.
|
||||
pub(crate) fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let before = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
app.cam.orbit(dx, dy);
|
||||
let after = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
if let (Some(a), Some(b)) = (before, after) {
|
||||
app.cam.pan(f64::from(a.x - b.x), f64::from(a.y - b.y), f64::from(rect.height().max(1.0)));
|
||||
}
|
||||
app.cam.orbit_about(dx, dy, pivot);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -849,47 +849,18 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
|
||||
}
|
||||
|
||||
/// The model point under a screen position. Over empty space: the centre of the visible bodies
|
||||
/// and sketches (what Fit frames) while it is on screen; zoomed in so far that it isn't, the
|
||||
/// model point at the view's centre, so what you look at stays in view (#2138); and the view's
|
||||
/// centre when neither is there.
|
||||
/// and sketches (what Fit frames), or the view's centre when nothing is visible.
|
||||
pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 {
|
||||
let under = |at: Pos2| {
|
||||
use crate::viewport::Hit;
|
||||
crate::viewport::pick(app, proj, at).into_iter().find_map(|h| match h {
|
||||
Hit::Face { point, .. } | Hit::Vertex { point, .. } => Some(point),
|
||||
// The point of the edge under the cursor: a long edge's midpoint can be far off screen.
|
||||
Hit::Edge { body, index, mid } => Some(edge_point_near(app, proj, &body, index, at).unwrap_or(mid)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
if let Some(p) = under(at) {
|
||||
return p;
|
||||
}
|
||||
let b = crate::viewport::view_bounds(app);
|
||||
if !b.is_empty() && proj.to_screen(b.center()).is_some_and(|s| proj.rect.contains(s)) {
|
||||
return b.center();
|
||||
}
|
||||
under(proj.rect.center()).unwrap_or(app.cam.target)
|
||||
}
|
||||
|
||||
/// The point of a body's edge nearest `at` on screen (each segment of its polyline measured on
|
||||
/// screen, the nearest point taken along the segment in the model).
|
||||
fn edge_point_near(app: &SolveApp, proj: &crate::viewport::Proj, body: &str, index: usize, at: Pos2) -> Option<Vec3> {
|
||||
let st = app.session.world_state();
|
||||
let mesh = st.body(body)?.mesh();
|
||||
let edge = mesh.edges.get(index)?;
|
||||
let mut best: Option<(f32, Vec3)> = None;
|
||||
for w in edge.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first().copied(), w.get(1).copied()) else { continue };
|
||||
let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { continue };
|
||||
let d = sb - sa;
|
||||
let t = if d.length_sq() > 1e-6 { ((at - sa).dot(d) / d.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = (sa + d * t).distance(at);
|
||||
if best.is_none_or(|(bd, _)| dist < bd) {
|
||||
best = Some((dist, a + (b - a) * f64::from(t)));
|
||||
use crate::viewport::Hit;
|
||||
for h in crate::viewport::pick(app, proj, at) {
|
||||
match h {
|
||||
Hit::Face { point, .. } | Hit::Vertex { point, .. } => return point,
|
||||
Hit::Edge { mid, .. } => return mid,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p)| p)
|
||||
let b = crate::viewport::view_bounds(app);
|
||||
if b.is_empty() { app.cam.target } else { b.center() }
|
||||
}
|
||||
|
||||
/// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5"
|
||||
@@ -2424,35 +2395,6 @@ mod tests {
|
||||
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
|
||||
}
|
||||
|
||||
/// #2138: zoomed in on one end of a long part, the visible geometry's centre is off screen. A
|
||||
/// drag from empty space must not turn around it, or the end you are looking at swings out of
|
||||
/// view; it turns around the model point at the view's centre instead.
|
||||
#[test]
|
||||
fn orbiting_from_empty_space_keeps_a_zoomed_in_view_on_screen() {
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", json!({"length": 400, "width": 10, "height": 10})).unwrap();
|
||||
app.preferences.orbit_around = OrbitAround::Cursor;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
app.fit_view();
|
||||
// Look closely at the end near the origin.
|
||||
let end = Vec3::new(5.0, 5.0, 5.0);
|
||||
app.cam.target = end;
|
||||
app.cam.distance = 60.0;
|
||||
let proj = crate::viewport::projection(&app, rect);
|
||||
let centre = crate::viewport::view_bounds(&app).center();
|
||||
assert!(proj.to_screen(centre).is_none_or(|s| !rect.contains(s)), "the part's centre is off screen");
|
||||
let corner = egui::pos2(10.0, 10.0);
|
||||
use crate::viewport::Hit;
|
||||
assert!(!crate::viewport::pick(&app, &proj, corner).iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })));
|
||||
for _ in 0..4 {
|
||||
orbit(&mut app, rect, 60.0, 20.0, Some(corner));
|
||||
}
|
||||
assert_ne!(app.viewport.orbit_pivot, Some(centre));
|
||||
let s = crate::viewport::projection(&app, rect).to_screen(end);
|
||||
assert!(s.is_some_and(|s| rect.contains(s)), "the end stays in view: {s:?}");
|
||||
}
|
||||
|
||||
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
|
||||
#[test]
|
||||
fn up_axis_preference_turns_the_view() {
|
||||
|
||||
@@ -5911,6 +5911,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An orbit drag that starts on the model turns about the point under the cursor, which
|
||||
/// stays on its pixel, in perspective as in parallel views. The perspective view used to
|
||||
/// correct with a pan at the target's depth, so the point slid away and the view swung
|
||||
/// about the camera instead.
|
||||
#[test]
|
||||
fn cursor_orbit_keeps_the_picked_point_on_its_pixel() {
|
||||
for perspective in [false, true] {
|
||||
for offset in [vec2(30.0, 20.0), vec2(-60.0, 10.0), vec2(120.0, -40.0)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
|
||||
h.app.ui.perspective = perspective;
|
||||
h.app.run("solid.box", json!({"length":80,"width":50,"height":20})).unwrap();
|
||||
h.app.fit_view();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
let at = rect.center() + offset;
|
||||
let proj = projection(&h.app, rect);
|
||||
assert!(pick(&h.app, &proj, at).iter().any(|hit| matches!(hit, Hit::Face { .. })), "the drag starts on the box");
|
||||
let pivot = crate::prefs::pivot_under(&h.app, &proj, at);
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+150.0,"y1":at.y+60.0,"button":"middle","shift":true,"steps":20}));
|
||||
assert!((h.app.cam.yaw - before.yaw).abs() > 0.5, "the view turned");
|
||||
let after = projection(&h.app, rect).to_screen(pivot).unwrap();
|
||||
assert!(after.distance(at) < 0.5, "perspective={perspective}: {at:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
Reference in New Issue
Block a user