use glam::{Quat, Vec2, Vec3}; use serde::{Deserialize, Serialize}; use super::{BoundingSphere, Transform}; /// IDs share one namespace so an editor can never confuse a primitive and a /// light after save/load or duplication. pub type EntityId = u32; pub type PrimitiveId = EntityId; pub type LightId = EntityId; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct Material { pub albedo: Vec3, pub roughness: f32, pub metallic: f32, pub emissive_color: Vec3, pub emission_strength: f32, } impl Default for Material { fn default() -> Self { Self { albedo: Vec3::splat(0.7), roughness: 0.6, metallic: 0.0, emissive_color: Vec3::ONE, emission_strength: 0.0, } } } impl Material { pub(crate) fn validate(&self) -> Result<(), String> { if !self.albedo.is_finite() || self.albedo.min_element() < 0.0 { return Err("material albedo must be finite and non-negative".into()); } if !self.emissive_color.is_finite() || self.emissive_color.min_element() < 0.0 { return Err("material emissive_color must be finite and non-negative".into()); } if !self.roughness.is_finite() || !(0.0..=1.0).contains(&self.roughness) { return Err("material roughness must be in 0..=1".into()); } if !self.metallic.is_finite() || !(0.0..=1.0).contains(&self.metallic) { return Err("material metallic must be in 0..=1".into()); } if !self.emission_strength.is_finite() || self.emission_strength < 0.0 { return Err("material emission_strength must be finite and non-negative".into()); } Ok(()) } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LightKind { DirectionalSun, Point { range: f32, }, Spot { range: f32, inner_angle_radians: f32, outer_angle_radians: f32, }, } impl LightKind { pub fn point_default() -> Self { Self::Point { range: 25.0 } } pub fn spot_default() -> Self { Self::Spot { range: 25.0, inner_angle_radians: 20.0_f32.to_radians(), outer_angle_radians: 30.0_f32.to_radians(), } } pub fn display_name(&self) -> &'static str { match self { Self::DirectionalSun => "Солнце", Self::Point { .. } => "Точечный свет", Self::Spot { .. } => "Прожектор", } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct SceneLight { pub id: LightId, pub name: String, pub transform: Transform, pub kind: LightKind, pub color: Vec3, pub intensity: f32, pub enabled: bool, } impl Default for SceneLight { fn default() -> Self { Self { id: 0, name: "Солнце".into(), transform: Transform::default(), kind: LightKind::DirectionalSun, color: Vec3::ONE, intensity: 4.0, enabled: true, } } } impl SceneLight { /// Directional and spot lights point down their local -Z axis. A point /// light has no direction, but returning the same stable vector keeps /// editor helpers and the fixed GPU ABI uniform across all light kinds. pub fn direction(&self) -> Vec3 { let rotation = if self.transform.rotation.is_finite() && self.transform.rotation.length_squared() > Transform::MIN_SCALE { self.transform.rotation.normalize() } else { Quat::IDENTITY }; rotation * -Vec3::Z } pub fn position(&self) -> Vec3 { self.transform.translation } pub fn selection_sphere(&self, icon_radius: f32) -> BoundingSphere { BoundingSphere { center: self.position(), radius: icon_radius.abs().max(0.01), } } pub(crate) fn validate(&self) -> Result<(), String> { if !self.transform.is_finite() { return Err("light transform contains invalid values".into()); } if !self.color.is_finite() || self.color.min_element() < 0.0 { return Err("light color must be finite and non-negative".into()); } if !self.intensity.is_finite() || self.intensity < 0.0 { return Err("light intensity must be finite and non-negative".into()); } match self.kind { LightKind::DirectionalSun => {} LightKind::Point { range } => { if !range.is_finite() || range <= 0.0 { return Err("point range must be finite and greater than zero".into()); } } LightKind::Spot { range, inner_angle_radians, outer_angle_radians, } => { if !range.is_finite() || range <= 0.0 { return Err("spot range must be finite and greater than zero".into()); } if !inner_angle_radians.is_finite() || !outer_angle_radians.is_finite() || inner_angle_radians < 0.0 || outer_angle_radians <= 0.0 || inner_angle_radians > outer_angle_radians || outer_angle_radians >= core::f32::consts::FRAC_PI_2 { return Err("spot cone angles must satisfy 0 <= inner <= outer < pi/2".into()); } } } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct LightingSettings { pub ambient_color: Vec3, pub ambient_intensity: f32, pub shadow_softness: f32, pub max_shadow_distance: f32, } impl Default for LightingSettings { fn default() -> Self { Self { ambient_color: Vec3::new(0.35, 0.42, 0.55), ambient_intensity: 0.12, shadow_softness: 12.0, max_shadow_distance: 200.0, } } } impl LightingSettings { pub(crate) fn validate(&self) -> Result<(), String> { if !self.ambient_color.is_finite() || self.ambient_color.min_element() < 0.0 { return Err("ambient color must be finite and non-negative".into()); } for (name, value) in [ ("ambient_intensity", self.ambient_intensity), ("shadow_softness", self.shadow_softness), ("max_shadow_distance", self.max_shadow_distance), ] { if !value.is_finite() || value < 0.0 { return Err(format!("{name} must be finite and non-negative")); } } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct CloudSettings { pub enabled: bool, pub base_height: f32, pub thickness: f32, pub coverage: f32, pub density: f32, pub scale: f32, pub wind_direction: Vec2, pub wind_speed: f32, pub color: Vec3, pub absorption: f32, } impl Default for CloudSettings { fn default() -> Self { Self { enabled: true, base_height: 80.0, thickness: 35.0, coverage: 0.45, density: 0.7, scale: 0.012, wind_direction: Vec2::new(1.0, 0.2).normalize(), wind_speed: 1.5, color: Vec3::ONE, absorption: 0.6, } } } impl CloudSettings { pub(crate) fn validate(&self) -> Result<(), String> { for (name, value) in [ ("base_height", self.base_height), ("thickness", self.thickness), ("density", self.density), ("scale", self.scale), ("wind_speed", self.wind_speed), ("absorption", self.absorption), ] { if !value.is_finite() || value < 0.0 { return Err(format!("cloud {name} must be finite and non-negative")); } } if !self.coverage.is_finite() || !(0.0..=1.0).contains(&self.coverage) { return Err("cloud coverage must be in 0..=1".into()); } if !self.wind_direction.is_finite() || self.wind_direction.length_squared() < 1.0e-6 { return Err("cloud wind_direction must be finite and non-zero".into()); } if !self.color.is_finite() || self.color.min_element() < 0.0 { return Err("cloud color must be finite and non-negative".into()); } Ok(()) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct EditorCamera { pub position: Vec3, pub yaw_radians: f32, pub pitch_radians: f32, pub movement_speed: f32, pub orbit_pivot: Vec3, pub field_of_view_y_radians: f32, pub near_plane: f32, pub far_plane: f32, } impl Default for EditorCamera { fn default() -> Self { Self { position: Vec3::new(8.0, -8.0, 6.0), yaw_radians: -45.0_f32.to_radians(), pitch_radians: -27.938_353_f32.to_radians(), movement_speed: 8.0, orbit_pivot: Vec3::ZERO, field_of_view_y_radians: 60.0_f32.to_radians(), near_plane: 0.01, far_plane: 2_000.0, } } } impl EditorCamera { pub(crate) fn validate(&self) -> Result<(), String> { if !self.position.is_finite() || !self.orbit_pivot.is_finite() { return Err("editor camera vectors must be finite".into()); } for (name, value) in [ ("yaw_radians", self.yaw_radians), ("pitch_radians", self.pitch_radians), ("movement_speed", self.movement_speed), ("field_of_view_y_radians", self.field_of_view_y_radians), ("near_plane", self.near_plane), ("far_plane", self.far_plane), ] { if !value.is_finite() { return Err(format!("editor camera {name} must be finite")); } } if self.movement_speed <= 0.0 || self.field_of_view_y_radians <= 0.0 || self.field_of_view_y_radians >= core::f32::consts::PI || self.near_plane <= 0.0 || self.far_plane <= self.near_plane { return Err("editor camera projection or movement settings are invalid".into()); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn point_light_has_stable_serde_shape_and_round_trips() { let kind = LightKind::Point { range: 17.5 }; let json = serde_json::to_string(&kind).unwrap(); assert_eq!(json, r#"{"type":"point","range":17.5}"#); assert_eq!(serde_json::from_str::(&json).unwrap(), kind); } #[test] fn point_light_range_must_be_positive_and_finite() { let mut light = SceneLight { kind: LightKind::Point { range: 0.0 }, ..SceneLight::default() }; assert!(light.validate().is_err()); light.kind = LightKind::Point { range: f32::NAN }; assert!(light.validate().is_err()); light.kind = LightKind::point_default(); assert!(light.validate().is_ok()); } }