1use 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
30struct 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 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 ui.paint_rect_filled(min, size, [0.05, 0.06, 0.09, 0.86], 6.0 * scale);
92
93 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 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 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 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 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 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 ui.paint_line(center, end, color, line_w);
205 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 if let Ok(mut state) = self.state.lock() {
277 state.viewport_hovered = false;
278 state.viewport_screen_rect = None;
279 }
280
281 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 if let Some(payload) = ui.dnd_take_drop_payload() {
294 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 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 let total_w = btn_w * tools.len() as f32 + 8.0;
360
361 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; }
406
407 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 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 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 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 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 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 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 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, };
664
665 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 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 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 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}