From 2657bb302ba7f2da335a717721138ea55b2afb01 Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 12 Jun 2026 11:56:03 +0300 Subject: [PATCH] view cube: cell model, projection, placement Lewis: May this revision serve well! --- crates/bone-app/src/view_cube.rs | 440 +++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 crates/bone-app/src/view_cube.rs diff --git a/crates/bone-app/src/view_cube.rs b/crates/bone-app/src/view_cube.rs new file mode 100644 index 0000000..8da179a --- /dev/null +++ b/crates/bone-app/src/view_cube.rs @@ -0,0 +1,440 @@ +use bone_render::{CameraTween, lower_f32}; +use bone_types::{Camera3, StandardView, Tolerance, UnitVec3}; +use bone_ui::a11y::{AccessNode, Role}; +use bone_ui::frame::{FrameCtx, InteractDeclaration}; +use bone_ui::hit_test::Sense; +use bone_ui::input::{FrameInstant, PointerButton}; +use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; +use bone_ui::strings::StringKey; +use bone_ui::theme::{Border, Color, Step12, StrokeWidth, Theme}; +use bone_ui::widgets::{ + Button, ButtonVariant, ContextMenu, ConvexPoly, Menu, MenuItem, MenuState, PolyPath, Tooltip, + TooltipPlacement, TooltipState, WidgetPaint, show_button, show_context_menu, show_menu, + show_tooltip, +}; +use bone_ui::{WidgetId, WidgetKey}; + +use crate::strings; + +const CUBE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); +const CUBE_SIDE_PX: f32 = 150.0; +const CUBE_INSET_PX: f32 = 12.0; +const CUBE_MARGIN_PX: f32 = 8.0; +const FACE_LABEL_W_PX: f32 = 46.0; +const FACE_LABEL_H_PX: f32 = 22.0; +const EDGE_HIT_PX: f32 = 18.0; +const CORNER_HIT_PX: f32 = 16.0; +const EDGE_HOVER_WIDTH_PX: f32 = 3.0; +const CORNER_HOVER_HALF_PX: f32 = 7.0; +const LIGHT_DIR: (f64, f64, f64) = (0.35, -0.45, 1.0); +const HOME_WIDTH_PX: f32 = 54.0; +const HOME_HEIGHT_PX: f32 = 16.0; +const HOME_GAP_PX: f32 = 6.0; +const CONFIRM_RESERVE_PX: f32 = 56.0; +const TOOLTIP_SIZE: LayoutSize = LayoutSize::new(LayoutPx::new(72.0), LayoutPx::new(18.0)); +const VISIBILITY_EPSILON: f64 = 1.0e-6; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CellKind { + Face, + Edge, + Corner, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct CubeCell { + x: i8, + y: i8, + z: i8, +} + +impl CubeCell { + fn try_new(x: i8, y: i8, z: i8) -> Option { + let in_range = [x, y, z].into_iter().all(|c| (-1..=1).contains(&c)); + ((x, y, z) != (0, 0, 0) && in_range).then_some(Self { x, y, z }) + } + + #[must_use] + pub fn all() -> Vec { + (-1..=1) + .flat_map(|x| (-1..=1).flat_map(move |y| (-1..=1).map(move |z| (x, y, z)))) + .filter_map(|(x, y, z)| Self::try_new(x, y, z)) + .collect() + } + + fn index(self) -> u64 { + let code = |component: i8| match component { + -1 => 0, + 0 => 1, + _ => 2, + }; + code(self.x) * 9 + code(self.y) * 3 + code(self.z) + } + + #[must_use] + pub fn kind(self) -> CellKind { + match [self.x, self.y, self.z] + .into_iter() + .filter(|c| *c != 0) + .count() + { + 1 => CellKind::Face, + 2 => CellKind::Edge, + _ => CellKind::Corner, + } + } + + #[must_use] + pub fn direction(self) -> UnitVec3 { + let Ok(dir) = UnitVec3::try_from_components( + f64::from(self.x), + f64::from(self.y), + f64::from(self.z), + CUBE_TOLERANCE, + ) else { + unreachable!("a non-zero cell of small integers normalizes"); + }; + dir + } + + #[must_use] + pub fn standard_view(self) -> Option { + match (self.x, self.y, self.z) { + (0, -1, 0) => Some(StandardView::Front), + (0, 1, 0) => Some(StandardView::Back), + (-1, 0, 0) => Some(StandardView::Left), + (1, 0, 0) => Some(StandardView::Right), + (0, 0, 1) => Some(StandardView::Top), + (0, 0, -1) => Some(StandardView::Bottom), + _ => None, + } + } + + fn pick(self) -> ViewPick { + self.standard_view() + .map_or_else(|| ViewPick::Direction(self.direction()), ViewPick::Standard) + } + + fn a11y_label(self) -> StringKey { + match self.kind() { + CellKind::Face => { + standard_view_label(self.standard_view().unwrap_or(StandardView::Isometric)) + } + CellKind::Edge => strings::VIEW_CUBE_EDGE, + CellKind::Corner => strings::VIEW_CUBE_CORNER, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum ViewPick { + Standard(StandardView), + Direction(UnitVec3), + Home, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ViewCubeMenuAction { + SetAsHome, + FitToWindow, + ViewNormalTo, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct ViewCubeOutcome { + pub pick: Option, + pub menu: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ActiveTween { + pub tween: CameraTween, + pub started: FrameInstant, +} + +#[derive(Clone, Debug)] +pub struct ViewUi { + pub cube_hidden: bool, + pub selector_open: bool, + pub selector_menu: MenuState, + pub cube_menu_open: bool, + pub cube_menu_anchor: LayoutPos, + pub cube_menu: MenuState, + pub cube_tooltip: TooltipState, + pub home: Option, + pub tween: Option, +} + +impl Default for ViewUi { + fn default() -> Self { + Self { + cube_hidden: false, + selector_open: false, + selector_menu: MenuState::default(), + cube_menu_open: false, + cube_menu_anchor: LayoutPos::ORIGIN, + cube_menu: MenuState::default(), + cube_tooltip: TooltipState::default(), + home: None, + tween: None, + } + } +} + +impl ViewUi { + pub fn toggle_cube(&mut self) { + self.cube_hidden = !self.cube_hidden; + } + + pub fn toggle_selector(&mut self) { + self.selector_open = !self.selector_open; + if self.selector_open { + self.selector_menu = MenuState::default(); + } + } +} + +#[must_use] +pub fn standard_view_label(view: StandardView) -> StringKey { + match view { + StandardView::Front => strings::VIEW_FRONT, + StandardView::Back => strings::VIEW_BACK, + StandardView::Left => strings::VIEW_LEFT, + StandardView::Right => strings::VIEW_RIGHT, + StandardView::Top => strings::VIEW_TOP, + StandardView::Bottom => strings::VIEW_BOTTOM, + StandardView::Isometric => strings::VIEW_ISOMETRIC, + StandardView::NormalTo => strings::VIEW_NORMAL_TO, + } +} + +#[derive(Copy, Clone)] +struct Basis { + right: Vec3f, + up: Vec3f, + forward: Vec3f, +} + +#[derive(Copy, Clone)] +struct Vec3f { + x: f64, + y: f64, + z: f64, +} + +impl Vec3f { + const fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + fn dot(self, other: Self) -> f64 { + self.x * other.x + self.y * other.y + self.z * other.z + } + + fn cross(self, other: Self) -> Self { + Self::new( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x, + ) + } + + fn normalized(self) -> Option { + let norm = self.dot(self).sqrt(); + (norm > VISIBILITY_EPSILON).then(|| Self::new(self.x / norm, self.y / norm, self.z / norm)) + } +} + +fn view_basis(camera: Camera3) -> Option { + let (ex, ey, ez) = camera.eye().coords_mm(); + let (tx, ty, tz) = camera.target().coords_mm(); + let (ux, uy, uz) = camera.up().components(); + let forward = Vec3f::new(tx - ex, ty - ey, tz - ez).normalized()?; + let right = forward.cross(Vec3f::new(ux, uy, uz)).normalized()?; + let up = right.cross(forward); + Some(Basis { right, up, forward }) +} + +fn cell_vec(cell: CubeCell) -> Vec3f { + Vec3f::new(f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)) +} + +fn cell_visible(cell: CubeCell, forward: Vec3f) -> bool { + [ + (cell.x, forward.x), + (cell.y, forward.y), + (cell.z, forward.z), + ] + .into_iter() + .any(|(component, toward)| f64::from(component) * toward < -VISIBILITY_EPSILON) +} + +#[derive(Clone)] +enum CellGeom { + Face { + quad: ConvexPoly, + label_at: LayoutPos, + }, + Edge { + a: LayoutPos, + b: LayoutPos, + }, + Corner { + at: LayoutPos, + }, +} + +#[derive(Clone)] +struct Placement { + cell: CubeCell, + geom: CellGeom, + pick_rect: LayoutRect, + depth: f64, +} + +fn project(point: (f64, f64, f64), basis: Basis, center: (f32, f32), radius: f32) -> LayoutPos { + let v = Vec3f::new(point.0, point.1, point.2); + let scale = radius / 3.0_f32.sqrt(); + let sx = lower_f32(v.dot(basis.right)) * scale; + let sy = lower_f32(v.dot(basis.up)) * scale; + LayoutPos::new( + LayoutPx::saturating(center.0 + sx), + LayoutPx::saturating(center.1 - sy), + ) +} + +fn cell_corners(cell: CubeCell) -> Vec<(f64, f64, f64)> { + let base = [f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)]; + let zeros: Vec = (0..3).filter(|&i| base[i] == 0.0).collect(); + let corner = |overrides: &[(usize, f64)]| { + let at = |i: usize| { + overrides + .iter() + .find_map(|&(j, v)| (j == i).then_some(v)) + .unwrap_or(base[i]) + }; + (at(0), at(1), at(2)) + }; + match zeros.as_slice() { + [z] => [-1.0, 1.0] + .into_iter() + .map(|s| corner(&[(*z, s)])) + .collect(), + [u, v] => [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + .into_iter() + .map(|(su, sv)| corner(&[(*u, su), (*v, sv)])) + .collect(), + _ => vec![corner(&[])], + } +} + +fn placement(cell: CubeCell, basis: Basis, center: (f32, f32), radius: f32) -> Option { + let projected: Vec = cell_corners(cell) + .into_iter() + .map(|c| project(c, basis, center, radius)) + .collect(); + let geom = match cell.kind() { + CellKind::Face => CellGeom::Face { + quad: ConvexPoly::new(projected)?, + label_at: project( + (f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)), + basis, + center, + radius, + ), + }, + CellKind::Edge => CellGeom::Edge { + a: *projected.first()?, + b: *projected.get(1)?, + }, + CellKind::Corner => CellGeom::Corner { + at: *projected.first()?, + }, + }; + let pick_rect = pick_rect_for(&geom); + Some(Placement { + cell, + geom, + pick_rect, + depth: cell_vec(cell).dot(basis.forward), + }) +} + +fn pick_rect_for(geom: &CellGeom) -> LayoutRect { + match geom { + CellGeom::Face { label_at, .. } => { + centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX) + } + CellGeom::Edge { a, b } => centered_rect(midpoint(*a, *b), EDGE_HIT_PX, EDGE_HIT_PX), + CellGeom::Corner { at } => centered_rect(*at, CORNER_HIT_PX, CORNER_HIT_PX), + } +} + +fn centered_rect(at: LayoutPos, width: f32, height: f32) -> LayoutRect { + LayoutRect::new( + LayoutPos::new( + LayoutPx::saturating(at.x.value() - width / 2.0), + LayoutPx::saturating(at.y.value() - height / 2.0), + ), + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)), + ) +} + +fn midpoint(a: LayoutPos, b: LayoutPos) -> LayoutPos { + LayoutPos::new( + LayoutPx::saturating((a.x.value() + b.x.value()) * 0.5), + LayoutPx::saturating((a.y.value() + b.y.value()) * 0.5), + ) +} + +fn face_shade(cell: CubeCell) -> f32 { + let (nx, ny, nz) = cell.direction().components(); + let norm = + (LIGHT_DIR.0 * LIGHT_DIR.0 + LIGHT_DIR.1 * LIGHT_DIR.1 + LIGHT_DIR.2 * LIGHT_DIR.2).sqrt(); + let dot = (nx * LIGHT_DIR.0 + ny * LIGHT_DIR.1 + nz * LIGHT_DIR.2) / norm; + lower_f32(0.55 + 0.45 * dot.clamp(-1.0, 1.0)) +} + +fn kind_rank(kind: CellKind) -> u8 { + match kind { + CellKind::Face => 0, + CellKind::Edge => 1, + CellKind::Corner => 2, + } +} + +fn cube_anchor(viewport: LayoutRect, top_offset: f32) -> Option<(LayoutRect, (f32, f32), f32)> { + let span_w = CUBE_SIDE_PX + 2.0 * CUBE_INSET_PX; + let span_h = span_w + top_offset; + if viewport.size.width.value() < span_w || viewport.size.height.value() < span_h { + return None; + } + let right_edge = viewport.min_x().value() + viewport.size.width.value() - CUBE_INSET_PX; + let top = viewport.min_y().value() + CUBE_INSET_PX + top_offset; + let rect = LayoutRect::new( + LayoutPos::new(LayoutPx::new(right_edge - CUBE_SIDE_PX), LayoutPx::new(top)), + LayoutSize::new(LayoutPx::new(CUBE_SIDE_PX), LayoutPx::new(CUBE_SIDE_PX)), + ); + let center = (right_edge - CUBE_SIDE_PX / 2.0, top + CUBE_SIDE_PX / 2.0); + let radius = CUBE_SIDE_PX / 2.0 - CUBE_MARGIN_PX; + Some((rect, center, radius)) +} + +fn ordered_visible(basis: Basis, center: (f32, f32), radius: f32) -> Vec { + let mut placements: Vec = CubeCell::all() + .into_iter() + .filter(|cell| cell_visible(*cell, basis.forward)) + .filter_map(|cell| placement(cell, basis, center, radius)) + .collect(); + placements.sort_by(|a, b| { + kind_rank(a.cell.kind()) + .cmp(&kind_rank(b.cell.kind())) + .then( + b.depth + .partial_cmp(&a.depth) + .unwrap_or(core::cmp::Ordering::Equal), + ) + .then(a.cell.index().cmp(&b.cell.index())) + }); + placements +} -- 2.51.2