Skip to main content

concinnity_world/
validate.rs

1//! Named bake-time validators for the data-only assets. Each function clamps or
2//! normalizes an asset's authored value into a self-consistent runtime value.
3//! The registry entry names the function via `validate: <fn>`; the build-side
4//! `RegisteredType::reserialize_args` applies it while baking the blob record.
5//! The runtime never runs these -- a baked record is already validated.
6
7use crate::components::{
8    Decal, DirectionalLight, GlassPanel, GlassPanelGeometry, InstancedProp, Material,
9    ParticleEmitter, PhysicsJoint, PhysicsJointKind, PointLight, Prop, RectAreaLight,
10    ReflectionProbe, RigidBody, SPOT_MAX_ANGLE_DEG, SdfVolume, SpotLight, SpotLightGeometry,
11    VolumetricFog, VoxelChunk, WaterSurface, WaterWave,
12};
13
14// The wave ceiling lives with the schema in concinnity-core and is shared with
15// the render backends; re-imported here for the clamp.
16use crate::components::MAX_WATER_WAVES;
17
18// Resolve the fragment shader source path for the current build backend from a
19// volume's `fragment_shaders` map (preferred) or its `fragment_shader`
20// fallback. Mirrors the source selection in `source_args`.
21fn sdf_current_platform_source(v: &SdfVolume) -> Option<String> {
22    let platform = crate::platform::Platform::current();
23    if let Some(map) = &v.fragment_shaders
24        && let Some(src) = map.get(platform.key()).filter(|s| !s.is_empty())
25    {
26        return Some(src.clone());
27    }
28    if v.fragment_shader.is_empty() {
29        return None;
30    }
31    let ext = std::path::Path::new(&v.fragment_shader)
32        .extension()
33        .and_then(|e| e.to_str())
34        .unwrap_or("");
35    if platform.accepts_ext(ext) {
36        Some(v.fragment_shader.clone())
37    } else {
38        None
39    }
40}
41
42/// Normalize an authored volume for the runtime: clamp the raymarch knobs to
43/// sane bounds, force shadows off for translucent volumetrics (they write no
44/// depth), and collapse the per-backend `fragment_shaders` map to the current
45/// backend's `fragment_shader` (the DirectX raymarch pass filters volumes by
46/// that path's extension). The step-count bounds stay in core: they double as
47/// the runtime kernel's loop bound.
48pub fn sdf_volume(mut v: SdfVolume) -> SdfVolume {
49    use crate::components::sdf_volume::{SDF_MAX_STEPS_CEILING, SDF_MAX_STEPS_FLOOR};
50    // Extents must be positive: a zero or negative extent would produce an
51    // inside-out bounding box no fragment ever enters.
52    for axis in v.extent.iter_mut() {
53        if !axis.is_finite() || *axis <= 0.0 {
54            *axis = 1.0;
55        }
56    }
57    if !v.max_gradient.is_finite() || v.max_gradient <= 0.0 {
58        v.max_gradient = 1.0;
59    }
60    v.max_steps = v
61        .max_steps
62        .clamp(SDF_MAX_STEPS_FLOOR, SDF_MAX_STEPS_CEILING);
63    if !v.max_distance.is_finite() || v.max_distance < 0.1 {
64        v.max_distance = 0.1;
65    }
66    if v.volumetric {
67        v.cast_shadows = false;
68    }
69    if let Some(src) = sdf_current_platform_source(&v) {
70        v.fragment_shader = src;
71    }
72    v
73}
74
75/// Clamp a `PointLight`'s authored fields into their valid ranges.
76pub fn point_light(mut args: PointLight) -> PointLight {
77    args.intensity = args.intensity.max(0.0);
78    args.range = args.range.max(0.0);
79    args
80}
81
82pub(crate) fn spot_light(mut args: SpotLight) -> SpotLight {
83    args.intensity = args.intensity.max(0.0);
84    args.range = args.range.max(0.0);
85    args.direction = args.unit_direction();
86    args.outer_angle = args.outer_angle.clamp(0.0, SPOT_MAX_ANGLE_DEG);
87    args.inner_angle = args.inner_angle.clamp(0.0, args.outer_angle);
88    args
89}
90
91pub(crate) fn rect_area_light(mut args: RectAreaLight) -> RectAreaLight {
92    args.intensity = args.intensity.max(0.0);
93    args.range = args.range.max(0.0);
94    // A degenerate normal would collapse the panel's tangent frame; a zero
95    // half-extent would collapse its area and divide by zero in the integrator.
96    let n = args.normal;
97    let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
98    args.normal = if len < 1e-6 {
99        [0.0, 0.0, 1.0]
100    } else {
101        [n[0] / len, n[1] / len, n[2] / len]
102    };
103    args.half_size[0] = args.half_size[0].max(1e-3);
104    args.half_size[1] = args.half_size[1].max(1e-3);
105    args
106}
107
108pub(crate) fn directional_light(mut args: DirectionalLight) -> DirectionalLight {
109    args.intensity = args.intensity.max(0.0);
110    args
111}
112
113/// Public so the cook-side Material data-resource compiler can apply the same
114/// clamps: Material left the component registry, so its generated `from_args` (the
115/// usual caller of this validator) no longer runs -- cook must call it explicitly
116/// before baking the material into its `data_bytes`.
117pub fn material(mut args: Material) -> Material {
118    args.roughness = args.roughness.clamp(0.0, 1.0);
119    args.metallic = args.metallic.clamp(0.0, 1.0);
120    args.macro_variation = args.macro_variation.clamp(0.0, 1.0);
121    args.terrain_blend = args.terrain_blend.clamp(0.0, 1.0);
122    args.secondary_blend_sharpness = args.secondary_blend_sharpness.clamp(0.0, 1.0);
123    args.alpha_cutoff = args.alpha_cutoff.clamp(0.0, 1.0);
124    args.opacity = args.opacity.clamp(0.0, 1.0);
125    // See-through glass is by definition transparent; opting into it implies
126    // the transparent pass even if the author only set `see_through`.
127    if args.see_through {
128        args.transparent = true;
129    }
130    args
131}
132
133pub(crate) fn glass_panel(mut args: GlassPanel) -> GlassPanel {
134    args.normal = args.unit_normal();
135    args.half_size[0] = args.half_size[0].max(1e-3);
136    args.half_size[1] = args.half_size[1].max(1e-3);
137    args.opacity = args.opacity.clamp(0.0, 1.0);
138    args.refraction_strength = args.refraction_strength.max(0.0);
139    args.fresnel_power = args.fresnel_power.max(0.0);
140    args
141}
142
143pub(crate) fn water_surface(mut args: WaterSurface) -> WaterSurface {
144    args.subdivisions = args.subdivisions.clamp(8, 255);
145    if args.waves.len() > MAX_WATER_WAVES {
146        args.waves.truncate(MAX_WATER_WAVES);
147    }
148    if args.waves.is_empty() {
149        args.waves.push(WaterWave::default());
150    }
151    args
152}
153
154/// Clamp a `PhysicsJoint`'s authored fields into their valid ranges.
155pub fn joint(mut args: PhysicsJoint) -> PhysicsJoint {
156    // Normalise the kind string so `to_args` round-trips cleanly.
157    if let Some(k) = PhysicsJointKind::from_str_norm(&args.kind) {
158        args.kind = k.as_str().to_string();
159    }
160    args
161}
162
163/// Clamp a `Decal`'s authored fields into their valid ranges.
164pub fn decal(mut args: Decal) -> Decal {
165    // Clamp the alpha to [0, 1] so a stray > 1 doesn't blow out the
166    // composite. The size components are left as-authored: a non-positive
167    // value silently disables the decal in the gfx-side resolver below.
168    args.tint[3] = args.tint[3].clamp(0.0, 1.0);
169    args
170}
171
172pub(crate) fn reflection_probe(mut args: ReflectionProbe) -> ReflectionProbe {
173    // Half-extents are sizes: keep them non-negative so the influence box is
174    // never inverted.
175    for e in &mut args.half_extents {
176        *e = e.max(0.0);
177    }
178    args
179}
180
181pub(crate) fn rigid_body(mut args: RigidBody) -> RigidBody {
182    // Runtime state is always reset on construction.
183    args.is_grounded = true;
184    args
185}
186
187/// Clamp a `Prop`'s authored fields into their valid ranges.
188pub fn prop(mut args: Prop) -> Prop {
189    args.cull_distance = args.cull_distance.max(0.0);
190    args.is_held = false;
191    args
192}
193
194pub(crate) fn particle_emitter(mut args: ParticleEmitter) -> ParticleEmitter {
195    // Asset-side floor: keep every authored field in a self-consistent
196    // range. The gfx-side `build_particle_records` adds its own clamps
197    // for fields that affect GPU buffer sizing.
198    args.spread_deg = args.spread_deg.clamp(0.0, 180.0);
199    args.speed_min = args.speed_min.max(0.0);
200    if !args.speed_max.is_finite() || args.speed_max < args.speed_min {
201        args.speed_max = args.speed_min;
202    }
203    if !args.lifetime_min.is_finite() || args.lifetime_min <= 0.0 {
204        args.lifetime_min = 0.001;
205    }
206    if !args.lifetime_max.is_finite() || args.lifetime_max < args.lifetime_min {
207        args.lifetime_max = args.lifetime_min;
208    }
209    args.spawn_rate = args.spawn_rate.max(0.0);
210    args.max_particles = args.max_particles.clamp(1, 65_536);
211    args.size_start = args.size_start.max(0.0);
212    args.size_end = args.size_end.max(0.0);
213    for c in args.color_start.iter_mut().chain(args.color_end.iter_mut()) {
214        if !c.is_finite() {
215            *c = 0.0;
216        }
217    }
218    args
219}
220
221/// Clamp a `VolumetricFog`'s authored fields into their valid ranges.
222pub fn volumetric_fog(mut args: VolumetricFog) -> VolumetricFog {
223    // Density / falloff / ambient floor at 0; max_distance must stay
224    // positive so the gfx-side resolver does not divide by zero when
225    // computing the per-step length.
226    args.density = args.density.max(0.0);
227    args.height_falloff = args.height_falloff.max(0.0);
228    args.ambient = args.ambient.max(0.0);
229    if args.max_distance <= 0.0 || !args.max_distance.is_finite() {
230        args.max_distance = 1.0;
231    }
232    // Henyey-Greenstein blows up at |g| = 1; clamp inside the open
233    // interval so the closed-form `(1 - g²)` factor stays positive.
234    args.phase_g = args.phase_g.clamp(-0.95, 0.95);
235    args
236}
237
238pub(crate) fn instanced_prop(mut args: InstancedProp) -> InstancedProp {
239    args.cull_distance = args.cull_distance.max(0.0);
240    args
241}
242
243/// Clamp a `VoxelChunk`'s authored fields into their valid ranges.
244pub fn voxel_chunk(mut args: VoxelChunk) -> VoxelChunk {
245    args.block_size = args.block_size.max(0.0);
246    if args.lod_levels == 0 {
247        args.lod_levels = 1;
248    }
249    args.lod_levels = args.lod_levels.min(8);
250    args
251}
252
253#[cfg(test)]
254mod tests {
255    use crate::components::*;
256
257    mod material {
258        use super::*;
259
260        #[test]
261        fn default_is_opaque_and_not_see_through() {
262            let m = Material::default();
263            assert!(!m.transparent);
264            assert!(!m.see_through);
265            assert_eq!(m.opacity, 1.0);
266        }
267
268        #[test]
269        fn see_through_implies_transparent() {
270            // A material that opts into see-through but leaves `transparent` at
271            // its default must still route through the transparent pass.
272            let m = super::super::material(Material {
273                see_through: true,
274                ..Material::default()
275            });
276            assert!(m.see_through);
277            assert!(m.transparent);
278        }
279
280        #[test]
281        fn transparent_without_see_through_stays_opaque_layer() {
282            // The importer's glass detection sets `transparent` only; that
283            // material stays Layer 1 (opaque reflective) and keeps see-through off.
284            let m = super::super::material(Material {
285                transparent: true,
286                ..Material::default()
287            });
288            assert!(m.transparent);
289            assert!(!m.see_through);
290        }
291    }
292
293    mod decal {
294        use super::*;
295
296        #[test]
297        fn deserialises_with_defaults() {
298            let d: Decal = serde_json::from_str("{}").unwrap();
299            assert_eq!(d.position, [0.0, 0.0, 0.0]);
300            assert_eq!(d.size, [1.0, 1.0, 1.0]);
301            assert_eq!(d.tint, [1.0, 1.0, 1.0, 1.0]);
302            assert!(d.visible);
303            assert!(d.texture.is_none());
304        }
305
306        #[test]
307        fn deserialises_with_all_fields() {
308            crate::ecs::asset_id::reset_interner();
309            let json = r#"{
310                "texture":"tex_bullet",
311                "position":[1.0,2.0,3.0],
312                "rotation_deg":[0,90,0],
313                "size":[0.4,0.2,0.4],
314                "tint":[0.9,0.2,0.1,0.8],
315                "visible":false
316            }"#;
317            let d: Decal = serde_json::from_str(json).unwrap();
318            assert_eq!(d.position, [1.0, 2.0, 3.0]);
319            assert_eq!(d.rotation_deg, [0.0, 90.0, 0.0]);
320            assert_eq!(d.size, [0.4, 0.2, 0.4]);
321            assert_eq!(d.tint, [0.9, 0.2, 0.1, 0.8]);
322            assert!(!d.visible);
323            assert!(d.texture.is_some());
324        }
325
326        #[test]
327        fn clamps_alpha_through_from_args() {
328            let json = r#"{"tint":[1,1,1,5.0]}"#;
329            let parsed: Decal = serde_json::from_str(json).unwrap();
330            let normalised = super::super::decal(parsed);
331            assert_eq!(normalised.tint[3], 1.0);
332
333            let json = r#"{"tint":[1,1,1,-0.5]}"#;
334            let parsed: Decal = serde_json::from_str(json).unwrap();
335            let normalised = super::super::decal(parsed);
336            assert_eq!(normalised.tint[3], 0.0);
337        }
338    }
339
340    mod glass_panel {
341        use super::*;
342
343        #[test]
344        fn from_args_normalizes_normal() {
345            let g = super::super::glass_panel(GlassPanel {
346                normal: [0.0, 0.0, 4.0],
347                ..Default::default()
348            });
349            let len = (g.normal[0].powi(2) + g.normal[1].powi(2) + g.normal[2].powi(2)).sqrt();
350            assert!((len - 1.0).abs() < 1e-5);
351            assert!((g.normal[2] - 1.0).abs() < 1e-5);
352        }
353
354        #[test]
355        fn from_args_falls_back_on_degenerate_normal() {
356            let g = super::super::glass_panel(GlassPanel {
357                normal: [0.0, 0.0, 0.0],
358                ..Default::default()
359            });
360            assert_eq!(g.normal, [0.0, 0.0, 1.0]);
361        }
362
363        #[test]
364        fn from_args_clamps_ranges() {
365            let g = super::super::glass_panel(GlassPanel {
366                half_size: [-2.0, 0.0],
367                opacity: 1.5,
368                refraction_strength: -0.1,
369                fresnel_power: -3.0,
370                ..Default::default()
371            });
372            assert!(g.half_size[0] > 0.0 && g.half_size[1] > 0.0);
373            assert_eq!(g.opacity, 1.0);
374            assert_eq!(g.refraction_strength, 0.0);
375            assert_eq!(g.fresnel_power, 0.0);
376        }
377    }
378
379    mod joint {
380        use super::*;
381
382        #[test]
383        fn deserialises_with_defaults() {
384            let j: PhysicsJoint = serde_json::from_str("{}").unwrap();
385            assert_eq!(j.kind, "fixed");
386            assert_eq!(j.anchor_a, [0.0, 0.0, 0.0]);
387            assert_eq!(j.axis, [0.0, 1.0, 0.0]);
388            assert!(!j.limits_enabled);
389            assert_eq!(j.motor_max_force, 0.0);
390        }
391
392        #[test]
393        fn deserialises_all_fields() {
394            crate::ecs::asset_id::reset_interner();
395            let json = r#"{
396                "kind":"revolute",
397                "body_a":"door",
398                "body_b":"wall",
399                "anchor_a":[0.5,1.0,0.0],
400                "anchor_b":[1.0,1.0,0.0],
401                "axis":[0,1,0],
402                "limits_enabled":true,
403                "limits":[-90,90],
404                "motor_target_velocity":30.0,
405                "motor_max_force":50.0
406            }"#;
407            let j: PhysicsJoint = serde_json::from_str(json).unwrap();
408            assert_eq!(j.parsed_kind(), PhysicsJointKind::Revolute);
409            assert!(j.body_a.is_some());
410            assert!(j.body_b.is_some());
411            assert!(j.limits_enabled);
412        }
413
414        #[test]
415        fn aliases_resolve_to_canonical_kind() {
416            assert_eq!(
417                PhysicsJointKind::from_str_norm("hinge"),
418                Some(PhysicsJointKind::Revolute)
419            );
420            assert_eq!(
421                PhysicsJointKind::from_str_norm("WELD"),
422                Some(PhysicsJointKind::Fixed)
423            );
424            assert_eq!(
425                PhysicsJointKind::from_str_norm("ball"),
426                Some(PhysicsJointKind::Spherical)
427            );
428            assert_eq!(
429                PhysicsJointKind::from_str_norm("slider"),
430                Some(PhysicsJointKind::Prismatic)
431            );
432        }
433
434        #[test]
435        fn from_args_normalises_kind_string() {
436            let json = r#"{"kind":"HINGE"}"#;
437            let parsed: PhysicsJoint = serde_json::from_str(json).unwrap();
438            let normalised = super::super::joint(parsed);
439            assert_eq!(normalised.kind, "revolute");
440        }
441
442        #[test]
443        fn unknown_kind_falls_back_to_fixed() {
444            let j = PhysicsJoint {
445                kind: "frumpus".to_string(),
446                ..Default::default()
447            };
448            assert_eq!(j.parsed_kind(), PhysicsJointKind::Fixed);
449        }
450    }
451
452    mod particle_emitter {
453        use super::*;
454
455        #[test]
456        fn deserialises_with_defaults() {
457            let p: ParticleEmitter = serde_json::from_str("{}").unwrap();
458            assert_eq!(p.position, [0.0, 0.0, 0.0]);
459            assert_eq!(p.direction, [0.0, 1.0, 0.0]);
460            assert_eq!(p.max_particles, 256);
461            assert!(p.visible);
462            assert!(p.texture.is_none());
463        }
464
465        #[test]
466        fn deserialises_with_all_fields() {
467            crate::ecs::asset_id::reset_interner();
468            let json = r#"{
469                "texture":"tex_spark","position":[1,2,3],"direction":[0,1,0],
470                "spread_deg":30,"speed_min":1.5,"speed_max":4.0,
471                "lifetime_min":0.5,"lifetime_max":1.0,"gravity":[0,-1,0],
472                "spawn_rate":60,"max_particles":128,"size_start":0.1,"size_end":0.02,
473                "color_start":[1,0.5,0,1],"color_end":[1,0,0,0],"visible":false
474            }"#;
475            let p: ParticleEmitter = serde_json::from_str(json).unwrap();
476            assert_eq!(p.position, [1.0, 2.0, 3.0]);
477            assert_eq!(p.max_particles, 128);
478            assert_eq!(p.color_start, [1.0, 0.5, 0.0, 1.0]);
479            assert!(!p.visible);
480            assert!(p.texture.is_some());
481        }
482
483        #[test]
484        fn from_args_clamps_invalid_inputs() {
485            let a = ParticleEmitter {
486                spread_deg: 300.0,
487                speed_min: -1.0,
488                speed_max: -5.0,
489                lifetime_min: -0.4,
490                lifetime_max: -2.0,
491                spawn_rate: -10.0,
492                max_particles: 0,
493                size_start: -0.5,
494                size_end: -0.1,
495                ..Default::default()
496            };
497            let n = super::super::particle_emitter(a);
498            assert_eq!(n.spread_deg, 180.0);
499            assert_eq!(n.speed_min, 0.0);
500            assert_eq!(n.speed_max, 0.0);
501            assert!(n.lifetime_min > 0.0);
502            assert!(n.lifetime_max >= n.lifetime_min);
503            assert_eq!(n.spawn_rate, 0.0);
504            assert_eq!(n.max_particles, 1);
505            assert_eq!(n.size_start, 0.0);
506            assert_eq!(n.size_end, 0.0);
507        }
508
509        #[test]
510        fn from_args_lifts_speed_max_to_speed_min() {
511            let a = ParticleEmitter {
512                speed_min: 5.0,
513                speed_max: 2.0,
514                ..Default::default()
515            };
516            let n = super::super::particle_emitter(a);
517            assert!((n.speed_max - n.speed_min).abs() < 1e-6);
518        }
519
520        #[test]
521        fn from_args_clamps_max_particles_upper() {
522            let a = ParticleEmitter {
523                max_particles: 200_000,
524                ..Default::default()
525            };
526            let n = super::super::particle_emitter(a);
527            assert_eq!(n.max_particles, 65_536);
528        }
529    }
530
531    mod volumetric_fog {
532        use super::*;
533
534        #[test]
535        fn deserialises_with_defaults() {
536            let f: VolumetricFog = serde_json::from_str("{}").unwrap();
537            assert!(f.enabled);
538            assert_eq!(f.color, [0.7, 0.78, 0.85]);
539            assert!((f.density - 0.05).abs() < 1e-6);
540            assert!((f.max_distance - 200.0).abs() < 1e-6);
541        }
542
543        #[test]
544        fn deserialises_with_explicit_fields() {
545            let json = r#"{
546                "enabled":false,"density":0.12,"color":[0.5,0.6,0.7],
547                "height_falloff":0.3,"height_reference":1.5,
548                "max_distance":80.0,"phase_g":0.7,"ambient":0.25
549            }"#;
550            let f: VolumetricFog = serde_json::from_str(json).unwrap();
551            assert!(!f.enabled);
552            assert_eq!(f.color, [0.5, 0.6, 0.7]);
553            assert!((f.phase_g - 0.7).abs() < 1e-6);
554        }
555
556        #[test]
557        fn from_args_clamps_invalid_inputs() {
558            let a = VolumetricFog {
559                density: -1.0,
560                height_falloff: -0.4,
561                ambient: -2.0,
562                max_distance: -1.0,
563                phase_g: 1.4,
564                ..Default::default()
565            };
566            let n = super::super::volumetric_fog(a);
567            assert_eq!(n.density, 0.0);
568            assert_eq!(n.height_falloff, 0.0);
569            assert_eq!(n.ambient, 0.0);
570            assert!(n.max_distance > 0.0);
571            assert!(n.phase_g <= 0.95 && n.phase_g > 0.0);
572        }
573
574        #[test]
575        fn from_args_passes_through_valid_inputs() {
576            let a = VolumetricFog {
577                density: 0.08,
578                phase_g: -0.3,
579                ..Default::default()
580            };
581            let n = super::super::volumetric_fog(a);
582            assert!((n.density - 0.08).abs() < 1e-6);
583            assert!((n.phase_g - (-0.3)).abs() < 1e-6);
584        }
585    }
586
587    mod instanced_prop {
588        use super::*;
589        use crate::components::{InstanceTransform, InstancedPropGeometry};
590        use crate::ecs::asset_id::AssetId;
591
592        fn empty() -> InstancedProp {
593            InstancedProp {
594                asset_id: AssetId::default(),
595                mesh: None,
596                material: None,
597                texture: None,
598                instances: Vec::new(),
599                cull_distance: 0.0,
600            }
601        }
602
603        #[test]
604        fn instance_model_matrix_default_is_identity() {
605            let mut p = empty();
606            p.instances.push(InstanceTransform::default());
607            let m = p.instance_model_matrix(0).unwrap();
608            assert_eq!(m[3], [0.0, 0.0, 0.0, 1.0]);
609            assert!((m[0][0] - 1.0).abs() < 1e-5);
610            assert!((m[1][1] - 1.0).abs() < 1e-5);
611            assert!((m[2][2] - 1.0).abs() < 1e-5);
612        }
613
614        #[test]
615        fn instance_model_matrix_translates() {
616            let mut p = empty();
617            p.instances.push(InstanceTransform {
618                position: [5.0, -2.0, 3.0],
619                ..InstanceTransform::default()
620            });
621            let m = p.instance_model_matrix(0).unwrap();
622            assert_eq!(m[3], [5.0, -2.0, 3.0, 1.0]);
623        }
624
625        #[test]
626        fn instance_model_matrix_scales() {
627            let mut p = empty();
628            p.instances.push(InstanceTransform {
629                scale: [2.0, 3.0, 4.0],
630                ..InstanceTransform::default()
631            });
632            let m = p.instance_model_matrix(0).unwrap();
633            // diagonal entries should be the scale factors (no rotation)
634            assert!((m[0][0] - 2.0).abs() < 1e-5);
635            assert!((m[1][1] - 3.0).abs() < 1e-5);
636            assert!((m[2][2] - 4.0).abs() < 1e-5);
637        }
638
639        #[test]
640        fn instance_model_matrix_out_of_range_returns_none() {
641            let p = empty();
642            assert!(p.instance_model_matrix(0).is_none());
643        }
644
645        #[test]
646        fn from_args_clamps_negative_cull_distance() {
647            let args = InstancedProp {
648                cull_distance: -5.0,
649                ..InstancedProp::default()
650            };
651            let p = super::super::instanced_prop(args);
652            assert_eq!(p.cull_distance, 0.0);
653        }
654    }
655
656    mod sdf_volume {
657        use super::*;
658        use crate::components::sdf_volume::{SDF_MAX_STEPS_CEILING, SDF_MAX_STEPS_FLOOR};
659
660        // File extension matching the backend these tests compile against, so a
661        // single `fragment_shader` path resolves as current-platform-compatible
662        // on Metal, DirectX, and Vulkan alike.
663        fn platform_ext() -> &'static str {
664            crate::platform::Platform::current().key()
665        }
666
667        #[test]
668        fn clamps_steps() {
669            let mut a = SdfVolume {
670                max_steps: 1,
671                ..Default::default()
672            };
673            let fixed = super::super::sdf_volume(a.clone());
674            assert_eq!(fixed.max_steps, SDF_MAX_STEPS_FLOOR);
675
676            a.max_steps = 9999;
677            let fixed = super::super::sdf_volume(a);
678            assert_eq!(fixed.max_steps, SDF_MAX_STEPS_CEILING);
679        }
680
681        #[test]
682        fn repairs_bad_extent() {
683            let a = SdfVolume {
684                extent: [0.0, -1.0, f32::NAN],
685                ..Default::default()
686            };
687            let fixed = super::super::sdf_volume(a);
688            assert_eq!(fixed.extent, [1.0, 1.0, 1.0]);
689        }
690
691        #[test]
692        fn repairs_bad_gradient_and_distance() {
693            let a = SdfVolume {
694                max_gradient: -0.5,
695                max_distance: f32::NAN,
696                ..Default::default()
697            };
698            let fixed = super::super::sdf_volume(a);
699            assert_eq!(fixed.max_gradient, 1.0);
700            assert_eq!(fixed.max_distance, 0.1);
701        }
702
703        #[test]
704        fn collapses_map_to_current_backend() {
705            // The runtime struct should carry the current backend's path in
706            // `fragment_shader` so the DirectX path-extension filter still works
707            // for map-authored volumes.
708            // Include every backend so the collapse resolves regardless of which
709            // backend this test build targets (metal / hlsl / glsl).
710            let mut map = std::collections::BTreeMap::new();
711            map.insert("metal".to_string(), "shaders/blob.metal".to_string());
712            map.insert("hlsl".to_string(), "shaders/blob.hlsl".to_string());
713            map.insert("glsl".to_string(), "shaders/blob.glsl".to_string());
714            let a = SdfVolume {
715                fragment_shaders: Some(map),
716                ..Default::default()
717            };
718            let resolved = super::super::sdf_volume(a);
719            assert_eq!(
720                resolved.fragment_shader,
721                format!("shaders/blob.{}", platform_ext())
722            );
723        }
724
725        #[test]
726        fn volumetric_forces_cast_shadows_off() {
727            let a = SdfVolume {
728                volumetric: true,
729                cast_shadows: true,
730                ..Default::default()
731            };
732            let fixed = super::super::sdf_volume(a);
733            assert!(fixed.volumetric);
734            assert!(
735                !fixed.cast_shadows,
736                "volumetric SDFs are translucent and must not cast hard shadows"
737            );
738        }
739
740        #[test]
741        fn roundtrip_through_args() {
742            let mut v = SdfVolume {
743                centre: [1.0, 2.0, 3.0],
744                extent: [4.0, 5.0, 6.0],
745                fragment_shader: "shaders/foo.metal".to_string(),
746                ..Default::default()
747            };
748            v.params[7] = 0.42;
749            let json = serde_json::to_value(v.clone()).expect("serialises");
750            let back: SdfVolume = serde_json::from_value(json).expect("deserialises");
751            let back = super::super::sdf_volume(back);
752            assert_eq!(back.centre, [1.0, 2.0, 3.0]);
753            assert_eq!(back.extent, [4.0, 5.0, 6.0]);
754            assert_eq!(back.fragment_shader, "shaders/foo.metal");
755            assert_eq!(back.params[7], 0.42);
756        }
757    }
758}