Skip to main content

gpui_kit/media/
model_viewer.rs

1//! A bounded viewer for a 3D model: orbit, flat shading, and a refusal.
2//!
3//! # What this component will not do
4//!
5//! **It does not load anything.** The bytes are read by
6//! [`ModelScene::parse`], which the host calls, and the outcome — a scene or a
7//! refusal — is what this component is handed. There is no file, no network,
8//! and no asset resolution here.
9//!
10//! **It does not draw what it did not read.** The reader takes positions and
11//! triangles; it does not take materials, textures, or normals. So the model
12//! is drawn flat-shaded from face normals it computed, or as a wireframe, and
13//! neither is presented as the material the document described.
14//!
15//! **It does not scale a refusal down into an empty frame.** A document past
16//! a bound and a document outside the subset are two different sentences, and
17//! each names what it asked for and what was allowed. A viewer with no model
18//! at all is a third.
19//!
20//! **It does not turn the model by itself.** Orbit is caller-owned, exactly
21//! as an image viewer's fit is: a drag reports the angles it asks for, and the
22//! model turns when the caller says it did.
23
24use std::f32::consts::PI;
25use std::rc::Rc;
26
27use gpui::{
28    App, Bounds, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, PathBuilder,
29    Pixels, Point, RenderOnce, SharedString, Size, Styled, Window, canvas, div,
30    prelude::FluentBuilder, px, size,
31};
32use gpui_kit_assets::Icon;
33use gpui_kit_semantics::{NodeSpec, Role, Semantic};
34use gpui_kit_theme::{
35    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, TypeScale,
36};
37
38use crate::controls::button::IconButton;
39use crate::controls::segmented::{Segment, SegmentedControl};
40use crate::foundation::{Disableable, FocusRing, Ident, Sizable, StyledExt, text};
41use crate::layout::measure;
42use crate::media::gltf::{ModelBounds, ModelError, ModelScene};
43use crate::media::notice;
44use crate::motion::keyed;
45use crate::strings::{ActiveStrings, StringKey};
46
47/// How tall the frame is when the caller says nothing.
48const DEFAULT_HEIGHT: f32 = 320.0;
49
50/// How much of the shorter side of the frame the model's own sphere fills.
51const FIT: f32 = 0.42;
52
53/// How far a full drag across the frame turns the model, in turns.
54const DRAG_TURNS: f32 = 1.0;
55
56/// How far the pitch may go before the model would pass through its own pole.
57const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
58
59/// The darkest a lit face gets, so a face turned away is still a face.
60const AMBIENT: f32 = 0.32;
61
62/// How the model is drawn.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum ModelShading {
65    /// Every face filled, lit by its own normal. Nothing here is the
66    /// document's material, because the reader does not read one.
67    #[default]
68    Flat,
69    /// Every triangle's three edges, and no fill.
70    Wireframe,
71}
72
73impl ModelShading {
74    /// The name a semantic node publishes and a control addresses.
75    pub fn name(self) -> &'static str {
76        match self {
77            Self::Flat => "flat",
78            Self::Wireframe => "wireframe",
79        }
80    }
81}
82
83/// What the viewer has, as the host reports it.
84#[derive(Debug, Clone, PartialEq)]
85pub enum ModelState {
86    /// No model has been handed to this viewer.
87    Empty,
88    /// The host is still reading one.
89    Loading,
90    /// A document the reader accepted.
91    Ready(Rc<ModelScene>),
92    /// A document the reader refused, and why.
93    Rejected(ModelError),
94}
95
96impl ModelState {
97    /// Reads a document and answers with the state it produced.
98    ///
99    /// This is the whole of what a host has to do: the refusal is a state and
100    /// not an error to be handled somewhere the reader cannot see it.
101    pub fn read(bytes: &[u8], bounds: ModelBounds) -> Self {
102        match ModelScene::parse(bytes, bounds) {
103            Ok(scene) => Self::Ready(Rc::new(scene)),
104            Err(error) => Self::Rejected(error),
105        }
106    }
107
108    /// The name a semantic node publishes.
109    pub fn name(&self) -> &'static str {
110        match self {
111            Self::Empty => "empty",
112            Self::Loading => "loading",
113            Self::Ready(_) => "ready",
114            Self::Rejected(error) => error.name(),
115        }
116    }
117}
118
119/// What a model viewer reports. It applies none of it.
120#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum ModelViewerEvent {
122    /// A drag or the reset control asked for these angles, in radians.
123    OrbitChanged { yaw: f32, pitch: f32 },
124    /// The reader asked for the other shading.
125    ShadingChanged(ModelShading),
126}
127
128type EventHandler = Rc<dyn Fn(&ModelViewerEvent, &mut Window, &mut App)>;
129
130/// What a drag remembers between two builds of the same viewer.
131#[derive(Debug, Default)]
132struct Orbiting {
133    held: bool,
134    at: Option<Point<Pixels>>,
135}
136
137/// A bounded viewer for one 3D model.
138#[derive(IntoElement)]
139pub struct ModelViewer {
140    ident: Ident,
141    title: Option<SharedString>,
142    state: ModelState,
143    shading: ModelShading,
144    yaw: f32,
145    pitch: f32,
146    height: f32,
147    disabled: bool,
148    on_event: Option<EventHandler>,
149}
150
151impl std::fmt::Debug for ModelViewer {
152    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        formatter
154            .debug_struct("ModelViewer")
155            .field("ident", &self.ident)
156            .field("state", &self.state.name())
157            .field("shading", &self.shading)
158            .field("orbit", &(self.yaw, self.pitch))
159            .field("disabled", &self.disabled)
160            .field("has_handler", &self.on_event.is_some())
161            .finish()
162    }
163}
164
165impl ModelViewer {
166    /// A viewer with nothing in it.
167    pub fn new(ident: impl Into<Ident>) -> Self {
168        Self {
169            ident: ident.into(),
170            title: None,
171            state: ModelState::Empty,
172            shading: ModelShading::default(),
173            yaw: PI / 6.0,
174            pitch: PI / 8.0,
175            height: DEFAULT_HEIGHT,
176            disabled: false,
177            on_event: None,
178        }
179    }
180
181    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
182        self.title = Some(title.into());
183        self
184    }
185
186    pub fn state(mut self, state: ModelState) -> Self {
187        self.state = state;
188        self
189    }
190
191    /// A document the reader accepted.
192    pub fn scene(self, scene: Rc<ModelScene>) -> Self {
193        self.state(ModelState::Ready(scene))
194    }
195
196    /// A document the reader refused.
197    pub fn rejected(self, error: ModelError) -> Self {
198        self.state(ModelState::Rejected(error))
199    }
200
201    pub fn loading(self) -> Self {
202        self.state(ModelState::Loading)
203    }
204
205    pub fn shading(mut self, shading: ModelShading) -> Self {
206        self.shading = shading;
207        self
208    }
209
210    /// Where the caller says the camera stands, in radians. The viewer draws
211    /// this and reports every request to change it.
212    pub fn orbit(mut self, yaw: f32, pitch: f32) -> Self {
213        self.yaw = yaw;
214        self.pitch = pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT);
215        self
216    }
217
218    pub fn height(mut self, height: f32) -> Self {
219        self.height = height.max(1.0);
220        self
221    }
222
223    pub fn on_event(
224        mut self,
225        handler: impl Fn(&ModelViewerEvent, &mut Window, &mut App) + 'static,
226    ) -> Self {
227        self.on_event = Some(Rc::new(handler));
228        self
229    }
230}
231
232impl Disableable for ModelViewer {
233    fn disabled(mut self, disabled: bool) -> Self {
234        self.disabled = disabled;
235        self
236    }
237}
238
239impl RenderOnce for ModelViewer {
240    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
241        let theme = cx.theme().clone();
242        let ident = self.ident.clone();
243        let strings = cx.strings().clone();
244        let actionable = !self.disabled && self.on_event.is_some();
245
246        let report = {
247            let handler = self.on_event.clone().filter(|_| actionable);
248            Rc::new(
249                move |event: ModelViewerEvent, window: &mut Window, cx: &mut App| {
250                    if let Some(handler) = &handler {
251                        handler(&event, window, cx);
252                    }
253                },
254            )
255        };
256
257        let measured = measure::cell(&ident.child("frame").semantic_id(), cx);
258        let dragging = keyed::slot::<Orbiting>(&ident.semantic_id(), cx);
259
260        let camera = Camera {
261            yaw: self.yaw,
262            pitch: self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT),
263        };
264
265        let mut frame = div()
266            .id(ident.child("frame").element_id())
267            .relative()
268            .w_full()
269            .h(px(self.height))
270            .overflow_hidden()
271            .radius(&theme, Radius::Card)
272            .frame(&theme, Surface::Sunken, Elevation::Flat);
273
274        match &self.state {
275            ModelState::Ready(scene) => {
276                frame = frame.child(
277                    div()
278                        .absolute()
279                        .inset_0()
280                        .child(paint(
281                            Rc::clone(scene),
282                            camera,
283                            self.shading,
284                            theme.colors.accent,
285                            theme.colors.hairline_strong,
286                        ))
287                        // Where the camera stands is published only where
288                        // there is something for it to look at.
289                        .semantic_in(
290                            cx,
291                            NodeSpec::new(ident.child("camera").semantic_id(), Role::Status)
292                                .parent(ident.semantic_id())
293                                .value(format!(
294                                    "{} {}",
295                                    degrees(camera.yaw),
296                                    degrees(camera.pitch)
297                                )),
298                        ),
299                );
300            }
301            ModelState::Loading => {
302                frame = frame.child(notice(
303                    &theme,
304                    theme.colors.text_muted,
305                    self.title
306                        .clone()
307                        .unwrap_or_else(|| strings.text(StringKey::ModelEmpty)),
308                    strings.text(StringKey::Loading),
309                ));
310            }
311            ModelState::Empty => {
312                frame = frame.child(notice(
313                    &theme,
314                    theme.colors.text_muted,
315                    strings.text(StringKey::ModelEmpty),
316                    strings.text(StringKey::ModelEmptyDetail),
317                ));
318            }
319            ModelState::Rejected(error) => {
320                frame = frame.child(notice(
321                    &theme,
322                    theme.colors.danger,
323                    strings.text(StringKey::ModelRefused),
324                    refusal(&strings, *error),
325                ));
326            }
327        }
328
329        let orbitable = actionable && matches!(self.state, ModelState::Ready(_));
330        if orbitable {
331            let down = Rc::clone(&dragging);
332            frame = frame
333                .cursor_pointer()
334                .on_mouse_down(MouseButton::Left, move |event, _, _| {
335                    let mut state = down.borrow_mut();
336                    state.held = true;
337                    state.at = Some(event.position);
338                });
339
340            let moved = Rc::clone(&dragging);
341            let bounds = Rc::clone(&measured);
342            let turn = Rc::clone(&report);
343            let (yaw, pitch) = (camera.yaw, camera.pitch);
344            frame = frame.on_mouse_move(move |event, window, cx| {
345                let previous = {
346                    let mut state = moved.borrow_mut();
347                    if !state.held {
348                        return;
349                    }
350                    if event.pressed_button != Some(MouseButton::Left) {
351                        state.held = false;
352                        state.at = None;
353                        return;
354                    }
355                    let previous = state.at;
356                    state.at = Some(event.position);
357                    previous
358                };
359                let Some(previous) = previous else {
360                    return;
361                };
362                let extent = frame_extent(bounds.get());
363                if extent.width <= 0.0 || extent.height <= 0.0 {
364                    return;
365                }
366                let (yaw, pitch) = orbit_by(
367                    yaw,
368                    pitch,
369                    point(
370                        f32::from(event.position.x - previous.x),
371                        f32::from(event.position.y - previous.y),
372                    ),
373                    extent,
374                );
375                turn(ModelViewerEvent::OrbitChanged { yaw, pitch }, window, cx);
376            });
377
378            let up = Rc::clone(&dragging);
379            frame = frame.on_mouse_up(MouseButton::Left, move |_, _, _| {
380                let mut state = up.borrow_mut();
381                state.held = false;
382                state.at = None;
383            });
384        }
385
386        let shadings = {
387            let report = Rc::clone(&report);
388            let mut control = SegmentedControl::new(ident.child("shading"))
389                .control_size(ControlSize::Sm)
390                .segments([
391                    Segment::new(
392                        ModelShading::Flat.name(),
393                        strings.text(StringKey::ModelFlat),
394                    ),
395                    Segment::new(
396                        ModelShading::Wireframe.name(),
397                        strings.text(StringKey::ModelWireframe),
398                    ),
399                ])
400                .selected(self.shading.name())
401                .disabled(!orbitable);
402            if orbitable {
403                control = control.on_select(move |id, window, cx| {
404                    let shading = match id.as_ref() {
405                        "wireframe" => ModelShading::Wireframe,
406                        _ => ModelShading::Flat,
407                    };
408                    report(ModelViewerEvent::ShadingChanged(shading), window, cx);
409                });
410            }
411            control
412        };
413
414        let reset = {
415            let report = Rc::clone(&report);
416            let mut control = IconButton::new(
417                ident.child("reset"),
418                Icon::Refresh,
419                strings.text(StringKey::ModelReset),
420            )
421            .ghost()
422            .control_size(ControlSize::Sm)
423            .semantic_parent(ident.semantic_id())
424            .disabled(!orbitable);
425            if orbitable {
426                control = control.on_click(move |window, cx| {
427                    report(
428                        ModelViewerEvent::OrbitChanged {
429                            yaw: PI / 6.0,
430                            pitch: PI / 8.0,
431                        },
432                        window,
433                        cx,
434                    )
435                });
436            }
437            control
438        };
439
440        // Every number here is one the reader counted. A viewer holding no
441        // model publishes no counts rather than three zeroes, because zero
442        // triangles is a thing a document can contain and this is not it.
443        let counts = match &self.state {
444            ModelState::Ready(scene) => Some(
445                div()
446                    .row()
447                    .w_full()
448                    .flex_wrap()
449                    .gap_token(&theme, Space::Md)
450                    .child(count(
451                        cx,
452                        &theme,
453                        &strings,
454                        &ident,
455                        "meshes",
456                        strings.text(StringKey::ModelMeshes),
457                        scene.mesh_count(),
458                    ))
459                    .child(count(
460                        cx,
461                        &theme,
462                        &strings,
463                        &ident,
464                        "vertices",
465                        strings.text(StringKey::ModelVertices),
466                        scene.vertex_count(),
467                    ))
468                    .child(count(
469                        cx,
470                        &theme,
471                        &strings,
472                        &ident,
473                        "triangles",
474                        strings.text(StringKey::ModelTriangles),
475                        scene.triangle_count(),
476                    )),
477            ),
478            _ => None,
479        };
480
481        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group)
482            .disabled(self.disabled)
483            .busy(matches!(self.state, ModelState::Loading))
484            .invalid(matches!(self.state, ModelState::Rejected(_)))
485            .value(self.state.name());
486        if let Some(title) = self.title.clone() {
487            spec = spec.text(title);
488        }
489
490        div()
491            .id(ident.element_id())
492            .column()
493            .w_full()
494            .gap_token(&theme, Space::Sm)
495            .when(self.disabled, |element| {
496                element.opacity(theme.opacity.disabled)
497            })
498            .when(orbitable, |element| element.tab_index(0).focus_ring(&theme))
499            .child(
500                div()
501                    .row()
502                    .w_full()
503                    .items_center()
504                    .justify_between()
505                    .gap_token(&theme, Space::Sm)
506                    .children(
507                        self.title
508                            .clone()
509                            .map(|title| text(&theme, TypeScale::Subtitle, title)),
510                    )
511                    .child(
512                        div()
513                            .row()
514                            .gap_token(&theme, Space::Xs)
515                            .child(shadings)
516                            .child(reset),
517                    ),
518            )
519            // The frame is measured through a plain wrapper, because only that
520            // element carries the prepaint hook and only prepaint knows how
521            // wide the frame turned out — which is what a drag's angle per
522            // pixel is computed against.
523            .child(
524                div()
525                    .w_full()
526                    .on_children_prepainted({
527                        let measured = Rc::clone(&measured);
528                        move |bounds, window, _| {
529                            if let Some(first) = bounds.first() {
530                                measure::record(&measured, *first, window);
531                            }
532                        }
533                    })
534                    .child(frame)
535                    .semantic_in(
536                        cx,
537                        NodeSpec::new(ident.child("frame").semantic_id(), Role::Image)
538                            .parent(ident.semantic_id())
539                            .busy(matches!(self.state, ModelState::Loading))
540                            .invalid(matches!(self.state, ModelState::Rejected(_)))
541                            .value(self.state.name()),
542                    ),
543            )
544            .children(counts)
545            .semantic_in(cx, spec)
546    }
547}
548
549/// One counted fact, published so a test reads the count rather than the row.
550fn count(
551    cx: &mut App,
552    theme: &gpui_kit_theme::Theme,
553    strings: &crate::strings::Strings,
554    ident: &Ident,
555    name: &'static str,
556    label: SharedString,
557    value: usize,
558) -> impl IntoElement {
559    text(
560        theme,
561        TypeScale::Caption,
562        strings.format(StringKey::ModelCount, &[&label, &value.to_string()]),
563    )
564    .text_tone(theme, TextTone::Muted)
565    .semantic_in(
566        cx,
567        NodeSpec::new(ident.child(name).semantic_id(), Role::Text)
568            .parent(ident.semantic_id())
569            .text(label)
570            .value(value.to_string()),
571    )
572}
573
574/// The host-facing sentence for a refusal, with the reader's own code in it.
575fn refusal(strings: &crate::strings::Strings, error: ModelError) -> SharedString {
576    match error {
577        ModelError::TooLarge {
578            limit,
579            found,
580            allowed,
581        } => strings.format(
582            StringKey::ModelTooLarge,
583            &[limit.name(), &found.to_string(), &allowed.to_string()],
584        ),
585        ModelError::Rejected(defect) => strings.format(StringKey::ModelRejected, &[defect.name()]),
586    }
587}
588
589fn degrees(radians: f32) -> i64 {
590    (radians * 180.0 / PI).round() as i64
591}
592
593fn frame_extent(bounds: Bounds<Pixels>) -> Size<f32> {
594    size(f32::from(bounds.size.width), f32::from(bounds.size.height))
595}
596
597fn point(x: f32, y: f32) -> Point<f32> {
598    Point { x, y }
599}
600
601/// Where the camera stands. Distance is not one of its facts: the projection
602/// is orthographic and fits the model's own sphere, so orbiting cannot change
603/// how big the model is.
604#[derive(Debug, Clone, Copy, PartialEq)]
605struct Camera {
606    yaw: f32,
607    pitch: f32,
608}
609
610impl Camera {
611    /// A point in the document's space, in the camera's.
612    ///
613    /// The camera looks down its own negative Z, so a larger `z` is nearer,
614    /// which is what both the depth sort and the facing test read.
615    fn view(self, point: [f32; 3], centre: [f32; 3]) -> [f32; 3] {
616        let (x, y, z) = (
617            point[0] - centre[0],
618            point[1] - centre[1],
619            point[2] - centre[2],
620        );
621        let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
622        let (x, z) = (x * cos_yaw + z * sin_yaw, -x * sin_yaw + z * cos_yaw);
623        let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
624        let (y, z) = (y * cos_pitch - z * sin_pitch, y * sin_pitch + z * cos_pitch);
625        [x, y, z]
626    }
627}
628
629/// The angles a drag of `delta` across a frame of `extent` asks for.
630fn orbit_by(yaw: f32, pitch: f32, delta: Point<f32>, extent: Size<f32>) -> (f32, f32) {
631    let turns = |travel: f32, across: f32| {
632        if across <= 0.0 {
633            0.0
634        } else {
635            travel / across * DRAG_TURNS * 2.0 * PI
636        }
637    };
638    (
639        yaw + turns(delta.x, extent.width),
640        // Pitch stops short of the pole: past it the model would turn inside
641        // out, which reads as a rendering fault rather than as a limit.
642        (pitch + turns(delta.y, extent.height)).clamp(-PITCH_LIMIT, PITCH_LIMIT),
643    )
644}
645
646/// How much of a face's own colour a normal pointing this way keeps.
647///
648/// The light is fixed and in front of the model, so a face turned away is
649/// darker rather than black: nothing here is the document's material, and a
650/// black face would read as a hole in the geometry.
651fn shade(normal: [f32; 3]) -> f32 {
652    let length = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
653    if length <= f32::EPSILON {
654        return AMBIENT;
655    }
656    const LIGHT: [f32; 3] = [0.35, 0.58, 0.74];
657    let lambert = (normal[0] * LIGHT[0] + normal[1] * LIGHT[1] + normal[2] * LIGHT[2]) / length;
658    AMBIENT + (1.0 - AMBIENT) * lambert.clamp(0.0, 1.0)
659}
660
661/// The face normal of a triangle already in camera space.
662fn normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
663    let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
664    let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
665    [
666        u[1] * v[2] - u[2] * v[1],
667        u[2] * v[0] - u[0] * v[2],
668        u[0] * v[1] - u[1] * v[0],
669    ]
670}
671
672/// One triangle, ready to paint.
673struct Face {
674    corners: [Point<Pixels>; 3],
675    depth: f32,
676    shade: f32,
677}
678
679/// Every triangle of a scene, projected, culled, and sorted back to front.
680fn faces(scene: &ModelScene, camera: Camera, bounds: Bounds<Pixels>, cull: bool) -> Vec<Face> {
681    let extent = frame_extent(bounds);
682    let aabb = scene.aabb();
683    let radius = aabb.radius();
684    if extent.width <= 0.0 || extent.height <= 0.0 || radius <= f32::EPSILON {
685        return Vec::new();
686    }
687    let centre = aabb.centre();
688    let scale = FIT * extent.width.min(extent.height) / radius;
689    let origin = (
690        f32::from(bounds.origin.x) + extent.width / 2.0,
691        f32::from(bounds.origin.y) + extent.height / 2.0,
692    );
693    // Screen y grows downwards and the model's does not, so the projection
694    // negates it rather than the model being flipped on the way in.
695    let project = |view: [f32; 3]| Point {
696        x: px(origin.0 + view[0] * scale),
697        y: px(origin.1 - view[1] * scale),
698    };
699
700    let mut out = Vec::new();
701    for mesh in scene.meshes() {
702        let positions = mesh.positions();
703        for triangle in mesh.indices().chunks_exact(3) {
704            let Some(corners) = triangle
705                .iter()
706                .map(|index| positions.get(*index as usize).copied())
707                .collect::<Option<Vec<[f32; 3]>>>()
708            else {
709                continue;
710            };
711            let view = [
712                camera.view(corners[0], centre),
713                camera.view(corners[1], centre),
714                camera.view(corners[2], centre),
715            ];
716            let normal = normal(view[0], view[1], view[2]);
717            // glTF winds a front face counter-clockwise, so a normal pointing
718            // away from the camera is the back of a surface. A wireframe keeps
719            // both, because the far edges are what makes it read as a solid.
720            if cull && normal[2] <= 0.0 {
721                continue;
722            }
723            out.push(Face {
724                corners: [project(view[0]), project(view[1]), project(view[2])],
725                depth: (view[0][2] + view[1][2] + view[2][2]) / 3.0,
726                shade: shade(normal),
727            });
728        }
729    }
730    // Painter's order: the furthest face is drawn first, so a nearer one
731    // covers it. There is no depth buffer to reach from a canvas.
732    out.sort_by(|a, b| a.depth.total_cmp(&b.depth));
733    out
734}
735
736/// The canvas the model is drawn on.
737fn paint(
738    scene: Rc<ModelScene>,
739    camera: Camera,
740    shading: ModelShading,
741    fill: Hsla,
742    line: Hsla,
743) -> impl IntoElement {
744    canvas(
745        |_, _, _| {},
746        move |bounds, _, window, _| {
747            let wireframe = matches!(shading, ModelShading::Wireframe);
748            for face in faces(&scene, camera, bounds, !wireframe) {
749                let mut builder = if wireframe {
750                    PathBuilder::stroke(px(1.0))
751                } else {
752                    PathBuilder::fill()
753                };
754                builder.move_to(face.corners[0]);
755                builder.line_to(face.corners[1]);
756                builder.line_to(face.corners[2]);
757                builder.close();
758                let Ok(path) = builder.build() else {
759                    continue;
760                };
761                let color = if wireframe {
762                    line
763                } else {
764                    Hsla {
765                        l: (fill.l * face.shade).clamp(0.0, 1.0),
766                        ..fill
767                    }
768                };
769                window.paint_path(path, color);
770            }
771        },
772    )
773    .size_full()
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    const EXTENT: Size<f32> = Size {
781        width: 400.0,
782        height: 300.0,
783    };
784
785    #[test]
786    fn a_drag_across_the_frame_turns_the_model_a_whole_turn() {
787        let (yaw, _) = orbit_by(0.0, 0.0, point(400.0, 0.0), EXTENT);
788        assert!(
789            (yaw - 2.0 * PI * DRAG_TURNS).abs() < 0.001,
790            "a drag the width of the frame is one turn: {yaw}"
791        );
792    }
793
794    #[test]
795    fn a_drag_past_the_pole_stops_short_of_it() {
796        let (_, up) = orbit_by(0.0, 0.0, point(0.0, 5000.0), EXTENT);
797        assert!((up - PITCH_LIMIT).abs() < 0.001);
798        let (_, down) = orbit_by(0.0, 0.0, point(0.0, -5000.0), EXTENT);
799        assert!((down + PITCH_LIMIT).abs() < 0.001);
800    }
801
802    #[test]
803    fn a_frame_nobody_measured_turns_nothing() {
804        let flat = Size {
805            width: 0.0,
806            height: 0.0,
807        };
808        assert_eq!(orbit_by(1.0, 0.5, point(80.0, 80.0), flat), (1.0, 0.5));
809    }
810
811    #[test]
812    fn the_camera_turns_the_model_rather_than_moving_it() {
813        let camera = Camera {
814            yaw: PI / 2.0,
815            pitch: 0.0,
816        };
817        let turned = camera.view([1.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
818        assert!(turned[0].abs() < 0.001, "{turned:?}");
819        assert!(
820            (turned[2] + 1.0).abs() < 0.001,
821            "a quarter turn puts +X behind"
822        );
823
824        let still = Camera {
825            yaw: 0.0,
826            pitch: 0.0,
827        };
828        assert_eq!(
829            still.view([2.0, 3.0, 4.0], [1.0, 1.0, 1.0]),
830            [1.0, 2.0, 3.0]
831        );
832    }
833
834    #[test]
835    fn a_face_turned_away_is_darker_and_never_black() {
836        let towards = shade([0.35, 0.58, 0.74]);
837        let away = shade([-0.35, -0.58, -0.74]);
838        assert!(towards > away);
839        assert!(away >= AMBIENT, "an unlit face is still a face: {away}");
840        assert!(towards <= 1.0);
841        assert_eq!(shade([0.0, 0.0, 0.0]), AMBIENT);
842    }
843
844    #[test]
845    fn a_normal_points_out_of_a_counter_clockwise_face() {
846        let facing = normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
847        assert!(facing[2] > 0.0, "{facing:?}");
848        let away = normal([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]);
849        assert!(away[2] < 0.0, "{away:?}");
850    }
851
852    #[test]
853    fn a_state_names_itself_and_a_refusal_names_the_refusal() {
854        assert_eq!(ModelState::Empty.name(), "empty");
855        assert_eq!(
856            ModelState::Rejected(ModelError::TooLarge {
857                limit: crate::media::gltf::ModelLimit::Vertices,
858                found: 9,
859                allowed: 2,
860            })
861            .name(),
862            "too-large"
863        );
864    }
865}