gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! A shape being drawn on the definition canvas, and the LEDs it carries.

use crate::fixture_definition_editor::draw_tools::Tool;
use gled_model::patching::definition::Pixel;
use gled_model::patching::definition::generators::{
    PathSpec, PixelTemplate, RingSpec, path, ring, smooth,
};

/// Straight pieces between two corners of a smooth path.
const SMOOTH_STEPS: usize = 16;

/// A shape being drawn, in metres in the definition's frame.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Shape {
    Line {
        from: [f32; 2],
        to: [f32; 2],
    },
    Circle {
        centre: [f32; 2],
        radius: f32,
        start_deg: f32,
    },
    Path {
        points: Vec<[f32; 2]>,
        closed: bool,
        finished: bool,
    },
}

/// The drawing tools' state.
#[derive(Debug, Clone)]
pub(crate) struct DrawState {
    pub(crate) tool: Tool,
    pub(crate) shape: Option<Shape>,
    /// How many LEDs the shape carries.
    pub(crate) count: usize,
    /// On a line or an open path, half a pitch stays free at either end.
    pub(crate) inset: bool,
    /// A path runs as a smooth curve through its corners.
    pub(crate) smooth: bool,
}

impl Default for DrawState {
    fn default() -> Self {
        Self {
            tool: Tool::Select,
            shape: None,
            count: 10,
            inset: false,
            smooth: false,
        }
    }
}

impl DrawState {
    /// Whether the shape is drawn far enough to carry LEDs.
    pub(crate) fn complete(&self) -> bool {
        match &self.shape {
            Some(Shape::Line { from, to }) => from != to,
            Some(Shape::Circle { radius, .. }) => *radius > 0.0,
            Some(Shape::Path { points, .. }) => points.len() >= 2,
            None => false,
        }
    }

    /// How long the shape is that the LEDs are spread along, in metres.
    fn length(&self) -> Option<f32> {
        if !self.complete() {
            return None;
        }
        match &self.shape {
            Some(Shape::Line { from, to }) => Some((to[0] - from[0]).hypot(to[1] - from[1])),
            Some(Shape::Circle { radius, .. }) => Some(std::f32::consts::TAU * radius),
            Some(Shape::Path { points, closed, .. }) => {
                let mut course = self.course(points, *closed);
                if *closed && let Some(first) = course.first().copied() {
                    course.push(first);
                }
                Some(
                    course
                        .windows(2)
                        .map(|pair| (pair[1][0] - pair[0][0]).hypot(pair[1][1] - pair[0][1]))
                        .sum(),
                )
            }
            None => None,
        }
    }

    /// Whether an LED sits on each end point: on a line or an open path
    /// without free ends.
    fn ends_on_points(&self) -> bool {
        match &self.shape {
            Some(Shape::Line { .. }) => !self.inset,
            Some(Shape::Path { closed, .. }) => !closed && !self.inset,
            _ => false,
        }
    }

    /// The distance from LED to LED along the shape, in metres.
    pub(crate) fn pitch(&self) -> Option<f32> {
        let length = self.length()?;
        let gaps = if self.ends_on_points() {
            self.count.saturating_sub(1)
        } else {
            self.count
        };
        (gaps > 0).then(|| length / gaps as f32)
    }

    /// How many LEDs put `pitch` metres between neighbours on this shape.
    pub(crate) fn count_for_pitch(&self, pitch: f32) -> usize {
        let Some(length) = self.length().filter(|_| pitch > 0.0) else {
            return self.count;
        };
        let gaps = ((length / pitch).round() as usize).max(1);
        if self.ends_on_points() {
            gaps + 1
        } else {
            gaps
        }
    }

    /// The line a path's LEDs follow: its corners, or the smooth curve
    /// through them.
    pub(crate) fn course(&self, points: &[[f32; 2]], closed: bool) -> Vec<[f32; 2]> {
        if self.smooth {
            smooth(points, closed, SMOOTH_STEPS)
        } else {
            points.to_vec()
        }
    }

