Skip to main content

polytope_playground/
catalog.rs

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
69// `body_color` channels pass straight to the WGSL kernel.
70pub(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}