pts 0.1.3

GUI to create, manipulate, and arrange points on a canvas
Documentation
//! Core application state management including points, selection, and interaction modes.

use crate::persistence::{Point, PointShape};
use eframe::egui;
use facet::Facet;

#[derive(Clone, Facet)]
#[repr(u8)]
pub enum Selection {
    None,
    Single(usize),
    Multiple(Vec<usize>),
}

#[derive(Clone, Copy, PartialEq)]
pub enum PendingMode {
    None,
    Clone,
    Shape,
    View,
}

#[derive(Clone, Copy, PartialEq)]
pub enum InteractionMode {
    Normal,
    BoxSelect,
    Paintbrush,
}

pub struct AppState {
    pub points: Vec<Point>,
    pub selection: Selection,
    pub dragging: Option<usize>,
    pub pending_mode: PendingMode,
    pub interaction_mode: InteractionMode,
    pub show_help: bool,
    pub next_id: u64,
    pub box_select_start: Option<egui::Pos2>,
    pub box_select_end: Option<egui::Pos2>,
    pub snap_to_grid: bool,
    pub zoom: f32,
    pub last_paint_pos: Option<egui::Pos2>,
}

impl AppState {
    #[must_use]
    pub fn new(points: Vec<Point>) -> Self {
        let next_id = points.iter().map(|p| p.id).max().unwrap_or(0) + 1;
        let selection = if points.is_empty() {
            Selection::None
        } else {
            Selection::Single(0)
        };
        Self {
            points,
            selection,
            dragging: None,
            pending_mode: PendingMode::None,
            interaction_mode: InteractionMode::Normal,
            show_help: false,
            next_id,
            box_select_start: None,
            box_select_end: None,
            snap_to_grid: false,
            zoom: 1.0,
            last_paint_pos: None,
        }
    }

    #[must_use]
    pub fn point_at_pos(&self, pos: egui::Pos2, radius: f32) -> Option<usize> {
        self.points.iter().position(|pt| {
            let dx = pos.x - pt.x;
            let dy = pos.y - pt.y;
            (dx * dx + dy * dy).sqrt() < radius * 2.0
        })
    }

    #[must_use]
    pub fn selected_indices(&self) -> Vec<usize> {
        match &self.selection {
            Selection::None => vec![],
            Selection::Single(idx) => vec![*idx],
            Selection::Multiple(indices) => indices.clone(),
        }
    }

    pub fn move_selected(&mut self, dx: f32, dy: f32) {
        for idx in self.selected_indices() {
            self.points[idx].x += dx;
            self.points[idx].y += dy;
        }
    }

    pub fn snap_to_grid(&mut self, grid_spacing: f32, radius: f32) {
        for idx in self.selected_indices() {
            let pt = &mut self.points[idx];

            let snap_edge = |edge: f32| -> f32 { (edge / grid_spacing).round() * grid_spacing };

            let edges = [
                (pt.x - radius, pt.x + radius),
                (pt.y - radius, pt.y + radius),
            ];

            let snapped = edges.map(|(min, max)| {
                let min_snap = snap_edge(min);
                let max_snap = snap_edge(max);
                if (min - min_snap).abs() < (max - max_snap).abs() {
                    min_snap + radius
                } else {
                    max_snap - radius
                }
            });

            pt.x = snapped[0];
            pt.y = snapped[1];
        }
    }

    #[must_use]
    pub fn quantize_position(pos: f32, step: f32) -> f32 {
        (pos / step).round() * step
    }

    pub fn clone_selected(&mut self, dx: f32, dy: f32) {
        let indices = self.selected_indices();
        let mut new_points = Vec::new();

        for idx in indices {
            let pt = &self.points[idx];
            new_points.push(Point {
                id: self.next_id,
                x: pt.x + dx,
                y: pt.y + dy,
                shape: pt.shape,
                rotation: pt.rotation, // ADD THIS LINE
            });
            self.next_id += 1;
        }

        let start_idx = self.points.len();
        self.points.extend(new_points);

        if self.points.len() - start_idx == 1 {
            self.selection = Selection::Single(start_idx);
        } else {
            self.selection = Selection::Multiple((start_idx..self.points.len()).collect());
        }
    }

    pub fn set_selected_shape(&mut self, shape: PointShape) {
        for idx in self.selected_indices() {
            self.points[idx].shape = shape;
        }
    }

    /// # Panics
    ///
    /// May panic when unwrapping the max idx (?) TODO investigate
    pub fn delete_selected(&mut self) {
        let indices = self.selected_indices();
        if indices.is_empty() {
            return;
        }

        let mut indices_sorted = indices.clone();
        indices_sorted.sort_by(|a, b| b.cmp(a));

        for idx in indices_sorted {
            self.points.remove(idx);
        }

        if self.points.is_empty() {
            self.selection = Selection::None;
        } else {
            let max_id = self.points.iter().map(|p| p.id).max().unwrap();
            let max_idx = self.points.iter().position(|p| p.id == max_id).unwrap();
            self.selection = Selection::Single(max_idx);
        }
    }

    #[must_use]
    pub fn point_in_box(&self, idx: usize, rect: egui::Rect, radius: f32) -> bool {
        let pt = &self.points[idx];
        match pt.shape {
            PointShape::Circle | PointShape::Square => {
                rect.contains(egui::pos2(pt.x - radius, pt.y - radius))
                    && rect.contains(egui::pos2(pt.x + radius, pt.y + radius))
                    && rect.contains(egui::pos2(pt.x - radius, pt.y + radius))
                    && rect.contains(egui::pos2(pt.x + radius, pt.y - radius))
            }
            PointShape::Diamond | PointShape::Semicircle => {
                rect.contains(egui::pos2(pt.x, pt.y - radius))
                    && rect.contains(egui::pos2(pt.x + radius, pt.y))
                    && rect.contains(egui::pos2(pt.x, pt.y + radius))
                    && rect.contains(egui::pos2(pt.x - radius, pt.y))
            }
        }
    }

