Another project
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}