1use 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
47const DEFAULT_HEIGHT: f32 = 320.0;
49
50const FIT: f32 = 0.42;
52
53const DRAG_TURNS: f32 = 1.0;
55
56const PITCH_LIMIT: f32 = PI / 2.0 - 0.01;
58
59const AMBIENT: f32 = 0.32;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum ModelShading {
65 #[default]
68 Flat,
69 Wireframe,
71}
72
73impl ModelShading {
74 pub fn name(self) -> &'static str {
76 match self {
77 Self::Flat => "flat",
78 Self::Wireframe => "wireframe",
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub enum ModelState {
86 Empty,
88 Loading,
90 Ready(Rc<ModelScene>),
92 Rejected(ModelError),
94}
95
96impl ModelState {
97 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 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#[derive(Debug, Clone, Copy, PartialEq)]
121pub enum ModelViewerEvent {
122 OrbitChanged { yaw: f32, pitch: f32 },
124 ShadingChanged(ModelShading),
126}
127
128type EventHandler = Rc<dyn Fn(&ModelViewerEvent, &mut Window, &mut App)>;
129
130#[derive(Debug, Default)]
132struct Orbiting {
133 held: bool,
134 at: Option<Point<Pixels>>,
135}
136
137#[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 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 pub fn scene(self, scene: Rc<ModelScene>) -> Self {
193 self.state(ModelState::Ready(scene))
194 }
195
196 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 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 .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 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 .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
549fn 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
574fn 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#[derive(Debug, Clone, Copy, PartialEq)]
605struct Camera {
606 yaw: f32,
607 pitch: f32,
608}
609
610impl Camera {
611 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
629fn 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 + turns(delta.y, extent.height)).clamp(-PITCH_LIMIT, PITCH_LIMIT),
643 )
644}
645
646fn 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
661fn 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
672struct Face {
674 corners: [Point<Pixels>; 3],
675 depth: f32,
676 shade: f32,
677}
678
679fn 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 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 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 out.sort_by(|a, b| a.depth.total_cmp(&b.depth));
733 out
734}
735
736fn 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}