gled-ui 2.31.2

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Scaling a selection by the handles of its bounding box.

use crate::canvas::shapes::Shapes;
use egui::emath::TSTransform;
use egui::{
    Color32, Id, PointerButton, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2,
};

/// Radius of a handle's knob, in screen pixels.
pub(crate) const HANDLE_RADIUS: f32 = 5.0;
/// Side of the square a handle reacts in, in screen pixels: a little more
/// than its knob, so it is easy to hit.
pub(crate) const HANDLE_HIT: f32 = HANDLE_RADIUS + 6.0;

/// Handle centres of a box: 4 corners first, then the 4 edge midpoints.
fn handle_positions(bbox: Rect) -> [Pos2; 8] {
    let (cx, cy) = (bbox.center().x, bbox.center().y);
    [
        pos2(bbox.min.x, bbox.min.y),
        pos2(bbox.max.x, bbox.min.y),
        pos2(bbox.max.x, bbox.max.y),
        pos2(bbox.min.x, bbox.max.y),
        pos2(cx, bbox.min.y),
        pos2(bbox.max.x, cy),
        pos2(cx, bbox.max.y),
        pos2(bbox.min.x, cy),
    ]
}

/// The point a handle scales about: the opposite corner, or the opposite edge
/// for an edge handle.
fn handle_origin(bbox: Rect, handle: usize) -> Pos2 {
    let opposite = (handle + 2) % 4;
    if handle < 4 {
        handle_positions(bbox)[opposite]
    } else {
        handle_positions(bbox)[4 + opposite]
    }
}

fn scale_ratio(old: f32, new: f32) -> f32 {
    if old.abs() > 1e-3 {
        (new / old).max(0.01)
    } else {
        1.0
    }
}

/// How far a handle drag stretches the box, per axis.
///
/// Edge handles move along their own axis only. Corner handles keep the box's
/// aspect ratio by projecting the cursor onto the box diagonal, unless shift
/// asks for a free stretch.
fn box_scale_factors(
    bbox: Rect,
    handle: usize,
    origin: Pos2,
    target: Pos2,
    free_aspect: bool,
) -> (f32, f32) {
    let old = handle_positions(bbox)[handle] - origin;
    let new = target - origin;
    match handle {
        0..=3 if free_aspect => (scale_ratio(old.x, new.x), scale_ratio(old.y, new.y)),
        0..=3 => {
            let square = old.x * old.x + old.y * old.y;
            let uniform = if square > 1e-6 {
                ((new.x * old.x + new.y * old.y) / square).max(0.01)
            } else {
                1.0
            };
            (uniform, uniform)
        }
        4 | 6 => (1.0, scale_ratio(old.y, new.y)),
        _ => (scale_ratio(old.x, new.x), 1.0),
    }
}

/// How much a fixture's own length axis is stretched by a box scale, given how
/// the fixture is turned inside the box.
///
/// It is the length of the fixture's unit axis after the scale, so a box that
/// is not being stretched leaves the fixture alone whatever its angle.
pub(crate) fn along_axis_factor(angle: f32, scale_x: f32, scale_y: f32) -> f32 {
    let (sin, cos) = angle.sin_cos();
    ((cos * scale_x).powi(2) + (sin * scale_y).powi(2)).sqrt()
}

pub(crate) fn scaled_box(bbox: Rect, origin: Pos2, scale_x: f32, scale_y: f32) -> Rect {
    let map = |p: Pos2| {
        let offset = p - origin;
        origin + Vec2::new(offset.x * scale_x, offset.y * scale_y)
    };
    Rect::from_two_pos(map(bbox.min), map(bbox.max))
}

/// A bounding-box scale in flight.
///
/// Everything is anchored on the state at drag start: deriving the scale from
/// the previous frame's box instead accumulates the difference between the box
/// and what the items actually cover, and the handle drifts away from the
/// cursor. `P` is what each item looked like when the drag started.
#[derive(Debug, Clone)]
pub(crate) struct BoxScale<P> {
    pub(crate) handle: usize,
    pub(crate) start_box: Rect,
    pub(crate) start_pointer: Pos2,
    pub(crate) start: Vec<P>,
}

impl<P> BoxScale<P> {
    /// The point the box scales about and the factors per axis, for the
    /// pointer at `pointer`. Alt scales about the centre, shift frees a corner
    /// handle from the aspect ratio.
    pub(crate) fn factors(
        &self,
        pointer: Pos2,
        about_centre: bool,
        free_aspect: bool,
    ) -> (Pos2, f32, f32) {
        let origin = if about_centre {
            self.start_box.center()
        } else {
            handle_origin(self.start_box, self.handle)
        };
        let target = handle_positions(self.start_box)[self.handle] + (pointer - self.start_pointer);
        let (scale_x, scale_y) =
            box_scale_factors(self.start_box, self.handle, origin, target, free_aspect);
        (origin, scale_x, scale_y)
    }
}

