1use glam::Vec2;
35
36use crate::application::Application;
37use crate::color::Color;
38use crate::math::Rect;
39use crate::render::{RenderStats, Renderer};
40use crate::scene::Stage;
41use crate::scene::clip::MaskShape;
42use crate::scene::container::Container;
43use crate::scene::graphics::{Fill, Graphics, Stroke};
44use crate::scene::node::{Node, NodeId};
45use crate::scene::sprite::Sprite;
46use crate::scene::transform::Transform;
47use crate::texture::Texture;
48use crate::texture::video_texture::VideoTexture;
49
50#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct CamLayout {
55 pub center: Vec2,
57 pub radius: f32,
61}
62
63impl CamLayout {
64 pub const BOTTOM_RIGHT: Self = Self {
68 center: Vec2::new(0.74, -0.74),
69 radius: 0.20,
70 };
71
72 pub const BOTTOM_LEFT: Self = Self {
76 center: Vec2::new(-0.74, -0.74),
77 radius: 0.20,
78 };
79
80 pub const TOP_LEFT: Self = Self {
82 center: Vec2::new(-0.74, 0.74),
83 radius: 0.20,
84 };
85}
86
87impl Default for CamLayout {
88 fn default() -> Self {
89 Self::BOTTOM_LEFT
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct CursorRipple {
100 pub center: Vec2,
102 pub radius: f32,
104 pub alpha: f32,
106}
107
108fn draw_pointer(g: &mut Graphics, tip: Vec2, half: f32, color: Color) {
119 let p = |x: f32, y: f32| tip + Vec2::new(x, y) * half;
120 g.fill(Fill::Solid(color));
121 g.draw_polygon(&[p(0.0, 0.0), p(0.0, -1.5), p(0.45, -1.75), p(1.05, -0.95)]);
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub struct StreamDimensions {
130 pub width: u32,
132 pub height: u32,
134}
135
136impl StreamDimensions {
137 #[must_use]
139 pub fn new(width: u32, height: u32) -> Self {
140 Self { width, height }
141 }
142
143 #[must_use]
145 pub fn byte_len(self) -> usize {
146 (self.width as usize) * (self.height as usize) * 4
147 }
148}
149
150fn flip_bgra_rows_top_down_to_bottom_up(src: &[u8], width: u32, height: u32) -> Vec<u8> {
162 let row_bytes = (width as usize) * 4;
165 let h = height as usize;
166 let mut out = Vec::with_capacity(row_bytes * h);
167 for row in (0..h).rev() {
168 let start = row * row_bytes;
169 out.extend_from_slice(&src[start..start + row_bytes]);
170 }
171 out
172}
173
174pub struct RecordingScene {
181 stage: Stage,
182 screen_video: VideoTexture,
183 cam_video: VideoTexture,
184 screen_dims: StreamDimensions,
185 cam_dims: StreamDimensions,
186 cam_layout: CamLayout,
187 screen_sprite: NodeId,
188 cam_container: NodeId,
189 cam_sprite: NodeId,
190 backdrop: Option<NodeId>,
195 cursor: Option<NodeId>,
200 shadow: Option<NodeId>,
204 border: Option<NodeId>,
208 wallpaper: Option<NodeId>,
215}
216
217impl std::fmt::Debug for RecordingScene {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("RecordingScene")
223 .field("screen_dims", &self.screen_dims)
224 .field("cam_dims", &self.cam_dims)
225 .field("cam_layout", &self.cam_layout)
226 .field("nodes_in_stage", &self.stage.len())
227 .finish_non_exhaustive()
228 }
229}
230
231impl RecordingScene {
232 #[must_use]
245 pub fn new(
246 app: &Application,
247 screen_dims: StreamDimensions,
248 cam_dims: StreamDimensions,
249 cam_layout: CamLayout,
250 ) -> Self {
251 assert!(
252 screen_dims.width > 0 && screen_dims.height > 0,
253 "RecordingScene: screen dims must be non-zero (got {}×{})",
254 screen_dims.width,
255 screen_dims.height,
256 );
257 assert!(
258 cam_dims.width > 0 && cam_dims.height > 0,
259 "RecordingScene: cam dims must be non-zero (got {}×{})",
260 cam_dims.width,
261 cam_dims.height,
262 );
263
264 let mut stage = Stage::new();
265 let screen_video = VideoTexture::new(app, screen_dims.width, screen_dims.height);
266 let cam_video = VideoTexture::new(app, cam_dims.width, cam_dims.height);
267
268 let mut screen_sprite =
272 Sprite::from_texture(screen_video.texture().clone()).with_anchor(Vec2::splat(0.5));
273 screen_sprite.container.transform = Transform {
274 scale: Vec2::splat(2.0),
275 ..Transform::IDENTITY
276 };
277 let screen_sprite_id = stage
278 .add_child(stage.root(), screen_sprite)
279 .expect("Stage root is alive at scene construction");
280
281 let dim_to_f32 = |d: u32| f32::from(u16::try_from(d.min(u32::from(u16::MAX))).unwrap_or(1));
295 let canvas_width = dim_to_f32(screen_dims.width);
296 let canvas_height = dim_to_f32(screen_dims.height);
297 let min_side = canvas_width.min(canvas_height);
298 let cam_half_extents = Vec2::new(
299 cam_layout.radius * min_side / canvas_width,
300 cam_layout.radius * min_side / canvas_height,
301 );
302
303 let cam_container = Container {
307 clip: Some(MaskShape::ellipse(cam_layout.center, cam_half_extents)),
308 ..Container::default()
309 };
310 let cam_container_id = stage
311 .add_child(stage.root(), cam_container)
312 .expect("Stage root is alive at scene construction");
313
314 let mut cam_sprite =
320 Sprite::from_texture(cam_video.texture().clone()).with_anchor(Vec2::splat(0.5));
321 cam_sprite.container.transform = Transform {
322 position: cam_layout.center,
323 scale: cam_half_extents * 2.0,
324 ..Transform::IDENTITY
325 };
326 let cam_sprite_id = stage
327 .add_child(cam_container_id, cam_sprite)
328 .expect("cam container is alive immediately after add_child");
329
330 Self {
331 stage,
332 screen_video,
333 cam_video,
334 screen_dims,
335 cam_dims,
336 cam_layout,
337 screen_sprite: screen_sprite_id,
338 cam_container: cam_container_id,
339 cam_sprite: cam_sprite_id,
340 backdrop: None,
341 cursor: None,
342 shadow: None,
343 border: None,
344 wallpaper: None,
345 }
346 }
347
348 pub fn set_screen_frame(&mut self, app: &Application, bgra: &[u8]) {
360 assert_eq!(
361 bgra.len(),
362 self.screen_dims.byte_len(),
363 "VideoTexture::upload_bgra: byte length mismatch"
364 );
365 let flipped = flip_bgra_rows_top_down_to_bottom_up(
366 bgra,
367 self.screen_dims.width,
368 self.screen_dims.height,
369 );
370 self.screen_video.upload_bgra(app, &flipped);
371 }
372
373 pub fn set_camera_frame(&mut self, app: &Application, bgra: &[u8]) {
382 assert_eq!(
383 bgra.len(),
384 self.cam_dims.byte_len(),
385 "VideoTexture::upload_bgra: byte length mismatch"
386 );
387 let flipped =
388 flip_bgra_rows_top_down_to_bottom_up(bgra, self.cam_dims.width, self.cam_dims.height);
389 self.cam_video.upload_bgra(app, &flipped);
390 }
391
392 pub fn render(
396 &self,
397 renderer: &Renderer,
398 app: &Application,
399 view: &wgpu::TextureView,
400 clear: Color,
401 ) -> RenderStats {
402 renderer.render_stage(app, view, clear, &self.stage)
403 }
404
405 #[must_use]
408 pub fn stage(&self) -> &Stage {
409 &self.stage
410 }
411
412 #[must_use]
416 pub fn stage_mut(&mut self) -> &mut Stage {
417 &mut self.stage
418 }
419
420 #[must_use]
422 pub fn screen_dims(&self) -> StreamDimensions {
423 self.screen_dims
424 }
425
426 #[must_use]
428 pub fn cam_dims(&self) -> StreamDimensions {
429 self.cam_dims
430 }
431
432 #[must_use]
434 pub fn cam_layout(&self) -> CamLayout {
435 self.cam_layout
436 }
437
438 #[must_use]
442 pub fn screen_sprite_id(&self) -> NodeId {
443 self.screen_sprite
444 }
445
446 #[must_use]
449 pub fn cam_container_id(&self) -> NodeId {
450 self.cam_container
451 }
452
453 #[must_use]
455 pub fn cam_sprite_id(&self) -> NodeId {
456 self.cam_sprite
457 }
458
459 pub fn set_camera_visible(&mut self, visible: bool) {
465 if let Some(node) = self.stage.get_mut(self.cam_container) {
466 node.container_mut().visible = visible;
467 }
468 }
469
470 pub fn set_screen_visible(&mut self, visible: bool) {
473 if let Some(node) = self.stage.get_mut(self.screen_sprite) {
474 node.container_mut().visible = visible;
475 }
476 }
477
478 pub fn set_screen_transform(&mut self, transform: Transform) {
485 if let Some(node) = self.stage.get_mut(self.screen_sprite) {
486 node.container_mut().transform = transform;
487 }
488 }
489
490 pub fn set_screen_clip(&mut self, shape: Option<MaskShape>) {
497 if let Some(node) = self.stage.get_mut(self.screen_sprite) {
498 node.container_mut().clip = shape;
499 }
500 }
501
502 fn backdrop_graphics_mut(&mut self) -> &mut Graphics {
508 if self.backdrop.is_none() {
509 let root = self.stage.root();
510 let id = self
511 .stage
512 .add_child(root, Graphics::new())
513 .expect("Stage root is alive");
514 self.backdrop = Some(id);
515 }
516 let id = self.backdrop.expect("backdrop node just ensured");
517 match self.stage.get_mut(id) {
518 Some(Node::Graphics(g)) => g,
519 _ => unreachable!("backdrop node is always a Graphics"),
520 }
521 }
522
523 pub fn set_background_gradient(&mut self, from: Color, to: Color, angle_deg: f32) {
527 let theta = angle_deg.to_radians();
528 let dir = Vec2::new(theta.cos(), theta.sin());
530 let g = self.backdrop_graphics_mut();
531 g.primitives.clear();
532 g.fill(Fill::LinearGradient {
533 start: -dir,
534 end: dir,
535 color_a: from,
536 color_b: to,
537 });
538 g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
539 g.container.visible = true;
540 }
541
542 pub fn set_background_color(&mut self, color: Color) {
544 let g = self.backdrop_graphics_mut();
545 g.primitives.clear();
546 g.fill(Fill::Solid(color));
547 g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
548 g.container.visible = true;
549 }
550
551 pub fn set_background_visible(&mut self, visible: bool) {
554 if let Some(id) = self.backdrop
555 && let Some(node) = self.stage.get_mut(id)
556 {
557 node.container_mut().visible = visible;
558 }
559 }
560
561 fn cursor_graphics_mut(&mut self) -> &mut Graphics {
567 if self.cursor.is_none() {
568 let root = self.stage.root();
569 let mut g = Graphics::new();
570 g.container.clip = Some(MaskShape::rect(Rect::new(-1.0, -1.0, 2.0, 2.0)));
571 let id = self.stage.add_child(root, g).expect("Stage root is alive");
572 self.cursor = Some(id);
573 }
574 let id = self.cursor.expect("cursor node just ensured");
575 match self.stage.get_mut(id) {
576 Some(Node::Graphics(g)) => g,
577 _ => unreachable!("cursor node is always a Graphics"),
578 }
579 }
580
581 pub fn set_cursor(&mut self, pointer_ndc: Vec2, half: f32, ripples: &[CursorRipple]) {
588 let g = self.cursor_graphics_mut();
589 g.primitives.clear();
590 g.stroke(None);
591 for r in ripples {
593 g.fill(Fill::Solid(Color::rgba(1.0, 1.0, 1.0, r.alpha)));
594 g.draw_ellipse(r.center, Vec2::splat(r.radius));
595 }
596 draw_pointer(g, pointer_ndc, half * 1.25, Color::rgb_u8(20, 20, 20));
600 draw_pointer(g, pointer_ndc, half, Color::WHITE);
601 g.container.visible = true;
602 }
603
604 pub fn set_cursor_visible(&mut self, visible: bool) {
607 if let Some(id) = self.cursor
608 && let Some(node) = self.stage.get_mut(id)
609 {
610 node.container_mut().visible = visible;
611 }
612 }
613
614 fn shadow_graphics_mut(&mut self) -> &mut Graphics {
619 if self.shadow.is_none() {
620 let root = self.stage.root();
621 let id = self
622 .stage
623 .add_child(root, Graphics::new())
624 .expect("Stage root is alive");
625 self.shadow = Some(id);
626 }
627 let id = self.shadow.expect("shadow node just ensured");
628 match self.stage.get_mut(id) {
629 Some(Node::Graphics(g)) => g,
630 _ => unreachable!("shadow node is always a Graphics"),
631 }
632 }
633
634 pub fn set_frame_shadow(
639 &mut self,
640 window: Rect,
641 corner_radius: f32,
642 offset: Vec2,
643 color: Color,
644 ) {
645 let g = self.shadow_graphics_mut();
646 g.primitives.clear();
647 g.stroke(None);
648 g.fill(Fill::Solid(color));
649 let shifted = Rect::new(
650 window.min.x + offset.x,
651 window.min.y + offset.y,
652 window.size.x,
653 window.size.y,
654 );
655 g.draw_rounded_rect(shifted, corner_radius);
656 g.container.visible = true;
657 }
658
659 pub fn set_frame_shadow_visible(&mut self, visible: bool) {
661 if let Some(id) = self.shadow
662 && let Some(node) = self.stage.get_mut(id)
663 {
664 node.container_mut().visible = visible;
665 }
666 }
667
668 fn border_graphics_mut(&mut self) -> &mut Graphics {
672 if self.border.is_none() {
673 let root = self.stage.root();
674 let mut g = Graphics::new();
675 g.container.clip = Some(MaskShape::rect(Rect::new(-1.0, -1.0, 2.0, 2.0)));
676 let id = self.stage.add_child(root, g).expect("Stage root is alive");
677 self.border = Some(id);
678 }
679 let id = self.border.expect("border node just ensured");
680 match self.stage.get_mut(id) {
681 Some(Node::Graphics(g)) => g,
682 _ => unreachable!("border node is always a Graphics"),
683 }
684 }
685
686 pub fn set_frame_border(&mut self, window: Rect, corner_radius: f32, stroke: Stroke) {
690 let g = self.border_graphics_mut();
691 g.primitives.clear();
692 g.fill(Fill::Solid(Color::rgba(0.0, 0.0, 0.0, 0.0)));
693 g.stroke(Some(stroke));
694 g.draw_rounded_rect(window, corner_radius);
695 g.container.visible = true;
696 }
697
698 pub fn set_frame_border_visible(&mut self, visible: bool) {
700 if let Some(id) = self.border
701 && let Some(node) = self.stage.get_mut(id)
702 {
703 node.container_mut().visible = visible;
704 }
705 }
706
707 pub fn set_background_wallpaper(
718 &mut self,
719 app: &Application,
720 width: u32,
721 height: u32,
722 rgba: &[u8],
723 ) {
724 let tex = Texture::from_rgba(app, width, height, rgba);
725 let mut sprite = Sprite::from_texture(tex).with_anchor(Vec2::splat(0.5));
726 sprite.container.transform = Transform {
727 scale: Vec2::splat(2.0),
728 ..Transform::IDENTITY
729 };
730 if let Some(old) = self.wallpaper.take() {
732 self.stage.destroy(old);
733 }
734 let root = self.stage.root();
735 let id = self
736 .stage
737 .add_child(root, sprite)
738 .expect("Stage root is alive");
739 self.wallpaper = Some(id);
740 }
741
742 pub fn set_background_wallpaper_visible(&mut self, visible: bool) {
745 if let Some(id) = self.wallpaper
746 && let Some(node) = self.stage.get_mut(id)
747 {
748 node.container_mut().visible = visible;
749 }
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::application::AppConfig;
757
758 fn boot() -> Application {
759 pollster::block_on(Application::new(AppConfig::default())).expect("init")
760 }
761
762 #[test]
763 fn cam_layout_default_is_bottom_left() {
764 assert_eq!(CamLayout::default(), CamLayout::BOTTOM_LEFT);
765 }
766
767 #[test]
768 fn cam_layout_constants_are_in_ndc_range() {
769 for layout in [
770 CamLayout::BOTTOM_RIGHT,
771 CamLayout::BOTTOM_LEFT,
772 CamLayout::TOP_LEFT,
773 ] {
774 assert!(layout.center.x.abs() <= 1.0);
775 assert!(layout.center.y.abs() <= 1.0);
776 assert!(layout.radius > 0.0 && layout.radius <= 1.0);
777 }
778 }
779
780 #[test]
781 fn stream_dims_byte_len_matches_w_h_4() {
782 assert_eq!(StreamDimensions::new(64, 32).byte_len(), 64 * 32 * 4);
783 assert_eq!(
784 StreamDimensions::new(1920, 1080).byte_len(),
785 1920 * 1080 * 4
786 );
787 }
788
789 #[test]
790 fn new_inserts_three_nodes_under_root() {
791 let app = boot();
792 let scene = RecordingScene::new(
793 &app,
794 StreamDimensions::new(128, 72),
795 StreamDimensions::new(64, 64),
796 CamLayout::default(),
797 );
798 assert_eq!(scene.stage().len(), 4);
801 assert_eq!(scene.screen_dims().width, 128);
802 assert_eq!(scene.cam_dims().height, 64);
803 assert!(scene.backdrop.is_none(), "no backdrop until set_background");
809 assert!(scene.cursor.is_none(), "no cursor overlay until set_cursor");
810 assert!(
811 scene.shadow.is_none(),
812 "no drop shadow until set_frame_shadow"
813 );
814 assert!(
815 scene.border.is_none(),
816 "no inset border until set_frame_border"
817 );
818 assert!(
819 scene.wallpaper.is_none(),
820 "no wallpaper until set_background_wallpaper"
821 );
822 assert!(
823 scene
824 .stage()
825 .get(scene.screen_sprite_id())
826 .unwrap()
827 .container()
828 .clip
829 .is_none(),
830 "screen sprite is un-clipped in the recorder path"
831 );
832 }
833
834 #[test]
835 fn set_cursor_adds_one_overlay_node_and_toggles() {
836 let app = boot();
837 let mut scene = RecordingScene::new(
838 &app,
839 StreamDimensions::new(64, 64),
840 StreamDimensions::new(32, 32),
841 CamLayout::default(),
842 );
843 let before = scene.stage().len();
844 let ripples = [CursorRipple {
845 center: Vec2::new(0.2, -0.3),
846 radius: 0.1,
847 alpha: 0.5,
848 }];
849 scene.set_cursor(Vec2::new(0.1, 0.2), 0.08, &ripples);
850 assert_eq!(scene.stage().len(), before + 1);
852 let id = scene.cursor.expect("cursor created");
853 assert!(scene.stage().get(id).unwrap().container().visible);
854 assert!(
856 scene.stage().get(id).unwrap().container().clip.is_some(),
857 "cursor node dispatches (clip set) so it composites on top"
858 );
859 scene.set_cursor(Vec2::new(-0.4, 0.5), 0.06, &[]);
861 assert_eq!(scene.stage().len(), before + 1, "cursor node reused");
862 scene.set_cursor_visible(false);
864 assert!(!scene.stage().get(id).unwrap().container().visible);
865 }
866
867 #[test]
868 fn set_frame_shadow_and_border_add_one_node_each_with_right_dispatch() {
869 let app = boot();
870 let mut scene = RecordingScene::new(
871 &app,
872 StreamDimensions::new(64, 64),
873 StreamDimensions::new(32, 32),
874 CamLayout::default(),
875 );
876 let before = scene.stage().len();
877 let window = Rect::new(-0.8, -0.8, 1.6, 1.6);
878
879 scene.set_frame_shadow(
881 window,
882 0.05,
883 Vec2::new(0.02, -0.02),
884 Color::rgba(0.0, 0.0, 0.0, 0.4),
885 );
886 assert_eq!(scene.stage().len(), before + 1);
887 let sid = scene.shadow.expect("shadow created");
888 assert!(scene.stage().get(sid).unwrap().container().visible);
889 assert!(
890 scene.stage().get(sid).unwrap().container().clip.is_none(),
891 "shadow is unclipped (Phase 1, behind the screen)"
892 );
893
894 scene.set_frame_border(window, 0.05, Stroke::new(0.01, Color::WHITE));
896 assert_eq!(scene.stage().len(), before + 2);
897 let bid = scene.border.expect("border created");
898 assert!(
899 scene.stage().get(bid).unwrap().container().clip.is_some(),
900 "border dispatches (clip set) so it composites over the screen"
901 );
902
903 scene.set_frame_shadow(window, 0.05, Vec2::ZERO, Color::BLACK);
905 scene.set_frame_border(window, 0.05, Stroke::new(0.02, Color::BLACK));
906 assert_eq!(scene.stage().len(), before + 2, "nodes reused, no leak");
907 scene.set_frame_shadow_visible(false);
908 scene.set_frame_border_visible(false);
909 assert!(!scene.stage().get(sid).unwrap().container().visible);
910 assert!(!scene.stage().get(bid).unwrap().container().visible);
911 }
912
913 #[test]
914 fn set_background_wallpaper_adds_one_sprite_and_rebuilds() {
915 let app = boot();
916 let mut scene = RecordingScene::new(
917 &app,
918 StreamDimensions::new(64, 64),
919 StreamDimensions::new(32, 32),
920 CamLayout::default(),
921 );
922 let before = scene.stage().len();
923 let rgba = vec![120u8; 16 * 16 * 4];
924 scene.set_background_wallpaper(&app, 16, 16, &rgba);
925 assert_eq!(scene.stage().len(), before + 1, "one wallpaper sprite");
926 let first = scene.wallpaper.expect("wallpaper created");
927 assert!(scene.stage().get(first).unwrap().container().visible);
928 scene.set_background_wallpaper(&app, 8, 8, &vec![200u8; 8 * 8 * 4]);
931 assert_eq!(scene.stage().len(), before + 1, "rebuilt, no leak");
932 scene.set_background_wallpaper_visible(false);
933 let id = scene.wallpaper.expect("wallpaper present");
934 assert!(!scene.stage().get(id).unwrap().container().visible);
935 }
936
937 #[test]
938 fn set_background_gradient_adds_one_visible_backdrop_node() {
939 let app = boot();
940 let mut scene = RecordingScene::new(
941 &app,
942 StreamDimensions::new(64, 64),
943 StreamDimensions::new(32, 32),
944 CamLayout::default(),
945 );
946 let before = scene.stage().len();
947 scene.set_background_gradient(
948 Color::rgb_u8(255, 138, 128),
949 Color::rgb_u8(40, 53, 147),
950 135.0,
951 );
952 assert_eq!(scene.stage().len(), before + 1);
954 let id = scene.backdrop.expect("backdrop created");
955 assert!(scene.stage().get(id).unwrap().container().visible);
956 scene.set_background_color(Color::rgb_u8(10, 20, 30));
958 assert_eq!(scene.stage().len(), before + 1, "backdrop node is reused");
959 scene.set_background_visible(false);
961 assert!(!scene.stage().get(id).unwrap().container().visible);
962 }
963
964 #[test]
965 fn set_screen_clip_sets_and_clears_the_screen_clip() {
966 let app = boot();
967 let mut scene = RecordingScene::new(
968 &app,
969 StreamDimensions::new(64, 64),
970 StreamDimensions::new(32, 32),
971 CamLayout::default(),
972 );
973 let window = Rect::new(-0.9, -0.9, 1.8, 1.8);
974 scene.set_screen_clip(Some(MaskShape::rounded_rect(window, 0.05)));
975 let clip = scene
976 .stage()
977 .get(scene.screen_sprite_id())
978 .unwrap()
979 .container()
980 .clip
981 .expect("clip set");
982 assert!(matches!(clip, MaskShape::RoundedRect { .. }));
983 scene.set_screen_clip(None);
984 assert!(
985 scene
986 .stage()
987 .get(scene.screen_sprite_id())
988 .unwrap()
989 .container()
990 .clip
991 .is_none(),
992 "clip cleared"
993 );
994 }
995
996 #[test]
997 fn set_screen_transform_updates_the_screen_sprite() {
998 let app = boot();
999 let mut scene = RecordingScene::new(
1000 &app,
1001 StreamDimensions::new(64, 64),
1002 StreamDimensions::new(32, 32),
1003 CamLayout::default(),
1004 );
1005 let t = Transform {
1007 scale: Vec2::splat(4.0),
1008 position: Vec2::new(0.5, -0.25),
1009 ..Transform::IDENTITY
1010 };
1011 scene.set_screen_transform(t);
1012 let node = scene
1013 .stage()
1014 .get(scene.screen_sprite_id())
1015 .expect("screen sprite node");
1016 assert_eq!(node.container().transform, t);
1017 }
1018
1019 #[test]
1020 fn cam_container_clip_is_circle_on_square_canvas() {
1021 let app = boot();
1022 let scene = RecordingScene::new(
1025 &app,
1026 StreamDimensions::new(64, 64),
1027 StreamDimensions::new(32, 32),
1028 CamLayout::BOTTOM_RIGHT,
1029 );
1030 let cam_node = scene
1031 .stage()
1032 .get(scene.cam_container_id())
1033 .expect("cam container alive");
1034 let container: &Container = cam_node.container();
1035 let clip = container.clip.expect("cam container has clip");
1036 match clip {
1037 MaskShape::Ellipse {
1038 center,
1039 half_extents,
1040 } => {
1041 assert_eq!(center, CamLayout::BOTTOM_RIGHT.center);
1042 assert!(
1043 (half_extents.x - half_extents.y).abs() < 1e-6,
1044 "square canvas → equal half-extents (circle)"
1045 );
1046 assert!((half_extents.x - CamLayout::BOTTOM_RIGHT.radius).abs() < 1e-6);
1047 }
1048 other => panic!("expected Ellipse, got {other:?}"),
1049 }
1050 }
1051
1052 #[test]
1053 fn cam_bubble_aspect_compensated_on_landscape_canvas() {
1054 let app = boot();
1055 let scene = RecordingScene::new(
1060 &app,
1061 StreamDimensions::new(1920, 1080),
1062 StreamDimensions::new(64, 64),
1063 CamLayout::BOTTOM_RIGHT,
1064 );
1065 let clip = scene
1066 .stage()
1067 .get(scene.cam_container_id())
1068 .expect("cam container alive")
1069 .container()
1070 .clip
1071 .expect("cam container has clip");
1072 let MaskShape::Ellipse {
1073 half_extents: he, ..
1074 } = clip
1075 else {
1076 panic!("expected Ellipse, got {clip:?}");
1077 };
1078 let r = CamLayout::BOTTOM_RIGHT.radius;
1079 assert!((he.x - r * 1080.0 / 1920.0).abs() < 1e-6, "hx compensated");
1081 assert!((he.y - r).abs() < 1e-6, "hy unchanged on landscape");
1082 assert!(
1083 (he.x * 1920.0 - he.y * 1080.0).abs() < 1e-3,
1084 "equal pixel radii → true circle"
1085 );
1086 }
1087
1088 #[test]
1089 fn set_screen_frame_round_trips_correct_byte_count() {
1090 let app = boot();
1091 let mut scene = RecordingScene::new(
1092 &app,
1093 StreamDimensions::new(32, 32),
1094 StreamDimensions::new(16, 16),
1095 CamLayout::default(),
1096 );
1097 let buf = vec![128u8; 32 * 32 * 4];
1098 scene.set_screen_frame(&app, &buf);
1099 }
1101
1102 #[test]
1103 fn set_camera_frame_round_trips_correct_byte_count() {
1104 let app = boot();
1105 let mut scene = RecordingScene::new(
1106 &app,
1107 StreamDimensions::new(32, 32),
1108 StreamDimensions::new(16, 16),
1109 CamLayout::default(),
1110 );
1111 let buf = vec![64u8; 16 * 16 * 4];
1112 scene.set_camera_frame(&app, &buf);
1113 }
1114
1115 #[test]
1116 #[should_panic(expected = "byte length mismatch")]
1117 fn set_screen_frame_panics_on_wrong_byte_count() {
1118 let app = boot();
1119 let mut scene = RecordingScene::new(
1120 &app,
1121 StreamDimensions::new(32, 32),
1122 StreamDimensions::new(16, 16),
1123 CamLayout::default(),
1124 );
1125 scene.set_screen_frame(&app, &[0u8; 100]);
1126 }
1127
1128 #[test]
1129 #[should_panic(expected = "screen dims must be non-zero")]
1130 fn new_panics_on_zero_screen_dim() {
1131 let app = boot();
1132 let _ = RecordingScene::new(
1133 &app,
1134 StreamDimensions::new(0, 32),
1135 StreamDimensions::new(16, 16),
1136 CamLayout::default(),
1137 );
1138 }
1139
1140 #[test]
1141 #[should_panic(expected = "cam dims must be non-zero")]
1142 fn new_panics_on_zero_cam_dim() {
1143 let app = boot();
1144 let _ = RecordingScene::new(
1145 &app,
1146 StreamDimensions::new(64, 64),
1147 StreamDimensions::new(0, 16),
1148 CamLayout::default(),
1149 );
1150 }
1151
1152 #[test]
1153 fn flip_helper_reverses_row_order() {
1154 let row_0 = [0xAAu8; 16];
1157 let row_1 = [0xCCu8; 16];
1158 let mut src = Vec::with_capacity(32);
1159 src.extend_from_slice(&row_0);
1160 src.extend_from_slice(&row_1);
1161 let out = flip_bgra_rows_top_down_to_bottom_up(&src, 4, 2);
1162 assert_eq!(out.len(), 32);
1163 assert_eq!(
1164 &out[0..16],
1165 &row_1,
1166 "row 1 (originally bottom) lands first in bottom-up output"
1167 );
1168 assert_eq!(&out[16..32], &row_0, "row 0 (originally top) lands last");
1169 }
1170
1171 #[test]
1172 fn flip_helper_is_identity_for_single_row() {
1173 let row = [0x42u8; 16];
1174 let out = flip_bgra_rows_top_down_to_bottom_up(&row, 4, 1);
1175 assert_eq!(out, row);
1176 }
1177
1178 #[test]
1179 fn set_camera_visible_toggles_cam_container_visibility() {
1180 let app = boot();
1181 let mut scene = RecordingScene::new(
1182 &app,
1183 StreamDimensions::new(32, 32),
1184 StreamDimensions::new(16, 16),
1185 CamLayout::default(),
1186 );
1187 let cam_id = scene.cam_container_id();
1189 assert!(scene.stage().get(cam_id).unwrap().container().visible);
1190
1191 scene.set_camera_visible(false);
1192 assert!(!scene.stage().get(cam_id).unwrap().container().visible);
1193
1194 scene.set_camera_visible(true);
1195 assert!(scene.stage().get(cam_id).unwrap().container().visible);
1196 }
1197
1198 #[test]
1199 fn set_screen_visible_toggles_screen_sprite_visibility() {
1200 let app = boot();
1201 let mut scene = RecordingScene::new(
1202 &app,
1203 StreamDimensions::new(32, 32),
1204 StreamDimensions::new(16, 16),
1205 CamLayout::default(),
1206 );
1207 let screen_id = scene.screen_sprite_id();
1208 assert!(scene.stage().get(screen_id).unwrap().container().visible);
1209 scene.set_screen_visible(false);
1210 assert!(!scene.stage().get(screen_id).unwrap().container().visible);
1211 }
1212
1213 #[test]
1214 fn stage_mut_allows_extension() {
1215 let app = boot();
1216 let mut scene = RecordingScene::new(
1217 &app,
1218 StreamDimensions::new(32, 32),
1219 StreamDimensions::new(16, 16),
1220 CamLayout::default(),
1221 );
1222 let before = scene.stage().len();
1223 let root = scene.stage().root();
1224 let extra = scene
1225 .stage_mut()
1226 .add_child(root, Container::default())
1227 .expect("add_child");
1228 assert_eq!(scene.stage().len(), before + 1);
1229 let _ = extra;
1231 }
1232}