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::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 (Z-up convention).
222    // X click -> Right view: eye at +X, up = Z.
223    // Y click -> Front view: eye at +Y, up = Z.
224    // Z click -> Top view:   eye at +Z, up = Y (identity).
225    let frac_1_sqrt_2 = std::f32::consts::FRAC_1_SQRT_2;
226    let front = glam::Quat::from_xyzw(0.0, frac_1_sqrt_2, frac_1_sqrt_2, 0.0);
227    let targets = [
228        AxisView {
229            orientation: glam::Quat::from_rotation_z(-std::f32::consts::FRAC_PI_2) * front,
230            axis_index: 0,
231        }, // X -> Right view
232        AxisView {
233            orientation: front,
234            axis_index: 1,
235        }, // Y -> Front view
236        AxisView {
237            orientation: glam::Quat::IDENTITY,
238            axis_index: 2,
239        }, // Z -> Top view
240    ];
241
242    // Check front-to-back (reverse depth order) so frontmost wins.
243    let mut order: [usize; 3] = [0, 1, 2];
244    order.sort_by(|&a, &b| {
245        let da = axes[a].dot(view_fwd);
246        let db = axes[b].dot(view_fwd);
247        db.partial_cmp(&da).unwrap() // front first
248    });
249
250    for &i in &order {
251        let (tx, ty) = project(axes[i]);
252        let dx = rel_x - tx;
253        let dy = rel_y - ty;
254        if dx * dx + dy * dy <= CIRCLE_RADIUS * CIRCLE_RADIUS {
255            return Some(targets[i]);
256        }
257    }
258
259    None
260}
261
262// ---------------------------------------------------------------------------
263// Geometry helpers
264// ---------------------------------------------------------------------------
265
266fn push_quad(
267    verts: &mut Vec<AxesVertex>,
268    a: [f32; 2],
269    b: [f32; 2],
270    c: [f32; 2],
271    d: [f32; 2],
272    color: [f32; 4],
273) {
274    // Two triangles: ABC, ACD
275    for &pos in &[a, b, c, a, c, d] {
276        verts.push(AxesVertex {
277            position: pos,
278            color,
279        });
280    }
281}
282
283fn push_circle_filled(
284    verts: &mut Vec<AxesVertex>,
285    cx: f32,
286    cy: f32,
287    rx: f32,
288    ry: f32,
289    color: [f32; 4],
290) {
291    let step = 2.0 * PI / CIRCLE_SEGMENTS as f32;
292    for i in 0..CIRCLE_SEGMENTS {
293        let a0 = step * i as f32;
294        let a1 = step * (i + 1) as f32;
295        verts.push(AxesVertex {
296            position: [cx, cy],
297            color,
298        });
299        verts.push(AxesVertex {
300            position: [cx + rx * a0.cos(), cy + ry * a0.sin()],
301            color,
302        });
303        verts.push(AxesVertex {
304            position: [cx + rx * a1.cos(), cy + ry * a1.sin()],
305            color,
306        });
307    }
308}
309
310fn push_circle_ring(
311    verts: &mut Vec<AxesVertex>,
312    cx: f32,
313    cy: f32,
314    rx: f32,
315    ry: f32,
316    inner_frac: f32,
317    color: [f32; 4],
318) {
319    let step = 2.0 * PI / CIRCLE_SEGMENTS as f32;
320    let irx = rx * inner_frac;
321    let iry = ry * inner_frac;
322    for i in 0..CIRCLE_SEGMENTS {
323        let a0 = step * i as f32;
324        let a1 = step * (i + 1) as f32;
325        let (c0, s0) = (a0.cos(), a0.sin());
326        let (c1, s1) = (a1.cos(), a1.sin());
327        let o0 = [cx + rx * c0, cy + ry * s0];
328        let o1 = [cx + rx * c1, cy + ry * s1];
329        let i0 = [cx + irx * c0, cy + iry * s0];
330        let i1 = [cx + irx * c1, cy + iry * s1];
331        // Two triangles per ring segment.
332        for &pos in &[o0, i0, o1, o1, i0, i1] {
333            verts.push(AxesVertex {
334                position: pos,
335                color,
336            });
337        }
338    }
339}
340
341/// Draw letter "X" as two crossing diagonal strokes.
342fn push_letter_x(
343    verts: &mut Vec<AxesVertex>,
344    cx: f32,
345    cy: f32,
346    hw: f32,
347    hh: f32,
348    lw_x: f32,
349    lw_y: f32,
350    color: [f32; 4],
351) {
352    // Diagonal \: top-left to bottom-right
353    push_line_segment(verts, cx - hw, cy + hh, cx + hw, cy - hh, lw_x, lw_y, color);
354    // Diagonal /: bottom-left to top-right
355    push_line_segment(verts, cx - hw, cy - hh, cx + hw, cy + hh, lw_x, lw_y, color);
356}
357
358/// Draw letter "Y": two strokes from top meeting at center, one vertical down.
359fn push_letter_y(
360    verts: &mut Vec<AxesVertex>,
361    cx: f32,
362    cy: f32,
363    hw: f32,
364    hh: f32,
365    lw_x: f32,
366    lw_y: f32,
367    color: [f32; 4],
368) {
369    // Top-left to center.
370    push_line_segment(verts, cx - hw, cy + hh, cx, cy, lw_x, lw_y, color);
371    // Top-right to center.
372    push_line_segment(verts, cx + hw, cy + hh, cx, cy, lw_x, lw_y, color);
373    // Center to bottom.
374    push_line_segment(verts, cx, cy, cx, cy - hh, lw_x, lw_y, color);
375}
376
377/// Draw letter "Z": top horizontal, diagonal, bottom horizontal.
378fn push_letter_z(
379    verts: &mut Vec<AxesVertex>,
380    cx: f32,
381    cy: f32,
382    hw: f32,
383    hh: f32,
384    lw_x: f32,
385    lw_y: f32,
386    color: [f32; 4],
387) {
388    // Top horizontal.
389    push_line_segment(verts, cx - hw, cy + hh, cx + hw, cy + hh, lw_x, lw_y, color);
390    // Diagonal: top-right to bottom-left.
391    push_line_segment(verts, cx + hw, cy + hh, cx - hw, cy - hh, lw_x, lw_y, color);
392    // Bottom horizontal.
393    push_line_segment(verts, cx - hw, cy - hh, cx + hw, cy - hh, lw_x, lw_y, color);
394}
395
396/// Push a line segment as a thin quad (2 triangles, 6 vertices).
397fn push_line_segment(
398    verts: &mut Vec<AxesVertex>,
399    x0: f32,
400    y0: f32,
401    x1: f32,
402    y1: f32,
403    lw_x: f32,
404    lw_y: f32,
405    color: [f32; 4],
406) {
407    let dx = x1 - x0;
408    let dy = y1 - y0;
409    let len = (dx * dx + dy * dy).sqrt().max(0.0001);
410    // Perpendicular in NDC (accounts for aspect via separate lw_x/lw_y).
411    let px = -(dy / len) * lw_x;
412    let py = (dx / len) * lw_y;
413
414    push_quad(
415        verts,
416        [x0 + px, y0 + py],
417        [x0 - px, y0 - py],
418        [x1 - px, y1 - py],
419        [x1 + px, y1 + py],
420        color,
421    );
422}