diff --git a/crates/bone-app/src/view_cube.rs b/crates/bone-app/src/view_cube.rs index 8da179a..9528d33 100644 --- a/crates/bone-app/src/view_cube.rs +++ b/crates/bone-app/src/view_cube.rs @@ -438,3 +438,655 @@ fn ordered_visible(basis: Basis, center: (f32, f32), radius: f32) -> Vec { + pub viewport: LayoutRect, + pub camera: Camera3, + pub base: WidgetId, + pub menu_id: WidgetId, + pub view: &'a mut ViewUi, + pub normal_to_available: bool, + pub confirm_visible: bool, +} + +#[must_use] +pub fn render_view_cube( + ctx: &mut FrameCtx<'_>, + inputs: ViewCubeInputs<'_>, + paints: &mut Vec, + popover_paints: &mut Vec, +) -> ViewCubeOutcome { + let ViewCubeInputs { + viewport, + camera, + base, + menu_id, + view, + normal_to_available, + confirm_visible, + } = inputs; + if view.cube_hidden { + return ViewCubeOutcome::default(); + } + let top_offset = if confirm_visible { + CONFIRM_RESERVE_PX + } else { + 0.0 + }; + let Some((anchor, center, radius)) = cube_anchor(viewport, top_offset) else { + return ViewCubeOutcome::default(); + }; + let Some(basis) = view_basis(camera) else { + return ViewCubeOutcome::default(); + }; + ctx.a11y.push( + base, + anchor, + AccessNode::new(Role::Group).with_label(strings::VIEW_CUBE), + ); + let placements = ordered_visible(basis, center, radius); + let mut pick: Option = None; + let mut hovered: Option<(CubeCell, LayoutRect)> = None; + let mut labels: Vec<(LayoutRect, StringKey, Color)> = Vec::new(); + placements.iter().for_each(|place| { + let id = base.child_indexed(WidgetKey::new("cell"), place.cell.index()); + let interaction = ctx.interact( + InteractDeclaration::new(id, place.pick_rect, Sense::INTERACTIVE) + .a11y(AccessNode::new(Role::Button).with_label(place.cell.a11y_label())), + ); + let active = interaction.hover(); + draw_cell(ctx.theme(), place, active, paints, &mut labels); + if interaction.click() && pick.is_none() { + pick = Some(place.cell.pick()); + } + if active && hovered.is_none() { + hovered = Some((place.cell, place.pick_rect)); + } + }); + let label_role = ctx.theme().typography.label; + labels.into_iter().for_each(|(rect, key, color)| { + paints.push(WidgetPaint::Label { + rect, + text: key.into(), + color, + role: label_role, + }); + }); + if let Some((cell, rect)) = hovered { + let id = base.child_indexed(WidgetKey::new("cell"), cell.index()); + let tip = show_tooltip( + ctx, + Tooltip::new( + id, + rect, + cell.a11y_label(), + TooltipPlacement::Left, + TOOLTIP_SIZE, + ), + &mut view.cube_tooltip, + ); + popover_paints.extend(tip); + } + if view.home.is_some() && draw_home_button(ctx, base, anchor, paints) && pick.is_none() { + pick = Some(ViewPick::Home); + } + let menu = run_cube_menu( + ctx, + menu_id, + anchor, + view, + normal_to_available, + popover_paints, + ); + ViewCubeOutcome { pick, menu } +} + +fn draw_home_button( + ctx: &mut FrameCtx<'_>, + base: WidgetId, + anchor: LayoutRect, + paints: &mut Vec, +) -> bool { + let rect = LayoutRect::new( + LayoutPos::new( + LayoutPx::new( + anchor.min_x().value() + (anchor.size.width.value() - HOME_WIDTH_PX) / 2.0, + ), + LayoutPx::new(anchor.min_y().value() + anchor.size.height.value() + HOME_GAP_PX), + ), + LayoutSize::new(LayoutPx::new(HOME_WIDTH_PX), LayoutPx::new(HOME_HEIGHT_PX)), + ); + let response = show_button( + ctx, + Button::new( + base.child(WidgetKey::new("home")), + rect, + strings::VIEW_HOME, + ButtonVariant::Secondary, + ), + ); + paints.extend(response.paint); + response.activated +} + +fn draw_cell( + theme: &Theme, + place: &Placement, + active: bool, + paints: &mut Vec, + labels: &mut Vec<(LayoutRect, StringKey, Color)>, +) { + match &place.geom { + CellGeom::Face { quad, label_at } => { + let fill = face_fill(theme, place.cell, active); + paints.push(WidgetPaint::ConvexFill { + poly: quad.clone(), + fill, + border: Some(Border { + width: StrokeWidth::HAIRLINE, + color: theme.colors.neutral.step(Step12::TEXT_MUTED), + }), + }); + let bounds = quad.bounds(); + if bounds.size.width.value() >= FACE_LABEL_W_PX + && bounds.size.height.value() >= FACE_LABEL_H_PX + { + labels.push(( + centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX), + place.cell.a11y_label(), + theme.colors.contrast_text(fill), + )); + } + } + CellGeom::Edge { a, b } if active => { + if let Some(path) = PolyPath::open(vec![*a, *b]) { + paints.push(WidgetPaint::Stroke { + path, + width: StrokeWidth::px(EDGE_HOVER_WIDTH_PX), + color: theme.colors.accent_solid(), + }); + } + } + CellGeom::Corner { at } if active => { + if let Some(diamond) = corner_diamond(*at) { + paints.push(WidgetPaint::ConvexFill { + poly: diamond, + fill: theme.colors.accent_solid(), + border: None, + }); + } + } + _ => {} + } +} + +fn face_fill(theme: &Theme, cell: CubeCell, active: bool) -> Color { + let base = theme.colors.neutral.step(Step12::SUBTLE_BORDER).blend( + theme.colors.neutral.step(Step12::ELEMENT_BG), + face_shade(cell), + ); + if active { + base.blend(theme.colors.accent_solid(), 0.35) + } else { + base + } +} + +fn corner_diamond(at: LayoutPos) -> Option { + let (x, y) = (at.x.value(), at.y.value()); + let h = CORNER_HOVER_HALF_PX; + ConvexPoly::new(vec![ + LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y - h)), + LayoutPos::new(LayoutPx::saturating(x + h), LayoutPx::saturating(y)), + LayoutPos::new(LayoutPx::saturating(x), LayoutPx::saturating(y + h)), + LayoutPos::new(LayoutPx::saturating(x - h), LayoutPx::saturating(y)), + ]) +} + +fn run_cube_menu( + ctx: &mut FrameCtx<'_>, + menu_id: WidgetId, + anchor: LayoutRect, + view: &mut ViewUi, + normal_to_available: bool, + popover_paints: &mut Vec, +) -> Option { + if let Some(cursor) = secondary_press_in(ctx, anchor) { + view.cube_menu_open = true; + view.cube_menu_anchor = cursor; + view.cube_menu = MenuState::default(); + } + if !view.cube_menu_open { + return None; + } + let items = cube_menu_items(menu_id, normal_to_available); + let response = show_context_menu( + ctx, + ContextMenu::at_cursor( + menu_id, + view.cube_menu_anchor, + strings::VIEW_CUBE, + &items, + &mut view.cube_menu, + ), + ); + popover_paints.extend(response.paint); + if response.close { + view.cube_menu_open = false; + } + response + .activated + .and_then(|id| menu_action_for(menu_id, id)) +} + +fn cube_menu_items(menu_id: WidgetId, normal_to_available: bool) -> Vec { + vec![ + MenuItem::Action { + id: menu_id.child(WidgetKey::new("home")), + label: strings::VIEW_CUBE_SET_HOME, + shortcut: None, + disabled: false, + }, + MenuItem::Action { + id: menu_id.child(WidgetKey::new("fit")), + label: strings::VIEW_CUBE_FIT, + shortcut: None, + disabled: false, + }, + MenuItem::Action { + id: menu_id.child(WidgetKey::new("normal_to")), + label: strings::VIEW_CUBE_NORMAL_TO, + shortcut: None, + disabled: !normal_to_available, + }, + ] +} + +fn menu_action_for(menu_id: WidgetId, activated: WidgetId) -> Option { + [ + ("home", ViewCubeMenuAction::SetAsHome), + ("fit", ViewCubeMenuAction::FitToWindow), + ("normal_to", ViewCubeMenuAction::ViewNormalTo), + ] + .into_iter() + .find_map(|(key, action)| (menu_id.child(WidgetKey::new(key)) == activated).then_some(action)) +} + +fn secondary_press_in(ctx: &FrameCtx<'_>, rect: LayoutRect) -> Option { + let pressed = ctx.input.buttons_pressed.contains(PointerButton::Secondary); + let cursor = ctx.input.pointer.map(|p| p.position)?; + (pressed && rect.contains(cursor)).then_some(cursor) +} + +const SELECTOR_VIEWS: [StandardView; 8] = [ + StandardView::Front, + StandardView::Back, + StandardView::Left, + StandardView::Right, + StandardView::Top, + StandardView::Bottom, + StandardView::Isometric, + StandardView::NormalTo, +]; + +#[must_use] +pub fn render_view_selector( + ctx: &mut FrameCtx<'_>, + viewport: LayoutRect, + menu_id: WidgetId, + view: &mut ViewUi, + normal_to_available: bool, + popover_paints: &mut Vec, +) -> Option { + if !view.selector_open { + return None; + } + let items: Vec = SELECTOR_VIEWS + .into_iter() + .map(|v| MenuItem::Action { + id: selector_item_id(menu_id, v), + label: standard_view_label(v), + shortcut: None, + disabled: matches!(v, StandardView::NormalTo) && !normal_to_available, + }) + .collect(); + let origin = selector_origin(viewport); + let response = show_menu( + ctx, + Menu::new( + menu_id, + origin, + strings::VIEW_SELECTOR, + &items, + &mut view.selector_menu, + ), + ); + popover_paints.extend(response.paint); + if response.close { + view.selector_open = false; + } + response.activated.and_then(|id| { + SELECTOR_VIEWS + .into_iter() + .find(|v| selector_item_id(menu_id, *v) == id) + }) +} + +fn selector_item_id(menu_id: WidgetId, view: StandardView) -> WidgetId { + menu_id.child_named(WidgetKey::new("view"), view.label()) +} + +fn selector_origin(viewport: LayoutRect) -> LayoutPos { + LayoutPos::new( + LayoutPx::new(viewport.min_x().value() + CUBE_INSET_PX), + LayoutPx::new(viewport.min_y().value() + CUBE_INSET_PX), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + use std::sync::Arc; + + use bone_types::{Point3, Projection}; + use bone_ui::a11y::AccessTreeBuilder; + use bone_ui::focus::FocusManager; + use bone_ui::hit_test::{HitFrame, HitState}; + use bone_ui::hotkey::HotkeyTable; + use bone_ui::input::InputSnapshot; + use bone_ui::raster::{CanvasPx, CanvasSize, decode_png, encode_png, rasterize}; + use bone_ui::strings::Locale; + use bone_ui::theme::Theme; + use uom::si::f64::Length; + use uom::si::length::millimeter; + + const CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(256), CanvasPx::new(256)); + const CHANNEL_TOLERANCE: u8 = 2; + + fn ortho_camera(eye: Point3, up: UnitVec3) -> Camera3 { + let Ok(projection) = Projection::orthographic(Length::new::(2.0)) else { + unreachable!("half height is positive"); + }; + let Ok(camera) = Camera3::new(eye, Point3::origin(), up, projection) else { + unreachable!("camera is non-degenerate"); + }; + camera + } + + fn front_camera() -> Camera3 { + ortho_camera(Point3::from_mm(0.0, -10.0, 0.0), UnitVec3::z_axis()) + } + + fn isometric_camera() -> Camera3 { + ortho_camera(Point3::from_mm(10.0, 10.0, 10.0), UnitVec3::z_axis()) + } + + fn midpoint_camera() -> Camera3 { + let top = ortho_camera(Point3::from_mm(0.0, 0.0, 10.0), UnitVec3::y_axis()); + let tween = CameraTween::eased( + front_camera(), + top, + core::time::Duration::from_millis(200), + bone_types::CubicEasing::STANDARD, + ); + let Ok(camera) = tween.sample(core::time::Duration::from_millis(100)) else { + unreachable!("a front-to-top tween samples at its midpoint"); + }; + camera + } + + fn visible_faces(camera: Camera3) -> usize { + let Some(basis) = view_basis(camera) else { + unreachable!("a non-degenerate camera has a basis"); + }; + CubeCell::all() + .into_iter() + .filter(|cell| cell.kind() == CellKind::Face && cell_visible(*cell, basis.forward)) + .count() + } + + #[test] + fn cube_has_twenty_six_cells() { + assert_eq!(CubeCell::all().len(), 26); + } + + #[test] + fn cells_partition_into_faces_edges_and_corners() { + let counts = CubeCell::all() + .into_iter() + .fold([0_usize; 3], |mut acc, cell| { + let slot = match cell.kind() { + CellKind::Face => 0, + CellKind::Edge => 1, + CellKind::Corner => 2, + }; + acc[slot] += 1; + acc + }); + assert_eq!(counts, [6, 12, 8]); + } + + #[test] + fn cell_indices_are_distinct() { + let mut indices: Vec = CubeCell::all().into_iter().map(CubeCell::index).collect(); + indices.sort_unstable(); + indices.dedup(); + assert_eq!( + indices.len(), + 26, + "every cell maps to a distinct child index" + ); + } + + #[test] + fn the_six_faces_map_to_standard_views() { + let faces: Vec = CubeCell::all() + .into_iter() + .filter_map(CubeCell::standard_view) + .collect(); + assert_eq!(faces.len(), 6); + [ + StandardView::Front, + StandardView::Back, + StandardView::Left, + StandardView::Right, + StandardView::Top, + StandardView::Bottom, + ] + .into_iter() + .for_each(|view| assert!(faces.contains(&view), "{view} must be a cube face")); + } + + #[test] + fn edges_and_corners_pick_a_direction_not_a_standard_view() { + CubeCell::all() + .into_iter() + .filter(|cell| cell.kind() != CellKind::Face) + .for_each(|cell| { + assert_eq!(cell.standard_view(), None); + assert!(matches!(cell.pick(), ViewPick::Direction(_))); + }); + } + + #[test] + fn front_view_shows_the_front_face_and_culls_the_back() { + let Some(basis) = view_basis(front_camera()) else { + unreachable!("front camera has a basis"); + }; + let Some(front) = CubeCell::try_new(0, -1, 0) else { + unreachable!("front face is a valid cell"); + }; + let Some(back) = CubeCell::try_new(0, 1, 0) else { + unreachable!("back face is a valid cell"); + }; + assert!( + cell_visible(front, basis.forward), + "front face faces the camera" + ); + assert!(!cell_visible(back, basis.forward), "back face is culled"); + assert_eq!( + visible_faces(front_camera()), + 1, + "a face-on view shows one face" + ); + } + + #[test] + fn isometric_view_shows_three_faces() { + assert_eq!( + visible_faces(isometric_camera()), + 3, + "the isometric view shows exactly three faces", + ); + } + + fn render_cube(theme: &Theme, camera: Camera3) -> Vec { + let strings = crate::strings::make_strings(Locale::EnUs); + let viewport = LayoutRect::new( + LayoutPos::new(LayoutPx::new(0.0), LayoutPx::new(0.0)), + LayoutSize::new(LayoutPx::new(256.0), LayoutPx::new(256.0)), + ); + let base = WidgetId::ROOT.child(WidgetKey::new("view_cube")); + let menu_id = WidgetId::ROOT.child(WidgetKey::new("view_cube.menu")); + let mut view = ViewUi { + home: Some(camera), + ..ViewUi::default() + }; + let mut paints = Vec::new(); + let mut popover = Vec::new(); + { + let mut focus = FocusManager::new(); + let table = HotkeyTable::new(); + let mut hits = HitFrame::new(); + let previous = HitState::new(); + let mut input = InputSnapshot::idle(bone_ui::input::FrameInstant::ZERO); + let mut shaper = bone_text::Shaper::new(); + let mut a11y = AccessTreeBuilder::new(); + let mut ctx = FrameCtx::new( + Arc::new((*theme).clone()), + &mut input, + &mut focus, + &table, + &strings, + &mut hits, + &previous, + &mut a11y, + &mut shaper, + ); + let _ = render_view_cube( + &mut ctx, + ViewCubeInputs { + viewport, + camera, + base, + menu_id, + view: &mut view, + normal_to_available: false, + confirm_visible: false, + }, + &mut paints, + &mut popover, + ); + } + rasterize(theme, &paints, CANVAS, &strings) + } + + fn background(theme: &Theme) -> [u8; 4] { + let probe = rasterize( + theme, + &[], + CANVAS, + &crate::strings::make_strings(Locale::EnUs), + ); + [probe[0], probe[1], probe[2], probe[3]] + } + + fn painted_pixels(rgba: &[u8], background: [u8; 4]) -> usize { + rgba.chunks_exact(4) + .filter(|px| px[..3] != background[..3]) + .count() + } + + fn differ(a: &[u8], b: &[u8]) -> bool { + a.chunks_exact(4).zip(b.chunks_exact(4)).any(|(x, y)| { + x[..3] + .iter() + .zip(&y[..3]) + .any(|(p, q)| p.abs_diff(*q) > CHANNEL_TOLERANCE) + }) + } + + fn golden_path(name: &str) -> PathBuf { + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests"); + path.push("goldens"); + path.push(format!("view_cube_{name}_256.png")); + path + } + + fn bless_or_compare(name: &str, rgba: &[u8]) { + let path = golden_path(name); + if std::env::var_os("BONE_UPDATE_VIEW_CUBE_GOLDENS").is_some() { + let Ok(png) = encode_png(rgba, CANVAS) else { + panic!("encode_png({name}) failed"); + }; + if let Some(parent) = path.parent() { + let Ok(()) = fs::create_dir_all(parent) else { + panic!("create goldens dir failed"); + }; + } + let Ok(()) = fs::write(&path, &png) else { + panic!("write golden {name} failed"); + }; + return; + } + let Ok(bytes) = fs::read(&path) else { + panic!( + "golden {} missing; run with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 to bless", + path.display(), + ); + }; + let Ok(pinned) = decode_png(&bytes, CANVAS) else { + panic!("decode golden {name} failed"); + }; + assert!( + !differ(rgba, &pinned), + "view cube {name} render drifted from its golden; \ + re-bless with BONE_UPDATE_VIEW_CUBE_GOLDENS=1 after eyeballing", + ); + } + + #[test] + fn view_cube_snapshot_reflects_orientation() { + [("", Theme::light()), ("_dark", Theme::dark())] + .into_iter() + .for_each(|(suffix, theme)| { + let bg = background(&theme); + let front = render_cube(&theme, front_camera()); + let iso = render_cube(&theme, isometric_camera()); + let mid = render_cube(&theme, midpoint_camera()); + [("front", &front), ("iso", &iso), ("mid", &mid)] + .into_iter() + .for_each(|(name, rgba)| { + assert!( + painted_pixels(rgba, bg) > 0, + "the {name}{suffix} cube must paint something", + ); + }); + assert!( + differ(&front, &iso), + "front and isometric cubes must differ" + ); + assert!(differ(&front, &mid), "front and midpoint cubes must differ"); + assert!( + differ(&iso, &mid), + "isometric and midpoint cubes must differ" + ); + bless_or_compare(&format!("front{suffix}"), &front); + bless_or_compare(&format!("iso{suffix}"), &iso); + bless_or_compare(&format!("mid{suffix}"), &mid); + }); + } +} diff --git a/crates/bone-app/tests/goldens/view_cube_front_256.png b/crates/bone-app/tests/goldens/view_cube_front_256.png new file mode 100644 index 0000000000000000000000000000000000000000..bd06d0f4fd3d2c9ff9caaa3d7659a34d39a61280 GIT binary patch literal 2655 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%ZpYMw5RAr-gY-p(!vc`AGS zV?2+6$^-_^#;Epe1+|A}%pvQv6jWoP1XvswyyaM^CCkHlD0IT2qKziH(-$pWD%#kv zRezPy+$8O|z z2d-VOTWu^q|KFia6Bo+|Hn;Jveq;GfTx?b7>yWFxtE1N*?`AllZLZ94db-{6ceYil zOJ|!+Ox4u%4cYDYR;#+Na&ub7t1mOXGA}KO>KA8h2+s>+*k_s3^6uP;ABR4@&Ho?z z^5x81yPw;Nii>C8=x8tbd#m*A&)<2nwnY)V(yA+?zss$fKS5yT{D~7KqyEq9>gu|( zFnRd{!^7|YX@8n`{lLNd0ZvKGTeI&w#_#-mF6*W^6T|vDYZ&g_|IWBIbGBGda&q$2 z8w(oK{7zpCf4|GSxt)hs@9ymF87p;Xo2{@Z$!dE(-=fZEw)x&QM^Aqe5tkP~lYeiI z>j$U)&d!UkrdG}}EY5q8|9DHq#mm3GEf$_SWy+c*Pg1U~j^7{ktrg^PZHDLP=Z7CY z|NiG})BKeelaI6Nzj*WBZsC?MUlM-)T=eoa{E0it+r;rzGqjvleNHwL14$!?FZXxV$HBV&he0=;L5u+HS>AJg+LM zchaP$k1vClZ+WGDdh3s$drr);|NrsqgW|@2k<9jWAuq%%e~x4&3Dk*d+D!(e=KMHd2;2!=f?k{4f6W&%h=odLsnn8 zl4Tpm!VtI8laa%^@=;4%#9pV~;Gi!TFDh4MT#@*?@BKghx&P-gTKss}UiEWn>8`4$ zUHgm`qb1+o3O~J1fT3ck+dmh>GJxSR|ug!TY#MqGjLvzExCVN`u#HVfy z0=7&H`7O*0bSNU|Gto1TkR#qw7w4RTPi(d;e zgq-TTEAL|ZUOc;gRo&msS9X=oKCx-BJAd}=_uF1=Jn4UJ$KO{Mw>tOUUiQj`AwcsX zL&=$(=!bh;yT!7eoM@jqbEafu*@FX&PxEiBa6NA$-LpP+|GK;H|M|A_%LWAnO{)6# zYH3T$krRKm-}}{VbbBlJ*~;+wb)Pz`zPyp#wJ2lhTx+h9FDC?-rl+hZp1!V&^DOWD zGyi`)sC|2Xe|+idYoXio?q1!Jd3nXRmw#*7IT-|ar!%Ca@`gm+x;rtIo&R5O^!*BD zp@y%o>})cb{?NPYu2iThvlwJ)t;aE@6S&u z!+<|@^^FCcuoX)dTtC`AGMT~TU={tp;zl|FNOw< zoh%DpTvjsm$`XkblT%kwVPVwJ(z>+7^X94Z_t#jJzG8W8o)fV?Y+?D2d*APg_Z(*Y z@o&q^tlQu2pWj~gwma+Enw@OCQZhZTmzSkXUp-8a;X7b_-fsDm zCr=Xgq@I4Jz!vK?{cmFR_jecf)waI6xOjO{QPGvH+0!qGy6!yXy!=}$xA>KP&g{N3 z3^qbd*J4ojpL=DKs&|w3Kk03`zxS?--@VMSxt-;)KDR?u)GVv=cS1df88vitLiX43 zXS}?m8nrPgb?LWG28O^@s~BQ*cdweG8LTw@$8;}!&GKVBPoF(omHNU=LFX1jkL2f$ zXXj?FEqr`zb@Fn*gS)a9PM9)fiiV*f%<@ms4UdoaYrl6teRWBuuq;=tutD9Q?N^rj z-#_sugIS>N;e6Zju`p?8vBHyuh*^+e(3=9mOu6{1-oD!M<)7=P5 literal 0 HcmV?d00001 diff --git a/crates/bone-app/tests/goldens/view_cube_front_dark_256.png b/crates/bone-app/tests/goldens/view_cube_front_dark_256.png new file mode 100644 index 0000000000000000000000000000000000000000..d54382cb8a03ca8bf330f82b62adae369d524dcc GIT binary patch literal 2657 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Zp8lEnWAr-gY-mcCGE0;a~ z(Vx4SBPcIjhiyzg?`mX@60&<$>%_*yKHPj$i;Ws@xywey;7CT6_({5ncw|NGxx@4ufDKmYlh$@9J! zpDVsKL+z~G-{lN5M4TBIc5^T!NH8++C^Ix9vM?C*FfgleJHYHl-UMrQp#`|Z`Wt*hPoWtT4s-p~JR`m||U z>gM6||35C~mADX}UAH&v`uh0m(wCRBt}p(1DfREi!iYWi z(cAM_A7@`*r@8R{fd?)3c4kk1roNoz^yBJ(4xXQ%z1sSZx%AzkqX{?rdUSWy{M@AG zGf(V{|M?RaCS4Idxa~c64;OPbC^I!2Xy!X` zr}Fbw_3C(oy4nv8y^_XJ_ut;JHA=txYHHV7;nj)C$Nydbqrl)hTdVMf2GfHM28Dpx zOdo!PM`cI5&DypsT_g1-SMBn{+qYyLEy}&ILGef3ga51w`sMS>-k%J4^(oY}Y7Ym) zo}k%G0%l1s1bQXxe9ZIjg~Y_nxo2PK^jOX3-~7-2jTtU4_X)bcPhZn)Ti>S5n@!a- z6Cr_?^*EmC+R9+}x$|qI($CGie*a(H)@wQ85{wPuKY|kmQTnHHmOOQrL7|F;;T{J& z!#fU!hIX;RSF}0w-rA?_{a#c|Bek$7<>rz3{-NLH?uQ@h{&6vX^h;mg^1YnCC-U_-?e`98Yi}=`<-!o4`jA0ne|*HtJ%4vr zTGeQTpX8pTbb8sJq+NA4R&TGe`@T2f&YsH82{#|--deR~ONeCV?5XqTi_e_DDQ72F z|C850<347|h>P!9WU*@PLd*O;mceVIw_p4D`MF8@H=VotYNJbEU*m0ks%!i4N$@HS z{qS>*e@}V%cW2Gt_t!_9iNWQtD#P+F|5XYlO!u#;HnAbm4+M4lkh8vIf znv!Tv27#rocCkvB*EPj))Ovld+UK6~_tBJ)trcsQ3bFsSkZ5P#P;!y!?Y+I)68U!~ zt?So|yRaty-cP>7Yi}&8j7m<*?=zK{V-cdNs#;#RGkJ--+)D8yi|RGFrYbTxOj)JP z;4Bn>{lde=R~APvKk+Mc^>r;Xv#{iUe>vs(4orIT^VFTq))MW^1s@Mp?zR2AHbI8v z!OF5*v$wh{yY=pUzBNXGLBZ>)XoFeqt&G3VzMN34JKiU|wc=$_?%hqEtLwKK&5+M! zSmyIad0HMfWY-v*+;CJZ(mXMIm$j8QXZ}S2`}#ZrrrV z$n;l{vZ(mnD@!;ZD}7$fd%&M<&$5=5aFe7n{-N9bVy8~OAJp8&J9TsYd@+@?YawBj z!7Q-$!{gn4b9dkQnfCwGRQ*3yvQ{M=^Vn(%R#{YRk>F)wa9O#FIY1iZVwMB^EDWJe z41XLL0#vbZgx>CFw$I^UU@#s;t&o9f7Jbvt_B$SJpt$u70|Nttr>mdKI;Vst0DD*b A8UO$Q literal 0 HcmV?d00001 diff --git a/crates/bone-app/tests/goldens/view_cube_iso_256.png b/crates/bone-app/tests/goldens/view_cube_iso_256.png new file mode 100644 index 0000000000000000000000000000000000000000..e145a31840da03b25d9f0d369235cc1ba0aef034 GIT binary patch literal 7507 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 U^Wkq97#J8lUHx3vIVCg!0Nm{Im;e9( literal 0 HcmV?d00001 diff --git a/crates/bone-app/tests/goldens/view_cube_iso_dark_256.png b/crates/bone-app/tests/goldens/view_cube_iso_dark_256.png new file mode 100644 index 0000000000000000000000000000000000000000..df7659222aa13f12d2215089a0df20ce297c09c5 GIT binary patch literal 7537 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 S+t0wjz~JfX=d#Wzp$Pysf$)X^ literal 0 HcmV?d00001 diff --git a/crates/bone-app/tests/goldens/view_cube_mid_256.png b/crates/bone-app/tests/goldens/view_cube_mid_256.png new file mode 100644 index 0000000000000000000000000000000000000000..4e711a30b55de665db7ddd56452dcbb984161432 GIT binary patch literal 2677 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%ZpHl8kyAr-gY-l@)+`c(G7 z$9Ub$D(lz`?x{Kf0-DpA^bUx0E>Nu-^g&_IqQRV`wFk1 zpbiI>tgKZ}x7r?MVXzWlFlS*{HJHgQr3|yAq*Kc;p8LPM{!Yt#uj_d?H&$+HVQ`qa zjnAR@#ireHadL9@;XAzM&9nC3Rq}I}y`R7T^d(Q8%&?7TVOVwNWpu;OV(yfmr%V^l z|8@7z)q~aP=RG`6roNQmI(Q+mQix+oYrpNk?a7yR`^#HaKihI8B>c2F8}GDv-@j#D z{wc(;Ldcu(M))4}ElwGeCN7qnn4P+G)Az68sh7K>4{0|Y<$mUQ?QZ$$Co6vniHoc6 zF3Xu7x%s)*`Z@P<&ElCDLX%V(8XX+O{#Nvf?2XdlTCYpasM>key{3h@vV2wl^GkfHVQSgwN?Fm+gQ8l(el=Zwd(in^!~3} zF~M{4@*m0i4BFx1Udzu#{(OEA##yGn=gsu}?2{*NIQ6-6{8) zT>mxi-E}J}a&Oj7oroS zIK1xuo(J zD*s={#30MjAkD-ON(s4#fhFi?JcC|dXt*%L3jrQBOU}R(XU^nY|Ex~;QVywPeV|?X z;(~P1fAja#-@Lnf__mj~_tPhx!k;SY_I=!3{9KU1<>`}|{K{5&P*bu*7tUr2t-ddk|Hle@1z81e% z_VJPH&Hef7*HqW-wa?4Rnq<8E{?tjQr>ol9#(rw!pYEWbTk~gQa{bi0ojVsM`<-4s zzeefsr%z7WU2>n!ofIzmdP?-=3V;I@7WZo$TZki$4!0~z#>AD z-IO~!KK}c0q;vbyO`C$;`eal$r=9&`AKuO<`=@rf-(F?&ygL&*h2?V#*Re2k1Z3Z3 znldG3+TQB!r5g%zEB=3cTtB_5i)*G)>MBsd^yk-nZ#TC#d%KDc4f4$g7pCri{`dQT z#pmbePfk4iDdpjzR-=>?4=!{qik#&<{oC2u=1c!KFd8KvJ0teoi=lyIYZ$|+_0v!5 zo8^3%{wLm|>P!EnD>pPAAM^ik{J%DXrKP1&#)X9XH>?lt>?}6Qx>BC<_!#e|)YI2i z#0fJpC|!wST#>(Gdh6%s?bCm3E#0~)=Vegjww#$I{CC+Zlz1KL{t0i&ddgM#>EuhJ z5Bq-qsqI}j;mVaO9#K(I-NmX54lPliV;eSYGP?Qa5%(p_)th7Z_6i%+{tEtd!CCq6 zad#<(_tN`~A~aohmz{dM>-MB?TTOPx)y1ZXig@JZ{fBtKRjR?GQathN+UQAh=IDI< z_%Z(1=c{+uO>XardKlT`!V37z7qx<8#naU~p*RXy9XE2vh{s<1VcXFB}*I zM41*aGdhGoBs#yGZ@ewd#BhLTpc^Sj*>_-CO@F1emqd1?KWCc5z`(%Z>FVdQ&MBb@ E0J`S|?*IS* literal 0 HcmV?d00001 diff --git a/crates/bone-app/tests/goldens/view_cube_mid_dark_256.png b/crates/bone-app/tests/goldens/view_cube_mid_dark_256.png new file mode 100644 index 0000000000000000000000000000000000000000..e19f28fde04020c44b373be03baf0aab59e769bd GIT binary patch literal 2689 zcmeAS@N?(olHy`uVBq!ia0y~yU}OMc4mJh`hM1xiX$%Zp9-c0aAr-gY-mdnU`ds$F zM|qKzTZOFyvy+VGM&Gi!E7rUEV~|XCOyKovj$0$%b1c!g78Yx@c6)&SiVmx_)3ba+ zS(Yk^2Y$Siv^H0DamF^og~8U&Ilh0A50?rZJTmw5u6axAb>_53+5D-yf4=_w?>!Tq z+cdtfJYRi|vwBJKbZe#qOAMG8Zn!cq9Oht1kYHrsQD$gJWMMGqVPI$zWH?~J#312D zf64P5J1*upanI4PzO(GQKf{BYTWb4FOwBe4^4)TE^Qt*kH6) zw&B}b`6)|Q{{Q`a=G1w5>i+YlaGRRm`*COI=TDxlsVClvGd5^_|E=oqb^fZ9+uOE3 z<4eSc=TCKo zMy}lz$HWloq{)!l-mR*ma&9-g1K^{WuW3L!1VjQjics;H=(SfJ?7`fOY7J&n7&?X}Jy z@Be)4AH$lHQQEtUKkfP0@-aE};GdtDzNa(8EzC2$H%T%x^V4TWf3{~uW$&l3A3B`+ z=g7)?H{=`M-Y&1u7ypyCZ0U(FOH?F3bRS&14eUi#hNbR(UWJK`PoBR0ocD%T&Lm)4 z?p>w#Z*HmHd~II&{1l zCr@5_u=}F8sHm!_sOY3Alf*2p%Qw7wmG$!O?(HqV*YQY6Nj=$W`FY8vS2e5GPG@kK z8t_{s!NsNJ2%o%K{oVlO@clc!^;*_57~P9eGd10MX{q;?DfI=*u3mR%Sl|`?OXI_J z#)|C=UhG@j$lx$_n@B^i^ffO(zjYyhK3@L4jg5&R^oP*|X(onHj)tL4{$c#_qQp}A zVM2zF4r2pfws~&4a8yEGmDlVzd&Y+2^{fH^&)07dE&z-xUUOIaI zMvuau7svZg?wGD7%<#g2;f?^q3MUH4NBj=K$;#3X7sRHhFgEZhKXTj5savqi^xi4a(@!V& z+fSQx>2PE6V)vt-XMzns{rbbI_K7^05IH>~P1K07bGY*Ea;`u8ns z&cz5?n0+r_e|r66w^PRc|1U|qySrB(ue-2$`kftx&YwPiJGCyQi zEL^-KIAqHEu1C%{pIblw&MoHIFK7GY(b4^$$1WW$`u#1pv!mlf>r>v!Hy_oPtlSvb zC$ByE&)-T%+hvT+ZGsF65_*gl;h~z#YyTgJmws0LP5#G|1r7|KKJj}+M9i2Xt2@=M z_SdNgiq5I$-pmY&jC^@^zEyG9ztY8VescC|{G~4x&i8gs-(O!BX}5aq#O(MwP5mD4 zFMr=8o{XBSXnVW9+Cg2$IS- zW$XKS+}GDPj=`-@O1FDj%84x}^*wWQ*P74Y{lB56@aw8GvyE>{|7H%TP>45MbN<^g zb}sKHzrMbnG|lR+^!jtBrk%SOoAUl$c}nCnDbJe3o-0?cRu*fAubsFqcDHBIr73CG zy%-u4?yjEBV0<}#VtVcQONGiw%%3A~zFwNW-97cxk&mD5d&Iq~D*st3N+} zpGvp*y}j)lDl*$_-tB(!eZR>Jlhj#Pu3x|Z_c5Pv=5@VG-AB9kX_&M#FgWfCXPjY^ zy2@wvJgtp}&P<UTRD3Bo~Lu=a~PiO zv+^$bbZ=_#)AuJg^(?&?^Zni3+V7%uN>87@H{TrwHew(51QT0VwO{L4q@<*lJpOLw zb#?jFPp`C%nV+*uH*j_)tJ>K)PKuwWD9G>Pk^ETq<%bW4i}t)(k+Lr0+M6g&27x6B z%mO<9ZrfzQ2FJSGwR|%+&AKk_zopr04R9}f&c&j literal 0 HcmV?d00001