1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use pfx_core::rig::{Bob, Bounds, Follow, FollowTuning, Person, PersonTuning, Rig, Third};
5use pfx_load::scene::project_root;
6use serde::Deserialize;
7
8pub const FILE: &str = "rigs.toml";
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Kind {
12 Follow2d,
13 Follow3d,
14 FirstPerson,
15 ThirdPerson,
16}
17
18impl Kind {
19 pub fn word(self) -> &'static str {
20 match self {
21 Kind::Follow2d => "follow2d",
22 Kind::Follow3d => "follow3d",
23 Kind::FirstPerson => "first_person",
24 Kind::ThirdPerson => "third_person",
25 }
26 }
27
28 fn of(word: &str) -> Option<Kind> {
29 Some(match word {
30 "follow2d" => Kind::Follow2d,
31 "follow3d" => Kind::Follow3d,
32 "first_person" => Kind::FirstPerson,
33 "third_person" => Kind::ThirdPerson,
34 _ => return None,
35 })
36 }
37
38 fn follows(self) -> bool {
39 matches!(self, Kind::Follow2d | Kind::Follow3d)
40 }
41}
42
43#[derive(Clone, Debug, PartialEq)]
44pub struct RigDesc {
45 pub name: String,
46 pub kind: Kind,
47 pub target: Option<String>,
48 pub look: String,
49 pub mouse: bool,
50 pub blend: f32,
51 pub collide: u32,
52 pub height: Option<f32>,
53 pub fit_view: bool,
54 pub rig: Rig,
55}
56
57#[derive(Clone, Debug, Default, PartialEq)]
58pub struct Rigs {
59 file: Option<PathBuf>,
60 list: BTreeMap<String, RigDesc>,
61}
62
63#[derive(Deserialize)]
64#[serde(untagged)]
65enum Numbers {
66 One(f32),
67 Many(Vec<f32>),
68}
69
70#[derive(Deserialize)]
71#[serde(deny_unknown_fields)]
72struct BobEntry {
73 stride: Option<f32>,
74 vertical: Option<f32>,
75 sway: Option<f32>,
76 full_speed: Option<f32>,
77 ease: Option<f32>,
78}
79
80#[derive(Deserialize)]
81#[serde(deny_unknown_fields)]
82struct ThirdEntry {
83 distance: Option<f32>,
84 radius: Option<f32>,
85 margin: Option<f32>,
86 min_distance: Option<f32>,
87 recover: Option<f32>,
88 shoulder: Option<Numbers>,
89}
90
91#[derive(Deserialize)]
92#[serde(deny_unknown_fields)]
93struct BoundsEntry {
94 min: Numbers,
95 max: Numbers,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct Entry {
101 name: String,
102 kind: String,
103 target: Option<String>,
104 look: Option<String>,
105 mouse: Option<bool>,
106 blend: Option<f32>,
107 collide: Option<u32>,
108 offset: Option<Numbers>,
109 arm: Option<Numbers>,
110 dead_zone: Option<Numbers>,
111 look_ahead: Option<f32>,
112 look_ahead_speed: Option<f32>,
113 look_ahead_damping: Option<f32>,
114 damping: Option<Numbers>,
115 bounds: Option<BoundsEntry>,
116 handover: Option<f32>,
117 pixels_per_unit: Option<f32>,
118 height: Option<f32>,
119 fit_view: Option<bool>,
120 eye: Option<Numbers>,
121 yaw: Option<f32>,
122 pitch: Option<f32>,
123 mouse_sensitivity: Option<f32>,
124 stick_sensitivity: Option<f32>,
125 invert_x: Option<bool>,
126 invert_y: Option<bool>,
127 smoothing: Option<f32>,
128 pitch_min: Option<f32>,
129 pitch_max: Option<f32>,
130 bob: Option<BobEntry>,
131 third: Option<ThirdEntry>,
132 view: Option<String>,
133}
134
135#[derive(Deserialize)]
136#[serde(deny_unknown_fields)]
137struct File {
138 #[serde(default)]
139 rig: Vec<Entry>,
140}
141
142fn vec3(value: &Numbers, fill: f32) -> Result<[f32; 3], String> {
143 let parts = match value {
144 Numbers::One(value) => vec![*value; 3],
145 Numbers::Many(parts) => parts.clone(),
146 };
147 let out = match parts.as_slice() {
148 [x, y] => [*x, *y, fill],
149 [x, y, z] => [*x, *y, *z],
150 _ => return Err("needs 2 or 3 numbers".into()),
151 };
152 if out.iter().any(|part| !part.is_finite()) {
153 return Err("needs finite numbers".into());
154 }
155 Ok(out)
156}
157
158fn pair(value: &Numbers) -> Result<[f32; 2], String> {
159 match value {
160 Numbers::Many(parts) if parts.len() == 2 && parts.iter().all(|part| part.is_finite()) => {
161 Ok([parts[0], parts[1]])
162 }
163 _ => Err("needs 2 numbers".into()),
164 }
165}
166
167fn number(value: f32, least: f32) -> Result<f32, String> {
168 if value.is_finite() && value >= least {
169 Ok(value)
170 } else {
171 Err(format!("needs a number of {least} or more"))
172 }
173}
174
175fn above(value: f32) -> Result<f32, String> {
176 if value.is_finite() && value > 0.0 {
177 Ok(value)
178 } else {
179 Err("needs a number above 0".into())
180 }
181}
182
183fn field<T>(key: &str, result: Result<T, String>) -> Result<T, String> {
184 result.map_err(|message| format!("{key}: {message}"))
185}
186
187fn follows_only(entry: &Entry) -> Option<&'static str> {
188 [
189 ("dead_zone", entry.dead_zone.is_some()),
190 ("look_ahead", entry.look_ahead.is_some()),
191 ("look_ahead_speed", entry.look_ahead_speed.is_some()),
192 ("look_ahead_damping", entry.look_ahead_damping.is_some()),
193 ("damping", entry.damping.is_some()),
194 ("bounds", entry.bounds.is_some()),
195 ("arm", entry.arm.is_some()),
196 ("pixels_per_unit", entry.pixels_per_unit.is_some()),
197 ("height", entry.height.is_some()),
198 ("fit_view", entry.fit_view.is_some()),
199 ]
200 .into_iter()
201 .find_map(|(key, given)| given.then_some(key))
202}
203
204fn people_only(entry: &Entry) -> Option<&'static str> {
205 [
206 ("eye", entry.eye.is_some()),
207 ("yaw", entry.yaw.is_some()),
208 ("pitch", entry.pitch.is_some()),
209 ("mouse_sensitivity", entry.mouse_sensitivity.is_some()),
210 ("stick_sensitivity", entry.stick_sensitivity.is_some()),
211 ("invert_x", entry.invert_x.is_some()),
212 ("invert_y", entry.invert_y.is_some()),
213 ("smoothing", entry.smoothing.is_some()),
214 ("pitch_min", entry.pitch_min.is_some()),
215 ("pitch_max", entry.pitch_max.is_some()),
216 ("bob", entry.bob.is_some()),
217 ("third", entry.third.is_some()),
218 ("view", entry.view.is_some()),
219 ]
220 .into_iter()
221 .find_map(|(key, given)| given.then_some(key))
222}
223
224fn build(entry: Entry) -> Result<RigDesc, String> {
225 let kind = Kind::of(&entry.kind).ok_or_else(|| {
226 format!(
227 "kind: {:?} is not follow2d, follow3d, first_person or third_person",
228 entry.kind
229 )
230 })?;
231 if kind.follows() {
232 if let Some(key) = people_only(&entry) {
233 return Err(format!("{key}: a {} rig has no {key}", kind.word()));
234 }
235 } else if let Some(key) = follows_only(&entry) {
236 return Err(format!("{key}: a {} rig has no {key}", kind.word()));
237 }
238 let plane = kind == Kind::Follow2d;
239 if !plane
240 && let Some(key) = [
241 ("height", entry.height.is_some()),
242 ("pixels_per_unit", entry.pixels_per_unit.is_some()),
243 ("fit_view", entry.fit_view.is_some()),
244 ]
245 .into_iter()
246 .find_map(|(key, given)| given.then_some(key))
247 {
248 return Err(format!("{key}: only a follow2d rig has {key}"));
249 }
250 let blend = field("blend", number(entry.blend.unwrap_or(0.4), 0.0))?;
251 let look = entry.look.clone().unwrap_or_else(|| "look".into());
252 let mouse = entry.mouse.unwrap_or(!kind.follows());
253 let collide = entry.collide.unwrap_or(u32::MAX);
254 let offset = |value: &Option<Numbers>, key: &str, default: [f32; 3]| match value {
255 Some(value) => field(key, vec3(value, 0.0)),
256 None => Ok(default),
257 };
258 let rig = if kind.follows() {
259 let mut tuning = if plane {
260 FollowTuning::follow2()
261 } else {
262 FollowTuning::follow3()
263 };
264 tuning.offset = offset(&entry.offset, "offset", tuning.offset)?;
265 tuning.arm = offset(&entry.arm, "arm", tuning.arm)?;
266 let zone = offset(&entry.dead_zone, "dead_zone", tuning.dead_zone)?;
267 for part in zone {
268 field("dead_zone", number(part, 0.0))?;
269 }
270 tuning.dead_zone = zone;
271 let damping = offset(&entry.damping, "damping", tuning.damping)?;
272 for part in damping {
273 field("damping", number(part, 0.0))?;
274 }
275 tuning.damping = damping;
276 if let Some(value) = entry.look_ahead {
277 tuning.look_ahead = field("look_ahead", number(value, 0.0))?;
278 }
279 if let Some(value) = entry.look_ahead_speed {
280 tuning.look_ahead_speed = field("look_ahead_speed", above(value))?;
281 }
282 if let Some(value) = entry.look_ahead_damping {
283 tuning.look_ahead_damping = field("look_ahead_damping", number(value, 0.0))?;
284 }
285 if let Some(value) = entry.handover {
286 tuning.handover = field("handover", number(value, 0.0))?;
287 }
288 if let Some(value) = entry.pixels_per_unit {
289 tuning.pixels_per_unit = Some(field("pixels_per_unit", above(value))?);
290 }
291 if let Some(bounds) = &entry.bounds {
292 let min = field("bounds.min", vec3(&bounds.min, 0.0))?;
293 let max = field("bounds.max", vec3(&bounds.max, 0.0))?;
294 if (0..3).any(|axis| min[axis] > max[axis]) {
295 return Err("bounds: min is above max".into());
296 }
297 tuning.bounds = Some(Bounds::new(min, max));
298 }
299 Rig::Follow(Follow::new(tuning))
300 } else {
301 let mut tuning = PersonTuning {
302 eye: offset(&entry.eye, "eye", PersonTuning::default().eye)?,
303 ..PersonTuning::default()
304 };
305 if let Some(value) = entry.yaw {
306 tuning.yaw = field("yaw", number(value, f32::MIN))?;
307 }
308 if let Some(value) = entry.pitch {
309 tuning.pitch = field("pitch", number(value, f32::MIN))?;
310 }
311 if let Some(value) = entry.mouse_sensitivity {
312 tuning.mouse = field("mouse_sensitivity", above(value))?;
313 }
314 if let Some(value) = entry.stick_sensitivity {
315 tuning.stick = field("stick_sensitivity", above(value))?;
316 }
317 tuning.invert_x = entry.invert_x.unwrap_or(false);
318 tuning.invert_y = entry.invert_y.unwrap_or(false);
319 if let Some(value) = entry.smoothing {
320 tuning.smoothing = field("smoothing", number(value, 0.0))?;
321 }
322 let low = entry.pitch_min.unwrap_or(tuning.pitch_range[0]);
323 let high = entry.pitch_max.unwrap_or(tuning.pitch_range[1]);
324 if !(low.is_finite() && high.is_finite() && low < high) {
325 return Err("pitch_min: needs numbers with pitch_min below pitch_max".into());
326 }
327 tuning.pitch_range = [low, high];
328 if let Some(value) = entry.handover {
329 tuning.handover = field("handover", number(value, 0.0))?;
330 }
331 if let Some(bob) = &entry.bob {
332 let mut set = Bob::default();
333 if let Some(value) = bob.stride {
334 set.stride = field("bob.stride", above(value))?;
335 }
336 if let Some(value) = bob.vertical {
337 set.vertical = field("bob.vertical", number(value, 0.0))?;
338 }
339 if let Some(value) = bob.sway {
340 set.sway = field("bob.sway", number(value, 0.0))?;
341 }
342 if let Some(value) = bob.full_speed {
343 set.full_speed = field("bob.full_speed", above(value))?;
344 }
345 if let Some(value) = bob.ease {
346 set.ease = field("bob.ease", number(value, 0.0))?;
347 }
348 tuning.bob = Some(set);
349 }
350 if let Some(third) = &entry.third {
351 if kind == Kind::FirstPerson {
352 return Err("third: a first_person rig has no third".into());
353 }
354 tuning.third = Some(third_of(third)?);
355 } else if kind == Kind::ThirdPerson {
356 tuning.third = Some(Third::default());
357 }
358 let mut person = Person::new(tuning);
359 let third = match entry.view.as_deref() {
360 Some("first") => false,
361 Some("third") => true,
362 None => kind == Kind::ThirdPerson,
363 Some(other) => return Err(format!("view: {other:?} is not first or third")),
364 };
365 person.set_third(third);
366 Rig::Person(person)
367 };
368 let height = match entry.height {
369 Some(value) => Some(field("height", above(value))?),
370 None => plane.then_some(10.0),
371 };
372 Ok(RigDesc {
373 name: entry.name,
374 kind,
375 target: entry.target,
376 look,
377 mouse,
378 blend,
379 collide,
380 height,
381 fit_view: entry.fit_view.unwrap_or(false),
382 rig,
383 })
384}
385
386fn third_of(entry: &ThirdEntry) -> Result<Third, String> {
387 let mut third = Third::default();
388 if let Some(value) = entry.distance {
389 third.distance = field("third.distance", above(value))?;
390 }
391 if let Some(value) = entry.radius {
392 third.radius = field("third.radius", number(value, 0.0))?;
393 }
394 if let Some(value) = entry.margin {
395 third.margin = field("third.margin", number(value, 0.0))?;
396 }
397 if let Some(value) = entry.min_distance {
398 third.min_distance = field("third.min_distance", number(value, 0.0))?;
399 }
400 if let Some(value) = entry.recover {
401 third.recover = field("third.recover", number(value, 0.0))?;
402 }
403 if let Some(value) = &entry.shoulder {
404 third.shoulder = field("third.shoulder", pair(value))?;
405 }
406 Ok(third)
407}
408
409impl Rigs {
410 pub fn parse(text: &str, file: &Path) -> Result<Rigs, String> {
411 let shown = file.display();
412 let parsed: File = toml::from_str(text).map_err(|error| format!("{shown}: {error}"))?;
413 let mut list = BTreeMap::new();
414 for entry in parsed.rig {
415 let name = entry.name.clone();
416 if name.is_empty() {
417 return Err(format!("{shown}: [[rig]] needs a name"));
418 }
419 let desc =
420 build(entry).map_err(|message| format!("{shown}: [[rig]] {name}: {message}"))?;
421 if list.insert(name.clone(), desc).is_some() {
422 return Err(format!("{shown}: [[rig]] {name}: two rigs share the name"));
423 }
424 }
425 Ok(Rigs {
426 file: Some(file.to_path_buf()),
427 list,
428 })
429 }
430
431 pub fn open(file: &Path) -> Result<Rigs, String> {
432 let text = std::fs::read_to_string(file)
433 .map_err(|error| format!("{}: {error}", file.display()))?;
434 Self::parse(&text, file)
435 }
436
437 pub fn find(root: &Path) -> Option<PathBuf> {
438 let named = std::fs::read_to_string(root.join("project.toml"))
439 .ok()
440 .and_then(|text| toml::from_str::<toml::Table>(&text).ok())
441 .and_then(|table| table.get("rigs")?.as_str().map(PathBuf::from));
442 let file = root.join(named.unwrap_or_else(|| PathBuf::from(FILE)));
443 Some(file).filter(|file| file.is_file())
444 }
445
446 pub fn of_scene(scene: &Path) -> Result<Rigs, String> {
447 match Self::find(&project_root(scene)) {
448 Some(file) => Self::open(&file),
449 None => Ok(Rigs::default()),
450 }
451 }
452
453 pub fn file(&self) -> Option<&Path> {
454 self.file.as_deref()
455 }
456
457 pub fn get(&self, name: &str) -> Option<&RigDesc> {
458 self.list.get(name)
459 }
460
461 pub fn len(&self) -> usize {
462 self.list.len()
463 }
464
465 pub fn is_empty(&self) -> bool {
466 self.list.is_empty()
467 }
468
469 pub(crate) fn into_map(self) -> BTreeMap<String, RigDesc> {
470 self.list
471 }
472
473 pub fn iter(&self) -> impl Iterator<Item = &RigDesc> {
474 self.list.values()
475 }
476
477 pub fn names(&self) -> impl Iterator<Item = &str> {
478 self.list.keys().map(String::as_str)
479 }
480}