// ViewCube wgpu pipeline — OpenCADStudio-style interactive 3D navigation cube. // // 26 selectable regions: 6 faces + 12 edges + 8 corners. // Phong shading + hover highlight passed as uniform. // Hit-test is 100% CPU — no GPU readback needed. // // The ViewCube rotation matrix is derived directly from the camera quaternion // (cam_rotation: Mat4) everywhere — shader, text labels, hit-test, hover-id. // This eliminates gimbal lock at top/bottom views and keeps the cube in sync // with arcball orbit at all angles. use bytemuck::{Pod, Zeroable}; use glam::camera::rh::proj::directx::orthographic; use glam::{Mat4, Vec3, Vec4}; use iced::wgpu; use iced::{Rectangle, Size}; // ── ViewCube layout ─────────────────────────────────────────────────────── pub const VIEWCUBE_PX: u32 = 84; // 30% smaller cube (was 120) pub const VIEWCUBE_SCALE: f32 = 0.36; pub const VIEWCUBE_DRAW_PX: f32 = VIEWCUBE_PX as f32 * VIEWCUBE_SCALE * 2.0; pub const VIEWCUBE_PAD: f32 = 12.0; /// The cube centre is inset from the screen corner by this multiple of the /// cube half-size, leaving room for the compass ring and the nav controls /// (home / roll / nudge) drawn around it. Tighter than before so the controls /// hug the cube in the corner instead of floating in a large dead region. pub const NAV_INSET_F: f32 = 2.0; /// Side of the whole nav widget (cube + compass ring + controls) in pixels. pub const VIEWCUBE_REGION_PX: f32 = VIEWCUBE_DRAW_PX * NAV_INSET_F; /// Z height of the compass ring + cardinals in cube-local space. The cube /// spans ±1, so −1 parks the ring at the cube's base — it sits *under* the /// cube in 3D views and reads as a ground compass. const RING_Z: f32 = -1.0; /// Horizontal radius of the N/E/S/W cardinal letters — the ring's mid-line, so /// they sit painted on the ring band. const R_CARD: f32 = 1.57; /// Compass cardinal directions, mapped to a side-face snap when clicked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Cardinal { North, East, South, West, } /// 90° view nudge directions (tip the cube up/down or spin it left/right). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NudgeDir { Up, Down, Left, Right, } const FACE_LABELS: [&str; 6] = ["TOP", "BOTTOM", "FRONT", "BACK", "RIGHT", "LEFT"]; const FACE_CENTERS: [[f32; 3]; 6] = [ [0.0, 0.0, 1.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], ]; pub const FACE_TOP: usize = 0; pub const FACE_BOTTOM: usize = 1; pub const FACE_FRONT: usize = 2; pub const FACE_BACK: usize = 3; pub const FACE_RIGHT: usize = 4; pub const FACE_LEFT: usize = 5; pub const EDGE_TOP_FRONT: usize = 6; pub const EDGE_TOP_BACK: usize = 7; pub const EDGE_TOP_RIGHT: usize = 8; pub const EDGE_TOP_LEFT: usize = 9; pub const EDGE_BOT_FRONT: usize = 10; pub const EDGE_BOT_BACK: usize = 11; pub const EDGE_BOT_RIGHT: usize = 12; pub const EDGE_BOT_LEFT: usize = 13; pub const EDGE_FRONT_RIGHT: usize = 14; pub const EDGE_FRONT_LEFT: usize = 15; pub const EDGE_BACK_RIGHT: usize = 16; pub const EDGE_BACK_LEFT: usize = 17; pub const CORNER_TPF_R: usize = 18; pub const CORNER_TPF_L: usize = 19; pub const CORNER_TBK_R: usize = 20; pub const CORNER_TBK_L: usize = 21; pub const CORNER_BTF_R: usize = 22; pub const CORNER_BTF_L: usize = 23; pub const CORNER_BBK_R: usize = 24; pub const CORNER_BBK_L: usize = 25; pub const NUM_REGIONS: usize = 26; #[derive(Debug, Clone, Copy, PartialEq)] pub enum CubeRegion { Face(usize), Edge(usize), Corner(usize), } impl CubeRegion { pub fn id(self) -> usize { match self { Self::Face(i) | Self::Edge(i) | Self::Corner(i) => i, } } /// Unit eye-direction vector (from target toward the camera) that /// looks straight at this region. Used by `Camera::snap_to_direction` /// which derives the full orientation by re-using the current /// camera's up vector, projected onto the plane perpendicular to /// this direction — so clicking an edge spins the cube around the /// edge without rolling the user's "up" sense. pub fn snap_direction(self) -> glam::Vec3 { let c = region_centroids()[self.id()]; glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z) } pub fn opposite(self) -> CubeRegion { match self { CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM), CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP), CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK), CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT), CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT), CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT), other => other, } } pub fn label(self) -> &'static str { match self.id() { 0 => "TOP", 1 => "BOTTOM", 2 => "FRONT", 3 => "BACK", 4 => "RIGHT", 5 => "LEFT", 6 => "Top Front", 7 => "Top Back", 8 => "Top Right", 9 => "Top Left", 10 => "Bot Front", 11 => "Bot Back", 12 => "Bot Right", 13 => "Bot Left", 14 => "Front Right", 15 => "Front Left", 16 => "Back Right", 17 => "Back Left", 18 => "Top Front Right", 19 => "Top Front Left", 20 => "Top Back Right", 21 => "Top Back Left", 22 => "Bot Front Right", 23 => "Bot Front Left", 24 => "Bot Back Right", 25 => "Bot Back Left", _ => "?", } } } // ── Vertex ──────────────────────────────────────────────────────────────── #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] pub struct CubeVertex { pub pos: [f32; 3], pub normal: [f32; 3], pub color: [f32; 3], pub region_f: f32, } impl CubeVertex { const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![ 0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32, ]; pub fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] pub struct CubeUniforms { pub view_proj: [f32; 16], pub rotation: [f32; 16], pub hover_region: [f32; 4], } impl CubeUniforms { /// Build uniforms from the camera quaternion-derived rotation matrix. /// `cam_rotation` = `Mat4::from_quat(camera.rotation)`. pub fn new( cam_rotation: Mat4, cube_px: u32, vp_w: u32, vp_h: u32, hover: Option, ) -> Self { let (hw, hh) = (vp_w as f32 * 0.5, vp_h as f32 * 0.5); let cube_half = cube_px as f32 * VIEWCUBE_SCALE; // Inset the cube centre to leave room for the ring + controls. let inset = cube_half * NAV_INSET_F; let cx = hw - inset - VIEWCUBE_PAD; let cy = hh - inset - VIEWCUBE_PAD; let view_proj = orthographic(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new(cx, cy, 0.0)) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); Self { view_proj: view_proj.to_cols_array(), rotation: cam_rotation.to_cols_array(), hover_region: [ hover.map(|h| h as f32 / 25.0).unwrap_or(-1.0), 0.0, 0.0, 0.0, ], } } } // ── Bitmap text ─────────────────────────────────────────────────────────── const GLYPH_W: usize = 5; const GLYPH_H: usize = 7; const CELL_W: usize = 6; const CELL_H: usize = 8; const ATLAS_COLS: usize = 8; const ATLAS_ROWS: usize = 3; const MAX_LABEL_CHARS: usize = 6; const LABEL_COUNT: usize = 6; // Face labels + the four compass cardinals (one glyph each). const MAX_GLYPHS: usize = MAX_LABEL_CHARS * LABEL_COUNT + 4; const MAX_VERTS: usize = MAX_GLYPHS * 6; #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct TextUniforms { screen: [f32; 2], _pad: [f32; 2], } #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct TextVertex { pos: [f32; 2], uv: [f32; 2], color: [f32; 4], } impl TextVertex { const ATTRIBS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 0 => Float32x2, 1 => Float32x2, 2 => Float32x4, ]; fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } fn glyph_index(c: char) -> Option { match c { 'A' => Some(0), 'B' => Some(1), 'C' => Some(2), 'E' => Some(3), 'F' => Some(4), 'G' => Some(5), 'H' => Some(6), 'I' => Some(7), 'K' => Some(8), 'L' => Some(9), 'M' => Some(10), 'N' => Some(11), 'O' => Some(12), 'P' => Some(13), 'R' => Some(14), 'T' => Some(15), 'S' => Some(16), 'W' => Some(17), _ => None, } } fn glyph_rows(c: char) -> [u8; GLYPH_H] { match c { 'A' => [ 0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, ], 'B' => [ 0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110, ], 'C' => [ 0b01111, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b01111, ], 'E' => [ 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111, ], 'F' => [ 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, ], 'G' => [ 0b01111, 0b10000, 0b10000, 0b10011, 0b10001, 0b10001, 0b01111, ], 'H' => [ 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001, ], 'I' => [ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111, ], 'K' => [ 0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001, ], 'L' => [ 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111, ], 'M' => [ 0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001, ], 'N' => [ 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001, ], 'O' => [ 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110, ], 'P' => [ 0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000, ], 'R' => [ 0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001, ], 'T' => [ 0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, ], 'S' => [ 0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110, ], 'W' => [ 0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001, ], _ => [0; GLYPH_H], } } fn build_atlas() -> (Vec, u32, u32) { let w = (ATLAS_COLS * CELL_W) as u32; let h = (ATLAS_ROWS * CELL_H) as u32; let mut data = vec![0u8; (w * h) as usize]; let glyphs = [ 'A', 'B', 'C', 'E', 'F', 'G', 'H', 'I', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'T', 'S', 'W', ]; for (i, &ch) in glyphs.iter().enumerate() { let col = i % ATLAS_COLS; let row = i / ATLAS_COLS; let x0 = col * CELL_W; let y0 = row * CELL_H; let rows = glyph_rows(ch); for y in 0..GLYPH_H { let bits = rows[y]; for x in 0..GLYPH_W { if (bits >> (GLYPH_W - 1 - x)) & 1 == 0 { continue; } data[(y0 + y) as usize * w as usize + (x0 + x)] = 255; } } } (data, w, h) } fn glyph_uv(index: usize, atlas_w: f32, atlas_h: f32) -> (f32, f32, f32, f32) { let col = index % ATLAS_COLS; let row = index / ATLAS_COLS; let x0 = (col * CELL_W) as f32; let y0 = (row * CELL_H) as f32; ( x0 / atlas_w, y0 / atlas_h, (x0 + GLYPH_W as f32) / atlas_w, (y0 + GLYPH_H as f32) / atlas_h, ) } struct ViewCubeText { pipeline: wgpu::RenderPipeline, vertex_buffer: wgpu::Buffer, vertex_capacity: u32, vertex_count: u32, uniform_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, atlas_w: f32, atlas_h: f32, } impl ViewCubeText { fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { let (atlas, w, h) = build_atlas(); let atlas_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("vc.text_atlas"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let bytes_per_row = w; let aligned_bpr = ((bytes_per_row + 255) / 256) * 256; let atlas_bytes = if aligned_bpr == bytes_per_row { atlas } else { let mut padded = vec![0u8; (aligned_bpr * h) as usize]; for row in 0..h as usize { let src = row * bytes_per_row as usize; let dst = row * aligned_bpr as usize; padded[dst..dst + bytes_per_row as usize] .copy_from_slice(&atlas[src..src + bytes_per_row as usize]); } padded }; queue.write_texture( atlas_tex.as_image_copy(), &atlas_bytes, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(aligned_bpr), rows_per_image: Some(h), }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); let atlas_view = atlas_tex.create_view(&wgpu::TextureViewDescriptor::default()); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("vc.text_sampler"), mag_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("vc.text_uniform"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("vc.text_bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { multisampled: false, view_dimension: wgpu::TextureViewDimension::D2, sample_type: wgpu::TextureSampleType::Float { filterable: true }, }, count: None, }, ], }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("vc.text_bg"), layout: &bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: uniform_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&atlas_view), }, ], }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("vc.text_layout"), bind_group_layouts: &[&bgl], push_constant_ranges: &[], }); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("vc.text_shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( "../../shaders/viewcube_text.wgsl" ))), }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("vc.text_pipe"), layout: Some(&layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), buffers: &[TextVertex::desc()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: None, ..Default::default() }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: wgpu::PipelineCompilationOptions::default(), }), multiview: None, cache: None, }); let vertex_capacity = MAX_VERTS as u32; let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("vc.text_vb"), size: (vertex_capacity as usize * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); Self { pipeline, vertex_buffer, vertex_capacity, vertex_count: 0, uniform_buffer, bind_group, atlas_w: w as f32, atlas_h: h as f32, } } /// Update text labels using the quaternion-derived rotation matrix. fn update( &mut self, queue: &wgpu::Queue, cam_rotation: Mat4, compass_rotation: Mat4, vp_w: u32, vp_h: u32, cube_px: u32, ) { let (vw, vh) = (vp_w as f32, vp_h as f32); let cube_half = cube_px as f32 * VIEWCUBE_SCALE; let inset = cube_half * NAV_INSET_F; let (hw, hh) = (vw * 0.5, vh * 0.5); let view_proj = orthographic(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new( hw - inset - VIEWCUBE_PAD, hh - inset - VIEWCUBE_PAD, 0.0, )) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); // Glyph size in cube-local units. Letters are laid out on the face // plane itself (not screen-aligned), so they skew with perspective and // look painted onto the surface as the cube rotates. const GW: f32 = 0.17; // glyph width const GH: f32 = 0.24; // glyph height const ADV: f32 = 0.22; // pen advance // In-plane (right, up) axes per face, ordered as FACE_CENTERS. Chosen // so u × v = outward normal → letters never read mirrored. const FACE_U: [[f32; 3]; 6] = [ [1.0, 0.0, 0.0], // TOP [-1.0, 0.0, 0.0], // BOTTOM [1.0, 0.0, 0.0], // FRONT [-1.0, 0.0, 0.0], // BACK [0.0, 1.0, 0.0], // RIGHT [0.0, -1.0, 0.0], // LEFT ]; const FACE_V: [[f32; 3]; 6] = [ [0.0, 1.0, 0.0], // TOP [0.0, 1.0, 0.0], // BOTTOM [0.0, 0.0, 1.0], // FRONT [0.0, 0.0, 1.0], // BACK [0.0, 0.0, 1.0], // RIGHT [0.0, 0.0, 1.0], // LEFT ]; let mut verts: Vec = Vec::with_capacity(MAX_VERTS); let view_dir = Vec3::Z; // Local cube point → screen pixel. let project = |local: Vec3| -> Option<[f32; 2]> { let world = cam_rotation.transform_point3(local); let clip = view_proj * Vec4::new(world.x, world.y, world.z, 1.0); if clip.w.abs() < 1e-6 { return None; } Some([ (clip.x / clip.w + 1.0) * 0.5 * vw, (1.0 - clip.y / clip.w) * 0.5 * vh, ]) }; for (fi, &c) in FACE_CENTERS.iter().enumerate() { let face_n = Vec3::from(c); let world_n = cam_rotation.transform_vector3(face_n).normalize(); let dot = world_n.dot(view_dir); if dot < 0.12 { continue; } let alpha = ((dot - 0.12) / 0.88).clamp(0.0, 1.0); let color = [1.0, 1.0, 1.0, alpha]; let u = Vec3::from(FACE_U[fi]); let v = Vec3::from(FACE_V[fi]); let center = face_n; // unit normal = face surface centre (distance E) let label = FACE_LABELS[fi]; let total_w = label.len() as f32 * ADV; let mut pen = -total_w * 0.5; for ch in label.chars() { let Some(gi) = glyph_index(ch) else { pen += ADV; continue; }; let (u0, v0, u1, v1) = glyph_uv(gi, self.atlas_w, self.atlas_h); // Glyph quad corners on the face plane, then projected. let corner = |lx: f32, ly: f32| center + u * lx + v * ly; let tl = project(corner(pen, GH * 0.5)); let tr = project(corner(pen + GW, GH * 0.5)); let br = project(corner(pen + GW, -GH * 0.5)); let bl = project(corner(pen, -GH * 0.5)); if let (Some(tl), Some(tr), Some(br), Some(bl)) = (tl, tr, br, bl) { let mk = |pos: [f32; 2], uv: [f32; 2]| TextVertex { pos, uv, color }; verts.push(mk(tl, [u0, v0])); verts.push(mk(tr, [u1, v0])); verts.push(mk(br, [u1, v1])); verts.push(mk(tl, [u0, v0])); verts.push(mk(br, [u1, v1])); verts.push(mk(bl, [u0, v1])); } pen += ADV; if verts.len() >= self.vertex_capacity as usize { break; } } if verts.len() >= self.vertex_capacity as usize { break; } } // ── Compass cardinals (N / E / S / W) ────────────────────────────── // Painted flat onto the ring band (z = RING_Z) so they foreshorten with // the ring and read upright in plan. Local axes u = +X, v = +Y give // u × v = +Z → never mirrored when seen from above. // // The compass is world-fixed: it projects through `compass_rotation` // (camera only, no UCS), so N/E/S/W stay aligned to world even when the // cube reorients with the active UCS. let project_world = |local: Vec3| -> Option<[f32; 2]> { let world = compass_rotation.transform_point3(local); let clip = view_proj * Vec4::new(world.x, world.y, world.z, 1.0); if clip.w.abs() < 1e-6 { return None; } Some([ (clip.x / clip.w + 1.0) * 0.5 * vw, (1.0 - clip.y / clip.w) * 0.5 * vh, ]) }; const CARD_GW: f32 = 0.16; // glyph size in cube-local units const CARD_GH: f32 = 0.22; let cardinals = [ ('N', Vec3::new(0.0, 1.0, 0.0)), ('E', Vec3::new(1.0, 0.0, 0.0)), ('S', Vec3::new(0.0, -1.0, 0.0)), ('W', Vec3::new(-1.0, 0.0, 0.0)), ]; for (ch, dir) in cardinals { let Some(gi) = glyph_index(ch) else { continue; }; let center = Vec3::new(dir.x * R_CARD, dir.y * R_CARD, RING_Z); // Dim a cardinal whose ring point sits behind the cube. let alpha = if compass_rotation.transform_point3(center).z >= -0.15 { 1.0 } else { 0.5 }; let color = [1.0, 1.0, 1.0, alpha]; let (u0, v0, u1, v1) = glyph_uv(gi, self.atlas_w, self.atlas_h); let corner = |lx: f32, ly: f32| center + Vec3::X * lx + Vec3::Y * ly; let tl = project_world(corner(-CARD_GW * 0.5, CARD_GH * 0.5)); let tr = project_world(corner(CARD_GW * 0.5, CARD_GH * 0.5)); let br = project_world(corner(CARD_GW * 0.5, -CARD_GH * 0.5)); let bl = project_world(corner(-CARD_GW * 0.5, -CARD_GH * 0.5)); if let (Some(tl), Some(tr), Some(br), Some(bl)) = (tl, tr, br, bl) { let mk = |pos: [f32; 2], uv: [f32; 2]| TextVertex { pos, uv, color }; verts.push(mk(tl, [u0, v0])); verts.push(mk(tr, [u1, v0])); verts.push(mk(br, [u1, v1])); verts.push(mk(tl, [u0, v0])); verts.push(mk(br, [u1, v1])); verts.push(mk(bl, [u0, v1])); } if verts.len() >= self.vertex_capacity as usize { break; } } self.vertex_count = verts.len() as u32; if self.vertex_count > 0 { queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&verts)); } queue.write_buffer( &self.uniform_buffer, 0, bytemuck::bytes_of(&TextUniforms { screen: [vw, vh], _pad: [0.0; 2], }), ); } fn render( &self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, clip: Rectangle, ) { if self.vertex_count == 0 { return; } let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("vc.text_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); pass.set_viewport( clip.x as f32, clip.y as f32, clip.width as f32, clip.height as f32, 0.0, 1.0, ); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.bind_group, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.draw(0..self.vertex_count, 0..1); } } // ── Geometry ────────────────────────────────────────────────────────────── const F: f32 = 0.80; const E: f32 = 1.00; const C_TOP: [f32; 3] = [0.70, 0.80, 0.94]; const C_BOTTOM: [f32; 3] = [0.32, 0.32, 0.36]; const C_FRONT: [f32; 3] = [0.80, 0.83, 0.90]; const C_BACK: [f32; 3] = [0.46, 0.47, 0.52]; const C_RIGHT: [f32; 3] = [0.62, 0.60, 0.56]; const C_LEFT: [f32; 3] = [0.54, 0.55, 0.64]; const C_EDGE: [f32; 3] = [0.24, 0.25, 0.28]; const C_CORNER: [f32; 3] = [0.16, 0.17, 0.19]; fn push_quad( corners: [[f32; 3]; 4], rgb: [f32; 3], region: usize, vs: &mut Vec, is: &mut Vec, ) { let mut cs = corners; let center = { let s = Vec3::from(cs[0]) + Vec3::from(cs[1]) + Vec3::from(cs[2]) + Vec3::from(cs[3]); (s * 0.25).normalize_or_zero() }; let mut n = (Vec3::from(cs[1]) - Vec3::from(cs[0])) .cross(Vec3::from(cs[3]) - Vec3::from(cs[0])) .normalize_or_zero(); if n.dot(center) < 0.0 { cs = [cs[0], cs[3], cs[2], cs[1]]; n = (Vec3::from(cs[1]) - Vec3::from(cs[0])) .cross(Vec3::from(cs[3]) - Vec3::from(cs[0])) .normalize_or_zero(); } let n = n.to_array(); let rf = region as f32 / 25.0; let base = vs.len() as u32; for pos in cs { vs.push(CubeVertex { pos, normal: n, color: rgb, region_f: rf, }); } is.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } fn push_tri( a: [f32; 3], b: [f32; 3], c: [f32; 3], rgb: [f32; 3], region: usize, vs: &mut Vec, is: &mut Vec, ) { let mut b = b; let mut c = c; let center = { let s = Vec3::from(a) + Vec3::from(b) + Vec3::from(c); (s / 3.0).normalize_or_zero() }; let mut n = (Vec3::from(b) - Vec3::from(a)) .cross(Vec3::from(c) - Vec3::from(a)) .normalize_or_zero(); if n.dot(center) < 0.0 { std::mem::swap(&mut b, &mut c); n = (Vec3::from(b) - Vec3::from(a)) .cross(Vec3::from(c) - Vec3::from(a)) .normalize_or_zero(); } let n = n.to_array(); let rf = region as f32 / 25.0; let base = vs.len() as u32; for pos in [a, b, c] { vs.push(CubeVertex { pos, normal: n, color: rgb, region_f: rf, }); } is.extend_from_slice(&[base, base + 1, base + 2]); } pub fn build_geometry() -> (Vec, Vec) { let (mut vs, mut is) = (Vec::::new(), Vec::::new()); push_quad( [[-F, -F, E], [F, -F, E], [F, F, E], [-F, F, E]], C_TOP, FACE_TOP, &mut vs, &mut is, ); push_quad( [[-F, F, -E], [F, F, -E], [F, -F, -E], [-F, -F, -E]], C_BOTTOM, FACE_BOTTOM, &mut vs, &mut is, ); push_quad( [[F, -E, -F], [-F, -E, -F], [-F, -E, F], [F, -E, F]], C_FRONT, FACE_FRONT, &mut vs, &mut is, ); push_quad( [[-F, E, -F], [F, E, -F], [F, E, F], [-F, E, F]], C_BACK, FACE_BACK, &mut vs, &mut is, ); push_quad( [[E, F, -F], [E, -F, -F], [E, -F, F], [E, F, F]], C_RIGHT, FACE_RIGHT, &mut vs, &mut is, ); push_quad( [[-E, -F, -F], [-E, F, -F], [-E, F, F], [-E, -F, F]], C_LEFT, FACE_LEFT, &mut vs, &mut is, ); push_quad( [[F, -F, E], [-F, -F, E], [-F, -E, F], [F, -E, F]], C_EDGE, EDGE_TOP_FRONT, &mut vs, &mut is, ); push_quad( [[-F, F, E], [F, F, E], [F, E, F], [-F, E, F]], C_EDGE, EDGE_TOP_BACK, &mut vs, &mut is, ); push_quad( [[F, F, E], [F, -F, E], [E, -F, F], [E, F, F]], C_EDGE, EDGE_TOP_RIGHT, &mut vs, &mut is, ); push_quad( [[-F, -F, E], [-F, F, E], [-E, F, F], [-E, -F, F]], C_EDGE, EDGE_TOP_LEFT, &mut vs, &mut is, ); push_quad( [[F, -F, -E], [-F, -F, -E], [-F, -E, -F], [F, -E, -F]], C_EDGE, EDGE_BOT_FRONT, &mut vs, &mut is, ); push_quad( [[-F, F, -E], [F, F, -E], [F, E, -F], [-F, E, -F]], C_EDGE, EDGE_BOT_BACK, &mut vs, &mut is, ); push_quad( [[F, F, -E], [F, -F, -E], [E, -F, -F], [E, F, -F]], C_EDGE, EDGE_BOT_RIGHT, &mut vs, &mut is, ); push_quad( [[-F, -F, -E], [-F, F, -E], [-E, F, -F], [-E, -F, -F]], C_EDGE, EDGE_BOT_LEFT, &mut vs, &mut is, ); // Side edges: diagonal chamfer strips connecting vertical face pairs. // Each strip spans from one face edge to the adjacent face edge — not flat in one plane. push_quad( [[F, -E, -F], [F, -E, F], [E, -F, F], [E, -F, -F]], C_EDGE, EDGE_FRONT_RIGHT, &mut vs, &mut is, ); push_quad( [[-F, -E, F], [-F, -E, -F], [-E, -F, -F], [-E, -F, F]], C_EDGE, EDGE_FRONT_LEFT, &mut vs, &mut is, ); push_quad( [[F, E, F], [F, E, -F], [E, F, -F], [E, F, F]], C_EDGE, EDGE_BACK_RIGHT, &mut vs, &mut is, ); push_quad( [[-F, E, F], [-F, E, -F], [-E, F, -F], [-E, F, F]], C_EDGE, EDGE_BACK_LEFT, &mut vs, &mut is, ); for &([sx, sy, sz], region) in &[ ([1.0f32, 1.0, 1.0], CORNER_TBK_R), // sy=+1 → BACK direction ([-1.0, 1.0, 1.0], CORNER_TBK_L), ([1.0, 1.0, -1.0], CORNER_BBK_R), ([-1.0, 1.0, -1.0], CORNER_BBK_L), ([1.0, -1.0, 1.0], CORNER_TPF_R), // sy=-1 → FRONT direction ([-1.0, -1.0, 1.0], CORNER_TPF_L), ([1.0, -1.0, -1.0], CORNER_BTF_R), ([-1.0, -1.0, -1.0], CORNER_BTF_L), ] { push_tri( [sx * F, sy * F, sz * E], [sx * F, sy * E, sz * F], [sx * E, sy * F, sz * F], C_CORNER, region, &mut vs, &mut is, ); } build_ring(&mut vs, &mut is); (vs, is) } /// A flat compass ring in the cube's local XY plane (the ground plane), /// surrounding the cube. Pushed with a sentinel `region_f = -1.0` so the /// shader never highlights it on hover, and a constant grey colour. fn build_ring(vs: &mut Vec, is: &mut Vec) { const SEG: usize = 64; const R0: f32 = 1.40; // inner radius — clear gap to the cube faces const R1: f32 = 1.74; // outer radius — wider, thicker band const RING_RGB: [f32; 3] = [0.22, 0.23, 0.26]; for s in 0..SEG { let a0 = s as f32 / SEG as f32 * std::f32::consts::TAU; let a1 = (s + 1) as f32 / SEG as f32 * std::f32::consts::TAU; let (c0, s0) = (a0.cos(), a0.sin()); let (c1, s1) = (a1.cos(), a1.sin()); let quad = [ [c0 * R0, s0 * R0, RING_Z], [c1 * R0, s1 * R0, RING_Z], [c1 * R1, s1 * R1, RING_Z], [c0 * R1, s0 * R1, RING_Z], ]; let base = vs.len() as u32; for pos in quad { vs.push(CubeVertex { pos, normal: [0.0, 0.0, 1.0], color: RING_RGB, region_f: -1.0, }); } is.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } } pub fn region_centroids() -> [[f32; 3]; NUM_REGIONS] { let m = (F + E) * 0.5; [ [0.0, 0.0, E], // FACE_TOP [0.0, 0.0, -E], // FACE_BOTTOM [0.0, -E, 0.0], // FACE_FRONT (geometry Y=-E) [0.0, E, 0.0], // FACE_BACK (geometry Y=+E) [E, 0.0, 0.0], // FACE_RIGHT [-E, 0.0, 0.0], // FACE_LEFT [0.0, -m, m], // EDGE_TOP_FRONT [0.0, m, m], // EDGE_TOP_BACK [m, 0.0, m], // EDGE_TOP_RIGHT [-m, 0.0, m], // EDGE_TOP_LEFT [0.0, -m, -m], // EDGE_BOT_FRONT [0.0, m, -m], // EDGE_BOT_BACK [m, 0.0, -m], // EDGE_BOT_RIGHT [-m, 0.0, -m], // EDGE_BOT_LEFT [m, -m, 0.0], // EDGE_FRONT_RIGHT [-m, -m, 0.0], // EDGE_FRONT_LEFT [m, m, 0.0], // EDGE_BACK_RIGHT [-m, m, 0.0], // EDGE_BACK_LEFT [m, -m, m], // CORNER_TPF_R (geometry sy=-1 = FRONT) [-m, -m, m], // CORNER_TPF_L [m, m, m], // CORNER_TBK_R (geometry sy=+1 = BACK) [-m, m, m], // CORNER_TBK_L [m, -m, -m], // CORNER_BTF_R (geometry sy=-1 = FRONT) [-m, -m, -m], // CORNER_BTF_L [m, m, -m], // CORNER_BBK_R (geometry sy=+1 = BACK) [-m, m, -m], // CORNER_BBK_L ] } fn threshold_sq(id: usize, cube_half_px: f32) -> f32 { let r = if id < 6 { cube_half_px * 0.92 } else if id < 18 { cube_half_px * 0.38 } else { cube_half_px * 0.28 }; r * r } // ── Pipeline ────────────────────────────────────────────────────────────── pub struct ViewCubePipeline { pipeline: wgpu::RenderPipeline, vertex_buffer: wgpu::Buffer, index_buffer: wgpu::Buffer, index_count: u32, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, depth_texture_size: Size, depth_view: wgpu::TextureView, pub cube_px: u32, text: ViewCubeText, } impl ViewCubePipeline { pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { use wgpu::util::DeviceExt; let (verts, idxs) = build_geometry(); let cube_px = VIEWCUBE_PX; let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("vc.vb"), contents: bytemuck::cast_slice(&verts), usage: wgpu::BufferUsages::VERTEX, }); let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("vc.ib"), contents: bytemuck::cast_slice(&idxs), usage: wgpu::BufferUsages::INDEX, }); let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("vc.ub"), size: std::mem::size_of::() as u64, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("vc.bgl"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }], }); let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("vc.bg"), layout: &bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform_buffer.as_entire_binding(), }], }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("vc.layout"), bind_group_layouts: &[&bgl], push_constant_ranges: &[], }); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("vc.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( "../../shaders/viewcube.wgsl" ))), }); let depth_tex = create_depth_texture(device, Size::new(1, 1)); let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default()); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("vc.pipe"), layout: Some(&layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), buffers: &[CubeVertex::desc()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: None, ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::Less, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: wgpu::PipelineCompilationOptions::default(), }), multiview: None, cache: None, }); let text = ViewCubeText::new(device, queue, format); Self { pipeline, vertex_buffer, index_buffer, index_count: idxs.len() as u32, uniform_buffer, uniform_bind_group, depth_texture_size: Size::new(1, 1), depth_view, cube_px, text, } } /// Upload using the quaternion rotation matrix. /// `cam_rotation` = `camera.view_rotation_mat()` = `Mat4::from_quat(camera.rotation)`. pub fn upload( &mut self, queue: &wgpu::Queue, cam_rotation: Mat4, compass_rotation: Mat4, vp_w: u32, vp_h: u32, hover: Option, ) { queue.write_buffer( &self.uniform_buffer, 0, bytemuck::bytes_of(&CubeUniforms::new( cam_rotation, self.cube_px, vp_w, vp_h, hover, )), ); self.text .update(queue, cam_rotation, compass_rotation, vp_w, vp_h, self.cube_px); } pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { if self.depth_texture_size != size { let tex = create_depth_texture(device, size); self.depth_view = tex.create_view(&wgpu::TextureViewDescriptor::default()); self.depth_texture_size = size; } } pub fn render( &self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, clip: Rectangle, ) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("vc.pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.depth_view, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), stencil_ops: None, }), timestamp_writes: None, occlusion_query_set: None, }); pass.set_viewport( clip.x as f32, clip.y as f32, clip.width as f32, clip.height as f32, 0.0, 1.0, ); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..self.index_count, 0, 0..1); drop(pass); self.text.render(encoder, target, clip); } } impl iced::widget::shader::Pipeline for ViewCubePipeline { fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { Self::new(device, queue, format) } } fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("vc.depth_texture"), size: wgpu::Extent3d { width: size.width.max(1), height: size.height.max(1), depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Depth24PlusStencil8, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }) } // ── Hit test ────────────────────────────────────────────────────────────── // // hit_test and hover_id now take cam_rotation: Mat4 (same matrix the shader // uses) so click regions always match what is drawn — including after free // arcball orbit from any angle. /// Returns the ViewCube region under screen position (mx, my), or None. /// `cam_rotation` must be `camera.view_rotation_mat()`. pub fn hit_test( mx: f32, my: f32, vp_w: f32, vp_h: f32, cam_rotation: Mat4, cube_px: u32, ) -> Option { let half = cube_px as f32 * VIEWCUBE_SCALE; let inset = half * NAV_INSET_F; let cx = vp_w - inset - VIEWCUBE_PAD; let cy = inset + VIEWCUBE_PAD; if (mx - cx).abs() > half || (my - cy).abs() > half { return None; } let (hw, hh) = (vp_w * 0.5, vp_h * 0.5); let vp = orthographic(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new( hw - inset - VIEWCUBE_PAD, hh - inset - VIEWCUBE_PAD, 0.0, )) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); let view_dir = Vec3::Z; let centroids = region_centroids(); let (mut best, mut best_d) = (None, f32::MAX); for (id, &c) in centroids.iter().enumerate() { let world = cam_rotation.transform_point3(Vec3::from(c)); if world.normalize().dot(view_dir) < 0.05 { continue; } let clip = vp * Vec4::new(world.x, world.y, world.z, 1.0); if clip.w.abs() < 1e-6 { continue; } let sx = (clip.x / clip.w + 1.0) * 0.5 * vp_w; let sy = (1.0 - clip.y / clip.w) * 0.5 * vp_h; let d = (sx - mx).powi(2) + (sy - my).powi(2); if d < threshold_sq(id, half) && d < best_d { best_d = d; best = Some(if id < 6 { CubeRegion::Face(id) } else if id < 18 { CubeRegion::Edge(id) } else { CubeRegion::Corner(id) }); } } best } /// Returns the hovered region id (0-25), or None. pub fn hover_id( mx: f32, my: f32, vp_w: f32, vp_h: f32, cam_rotation: Mat4, cube_px: u32, ) -> Option { hit_test(mx, my, vp_w, vp_h, cam_rotation, cube_px).map(|r| r.id()) } impl Cardinal { /// The side-elevation face a compass letter snaps to when clicked. pub fn face_region(self) -> CubeRegion { CubeRegion::Face(match self { Cardinal::North => FACE_BACK, Cardinal::South => FACE_FRONT, Cardinal::East => FACE_RIGHT, Cardinal::West => FACE_LEFT, }) } } /// Returns the compass cardinal under (mx, my), or None. Projects each of the /// four ring letters and accepts the nearest within a small pixel radius — the /// caller tries the cube body first, so this only fires out on the ring. pub fn hit_test_cardinal( mx: f32, my: f32, vp_w: f32, vp_h: f32, cam_rotation: Mat4, cube_px: u32, ) -> Option { let half = cube_px as f32 * VIEWCUBE_SCALE; let inset = half * NAV_INSET_F; let cx = vp_w - inset - VIEWCUBE_PAD; let cy = inset + VIEWCUBE_PAD; if (mx - cx).abs() > inset || (my - cy).abs() > inset { return None; } let (hw, hh) = (vp_w * 0.5, vp_h * 0.5); let vp = orthographic(-hw, hw, -hh, hh, -2000.0, 2000.0) * Mat4::from_translation(Vec3::new( hw - inset - VIEWCUBE_PAD, hh - inset - VIEWCUBE_PAD, 0.0, )) * Mat4::from_scale(Vec3::splat(cube_px as f32 * VIEWCUBE_SCALE)); let dirs = [ (Cardinal::North, Vec3::new(0.0, 1.0, 0.0)), (Cardinal::East, Vec3::new(1.0, 0.0, 0.0)), (Cardinal::South, Vec3::new(0.0, -1.0, 0.0)), (Cardinal::West, Vec3::new(-1.0, 0.0, 0.0)), ]; let thresh = (half * 0.34).powi(2); let (mut best, mut best_d) = (None, f32::MAX); for (card, dir) in dirs { let anchor = Vec3::new(dir.x * R_CARD, dir.y * R_CARD, RING_Z); let world = cam_rotation.transform_point3(anchor); let clip = vp * Vec4::new(world.x, world.y, world.z, 1.0); if clip.w.abs() < 1e-6 { continue; } let sx = (clip.x / clip.w + 1.0) * 0.5 * vp_w; let sy = (1.0 - clip.y / clip.w) * 0.5 * vp_h; let d = (sx - mx).powi(2) + (sy - my).powi(2); if d < thresh && d < best_d { best_d = d; best = Some(card); } } best }