Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(render): standard views, camera tween

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 6, 2026, 9:40 AM +0300) commit d7aef0be parent be9a9020 change-id wssplqsq
+908 -29
+1
Cargo.lock
··· 534 534 dependencies = [ 535 535 "accesskit", 536 536 "bone-text", 537 + "bone-types", 537 538 "insta", 538 539 "lyon_tessellation", 539 540 "palette",
+205 -10
crates/bone-render/src/camera3.rs
··· 4 4 use uom::si::length::millimeter; 5 5 6 6 use bone_types::{ 7 - AxisAngle, Camera3, Point3, Projection, ProjectionKind, Result, Tolerance, TypesError, 8 - UnitVec3, Vec3, ZoomFactor, 7 + Aabb3, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, StandardView, 8 + Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, 9 9 }; 10 10 11 11 use crate::camera::ViewportExtent; ··· 13 13 const ORTHO_DEPTH_FACTOR: f64 = 8.0; 14 14 const RAY_TOLERANCE: Tolerance = Tolerance::new(1.0e-12); 15 15 const FOCAL_DENOM_FLOOR: f64 = 1.0e-9; 16 + const FRAME_MARGIN: f64 = 1.2; 17 + const SILHOUETTE_COLLAPSE_FRACTION: f64 = 1.0e-6; 16 18 17 19 #[derive(Copy, Clone, Debug, PartialEq)] 18 20 pub struct ViewportPoint { ··· 123 125 ) 124 126 } 125 127 126 - pub fn frame_isometric(aabb: bone_types::Aabb3, extent: ViewportExtent) -> Result<Camera3> { 128 + pub fn frame_isometric(aabb: Aabb3, extent: ViewportExtent) -> Result<Camera3> { 129 + let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; 130 + frame_along(aabb, extent, direction, UnitVec3::z_axis()) 131 + } 132 + 133 + pub fn frame_standard_view( 134 + aabb: Aabb3, 135 + extent: ViewportExtent, 136 + view: StandardView, 137 + normal_to: Option<Plane3>, 138 + ) -> Result<Camera3> { 139 + let (from_center_to_eye, up) = match view { 140 + StandardView::Front => (UnitVec3::y_axis().reversed(), UnitVec3::z_axis()), 141 + StandardView::Back => (UnitVec3::y_axis(), UnitVec3::z_axis()), 142 + StandardView::Left => (UnitVec3::x_axis().reversed(), UnitVec3::z_axis()), 143 + StandardView::Right => (UnitVec3::x_axis(), UnitVec3::z_axis()), 144 + StandardView::Top => (UnitVec3::z_axis(), UnitVec3::y_axis()), 145 + StandardView::Bottom => (UnitVec3::z_axis().reversed(), UnitVec3::y_axis().reversed()), 146 + StandardView::Isometric => return frame_isometric(aabb, extent), 147 + StandardView::NormalTo => { 148 + let plane = normal_to.ok_or(TypesError::NormalToRequiresPlane)?; 149 + return frame_along(aabb, extent, plane.normal(), plane.y_axis()); 150 + } 151 + }; 152 + frame_along(aabb, extent, from_center_to_eye, up) 153 + } 154 + 155 + fn frame_along( 156 + aabb: Aabb3, 157 + extent: ViewportExtent, 158 + from_center_to_eye: UnitVec3, 159 + up: UnitVec3, 160 + ) -> Result<Camera3> { 127 161 let center = aabb.center(); 128 - let span = 0.5 * aabb.extent().norm_mm(); 129 - let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; 130 - let eye = center + direction.into_vec(Length::new::<millimeter>(span * 3.0)); 131 - let fit = (1.0 / aspect_ratio(extent)).max(1.0); 132 - let half_height = Length::new::<millimeter>(span * 1.2 * fit); 162 + let radius = 0.5 * aabb.extent().norm_mm(); 163 + let eye = center + from_center_to_eye.into_vec(Length::new::<millimeter>(radius * 3.0)); 164 + let silhouette = silhouette_half_height(aabb, extent, from_center_to_eye, up); 165 + let half_height = if silhouette > radius * SILHOUETTE_COLLAPSE_FRACTION { 166 + silhouette 167 + } else { 168 + FRAME_MARGIN * radius 169 + }; 133 170 Camera3::new( 134 171 eye, 135 172 center, 136 - UnitVec3::z_axis(), 137 - Projection::orthographic(half_height)?, 173 + up, 174 + Projection::orthographic(Length::new::<millimeter>(half_height))?, 138 175 ) 176 + } 177 + 178 + fn silhouette_half_height( 179 + aabb: Aabb3, 180 + extent: ViewportExtent, 181 + view_axis: UnitVec3, 182 + up: UnitVec3, 183 + ) -> f64 { 184 + let (sx, sy, sz) = aabb.extent().coords_mm(); 185 + let (hx, hy, hz) = (0.5 * sx, 0.5 * sy, 0.5 * sz); 186 + let (nx, ny, nz) = view_axis.components(); 187 + let normal = NVec3::new(nx, ny, nz); 188 + let (ux, uy, uz) = up.components(); 189 + let up_vec = NVec3::new(ux, uy, uz); 190 + let screen_up = (up_vec - normal * up_vec.dot(&normal)).normalize(); 191 + let screen_right = normal.cross(&screen_up).normalize(); 192 + let project = |axis: NVec3<f64>| hx * axis.x.abs() + hy * axis.y.abs() + hz * axis.z.abs(); 193 + FRAME_MARGIN * project(screen_up).max(project(screen_right) / aspect_ratio(extent)) 139 194 } 140 195 141 196 fn clip_from_world_mat(camera: Camera3, extent: ViewportExtent) -> Result<Matrix4<f64>> { ··· 574 629 let p = Point3::from_mm(1.0, 2.0, 3.0); 575 630 let degenerate = Aabb3::from_corners(p, p); 576 631 assert!(frame_isometric(degenerate, extent()).is_err()); 632 + } 633 + 634 + fn box_aabb() -> Aabb3 { 635 + Aabb3::from_corners( 636 + Point3::from_mm(2.0, 4.0, 6.0), 637 + Point3::from_mm(4.0, 8.0, 12.0), 638 + ) 639 + } 640 + 641 + fn standard(view: bone_types::StandardView) -> Camera3 { 642 + let Ok(camera) = frame_standard_view(box_aabb(), extent(), view, None) else { 643 + panic!("a fixed standard view frames a non-degenerate box"); 644 + }; 645 + camera 646 + } 647 + 648 + #[test] 649 + fn every_fixed_standard_view_looks_at_the_box_center() { 650 + use bone_types::StandardView::{Back, Bottom, Front, Isometric, Left, Right, Top}; 651 + let box_center = box_aabb().center(); 652 + [Front, Back, Left, Right, Top, Bottom, Isometric] 653 + .into_iter() 654 + .for_each(|view| { 655 + let focus = focal(standard(view), center()); 656 + assert!( 657 + close(focus, box_center, 1e-6), 658 + "{view} must look at the box center", 659 + ); 660 + }); 661 + } 662 + 663 + #[test] 664 + fn standard_view_eyes_sit_on_the_named_side() { 665 + use bone_types::StandardView::{Back, Bottom, Front, Left, Right, Top}; 666 + let (cx, cy, cz) = box_aabb().center().coords_mm(); 667 + let offset = |view| { 668 + let (ex, ey, ez) = standard(view).eye().coords_mm(); 669 + (ex - cx, ey - cy, ez - cz) 670 + }; 671 + let (_, fy, _) = offset(Front); 672 + assert!(fy < 0.0, "front looks from -Y"); 673 + let (_, by, _) = offset(Back); 674 + assert!(by > 0.0, "back looks from +Y"); 675 + let (lx, _, _) = offset(Left); 676 + assert!(lx < 0.0, "left looks from -X"); 677 + let (rx, _, _) = offset(Right); 678 + assert!(rx > 0.0, "right looks from +X"); 679 + let (_, _, tz) = offset(Top); 680 + assert!(tz > 0.0, "top looks from +Z"); 681 + let (_, _, bz) = offset(Bottom); 682 + assert!(bz < 0.0, "bottom looks from -Z"); 683 + } 684 + 685 + #[test] 686 + fn normal_to_without_a_plane_is_rejected() { 687 + assert!(matches!( 688 + frame_standard_view( 689 + box_aabb(), 690 + extent(), 691 + bone_types::StandardView::NormalTo, 692 + None 693 + ), 694 + Err(TypesError::NormalToRequiresPlane) 695 + )); 696 + } 697 + 698 + #[test] 699 + fn normal_to_looks_along_the_plane_normal() { 700 + let Ok(plane) = bone_types::Plane3::new( 701 + Point3::origin(), 702 + UnitVec3::x_axis(), 703 + UnitVec3::z_axis(), 704 + RAY_TOLERANCE, 705 + ) else { 706 + panic!("x and z axes are orthonormal"); 707 + }; 708 + let Ok(camera) = frame_standard_view( 709 + box_aabb(), 710 + extent(), 711 + bone_types::StandardView::NormalTo, 712 + Some(plane), 713 + ) else { 714 + panic!("normal-to frames against the supplied plane"); 715 + }; 716 + let view = view_direction(camera); 717 + let Ok(view) = view else { 718 + panic!("the framed camera has a view direction"); 719 + }; 720 + let along = view.dot(plane.normal()); 721 + assert!( 722 + (along.abs() - 1.0).abs() < 1e-6, 723 + "the view direction must be parallel to the plane normal: dot {along}", 724 + ); 725 + } 726 + 727 + #[test] 728 + fn silhouette_collapse_falls_back_to_sphere_fit() { 729 + let segment = Aabb3::from_corners( 730 + Point3::from_mm(0.0, 0.0, 0.0), 731 + Point3::from_mm(0.0, 5.0, 0.0), 732 + ); 733 + let Ok(camera) = frame_standard_view(segment, extent(), StandardView::Front, None) else { 734 + panic!("a segment parallel to the view axis still frames via the sphere fallback"); 735 + }; 736 + assert!( 737 + close(focal(camera, center()), segment.center(), 1e-6), 738 + "the fallback framing still looks at the segment center" 739 + ); 740 + let ProjectionKind::Orthographic { half_height } = camera.projection().kind() else { 741 + panic!("standard views are orthographic"); 742 + }; 743 + let radius = 0.5 * segment.extent().norm_mm(); 744 + assert!( 745 + (half_height.get::<millimeter>() - FRAME_MARGIN * radius).abs() < 1e-9, 746 + "a collapsed silhouette frames the bounding sphere, not a zero-height view" 747 + ); 748 + } 749 + 750 + #[test] 751 + fn full_silhouette_views_keep_the_tight_fit() { 752 + let silhouette = silhouette_half_height( 753 + box_aabb(), 754 + extent(), 755 + UnitVec3::y_axis().reversed(), 756 + UnitVec3::z_axis(), 757 + ); 758 + let radius = 0.5 * box_aabb().extent().norm_mm(); 759 + assert!( 760 + silhouette > radius * SILHOUETTE_COLLAPSE_FRACTION, 761 + "a real view stays on the tight silhouette path, not the fallback" 762 + ); 763 + let standard_front = standard(StandardView::Front); 764 + let ProjectionKind::Orthographic { half_height } = standard_front.projection().kind() 765 + else { 766 + panic!("standard views are orthographic"); 767 + }; 768 + assert!( 769 + (half_height.get::<millimeter>() - silhouette).abs() < 1e-9, 770 + "the front view frames on the silhouette, the clamp does not perturb it" 771 + ); 577 772 } 578 773 579 774 #[test]
+5 -2
crates/bone-render/src/lib.rs
··· 8 8 pub mod scene; 9 9 pub mod snapshot; 10 10 pub mod surface; 11 + pub mod tween; 11 12 12 13 pub use camera::{Camera2, GridSpacing, PixelsPerMm, ViewportExtent, ViewportPx}; 13 14 pub use camera3::{ 14 - ViewportPoint, clip_from_world, frame_isometric, orbit_about_pixel, orbit_about_point, 15 - pan_pixels, world_from_clip, world_on_focal_plane, world_ray, zoom_about_pixel, 15 + ViewportPoint, clip_from_world, frame_isometric, frame_standard_view, orbit_about_pixel, 16 + orbit_about_point, pan_pixels, world_from_clip, world_on_focal_plane, world_ray, 17 + zoom_about_pixel, 16 18 }; 17 19 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 18 20 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; ··· 36 38 decode_png, encode_png, 37 39 }; 38 40 pub use surface::{SurfaceContext, SurfaceError}; 41 + pub use tween::CameraTween; 39 42 40 43 #[allow( 41 44 clippy::cast_possible_truncation,
+3
crates/bone-render/src/pipelines/solid.rs
··· 11 11 pub(crate) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; 12 12 13 13 const LIGHT_DIR: [f32; 4] = [0.302, 0.503, 0.809, 0.0]; 14 + const FILL_DIR: [f32; 4] = [-0.302, -0.503, -0.809, 0.4]; 14 15 const BASE_COLOR: [f32; 4] = [0.72, 0.74, 0.78, 1.0]; 15 16 const AMBIENT: f32 = 0.28; 16 17 ··· 63 64 struct SolidUniform { 64 65 clip_from_world: [f32; 16], 65 66 light_dir: [f32; 4], 67 + fill_dir: [f32; 4], 66 68 base_color: [f32; 4], 67 69 background: [f32; 4], 68 70 eye_world: [f32; 4], ··· 200 202 let uniform = SolidUniform { 201 203 clip_from_world: view.clip_from_world, 202 204 light_dir: LIGHT_DIR, 205 + fill_dir: FILL_DIR, 203 206 base_color: BASE_COLOR, 204 207 background: style.background().to_rgba_array(), 205 208 eye_world: [view.eye_world[0], view.eye_world[1], view.eye_world[2], 1.0],
+5 -2
crates/bone-render/src/pipelines/solid.wgsl
··· 1 1 struct Uniform { 2 2 clip_from_world: mat4x4<f32>, 3 3 light_dir: vec4<f32>, 4 + fill_dir: vec4<f32>, 4 5 base_color: vec4<f32>, 5 6 background: vec4<f32>, 6 7 eye_world: vec4<f32>, ··· 55 56 let n = normalize(in.world_normal); 56 57 let l = normalize(u.light_dir.xyz); 57 58 let ndl = dot(n, l); 58 - let lambert = max(ndl, 0.0); 59 + let key = max(ndl, 0.0); 60 + let fill = max(dot(n, normalize(u.fill_dir.xyz)), 0.0) * u.fill_dir.w; 59 61 let wrap = 0.5 * ndl + 0.5; 60 62 let ambient = u.ambient * wrap * wrap; 61 - let shade = ambient + (1.0 - u.ambient) * lambert; 63 + let diffuse = min(key + fill, 1.0); 64 + let shade = ambient + (1.0 - u.ambient) * diffuse; 62 65 var rgb = u.base_color.rgb * shade; 63 66 64 67 if (u.shading_model == SHADING_PHONG) {
+370
crates/bone-render/src/tween.rs
··· 1 + use core::time::Duration; 2 + 3 + use nalgebra::{UnitQuaternion, Vector3}; 4 + use uom::si::angle::radian; 5 + use uom::si::f64::{Angle, Length}; 6 + use uom::si::length::millimeter; 7 + 8 + use bone_types::{Camera3, CubicEasing, Point3, Projection, ProjectionKind, Result, UnitVec3}; 9 + 10 + const SLERP_EPSILON: f64 = 1.0e-9; 11 + const NEWTON_ITERATIONS: u32 = 8; 12 + const BISECTION_ITERATIONS: u32 = 24; 13 + const SOLVE_EPSILON: f64 = 1.0e-7; 14 + 15 + #[derive(Copy, Clone, Debug, PartialEq)] 16 + pub struct CameraTween { 17 + from: Camera3, 18 + to: Camera3, 19 + duration: Duration, 20 + easing: CubicEasing, 21 + } 22 + 23 + impl CameraTween { 24 + #[must_use] 25 + pub fn eased(from: Camera3, to: Camera3, duration: Duration, easing: CubicEasing) -> Self { 26 + Self { 27 + from, 28 + to, 29 + duration, 30 + easing, 31 + } 32 + } 33 + 34 + #[must_use] 35 + pub fn immediate(to: Camera3) -> Self { 36 + Self { 37 + from: to, 38 + to, 39 + duration: Duration::ZERO, 40 + easing: CubicEasing::LINEAR, 41 + } 42 + } 43 + 44 + #[must_use] 45 + pub fn to(self) -> Camera3 { 46 + self.to 47 + } 48 + 49 + #[must_use] 50 + pub fn is_done(self, elapsed: Duration) -> bool { 51 + elapsed >= self.duration 52 + } 53 + 54 + pub fn sample(self, elapsed: Duration) -> Result<Camera3> { 55 + let progress = if self.duration.is_zero() { 56 + 1.0 57 + } else { 58 + (elapsed.as_secs_f64() / self.duration.as_secs_f64()).clamp(0.0, 1.0) 59 + }; 60 + interpolate(self.from, self.to, ease(self.easing, progress)) 61 + } 62 + } 63 + 64 + fn interpolate(from: Camera3, to: Camera3, t: f64) -> Result<Camera3> { 65 + let t = t.clamp(0.0, 1.0); 66 + if t <= 0.0 { 67 + return Ok(from); 68 + } 69 + if t >= 1.0 { 70 + return Ok(to); 71 + } 72 + let orientation = orientation_of(from) 73 + .try_slerp(&orientation_of(to), t, SLERP_EPSILON) 74 + .unwrap_or_else(|| orientation_of(to)); 75 + let forward = orientation * Vector3::z(); 76 + let up_axis = orientation * Vector3::y(); 77 + let target = lerp_point(from.target(), to.target(), t); 78 + let distance = lerp(eye_distance(from), eye_distance(to), t); 79 + let eye = target 80 + + UnitVec3::new_unchecked(forward.x, forward.y, forward.z) 81 + .into_vec(Length::new::<millimeter>(-distance)); 82 + let up = UnitVec3::new_unchecked(up_axis.x, up_axis.y, up_axis.z); 83 + Camera3::new( 84 + eye, 85 + target, 86 + up, 87 + lerp_projection(from.projection(), to.projection(), t)?, 88 + ) 89 + } 90 + 91 + fn orientation_of(camera: Camera3) -> UnitQuaternion<f64> { 92 + let (ex, ey, ez) = camera.eye().coords_mm(); 93 + let (tx, ty, tz) = camera.target().coords_mm(); 94 + let (ux, uy, uz) = camera.up().components(); 95 + let forward = Vector3::new(tx - ex, ty - ey, tz - ez); 96 + let up = Vector3::new(ux, uy, uz); 97 + UnitQuaternion::face_towards(&forward, &up) 98 + } 99 + 100 + fn eye_distance(camera: Camera3) -> f64 { 101 + (camera.target() - camera.eye()).norm_mm() 102 + } 103 + 104 + fn lerp(a: f64, b: f64, t: f64) -> f64 { 105 + a + (b - a) * t 106 + } 107 + 108 + fn lerp_point(a: Point3, b: Point3, t: f64) -> Point3 { 109 + let (ax, ay, az) = a.coords_mm(); 110 + let (bx, by, bz) = b.coords_mm(); 111 + Point3::from_mm(lerp(ax, bx, t), lerp(ay, by, t), lerp(az, bz, t)) 112 + } 113 + 114 + fn lerp_mm(a: Length, b: Length, t: f64) -> Length { 115 + Length::new::<millimeter>(lerp(a.get::<millimeter>(), b.get::<millimeter>(), t)) 116 + } 117 + 118 + fn lerp_projection(a: Projection, b: Projection, t: f64) -> Result<Projection> { 119 + match (a.kind(), b.kind()) { 120 + ( 121 + ProjectionKind::Orthographic { half_height: ha }, 122 + ProjectionKind::Orthographic { half_height: hb }, 123 + ) => Projection::orthographic(lerp_mm(ha, hb, t)), 124 + ( 125 + ProjectionKind::Perspective { 126 + fov: fa, 127 + near: na, 128 + far: la, 129 + }, 130 + ProjectionKind::Perspective { 131 + fov: fb, 132 + near: nb, 133 + far: lb, 134 + }, 135 + ) => { 136 + let fov = Angle::new::<radian>(lerp(fa.get::<radian>(), fb.get::<radian>(), t)); 137 + Projection::perspective(fov, lerp_mm(na, nb, t), lerp_mm(la, lb, t)) 138 + } 139 + _ => Ok(if t < 0.5 { a } else { b }), 140 + } 141 + } 142 + 143 + fn ease(control: CubicEasing, x: f64) -> f64 { 144 + let x = x.clamp(0.0, 1.0); 145 + if x <= 0.0 || x >= 1.0 { 146 + return x; 147 + } 148 + bezier_axis( 149 + control.y1(), 150 + control.y2(), 151 + solve_for_t(control.x1(), control.x2(), x), 152 + ) 153 + } 154 + 155 + fn bezier_axis(p1: f64, p2: f64, s: f64) -> f64 { 156 + let c = 3.0 * p1; 157 + let b = 3.0 * (p2 - p1) - c; 158 + let a = 1.0 - c - b; 159 + ((a * s + b) * s + c) * s 160 + } 161 + 162 + fn bezier_slope(p1: f64, p2: f64, s: f64) -> f64 { 163 + let c = 3.0 * p1; 164 + let b = 3.0 * (p2 - p1) - c; 165 + let a = 1.0 - c - b; 166 + (3.0 * a * s + 2.0 * b) * s + c 167 + } 168 + 169 + fn solve_for_t(x1: f64, x2: f64, x: f64) -> f64 { 170 + let newton = (0..NEWTON_ITERATIONS).try_fold(x, |guess, _| { 171 + let error = bezier_axis(x1, x2, guess) - x; 172 + if error.abs() < SOLVE_EPSILON { 173 + return Err(guess); 174 + } 175 + let slope = bezier_slope(x1, x2, guess); 176 + if slope.abs() < SOLVE_EPSILON { 177 + return Err(guess); 178 + } 179 + Ok(guess - error / slope) 180 + }); 181 + match newton { 182 + Err(solved) => solved, 183 + Ok(guess) if (0.0..=1.0).contains(&guess) && bezier_close(x1, x2, guess, x) => guess, 184 + Ok(_) => bisect(x1, x2, x), 185 + } 186 + } 187 + 188 + fn bezier_close(x1: f64, x2: f64, s: f64, x: f64) -> bool { 189 + (bezier_axis(x1, x2, s) - x).abs() < SOLVE_EPSILON 190 + } 191 + 192 + fn bisect(x1: f64, x2: f64, x: f64) -> f64 { 193 + let result = (0..BISECTION_ITERATIONS).try_fold((0.0_f64, 1.0_f64), |(low, high), _| { 194 + let mid = f64::midpoint(low, high); 195 + let value = bezier_axis(x1, x2, mid); 196 + if (value - x).abs() < SOLVE_EPSILON { 197 + return Err(mid); 198 + } 199 + if value < x { 200 + Ok((mid, high)) 201 + } else { 202 + Ok((low, mid)) 203 + } 204 + }); 205 + match result { 206 + Err(mid) => mid, 207 + Ok((low, high)) => f64::midpoint(low, high), 208 + } 209 + } 210 + 211 + #[cfg(test)] 212 + mod tests { 213 + use super::*; 214 + use bone_types::Angle; 215 + use uom::si::angle::degree; 216 + 217 + fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 { 218 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 219 + panic!("half height is positive"); 220 + }; 221 + let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else { 222 + panic!("camera is non-degenerate"); 223 + }; 224 + camera 225 + } 226 + 227 + fn front() -> Camera3 { 228 + ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis()) 229 + } 230 + 231 + fn top() -> Camera3 { 232 + ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis()) 233 + } 234 + 235 + fn bottom() -> Camera3 { 236 + ortho_camera( 237 + Point3::from_mm(0.0, 0.0, -10.0), 238 + UnitVec3::y_axis().reversed(), 239 + ) 240 + } 241 + 242 + fn close(a: Point3, b: Point3) -> bool { 243 + let (ax, ay, az) = a.coords_mm(); 244 + let (bx, by, bz) = b.coords_mm(); 245 + (ax - bx).abs() < 1e-9 && (ay - by).abs() < 1e-9 && (az - bz).abs() < 1e-9 246 + } 247 + 248 + #[test] 249 + fn endpoints_reproduce_the_input_cameras() { 250 + let Ok(start) = interpolate(front(), top(), 0.0) else { 251 + panic!("t=0 interpolates"); 252 + }; 253 + let Ok(end) = interpolate(front(), top(), 1.0) else { 254 + panic!("t=1 interpolates"); 255 + }; 256 + assert!( 257 + close(start.eye(), front().eye()), 258 + "t=0 must be the start eye" 259 + ); 260 + assert!(close(end.eye(), top().eye()), "t=1 must be the end eye"); 261 + assert!(close(start.target(), front().target())); 262 + assert!(close(end.target(), top().target())); 263 + } 264 + 265 + #[test] 266 + fn immediate_tween_is_the_target_at_any_time() { 267 + let tween = CameraTween::immediate(top()); 268 + let Ok(at_zero) = tween.sample(Duration::ZERO) else { 269 + panic!("immediate samples at zero"); 270 + }; 271 + let Ok(at_later) = tween.sample(Duration::from_secs(5)) else { 272 + panic!("immediate samples later"); 273 + }; 274 + assert!(close(at_zero.eye(), top().eye())); 275 + assert!(close(at_later.eye(), top().eye())); 276 + assert!( 277 + close(tween.to().eye(), top().eye()), 278 + "the endpoint is the target" 279 + ); 280 + assert!(tween.is_done(Duration::ZERO), "a zero-length tween is done"); 281 + } 282 + 283 + #[test] 284 + fn opposite_views_interpolate_without_panic() { 285 + let Ok(mid) = interpolate(top(), bottom(), 0.5) else { 286 + panic!("a 180-degree swing between top and bottom is well defined"); 287 + }; 288 + let span = (mid.eye() - mid.target()).norm_mm(); 289 + assert!(span > 0.0, "the midpoint camera keeps eye and target apart"); 290 + } 291 + 292 + #[test] 293 + fn midpoint_keeps_the_orbit_radius() { 294 + let Ok(mid) = interpolate(front(), top(), 0.5) else { 295 + panic!("midpoint interpolates"); 296 + }; 297 + let radius = (mid.eye() - mid.target()).norm_mm(); 298 + assert!( 299 + (radius - 10.0).abs() < 1e-9, 300 + "equal-radius endpoints keep the radius across the slerp: {radius}", 301 + ); 302 + } 303 + 304 + #[test] 305 + fn sample_runs_the_tween_from_start_to_finish() { 306 + let tween = CameraTween::eased( 307 + front(), 308 + top(), 309 + Duration::from_millis(180), 310 + CubicEasing::STANDARD, 311 + ); 312 + let Ok(start) = tween.sample(Duration::ZERO) else { 313 + panic!("samples at start"); 314 + }; 315 + let Ok(finish) = tween.sample(Duration::from_millis(180)) else { 316 + panic!("samples at finish"); 317 + }; 318 + assert!(close(start.eye(), front().eye())); 319 + assert!(close(finish.eye(), top().eye())); 320 + } 321 + 322 + #[test] 323 + fn linear_easing_is_the_identity() { 324 + [0.0, 0.25, 0.5, 0.75, 1.0].into_iter().for_each(|x| { 325 + assert!( 326 + (ease(CubicEasing::LINEAR, x) - x).abs() < SOLVE_EPSILON, 327 + "linear ease passes x through within solver precision", 328 + ); 329 + }); 330 + } 331 + 332 + #[test] 333 + fn standard_easing_is_monotonic_and_pinned_at_the_ends() { 334 + assert!((ease(CubicEasing::STANDARD, 0.0)).abs() < 1e-9); 335 + assert!((ease(CubicEasing::STANDARD, 1.0) - 1.0).abs() < 1e-9); 336 + let samples: Vec<f64> = (0..=20) 337 + .map(|i| ease(CubicEasing::STANDARD, f64::from(i) / 20.0)) 338 + .collect(); 339 + samples.windows(2).for_each(|pair| { 340 + assert!(pair[1] >= pair[0] - 1e-9, "easing must not move backward"); 341 + }); 342 + } 343 + 344 + #[test] 345 + fn perspective_projection_interpolates_in_kind() { 346 + let Ok(near_proj) = Projection::perspective( 347 + Angle::new::<degree>(40.0), 348 + Length::new::<millimeter>(1.0), 349 + Length::new::<millimeter>(100.0), 350 + ) else { 351 + panic!("projection is valid"); 352 + }; 353 + let Ok(far_proj) = Projection::perspective( 354 + Angle::new::<degree>(60.0), 355 + Length::new::<millimeter>(2.0), 356 + Length::new::<millimeter>(200.0), 357 + ) else { 358 + panic!("projection is valid"); 359 + }; 360 + let Ok(mid) = lerp_projection(near_proj, far_proj, 0.5) else { 361 + panic!("two perspectives interpolate"); 362 + }; 363 + let ProjectionKind::Perspective { fov, near, far } = mid.kind() else { 364 + panic!("the interpolation stays perspective"); 365 + }; 366 + assert!((fov.get::<degree>() - 50.0).abs() < 1e-9); 367 + assert!((near.get::<millimeter>() - 1.5).abs() < 1e-9); 368 + assert!((far.get::<millimeter>() - 150.0).abs() < 1e-9); 369 + } 370 + }
crates/bone-render/tests/goldens/cube_iso_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/hidden_line_gray_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/hidden_line_removed_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/shaded_no_edges_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/shaded_no_edges_cylinder_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/shaded_with_edges_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/shaded_with_edges_cylinder_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/solid_cylinder_front_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/standard_view_front_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/standard_view_right_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/standard_view_top_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/view_tween_front_to_top_mid_256.png

This is a binary file and will not be displayed.

crates/bone-render/tests/goldens/wireframe_256.png

This is a binary file and will not be displayed.

+202
crates/bone-render/tests/standard_views.rs
··· 1 + use core::time::Duration; 2 + 3 + use bone_kernel::{ 4 + BrepSolid, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeProfile, 5 + ExtrudeSense, Line2, MergeResult, ProfileEdge, ProfileLoop, evaluate_extrude, 6 + }; 7 + use bone_render::{ 8 + CameraTween, EdgeScene, OffscreenContext, SnapshotFrame, SolidRenderer, SolidScene, Style, 9 + frame_standard_view, 10 + }; 11 + use bone_types::{ 12 + Aabb3, AngleTolerance, Camera3, ChordHeightTolerance, CubicEasing, DisplayMode, FeatureId, 13 + Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, StandardView, Tolerance, 14 + UnitVec3, millimeter, 15 + }; 16 + use slotmap::{Key, SlotMap}; 17 + 18 + mod common; 19 + 20 + use common::{check_golden, extent_square as extent, make_context}; 21 + 22 + const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 23 + const CHORD_MM: f64 = 0.05; 24 + const ANGLE_RAD: f64 = 0.2; 25 + const UPDATE_ENV: &str = "BONE_UPDATE_STANDARD_VIEW_GOLDENS"; 26 + 27 + fn slab() -> BrepSolid { 28 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 29 + let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key(); 30 + let Ok(plane) = bone_types::Plane3::new( 31 + Point3::origin(), 32 + UnitVec3::x_axis(), 33 + UnitVec3::y_axis(), 34 + TOLERANCE, 35 + ) else { 36 + panic!("x and y axes are orthonormal"); 37 + }; 38 + let corners = [ 39 + Point2::from_mm(0.0, 0.0), 40 + Point2::from_mm(2.0, 0.0), 41 + Point2::from_mm(2.0, 1.0), 42 + Point2::from_mm(0.0, 1.0), 43 + ]; 44 + let edges = (0..4) 45 + .map(|index| { 46 + let start = corners[index]; 47 + let end = corners[(index + 1) % 4]; 48 + let Ok(segment) = Line2::new(start, end, TOLERANCE) else { 49 + panic!("rectangle endpoints are distinct"); 50 + }; 51 + ProfileEdge::new( 52 + Curve2Kind::Line(segment), 53 + entities.insert(()), 54 + entities.insert(()), 55 + ) 56 + }) 57 + .collect(); 58 + let profile = ExtrudeProfile::new(plane, vec![ProfileLoop::Open(edges)]); 59 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(3.0)) else { 60 + panic!("3 mm is a positive length"); 61 + }; 62 + let feature = ExtrudeFeature { 63 + sketch: SketchId::null(), 64 + direction: ExtrudeDirection::Normal { 65 + sense: ExtrudeSense::Forward, 66 + }, 67 + end_condition: ExtrudeEndCondition::Blind { depth }, 68 + draft: None, 69 + thin_wall: None, 70 + merge_result: MergeResult::Merge, 71 + }; 72 + let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &feature) else { 73 + panic!("the rectangle extrudes into a 2x1x3 slab"); 74 + }; 75 + solid 76 + } 77 + 78 + fn aabb_of(solid: &BrepSolid) -> Aabb3 { 79 + let Some(aabb) = solid.bounding_box() else { 80 + panic!("the slab has a bounding box"); 81 + }; 82 + aabb 83 + } 84 + 85 + fn scenes(solid: &BrepSolid) -> (SolidScene, EdgeScene) { 86 + let Ok(mesh) = solid.tessellate( 87 + ChordHeightTolerance::from_mm(CHORD_MM), 88 + AngleTolerance::from_radians(ANGLE_RAD), 89 + ) else { 90 + panic!("the solid tessellates"); 91 + }; 92 + let Ok(faces) = SolidScene::from_mesh(&mesh) else { 93 + panic!("the mesh packs face pick ids"); 94 + }; 95 + let Ok(edges) = EdgeScene::from_solid(solid, &mesh, ChordHeightTolerance::from_mm(CHORD_MM)) 96 + else { 97 + panic!("the solid packs edge pick ids"); 98 + }; 99 + (faces, edges) 100 + } 101 + 102 + fn render( 103 + ctx: &OffscreenContext, 104 + faces: &SolidScene, 105 + edges: &EdgeScene, 106 + camera: Camera3, 107 + ) -> SnapshotFrame { 108 + let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); 109 + let Ok(frame) = renderer.render_display( 110 + ctx, 111 + faces, 112 + edges, 113 + camera, 114 + &Style::default(), 115 + DisplayMode::ShadedWithEdges, 116 + ) else { 117 + panic!("SolidRenderer::render_display failed"); 118 + }; 119 + frame 120 + } 121 + 122 + fn view(solid: &BrepSolid, which: StandardView, size: bone_render::ViewportExtent) -> Camera3 { 123 + let Ok(camera) = frame_standard_view(aabb_of(solid), size, which, None) else { 124 + panic!("a fixed standard view frames the slab"); 125 + }; 126 + camera 127 + } 128 + 129 + #[test] 130 + fn front_view_matches_golden() { 131 + let size = extent(256); 132 + let ctx = make_context(size); 133 + let solid = slab(); 134 + let (faces, edges) = scenes(&solid); 135 + let frame = render( 136 + &ctx, 137 + &faces, 138 + &edges, 139 + view(&solid, StandardView::Front, size), 140 + ); 141 + check_golden( 142 + &frame, 143 + "tests/goldens/standard_view_front_256.png", 144 + UPDATE_ENV, 145 + ); 146 + } 147 + 148 + #[test] 149 + fn top_view_matches_golden() { 150 + let size = extent(256); 151 + let ctx = make_context(size); 152 + let solid = slab(); 153 + let (faces, edges) = scenes(&solid); 154 + let frame = render(&ctx, &faces, &edges, view(&solid, StandardView::Top, size)); 155 + check_golden( 156 + &frame, 157 + "tests/goldens/standard_view_top_256.png", 158 + UPDATE_ENV, 159 + ); 160 + } 161 + 162 + #[test] 163 + fn right_view_matches_golden() { 164 + let size = extent(256); 165 + let ctx = make_context(size); 166 + let solid = slab(); 167 + let (faces, edges) = scenes(&solid); 168 + let frame = render( 169 + &ctx, 170 + &faces, 171 + &edges, 172 + view(&solid, StandardView::Right, size), 173 + ); 174 + check_golden( 175 + &frame, 176 + "tests/goldens/standard_view_right_256.png", 177 + UPDATE_ENV, 178 + ); 179 + } 180 + 181 + #[test] 182 + fn front_to_top_tween_midpoint_matches_golden() { 183 + let size = extent(256); 184 + let ctx = make_context(size); 185 + let solid = slab(); 186 + let (faces, edges) = scenes(&solid); 187 + let tween = CameraTween::eased( 188 + view(&solid, StandardView::Front, size), 189 + view(&solid, StandardView::Top, size), 190 + Duration::from_millis(180), 191 + CubicEasing::STANDARD, 192 + ); 193 + let Ok(mid) = tween.sample(Duration::from_millis(90)) else { 194 + panic!("the tween samples a valid midpoint camera"); 195 + }; 196 + let frame = render(&ctx, &faces, &edges, mid); 197 + check_golden( 198 + &frame, 199 + "tests/goldens/view_tween_front_to_top_mid_256.png", 200 + UPDATE_ENV, 201 + ); 202 + }
+57
crates/bone-types/src/camera.rs
··· 286 286 } 287 287 } 288 288 289 + #[derive(Copy, Clone, Debug, PartialEq)] 290 + pub struct CubicEasing { 291 + x1: f64, 292 + y1: f64, 293 + x2: f64, 294 + y2: f64, 295 + } 296 + 297 + impl CubicEasing { 298 + pub const LINEAR: Self = Self { 299 + x1: 0.0, 300 + y1: 0.0, 301 + x2: 1.0, 302 + y2: 1.0, 303 + }; 304 + 305 + pub const STANDARD: Self = Self { 306 + x1: 0.2, 307 + y1: 0.0, 308 + x2: 0.0, 309 + y2: 1.0, 310 + }; 311 + 312 + pub fn new(x1: f64, y1: f64, x2: f64, y2: f64) -> Result<Self> { 313 + let finite = [x1, y1, x2, y2].iter().all(|v| v.is_finite()); 314 + if !finite || !(0.0..=1.0).contains(&x1) || !(0.0..=1.0).contains(&x2) { 315 + return Err(TypesError::InvalidEasingControl { x1, y1, x2, y2 }); 316 + } 317 + Ok(Self { x1, y1, x2, y2 }) 318 + } 319 + 320 + #[must_use] 321 + pub const fn new_unchecked(x1: f64, y1: f64, x2: f64, y2: f64) -> Self { 322 + Self { x1, y1, x2, y2 } 323 + } 324 + 325 + #[must_use] 326 + pub const fn x1(self) -> f64 { 327 + self.x1 328 + } 329 + 330 + #[must_use] 331 + pub const fn y1(self) -> f64 { 332 + self.y1 333 + } 334 + 335 + #[must_use] 336 + pub const fn x2(self) -> f64 { 337 + self.x2 338 + } 339 + 340 + #[must_use] 341 + pub const fn y2(self) -> f64 { 342 + self.y2 343 + } 344 + } 345 + 289 346 fn quat_to_axis_angle(q: UnitQuaternion<f64>) -> AxisAngle { 290 347 match q.axis_angle() { 291 348 Some((axis, angle)) => AxisAngle::new(
+26 -10
crates/bone-types/src/lib.rs
··· 13 13 pub mod space; 14 14 pub mod step; 15 15 16 - pub use camera::{Camera3, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor}; 16 + pub use camera::{ 17 + Camera3, CubicEasing, OrbitState, Projection, ProjectionKind, StandardView, ZoomFactor, 18 + }; 17 19 pub use content::SolidKey; 18 20 pub use display::{DisplayMode, ShadingModel}; 19 21 pub use label::{ ··· 77 79 ZeroStepEntityId, 78 80 #[error("length must be finite and positive: {0} m")] 79 81 NonPositiveLength(f64), 82 + #[error("normal-to view requires a reference plane")] 83 + NormalToRequiresPlane, 84 + #[error("cubic easing needs finite controls with x1,x2 in 0..=1: ({x1},{y1}) ({x2},{y2})")] 85 + InvalidEasingControl { x1: f64, y1: f64, x2: f64, y2: f64 }, 80 86 } 81 87 82 88 pub type Result<T, E = TypesError> = core::result::Result<T, E>; ··· 330 336 mod tests { 331 337 use super::{ 332 338 Aabb3, Angle, AngleTolerance, AxisAngle, BodyId, BrepEdgeId, BrepFaceId, BrepLoopId, 333 - BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, CreaseAngle, DegreesOfFreedom, 334 - DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, FaceLabel, 335 - FaceRole, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, MeshGeneration, NodeId, 336 - OrbitState, OrientedBox3, Parameter, Plane3, Point2, Point3, PositiveLength, Projection, 337 - ProjectionKind, ShadingModel, ShellId, SideKind, SketchDimensionId, SketchEntityId, 338 - SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, SolverResidual, 339 - StandardView, StepEntityId, StepEntityKind, StepFileHeader, StepFileName, StepOrganization, 340 - StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, Vec3, VertexId, 341 - VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, 339 + BrepShellId, BrepVertexId, Camera3, ChordHeightTolerance, CreaseAngle, CubicEasing, 340 + DegreesOfFreedom, DisplayMode, DocumentId, EdgeId, EdgeLabel, EdgeRole, ExtrudeId, FaceId, 341 + FaceLabel, FaceRole, FeatureId, ImportOrdinal, Length, LoopId, LoopIndex, MeshGeneration, 342 + NodeId, OrbitState, OrientedBox3, Parameter, Plane3, Point2, Point3, PositiveLength, 343 + Projection, ProjectionKind, ShadingModel, ShellId, SideKind, SketchDimensionId, 344 + SketchEntityId, SketchId, SketchParameterId, SketchPlaneBasis, SketchRelationId, SolidId, 345 + SolverResidual, StandardView, StepEntityId, StepEntityKind, StepFileHeader, StepFileName, 346 + StepOrganization, StepOriginatingSystem, StepSchema, Tolerance, UnitVec2, UnitVec3, Vec2, 347 + Vec3, VertexId, VertexLabel, VertexRole, WireId, ZoomFactor, degree, millimeter, radian, 342 348 }; 343 349 use slotmap::Key; 344 350 use uom::si::length::meter; ··· 1155 1161 assert!(ZoomFactor::new(-1.0).is_err()); 1156 1162 assert!(ZoomFactor::new(f64::NAN).is_err()); 1157 1163 assert!(ZoomFactor::new(f64::INFINITY).is_err()); 1164 + } 1165 + 1166 + #[test] 1167 + fn cubic_easing_constrains_x_controls_to_the_unit_interval() { 1168 + assert!(CubicEasing::new(0.2, 0.0, 0.0, 1.0).is_ok()); 1169 + assert!(CubicEasing::new(0.0, -2.0, 1.0, 3.0).is_ok()); 1170 + assert!(CubicEasing::new(1.5, 0.0, 0.5, 1.0).is_err()); 1171 + assert!(CubicEasing::new(0.2, 0.0, -0.5, 1.0).is_err()); 1172 + assert!(CubicEasing::new(f64::NAN, 0.0, 0.5, 1.0).is_err()); 1173 + assert!(CubicEasing::new(0.2, f64::INFINITY, 0.5, 1.0).is_err()); 1158 1174 } 1159 1175 1160 1176 #[test]
+1
crates/bone-ui/Cargo.toml
··· 8 8 [dependencies] 9 9 accesskit = { workspace = true } 10 10 bone-text = { workspace = true } 11 + bone-types = { workspace = true } 11 12 lyon_tessellation = { workspace = true } 12 13 palette = { workspace = true } 13 14 png = { workspace = true }
+33 -5
crates/bone-ui/src/theme/motion.rs
··· 1 1 use core::time::Duration; 2 2 3 + use bone_types::CubicEasing; 3 4 use serde::{Deserialize, Serialize}; 4 5 5 6 #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ··· 12 13 13 14 impl Easing { 14 15 #[must_use] 15 - pub const fn cubic_bezier(self) -> [f32; 4] { 16 + pub const fn cubic_bezier(self) -> CubicEasing { 16 17 match self { 17 - Self::Standard => [0.2, 0.0, 0.0, 1.0], 18 - Self::Accelerate => [0.3, 0.0, 1.0, 1.0], 19 - Self::Decelerate => [0.0, 0.0, 0.0, 1.0], 20 - Self::Linear => [0.0, 0.0, 1.0, 1.0], 18 + Self::Standard => CubicEasing::STANDARD, 19 + Self::Accelerate => CubicEasing::new_unchecked(0.3, 0.0, 1.0, 1.0), 20 + Self::Decelerate => CubicEasing::new_unchecked(0.0, 0.0, 0.0, 1.0), 21 + Self::Linear => CubicEasing::LINEAR, 21 22 } 22 23 } 23 24 } ··· 78 79 }, 79 80 }; 80 81 } 82 + 83 + #[cfg(test)] 84 + mod tests { 85 + use super::*; 86 + 87 + #[test] 88 + fn every_easing_curve_passes_cubic_validation() { 89 + [ 90 + Easing::Standard, 91 + Easing::Accelerate, 92 + Easing::Decelerate, 93 + Easing::Linear, 94 + ] 95 + .into_iter() 96 + .for_each(|easing| { 97 + let curve = easing.cubic_bezier(); 98 + let Ok(validated) = CubicEasing::new(curve.x1(), curve.y1(), curve.x2(), curve.y2()) 99 + else { 100 + panic!("{easing} must define a valid cubic easing"); 101 + }; 102 + assert_eq!( 103 + validated, curve, 104 + "{easing} control points must survive validation unchanged", 105 + ); 106 + }); 107 + } 108 + }