Skip to main content

khora_core/ui/editor/
gizmo.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Editor gizmo geometry generation.
16//!
17//! Pure functions that generate wireframe line segments for rendering
18//! in the 3D viewport. All math goes through `khora_core::math`.
19
20use super::gizmo_interact::{perpendiculars, GizmoAxis, GizmoBasis};
21use super::state::GizmoMode;
22use crate::math::{Mat4, Vec3};
23use crate::renderer::api::resource::view::ViewInfo;
24
25/// Fraction of the viewport's half-height a manipulator arm spans.
26///
27/// The manipulator is sized in world units but wants a constant *apparent*
28/// size: a fixed world length — what this used to be — swallows the screen when
29/// you zoom in and shrinks below the cursor a few metres out, which is exactly
30/// when you still need to grab it.
31const GIZMO_SCREEN_FRACTION: f32 = 0.32;
32
33/// Segments per rotation ring. Enough that a ring reads as round rather than
34/// as a polygon, at three rings per selected entity.
35const RING_SEGMENTS: u32 = 48;
36
37/// A single line segment for GPU rendering.
38///
39/// `#[repr(C)]` layout matches the WGSL `GizmoLine` struct.
40#[repr(C)]
41#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
42pub struct GizmoLineInstance {
43    /// Start point in world space.
44    pub start: [f32; 4],
45    /// End point in world space.
46    pub end: [f32; 4],
47    /// RGBA color.
48    pub color: [f32; 4],
49}
50
51impl GizmoLineInstance {
52    /// Creates a new line segment.
53    pub fn new(start: Vec3, end: Vec3, color: [f32; 4]) -> Self {
54        Self {
55            start: [start.x, start.y, start.z, 1.0],
56            end: [end.x, end.y, end.z, 1.0],
57            color,
58        }
59    }
60}
61
62/// Generates a wireframe cube gizmo at the given world-space transform.
63pub fn wireframe_cube(
64    transform: &Mat4,
65    half_extents: Vec3,
66    color: [f32; 4],
67) -> Vec<GizmoLineInstance> {
68    let corners = [
69        Vec3::new(-half_extents.x, -half_extents.y, -half_extents.z),
70        Vec3::new(half_extents.x, -half_extents.y, -half_extents.z),
71        Vec3::new(half_extents.x, half_extents.y, -half_extents.z),
72        Vec3::new(-half_extents.x, half_extents.y, -half_extents.z),
73        Vec3::new(-half_extents.x, -half_extents.y, half_extents.z),
74        Vec3::new(half_extents.x, -half_extents.y, half_extents.z),
75        Vec3::new(half_extents.x, half_extents.y, half_extents.z),
76        Vec3::new(-half_extents.x, half_extents.y, half_extents.z),
77    ];
78
79    let edges = [
80        (0, 1),
81        (1, 2),
82        (2, 3),
83        (3, 0),
84        (4, 5),
85        (5, 6),
86        (6, 7),
87        (7, 4),
88        (0, 4),
89        (1, 5),
90        (2, 6),
91        (3, 7),
92    ];
93
94    edges
95        .iter()
96        .map(|&(a, b)| {
97            let start = transform.transform_point(corners[a]);
98            let end = transform.transform_point(corners[b]);
99            GizmoLineInstance::new(start, end, color)
100        })
101        .collect()
102}
103
104/// Generates XYZ axis lines at the given transform.
105///
106/// Directions are normalised, so a scaled entity gets a square set of axes
107/// rather than one stretched along whichever axis it happens to be scaled on.
108pub fn transform_axes(transform: &Mat4, length: f32) -> Vec<GizmoLineInstance> {
109    let origin = transform.cols[3].truncate();
110
111    [Vec3::X, Vec3::Y, Vec3::Z]
112        .into_iter()
113        .zip(GizmoAxis::ALL)
114        .map(|(local, axis)| {
115            let direction = transform.transform_vector(local).normalize();
116            GizmoLineInstance::new(origin, origin + direction * length, axis.color())
117        })
118        .collect()
119}
120
121/// World size a manipulator at `origin` needs to occupy a constant fraction of
122/// the viewport, whatever its distance from the camera.
123///
124/// Derived from the projection matrix rather than from a field of view passed
125/// alongside it, so the gizmo cannot drift out of agreement with what is
126/// actually being rendered.
127pub fn gizmo_world_size(view: &ViewInfo, origin: Vec3) -> f32 {
128    // A perspective projection stores `1 / tan(fov_y / 2)` in m11.
129    let focal = view.projection_matrix.cols[1].y;
130    if focal.abs() < 1e-6 {
131        return 1.0;
132    }
133    let distance = (origin - view.camera_position).length().max(1e-3);
134    distance / focal * GIZMO_SCREEN_FRACTION
135}
136
137/// The interactive handles for `mode`, drawn at `pivot` along `basis`.
138///
139/// Empty for [`GizmoMode::Select`]: the pointer tool manipulates nothing, so
140/// drawing grabbable-looking handles for it would be a lie.
141pub fn manipulator(
142    pivot: Vec3,
143    basis: &GizmoBasis,
144    mode: GizmoMode,
145    size: f32,
146) -> Vec<GizmoLineInstance> {
147    match mode {
148        GizmoMode::Select => Vec::new(),
149        GizmoMode::Move => move_handles(pivot, basis, size),
150        GizmoMode::Rotate => rotate_handles(pivot, basis, size),
151        GizmoMode::Scale => scale_handles(pivot, basis, size),
152    }
153}
154
155/// Three arrows: a shaft out to `size` and a four-barbed head at the tip.
156fn move_handles(pivot: Vec3, basis: &GizmoBasis, size: f32) -> Vec<GizmoLineInstance> {
157    let head = size * 0.18;
158    let mut lines = Vec::with_capacity(GizmoAxis::ALL.len() * 5);
159
160    for axis in GizmoAxis::ALL {
161        let direction = basis[axis.index()];
162        let color = axis.color();
163        let tip = pivot + direction * size;
164        let base = tip - direction * head;
165        let (u, v) = perpendiculars(direction);
166
167        lines.push(GizmoLineInstance::new(pivot, tip, color));
168        for barb in [u, -u, v, -v] {
169            lines.push(GizmoLineInstance::new(
170                tip,
171                base + barb * (head * 0.4),
172                color,
173            ));
174        }
175    }
176    lines
177}
178
179/// Three arms, each capped with a small box — the cap is what says "scale"
180/// rather than "move" at a glance.
181fn scale_handles(pivot: Vec3, basis: &GizmoBasis, size: f32) -> Vec<GizmoLineInstance> {
182    let half = size * 0.09;
183    let mut lines = Vec::with_capacity(GizmoAxis::ALL.len() * 13);
184
185    for axis in GizmoAxis::ALL {
186        let direction = basis[axis.index()];
187        let color = axis.color();
188        let tip = pivot + direction * size;
189        let (u, v) = perpendiculars(direction);
190
191        lines.push(GizmoLineInstance::new(pivot, tip, color));
192
193        // Eight corners of the cap, indexed so the low bit walks `direction`,
194        // the middle bit `u`, and the high bit `v`.
195        let corner = |i: usize| {
196            let sign = |bit: usize| if i & (1 << bit) == 0 { -1.0 } else { 1.0 };
197            tip + direction * (half * sign(0)) + u * (half * sign(1)) + v * (half * sign(2))
198        };
199        // Every pair of corners differing by exactly one bit is an edge.
200        for i in 0..8usize {
201            for bit in 0..3 {
202                let j = i | (1 << bit);
203                if j != i {
204                    lines.push(GizmoLineInstance::new(corner(i), corner(j), color));
205                }
206            }
207        }
208    }
209    lines
210}
211
212/// Three rings, one in each plane normal to a handle.
213fn rotate_handles(pivot: Vec3, basis: &GizmoBasis, size: f32) -> Vec<GizmoLineInstance> {
214    let mut lines = Vec::with_capacity(GizmoAxis::ALL.len() * RING_SEGMENTS as usize);
215
216    for axis in GizmoAxis::ALL {
217        let direction = basis[axis.index()];
218        let color = axis.color();
219        let (u, v) = perpendiculars(direction);
220
221        for segment in 0..RING_SEGMENTS {
222            let theta = |s: u32| std::f32::consts::TAU * (s as f32 / RING_SEGMENTS as f32);
223            let point = |s: u32| {
224                let t = theta(s);
225                pivot + u * (size * t.cos()) + v * (size * t.sin())
226            };
227            lines.push(GizmoLineInstance::new(
228                point(segment),
229                point(segment + 1),
230                color,
231            ));
232        }
233    }
234    lines
235}
236
237/// Generates a wireframe camera frustum at the given transform.
238pub fn camera_frustum(
239    transform: &Mat4,
240    fov_y: f32,
241    aspect: f32,
242    near: f32,
243    far: f32,
244    color: [f32; 4],
245) -> Vec<GizmoLineInstance> {
246    let half_h_near = (fov_y * 0.5).tan() * near;
247    let half_w_near = half_h_near * aspect;
248    let half_h_far = (fov_y * 0.5).tan() * far;
249    let half_w_far = half_h_far * aspect;
250
251    let near_corners = [
252        Vec3::new(-half_w_near, -half_h_near, -near),
253        Vec3::new(half_w_near, -half_h_near, -near),
254        Vec3::new(half_w_near, half_h_near, -near),
255        Vec3::new(-half_w_near, half_h_near, -near),
256    ];
257    let far_corners = [
258        Vec3::new(-half_w_far, -half_h_far, -far),
259        Vec3::new(half_w_far, -half_h_far, -far),
260        Vec3::new(half_w_far, half_h_far, -far),
261        Vec3::new(-half_w_far, half_h_far, -far),
262    ];
263
264    let mut lines = Vec::with_capacity(16);
265
266    for i in 0..4 {
267        let j = (i + 1) % 4;
268        lines.push(GizmoLineInstance::new(
269            transform.transform_point(near_corners[i]),
270            transform.transform_point(near_corners[j]),
271            color,
272        ));
273    }
274
275    for i in 0..4 {
276        let j = (i + 1) % 4;
277        lines.push(GizmoLineInstance::new(
278            transform.transform_point(far_corners[i]),
279            transform.transform_point(far_corners[j]),
280            color,
281        ));
282    }
283
284    for i in 0..4 {
285        lines.push(GizmoLineInstance::new(
286            transform.transform_point(near_corners[i]),
287            transform.transform_point(far_corners[i]),
288            color,
289        ));
290    }
291
292    lines
293}
294
295/// Generates a wireframe sphere for point lights.
296pub fn wireframe_sphere(
297    transform: &Mat4,
298    radius: f32,
299    segments: u32,
300    color: [f32; 4],
301) -> Vec<GizmoLineInstance> {
302    let mut lines = Vec::new();
303
304    for ring in 0..3 {
305        let phi = std::f32::consts::PI * ((ring as f32 + 0.5) / 3.0 - 0.5);
306        let ring_radius = radius * phi.cos();
307        let y = radius * phi.sin();
308        for seg in 0..segments {
309            let theta0 = 2.0 * std::f32::consts::PI * (seg as f32 / segments as f32);
310            let theta1 = 2.0 * std::f32::consts::PI * ((seg + 1) as f32 / segments as f32);
311            let p0 = Vec3::new(ring_radius * theta0.cos(), y, ring_radius * theta0.sin());
312            let p1 = Vec3::new(ring_radius * theta1.cos(), y, ring_radius * theta1.sin());
313            lines.push(GizmoLineInstance::new(
314                transform.transform_point(p0),
315                transform.transform_point(p1),
316                color,
317            ));
318        }
319    }
320
321    for seg in 0..segments {
322        let theta = 2.0 * std::f32::consts::PI * (seg as f32 / segments as f32);
323        let dir = Vec3::new(theta.cos(), 0.0, theta.sin());
324        for step in 0..8 {
325            let phi0 = std::f32::consts::PI * (step as f32 / 8.0 - 0.5);
326            let phi1 = std::f32::consts::PI * ((step + 1) as f32 / 8.0 - 0.5);
327            let p0 = Vec3::new(
328                dir.x * radius * phi0.cos(),
329                radius * phi0.sin(),
330                dir.z * radius * phi0.cos(),
331            );
332            let p1 = Vec3::new(
333                dir.x * radius * phi1.cos(),
334                radius * phi1.sin(),
335                dir.z * radius * phi1.cos(),
336            );
337            lines.push(GizmoLineInstance::new(
338                transform.transform_point(p0),
339                transform.transform_point(p1),
340                color,
341            ));
342        }
343    }
344
345    lines
346}
347
348/// Generates a directional light icon (arrow).
349pub fn directional_light_icon(
350    transform: &Mat4,
351    length: f32,
352    color: [f32; 4],
353) -> Vec<GizmoLineInstance> {
354    let origin = transform.cols[3].truncate();
355    let dir = transform
356        .transform_vector(Vec3::new(0.0, 0.0, -1.0))
357        .normalize();
358    let tip = origin + dir * length;
359
360    let right = transform.transform_vector(Vec3::X).normalize();
361    let up = transform.transform_vector(Vec3::Y).normalize();
362    let head_size = length * 0.3;
363
364    vec![
365        GizmoLineInstance::new(origin, tip, color),
366        GizmoLineInstance::new(tip, tip - dir * head_size + right * head_size * 0.5, color),
367        GizmoLineInstance::new(tip, tip - dir * head_size - right * head_size * 0.5, color),
368        GizmoLineInstance::new(tip, tip - dir * head_size + up * head_size * 0.5, color),
369        GizmoLineInstance::new(tip, tip - dir * head_size - up * head_size * 0.5, color),
370    ]
371}
372
373/// One selected entity's gizmo request.
374#[derive(Debug, Clone)]
375pub struct SelectionGizmo {
376    /// World transform of the entity.
377    pub transform: Mat4,
378    /// Which outline to draw around it.
379    pub kind: GizmoKind,
380    /// World size of the entity's own axis cross — see [`gizmo_world_size`].
381    pub size: f32,
382}
383
384/// Generates all gizmo lines for a set of selected entities.
385pub fn generate_selection_gizmos(
386    selected_entities: &[SelectionGizmo],
387    gizmo_mode: GizmoMode,
388) -> Vec<GizmoLineInstance> {
389    let mut lines = Vec::new();
390
391    for SelectionGizmo {
392        transform,
393        kind,
394        size,
395    } in selected_entities
396    {
397        match kind {
398            GizmoKind::Empty => {
399                lines.extend(wireframe_cube(
400                    transform,
401                    Vec3::new(0.5, 0.5, 0.5),
402                    [0.5, 0.5, 0.6, 1.0],
403                ));
404            }
405            GizmoKind::Camera {
406                fov_y,
407                aspect,
408                near,
409                far,
410            } => {
411                lines.extend(camera_frustum(
412                    transform,
413                    *fov_y,
414                    *aspect,
415                    *near,
416                    *far,
417                    [0.35, 0.63, 0.97, 1.0],
418                ));
419            }
420            GizmoKind::DirectionalLight => {
421                lines.extend(directional_light_icon(
422                    transform,
423                    1.0,
424                    [1.0, 0.95, 0.5, 1.0],
425                ));
426            }
427            GizmoKind::PointLight { radius } => {
428                lines.extend(wireframe_sphere(
429                    transform,
430                    *radius,
431                    12,
432                    [1.0, 0.7, 0.3, 1.0],
433                ));
434            }
435            GizmoKind::Audio => {
436                lines.extend(wireframe_sphere(transform, 0.4, 8, [0.7, 0.3, 1.0, 1.0]));
437            }
438            GizmoKind::Mesh => {
439                lines.extend(wireframe_cube(
440                    transform,
441                    Vec3::new(0.5, 0.5, 0.5),
442                    [0.3, 0.85, 0.6, 1.0],
443                ));
444            }
445        }
446
447        // The pointer tool manipulates nothing, so it shows each entity's own
448        // orientation instead. The three real tools get a single [`manipulator`]
449        // at the selection's shared pivot — drawn by the caller, because one
450        // per entity would stack five identical sets of handles on a
451        // five-entity selection, none of them where a drag actually pivots.
452        if gizmo_mode == GizmoMode::Select {
453            lines.extend(transform_axes(transform, *size));
454        }
455    }
456
457    lines
458}
459
460/// The kind of gizmo to draw for an entity.
461#[derive(Debug, Clone)]
462pub enum GizmoKind {
463    /// Empty entity — wireframe cube.
464    Empty,
465    /// Camera entity — frustum wireframe.
466    Camera {
467        /// Vertical field of view, in radians.
468        fov_y: f32,
469        /// Width / height ratio of the projection.
470        aspect: f32,
471        /// Distance to the near clip plane.
472        near: f32,
473        /// Distance to the far clip plane.
474        far: f32,
475    },
476    /// Directional light — arrow icon.
477    DirectionalLight,
478    /// Point light — wireframe sphere.
479    PointLight {
480        /// Influence radius of the light.
481        radius: f32,
482    },
483    /// Audio source — wireframe sphere.
484    Audio,
485    /// Entity with a mesh — highlighted cube.
486    Mesh,
487}