Another project
0

Configure Feed

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

at main 34 kB View raw
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}