Skip to main content

mittens_engine/engine/ecs/system/
gizmo_system.rs

1use crate::engine::ecs::component::{
2    GestureCoordType, GestureCoordTypeComponent, SignalRouteUpwardComponent,
3    TransformCameraSpecificComponent, TransformCameraSpecificMode, TransformComponent,
4    TransformDropComponent, TransformForkTRSComponent, TransformGizmoAxis, TransformGizmoComponent,
5    TransformGizmoRotateComponent, TransformGizmoScaleComponent, TransformGizmoTranslateComponent,
6    TransformMapRotationComponent, TransformMapScaleComponent, TransformMapTranslationComponent,
7};
8use crate::engine::ecs::system::GridSystem;
9use crate::engine::ecs::system::editor::context::EditorContextState;
10use crate::engine::ecs::{
11    ComponentId, EventSignal, IntentValue, RxWorld, SignalEmitter, SignalKind, World,
12};
13use crate::engine::user_input::InputState;
14use std::sync::OnceLock;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17
18#[derive(Debug, Clone, Copy)]
19enum TransformGizmoOp {
20    Translate(TransformGizmoAxis),
21    Rotate(TransformGizmoAxis),
22    Scale(TransformGizmoAxis),
23}
24
25#[derive(Debug, Default)]
26pub struct TransformGizmoSystem {
27    editor_context_state: Option<Arc<Mutex<EditorContextState>>>,
28    live_gizmos: std::collections::HashSet<ComponentId>,
29}
30
31impl TransformGizmoSystem {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    pub fn set_editor_context_state(
37        &mut self,
38        editor_context_state: Arc<Mutex<EditorContextState>>,
39    ) {
40        self.editor_context_state = Some(editor_context_state);
41    }
42
43    /// Update the ordinary settings transforms used by camera-specific gizmo anchors.
44    /// Returns anchors whose effective world matrices must be propagated immediately.
45    pub fn update_camera_scales(
46        &mut self,
47        world: &mut World,
48        visuals: &crate::engine::graphics::VisualWorld,
49        has_window_camera: bool,
50    ) -> Vec<ComponentId> {
51        use crate::engine::graphics::CameraTarget;
52        const REFERENCE_DISTANCE: f32 = 4.0;
53        const MIN_SCALE: f32 = 0.02;
54        const MAX_SCALE: f32 = 20.0;
55
56        self.live_gizmos.retain(|id| {
57            world
58                .get_component_by_id_as::<TransformGizmoComponent>(*id)
59                .is_some()
60        });
61        let stereo_eyes = visuals
62            .visual_camera(CameraTarget::Xr)
63            .filter(|c| visuals.active_xr_camera().is_some() && !c.eyes.is_empty());
64        let stereo_active = stereo_eyes.is_some();
65        let mut changed = Vec::new();
66
67        for gizmo in self.live_gizmos.iter().copied().collect::<Vec<_>>() {
68            let Some(g) = world
69                .get_component_by_id_as::<TransformGizmoComponent>(gizmo)
70                .copied()
71            else {
72                continue;
73            };
74            let Some(anchor) = g.visual_root else {
75                continue;
76            };
77            let p = world
78                .get_component_by_id_as::<TransformComponent>(anchor)
79                .map(|t| t.transform.matrix_world[3])
80                .unwrap_or([0.0, 0.0, 0.0, 1.0]);
81            let views: Vec<[[f32; 4]; 4]> = if let Some(c) = stereo_eyes {
82                c.eyes.iter().map(|e| e.view).collect()
83            } else if has_window_camera {
84                visuals
85                    .visual_camera(CameraTarget::Window)
86                    .and_then(|c| c.eyes.first())
87                    .map(|e| vec![e.view])
88                    .unwrap_or_default()
89            } else {
90                Vec::new()
91            };
92            let depths: Vec<f32> = views
93                .iter()
94                .map(|v| {
95                    let z = v[0][2] * p[0] + v[1][2] * p[1] + v[2][2] * p[2] + v[3][2];
96                    -z
97                })
98                .collect();
99            if depths.is_empty() || depths.iter().any(|d| !d.is_finite() || *d <= 0.0) {
100                continue;
101            }
102            let depth = depths.iter().sum::<f32>() / depths.len() as f32;
103            let scale = (g.scale * depth / REFERENCE_DISTANCE).clamp(MIN_SCALE, MAX_SCALE);
104
105            let desired_mode = if stereo_active {
106                TransformCameraSpecificMode::Stereoscopic
107            } else {
108                TransformCameraSpecificMode::Monoscopic
109            };
110            let settings = world
111                .children_of(anchor)
112                .iter()
113                .copied()
114                .find_map(|marker| {
115                    let c =
116                        world.get_component_by_id_as::<TransformCameraSpecificComponent>(marker)?;
117                    (c.mode == desired_mode)
118                        .then(|| {
119                            world.children_of(marker).iter().copied().find(|id| {
120                                world
121                                    .get_component_by_id_as::<TransformComponent>(*id)
122                                    .is_some()
123                            })
124                        })
125                        .flatten()
126                });
127            if let Some(settings) = settings {
128                if let Some(t) = world.get_component_by_id_as_mut::<TransformComponent>(settings) {
129                    t.transform.scale = [scale; 3];
130                    t.transform.recompute_model();
131                    changed.push(anchor);
132                }
133            }
134        }
135        changed
136    }
137
138    /// Install per-gizmo scoped handlers rooted at the `TransformGizmoComponent` node.
139    ///
140    /// Drag events are scoped to the hit renderable; because gizmo handle renderables live under
141    /// the gizmo node, scoped handlers rooted at the gizmo will run for drag events on its handles.
142    pub fn install_scoped_handlers_for_gizmo(&mut self, rx: &mut RxWorld, gizmo_root: ComponentId) {
143        rx.add_handler(
144            SignalKind::ParentChanged,
145            gizmo_root,
146            Self::on_parent_changed,
147        );
148        rx.add_handler(SignalKind::DragStart, gizmo_root, Self::on_drag_start);
149        let editor_context_state = self.editor_context_state.clone();
150        rx.add_handler_closure_named(
151            SignalKind::DragMove,
152            gizmo_root,
153            None,
154            move |world, emit, env| {
155                Self::on_drag_move(world, emit, env, editor_context_state.clone());
156            },
157        );
158        rx.add_handler(SignalKind::DragEnd, gizmo_root, Self::on_drag_end);
159    }
160
161    fn debug_drag_plane_enabled() -> bool {
162        static ENABLED: OnceLock<bool> = OnceLock::new();
163        *ENABLED.get_or_init(|| {
164            let v = std::env::var("CAT_DEBUG_GIZMO_DRAG_PLANE").unwrap_or_default();
165            let v = v.trim().to_ascii_lowercase();
166            matches!(v.as_str(), "1" | "true" | "yes" | "on")
167        })
168    }
169
170    fn debug_enabled() -> bool {
171        static ENABLED: OnceLock<bool> = OnceLock::new();
172        *ENABLED.get_or_init(|| {
173            let v = std::env::var("CAT_DEBUG_GIZMO").unwrap_or_default();
174            let v = v.trim().to_ascii_lowercase();
175            matches!(v.as_str(), "1" | "true" | "yes" | "on")
176        })
177    }
178
179    fn debug_target_enabled() -> bool {
180        static ENABLED: OnceLock<bool> = OnceLock::new();
181        *ENABLED.get_or_init(|| {
182            let v = std::env::var("CAT_DEBUG_GIZMO_TARGET").unwrap_or_default();
183            let v = v.trim().to_ascii_lowercase();
184            matches!(v.as_str(), "1" | "true" | "yes" | "on")
185        })
186    }
187
188    fn debug_sanity_enabled() -> bool {
189        static ENABLED: OnceLock<bool> = OnceLock::new();
190        *ENABLED.get_or_init(|| {
191            let v = std::env::var("CAT_DEBUG_GIZMO_SANITY").unwrap_or_default();
192            let v = v.trim().to_ascii_lowercase();
193            matches!(v.as_str(), "1" | "true" | "yes" | "on")
194        })
195    }
196
197    fn debug_apply_enabled() -> bool {
198        static ENABLED: OnceLock<bool> = OnceLock::new();
199        *ENABLED.get_or_init(|| {
200            let v = std::env::var("CAT_DEBUG_GIZMO_APPLY").unwrap_or_default();
201            let v = v.trim().to_ascii_lowercase();
202            matches!(v.as_str(), "1" | "true" | "yes" | "on")
203        })
204    }
205
206    fn debug_hit_enabled() -> bool {
207        static ENABLED: OnceLock<bool> = OnceLock::new();
208        *ENABLED.get_or_init(|| {
209            let v = std::env::var("CAT_DEBUG_GIZMO_HIT").unwrap_or_default();
210            let v = v.trim().to_ascii_lowercase();
211            matches!(v.as_str(), "1" | "true" | "yes" | "on")
212        })
213    }
214
215    fn log_apply(world: &World, op: &str, target_transform: ComponentId, extra: &str) {
216        static LOG_COUNT: AtomicUsize = AtomicUsize::new(0);
217        let n = LOG_COUNT.fetch_add(1, Ordering::Relaxed);
218        if n >= 96 {
219            return;
220        }
221
222        let name = world
223            .get_component_record(target_transform)
224            .map(|n| {
225                if n.name.is_empty() {
226                    n.component_type.clone()
227                } else {
228                    format!("{}: {}", n.component_type, n.name)
229                }
230            })
231            .unwrap_or_else(|| "<missing>".to_string());
232
233        println!(
234            "[TransformGizmoSystem] APPLY op={} target={:?} '{}' {}",
235            op, target_transform, name, extra
236        );
237    }
238
239    fn sanity_check_transform_values(
240        world: &World,
241        target_transform: ComponentId,
242        translation: [f32; 3],
243        rotation_xyzw: [f32; 4],
244        scale: [f32; 3],
245    ) {
246        fn finite_f32(x: f32) -> bool {
247            x.is_finite()
248        }
249        fn finite3(v: [f32; 3]) -> bool {
250            finite_f32(v[0]) && finite_f32(v[1]) && finite_f32(v[2])
251        }
252        fn finite4(v: [f32; 4]) -> bool {
253            finite_f32(v[0]) && finite_f32(v[1]) && finite_f32(v[2]) && finite_f32(v[3])
254        }
255        fn too_large3(v: [f32; 3]) -> bool {
256            let lim = 1.0e6_f32;
257            v[0].abs() > lim || v[1].abs() > lim || v[2].abs() > lim
258        }
259
260        if finite3(translation)
261            && finite4(rotation_xyzw)
262            && finite3(scale)
263            && !too_large3(translation)
264            && !too_large3(scale)
265        {
266            return;
267        }
268
269        static LOG_COUNT: AtomicUsize = AtomicUsize::new(0);
270        let n = LOG_COUNT.fetch_add(1, Ordering::Relaxed);
271        if n >= 32 {
272            return;
273        }
274
275        let name = world
276            .get_component_record(target_transform)
277            .map(|n| {
278                if n.name.is_empty() {
279                    n.component_type.clone()
280                } else {
281                    format!("{}: {}", n.component_type, n.name)
282                }
283            })
284            .unwrap_or_else(|| "<missing>".to_string());
285
286        println!(
287            "[TransformGizmoSystem] SANITY target={:?} '{}' translation={:?} rotation={:?} scale={:?}",
288            target_transform, name, translation, rotation_xyzw, scale
289        );
290    }
291
292    fn apply_route_upward_if_present(
293        world: &World,
294        kind_name: &str,
295        start: ComponentId,
296    ) -> ComponentId {
297        let mut cur_target = start;
298
299        // Apply all child route-up operators in order of appearance.
300        // (In current usage there will typically be 0 or 1.)
301        for &ch in world.children_of(start) {
302            let Some(op) = world.get_component_by_id_as::<SignalRouteUpwardComponent>(ch) else {
303                continue;
304            };
305
306            let want = op.intent_kind.trim();
307            let applies = want.is_empty() || want == "any" || want == kind_name;
308            if !applies {
309                continue;
310            }
311
312            let parent_type = op.parent_type.trim();
313            if parent_type.is_empty() {
314                continue;
315            }
316
317            // Ancestor search: do not match the start node itself.
318            let mut cur = world.parent_of(cur_target);
319            while let Some(cid) = cur {
320                let Some(node) = world.get_component_node(cid) else {
321                    break;
322                };
323
324                if node.component.name() == parent_type {
325                    cur_target = cid;
326                    break;
327                }
328
329                cur = world.parent_of(cid);
330            }
331        }
332
333        cur_target
334    }
335
336    fn mat4_identity() -> crate::engine::graphics::primitives::TransformMatrix {
337        [
338            [1.0, 0.0, 0.0, 0.0],
339            [0.0, 1.0, 0.0, 0.0],
340            [0.0, 0.0, 1.0, 0.0],
341            [0.0, 0.0, 0.0, 1.0],
342        ]
343    }
344
345    fn mat4_mul_vec4(
346        m: crate::engine::graphics::primitives::TransformMatrix,
347        v: [f32; 4],
348    ) -> [f32; 4] {
349        [
350            m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3],
351            m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3],
352            m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3],
353            m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3],
354        ]
355    }
356
357    fn parent_transform_world_matrix(
358        world: &World,
359        transform_cid: ComponentId,
360    ) -> Option<crate::engine::graphics::primitives::TransformMatrix> {
361        let mut cur = transform_cid;
362        while let Some(parent) = world.parent_of(cur) {
363            if let Some(t) = world
364                .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(parent)
365            {
366                return Some(t.transform.matrix_world);
367            }
368            cur = parent;
369        }
370        None
371    }
372
373    fn world_delta_to_target_local(
374        world: &World,
375        target_transform: ComponentId,
376        delta_world: [f32; 3],
377    ) -> [f32; 3] {
378        use crate::utils::math;
379
380        let parent_world = Self::parent_transform_world_matrix(world, target_transform)
381            .unwrap_or_else(Self::mat4_identity);
382        let inv_parent_world = math::mat4_inverse(parent_world).unwrap_or_else(Self::mat4_identity);
383
384        let v = Self::mat4_mul_vec4(
385            inv_parent_world,
386            [delta_world[0], delta_world[1], delta_world[2], 0.0],
387        );
388        [v[0], v[1], v[2]]
389    }
390
391    fn target_translation_local_to_world(
392        world: &World,
393        target_transform: ComponentId,
394        point_local: [f32; 3],
395    ) -> [f32; 3] {
396        let parent_world = Self::parent_transform_world_matrix(world, target_transform)
397            .unwrap_or_else(Self::mat4_identity);
398        let v = Self::mat4_mul_vec4(
399            parent_world,
400            [point_local[0], point_local[1], point_local[2], 1.0],
401        );
402        [v[0], v[1], v[2]]
403    }
404
405    fn world_point_to_target_translation_local(
406        world: &World,
407        target_transform: ComponentId,
408        point_world: [f32; 3],
409    ) -> [f32; 3] {
410        use crate::utils::math;
411
412        let parent_world = Self::parent_transform_world_matrix(world, target_transform)
413            .unwrap_or_else(Self::mat4_identity);
414        let inv_parent_world = math::mat4_inverse(parent_world).unwrap_or_else(Self::mat4_identity);
415        let v = Self::mat4_mul_vec4(
416            inv_parent_world,
417            [point_world[0], point_world[1], point_world[2], 1.0],
418        );
419        [v[0], v[1], v[2]]
420    }
421
422    fn active_snap_grid_for_translate(
423        world: &World,
424        editor_context_state: Option<Arc<Mutex<EditorContextState>>>,
425    ) -> Option<crate::engine::ecs::system::grid_system::ActiveGrid> {
426        let owner_transform = editor_context_state
427            .as_ref()
428            .and_then(|state| state.lock().ok())
429            .and_then(|state| state.active_grid_owner_transform)?;
430        let grids = GridSystem::new();
431        grids.active_grid_for_owner_transform(world, owner_transform)
432    }
433
434    fn world_dir_to_target_local(
435        world: &World,
436        target_transform: ComponentId,
437        dir_world: [f32; 3],
438    ) -> [f32; 3] {
439        use crate::utils::math;
440
441        let d = Self::world_delta_to_target_local(world, target_transform, dir_world);
442        math::vec3_normalize(d)
443    }
444
445    fn translation_drag_next_local(
446        world: &World,
447        target_transform: ComponentId,
448        axis_world: [f32; 3],
449        drag_start_hit_point_world: [f32; 3],
450        drag_start_target_translation: [f32; 3],
451        current_hit_point_world: [f32; 3],
452    ) -> [f32; 3] {
453        fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
454            a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
455        }
456
457        fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
458            [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
459        }
460
461        fn mul(v: [f32; 3], s: f32) -> [f32; 3] {
462            [v[0] * s, v[1] * s, v[2] * s]
463        }
464
465        fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
466            [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
467        }
468
469        let drag_world = sub(current_hit_point_world, drag_start_hit_point_world);
470        let axis_distance_world = dot(drag_world, axis_world);
471        let delta_world_axis = mul(axis_world, axis_distance_world);
472        let delta_local =
473            Self::world_delta_to_target_local(world, target_transform, delta_world_axis);
474        add(drag_start_target_translation, delta_local)
475    }
476
477    fn resolve_translation_space(
478        world: &World,
479        gizmo_cid: ComponentId,
480    ) -> crate::engine::ecs::component::TransformGizmoCoordSpace {
481        let mut translation_space = crate::engine::ecs::component::TransformGizmoCoordSpace::World;
482        let mut cur = Some(gizmo_cid);
483        while let Some(node) = cur {
484            if let Some(ed) =
485                world.get_component_by_id_as::<crate::engine::ecs::component::EditorComponent>(node)
486            {
487                translation_space = ed.transform_gizmo_translation_space;
488                break;
489            }
490            cur = world.parent_of(node);
491        }
492        translation_space
493    }
494
495    fn transform_direction(
496        m: crate::engine::graphics::primitives::TransformMatrix,
497        v: [f32; 3],
498    ) -> [f32; 3] {
499        [
500            m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2],
501            m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2],
502            m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2],
503        ]
504    }
505
506    fn translation_axis_world(
507        world: &World,
508        target_transform: ComponentId,
509        space: crate::engine::ecs::component::TransformGizmoCoordSpace,
510        axis: TransformGizmoAxis,
511    ) -> [f32; 3] {
512        use crate::engine::ecs::system::transform_system::TransformSystem;
513        use crate::utils::math;
514
515        let axis_local = axis.unit_vec3();
516        match space {
517            crate::engine::ecs::component::TransformGizmoCoordSpace::World => axis_local,
518            crate::engine::ecs::component::TransformGizmoCoordSpace::Local => {
519                let target_world = TransformSystem::world_model(world, target_transform)
520                    .unwrap_or_else(Self::mat4_identity);
521                math::vec3_normalize(Self::transform_direction(target_world, axis_local))
522            }
523        }
524    }
525
526    fn quat_from_z_to_dir(dir: [f32; 3]) -> [f32; 4] {
527        use crate::utils::math;
528
529        fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
530            [
531                a[1] * b[2] - a[2] * b[1],
532                a[2] * b[0] - a[0] * b[2],
533                a[0] * b[1] - a[1] * b[0],
534            ]
535        }
536
537        // Rotate local +Z to `dir`.
538        let z = [0.0f32, 0.0f32, 1.0f32];
539        let d = math::vec3_normalize(dir);
540        let dot_ = z[0] * d[0] + z[1] * d[1] + z[2] * d[2];
541
542        if dot_ >= 1.0 - 1e-6 {
543            return [0.0, 0.0, 0.0, 1.0];
544        }
545        if dot_ <= -1.0 + 1e-6 {
546            // 180-degree flip around X (any axis orthogonal to Z works).
547            return math::quat_from_axis_angle([1.0, 0.0, 0.0], std::f32::consts::PI);
548        }
549
550        let axis = cross(z, d);
551        let axis_len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
552        if axis_len <= 1e-6 {
553            return [0.0, 0.0, 0.0, 1.0];
554        }
555        let axis_n = [axis[0] / axis_len, axis[1] / axis_len, axis[2] / axis_len];
556        let angle = dot_.clamp(-1.0, 1.0).acos();
557        math::quat_from_axis_angle(axis_n, angle)
558    }
559
560    fn spawn_debug_drag_plane(
561        world: &mut World,
562        emit: &mut dyn SignalEmitter,
563        hit_point: [f32; 3],
564        plane_normal: [f32; 3],
565    ) -> ComponentId {
566        use crate::engine::ecs::component::{
567            ColorComponent, EmissiveComponent, OpacityComponent, RenderableComponent,
568            TransformComponent,
569        };
570        use crate::engine::graphics::primitives::{CpuMeshHandle, MaterialHandle, Renderable};
571
572        let q = Self::quat_from_z_to_dir(plane_normal);
573
574        // Use a very thin cube so it is visible from both sides (debug aid).
575        let size = 2.0_f32;
576        let thickness = 0.005_f32;
577
578        let t = world.add_component_boxed_named(
579            "gizmo_drag_plane_t",
580            Box::new(
581                TransformComponent::new()
582                    .with_position(hit_point[0], hit_point[1], hit_point[2])
583                    .with_rotation_quat(q)
584                    .with_scale(size, size, thickness),
585            ),
586        );
587        let r = world.add_component_boxed_named(
588            "gizmo_drag_plane_r",
589            Box::new(RenderableComponent::new(Renderable::new(
590                CpuMeshHandle::CUBE,
591                MaterialHandle::UNLIT_MESH,
592            ))),
593        );
594        let c = world.add_component_boxed_named(
595            "gizmo_drag_plane_color",
596            Box::new(ColorComponent::rgba(1.0, 0.0, 1.0, 0.35)),
597        );
598        let o = world.add_component_boxed_named(
599            "gizmo_drag_plane_opacity",
600            Box::new(
601                OpacityComponent::new()
602                    .with_opacity(0.35)
603                    .with_multiple_layers(),
604            ),
605        );
606        let e = world.add_component_boxed_named(
607            "gizmo_drag_plane_emissive",
608            Box::new(EmissiveComponent::on()),
609        );
610
611        let _ = world.add_child(t, r);
612        let _ = world.add_child(r, c);
613        let _ = world.add_child(r, o);
614        let _ = world.add_child(r, e);
615
616        world.init_component_tree(t, emit);
617        t
618    }
619
620    fn on_parent_changed(
621        world: &mut World,
622        _emit: &mut dyn SignalEmitter,
623        env: &crate::engine::ecs::Signal,
624    ) {
625        let Some(EventSignal::ParentChanged {
626            child, new_parent, ..
627        }) = env.event.as_ref()
628        else {
629            return;
630        };
631
632        if world
633            .get_component_by_id_as::<TransformGizmoComponent>(*child)
634            .is_none()
635        {
636            return;
637        }
638
639        let mut target: Option<ComponentId> = None;
640        let mut cur = *new_parent;
641        while let Some(node) = cur {
642            if world
643                .get_component_by_id_as::<TransformComponent>(node)
644                .is_some()
645            {
646                target = Some(node);
647                break;
648            }
649            cur = world.parent_of(node);
650        }
651
652        let old_target = world
653            .get_component_by_id_as::<TransformGizmoComponent>(*child)
654            .and_then(|g| g.target_transform);
655
656        // If the newly-selected transform is a proxy (e.g. glTF viz:* transform), allow it to
657        // carry routing operators that redirect gizmo edits to an ancestor target.
658        let routed_target =
659            target.map(|t| Self::apply_route_upward_if_present(world, "update_transform", t));
660
661        if Self::debug_target_enabled() {
662            if let (Some(orig), Some(routed)) = (target, routed_target) {
663                if orig != routed {
664                    let orig_name = world
665                        .get_component_record(orig)
666                        .map(|n| {
667                            if n.name.is_empty() {
668                                n.component_type.clone()
669                            } else {
670                                format!("{}: {}", n.component_type, n.name)
671                            }
672                        })
673                        .unwrap_or_else(|| "<missing>".to_string());
674                    let routed_name = world
675                        .get_component_record(routed)
676                        .map(|n| {
677                            if n.name.is_empty() {
678                                n.component_type.clone()
679                            } else {
680                                format!("{}: {}", n.component_type, n.name)
681                            }
682                        })
683                        .unwrap_or_else(|| "<missing>".to_string());
684                    println!(
685                        "[TransformGizmoSystem] routed target_transform {:?} '{}' -> {:?} '{}'",
686                        orig, orig_name, routed, routed_name
687                    );
688                }
689            }
690        }
691
692        if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(*child) {
693            g.target_transform = routed_target;
694            g.active_raycaster = None;
695        }
696
697        if Self::debug_enabled() {
698            println!(
699                "[TransformGizmoSystem] ParentChanged gizmo={:?} new_parent={:?} old_target={:?} new_target={:?}",
700                child, new_parent, old_target, target
701            );
702        }
703    }
704
705    fn on_drag_start(
706        world: &mut World,
707        emit: &mut dyn SignalEmitter,
708        env: &crate::engine::ecs::Signal,
709    ) {
710        let Some(EventSignal::DragStart {
711            raycaster,
712            renderable,
713            hit_point,
714            ray_dir_world,
715            ..
716        }) = env.event.as_ref()
717        else {
718            return;
719        };
720
721        let Some((gizmo_cid, _op)) = Self::resolve_gizmo_op_for_renderable(world, *renderable)
722        else {
723            return;
724        };
725
726        let drag_start_target_translation = world
727            .get_component_by_id_as::<TransformGizmoComponent>(gizmo_cid)
728            .and_then(|g| g.target_transform)
729            .and_then(|target| world.get_component_by_id_as::<TransformComponent>(target))
730            .map(|t| t.transform.translation);
731
732        let mut old_debug_root: Option<ComponentId> = None;
733        if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(gizmo_cid) {
734            g.active_raycaster = Some(*raycaster);
735            g.active_drag_slider_last_angle = 0.0;
736            g.active_drag_start_hit_point_world = Some(*hit_point);
737            g.active_drag_start_target_translation = drag_start_target_translation;
738            if Self::debug_drag_plane_enabled() {
739                old_debug_root = g.debug_drag_plane_root.take();
740            }
741        }
742
743        if let Some(root) = old_debug_root {
744            emit.push_intent_now(
745                root,
746                IntentValue::RemoveSubtree {
747                    component_ids: vec![root],
748                },
749            );
750        }
751
752        if Self::debug_drag_plane_enabled() {
753            let plane_root = Self::spawn_debug_drag_plane(world, emit, *hit_point, *ray_dir_world);
754            if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(gizmo_cid)
755            {
756                g.debug_drag_plane_root = Some(plane_root);
757            }
758        }
759    }
760
761    fn on_drag_move(
762        world: &mut World,
763        emit: &mut dyn SignalEmitter,
764        env: &crate::engine::ecs::Signal,
765        editor_context_state: Option<Arc<Mutex<EditorContextState>>>,
766    ) {
767        use crate::engine::ecs::system::transform_system::TransformSystem;
768        use crate::utils::math;
769
770        fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
771            a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
772        }
773
774        fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
775            [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
776        }
777
778        fn mul(v: [f32; 3], s: f32) -> [f32; 3] {
779            [v[0] * s, v[1] * s, v[2] * s]
780        }
781
782        fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
783            [
784                a[1] * b[2] - a[2] * b[1],
785                a[2] * b[0] - a[0] * b[2],
786                a[0] * b[1] - a[1] * b[0],
787            ]
788        }
789
790        let Some(EventSignal::DragMove {
791            raycaster,
792            renderable,
793            delta_world,
794            hit_point,
795            screen_pos_px: _screen_pos_px,
796            screen_delta_px,
797            ..
798        }) = env.event.as_ref()
799        else {
800            return;
801        };
802
803        let Some((gizmo_cid, op)) = Self::resolve_gizmo_op_for_renderable(world, *renderable)
804        else {
805            return;
806        };
807
808        // Copy out what we need without holding a mutable borrow.
809        let Some((
810            target_transform,
811            active,
812            slider_last_angle,
813            drag_start_hit_point_world,
814            drag_start_target_translation,
815        )) = world
816            .get_component_by_id_as::<TransformGizmoComponent>(gizmo_cid)
817            .map(|g| {
818                (
819                    g.target_transform,
820                    g.active_raycaster,
821                    g.active_drag_slider_last_angle,
822                    g.active_drag_start_hit_point_world,
823                    g.active_drag_start_target_translation,
824                )
825            })
826        else {
827            return;
828        };
829
830        let Some(target_transform) = target_transform else {
831            return;
832        };
833
834        if active != Some(*raycaster) {
835            return;
836        }
837
838        match op {
839            TransformGizmoOp::Translate(axis) => {
840                let translation_space = Self::resolve_translation_space(world, gizmo_cid);
841                let axis_v =
842                    Self::translation_axis_world(world, target_transform, translation_space, axis);
843                let Some(drag_start_hit_point_world) = drag_start_hit_point_world else {
844                    return;
845                };
846                let Some(drag_start_target_translation) = drag_start_target_translation else {
847                    return;
848                };
849                let unsnapped_next = Self::translation_drag_next_local(
850                    world,
851                    target_transform,
852                    axis_v,
853                    drag_start_hit_point_world,
854                    drag_start_target_translation,
855                    *hit_point,
856                );
857                let next = if let Some(active_grid) =
858                    Self::active_snap_grid_for_translate(world, editor_context_state.clone())
859                {
860                    let candidate_world = Self::target_translation_local_to_world(
861                        world,
862                        target_transform,
863                        unsnapped_next,
864                    );
865                    let snapped_world = GridSystem::snap_point_preserving_plane_offset(
866                        &active_grid,
867                        candidate_world,
868                    )
869                    .point_world;
870                    Self::world_point_to_target_translation_local(
871                        world,
872                        target_transform,
873                        snapped_world,
874                    )
875                } else {
876                    unsnapped_next
877                };
878
879                if Self::debug_apply_enabled() {
880                    Self::log_apply(
881                        world,
882                        "translate",
883                        target_transform,
884                        &format!(
885                            "translation_space={:?} hit_point={:?} drag_start_hit_point={:?} axis_world={:?} drag_start_t={:?} next_t={:?}",
886                            translation_space,
887                            *hit_point,
888                            drag_start_hit_point_world,
889                            axis_v,
890                            drag_start_target_translation,
891                            next,
892                        ),
893                    );
894                }
895
896                let Some(t_ro) =
897                    world.get_component_by_id_as::<TransformComponent>(target_transform)
898                else {
899                    return;
900                };
901                if Self::debug_sanity_enabled() {
902                    Self::sanity_check_transform_values(
903                        world,
904                        target_transform,
905                        next,
906                        t_ro.transform.rotation,
907                        t_ro.transform.scale,
908                    );
909                }
910
911                let Some(t) =
912                    world.get_component_by_id_as_mut::<TransformComponent>(target_transform)
913                else {
914                    return;
915                };
916                t.set_position(emit, next[0], next[1], next[2]);
917            }
918            TransformGizmoOp::Rotate(axis) => {
919                let coord_type =
920                    Self::resolve_gesture_coord_type_for_renderable(world, *renderable);
921
922                // Resolve rotation coord space (default Local). This controls how we interpret the
923                // axis when applying the drag angle.
924                let mut rotation_space =
925                    crate::engine::ecs::component::TransformGizmoCoordSpace::Local;
926                {
927                    let mut cur = Some(gizmo_cid);
928                    while let Some(node) = cur {
929                        if let Some(ed) = world.get_component_by_id_as::<crate::engine::ecs::component::EditorComponent>(node) {
930                            rotation_space = ed.transform_gizmo_rotation_space;
931                            break;
932                        }
933                        cur = world.parent_of(node);
934                    }
935                }
936
937                let axis_v = axis.unit_vec3();
938                let (angle, new_slider_last_angle) = match coord_type {
939                    Some(GestureCoordType::ScreenSpace1DSlider) => {
940                        match *screen_delta_px {
941                            Some((dx, dy)) => {
942                                // Incremental mapping: avoid any “flip” behavior caused by crossing
943                                // a reference vector/origin by only integrating the per-move delta.
944                                let radians_per_px = 0.01_f32;
945                                let delta_px = dx + dy;
946                                let delta_angle = delta_px * radians_per_px;
947                                (delta_angle, slider_last_angle + delta_angle)
948                            }
949                            None => {
950                                // If screen deltas aren't available (e.g. XR pointers), do nothing.
951                                (0.0_f32, slider_last_angle)
952                            }
953                        }
954                    }
955                    _ => {
956                        let pivot = TransformSystem::world_position(world, target_transform)
957                            .unwrap_or([0.0, 0.0, 0.0]);
958                        let prev_hit = sub(*hit_point, *delta_world);
959
960                        let mut v0 = sub(prev_hit, pivot);
961                        let mut v1 = sub(*hit_point, pivot);
962
963                        // Project onto plane orthogonal to the axis.
964                        v0 = sub(v0, mul(axis_v, dot(v0, axis_v)));
965                        v1 = sub(v1, mul(axis_v, dot(v1, axis_v)));
966                        v0 = math::vec3_normalize(v0);
967                        v1 = math::vec3_normalize(v1);
968
969                        // Signed angle about axis.
970                        let c = cross(v0, v1);
971                        let s = dot(axis_v, c);
972                        let d = dot(v0, v1);
973                        (s.atan2(d), slider_last_angle)
974                    }
975                };
976
977                if angle != 0.0 {
978                    let axis_local = match rotation_space {
979                        crate::engine::ecs::component::TransformGizmoCoordSpace::Local => axis_v,
980                        // World mode will be implemented next; for now keep the previous behavior
981                        // (convert the world axis into target-local space).
982                        crate::engine::ecs::component::TransformGizmoCoordSpace::World => {
983                            Self::world_dir_to_target_local(world, target_transform, axis_v)
984                        }
985                    };
986
987                    let Some(t_ro) =
988                        world.get_component_by_id_as::<TransformComponent>(target_transform)
989                    else {
990                        return;
991                    };
992                    let q_delta_local = math::quat_from_axis_angle(axis_local, angle);
993                    // Quaternion multiplication order determines the frame the delta is applied in:
994                    // - Local: post-multiply (rotate in the object's local frame)
995                    // - World: pre-multiply (rotate in the parent/world frame)
996                    let q_next = match rotation_space {
997                        crate::engine::ecs::component::TransformGizmoCoordSpace::Local => {
998                            math::quat_mul(t_ro.transform.rotation, q_delta_local)
999                        }
1000                        crate::engine::ecs::component::TransformGizmoCoordSpace::World => {
1001                            math::quat_mul(q_delta_local, t_ro.transform.rotation)
1002                        }
1003                    };
1004
1005                    if Self::debug_apply_enabled() {
1006                        Self::log_apply(
1007                            world,
1008                            "rotate",
1009                            target_transform,
1010                            &format!(
1011                                "delta_world={:?} axis_world={:?} axis_local={:?} angle={:.6} cur_q={:?} next_q={:?} pivot_world={:?}",
1012                                *delta_world,
1013                                axis_v,
1014                                axis_local,
1015                                angle,
1016                                t_ro.transform.rotation,
1017                                q_next,
1018                                TransformSystem::world_position(world, target_transform)
1019                                    .unwrap_or([0.0, 0.0, 0.0]),
1020                            ),
1021                        );
1022                    }
1023
1024                    if Self::debug_sanity_enabled() {
1025                        Self::sanity_check_transform_values(
1026                            world,
1027                            target_transform,
1028                            t_ro.transform.translation,
1029                            q_next,
1030                            t_ro.transform.scale,
1031                        );
1032                    }
1033
1034                    let Some(t) =
1035                        world.get_component_by_id_as_mut::<TransformComponent>(target_transform)
1036                    else {
1037                        return;
1038                    };
1039                    t.set_rotation_quat(emit, q_next);
1040                }
1041
1042                if coord_type == Some(GestureCoordType::ScreenSpace1DSlider) {
1043                    if let Some(g) =
1044                        world.get_component_by_id_as_mut::<TransformGizmoComponent>(gizmo_cid)
1045                    {
1046                        g.active_drag_slider_last_angle = new_slider_last_angle;
1047                    }
1048                }
1049            }
1050            TransformGizmoOp::Scale(axis) => {
1051                let d = dot(*delta_world, axis.unit_vec3());
1052
1053                // Convert the world-space drag delta into target-local space so scaling behaves
1054                // consistently even when the target has a rotated/scaled parent.
1055                let delta_world_axis = mul(axis.unit_vec3(), d);
1056                let delta_local_axis =
1057                    Self::world_delta_to_target_local(world, target_transform, delta_world_axis);
1058                let axis_local_dir =
1059                    Self::world_dir_to_target_local(world, target_transform, axis.unit_vec3());
1060                let d_local = dot(delta_local_axis, axis_local_dir);
1061
1062                let Some(t_ro) =
1063                    world.get_component_by_id_as::<TransformComponent>(target_transform)
1064                else {
1065                    return;
1066                };
1067                let mut s = t_ro.transform.scale;
1068                match axis {
1069                    TransformGizmoAxis::X => s[0] = (s[0] + d_local).max(0.001),
1070                    TransformGizmoAxis::Y => s[1] = (s[1] + d_local).max(0.001),
1071                    TransformGizmoAxis::Z => s[2] = (s[2] + d_local).max(0.001),
1072                }
1073
1074                if Self::debug_apply_enabled() {
1075                    Self::log_apply(
1076                        world,
1077                        "scale",
1078                        target_transform,
1079                        &format!(
1080                            "delta_world={:?} axis_world={:?} d_world={:.6} delta_world_axis={:?} delta_local_axis={:?} axis_local_dir={:?} d_local={:.6} cur_s={:?} next_s={:?}",
1081                            *delta_world,
1082                            axis.unit_vec3(),
1083                            d,
1084                            delta_world_axis,
1085                            delta_local_axis,
1086                            axis_local_dir,
1087                            d_local,
1088                            t_ro.transform.scale,
1089                            s,
1090                        ),
1091                    );
1092                }
1093
1094                if Self::debug_sanity_enabled() {
1095                    Self::sanity_check_transform_values(
1096                        world,
1097                        target_transform,
1098                        t_ro.transform.translation,
1099                        t_ro.transform.rotation,
1100                        s,
1101                    );
1102                }
1103
1104                let Some(t) =
1105                    world.get_component_by_id_as_mut::<TransformComponent>(target_transform)
1106                else {
1107                    return;
1108                };
1109                t.set_scale(emit, s[0], s[1], s[2]);
1110            }
1111        }
1112    }
1113
1114    fn on_drag_end(
1115        world: &mut World,
1116        emit: &mut dyn SignalEmitter,
1117        env: &crate::engine::ecs::Signal,
1118    ) {
1119        let Some(EventSignal::DragEnd {
1120            raycaster,
1121            renderable,
1122            ..
1123        }) = env.event.as_ref()
1124        else {
1125            return;
1126        };
1127
1128        let Some((gizmo_cid, _op)) = Self::resolve_gizmo_op_for_renderable(world, *renderable)
1129        else {
1130            return;
1131        };
1132
1133        if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(gizmo_cid) {
1134            if g.active_raycaster == Some(*raycaster) {
1135                g.active_raycaster = None;
1136            }
1137            g.active_drag_slider_last_angle = 0.0;
1138            g.active_drag_start_hit_point_world = None;
1139            g.active_drag_start_target_translation = None;
1140
1141            if Self::debug_drag_plane_enabled() {
1142                if let Some(root) = g.debug_drag_plane_root.take() {
1143                    emit.push_intent_now(
1144                        root,
1145                        IntentValue::RemoveSubtree {
1146                            component_ids: vec![root],
1147                        },
1148                    );
1149                }
1150            }
1151        }
1152    }
1153
1154    /// Spawn the 9-part gizmo visual subtree for a TransformGizmoComponent.
1155    ///
1156    /// Contract: TransformGizmoComponent is expected to be attached under a TransformComponent.
1157    pub fn register_transform_gizmo(
1158        &mut self,
1159        world: &mut World,
1160        component: ComponentId,
1161        emit: &mut dyn SignalEmitter,
1162    ) {
1163        use crate::engine::ecs::component::{
1164            EditorComponent, OverlayComponent, TransformComponent, TransformGizmoAxis,
1165            TransformGizmoComponent, TransformGizmoCoordSpace, TransformGizmoRotateComponent,
1166            TransformGizmoTranslateComponent,
1167        };
1168        use crate::engine::graphics::primitives::CpuMeshHandle;
1169
1170        // Must be a gizmo.
1171        let Some(_) = world.get_component_by_id_as::<TransformGizmoComponent>(component) else {
1172            return;
1173        };
1174
1175        // Find the nearest ancestor transform to attach visuals under.
1176        let mut cur = component;
1177        let mut parent_transform: Option<ComponentId> = None;
1178        while let Some(p) = world.parent_of(cur) {
1179            if world
1180                .get_component_by_id_as::<TransformComponent>(p)
1181                .is_some()
1182            {
1183                parent_transform = Some(p);
1184                break;
1185            }
1186            cur = p;
1187        }
1188        if parent_transform.is_none() {
1189            return;
1190        }
1191        let parent_transform = parent_transform.unwrap();
1192
1193        // Bind gizmo target to the attached TransformComponent.
1194        // This is the only supported targeting mode (works for joints/armatures and normal transforms).
1195        if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(component) {
1196            g.target_transform = Some(parent_transform);
1197        }
1198
1199        // Avoid respawn.
1200        if let Some(g) = world.get_component_by_id_as::<TransformGizmoComponent>(component) {
1201            if g.visual_root.is_some() {
1202                return;
1203            }
1204        }
1205
1206        let gizmo_scale = world
1207            .get_component_by_id_as::<TransformGizmoComponent>(component)
1208            .map(|g| g.scale)
1209            .unwrap_or(1.0);
1210
1211        // Gizmos are parented under the target transform, so by default they'd inherit whatever
1212        // scale the target (and its ancestors) have.
1213        //
1214        // We now spawn gizmo visuals under an explicit transform pipeline that keeps translation
1215        // + rotation but drops inherited scale. This prevents non-uniform target scales from
1216        // squashing the gizmo and means `TransformGizmoComponent.scale` can be interpreted
1217        // directly as a world-ish size knob (modulo camera projection).
1218        fn mat4_identity() -> crate::engine::graphics::primitives::TransformMatrix {
1219            [
1220                [1.0, 0.0, 0.0, 0.0],
1221                [0.0, 1.0, 0.0, 0.0],
1222                [0.0, 0.0, 1.0, 0.0],
1223                [0.0, 0.0, 0.0, 1.0],
1224            ]
1225        }
1226
1227        fn mat4_mul(
1228            a: crate::engine::graphics::primitives::TransformMatrix,
1229            b: crate::engine::graphics::primitives::TransformMatrix,
1230        ) -> crate::engine::graphics::primitives::TransformMatrix {
1231            let mut out = [[0.0f32; 4]; 4];
1232            for c in 0..4 {
1233                for r in 0..4 {
1234                    out[c][r] = a[0][r] * b[c][0]
1235                        + a[1][r] * b[c][1]
1236                        + a[2][r] * b[c][2]
1237                        + a[3][r] * b[c][3];
1238                }
1239            }
1240            out
1241        }
1242
1243        fn max_basis_scale(m: crate::engine::graphics::primitives::TransformMatrix) -> f32 {
1244            fn len3(v: [f32; 4]) -> f32 {
1245                (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
1246            }
1247            // Column-major: columns 0..2 are the scaled basis vectors.
1248            len3(m[0]).max(len3(m[1])).max(len3(m[2]))
1249        }
1250
1251        fn world_model_uncached(
1252            world: &World,
1253            start_transform: ComponentId,
1254        ) -> crate::engine::graphics::primitives::TransformMatrix {
1255            // Collect local model matrices for all ancestor transforms, then multiply root->leaf.
1256            let mut chain: Vec<crate::engine::graphics::primitives::TransformMatrix> = Vec::new();
1257            let mut cur: Option<ComponentId> = Some(start_transform);
1258            while let Some(node) = cur {
1259                if let Some(t) = world.get_component_by_id_as::<TransformComponent>(node) {
1260                    chain.push(t.transform.model);
1261                }
1262                cur = world.parent_of(node);
1263            }
1264            chain.reverse();
1265            let mut out = mat4_identity();
1266            for m in chain {
1267                out = mat4_mul(out, m);
1268            }
1269            out
1270        }
1271
1272        let parent_world = world_model_uncached(world, parent_transform);
1273        let parent_world_scale = max_basis_scale(parent_world).max(1e-4);
1274        let gizmo_local_scale = gizmo_scale;
1275
1276        fn add_pipeline_group(
1277            world: &mut World,
1278            parent: ComponentId,
1279            pipeline_name: &str,
1280            include_translation_map: bool,
1281            include_rotation_map: bool,
1282            include_scale_map: bool,
1283            drop_translation: bool,
1284            drop_rotation: bool,
1285            drop_scale: bool,
1286        ) -> ComponentId {
1287            let fork = world.add_component_boxed_named(
1288                pipeline_name,
1289                Box::new(TransformForkTRSComponent::new()),
1290            );
1291            let _ = world.add_child(parent, fork);
1292
1293            if include_translation_map {
1294                let map = world.add_component_boxed_named(
1295                    format!("{pipeline_name}:map_translation"),
1296                    Box::new(TransformMapTranslationComponent::new()),
1297                );
1298                let _ = world.add_child(fork, map);
1299                if drop_translation {
1300                    let drop = world.add_component_boxed_named(
1301                        format!("{pipeline_name}:drop_translation"),
1302                        Box::new(TransformDropComponent::new()),
1303                    );
1304                    let _ = world.add_child(map, drop);
1305                }
1306            }
1307
1308            if include_rotation_map {
1309                let map = world.add_component_boxed_named(
1310                    format!("{pipeline_name}:map_rotation"),
1311                    Box::new(TransformMapRotationComponent::new()),
1312                );
1313                let _ = world.add_child(fork, map);
1314                if drop_rotation {
1315                    let drop = world.add_component_boxed_named(
1316                        format!("{pipeline_name}:drop_rotation"),
1317                        Box::new(TransformDropComponent::new()),
1318                    );
1319                    let _ = world.add_child(map, drop);
1320                }
1321            }
1322
1323            if include_scale_map {
1324                let map = world.add_component_boxed_named(
1325                    format!("{pipeline_name}:map_scale"),
1326                    Box::new(TransformMapScaleComponent::new()),
1327                );
1328                let _ = world.add_child(fork, map);
1329                if drop_scale {
1330                    let drop = world.add_component_boxed_named(
1331                        format!("{pipeline_name}:drop_scale"),
1332                        Box::new(TransformDropComponent::new()),
1333                    );
1334                    let _ = world.add_child(map, drop);
1335                }
1336            }
1337
1338            fork
1339        }
1340
1341        if Self::debug_enabled() {
1342            println!(
1343                "[TransformGizmoSystem] register gizmo={:?} target_transform={:?} requested_world_scale={:.4} parent_world_scale={:.4} gizmo_local_scale={:.4}",
1344                component, parent_transform, gizmo_scale, parent_world_scale, gizmo_local_scale
1345            );
1346        }
1347
1348        // Process inherited transforms from the target explicitly via a transform pipeline:
1349        // keep translation + rotation, drop scale.
1350        let gizmo_output = add_pipeline_group(
1351            world,
1352            component,
1353            "gizmo_pipeline",
1354            true,
1355            true,
1356            true,
1357            false,
1358            false,
1359            true,
1360        );
1361
1362        // Create a root transform for the gizmo visuals under the GizmoComponent node.
1363        let gizmo_root =
1364            world.add_component_boxed_named("gizmo_root", Box::new(TransformComponent::new()));
1365        let _ = world.add_child(gizmo_output, gizmo_root);
1366
1367        // Camera-specific configuration children are ordinary TRS values. The stream
1368        // evaluates the anchor first and then composes exactly one settings transform.
1369        for (name, marker) in [
1370            (
1371                "gizmo_camera_mono",
1372                TransformCameraSpecificComponent::active_monoscopic(),
1373            ),
1374            (
1375                "gizmo_camera_stereo",
1376                TransformCameraSpecificComponent::active_stereoscopic(),
1377            ),
1378        ] {
1379            let mode = world.add_component_boxed_named(name, Box::new(marker));
1380            let settings = world.add_component_boxed_named(
1381                format!("{name}_settings"),
1382                Box::new(TransformComponent::new().with_scale(
1383                    gizmo_local_scale,
1384                    gizmo_local_scale,
1385                    gizmo_local_scale,
1386                )),
1387            );
1388            let _ = world.add_child(gizmo_root, mode);
1389            let _ = world.add_child(mode, settings);
1390        }
1391
1392        // Wrap all gizmo visuals in an overlay marker so they render in the overlay pass.
1393        let gizmo_overlay =
1394            world.add_component_boxed_named("gizmo_overlay", Box::new(OverlayComponent::new()));
1395        let _ = world.add_child(gizmo_root, gizmo_overlay);
1396
1397        let gizmo_visual_parent = gizmo_overlay;
1398
1399        // Resolve editor settings (coord spaces) by walking up ancestry to the nearest EditorComponent.
1400        let mut translation_space = TransformGizmoCoordSpace::World;
1401        let mut rotation_space = TransformGizmoCoordSpace::Local;
1402        {
1403            let mut cur = Some(component);
1404            while let Some(node) = cur {
1405                if let Some(ed) = world.get_component_by_id_as::<EditorComponent>(node) {
1406                    translation_space = ed.transform_gizmo_translation_space;
1407                    rotation_space = ed.transform_gizmo_rotation_space;
1408                    break;
1409                }
1410                cur = world.parent_of(node);
1411            }
1412        }
1413
1414        // Create two coord-space groups so translation and rotation handles can be oriented
1415        // independently (e.g. translate in World while rotating in Local).
1416        //
1417        // These pipeline groups sit under the gizmo's uniform scale transform (`gizmo_root`), so
1418        // they keep gizmo size but can optionally drop inherited rotation.
1419        let gizmo_space_world = add_pipeline_group(
1420            world,
1421            gizmo_visual_parent,
1422            "gizmo_space_world_pipeline",
1423            true,
1424            true,
1425            true,
1426            false,
1427            true,
1428            false,
1429        );
1430        let gizmo_space_local = add_pipeline_group(
1431            world,
1432            gizmo_visual_parent,
1433            "gizmo_space_local_pipeline",
1434            true,
1435            true,
1436            true,
1437            false,
1438            false,
1439            false,
1440        );
1441
1442        let translate_parent = match translation_space {
1443            TransformGizmoCoordSpace::World => gizmo_space_world,
1444            TransformGizmoCoordSpace::Local => gizmo_space_local,
1445        };
1446
1447        let rotate_parent = match rotation_space {
1448            TransformGizmoCoordSpace::World => gizmo_space_world,
1449            TransformGizmoCoordSpace::Local => gizmo_space_local,
1450        };
1451
1452        // Write back visual root.
1453        if let Some(g) = world.get_component_by_id_as_mut::<TransformGizmoComponent>(component) {
1454            g.visual_root = Some(gizmo_root);
1455        }
1456        self.live_gizmos.insert(component);
1457
1458        // Helper: spawn a renderable under a transform with color+emissive.
1459        fn spawn_part(
1460            world: &mut World,
1461            parent: ComponentId,
1462            name: &str,
1463            mesh: CpuMeshHandle,
1464            pos: [f32; 3],
1465            rot_euler: [f32; 3],
1466            scale: [f32; 3],
1467            rgba: [f32; 4],
1468        ) {
1469            use crate::engine::ecs::component::{
1470                ColorComponent, EmissiveComponent, RenderableComponent, TransformComponent,
1471            };
1472            use crate::engine::graphics::primitives::{MaterialHandle, Renderable};
1473
1474            let t = world.add_component_boxed_named(
1475                format!("{name}_t"),
1476                Box::new(
1477                    TransformComponent::new()
1478                        .with_position(pos[0], pos[1], pos[2])
1479                        .with_rotation_euler(rot_euler[0], rot_euler[1], rot_euler[2])
1480                        .with_scale(scale[0], scale[1], scale[2]),
1481                ),
1482            );
1483            let r = world.add_component_boxed_named(
1484                format!("{name}_r"),
1485                Box::new(RenderableComponent::new(Renderable::new(
1486                    mesh,
1487                    MaterialHandle::TOON_MESH,
1488                ))),
1489            );
1490            let c = world.add_component_boxed_named(
1491                format!("{name}_color"),
1492                Box::new(ColorComponent::rgba(rgba[0], rgba[1], rgba[2], rgba[3])),
1493            );
1494            let e = world.add_component_boxed_named(
1495                format!("{name}_emissive"),
1496                Box::new(EmissiveComponent::on()),
1497            );
1498
1499            let _ = world.add_child(parent, t);
1500            let _ = world.add_child(t, r);
1501            let _ = world.add_child(r, c);
1502            let _ = world.add_child(r, e);
1503        }
1504
1505        // Helper: create a single raycastable root node for a handle subtree.
1506        // Descendant renderables become BVH-eligible via ancestry.
1507        fn spawn_raycastable_root(
1508            world: &mut World,
1509            parent: ComponentId,
1510            name: &str,
1511        ) -> ComponentId {
1512            use crate::engine::ecs::component::RaycastableComponent;
1513
1514            let rc = world.add_component_boxed_named(
1515                name,
1516                Box::new(RaycastableComponent::drag_only().with_interaction_priority(1)),
1517            );
1518            let _ = world.add_child(parent, rc);
1519            rc
1520        }
1521
1522        fn spawn_gesture_coord_type_root(
1523            world: &mut World,
1524            parent: ComponentId,
1525            name: &str,
1526            coord_type: GestureCoordType,
1527        ) -> ComponentId {
1528            let c = world.add_component_boxed_named(
1529                name,
1530                Box::new(GestureCoordTypeComponent::new(coord_type)),
1531            );
1532            let _ = world.add_child(parent, c);
1533            c
1534        }
1535
1536        fn spawn_translate_handle_root(
1537            world: &mut World,
1538            parent: ComponentId,
1539            axis: TransformGizmoAxis,
1540            name: &str,
1541        ) -> ComponentId {
1542            let h = world.add_component_boxed_named(
1543                name,
1544                Box::new(TransformGizmoTranslateComponent::new(axis)),
1545            );
1546            let _ = world.add_child(parent, h);
1547            h
1548        }
1549
1550        fn spawn_rotate_handle_root(
1551            world: &mut World,
1552            parent: ComponentId,
1553            axis: TransformGizmoAxis,
1554            name: &str,
1555        ) -> ComponentId {
1556            let h = world.add_component_boxed_named(
1557                name,
1558                Box::new(TransformGizmoRotateComponent::new(axis)),
1559            );
1560            let _ = world.add_child(parent, h);
1561            h
1562        }
1563
1564        // Axis colors.
1565        let red = [1.0, 0.15, 0.15, 1.0];
1566        let green = [0.15, 1.0, 0.15, 1.0];
1567        let blue = [0.15, 0.35, 1.0, 1.0];
1568
1569        // Rotation rings (thin annulus) for X/Y/Z axes.
1570        let ring_mesh = CpuMeshHandle::CIRCLE_2D;
1571        let ring_scale = [1.4, 1.4, 1.0];
1572
1573        // Rotation rings live under per-axis rotate handle components.
1574        let rot_x_root =
1575            spawn_rotate_handle_root(world, rotate_parent, TransformGizmoAxis::X, "gizmo_rot_x");
1576        let rot_x_coord = spawn_gesture_coord_type_root(
1577            world,
1578            rot_x_root,
1579            "gizmo_rot_x_coord",
1580            GestureCoordType::ScreenSpace1DSlider,
1581        );
1582        let rot_x_pick = spawn_raycastable_root(world, rot_x_coord, "gizmo_rot_x_pick");
1583        spawn_part(
1584            world,
1585            rot_x_pick,
1586            "gizmo_rot_x_ring",
1587            ring_mesh,
1588            [0.0, 0.0, 0.0],
1589            [0.0, -std::f32::consts::FRAC_PI_2, 0.0],
1590            ring_scale,
1591            red,
1592        );
1593
1594        let rot_y_root =
1595            spawn_rotate_handle_root(world, rotate_parent, TransformGizmoAxis::Y, "gizmo_rot_y");
1596        let rot_y_coord = spawn_gesture_coord_type_root(
1597            world,
1598            rot_y_root,
1599            "gizmo_rot_y_coord",
1600            GestureCoordType::ScreenSpace1DSlider,
1601        );
1602        let rot_y_pick = spawn_raycastable_root(world, rot_y_coord, "gizmo_rot_y_pick");
1603        spawn_part(
1604            world,
1605            rot_y_pick,
1606            "gizmo_rot_y_ring",
1607            ring_mesh,
1608            [0.0, 0.0, 0.0],
1609            [std::f32::consts::FRAC_PI_2, 0.0, 0.0],
1610            ring_scale,
1611            green,
1612        );
1613
1614        let rot_z_root =
1615            spawn_rotate_handle_root(world, rotate_parent, TransformGizmoAxis::Z, "gizmo_rot_z");
1616        let rot_z_coord = spawn_gesture_coord_type_root(
1617            world,
1618            rot_z_root,
1619            "gizmo_rot_z_coord",
1620            GestureCoordType::ScreenSpace1DSlider,
1621        );
1622        let rot_z_pick = spawn_raycastable_root(world, rot_z_coord, "gizmo_rot_z_pick");
1623        spawn_part(
1624            world,
1625            rot_z_pick,
1626            "gizmo_rot_z_ring",
1627            ring_mesh,
1628            [0.0, 0.0, 0.0],
1629            [0.0, 0.0, 0.0],
1630            ring_scale,
1631            blue,
1632        );
1633
1634        // Translation arrows: stem (cube) + cone tip.
1635        let stem_mesh = CpuMeshHandle::CUBE;
1636        let cone_mesh = CpuMeshHandle::CONE;
1637        let stem_len = 1.0_f32;
1638        let stem_thick = 0.06_f32;
1639        let cone_len = 0.22_f32;
1640        let cone_radius = 0.12_f32;
1641
1642        // Translation arrows live under per-axis translate handle components.
1643        let move_x_root = spawn_translate_handle_root(
1644            world,
1645            translate_parent,
1646            TransformGizmoAxis::X,
1647            "gizmo_move_x",
1648        );
1649        let move_x_pick = spawn_raycastable_root(world, move_x_root, "gizmo_move_x_pick");
1650        // +X arrow: rotate +Z axis to +X (yaw +90deg).
1651        let rot_x = [0.0, std::f32::consts::FRAC_PI_2, 0.0];
1652        spawn_part(
1653            world,
1654            move_x_pick,
1655            "gizmo_move_x_stem",
1656            stem_mesh,
1657            [stem_len * 0.5, 0.0, 0.0],
1658            [0.0, 0.0, 0.0],
1659            [stem_len, stem_thick, stem_thick],
1660            red,
1661        );
1662        spawn_part(
1663            world,
1664            move_x_pick,
1665            "gizmo_move_x_tip",
1666            cone_mesh,
1667            [stem_len + cone_len * 0.5, 0.0, 0.0],
1668            rot_x,
1669            [cone_radius, cone_radius, cone_len],
1670            red,
1671        );
1672
1673        let move_y_root = spawn_translate_handle_root(
1674            world,
1675            translate_parent,
1676            TransformGizmoAxis::Y,
1677            "gizmo_move_y",
1678        );
1679        let move_y_pick = spawn_raycastable_root(world, move_y_root, "gizmo_move_y_pick");
1680        // +Y arrow: rotate +Z axis to +Y (pitch -90deg around X).
1681        let rot_y = [-std::f32::consts::FRAC_PI_2, 0.0, 0.0];
1682        spawn_part(
1683            world,
1684            move_y_pick,
1685            "gizmo_move_y_stem",
1686            stem_mesh,
1687            [0.0, stem_len * 0.5, 0.0],
1688            [0.0, 0.0, 0.0],
1689            [stem_thick, stem_len, stem_thick],
1690            green,
1691        );
1692        spawn_part(
1693            world,
1694            move_y_pick,
1695            "gizmo_move_y_tip",
1696            cone_mesh,
1697            [0.0, stem_len + cone_len * 0.5, 0.0],
1698            rot_y,
1699            [cone_radius, cone_radius, cone_len],
1700            green,
1701        );
1702
1703        let move_z_root = spawn_translate_handle_root(
1704            world,
1705            translate_parent,
1706            TransformGizmoAxis::Z,
1707            "gizmo_move_z",
1708        );
1709        let move_z_pick = spawn_raycastable_root(world, move_z_root, "gizmo_move_z_pick");
1710        // +Z arrow: no rotation.
1711        spawn_part(
1712            world,
1713            move_z_pick,
1714            "gizmo_move_z_stem",
1715            stem_mesh,
1716            [0.0, 0.0, stem_len * 0.5],
1717            [0.0, 0.0, 0.0],
1718            [stem_thick, stem_thick, stem_len],
1719            blue,
1720        );
1721        spawn_part(
1722            world,
1723            move_z_pick,
1724            "gizmo_move_z_tip",
1725            cone_mesh,
1726            [0.0, 0.0, stem_len + cone_len * 0.5],
1727            [0.0, 0.0, 0.0],
1728            [cone_radius, cone_radius, cone_len],
1729            blue,
1730        );
1731
1732        // Init the subtree (queues renderable/transform/color registrations).
1733        world.init_component_tree(gizmo_root, emit);
1734    }
1735
1736    /// Resolve (gizmo, operation) for a hit renderable by walking up ancestry.
1737    ///
1738    /// Contract: the TRS handle component must be an ancestor of the clicked renderable.
1739    fn resolve_gizmo_op_for_renderable(
1740        world: &World,
1741        renderable: ComponentId,
1742    ) -> Option<(ComponentId, TransformGizmoOp)> {
1743        let mut cur = Some(renderable);
1744        let mut op: Option<TransformGizmoOp> = None;
1745        let mut gizmo: Option<ComponentId> = None;
1746
1747        while let Some(node) = cur {
1748            if op.is_none() {
1749                if let Some(h) =
1750                    world.get_component_by_id_as::<TransformGizmoTranslateComponent>(node)
1751                {
1752                    op = Some(TransformGizmoOp::Translate(h.axis));
1753                } else if let Some(h) =
1754                    world.get_component_by_id_as::<TransformGizmoRotateComponent>(node)
1755                {
1756                    op = Some(TransformGizmoOp::Rotate(h.axis));
1757                } else if let Some(h) =
1758                    world.get_component_by_id_as::<TransformGizmoScaleComponent>(node)
1759                {
1760                    op = Some(TransformGizmoOp::Scale(h.axis));
1761                }
1762            }
1763
1764            if gizmo.is_none()
1765                && world
1766                    .get_component_by_id_as::<TransformGizmoComponent>(node)
1767                    .is_some()
1768            {
1769                gizmo = Some(node);
1770            }
1771
1772            if op.is_some() && gizmo.is_some() {
1773                break;
1774            }
1775
1776            cur = world.parent_of(node);
1777        }
1778
1779        let resolved = Some((gizmo?, op?));
1780
1781        if Self::debug_hit_enabled() {
1782            let (gizmo_cid, op) = resolved?;
1783            let renderable_name = world
1784                .get_component_record(renderable)
1785                .map(|n| {
1786                    if n.name.is_empty() {
1787                        n.component_type.clone()
1788                    } else {
1789                        format!("{}: {}", n.component_type, n.name)
1790                    }
1791                })
1792                .unwrap_or_else(|| "<missing>".to_string());
1793            let gizmo_name = world
1794                .get_component_record(gizmo_cid)
1795                .map(|n| {
1796                    if n.name.is_empty() {
1797                        n.component_type.clone()
1798                    } else {
1799                        format!("{}: {}", n.component_type, n.name)
1800                    }
1801                })
1802                .unwrap_or_else(|| "<missing>".to_string());
1803            let target_transform = world
1804                .get_component_by_id_as::<TransformGizmoComponent>(gizmo_cid)
1805                .and_then(|g| g.target_transform);
1806            let target_name = target_transform
1807                .and_then(|cid| world.get_component_record(cid).map(|n| (cid, n)))
1808                .map(|(cid, n)| {
1809                    if n.name.is_empty() {
1810                        format!("{cid:?} '{}'", n.component_type)
1811                    } else {
1812                        format!("{cid:?} '{}: {}'", n.component_type, n.name)
1813                    }
1814                })
1815                .unwrap_or_else(|| "<none>".to_string());
1816            println!(
1817                "[TransformGizmoSystem] resolve_gizmo_op renderable={renderable:?} '{}' gizmo={gizmo_cid:?} '{}' op={op:?} target={target_name}",
1818                renderable_name, gizmo_name,
1819            );
1820            return Some((gizmo_cid, op));
1821        }
1822
1823        resolved
1824    }
1825
1826    fn resolve_gesture_coord_type_for_renderable(
1827        world: &World,
1828        renderable: ComponentId,
1829    ) -> Option<GestureCoordType> {
1830        let mut cur = Some(renderable);
1831        while let Some(node) = cur {
1832            if let Some(c) = world.get_component_by_id_as::<GestureCoordTypeComponent>(node) {
1833                return Some(c.coord_type);
1834            }
1835            cur = world.parent_of(node);
1836        }
1837        None
1838    }
1839
1840    #[allow(dead_code)]
1841    fn gizmos_for_hit_renderable(world: &World, renderable: ComponentId) -> Vec<ComponentId> {
1842        let mut out: Vec<ComponentId> = world
1843            .children_of(renderable)
1844            .iter()
1845            .copied()
1846            .filter(|&ch| {
1847                world
1848                    .get_component_by_id_as::<TransformGizmoComponent>(ch)
1849                    .is_some()
1850            })
1851            .collect();
1852
1853        // Also support gizmo-as-ancestor (new gizmo visuals are children of the gizmo node).
1854        let mut cur = Some(renderable);
1855        while let Some(node) = cur {
1856            if world
1857                .get_component_by_id_as::<TransformGizmoComponent>(node)
1858                .is_some()
1859            {
1860                out.push(node);
1861            }
1862            cur = world.parent_of(node);
1863        }
1864
1865        out.sort();
1866        out.dedup();
1867        out
1868    }
1869
1870    pub fn tick_with_queue(
1871        &mut self,
1872        world: &mut World,
1873        _input: &InputState,
1874        emit: &mut dyn SignalEmitter,
1875        _rx: &mut RxWorld,
1876    ) {
1877        // Handler-driven: drag + parent events are handled during drain points.
1878        // Keep `tick_with_queue` as a no-op entrypoint for now.
1879        let _ = (world, emit);
1880    }
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885    use super::TransformGizmoSystem;
1886    use crate::engine::ecs::World;
1887    use crate::engine::ecs::component::{
1888        TransformComponent, TransformGizmoAxis, TransformGizmoCoordSpace,
1889    };
1890
1891    fn approx3(a: [f32; 3], b: [f32; 3]) {
1892        for i in 0..3 {
1893            assert!(
1894                (a[i] - b[i]).abs() < 1e-4,
1895                "index {i}: left={:?} right={:?}",
1896                a,
1897                b
1898            );
1899        }
1900    }
1901
1902    #[test]
1903    fn world_delta_is_converted_through_rotated_parent_inverse() {
1904        let mut world = World::default();
1905        let parent = world.add_component(TransformComponent::new().with_rotation_euler(
1906            0.0,
1907            0.0,
1908            std::f32::consts::FRAC_PI_2,
1909        ));
1910        let target = world.add_component(TransformComponent::new());
1911        world.add_child(parent, target).expect("attach target");
1912
1913        let parent_world = world
1914            .get_component_by_id_as::<TransformComponent>(parent)
1915            .expect("parent transform")
1916            .transform
1917            .model;
1918        world
1919            .get_component_by_id_as_mut::<TransformComponent>(parent)
1920            .expect("parent transform")
1921            .transform
1922            .matrix_world = parent_world;
1923
1924        let local =
1925            TransformGizmoSystem::world_delta_to_target_local(&world, target, [1.0, 0.0, 0.0]);
1926        approx3(local, [0.0, -1.0, 0.0]);
1927    }
1928
1929    #[test]
1930    fn local_translation_axis_uses_target_world_rotation() {
1931        let mut world = World::default();
1932        let target = world.add_component(TransformComponent::new().with_rotation_euler(
1933            0.0,
1934            0.0,
1935            std::f32::consts::FRAC_PI_2,
1936        ));
1937        let target_world = world
1938            .get_component_by_id_as::<TransformComponent>(target)
1939            .expect("target transform")
1940            .transform
1941            .model;
1942        world
1943            .get_component_by_id_as_mut::<TransformComponent>(target)
1944            .expect("target transform")
1945            .transform
1946            .matrix_world = target_world;
1947        let axis = TransformGizmoSystem::translation_axis_world(
1948            &world,
1949            target,
1950            TransformGizmoCoordSpace::Local,
1951            TransformGizmoAxis::X,
1952        );
1953        approx3(axis, [0.0, 1.0, 0.0]);
1954    }
1955
1956    #[test]
1957    fn translation_drag_uses_drag_start_anchor_instead_of_frame_delta() {
1958        let mut world = World::default();
1959        let target = world.add_component(TransformComponent::new().with_position(10.0, 0.0, 0.0));
1960
1961        let next = TransformGizmoSystem::translation_drag_next_local(
1962            &world,
1963            target,
1964            [1.0, 0.0, 0.0],
1965            [0.0, 0.0, 0.0],
1966            [10.0, 0.0, 0.0],
1967            [0.25, 0.5, 0.0],
1968        );
1969
1970        approx3(next, [10.25, 0.0, 0.0]);
1971    }
1972
1973    #[test]
1974    fn translation_drag_respects_rotated_parent_space_from_drag_start() {
1975        let mut world = World::default();
1976        let parent = world.add_component(TransformComponent::new().with_rotation_euler(
1977            0.0,
1978            0.0,
1979            std::f32::consts::FRAC_PI_2,
1980        ));
1981        let target = world.add_component(TransformComponent::new().with_position(0.0, 2.0, 0.0));
1982        world.add_child(parent, target).expect("attach target");
1983
1984        let parent_world = world
1985            .get_component_by_id_as::<TransformComponent>(parent)
1986            .expect("parent transform")
1987            .transform
1988            .model;
1989        world
1990            .get_component_by_id_as_mut::<TransformComponent>(parent)
1991            .expect("parent transform")
1992            .transform
1993            .matrix_world = parent_world;
1994
1995        let next = TransformGizmoSystem::translation_drag_next_local(
1996            &world,
1997            target,
1998            [1.0, 0.0, 0.0],
1999            [0.0, 0.0, 0.0],
2000            [0.0, 2.0, 0.0],
2001            [1.0, 0.25, 0.0],
2002        );
2003
2004        approx3(next, [0.0, 1.0, 0.0]);
2005    }
2006
2007    #[test]
2008    fn translation_local_world_conversion_uses_parent_space_not_target_rotation() {
2009        let mut world = World::default();
2010        let parent = world.add_component(TransformComponent::new());
2011        let target = world.add_component(
2012            TransformComponent::new()
2013                .with_position(1.0, 2.0, 3.0)
2014                .with_rotation_euler(std::f32::consts::FRAC_PI_2, 0.0, 0.0),
2015        );
2016        world.add_child(parent, target).expect("attach target");
2017
2018        let world_point = TransformGizmoSystem::target_translation_local_to_world(
2019            &world,
2020            target,
2021            [4.0, 5.0, 6.0],
2022        );
2023        approx3(world_point, [4.0, 5.0, 6.0]);
2024
2025        let local_point = TransformGizmoSystem::world_point_to_target_translation_local(
2026            &world,
2027            target,
2028            [7.0, 8.0, 9.0],
2029        );
2030        approx3(local_point, [7.0, 8.0, 9.0]);
2031    }
2032}