    /// The LEDs the shape carries, numbered on from `template`.
    pub(crate) fn pixels(&self, template: &PixelTemplate) -> Vec<Pixel> {
        if !self.complete() {
            return vec![];
        }
        match &self.shape {
            Some(Shape::Line { from, to }) => path(
                template,
                &PathSpec {
                    points_m: vec![*from, *to],
                    closed: false,
                    count: self.count,
                    inset_ends: self.inset,
                },
            ),
            Some(Shape::Circle {
                centre,
                radius,
                start_deg,
            }) => ring(
                template,
                &RingSpec {
                    centre_m: *centre,
                    radius_m: *radius,
                    count: self.count,
                    start_deg: *start_deg,
                    clockwise: true,
                    along_ring: true,
                },
            ),
            Some(Shape::Path { points, closed, .. }) => path(
                template,
                &PathSpec {
                    points_m: self.course(points, *closed),
                    closed: *closed,
                    count: self.count,
                    inset_ends: self.inset && !closed,
                },
            ),
            None => vec![],
        }
    }
}

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

    #[test]
    fn a_smooth_path_bends_through_its_corners() {
        let corners = vec![[0.0, 0.0], [0.1, 0.1], [0.2, 0.0]];
        let mut draw = DrawState {
            shape: Some(Shape::Path {
                points: corners,
                closed: false,
                finished: true,
            }),
            count: 5,
            ..Default::default()
        };
        let straight = draw.pixels(&template());
        draw.smooth = true;
        let curved = draw.pixels(&template());
        // the middle LED sits on the middle corner either way
        assert!(
            (curved[2].position_m[1] - 0.1).abs() < 0.005,
            "{:?}",
            curved[2]
        );
        // on the way there the curve bulges out past the straight line
        assert!(curved[1].position_m[1] > straight[1].position_m[1] + 0.005);
    }

    fn template() -> PixelTemplate {
        PixelTemplate {
            prefix: "p".to_string(),
            first_number: 0,
            model: 0,
            order: Default::default(),
        }
    }

    #[test]
    fn a_line_carries_its_count_from_end_to_end() {
        let draw = DrawState {
            shape: Some(Shape::Line {
                from: [0.0, 0.0],
                to: [0.9, 0.0],
            }),
            count: 10,
            ..Default::default()
        };
        let pixels = draw.pixels(&template());
        assert_eq!(pixels.len(), 10);
        assert!((pixels[9].position_m[0] - 0.9).abs() < 1e-5);
    }

    #[test]
    fn a_circle_starts_where_it_was_dragged_to() {
        let draw = DrawState {
            shape: Some(Shape::Circle {
                centre: [0.0, 0.0],
                radius: 0.1,
                start_deg: 90.0,
            }),
            count: 4,
            ..Default::default()
        };
        let pixels = draw.pixels(&template());
        // 90° clockwise from twelve is three o'clock
        assert!((pixels[0].position_m[0] - 0.1).abs() < 1e-5);
        assert!(pixels[0].position_m[1].abs() < 1e-5);
    }

    #[test]
    fn a_path_with_one_corner_carries_nothing_yet() {
        let draw = DrawState {
            shape: Some(Shape::Path {
                points: vec![[0.0, 0.0]],
                closed: false,
                finished: false,
            }),
            ..Default::default()
        };
        assert!(draw.pixels(&template()).is_empty());
    }

    #[test]
    fn the_pitch_follows_the_count_and_back() {
        let mut draw = DrawState {
            shape: Some(Shape::Line {
                from: [0.0, 0.0],
                to: [1.0, 0.0],
            }),
            count: 11,
            ..Default::default()
        };
        // eleven LEDs on a metre, one on each end: ten gaps
        assert!((draw.pitch().expect("a complete line") - 0.1).abs() < 1e-6);
        assert_eq!(draw.count_for_pitch(1.0 / 60.0), 61);
        draw.inset = true;
        assert_eq!(draw.count_for_pitch(0.1), 10);
    }
}