Skip to main content

mittens_engine/engine/ecs/system/
editor_paint_system.rs

1use std::collections::HashSet;
2use std::sync::{Arc, Mutex};
3use std::time::Instant;
4
5use crate::engine::ecs::component::{
6    DataComponent, DataValue, EditorComponent, OptionComponent, RaycastableComponent,
7    SelectableComponent, SelectionComponent, TransformComponent, TransformGizmoComponent,
8};
9use crate::engine::ecs::system::editor::context::{
10    EDITOR_WORKSPACE_ASSET_SELECTION_CHANGED, EDITOR_WORKSPACE_COLOR_SELECTION_CHANGED,
11    EditorContextState,
12};
13use crate::engine::ecs::system::editor_paint_system_state_manager::{
14    COLOR_PANEL_ROOT_SELECTOR, COLOR_PANEL_SELECTION_SELECTOR, COLOR_SWATCH_PAYLOAD_NAME,
15    PaintEvent, PaintState, PaintTool, is_paint_active, is_paint_panel_focused,
16    is_paint_workspace_focused, paint_tool_from_item, reduce_paint_state,
17};
18use crate::engine::ecs::system::editor_system::select_editor_target;
19use crate::engine::ecs::system::grid_system::{
20    GridSnapResult, GridSpawnSpec, GridStep, GridSystem, remap_grid_rotation_to_surface_up,
21};
22use crate::engine::ecs::system::object_placement_preview::{
23    PlacementKind, PlacementPreviewSession, PlacementPreviewStyle, commit_preview,
24    create_preview_shell, update_preview_pose,
25};
26use crate::engine::ecs::system::paint_placement::{
27    PlacementError, resolve_placement_pose, resolve_surface_aligned_pose_from_frame,
28    resolve_surface_placement_frame,
29};
30use crate::engine::ecs::{
31    ComponentId, EventSignal, IntentValue, RxWorld, Signal, SignalEmitter, SignalKind, World,
32};
33use crate::scripting::object::Value;
34use crate::scripting::runner::{LoadedMmsModule, MeowMeowRunner};
35
36const PAINT_PANEL_ROOT_SELECTOR: &str = "#paint_panel_root";
37const PAINT_TOOL_SELECTION_SELECTOR: &str = "#paint_tool_selection";
38const PAINT_STATUS_WRAP_SELECTOR: &str = "#paint_status_wrap";
39const PANEL_STATUS_VALUE_SELECTOR: &str = "#paint_panel_status_value";
40const RUNTIME_UI_ROOT_NAME: &str = "editor_runtime_ui_root";
41const EDITOR_WORKSPACE_GRIDS_CHANGED: &str = "EditorWorkspaceGridsChanged";
42
43fn paint_perf(label: &str, start: Instant, detail: impl AsRef<str>) {
44    eprintln!(
45        "🎨⏱️ paint_perf {label} took {:?} {}",
46        start.elapsed(),
47        detail.as_ref()
48    );
49}
50
51#[derive(Debug, Clone)]
52pub struct PaintAssetTemplate {
53    pub key: String,
54    pub title: String,
55    pub module: Arc<LoadedMmsModule>,
56    pub export_name: String,
57    pub param_names: Vec<String>,
58}
59
60#[derive(Debug, Clone, Default)]
61struct PaintStrokeRuntime {
62    active: bool,
63    captured_renderable: Option<ComponentId>,
64    non_grid_placed: bool,
65    last_grid_step: Option<GridStep>,
66    last_placed_position: Option<[f32; 3]>,
67    preview_session: Option<PlacementPreviewSession>,
68}
69
70#[derive(Debug, Default)]
71pub struct EditorPaintSystem {
72    installed_editor_roots: HashSet<ComponentId>,
73    shared_panel_handlers_installed: bool,
74    shared_state: Arc<Mutex<PaintState>>,
75    shared_templates: Arc<Mutex<Vec<PaintAssetTemplate>>>,
76}
77
78impl EditorPaintSystem {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    pub fn install_scoped_handlers_for_editor(
84        &mut self,
85        rx: &mut RxWorld,
86        world: &World,
87        grid_system: GridSystem,
88        editor_root: ComponentId,
89        panel_query_root: ComponentId,
90        editor_context_state: Arc<Mutex<EditorContextState>>,
91        templates: Vec<PaintAssetTemplate>,
92    ) {
93        *self
94            .shared_templates
95            .lock()
96            .expect("paint templates mutex poisoned") = templates;
97
98        if !self.shared_panel_handlers_installed {
99            self.shared_panel_handlers_installed = true;
100            install_shared_panel_handlers(
101                rx,
102                world,
103                panel_query_root,
104                grid_system.clone(),
105                Arc::clone(&self.shared_state),
106                Arc::clone(&editor_context_state),
107                Arc::clone(&self.shared_templates),
108            );
109            bootstrap_paint_state(world, panel_query_root, &self.shared_state);
110        }
111
112        if self.installed_editor_roots.contains(&editor_root) {
113            return;
114        }
115        self.installed_editor_roots.insert(editor_root);
116
117        let stroke_runtime = Arc::new(Mutex::new(PaintStrokeRuntime::default()));
118        install_editor_scene_handlers(
119            rx,
120            editor_root,
121            panel_query_root,
122            grid_system,
123            Arc::clone(&self.shared_state),
124            editor_context_state,
125            Arc::clone(&self.shared_templates),
126            stroke_runtime,
127        );
128    }
129}
130
131fn install_shared_panel_handlers(
132    rx: &mut RxWorld,
133    world: &World,
134    panel_query_root: ComponentId,
135    grid_system: GridSystem,
136    paint_state: Arc<Mutex<PaintState>>,
137    editor_context_state: Arc<Mutex<EditorContextState>>,
138    templates: Arc<Mutex<Vec<PaintAssetTemplate>>>,
139) {
140    let _ = world;
141
142    let state = Arc::clone(&paint_state);
143    let ctx = Arc::clone(&editor_context_state);
144    let tpl = Arc::clone(&templates);
145    let asset_grid_system = grid_system.clone();
146    rx.add_handler_closure_named(
147        SignalKind::DataEvent,
148        panel_query_root,
149        Some("paint_system".to_string()),
150        move |world, emit, signal| {
151            let Some(EventSignal::DataEvent { name, payload }) = signal.event.as_ref() else {
152                return;
153            };
154            if name != EDITOR_WORKSPACE_ASSET_SELECTION_CHANGED {
155                return;
156            }
157
158            let component = *payload;
159            let label = component.and_then(|id| label_from_component_id(world, id));
160            let event = PaintEvent::AssetSelectionChanged {
161                item: label,
162                component,
163            };
164            handle_paint_event(
165                world,
166                emit,
167                panel_query_root,
168                &asset_grid_system,
169                &tpl,
170                &state,
171                &ctx,
172                None,
173                &event,
174            );
175        },
176    );
177
178    let state = Arc::clone(&paint_state);
179    let ctx = Arc::clone(&editor_context_state);
180    let tpl = Arc::clone(&templates);
181    rx.add_handler_closure_named(
182        SignalKind::SelectionChanged,
183        panel_query_root,
184        Some("paint_system".to_string()),
185        move |world, emit, signal| {
186            let Some(ref event) = signal.event else { return };
187            let EventSignal::SelectionChanged {
188                selection_root,
189                selected_component,
190                selected_payload,
191                ..
192            } = event else { return };
193
194            let component = selected_payload.or(*selected_component);
195            let label = component.and_then(|id| label_from_component_id(world, id));
196
197            if let Some(id) = component {
198                if label.is_none() {
199                    if let Some(rec) = world.get_component_record(id) {
200                        let dc = world.get_component_by_id_as::<DataComponent>(id);
201                        let has_dc = dc.is_some();
202                        let entries_str = dc.map(|d| format!("{:?}", d.entries())).unwrap_or_default();
203                        if PAINT_DEBUG_LOGS {
204                            eprintln!("paint_debug bridge: label missing for component={id:?} type={} name={:?} has_DataComponent={} entries={}", rec.component_type, rec.name, has_dc, entries_str);
205                        }
206                    } else {
207                        if PAINT_DEBUG_LOGS {
208                            eprintln!("paint_debug bridge: component={id:?} has no record");
209                        }
210                    }
211                }
212            }
213
214            let is_tool = world
215                .find_component(panel_query_root, PAINT_TOOL_SELECTION_SELECTOR)
216                .is_some_and(|root| root == *selection_root);
217
218            if PAINT_DEBUG_LOGS {
219                println!(
220                    "paint_debug bridge SelectionChanged is_tool={is_tool} selection_root={selection_root:?} component={component:?} label={label:?}",
221                );
222            }
223            if is_tool {
224                let event = PaintEvent::ToolSelectionChanged {
225                    tool: paint_tool_from_item(label.clone()),
226                    item: label,
227                    component,
228                };
229                handle_paint_event(
230                    world,
231                    emit,
232                    panel_query_root,
233                    &grid_system,
234                    &tpl,
235                    &state,
236                    &ctx,
237                    None,
238                    &event,
239                );
240                return;
241            }
242
243            let is_color = world
244                .find_component(panel_query_root, COLOR_PANEL_SELECTION_SELECTOR)
245                .is_some_and(|root| root == *selection_root);
246            if is_color {
247                let rgba = component.and_then(|id| rgba_from_payload(world, id));
248                if let Some(runtime_ui_root) = world.all_components().find(|&component_id| {
249                    world.parent_of(component_id).is_none()
250                        && world.component_label(component_id) == Some(RUNTIME_UI_ROOT_NAME)
251                }) {
252                    emit.push_event(
253                        runtime_ui_root,
254                        EventSignal::DataEvent {
255                            name: EDITOR_WORKSPACE_COLOR_SELECTION_CHANGED.to_string(),
256                            payload: component,
257                        },
258                    );
259                }
260                let event = PaintEvent::ColorSelectionChanged {
261                    item: label,
262                    component,
263                    rgba,
264                };
265                handle_paint_event(
266                    world,
267                    emit,
268                    panel_query_root,
269                    &grid_system,
270                    &tpl,
271                    &state,
272                    &ctx,
273                    None,
274                    &event,
275                );
276            }
277        },
278    );
279}
280
281fn install_editor_scene_handlers(
282    rx: &mut RxWorld,
283    editor_root: ComponentId,
284    panel_query_root: ComponentId,
285    grid_system: GridSystem,
286    paint_state: Arc<Mutex<PaintState>>,
287    editor_context_state: Arc<Mutex<EditorContextState>>,
288    templates: Arc<Mutex<Vec<PaintAssetTemplate>>>,
289    stroke_runtime: Arc<Mutex<PaintStrokeRuntime>>,
290) {
291    for signal_kind in [
292        SignalKind::SelectionChanged,
293        SignalKind::Click,
294        SignalKind::DragStart,
295        SignalKind::DragMove,
296        SignalKind::DragEnd,
297    ] {
298        let state = Arc::clone(&paint_state);
299        let editor_context = Arc::clone(&editor_context_state);
300        let shared_templates = Arc::clone(&templates);
301        let runtime = Arc::clone(&stroke_runtime);
302        let grid_system = grid_system.clone();
303        rx.add_handler_closure_named(
304            signal_kind,
305            editor_root,
306            Some("paint_system".to_string()),
307            move |world, emit, signal| {
308                let Some(event) =
309                    paint_event_from_editor_signal(world, editor_root, panel_query_root, signal)
310                else {
311                    return;
312                };
313
314                handle_paint_event(
315                    world,
316                    emit,
317                    panel_query_root,
318                    &grid_system,
319                    &shared_templates,
320                    &state,
321                    &editor_context,
322                    Some(&runtime),
323                    &event,
324                );
325            },
326        );
327    }
328}
329
330fn handle_paint_event(
331    world: &mut World,
332    emit: &mut dyn SignalEmitter,
333    panel_query_root: ComponentId,
334    grid_system: &GridSystem,
335    templates: &Arc<Mutex<Vec<PaintAssetTemplate>>>,
336    paint_state: &Arc<Mutex<PaintState>>,
337    editor_context_state: &Arc<Mutex<EditorContextState>>,
338    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
339    event: &PaintEvent,
340) {
341    let total_start = Instant::now();
342    if PAINT_DEBUG_LOGS {
343        eprintln!("paint_debug handle_paint_event event={event:?}");
344    }
345    let reduce_start = Instant::now();
346    let (old_state, new_state) = {
347        let mut state = paint_state.lock().expect("paint state mutex poisoned");
348        let old_state = state.clone();
349        let new_state = reduce_paint_state(&old_state, event);
350        *state = new_state.clone();
351        if PAINT_DEBUG_LOGS {
352            eprintln!("paint_debug   old={old_state:?} new={new_state:?}");
353        }
354        (old_state, new_state)
355    };
356    paint_perf(
357        "handle_paint_event.reduce",
358        reduce_start,
359        format!("event={event:?}"),
360    );
361
362    let effects_start = Instant::now();
363    apply_paint_side_effects(
364        world,
365        emit,
366        panel_query_root,
367        grid_system,
368        templates,
369        editor_context_state,
370        &old_state,
371        &new_state,
372        event,
373        stroke_runtime,
374    );
375    paint_perf(
376        "handle_paint_event.side_effects",
377        effects_start,
378        format!("event={event:?}"),
379    );
380    paint_perf(
381        "handle_paint_event.total",
382        total_start,
383        format!("event={event:?}"),
384    );
385}
386
387fn bootstrap_paint_state(
388    world: &World,
389    panel_query_root: ComponentId,
390    paint_state: &Arc<Mutex<PaintState>>,
391) {
392    let mut events = Vec::new();
393
394    if let Some(tool_event) = bootstrap_selection_event(
395        world,
396        panel_query_root,
397        PAINT_TOOL_SELECTION_SELECTOR,
398        |selection, w| {
399            let label = label_from_selected_payload(w, selection);
400            PaintEvent::ToolSelectionChanged {
401                tool: paint_tool_from_item(label.clone()),
402                item: label,
403                component: selection.selected_component,
404            }
405        },
406    ) {
407        events.push(tool_event);
408    }
409
410    if let Some(color_event) = bootstrap_selection_event(
411        world,
412        panel_query_root,
413        COLOR_PANEL_SELECTION_SELECTOR,
414        |selection, w| {
415            let component = selection.selected_payload.or(selection.selected_component);
416            PaintEvent::ColorSelectionChanged {
417                item: component.and_then(|id| label_from_component_id(w, id)),
418                component,
419                rgba: component.and_then(|id| rgba_from_payload(w, id)),
420            }
421        },
422    ) {
423        events.push(color_event);
424    }
425
426    let state_str;
427    {
428        let mut state = paint_state.lock().expect("paint state mutex poisoned");
429        for event in &events {
430            *state = reduce_paint_state(&state, event);
431        }
432        // If the tool selection component exists but has no selected item,
433        // default to FreeDraw. The MMS template creates the selection
434        // component but doesn't set an initial selection.
435        if matches!(state.selected_tool, PaintTool::Unknown(None)) {
436            *state = reduce_paint_state(
437                &state,
438                &PaintEvent::ToolSelectionChanged {
439                    tool: PaintTool::FreeDraw,
440                    item: Some("FreeDraw".to_string()),
441                    component: None,
442                },
443            );
444        }
445        if state.selected_color.is_none() {
446            state.selected_color = Some([1.0, 1.0, 1.0, 1.0]);
447        }
448        state_str = format!("{state:?}");
449    }
450    if PAINT_DEBUG_LOGS {
451        eprintln!("paint_debug bootstrap_paint_state done -> {}", state_str);
452    }
453}
454
455fn label_from_component_id(world: &World, id: ComponentId) -> Option<String> {
456    if let Some(data) = world.get_component_by_id_as::<DataComponent>(id) {
457        if let Some(DataValue::Text(label)) = data.get("label") {
458            return Some(label.clone());
459        }
460    }
461    None
462}
463
464fn label_from_selected_payload(world: &World, selection: &SelectionComponent) -> Option<String> {
465    let payload_id = selection.selected_payload?;
466    label_from_component_id(world, payload_id)
467}
468
469fn rgba_from_payload(world: &World, id: ComponentId) -> Option<[f32; 4]> {
470    let data = world.get_component_by_id_as::<DataComponent>(id)?;
471    let DataValue::Text(text) = data.get("rgba")? else {
472        return None;
473    };
474    let parts: Vec<&str> = text.split(',').collect();
475    if parts.len() != 4 {
476        return None;
477    }
478    let mut rgba = [0.0; 4];
479    for (idx, value) in parts.iter().enumerate() {
480        rgba[idx] = value.parse::<f32>().ok()?;
481    }
482    Some(rgba)
483}
484
485fn bootstrap_selection_event<F>(
486    world: &World,
487    panel_query_root: ComponentId,
488    selector: &str,
489    event_builder: F,
490) -> Option<PaintEvent>
491where
492    F: FnOnce(&SelectionComponent, &World) -> PaintEvent,
493{
494    let selection_root = world.find_component(panel_query_root, selector)?;
495    let selection = world.get_component_by_id_as::<SelectionComponent>(selection_root)?;
496    Some(event_builder(selection, world))
497}
498
499fn paint_event_from_editor_signal(
500    world: &World,
501    editor_root: ComponentId,
502    panel_query_root: ComponentId,
503    signal: &Signal,
504) -> Option<PaintEvent> {
505    let event = match signal.event.as_ref()? {
506        EventSignal::SelectionChanged {
507            selection_root,
508            selected_component,
509            ..
510        } if *selection_root == editor_root => Some(PaintEvent::EditorSelectionChanged {
511            editor: editor_root,
512            component: *selected_component,
513        }),
514        EventSignal::Click {
515            renderable,
516            hit_point,
517            ..
518        } => eligible_scene_hit(world, editor_root, panel_query_root, *renderable).then_some(
519            PaintEvent::SceneClick {
520                editor: editor_root,
521                renderable: *renderable,
522                hit_point: *hit_point,
523            },
524        ),
525        EventSignal::DragStart {
526            renderable,
527            hit_point,
528            ..
529        } => eligible_scene_hit(world, editor_root, panel_query_root, *renderable).then_some(
530            PaintEvent::StrokeStarted {
531                editor: editor_root,
532                renderable: *renderable,
533                hit_point: *hit_point,
534            },
535        ),
536        EventSignal::DragMove {
537            renderable,
538            hit_point,
539            ..
540        } => eligible_scene_hit(world, editor_root, panel_query_root, *renderable).then_some(
541            PaintEvent::StrokeMoved {
542                editor: editor_root,
543                renderable: *renderable,
544                hit_point: *hit_point,
545            },
546        ),
547        EventSignal::DragEnd { .. } => Some(PaintEvent::StrokeEnded {
548            editor: editor_root,
549        }),
550        _ => None,
551    };
552
553    event
554}
555
556fn apply_paint_side_effects(
557    world: &mut World,
558    emit: &mut dyn SignalEmitter,
559    panel_query_root: ComponentId,
560    grid_system: &GridSystem,
561    templates: &Arc<Mutex<Vec<PaintAssetTemplate>>>,
562    editor_context_state: &Arc<Mutex<EditorContextState>>,
563    _old_state: &PaintState,
564    new_state: &PaintState,
565    event: &PaintEvent,
566    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
567) {
568    let total_start = Instant::now();
569    let editor_context = current_editor_context(editor_context_state);
570    update_last_scene_interacted_editor(world, editor_context_state, event);
571    let active_editor = event_active_editor(event).or(editor_context.active_editor);
572    paint_perf(
573        "apply_paint_side_effects.context",
574        total_start,
575        format!("event={event:?} active_editor={active_editor:?}"),
576    );
577    let mut status_override = None;
578    let templates_start = Instant::now();
579    let templates_lock = templates.lock().expect("paint templates mutex poisoned");
580    let templates = &*templates_lock;
581    paint_perf(
582        "apply_paint_side_effects.clone_templates",
583        templates_start,
584        format!("count={}", templates.len()),
585    );
586
587    let event_specific_start = Instant::now();
588    match event {
589        PaintEvent::SceneClick {
590            editor,
591            renderable,
592            hit_point,
593        } if Some(*editor) == active_editor => {
594            status_override = handle_scene_click(
595                world,
596                emit,
597                *editor,
598                panel_query_root,
599                grid_system,
600                templates,
601                new_state,
602                &editor_context,
603                stroke_runtime,
604                *renderable,
605                *hit_point,
606            );
607        }
608        PaintEvent::StrokeStarted {
609            editor,
610            renderable,
611            hit_point,
612        } if Some(*editor) == active_editor => {
613            let _ = hit_point;
614            if let Some(runtime) = stroke_runtime {
615                let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
616                if let Some(context) = resolve_paint_context(
617                    world,
618                    grid_system,
619                    *editor,
620                    panel_query_root,
621                    new_state,
622                    &editor_context,
623                    templates,
624                ) {
625                    *runtime = PaintStrokeRuntime {
626                        active: true,
627                        captured_renderable: Some(*renderable),
628                        non_grid_placed: false,
629                        last_grid_step: None,
630                        last_placed_position: None,
631                        preview_session: start_preview_session_for_tool(
632                            world,
633                            emit,
634                            *editor,
635                            *renderable,
636                            *hit_point,
637                            new_state.selected_tool.clone(),
638                            &context,
639                            &editor_context,
640                        ),
641                    };
642                } else {
643                    *runtime = PaintStrokeRuntime::default();
644                }
645            }
646        }
647        PaintEvent::StrokeMoved {
648            editor,
649            renderable,
650            hit_point,
651        } if Some(*editor) == active_editor => {
652            status_override = handle_stroke_move(
653                world,
654                emit,
655                *editor,
656                panel_query_root,
657                grid_system,
658                templates,
659                new_state,
660                &editor_context,
661                stroke_runtime,
662                *renderable,
663                *hit_point,
664            );
665        }
666        PaintEvent::StrokeEnded { .. } => {
667            if let Some(runtime) = stroke_runtime {
668                let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
669                if let Some(session) = runtime.preview_session.take() {
670                    commit_preview(world, session.preview_root_component_id);
671                    if session.placement_kind == PlacementKind::Grid {
672                        let _ = grid_system.set_grid_hidden(
673                            world,
674                            emit,
675                            session.preview_root_component_id,
676                            false,
677                        );
678                        grid_system.mark_dirty();
679                        emit.push_event(
680                            panel_query_root,
681                            EventSignal::DataEvent {
682                                name: EDITOR_WORKSPACE_GRIDS_CHANGED.to_string(),
683                                payload: Some(session.active_editor),
684                            },
685                        );
686                        select_editor_target(
687                            world,
688                            emit,
689                            session.active_editor,
690                            session.preview_root_component_id,
691                            false,
692                        );
693                    }
694                    status_override = Some("paint placed".to_string());
695                }
696                *runtime = PaintStrokeRuntime::default();
697            }
698        }
699        _ => {}
700    }
701    paint_perf(
702        "apply_paint_side_effects.event_specific",
703        event_specific_start,
704        format!("event={event:?} status_override={status_override:?}"),
705    );
706
707    let update_status_start = Instant::now();
708    update_paint_status(
709        world,
710        emit,
711        active_editor,
712        panel_query_root,
713        grid_system,
714        new_state,
715        &editor_context,
716        status_override,
717    );
718    paint_perf(
719        "apply_paint_side_effects.update_status",
720        update_status_start,
721        format!("event={event:?}"),
722    );
723    paint_perf(
724        "apply_paint_side_effects.total",
725        total_start,
726        format!("event={event:?}"),
727    );
728}
729
730fn current_editor_context(
731    editor_context_state: &Arc<Mutex<EditorContextState>>,
732) -> EditorContextState {
733    editor_context_state
734        .lock()
735        .expect("editor context mutex poisoned")
736        .clone()
737}
738
739fn event_active_editor(event: &PaintEvent) -> Option<ComponentId> {
740    match event {
741        PaintEvent::ActiveEditorChanged { editor } => *editor,
742        PaintEvent::WorldPanelSelectionChanged { editor, .. } => *editor,
743        PaintEvent::EditorSelectionChanged { editor, .. }
744        | PaintEvent::SceneClick { editor, .. }
745        | PaintEvent::StrokeStarted { editor, .. }
746        | PaintEvent::StrokeMoved { editor, .. }
747        | PaintEvent::StrokeEnded { editor } => Some(*editor),
748        PaintEvent::ColorSelectionChanged { .. } => None,
749        PaintEvent::AssetSelectionChanged { .. }
750        | PaintEvent::ToolSelectionChanged { .. }
751        | PaintEvent::PanelFocusChanged { .. } => None,
752    }
753}
754
755fn random_offset_xz(max_dist: f32) -> [f32; 3] {
756    use std::sync::atomic::{AtomicU32, Ordering};
757    static SEED: AtomicU32 = AtomicU32::new(987654321);
758
759    let mut x = SEED.load(Ordering::Relaxed);
760    if x == 0 {
761        x = 987654321;
762    }
763    x ^= x << 13;
764    x ^= x >> 17;
765    x ^= x << 5;
766    SEED.store(x, Ordering::Relaxed);
767
768    let r1 = ((x & 0xffff) as f32) / 65535.0; // [0, 1]
769
770    let mut y = x.wrapping_mul(1103515245).wrapping_add(12345);
771    if y == 0 {
772        y = 123456789;
773    }
774    y ^= y << 13;
775    y ^= y >> 17;
776    y ^= y << 5;
777    let r2 = ((y & 0xffff) as f32) / 65535.0; // [0, 1]
778
779    let r = r1.sqrt() * max_dist;
780    let theta = r2 * 2.0 * std::f32::consts::PI;
781
782    [r * theta.cos(), 0.0, r * theta.sin()]
783}
784
785fn find_painted_asset_root(world: &World, start: ComponentId) -> Option<ComponentId> {
786    let mut cur = Some(start);
787    while let Some(node) = cur {
788        if world.component_label(node) == Some("painted_asset_raycastable") {
789            return Some(node);
790        }
791        cur = world.parent_of(node);
792    }
793    None
794}
795
796fn handle_free_draw_click(
797    world: &mut World,
798    emit: &mut dyn SignalEmitter,
799    editor_root: ComponentId,
800    context: &PaintContext<'_>,
801    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
802    renderable: ComponentId,
803    hit_point: [f32; 3],
804) -> Option<String> {
805    let _ = (
806        world,
807        emit,
808        editor_root,
809        context,
810        stroke_runtime,
811        renderable,
812        hit_point,
813    );
814    None
815}
816
817fn handle_free_draw_stroke_move(
818    world: &mut World,
819    emit: &mut dyn SignalEmitter,
820    editor_root: ComponentId,
821    context: &PaintContext<'_>,
822    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
823    renderable: ComponentId,
824    hit_point: [f32; 3],
825) -> Option<String> {
826    let runtime = stroke_runtime?;
827    let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
828    if !runtime.active {
829        return None;
830    }
831    if runtime
832        .captured_renderable
833        .is_some_and(|captured| captured != renderable)
834    {
835        return None;
836    }
837
838    let Some(session) = runtime.preview_session.as_mut() else {
839        return None;
840    };
841    let grid_snap = context.grid_snap(world, renderable, hit_point);
842    let frame = match resolve_surface_placement_frame(world, renderable, hit_point, grid_snap) {
843        Ok(frame) => frame,
844        Err(_) => return None,
845    };
846    let pose = resolve_surface_aligned_pose_from_frame(&frame, session.local_min_z).ok()?;
847    update_preview_pose(world, emit, session.preview_root_component_id, pose);
848    session.last_valid_placement_frame = Some(frame);
849    Some(match session.placement_kind {
850        PlacementKind::PaintAsset => format!("paint preview: {}", context.asset.title),
851        PlacementKind::Grid => "grid preview".to_string(),
852    })
853}
854
855fn handle_spray_can_click(
856    world: &mut World,
857    emit: &mut dyn SignalEmitter,
858    editor_root: ComponentId,
859    context: &PaintContext<'_>,
860    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
861    renderable: ComponentId,
862    hit_point: [f32; 3],
863) -> Option<String> {
864    let runtime = stroke_runtime?;
865    let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
866    if runtime.non_grid_placed {
867        return None;
868    }
869    runtime.non_grid_placed = true;
870    runtime.last_placed_position = Some(hit_point);
871
872    let offset = random_offset_xz(1.5);
873    let offset_hit_point = [
874        hit_point[0] + offset[0],
875        hit_point[1] + offset[1],
876        hit_point[2] + offset[2],
877    ];
878
879    Some(place_asset(
880        world,
881        emit,
882        context.destination_editor,
883        renderable,
884        offset_hit_point,
885        &context.asset,
886        context.selected_color,
887        None,
888    ))
889}
890
891fn handle_spray_can_stroke_move(
892    world: &mut World,
893    emit: &mut dyn SignalEmitter,
894    editor_root: ComponentId,
895    context: &PaintContext<'_>,
896    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
897    renderable: ComponentId,
898    hit_point: [f32; 3],
899) -> Option<String> {
900    let runtime = stroke_runtime?;
901    let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
902    if !runtime.active {
903        return None;
904    }
905    if runtime
906        .captured_renderable
907        .is_some_and(|captured| captured != renderable)
908    {
909        return None;
910    }
911
912    let dist = if let Some(last_pos) = runtime.last_placed_position {
913        let dx = hit_point[0] - last_pos[0];
914        let dy = hit_point[1] - last_pos[1];
915        let dz = hit_point[2] - last_pos[2];
916        (dx * dx + dy * dy + dz * dz).sqrt()
917    } else {
918        f32::MAX
919    };
920
921    if dist >= 0.5 {
922        runtime.last_placed_position = Some(hit_point);
923
924        let offset = random_offset_xz(1.5);
925        let offset_hit_point = [
926            hit_point[0] + offset[0],
927            hit_point[1] + offset[1],
928            hit_point[2] + offset[2],
929        ];
930
931        Some(place_asset(
932            world,
933            emit,
934            context.destination_editor,
935            renderable,
936            offset_hit_point,
937            &context.asset,
938            context.selected_color,
939            None,
940        ))
941    } else {
942        None
943    }
944}
945
946fn handle_erase_click(
947    world: &mut World,
948    _emit: &mut dyn SignalEmitter,
949    _editor_root: ComponentId,
950    _context: &PaintContext<'_>,
951    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
952    renderable: ComponentId,
953    _hit_point: [f32; 3],
954) -> Option<String> {
955    let runtime = stroke_runtime?;
956    let mut runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
957    if runtime.non_grid_placed {
958        return None;
959    }
960    runtime.non_grid_placed = true;
961
962    if let Some(target_root) = find_painted_asset_root(world, renderable) {
963        if world.remove_component_subtree(target_root).is_ok() {
964            Some("erased asset".to_string())
965        } else {
966            None
967        }
968    } else {
969        None
970    }
971}
972
973fn handle_erase_stroke_move(
974    world: &mut World,
975    _emit: &mut dyn SignalEmitter,
976    _editor_root: ComponentId,
977    _context: &PaintContext<'_>,
978    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
979    renderable: ComponentId,
980    _hit_point: [f32; 3],
981) -> Option<String> {
982    let runtime = stroke_runtime?;
983    let runtime = runtime.lock().expect("paint stroke runtime mutex poisoned");
984    if !runtime.active {
985        return None;
986    }
987
988    if let Some(target_root) = find_painted_asset_root(world, renderable) {
989        if world.remove_component_subtree(target_root).is_ok() {
990            return Some("erased asset".to_string());
991        }
992    }
993    None
994}
995
996fn handle_line_click(
997    _world: &mut World,
998    _emit: &mut dyn SignalEmitter,
999    _editor_root: ComponentId,
1000    _context: &PaintContext<'_>,
1001    _stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1002    _renderable: ComponentId,
1003    _hit_point: [f32; 3],
1004) -> Option<String> {
1005    None
1006}
1007
1008fn handle_line_stroke_move(
1009    _world: &mut World,
1010    _emit: &mut dyn SignalEmitter,
1011    _editor_root: ComponentId,
1012    _context: &PaintContext<'_>,
1013    _stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1014    _renderable: ComponentId,
1015    _hit_point: [f32; 3],
1016) -> Option<String> {
1017    None
1018}
1019
1020fn handle_fill_click(
1021    world: &mut World,
1022    _emit: &mut dyn SignalEmitter,
1023    _editor_root: ComponentId,
1024    context: &PaintContext<'_>,
1025    _stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1026    renderable: ComponentId,
1027    _hit_point: [f32; 3],
1028) -> Option<String> {
1029    let hit =
1030        crate::engine::ecs::system::editor_scene_hit::resolve_world_scene_hit(world, renderable)?;
1031    if apply_color_to_subtree(world, hit.target_transform, context.selected_color, false) {
1032        Some("fill applied".to_string())
1033    } else {
1034        Some("fill inactive: target has no color channel".to_string())
1035    }
1036}
1037
1038fn handle_fill_stroke_move(
1039    _world: &mut World,
1040    _emit: &mut dyn SignalEmitter,
1041    _editor_root: ComponentId,
1042    _context: &PaintContext<'_>,
1043    _stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1044    _renderable: ComponentId,
1045    _hit_point: [f32; 3],
1046) -> Option<String> {
1047    None
1048}
1049
1050fn handle_scene_click(
1051    world: &mut World,
1052    emit: &mut dyn SignalEmitter,
1053    editor_root: ComponentId,
1054    panel_query_root: ComponentId,
1055    grid_system: &GridSystem,
1056    templates: &[PaintAssetTemplate],
1057    paint_state: &PaintState,
1058    editor_context: &EditorContextState,
1059    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1060    renderable: ComponentId,
1061    hit_point: [f32; 3],
1062) -> Option<String> {
1063    let start = Instant::now();
1064    let context = resolve_paint_context(
1065        world,
1066        grid_system,
1067        editor_root,
1068        panel_query_root,
1069        paint_state,
1070        editor_context,
1071        templates,
1072    )?;
1073    paint_perf(
1074        "handle_scene_click.resolve_context",
1075        start,
1076        format!("tool={:?}", paint_state.selected_tool),
1077    );
1078
1079    let apply_tool_start = Instant::now();
1080    let result = match paint_state.selected_tool {
1081        PaintTool::FreeDraw => handle_free_draw_click(
1082            world,
1083            emit,
1084            editor_root,
1085            &context,
1086            stroke_runtime,
1087            renderable,
1088            hit_point,
1089        ),
1090        PaintTool::GridTool => None,
1091        PaintTool::SprayCan => handle_spray_can_click(
1092            world,
1093            emit,
1094            editor_root,
1095            &context,
1096            stroke_runtime,
1097            renderable,
1098            hit_point,
1099        ),
1100        PaintTool::Erase => handle_erase_click(
1101            world,
1102            emit,
1103            editor_root,
1104            &context,
1105            stroke_runtime,
1106            renderable,
1107            hit_point,
1108        ),
1109        PaintTool::Line => handle_line_click(
1110            world,
1111            emit,
1112            editor_root,
1113            &context,
1114            stroke_runtime,
1115            renderable,
1116            hit_point,
1117        ),
1118        PaintTool::Fill => handle_fill_click(
1119            world,
1120            emit,
1121            editor_root,
1122            &context,
1123            stroke_runtime,
1124            renderable,
1125            hit_point,
1126        ),
1127        PaintTool::Unknown(_) => None,
1128    };
1129    paint_perf(
1130        "handle_scene_click.apply_tool",
1131        apply_tool_start,
1132        format!("tool={:?} result={result:?}", paint_state.selected_tool),
1133    );
1134    paint_perf(
1135        "handle_scene_click.total",
1136        start,
1137        format!("tool={:?} result={result:?}", paint_state.selected_tool),
1138    );
1139    result
1140}
1141
1142fn handle_stroke_move(
1143    world: &mut World,
1144    emit: &mut dyn SignalEmitter,
1145    editor_root: ComponentId,
1146    panel_query_root: ComponentId,
1147    grid_system: &GridSystem,
1148    templates: &[PaintAssetTemplate],
1149    paint_state: &PaintState,
1150    editor_context: &EditorContextState,
1151    stroke_runtime: Option<&Arc<Mutex<PaintStrokeRuntime>>>,
1152    renderable: ComponentId,
1153    hit_point: [f32; 3],
1154) -> Option<String> {
1155    let start = Instant::now();
1156    let context = resolve_paint_context(
1157        world,
1158        grid_system,
1159        editor_root,
1160        panel_query_root,
1161        paint_state,
1162        editor_context,
1163        templates,
1164    )?;
1165    paint_perf(
1166        "handle_stroke_move.resolve_context",
1167        start,
1168        format!("tool={:?}", paint_state.selected_tool),
1169    );
1170
1171    let apply_tool_start = Instant::now();
1172    let result = match paint_state.selected_tool {
1173        PaintTool::FreeDraw => handle_free_draw_stroke_move(
1174            world,
1175            emit,
1176            editor_root,
1177            &context,
1178            stroke_runtime,
1179            renderable,
1180            hit_point,
1181        ),
1182        PaintTool::GridTool => handle_free_draw_stroke_move(
1183            world,
1184            emit,
1185            editor_root,
1186            &context,
1187            stroke_runtime,
1188            renderable,
1189            hit_point,
1190        ),
1191        PaintTool::SprayCan => handle_spray_can_stroke_move(
1192            world,
1193            emit,
1194            editor_root,
1195            &context,
1196            stroke_runtime,
1197            renderable,
1198            hit_point,
1199        ),
1200        PaintTool::Erase => handle_erase_stroke_move(
1201            world,
1202            emit,
1203            editor_root,
1204            &context,
1205            stroke_runtime,
1206            renderable,
1207            hit_point,
1208        ),
1209        PaintTool::Line => handle_line_stroke_move(
1210            world,
1211            emit,
1212            editor_root,
1213            &context,
1214            stroke_runtime,
1215            renderable,
1216            hit_point,
1217        ),
1218        PaintTool::Fill => handle_fill_stroke_move(
1219            world,
1220            emit,
1221            editor_root,
1222            &context,
1223            stroke_runtime,
1224            renderable,
1225            hit_point,
1226        ),
1227        PaintTool::Unknown(_) => None,
1228    };
1229    paint_perf(
1230        "handle_stroke_move.apply_tool",
1231        apply_tool_start,
1232        format!("tool={:?} result={result:?}", paint_state.selected_tool),
1233    );
1234    paint_perf(
1235        "handle_stroke_move.total",
1236        start,
1237        format!("tool={:?} result={result:?}", paint_state.selected_tool),
1238    );
1239    result
1240}
1241
1242#[derive(Debug, Clone)]
1243struct PaintContext<'a> {
1244    asset: &'a PaintAssetTemplate,
1245    destination_editor: ComponentId,
1246    selected_color: [f32; 4],
1247    grid_system: GridSystem,
1248}
1249
1250impl<'a> PaintContext<'a> {
1251    fn grid_snap(
1252        &self,
1253        world: &World,
1254        target_renderable: ComponentId,
1255        hit_point: [f32; 3],
1256    ) -> Option<GridSnapResult> {
1257        let grid = self
1258            .grid_system
1259            .grid_hit_context_for_renderable(world, target_renderable)?;
1260        Some(GridSystem::snap_hit(&grid, hit_point))
1261    }
1262}
1263
1264fn resolve_paint_context<'a>(
1265    world: &World,
1266    grid_system: &GridSystem,
1267    editor_root: ComponentId,
1268    panel_query_root: ComponentId,
1269    paint_state: &PaintState,
1270    editor_context: &EditorContextState,
1271    templates: &'a [PaintAssetTemplate],
1272) -> Option<PaintContext<'a>> {
1273    let start = Instant::now();
1274    let paint_panel_root = world.find_component(panel_query_root, PAINT_PANEL_ROOT_SELECTOR);
1275    let color_panel_root = world.find_component(panel_query_root, COLOR_PANEL_ROOT_SELECTOR);
1276    if !is_paint_active(
1277        paint_panel_root,
1278        color_panel_root,
1279        paint_state,
1280        editor_context,
1281    ) {
1282        paint_perf(
1283            "resolve_paint_context.inactive",
1284            start,
1285            format!(
1286                "tool={:?} panel={paint_panel_root:?}",
1287                paint_state.selected_tool
1288            ),
1289        );
1290        return None;
1291    }
1292    let destination_editor = match editor_context
1293        .last_scene_interacted_editor
1294        .or(Some(editor_root))
1295    {
1296        Some(editor) => editor,
1297        None => return None,
1298    };
1299    let asset = match paint_state.selected_tool {
1300        PaintTool::GridTool | PaintTool::Erase => templates.first(),
1301        _ => {
1302            let selected_asset = paint_state.selected_asset.as_ref()?;
1303            let payload = selected_asset.component?;
1304            let asset_key =
1305                if let Some(data) = world.get_component_by_id_as::<DataComponent>(payload) {
1306                    match data.get("asset_key") {
1307                        Some(crate::engine::ecs::component::DataValue::Text(asset_key)) => {
1308                            asset_key.clone()
1309                        }
1310                        _ => return None,
1311                    }
1312                } else {
1313                    return None;
1314                };
1315            templates.iter().find(|template| template.key == asset_key)
1316        }
1317    }?;
1318    let context = PaintContext {
1319        asset,
1320        destination_editor,
1321        selected_color: paint_state.selected_color.unwrap_or([1.0, 1.0, 1.0, 1.0]),
1322        grid_system: grid_system.clone(),
1323    };
1324    paint_perf(
1325        "resolve_paint_context.active",
1326        start,
1327        format!(
1328            "tool={:?} asset_key={}",
1329            paint_state.selected_tool, asset.key,
1330        ),
1331    );
1332    Some(context)
1333}
1334
1335struct PaintActivityStatus {
1336    active: bool,
1337    reason: String,
1338}
1339
1340fn start_preview_session_for_tool(
1341    world: &mut World,
1342    emit: &mut dyn SignalEmitter,
1343    editor_root: ComponentId,
1344    target_renderable: ComponentId,
1345    hit_point: [f32; 3],
1346    selected_tool: PaintTool,
1347    context: &PaintContext<'_>,
1348    editor_context: &EditorContextState,
1349) -> Option<PlacementPreviewSession> {
1350    match selected_tool {
1351        PaintTool::FreeDraw => start_paint_preview_session(
1352            world,
1353            emit,
1354            editor_root,
1355            target_renderable,
1356            hit_point,
1357            context,
1358        ),
1359        PaintTool::GridTool => start_grid_preview_session(
1360            world,
1361            emit,
1362            editor_root,
1363            target_renderable,
1364            hit_point,
1365            context,
1366            editor_context,
1367        ),
1368        _ => None,
1369    }
1370}
1371
1372fn start_paint_preview_session(
1373    world: &mut World,
1374    emit: &mut dyn SignalEmitter,
1375    editor_root: ComponentId,
1376    target_renderable: ComponentId,
1377    hit_point: [f32; 3],
1378    context: &PaintContext<'_>,
1379) -> Option<PlacementPreviewSession> {
1380    let scene_parent = resolve_scene_parent(world, context.destination_editor);
1381    let asset_root =
1382        spawn_asset_subtree(world, emit, &context.asset, context.selected_color).ok()?;
1383    let preview_root =
1384        world.add_component_boxed_named("painted_asset_root", Box::new(TransformComponent::new()));
1385    let raycastable_root = world.add_component_boxed_named(
1386        "painted_asset_raycastable",
1387        Box::new(RaycastableComponent::enabled()),
1388    );
1389    let _ = world.add_child(raycastable_root, preview_root);
1390    let _ = world.add_child(preview_root, asset_root);
1391    let _ = world.add_child(scene_parent, raycastable_root);
1392    create_preview_shell(world, preview_root, emit, PlacementPreviewStyle::default());
1393    world.init_component_tree(raycastable_root, emit);
1394    let grid_snap = context.grid_snap(world, target_renderable, hit_point);
1395    let frame =
1396        resolve_surface_placement_frame(world, target_renderable, hit_point, grid_snap).ok()?;
1397    let pose =
1398        resolve_surface_aligned_pose_from_frame(&frame, asset_local_min_z(world, preview_root)?)
1399            .ok()?;
1400    update_preview_pose(world, emit, preview_root, pose);
1401    Some(PlacementPreviewSession {
1402        active_editor: editor_root,
1403        placement_kind: PlacementKind::PaintAsset,
1404        preview_root_component_id: preview_root,
1405        target_renderable: Some(target_renderable),
1406        last_valid_placement_frame: Some(frame),
1407        local_min_z: asset_local_min_z(world, preview_root)?,
1408    })
1409}
1410
1411fn start_grid_preview_session(
1412    world: &mut World,
1413    emit: &mut dyn SignalEmitter,
1414    editor_root: ComponentId,
1415    target_renderable: ComponentId,
1416    hit_point: [f32; 3],
1417    context: &PaintContext<'_>,
1418    editor_context: &EditorContextState,
1419) -> Option<PlacementPreviewSession> {
1420    let grid_snap = context.grid_snap(world, target_renderable, hit_point);
1421    let frame =
1422        resolve_surface_placement_frame(world, target_renderable, hit_point, grid_snap).ok()?;
1423    let pose = resolve_surface_aligned_pose_from_frame(&frame, 0.0).ok()?;
1424    let mut preview_context = editor_context.clone();
1425    preview_context.cursor_translation = Some(pose.translation);
1426    preview_context.cursor_rotation = Some(remap_grid_rotation_to_surface_up(pose.rotation));
1427    let preview_root = GridSystem::new().spawn_grid_for_editor(
1428        world,
1429        emit,
1430        editor_root,
1431        GridSpawnSpec::from_cursor_pose(
1432            preview_context.cursor_translation,
1433            preview_context.cursor_rotation,
1434            true,
1435        ),
1436    );
1437    create_preview_shell(world, preview_root, emit, PlacementPreviewStyle::default());
1438    update_preview_pose(world, emit, preview_root, pose);
1439    Some(PlacementPreviewSession {
1440        active_editor: editor_root,
1441        placement_kind: PlacementKind::Grid,
1442        preview_root_component_id: preview_root,
1443        target_renderable: Some(target_renderable),
1444        last_valid_placement_frame: Some(frame),
1445        local_min_z: 0.0,
1446    })
1447}
1448
1449fn paint_activity_status(
1450    world: &World,
1451    grid_system: &GridSystem,
1452    active_editor: Option<ComponentId>,
1453    panel_query_root: ComponentId,
1454    paint_state: &PaintState,
1455    editor_context: &EditorContextState,
1456) -> PaintActivityStatus {
1457    let paint_panel_root = world.find_component(panel_query_root, PAINT_PANEL_ROOT_SELECTOR);
1458    if !is_paint_panel_focused(paint_panel_root, editor_context) {
1459        return PaintActivityStatus {
1460            active: false,
1461            reason: "focus Paint panel".to_string(),
1462        };
1463    }
1464
1465    let asset_required = !matches!(
1466        paint_state.selected_tool,
1467        PaintTool::Erase | PaintTool::GridTool
1468    );
1469    if asset_required
1470        && paint_state
1471            .selected_asset
1472            .as_ref()
1473            .and_then(|selection| selection.component)
1474            .is_none()
1475    {
1476        return PaintActivityStatus {
1477            active: false,
1478            reason: "no asset selected".to_string(),
1479        };
1480    }
1481
1482    match paint_state.selected_tool {
1483        PaintTool::FreeDraw | PaintTool::GridTool | PaintTool::SprayCan | PaintTool::Erase => {}
1484        _ => {
1485            return PaintActivityStatus {
1486                active: false,
1487                reason: format!("tool is not supported ({:?})", paint_state.selected_tool),
1488            };
1489        }
1490    }
1491
1492    let Some(_editor_root) = active_editor else {
1493        return PaintActivityStatus {
1494            active: false,
1495            reason: "no active editor".to_string(),
1496        };
1497    };
1498
1499    PaintActivityStatus {
1500        active: true,
1501        reason: "snap on shown grid hits only".to_string(),
1502    }
1503}
1504
1505fn place_asset(
1506    world: &mut World,
1507    emit: &mut dyn SignalEmitter,
1508    editor_root: ComponentId,
1509    target_renderable: ComponentId,
1510    hit_point: [f32; 3],
1511    asset: &PaintAssetTemplate,
1512    selected_color: [f32; 4],
1513    grid_snap: Option<GridSnapResult>,
1514) -> String {
1515    let scene_parent = resolve_scene_parent(world, editor_root);
1516
1517    let asset_root = match spawn_asset_subtree(world, emit, asset, selected_color) {
1518        Ok(asset_root) => asset_root,
1519        Err(error) => return error,
1520    };
1521
1522    let pose =
1523        match resolve_placement_pose(world, target_renderable, hit_point, asset_root, grid_snap) {
1524            Ok(pose) => pose,
1525            Err(PlacementError::UnsupportedSurface) => {
1526                let _ = world.remove_component_subtree(asset_root);
1527                return "paint inactive: unsupported target surface".to_string();
1528            }
1529            Err(PlacementError::MissingAssetBounds) => {
1530                let _ = world.remove_component_subtree(asset_root);
1531                return "paint failed: asset bounds unavailable".to_string();
1532            }
1533            Err(PlacementError::MissingTargetTransform) => {
1534                let _ = world.remove_component_subtree(asset_root);
1535                return "paint failed: target transform unavailable".to_string();
1536            }
1537        };
1538
1539    let raycastable_root = world.add_component_boxed_named(
1540        "painted_asset_raycastable",
1541        Box::new(RaycastableComponent::enabled()),
1542    );
1543    let wrapper = world.add_component_boxed_named(
1544        "painted_asset_root",
1545        Box::new(
1546            TransformComponent::new()
1547                .with_position(
1548                    pose.translation[0],
1549                    pose.translation[1],
1550                    pose.translation[2],
1551                )
1552                .with_rotation_quat(pose.rotation),
1553        ),
1554    );
1555    let _ = world.add_child(raycastable_root, wrapper);
1556    let _ = world.add_child(wrapper, asset_root);
1557    world.init_component_tree(raycastable_root, emit);
1558    emit.push_intent_now(
1559        raycastable_root,
1560        IntentValue::Attach {
1561            parents: vec![scene_parent],
1562            child: raycastable_root,
1563        },
1564    );
1565
1566    let grid_text = if grid_snap.is_some() {
1567        "snapped on grid hit"
1568    } else {
1569        "unsnapped"
1570    };
1571    format!("paint placed: {} | {}", asset.title, grid_text)
1572}
1573
1574fn spawn_asset_subtree(
1575    world: &mut World,
1576    emit: &mut dyn SignalEmitter,
1577    asset: &PaintAssetTemplate,
1578    selected_color: [f32; 4],
1579) -> Result<ComponentId, String> {
1580    let asset_root = MeowMeowRunner::spawn_mms_module_component_uninitialized(
1581        &asset.module,
1582        &asset.export_name,
1583        default_asset_args(asset, selected_color),
1584        world,
1585        emit,
1586    )
1587    .map_err(|error| format!("paint failed: asset spawn error: {error}"))?;
1588    sanitize_painted_asset_subtree(world, asset_root);
1589    apply_color_to_subtree(world, asset_root, selected_color, true);
1590    Ok(asset_root)
1591}
1592
1593fn asset_local_min_z(world: &World, root: ComponentId) -> Option<f32> {
1594    crate::engine::ecs::system::paint_placement::measure_subtree_local_bounds(world, root)
1595        .map(|bounds| bounds.min[2])
1596}
1597
1598fn sanitize_painted_asset_subtree(world: &mut World, root: ComponentId) {
1599    let mut stack = vec![root];
1600    let mut selection_components = Vec::new();
1601    let mut option_components = Vec::new();
1602
1603    while let Some(node) = stack.pop() {
1604        if world
1605            .get_component_by_id_as::<SelectionComponent>(node)
1606            .is_some()
1607        {
1608            selection_components.push(node);
1609        }
1610        if world
1611            .get_component_by_id_as::<OptionComponent>(node)
1612            .is_some()
1613        {
1614            option_components.push(node);
1615        }
1616        for &child in world.children_of(node) {
1617            stack.push(child);
1618        }
1619    }
1620
1621    for component in option_components {
1622        let _ = world.remove_component_leaf(component);
1623    }
1624    for component in selection_components {
1625        let _ = world.remove_component_leaf(component);
1626    }
1627}
1628
1629fn default_asset_args(asset: &PaintAssetTemplate, selected_color: [f32; 4]) -> Vec<Value> {
1630    asset
1631        .param_names
1632        .iter()
1633        .map(|name| {
1634            let lower_name = name.to_lowercase();
1635            if lower_name.contains("color") {
1636                Value::Array(vec![
1637                    Value::Number(selected_color[0] as f64),
1638                    Value::Number(selected_color[1] as f64),
1639                    Value::Number(selected_color[2] as f64),
1640                    Value::Number(selected_color[3] as f64),
1641                ])
1642            } else if lower_name.contains("items") || lower_name.contains("sequence") {
1643                Value::Array(Vec::new())
1644            } else if lower_name.contains("path")
1645                || lower_name.contains("url")
1646                || lower_name.contains("uri")
1647            {
1648                Value::String("assets/world/default.mms".to_string())
1649            } else if lower_name.contains("title")
1650                || lower_name.contains("label")
1651                || lower_name.contains("name")
1652                || lower_name.contains("text")
1653            {
1654                Value::String("Paint".to_string())
1655            } else {
1656                Value::Null
1657            }
1658        })
1659        .collect()
1660}
1661
1662fn update_paint_status(
1663    world: &mut World,
1664    emit: &mut dyn SignalEmitter,
1665    active_editor: Option<ComponentId>,
1666    panel_query_root: ComponentId,
1667    grid_system: &GridSystem,
1668    paint_state: &PaintState,
1669    editor_context: &EditorContextState,
1670    override_text: Option<String>,
1671) {
1672    let start = Instant::now();
1673    let Some(paint_panel_root) = world.find_component(panel_query_root, PAINT_PANEL_ROOT_SELECTOR)
1674    else {
1675        return;
1676    };
1677    let Some(status_wrap) = world.find_component(paint_panel_root, PAINT_STATUS_WRAP_SELECTOR)
1678    else {
1679        return;
1680    };
1681    let text = override_text.unwrap_or_else(|| {
1682        base_status_text(
1683            world,
1684            active_editor,
1685            panel_query_root,
1686            grid_system,
1687            paint_state,
1688            editor_context,
1689        )
1690    });
1691    paint_perf(
1692        "update_paint_status.compute_text",
1693        start,
1694        format!("active_editor={active_editor:?} text={text}"),
1695    );
1696    let set_start = Instant::now();
1697    set_status_text(world, emit, status_wrap, &text);
1698    paint_perf(
1699        "update_paint_status.set_text",
1700        set_start,
1701        format!("text={text}"),
1702    );
1703    paint_perf("update_paint_status.total", start, format!("text={text}"));
1704}
1705
1706fn base_status_text(
1707    world: &World,
1708    active_editor: Option<ComponentId>,
1709    panel_query_root: ComponentId,
1710    grid_system: &GridSystem,
1711    paint_state: &PaintState,
1712    editor_context: &EditorContextState,
1713) -> String {
1714    let paint_panel_root = world.find_component(panel_query_root, PAINT_PANEL_ROOT_SELECTOR);
1715    let color_panel_root = world.find_component(panel_query_root, COLOR_PANEL_ROOT_SELECTOR);
1716    if !is_paint_workspace_focused(paint_panel_root, color_panel_root, editor_context) {
1717        return "paint inactive: focus Paint panel".to_string();
1718    }
1719
1720    let asset_required = !matches!(
1721        paint_state.selected_tool,
1722        PaintTool::Erase | PaintTool::GridTool
1723    );
1724    if asset_required
1725        && paint_state
1726            .selected_asset
1727            .as_ref()
1728            .and_then(|selection| selection.component)
1729            .is_none()
1730    {
1731        return "paint inactive: no asset selected".to_string();
1732    }
1733
1734    match paint_state.selected_tool {
1735        PaintTool::FreeDraw | PaintTool::GridTool | PaintTool::SprayCan | PaintTool::Erase => {}
1736        _ => {
1737            return format!(
1738                "paint inactive: tool is not supported ({:?})",
1739                paint_state.selected_tool
1740            );
1741        }
1742    }
1743
1744    let Some(_editor_root) = active_editor else {
1745        return "paint inactive: no active editor".to_string();
1746    };
1747
1748    let tool_name = match paint_state.selected_tool {
1749        PaintTool::FreeDraw => "Free Draw",
1750        PaintTool::GridTool => "Grid Tool",
1751        PaintTool::SprayCan => "Spray Can",
1752        PaintTool::Erase => "Erase",
1753        _ => unreachable!(),
1754    };
1755
1756    format!("{tool_name} active | snap only on shown grid hits")
1757}
1758
1759fn set_status_text(
1760    world: &mut World,
1761    emit: &mut dyn SignalEmitter,
1762    status_wrap: ComponentId,
1763    text: &str,
1764) {
1765    let Some(status_text) = world.find_component(status_wrap, PANEL_STATUS_VALUE_SELECTOR) else {
1766        return;
1767    };
1768    let Some(text_component) = world
1769        .get_component_by_id_as_mut::<crate::engine::ecs::component::TextComponent>(status_text)
1770    else {
1771        return;
1772    };
1773    if text_component.text == text {
1774        return;
1775    }
1776    text_component.text = text.to_string();
1777    text_component.mark_unbuilt();
1778    emit.push_intent_now(
1779        status_text,
1780        IntentValue::SetText {
1781            component_ids: vec![status_text],
1782            text: text.to_string(),
1783        },
1784    );
1785}
1786
1787fn resolve_scene_parent(world: &World, editor_root: ComponentId) -> ComponentId {
1788    world
1789        .children_of(editor_root)
1790        .iter()
1791        .copied()
1792        .find(|&child| {
1793            world.component_label(child) != Some(RUNTIME_UI_ROOT_NAME)
1794                && world.component_label(child) != Some("editor_gizmo_anchor")
1795        })
1796        .unwrap_or(editor_root)
1797}
1798
1799fn eligible_scene_hit(
1800    world: &World,
1801    editor_root: ComponentId,
1802    panel_query_root: ComponentId,
1803    renderable: ComponentId,
1804) -> bool {
1805    if nearest_editor_ancestor(world, renderable) != Some(editor_root) {
1806        return false;
1807    }
1808    if is_descendant_or_self(world, panel_query_root, renderable) {
1809        return false;
1810    }
1811    if has_selectable_off_ancestor(world, renderable) {
1812        return false;
1813    }
1814    if has_transform_gizmo_ancestor(world, renderable) {
1815        return false;
1816    }
1817    true
1818}
1819
1820fn nearest_editor_ancestor(world: &World, start: ComponentId) -> Option<ComponentId> {
1821    let mut cur = Some(start);
1822    while let Some(node) = cur {
1823        if world
1824            .get_component_by_id_as::<EditorComponent>(node)
1825            .is_some()
1826        {
1827            return Some(node);
1828        }
1829        cur = world.parent_of(node);
1830    }
1831    None
1832}
1833
1834fn has_selectable_off_ancestor(world: &World, start: ComponentId) -> bool {
1835    let mut cur = Some(start);
1836    while let Some(node) = cur {
1837        if world
1838            .get_component_by_id_as::<SelectableComponent>(node)
1839            .map(|selectable| !selectable.enabled)
1840            .unwrap_or(false)
1841        {
1842            return true;
1843        }
1844        cur = world.parent_of(node);
1845    }
1846    false
1847}
1848
1849fn has_transform_gizmo_ancestor(world: &World, start: ComponentId) -> bool {
1850    let mut cur = Some(start);
1851    while let Some(node) = cur {
1852        if world
1853            .get_component_by_id_as::<TransformGizmoComponent>(node)
1854            .is_some()
1855        {
1856            return true;
1857        }
1858        cur = world.parent_of(node);
1859    }
1860    false
1861}
1862
1863fn is_descendant_or_self(world: &World, ancestor: ComponentId, node: ComponentId) -> bool {
1864    let mut current = Some(node);
1865    while let Some(component) = current {
1866        if component == ancestor {
1867            return true;
1868        }
1869        current = world.parent_of(component);
1870    }
1871    false
1872}
1873
1874fn update_last_scene_interacted_editor(
1875    world: &World,
1876    editor_context_state: &Arc<Mutex<EditorContextState>>,
1877    event: &PaintEvent,
1878) {
1879    let renderable = match event {
1880        PaintEvent::SceneClick { renderable, .. }
1881        | PaintEvent::StrokeStarted { renderable, .. }
1882        | PaintEvent::StrokeMoved { renderable, .. } => *renderable,
1883        _ => return,
1884    };
1885    let Some(hit) =
1886        crate::engine::ecs::system::editor_scene_hit::resolve_world_scene_hit(world, renderable)
1887    else {
1888        return;
1889    };
1890    let Some(editor_root) = hit.editor_root else {
1891        return;
1892    };
1893    if let Ok(mut guard) = editor_context_state.lock() {
1894        guard.last_scene_interacted_editor = Some(editor_root);
1895    }
1896}
1897
1898fn apply_color_to_subtree(
1899    world: &mut World,
1900    root: ComponentId,
1901    rgba: [f32; 4],
1902    attach_if_missing: bool,
1903) -> bool {
1904    let mut stack = vec![root];
1905    let mut found_color = false;
1906    let mut first_renderable = None;
1907    while let Some(node) = stack.pop() {
1908        if world
1909            .get_component_by_id_as::<crate::engine::ecs::component::RenderableComponent>(node)
1910            .is_some()
1911            && first_renderable.is_none()
1912        {
1913            first_renderable = Some(node);
1914        }
1915        if let Some(color) =
1916            world.get_component_by_id_as_mut::<crate::engine::ecs::component::ColorComponent>(node)
1917        {
1918            color.rgba = rgba;
1919            found_color = true;
1920        }
1921        for &child in world.children_of(node) {
1922            stack.push(child);
1923        }
1924    }
1925    if !found_color && attach_if_missing {
1926        if let Some(parent) = first_renderable.or(Some(root)) {
1927            let color = world.add_component(crate::engine::ecs::component::ColorComponent::rgba(
1928                rgba[0], rgba[1], rgba[2], rgba[3],
1929            ));
1930            let _ = world.add_child(parent, color);
1931            return true;
1932        }
1933    }
1934    found_color
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939    use super::*;
1940    use crate::engine::ecs::command_queue::CommandQueue;
1941    use crate::engine::ecs::component::{ColorComponent, GridComponent, RenderableComponent};
1942    use crate::engine::ecs::system::SystemWorld;
1943    use crate::engine::graphics::{RenderAssets, VisualWorld};
1944    use std::path::PathBuf;
1945    use std::time::{SystemTime, UNIX_EPOCH};
1946
1947    fn temp_asset_directory() -> PathBuf {
1948        let now = SystemTime::now()
1949            .duration_since(UNIX_EPOCH)
1950            .expect("time went backwards")
1951            .as_nanos();
1952        let tmp_dir = std::env::temp_dir().join(format!("mittens_engine_paint_assets_{}", now));
1953        std::fs::create_dir_all(&tmp_dir).expect("create temp dir");
1954        tmp_dir
1955    }
1956
1957    fn write_test_asset(dir: &std::path::Path) {
1958        std::fs::write(
1959            dir.join("paint_asset.mms"),
1960            r#"
1961                export fn cube_stamp() {
1962                    return T {
1963                        T.scale(0.2, 0.2, 0.2) {
1964                            C.rgba(1.0, 0.2, 0.2, 1.0) {
1965                                Renderable.cube()
1966                            }
1967                        }
1968                    }
1969                }
1970            "#,
1971        )
1972        .expect("write asset");
1973    }
1974
1975    fn find_named_root(world: &World, name: &str) -> ComponentId {
1976        world
1977            .all_components()
1978            .find(|&component_id| {
1979                world.parent_of(component_id).is_none()
1980                    && world.component_label(component_id) == Some(name)
1981            })
1982            .expect("named root")
1983    }
1984
1985    fn push_click(systems: &mut SystemWorld, renderable: ComponentId) {
1986        systems.rx.push_event(
1987            renderable,
1988            EventSignal::Click {
1989                raycaster: renderable,
1990                renderable,
1991                hit_point: [0.0, 0.0, 0.5],
1992                screen_pos_px: None,
1993            },
1994        );
1995    }
1996
1997    fn push_drag_place(systems: &mut SystemWorld, renderable: ComponentId) {
1998        systems.rx.push_event(
1999            renderable,
2000            EventSignal::DragStart {
2001                raycaster: renderable,
2002                renderable,
2003                hit_point: [0.0, 0.0, 0.5],
2004                ray_dir_world: [0.0, 0.0, -1.0],
2005                screen_pos_px: None,
2006            },
2007        );
2008        systems.rx.push_event(
2009            renderable,
2010            EventSignal::DragMove {
2011                raycaster: renderable,
2012                renderable,
2013                hit_point: [0.2, 0.0, 0.5],
2014                delta_world: [0.2, 0.0, 0.0],
2015                screen_pos_px: None,
2016                screen_delta_px: None,
2017            },
2018        );
2019        systems.rx.push_event(
2020            renderable,
2021            EventSignal::DragEnd {
2022                raycaster: renderable,
2023                renderable,
2024                hit_point: Some([0.2, 0.0, 0.5]),
2025            },
2026        );
2027    }
2028
2029    fn count_named_descendants(world: &World, root: ComponentId, name: &str) -> usize {
2030        let mut count = 0;
2031        let mut stack = vec![root];
2032        while let Some(node) = stack.pop() {
2033            if world.component_label(node) == Some(name) {
2034                count += 1;
2035            }
2036            for &child in world.children_of(node) {
2037                stack.push(child);
2038            }
2039        }
2040        count
2041    }
2042
2043    fn push_asset_and_panel_focus(
2044        world: &World,
2045        systems: &mut SystemWorld,
2046        paint_panel_root: ComponentId,
2047    ) {
2048        let asset_item = world
2049            .find_component(
2050                find_named_root(world, RUNTIME_UI_ROOT_NAME),
2051                "[name='asset_item']",
2052            )
2053            .expect("asset item");
2054        systems.rx.push_event(
2055            asset_item,
2056            EventSignal::Click {
2057                raycaster: asset_item,
2058                renderable: asset_item,
2059                hit_point: [0.0, 0.0, 0.0],
2060                screen_pos_px: None,
2061            },
2062        );
2063        systems.rx.push_event(
2064            paint_panel_root,
2065            EventSignal::Click {
2066                raycaster: paint_panel_root,
2067                renderable: paint_panel_root,
2068                hit_point: [0.0, 0.0, 0.0],
2069                screen_pos_px: None,
2070            },
2071        );
2072    }
2073
2074    fn init_editor_fixture() -> (
2075        World,
2076        CommandQueue,
2077        VisualWorld,
2078        SystemWorld,
2079        RenderAssets,
2080        ComponentId,
2081        ComponentId,
2082        ComponentId,
2083        ComponentId,
2084    ) {
2085        let tmp_dir = temp_asset_directory();
2086        write_test_asset(&tmp_dir);
2087
2088        let mut world = World::default();
2089        let mut emit = CommandQueue::new();
2090        let mut visuals = VisualWorld::default();
2091        let mut systems = SystemWorld::default();
2092        let mut render_assets = RenderAssets::new();
2093
2094        systems
2095            .asset_system
2096            .scan_assets_dir(&tmp_dir)
2097            .expect("scan");
2098        systems.selection.install_handlers(&mut systems.rx);
2099
2100        let editor_root =
2101            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
2102        let scene_root =
2103            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
2104        let target = world.add_component_boxed_named("target", Box::new(TransformComponent::new()));
2105        let color = world.add_component(ColorComponent::rgba(0.7, 0.7, 0.7, 1.0));
2106        let renderable = world.add_component(RenderableComponent::cube());
2107        let _ = world.add_child(editor_root, scene_root);
2108        let _ = world.add_child(scene_root, target);
2109        let _ = world.add_child(target, color);
2110        let _ = world.add_child(color, renderable);
2111
2112        world.init_component_tree(editor_root, &mut emit);
2113        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2114
2115        let runtime_ui_root = find_named_root(&world, RUNTIME_UI_ROOT_NAME);
2116        let paint_panel_root = world
2117            .find_component(runtime_ui_root, PAINT_PANEL_ROOT_SELECTOR)
2118            .expect("paint panel");
2119
2120        push_asset_and_panel_focus(&world, &mut systems, paint_panel_root);
2121        let _ = systems.process_signals(
2122            &mut world,
2123            &mut visuals,
2124            &mut render_assets,
2125            &mut emit,
2126            100_000,
2127        );
2128        systems.rx.begin_frame();
2129        let _ = systems.process_signals(
2130            &mut world,
2131            &mut visuals,
2132            &mut render_assets,
2133            &mut emit,
2134            100_000,
2135        );
2136        systems.rx.begin_frame();
2137        let _ = systems.process_signals(
2138            &mut world,
2139            &mut visuals,
2140            &mut render_assets,
2141            &mut emit,
2142            100_000,
2143        );
2144        systems.rx.push_event(
2145            paint_panel_root,
2146            EventSignal::Click {
2147                raycaster: paint_panel_root,
2148                renderable: paint_panel_root,
2149                hit_point: [0.0, 0.0, 0.0],
2150                screen_pos_px: None,
2151            },
2152        );
2153        let _ = systems.process_signals(
2154            &mut world,
2155            &mut visuals,
2156            &mut render_assets,
2157            &mut emit,
2158            100_000,
2159        );
2160        systems.rx.begin_frame();
2161        let _ = systems.process_signals(
2162            &mut world,
2163            &mut visuals,
2164            &mut render_assets,
2165            &mut emit,
2166            100_000,
2167        );
2168
2169        (
2170            world,
2171            emit,
2172            visuals,
2173            systems,
2174            render_assets,
2175            editor_root,
2176            scene_root,
2177            renderable,
2178            paint_panel_root,
2179        )
2180    }
2181
2182    #[test]
2183    fn paint_inactive_without_asset_selection() {
2184        let mut world = World::default();
2185        let mut emit = CommandQueue::new();
2186        let mut visuals = VisualWorld::default();
2187        let mut systems = SystemWorld::default();
2188        let mut render_assets = RenderAssets::new();
2189
2190        let editor_root =
2191            world.add_component_boxed_named("editor_root", Box::new(EditorComponent::new()));
2192        let scene_root =
2193            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
2194        let target = world.add_component_boxed_named("target", Box::new(TransformComponent::new()));
2195        let color = world.add_component(ColorComponent::rgba(1.0, 1.0, 1.0, 1.0));
2196        let renderable = world.add_component(RenderableComponent::cube());
2197        let _ = world.add_child(editor_root, scene_root);
2198        let _ = world.add_child(scene_root, target);
2199        let _ = world.add_child(target, color);
2200        let _ = world.add_child(color, renderable);
2201
2202        world.init_component_tree(editor_root, &mut emit);
2203        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2204
2205        push_drag_place(&mut systems, renderable);
2206        let _ = systems.process_signals(
2207            &mut world,
2208            &mut visuals,
2209            &mut render_assets,
2210            &mut emit,
2211            100_000,
2212        );
2213        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2214
2215        assert_eq!(
2216            count_named_descendants(&world, editor_root, "painted_asset_root"),
2217            0
2218        );
2219    }
2220
2221    #[test]
2222    fn asset_selection_bootstraps_to_data_payload() {
2223        let (
2224            world,
2225            _emit,
2226            _visuals,
2227            _systems,
2228            _render_assets,
2229            _editor_root,
2230            _scene_root,
2231            _renderable,
2232            _paint_panel_root,
2233        ) = init_editor_fixture();
2234
2235        let payload = _systems
2236            .editor_context
2237            .shared_state()
2238            .lock()
2239            .expect("editor context state")
2240            .selected_asset_payload
2241            .expect("selected payload");
2242        let asset_payload = world
2243            .get_component_by_id_as::<DataComponent>(payload)
2244            .expect("asset payload data");
2245
2246        assert!(matches!(
2247            asset_payload.get("asset_key"),
2248            Some(DataValue::Text(asset_key)) if !asset_key.is_empty()
2249        ));
2250    }
2251
2252    #[test]
2253    fn paint_places_when_focused_and_asset_selected() {
2254        let (
2255            mut world,
2256            mut emit,
2257            mut visuals,
2258            mut systems,
2259            mut render_assets,
2260            editor_root,
2261            _scene_root,
2262            renderable,
2263            _paint_panel_root,
2264        ) = init_editor_fixture();
2265
2266        push_drag_place(&mut systems, renderable);
2267        let _ = systems.process_signals(
2268            &mut world,
2269            &mut visuals,
2270            &mut render_assets,
2271            &mut emit,
2272            100_000,
2273        );
2274        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2275
2276        assert_eq!(
2277            count_named_descendants(&world, editor_root, "painted_asset_root"),
2278            1
2279        );
2280    }
2281
2282    #[test]
2283    fn painted_assets_get_raycastable_ancestor() {
2284        let (
2285            mut world,
2286            mut emit,
2287            mut visuals,
2288            mut systems,
2289            mut render_assets,
2290            editor_root,
2291            _scene_root,
2292            renderable,
2293            _paint_panel_root,
2294        ) = init_editor_fixture();
2295
2296        push_drag_place(&mut systems, renderable);
2297        let _ = systems.process_signals(
2298            &mut world,
2299            &mut visuals,
2300            &mut render_assets,
2301            &mut emit,
2302            100_000,
2303        );
2304        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2305
2306        let painted_root = world
2307            .find_component(editor_root, "[name='painted_asset_root']")
2308            .expect("painted asset root");
2309        let painted_renderable = world
2310            .find_component(painted_root, "Renderable")
2311            .expect("painted renderable");
2312
2313        assert!(
2314            crate::engine::ecs::system::BvhSystem::renderable_is_raycastable(
2315                &world,
2316                painted_renderable
2317            ),
2318            "expected painted asset renderables to inherit a raycastable ancestor"
2319        );
2320    }
2321
2322    #[test]
2323    fn non_grid_drag_places_once_per_gesture() {
2324        let (
2325            mut world,
2326            mut emit,
2327            mut visuals,
2328            mut systems,
2329            mut render_assets,
2330            editor_root,
2331            _scene_root,
2332            renderable,
2333            _paint_panel_root,
2334        ) = init_editor_fixture();
2335
2336        systems.rx.push_event(
2337            renderable,
2338            EventSignal::DragStart {
2339                raycaster: renderable,
2340                renderable,
2341                hit_point: [0.0, 0.0, 0.5],
2342                ray_dir_world: [0.0, 0.0, -1.0],
2343                screen_pos_px: None,
2344            },
2345        );
2346        systems.rx.push_event(
2347            renderable,
2348            EventSignal::DragMove {
2349                raycaster: renderable,
2350                renderable,
2351                hit_point: [0.1, 0.0, 0.5],
2352                delta_world: [0.1, 0.0, 0.0],
2353                screen_pos_px: None,
2354                screen_delta_px: None,
2355            },
2356        );
2357        systems.rx.push_event(
2358            renderable,
2359            EventSignal::DragMove {
2360                raycaster: renderable,
2361                renderable,
2362                hit_point: [0.2, 0.0, 0.5],
2363                delta_world: [0.1, 0.0, 0.0],
2364                screen_pos_px: None,
2365                screen_delta_px: None,
2366            },
2367        );
2368        systems.rx.push_event(
2369            renderable,
2370            EventSignal::DragEnd {
2371                raycaster: renderable,
2372                renderable,
2373                hit_point: Some([0.2, 0.0, 0.5]),
2374            },
2375        );
2376        let _ = systems.process_signals(
2377            &mut world,
2378            &mut visuals,
2379            &mut render_assets,
2380            &mut emit,
2381            100_000,
2382        );
2383        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2384
2385        assert_eq!(
2386            count_named_descendants(&world, editor_root, "painted_asset_root"),
2387            1
2388        );
2389    }
2390
2391    #[test]
2392    fn grid_drag_keeps_painting_scoped_to_one_editor() {
2393        let (
2394            mut world,
2395            mut emit,
2396            mut visuals,
2397            mut systems,
2398            mut render_assets,
2399            editor_root,
2400            scene_root,
2401            renderable,
2402            _paint_panel_root,
2403        ) = init_editor_fixture();
2404
2405        let grid = world.add_component_boxed_named("grid", Box::new(GridComponent::new(0.5)));
2406        let _ = world.add_child(scene_root, grid);
2407        world.init_component_tree(grid, &mut emit);
2408        world
2409            .get_component_by_id_as_mut::<EditorComponent>(editor_root)
2410            .expect("editor")
2411            .selected = Some(grid);
2412
2413        systems.rx.push_event(
2414            renderable,
2415            EventSignal::DragStart {
2416                raycaster: renderable,
2417                renderable,
2418                hit_point: [0.0, 0.0, 0.5],
2419                ray_dir_world: [0.0, 0.0, -1.0],
2420                screen_pos_px: None,
2421            },
2422        );
2423        systems.rx.push_event(
2424            renderable,
2425            EventSignal::DragMove {
2426                raycaster: renderable,
2427                renderable,
2428                hit_point: [0.1, 0.1, 0.5],
2429                delta_world: [0.1, 0.1, 0.0],
2430                screen_pos_px: None,
2431                screen_delta_px: None,
2432            },
2433        );
2434        systems.rx.push_event(
2435            renderable,
2436            EventSignal::DragMove {
2437                raycaster: renderable,
2438                renderable,
2439                hit_point: [0.15, 0.1, 0.5],
2440                delta_world: [0.05, 0.0, 0.0],
2441                screen_pos_px: None,
2442                screen_delta_px: None,
2443            },
2444        );
2445        systems.rx.push_event(
2446            renderable,
2447            EventSignal::DragMove {
2448                raycaster: renderable,
2449                renderable,
2450                hit_point: [0.6, 0.1, 0.5],
2451                delta_world: [0.45, 0.0, 0.0],
2452                screen_pos_px: None,
2453                screen_delta_px: None,
2454            },
2455        );
2456        systems.rx.push_event(
2457            renderable,
2458            EventSignal::DragEnd {
2459                raycaster: renderable,
2460                renderable,
2461                hit_point: Some([0.6, 0.1, 0.5]),
2462            },
2463        );
2464        let _ = systems.process_signals(
2465            &mut world,
2466            &mut visuals,
2467            &mut render_assets,
2468            &mut emit,
2469            100_000,
2470        );
2471        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2472
2473        assert_eq!(
2474            count_named_descendants(&world, editor_root, "painted_asset_root"),
2475            1
2476        );
2477    }
2478
2479    #[test]
2480    fn shared_ui_state_routes_paint_to_latest_scene_interaction_editor() {
2481        let tmp_dir = temp_asset_directory();
2482        write_test_asset(&tmp_dir);
2483
2484        let mut world = World::default();
2485        let mut emit = CommandQueue::new();
2486        let mut visuals = VisualWorld::default();
2487        let mut systems = SystemWorld::default();
2488        let mut render_assets = RenderAssets::new();
2489
2490        systems
2491            .asset_system
2492            .scan_assets_dir(&tmp_dir)
2493            .expect("scan");
2494        systems.selection.install_handlers(&mut systems.rx);
2495
2496        let editor_a =
2497            world.add_component_boxed_named("editor_a", Box::new(EditorComponent::new()));
2498        let scene_a =
2499            world.add_component_boxed_named("scene_a", Box::new(TransformComponent::new()));
2500        let target_a =
2501            world.add_component_boxed_named("target_a", Box::new(TransformComponent::new()));
2502        let color_a = world.add_component(ColorComponent::rgba(0.7, 0.7, 0.7, 1.0));
2503        let renderable_a = world.add_component(RenderableComponent::cube());
2504        let _ = world.add_child(editor_a, scene_a);
2505        let _ = world.add_child(scene_a, target_a);
2506        let _ = world.add_child(target_a, color_a);
2507        let _ = world.add_child(color_a, renderable_a);
2508
2509        let editor_b =
2510            world.add_component_boxed_named("editor_b", Box::new(EditorComponent::new()));
2511        let scene_b =
2512            world.add_component_boxed_named("scene_b", Box::new(TransformComponent::new()));
2513        let target_b =
2514            world.add_component_boxed_named("target_b", Box::new(TransformComponent::new()));
2515        let color_b = world.add_component(ColorComponent::rgba(0.7, 0.7, 0.7, 1.0));
2516        let renderable_b = world.add_component(RenderableComponent::cube());
2517        let _ = world.add_child(editor_b, scene_b);
2518        let _ = world.add_child(scene_b, target_b);
2519        let _ = world.add_child(target_b, color_b);
2520        let _ = world.add_child(color_b, renderable_b);
2521
2522        world.init_component_tree(editor_a, &mut emit);
2523        world.init_component_tree(editor_b, &mut emit);
2524        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2525
2526        let runtime_ui_root = find_named_root(&world, RUNTIME_UI_ROOT_NAME);
2527        let paint_panel_root = world
2528            .find_component(runtime_ui_root, PAINT_PANEL_ROOT_SELECTOR)
2529            .expect("paint panel");
2530
2531        push_asset_and_panel_focus(&world, &mut systems, paint_panel_root);
2532        let _ = systems.process_signals(
2533            &mut world,
2534            &mut visuals,
2535            &mut render_assets,
2536            &mut emit,
2537            100_000,
2538        );
2539        systems.rx.begin_frame();
2540        let _ = systems.process_signals(
2541            &mut world,
2542            &mut visuals,
2543            &mut render_assets,
2544            &mut emit,
2545            100_000,
2546        );
2547        systems.rx.begin_frame();
2548        let _ = systems.process_signals(
2549            &mut world,
2550            &mut visuals,
2551            &mut render_assets,
2552            &mut emit,
2553            100_000,
2554        );
2555        push_click(&mut systems, paint_panel_root);
2556        let _ = systems.process_signals(
2557            &mut world,
2558            &mut visuals,
2559            &mut render_assets,
2560            &mut emit,
2561            100_000,
2562        );
2563        systems.rx.begin_frame();
2564        let _ = systems.process_signals(
2565            &mut world,
2566            &mut visuals,
2567            &mut render_assets,
2568            &mut emit,
2569            100_000,
2570        );
2571
2572        push_drag_place(&mut systems, renderable_b);
2573        let _ = systems.process_signals(
2574            &mut world,
2575            &mut visuals,
2576            &mut render_assets,
2577            &mut emit,
2578            100_000,
2579        );
2580        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2581
2582        assert_eq!(
2583            count_named_descendants(&world, editor_a, "painted_asset_root"),
2584            0
2585        );
2586        assert_eq!(
2587            count_named_descendants(&world, editor_b, "painted_asset_root"),
2588            1
2589        );
2590    }
2591
2592    #[test]
2593    fn paint_tool_line_and_fill_noop() {
2594        let (
2595            mut world,
2596            mut emit,
2597            mut visuals,
2598            mut systems,
2599            mut render_assets,
2600            editor_root,
2601            _scene_root,
2602            renderable,
2603            _paint_panel_root,
2604        ) = init_editor_fixture();
2605
2606        // 1. Line tool
2607        {
2608            let mut state = systems.editor_paint.shared_state.lock().unwrap();
2609            state.selected_tool = PaintTool::Line;
2610        }
2611
2612        push_drag_place(&mut systems, renderable);
2613        let _ = systems.process_signals(
2614            &mut world,
2615            &mut visuals,
2616            &mut render_assets,
2617            &mut emit,
2618            100_000,
2619        );
2620        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2621
2622        assert_eq!(
2623            count_named_descendants(&world, editor_root, "painted_asset_root"),
2624            0,
2625            "Line tool should NOT place assets on click (NOOP)"
2626        );
2627
2628        // 2. Fill tool
2629        {
2630            let mut state = systems.editor_paint.shared_state.lock().unwrap();
2631            state.selected_tool = PaintTool::Fill;
2632        }
2633
2634        push_drag_place(&mut systems, renderable);
2635        let _ = systems.process_signals(
2636            &mut world,
2637            &mut visuals,
2638            &mut render_assets,
2639            &mut emit,
2640            100_000,
2641        );
2642        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2643
2644        assert_eq!(
2645            count_named_descendants(&world, editor_root, "painted_asset_root"),
2646            0,
2647            "Fill tool should NOT place assets on click (NOOP)"
2648        );
2649    }
2650
2651    #[test]
2652    fn paint_tool_spray_can_scatters_assets() {
2653        let (
2654            mut world,
2655            mut emit,
2656            mut visuals,
2657            mut systems,
2658            mut render_assets,
2659            editor_root,
2660            _scene_root,
2661            renderable,
2662            _paint_panel_root,
2663        ) = init_editor_fixture();
2664
2665        {
2666            let mut state = systems.editor_paint.shared_state.lock().unwrap();
2667            state.selected_tool = PaintTool::SprayCan;
2668        }
2669
2670        // Click hit point is [0.0, 0.0, 0.5] in push_click
2671        push_drag_place(&mut systems, renderable);
2672        let _ = systems.process_signals(
2673            &mut world,
2674            &mut visuals,
2675            &mut render_assets,
2676            &mut emit,
2677            100_000,
2678        );
2679        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2680
2681        assert_eq!(
2682            count_named_descendants(&world, editor_root, "painted_asset_root"),
2683            1,
2684            "Spray Can tool should place 1 asset"
2685        );
2686
2687        let painted_root = world
2688            .find_component(editor_root, "[name='painted_asset_root']")
2689            .expect("painted asset root");
2690        let transform = world
2691            .get_component_by_id_as::<TransformComponent>(painted_root)
2692            .expect("transform");
2693        let position = transform.transform.translation;
2694
2695        // Since Spray Can places with random offset, it should NOT be exactly at [0.0, 0.0, 0.5].
2696        let dx = position[0] - 0.0;
2697        let dz = position[2] - 0.5;
2698        let dist = (dx * dx + dz * dz).sqrt();
2699        assert!(
2700            dist > 0.0,
2701            "Spray Can should offset the asset placement by a random distance: position={:?}",
2702            position
2703        );
2704        assert!(
2705            dist <= 1.5,
2706            "Spray Can offset should be at most 1.5 units: position={:?}",
2707            position
2708        );
2709    }
2710
2711    #[test]
2712    fn paint_tool_erase_removes_painted_assets() {
2713        let (
2714            mut world,
2715            mut emit,
2716            mut visuals,
2717            mut systems,
2718            mut render_assets,
2719            editor_root,
2720            _scene_root,
2721            renderable,
2722            _paint_panel_root,
2723        ) = init_editor_fixture();
2724
2725        // 1. First, place an asset using Free Draw
2726        push_drag_place(&mut systems, renderable);
2727        let _ = systems.process_signals(
2728            &mut world,
2729            &mut visuals,
2730            &mut render_assets,
2731            &mut emit,
2732            100_000,
2733        );
2734        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2735
2736        assert_eq!(
2737            count_named_descendants(&world, editor_root, "painted_asset_root"),
2738            1,
2739            "expected 1 painted asset root initially"
2740        );
2741
2742        let painted_root = world
2743            .find_component(editor_root, "[name='painted_asset_root']")
2744            .expect("painted asset root");
2745        let painted_renderable = world
2746            .find_component(painted_root, "Renderable")
2747            .expect("painted renderable");
2748
2749        // 2. Select Erase tool
2750        {
2751            let mut state = systems.editor_paint.shared_state.lock().unwrap();
2752            state.selected_tool = PaintTool::Erase;
2753        }
2754
2755        // 3. Click directly on the painted renderable to erase it
2756        push_click(&mut systems, painted_renderable);
2757        let _ = systems.process_signals(
2758            &mut world,
2759            &mut visuals,
2760            &mut render_assets,
2761            &mut emit,
2762            100_000,
2763        );
2764        systems.process_commands(&mut world, &mut visuals, &mut render_assets, &mut emit);
2765
2766        assert_eq!(
2767            count_named_descendants(&world, editor_root, "painted_asset_root"),
2768            0,
2769            "Erase tool should remove the painted asset subtree"
2770        );
2771    }
2772}
2773const PAINT_DEBUG_LOGS: bool = false;