Another project
0

Configure Feed

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

at main 11 kB View raw
1use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 2use bone_render::{ 3 Camera2, PickAperture, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, Style, 4 ViewportExtent, ViewportPx, 5}; 6use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 7use uom::si::length::millimeter; 8 9mod common; 10 11const SIDE: u32 = 256; 12const CENTER_PX: f32 = 128.0; 13const PX_PER_MM: f32 = 10.0; 14 15fn extent() -> ViewportExtent { 16 common::extent_square(SIDE) 17} 18 19fn plane() -> SketchPlaneBasis { 20 let Ok(basis) = SketchPlaneBasis::new( 21 Point3::origin(), 22 UnitVec3::x_axis(), 23 UnitVec3::y_axis(), 24 Tolerance::new(1e-9), 25 ) else { 26 panic!("xy plane basis is orthogonal"); 27 }; 28 basis 29} 30 31fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 32 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 33 Point2::from_mm(x, y), 34 ))) else { 35 panic!("add point"); 36 }; 37 (next, id) 38} 39 40fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) { 41 let Ok((next, EditOutcome::Entity(id))) = 42 s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 43 else { 44 panic!("add line"); 45 }; 46 (next, id) 47} 48 49fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { 50 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 51 center, 52 Length::new::<millimeter>(radius_mm), 53 false, 54 ))) else { 55 panic!("add circle"); 56 }; 57 (next, id) 58} 59 60fn add_arc( 61 s: Sketch, 62 center: SketchEntityId, 63 start: SketchEntityId, 64 end: SketchEntityId, 65) -> (Sketch, SketchEntityId) { 66 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::arc( 67 center, start, end, false, 68 ))) else { 69 panic!("add arc"); 70 }; 71 (next, id) 72} 73 74#[allow( 75 clippy::cast_possible_truncation, 76 clippy::cast_sign_loss, 77 reason = "fixture coordinates are bounded within the small test viewport" 78)] 79fn world_to_px(x_mm: f32, y_mm: f32) -> PickQuery { 80 let x_px = (CENTER_PX + x_mm * PX_PER_MM).round() as u32; 81 let y_px = (CENTER_PX - y_mm * PX_PER_MM).round() as u32; 82 PickQuery::new(ViewportPx::new(x_px), ViewportPx::new(y_px)) 83} 84 85struct Fixture { 86 scene: SketchScene, 87 probe_point: (PickQuery, SketchEntityId), 88 probe_line: (PickQuery, SketchEntityId), 89 probe_circle: (PickQuery, SketchEntityId), 90 probe_arc: (PickQuery, SketchEntityId), 91} 92 93fn build_fixture() -> Fixture { 94 let s = Sketch::new(plane()); 95 96 let (s, line_a) = add_point(s, -6.0, 2.0); 97 let (s, line_b) = add_point(s, -6.0, -2.0); 98 let (s, line) = add_line(s, line_a, line_b); 99 100 let (s, standalone) = add_point(s, 6.0, 0.0); 101 102 let (s, circle_center) = add_point(s, 0.0, 4.0); 103 let (s, circle) = add_circle(s, circle_center, 2.0); 104 105 let (s, arc_center) = add_point(s, 0.0, -4.0); 106 let (s, arc_start) = add_point(s, 3.0, -4.0); 107 let (s, arc_end) = add_point(s, 0.0, -1.0); 108 let (s, arc) = add_arc(s, arc_center, arc_start, arc_end); 109 110 let Ok(scene) = SketchScene::extract(&s) else { 111 panic!("scene extract"); 112 }; 113 114 Fixture { 115 scene, 116 probe_point: (world_to_px(6.0, 0.0), standalone), 117 probe_line: (world_to_px(-6.0, 0.0), line), 118 probe_circle: (world_to_px(2.0, 4.0), circle), 119 probe_arc: ( 120 world_to_px( 121 3.0 * core::f32::consts::FRAC_1_SQRT_2, 122 -4.0 + 3.0 * core::f32::consts::FRAC_1_SQRT_2, 123 ), 124 arc, 125 ), 126 } 127} 128 129#[test] 130fn each_entity_centroid_round_trips_to_pick_id() { 131 let ctx = common::make_context(extent()); 132 let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 133 let camera = Camera2::new(extent()); 134 let style = Style::default(); 135 136 let fx = build_fixture(); 137 let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 138 panic!("SketchRenderer::render failed"); 139 }; 140 let Ok(index) = fx.scene.pick_index() else { 141 panic!("pick index build"); 142 }; 143 let picker = ctx.picker(index); 144 145 let cases = [ 146 ( 147 "point", 148 fx.probe_point.0.with_aperture(PickAperture::EXACT), 149 PickedItem::Point(fx.probe_point.1), 150 ), 151 ( 152 "line", 153 fx.probe_line.0.with_aperture(PickAperture::EXACT), 154 PickedItem::Line(fx.probe_line.1), 155 ), 156 ( 157 "circle", 158 fx.probe_circle.0.with_aperture(PickAperture::EXACT), 159 PickedItem::Circle(fx.probe_circle.1), 160 ), 161 ( 162 "arc", 163 fx.probe_arc.0.with_aperture(PickAperture::EXACT), 164 PickedItem::Arc(fx.probe_arc.1), 165 ), 166 ]; 167 168 let mismatches: Vec<String> = cases 169 .into_iter() 170 .filter_map(|(label, query, expected)| match picker.at(query) { 171 Ok(Some(got)) if got == expected => None, 172 Ok(got) => Some(format!( 173 "{label} pick at {query}: expected {expected:?}, got {got:?}" 174 )), 175 Err(e) => Some(format!("{label} pick at {query}: error {e}")), 176 }) 177 .collect(); 178 assert!(mismatches.is_empty(), "pick mismatches: {mismatches:#?}"); 179} 180 181#[test] 182fn empty_space_decodes_to_none() { 183 let ctx = common::make_context(extent()); 184 let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 185 let camera = Camera2::new(extent()); 186 let style = Style::default(); 187 188 let fx = build_fixture(); 189 let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 190 panic!("SketchRenderer::render failed"); 191 }; 192 let Ok(index) = fx.scene.pick_index() else { 193 panic!("pick index"); 194 }; 195 let picker = ctx.picker(index); 196 197 let empty = world_to_px(-8.0, 8.0); 198 let Ok(hit) = picker.at(empty) else { 199 panic!("picker at empty failed"); 200 }; 201 assert_eq!(hit, None, "empty pixel expected to decode to None"); 202} 203 204#[test] 205fn repeated_render_picks_deterministic() { 206 let ctx = common::make_context(extent()); 207 let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 208 let camera = Camera2::new(extent()); 209 let style = Style::default(); 210 211 let fx = build_fixture(); 212 let Ok(index) = fx.scene.pick_index() else { 213 panic!("pick index"); 214 }; 215 216 let mut collect_picks = || { 217 let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 218 panic!("render"); 219 }; 220 let pp = ctx.picker(index.clone()); 221 [ 222 fx.probe_point.0, 223 fx.probe_line.0, 224 fx.probe_circle.0, 225 fx.probe_arc.0, 226 ] 227 .into_iter() 228 .map(|q| { 229 let Ok(raw) = pp.raw_at(q) else { 230 panic!("raw_at failed"); 231 }; 232 raw.raw() 233 }) 234 .collect::<Vec<_>>() 235 }; 236 237 let first = collect_picks(); 238 let second = collect_picks(); 239 assert_eq!( 240 first, second, 241 "repeated render produced non-deterministic ids" 242 ); 243} 244 245#[test] 246fn aperture_picks_line_when_cursor_misses_by_a_few_pixels() { 247 let ctx = common::make_context(extent()); 248 let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 249 let camera = Camera2::new(extent()); 250 let style = Style::default(); 251 252 let fx = build_fixture(); 253 let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 254 panic!("SketchRenderer::render failed"); 255 }; 256 let Ok(index) = fx.scene.pick_index() else { 257 panic!("pick index build"); 258 }; 259 let picker = ctx.picker(index); 260 261 let line_centroid = fx.probe_line.0; 262 #[allow( 263 clippy::cast_possible_truncation, 264 clippy::cast_sign_loss, 265 reason = "stroke half-width is small and bounded; ceil keeps offset positive" 266 )] 267 let stroke_clearance_px = (style.strokes().stroke_width_px() * 0.5 + 1.0).ceil() as u32; 268 let off_axis = PickQuery::new( 269 ViewportPx::new(line_centroid.x().value() + stroke_clearance_px), 270 line_centroid.y(), 271 ); 272 273 let Ok(default_hit) = picker.at(off_axis) else { 274 panic!("aperture pick failed"); 275 }; 276 assert_eq!( 277 default_hit, 278 Some(PickedItem::Line(fx.probe_line.1)), 279 "default aperture should snap a near-miss onto the line" 280 ); 281 282 let Ok(exact_hit) = picker.at(off_axis.with_aperture(PickAperture::EXACT)) else { 283 panic!("exact pick failed"); 284 }; 285 assert_eq!( 286 exact_hit, None, 287 "exact aperture must keep the near-miss as empty space" 288 ); 289} 290 291#[test] 292fn aperture_picks_closer_entity_when_two_in_range() { 293 let ctx = common::make_context(extent()); 294 let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 295 let camera = Camera2::new(extent()); 296 let style = Style::default(); 297 298 let s = Sketch::new(plane()); 299 let (s, far_id) = add_point(s, 6.0, 0.0); 300 let (s, near_id) = add_point(s, 9.0, 0.0); 301 let Ok(scene) = SketchScene::extract(&s) else { 302 panic!("scene extract"); 303 }; 304 let Ok(_) = renderer.render(&ctx, &scene, camera, &style) else { 305 panic!("SketchRenderer::render failed"); 306 }; 307 let Ok(index) = scene.pick_index() else { 308 panic!("pick index build"); 309 }; 310 let picker = ctx.picker(index); 311 312 let query = PickQuery::new(ViewportPx::new(212), ViewportPx::new(128)) 313 .with_aperture(PickAperture::new(30)); 314 let Ok(hit) = picker.at(query) else { 315 panic!("aperture pick failed"); 316 }; 317 assert_eq!( 318 hit, 319 Some(PickedItem::Point(near_id)), 320 "wider aperture must pick the nearer of two in-range entities (near {near_id:?} over far {far_id:?})", 321 ); 322} 323 324#[test] 325fn out_of_bounds_query_is_rejected() { 326 let ctx = common::make_context(extent()); 327 let Ok(index) = SketchScene::empty().pick_index() else { 328 panic!("pick index"); 329 }; 330 let picker = ctx.picker(index); 331 let cases = [ 332 ( 333 "x-axis edge", 334 PickQuery::new(ViewportPx::new(SIDE), ViewportPx::new(0)), 335 ), 336 ( 337 "y-axis edge", 338 PickQuery::new(ViewportPx::new(0), ViewportPx::new(SIDE)), 339 ), 340 ]; 341 let failures: Vec<String> = cases 342 .into_iter() 343 .filter_map(|(label, query)| match picker.at(query) { 344 Err(RenderError::PickOutOfBounds { .. }) => None, 345 other => Some(format!("{label}: expected OOB, got {other:?}")), 346 }) 347 .collect(); 348 assert!(failures.is_empty(), "OOB rejection failures: {failures:#?}"); 349}