    pub fn select_in_box(&mut self, rect: egui::Rect, radius: f32) {
        let mut selected = Vec::new();
        for (idx, _) in self.points.iter().enumerate() {
            if self.point_in_box(idx, rect, radius) {
                selected.push(idx);
            }
        }

        self.selection = if selected.is_empty() {
            Selection::None
        } else if selected.len() == 1 {
            Selection::Single(selected[0])
        } else {
            Selection::Multiple(selected)
        };
    }

    #[must_use]
    pub fn convex_hull_offset(&self, direction: (f32, f32), radius: f32) -> (f32, f32) {
        let indices = self.selected_indices();
        if indices.is_empty() {
            return (0.0, 0.0);
        }

        let (dx, dy) = direction;

        if dx.abs() > 0.0 {
            let (min, max) = self.bounds_along_axis(|pt| pt.x, &indices, radius);
            (dx * (max - min), 0.0)
        } else {
            let (min, max) = self.bounds_along_axis(|pt| pt.y, &indices, radius);
            (0.0, dy * (max - min))
        }
    }

    fn bounds_along_axis<F>(&self, axis: F, indices: &[usize], radius: f32) -> (f32, f32)
    where
        F: Fn(&Point) -> f32,
    {
        let mut min = f32::MAX;
        let mut max = f32::MIN;
        for idx in indices {
            let val = axis(&self.points[*idx]);
            min = min.min(val - radius);
            max = max.max(val + radius);
        }
        (min, max)
    }

    pub fn expand_selection_box(&mut self, direction: (f32, f32), radius: f32) {
        let current = self.selected_indices();
        if current.is_empty() {
            return;
        }

        let mut candidates = Vec::new();
        for idx in current {
            let pt = &self.points[idx];
            let search_pos = egui::pos2(
                pt.x + direction.0 * radius * 2.0,
                pt.y + direction.1 * radius * 2.0,
            );

            for (i, other) in self.points.iter().enumerate() {
                let dist_sq = (other.x - search_pos.x).powi(2) + (other.y - search_pos.y).powi(2);
                if dist_sq < (radius * 2.5).powi(2) {
                    candidates.push(i);
                }
            }
        }

        let mut all_selected = self.selected_indices();
        for c in candidates {
            if !all_selected.contains(&c) {
                all_selected.push(c);
            }
        }

        self.selection = if all_selected.len() == 1 {
            Selection::Single(all_selected[0])
        } else {
            Selection::Multiple(all_selected)
        };
    }

    #[must_use]
    pub fn status_text(&self) -> Option<String> {
        if self.interaction_mode == InteractionMode::Paintbrush {
            Some("Paintbrush".to_string())
        } else if self.interaction_mode == InteractionMode::BoxSelect {
            Some("Box Select".to_string())
        } else if self.pending_mode == PendingMode::Clone {
            Some("Clone mode".to_string())
        } else if self.pending_mode == PendingMode::Shape {
            Some("Shape mode".to_string())
        } else if self.snap_to_grid {
            Some("Snap to Grid".to_string())
        } else {
            None
        }
    }

    #[must_use]
    pub fn get_paint_shape(&self) -> PointShape {
        match &self.selection {
            Selection::Single(idx) => self.points[*idx].shape,
            Selection::Multiple(indices) => {
                if let Some(idx) = indices.first() {
                    self.points[*idx].shape
                } else {
                    PointShape::Circle
                }
            }
            Selection::None => PointShape::Circle,
        }
    }

    pub fn paint_point(
        &mut self,
        pos: egui::Pos2,
        radius: f32,
        move_step: f32,
        grid_spacing: f32,
        snap: bool,
    ) {
        let quantized_x = Self::quantize_position(pos.x, move_step);
        let quantized_y = Self::quantize_position(pos.y, move_step);

        if let Some(last_pos) = self.last_paint_pos {
            let dx = (quantized_x - last_pos.x).abs();
            let dy = (quantized_y - last_pos.y).abs();

            if dx < radius * 2.0 && dy < radius * 2.0 {
                return;
            }
        }

        let shape = self.get_paint_shape();
        let rotation = self.get_paint_rotation();
        let new_point = Point {
            id: self.next_id,
            x: quantized_x,
            y: quantized_y,
            shape,
            rotation,
        };

        self.next_id += 1;
        self.points.push(new_point.clone());

        if snap {
            let idx = self.points.len() - 1;
            let temp_selection = self.selection.clone();
            self.selection = Selection::Single(idx);
            self.snap_to_grid(grid_spacing, radius);
            self.selection = temp_selection;
        }

        self.last_paint_pos = Some(egui::pos2(quantized_x, quantized_y));
    }

    pub fn rotate_selected(&mut self, angle: f32) {
        for idx in self.selected_indices() {
            let pt = &mut self.points[idx];
            pt.rotation += angle;
            // Normalize to 0..2π range
            pt.rotation = pt.rotation.rem_euclid(2.0 * std::f32::consts::PI);
        }
    }

    #[must_use]
    pub fn get_paint_rotation(&self) -> f32 {
        match &self.selection {
            Selection::Single(idx) => self.points[*idx].rotation,
            Selection::Multiple(indices) => {
                if let Some(idx) = indices.first() {
                    self.points[*idx].rotation
                } else {
                    0.0
                }
            }
            Selection::None => 0.0,
        }
    }
}