137 lines
5.7 KiB
Rust
137 lines
5.7 KiB
Rust
//! Floating controls reserve drawing space without changing rendering or picking projection.
|
|
use egui::{Rect, pos2, vec2};
|
|
use solvecraft_engine::geom::{Aabb3, Vec3};
|
|
use solvecraft_engine::render::Camera;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct Layout {
|
|
pub viewport: Rect,
|
|
pub drawable: Rect,
|
|
pub axis_view: Rect,
|
|
pub navigation: Rect,
|
|
}
|
|
|
|
/// Enumerate rectangles whose edges meet the viewport or a bounded number of overlays.
|
|
fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect {
|
|
let mut xs = vec![view.left(), view.right()];
|
|
let mut ys = vec![view.top(), view.bottom()];
|
|
let obstacles: Vec<_> = obstacles.iter().take(8).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect();
|
|
for r in &obstacles {
|
|
xs.extend([r.left(), r.right()]);
|
|
ys.extend([r.top(), r.bottom()]);
|
|
}
|
|
xs.sort_by(f32::total_cmp);
|
|
ys.sort_by(f32::total_cmp);
|
|
let mut best = Rect::from_min_size(view.min, vec2(0.0, 0.0));
|
|
for (i, &left) in xs.iter().enumerate() {
|
|
for &right in xs.iter().skip(i + 1) {
|
|
for (j, &top) in ys.iter().enumerate() {
|
|
for &bottom in ys.iter().skip(j + 1) {
|
|
let r = Rect::from_min_max(pos2(left, top), pos2(right, bottom));
|
|
if r.area() > best.area().max(0.0) && !obstacles.iter().any(|o| r.intersect(*o).is_positive()) {
|
|
best = r;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
impl Layout {
|
|
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
|
|
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
|
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
|
|
// Navigation is anchored to the viewport, never to transient dialog space.
|
|
let available = viewport;
|
|
let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
|
|
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
|
|
} else {
|
|
Rect::NOTHING
|
|
};
|
|
let nav_space = viewport;
|
|
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
|
|
let nav_height = (10.0 / columns).ceil() * 30.0;
|
|
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
|
|
Rect::from_center_size(pos2(nav_space.center().x, nav_space.bottom() - nav_height * 0.5 - 7.0), vec2(columns * 30.0 + 10.0, nav_height))
|
|
} else {
|
|
Rect::NOTHING
|
|
};
|
|
// On tiny viewports keep the bottom controls usable rather than overlapping them.
|
|
// This decision depends only on viewport size, never on an overlay.
|
|
if axis_view.intersect(navigation).is_positive() {
|
|
axis_view = Rect::NOTHING;
|
|
}
|
|
if axis_view.is_positive() {
|
|
obstacles.push(axis_view);
|
|
}
|
|
if navigation.is_positive() {
|
|
obstacles.push(navigation);
|
|
}
|
|
let drawable = free_rect(viewport, &obstacles).shrink(16.0 / scale);
|
|
Self { viewport, drawable, axis_view, navigation }
|
|
}
|
|
}
|
|
|
|
pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, perspective: bool) -> Option<Camera> {
|
|
if bounds.is_empty()
|
|
|| !bounds.min.is_finite()
|
|
|| !bounds.max.is_finite()
|
|
|| !cam.is_valid()
|
|
|| !layout.viewport.is_positive()
|
|
|| !layout.drawable.is_positive()
|
|
|| !layout.drawable.is_finite()
|
|
{
|
|
return None;
|
|
}
|
|
let original_fov = cam.fov;
|
|
cam.fov = if perspective { 0.6 } else { 0.0 };
|
|
let (r, u, b) = cam.basis();
|
|
let centre = bounds.center();
|
|
let mut hx: f64 = 0.0;
|
|
let mut hy: f64 = 0.0;
|
|
let mut depth: f64 = 0.0;
|
|
let corners: Vec<_> = [bounds.min.x, bounds.max.x]
|
|
.into_iter()
|
|
.flat_map(|x| {
|
|
[bounds.min.y, bounds.max.y].into_iter().flat_map(move |y| [bounds.min.z, bounds.max.z].into_iter().map(move |z| Vec3::new(x, y, z)))
|
|
})
|
|
.collect();
|
|
for p in &corners {
|
|
let d = *p - centre;
|
|
hx = hx.max(d.dot(r).abs());
|
|
hy = hy.max(d.dot(u).abs());
|
|
depth = depth.max(d.dot(b).abs());
|
|
}
|
|
let h = f64::from(layout.viewport.height());
|
|
let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5);
|
|
let offset = layout.drawable.center() - layout.viewport.center();
|
|
let tangent = if perspective { (0.3_f64).tan() } else { 0.4 };
|
|
cam.distance = hh / tangent + if perspective { depth } else { 0.0 };
|
|
// Offset the target so the model projects into the free rectangle in the original viewport.
|
|
// Perspective needs a bounded correction because corners have different depths.
|
|
for _ in 0..16 {
|
|
let k = 2.0 * cam.half_height() / h;
|
|
cam.target = centre - r * (f64::from(offset.x) * k) + u * (f64::from(offset.y) * k);
|
|
let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0);
|
|
let fits = corners.iter().all(|p| {
|
|
cam.to_screen(*p, f64::from(layout.viewport.width()), h, radius).is_some_and(|(x, y, z)| {
|
|
x.is_finite()
|
|
&& y.is_finite()
|
|
&& (0.0..=1.0).contains(&z)
|
|
&& layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32))
|
|
})
|
|
});
|
|
if fits {
|
|
cam.fov = original_fov;
|
|
return cam.is_valid().then_some(cam);
|
|
}
|
|
cam.distance *= 1.15;
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "viewport_layout_tests.rs"]
|
|
mod tests;
|