Skip to main content

nightshade_api/web/
hud.rs

1//! Overlay chrome for a render surface: floating panels, an orientation gizmo,
2//! and a card summarizing the selected entity.
3
4use crate::wire::SelectedEntity;
5use leptos::prelude::*;
6
7/// A positioning layer stacked over the render surface to hold HUD elements.
8#[component]
9pub fn ViewportOverlay(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
10    view! { <div class=format!("nightshade-viewport-overlay {class}")>{children()}</div> }
11}
12
13/// A floating panel for on-surface controls or readouts.
14#[component]
15pub fn HudPanel(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
16    view! { <div class=format!("nightshade-hud {class}")>{children()}</div> }
17}
18
19fn dot(left: [f32; 3], right: [f32; 3]) -> f64 {
20    (left[0] * right[0] + left[1] * right[1] + left[2] * right[2]) as f64
21}
22
23fn project(basis: [[f32; 3]; 3], world: [f32; 3]) -> (f64, f64, f64) {
24    let [right, up, forward] = basis;
25    (dot(world, right), -dot(world, up), dot(world, forward))
26}
27
28const AXES: [([f32; 3], &str, usize, &str); 6] = [
29    ([1.0, 0.0, 0.0], "X", 0, "x"),
30    ([-1.0, 0.0, 0.0], "", 1, "x"),
31    ([0.0, 1.0, 0.0], "Y", 2, "y"),
32    ([0.0, -1.0, 0.0], "", 3, "y"),
33    ([0.0, 0.0, 1.0], "Z", 4, "z"),
34    ([0.0, 0.0, -1.0], "", 5, "z"),
35];
36
37/// An SVG orientation gizmo that projects the camera `basis` (right, up, forward
38/// vectors) into six labelled axis dots, depth-sorted so near axes draw on top.
39/// Clicking an axis runs `on_axis` with its index.
40#[component]
41pub fn NavGizmo(
42    #[prop(into)] basis: Signal<[[f32; 3]; 3]>,
43    #[prop(optional)] on_axis: Option<Callback<usize>>,
44) -> impl IntoView {
45    let size = 76.0_f64;
46    let center = size / 2.0;
47    let radius = center - 12.0;
48    view! {
49        <svg
50            class="nightshade-nav-gizmo"
51            viewBox=format!("0 0 {size} {size}")
52            width=size
53            height=size
54        >
55            {move || {
56                let current = basis.get();
57                let mut projected = AXES
58                    .iter()
59                    .map(|(vector, label, index, axis)| {
60                        let (x, y, depth) = project(current, *vector);
61                        (center + x * radius, center + y * radius, depth, *label, *index, *axis, index % 2 == 0)
62                    })
63                    .collect::<Vec<_>>();
64                projected.sort_by(|left, right| {
65                    left.2.partial_cmp(&right.2).unwrap_or(std::cmp::Ordering::Equal)
66                });
67                projected
68                    .into_iter()
69                    .map(|(px, py, depth, label, index, axis, positive)| {
70                        let opacity = 0.35 + 0.65 * ((depth + 1.0) / 2.0);
71                        let dot_class = format!(
72                            "nightshade-gizmo-axis {axis} {}",
73                            if positive { "pos" } else { "neg" },
74                        );
75                        let handle = move |_: web_sys::MouseEvent| {
76                            if let Some(callback) = on_axis {
77                                callback.run(index);
78                            }
79                        };
80                        view! {
81                            <g style=format!("opacity:{opacity:.3}") on:click=handle>
82                                <line
83                                    class="nightshade-gizmo-line"
84                                    x1=center
85                                    y1=center
86                                    x2=px
87                                    y2=py
88                                />
89                                <circle class=dot_class cx=px cy=py r=if positive { 8.0 } else { 5.0 } />
90                                <text
91                                    class="nightshade-gizmo-label"
92                                    x=px
93                                    y=py
94                                    text-anchor="middle"
95                                    dominant-baseline="central"
96                                >
97                                    {label}
98                                </text>
99                            </g>
100                        }
101                    })
102                    .collect_view()
103            }}
104        </svg>
105    }
106}
107
108/// Renders a small card describing the `selected` entity's name and id, or a
109/// "None" placeholder when nothing is selected.
110#[component]
111pub fn SelectedCard(#[prop(into)] selected: Signal<Option<SelectedEntity>>) -> impl IntoView {
112    view! {
113        <div class="nightshade-selected-card">
114            {move || match selected.get() {
115                Some(entity) => {
116                    view! {
117                        <div class="nightshade-selected-row">
118                            <span class="key">"Name"</span>
119                            <span>{entity.name}</span>
120                        </div>
121                        <div class="nightshade-selected-row">
122                            <span class="key">"Id"</span>
123                            <span>{entity.id}</span>
124                        </div>
125                    }
126                        .into_any()
127                }
128                None => {
129                    view! {
130                        <div class="nightshade-selected-row">
131                            <span class="key">"Selection"</span>
132                            <span>"None"</span>
133                        </div>
134                    }
135                        .into_any()
136                }
137            }}
138        </div>
139    }
140}