mittens-engine 0.7.0

A Vulkan and OpenXR scene engine with ECS, reactive signals, and Meow Meow scripting
use crate::engine::ecs::component::{
    EditorComponent, SelectableComponent, TransformComponent, TransformGizmoComponent,
};
use crate::engine::ecs::{ComponentId, World};
use std::sync::OnceLock;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorldSceneHit {
    pub editor_root: Option<ComponentId>,
    pub target_renderable: ComponentId,
    pub target_transform: ComponentId,
}

fn debug_editor_scene_hit_enabled() -> bool {
    static ENABLED: OnceLock<bool> = OnceLock::new();
    *ENABLED.get_or_init(|| {
        let v = std::env::var("CAT_DEBUG_EDITOR_SCENE_HIT").unwrap_or_default();
        let v = v.trim().to_ascii_lowercase();
        matches!(v.as_str(), "1" | "true" | "yes" | "on")
    })
}

fn debug_component_label(world: &World, component: ComponentId) -> String {
    world
        .get_component_record(component)
        .map(|n| {
            if n.name.is_empty() {
                n.component_type.clone()
            } else {
                format!("{}: {}", n.component_type, n.name)
            }
        })
        .unwrap_or_else(|| "<missing>".to_string())
}

pub fn resolve_world_scene_hit(world: &World, renderable: ComponentId) -> Option<WorldSceneHit> {
    // Runtime editor panels may be camera-anchored, but they are controls rather than
    // scene geometry. Never let the global editor picker select their camera/anchor transform.
    if has_named_ancestor(world, renderable, "editor_runtime_ui_root") {
        return None;
    }
    let editor_root = nearest_editor_ancestor(world, renderable);
    let blocked_by_selectable = has_selectable_off_ancestor(world, renderable);
    let blocked_by_gizmo = has_transform_gizmo_ancestor(world, renderable);
    if blocked_by_selectable || blocked_by_gizmo {
        if debug_editor_scene_hit_enabled() {
            println!(
                "[WorldSceneHit] reject renderable={renderable:?} '{}' editor_root={editor_root:?} selectable_off={} gizmo_ancestor={}",
                debug_component_label(world, renderable),
                blocked_by_selectable,
                blocked_by_gizmo,
            );
        }
        return None;
    }
    let target_transform = preferred_scene_selection_transform(world, renderable)
        .or_else(|| nearest_transform_ancestor(world, renderable))?;
    if debug_editor_scene_hit_enabled() {
        println!(
            "[WorldSceneHit] accept renderable={renderable:?} '{}' -> target_transform={target_transform:?} '{}' editor_root={editor_root:?}",
            debug_component_label(world, renderable),
            debug_component_label(world, target_transform),
        );
    }
    Some(WorldSceneHit {
        editor_root,
        target_renderable: renderable,
        target_transform,
    })
}

fn has_named_ancestor(world: &World, start: ComponentId, name: &str) -> bool {
    let mut current = Some(start);
    while let Some(component_id) = current {
        if world.component_label(component_id) == Some(name) {
            return true;
        }
        current = world.parent_of(component_id);
    }
    false
}

fn preferred_scene_selection_transform(world: &World, start: ComponentId) -> Option<ComponentId> {
    let mut cur = Some(start);
    while let Some(node) = cur {
        if world.component_label(node) == Some("camera_visualization_marker") {
            return world.parent_of(node).filter(|parent| {
                world
                    .get_component_by_id_as::<TransformComponent>(*parent)
                    .is_some()
            });
        }
        if world.component_label(node) == Some("painted_asset_root") {
            return Some(node);
        }
        cur = world.parent_of(node);
    }
    None
}

pub fn nearest_editor_ancestor(world: &World, start: ComponentId) -> Option<ComponentId> {
    let mut cur = Some(start);
    while let Some(node) = cur {
        if world
            .get_component_by_id_as::<EditorComponent>(node)
            .is_some()
        {
            return Some(node);
        }
        cur = world.parent_of(node);
    }
    None
}

