gled-ui 2.31.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A pointer drag on the patch canvas, anchored on how things were when it
//! began. Deriving each frame from the one before accumulates rounding and
//! whatever the drawing adds to the true geometry — a fixture blown up to
//! stay legible, say — and the dragged thing drifts away from the pointer.

use crate::canvas::box_scale::{BoxScale, along_axis_factor};
use crate::canvas::box_select::Band;
use crate::canvas::definition_drawing::fixture_footprint;
use crate::canvas::rotate_handle::RotateDrag;
use egui::emath::Rot2;
use egui::{Modifiers, Pos2, Vec2};
use gled_model::patching::definition::{FixtureDefinition, Scaling};
use gled_model::patching::fixture::{FixtureId, LayoutPosition};
use gled_model::patching::layout::Layout;

/// What the pointer is doing on the canvas.
#[derive(Debug, Clone)]
pub(crate) enum Drag {
    /// Moving the selected fixtures: where each was, and where the pointer
    /// was pressed.
    Move {
        start_pointer: Pos2,
        start: Vec<(FixtureId, LayoutPosition)>,
    },
    /// A rubber band over the fixtures.
    Band(Band<FixtureId>),
    /// Scaling the selection by a handle of its bounding box.
    Scale(BoxScale<(FixtureId, LayoutPosition)>),
    /// Turning the selection by the handle above it.
    Rotate(RotateDrag<(FixtureId, LayoutPosition)>),
    /// Turning and stretching one fixture by one of its ends.
    End(EndDrag),
}

impl Drag {
    /// Whether the drag changes the patch, and so is one undo step; a band
    /// only selects.
    pub(crate) fn edits_patch(&self) -> bool {
        !matches!(self, Self::Band(_))
    }
}

/// One of the two ends of a fixture's length axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum FixtureEnd {
    /// Where the signal comes in.
    Near,
    Far,
}

impl FixtureEnd {
    /// How far along the length axis the end is, from 0 to 1.
    pub(crate) fn along(self) -> f32 {
        match self {
            Self::Near => 0.0,
            Self::Far => 1.0,
        }
    }

    fn opposite(self) -> Self {
        match self {
            Self::Near => Self::Far,
            Self::Far => Self::Near,
        }
    }
}

/// A drag on one end of a fixture: it turns and stretches the fixture about
/// the other end, so the grabbed end follows the pointer.
///
/// Both ends are taken from the fixture's true size, not the legible one it
/// is drawn at: stretching a fixture by what its drawing grew would not move
/// the drawing at all while it stays under the legibility floor.
#[derive(Debug, Clone)]
pub(crate) struct EndDrag {
    pub(crate) fixture: FixtureId,
    start: LayoutPosition,
    pivot: Pos2,
    grab: Pos2,
    start_pointer: Pos2,
}

impl EndDrag {
    pub(crate) fn new(
        fixture: FixtureId,
        definition: &FixtureDefinition,
        start: LayoutPosition,
        end: FixtureEnd,
        start_pointer: Pos2,
    ) -> Self {
        let footprint = fixture_footprint(definition, &start, 1.0, 0.0);
        Self {
            fixture,
            start,
            pivot: footprint.along_axis(end.opposite().along()),
            grab: footprint.along_axis(end.along()),
            start_pointer,
        }
    }

    /// Where the fixture is with the pointer at `pointer`.
    pub(crate) fn position(&self, pointer: Pos2) -> LayoutPosition {
        end_scaled(
            self.start,
            self.pivot,
            self.grab,
            self.grab + (pointer - self.start_pointer),
        )
    }
}

/// `start` turned and stretched about `pivot` so that its point `grab`
/// lands on `target`. A bar's width follows its length: its pixels sit on one
/// line, so stretching it across that line would mean nothing.
pub(crate) fn end_scaled(
    start: LayoutPosition,
    pivot: Pos2,
    grab: Pos2,
    target: Pos2,
) -> LayoutPosition {
    let from = grab - pivot;
    let to = target - pivot;
    if from.length() < f32::EPSILON || to.length() < f32::EPSILON {
        return start;
    }
    let ratio = to.length() / from.length();
    let turn = to.angle() - from.angle();
    let rotation = Rot2::from_angle(turn);
    LayoutPosition {
        pos: pivot + rotation * ((start.pos - pivot) * ratio),
        angle: start.angle + turn,
        scale: [start.scale[0] * ratio, start.scale[1] * ratio],
    }
}

