Files
RaymarchingEditor/src/scene/model.rs
T
Efim Beshmenev 0c36122baa Initial commit
2026-07-19 00:19:41 +03:00

364 lines
11 KiB
Rust

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::<LightKind>(&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());
}
}