pub fn nearest_transform_ancestor(world: &World, start: ComponentId) -> Option<ComponentId> {
    let mut cur = Some(start);
    while let Some(node) = cur {
        if world
            .get_component_by_id_as::<TransformComponent>(node)
            .is_some()
        {
            return Some(node);
        }
        cur = world.parent_of(node);
    }
    None
}

pub fn has_transform_gizmo_ancestor(world: &World, start: ComponentId) -> bool {
    let mut cur = Some(start);
    while let Some(node) = cur {
        if world
            .get_component_by_id_as::<TransformGizmoComponent>(node)
            .is_some()
        {
            return true;
        }
        cur = world.parent_of(node);
    }
    false
}

pub fn has_selectable_off_ancestor(world: &World, start: ComponentId) -> bool {
    let mut cur = Some(start);
    while let Some(node) = cur {
        if has_selectable_off_marker(world, node) {
            return true;
        }
        cur = world.parent_of(node);
    }
    false
}

fn has_selectable_off_marker(world: &World, component_id: ComponentId) -> bool {
    world
        .get_component_by_id_as::<SelectableComponent>(component_id)
        .map(|s| !s.enabled)
        .unwrap_or(false)
        || world
            .children_of(component_id)
            .iter()
            .copied()
            .any(|child| {
                world
                    .get_component_by_id_as::<SelectableComponent>(child)
                    .map(|s| !s.enabled)
                    .unwrap_or(false)
            })
}

#[cfg(test)]
mod tests {
    use super::resolve_world_scene_hit;
    use crate::engine::ecs::World;
    use crate::engine::ecs::component::{
        EditorComponent, RenderableComponent, SelectableComponent, TransformComponent,
    };

    #[test]
    fn runtime_editor_ui_is_not_a_world_scene_hit() {
        let mut world = World::default();
        let camera_anchor = world.add_component(TransformComponent::new());
        let ui_root = world.add_component_boxed_named(
            "editor_runtime_ui_root",
            Box::new(TransformComponent::new()),
        );
        let title_bar = world.add_component(TransformComponent::new());
        let renderable = world.add_component(RenderableComponent::cube());
        world.add_child(camera_anchor, ui_root).expect("attach ui");
        world.add_child(ui_root, title_bar).expect("attach title");
        world
            .add_child(title_bar, renderable)
            .expect("attach renderable");

        assert!(resolve_world_scene_hit(&world, renderable).is_none());
    }

    #[test]
    fn painted_asset_hits_resolve_to_wrapper_root() {
        let mut world = World::default();
        let editor = world.add_component(EditorComponent::new());
        let raycastable_root = world.add_component_boxed_named(
            "painted_asset_raycastable",
            Box::new(TransformComponent::new()),
        );
        let painted_root = world
            .add_component_boxed_named("painted_asset_root", Box::new(TransformComponent::new()));
        let internal = world
            .add_component_boxed_named("internal_mesh_root", Box::new(TransformComponent::new()));
        let renderable = world.add_component(RenderableComponent::cube());

        world
            .add_child(editor, raycastable_root)
            .expect("attach raycastable");
        world
            .add_child(raycastable_root, painted_root)
            .expect("attach painted root");
        world
            .add_child(painted_root, internal)
            .expect("attach internal");
        world
            .add_child(internal, renderable)
            .expect("attach renderable");

        let hit = resolve_world_scene_hit(&world, renderable).expect("scene hit");
        assert_eq!(hit.target_transform, painted_root);
        assert_eq!(hit.editor_root, Some(editor));
    }

    #[test]
    fn selectable_off_child_on_ancestor_blocks_scene_hit() {
        let mut world = World::default();
        let editor = world.add_component(EditorComponent::new());
        let scene_root = world.add_component(TransformComponent::new());
        let selectable = world.add_component(SelectableComponent::off());
        let child = world.add_component(TransformComponent::new());
        let renderable = world.add_component(RenderableComponent::cube());

        world
            .add_child(editor, scene_root)
            .expect("attach scene root");
        world
            .add_child(scene_root, selectable)
            .expect("attach selectable marker");
        world.add_child(scene_root, child).expect("attach child");
        world
            .add_child(child, renderable)
            .expect("attach renderable");

        assert!(resolve_world_scene_hit(&world, renderable).is_none());
    }
}