Another project
1use bone_render::{CameraTween, lower_f32};
2use bone_types::{Camera3, StandardView, Tolerance, UnitVec3};
3use bone_ui::a11y::{AccessNode, Role};
4use bone_ui::frame::{FrameCtx, InteractDeclaration};
5use bone_ui::hit_test::Sense;
6use bone_ui::input::{FrameInstant, PointerButton};
7use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
8use bone_ui::strings::StringKey;
9use bone_ui::theme::{Border, Color, Step12, StrokeWidth, Theme};
10use bone_ui::widgets::{
11 Button, ButtonVariant, ContextMenu, ConvexPoly, Menu, MenuItem, MenuState, PolyPath, Tooltip,
12 TooltipPlacement, TooltipState, WidgetPaint, show_button, show_context_menu, show_menu,
13 show_tooltip,
14};
15use bone_ui::{WidgetId, WidgetKey};
16
17use crate::strings;
18
19const CUBE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9);
20const CUBE_SIDE_PX: f32 = 150.0;
21const CUBE_INSET_PX: f32 = 12.0;
22const CUBE_MARGIN_PX: f32 = 8.0;
23const FACE_LABEL_W_PX: f32 = 46.0;
24const FACE_LABEL_H_PX: f32 = 22.0;
25const EDGE_HIT_PX: f32 = 18.0;
26const CORNER_HIT_PX: f32 = 16.0;
27const EDGE_HOVER_WIDTH_PX: f32 = 3.0;
28const CORNER_HOVER_HALF_PX: f32 = 7.0;
29const LIGHT_DIR: (f64, f64, f64) = (0.35, -0.45, 1.0);
30const HOME_WIDTH_PX: f32 = 54.0;
31const HOME_HEIGHT_PX: f32 = 16.0;
32const HOME_GAP_PX: f32 = 6.0;
33const CONFIRM_RESERVE_PX: f32 = 56.0;
34const TOOLTIP_SIZE: LayoutSize = LayoutSize::new(LayoutPx::new(72.0), LayoutPx::new(18.0));
35const VISIBILITY_EPSILON: f64 = 1.0e-6;
36
37#[derive(Copy, Clone, Debug, PartialEq, Eq)]
38pub enum CellKind {
39 Face,
40 Edge,
41 Corner,
42}
43
44#[derive(Copy, Clone, Debug, PartialEq, Eq)]
45pub struct CubeCell {
46 x: i8,
47 y: i8,
48 z: i8,
49}
50
51impl CubeCell {
52 fn try_new(x: i8, y: i8, z: i8) -> Option<Self> {
53 let in_range = [x, y, z].into_iter().all(|c| (-1..=1).contains(&c));
54 ((x, y, z) != (0, 0, 0) && in_range).then_some(Self { x, y, z })
55 }
56
57 #[must_use]
58 pub fn all() -> Vec<Self> {
59 (-1..=1)
60 .flat_map(|x| (-1..=1).flat_map(move |y| (-1..=1).map(move |z| (x, y, z))))
61 .filter_map(|(x, y, z)| Self::try_new(x, y, z))
62 .collect()
63 }
64
65 fn index(self) -> u64 {
66 let code = |component: i8| match component {
67 -1 => 0,
68 0 => 1,
69 _ => 2,
70 };
71 code(self.x) * 9 + code(self.y) * 3 + code(self.z)
72 }
73
74 #[must_use]
75 pub fn kind(self) -> CellKind {
76 match [self.x, self.y, self.z]
77 .into_iter()
78 .filter(|c| *c != 0)
79 .count()
80 {
81 1 => CellKind::Face,
82 2 => CellKind::Edge,
83 _ => CellKind::Corner,
84 }
85 }
86
87 #[must_use]
88 pub fn direction(self) -> UnitVec3 {
89 let Ok(dir) = UnitVec3::try_from_components(
90 f64::from(self.x),
91 f64::from(self.y),
92 f64::from(self.z),
93 CUBE_TOLERANCE,
94 ) else {
95 unreachable!("a non-zero cell of small integers normalizes");
96 };
97 dir
98 }
99
100 #[must_use]
101 pub fn standard_view(self) -> Option<StandardView> {
102 match (self.x, self.y, self.z) {
103 (0, -1, 0) => Some(StandardView::Front),
104 (0, 1, 0) => Some(StandardView::Back),
105 (-1, 0, 0) => Some(StandardView::Left),
106 (1, 0, 0) => Some(StandardView::Right),
107 (0, 0, 1) => Some(StandardView::Top),
108 (0, 0, -1) => Some(StandardView::Bottom),
109 _ => None,
110 }
111 }
112
113 fn pick(self) -> ViewPick {
114 self.standard_view()
115 .map_or_else(|| ViewPick::Direction(self.direction()), ViewPick::Standard)
116 }
117
118 fn a11y_label(self) -> StringKey {
119 match self.kind() {
120 CellKind::Face => {
121 standard_view_label(self.standard_view().unwrap_or(StandardView::Isometric))
122 }
123 CellKind::Edge => strings::VIEW_CUBE_EDGE,
124 CellKind::Corner => strings::VIEW_CUBE_CORNER,
125 }
126 }
127}
128
129#[must_use]
130pub fn cell_widget_id(base: WidgetId, cell: CubeCell) -> WidgetId {
131 base.child_indexed(WidgetKey::new("cell"), cell.index())
132}
133
134#[derive(Copy, Clone, Debug, PartialEq)]
135pub enum ViewPick {
136 Standard(StandardView),
137 Direction(UnitVec3),
138 Home,
139}
140
141#[derive(Copy, Clone, Debug, PartialEq, Eq)]
142pub enum ViewCubeMenuAction {
143 SetAsHome,
144 FitToWindow,
145 ViewNormalTo,
146}
147
148#[derive(Copy, Clone, Debug, Default, PartialEq)]
149pub struct ViewCubeOutcome {
150 pub pick: Option<ViewPick>,
151 pub menu: Option<ViewCubeMenuAction>,
152}
153
154#[derive(Copy, Clone, Debug, PartialEq)]
155pub struct ActiveTween {
156 pub tween: CameraTween,
157 pub started: FrameInstant,
158}
159
160#[derive(Clone, Debug)]
161pub struct ViewUi {
162 pub cube_hidden: bool,
163 pub selector_open: bool,
164 pub selector_menu: MenuState,
165 pub cube_menu_open: bool,
166 pub cube_menu_anchor: LayoutPos,
167 pub cube_menu: MenuState,
168 pub cube_tooltip: TooltipState,
169 pub home: Option<Camera3>,
170 pub tween: Option<ActiveTween>,
171}
172
173impl Default for ViewUi {
174 fn default() -> Self {
175 Self {
176 cube_hidden: false,
177 selector_open: false,
178 selector_menu: MenuState::default(),
179 cube_menu_open: false,
180 cube_menu_anchor: LayoutPos::ORIGIN,
181 cube_menu: MenuState::default(),
182 cube_tooltip: TooltipState::default(),
183 home: None,
184 tween: None,
185 }
186 }
187}
188
189impl ViewUi {
190 pub fn toggle_cube(&mut self) {
191 self.cube_hidden = !self.cube_hidden;
192 }
193
194 pub fn toggle_selector(&mut self) {
195 self.selector_open = !self.selector_open;
196 if self.selector_open {
197 self.selector_menu = MenuState::default();
198 }
199 }
200}
201
202#[must_use]
203pub fn standard_view_label(view: StandardView) -> StringKey {
204 match view {
205 StandardView::Front => strings::VIEW_FRONT,
206 StandardView::Back => strings::VIEW_BACK,
207 StandardView::Left => strings::VIEW_LEFT,
208 StandardView::Right => strings::VIEW_RIGHT,
209 StandardView::Top => strings::VIEW_TOP,
210 StandardView::Bottom => strings::VIEW_BOTTOM,
211 StandardView::Isometric => strings::VIEW_ISOMETRIC,
212 StandardView::NormalTo => strings::VIEW_NORMAL_TO,
213 }
214}
215
216#[derive(Copy, Clone)]
217struct Basis {
218 right: Vec3f,
219 up: Vec3f,
220 forward: Vec3f,
221}
222
223#[derive(Copy, Clone)]
224struct Vec3f {
225 x: f64,
226 y: f64,
227 z: f64,
228}
229
230impl Vec3f {
231 const fn new(x: f64, y: f64, z: f64) -> Self {
232 Self { x, y, z }
233 }
234
235 fn dot(self, other: Self) -> f64 {
236 self.x * other.x + self.y * other.y + self.z * other.z
237 }
238
239 fn cross(self, other: Self) -> Self {
240 Self::new(
241 self.y * other.z - self.z * other.y,
242 self.z * other.x - self.x * other.z,
243 self.x * other.y - self.y * other.x,
244 )
245 }
246
247 fn normalized(self) -> Option<Self> {
248 let norm = self.dot(self).sqrt();
249 (norm > VISIBILITY_EPSILON).then(|| Self::new(self.x / norm, self.y / norm, self.z / norm))
250 }
251}
252
253fn view_basis(camera: Camera3) -> Option<Basis> {
254 let (ex, ey, ez) = camera.eye().coords_mm();
255 let (tx, ty, tz) = camera.target().coords_mm();
256 let (ux, uy, uz) = camera.up().components();
257 let forward = Vec3f::new(tx - ex, ty - ey, tz - ez).normalized()?;
258 let right = forward.cross(Vec3f::new(ux, uy, uz)).normalized()?;
259 let up = right.cross(forward);
260 Some(Basis { right, up, forward })
261}
262
263fn cell_vec(cell: CubeCell) -> Vec3f {
264 Vec3f::new(f64::from(cell.x), f64::from(cell.y), f64::from(cell.z))
265}
266
267fn cell_visible(cell: CubeCell, forward: Vec3f) -> bool {
268 [
269 (cell.x, forward.x),
270 (cell.y, forward.y),
271 (cell.z, forward.z),
272 ]
273 .into_iter()
274 .any(|(component, toward)| f64::from(component) * toward < -VISIBILITY_EPSILON)
275}
276
277#[derive(Clone)]
278enum CellGeom {
279 Face {
280 quad: ConvexPoly,
281 label_at: LayoutPos,
282 },
283 Edge {
284 a: LayoutPos,
285 b: LayoutPos,
286 },
287 Corner {
288 at: LayoutPos,
289 },
290}
291
292#[derive(Clone)]
293struct Placement {
294 cell: CubeCell,
295 geom: CellGeom,
296 pick_rect: LayoutRect,
297 depth: f64,
298}
299
300fn project(point: (f64, f64, f64), basis: Basis, center: (f32, f32), radius: f32) -> LayoutPos {
301 let v = Vec3f::new(point.0, point.1, point.2);
302 let scale = radius / 3.0_f32.sqrt();
303 let sx = lower_f32(v.dot(basis.right)) * scale;
304 let sy = lower_f32(v.dot(basis.up)) * scale;
305 LayoutPos::new(
306 LayoutPx::saturating(center.0 + sx),
307 LayoutPx::saturating(center.1 - sy),
308 )
309}
310
311fn cell_corners(cell: CubeCell) -> Vec<(f64, f64, f64)> {
312 let base = [f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)];
313 let zeros: Vec<usize> = (0..3).filter(|&i| base[i] == 0.0).collect();
314 let corner = |overrides: &[(usize, f64)]| {
315 let at = |i: usize| {
316 overrides
317 .iter()
318 .find_map(|&(j, v)| (j == i).then_some(v))
319 .unwrap_or(base[i])
320 };
321 (at(0), at(1), at(2))
322 };
323 match zeros.as_slice() {
324 [z] => [-1.0, 1.0]
325 .into_iter()
326 .map(|s| corner(&[(*z, s)]))
327 .collect(),
328 [u, v] => [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
329 .into_iter()
330 .map(|(su, sv)| corner(&[(*u, su), (*v, sv)]))
331 .collect(),
332 _ => vec![corner(&[])],
333 }
334}
335
336fn placement(cell: CubeCell, basis: Basis, center: (f32, f32), radius: f32) -> Option<Placement> {
337 let projected: Vec<LayoutPos> = cell_corners(cell)
338 .into_iter()
339 .map(|c| project(c, basis, center, radius))
340 .collect();
341 let geom = match cell.kind() {
342 CellKind::Face => CellGeom::Face {
343 quad: ConvexPoly::new(projected)?,
344 label_at: project(
345 (f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)),
346 basis,
347 center,
348 radius,
349 ),
350 },
351 CellKind::Edge => CellGeom::Edge {
352 a: *projected.first()?,
353 b: *projected.get(1)?,
354 },
355 CellKind::Corner => CellGeom::Corner {
356 at: *projected.first()?,
357 },
358 };
359 let pick_rect = pick_rect_for(&geom);
360 Some(Placement {
361 cell,
362 geom,
363 pick_rect,
364 depth: cell_vec(cell).dot(basis.forward),
365 })
366}
367
368fn pick_rect_for(geom: &CellGeom) -> LayoutRect {
369 match geom {
370 CellGeom::Face { label_at, .. } => {
371 centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX)
372 }
373 CellGeom::Edge { a, b } => centered_rect(midpoint(*a, *b), EDGE_HIT_PX, EDGE_HIT_PX),
374 CellGeom::Corner { at } => centered_rect(*at, CORNER_HIT_PX, CORNER_HIT_PX),
375 }
376}
377
378fn centered_rect(at: LayoutPos, width: f32, height: f32) -> LayoutRect {
379 LayoutRect::new(
380 LayoutPos::new(
381 LayoutPx::saturating(at.x.value() - width / 2.0),
382 LayoutPx::saturating(at.y.value() - height / 2.0),
383 ),
384 LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)),
385 )
386}
387
388fn midpoint(a: LayoutPos, b: LayoutPos) -> LayoutPos {
389 LayoutPos::new(
390 LayoutPx::saturating((a.x.value() + b.x.value()) * 0.5),
391 LayoutPx::saturating((a.y.value() + b.y.value()) * 0.5),
392 )
393}
394
395fn face_shade(cell: CubeCell) -> f32 {
396 let (nx, ny, nz) = cell.direction().components();
397 let norm =
398 (LIGHT_DIR.0 * LIGHT_DIR.0 + LIGHT_DIR.1 * LIGHT_DIR.1 + LIGHT_DIR.2 * LIGHT_DIR.2).sqrt();
399 let dot = (nx * LIGHT_DIR.0 + ny * LIGHT_DIR.1 + nz * LIGHT_DIR.2) / norm;
400 lower_f32(0.55 + 0.45 * dot.clamp(-1.0, 1.0))
401}
402
403fn kind_rank(kind: CellKind) -> u8 {
404 match kind {
405 CellKind::Face => 0,
406 CellKind::Edge => 1,
407 CellKind::Corner => 2,
408 }
409}
410
411fn cube_anchor(viewport: LayoutRect, top_offset: f32) -> Option<(LayoutRect, (f32, f32), f32)> {
412 let span_w = CUBE_SIDE_PX + 2.0 * CUBE_INSET_PX;
413 let span_h = span_w + top_offset;
414 if viewport.size.width.value() < span_w || viewport.size.height.value() < span_h {
415 return None;
416 }
417 let right_edge = viewport.min_x().value() + viewport.size.width.value() - CUBE_INSET_PX;
418 let top = viewport.min_y().value() + CUBE_INSET_PX + top_offset;
419 let rect = LayoutRect::new(
420 LayoutPos::new(LayoutPx::new(right_edge - CUBE_SIDE_PX), LayoutPx::new(top)),
421 LayoutSize::new(LayoutPx::new(CUBE_SIDE_PX), LayoutPx::new(CUBE_SIDE_PX)),
422 );
423 let center = (right_edge - CUBE_SIDE_PX / 2.0, top + CUBE_SIDE_PX / 2.0);
424 let radius = CUBE_SIDE_PX / 2.0 - CUBE_MARGIN_PX;
425 Some((rect, center, radius))
426}
427
428fn ordered_visible(basis: Basis, center: (f32, f32), radius: f32) -> Vec<Placement> {
429 let mut placements: Vec<Placement> = CubeCell::all()
430 .into_iter()
431 .filter(|cell| cell_visible(*cell, basis.forward))
432 .filter_map(|cell| placement(cell, basis, center, radius))
433 .collect();
434 placements.sort_by(|a, b| {
435 kind_rank(a.cell.kind())
436 .cmp(&kind_rank(b.cell.kind()))
437 .then(
438 b.depth
439 .partial_cmp(&a.depth)
440 .unwrap_or(core::cmp::Ordering::Equal),
441 )
442 .then(a.cell.index().cmp(&b.cell.index()))
443 });
444 placements
445}
446
447pub struct ViewCubeInputs<'a> {
448 pub viewport: LayoutRect,
449 pub camera: Camera3,
450 pub base: WidgetId,
451 pub menu_id: WidgetId,
452 pub view: &'a mut ViewUi,
453 pub normal_to_available: bool,
454 pub confirm_visible: bool,
455}
456
457#[must_use]
458pub fn render_view_cube(
459 ctx: &mut FrameCtx<'_>,
460 inputs: ViewCubeInputs<'_>,
461 paints: &mut Vec<WidgetPaint>,
462 popover_paints: &mut Vec<WidgetPaint>,
463) -> ViewCubeOutcome {
464 let ViewCubeInputs {
465 viewport,
466 camera,
467 base,
468 menu_id,
469 view,
470 normal_to_available,
471 confirm_visible,
472 } = inputs;
473 if view.cube_hidden {
474 return ViewCubeOutcome::default();
475 }
476 let top_offset = if confirm_visible {
477 CONFIRM_RESERVE_PX
478 } else {
479 0.0
480 };
481 let Some((anchor, center, radius)) = cube_anchor(viewport, top_offset) else {
482 return ViewCubeOutcome::default();
483 };
484 let Some(basis) = view_basis(camera) else {
485 return ViewCubeOutcome::default();
486 };
487 ctx.a11y.push(
488 base,
489 anchor,
490 AccessNode::new(Role::Group).with_label(strings::VIEW_CUBE),
491 );
492 let placements = ordered_visible(basis, center, radius);
493 let mut pick: Option<ViewPick> = None;
494 let mut hovered: Option<(CubeCell, LayoutRect)> = None;
495 let mut labels: Vec<(LayoutRect, StringKey, Color)> = Vec::new();
496 placements.iter().for_each(|place| {
497 let id = cell_widget_id(base, place.cell);
498 let interaction = ctx.interact(
499 InteractDeclaration::new(id, place.pick_rect, Sense::INTERACTIVE)
500 .a11y(AccessNode::new(Role::Button).with_label(place.cell.a11y_label())),
501 );
502 let active = interaction.hover();
503 draw_cell(ctx.theme(), place, active, paints, &mut labels);
504 if interaction.click() && pick.is_none() {
505 pick = Some(place.cell.pick());
506 }
507 if active && hovered.is_none() {
508 hovered = Some((place.cell, place.pick_rect));
509 }
510 });
511 let label_role = ctx.theme().typography.label;
512 labels.into_iter().for_each(|(rect, key, color)| {
513 paints.push(WidgetPaint::Label {
514 rect,
515 text: key.into(),
516 color,
517 role: label_role,
518 });
519 });
520 if let Some((cell, rect)) = hovered {
521 let id = cell_widget_id(base, cell);
522 let tip = show_tooltip(
523 ctx,
524 Tooltip::new(
525 id,
526 rect,
527 cell.a11y_label(),
528 TooltipPlacement::Left,
529 TOOLTIP_SIZE,
530 ),
531 &mut view.cube_tooltip,
532 );
533 popover_paints.extend(tip);
534 }
535 if view.home.is_some() && draw_home_button(ctx, base, anchor, paints) && pick.is_none() {
536 pick = Some(ViewPick::Home);
537 }
538 let menu = run_cube_menu(
539 ctx,
540 menu_id,
541 anchor,
542 view,
543 normal_to_available,
544 popover_paints,
545 );
546 ViewCubeOutcome { pick, menu }
547}
548
549fn draw_home_button(
550 ctx: &mut FrameCtx<'_>,
551 base: WidgetId,
552 anchor: LayoutRect,
553 paints: &mut Vec<WidgetPaint>,
554) -> bool {
555 let rect = LayoutRect::new(
556 LayoutPos::new(
557 LayoutPx::new(
558 anchor.min_x().value() + (anchor.size.width.value() - HOME_WIDTH_PX) / 2.0,
559 ),
560 LayoutPx::new(anchor.min_y().value() + anchor.size.height.value() + HOME_GAP_PX),
561 ),
562 LayoutSize::new(LayoutPx::new(HOME_WIDTH_PX), LayoutPx::new(HOME_HEIGHT_PX)),
563 );
564 let response = show_button(
565 ctx,
566 Button::new(
567 base.child(WidgetKey::new("home")),
568 rect,
569 strings::VIEW_HOME,
570 ButtonVariant::Secondary,
571 ),
572 );
573 paints.extend(response.paint);
574 response.activated
575}
576
577fn draw_cell(
578 theme: &Theme,
579 place: &Placement,
580 active: bool,
581 paints: &mut Vec<WidgetPaint>,
582 labels: &mut Vec<(LayoutRect, StringKey, Color)>,
583) {
584 match &place.geom {
585 CellGeom::Face { quad, label_at } => {
586 let fill = face_fill(theme, place.cell, active);
587 paints.push(WidgetPaint::ConvexFill {
588 poly: quad.clone(),
589 fill,
590 border: Some(Border {
591 width: StrokeWidth::HAIRLINE,
592 color: theme.colors.neutral.step(Step12::TEXT_MUTED),
593 }),
594 });
595 let bounds = quad.bounds();
596 if bounds.size.width.value() >= FACE_LABEL_W_PX
597 && bounds.size.height.value() >= FACE_LABEL_H_PX
598 {
599 labels.push((
600 centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX),
601 place.cell.a11y_label(),
602 theme.colors.contrast_text(fill),
603 ));
604 }
605 }
606 CellGeom::Edge { a, b } if active => {
607 if let Some(path) = PolyPath::open(vec![*a, *b]) {
608 paints.push(WidgetPaint::Stroke {
609 path,
610 width: StrokeWidth::px(EDGE_HOVER_WIDTH_PX),
611 color: theme.colors.accent_solid(),
612 });
613 }
614 }
615 CellGeom::Corner { at } if active => {
616 if let Some(diamond) = corner_diamond(*at) {
617 paints.push(WidgetPaint::ConvexFill {
618 poly: diamond,
619 fill: theme.colors.accent_solid(),
620 border: None,
621 });
622 }
623 }
624 _ => {}
625 }
626}
627
628fn face_fill(theme: &Theme, cell: CubeCell, active: bool) -> Color {
629 let base = theme.colors.neutral.step(Step12::SUBTLE_BORDER).blend(
630 theme.colors.neutral.step(Step12::ELEMENT_BG),
631 face_shade(cell),
632 );
633 if active {
634 base.blend(theme.colors.accent_solid(), 0.35)
635 } else {
636 base
637 }
638}
639
640fn corner_diamond(at: LayoutPos) -> Option<ConvexPoly> {
641 let (x, y) = (at.x.value(), at.y.value());
642 let h = CORNER_HOVER_HALF_PX;
643 ConvexPoly::new(vec![
644 LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y - h)),
645 LayoutPos::new(LayoutPx::saturating(x + h), LayoutPx::saturating(y)),
646 LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y + h)),
647 LayoutPos::new(LayoutPx::saturating(x - h), LayoutPx::saturating(y)),
648 ])
649}
650
651fn run_cube_menu(
652 ctx: &mut FrameCtx<'_>,
653 menu_id: WidgetId,
654 anchor: LayoutRect,
655 view: &mut ViewUi,
656 normal_to_available: bool,
657 popover_paints: &mut Vec<WidgetPaint>,
658) -> Option<ViewCubeMenuAction> {
659 if let Some(cursor) = secondary_press_in(ctx, anchor) {
660 view.cube_menu_open = true;
661 view.cube_menu_anchor = cursor;
662 view.cube_menu = MenuState::default();
663 }
664 if !view.cube_menu_open {
665 return None;
666 }
667 let items = cube_menu_items(menu_id, normal_to_available);
668 let response = show_context_menu(
669 ctx,
670 ContextMenu::at_cursor(
671 menu_id,
672 view.cube_menu_anchor,
673 strings::VIEW_CUBE,
674 &items,
675 &mut view.cube_menu,
676 ),
677 );
678 popover_paints.extend(response.paint);
679 if response.close {
680 view.cube_menu_open = false;
681 }
682 response
683 .activated
684 .and_then(|id| menu_action_for(menu_id, id))
685}
686
687fn cube_menu_items(menu_id: WidgetId, normal_to_available: bool) -> Vec<MenuItem> {
688 vec![
689 MenuItem::Action {
690 id: menu_id.child(WidgetKey::new("home")),
691 label: strings::VIEW_CUBE_SET_HOME,
692 shortcut: None,
693 disabled: false,
694 },
695 MenuItem::Action {
696 id: menu_id.child(WidgetKey::new("fit")),
697 label: strings::VIEW_CUBE_FIT,
698 shortcut: None,
699 disabled: false,
700 },
701 MenuItem::Action {
702 id: menu_id.child(WidgetKey::new("normal_to")),
703 label: strings::VIEW_CUBE_NORMAL_TO,
704 shortcut: None,
705 disabled: !normal_to_available,
706 },
707 ]
708}
709
710fn menu_action_for(menu_id: WidgetId, activated: WidgetId) -> Option<ViewCubeMenuAction> {
711 [
712 ("home", ViewCubeMenuAction::SetAsHome),
713 ("fit", ViewCubeMenuAction::FitToWindow),
714 ("normal_to", ViewCubeMenuAction::ViewNormalTo),
715 ]
716 .into_iter()
717 .find_map(|(key, action)| (menu_id.child(WidgetKey::new(key)) == activated).then_some(action))
718}
719
720fn secondary_press_in(ctx: &FrameCtx<'_>, rect: LayoutRect) -> Option<LayoutPos> {
721 let pressed = ctx.input.buttons_pressed.contains(PointerButton::Secondary);
722 let cursor = ctx.input.pointer.map(|p| p.position)?;
723 (pressed && rect.contains(cursor)).then_some(cursor)
724}
725
726const SELECTOR_VIEWS: [StandardView; 8] = [
727 StandardView::Front,
728 StandardView::Back,
729 StandardView::Left,
730 StandardView::Right,
731 StandardView::Top,
732 StandardView::Bottom,
733 StandardView::Isometric,
734 StandardView::NormalTo,
735];
736
737#[must_use]
738pub fn render_view_selector(
739 ctx: &mut FrameCtx<'_>,
740 viewport: LayoutRect,
741 menu_id: WidgetId,
742 view: &mut ViewUi,
743 normal_to_available: bool,
744 popover_paints: &mut Vec<WidgetPaint>,
745) -> Option<StandardView> {
746 if !view.selector_open {
747 return None;
748 }
749 let items: Vec<MenuItem> = SELECTOR_VIEWS
750 .into_iter()
751 .map(|v| MenuItem::Action {
752 id: selector_item_id(menu_id, v),
753 label: standard_view_label(v),
754 shortcut: None,
755 disabled: matches!(v, StandardView::NormalTo) && !normal_to_available,
756 })
757 .collect();
758 let origin = selector_origin(viewport);
759 let response = show_menu(
760 ctx,
761 Menu::new(
762 menu_id,
763 origin,
764 strings::VIEW_SELECTOR,
765 &items,
766 &mut view.selector_menu,
767 ),
768 );
769 popover_paints.extend(response.paint);
770 if response.close {
771 view.selector_open = false;
772 }
773 response.activated.and_then(|id| {
774 SELECTOR_VIEWS
775 .into_iter()
776 .find(|v| selector_item_id(menu_id, *v) == id)
777 })
778}
779
780fn selector_item_id(menu_id: WidgetId, view: StandardView) -> WidgetId {
781 menu_id.child_named(WidgetKey::new("view"), view.label())
782}
783
784fn selector_origin(viewport: LayoutRect) -> LayoutPos {
785 LayoutPos::new(
786 LayoutPx::new(viewport.min_x().value() + CUBE_INSET_PX),
787 LayoutPx::new(viewport.min_y().value() + CUBE_INSET_PX),
788 )
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use std::fs;
795 use std::path::PathBuf;
796 use std::sync::Arc;
797
798 use bone_types::{Point3, Projection};
799 use bone_ui::a11y::AccessTreeBuilder;
800 use bone_ui::focus::FocusManager;
801 use bone_ui::hit_test::{HitFrame, HitState};
802 use bone_ui::hotkey::HotkeyTable;
803 use bone_ui::input::InputSnapshot;
804 use bone_ui::raster::{CanvasPx, CanvasSize, decode_png, encode_png, rasterize};
805 use bone_ui::strings::Locale;
806 use bone_ui::theme::Theme;
807 use uom::si::f64::Length;
808 use uom::si::length::millimeter;
809
810 const CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(256), CanvasPx::new(256));
811 const CHANNEL_TOLERANCE: u8 = 2;
812
813 fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 {
814 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else {
815 unreachable!("half height is positive");
816 };
817 let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else {
818 unreachable!("camera is non-degenerate");
819 };
820 camera
821 }
822
823 fn front_camera() -> Camera3 {
824 ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis())
825 }
826
827 fn isometric_camera() -> Camera3 {
828 ortho_camera(Point3::from_mm(10.0, 10.0, 10.0), UnitVec3::z_axis())
829 }
830
831 fn midpoint_camera() -> Camera3 {
832 let top = ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis());
833 let tween = CameraTween::eased(
834 front_camera(),
835 top,
836 core::time::Duration::from_millis(200),
837 bone_types::CubicEasing::STANDARD,
838 );
839 let Ok(camera) = tween.sample(core::time::Duration::from_millis(100)) else {
840 unreachable!("a front-to-top tween samples at its midpoint");
841 };
842 camera
843 }
844
845 fn visible_faces(camera: Camera3) -> usize {
846 let Some(basis) = view_basis(camera) else {
847 unreachable!("a non-degenerate camera has a basis");
848 };
849 CubeCell::all()
850 .into_iter()
851 .filter(|cell| cell.kind() == CellKind::Face && cell_visible(*cell, basis.forward))
852 .count()
853 }
854
855 #[test]
856 fn cube_has_twenty_six_cells() {
857 assert_eq!(CubeCell::all().len(), 26);
858 }
859
860 #[test]
861 fn cells_partition_into_faces_edges_and_corners() {
862 let counts = CubeCell::all()
863 .into_iter()
864 .fold([0_usize; 3], |mut acc, cell| {
865 let slot = match cell.kind() {
866 CellKind::Face => 0,
867 CellKind::Edge => 1,
868 CellKind::Corner => 2,
869 };
870 acc[slot] += 1;
871 acc
872 });
873 assert_eq!(counts, [6, 12, 8]);
874 }
875
876 #[test]
877 fn cell_indices_are_distinct() {
878 let mut indices: Vec<u64> = CubeCell::all().into_iter().map(CubeCell::index).collect();
879 indices.sort_unstable();
880 indices.dedup();
881 assert_eq!(
882 indices.len(),
883 26,
884 "every cell maps to a distinct child index"
885 );
886 }
887
888 #[test]
889 fn the_six_faces_map_to_standard_views() {
890 let faces: Vec<StandardView> = CubeCell::all()
891 .into_iter()
892 .filter_map(CubeCell::standard_view)
893 .collect();
894 assert_eq!(faces.len(), 6);
895 [
896 StandardView::Front,
897 StandardView::Back,
898 StandardView::Left,
899 StandardView::Right,
900 StandardView::Top,
901 StandardView::Bottom,
902 ]
903 .into_iter()
904 .for_each(|view| assert!(faces.contains(&view), "{view} must be a cube face"));
905 }
906
907 #[test]
908 fn edges_and_corners_pick_a_direction_not_a_standard_view() {
909 CubeCell::all()
910 .into_iter()
911 .filter(|cell| cell.kind() != CellKind::Face)
912 .for_each(|cell| {
913 assert_eq!(cell.standard_view(), None);
914 assert!(matches!(cell.pick(), ViewPick::Direction(_)));
915 });
916 }
917
918 #[test]
919 fn front_view_shows_the_front_face_and_culls_the_back() {
920 let Some(basis) = view_basis(front_camera()) else {
921 unreachable!("front camera has a basis");
922 };
923 let Some(front) = CubeCell::try_new(0, -1, 0) else {
924 unreachable!("front face is a valid cell");
925 };
926 let Some(back) = CubeCell::try_new(0, 1, 0) else {
927 unreachable!("back face is a valid cell");
928 };
929 assert!(
930 cell_visible(front, basis.forward),
931 "front face faces the camera"
932 );
933 assert!(!cell_visible(back, basis.forward), "back face is culled");
934 assert_eq!(
935 visible_faces(front_camera()),
936 1,
937 "a face-on view shows one face"
938 );
939 }
940
941 #[test]
942 fn isometric_view_shows_three_faces() {
943 assert_eq!(
944 visible_faces(isometric_camera()),
945 3,
946 "the isometric view shows exactly three faces",
947 );
948 }
949
950 fn render_cube(theme: &Theme, camera: Camera3) -> Vec<u8> {
951 let strings = crate::strings::make_strings(Locale::EnUs);
952 let viewport = LayoutRect::new(
953 LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)),
954 LayoutSize::new(LayoutPx::new(256.0), LayoutPx::new(256.0)),
955 );
956 let base = WidgetId::ROOT.child(WidgetKey::new("view_cube"));
957 let menu_id = WidgetId::ROOT.child(WidgetKey::new("view_cube.menu"));
958 let mut view = ViewUi {
959 home: Some(camera),
960 ..ViewUi::default()
961 };
962 let mut paints = Vec::new();
963 let mut popover = Vec::new();
964 {
965 let mut focus = FocusManager::new();
966 let table = HotkeyTable::new();
967 let mut hits = HitFrame::new();
968 let previous = HitState::new();
969 let mut input = InputSnapshot::idle(bone_ui::input::FrameInstant::ZERO);
970 let mut shaper = bone_text::Shaper::new();
971 let mut a11y = AccessTreeBuilder::new();
972 let mut ctx = FrameCtx::new(
973 Arc::new((*theme).clone()),
974 &mut input,
975 &mut focus,
976 &table,
977 &strings,
978 &mut hits,
979 &previous,
980 &mut a11y,
981 &mut shaper,
982 );
983 let _ = render_view_cube(
984 &mut ctx,
985 ViewCubeInputs {
986 viewport,
987 camera,
988 base,
989 menu_id,
990 view: &mut view,
991 normal_to_available: false,
992 confirm_visible: false,
993 },
994 &mut paints,
995 &mut popover,
996 );
997 }
998 rasterize(theme, &paints, CANVAS, &strings)
999 }
1000
1001 fn background(theme: &Theme) -> [u8; 4] {
1002 let probe = rasterize(
1003 theme,
1004 &[],
1005 CANVAS,
1006 &crate::strings::make_strings(Locale::EnUs),
1007 );
1008 [probe[0], probe[1], probe[2], probe[3]]
1009 }
1010
1011 fn painted_pixels(rgba: &[u8], background: [u8; 4]) -> usize {
1012 rgba.chunks_exact(4)
1013 .filter(|px| px[..3] != background[..3])
1014 .count()
1015 }
1016
1017 fn differ(a: &[u8], b: &[u8]) -> bool {
1018 a.chunks_exact(4).zip(b.chunks_exact(4)).any(|(x, y)| {
1019 x[..3]
1020 .iter()
1021 .zip(&y[..3])
1022 .any(|(p, q)| p.abs_diff(*q) > CHANNEL_TOLERANCE)
1023 })
1024 }
1025
1026 fn golden_path(name: &str) -> PathBuf {
1027 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1028 path.push("tests");
1029 path.push("goldens");
1030 path.push(format!("view_cube_{name}_256.png"));
1031 path
1032 }
1033
1034 fn bless_or_compare(name: &str, rgba: &[u8]) {
1035 let path = golden_path(name);
1036 if std::env::var_os("BONE_UPDATE_VIEW_CUBE_GOLDENS").is_some() {
1037 let Ok(png) = encode_png(rgba, CANVAS) else {
1038 panic!("encode_png({name}) failed");
1039 };
1040 if let Some(parent) = path.parent() {
1041 let Ok(()) = fs::create_dir_all(parent) else {
1042 panic!("create goldens dir failed");
1043 };
1044 }
1045 let Ok(()) = fs::write(&path, &png) else {
1046 panic!("write golden {name} failed");
1047 };
1048 return;
1049 }
1050 let Ok(bytes) = fs::read(&path) else {
1051 panic!(
1052 "golden {} missing; run with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 to bless",
1053 path.display(),
1054 );
1055 };
1056 let Ok(pinned) = decode_png(&bytes, CANVAS) else {
1057 panic!("decode golden {name} failed");
1058 };
1059 assert!(
1060 !differ(rgba, &pinned),
1061 "view cube {name} render drifted from its golden; \
1062 re-bless with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 after eyeballing",
1063 );
1064 }
1065
1066 #[test]
1067 fn view_cube_snapshot_reflects_orientation() {
1068 [("", Theme::light()), ("_dark", Theme::dark())]
1069 .into_iter()
1070 .for_each(|(suffix, theme)| {
1071 let bg = background(&theme);
1072 let front = render_cube(&theme, front_camera());
1073 let iso = render_cube(&theme, isometric_camera());
1074 let mid = render_cube(&theme, midpoint_camera());
1075 [("front", &front), ("iso", &iso), ("mid", &mid)]
1076 .into_iter()
1077 .for_each(|(name, rgba)| {
1078 assert!(
1079 painted_pixels(rgba, bg) > 0,
1080 "the {name}{suffix} cube must paint something",
1081 );
1082 });
1083 assert!(
1084 differ(&front, &iso),
1085 "front and isometric cubes must differ"
1086 );
1087 assert!(differ(&front, &mid), "front and midpoint cubes must differ");
1088 assert!(
1089 differ(&iso, &mid),
1090 "isometric and midpoint cubes must differ"
1091 );
1092 bless_or_compare(&format!("front{suffix}"), &front);
1093 bless_or_compare(&format!("iso{suffix}"), &iso);
1094 bless_or_compare(&format!("mid{suffix}"), &mid);
1095 });
1096 }
1097}