Skip to main content

pfx_play/
tunables.rs

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}