Skip to main content

autumn_plugin_three/
scene.rs

1//! The typed scene builder: [`Scene`], [`Mesh`], [`Model`], [`Light`].
2//!
3//! A [`Scene`] renders one `<div data-three="scene">` element. Each object
4//! renders one hidden child declaration (`data-three-mesh`,
5//! `data-three-model`, `data-three-light`). `init.js` reads the markup and
6//! builds the Three.js scene. You can also write the markup by hand. The
7//! README gives the attribute reference.
8//!
9//! Lengths use Three.js units. Angles use degrees. Spin and turntable
10//! speeds use degrees per second.
11//!
12//! The builder never writes a non-finite number. A setter ignores `NaN` and
13//! infinite input. A setter clamps input that is out of range.
14
15use autumn_web::{Markup, html};
16use maud::Render;
17
18/// Returns `value` when it is finite, else `fallback`.
19const fn finite_or(value: f32, fallback: f32) -> f32 {
20    if value.is_finite() { value } else { fallback }
21}
22
23/// Returns `Some(value)` when it is finite, else `None`.
24const fn finite(value: f32) -> Option<f32> {
25    if value.is_finite() { Some(value) } else { None }
26}
27
28/// Formats a finite number for an attribute. `-0` becomes `0`.
29fn num(value: f32) -> String {
30    if value == 0.0 {
31        "0".to_owned()
32    } else {
33        format!("{value}")
34    }
35}
36
37/// A 3D vector: position, rotation (degrees), scale, or spin (degrees per
38/// second).
39///
40/// ```rust
41/// use autumn_plugin_three::Vec3;
42///
43/// assert_eq!(Vec3::from([1.0, 2.0, 3.0]), Vec3::new(1.0, 2.0, 3.0));
44/// assert_eq!(Vec3::splat(2.0), Vec3::new(2.0, 2.0, 2.0));
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub struct Vec3 {
48    /// X component.
49    pub x: f32,
50    /// Y component.
51    pub y: f32,
52    /// Z component.
53    pub z: f32,
54}
55
56impl Vec3 {
57    /// The zero vector.
58    pub const ZERO: Self = Self::new(0.0, 0.0, 0.0);
59
60    /// Makes a vector.
61    #[must_use]
62    pub const fn new(x: f32, y: f32, z: f32) -> Self {
63        Self { x, y, z }
64    }
65
66    /// Makes a vector with all three components set to `value`.
67    #[must_use]
68    pub const fn splat(value: f32) -> Self {
69        Self::new(value, value, value)
70    }
71
72    /// Replaces each non-finite component with the same component of
73    /// `fallback`.
74    const fn finite_or(self, fallback: Self) -> Self {
75        Self::new(
76            finite_or(self.x, fallback.x),
77            finite_or(self.y, fallback.y),
78            finite_or(self.z, fallback.z),
79        )
80    }
81
82    /// The `x,y,z` attribute value.
83    fn attr(self) -> String {
84        format!("{},{},{}", num(self.x), num(self.y), num(self.z))
85    }
86}
87
88impl From<[f32; 3]> for Vec3 {
89    fn from([x, y, z]: [f32; 3]) -> Self {
90        Self::new(x, y, z)
91    }
92}
93
94impl From<(f32, f32, f32)> for Vec3 {
95    fn from((x, y, z): (f32, f32, f32)) -> Self {
96        Self::new(x, y, z)
97    }
98}
99
100/// An sRGB color.
101///
102/// ```rust
103/// use autumn_plugin_three::Color;
104///
105/// assert_eq!(Color::hex(0xff8800).to_string(), "#ff8800");
106/// assert_eq!(Color::rgb(0, 128, 255).to_string(), "#0080ff");
107/// ```
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub struct Color(u32);
110
111impl Color {
112    /// White (`#ffffff`).
113    pub const WHITE: Self = Self(0x00ff_ffff);
114    /// Black (`#000000`).
115    pub const BLACK: Self = Self(0);
116
117    /// Makes a color from `0xRRGGBB`. The method ignores the bits above the
118    /// low 24 bits.
119    #[must_use]
120    pub const fn hex(value: u32) -> Self {
121        Self(value & 0x00ff_ffff)
122    }
123
124    /// Makes a color from red, green, and blue channels.
125    #[must_use]
126    pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
127        Self(((red as u32) << 16) | ((green as u32) << 8) | blue as u32)
128    }
129
130    /// The color as `0xRRGGBB`.
131    #[must_use]
132    pub const fn value(self) -> u32 {
133        self.0
134    }
135}
136
137impl std::fmt::Display for Color {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(f, "#{:06x}", self.0)
140    }
141}
142
143/// Smallest aspect ratio. Same as `ASPECT_RANGE` in `parse.js`.
144const ASPECT_MIN: f32 = 0.1;
145/// Largest aspect ratio. Extreme ratios break the page layout.
146const ASPECT_MAX: f32 = 10.0;
147
148/// The aspect ratio (width / height) of a scene.
149///
150/// The named ratios have CSS rules in `three.css`, so the size is correct
151/// before JavaScript runs. `init.js` applies a [`Aspect::Ratio`].
152#[derive(Debug, Clone, Copy, PartialEq, Default)]
153#[non_exhaustive]
154pub enum Aspect {
155    /// 16 / 9 (default).
156    #[default]
157    Wide,
158    /// 4 / 3.
159    Standard,
160    /// 1 / 1.
161    Square,
162    /// 21 / 9.
163    Ultrawide,
164    /// 3 / 2.
165    Photo,
166    /// 3 / 4.
167    Portrait,
168    /// A custom `width / height`. Both must be finite and positive, else
169    /// the scene uses [`Aspect::Wide`]. The ratio is clamped to `0.1..=10`.
170    Ratio(f32, f32),
171}
172
173impl Aspect {
174    /// The `data-three-aspect` value.
175    fn attr(self) -> String {
176        match self {
177            Self::Wide => "16/9".to_owned(),
178            Self::Standard => "4/3".to_owned(),
179            Self::Square => "1/1".to_owned(),
180            Self::Ultrawide => "21/9".to_owned(),
181            Self::Photo => "3/2".to_owned(),
182            Self::Portrait => "3/4".to_owned(),
183            Self::Ratio(w, h) if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => {
184                let ratio = w / h;
185                if (ASPECT_MIN..=ASPECT_MAX).contains(&ratio) {
186                    format!("{}/{}", num(w), num(h))
187                } else {
188                    format!("{}/1", num(ratio.clamp(ASPECT_MIN, ASPECT_MAX)))
189                }
190            }
191            Self::Ratio(..) => Self::Wide.attr(),
192        }
193    }
194}
195
196/// Camera controls.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198#[non_exhaustive]
199pub enum Controls {
200    /// No user control (default).
201    #[default]
202    None,
203    /// Orbit: drag to rotate, wheel or pinch to zoom, right-drag to pan.
204    Orbit,
205    /// Orbit without zoom, so the mouse wheel scrolls the page.
206    OrbitNoZoom,
207}
208
209impl Controls {
210    /// The `data-three-controls` value.
211    const fn attr(self) -> &'static str {
212        match self {
213            Self::None => "none",
214            Self::Orbit => "orbit",
215            Self::OrbitNoZoom => "orbit-no-zoom",
216        }
217    }
218}
219
220/// Image-based scene light.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[non_exhaustive]
223pub enum Environment {
224    /// Three.js `RoomEnvironment`. It gives diffuse studio light. Use it with
225    /// PBR materials and models.
226    Room,
227}
228
229impl Environment {
230    /// The `data-three-environment` value.
231    const fn attr(self) -> &'static str {
232        match self {
233            Self::Room => "room",
234        }
235    }
236}
237
238/// A perspective camera.
239///
240/// Defaults (applied by `init.js`): field of view `50`, position `0,1,4`,
241/// target `0,0,0`.
242///
243/// ```rust
244/// use autumn_plugin_three::Camera;
245///
246/// let camera = Camera::perspective().fov(40.0).position([0.0, 2.0, 6.0]);
247/// # let _ = camera;
248/// ```
249#[derive(Debug, Clone, Copy, PartialEq, Default)]
250#[must_use]
251pub struct Camera {
252    fov: Option<f32>,
253    position: Option<Vec3>,
254    target: Option<Vec3>,
255}
256
257impl Camera {
258    /// Makes a perspective camera with the defaults.
259    pub const fn perspective() -> Self {
260        Self {
261            fov: None,
262            position: None,
263            target: None,
264        }
265    }
266
267    /// Sets the vertical field of view in degrees. Clamped to `1..=179`.
268    pub const fn fov(mut self, degrees: f32) -> Self {
269        if let Some(value) = finite(degrees) {
270            self.fov = Some(value.clamp(1.0, 179.0));
271        }
272        self
273    }
274
275    /// Sets the camera position.
276    pub fn position(mut self, position: impl Into<Vec3>) -> Self {
277        self.position = Some(position.into().finite_or(Vec3::new(0.0, 1.0, 4.0)));
278        self
279    }
280
281    /// Sets the point the camera looks at. Orbit controls rotate around it.
282    pub fn target(mut self, target: impl Into<Vec3>) -> Self {
283        self.target = Some(target.into().finite_or(Vec3::ZERO));
284        self
285    }
286}
287
288/// A primitive geometry. Sizes are in Three.js units.
289///
290/// A size that is not finite, or is negative, renders as the default size
291/// for that geometry.
292#[derive(Debug, Clone, Copy, PartialEq)]
293#[non_exhaustive]
294pub enum Geometry {
295    /// A box (`box`). Default `1,1,1`.
296    Box {
297        /// Size on X.
298        width: f32,
299        /// Size on Y.
300        height: f32,
301        /// Size on Z.
302        depth: f32,
303    },
304    /// A sphere (`sphere`). Default radius `0.5`.
305    Sphere {
306        /// Radius.
307        radius: f32,
308    },
309    /// A flat plane in the XY plane (`plane`). Default `1,1`.
310    Plane {
311        /// Size on X.
312        width: f32,
313        /// Size on Y.
314        height: f32,
315    },
316    /// A torus (`torus`). Default `0.5,0.2`.
317    Torus {
318        /// Radius from the center to the tube center.
319        radius: f32,
320        /// Tube radius.
321        tube: f32,
322    },
323    /// A torus knot (`torus-knot`). Default `0.5,0.15`.
324    TorusKnot {
325        /// Radius of the knot.
326        radius: f32,
327        /// Tube radius.
328        tube: f32,
329    },
330    /// A cylinder (`cylinder`). Default `0.5,0.5,1`.
331    Cylinder {
332        /// Top radius.
333        radius_top: f32,
334        /// Bottom radius.
335        radius_bottom: f32,
336        /// Height.
337        height: f32,
338    },
339    /// A cone (`cone`). Default `0.5,1`.
340    Cone {
341        /// Base radius.
342        radius: f32,
343        /// Height.
344        height: f32,
345    },
346    /// A capsule (`capsule`). Default `0.3,0.6`.
347    Capsule {
348        /// Radius.
349        radius: f32,
350        /// Length of the middle section.
351        length: f32,
352    },
353    /// An icosahedron (`icosahedron`). Default radius `0.5`.
354    Icosahedron {
355        /// Radius.
356        radius: f32,
357    },
358    /// A dodecahedron (`dodecahedron`). Default radius `0.5`.
359    Dodecahedron {
360        /// Radius.
361        radius: f32,
362    },
363    /// An octahedron (`octahedron`). Default radius `0.5`.
364    Octahedron {
365        /// Radius.
366        radius: f32,
367    },
368    /// A tetrahedron (`tetrahedron`). Default radius `0.5`.
369    Tetrahedron {
370        /// Radius.
371        radius: f32,
372    },
373    /// A flat ring in the XY plane (`ring`). Default `0.25,0.5`.
374    Ring {
375        /// Inner radius.
376        inner_radius: f32,
377        /// Outer radius.
378        outer_radius: f32,
379    },
380}
381
382impl Geometry {
383    /// The `data-three-mesh` value.
384    #[must_use]
385    pub const fn kind(&self) -> &'static str {
386        match self {
387            Self::Box { .. } => "box",
388            Self::Sphere { .. } => "sphere",
389            Self::Plane { .. } => "plane",
390            Self::Torus { .. } => "torus",
391            Self::TorusKnot { .. } => "torus-knot",
392            Self::Cylinder { .. } => "cylinder",
393            Self::Cone { .. } => "cone",
394            Self::Capsule { .. } => "capsule",
395            Self::Icosahedron { .. } => "icosahedron",
396            Self::Dodecahedron { .. } => "dodecahedron",
397            Self::Octahedron { .. } => "octahedron",
398            Self::Tetrahedron { .. } => "tetrahedron",
399            Self::Ring { .. } => "ring",
400        }
401    }
402
403    /// The sizes, in `data-three-args` order, with their defaults.
404    fn sizes(&self) -> Vec<(f32, f32)> {
405        match *self {
406            Self::Box {
407                width,
408                height,
409                depth,
410            } => vec![(width, 1.0), (height, 1.0), (depth, 1.0)],
411            Self::Sphere { radius }
412            | Self::Icosahedron { radius }
413            | Self::Dodecahedron { radius }
414            | Self::Octahedron { radius }
415            | Self::Tetrahedron { radius } => vec![(radius, 0.5)],
416            Self::Plane { width, height } => vec![(width, 1.0), (height, 1.0)],
417            Self::Torus { radius, tube } => vec![(radius, 0.5), (tube, 0.2)],
418            Self::TorusKnot { radius, tube } => vec![(radius, 0.5), (tube, 0.15)],
419            Self::Cylinder {
420                radius_top,
421                radius_bottom,
422                height,
423            } => vec![(radius_top, 0.5), (radius_bottom, 0.5), (height, 1.0)],
424            Self::Cone { radius, height } => vec![(radius, 0.5), (height, 1.0)],
425            Self::Capsule { radius, length } => vec![(radius, 0.3), (length, 0.6)],
426            Self::Ring {
427                inner_radius,
428                outer_radius,
429            } => vec![(inner_radius, 0.25), (outer_radius, 0.5)],
430        }
431    }
432
433    /// The `data-three-args` value. Bad sizes become defaults.
434    fn args(&self) -> String {
435        self.sizes()
436            .into_iter()
437            .map(|(value, default)| {
438                num(if value.is_finite() && value >= 0.0 {
439                    value
440                } else {
441                    default
442                })
443            })
444            .collect::<Vec<_>>()
445            .join(",")
446    }
447}
448
449/// A mesh material.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
451#[non_exhaustive]
452pub enum Material {
453    /// PBR metal/roughness (`standard`, default).
454    #[default]
455    Standard,
456    /// PBR with more options (`physical`).
457    Physical,
458    /// No lighting (`basic`).
459    Basic,
460    /// A matte surface (`lambert`). It uses less GPU time than PBR.
461    Lambert,
462    /// A shiny surface (`phong`). It uses less GPU time than PBR.
463    Phong,
464    /// Colors from surface normals (`normal`). Needs no light.
465    Normal,
466}
467
468impl Material {
469    /// The `data-three-material` value.
470    const fn attr(self) -> &'static str {
471        match self {
472            Self::Standard => "standard",
473            Self::Physical => "physical",
474            Self::Basic => "basic",
475            Self::Lambert => "lambert",
476            Self::Phong => "phong",
477            Self::Normal => "normal",
478        }
479    }
480}
481
482/// Position, rotation, scale, and spin of an object.
483#[derive(Debug, Clone, Copy, PartialEq, Default)]
484struct Transform {
485    position: Option<Vec3>,
486    rotation: Option<Vec3>,
487    scale: Option<Vec3>,
488    spin: Option<Vec3>,
489}
490
491/// Adds the transform setters to an object builder.
492macro_rules! transform_setters {
493    () => {
494        /// Sets the position.
495        pub fn position(mut self, position: impl Into<Vec3>) -> Self {
496            self.transform.position = Some(position.into().finite_or(Vec3::ZERO));
497            self
498        }
499
500        /// Sets the rotation in degrees (X, Y, Z order).
501        pub fn rotation(mut self, degrees: impl Into<Vec3>) -> Self {
502            self.transform.rotation = Some(degrees.into().finite_or(Vec3::ZERO));
503            self
504        }
505
506        /// Sets the scale. Use [`Vec3::splat`] for a uniform scale.
507        pub fn scale(mut self, scale: impl Into<Vec3>) -> Self {
508            self.transform.scale = Some(scale.into().finite_or(Vec3::splat(1.0)));
509            self
510        }
511
512        /// Spins the object, in degrees per second on each axis.
513        ///
514        /// Spin stops when the user prefers reduced motion, unless the scene
515        /// calls [`Scene::animate_reduced_motion`].
516        pub fn spin(mut self, degrees_per_second: impl Into<Vec3>) -> Self {
517            self.transform.spin = Some(degrees_per_second.into().finite_or(Vec3::ZERO));
518            self
519        }
520    };
521}
522
523/// A primitive mesh: a [`Geometry`] with a [`Material`].
524///
525/// ```rust
526/// use autumn_plugin_three::{Color, Mesh};
527///
528/// let knot = Mesh::torus_knot(0.8, 0.25)
529///     .color(Color::hex(0xff7a18))
530///     .metalness(0.3)
531///     .roughness(0.4)
532///     .spin([0.0, 45.0, 0.0]);
533/// # let _ = knot;
534/// ```
535#[derive(Debug, Clone, Copy, PartialEq)]
536#[must_use]
537pub struct Mesh {
538    geometry: Geometry,
539    material: Option<Material>,
540    color: Option<Color>,
541    emissive: Option<Color>,
542    metalness: Option<f32>,
543    roughness: Option<f32>,
544    opacity: Option<f32>,
545    wireframe: bool,
546    transform: Transform,
547}
548
549impl Mesh {
550    /// Makes a mesh with the default material.
551    pub const fn new(geometry: Geometry) -> Self {
552        Self {
553            geometry,
554            material: None,
555            color: None,
556            emissive: None,
557            metalness: None,
558            roughness: None,
559            opacity: None,
560            wireframe: false,
561            transform: Transform {
562                position: None,
563                rotation: None,
564                scale: None,
565                spin: None,
566            },
567        }
568    }
569
570    /// A cube with edge length `size`.
571    pub const fn cube(size: f32) -> Self {
572        Self::cuboid(size, size, size)
573    }
574
575    /// A box.
576    pub const fn cuboid(width: f32, height: f32, depth: f32) -> Self {
577        Self::new(Geometry::Box {
578            width,
579            height,
580            depth,
581        })
582    }
583
584    /// A sphere.
585    pub const fn sphere(radius: f32) -> Self {
586        Self::new(Geometry::Sphere { radius })
587    }
588
589    /// A flat plane.
590    pub const fn plane(width: f32, height: f32) -> Self {
591        Self::new(Geometry::Plane { width, height })
592    }
593
594    /// A torus.
595    pub const fn torus(radius: f32, tube: f32) -> Self {
596        Self::new(Geometry::Torus { radius, tube })
597    }
598
599    /// A torus knot.
600    pub const fn torus_knot(radius: f32, tube: f32) -> Self {
601        Self::new(Geometry::TorusKnot { radius, tube })
602    }
603
604    /// A cylinder.
605    pub const fn cylinder(radius_top: f32, radius_bottom: f32, height: f32) -> Self {
606        Self::new(Geometry::Cylinder {
607            radius_top,
608            radius_bottom,
609            height,
610        })
611    }
612
613    /// A cone.
614    pub const fn cone(radius: f32, height: f32) -> Self {
615        Self::new(Geometry::Cone { radius, height })
616    }
617
618    /// A capsule.
619    pub const fn capsule(radius: f32, length: f32) -> Self {
620        Self::new(Geometry::Capsule { radius, length })
621    }
622
623    /// An icosahedron.
624    pub const fn icosahedron(radius: f32) -> Self {
625        Self::new(Geometry::Icosahedron { radius })
626    }
627
628    /// A dodecahedron.
629    pub const fn dodecahedron(radius: f32) -> Self {
630        Self::new(Geometry::Dodecahedron { radius })
631    }
632
633    /// An octahedron.
634    pub const fn octahedron(radius: f32) -> Self {
635        Self::new(Geometry::Octahedron { radius })
636    }
637
638    /// A tetrahedron.
639    pub const fn tetrahedron(radius: f32) -> Self {
640        Self::new(Geometry::Tetrahedron { radius })
641    }
642
643    /// A flat ring.
644    pub const fn ring(inner_radius: f32, outer_radius: f32) -> Self {
645        Self::new(Geometry::Ring {
646            inner_radius,
647            outer_radius,
648        })
649    }
650
651    /// Sets the material. Default: [`Material::Standard`].
652    pub const fn material(mut self, material: Material) -> Self {
653        self.material = Some(material);
654        self
655    }
656
657    /// Sets the base color. Default: white.
658    pub const fn color(mut self, color: Color) -> Self {
659        self.color = Some(color);
660        self
661    }
662
663    /// Sets the emissive (glow) color. Lit materials only.
664    pub const fn emissive(mut self, color: Color) -> Self {
665        self.emissive = Some(color);
666        self
667    }
668
669    /// Sets the metalness, `0..=1`. PBR materials only.
670    pub const fn metalness(mut self, value: f32) -> Self {
671        if let Some(v) = finite(value) {
672            self.metalness = Some(v.clamp(0.0, 1.0));
673        }
674        self
675    }
676
677    /// Sets the roughness, `0..=1`. PBR materials only.
678    pub const fn roughness(mut self, value: f32) -> Self {
679        if let Some(v) = finite(value) {
680            self.roughness = Some(v.clamp(0.0, 1.0));
681        }
682        self
683    }
684
685    /// Sets the opacity, `0..=1`. A value below `1` makes the material
686    /// transparent.
687    pub const fn opacity(mut self, value: f32) -> Self {
688        if let Some(v) = finite(value) {
689            self.opacity = Some(v.clamp(0.0, 1.0));
690        }
691        self
692    }
693
694    /// Renders the edges only.
695    pub const fn wireframe(mut self) -> Self {
696        self.wireframe = true;
697        self
698    }
699
700    transform_setters!();
701}
702
703impl Render for Mesh {
704    fn render(&self) -> Markup {
705        let t = &self.transform;
706        html! {
707            div hidden data-three-mesh=(self.geometry.kind())
708                data-three-args=(self.geometry.args())
709                data-three-material=[self.material.map(Material::attr)]
710                data-three-color=[self.color]
711                data-three-emissive=[self.emissive]
712                data-three-metalness=[self.metalness.map(num)]
713                data-three-roughness=[self.roughness.map(num)]
714                data-three-opacity=[self.opacity.map(num)]
715                data-three-wireframe=[self.wireframe.then_some("true")]
716                data-three-position=[t.position.map(Vec3::attr)]
717                data-three-rotation=[t.rotation.map(Vec3::attr)]
718                data-three-scale=[t.scale.map(Vec3::attr)]
719                data-three-spin=[t.spin.map(Vec3::attr)] {}
720        }
721    }
722}
723
724/// Which animation clips of a [`Model`] play.
725#[derive(Debug, Clone, PartialEq, Eq)]
726#[non_exhaustive]
727pub enum Clip {
728    /// Play all clips (`*`).
729    All,
730    /// Play the clip with this name. The name `*` means all clips.
731    Named(String),
732}
733
734/// A glTF / GLB model, loaded with the vendored `GLTFLoader`.
735///
736/// ```rust
737/// use autumn_plugin_three::Model;
738///
739/// let duck = Model::gltf("/static/models/duck.glb").fit(2.0).play_all();
740/// # let _ = duck;
741/// ```
742///
743/// The URL must be `http(s)` or relative. The app CSP must allow the URL in
744/// `connect-src`. The default CSP allows same-origin URLs. Images inside a
745/// GLB work under the default CSP. Images at external URLs need `img-src`
746/// and `connect-src` to allow them.
747#[derive(Debug, Clone, PartialEq)]
748#[must_use]
749pub struct Model {
750    src: String,
751    fit: Option<f32>,
752    clip: Option<Clip>,
753    transform: Transform,
754}
755
756impl Model {
757    /// Makes a model from a `.gltf` or `.glb` URL.
758    pub fn gltf(src: impl Into<String>) -> Self {
759        Self {
760            src: src.into(),
761            fit: None,
762            clip: None,
763            transform: Transform::default(),
764        }
765    }
766
767    /// Scales the model so that its largest side is `size`. It also puts the
768    /// model center at the model position. The method ignores a size that
769    /// is not a finite positive number.
770    pub const fn fit(mut self, size: f32) -> Self {
771        if size.is_finite() && size > 0.0 {
772            self.fit = Some(size);
773        }
774        self
775    }
776
777    /// Plays all animation clips in a loop.
778    pub fn play_all(mut self) -> Self {
779        self.clip = Some(Clip::All);
780        self
781    }
782
783    /// Plays the clip with this name in a loop. The runtime trims the name,
784    /// so this method trims it too. An empty name is ignored. The name `*`
785    /// plays all clips.
786    pub fn play(mut self, name: impl Into<String>) -> Self {
787        let name = name.into();
788        let name = name.trim();
789        if !name.is_empty() {
790            self.clip = Some(Clip::Named(name.to_owned()));
791        }
792        self
793    }
794
795    transform_setters!();
796}
797
798impl Render for Model {
799    fn render(&self) -> Markup {
800        let t = &self.transform;
801        let clip = self.clip.as_ref().map(|clip| match clip {
802            Clip::All => "*",
803            Clip::Named(name) => name.as_str(),
804        });
805        html! {
806            div hidden data-three-model=(self.src)
807                data-three-fit=[self.fit.map(num)]
808                data-three-clip=[clip]
809                data-three-position=[t.position.map(Vec3::attr)]
810                data-three-rotation=[t.rotation.map(Vec3::attr)]
811                data-three-scale=[t.scale.map(Vec3::attr)]
812                data-three-spin=[t.spin.map(Vec3::attr)] {}
813        }
814    }
815}
816
817/// The kind of a [`Light`].
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819#[non_exhaustive]
820pub enum LightKind {
821    /// Even light from all sides (`ambient`).
822    Ambient,
823    /// Parallel rays, like the sun (`directional`).
824    Directional,
825    /// Light from one point (`point`).
826    Point,
827    /// A cone of light that points at the origin (`spot`).
828    Spot,
829    /// Sky color from above, ground color from below (`hemisphere`).
830    Hemisphere,
831}
832
833impl LightKind {
834    /// The default position. Same as `LIGHTS` in `parse.js`.
835    #[must_use]
836    pub const fn default_position(self) -> Vec3 {
837        match self {
838            Self::Ambient => Vec3::ZERO,
839            Self::Directional => Vec3::new(3.0, 5.0, 4.0),
840            Self::Point => Vec3::new(2.0, 3.0, 2.0),
841            Self::Spot => Vec3::new(0.0, 4.0, 2.0),
842            Self::Hemisphere => Vec3::new(0.0, 1.0, 0.0),
843        }
844    }
845
846    /// The `data-three-light` value.
847    const fn attr(self) -> &'static str {
848        match self {
849            Self::Ambient => "ambient",
850            Self::Directional => "directional",
851            Self::Point => "point",
852            Self::Spot => "spot",
853            Self::Hemisphere => "hemisphere",
854        }
855    }
856}
857
858/// A light.
859///
860/// When a scene has no light and no [`Environment`], `init.js` adds a
861/// default hemisphere light and a directional light.
862///
863/// ```rust
864/// use autumn_plugin_three::{Color, Light};
865///
866/// let sun = Light::directional().intensity(2.5).position([3.0, 5.0, 2.0]);
867/// let sky = Light::hemisphere().ground(Color::hex(0x332211));
868/// # let _ = (sun, sky);
869/// ```
870#[derive(Debug, Clone, Copy, PartialEq)]
871#[must_use]
872pub struct Light {
873    kind: LightKind,
874    color: Option<Color>,
875    ground: Option<Color>,
876    intensity: Option<f32>,
877    position: Option<Vec3>,
878}
879
880impl Light {
881    /// Makes a light of `kind` with the defaults for that kind.
882    pub const fn new(kind: LightKind) -> Self {
883        Self {
884            kind,
885            color: None,
886            ground: None,
887            intensity: None,
888            position: None,
889        }
890    }
891
892    /// An ambient light.
893    pub const fn ambient() -> Self {
894        Self::new(LightKind::Ambient)
895    }
896
897    /// A directional light.
898    pub const fn directional() -> Self {
899        Self::new(LightKind::Directional)
900    }
901
902    /// A point light.
903    pub const fn point() -> Self {
904        Self::new(LightKind::Point)
905    }
906
907    /// A spot light.
908    pub const fn spot() -> Self {
909        Self::new(LightKind::Spot)
910    }
911
912    /// A hemisphere light.
913    pub const fn hemisphere() -> Self {
914        Self::new(LightKind::Hemisphere)
915    }
916
917    /// Sets the color (the sky color for a hemisphere light).
918    pub const fn color(mut self, color: Color) -> Self {
919        self.color = Some(color);
920        self
921    }
922
923    /// Sets the ground color. Hemisphere lights only.
924    pub const fn ground(mut self, color: Color) -> Self {
925        self.ground = Some(color);
926        self
927    }
928
929    /// Sets the intensity. Negative values become `0`.
930    pub const fn intensity(mut self, value: f32) -> Self {
931        if let Some(v) = finite(value) {
932            self.intensity = Some(v.max(0.0));
933        }
934        self
935    }
936
937    /// Sets the position. Ambient lights ignore it. For a hemisphere light,
938    /// the position sets the sky direction. A non-finite component uses the
939    /// default of the kind ([`LightKind::default_position`]).
940    pub fn position(mut self, position: impl Into<Vec3>) -> Self {
941        self.position = Some(position.into().finite_or(self.kind.default_position()));
942        self
943    }
944}
945
946impl Render for Light {
947    fn render(&self) -> Markup {
948        html! {
949            div hidden data-three-light=(self.kind.attr())
950                data-three-color=[self.color]
951                data-three-ground=[self.ground]
952                data-three-intensity=[self.intensity.map(num)]
953                data-three-position=[self.position.map(Vec3::attr)] {}
954        }
955    }
956}
957
958/// One object in a [`Scene`].
959#[derive(Debug, Clone, PartialEq)]
960#[non_exhaustive]
961pub enum SceneObject {
962    /// A primitive mesh.
963    Mesh(Mesh),
964    /// A glTF / GLB model.
965    Model(Model),
966    /// A light.
967    Light(Light),
968}
969
970impl From<Mesh> for SceneObject {
971    fn from(mesh: Mesh) -> Self {
972        Self::Mesh(mesh)
973    }
974}
975
976impl From<Model> for SceneObject {
977    fn from(model: Model) -> Self {
978        Self::Model(model)
979    }
980}
981
982impl From<Light> for SceneObject {
983    fn from(light: Light) -> Self {
984        Self::Light(light)
985    }
986}
987
988impl Render for SceneObject {
989    fn render(&self) -> Markup {
990        match self {
991            Self::Mesh(mesh) => mesh.render(),
992            Self::Model(model) => model.render(),
993            Self::Light(light) => light.render(),
994        }
995    }
996}
997
998/// A 3D scene. Renders one `<div data-three="scene">` element.
999///
1000/// ```rust
1001/// use autumn_plugin_three::{Camera, Color, Controls, Light, Mesh, Scene};
1002/// use autumn_web::html;
1003///
1004/// let scene = Scene::new()
1005///     .label("A spinning orange torus knot")
1006///     .camera(Camera::perspective().position([0.0, 0.0, 4.0]))
1007///     .controls(Controls::OrbitNoZoom)
1008///     .add(Mesh::torus_knot(0.8, 0.25).color(Color::hex(0xff7a18)).spin([0.0, 45.0, 0.0]))
1009///     .add(Light::directional().position([3.0, 5.0, 2.0]))
1010///     .fallback(html! { p { "3D preview needs WebGL." } });
1011///
1012/// let html = html! { (scene) }.into_string();
1013/// assert!(html.starts_with(r#"<div role="img""#), "{html}");
1014/// assert!(html.contains(r#"data-three="scene""#));
1015/// assert!(html.contains(r#"data-three-mesh="torus-knot""#));
1016/// ```
1017#[derive(Debug, Clone, Default)]
1018#[must_use]
1019pub struct Scene {
1020    id: Option<String>,
1021    class: Option<String>,
1022    label: Option<String>,
1023    aspect: Option<Aspect>,
1024    background: Option<Color>,
1025    camera: Option<Camera>,
1026    controls: Option<Controls>,
1027    environment: Option<Environment>,
1028    turntable: Option<f32>,
1029    animate_reduced_motion: bool,
1030    objects: Vec<SceneObject>,
1031    fallback: Option<Markup>,
1032}
1033
1034impl Scene {
1035    /// Makes an empty scene with the defaults.
1036    pub fn new() -> Self {
1037        Self::default()
1038    }
1039
1040    /// Sets the element `id`.
1041    pub fn id(mut self, id: impl Into<String>) -> Self {
1042        self.id = Some(id.into());
1043        self
1044    }
1045
1046    /// Sets the element `class`, for your own size or layout CSS.
1047    pub fn class(mut self, class: impl Into<String>) -> Self {
1048        self.class = Some(class.into());
1049        self
1050    }
1051
1052    /// Sets an accessible label. The element gets `role="img"` and
1053    /// `aria-label`.
1054    pub fn label(mut self, label: impl Into<String>) -> Self {
1055        self.label = Some(label.into());
1056        self
1057    }
1058
1059    /// Sets the aspect ratio. Default: [`Aspect::Wide`] (16 / 9).
1060    pub const fn aspect(mut self, aspect: Aspect) -> Self {
1061        self.aspect = Some(aspect);
1062        self
1063    }
1064
1065    /// Sets a solid background color. Default: transparent.
1066    pub const fn background(mut self, color: Color) -> Self {
1067        self.background = Some(color);
1068        self
1069    }
1070
1071    /// Sets the camera.
1072    pub const fn camera(mut self, camera: Camera) -> Self {
1073        self.camera = Some(camera);
1074        self
1075    }
1076
1077    /// Sets the camera controls. Default: [`Controls::None`].
1078    pub const fn controls(mut self, controls: Controls) -> Self {
1079        self.controls = Some(controls);
1080        self
1081    }
1082
1083    /// Sets the image-based light.
1084    pub const fn environment(mut self, environment: Environment) -> Self {
1085        self.environment = Some(environment);
1086        self
1087    }
1088
1089    /// Turns the whole scene around the Y axis, in degrees per second.
1090    /// Negative values turn clockwise (seen from above).
1091    pub const fn turntable(mut self, degrees_per_second: f32) -> Self {
1092        if let Some(v) = finite(degrees_per_second) {
1093            self.turntable = Some(v);
1094        }
1095        self
1096    }
1097
1098    /// Keeps automatic motion (spin, turntable, clips) when the user prefers
1099    /// reduced motion. Default: automatic motion stops.
1100    pub const fn animate_reduced_motion(mut self) -> Self {
1101        self.animate_reduced_motion = true;
1102        self
1103    }
1104
1105    /// Adds a mesh, model, or light.
1106    #[expect(
1107        clippy::should_implement_trait,
1108        reason = "builder method, not arithmetic"
1109    )]
1110    pub fn add(mut self, object: impl Into<SceneObject>) -> Self {
1111        self.objects.push(object.into());
1112        self
1113    }
1114
1115    /// Sets fallback content. It shows without JavaScript, without WebGL,
1116    /// and when a model fails to load.
1117    pub fn fallback(mut self, markup: Markup) -> Self {
1118        self.fallback = Some(markup);
1119        self
1120    }
1121}
1122
1123impl Render for Scene {
1124    fn render(&self) -> Markup {
1125        let camera = self.camera.unwrap_or_default();
1126        html! {
1127            div role=[self.label.as_ref().map(|_| "img")]
1128                aria-label=[self.label.as_deref()]
1129                id=[self.id.as_deref()]
1130                class=[self.class.as_deref()]
1131                data-three="scene"
1132                data-three-aspect=[self.aspect.map(Aspect::attr)]
1133                data-three-background=[self.background]
1134                data-three-camera=[camera.position.map(Vec3::attr)]
1135                data-three-target=[camera.target.map(Vec3::attr)]
1136                data-three-fov=[camera.fov.map(num)]
1137                data-three-controls=[self.controls.map(Controls::attr)]
1138                data-three-environment=[self.environment.map(Environment::attr)]
1139                data-three-turntable=[self.turntable.map(num)]
1140                data-three-reduced=[self.animate_reduced_motion.then_some("animate")] {
1141                @for object in &self.objects {
1142                    (object)
1143                }
1144                @if let Some(fallback) = &self.fallback {
1145                    div data-three-fallback { (fallback) }
1146                }
1147            }
1148        }
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use proptest::prelude::*;
1156
1157    fn render(value: &impl Render) -> String {
1158        value.render().into_string()
1159    }
1160
1161    /// A scene that uses every attribute the builder can emit.
1162    fn full_scene() -> Scene {
1163        Scene::new()
1164            .id("hero")
1165            .class("hero-3d")
1166            .label("A demo")
1167            .aspect(Aspect::Square)
1168            .background(Color::hex(0x0010_1018))
1169            .camera(
1170                Camera::perspective()
1171                    .fov(40.0)
1172                    .position([0.0, 2.0, 6.0])
1173                    .target([0.0, 0.5, 0.0]),
1174            )
1175            .controls(Controls::Orbit)
1176            .environment(Environment::Room)
1177            .turntable(-12.5)
1178            .animate_reduced_motion()
1179            .add(
1180                Mesh::sphere(1.0)
1181                    .material(Material::Physical)
1182                    .color(Color::rgb(255, 0, 0))
1183                    .emissive(Color::hex(0x0000_ff00))
1184                    .metalness(0.5)
1185                    .roughness(0.25)
1186                    .opacity(0.75)
1187                    .wireframe()
1188                    .position([1.0, 2.0, 3.0])
1189                    .rotation([0.0, 90.0, 0.0])
1190                    .scale(Vec3::splat(2.0))
1191                    .spin([0.0, 30.0, 0.0]),
1192            )
1193            .add(
1194                Model::gltf("/m.glb")
1195                    .fit(2.0)
1196                    .play("Walk")
1197                    .position([0.0, -1.0, 0.0]),
1198            )
1199            .add(Model::gltf("/n.glb").play_all())
1200            .add(
1201                Light::hemisphere()
1202                    .color(Color::WHITE)
1203                    .ground(Color::BLACK)
1204                    .intensity(2.0)
1205                    .position([0.0, 5.0, 0.0]),
1206            )
1207            .fallback(html! { p { "No WebGL" } })
1208    }
1209
1210    #[test]
1211    fn empty_scene_is_one_bare_element() {
1212        assert_eq!(render(&Scene::new()), r#"<div data-three="scene"></div>"#);
1213    }
1214
1215    #[test]
1216    fn full_scene_renders_every_scene_attribute() {
1217        let html = render(&full_scene());
1218        for expected in [
1219            r#"role="img""#,
1220            r#"aria-label="A demo""#,
1221            r#"id="hero""#,
1222            r#"class="hero-3d""#,
1223            r#"data-three-aspect="1/1""#,
1224            r##"data-three-background="#101018""##,
1225            r#"data-three-camera="0,2,6""#,
1226            r#"data-three-target="0,0.5,0""#,
1227            r#"data-three-fov="40""#,
1228            r#"data-three-controls="orbit""#,
1229            r#"data-three-environment="room""#,
1230            r#"data-three-turntable="-12.5""#,
1231            r#"data-three-reduced="animate""#,
1232            "<div data-three-fallback><p>No WebGL</p></div>",
1233        ] {
1234            assert!(html.contains(expected), "{expected} in {html}");
1235        }
1236    }
1237
1238    #[test]
1239    fn mesh_renders_every_attribute() {
1240        let scene = full_scene();
1241        let SceneObject::Mesh(mesh) = &scene.objects[0] else {
1242            panic!("first object is the mesh");
1243        };
1244        assert_eq!(
1245            render(mesh),
1246            concat!(
1247                r#"<div hidden data-three-mesh="sphere" data-three-args="1" "#,
1248                r##"data-three-material="physical" data-three-color="#ff0000" "##,
1249                r##"data-three-emissive="#00ff00" data-three-metalness="0.5" "##,
1250                r#"data-three-roughness="0.25" data-three-opacity="0.75" "#,
1251                r#"data-three-wireframe="true" data-three-position="1,2,3" "#,
1252                r#"data-three-rotation="0,90,0" data-three-scale="2,2,2" "#,
1253                r#"data-three-spin="0,30,0"></div>"#
1254            )
1255        );
1256    }
1257
1258    #[test]
1259    fn model_and_light_render_their_attributes() {
1260        let html = render(&full_scene());
1261        assert!(html.contains(concat!(
1262            r#"<div hidden data-three-model="/m.glb" data-three-fit="2" "#,
1263            r#"data-three-clip="Walk" data-three-position="0,-1,0"></div>"#
1264        )));
1265        assert!(
1266            html.contains(r#"<div hidden data-three-model="/n.glb" data-three-clip="*"></div>"#)
1267        );
1268        assert!(html.contains(concat!(
1269            r##"<div hidden data-three-light="hemisphere" data-three-color="#ffffff" "##,
1270            r##"data-three-ground="#000000" data-three-intensity="2" "##,
1271            r#"data-three-position="0,5,0"></div>"#
1272        )));
1273    }
1274
1275    #[test]
1276    fn objects_render_in_order_before_the_fallback() {
1277        let html = render(&full_scene());
1278        let mesh = html.find("data-three-mesh").expect("mesh");
1279        let model = html.find("data-three-model").expect("model");
1280        let light = html.find("data-three-light").expect("light");
1281        let fallback = html.find("data-three-fallback").expect("fallback");
1282        assert!(mesh < model && model < light && light < fallback, "{html}");
1283    }
1284
1285    #[test]
1286    fn every_geometry_renders_its_kind_and_sizes() {
1287        let cases = [
1288            (Mesh::cube(2.0), "box", "2,2,2"),
1289            (Mesh::cuboid(1.0, 2.0, 3.0), "box", "1,2,3"),
1290            (Mesh::sphere(0.5), "sphere", "0.5"),
1291            (Mesh::plane(4.0, 3.0), "plane", "4,3"),
1292            (Mesh::torus(1.0, 0.3), "torus", "1,0.3"),
1293            (Mesh::torus_knot(1.0, 0.3), "torus-knot", "1,0.3"),
1294            (Mesh::cylinder(0.0, 1.0, 2.0), "cylinder", "0,1,2"),
1295            (Mesh::cone(1.0, 2.0), "cone", "1,2"),
1296            (Mesh::capsule(0.5, 1.0), "capsule", "0.5,1"),
1297            (Mesh::icosahedron(1.0), "icosahedron", "1"),
1298            (Mesh::dodecahedron(1.0), "dodecahedron", "1"),
1299            (Mesh::octahedron(1.0), "octahedron", "1"),
1300            (Mesh::tetrahedron(1.0), "tetrahedron", "1"),
1301            (Mesh::ring(0.5, 1.0), "ring", "0.5,1"),
1302        ];
1303        for (mesh, kind, args) in cases {
1304            let html = render(&mesh);
1305            assert!(
1306                html.contains(&format!(r#"data-three-mesh="{kind}""#)),
1307                "{html}"
1308            );
1309            assert!(
1310                html.contains(&format!(r#"data-three-args="{args}""#)),
1311                "{html}"
1312            );
1313        }
1314    }
1315
1316    #[test]
1317    fn bad_sizes_render_as_defaults() {
1318        let html = render(&Mesh::cuboid(f32::NAN, -1.0, f32::INFINITY));
1319        assert!(html.contains(r#"data-three-args="1,1,1""#), "{html}");
1320        let html = render(&Mesh::torus(-0.0, f32::NEG_INFINITY));
1321        assert!(html.contains(r#"data-three-args="0,0.2""#), "{html}");
1322    }
1323
1324    #[test]
1325    fn setters_ignore_non_finite_input() {
1326        let html = render(
1327            &Scene::new()
1328                .camera(Camera::perspective().fov(f32::NAN))
1329                .turntable(f32::INFINITY)
1330                .add(
1331                    Mesh::cube(1.0)
1332                        .metalness(f32::NAN)
1333                        .roughness(f32::INFINITY)
1334                        .opacity(f32::NEG_INFINITY),
1335                )
1336                .add(Model::gltf("/m.glb").fit(f32::NAN))
1337                .add(Light::point().intensity(f32::NAN)),
1338        );
1339        for attr in [
1340            "data-three-fov",
1341            "data-three-turntable",
1342            "data-three-metalness",
1343            "data-three-roughness",
1344            "data-three-opacity",
1345            "data-three-fit",
1346            "data-three-intensity",
1347        ] {
1348            assert!(!html.contains(attr), "{attr} in {html}");
1349        }
1350    }
1351
1352    #[test]
1353    fn vectors_replace_non_finite_components() {
1354        let html = render(
1355            &Mesh::cube(1.0)
1356                .position([f32::NAN, 1.0, 2.0])
1357                .scale([f32::INFINITY, 2.0, 2.0])
1358                .spin([0.0, f32::NAN, 0.0]),
1359        );
1360        assert!(html.contains(r#"data-three-position="0,1,2""#), "{html}");
1361        assert!(html.contains(r#"data-three-scale="1,2,2""#), "{html}");
1362        assert!(html.contains(r#"data-three-spin="0,0,0""#), "{html}");
1363        let html =
1364            render(&Scene::new().camera(Camera::perspective().position([0.0, f32::NAN, 9.0])));
1365        assert!(html.contains(r#"data-three-camera="0,1,9""#), "{html}");
1366    }
1367
1368    #[test]
1369    fn ranges_are_clamped() {
1370        let html = render(&Mesh::cube(1.0).metalness(2.0).roughness(-1.0).opacity(1.5));
1371        assert!(html.contains(r#"data-three-metalness="1""#), "{html}");
1372        assert!(html.contains(r#"data-three-roughness="0""#), "{html}");
1373        assert!(html.contains(r#"data-three-opacity="1""#), "{html}");
1374        let html = render(&Scene::new().camera(Camera::perspective().fov(500.0)));
1375        assert!(html.contains(r#"data-three-fov="179""#), "{html}");
1376        let html = render(&Light::spot().intensity(-3.0));
1377        assert!(html.contains(r#"data-three-intensity="0""#), "{html}");
1378        let html = render(&Model::gltf("/m.glb").fit(-1.0));
1379        assert!(!html.contains("data-three-fit"), "{html}");
1380    }
1381
1382    #[test]
1383    fn aspects_render_ratios() {
1384        for (aspect, value) in [
1385            (Aspect::Wide, "16/9"),
1386            (Aspect::Standard, "4/3"),
1387            (Aspect::Square, "1/1"),
1388            (Aspect::Ultrawide, "21/9"),
1389            (Aspect::Photo, "3/2"),
1390            (Aspect::Portrait, "3/4"),
1391            (Aspect::Ratio(2.35, 1.0), "2.35/1"),
1392            (Aspect::Ratio(0.0, 1.0), "16/9"),
1393            (Aspect::Ratio(f32::NAN, 1.0), "16/9"),
1394        ] {
1395            let html = render(&Scene::new().aspect(aspect));
1396            assert!(
1397                html.contains(&format!(r#"data-three-aspect="{value}""#)),
1398                "{html}"
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn enums_render_their_names() {
1405        for (controls, value) in [
1406            (Controls::None, "none"),
1407            (Controls::Orbit, "orbit"),
1408            (Controls::OrbitNoZoom, "orbit-no-zoom"),
1409        ] {
1410            assert!(
1411                render(&Scene::new().controls(controls))
1412                    .contains(&format!(r#"data-three-controls="{value}""#))
1413            );
1414        }
1415        for (material, value) in [
1416            (Material::Standard, "standard"),
1417            (Material::Physical, "physical"),
1418            (Material::Basic, "basic"),
1419            (Material::Lambert, "lambert"),
1420            (Material::Phong, "phong"),
1421            (Material::Normal, "normal"),
1422        ] {
1423            assert!(
1424                render(&Mesh::cube(1.0).material(material))
1425                    .contains(&format!(r#"data-three-material="{value}""#))
1426            );
1427        }
1428        for (light, value) in [
1429            (Light::ambient(), "ambient"),
1430            (Light::directional(), "directional"),
1431            (Light::point(), "point"),
1432            (Light::spot(), "spot"),
1433            (Light::hemisphere(), "hemisphere"),
1434        ] {
1435            assert_eq!(
1436                render(&light),
1437                format!(r#"<div hidden data-three-light="{value}"></div>"#)
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn light_positions_fall_back_to_per_kind_defaults() {
1444        let parse = include_str!("../assets/parse.js");
1445        for kind in ALL_LIGHTS {
1446            let html = render(&Light::new(kind).position([f32::NAN; 3]));
1447            let default = kind.default_position().attr();
1448            assert!(
1449                html.contains(&format!(r#"data-three-position="{default}""#)),
1450                "{html}"
1451            );
1452            // parse.js has the same default: `position: [x, y, z]`.
1453            let js = format!("position: [{}]", default.replace(',', ", "));
1454            let line = parse
1455                .lines()
1456                .find(|l| l.contains(&format!("\"{}\": Object.freeze", kind.attr())))
1457                .expect("LIGHTS entry");
1458            assert!(line.contains(&js), "{line} has {js}");
1459        }
1460    }
1461
1462    #[test]
1463    fn extreme_aspect_ratios_are_clamped() {
1464        for (aspect, value) in [
1465            (Aspect::Ratio(1.0, 1e-30), "10/1"),
1466            (Aspect::Ratio(1.0, 1000.0), "0.1/1"),
1467            (Aspect::Ratio(10.0, 1.0), "10/1"),
1468        ] {
1469            let html = render(&Scene::new().aspect(aspect));
1470            assert!(
1471                html.contains(&format!(r#"data-three-aspect="{value}""#)),
1472                "{html}"
1473            );
1474        }
1475    }
1476
1477    #[test]
1478    fn clip_names_are_trimmed_and_empty_names_ignored() {
1479        assert!(
1480            render(&Model::gltf("/m.glb").play(" Walk ")).contains(r#"data-three-clip="Walk""#)
1481        );
1482        assert!(!render(&Model::gltf("/m.glb").play("  ")).contains("data-three-clip"));
1483    }
1484
1485    #[test]
1486    fn defaults_match_the_documented_defaults() {
1487        assert_eq!(Aspect::default(), Aspect::Wide);
1488        assert_eq!(Controls::default(), Controls::None);
1489        assert_eq!(Material::default(), Material::Standard);
1490        assert_eq!(Vec3::default(), Vec3::ZERO);
1491    }
1492
1493    #[test]
1494    fn colors_format_as_hex() {
1495        assert_eq!(Color::hex(0xff12_3456).to_string(), "#123456");
1496        assert_eq!(Color::hex(0xff12_3456).value(), 0x0012_3456);
1497        assert_eq!(Color::rgb(1, 2, 3).to_string(), "#010203");
1498        assert_eq!(Color::WHITE.to_string(), "#ffffff");
1499        assert_eq!(Color::BLACK.to_string(), "#000000");
1500    }
1501
1502    #[test]
1503    fn vec3_conversions_agree() {
1504        assert_eq!(Vec3::from((1.0, 2.0, 3.0)), Vec3::new(1.0, 2.0, 3.0));
1505        assert_eq!(Vec3::from([1.0, 2.0, 3.0]), Vec3::new(1.0, 2.0, 3.0));
1506        assert_eq!(Vec3::ZERO, Vec3::splat(0.0));
1507    }
1508
1509    #[test]
1510    fn user_text_is_escaped() {
1511        let html = render(
1512            &Scene::new()
1513                .label(r#""><script>alert(1)</script>"#)
1514                .add(Model::gltf(r#"/a.glb"><script>"#).play(r#"x"y"#)),
1515        );
1516        assert!(!html.contains("<script>"), "{html}");
1517        assert!(html.contains("&quot;&gt;&lt;script&gt;"), "{html}");
1518    }
1519
1520    #[test]
1521    fn scene_renders_inside_maud() {
1522        let html = html! { main { (Scene::new()) } }.into_string();
1523        assert_eq!(html, r#"<main><div data-three="scene"></div></main>"#);
1524    }
1525
1526    #[test]
1527    fn every_emitted_attribute_and_value_is_known_to_the_runtime() {
1528        // Rust and JS stay in lockstep: parse.js must name every attribute
1529        // and every keyword value the builder emits.
1530        let parse = include_str!("../assets/parse.js");
1531        let html = render(&full_scene());
1532        let mut names: Vec<&str> = html
1533            .split(|c: char| c.is_whitespace() || c == '<' || c == '>')
1534            .filter_map(|token| token.split('=').next())
1535            .filter(|name| name.starts_with("data-three"))
1536            .collect();
1537        names.sort_unstable();
1538        names.dedup();
1539        assert!(names.len() >= 30, "{names:?}");
1540        for name in names {
1541            assert!(
1542                parse.contains(&format!("\"{name}\"")),
1543                "parse.js names {name}"
1544            );
1545        }
1546        let geometries: Vec<&str> = all_geometries().iter().map(Geometry::kind).collect();
1547        let mut keywords: Vec<&str> = Vec::new();
1548        keywords.extend(ALL_CONTROLS.map(Controls::attr));
1549        keywords.extend(ALL_ENVIRONMENTS.map(Environment::attr));
1550        keywords.extend(ALL_MATERIALS.map(Material::attr));
1551        keywords.extend(ALL_LIGHTS.map(LightKind::attr));
1552        keywords.push("animate");
1553        for value in geometries.iter().chain(&keywords) {
1554            assert!(
1555                parse.contains(&format!("\"{value}\"")),
1556                "parse.js knows {value}"
1557            );
1558        }
1559    }
1560
1561    /// Every `Controls` variant. The match fails to compile when a variant is added.
1562    const ALL_CONTROLS: [Controls; 3] = [Controls::None, Controls::Orbit, Controls::OrbitNoZoom];
1563    const _: fn(Controls) = |c| match c {
1564        Controls::None | Controls::Orbit | Controls::OrbitNoZoom => {}
1565    };
1566    /// Every `Environment` variant.
1567    const ALL_ENVIRONMENTS: [Environment; 1] = [Environment::Room];
1568    const _: fn(Environment) = |e| match e {
1569        Environment::Room => {}
1570    };
1571    /// Every `Material` variant.
1572    const ALL_MATERIALS: [Material; 6] = [
1573        Material::Standard,
1574        Material::Physical,
1575        Material::Basic,
1576        Material::Lambert,
1577        Material::Phong,
1578        Material::Normal,
1579    ];
1580    const _: fn(Material) = |m| match m {
1581        Material::Standard
1582        | Material::Physical
1583        | Material::Basic
1584        | Material::Lambert
1585        | Material::Phong
1586        | Material::Normal => {}
1587    };
1588    /// Every `LightKind` variant.
1589    const ALL_LIGHTS: [LightKind; 5] = [
1590        LightKind::Ambient,
1591        LightKind::Directional,
1592        LightKind::Point,
1593        LightKind::Spot,
1594        LightKind::Hemisphere,
1595    ];
1596    const _: fn(LightKind) = |k| match k {
1597        LightKind::Ambient
1598        | LightKind::Directional
1599        | LightKind::Point
1600        | LightKind::Spot
1601        | LightKind::Hemisphere => {}
1602    };
1603
1604    /// One value of every `Geometry` variant.
1605    fn all_geometries() -> Vec<Geometry> {
1606        let geometries: Vec<Geometry> = [
1607            Mesh::cube(1.0),
1608            Mesh::sphere(1.0),
1609            Mesh::plane(1.0, 1.0),
1610            Mesh::torus(1.0, 0.2),
1611            Mesh::torus_knot(1.0, 0.2),
1612            Mesh::cylinder(1.0, 1.0, 1.0),
1613            Mesh::cone(1.0, 1.0),
1614            Mesh::capsule(1.0, 1.0),
1615            Mesh::icosahedron(1.0),
1616            Mesh::dodecahedron(1.0),
1617            Mesh::octahedron(1.0),
1618            Mesh::tetrahedron(1.0),
1619            Mesh::ring(0.5, 1.0),
1620        ]
1621        .map(|mesh| mesh.geometry)
1622        .into();
1623        for g in &geometries {
1624            // Fails to compile when a variant is added.
1625            match g {
1626                Geometry::Box { .. }
1627                | Geometry::Sphere { .. }
1628                | Geometry::Plane { .. }
1629                | Geometry::Torus { .. }
1630                | Geometry::TorusKnot { .. }
1631                | Geometry::Cylinder { .. }
1632                | Geometry::Cone { .. }
1633                | Geometry::Capsule { .. }
1634                | Geometry::Icosahedron { .. }
1635                | Geometry::Dodecahedron { .. }
1636                | Geometry::Octahedron { .. }
1637                | Geometry::Tetrahedron { .. }
1638                | Geometry::Ring { .. } => {}
1639            }
1640        }
1641        geometries
1642    }
1643
1644    /// True when `token` matches the parse.js `NUMBER` grammar:
1645    /// `^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$` (any case).
1646    fn js_number(token: &str) -> bool {
1647        let body = token.strip_prefix(['+', '-']).unwrap_or(token);
1648        let (mantissa, exponent) = match body.split_once(['e', 'E']) {
1649            Some((m, e)) => (m, Some(e)),
1650            None => (body, None),
1651        };
1652        let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
1653        let mantissa_ok = match mantissa.split_once('.') {
1654            Some((int, frac)) => {
1655                (digits(int) && (frac.is_empty() || digits(frac)))
1656                    || (int.is_empty() && digits(frac))
1657            }
1658            None => digits(mantissa),
1659        };
1660        let exponent_ok = exponent.is_none_or(|e| digits(e.strip_prefix(['+', '-']).unwrap_or(e)));
1661        mantissa_ok && exponent_ok
1662    }
1663
1664    #[test]
1665    fn js_number_matches_the_parse_js_grammar() {
1666        for good in [
1667            "0",
1668            "-1",
1669            "+2.5",
1670            "3.",
1671            ".5",
1672            "1e5",
1673            "1E-5",
1674            "340282350000000000000000000000000000000",
1675        ] {
1676            assert!(js_number(good), "{good}");
1677        }
1678        for bad in [
1679            "", "-", ".", "1.2.3", "NaN", "inf", "1e", "0x10", "1px", "e5",
1680        ] {
1681            assert!(!js_number(bad), "{bad}");
1682        }
1683    }
1684
1685    /// Any `f32`, with non-finite values made likely.
1686    fn any_f32() -> impl Strategy<Value = f32> {
1687        prop_oneof![
1688            Just(f32::NAN),
1689            Just(f32::INFINITY),
1690            Just(f32::NEG_INFINITY),
1691            Just(-0.0_f32),
1692            any::<f32>(),
1693        ]
1694    }
1695
1696    /// Each number inside the `data-three-*` attribute values.
1697    fn numbers(html: &str) -> Vec<String> {
1698        let mut out = Vec::new();
1699        for part in html.split("data-three-").skip(1) {
1700            let Some(value) = part.split('"').nth(1) else {
1701                continue;
1702            };
1703            if value.starts_with(|c: char| c.is_ascii_digit() || c == '-') {
1704                out.extend(value.split([',', '/']).map(str::to_owned));
1705            }
1706        }
1707        out
1708    }
1709
1710    proptest! {
1711        #[test]
1712        fn builder_never_emits_non_finite_numbers(
1713            a in any_f32(), b in any_f32(), c in any_f32(), d in any_f32()
1714        ) {
1715            let html = render(
1716                &Scene::new()
1717                    .aspect(Aspect::Ratio(a, b))
1718                    .camera(Camera::perspective().fov(a).position([a, b, c]).target([d, a, b]))
1719                    .turntable(c)
1720                    .add(
1721                        Mesh::cylinder(a, b, c)
1722                            .metalness(d)
1723                            .roughness(a)
1724                            .opacity(b)
1725                            .position([a, b, c])
1726                            .rotation([b, c, d])
1727                            .scale([c, d, a])
1728                            .spin([d, a, b]),
1729                    )
1730                    .add(Model::gltf("/m.glb").fit(a).position([d, d, d]))
1731                    .add(Light::spot().intensity(c).position([a, c, d])),
1732            );
1733            prop_assert!(!html.contains("NaN"), "{}", html);
1734            prop_assert!(!html.contains("inf"), "{}", html);
1735            let numbers = numbers(&html);
1736            prop_assert!(numbers.len() > 20, "{:?}", numbers);
1737            for number in numbers {
1738                prop_assert!(js_number(&number), "parse.js reads {}", number);
1739                let parsed: f32 = number.parse().map_err(|e| TestCaseError::fail(format!("{number}: {e}")))?;
1740                prop_assert!(parsed.is_finite(), "{}", number);
1741                prop_assert!(number != "-0", "{}", html);
1742            }
1743        }
1744
1745        #[test]
1746        fn vec3_attr_round_trips_finite_values(x in any::<f32>(), y in any::<f32>(), z in any::<f32>()) {
1747            prop_assume!(x.is_finite() && y.is_finite() && z.is_finite());
1748            let attr = Vec3::new(x, y, z).attr();
1749            let parts: Vec<f32> = attr.split(',').map(|p| p.parse().expect("number")).collect();
1750            prop_assert_eq!(parts, vec![x, y, z]);
1751            prop_assert!(attr.split(',').all(js_number), "{}", attr);
1752        }
1753
1754        #[test]
1755        fn clamped_values_stay_in_range(v in any_f32()) {
1756            let mesh = Mesh::cube(1.0).metalness(v).roughness(v).opacity(v);
1757            for value in [mesh.metalness, mesh.roughness, mesh.opacity].into_iter().flatten() {
1758                prop_assert!((0.0..=1.0).contains(&value));
1759            }
1760            if let Some(fov) = Camera::perspective().fov(v).fov {
1761                prop_assert!((1.0..=179.0).contains(&fov));
1762            }
1763        }
1764    }
1765}