/// Carries a drag that changes positions to `pointer`, in scene units: every
/// position is derived from where the fixtures were when the drag began.
/// Alt scales about the box's centre, shift frees a corner handle from the
/// aspect ratio and snaps a turn to 15°.
pub(crate) fn drag_positions(
    drag: &Drag,
    layout: &mut Layout,
    pointer: Pos2,
    modifiers: Modifiers,
    definition_of: impl Fn(FixtureId) -> Scaling,
) {
    let mut place = |id: &FixtureId, position: LayoutPosition| {
        if let Some(member) = layout.members.get_mut(id) {
            *member = position;
        }
    };
    match drag {
        Drag::Move {
            start_pointer,
            start,
        } => {
            let delta = pointer - *start_pointer;
            for (id, position) in start {
                place(
                    id,
                    LayoutPosition {
                        pos: position.pos + delta,
                        ..*position
                    },
                );
            }
        }
        Drag::End(end) => place(&end.fixture, end.position(pointer)),
        Drag::Scale(scale) => {
            let (origin, scale_x, scale_y) = scale.factors(pointer, modifiers.alt, modifiers.shift);
            for (id, start) in &scale.start {
                let offset = start.pos - origin;
                // The arrangement stretches per axis, but a fixture is only
                // distorted as far as its own definition allows.
                let scale = if definition_of(*id) == Scaling::Free {
                    [start.scale[0] * scale_x, start.scale[1] * scale_y]
                } else {
                    // its size follows its own axis, and its width follows that
                    let along = along_axis_factor(start.angle, scale_x, scale_y);
                    [start.scale[0] * along, start.scale[1] * along]
                };
                place(
                    id,
                    LayoutPosition {
                        pos: origin + Vec2::new(offset.x * scale_x, offset.y * scale_y),
                        scale,
                        ..*start
                    },
                );
            }
        }
        Drag::Rotate(rotation) => {
            let angle = rotation.angle(pointer, modifiers.shift);
            let turn = Rot2::from_angle(angle);
            for (id, start) in &rotation.start {
                place(
                    id,
                    LayoutPosition {
                        pos: rotation.centre + turn * (start.pos - rotation.centre),
                        angle: start.angle + angle,
                        ..*start
                    },
                );
            }
        }
        Drag::Band(_) => {}
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use egui::{pos2, vec2};

    /// A bar 20 units long and 1 deep: at zoom 1, 20 × 1 screen pixels.
    fn tiny_bar() -> FixtureDefinition {
        FixtureDefinition {
            size_m: [0.2, 0.01, 0.01],
            ..Default::default()
        }
    }

    /// Regression: the end handle took each frame's pointer move relative to
    /// the end as drawn that frame, so on a fixture blown up to stay legible
    /// it ran away from the pointer instead of staying under it.
    #[test]
    fn a_dragged_end_stays_under_the_pointer() {
        let definition = tiny_bar();
        let start = LayoutPosition::at(pos2(100.0, 100.0));
        let far_end = pos2(110.0, 100.0);
        let drag = EndDrag::new(
            FixtureId::new(),
            &definition,
            start,
            FixtureEnd::Far,
            far_end,
        );

        let mut pointer = far_end;
        let mut position = start;
        for _ in 0..20 {
            pointer += vec2(1.0, 0.5);
            position = drag.position(pointer);
        }

        let end = fixture_footprint(&definition, &position, 1.0, 0.0).along_axis(1.0);
        assert!((end - pointer).length() < 1e-3, "{end:?} vs {pointer:?}");
        let near = fixture_footprint(&definition, &position, 1.0, 0.0).along_axis(0.0);
        assert!(
            (near - pos2(90.0, 100.0)).length() < 1e-3,
            "the other end stays put: {near:?}"
        );
    }

    #[test]
    fn a_drag_onto_the_pivot_leaves_the_fixture_alone() {
        let start = LayoutPosition::at(pos2(0.0, 0.0));
        let moved = end_scaled(start, pos2(-10.0, 0.0), pos2(10.0, 0.0), pos2(-10.0, 0.0));
        assert_eq!(moved, start);
    }
}