1use autumn_web::{Markup, html};
16use maud::Render;
17
18const fn finite_or(value: f32, fallback: f32) -> f32 {
20 if value.is_finite() { value } else { fallback }
21}
22
23const fn finite(value: f32) -> Option<f32> {
25 if value.is_finite() { Some(value) } else { None }
26}
27
28fn num(value: f32) -> String {
30 if value == 0.0 {
31 "0".to_owned()
32 } else {
33 format!("{value}")
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Default)]
47pub struct Vec3 {
48 pub x: f32,
50 pub y: f32,
52 pub z: f32,
54}
55
56impl Vec3 {
57 pub const ZERO: Self = Self::new(0.0, 0.0, 0.0);
59
60 #[must_use]
62 pub const fn new(x: f32, y: f32, z: f32) -> Self {
63 Self { x, y, z }
64 }
65
66 #[must_use]
68 pub const fn splat(value: f32) -> Self {
69 Self::new(value, value, value)
70 }
71
72 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub struct Color(u32);
110
111impl Color {
112 pub const WHITE: Self = Self(0x00ff_ffff);
114 pub const BLACK: Self = Self(0);
116
117 #[must_use]
120 pub const fn hex(value: u32) -> Self {
121 Self(value & 0x00ff_ffff)
122 }
123
124 #[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 #[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
143const ASPECT_MIN: f32 = 0.1;
145const ASPECT_MAX: f32 = 10.0;
147
148#[derive(Debug, Clone, Copy, PartialEq, Default)]
153#[non_exhaustive]
154pub enum Aspect {
155 #[default]
157 Wide,
158 Standard,
160 Square,
162 Ultrawide,
164 Photo,
166 Portrait,
168 Ratio(f32, f32),
171}
172
173impl Aspect {
174 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198#[non_exhaustive]
199pub enum Controls {
200 #[default]
202 None,
203 Orbit,
205 OrbitNoZoom,
207}
208
209impl Controls {
210 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[non_exhaustive]
223pub enum Environment {
224 Room,
227}
228
229impl Environment {
230 const fn attr(self) -> &'static str {
232 match self {
233 Self::Room => "room",
234 }
235 }
236}
237
238#[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 pub const fn perspective() -> Self {
260 Self {
261 fov: None,
262 position: None,
263 target: None,
264 }
265 }
266
267 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
293#[non_exhaustive]
294pub enum Geometry {
295 Box {
297 width: f32,
299 height: f32,
301 depth: f32,
303 },
304 Sphere {
306 radius: f32,
308 },
309 Plane {
311 width: f32,
313 height: f32,
315 },
316 Torus {
318 radius: f32,
320 tube: f32,
322 },
323 TorusKnot {
325 radius: f32,
327 tube: f32,
329 },
330 Cylinder {
332 radius_top: f32,
334 radius_bottom: f32,
336 height: f32,
338 },
339 Cone {
341 radius: f32,
343 height: f32,
345 },
346 Capsule {
348 radius: f32,
350 length: f32,
352 },
353 Icosahedron {
355 radius: f32,
357 },
358 Dodecahedron {
360 radius: f32,
362 },
363 Octahedron {
365 radius: f32,
367 },
368 Tetrahedron {
370 radius: f32,
372 },
373 Ring {
375 inner_radius: f32,
377 outer_radius: f32,
379 },
380}
381
382impl Geometry {
383 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
451#[non_exhaustive]
452pub enum Material {
453 #[default]
455 Standard,
456 Physical,
458 Basic,
460 Lambert,
462 Phong,
464 Normal,
466}
467
468impl Material {
469 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#[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
491macro_rules! transform_setters {
493 () => {
494 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 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 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 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#[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 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 pub const fn cube(size: f32) -> Self {
572 Self::cuboid(size, size, size)
573 }
574
575 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 pub const fn sphere(radius: f32) -> Self {
586 Self::new(Geometry::Sphere { radius })
587 }
588
589 pub const fn plane(width: f32, height: f32) -> Self {
591 Self::new(Geometry::Plane { width, height })
592 }
593
594 pub const fn torus(radius: f32, tube: f32) -> Self {
596 Self::new(Geometry::Torus { radius, tube })
597 }
598
599 pub const fn torus_knot(radius: f32, tube: f32) -> Self {
601 Self::new(Geometry::TorusKnot { radius, tube })
602 }
603
604 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 pub const fn cone(radius: f32, height: f32) -> Self {
615 Self::new(Geometry::Cone { radius, height })
616 }
617
618 pub const fn capsule(radius: f32, length: f32) -> Self {
620 Self::new(Geometry::Capsule { radius, length })
621 }
622
623 pub const fn icosahedron(radius: f32) -> Self {
625 Self::new(Geometry::Icosahedron { radius })
626 }
627
628 pub const fn dodecahedron(radius: f32) -> Self {
630 Self::new(Geometry::Dodecahedron { radius })
631 }
632
633 pub const fn octahedron(radius: f32) -> Self {
635 Self::new(Geometry::Octahedron { radius })
636 }
637
638 pub const fn tetrahedron(radius: f32) -> Self {
640 Self::new(Geometry::Tetrahedron { radius })
641 }
642
643 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 pub const fn material(mut self, material: Material) -> Self {
653 self.material = Some(material);
654 self
655 }
656
657 pub const fn color(mut self, color: Color) -> Self {
659 self.color = Some(color);
660 self
661 }
662
663 pub const fn emissive(mut self, color: Color) -> Self {
665 self.emissive = Some(color);
666 self
667 }
668
669 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
726#[non_exhaustive]
727pub enum Clip {
728 All,
730 Named(String),
732}
733
734#[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 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 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 pub fn play_all(mut self) -> Self {
779 self.clip = Some(Clip::All);
780 self
781 }
782
783 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819#[non_exhaustive]
820pub enum LightKind {
821 Ambient,
823 Directional,
825 Point,
827 Spot,
829 Hemisphere,
831}
832
833impl LightKind {
834 #[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 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#[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 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 pub const fn ambient() -> Self {
894 Self::new(LightKind::Ambient)
895 }
896
897 pub const fn directional() -> Self {
899 Self::new(LightKind::Directional)
900 }
901
902 pub const fn point() -> Self {
904 Self::new(LightKind::Point)
905 }
906
907 pub const fn spot() -> Self {
909 Self::new(LightKind::Spot)
910 }
911
912 pub const fn hemisphere() -> Self {
914 Self::new(LightKind::Hemisphere)
915 }
916
917 pub const fn color(mut self, color: Color) -> Self {
919 self.color = Some(color);
920 self
921 }
922
923 pub const fn ground(mut self, color: Color) -> Self {
925 self.ground = Some(color);
926 self
927 }
928
929 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 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#[derive(Debug, Clone, PartialEq)]
960#[non_exhaustive]
961pub enum SceneObject {
962 Mesh(Mesh),
964 Model(Model),
966 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#[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 pub fn new() -> Self {
1037 Self::default()
1038 }
1039
1040 pub fn id(mut self, id: impl Into<String>) -> Self {
1042 self.id = Some(id.into());
1043 self
1044 }
1045
1046 pub fn class(mut self, class: impl Into<String>) -> Self {
1048 self.class = Some(class.into());
1049 self
1050 }
1051
1052 pub fn label(mut self, label: impl Into<String>) -> Self {
1055 self.label = Some(label.into());
1056 self
1057 }
1058
1059 pub const fn aspect(mut self, aspect: Aspect) -> Self {
1061 self.aspect = Some(aspect);
1062 self
1063 }
1064
1065 pub const fn background(mut self, color: Color) -> Self {
1067 self.background = Some(color);
1068 self
1069 }
1070
1071 pub const fn camera(mut self, camera: Camera) -> Self {
1073 self.camera = Some(camera);
1074 self
1075 }
1076
1077 pub const fn controls(mut self, controls: Controls) -> Self {
1079 self.controls = Some(controls);
1080 self
1081 }
1082
1083 pub const fn environment(mut self, environment: Environment) -> Self {
1085 self.environment = Some(environment);
1086 self
1087 }
1088
1089 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 pub const fn animate_reduced_motion(mut self) -> Self {
1101 self.animate_reduced_motion = true;
1102 self
1103 }
1104
1105 #[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 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 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 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(""><script>"), "{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 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 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 const ALL_ENVIRONMENTS: [Environment; 1] = [Environment::Room];
1568 const _: fn(Environment) = |e| match e {
1569 Environment::Room => {}
1570 };
1571 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 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 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 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 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 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 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}