gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! What the patch canvas puts on a selection: the rubber band that makes
//! it, and the box and the handle that scale and turn it.

use crate::canvas::box_scale::{BoxScale, box_handles, scaled_box};
use crate::canvas::box_select::{Band, draw_band};
use crate::canvas::definition_drawing::{MIN_FIXTURE_SCREEN_PX, fixture_footprint};
use crate::canvas::rotate_handle::{RotateDrag, rotation_handle};
use crate::canvas::shapes::Shapes;
use crate::fixture_editor::canvas::{Definitions, definition_of};
use crate::fixture_editor::drag::Drag;
use crate::fixture_editor::state::EditorState;
use egui::emath::TSTransform;
use egui::{Modifiers, PointerButton, Pos2, Rect, Response, Ui};
use gled_model::patching::Patch;
use gled_model::patching::fixture::{FixtureId, LayoutPosition};
use gled_model::patching::layout::LayoutRef;

/// Starts a rubber band on a drag that begins on the background, at `press`
/// in scene units, and draws the band in flight.
pub(super) fn box_selection(
    state: &mut EditorState,
    canvas_response: &Response,
    overlay_painter: &mut Shapes,
    scene_transform: &TSTransform,
    press: Option<Pos2>,
    modifiers: Modifiers,
    fixture_took_focus: bool,
) {
    if canvas_response.drag_started_by(PointerButton::Primary)
        && state.drag.is_none()
        && !fixture_took_focus
        && let Some(press) = press
    {
        let band = Band::start(press, &mut state.selected_fixtures, modifiers);
        state.drag = Some(Drag::Band(band));
    }

    if let Some(Drag::Band(band)) = &state.drag {
        draw_band(overlay_painter, scene_transform, band.rect());
    }
}

/// The fixtures a band over `rect` (scene units) touches, at the size they
/// are drawn at `zoom`.
pub(super) fn covered_by(
    patch: &Patch,
    drawn: LayoutRef,
    rect: Rect,
    definitions: &Definitions,
    zoom: f32,
) -> Vec<FixtureId> {
    let Some(layout) = patch.layout(drawn) else {
        return vec![];
    };
    layout
        .members
        .iter()
        .filter(|(id, position)| {
            fixture_footprint(
                definition_of(definitions, **id),
                position,
                zoom,
                MIN_FIXTURE_SCREEN_PX,
            )
            .intersects(rect)
        })
        .map(|(id, _)| *id)
        .collect()
}

/// AABB of the selection in scene units, around the fixtures as they are
/// drawn at `zoom`.
fn selection_bbox(
    patch: &Patch,
    state: &EditorState,
    drawn: LayoutRef,
    definitions: &Definitions,
    zoom: f32,
) -> Option<Rect> {
    let layout = patch.layout(drawn)?;
    state
        .selected_fixtures
        .iter()
        .filter_map(|id| {
            let position = layout.members.get(id)?;
            let definition = definition_of(definitions, *id);
            Some(fixture_footprint(definition, position, zoom, MIN_FIXTURE_SCREEN_PX).bounds())
        })
        .reduce(|bounds, footprint| bounds.union(footprint))
}

/// Draws the AABB of the selected fixtures and puts 8 drag handles on it
/// (4 corners + 4 edges). Dragging a handle scales all selected fixtures'
/// positions and lengths relative to the opposite corner, or to the centre
/// while Alt is held.
pub(super) fn draw_bounding_box(
    ui: &mut Ui,
    patch: &Patch,
    state: &mut EditorState,
    drawn: LayoutRef,
    overlay_painter: &mut Shapes,
    scene_transform: &TSTransform,
    definitions: &Definitions,
    pointer: Option<Pos2>,
    modifiers: Modifiers,
) {
    let Some(live_box) = selection_bbox(patch, state, drawn, definitions, scene_transform.scaling)
    else {
        return;
    };

    // a box in flight is drawn where the drag has stretched it
    let shown_box = match (&state.drag, pointer) {
        (Some(Drag::Scale(scale)), Some(pointer)) => {
            let (origin, scale_x, scale_y) = scale.factors(pointer, modifiers.alt, modifiers.shift);
            scaled_box(scale.start_box, origin, scale_x, scale_y)
        }
        _ => live_box,
    };

    let started = box_handles(ui, overlay_painter, scene_transform, shown_box);

    if let (Some(handle), Some(pointer)) = (started, pointer) {
        state.drag = Some(Drag::Scale(BoxScale {
            handle,
            start_box: live_box,
            start_pointer: pointer,
            start: selected_positions(patch, state, drawn),
        }));
    }
}