/// Draws `shown_box` (scene units) with its 8 handles. Returns the handle a
/// drag started on this frame.
pub(crate) fn box_handles(
    ui: &mut Ui,
    overlay: &mut Shapes,
    scene_transform: &TSTransform,
    shown_box: Rect,
) -> Option<usize> {
    let global_box = Rect::from_min_max(
        *scene_transform * shown_box.min,
        *scene_transform * shown_box.max,
    );
    overlay.rect_stroke(
        global_box,
        0.0,
        Stroke::new(1.5, Color32::from_white_alpha(160)),
        StrokeKind::Outside,
    );

    let mut started = None;
    for (index, &handle_scene) in handle_positions(shown_box).iter().enumerate() {
        // Addressed by which handle it is: egui's positional auto-ids shift as
        // soon as the selection changes the number of allocated rects.
        let id = ui.id().with(("bounding box handle", index));
        let centre = *scene_transform * handle_scene;
        if drag_handle(ui, overlay, centre, Color32::WHITE, id)
            .drag_started_by(PointerButton::Primary)
        {
            started = Some(index);
        }
    }
    started
}

/// A handle as a knob of `fill` at `centre`, in screen coordinates, and its
/// response: a drag on it starts what the handle does.
pub(crate) fn drag_handle(
    ui: &mut Ui,
    overlay: &mut Shapes,
    centre: Pos2,
    fill: Color32,
    id: Id,
) -> Response {
    let to_local = ui
        .ctx()
        .layer_transform_from_global(ui.layer_id())
        .unwrap_or_default();
    let hit = Rect::from_center_size(
        to_local * centre,
        Vec2::splat(HANDLE_HIT * to_local.scaling),
    );
    let response = ui.interact(hit, id, Sense::drag());
    overlay.circle(
        centre,
        HANDLE_RADIUS,
        fill,
        Stroke::new(1.0, Color32::BLACK),
    );
    response
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A side handle may only stretch its own axis.
    #[test]
    fn an_edge_handle_scales_one_axis_only() {
        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 200.0));

        // right edge, dragged diagonally
        let origin = handle_origin(bbox, 5);
        let (x, y) = box_scale_factors(bbox, 5, origin, pos2(150.0, 400.0), false);
        assert_eq!((x, y), (1.5, 1.0));

        // bottom edge, dragged diagonally
        let origin = handle_origin(bbox, 6);
        let (x, y) = box_scale_factors(bbox, 6, origin, pos2(400.0, 300.0), false);
        assert_eq!((x, y), (1.0, 1.5));
    }

    /// Corner handles keep the aspect ratio unless shift asks for a free
    /// stretch.
    #[test]
    fn a_corner_handle_preserves_the_aspect_ratio_until_shift() {
        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 200.0));
        let origin = handle_origin(bbox, 2); // bottom-right scales about top-left

        let (x, y) = box_scale_factors(bbox, 2, origin, pos2(200.0, 400.0), false);
        assert!((x - 2.0).abs() < 1e-5, "uniform along the diagonal: {x}");
        assert_eq!(x, y, "the aspect ratio is kept");

        let (x, y) = box_scale_factors(bbox, 2, origin, pos2(200.0, 400.0), true);
        assert!((x - 2.0).abs() < 1e-5);
        assert!((y - 2.0).abs() < 1e-5);

        // off the diagonal, shift stretches the axes apart
        let (x, y) = box_scale_factors(bbox, 2, origin, pos2(50.0, 400.0), true);
        assert!((x - 0.5).abs() < 1e-5);
        assert!((y - 2.0).abs() < 1e-5);
    }

    /// Regression: a box that is not being stretched must leave its fixtures
    /// alone. The factor used to be `|cos|·x + |sin|·y`, which comes out at
    /// 1.41 for a fixture at 45°, so every selection grew the moment a handle
    /// was pressed.
    #[test]
    fn a_box_that_does_not_stretch_leaves_the_fixtures_alone() {
        for turns in 0..16 {
            let angle = turns as f32 * std::f32::consts::TAU / 16.0;
            let factor = along_axis_factor(angle, 1.0, 1.0);
            assert!((factor - 1.0).abs() < 1e-5, "at {angle} rad: {factor}");
        }
    }

    /// A fixture along an axis follows that axis alone.
    #[test]
    fn an_axis_aligned_fixture_follows_its_own_axis() {
        assert!((along_axis_factor(0.0, 2.0, 0.5) - 2.0).abs() < 1e-5);
        let right_angle = std::f32::consts::FRAC_PI_2;
        assert!((along_axis_factor(right_angle, 2.0, 0.5) - 0.5).abs() < 1e-5);
    }

    /// Dragging a corner along the box diagonal puts that corner exactly under
    /// the cursor — the handle must not lag behind or run ahead of it.
    #[test]
    fn a_corner_handle_lands_where_the_cursor_is() {
        let bbox = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 200.0));
        let origin = handle_origin(bbox, 3); // bottom-left scales about top-right
        // the bottom-left corner, moved along the diagonal away from the origin
        let target = pos2(-100.0, 400.0);

        let (x, y) = box_scale_factors(bbox, 3, origin, target, false);
        let scaled = scaled_box(bbox, origin, x, y);

        assert!((scaled.min.x - target.x).abs() < 1e-3, "{scaled:?}");
        assert!((scaled.max.y - target.y).abs() < 1e-3, "{scaled:?}");
    }
}