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