Skip to main content

concinnity_core/components/
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 authoring registry names the function via `validate: <fn>` and applies
4//! it while baking the blob record; a runtime bake
5//! applies the same function before installing the value. The runtime never runs these on a loaded
6//! world -- a baked record is already validated.
7
8use alloc::string::ToString;
9
10use crate::components::{
11    Decal, DirectionalLight, GlassPanel, GlassPanelGeometry, InstancedProp, MAX_WATER_WAVES,
12    Material, ParticleEmitter, PhysicsJoint, PhysicsJointKind, PointLight, Prop, RectAreaLight,
13    ReflectionProbe, RigidBody, SPOT_MAX_ANGLE_DEG, SdfVolume, SkyRotation, SpotLight,
14    SpotLightGeometry, VolumetricFog, VoxelChunk, WaterSurface, WaterWave,
15};
16use crate::math::sqrt;
17
18/// Normalize an authored volume for the runtime: clamp the raymarch knobs to
19/// sane bounds and force shadows off for translucent volumetrics, which write
20/// no depth to cast from. The step-count bounds stay with the schema: they
21/// double as the runtime kernel's loop bound.
22pub fn sdf_volume(mut v: SdfVolume) -> SdfVolume {
23    use crate::components::sdf_volume::{SDF_MAX_STEPS_CEILING, SDF_MAX_STEPS_FLOOR};
24    // Extents must be positive: a zero or negative extent would produce an
25    // inside-out bounding box no fragment ever enters.
26    for axis in v.extent.iter_mut() {
27        if !axis.is_finite() || *axis <= 0.0 {
28            *axis = 1.0;
29        }
30    }
31    if !v.max_gradient.is_finite() || v.max_gradient <= 0.0 {
32        v.max_gradient = 1.0;
33    }
34    v.max_steps = v
35        .max_steps
36        .clamp(SDF_MAX_STEPS_FLOOR, SDF_MAX_STEPS_CEILING);
37    if !v.max_distance.is_finite() || v.max_distance < 0.1 {
38        v.max_distance = 0.1;
39    }
40    if v.volumetric {
41        v.cast_shadows = false;
42    }
43    v
44}
45
46/// Clamp a `PointLight`'s authored fields into their valid ranges.
47pub fn point_light(mut args: PointLight) -> PointLight {
48    args.intensity = args.intensity.max(0.0);
49    args.range = args.range.max(0.0);
50    args
51}
52
53/// Clamp a `SpotLight`'s authored fields into their valid ranges.
54pub fn spot_light(mut args: SpotLight) -> SpotLight {
55    args.intensity = args.intensity.max(0.0);
56    args.range = args.range.max(0.0);
57    args.direction = args.unit_direction();
58    args.outer_angle = args.outer_angle.clamp(0.0, SPOT_MAX_ANGLE_DEG);
59    args.inner_angle = args.inner_angle.clamp(0.0, args.outer_angle);
60    args
61}
62
63/// Clamp a `RectAreaLight`'s authored fields into their valid ranges.
64pub fn rect_area_light(mut args: RectAreaLight) -> RectAreaLight {
65    args.intensity = args.intensity.max(0.0);
66    args.range = args.range.max(0.0);
67    // A degenerate normal would collapse the panel's tangent frame; a zero
68    // half-extent would collapse its area and divide by zero in the integrator.
69    let n = args.normal;
70    let len = sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
71    args.normal = if len < 1e-6 {
72        [0.0, 0.0, 1.0]
73    } else {
74        [n[0] / len, n[1] / len, n[2] / len]
75    };
76    args.half_size[0] = args.half_size[0].max(1e-3);
77    args.half_size[1] = args.half_size[1].max(1e-3);
78    args
79}
80
81/// Clamp a `DirectionalLight`'s authored fields into their valid ranges.
82pub fn directional_light(mut args: DirectionalLight) -> DirectionalLight {
83    args.intensity = args.intensity.max(0.0);
84    args
85}
86
87/// Replace a `SkyRotation`'s degenerate axis with the default pole: an axis
88/// with no direction would leave the sky in a fixed orientation whatever the
89/// rate said.
90pub fn sky_rotation(mut args: SkyRotation) -> SkyRotation {
91    let [x, y, z] = args.axis;
92    if sqrt(x * x + y * y + z * z) < 1e-6 {
93        args.axis = SkyRotation::default().axis;
94    }
95    args
96}
97
98/// Clamp a `Material`'s authored fields into their valid ranges. Material is a
99/// data resource, not a registered component, so no generated `from_args` runs
100/// this -- the material compilers call it explicitly before baking the bytes.
101pub fn material(mut args: Material) -> Material {
102    args.roughness = args.roughness.clamp(0.0, 1.0);
103    args.metallic = args.metallic.clamp(0.0, 1.0);
104    args.alpha_cutoff = args.alpha_cutoff.clamp(0.0, 1.0);
105    args.opacity = args.opacity.clamp(0.0, 1.0);
106    // See-through glass is by definition transparent; opting into it implies
107    // the transparent pass even if the author only set `see_through`.
108    if args.see_through {
109        args.transparent = true;
110    }
111    args
112}
113
114/// Clamp a `GlassPanel`'s authored fields into their valid ranges.
115pub fn glass_panel(mut args: GlassPanel) -> GlassPanel {
116    args.normal = args.unit_normal();
117    args.half_size[0] = args.half_size[0].max(1e-3);
118    args.half_size[1] = args.half_size[1].max(1e-3);
119    args.opacity = args.opacity.clamp(0.0, 1.0);
120    args.refraction_strength = args.refraction_strength.max(0.0);
121    args.fresnel_power = args.fresnel_power.max(0.0);
122    args
123}
124
125/// Clamp a `WaterSurface`'s authored fields into their valid ranges.
126pub fn water_surface(mut args: WaterSurface) -> WaterSurface {
127    args.subdivisions = args.subdivisions.clamp(8, 255);
128    if args.waves.len() > MAX_WATER_WAVES {
129        args.waves.truncate(MAX_WATER_WAVES);
130    }
131    if args.waves.is_empty() {
132        args.waves.push(WaterWave::default());
133    }
134    args
135}
136
137/// Clamp a `PhysicsJoint`'s authored fields into their valid ranges.
138pub fn joint(mut args: PhysicsJoint) -> PhysicsJoint {
139    // Normalise the kind string so `to_args` round-trips cleanly.
140    if let Some(k) = PhysicsJointKind::from_str_norm(&args.kind) {
141        args.kind = k.as_str().to_string();
142    }
143    args
144}
145
146/// Clamp a `Decal`'s authored fields into their valid ranges.
147pub fn decal(mut args: Decal) -> Decal {
148    // Clamp the alpha to [0, 1] so a stray > 1 doesn't blow out the
149    // composite. The size components are left as-authored: a non-positive
150    // value silently disables the decal in the gfx-side resolver.
151    args.tint[3] = args.tint[3].clamp(0.0, 1.0);
152    args
153}
154
155/// Clamp a `ReflectionProbe`'s authored fields into their valid ranges.
156pub fn reflection_probe(mut args: ReflectionProbe) -> ReflectionProbe {
157    // Half-extents are sizes: keep them non-negative so the influence box is
158    // never inverted.
159    for e in &mut args.half_extents {
160        *e = e.max(0.0);
161    }
162    args
163}
164
165/// Reset a `RigidBody`'s runtime state on construction.
166pub fn rigid_body(mut args: RigidBody) -> RigidBody {
167    args.is_grounded = true;
168    args
169}
170
171/// Clamp a `Prop`'s authored fields into their valid ranges.
172pub fn prop(mut args: Prop) -> Prop {
173    args.cull_distance = args.cull_distance.max(0.0);
174    args.is_held = false;
175    args
176}
177
178/// Clamp a `ParticleEmitter`'s authored fields into their valid ranges.
179pub fn particle_emitter(mut args: ParticleEmitter) -> ParticleEmitter {
180    // Asset-side floor: keep every authored field in a self-consistent
181    // range. The gfx-side `build_particle_records` adds its own clamps
182    // for fields that affect GPU buffer sizing.
183    args.spread_deg = args.spread_deg.clamp(0.0, 180.0);
184    args.speed_min = args.speed_min.max(0.0);
185    if !args.speed_max.is_finite() || args.speed_max < args.speed_min {
186        args.speed_max = args.speed_min;
187    }
188    if !args.lifetime_min.is_finite() || args.lifetime_min <= 0.0 {
189        args.lifetime_min = 0.001;
190    }
191    if !args.lifetime_max.is_finite() || args.lifetime_max < args.lifetime_min {
192        args.lifetime_max = args.lifetime_min;
193    }
194    args.spawn_rate = args.spawn_rate.max(0.0);
195    args.max_particles = args.max_particles.clamp(1, 65_536);
196    args.size_start = args.size_start.max(0.0);
197    args.size_end = args.size_end.max(0.0);
198    for c in args.color_start.iter_mut().chain(args.color_end.iter_mut()) {
199        if !c.is_finite() {
200            *c = 0.0;
201        }
202    }
203    args
204}
205
206/// Clamp a `VolumetricFog`'s authored fields into their valid ranges.
207pub fn volumetric_fog(mut args: VolumetricFog) -> VolumetricFog {
208    // Density / falloff / ambient floor at 0; max_distance must stay
209    // positive so the gfx-side resolver does not divide by zero when
210    // computing the per-step length.
211    args.density = args.density.max(0.0);
212    args.height_falloff = args.height_falloff.max(0.0);
213    args.ambient = args.ambient.max(0.0);
214    if args.max_distance <= 0.0 || !args.max_distance.is_finite() {
215        args.max_distance = 1.0;
216    }
217    // Henyey-Greenstein blows up at |g| = 1; clamp inside the open
218    // interval so the closed-form `(1 - g²)` factor stays positive.
219    args.phase_g = args.phase_g.clamp(-0.95, 0.95);
220    args
221}
222
223/// Clamp an `InstancedProp`'s authored fields into their valid ranges.
224pub fn instanced_prop(mut args: InstancedProp) -> InstancedProp {
225    args.cull_distance = args.cull_distance.max(0.0);
226    args
227}
228
229/// Clamp a `VoxelChunk`'s authored fields into their valid ranges.
230pub fn voxel_chunk(mut args: VoxelChunk) -> VoxelChunk {
231    args.block_size = args.block_size.max(0.0);
232    if args.lod_levels == 0 {
233        args.lod_levels = 1;
234    }
235    args.lod_levels = args.lod_levels.min(8);
236    args
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn material_clamps_and_see_through_implies_transparent() {
245        let m = material(Material {
246            roughness: 2.0,
247            metallic: -1.0,
248            opacity: 1.5,
249            see_through: true,
250            ..Material::default()
251        });
252        assert_eq!(m.roughness, 1.0);
253        assert_eq!(m.metallic, 0.0);
254        assert_eq!(m.opacity, 1.0);
255        assert!(m.transparent);
256    }
257
258    #[test]
259    fn water_surface_clamps_subdivisions_and_guarantees_a_wave() {
260        let w = water_surface(WaterSurface {
261            subdivisions: 3,
262            waves: alloc::vec::Vec::new(),
263            ..WaterSurface::default()
264        });
265        assert_eq!(w.subdivisions, 8);
266        assert_eq!(w.waves.len(), 1);
267    }
268
269    #[test]
270    fn lights_floor_intensity_and_range_at_zero() {
271        let p = point_light(PointLight {
272            intensity: -2.0,
273            range: -1.0,
274            ..PointLight::default()
275        });
276        assert_eq!((p.intensity, p.range), (0.0, 0.0));
277        let d = directional_light(DirectionalLight {
278            intensity: -1.0,
279            ..DirectionalLight::default()
280        });
281        assert_eq!(d.intensity, 0.0);
282    }
283}