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 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; 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; 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 {
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 {
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 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 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 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 {
2751 let mut state = systems.editor_paint.shared_state.lock().unwrap();
2752 state.selected_tool = PaintTool::Erase;
2753 }
2754
2755 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;