/// Puts the rotation handle above the selection. Dragging it turns every
/// selected fixture about the selection's centre, in 15° steps while Shift is
/// held.
pub(super) fn draw_rotation_handle(
    ui: &mut Ui,
    patch: &Patch,
    state: &mut EditorState,
    drawn: LayoutRef,
    overlay_painter: &mut Shapes,
    scene_transform: &TSTransform,
    definitions: &Definitions,
    pointer: Option<Pos2>,
) {
    let Some(live_box) = selection_bbox(patch, state, drawn, definitions, scene_transform.scaling)
    else {
        return;
    };

    // while turning, the handle stays where the drag took hold of it
    let shown_box = match &state.drag {
        Some(Drag::Rotate(rotation)) => rotation.start_box,
        _ => live_box,
    };
    let handle = rotation_handle(ui, overlay_painter, scene_transform, shown_box);
    if handle.drag_started_by(PointerButton::Primary)
        && let Some(pointer) = pointer
    {
        state.drag = Some(Drag::Rotate(RotateDrag::new(
            live_box,
            pointer,
            selected_positions(patch, state, drawn),
        )));
    }
}

/// Where each selected fixture sits in the drawn layout.
pub(super) fn selected_positions(
    patch: &Patch,
    state: &EditorState,
    drawn: LayoutRef,
) -> Vec<(FixtureId, LayoutPosition)> {
    patch
        .layout(drawn)
        .map(|layout| {
            state
                .selected_fixtures
                .iter()
                .filter_map(|id| layout.members.get(id).map(|position| (*id, *position)))
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use egui::pos2;
    use gled_model::asset::Asset;
    use gled_model::asset_id::AssetId;
    use gled_model::patching::definition::FixtureDefinition;
    use gled_model::patching::definition::{EmittedColor, Pixel};
    use gled_model::patching::fixture::Fixture;

    /// A 1 m wide fixture, so scene extents are 100 units.
    fn definitions_for(ids: &[FixtureId]) -> Definitions {
        let definition = FixtureDefinition {
            size_m: [1.0, 0.05, 0.05],
            pixels: vec![Pixel {
                name: "pixel-0".to_string(),
                model: 0,
                position_m: [0.0; 3],
                rotation: 0.0,
                channels: vec![EmittedColor::Red, EmittedColor::Green, EmittedColor::Blue],
            }],
            ..Default::default()
        };
        let asset = std::sync::Arc::new(Asset {
            id: AssetId::default(),
            path: vec!["bar".to_string()],
            data: definition,
        });
        ids.iter().map(|id| (*id, asset.clone())).collect()
    }

    #[test]
    fn covered_by_finds_fixtures_inside_the_box() {
        let mut patch = Patch::default();
        let inside =
            patch.add_fixture(Fixture::new("inside"), LayoutPosition::at(pos2(10.0, 10.0)));
        patch.add_fixture(
            Fixture::new("outside"),
            LayoutPosition::at(pos2(900.0, 900.0)),
        );

        let definitions = definitions_for(&[inside]);
        let hits = covered_by(
            &patch,
            LayoutRef::Topology,
            Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)),
            &definitions,
            1.0,
        );

        assert_eq!(hits, vec![inside]);
    }

    /// Regression: the band tested fixtures at their true size while the
    /// canvas drew them blown up to stay legible, so zoomed out a band over
    /// what was drawn of a thin bar missed it.
    #[test]
    fn a_fixture_below_the_legibility_floor_is_band_selected_as_drawn() {
        let mut patch = Patch::default();
        // 1 m × 5 cm: 2.5 units to either side of its centre line
        let bar = patch.add_fixture(Fixture::new("bar"), LayoutPosition::at(pos2(500.0, 500.0)));
        let definitions = definitions_for(&[bar]);
        // beside the bar itself, but inside what is drawn of it zoomed out
        let band = Rect::from_min_max(pos2(480.0, 506.0), pos2(520.0, 510.0));

        let zoomed_out = covered_by(&patch, LayoutRef::Topology, band, &definitions, 0.45);
        let zoomed_in = covered_by(&patch, LayoutRef::Topology, band, &definitions, 8.0);

        assert_eq!(zoomed_out, vec![bar], "drawn 11 px deep at 0.45");
        assert!(zoomed_in.is_empty(), "drawn at its true size at 8");
    }

    #[test]
    fn selection_bbox_spans_the_selected_fixtures() {
        let mut patch = Patch::default();
        let mut state = EditorState::default();
        let a = patch.add_fixture(Fixture::new("a"), LayoutPosition::at(pos2(0.0, 0.0)));
        let b = patch.add_fixture(Fixture::new("b"), LayoutPosition::at(pos2(200.0, 0.0)));
        state.selected_fixtures.extend([a, b]);

        let definitions = definitions_for(&[a, b]);
        let bbox = selection_bbox(&patch, &state, LayoutRef::Topology, &definitions, 1.0)
            .expect("two fixtures are selected");

        // two 1 m fixtures (100 units wide, centred) at x = 0 and x = 200
        assert_eq!(bbox.min.x, -50.0);
        assert_eq!(bbox.max.x, 250.0);
    }
}