Another project
0

Configure Feed

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

view cube: cell model, projection, placement

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

author
Lewis
date (Jun 12, 2026, 11:56 AM +0300) commit 2657bb30 parent c3566f16 change-id kypzzyzz
+440
+440
crates/bone-app/src/view_cube.rs
··· 1 + use bone_render::{CameraTween, lower_f32}; 2 + use bone_types::{Camera3, StandardView, Tolerance, UnitVec3}; 3 + use bone_ui::a11y::{AccessNode, Role}; 4 + use bone_ui::frame::{FrameCtx, InteractDeclaration}; 5 + use bone_ui::hit_test::Sense; 6 + use bone_ui::input::{FrameInstant, PointerButton}; 7 + use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 8 + use bone_ui::strings::StringKey; 9 + use bone_ui::theme::{Border, Color, Step12, StrokeWidth, Theme}; 10 + use 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 + }; 15 + use bone_ui::{WidgetId, WidgetKey}; 16 + 17 + use crate::strings; 18 + 19 + const CUBE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 20 + const CUBE_SIDE_PX: f32 = 150.0; 21 + const CUBE_INSET_PX: f32 = 12.0; 22 + const CUBE_MARGIN_PX: f32 = 8.0; 23 + const FACE_LABEL_W_PX: f32 = 46.0; 24 + const FACE_LABEL_H_PX: f32 = 22.0; 25 + const EDGE_HIT_PX: f32 = 18.0; 26 + const CORNER_HIT_PX: f32 = 16.0; 27 + const EDGE_HOVER_WIDTH_PX: f32 = 3.0; 28 + const CORNER_HOVER_HALF_PX: f32 = 7.0; 29 + const LIGHT_DIR: (f64, f64, f64) = (0.35, -0.45, 1.0); 30 + const HOME_WIDTH_PX: f32 = 54.0; 31 + const HOME_HEIGHT_PX: f32 = 16.0; 32 + const HOME_GAP_PX: f32 = 6.0; 33 + const CONFIRM_RESERVE_PX: f32 = 56.0; 34 + const TOOLTIP_SIZE: LayoutSize = LayoutSize::new(LayoutPx::new(72.0), LayoutPx::new(18.0)); 35 + const VISIBILITY_EPSILON: f64 = 1.0e-6; 36 + 37 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 38 + pub enum CellKind { 39 + Face, 40 + Edge, 41 + Corner, 42 + } 43 + 44 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 45 + pub struct CubeCell { 46 + x: i8, 47 + y: i8, 48 + z: i8, 49 + } 50 + 51 + impl 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 + #[derive(Copy, Clone, Debug, PartialEq)] 130 + pub enum ViewPick { 131 + Standard(StandardView), 132 + Direction(UnitVec3), 133 + Home, 134 + } 135 + 136 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 137 + pub enum ViewCubeMenuAction { 138 + SetAsHome, 139 + FitToWindow, 140 + ViewNormalTo, 141 + } 142 + 143 + #[derive(Copy, Clone, Debug, Default, PartialEq)] 144 + pub struct ViewCubeOutcome { 145 + pub pick: Option<ViewPick>, 146 + pub menu: Option<ViewCubeMenuAction>, 147 + } 148 + 149 + #[derive(Copy, Clone, Debug, PartialEq)] 150 + pub struct ActiveTween { 151 + pub tween: CameraTween, 152 + pub started: FrameInstant, 153 + } 154 + 155 + #[derive(Clone, Debug)] 156 + pub struct ViewUi { 157 + pub cube_hidden: bool, 158 + pub selector_open: bool, 159 + pub selector_menu: MenuState, 160 + pub cube_menu_open: bool, 161 + pub cube_menu_anchor: LayoutPos, 162 + pub cube_menu: MenuState, 163 + pub cube_tooltip: TooltipState, 164 + pub home: Option<Camera3>, 165 + pub tween: Option<ActiveTween>, 166 + } 167 + 168 + impl Default for ViewUi { 169 + fn default() -> Self { 170 + Self { 171 + cube_hidden: false, 172 + selector_open: false, 173 + selector_menu: MenuState::default(), 174 + cube_menu_open: false, 175 + cube_menu_anchor: LayoutPos::ORIGIN, 176 + cube_menu: MenuState::default(), 177 + cube_tooltip: TooltipState::default(), 178 + home: None, 179 + tween: None, 180 + } 181 + } 182 + } 183 + 184 + impl ViewUi { 185 + pub fn toggle_cube(&mut self) { 186 + self.cube_hidden = !self.cube_hidden; 187 + } 188 + 189 + pub fn toggle_selector(&mut self) { 190 + self.selector_open = !self.selector_open; 191 + if self.selector_open { 192 + self.selector_menu = MenuState::default(); 193 + } 194 + } 195 + } 196 + 197 + #[must_use] 198 + pub fn standard_view_label(view: StandardView) -> StringKey { 199 + match view { 200 + StandardView::Front => strings::VIEW_FRONT, 201 + StandardView::Back => strings::VIEW_BACK, 202 + StandardView::Left => strings::VIEW_LEFT, 203 + StandardView::Right => strings::VIEW_RIGHT, 204 + StandardView::Top => strings::VIEW_TOP, 205 + StandardView::Bottom => strings::VIEW_BOTTOM, 206 + StandardView::Isometric => strings::VIEW_ISOMETRIC, 207 + StandardView::NormalTo => strings::VIEW_NORMAL_TO, 208 + } 209 + } 210 + 211 + #[derive(Copy, Clone)] 212 + struct Basis { 213 + right: Vec3f, 214 + up: Vec3f, 215 + forward: Vec3f, 216 + } 217 + 218 + #[derive(Copy, Clone)] 219 + struct Vec3f { 220 + x: f64, 221 + y: f64, 222 + z: f64, 223 + } 224 + 225 + impl Vec3f { 226 + const fn new(x: f64, y: f64, z: f64) -> Self { 227 + Self { x, y, z } 228 + } 229 + 230 + fn dot(self, other: Self) -> f64 { 231 + self.x * other.x + self.y * other.y + self.z * other.z 232 + } 233 + 234 + fn cross(self, other: Self) -> Self { 235 + Self::new( 236 + self.y * other.z - self.z * other.y, 237 + self.z * other.x - self.x * other.z, 238 + self.x * other.y - self.y * other.x, 239 + ) 240 + } 241 + 242 + fn normalized(self) -> Option<Self> { 243 + let norm = self.dot(self).sqrt(); 244 + (norm > VISIBILITY_EPSILON).then(|| Self::new(self.x / norm, self.y / norm, self.z / norm)) 245 + } 246 + } 247 + 248 + fn view_basis(camera: Camera3) -> Option<Basis> { 249 + let (ex, ey, ez) = camera.eye().coords_mm(); 250 + let (tx, ty, tz) = camera.target().coords_mm(); 251 + let (ux, uy, uz) = camera.up().components(); 252 + let forward = Vec3f::new(tx - ex, ty - ey, tz - ez).normalized()?; 253 + let right = forward.cross(Vec3f::new(ux, uy, uz)).normalized()?; 254 + let up = right.cross(forward); 255 + Some(Basis { right, up, forward }) 256 + } 257 + 258 + fn cell_vec(cell: CubeCell) -> Vec3f { 259 + Vec3f::new(f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)) 260 + } 261 + 262 + fn cell_visible(cell: CubeCell, forward: Vec3f) -> bool { 263 + [ 264 + (cell.x, forward.x), 265 + (cell.y, forward.y), 266 + (cell.z, forward.z), 267 + ] 268 + .into_iter() 269 + .any(|(component, toward)| f64::from(component) * toward < -VISIBILITY_EPSILON) 270 + } 271 + 272 + #[derive(Clone)] 273 + enum CellGeom { 274 + Face { 275 + quad: ConvexPoly, 276 + label_at: LayoutPos, 277 + }, 278 + Edge { 279 + a: LayoutPos, 280 + b: LayoutPos, 281 + }, 282 + Corner { 283 + at: LayoutPos, 284 + }, 285 + } 286 + 287 + #[derive(Clone)] 288 + struct Placement { 289 + cell: CubeCell, 290 + geom: CellGeom, 291 + pick_rect: LayoutRect, 292 + depth: f64, 293 + } 294 + 295 + fn project(point: (f64, f64, f64), basis: Basis, center: (f32, f32), radius: f32) -> LayoutPos { 296 + let v = Vec3f::new(point.0, point.1, point.2); 297 + let scale = radius / 3.0_f32.sqrt(); 298 + let sx = lower_f32(v.dot(basis.right)) * scale; 299 + let sy = lower_f32(v.dot(basis.up)) * scale; 300 + LayoutPos::new( 301 + LayoutPx::saturating(center.0 + sx), 302 + LayoutPx::saturating(center.1 - sy), 303 + ) 304 + } 305 + 306 + fn cell_corners(cell: CubeCell) -> Vec<(f64, f64, f64)> { 307 + let base = [f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)]; 308 + let zeros: Vec<usize> = (0..3).filter(|&i| base[i] == 0.0).collect(); 309 + let corner = |overrides: &[(usize, f64)]| { 310 + let at = |i: usize| { 311 + overrides 312 + .iter() 313 + .find_map(|&(j, v)| (j == i).then_some(v)) 314 + .unwrap_or(base[i]) 315 + }; 316 + (at(0), at(1), at(2)) 317 + }; 318 + match zeros.as_slice() { 319 + [z] => [-1.0, 1.0] 320 + .into_iter() 321 + .map(|s| corner(&[(*z, s)])) 322 + .collect(), 323 + [u, v] => [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] 324 + .into_iter() 325 + .map(|(su, sv)| corner(&[(*u, su), (*v, sv)])) 326 + .collect(), 327 + _ => vec![corner(&[])], 328 + } 329 + } 330 + 331 + fn placement(cell: CubeCell, basis: Basis, center: (f32, f32), radius: f32) -> Option<Placement> { 332 + let projected: Vec<LayoutPos> = cell_corners(cell) 333 + .into_iter() 334 + .map(|c| project(c, basis, center, radius)) 335 + .collect(); 336 + let geom = match cell.kind() { 337 + CellKind::Face => CellGeom::Face { 338 + quad: ConvexPoly::new(projected)?, 339 + label_at: project( 340 + (f64::from(cell.x), f64::from(cell.y), f64::from(cell.z)), 341 + basis, 342 + center, 343 + radius, 344 + ), 345 + }, 346 + CellKind::Edge => CellGeom::Edge { 347 + a: *projected.first()?, 348 + b: *projected.get(1)?, 349 + }, 350 + CellKind::Corner => CellGeom::Corner { 351 + at: *projected.first()?, 352 + }, 353 + }; 354 + let pick_rect = pick_rect_for(&geom); 355 + Some(Placement { 356 + cell, 357 + geom, 358 + pick_rect, 359 + depth: cell_vec(cell).dot(basis.forward), 360 + }) 361 + } 362 + 363 + fn pick_rect_for(geom: &CellGeom) -> LayoutRect { 364 + match geom { 365 + CellGeom::Face { label_at, .. } => { 366 + centered_rect(*label_at, FACE_LABEL_W_PX, FACE_LABEL_H_PX) 367 + } 368 + CellGeom::Edge { a, b } => centered_rect(midpoint(*a, *b), EDGE_HIT_PX, EDGE_HIT_PX), 369 + CellGeom::Corner { at } => centered_rect(*at, CORNER_HIT_PX, CORNER_HIT_PX), 370 + } 371 + } 372 + 373 + fn centered_rect(at: LayoutPos, width: f32, height: f32) -> LayoutRect { 374 + LayoutRect::new( 375 + LayoutPos::new( 376 + LayoutPx::saturating(at.x.value() - width / 2.0), 377 + LayoutPx::saturating(at.y.value() - height / 2.0), 378 + ), 379 + LayoutSize::new(LayoutPx::new(width), LayoutPx::new(height)), 380 + ) 381 + } 382 + 383 + fn midpoint(a: LayoutPos, b: LayoutPos) -> LayoutPos { 384 + LayoutPos::new( 385 + LayoutPx::saturating((a.x.value() + b.x.value()) * 0.5), 386 + LayoutPx::saturating((a.y.value() + b.y.value()) * 0.5), 387 + ) 388 + } 389 + 390 + fn face_shade(cell: CubeCell) -> f32 { 391 + let (nx, ny, nz) = cell.direction().components(); 392 + let norm = 393 + (LIGHT_DIR.0 * LIGHT_DIR.0 + LIGHT_DIR.1 * LIGHT_DIR.1 + LIGHT_DIR.2 * LIGHT_DIR.2).sqrt(); 394 + let dot = (nx * LIGHT_DIR.0 + ny * LIGHT_DIR.1 + nz * LIGHT_DIR.2) / norm; 395 + lower_f32(0.55 + 0.45 * dot.clamp(-1.0, 1.0)) 396 + } 397 + 398 + fn kind_rank(kind: CellKind) -> u8 { 399 + match kind { 400 + CellKind::Face => 0, 401 + CellKind::Edge => 1, 402 + CellKind::Corner => 2, 403 + } 404 + } 405 + 406 + fn cube_anchor(viewport: LayoutRect, top_offset: f32) -> Option<(LayoutRect, (f32, f32), f32)> { 407 + let span_w = CUBE_SIDE_PX + 2.0 * CUBE_INSET_PX; 408 + let span_h = span_w + top_offset; 409 + if viewport.size.width.value() < span_w || viewport.size.height.value() < span_h { 410 + return None; 411 + } 412 + let right_edge = viewport.min_x().value() + viewport.size.width.value() - CUBE_INSET_PX; 413 + let top = viewport.min_y().value() + CUBE_INSET_PX + top_offset; 414 + let rect = LayoutRect::new( 415 + LayoutPos::new(LayoutPx::new(right_edge - CUBE_SIDE_PX), LayoutPx::new(top)), 416 + LayoutSize::new(LayoutPx::new(CUBE_SIDE_PX), LayoutPx::new(CUBE_SIDE_PX)), 417 + ); 418 + let center = (right_edge - CUBE_SIDE_PX / 2.0, top + CUBE_SIDE_PX / 2.0); 419 + let radius = CUBE_SIDE_PX / 2.0 - CUBE_MARGIN_PX; 420 + Some((rect, center, radius)) 421 + } 422 + 423 + fn ordered_visible(basis: Basis, center: (f32, f32), radius: f32) -> Vec<Placement> { 424 + let mut placements: Vec<Placement> = CubeCell::all() 425 + .into_iter() 426 + .filter(|cell| cell_visible(*cell, basis.forward)) 427 + .filter_map(|cell| placement(cell, basis, center, radius)) 428 + .collect(); 429 + placements.sort_by(|a, b| { 430 + kind_rank(a.cell.kind()) 431 + .cmp(&kind_rank(b.cell.kind())) 432 + .then( 433 + b.depth 434 + .partial_cmp(&a.depth) 435 + .unwrap_or(core::cmp::Ordering::Equal), 436 + ) 437 + .then(a.cell.index().cmp(&b.cell.index())) 438 + }); 439 + placements 440 + }