Skip to main content

khora_editor/
mod_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//! Gizmo rendering and manipulation for the editor viewport.
16//!
17//! The geometry and the drag math live in `khora-core`
18//! (`ui::editor::{gizmo, gizmo_interact}`); this module is the ECS side of it —
19//! it reads the selection out of the world, hands the pure code what it needs,
20//! and writes the resulting transforms back.
21
22use khora_sdk::editor_ui::gizmo::manipulator;
23use khora_sdk::editor_ui::{
24    generate_selection_gizmos, gizmo_basis, gizmo_world_size, EditorState, GizmoBasis, GizmoDelta,
25    GizmoKind, GizmoLineInstance, GizmoTransform, SelectionGizmo,
26};
27use khora_sdk::khora_core::math::{Mat4, Vec3};
28use khora_sdk::khora_core::renderer::api::resource::ViewInfo;
29use khora_sdk::khora_core::renderer::api::scene::mesh::Mesh;
30use khora_sdk::khora_core::renderer::light::LightType;
31use khora_sdk::prelude::ecs::{AudioSource, Camera, EntityId, GlobalTransform, Light, Transform};
32use khora_sdk::GameWorld;
33use khora_sdk::HandleComponent;
34
35/// Everything a manipulator needs to know about the current selection.
36pub struct SelectionFrame {
37    /// World-space centre the handles are drawn and dragged around.
38    pub pivot: Vec3,
39    /// World size of the manipulator at that pivot.
40    pub size: f32,
41    /// Handle directions for the active tool.
42    pub basis: GizmoBasis,
43}
44
45/// The frame the manipulator occupies for the current selection, or `None` when
46/// nothing is selected.
47///
48/// The pivot is the mean of the selected origins, so a multi-selection rotates
49/// and scales as one body rather than each entity about itself.
50pub fn selection_frame(
51    world: &GameWorld,
52    editor_state: &EditorState,
53    view_info: &ViewInfo,
54) -> Option<SelectionFrame> {
55    let mut sum = Vec3::ZERO;
56    let mut count = 0.0_f32;
57    let mut rotation = None;
58
59    for &entity in &editor_state.selection {
60        let Some(transform) = world.get_component::<Transform>(entity) else {
61            continue;
62        };
63        sum = sum + world_origin(world, entity);
64        count += 1.0;
65        // The scale handles align to the object, which only means anything for
66        // a single selection; with several, the first one's frame stands in.
67        rotation.get_or_insert(transform.rotation);
68    }
69
70    (count > 0.0).then(|| {
71        let pivot = sum / count;
72        SelectionFrame {
73            pivot,
74            size: gizmo_world_size(view_info, pivot),
75            basis: gizmo_basis(editor_state.gizmo_mode, rotation.unwrap_or_default()),
76        }
77    })
78}
79
80/// Collects gizmo line instances for all selected entities.
81pub fn collect_gizmo_lines(
82    world: &GameWorld,
83    editor_state: &EditorState,
84    view_info: &ViewInfo,
85) -> Vec<GizmoLineInstance> {
86    let frame = selection_frame(world, editor_state, view_info);
87    let mut entries: Vec<SelectionGizmo> = Vec::new();
88
89    for &entity_id in &editor_state.selection {
90        let Some(transform) = world.get_component::<Transform>(entity_id) else {
91            continue;
92        };
93        // Drawn where the entity actually renders: a child sits at its
94        // propagated global transform, not at its parent-relative one.
95        let world_matrix = world
96            .get_component::<GlobalTransform>(entity_id)
97            .map(|global| global.0 .0)
98            .unwrap_or_else(|| {
99                Mat4::from_translation(transform.translation)
100                    * Mat4::from_quat(transform.rotation)
101                    * Mat4::from_scale(transform.scale)
102            });
103
104        let kind = if world.get_component::<Camera>(entity_id).is_some() {
105            GizmoKind::Camera {
106                fov_y: std::f32::consts::FRAC_PI_4,
107                aspect: 16.0 / 9.0,
108                near: 0.1,
109                far: 1000.0,
110            }
111        } else if let Some(light) = world.get_component::<Light>(entity_id) {
112            match &light.light_type {
113                LightType::Directional(_) => GizmoKind::DirectionalLight,
114                LightType::Point(p) => GizmoKind::PointLight { radius: p.range },
115                LightType::Spot(s) => GizmoKind::PointLight { radius: s.range },
116            }
117        } else if world.get_component::<AudioSource>(entity_id).is_some() {
118            GizmoKind::Audio
119        } else if world
120            .get_component::<HandleComponent<Mesh>>(entity_id)
121            .is_some()
122        {
123            GizmoKind::Mesh
124        } else {
125            GizmoKind::Empty
126        };
127
128        entries.push(SelectionGizmo {
129            transform: world_matrix,
130            kind,
131            size: gizmo_world_size(view_info, world_matrix.cols[3].truncate()),
132        });
133    }
134
135    let mut lines = generate_selection_gizmos(&entries, editor_state.gizmo_mode);
136
137    // One manipulator, at the selection's shared pivot — the same pivot a drag
138    // resolves against, so the handles are where the gesture actually happens.
139    if let Some(frame) = frame {
140        lines.extend(manipulator(
141            frame.pivot,
142            &frame.basis,
143            editor_state.gizmo_mode,
144            frame.size,
145        ));
146    }
147
148    lines
149}
150
151/// The transforms the selected entities have right now, to be held for the
152/// duration of a drag.
153///
154/// A drag resolves against these rather than against the live values, so the
155/// entity always ends up where the cursor says it should be instead of
156/// integrating a chain of per-frame deltas.
157pub fn capture_starts(
158    world: &GameWorld,
159    editor_state: &EditorState,
160) -> Vec<(EntityId, GizmoTransform)> {
161    editor_state
162        .selection
163        .iter()
164        .filter_map(|&entity| {
165            let transform = world.get_component::<Transform>(entity)?;
166            Some((
167                entity,
168                GizmoTransform {
169                    translation: transform.translation,
170                    rotation: transform.rotation,
171                    scale: transform.scale,
172                },
173            ))
174        })
175        .collect()
176}
177
178/// Writes a drag's result back onto every entity it started with.
179///
180/// The delta is world-space and `Transform` is parent-relative, so a *child* of
181/// a moved or rotated parent is manipulated in its parent's frame rather than
182/// the world's. Correct for the root entities that make up almost every
183/// selection; reparenting-aware manipulation is a separate piece of work.
184pub fn apply_delta(
185    world: &mut GameWorld,
186    delta: GizmoDelta,
187    starts: &[(EntityId, GizmoTransform)],
188) {
189    for &(entity, start) in starts {
190        let result = delta.apply(start);
191        let Some(transform) = world.get_component_mut::<Transform>(entity) else {
192            continue;
193        };
194        transform.translation = result.translation;
195        transform.rotation = result.rotation;
196        transform.scale = result.scale;
197    }
198}
199
200/// World-space origin of `entity`, preferring the propagated global transform.
201fn world_origin(world: &GameWorld, entity: EntityId) -> Vec3 {
202    if let Some(global) = world.get_component::<GlobalTransform>(entity) {
203        return global.0 .0.cols[3].truncate();
204    }
205    world
206        .get_component::<Transform>(entity)
207        .map(|t| t.translation)
208        .unwrap_or(Vec3::ZERO)
209}