1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use pfx_load::scene::{DeclaredKind, DeclaredValue, ProjectFile, project_root};
5
6use crate::math::shortest;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub enum Kind {
10 Float,
11 Int,
12 Bool,
13 Vector,
14}
15
16impl Kind {
17 pub fn word(self) -> &'static str {
18 match self {
19 Kind::Float => "float",
20 Kind::Int => "int",
21 Kind::Bool => "bool",
22 Kind::Vector => "vector",
23 }
24 }
25}
26
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub enum TunableValue {
29 Float(f32),
30 Int(i64),
31 Bool(bool),
32 Vector([f32; 3]),
33}
34
35impl TunableValue {
36 pub fn kind(self) -> Kind {
37 match self {
38 TunableValue::Float(_) => Kind::Float,
39 TunableValue::Int(_) => Kind::Int,
40 TunableValue::Bool(_) => Kind::Bool,
41 TunableValue::Vector(_) => Kind::Vector,
42 }
43 }
44
45 pub fn as_kind(self, kind: Kind) -> Option<TunableValue> {
46 Some(match (self, kind) {
47 (value, kind) if value.kind() == kind => value,
48 (TunableValue::Int(value), Kind::Float) => TunableValue::Float(value as f32),
49 (TunableValue::Float(value), Kind::Int) if value.fract() == 0.0 => {
50 TunableValue::Int(value as i64)
51 }
52 _ => return None,
53 })
54 }
55
56 fn parts(self) -> Vec<f64> {
57 match self {
58 TunableValue::Float(value) => vec![f64::from(value)],
59 TunableValue::Int(value) => vec![value as f64],
60 TunableValue::Bool(_) => Vec::new(),
61 TunableValue::Vector(value) => value.iter().map(|part| f64::from(*part)).collect(),
62 }
63 }
64
65 fn written(self) -> toml_edit::Value {
66 match self {
67 TunableValue::Float(value) => toml_edit::Value::from(shortest(value)),
68 TunableValue::Int(value) => toml_edit::Value::from(value),
69 TunableValue::Bool(value) => toml_edit::Value::from(value),
70 TunableValue::Vector(value) => {
71 toml_edit::Value::Array(value.iter().map(|part| shortest(*part)).collect())
72 }
73 }
74 }
75}
76
77impl std::fmt::Display for TunableValue {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 TunableValue::Float(value) => write!(f, "{value}"),
81 TunableValue::Int(value) => write!(f, "{value}"),
82 TunableValue::Bool(value) => write!(f, "{value}"),
83 TunableValue::Vector([x, y, z]) => write!(f, "[{x}, {y}, {z}]"),
84 }
85 }
86}
87
88pub trait FromTunable: Sized {
89 fn from_tunable(value: TunableValue) -> Option<Self>;
90}
91
92impl FromTunable for TunableValue {
93 fn from_tunable(value: TunableValue) -> Option<Self> {
94 Some(value)
95 }
96}
97
98impl FromTunable for f32 {
99 fn from_tunable(value: TunableValue) -> Option<Self> {
100 match value {
101 TunableValue::Float(value) => Some(value),
102 TunableValue::Int(value) => Some(value as f32),
103 _ => None,
104 }
105 }
106}
107
108impl FromTunable for i64 {
109 fn from_tunable(value: TunableValue) -> Option<Self> {
110 match value {
111 TunableValue::Int(value) => Some(value),
112 _ => None,
113 }
114 }
115}
116
117impl FromTunable for i32 {
118 fn from_tunable(value: TunableValue) -> Option<Self> {
119 match value {
120 TunableValue::Int(value) => i32::try_from(value).ok(),
121 _ => None,
122 }
123 }
124}
125
126impl FromTunable for bool {
127 fn from_tunable(value: TunableValue) -> Option<Self> {
128 match value {
129 TunableValue::Bool(value) => Some(value),
130 _ => None,
131 }
132 }
133}
134
135impl FromTunable for [f32; 3] {
136 fn from_tunable(value: TunableValue) -> Option<Self> {
137 match value {
138 TunableValue::Vector(value) => Some(value),
139 _ => None,
140 }
141 }
142}
143
144#[derive(Clone, Debug, PartialEq)]
145pub struct Tunable {
146 pub name: String,
147 pub kind: Kind,
148 pub default: TunableValue,
149 pub min: Option<TunableValue>,
150 pub max: Option<TunableValue>,
151 pub group: String,
152}
153
154impl Tunable {
155 pub fn check(&self, value: TunableValue) -> Result<TunableValue, String> {
156 let value = value.as_kind(self.kind).ok_or_else(|| {
157 format!(
158 "tunable {}: {value} is not a {}",
159 self.name,
160 self.kind.word()
161 )
162 })?;
163 let parts = value.parts();
164 if parts.iter().any(|part| !part.is_finite()) {
165 return Err(format!("tunable {}: {value} is not finite", self.name));
166 }
167 if let Some(low) = self.min
168 && parts
169 .iter()
170 .zip(low.parts())
171 .any(|(part, limit)| *part < limit)
172 {
173 return Err(format!(
174 "tunable {}: {value} is below its min {low}",
175 self.name
176 ));
177 }
178 if let Some(high) = self.max
179 && parts
180 .iter()
181 .zip(high.parts())
182 .any(|(part, limit)| *part > limit)
183 {
184 return Err(format!(
185 "tunable {}: {value} is above its max {high}",
186 self.name
187 ));
188 }
189 Ok(value)
190 }
191
192 pub fn clamp(&self, value: TunableValue) -> Option<TunableValue> {
193 let value = value.as_kind(self.kind)?;
194 let low = self.min.map(TunableValue::parts);
195 let high = self.max.map(TunableValue::parts);
196 let fit = |at: usize, part: f64| {
197 let part = low
198 .as_ref()
199 .map_or(part, |low| part.max(low.get(at).copied().unwrap_or(part)));
200 high.as_ref()
201 .map_or(part, |high| part.min(high.get(at).copied().unwrap_or(part)))
202 };
203 Some(match value {
204 TunableValue::Float(value) => TunableValue::Float(fit(0, f64::from(value)) as f32),
205 TunableValue::Int(value) => TunableValue::Int(fit(0, value as f64) as i64),
206 TunableValue::Bool(value) => TunableValue::Bool(value),
207 TunableValue::Vector(value) => TunableValue::Vector(std::array::from_fn(|at| {
208 fit(at, f64::from(value[at])) as f32
209 })),
210 })
211 }
212}
213
214fn declared(value: DeclaredValue) -> TunableValue {
215 match value {
216 DeclaredValue::Float(value) => TunableValue::Float(value),
217 DeclaredValue::Int(value) => TunableValue::Int(value),
218 DeclaredValue::Bool(value) => TunableValue::Bool(value),
219 DeclaredValue::Vector(value) => TunableValue::Vector(value),
220 }
221}
222
223fn declared_kind(kind: DeclaredKind) -> Kind {
224 match kind {
225 DeclaredKind::Float => Kind::Float,
226 DeclaredKind::Int => Kind::Int,
227 DeclaredKind::Bool => Kind::Bool,
228 DeclaredKind::Vector => Kind::Vector,
229 }
230}
231
232#[derive(Clone, Debug, Default, PartialEq)]
233pub struct Tunables {
234 file: Option<PathBuf>,
235 list: BTreeMap<String, Tunable>,
236}
237
238fn valid_name(name: &str) -> bool {
239 !name.is_empty() && name.trim() == name && !name.chars().any(char::is_control)
240}
241
242impl Tunables {
243 pub fn parse(text: &str, file: &Path) -> Result<Tunables, String> {
244 let shown = file.display();
245 let parsed: ProjectFile =
246 toml::from_str(text).map_err(|error| format!("{shown}: {error}"))?;
247 let mut list = BTreeMap::new();
248 for (name, entry) in parsed.tunables {
249 let refuse = |message: String| format!("{shown}: [tunables.{name}] {message}");
250 if !valid_name(&name) {
251 return Err(refuse(
252 "needs printable characters without spaces at its ends".to_string(),
253 ));
254 }
255 let kind = declared_kind(entry.kind);
256 let read = |value: DeclaredValue, key: &str| {
257 declared(value).as_kind(kind).ok_or_else(|| {
258 refuse(format!(
259 "{key} {} is not a {}",
260 declared(value),
261 kind.word()
262 ))
263 })
264 };
265 let default = read(entry.default, "default")?;
266 if kind == Kind::Bool && (entry.min.is_some() || entry.max.is_some()) {
267 return Err(refuse("a bool takes no min or max".to_string()));
268 }
269 let min = entry.min.map(|value| read(value, "min")).transpose()?;
270 let max = entry.max.map(|value| read(value, "max")).transpose()?;
271 if let (Some(low), Some(high)) = (min, max)
272 && low.parts().iter().zip(high.parts()).any(|(a, b)| *a > b)
273 {
274 return Err(refuse(format!("min {low} is above max {high}")));
275 }
276 let tunable = Tunable {
277 name: name.clone(),
278 kind,
279 default,
280 min,
281 max,
282 group: entry.group.unwrap_or_default(),
283 };
284 tunable
285 .check(default)
286 .map_err(|message| format!("{shown}: default: {message}"))?;
287 list.insert(name, tunable);
288 }
289 Ok(Tunables {
290 file: Some(file.to_path_buf()),
291 list,
292 })
293 }
294
295 pub fn open(file: &Path) -> Result<Tunables, String> {
296 let text = std::fs::read_to_string(file)
297 .map_err(|error| format!("{}: {error}", file.display()))?;
298 Self::parse(&text, file)
299 }
300
301 pub fn find(root: &Path) -> Option<PathBuf> {
302 let project = root.join("project.toml");
303 std::fs::read_to_string(&project)
304 .ok()
305 .and_then(|text| toml::from_str::<toml::Table>(&text).ok())
306 .is_some_and(|table| table.contains_key("tunables"))
307 .then_some(project)
308 }
309
310 pub fn of_scene(scene: &Path) -> Result<Tunables, String> {
311 match Self::find(&project_root(scene)) {
312 Some(file) => Self::open(&file),
313 None => Ok(Tunables::default()),
314 }
315 }
316
317 pub fn file(&self) -> Option<&Path> {
318 self.file.as_deref()
319 }
320
321 pub fn get(&self, name: &str) -> Option<&Tunable> {
322 self.list.get(name)
323 }
324
325 pub fn len(&self) -> usize {
326 self.list.len()
327 }
328
329 pub fn is_empty(&self) -> bool {
330 self.list.is_empty()
331 }
332
333 pub fn iter(&self) -> impl Iterator<Item = &Tunable> {
334 self.list.values()
335 }
336
337 pub fn groups(&self) -> Vec<(&str, Vec<&Tunable>)> {
338 let mut groups: BTreeMap<&str, Vec<&Tunable>> = BTreeMap::new();
339 for tunable in self.list.values() {
340 groups.entry(&tunable.group).or_default().push(tunable);
341 }
342 groups.into_iter().collect()
343 }
344
345 pub fn check(&self, name: &str, value: TunableValue) -> Result<TunableValue, String> {
346 self.list
347 .get(name)
348 .ok_or_else(|| format!("no tunable {name}"))?
349 .check(value)
350 }
351
352 pub fn written(text: &str, name: &str, value: TunableValue) -> Result<String, String> {
353 let mut doc: toml_edit::DocumentMut = text.parse().map_err(|error| format!("{error}"))?;
354 let entry = doc
355 .get_mut("tunables")
356 .and_then(|tunables| tunables.get_mut(name))
357 .and_then(toml_edit::Item::as_table_like_mut)
358 .ok_or_else(|| format!("no [tunables.{name}] to write"))?;
359 match entry.get_mut("default") {
360 Some(toml_edit::Item::Value(old)) => {
361 let decor = old.decor().clone();
362 *old = value.written();
363 *old.decor_mut() = decor;
364 }
365 _ => {
366 entry.insert("default", toml_edit::Item::Value(value.written()));
367 }
368 }
369 Ok(doc.to_string())
370 }
371}