1use crate::vertex::{InstanceData, Vertex};
3use cvkg_core::Rect;
4use lru::LruCache;
5use std::num::NonZeroUsize;
6use std::sync::Arc;
7
8#[derive(Clone, Debug)]
12pub struct SvgModel {
13 pub vertices: Vec<Vertex>,
15 pub indices: Vec<u32>,
17 pub view_box: Rect,
19 pub paths: Vec<SvgPath>,
21 pub animations: Vec<SvgAnimation>,
23}
24
25#[derive(Clone, Debug)]
29pub struct SvgPath {
30 pub id: String,
32 pub vertex_range: std::ops::Range<usize>,
34 pub index_range: std::ops::Range<usize>,
36 pub local_transform: SvgTransform,
39}
40
41#[derive(Clone, Debug, Default)]
43pub struct SvgTransform {
44 pub translate: [f32; 2],
46 pub rotation: f32,
48 pub scale: f32,
50}
51
52#[derive(Clone, Debug)]
53pub struct SvgAnimation {
54 pub target_id: String,
55 pub attribute_name: String,
56 pub keyframe_values: Vec<f32>,
59 pub key_times: Vec<f32>,
61 pub duration: f32,
62 pub vertex_range: std::ops::Range<usize>,
63}
64
65impl SvgAnimation {
66 pub fn evaluate(&self, t: f32) -> f32 {
68 let vals = &self.keyframe_values;
69 if vals.is_empty() {
70 return 0.0;
71 }
72 if vals.len() == 1 {
73 return vals[0];
74 }
75 if vals.len() == 2 {
76 return vals[0] + (vals[1] - vals[0]) * t;
77 }
78 let times = if self.key_times.len() == vals.len() {
80 &self.key_times
81 } else {
82 return self.evaluate_uniform(t);
84 };
85 let t = t.clamp(0.0, 1.0);
87 for i in 0..times.len() - 1 {
88 if t >= times[i] && t <= times[i + 1] {
89 let seg_t = (t - times[i]) / (times[i + 1] - times[i]);
90 return vals[i] + (vals[i + 1] - vals[i]) * seg_t;
91 }
92 }
93 vals[vals.len() - 1]
94 }
95
96 fn evaluate_uniform(&self, t: f32) -> f32 {
97 let vals = &self.keyframe_values;
98 let n = vals.len() - 1;
99 let t = t.clamp(0.0, 1.0);
100 let idx_f = t * n as f32;
101 let idx = idx_f.floor() as usize;
102 let frac = idx_f - idx as f32;
103 if idx >= n {
104 vals[n]
105 } else {
106 vals[idx] + (vals[idx + 1] - vals[idx]) * frac
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
114pub(crate) struct DrawCall {
115 pub texture_id: Option<u32>,
116 pub scissor_rect: Option<Rect>,
117 pub index_start: u32,
118 pub index_count: u32,
119 pub instance_count: u32,
123 pub material: cvkg_core::DrawMaterial,
126 pub target_id: Option<u64>,
127 pub instance_start: u32,
128 pub draw_order: i32,
131}
132
133#[derive(Debug, Clone)]
144pub(crate) struct MemoEntry {
145 pub hash: u64,
146 pub frame_gen: u64,
147 pub vertices: Vec<crate::vertex::Vertex>,
148 pub indices: Vec<u32>,
149 pub instance_data: Vec<crate::vertex::InstanceData>,
150 pub draw_calls: Vec<DrawCall>,
151}
152
153pub struct OffscreenEffectConfig {
154 pub target_id: u64,
155 pub effect: String,
156 pub blend_mode: u32,
157 pub effect_args: [f32; 16],
158}
159
160#[derive(Debug, Clone, Copy)]
161pub(crate) struct ShadowState {
162 pub radius: f32,
163 pub color: [f32; 4],
164 pub _offset: [f32; 2],
165}
166
167pub(crate) struct SurfaceContext {
168 pub(crate) surface: wgpu::Surface<'static>,
169 pub(crate) config: wgpu::SurfaceConfiguration,
170 pub(crate) scene_texture: wgpu::TextureView,
171 pub(crate) scene_msaa_texture: wgpu::TextureView,
172 pub(crate) scene_bind_group: wgpu::BindGroup,
173 pub(crate) scene_texture_bind_group: wgpu::BindGroup,
174 pub(crate) depth_texture_view: wgpu::TextureView,
175 pub(crate) blur_tex_a: crate::kvasir::resource::ResourceId,
176 pub(crate) blur_tex_b: crate::kvasir::resource::ResourceId,
177 pub(crate) bloom_tex_a: crate::kvasir::resource::ResourceId,
178 pub(crate) bloom_tex_b: crate::kvasir::resource::ResourceId,
179 pub(crate) blur_env_bind_group_a: wgpu::BindGroup,
180 pub(crate) blur_env_bind_group_b: wgpu::BindGroup,
181 pub(crate) bloom_env_bind_group_a: wgpu::BindGroup,
182 pub(crate) bloom_env_bind_group_b: wgpu::BindGroup,
183 pub(crate) scale_factor: f32,
184 pub(crate) sampler: wgpu::Sampler,
185}
186
187pub struct HeadlessContext {
189 pub scene_texture: wgpu::TextureView,
190 pub scene_msaa_texture: wgpu::TextureView,
191 pub scene_bind_group: wgpu::BindGroup,
192 pub scene_texture_bind_group: wgpu::BindGroup,
193 pub depth_texture_view: wgpu::TextureView,
194 pub blur_tex_a: crate::kvasir::resource::ResourceId,
195 pub blur_tex_b: crate::kvasir::resource::ResourceId,
196 pub bloom_tex_a: crate::kvasir::resource::ResourceId,
197 pub bloom_tex_b: crate::kvasir::resource::ResourceId,
198 pub blur_env_bind_group_a: wgpu::BindGroup,
199 pub blur_env_bind_group_b: wgpu::BindGroup,
200 pub bloom_env_bind_group_a: wgpu::BindGroup,
201 pub bloom_env_bind_group_b: wgpu::BindGroup,
202 pub scale_factor: f32,
203 pub sampler: wgpu::Sampler,
204 pub width: u32,
205 pub height: u32,
206 pub output_texture: wgpu::Texture,
207 pub output_view: wgpu::TextureView,
208}
209
210pub(crate) const MAX_VERTICES: usize = 100_000;
211pub(crate) const MAX_INDICES: usize = 150_000;
212
213pub(crate) const MAX_PARTICLES: usize = 65536;
215
216#[repr(C)]
220#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
221pub struct GpuParticle {
222 pub pos_vel: [f32; 4],
223 pub color_life: [f32; 4],
224}
225
226#[repr(C)]
229#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
230pub struct ParticleUniforms {
231 pub dt: f32,
232 pub _pad: [f32; 7],
233}
234
235#[repr(C)]
236#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
237pub struct EffectUniforms {
238 pub time: f32,
239 pub pad0: f32,
240 pub size: [f32; 2],
241 pub args: [f32; 16],
242}
243
244#[repr(C)]
248#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
249pub struct GlassInstanceUniforms {
250 pub tint_override: [f32; 4],
253 pub ior_override: f32,
255 pub blur_multiplier: f32,
257 pub frost_override: f32,
259 pub scissor_px: [f32; 4],
262 pub portal_index: f32,
265 pub _pad: f32,
266}
267
268impl Default for GlassInstanceUniforms {
269 fn default() -> Self {
270 Self {
271 tint_override: [0.0; 4],
272 ior_override: 0.0,
273 blur_multiplier: 1.0,
274 frost_override: 0.0,
275 scissor_px: [0.0; 4],
276 portal_index: 0.0,
277 _pad: 0.0,
278 }
279 }
280}
281
282
283pub struct GeometryBuffers {
297 pub vertex_buffer: wgpu::Buffer,
299 pub index_buffer: wgpu::Buffer,
301 pub instance_buffer: wgpu::Buffer,
303 pub max_vertices: usize,
305 pub max_indices: usize,
307}
308
309impl GeometryBuffers {
310 pub fn forge(device: &wgpu::Device, max_vertices: usize, max_indices: usize) -> Self {
313 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
314 label: Some("Surtr Vertex Anvil"),
315 size: (max_vertices * std::mem::size_of::<Vertex>()) as u64,
316 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
317 mapped_at_creation: false,
318 });
319 let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
320 label: Some("Surtr Index Anvil"),
321 size: (max_indices * std::mem::size_of::<u32>()) as u64,
322 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
323 mapped_at_creation: false,
324 });
325 let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
326 label: Some("Surtr Instance Anvil"),
327 size: (max_vertices / 4 * std::mem::size_of::<InstanceData>()) as u64,
328 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
329 mapped_at_creation: false,
330 });
331 Self {
332 vertex_buffer,
333 index_buffer,
334 instance_buffer,
335 max_vertices,
336 max_indices,
337 }
338 }
339
340 pub fn vram_bytes(&self) -> u64 {
342 let vertex_bytes = self.max_vertices * std::mem::size_of::<Vertex>();
343 let index_bytes = self.max_indices * std::mem::size_of::<u32>();
344 let instance_bytes = (self.max_vertices / 4) * std::mem::size_of::<InstanceData>();
345 (vertex_bytes + index_bytes + instance_bytes) as u64
346 }
347
348 pub fn grow_vertex_buffer(
353 &mut self,
354 device: &wgpu::Device,
355 min_capacity: usize,
356 max_capacity: usize,
357 ) -> bool {
358 let current = self.vertex_buffer.size() as usize / std::mem::size_of::<Vertex>();
359 if min_capacity <= current {
360 return false;
361 }
362 let new_capacity = min_capacity.min(max_capacity);
363 if new_capacity <= current {
364 return false;
365 }
366 self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
367 label: Some("Vertex Buffer (Grown)"),
368 size: (new_capacity * std::mem::size_of::<Vertex>()) as u64,
369 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
370 mapped_at_creation: false,
371 });
372 true
373 }
374
375 pub fn grow_index_buffer(
379 &mut self,
380 device: &wgpu::Device,
381 min_capacity: usize,
382 max_capacity: usize,
383 ) -> bool {
384 let current = self.index_buffer.size() as usize / std::mem::size_of::<u32>();
385 if min_capacity <= current {
386 return false;
387 }
388 let new_capacity = min_capacity.min(max_capacity);
389 if new_capacity <= current {
390 return false;
391 }
392 self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
393 label: Some("Index Buffer (Grown)"),
394 size: (new_capacity * std::mem::size_of::<u32>()) as u64,
395 usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
396 mapped_at_creation: false,
397 });
398 true
399 }
400}
401
402pub struct TextSubsystem {
413 pub engine: cvkg_runic_text::RunicTextEngine,
416 pub glyph_cache: LruCache<u64, (cvkg_core::Rect, f32, f32, f32, f32)>,
419 pub shaped_cache:
423 LruCache<(String, u32), std::sync::Arc<cvkg_runic_text::ShapedText>>,
424}
425
426impl TextSubsystem {
427 pub fn forge(glyph_cache_capacity: NonZeroUsize) -> Self {
430 Self {
431 engine: cvkg_runic_text::RunicTextEngine::default(),
432 glyph_cache: LruCache::new(glyph_cache_capacity),
433 shaped_cache: LruCache::new(NonZeroUsize::new(2048).unwrap()),
434 }
435 }
436
437 pub fn clear_caches(&mut self) {
439 self.shaped_cache.clear();
440 }
444}
445
446pub struct SvgSubsystem {
457 pub model_cache: LruCache<String, SvgModel>,
459 pub tree_cache: LruCache<String, usvg::Tree>,
461 pub filter_engine: Option<cvkg_svg_filters::FilterEngine>,
463 pub filter_batches: Vec<cvkg_svg_filters::FilterNode>,
465 dirty_elements: std::collections::HashSet<String>,
468 dirty_sources: std::collections::HashSet<String>,
470}
471
472impl SvgSubsystem {
473 pub fn forge(
477 device: &Arc<wgpu::Device>,
478 queue: &Arc<wgpu::Queue>,
479 model_cache_capacity: NonZeroUsize,
480 tree_cache_capacity: NonZeroUsize,
481 ) -> Self {
482 let filter_engine = cvkg_svg_filters::FilterEngine::new(cvkg_svg_filters::GpuContext {
483 device: device.clone(),
484 queue: queue.clone(),
485 })
486 .ok();
487 Self {
488 model_cache: LruCache::new(model_cache_capacity),
489 tree_cache: LruCache::new(tree_cache_capacity),
490 filter_engine,
491 filter_batches: Vec::new(),
492 dirty_elements: std::collections::HashSet::new(),
493 dirty_sources: std::collections::HashSet::new(),
494 }
495 }
496
497 pub fn clear_filter_batches(&mut self) {
500 self.filter_batches.clear();
501 }
502
503 pub fn mark_element_dirty(&mut self, element_id: &str) {
507 self.dirty_elements.insert(element_id.to_string());
508 }
509
510 pub fn mark_source_dirty(&mut self, source_name: &str) {
512 self.dirty_sources.insert(source_name.to_string());
513 self.model_cache.pop(source_name);
515 }
516
517 pub fn is_element_dirty(&self, element_id: &str) -> bool {
519 self.dirty_elements.contains(element_id)
520 || self.dirty_sources.contains(element_id)
521 }
522
523 pub fn is_source_dirty(&self, source_name: &str) -> bool {
525 self.dirty_sources.contains(source_name)
526 }
527
528 pub fn clear_dirty(&mut self) {
530 self.dirty_elements.clear();
531 self.dirty_sources.clear();
532 }
533
534 pub fn dirty_count(&self) -> usize {
536 self.dirty_elements.len() + self.dirty_sources.len()
537 }
538}
539
540pub struct ParticleSubsystem {
553 pub staging: Vec<GpuParticle>,
556 pub count: u32,
558 pub write_head: u32,
561 pub last_compact: std::time::Instant,
563}
564
565impl ParticleSubsystem {
566 pub fn forge() -> Self {
568 Self {
569 staging: Vec::new(),
570 count: 0,
571 write_head: 0,
572 last_compact: std::time::Instant::now(),
573 }
574 }
575}
576
577
578#[cfg(test)]
579mod p1_1_geometry_buffers_tests {
580 use super::*;
581
582 #[test]
588 fn vram_bytes_is_sum_of_three_buffers() {
589 let max_vertices = 1000usize;
592 let max_indices = 1500usize;
593 let vertex_bytes = max_vertices * std::mem::size_of::<Vertex>();
594 let index_bytes = max_indices * std::mem::size_of::<u32>();
595 let instance_bytes = (max_vertices / 4) * std::mem::size_of::<InstanceData>();
596 let expected = (vertex_bytes + index_bytes + instance_bytes) as u64;
597 assert!(expected > 0, "expected vram bytes > 0");
601 assert!(std::mem::size_of::<Vertex>() >= 16);
603 assert!(std::mem::size_of::<InstanceData>() >= 16);
605 }
606
607 #[test]
608 fn size_of_vertex_is_known() {
609 let size = std::mem::size_of::<Vertex>();
615 assert_eq!(size % 4, 0, "Vertex size must be 4-byte aligned");
617 }
618}
619
620
621#[cfg(test)]
622mod p1_1_text_subsystem_tests {
623 use super::TextSubsystem;
624 use std::num::NonZeroUsize;
625
626 #[test]
627 fn forge_creates_glyph_cache_with_given_capacity() {
628 let cap = NonZeroUsize::new(100).unwrap();
631 let subsystem = TextSubsystem::forge(cap);
632 assert_eq!(subsystem.glyph_cache.cap().get(), 100);
633 assert!(subsystem.shaped_cache.is_empty());
635 }
636
637 #[test]
638 fn clear_caches_empties_shaped_but_keeps_glyph() {
639 let cap = NonZeroUsize::new(10).unwrap();
643 let mut subsystem = TextSubsystem::forge(cap);
644 subsystem.clear_caches();
654 assert!(subsystem.shaped_cache.is_empty());
655 assert_eq!(subsystem.glyph_cache.cap().get(), 10);
657 }
658
659 #[test]
660 fn default_capacity_is_8192_matching_p1_5() {
661 let cap = NonZeroUsize::new(8192).unwrap();
665 let subsystem = TextSubsystem::forge(cap);
666 assert_eq!(subsystem.glyph_cache.cap().get(), 8192);
667 }
668}
669
670#[derive(Clone, Debug)]
676pub struct OffscreenBudget {
677 pub max_targets: usize,
679 pub max_total_pixels: u64,
681 pub current_pixels: u64,
683 pub current_targets: usize,
685}
686
687impl Default for OffscreenBudget {
688 fn default() -> Self {
689 Self {
690 max_targets: 8,
691 max_total_pixels: 1920u64 * 1080 * 4,
693 current_pixels: 0,
694 current_targets: 0,
695 }
696 }
697}
698
699impl OffscreenBudget {
700 pub fn mobile() -> Self {
702 Self {
703 max_targets: 4,
704 max_total_pixels: 1280u64 * 720 * 2,
706 current_pixels: 0,
707 current_targets: 0,
708 }
709 }
710
711 pub fn can_allocate(&self, width: u32, height: u32) -> bool {
713 let pixels = width as u64 * height as u64;
714 self.current_targets < self.max_targets
715 && self.current_pixels + pixels <= self.max_total_pixels
716 }
717
718 pub fn register(&mut self, width: u32, height: u32) {
720 self.current_pixels += width as u64 * height as u64;
721 self.current_targets += 1;
722 }
723
724 pub fn release(&mut self, width: u32, height: u32) {
726 self.current_pixels = self.current_pixels.saturating_sub(width as u64 * height as u64);
727 self.current_targets = self.current_targets.saturating_sub(1);
728 }
729
730 pub fn reset(&mut self) {
732 self.current_pixels = 0;
733 self.current_targets = 0;
734 }
735
736 pub fn is_exhausted(&self) -> bool {
738 self.current_targets >= self.max_targets
739 }
740}
741
742#[cfg(test)]
743mod p1_27_offscreen_budget_tests {
744 use super::OffscreenBudget;
745
746 #[test]
747 fn default_budget_allows_allocation() {
748 let budget = OffscreenBudget::default();
749 assert!(budget.can_allocate(1920, 1080));
750 }
751
752 #[test]
753 fn mobile_budget_has_lower_limits() {
754 let budget = OffscreenBudget::mobile();
755 assert!(budget.can_allocate(1280, 720));
756 assert!(!budget.can_allocate(3840, 2160)); }
758
759 #[test]
760 fn budget_tracks_registration() {
761 let mut budget = OffscreenBudget::default();
762 budget.register(1920, 1080);
763 assert_eq!(budget.current_targets, 1);
764 assert_eq!(budget.current_pixels, 1920u64 * 1080);
765 }
766
767 #[test]
768 fn budget_enforces_max_targets() {
769 let mut budget = OffscreenBudget {
770 max_targets: 2,
771 max_total_pixels: u64::MAX,
772 current_pixels: 0,
773 current_targets: 0,
774 };
775 budget.register(100, 100);
776 budget.register(100, 100);
777 assert!(!budget.can_allocate(100, 100)); assert!(budget.is_exhausted());
779 }
780
781 #[test]
782 fn budget_enforces_pixel_limit() {
783 let mut budget = OffscreenBudget {
784 max_targets: 100,
785 max_total_pixels: 1000,
786 current_pixels: 0,
787 current_targets: 0,
788 };
789 assert!(budget.can_allocate(10, 10)); budget.register(10, 10);
791 assert!(!budget.can_allocate(100, 10)); }
793
794 #[test]
795 fn release_frees_budget() {
796 let mut budget = OffscreenBudget::default();
797 budget.register(1920, 1080);
798 budget.release(1920, 1080);
799 assert_eq!(budget.current_targets, 0);
800 assert_eq!(budget.current_pixels, 0);
801 }
802
803 #[test]
804 fn reset_clears_all() {
805 let mut budget = OffscreenBudget::default();
806 budget.register(1920, 1080);
807 budget.register(1280, 720);
808 budget.reset();
809 assert_eq!(budget.current_targets, 0);
810 assert_eq!(budget.current_pixels, 0);
811 }
812}
813
814#[derive(Clone, Copy, Debug, PartialEq, Eq)]
819pub enum EffectLod {
820 Full,
822 Reduced,
824 Minimal,
826}
827
828impl EffectLod {
829 pub fn from_active_count(count: usize) -> Self {
831 match count {
832 0..=2 => EffectLod::Full,
833 3..=4 => EffectLod::Reduced,
834 _ => EffectLod::Minimal,
835 }
836 }
837
838 pub fn blur_mip_levels(&self) -> u32 {
840 match self {
841 EffectLod::Full => 7,
842 EffectLod::Reduced => 4,
843 EffectLod::Minimal => 2,
844 }
845 }
846
847 pub fn enable_volumetric(&self) -> bool {
849 matches!(self, EffectLod::Full)
850 }
851
852 pub fn enable_bloom(&self) -> bool {
854 !matches!(self, EffectLod::Minimal)
855 }
856}
857
858#[cfg(test)]
859mod p1_28_effect_lod_tests {
860 use super::EffectLod;
861
862 #[test]
863 fn full_quality_for_few_effects() {
864 assert_eq!(EffectLod::from_active_count(0), EffectLod::Full);
865 assert_eq!(EffectLod::from_active_count(1), EffectLod::Full);
866 assert_eq!(EffectLod::from_active_count(2), EffectLod::Full);
867 }
868
869 #[test]
870 fn reduced_quality_for_moderate_effects() {
871 assert_eq!(EffectLod::from_active_count(3), EffectLod::Reduced);
872 assert_eq!(EffectLod::from_active_count(4), EffectLod::Reduced);
873 }
874
875 #[test]
876 fn minimal_quality_for_many_effects() {
877 assert_eq!(EffectLod::from_active_count(5), EffectLod::Minimal);
878 assert_eq!(EffectLod::from_active_count(10), EffectLod::Minimal);
879 }
880
881 #[test]
882 fn blur_mip_levels_scale_with_lod() {
883 assert_eq!(EffectLod::Full.blur_mip_levels(), 7);
884 assert_eq!(EffectLod::Reduced.blur_mip_levels(), 4);
885 assert_eq!(EffectLod::Minimal.blur_mip_levels(), 2);
886 }
887
888 #[test]
889 fn volumetric_only_at_full() {
890 assert!(EffectLod::Full.enable_volumetric());
891 assert!(!EffectLod::Reduced.enable_volumetric());
892 assert!(!EffectLod::Minimal.enable_volumetric());
893 }
894
895 #[test]
896 fn bloom_disabled_at_minimal() {
897 assert!(EffectLod::Full.enable_bloom());
898 assert!(EffectLod::Reduced.enable_bloom());
899 assert!(!EffectLod::Minimal.enable_bloom());
900 }
901}
902
903#[cfg(test)]
904mod p1_1_particle_subsystem_tests {
905 use super::ParticleSubsystem;
906
907 #[test]
908 fn forge_creates_empty_state() {
909 let p = ParticleSubsystem::forge();
912 assert!(p.staging.is_empty());
913 assert_eq!(p.count, 0);
914 assert_eq!(p.write_head, 0);
915 }
916
917 #[test]
918 fn fields_are_publicly_mutable() {
919 let mut p = ParticleSubsystem::forge();
923 p.staging.push(Default::default());
924 p.count = 1;
925 p.write_head = 1;
926 assert_eq!(p.staging.len(), 1);
927 assert_eq!(p.count, 1);
928 assert_eq!(p.write_head, 1);
929 }
930}
931
932#[cfg(test)]
935mod p1_24_incremental_svg_tests {
936 use super::SvgSubsystem;
937 use std::num::NonZeroUsize;
938 use std::sync::Arc;
939
940 #[test]
946 fn dirty_count_starts_at_zero() {
947 let dirty_elements: std::collections::HashSet<String> = std::collections::HashSet::new();
951 let dirty_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
952 assert_eq!(dirty_elements.len() + dirty_sources.len(), 0);
953 }
954
955 #[test]
956 fn mark_dirty_increments_count() {
957 let mut dirty = std::collections::HashSet::new();
958 dirty.insert("path1".to_string());
959 dirty.insert("path2".to_string());
960 assert_eq!(dirty.len(), 2);
961 }
962
963 #[test]
964 fn source_dirty_implies_all_elements_dirty() {
965 let mut sources: std::collections::HashSet<String> = std::collections::HashSet::new();
966 sources.insert("my_icon.svg".to_string());
967 assert!(sources.contains("my_icon.svg"));
969 assert!(!sources.contains("other.svg"));
970 }
971}
972
973#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
984pub struct ShaderFeatureFlags(pub u32);
985
986impl ShaderFeatureFlags {
987 pub const NONE: Self = Self(0);
988 pub const GLASS: Self = Self(1 << 0);
989 pub const BLOOM: Self = Self(1 << 1);
990 pub const VOLUMETRIC: Self = Self(1 << 2);
991 pub const COLOR_BLIND: Self = Self(1 << 3);
992 pub const PARTICLES: Self = Self(1 << 4);
993 pub const DROPSHADOW: Self = Self(1 << 5);
994 pub const ALL: Self = Self(0x3F);
995
996 pub fn count(self) -> u32 {
998 self.0.count_ones()
999 }
1000
1001 pub fn is_within_permutation_limit(self) -> bool {
1003 self.count() <= 4
1004 }
1005
1006 pub fn permutation_index(self) -> u32 {
1008 self.0
1009 }
1010
1011 pub fn has(self, flag: Self) -> bool {
1012 (self.0 & flag.0) != 0
1013 }
1014}
1015
1016impl std::ops::BitOr for ShaderFeatureFlags {
1017 type Output = Self;
1018 fn bitor(self, rhs: Self) -> Self::Output {
1019 Self(self.0 | rhs.0)
1020 }
1021}
1022
1023impl std::ops::BitAnd for ShaderFeatureFlags {
1024 type Output = Self;
1025 fn bitand(self, rhs: Self) -> Self::Output {
1026 Self(self.0 & rhs.0)
1027 }
1028}
1029
1030#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
1036pub enum ThermalState {
1037 Nominal,
1039 Fair,
1041 Serious,
1043 Critical,
1045}
1046
1047impl Default for ThermalState {
1048 fn default() -> Self {
1049 ThermalState::Nominal
1050 }
1051}
1052
1053impl ThermalState {
1054 pub fn from_temperature(temp: f32) -> Self {
1056 if temp < 0.6 {
1057 ThermalState::Nominal
1058 } else if temp < 0.75 {
1059 ThermalState::Fair
1060 } else if temp < 0.9 {
1061 ThermalState::Serious
1062 } else {
1063 ThermalState::Critical
1064 }
1065 }
1066
1067 pub fn quality_scale(&self) -> f32 {
1069 match self {
1070 ThermalState::Nominal => 1.0,
1071 ThermalState::Fair => 0.75,
1072 ThermalState::Serious => 0.5,
1073 ThermalState::Critical => 0.25,
1074 }
1075 }
1076
1077 pub fn enable_volumetric(&self) -> bool {
1079 matches!(self, ThermalState::Nominal)
1080 }
1081
1082 pub fn enable_bloom(&self) -> bool {
1084 matches!(self, ThermalState::Nominal | ThermalState::Fair)
1085 }
1086
1087 pub fn msaa_sample_count(&self) -> u32 {
1089 match self {
1090 ThermalState::Nominal => 4,
1091 ThermalState::Fair => 2,
1092 ThermalState::Serious | ThermalState::Critical => 1,
1093 }
1094 }
1095}
1096
1097#[derive(Clone, Copy, Debug)]
1099pub struct ThermalConfig {
1100 pub check_interval_frames: u32,
1102 pub hysteresis: f32,
1104}
1105
1106impl Default for ThermalConfig {
1107 fn default() -> Self {
1108 Self {
1109 check_interval_frames: 60, hysteresis: 0.05,
1111 }
1112 }
1113}
1114
1115#[derive(Clone, Debug)]
1121pub struct Frustum {
1122 pub planes: [[f32; 4]; 6],
1124}
1125
1126impl Frustum {
1127 pub fn from_view_proj(view_proj: &[[f32; 4]; 4]) -> Self {
1129 let mut planes = [[0.0f32; 4]; 6];
1130 let m = view_proj;
1131
1132 planes[0] = [
1134 m[0][3] + m[0][0],
1135 m[1][3] + m[1][0],
1136 m[2][3] + m[2][0],
1137 m[3][3] + m[3][0],
1138 ];
1139 planes[1] = [
1141 m[0][3] - m[0][0],
1142 m[1][3] - m[1][0],
1143 m[2][3] - m[2][0],
1144 m[3][3] - m[3][0],
1145 ];
1146 planes[2] = [
1148 m[0][3] - m[0][1],
1149 m[1][3] - m[1][1],
1150 m[2][3] - m[2][1],
1151 m[3][3] - m[3][1],
1152 ];
1153 planes[3] = [
1155 m[0][3] + m[0][1],
1156 m[1][3] + m[1][1],
1157 m[2][3] + m[2][1],
1158 m[3][3] + m[3][1],
1159 ];
1160 planes[4] = [
1162 m[0][3] + m[0][2],
1163 m[1][3] + m[1][2],
1164 m[2][3] + m[2][2],
1165 m[3][3] + m[3][2],
1166 ];
1167 planes[5] = [
1169 m[0][3] - m[0][2],
1170 m[1][3] - m[1][2],
1171 m[2][3] - m[2][2],
1172 m[3][3] - m[3][2],
1173 ];
1174
1175 for plane in &mut planes {
1177 let len = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
1178 if len > 0.0 {
1179 plane[0] /= len;
1180 plane[1] /= len;
1181 plane[2] /= len;
1182 plane[3] /= len;
1183 }
1184 }
1185
1186 Self { planes }
1187 }
1188
1189 pub fn intersects_aabb(&self, min: &[f32; 3], max: &[f32; 3]) -> bool {
1191 for plane in &self.planes {
1192 let px = if plane[0] > 0.0 { max[0] } else { min[0] };
1194 let py = if plane[1] > 0.0 { max[1] } else { min[1] };
1195 let pz = if plane[2] > 0.0 { max[2] } else { min[2] };
1196
1197 if plane[0] * px + plane[1] * py + plane[2] * pz + plane[3] < 0.0 {
1199 return false;
1200 }
1201 }
1202 true
1203 }
1204
1205 pub fn intersects_sphere(&self, center: &[f32; 3], radius: f32) -> bool {
1207 for plane in &self.planes {
1208 let dist = plane[0] * center[0] + plane[1] * center[1] + plane[2] * center[2] + plane[3];
1209 if dist < -radius {
1210 return false;
1211 }
1212 }
1213 true
1214 }
1215}
1216
1217#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1219pub struct SpatialCell {
1220 pub x: i32,
1221 pub y: i32,
1222 pub z: i32,
1223}
1224
1225#[derive(Clone, Debug)]
1227pub struct SpatialHash {
1228 cell_size: f32,
1229 cells: std::collections::HashMap<SpatialCell, Vec<u64>>,
1230}
1231
1232impl SpatialHash {
1233 pub fn new(cell_size: f32) -> Self {
1234 Self {
1235 cell_size,
1236 cells: std::collections::HashMap::new(),
1237 }
1238 }
1239
1240 pub fn insert(&mut self, entity_id: u64, position: &[f32; 3]) {
1242 let cell = self.world_to_cell(position);
1243 self.cells.entry(cell).or_default().push(entity_id);
1244 }
1245
1246 pub fn remove(&mut self, entity_id: u64, position: &[f32; 3]) {
1248 let cell = self.world_to_cell(position);
1249 if let Some(entities) = self.cells.get_mut(&cell) {
1250 entities.retain(|&id| id != entity_id);
1251 if entities.is_empty() {
1252 self.cells.remove(&cell);
1253 }
1254 }
1255 }
1256
1257 pub fn query_frustum(&self, frustum: &Frustum) -> Vec<u64> {
1259 let mut results = Vec::new();
1260 for (cell, entities) in &self.cells {
1262 let min = [
1264 cell.x as f32 * self.cell_size,
1265 cell.y as f32 * self.cell_size,
1266 cell.z as f32 * self.cell_size,
1267 ];
1268 let max = [
1269 min[0] + self.cell_size,
1270 min[1] + self.cell_size,
1271 min[2] + self.cell_size,
1272 ];
1273 if frustum.intersects_aabb(&min, &max) {
1274 results.extend(entities);
1275 }
1276 }
1277 results
1278 }
1279
1280 pub fn query_sphere(&self, center: &[f32; 3], radius: f32) -> Vec<u64> {
1282 let mut results = Vec::new();
1283 let min_cell = self.world_to_cell(&[
1285 center[0] - radius,
1286 center[1] - radius,
1287 center[2] - radius,
1288 ]);
1289 let max_cell = self.world_to_cell(&[
1290 center[0] + radius,
1291 center[1] + radius,
1292 center[2] + radius,
1293 ]);
1294
1295 for x in min_cell.x..=max_cell.x {
1296 for y in min_cell.y..=max_cell.y {
1297 for z in min_cell.z..=max_cell.z {
1298 let cell = SpatialCell { x, y, z };
1299 if let Some(entities) = self.cells.get(&cell) {
1300 results.extend(entities);
1301 }
1302 }
1303 }
1304 }
1305 results
1306 }
1307
1308 fn world_to_cell(&self, position: &[f32; 3]) -> SpatialCell {
1309 SpatialCell {
1310 x: (position[0] / self.cell_size).floor() as i32,
1311 y: (position[1] / self.cell_size).floor() as i32,
1312 z: (position[2] / self.cell_size).floor() as i32,
1313 }
1314 }
1315
1316 pub fn clear(&mut self) {
1318 self.cells.clear();
1319 }
1320
1321 pub fn len(&self) -> usize {
1323 self.cells.len()
1324 }
1325
1326 pub fn is_empty(&self) -> bool {
1327 self.cells.is_empty()
1328 }
1329}
1330
1331#[derive(Clone, Debug)]
1337pub struct GoldenImageConfig {
1338 pub pixel_tolerance: u8,
1340 pub max_diff_percent: f32,
1342 pub update_on_mismatch: bool,
1344}
1345
1346impl Default for GoldenImageConfig {
1347 fn default() -> Self {
1348 Self {
1349 pixel_tolerance: 3,
1350 max_diff_percent: 0.1,
1351 update_on_mismatch: false,
1352 }
1353 }
1354}
1355
1356#[derive(Clone, Debug)]
1358pub struct GoldenImageResult {
1359 pub passed: bool,
1361 pub diff_percent: f32,
1363 pub diff_count: u64,
1365 pub total_pixels: u64,
1367}
1368
1369pub struct GoldenImageComparator;
1371
1372impl GoldenImageComparator {
1373 pub fn compare(
1375 actual: &[u8],
1376 expected: &[u8],
1377 config: &GoldenImageConfig,
1378 ) -> GoldenImageResult {
1379 if actual.len() != expected.len() {
1380 return GoldenImageResult {
1381 passed: false,
1382 diff_percent: 100.0,
1383 diff_count: actual.len() as u64 / 4,
1384 total_pixels: actual.len() as u64 / 4,
1385 };
1386 }
1387
1388 let total_pixels = (actual.len() / 4) as u64;
1389 if total_pixels == 0 {
1390 return GoldenImageResult {
1391 passed: true,
1392 diff_percent: 0.0,
1393 diff_count: 0,
1394 total_pixels: 0,
1395 };
1396 }
1397
1398 let mut diff_count = 0u64;
1399 for i in 0..(actual.len() / 4) {
1400 let base = i * 4;
1401 let mut pixel_differs = false;
1402 for ch in 0..3 {
1403 if actual[base + ch].abs_diff(expected[base + ch]) > config.pixel_tolerance {
1405 pixel_differs = true;
1406 break;
1407 }
1408 }
1409 if pixel_differs {
1410 diff_count += 1;
1411 }
1412 }
1413
1414 let diff_percent = (diff_count as f32 / total_pixels as f32) * 100.0;
1415 GoldenImageResult {
1416 passed: diff_percent <= config.max_diff_percent,
1417 diff_percent,
1418 diff_count,
1419 total_pixels,
1420 }
1421 }
1422}
1423
1424#[cfg(test)]
1425mod p2_25_27_28_29_tests {
1426 use super::*;
1427
1428 #[test]
1430 fn shader_feature_flags_default_is_none() {
1431 let flags = ShaderFeatureFlags::NONE;
1432 assert_eq!(flags.count(), 0);
1433 assert!(flags.is_within_permutation_limit());
1434 }
1435
1436 #[test]
1437 fn shader_feature_flags_combine() {
1438 let flags = ShaderFeatureFlags::GLASS | ShaderFeatureFlags::BLOOM;
1439 assert_eq!(flags.count(), 2);
1440 assert!(flags.is_within_permutation_limit());
1441 }
1442
1443 #[test]
1444 fn shader_feature_flags_permutation_limit() {
1445 let flags = ShaderFeatureFlags::GLASS
1447 | ShaderFeatureFlags::BLOOM
1448 | ShaderFeatureFlags::VOLUMETRIC
1449 | ShaderFeatureFlags::COLOR_BLIND
1450 | ShaderFeatureFlags::PARTICLES;
1451 assert_eq!(flags.count(), 5);
1452 assert!(!flags.is_within_permutation_limit());
1453 }
1454
1455 #[test]
1456 fn shader_feature_flags_permutation_index() {
1457 let flags = ShaderFeatureFlags::GLASS | ShaderFeatureFlags::BLOOM;
1458 assert_eq!(flags.permutation_index(), 3); }
1460
1461 #[test]
1463 fn thermal_state_from_temperature() {
1464 assert_eq!(ThermalState::from_temperature(0.3), ThermalState::Nominal);
1465 assert_eq!(ThermalState::from_temperature(0.7), ThermalState::Fair);
1466 assert_eq!(ThermalState::from_temperature(0.85), ThermalState::Serious);
1467 assert_eq!(ThermalState::from_temperature(0.95), ThermalState::Critical);
1468 }
1469
1470 #[test]
1471 fn thermal_quality_scale() {
1472 assert_eq!(ThermalState::Nominal.quality_scale(), 1.0);
1473 assert_eq!(ThermalState::Fair.quality_scale(), 0.75);
1474 assert_eq!(ThermalState::Serious.quality_scale(), 0.5);
1475 assert_eq!(ThermalState::Critical.quality_scale(), 0.25);
1476 }
1477
1478 #[test]
1479 fn thermal_effect_enabling() {
1480 assert!(ThermalState::Nominal.enable_volumetric());
1481 assert!(!ThermalState::Fair.enable_volumetric());
1482 assert!(!ThermalState::Serious.enable_volumetric());
1483
1484 assert!(ThermalState::Nominal.enable_bloom());
1485 assert!(ThermalState::Fair.enable_bloom());
1486 assert!(!ThermalState::Serious.enable_bloom());
1487 }
1488
1489 #[test]
1490 fn thermal_msaa_samples() {
1491 assert_eq!(ThermalState::Nominal.msaa_sample_count(), 4);
1492 assert_eq!(ThermalState::Fair.msaa_sample_count(), 2);
1493 assert_eq!(ThermalState::Serious.msaa_sample_count(), 1);
1494 assert_eq!(ThermalState::Critical.msaa_sample_count(), 1);
1495 }
1496
1497 #[test]
1498 fn thermal_config_default() {
1499 let config = ThermalConfig::default();
1500 assert_eq!(config.check_interval_frames, 60);
1501 assert_eq!(config.hysteresis, 0.05);
1502 }
1503
1504 #[test]
1506 fn frustum_intersects_aabb_visible() {
1507 let identity = [
1509 [1.0, 0.0, 0.0, 0.0],
1510 [0.0, 1.0, 0.0, 0.0],
1511 [0.0, 0.0, 1.0, 0.0],
1512 [0.0, 0.0, 0.0, 1.0],
1513 ];
1514 let frustum = Frustum::from_view_proj(&identity);
1515 assert!(frustum.intersects_aabb(&[0.0, 0.0, 0.0], &[1.0, 1.0, 1.0]));
1517 }
1518
1519 #[test]
1520 fn frustum_intersects_aabb_outside() {
1521 let frustum = Frustum {
1523 planes: [
1524 [0.0, 0.0, -1.0, -10.0], [0.0, 0.0, 1.0, -10.0], [0.0, 0.0, 0.0, 0.0],
1527 [0.0, 0.0, 0.0, 0.0],
1528 [0.0, 0.0, 0.0, 0.0],
1529 [0.0, 0.0, 0.0, 0.0],
1530 ],
1531 };
1532 assert!(!frustum.intersects_aabb(&[0.0, 0.0, -11.0], &[1.0, 1.0, -10.5]));
1534 }
1535
1536 #[test]
1537 fn frustum_intersects_sphere() {
1538 let identity = [
1539 [1.0, 0.0, 0.0, 0.0],
1540 [0.0, 1.0, 0.0, 0.0],
1541 [0.0, 0.0, 1.0, 0.0],
1542 [0.0, 0.0, 0.0, 1.0],
1543 ];
1544 let frustum = Frustum::from_view_proj(&identity);
1545 assert!(frustum.intersects_sphere(&[0.0, 0.0, 0.0], 1.0));
1546 }
1547
1548 #[test]
1550 fn spatial_hash_insert_and_query() {
1551 let mut hash = SpatialHash::new(10.0);
1552 hash.insert(1, &[5.0, 5.0, 0.0]);
1553 hash.insert(2, &[15.0, 5.0, 0.0]);
1554 assert_eq!(hash.len(), 2);
1555 }
1556
1557 #[test]
1558 fn spatial_hash_query_frustum() {
1559 let mut hash = SpatialHash::new(10.0);
1560 hash.insert(1, &[5.0, 5.0, 0.0]);
1561 hash.insert(2, &[50.0, 50.0, 0.0]);
1562
1563 let identity = [
1564 [1.0, 0.0, 0.0, 0.0],
1565 [0.0, 1.0, 0.0, 0.0],
1566 [0.0, 0.0, 1.0, 0.0],
1567 [0.0, 0.0, 0.0, 1.0],
1568 ];
1569 let frustum = Frustum::from_view_proj(&identity);
1570 let results = hash.query_frustum(&frustum);
1571 assert!(!results.is_empty());
1572 }
1573
1574 #[test]
1575 fn spatial_hash_remove() {
1576 let mut hash = SpatialHash::new(10.0);
1577 hash.insert(1, &[5.0, 5.0, 0.0]);
1578 hash.remove(1, &[5.0, 5.0, 0.0]);
1579 assert_eq!(hash.len(), 0);
1581 }
1582
1583 #[test]
1584 fn spatial_hash_clear() {
1585 let mut hash = SpatialHash::new(10.0);
1586 hash.insert(1, &[5.0, 5.0, 0.0]);
1587 hash.insert(2, &[15.0, 5.0, 0.0]);
1588 hash.clear();
1589 assert!(hash.is_empty());
1590 }
1591
1592 #[test]
1594 fn golden_image_identical() {
1595 let pixels = vec![255u8; 400]; let config = GoldenImageConfig::default();
1597 let result = GoldenImageComparator::compare(&pixels, &pixels, &config);
1598 assert!(result.passed);
1599 assert_eq!(result.diff_percent, 0.0);
1600 }
1601
1602 #[test]
1603 fn golden_image_detects_difference() {
1604 let mut actual = vec![255u8; 400];
1605 let expected = vec![255u8; 400];
1606 actual[0] = 0;
1608 let config = GoldenImageConfig::default();
1609 let result = GoldenImageComparator::compare(&actual, &expected, &config);
1610 assert!(!result.passed);
1611 assert!(result.diff_percent > 0.0);
1612 }
1613
1614 #[test]
1615 fn golden_image_tolerance() {
1616 let mut actual = vec![255u8; 400];
1617 let expected = vec![255u8; 400];
1618 actual[0] = 253; let config = GoldenImageConfig::default();
1621 let result = GoldenImageComparator::compare(&actual, &expected, &config);
1622 assert!(result.passed);
1623 }
1624
1625 #[test]
1626 fn golden_image_different_sizes() {
1627 let actual = vec![255u8; 400];
1628 let expected = vec![255u8; 800];
1629 let config = GoldenImageConfig::default();
1630 let result = GoldenImageComparator::compare(&actual, &expected, &config);
1631 assert!(!result.passed);
1632 assert_eq!(result.diff_percent, 100.0);
1633 }
1634
1635 #[test]
1636 fn golden_image_empty() {
1637 let config = GoldenImageConfig::default();
1638 let result = GoldenImageComparator::compare(&[], &[], &config);
1639 assert!(result.passed);
1640 }
1641}