Skip to main content

khora_editor/panels/
viewport.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! 3D Viewport panel — displays the offscreen render texture.
16
17use std::path::{Path, MAIN_SEPARATOR_STR};
18use std::sync::{Arc, Mutex};
19
20use khora_sdk::editor_ui::*;
21use khora_sdk::prelude::math::Vec3;
22
23pub struct ViewportPanel {
24    handle: ViewportTextureHandle,
25    state: Arc<Mutex<EditorState>>,
26    camera: Arc<Mutex<EditorCamera>>,
27    theme: UiTheme,
28}
29
30/// Snapshot of the few status fields the stats card paints, copied while
31/// the EditorState mutex is held so we don't keep it locked during paint.
32struct StatsSnap {
33    fps: f32,
34    frame_time_ms: f32,
35    entity_count: usize,
36    memory_used_mb: f32,
37    draw_calls: u32,
38    triangles: u64,
39    vram_mb: f32,
40}
41
42impl ViewportPanel {
43    pub fn new(
44        handle: ViewportTextureHandle,
45        state: Arc<Mutex<EditorState>>,
46        camera: Arc<Mutex<EditorCamera>>,
47        theme: UiTheme,
48    ) -> Self {
49        Self {
50            handle,
51            state,
52            camera,
53            theme,
54        }
55    }
56
57    fn has_camera_node(nodes: &[SceneNode]) -> bool {
58        for node in nodes {
59            if node.icon == EntityIcon::Camera || Self::has_camera_node(&node.children) {
60                return true;
61            }
62        }
63        false
64    }
65
66    fn paint_camera_preview(
67        &self,
68        ui: &mut dyn UiBuilder,
69        viewport_min: [f32; 2],
70        viewport_size: [f32; 2],
71    ) {
72        // Keep the preview legible but avoid clutter on small viewports.
73        if viewport_size[0] < 320.0 || viewport_size[1] < 220.0 {
74            return;
75        }
76
77        let min_dim = viewport_size[0].min(viewport_size[1]);
78        let scale = (min_dim / 700.0).clamp(0.75, 1.30);
79
80        let preview_w = (viewport_size[0] * 0.26).clamp(120.0 * scale, 260.0 * scale);
81        let preview_h = (preview_w * 0.62).clamp(84.0 * scale, 170.0 * scale);
82        let margin = 10.0 * scale;
83
84        let min = [
85            viewport_min[0] + viewport_size[0] - preview_w - margin,
86            viewport_min[1] + viewport_size[1] - preview_h - margin,
87        ];
88        let size = [preview_w, preview_h];
89
90        // Background panel.
91        ui.paint_rect_filled(min, size, [0.05, 0.06, 0.09, 0.86], 6.0 * scale);
92
93        // Border.
94        let x0 = min[0];
95        let y0 = min[1];
96        let x1 = min[0] + size[0];
97        let y1 = min[1] + size[1];
98        let border = [0.24, 0.28, 0.36, 1.0];
99        let border_w = (1.0 * scale).clamp(1.0, 2.0);
100        ui.paint_line([x0, y0], [x1, y0], border, border_w);
101        ui.paint_line([x1, y0], [x1, y1], border, border_w);
102        ui.paint_line([x1, y1], [x0, y1], border, border_w);
103        ui.paint_line([x0, y1], [x0, y0], border, border_w);
104
105        // Fake frame content area to make the placeholder more informative.
106        let content_min = [x0 + 8.0 * scale, y0 + 34.0 * scale];
107        let content_size = [
108            (size[0] - 16.0 * scale).max(8.0),
109            (size[1] - 42.0 * scale).max(8.0),
110        ];
111        ui.paint_rect_filled(
112            content_min,
113            content_size,
114            [0.08, 0.11, 0.16, 0.95],
115            4.0 * scale,
116        );
117
118        // Crosshair inside preview frame.
119        let cx = content_min[0] + content_size[0] * 0.5;
120        let cy = content_min[1] + content_size[1] * 0.5;
121        ui.paint_line(
122            [content_min[0] + 6.0 * scale, cy],
123            [content_min[0] + content_size[0] - 6.0 * scale, cy],
124            [0.30, 0.38, 0.50, 1.0],
125            (1.0 * scale).clamp(1.0, 2.0),
126        );
127        ui.paint_line(
128            [cx, content_min[1] + 6.0 * scale],
129            [cx, content_min[1] + content_size[1] - 6.0 * scale],
130            [0.30, 0.38, 0.50, 1.0],
131            (1.0 * scale).clamp(1.0, 2.0),
132        );
133
134        ui.paint_text(
135            [x0 + 8.0 * scale, y0 + 8.0 * scale],
136            [0.88, 0.91, 0.95, 1.0],
137            "Camera Preview",
138        );
139        ui.paint_text(
140            [x0 + 8.0 * scale, y0 + 22.0 * scale],
141            [0.62, 0.67, 0.75, 1.0],
142            "MVP placeholder",
143        );
144    }
145
146    fn paint_axis_gizmo(
147        &self,
148        ui: &mut dyn UiBuilder,
149        viewport_min: [f32; 2],
150        viewport_size: [f32; 2],
151    ) {
152        let theme = &self.theme;
153        let min_dim = viewport_size[0].min(viewport_size[1]);
154        let scale = (min_dim / 700.0).clamp(0.75, 1.55);
155
156        // Top-right corner. This is where every 3D tool puts the view-
157        // orientation gizmo, and muscle memory is worth more here than
158        // novelty. The transport lives at the bottom centre, so nothing
159        // competes for the corner.
160        let plate_half = 34.0 * scale;
161        let length = 22.0 * scale;
162        let margin = 12.0 * scale;
163        let center = [
164            viewport_min[0] + viewport_size[0] - margin - plate_half,
165            viewport_min[1] + margin + plate_half,
166        ];
167
168        // A translucent puck so the gizmo stays legible over any scene without
169        // hiding it.
170        ui.paint_circle_filled(
171            center,
172            plate_half,
173            khora_tool_ui::widgets::paint::tint(theme.background, 0.7),
174        );
175        ui.paint_circle_stroke(center, plate_half, theme.border, 1.0);
176
177        let (right, up) = if let Ok(cam) = self.camera.lock() {
178            (cam.right(), cam.up())
179        } else {
180            (Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0))
181        };
182
183        let line_w = (2.0 * scale).clamp(1.5, 3.2);
184        let label_offset = 5.0 * scale;
185        let show_labels = min_dim >= 240.0;
186
187        // Per-axis paint: line out from center + colored knob at the tip
188        // with the axis label centered on the knob (compass-puck look).
189        let paint_axis = |ui: &mut dyn UiBuilder,
190                          axis: Vec3,
191                          label: &str,
192                          color: [f32; 4],
193                          center: [f32; 2],
194                          right: Vec3,
195                          up: Vec3,
196                          length: f32,
197                          line_w: f32,
198                          label_offset: f32,
199                          show_labels: bool| {
200            let sx = axis.dot(right);
201            let sy = axis.dot(up);
202            let end = [center[0] + sx * length, center[1] - sy * length];
203            // Line from origin to knob center.
204            ui.paint_line(center, end, color, line_w);
205            // Knob.
206            let knob_r = 7.0 * scale;
207            ui.paint_circle_filled(end, knob_r, color);
208            ui.paint_circle_stroke(end, knob_r, [0.05, 0.07, 0.10, 1.0], 1.0);
209            if show_labels {
210                ui.paint_text_styled(
211                    [end[0], end[1] - knob_r * 0.5 - 1.0],
212                    label,
213                    10.0,
214                    [0.02, 0.03, 0.06, 1.0],
215                    FontFamilyHint::Monospace,
216                    TextAlign::Center,
217                );
218            }
219            let _ = label_offset;
220        };
221
222        paint_axis(
223            ui,
224            Vec3::new(1.0, 0.0, 0.0),
225            "X",
226            [0.95, 0.32, 0.28, 1.0],
227            center,
228            right,
229            up,
230            length,
231            line_w,
232            label_offset,
233            show_labels,
234        );
235        paint_axis(
236            ui,
237            Vec3::new(0.0, 1.0, 0.0),
238            "Y",
239            [0.34, 0.88, 0.43, 1.0],
240            center,
241            right,
242            up,
243            length,
244            line_w,
245            label_offset,
246            show_labels,
247        );
248        paint_axis(
249            ui,
250            Vec3::new(0.0, 0.0, 1.0),
251            "Z",
252            [0.35, 0.63, 0.97, 1.0],
253            center,
254            right,
255            up,
256            length,
257            line_w,
258            label_offset,
259            show_labels,
260        );
261    }
262}
263
264impl EditorPanel for ViewportPanel {
265    fn id(&self) -> &str {
266        "khora.editor.viewport"
267    }
268    fn title(&self) -> &str {
269        "Viewport"
270    }
271    fn ui(&mut self, ui: &mut dyn UiBuilder) {
272        // Always reset the viewport's hovered/rect state at the start of
273        // the frame — otherwise the values from the previous Scene-mode
274        // frame leak into DCC mode and the input pipeline routes mouse
275        // events into the engine when the viewport isn't even visible.
276        if let Ok(mut state) = self.state.lock() {
277            state.viewport_hovered = false;
278            state.viewport_screen_rect = None;
279        }
280
281        // No mode check here: the workbench keeps a dock layout per workspace,
282        // so this panel is only ever laid out in the one that names it.
283        let w = ui.available_width();
284        let h = ui.available_height();
285        if w > 1.0 && h > 1.0 {
286            if let Some(min) = ui.viewport_image(self.handle, [w, h]) {
287                let hovered = ui.is_last_item_hovered();
288                // Drop target: any asset tile dragged onto the viewport. The
289                // asset browser tags its drag payload with `ASSET_DRAG_TAG`
290                // (high 32 bits) so we can tell our drops apart from the
291                // scene-tree reparent flow's `EntityId`-packed payloads. We
292                // dispatch by the asset's declared type.
293                if let Some(payload) = ui.dnd_take_drop_payload() {
294                    // Resolve index and epoch under one lock: the payload's
295                    // epoch stamp is only meaningful against the same read of
296                    // `asset_entries` the index will address.
297                    let entry = self.state.lock().ok().and_then(|s| {
298                        let idx = crate::panels::asset_browser::unpack_asset_drag(
299                            payload,
300                            s.asset_epoch,
301                        )?;
302                        s.asset_entries.get(idx as usize).cloned()
303                    });
304                    if let Some(entry) = entry {
305                        self.dispatch_asset_drop(ui, &entry, min, [w, h]);
306                    }
307                }
308                let mut show_camera_preview = false;
309                if let Ok(mut state) = self.state.lock() {
310                    state.viewport_hovered = hovered;
311                    state.viewport_screen_rect = Some([min[0], min[1], w, h]);
312                    show_camera_preview = Self::has_camera_node(&state.scene_roots);
313                }
314
315                if w >= 170.0 && h >= 140.0 {
316                    self.paint_axis_gizmo(ui, min, [w, h]);
317                }
318
319                if show_camera_preview {
320                    self.paint_camera_preview(ui, min, [w, h]);
321                }
322
323                // ── Branded overlays (Phase I) ────────────
324                self.paint_tool_pill(ui, min);
325                self.paint_transport_pill(ui, min, [w, h]);
326                self.paint_stats_card(ui, min, [w, h]);
327                self.paint_diamond_watermark(ui, min, [w, h]);
328                self.paint_play_mode_indicator(ui, min, [w, h]);
329            }
330        } else {
331            ui.label("Viewport (no space)");
332        }
333    }
334}
335
336impl ViewportPanel {
337    fn paint_tool_pill(&self, ui: &mut dyn UiBuilder, viewport_min: [f32; 2]) {
338        let theme = &self.theme;
339        let (current_gizmo, play_mode) = match self.state.lock() {
340            Ok(s) => (s.gizmo_mode, s.play_mode),
341            Err(_) => return,
342        };
343        let _ = play_mode;
344
345        let pill_x = viewport_min[0] + 12.0;
346        let pill_y = viewport_min[1] + 12.0;
347        let pill_h = 32.0;
348        let btn_w = 32.0;
349
350        let tools = [
351            (Icon::Hand, GizmoMode::Select, "h-tool-hand"),
352            (Icon::Move, GizmoMode::Move, "h-tool-move"),
353            (Icon::Rotate, GizmoMode::Rotate, "h-tool-rotate"),
354            (Icon::Scale, GizmoMode::Scale, "h-tool-scale"),
355        ];
356        // Local/World labels are decorations today (the toggle isn't wired).
357        // Keep the pill snug around just the gizmo tools so the labels can't
358        // overflow.
359        let total_w = btn_w * tools.len() as f32 + 8.0;
360
361        // bg
362        ui.paint_rect_filled(
363            [pill_x, pill_y],
364            [total_w, pill_h],
365            crate::widgets::paint::with_alpha(theme.surface_elevated, 0.92),
366            999.0,
367        );
368        ui.paint_rect_stroke(
369            [pill_x, pill_y],
370            [total_w, pill_h],
371            crate::widgets::paint::with_alpha(theme.separator, 0.6),
372            999.0,
373            1.0,
374        );
375
376        let mut cx = pill_x + 4.0;
377        for (icon, mode, salt) in &tools {
378            let active = current_gizmo == *mode;
379            let r = [cx, pill_y + 3.0, btn_w - 4.0, pill_h - 6.0];
380            let int = ui.interact_rect(salt, r);
381            if active {
382                ui.paint_rect_filled([r[0], r[1]], [r[2], r[3]], theme.surface_active, 999.0);
383            }
384            let icon_color = if active || int.hovered {
385                theme.text
386            } else {
387                theme.text_dim
388            };
389            crate::widgets::paint::paint_icon(
390                ui,
391                [r[0] + 6.0, r[1] + 6.0],
392                *icon,
393                14.0,
394                icon_color,
395            );
396            if int.clicked {
397                if let Ok(mut s) = self.state.lock() {
398                    s.gizmo_mode = *mode;
399                }
400            }
401            cx += btn_w;
402        }
403
404        let _ = cx; // Local/World toggle removed until it's actually wired.
405    }
406
407    /// The transport — play, pause, stop — floating at the bottom centre.
408    ///
409    /// Each button's *state* is derived from `PlayMode`, so the control always
410    /// tells the truth about what the engine is doing: a disabled Stop while
411    /// editing means there is genuinely nothing to stop. Buttons that cannot
412    /// act are dimmed and swallow their clicks rather than silently no-op.
413    fn paint_transport_pill(
414        &self,
415        ui: &mut dyn UiBuilder,
416        viewport_min: [f32; 2],
417        viewport_size: [f32; 2],
418    ) {
419        use khora_tool_ui::widgets::paint::{fill_stroke, icon_centered, tint};
420
421        let theme = &self.theme;
422        let mode = self
423            .state
424            .lock()
425            .ok()
426            .map(|s| s.play_mode)
427            .unwrap_or(PlayMode::Editing);
428
429        let playing = mode == PlayMode::Playing;
430        let paused = mode == PlayMode::Paused;
431        let running = playing || paused;
432
433        let btn = 26.0;
434        let gap = 4.0;
435        let pad = 6.0;
436        let pill_w = btn * 3.0 + gap * 2.0 + pad * 2.0;
437        let pill_h = btn + pad * 2.0;
438        let pill_x = viewport_min[0] + (viewport_size[0] - pill_w) * 0.5;
439        let pill_y = viewport_min[1] + viewport_size[1] - pill_h - 12.0;
440
441        if viewport_size[0] < pill_w + 24.0 || viewport_size[1] < 80.0 {
442            return;
443        }
444
445        fill_stroke(
446            ui,
447            [pill_x, pill_y, pill_w, pill_h],
448            tint(theme.surface_elevated, 0.92),
449            theme.border,
450            pill_h * 0.5,
451        );
452
453        let mut x = pill_x + pad;
454        let cy = pill_y + pad;
455
456        // ── Play / resume ──
457        // While playing, this is the "running" indicator rather than an action.
458        let play_rect = [x, cy, btn, btn];
459        let play_hit = ui.interact_rect("vp-play", play_rect);
460        if playing {
461            ui.paint_circle_filled([x + btn * 0.5, cy + btn * 0.5], btn * 0.5, theme.success);
462        }
463        icon_centered(
464            ui,
465            play_rect,
466            Icon::Play,
467            13.0,
468            if playing {
469                theme.text_inverse
470            } else if play_hit.hovered {
471                theme.success
472            } else {
473                theme.text
474            },
475        );
476        if play_hit.clicked && !playing {
477            self.dispatch("play");
478        }
479        x += btn + gap;
480
481        // ── Pause ── only meaningful while something is running.
482        let pause_rect = [x, cy, btn, btn];
483        let pause_hit = ui.interact_rect("vp-pause", pause_rect);
484        if paused {
485            ui.paint_circle_filled([x + btn * 0.5, cy + btn * 0.5], btn * 0.5, theme.warning);
486        }
487        icon_centered(
488            ui,
489            pause_rect,
490            Icon::Pause,
491            13.0,
492            if paused {
493                theme.text_inverse
494            } else if !running {
495                theme.text_disabled
496            } else if pause_hit.hovered {
497                theme.warning
498            } else {
499                theme.text
500            },
501        );
502        if pause_hit.clicked && playing {
503            self.dispatch("pause");
504        }
505        x += btn + gap;
506
507        // ── Stop ──
508        let stop_rect = [x, cy, btn, btn];
509        let stop_hit = ui.interact_rect("vp-stop", stop_rect);
510        icon_centered(
511            ui,
512            stop_rect,
513            Icon::Stop,
514            13.0,
515            if !running {
516                theme.text_disabled
517            } else if stop_hit.hovered {
518                theme.error
519            } else {
520                theme.text
521            },
522        );
523        if stop_hit.clicked && running {
524            self.dispatch("stop");
525        }
526    }
527
528    /// Queues a menu action for the app to pick up next tick.
529    fn dispatch(&self, action: &str) {
530        if let Ok(mut s) = self.state.lock() {
531            s.pending_menu_action = Some(action.to_owned());
532        }
533    }
534
535    fn paint_stats_card(
536        &self,
537        ui: &mut dyn UiBuilder,
538        viewport_min: [f32; 2],
539        viewport_size: [f32; 2],
540    ) {
541        let theme = &self.theme;
542        let snapshot = match self.state.lock() {
543            Ok(s) => StatsSnap {
544                fps: s.status.fps,
545                frame_time_ms: s.status.frame_time_ms,
546                entity_count: s.entity_count,
547                memory_used_mb: s.status.memory_used_mb,
548                draw_calls: s.status.draw_calls,
549                triangles: s.status.triangles,
550                vram_mb: s.status.vram_mb,
551            },
552            Err(_) => return,
553        };
554
555        let card_h = 50.0;
556        // Card width adapts to viewport: never wider than viewport - 24px
557        // margin, never narrower than 320px (FPS cell would otherwise become
558        // unreadable). On very small viewports we bail out entirely.
559        let avail_w = viewport_size[0] - 24.0;
560        if avail_w < 280.0 || viewport_size[1] < 120.0 {
561            return;
562        }
563        let card_w = avail_w.min(480.0);
564        let card_x = viewport_min[0] + 12.0;
565        let card_y = viewport_min[1] + viewport_size[1] - card_h - 12.0;
566
567        ui.paint_rect_filled(
568            [card_x, card_y],
569            [card_w, card_h],
570            crate::widgets::paint::with_alpha(theme.surface_elevated, 0.85),
571            theme.radius_lg,
572        );
573        ui.paint_rect_stroke(
574            [card_x, card_y],
575            [card_w, card_h],
576            crate::widgets::paint::with_alpha(theme.separator, 0.55),
577            theme.radius_lg,
578            1.0,
579        );
580
581        // Format triangle counts compactly.
582        let tris_str = if snapshot.triangles >= 1_000_000 {
583            format!("{:.1}M", snapshot.triangles as f64 / 1_000_000.0)
584        } else if snapshot.triangles >= 1_000 {
585            format!("{:.1}K", snapshot.triangles as f64 / 1_000.0)
586        } else {
587            format!("{}", snapshot.triangles)
588        };
589
590        let stats: [(&str, String, [f32; 4]); 6] = [
591            (
592                "FPS",
593                format!("{:.0}", snapshot.fps),
594                if snapshot.fps > 55.0 {
595                    theme.success
596                } else if snapshot.fps > 30.0 {
597                    theme.warning
598                } else {
599                    theme.error
600                },
601            ),
602            (
603                "FRAME",
604                format!("{:.2}ms", snapshot.frame_time_ms),
605                theme.text,
606            ),
607            ("ENTITIES", format!("{}", snapshot.entity_count), theme.text),
608            ("DRAWS", format!("{}", snapshot.draw_calls), theme.text),
609            ("TRIS", tris_str, theme.text),
610            (
611                if snapshot.vram_mb > 0.0 {
612                    "VRAM"
613                } else {
614                    "MEM"
615                },
616                if snapshot.vram_mb > 0.0 {
617                    format!("{:.1}GB", snapshot.vram_mb / 1024.0)
618                } else {
619                    format!("{:.0}MB", snapshot.memory_used_mb)
620                },
621                if snapshot.vram_mb > 1500.0 || snapshot.memory_used_mb > 1500.0 {
622                    theme.warning
623                } else {
624                    theme.text
625                },
626            ),
627        ];
628        let cell_w = card_w / stats.len() as f32;
629        for (i, (k, v, color)) in stats.iter().enumerate() {
630            let cx = card_x + i as f32 * cell_w + cell_w * 0.5;
631            ui.paint_text_styled(
632                [cx, card_y + 8.0],
633                k,
634                9.5,
635                theme.text_muted,
636                FontFamilyHint::Proportional,
637                TextAlign::Center,
638            );
639            ui.paint_text_styled(
640                [cx, card_y + 22.0],
641                v,
642                12.0,
643                *color,
644                FontFamilyHint::Monospace,
645                TextAlign::Center,
646            );
647        }
648    }
649
650    // Empty marker — closes paint_stats_card
651
652    fn paint_play_mode_indicator(
653        &self,
654        ui: &mut dyn UiBuilder,
655        viewport_min: [f32; 2],
656        viewport_size: [f32; 2],
657    ) {
658        let theme = &self.theme;
659        let (mode_label, color) = match self.state.lock().ok().map(|s| s.play_mode) {
660            Some(PlayMode::Playing) => ("PLAY", theme.success),
661            Some(PlayMode::Paused) => ("PAUSED", theme.warning),
662            _ => return, // Editing — no indicator.
663        };
664
665        // Colored border around the entire viewport so the user can't miss
666        // that they're not in edit mode.
667        let stroke_w = 2.0;
668        ui.paint_rect_stroke(
669            [viewport_min[0], viewport_min[1]],
670            viewport_size,
671            crate::widgets::paint::with_alpha(color, 0.85),
672            0.0,
673            stroke_w,
674        );
675
676        // Label pill in the top-center.
677        let label_w = ui.measure_text(mode_label, 11.0, FontFamilyHint::Proportional)[0] + 24.0;
678        let label_h = 22.0;
679        let lx = viewport_min[0] + (viewport_size[0] - label_w) * 0.5;
680        let ly = viewport_min[1] + 12.0;
681        ui.paint_rect_filled(
682            [lx, ly],
683            [label_w, label_h],
684            crate::widgets::paint::with_alpha(color, 0.85),
685            999.0,
686        );
687        ui.paint_circle_filled([lx + 10.0, ly + label_h * 0.5], 3.0, theme.background);
688        ui.paint_text_styled(
689            [lx + label_w * 0.5 + 6.0, ly + 4.5],
690            mode_label,
691            11.0,
692            theme.background,
693            FontFamilyHint::Proportional,
694            TextAlign::Center,
695        );
696    }
697
698    /// Routes a dropped asset by its declared type. Prefabs/scenes spawn or
699    /// load; a mesh materialises at the unprojected drop point; a texture or
700    /// material is assigned to the current selection.
701    fn dispatch_asset_drop(
702        &self,
703        ui: &dyn UiBuilder,
704        entry: &AssetEntry,
705        viewport_min: [f32; 2],
706        viewport_size: [f32; 2],
707    ) {
708        let rel = entry.source_path.clone();
709        match entry.asset_type.as_str() {
710            "prefab" => {
711                if let Ok(mut state) = self.state.lock() {
712                    state.pending_prefab_spawn = Some((rel, None));
713                    log::info!("Viewport: prefab '{}' dropped — spawning", entry.name);
714                }
715            }
716            "scene" => {
717                let abs = self
718                    .state
719                    .lock()
720                    .ok()
721                    .and_then(|s| s.project_folder.clone())
722                    .map(|pf| {
723                        Path::new(&pf)
724                            .join("assets")
725                            .join(rel.replace('/', MAIN_SEPARATOR_STR))
726                            .to_string_lossy()
727                            .to_string()
728                    });
729                match abs {
730                    Some(abs) => {
731                        if let Ok(mut state) = self.state.lock() {
732                            state.pending_scene_load = Some(abs);
733                            log::info!("Viewport: scene '{}' dropped — loading", entry.name);
734                        }
735                    }
736                    None => log::warn!(
737                        "Viewport: cannot load scene '{}' — no project folder set",
738                        rel
739                    ),
740                }
741            }
742            "mesh" => {
743                let point = self.compute_drop_point(ui, viewport_min, viewport_size);
744                if let Ok(mut state) = self.state.lock() {
745                    state.pending_spawn_mesh_asset = Some((rel, point, None));
746                    log::info!(
747                        "Viewport: mesh '{}' dropped at [{:.2}, {:.2}, {:.2}]",
748                        entry.name,
749                        point[0],
750                        point[1],
751                        point[2]
752                    );
753                }
754            }
755            "texture" | "material" => {
756                if let Ok(mut state) = self.state.lock() {
757                    match state.selection.iter().copied().next() {
758                        Some(target) => {
759                            state.pending_assign_texture = Some((rel, target));
760                            log::info!(
761                                "Viewport: '{}' dropped — assigning to selected entity",
762                                entry.name
763                            );
764                        }
765                        None => {
766                            log::warn!("Viewport: select an entity to assign '{}' to", entry.name)
767                        }
768                    }
769                }
770            }
771            other => log::info!("Viewport: dropped asset type '{other}' is not droppable here"),
772        }
773    }
774
775    /// Unprojects the drop point onto the ground plane (`y = 0`). Falls back to
776    /// a fixed distance along the ray when the ray is parallel to the ground or
777    /// the pointer position is unavailable (uses the viewport centre then).
778    fn compute_drop_point(
779        &self,
780        ui: &dyn UiBuilder,
781        viewport_min: [f32; 2],
782        viewport_size: [f32; 2],
783    ) -> [f32; 3] {
784        let [w, h] = viewport_size;
785        let (local_x, local_y) = match ui.pointer_position() {
786            Some([px, py]) => (px - viewport_min[0], py - viewport_min[1]),
787            None => (w * 0.5, h * 0.5),
788        };
789        let ray = match self.camera.lock() {
790            Ok(cam) => cam.screen_to_ray(local_x, local_y, w, h),
791            Err(_) => return [0.0, 0.0, 0.0],
792        };
793        let point = if ray.direction.y.abs() > 1e-4 {
794            let t = -ray.origin.y / ray.direction.y;
795            if t > 0.0 {
796                ray.origin + ray.direction * t
797            } else {
798                ray.origin + ray.direction * 10.0
799            }
800        } else {
801            ray.origin + ray.direction * 10.0
802        };
803        [point.x, point.y, point.z]
804    }
805
806    fn paint_diamond_watermark(
807        &self,
808        ui: &mut dyn UiBuilder,
809        viewport_min: [f32; 2],
810        viewport_size: [f32; 2],
811    ) {
812        let theme = &self.theme;
813        let cx = viewport_min[0] + viewport_size[0] * 0.5;
814        let cy = viewport_min[1] + viewport_size[1] * 0.5;
815        let size = (viewport_size[0].min(viewport_size[1]) * 0.18).clamp(80.0, 220.0);
816        crate::widgets::brand::paint_diamond_outline(
817            ui,
818            cx,
819            cy,
820            size,
821            crate::widgets::paint::with_alpha(theme.primary, 0.06),
822            1.5,
823        );
824    }
825}