Another project
0

Configure Feed

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

view cube: rendering, context menu, view selector, goldens

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

author
Lewis
date (Jun 12, 2026, 3:45 PM +0300) commit abfa35f4 parent 2657bb30 change-id rmwwyxsq
+652
+652
crates/bone-app/src/view_cube.rs
··· 438 438 }); 439 439 placements 440 440 } 441 + 442 + pub struct ViewCubeInputs<'a> { 443 + pub viewport: LayoutRect, 444 + pub camera: Camera3, 445 + pub base: WidgetId, 446 + pub menu_id: WidgetId, 447 + pub view: &'a mut ViewUi, 448 + pub normal_to_available: bool, 449 + pub confirm_visible: bool, 450 + } 451 + 452 + #[must_use] 453 + pub fn render_view_cube( 454 + ctx: &mut FrameCtx<'_>, 455 + inputs: ViewCubeInputs<'_>, 456 + paints: &mut Vec<WidgetPaint>, 457 + popover_paints: &mut Vec<WidgetPaint>, 458 + ) -> ViewCubeOutcome { 459 + let ViewCubeInputs { 460 + viewport, 461 + camera, 462 + base, 463 + menu_id, 464 + view, 465 + normal_to_available, 466 + confirm_visible, 467 + } = inputs; 468 + if view.cube_hidden { 469 + return ViewCubeOutcome::default(); 470 + } 471 + let top_offset = if confirm_visible { 472 + CONFIRM_RESERVE_PX 473 + } else { 474 + 0.0 475 + }; 476 + let Some((anchor, center, radius)) = cube_anchor(viewport, top_offset) else { 477 + return ViewCubeOutcome::default(); 478 + }; 479 + let Some(basis) = view_basis(camera) else { 480 + return ViewCubeOutcome::default(); 481 + }; 482 + ctx.a11y.push( 483 + base, 484 + anchor, 485 + AccessNode::new(Role::Group).with_label(strings::VIEW_CUBE), 486 + ); 487 + let placements = ordered_visible(basis, center, radius); 488 + let mut pick: Option<ViewPick> = None; 489 + let mut hovered: Option<(CubeCell, LayoutRect)> = None; 490 + let mut labels: Vec<(LayoutRect, StringKey, Color)> = Vec::new(); 491 + placements.iter().for_each(|place| { 492 + let id = base.child_indexed(WidgetKey::new("cell"), place.cell.index()); 493 + let interaction = ctx.interact( 494 + InteractDeclaration::new(id, place.pick_rect, Sense::INTERACTIVE) 495 + .a11y(AccessNode::new(Role::Button).with_label(place.cell.a11y_label())), 496 + ); 497 + let active = interaction.hover(); 498 + draw_cell(ctx.theme(), place, active, paints, &mut labels); 499 + if interaction.click() && pick.is_none() { 500 + pick = Some(place.cell.pick()); 501 + } 502 + if active && hovered.is_none() { 503 + hovered = Some((place.cell, place.pick_rect)); 504 + } 505 + }); 506 + let label_role = ctx.theme().typography.label; 507 + labels.into_iter().for_each(|(rect, key, color)| { 508 + paints.push(WidgetPaint::Label { 509 + rect, 510 + text: key.into(), 511 + color, 512 + role: label_role, 513 + }); 514 + }); 515 + if let Some((cell, rect)) = hovered { 516 + let id = base.child_indexed(WidgetKey::new("cell"), cell.index()); 517 + let tip = show_tooltip( 518 + ctx, 519 + Tooltip::new( 520 + id, 521 + rect, 522 + cell.a11y_label(), 523 + TooltipPlacement::Left, 524 + TOOLTIP_SIZE, 525 + ), 526 + &mut view.cube_tooltip, 527 + ); 528 + popover_paints.extend(tip); 529 + } 530 + if view.home.is_some() && draw_home_button(ctx, base, anchor, paints) && pick.is_none() { 531 + pick = Some(ViewPick::Home); 532 + } 533 + let menu = run_cube_menu( 534 + ctx, 535 + menu_id, 536 + anchor, 537 + view, 538 + normal_to_available, 539 + popover_paints, 540 + ); 541 + ViewCubeOutcome { pick, menu } 542 + } 543 + 544 + fn draw_home_button( 545 + ctx: &mut FrameCtx<'_>, 546 + base: WidgetId, 547 + anchor: LayoutRect, 548 + paints: &mut Vec<WidgetPaint>, 549 + ) -> bool { 550 + let rect = LayoutRect::new( 551 + LayoutPos::new( 552 + LayoutPx::new( 553 + anchor.min_x().value() + (anchor.size.width.value() - HOME_WIDTH_PX) / 2.0, 554 + ), 555 + LayoutPx::new(anchor.min_y().value() + anchor.size.height.value() + HOME_GAP_PX), 556 + ), 557 + LayoutSize::new(LayoutPx::new(HOME_WIDTH_PX), LayoutPx::new(HOME_HEIGHT_PX)), 558 + ); 559 + let response = show_button( 560 + ctx, 561 + Button::new( 562 + base.child(WidgetKey::new("home")), 563 + rect, 564 + strings::VIEW_HOME, 565 + ButtonVariant::Secondary, 566 + ), 567 + ); 568 + paints.extend(response.paint); 569 + response.activated 570 + } 571 + 572 + fn draw_cell( 573 + theme: &Theme, 574 + place: &Placement, 575 + active: bool, 576 + paints: &mut Vec<WidgetPaint>, 577 + labels: &mut Vec<(LayoutRect, StringKey, Color)>, 578 + ) { 579 + match &place.geom { 580 + CellGeom::Face { quad, label_at } => { 581 + let fill = face_fill(theme, place.cell, active); 582 + paints.push(WidgetPaint::ConvexFill { 583 + poly: quad.clone(), 584 + fill, 585 + border: Some(Border { 586 + width: StrokeWidth::HAIRLINE, 587 + color: theme.colors.neutral.step(Step12::TEXT_MUTED), 588 + }), 589 + }); 590 + let bounds = quad.bounds(); 591 + if bounds.size.width.value() >= FACE_LABEL_W_PX 592 + && bounds.size.height.value() >= FACE_LABEL_H_PX 593 + { 594 + labels.push(( 595 + centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX), 596 + place.cell.a11y_label(), 597 + theme.colors.contrast_text(fill), 598 + )); 599 + } 600 + } 601 + CellGeom::Edge { a, b } if active => { 602 + if let Some(path) = PolyPath::open(vec![*a, *b]) { 603 + paints.push(WidgetPaint::Stroke { 604 + path, 605 + width: StrokeWidth::px(EDGE_HOVER_WIDTH_PX), 606 + color: theme.colors.accent_solid(), 607 + }); 608 + } 609 + } 610 + CellGeom::Corner { at } if active => { 611 + if let Some(diamond) = corner_diamond(*at) { 612 + paints.push(WidgetPaint::ConvexFill { 613 + poly: diamond, 614 + fill: theme.colors.accent_solid(), 615 + border: None, 616 + }); 617 + } 618 + } 619 + _ => {} 620 + } 621 + } 622 + 623 + fn face_fill(theme: &Theme, cell: CubeCell, active: bool) -> Color { 624 + let base = theme.colors.neutral.step(Step12::SUBTLE_BORDER).blend( 625 + theme.colors.neutral.step(Step12::ELEMENT_BG), 626 + face_shade(cell), 627 + ); 628 + if active { 629 + base.blend(theme.colors.accent_solid(), 0.35) 630 + } else { 631 + base 632 + } 633 + } 634 + 635 + fn corner_diamond(at: LayoutPos) -> Option<ConvexPoly> { 636 + let (x, y) = (at.x.value(), at.y.value()); 637 + let h = CORNER_HOVER_HALF_PX; 638 + ConvexPoly::new(vec![ 639 + LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y - h)), 640 + LayoutPos::new(LayoutPx::saturating(x + h), LayoutPx::saturating(y)), 641 + LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y + h)), 642 + LayoutPos::new(LayoutPx::saturating(x - h), LayoutPx::saturating(y)), 643 + ]) 644 + } 645 + 646 + fn run_cube_menu( 647 + ctx: &mut FrameCtx<'_>, 648 + menu_id: WidgetId, 649 + anchor: LayoutRect, 650 + view: &mut ViewUi, 651 + normal_to_available: bool, 652 + popover_paints: &mut Vec<WidgetPaint>, 653 + ) -> Option<ViewCubeMenuAction> { 654 + if let Some(cursor) = secondary_press_in(ctx, anchor) { 655 + view.cube_menu_open = true; 656 + view.cube_menu_anchor = cursor; 657 + view.cube_menu = MenuState::default(); 658 + } 659 + if !view.cube_menu_open { 660 + return None; 661 + } 662 + let items = cube_menu_items(menu_id, normal_to_available); 663 + let response = show_context_menu( 664 + ctx, 665 + ContextMenu::at_cursor( 666 + menu_id, 667 + view.cube_menu_anchor, 668 + strings::VIEW_CUBE, 669 + &items, 670 + &mut view.cube_menu, 671 + ), 672 + ); 673 + popover_paints.extend(response.paint); 674 + if response.close { 675 + view.cube_menu_open = false; 676 + } 677 + response 678 + .activated 679 + .and_then(|id| menu_action_for(menu_id, id)) 680 + } 681 + 682 + fn cube_menu_items(menu_id: WidgetId, normal_to_available: bool) -> Vec<MenuItem> { 683 + vec![ 684 + MenuItem::Action { 685 + id: menu_id.child(WidgetKey::new("home")), 686 + label: strings::VIEW_CUBE_SET_HOME, 687 + shortcut: None, 688 + disabled: false, 689 + }, 690 + MenuItem::Action { 691 + id: menu_id.child(WidgetKey::new("fit")), 692 + label: strings::VIEW_CUBE_FIT, 693 + shortcut: None, 694 + disabled: false, 695 + }, 696 + MenuItem::Action { 697 + id: menu_id.child(WidgetKey::new("normal_to")), 698 + label: strings::VIEW_CUBE_NORMAL_TO, 699 + shortcut: None, 700 + disabled: !normal_to_available, 701 + }, 702 + ] 703 + } 704 + 705 + fn menu_action_for(menu_id: WidgetId, activated: WidgetId) -> Option<ViewCubeMenuAction> { 706 + [ 707 + ("home", ViewCubeMenuAction::SetAsHome), 708 + ("fit", ViewCubeMenuAction::FitToWindow), 709 + ("normal_to", ViewCubeMenuAction::ViewNormalTo), 710 + ] 711 + .into_iter() 712 + .find_map(|(key, action)| (menu_id.child(WidgetKey::new(key)) == activated).then_some(action)) 713 + } 714 + 715 + fn secondary_press_in(ctx: &FrameCtx<'_>, rect: LayoutRect) -> Option<LayoutPos> { 716 + let pressed = ctx.input.buttons_pressed.contains(PointerButton::Secondary); 717 + let cursor = ctx.input.pointer.map(|p| p.position)?; 718 + (pressed && rect.contains(cursor)).then_some(cursor) 719 + } 720 + 721 + const SELECTOR_VIEWS: [StandardView; 8] = [ 722 + StandardView::Front, 723 + StandardView::Back, 724 + StandardView::Left, 725 + StandardView::Right, 726 + StandardView::Top, 727 + StandardView::Bottom, 728 + StandardView::Isometric, 729 + StandardView::NormalTo, 730 + ]; 731 + 732 + #[must_use] 733 + pub fn render_view_selector( 734 + ctx: &mut FrameCtx<'_>, 735 + viewport: LayoutRect, 736 + menu_id: WidgetId, 737 + view: &mut ViewUi, 738 + normal_to_available: bool, 739 + popover_paints: &mut Vec<WidgetPaint>, 740 + ) -> Option<StandardView> { 741 + if !view.selector_open { 742 + return None; 743 + } 744 + let items: Vec<MenuItem> = SELECTOR_VIEWS 745 + .into_iter() 746 + .map(|v| MenuItem::Action { 747 + id: selector_item_id(menu_id, v), 748 + label: standard_view_label(v), 749 + shortcut: None, 750 + disabled: matches!(v, StandardView::NormalTo) && !normal_to_available, 751 + }) 752 + .collect(); 753 + let origin = selector_origin(viewport); 754 + let response = show_menu( 755 + ctx, 756 + Menu::new( 757 + menu_id, 758 + origin, 759 + strings::VIEW_SELECTOR, 760 + &items, 761 + &mut view.selector_menu, 762 + ), 763 + ); 764 + popover_paints.extend(response.paint); 765 + if response.close { 766 + view.selector_open = false; 767 + } 768 + response.activated.and_then(|id| { 769 + SELECTOR_VIEWS 770 + .into_iter() 771 + .find(|v| selector_item_id(menu_id, *v) == id) 772 + }) 773 + } 774 + 775 + fn selector_item_id(menu_id: WidgetId, view: StandardView) -> WidgetId { 776 + menu_id.child_named(WidgetKey::new("view"), view.label()) 777 + } 778 + 779 + fn selector_origin(viewport: LayoutRect) -> LayoutPos { 780 + LayoutPos::new( 781 + LayoutPx::new(viewport.min_x().value() + CUBE_INSET_PX), 782 + LayoutPx::new(viewport.min_y().value() + CUBE_INSET_PX), 783 + ) 784 + } 785 + 786 + #[cfg(test)] 787 + mod tests { 788 + use super::*; 789 + use std::fs; 790 + use std::path::PathBuf; 791 + use std::sync::Arc; 792 + 793 + use bone_types::{Point3, Projection}; 794 + use bone_ui::a11y::AccessTreeBuilder; 795 + use bone_ui::focus::FocusManager; 796 + use bone_ui::hit_test::{HitFrame, HitState}; 797 + use bone_ui::hotkey::HotkeyTable; 798 + use bone_ui::input::InputSnapshot; 799 + use bone_ui::raster::{CanvasPx, CanvasSize, decode_png, encode_png, rasterize}; 800 + use bone_ui::strings::Locale; 801 + use bone_ui::theme::Theme; 802 + use uom::si::f64::Length; 803 + use uom::si::length::millimeter; 804 + 805 + const CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(256), CanvasPx::new(256)); 806 + const CHANNEL_TOLERANCE: u8 = 2; 807 + 808 + fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 { 809 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 810 + unreachable!("half height is positive"); 811 + }; 812 + let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else { 813 + unreachable!("camera is non-degenerate"); 814 + }; 815 + camera 816 + } 817 + 818 + fn front_camera() -> Camera3 { 819 + ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis()) 820 + } 821 + 822 + fn isometric_camera() -> Camera3 { 823 + ortho_camera(Point3::from_mm(10.0, 10.0, 10.0), UnitVec3::z_axis()) 824 + } 825 + 826 + fn midpoint_camera() -> Camera3 { 827 + let top = ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis()); 828 + let tween = CameraTween::eased( 829 + front_camera(), 830 + top, 831 + core::time::Duration::from_millis(200), 832 + bone_types::CubicEasing::STANDARD, 833 + ); 834 + let Ok(camera) = tween.sample(core::time::Duration::from_millis(100)) else { 835 + unreachable!("a front-to-top tween samples at its midpoint"); 836 + }; 837 + camera 838 + } 839 + 840 + fn visible_faces(camera: Camera3) -> usize { 841 + let Some(basis) = view_basis(camera) else { 842 + unreachable!("a non-degenerate camera has a basis"); 843 + }; 844 + CubeCell::all() 845 + .into_iter() 846 + .filter(|cell| cell.kind() == CellKind::Face && cell_visible(*cell, basis.forward)) 847 + .count() 848 + } 849 + 850 + #[test] 851 + fn cube_has_twenty_six_cells() { 852 + assert_eq!(CubeCell::all().len(), 26); 853 + } 854 + 855 + #[test] 856 + fn cells_partition_into_faces_edges_and_corners() { 857 + let counts = CubeCell::all() 858 + .into_iter() 859 + .fold([0_usize; 3], |mut acc, cell| { 860 + let slot = match cell.kind() { 861 + CellKind::Face => 0, 862 + CellKind::Edge => 1, 863 + CellKind::Corner => 2, 864 + }; 865 + acc[slot] += 1; 866 + acc 867 + }); 868 + assert_eq!(counts, [6, 12, 8]); 869 + } 870 + 871 + #[test] 872 + fn cell_indices_are_distinct() { 873 + let mut indices: Vec<u64> = CubeCell::all().into_iter().map(CubeCell::index).collect(); 874 + indices.sort_unstable(); 875 + indices.dedup(); 876 + assert_eq!( 877 + indices.len(), 878 + 26, 879 + "every cell maps to a distinct child index" 880 + ); 881 + } 882 + 883 + #[test] 884 + fn the_six_faces_map_to_standard_views() { 885 + let faces: Vec<StandardView> = CubeCell::all() 886 + .into_iter() 887 + .filter_map(CubeCell::standard_view) 888 + .collect(); 889 + assert_eq!(faces.len(), 6); 890 + [ 891 + StandardView::Front, 892 + StandardView::Back, 893 + StandardView::Left, 894 + StandardView::Right, 895 + StandardView::Top, 896 + StandardView::Bottom, 897 + ] 898 + .into_iter() 899 + .for_each(|view| assert!(faces.contains(&view), "{view} must be a cube face")); 900 + } 901 + 902 + #[test] 903 + fn edges_and_corners_pick_a_direction_not_a_standard_view() { 904 + CubeCell::all() 905 + .into_iter() 906 + .filter(|cell| cell.kind() != CellKind::Face) 907 + .for_each(|cell| { 908 + assert_eq!(cell.standard_view(), None); 909 + assert!(matches!(cell.pick(), ViewPick::Direction(_))); 910 + }); 911 + } 912 + 913 + #[test] 914 + fn front_view_shows_the_front_face_and_culls_the_back() { 915 + let Some(basis) = view_basis(front_camera()) else { 916 + unreachable!("front camera has a basis"); 917 + }; 918 + let Some(front) = CubeCell::try_new(0, -1, 0) else { 919 + unreachable!("front face is a valid cell"); 920 + }; 921 + let Some(back) = CubeCell::try_new(0, 1, 0) else { 922 + unreachable!("back face is a valid cell"); 923 + }; 924 + assert!( 925 + cell_visible(front, basis.forward), 926 + "front face faces the camera" 927 + ); 928 + assert!(!cell_visible(back, basis.forward), "back face is culled"); 929 + assert_eq!( 930 + visible_faces(front_camera()), 931 + 1, 932 + "a face-on view shows one face" 933 + ); 934 + } 935 + 936 + #[test] 937 + fn isometric_view_shows_three_faces() { 938 + assert_eq!( 939 + visible_faces(isometric_camera()), 940 + 3, 941 + "the isometric view shows exactly three faces", 942 + ); 943 + } 944 + 945 + fn render_cube(theme: &Theme, camera: Camera3) -> Vec<u8> { 946 + let strings = crate::strings::make_strings(Locale::EnUs); 947 + let viewport = LayoutRect::new( 948 + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), 949 + LayoutSize::new(LayoutPx::new(256.0), LayoutPx::new(256.0)), 950 + ); 951 + let base = WidgetId::ROOT.child(WidgetKey::new("view_cube")); 952 + let menu_id = WidgetId::ROOT.child(WidgetKey::new("view_cube.menu")); 953 + let mut view = ViewUi { 954 + home: Some(camera), 955 + ..ViewUi::default() 956 + }; 957 + let mut paints = Vec::new(); 958 + let mut popover = Vec::new(); 959 + { 960 + let mut focus = FocusManager::new(); 961 + let table = HotkeyTable::new(); 962 + let mut hits = HitFrame::new(); 963 + let previous = HitState::new(); 964 + let mut input = InputSnapshot::idle(bone_ui::input::FrameInstant::ZERO); 965 + let mut shaper = bone_text::Shaper::new(); 966 + let mut a11y = AccessTreeBuilder::new(); 967 + let mut ctx = FrameCtx::new( 968 + Arc::new((*theme).clone()), 969 + &mut input, 970 + &mut focus, 971 + &table, 972 + &strings, 973 + &mut hits, 974 + &previous, 975 + &mut a11y, 976 + &mut shaper, 977 + ); 978 + let _ = render_view_cube( 979 + &mut ctx, 980 + ViewCubeInputs { 981 + viewport, 982 + camera, 983 + base, 984 + menu_id, 985 + view: &mut view, 986 + normal_to_available: false, 987 + confirm_visible: false, 988 + }, 989 + &mut paints, 990 + &mut popover, 991 + ); 992 + } 993 + rasterize(theme, &paints, CANVAS, &strings) 994 + } 995 + 996 + fn background(theme: &Theme) -> [u8; 4] { 997 + let probe = rasterize( 998 + theme, 999 + &[], 1000 + CANVAS, 1001 + &crate::strings::make_strings(Locale::EnUs), 1002 + ); 1003 + [probe[0], probe[1], probe[2], probe[3]] 1004 + } 1005 + 1006 + fn painted_pixels(rgba: &[u8], background: [u8; 4]) -> usize { 1007 + rgba.chunks_exact(4) 1008 + .filter(|px| px[..3] != background[..3]) 1009 + .count() 1010 + } 1011 + 1012 + fn differ(a: &[u8], b: &[u8]) -> bool { 1013 + a.chunks_exact(4).zip(b.chunks_exact(4)).any(|(x, y)| { 1014 + x[..3] 1015 + .iter() 1016 + .zip(&y[..3]) 1017 + .any(|(p, q)| p.abs_diff(*q) > CHANNEL_TOLERANCE) 1018 + }) 1019 + } 1020 + 1021 + fn golden_path(name: &str) -> PathBuf { 1022 + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 1023 + path.push("tests"); 1024 + path.push("goldens"); 1025 + path.push(format!("view_cube_{name}_256.png")); 1026 + path 1027 + } 1028 + 1029 + fn bless_or_compare(name: &str, rgba: &[u8]) { 1030 + let path = golden_path(name); 1031 + if std::env::var_os("BONE_UPDATE_VIEW_CUBE_GOLDENS").is_some() { 1032 + let Ok(png) = encode_png(rgba, CANVAS) else { 1033 + panic!("encode_png({name}) failed"); 1034 + }; 1035 + if let Some(parent) = path.parent() { 1036 + let Ok(()) = fs::create_dir_all(parent) else { 1037 + panic!("create goldens dir failed"); 1038 + }; 1039 + } 1040 + let Ok(()) = fs::write(&path, &png) else { 1041 + panic!("write golden {name} failed"); 1042 + }; 1043 + return; 1044 + } 1045 + let Ok(bytes) = fs::read(&path) else { 1046 + panic!( 1047 + "golden {} missing; run with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 to bless", 1048 + path.display(), 1049 + ); 1050 + }; 1051 + let Ok(pinned) = decode_png(&bytes, CANVAS) else { 1052 + panic!("decode golden {name} failed"); 1053 + }; 1054 + assert!( 1055 + !differ(rgba, &pinned), 1056 + "view cube {name} render drifted from its golden; \ 1057 + re-bless with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 after eyeballing", 1058 + ); 1059 + } 1060 + 1061 + #[test] 1062 + fn view_cube_snapshot_reflects_orientation() { 1063 + [("", Theme::light()), ("_dark", Theme::dark())] 1064 + .into_iter() 1065 + .for_each(|(suffix, theme)| { 1066 + let bg = background(&theme); 1067 + let front = render_cube(&theme, front_camera()); 1068 + let iso = render_cube(&theme, isometric_camera()); 1069 + let mid = render_cube(&theme, midpoint_camera()); 1070 + [("front", &front), ("iso", &iso), ("mid", &mid)] 1071 + .into_iter() 1072 + .for_each(|(name, rgba)| { 1073 + assert!( 1074 + painted_pixels(rgba, bg) > 0, 1075 + "the {name}{suffix} cube must paint something", 1076 + ); 1077 + }); 1078 + assert!( 1079 + differ(&front, &iso), 1080 + "front and isometric cubes must differ" 1081 + ); 1082 + assert!(differ(&front, &mid), "front and midpoint cubes must differ"); 1083 + assert!( 1084 + differ(&iso, &mid), 1085 + "isometric and midpoint cubes must differ" 1086 + ); 1087 + bless_or_compare(&format!("front{suffix}"), &front); 1088 + bless_or_compare(&format!("iso{suffix}"), &iso); 1089 + bless_or_compare(&format!("mid{suffix}"), &mid); 1090 + }); 1091 + } 1092 + }
crates/bone-app/tests/goldens/view_cube_front_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_front_dark_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_iso_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_iso_dark_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_mid_256.png

This is a binary file and will not be displayed.

crates/bone-app/tests/goldens/view_cube_mid_dark_256.png

This is a binary file and will not be displayed.