1use anyhow::{anyhow, Result};
2use loam_console::Console;
3use loam_render::sky_ground::{
4 DEFAULT_FOG_PER_UNIT, DEFAULT_SKY, GROUND_DARK_GREY, GROUND_LIGHT_GREY,
5};
6use loam_render::{Ground, Sky};
7
8const MAX_FOG_PER_UNIT: f32 = 1.0;
10
11#[derive(Copy, Clone, Debug, PartialEq)]
12pub struct Environment {
13 pub sky: Sky,
14 pub dark: [f32; 3],
15 pub light: [f32; 3],
16 pub fog_per_unit: f32,
17 pub fog_start: f32,
18 pub floor_visible: bool,
19}
20
21impl Default for Environment {
22 fn default() -> Self {
23 Self {
24 sky: DEFAULT_SKY,
25 dark: GROUND_DARK_GREY,
26 light: GROUND_LIGHT_GREY,
27 fog_per_unit: DEFAULT_FOG_PER_UNIT,
28 fog_start: 0.0,
29 floor_visible: true,
30 }
31 }
32}
33
34impl Environment {
35 pub fn ground(&self, y: f32, visible: bool) -> Ground {
36 Ground {
37 y,
38 dark: self.dark,
39 light: self.light,
40 fog_per_unit: self.fog_per_unit,
41 fog_start: self.fog_start,
42 visible,
43 }
44 }
45
46 pub fn report(&self, field: Option<&str>) -> String {
47 match field {
48 Some("dark") => format!("ground dark: {}", rgb_text(self.dark)),
49 Some("light") => format!("ground light: {}", rgb_text(self.light)),
50 Some("fog") => format!(
51 "ground fog: {:.4} per unit (half sky at {:.1} units)",
52 self.fog_per_unit,
53 half_blend_distance(self.fog_per_unit)
54 ),
55 _ => format!(
56 "ground dark {} light {} fog {:.4} (half sky at {:.1} units)",
57 rgb_text(self.dark),
58 rgb_text(self.light),
59 self.fog_per_unit,
60 half_blend_distance(self.fog_per_unit)
61 ),
62 }
63 }
64
65 pub fn apply(&mut self, args: &[&str]) -> Result<String> {
66 let Some(field) = args.first().copied() else {
67 return Ok(self.report(None));
68 };
69 match field {
70 "reset" => {
71 *self = Self {
72 sky: self.sky,
73 ..Self::default()
74 };
75 Ok(format!("ground: reset ({})", self.report(None)))
76 }
77 "dark" | "light" => {
78 if args.len() == 1 {
79 return Ok(self.report(Some(field)));
80 }
81 let rgb = parse_rgb(field, &args[1..])?;
82 if field == "dark" {
83 self.dark = rgb;
84 } else {
85 self.light = rgb;
86 }
87 Ok(format!("ground {field}: set to {}", rgb_text(rgb)))
88 }
89 "fog" => {
90 if args.len() == 1 {
91 return Ok(self.report(Some(field)));
92 }
93 self.fog_per_unit = parse_fog(args[1])?;
94 Ok(format!("ground fog: set to {}", self.report(Some("fog"))))
95 }
96 other => Err(anyhow!(
97 "ground: unknown field `{other}` (try dark|light|fog|reset)"
98 )),
99 }
100 }
101}
102
103pub fn half_blend_distance(fog_per_unit: f32) -> f32 {
104 if fog_per_unit <= 0.0 {
105 return f32::INFINITY;
106 }
107 std::f32::consts::LN_2 / fog_per_unit
108}
109
110fn rgb_text(rgb: [f32; 3]) -> String {
111 format!("{:.3} {:.3} {:.3}", rgb[0], rgb[1], rgb[2])
112}
113
114fn parse_rgb(field: &str, args: &[&str]) -> Result<[f32; 3]> {
115 let [r, g, b] = args else {
116 return Err(anyhow!(
117 "usage: ground {field} <r> <g> <b>, each a float in [0, 1]"
118 ));
119 };
120 let mut rgb = [0.0_f32; 3];
121 for (slot, token) in rgb.iter_mut().zip([r, g, b]) {
122 let value: f32 = token
123 .parse()
124 .map_err(|e| anyhow!("ground {field}: invalid channel `{token}`: {e}"))?;
125 if !(value.is_finite() && (0.0..=1.0).contains(&value)) {
126 return Err(anyhow!(
127 "ground {field}: channel {value} out of range; expected [0, 1]"
128 ));
129 }
130 *slot = value;
131 }
132 Ok(rgb)
133}
134
135fn parse_fog(token: &str) -> Result<f32> {
136 let value: f32 = token
137 .parse()
138 .map_err(|e| anyhow!("ground fog: invalid density `{token}`: {e}"))?;
139 if !(value.is_finite() && (0.0..=MAX_FOG_PER_UNIT).contains(&value)) {
140 return Err(anyhow!(
141 "ground fog: density {value} out of range; expected [0, {MAX_FOG_PER_UNIT}]"
142 ));
143 }
144 Ok(value)
145}
146
147pub fn register_ground_command<Ctx: 'static>(
148 console: &mut Console<Ctx>,
149 reach: fn(&mut Ctx) -> &mut Environment,
150) {
151 console.register(
152 loam_console::cmd::<Ctx, _>(
153 "ground",
154 "background checker colors and fog density (bare reads all three)",
155 move |args, ctx, out| {
156 let line = reach(ctx).apply(args)?;
157 out.line(line);
158 Ok(())
159 },
160 )
161 .with_args(&[&["dark", "light", "fog", "reset"]])
162 .with_long_help(
163 "ground read all three\n\
164 ground dark read the dark checker color\n\
165 ground dark <r> <g> <b> set it, each channel in [0, 1]\n\
166 ground light <r> <g> <b> the light checker color\n\
167 ground fog read the density\n\
168 ground fog <density> sky blended per world unit, in [0, 1];\n\
169 \x20 half the sky is mixed in at ln2/density\n\
170 ground reset back to the shipped defaults",
171 ),
172 );
173}
174
175pub fn register_floor_command<Ctx: 'static>(
176 console: &mut Console<Ctx>,
177 reach: fn(&mut Ctx) -> &mut Environment,
178) {
179 console.register(
180 loam_console::cmd::<Ctx, _>(
181 "floor",
182 "toggle the ground plane (on | off; bare flips)",
183 move |args, ctx, out| {
184 let env = reach(ctx);
185 let next = match args.first().copied() {
186 None => !env.floor_visible,
187 Some("on") => true,
188 Some("off") => false,
189 Some(other) => {
190 return Err(anyhow!("floor: unknown arg `{other}` (try on|off)"));
191 }
192 };
193 env.floor_visible = next;
194 out.line(format!("floor: {}", if next { "on" } else { "off" }));
195 Ok(())
196 },
197 )
198 .with_args(&[&["on", "off"]]),
199 );
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn the_parse_rejects_out_of_range_and_malformed_arguments() {
208 let mut env = Environment::default();
209 for line in [
210 vec!["fog", "-0.1"],
211 vec!["fog", "2.0"],
212 vec!["fog", "nan"],
213 vec!["fog", "wide"],
214 vec!["dark", "0.5"],
215 vec!["dark", "0.5", "0.5"],
216 vec!["dark", "0.5", "0.5", "0.5", "0.5"],
217 vec!["dark", "0.5", "0.5", "1.5"],
218 vec!["dark", "0.5", "0.5", "red"],
219 vec!["sky"],
220 ] {
221 assert!(
222 env.apply(&line).is_err(),
223 "`ground {}` was accepted",
224 line.join(" ")
225 );
226 }
227 assert_eq!(env, Environment::default(), "a rejected line still wrote");
228 }
229
230 #[test]
231 fn ground_reset_keeps_a_page_set_sky() {
232 let sky = Sky {
233 below: [0.9, 0.8, 0.7],
234 above: [0.6, 0.5, 0.4],
235 };
236 let mut env = Environment {
237 sky,
238 ..Environment::default()
239 };
240 env.apply(&["fog", "0.5"]).expect("set");
241 env.apply(&["reset"]).expect("reset");
242 assert_eq!(env.fog_per_unit, DEFAULT_FOG_PER_UNIT);
243 assert_eq!(env.sky, sky, "the ground verb wiped the sky");
244 }
245
246 #[test]
247 fn the_environment_reaches_the_uniform_the_shader_reads() {
248 let mut env = Environment::default();
249 env.apply(&["fog", "0.07"]).expect("set");
250 env.apply(&["dark", "0.1", "0.2", "0.3"]).expect("set");
251 env.sky = Sky {
252 below: [0.4, 0.5, 0.6],
253 above: [0.7, 0.8, 0.9],
254 };
255 let ground = env.ground(-1.5, false);
256 assert_eq!(ground.fog_per_unit, 0.07);
257 assert_eq!(ground.dark, [0.1, 0.2, 0.3]);
258 assert_eq!(ground.y, -1.5);
259 assert!(!ground.visible);
260
261 let uniforms = loam_render::SkyGroundUniforms::new(
262 glam::Mat4::IDENTITY,
263 loam_render::Viewport::full([4, 4]),
264 env.sky,
265 ground,
266 );
267 assert_eq!(uniforms.fog_per_unit, 0.07);
268 assert_eq!(uniforms.ground_dark, [0.1, 0.2, 0.3]);
269 assert_eq!(uniforms.show_ground, 0.0);
270 assert_eq!(uniforms.sky_below, [0.4, 0.5, 0.6]);
271 assert_eq!(uniforms.sky_above, [0.7, 0.8, 0.9]);
272 }
273
274 #[test]
275 fn the_verb_registers_on_a_console_and_dispatches_to_the_scene_state() {
276 let mut console = Console::<Environment>::new();
277 register_ground_command(&mut console, |env| env);
278 assert!(console.has_command("ground"));
279
280 let mut env = Environment::default();
281 console.dispatch("ground", &["fog", "0.03"], &mut env);
282 assert_eq!(env.fog_per_unit, 0.03, "the verb never reached the state");
283 console.dispatch("ground", &["fog"], &mut env);
284 assert_eq!(env.fog_per_unit, 0.03, "the bare read wrote");
285 }
286}