1use anyhow::{anyhow, Result};
2use loam::app::args::Args;
3use loam::render::raymarch::RaymarchShape;
4use loam::shape::polytope::Polytope4;
5
6#[derive(Copy, Clone, PartialEq, Eq, Debug)]
7pub(crate) enum Category {
8 RegularPolychoron,
9 SmoothSolid,
10}
11
12impl Category {
13 const ALL: [Category; 2] = [Category::RegularPolychoron, Category::SmoothSolid];
14
15 fn name(self) -> &'static str {
16 match self {
17 Category::RegularPolychoron => "Regular polychora",
18 Category::SmoothSolid => "Smooth solids",
19 }
20 }
21}
22
23#[derive(Copy, Clone, PartialEq, Debug)]
24pub(crate) struct ShapeEntry {
25 pub(crate) shape: RaymarchShape,
26 pub(crate) body_color: [f32; 3],
27 pub(crate) label: &'static str,
28 pub(crate) long_name: &'static str,
29 pub(crate) category: Category,
30}
31
32impl ShapeEntry {
33 pub(crate) fn collider_polytope(&self) -> Option<Polytope4> {
34 self.shape.polytope4()
35 }
36}
37
38pub(crate) const DEFAULT_ROW: &[ShapeEntry] = &[
39 ShapeEntry {
40 shape: RaymarchShape::Polytope(Polytope4::Cell24),
41 body_color: [0.95, 0.45, 0.85],
42 label: "24-cell",
43 long_name: "icositetrachoron",
44 category: Category::RegularPolychoron,
45 },
46 ShapeEntry {
47 shape: RaymarchShape::Polytope(Polytope4::Pentatope),
48 body_color: [0.95, 0.55, 0.30],
49 label: "5-cell",
50 long_name: "pentachoron",
51 category: Category::RegularPolychoron,
52 },
53 ShapeEntry {
54 shape: RaymarchShape::Polytope(Polytope4::Cell16),
55 body_color: [0.55, 0.95, 0.40],
56 label: "16-cell",
57 long_name: "hexadecachoron",
58 category: Category::RegularPolychoron,
59 },
60 ShapeEntry {
61 shape: RaymarchShape::Polytope(Polytope4::Tesseract),
62 body_color: [0.30, 0.55, 0.95],
63 label: "8-cell",
64 long_name: "tesseract",
65 category: Category::RegularPolychoron,
66 },
67];
68
69pub(crate) const SHAPE_CATALOG: &[ShapeEntry] = &[
71 ShapeEntry {
72 shape: RaymarchShape::Polytope(Polytope4::Pentatope),
73 body_color: [0.95, 0.55, 0.30],
74 label: "5-cell",
75 long_name: "pentachoron",
76 category: Category::RegularPolychoron,
77 },
78 ShapeEntry {
79 shape: RaymarchShape::Polytope(Polytope4::Tesseract),
80 body_color: [0.30, 0.55, 0.95],
81 label: "8-cell",
82 long_name: "tesseract",
83 category: Category::RegularPolychoron,
84 },
85 ShapeEntry {
86 shape: RaymarchShape::Polytope(Polytope4::Cell16),
87 body_color: [0.55, 0.95, 0.40],
88 label: "16-cell",
89 long_name: "hexadecachoron",
90 category: Category::RegularPolychoron,
91 },
92 ShapeEntry {
93 shape: RaymarchShape::Polytope(Polytope4::Cell24),
94 body_color: [0.95, 0.45, 0.85],
95 label: "24-cell",
96 long_name: "icositetrachoron",
97 category: Category::RegularPolychoron,
98 },
99 ShapeEntry {
100 shape: RaymarchShape::Polytope(Polytope4::Cell120),
101 body_color: [0.40, 0.85, 0.85],
102 label: "120-cell",
103 long_name: "hecatonicosachoron",
104 category: Category::RegularPolychoron,
105 },
106 ShapeEntry {
107 shape: RaymarchShape::Polytope(Polytope4::Cell600),
108 body_color: [0.95, 0.85, 0.40],
109 label: "600-cell",
110 long_name: "hexacosichoron",
111 category: Category::RegularPolychoron,
112 },
113 ShapeEntry {
114 shape: RaymarchShape::ThreeSphere,
115 body_color: [0.85, 0.40, 0.40],
116 label: "3-sphere",
117 long_name: "hypersphere (4-ball)",
118 category: Category::SmoothSolid,
119 },
120 ShapeEntry {
121 shape: RaymarchShape::Duocylinder,
122 body_color: [0.60, 0.45, 0.90],
123 label: "duocyl",
124 long_name: "duocylinder (D² × D²)",
125 category: Category::SmoothSolid,
126 },
127 ShapeEntry {
128 shape: RaymarchShape::CliffordTorus,
129 body_color: [0.70, 0.85, 0.35],
130 label: "clifford",
131 long_name: "Clifford torus tube",
132 category: Category::SmoothSolid,
133 },
134 ShapeEntry {
135 shape: RaymarchShape::Spherinder,
136 body_color: [0.85, 0.55, 0.75],
137 label: "spherinder",
138 long_name: "spherinder (B³ × interval)",
139 category: Category::SmoothSolid,
140 },
141];
142
143pub(crate) fn shape_catalog_menu(ui: &mut loam::app::egui::Ui, mut choose: impl FnMut(usize)) {
144 for category in Category::ALL {
145 ui.menu_button(category.name(), |ui| {
146 for (card, entry) in SHAPE_CATALOG
147 .iter()
148 .enumerate()
149 .filter(|(_, entry)| entry.category == category)
150 {
151 if ui
152 .button(entry.label)
153 .on_hover_text(entry.long_name)
154 .clicked()
155 {
156 choose(card);
157 ui.close_kind(loam::app::egui::UiKind::Menu);
158 }
159 }
160 });
161 }
162}
163
164pub(crate) fn parse_shape_name(name: &str) -> Result<ShapeEntry> {
165 let n = name.to_lowercase();
166 let needle: &str = n.as_str();
167 for entry in SHAPE_CATALOG {
168 if needle == entry.label.to_lowercase() || needle == entry.long_name.to_lowercase() {
169 return Ok(*entry);
170 }
171 }
172 Ok(match needle {
173 "5cell" | "pentatope" | "tetrahedron" => SHAPE_CATALOG[0],
174 "8cell" | "hypercube" | "cube" => SHAPE_CATALOG[1],
175 "16cell" | "octahedron" => SHAPE_CATALOG[2],
176 "24cell" | "cuboctahedron" => SHAPE_CATALOG[3],
177 "120cell" | "dodecahedron" => SHAPE_CATALOG[4],
178 "600cell" | "icosahedron" => SHAPE_CATALOG[5],
179 "hypersphere" | "3sphere" | "s3" | "4-ball" => SHAPE_CATALOG[6],
180 "duocylinder" => SHAPE_CATALOG[7],
181 "clifford" | "clifford-torus" | "torus" => SHAPE_CATALOG[8],
182 "spherinder" => SHAPE_CATALOG[9],
183 _ => {
184 return Err(anyhow!(
185 "unknown shape name {name:?}; valid: 5-cell, 8-cell, \
186 16-cell, 24-cell, 120-cell, 600-cell, 3-sphere, \
187 duocyl, clifford, spherinder (plus Platonic aliases: \
188 tetrahedron, cube, octahedron, cuboctahedron, \
189 dodecahedron, icosahedron)"
190 ));
191 }
192 })
193}
194
195pub(crate) fn parse_row(args: &Args) -> Result<Vec<ShapeEntry>> {
196 if args.has_bare_flag("shapes") {
197 return Err(anyhow!(
198 "`--shapes` needs its value attached with `=`, as in \
199 `--shapes=24-cell,8-cell` (comma-separated for several \
200 shapes); the space-separated form drops the value"
201 ));
202 }
203 let Some(raw) = args.get("shapes") else {
204 return Ok(DEFAULT_ROW.to_vec());
205 };
206 let names = args.get_many("shapes");
207 if names.is_empty() {
208 return Err(anyhow!("shapes={raw:?} listed no shape names"));
209 }
210 names.into_iter().map(parse_shape_name).collect()
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn row_comes_from_the_args_value_not_the_process_environment() {
219 assert_eq!(parse_row(&Args::default()).unwrap(), DEFAULT_ROW);
220 assert_eq!(
221 parse_row(&Args::from_pairs([("scene", "rotate")])).unwrap(),
222 DEFAULT_ROW
223 );
224
225 let row = parse_row(&Args::from_pairs([("shapes", "120-cell,tesseract")])).unwrap();
226 assert_eq!(
227 row.iter().map(|e| e.label).collect::<Vec<_>>(),
228 ["120-cell", "8-cell"]
229 );
230 }
231
232 #[test]
233 fn a_bare_shapes_flag_is_diagnosed_rather_than_silently_defaulting() {
234 let args = Args::from_argv(["--shapes", "120-cell,tesseract"]);
235 assert_eq!(args.get("shapes"), None);
236 let err = parse_row(&args).unwrap_err().to_string();
237 assert!(err.contains("--shapes="), "{err}");
238
239 assert!(parse_row(&Args::from_argv(["--scene=rotate", "--shapes"])).is_err());
240 }
241
242 #[test]
243 fn the_attached_form_and_unrelated_arguments_are_not_diagnosed() {
244 let row = parse_row(&Args::from_argv([
245 "--scene=rotate",
246 "--shapes=5-cell,8-cell",
247 ]))
248 .unwrap();
249 assert_eq!(
250 row.iter().map(|e| e.label).collect::<Vec<_>>(),
251 ["5-cell", "8-cell"]
252 );
253 assert_eq!(
254 parse_row(&Args::from_argv(["shapes", "--x=--shapes"])).unwrap(),
255 DEFAULT_ROW
256 );
257 }
258
259 #[test]
260 fn a_present_but_nameless_or_unknown_shapes_value_is_an_error() {
261 assert!(parse_row(&Args::from_pairs([("shapes", "")])).is_err());
262 assert!(parse_row(&Args::from_pairs([("shapes", ",,")])).is_err());
263 assert!(parse_row(&Args::from_pairs([("shapes", "5-cell,nonesuch")])).is_err());
264 }
265}