Skip to main content

viewport_lib/widgets/
axes_indicator.rs

1//! Axes orientation indicator — a small XYZ gizmo rendered in the bottom-left
2//! corner of the viewport as screen-space GPU geometry.
3//!
4//! Provides:
5//! - `build_axes_geometry`: generates triangle-list vertices for axis lines,
6//!   circles, and letter glyphs, all in NDC coordinates.
7//! - `hit_test`: given a click position in pixels, returns the target
8//!   (yaw, pitch) if an axis circle was hit.
9
10use std::f32::consts::{FRAC_PI_2, PI};
11
12// ---------------------------------------------------------------------------
13// Vertex type (matches axes_overlay.wgsl)
14// ---------------------------------------------------------------------------
15
16/// A 2D vertex for the axes indicator overlay (position in NDC + RGBA color).
17///
18/// Matches the layout expected by `axes_overlay.wgsl` at shader locations 0 and 1.
19#[repr(C)]
20#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
21pub(crate) struct AxesVertex {
22    /// 2D position in Normalized Device Coordinates (X right, Y up, range -1..1).
23    pub(crate) position: [f32; 2],
24    /// RGBA color in linear 0..1.
25    pub(crate) color: [f32; 4],
26}
27
28impl AxesVertex {
29    /// wgpu vertex buffer layout matching shader locations 0 (position) and 1 (color).
30    pub(crate) fn buffer_layout() -> wgpu::VertexBufferLayout<'static> {
31        wgpu::VertexBufferLayout {
32            array_stride: std::mem::size_of::<AxesVertex>() as wgpu::BufferAddress,
33            step_mode: wgpu::VertexStepMode::Vertex,
34            attributes: &[
35                wgpu::VertexAttribute {
36                    offset: 0,
37                    shader_location: 0,
38                    format: wgpu::VertexFormat::Float32x2,
39                },
40                wgpu::VertexAttribute {
41                    offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress,
42                    shader_location: 1,
43                    format: wgpu::VertexFormat::Float32x4,
44                },
45            ],
46        }
47    }
48}
49
50// ---------------------------------------------------------------------------
51// Axis view targets
52// ---------------------------------------------------------------------------
53
54/// Result of a hit test on the axes indicator.
55#[derive(Debug, Clone, Copy)]
56pub struct AxisView {
57    /// Target camera orientation for the clicked axis view.
58    pub orientation: glam::Quat,
59    /// Which axis was hit: 0 = X, 1 = Y, 2 = Z.
60    pub axis_index: usize,
61}
62
63// Colors (same as the original egui version).
64const X_COLOR: [f32; 4] = [0.878, 0.322, 0.322, 1.0]; // #e05252
65const Y_COLOR: [f32; 4] = [0.361, 0.722, 0.361, 1.0]; // #5cb85c
66const Z_COLOR: [f32; 4] = [0.290, 0.620, 1.000, 1.0]; // #4a9eff
67
68// Layout parameters (pixels, converted to NDC during generation).
69const ORIGIN_OFFSET: f32 = 40.0;
70const LINE_LENGTH: f32 = 30.0;
71const LINE_HALF_WIDTH: f32 = 1.0;
72const CIRCLE_RADIUS: f32 = 9.0;
73const CIRCLE_SEGMENTS: usize = 24;
74
75// ---------------------------------------------------------------------------
76// Geometry generation
77// ---------------------------------------------------------------------------
78
79/// Build all axes indicator triangles in NDC coordinates.
80///
81/// `viewport_size`: (width, height) in physical pixels.
82/// `orientation`: current camera orientation quaternion.
83///
84/// Returns a `Vec<AxesVertex>` for a TriangleList draw call (no index buffer).
85pub(crate) fn build_axes_geometry(
86    viewport_w: f32,
87    viewport_h: f32,
88    orientation: glam::Quat,
89) -> Vec<AxesVertex> {
90    let mut verts = Vec::with_capacity(1024);
91
92    // Pixel -> NDC conversion helpers.
93    let px_to_ndc_x = |px: f32| -> f32 { px * 2.0 / viewport_w };
94    let px_to_ndc_y = |py: f32| -> f32 { py * 2.0 / viewport_h };
95
96    // Origin in NDC (bottom-left corner).
97    let ox = -1.0 + px_to_ndc_x(ORIGIN_OFFSET);
98    let oy = -1.0 + px_to_ndc_y(ORIGIN_OFFSET);
99
100    // Derive view axes from orientation quaternion.
101    let view_right = orientation * glam::Vec3::X;
102    let view_up = orientation * glam::Vec3::Y;
103    let view_fwd = orientation * glam::Vec3::Z; // from center toward eye
104
105    let project = |world_axis: glam::Vec3| -> (f32, f32) {
106        let sx = world_axis.dot(view_right);
107        let sy = world_axis.dot(view_up); // NDC Y is up, no flip needed
108        (sx * px_to_ndc_x(LINE_LENGTH), sy * px_to_ndc_y(LINE_LENGTH))
109    };
110
111    let axes_world = [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z];
112    let colors = [X_COLOR, Y_COLOR, Z_COLOR];
113    let offsets: [(f32, f32); 3] = [
114        project(glam::Vec3::X),
115        project(glam::Vec3::Y),
116        project(glam::Vec3::Z),
117    ];
118
119    // Sort back-to-front by depth (view_fwd dot axis).
120    let mut order: [usize; 3] = [0, 1, 2];
121    order.sort_by(|&a, &b| {
122        let da = axes_world[a].dot(view_fwd);
123        let db = axes_world[b].dot(view_fwd);
124        da.partial_cmp(&db).unwrap()
125    });
126
127    for &i in &order {
128        let (dx, dy) = offsets[i];
129        let color = colors[i];
130        let tip_x = ox + dx;
131        let tip_y = oy + dy;
132
133        // --- Axis line (thin quad) ---
134        let lw_x = px_to_ndc_x(LINE_HALF_WIDTH);
135        let lw_y = px_to_ndc_y(LINE_HALF_WIDTH);
136        // Perpendicular direction to the line.
137        let len = (dx * dx + dy * dy).sqrt().max(0.001);
138        let perp_x = -dy / len;
139        let perp_y = dx / len;
140        let px_ = perp_x * lw_x;
141        let py_ = perp_y * lw_y;
142
143        push_quad(
144            &mut verts,
145            [ox + px_, oy + py_],
146            [ox - px_, oy - py_],
147            [tip_x - px_, tip_y - py_],
148            [tip_x + px_, tip_y + py_],
149            color,
150        );
151
152        // --- Circle background (filled) ---
153        let bg_color = [color[0] * 0.33, color[1] * 0.33, color[2] * 0.33, 0.7];
154        let cr_x = px_to_ndc_x(CIRCLE_RADIUS);
155        let cr_y = px_to_ndc_y(CIRCLE_RADIUS);
156        push_circle_filled(&mut verts, tip_x, tip_y, cr_x, cr_y, bg_color);
157
158        // --- Circle outline (ring) ---
159        let ring_inner = 0.82; // inner radius as fraction of outer
160        push_circle_ring(&mut verts, tip_x, tip_y, cr_x, cr_y, ring_inner, color);
161
162        // --- Letter glyph ---
163        let glyph_hw = px_to_ndc_x(4.5); // half-width of letter
164        let glyph_hh = px_to_ndc_y(4.5); // half-height of letter
165        let glw_x = px_to_ndc_x(0.8); // glyph line half-width
166        let glw_y = px_to_ndc_y(0.8);
167        match i {
168            0 => push_letter_x(
169                &mut verts, tip_x, tip_y, glyph_hw, glyph_hh, glw_x, glw_y, color,
170            ),
171            1 => push_letter_y(
172                &mut verts, tip_x, tip_y, glyph_hw, glyph_hh, glw_x, glw_y, color,
173            ),
174            2 => push_letter_z(
175                &mut verts, tip_x, tip_y, glyph_hw, glyph_hh, glw_x, glw_y, color,
176            ),
177            _ => {}
178        }
179    }
180
181    verts
182}
183
184// ---------------------------------------------------------------------------
185// Hit testing
186// ---------------------------------------------------------------------------
187
188/// Test if a click at `screen_pos` (pixels, origin top-left) hits an axis circle.
189/// Returns the target camera orientation if hit.
190///
191/// `viewport_rect`: (x, y, width, height) in pixels (the viewport panel rect).
192pub fn hit_test(
193    screen_pos: [f32; 2],
194    viewport_rect: [f32; 4],
195    orientation: glam::Quat,
196) -> Option<AxisView> {
197    let vp_x = viewport_rect[0];
198    let vp_y = viewport_rect[1];
199    let vp_h = viewport_rect[3];
200
201    // Click position relative to viewport, Y increasing upward.
202    let rel_x = screen_pos[0] - vp_x;
203    let rel_y = vp_h - (screen_pos[1] - vp_y); // flip Y
204
205    // Origin in pixels (bottom-left).
206    let ox = ORIGIN_OFFSET;
207    let oy = ORIGIN_OFFSET;
208
209    // Derive view axes from orientation quaternion.
210    let view_right = orientation * glam::Vec3::X;
211    let view_up = orientation * glam::Vec3::Y;
212    let view_fwd = orientation * glam::Vec3::Z; // from center toward eye
213
214    let project = |world_axis: glam::Vec3| -> (f32, f32) {
215        let sx = world_axis.dot(view_right);
216        let sy = world_axis.dot(view_up);
217        (ox + sx * LINE_LENGTH, oy + sy * LINE_LENGTH)
218    };
219
220    let axes = [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z];
221    // Snap targets: eye lands on each world axis respectively.
222    // from_rotation_y(π/2) * Z = X  ->  look along X from the +X side.
223    // from_rotation_x(π/2) * Z = Y  ->  look down from the +Y side.
224    // identity * Z = Z              ->  look along Z from the +Z side.
225    let targets = [
226        AxisView {
227            orientation: glam::Quat::from_rotation_y(FRAC_PI_2),
228            axis_index: 0,
229        }, // X
230        AxisView {
231            orientation: glam::Quat::from_rotation_x(FRAC_PI_2),
232            axis_index: 1,
233        }, // Y (top-down)
234        AxisView {
235            orientation: glam::Quat::IDENTITY,
236            axis_index: 2,
237        }, // Z
238    ];
239
240    // Check front-to-back (reverse depth order) so frontmost wins.
241    let mut order: [usize; 3] = [0, 1, 2];
242    order.sort_by(|&a, &b| {
243        let da = axes[a].dot(view_fwd);
244        let db = axes[b].dot(view_fwd);
245        db.partial_cmp(&da).unwrap() // front first
246    });
247
248    for &i in &order {
249        let (tx, ty) = project(axes[i]);
250        let dx = rel_x - tx;
251        let dy = rel_y - ty;
252        if dx * dx + dy * dy <= CIRCLE_RADIUS * CIRCLE_RADIUS {
253            return Some(targets[i]);
254        }
255    }
256
257    None
258}
259
260// ---------------------------------------------------------------------------
261// Geometry helpers
262// ---------------------------------------------------------------------------
263
264fn push_quad(
265    verts: &mut Vec<AxesVertex>,
266    a: [f32; 2],
267    b: [f32; 2],
268    c: [f32; 2],
269    d: [f32; 2],
270    color: [f32; 4],
271) {
272    // Two triangles: ABC, ACD
273    for &pos in &[a, b, c, a, c, d] {
274        verts.push(AxesVertex {
275            position: pos,
276            color,
277        });
278    }
279}
280
281fn push_circle_filled(
282    verts: &mut Vec<AxesVertex>,
283    cx: f32,
284    cy: f32,
285    rx: f32,
286    ry: f32,
287    color: [f32; 4],
288) {
289    let step = 2.0 * PI / CIRCLE_SEGMENTS as f32;
290    for i in 0..CIRCLE_SEGMENTS {
291        let a0 = step * i as f32;
292        let a1 = step * (i + 1) as f32;
293        verts.push(AxesVertex {
294            position: [cx, cy],
295            color,
296        });
297        verts.push(AxesVertex {
298            position: [cx + rx * a0.cos(), cy + ry * a0.sin()],
299            color,
300        });
301        verts.push(AxesVertex {
302            position: [cx + rx * a1.cos(), cy + ry * a1.sin()],
303            color,
304        });
305    }
306}
307
308fn push_circle_ring(
309    verts: &mut Vec<AxesVertex>,
310    cx: f32,
311    cy: f32,
312    rx: f32,
313    ry: f32,
314    inner_frac: f32,
315    color: [f32; 4],
316) {
317    let step = 2.0 * PI / CIRCLE_SEGMENTS as f32;
318    let irx = rx * inner_frac;
319    let iry = ry * inner_frac;
320    for i in 0..CIRCLE_SEGMENTS {
321        let a0 = step * i as f32;
322        let a1 = step * (i + 1) as f32;
323        let (c0, s0) = (a0.cos(), a0.sin());
324        let (c1, s1) = (a1.cos(), a1.sin());
325        let o0 = [cx + rx * c0, cy + ry * s0];
326        let o1 = [cx + rx * c1, cy + ry * s1];
327        let i0 = [cx + irx * c0, cy + iry * s0];
328        let i1 = [cx + irx * c1, cy + iry * s1];
329        // Two triangles per ring segment.
330        for &pos in &[o0, i0, o1, o1, i0, i1] {
331            verts.push(AxesVertex {
332                position: pos,
333                color,
334            });
335        }
336    }
337}
338
339/// Draw letter "X" as two crossing diagonal strokes.
340fn push_letter_x(
341    verts: &mut Vec<AxesVertex>,
342    cx: f32,
343    cy: f32,
344    hw: f32,
345    hh: f32,
346    lw_x: f32,
347    lw_y: f32,
348    color: [f32; 4],
349) {
350    // Diagonal \: top-left to bottom-right
351    push_line_segment(verts, cx - hw, cy + hh, cx + hw, cy - hh, lw_x, lw_y, color);
352    // Diagonal /: bottom-left to top-right
353    push_line_segment(verts, cx - hw, cy - hh, cx + hw, cy + hh, lw_x, lw_y, color);
354}
355
356/// Draw letter "Y": two strokes from top meeting at center, one vertical down.
357fn push_letter_y(
358    verts: &mut Vec<AxesVertex>,
359    cx: f32,
360    cy: f32,
361    hw: f32,
362    hh: f32,
363    lw_x: f32,
364    lw_y: f32,
365    color: [f32; 4],
366) {
367    // Top-left to center.
368    push_line_segment(verts, cx - hw, cy + hh, cx, cy, lw_x, lw_y, color);
369    // Top-right to center.
370    push_line_segment(verts, cx + hw, cy + hh, cx, cy, lw_x, lw_y, color);
371    // Center to bottom.
372    push_line_segment(verts, cx, cy, cx, cy - hh, lw_x, lw_y, color);
373}
374
375/// Draw letter "Z": top horizontal, diagonal, bottom horizontal.
376fn push_letter_z(
377    verts: &mut Vec<AxesVertex>,
378    cx: f32,
379    cy: f32,
380    hw: f32,
381    hh: f32,
382    lw_x: f32,
383    lw_y: f32,
384    color: [f32; 4],
385) {
386    // Top horizontal.
387    push_line_segment(verts, cx - hw, cy + hh, cx + hw, cy + hh, lw_x, lw_y, color);
388    // Diagonal: top-right to bottom-left.
389    push_line_segment(verts, cx + hw, cy + hh, cx - hw, cy - hh, lw_x, lw_y, color);
390    // Bottom horizontal.
391    push_line_segment(verts, cx - hw, cy - hh, cx + hw, cy - hh, lw_x, lw_y, color);
392}
393
394/// Push a line segment as a thin quad (2 triangles, 6 vertices).
395fn push_line_segment(
396    verts: &mut Vec<AxesVertex>,
397    x0: f32,
398    y0: f32,
399    x1: f32,
400    y1: f32,
401    lw_x: f32,
402    lw_y: f32,
403    color: [f32; 4],
404) {
405    let dx = x1 - x0;
406    let dy = y1 - y0;
407    let len = (dx * dx + dy * dy).sqrt().max(0.0001);
408    // Perpendicular in NDC (accounts for aspect via separate lw_x/lw_y).
409    let px = -(dy / len) * lw_x;
410    let py = (dx / len) * lw_y;
411
412    push_quad(
413        verts,
414        [x0 + px, y0 + py],
415        [x0 - px, y0 - py],
416        [x1 - px, y1 - py],
417        [x1 + px, y1 + py],
418        color,
419    );
420}