diff --git a/src/app_state.rs b/src/app_state.rs index 709113f..5a5e22b 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,6 +1,6 @@ use crate::canvas::{CanvasTransform, Uniforms}; use crate::document::Document; -use crate::drawing::{Element, ElementId, Tool, sync_id_counters}; +use crate::drawing::{Element, ElementId, FillStyle, Tool, sync_id_counters}; use crate::history::{Action, History}; use crate::state::{ Canvas, ColorPickerState, GeometryBuffers, GpuContext, InputState, SdfBuffers, SelectionState, @@ -40,6 +40,7 @@ pub struct State { pub current_color: [f32; 4], pub color_picker: ColorPickerState, pub stroke_width: f32, + pub current_fill_style: FillStyle, pub clipboard: Vec, pub ui_renderer: UiRenderer, @@ -425,6 +426,7 @@ impl State { current_color: [0.0, 0.0, 0.0, 1.0], color_picker: ColorPickerState::new(), stroke_width: 2.0, + current_fill_style: FillStyle::None, clipboard: Vec::new(), ui_renderer, text_renderer, diff --git a/src/document.rs b/src/document.rs index cb53eac..7f04abd 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::drawing::Element; +use crate::drawing::{Element, FillStyle}; pub const SCHEMA_VERSION: u32 = 1; @@ -106,7 +106,7 @@ mod tests { position: [50.0, 50.0], size: [200.0, 100.0], color: [0.0, 1.0, 0.0, 1.0], - fill: true, + fill_style: FillStyle::Solid, stroke_width: 1.5, rough_style: None, }, @@ -118,7 +118,7 @@ mod tests { center: [150.0, 150.0], radius: 75.0, color: [0.0, 0.0, 1.0, 1.0], - fill: false, + fill_style: FillStyle::None, stroke_width: 2.0, rough_style: None, }, @@ -130,7 +130,7 @@ mod tests { position: [300.0, 100.0], size: [80.0, 60.0], color: [1.0, 1.0, 0.0, 1.0], - fill: false, + fill_style: FillStyle::None, stroke_width: 2.5, rough_style: None, }, diff --git a/src/drawing.rs b/src/drawing.rs index 87b13e4..b8be9da 100644 --- a/src/drawing.rs +++ b/src/drawing.rs @@ -1,6 +1,70 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Fill style for shape primitives, following Excalidraw's approach. +/// +/// - `None`: No fill (stroke only) +/// - `Solid`: Solid color fill +/// - `Hachure`: Parallel sketchy lines at an angle (default Excalidraw style) +/// - `CrossHatch`: Two sets of hachure lines at perpendicular angles +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum FillStyle { + None, + Solid, + Hachure, + CrossHatch, +} + +impl FillStyle { + /// Cycle to the next fill style: None -> Hachure -> CrossHatch -> Solid -> None + pub fn next(self) -> Self { + match self { + FillStyle::None => FillStyle::Hachure, + FillStyle::Hachure => FillStyle::CrossHatch, + FillStyle::CrossHatch => FillStyle::Solid, + FillStyle::Solid => FillStyle::None, + } + } + + pub fn is_filled(self) -> bool { + self != FillStyle::None + } +} + +/// Custom deserializer that handles both old `fill: bool` and new `fill_style: "Hachure"` formats. +pub fn deserialize_fill_style<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de; + + struct FillStyleVisitor; + + impl<'de> de::Visitor<'de> for FillStyleVisitor { + type Value = FillStyle; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a FillStyle string or a boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(if v { FillStyle::Solid } else { FillStyle::None }) + } + + fn visit_str(self, v: &str) -> Result { + match v { + "None" => Ok(FillStyle::None), + "Solid" => Ok(FillStyle::Solid), + "Hachure" => Ok(FillStyle::Hachure), + "CrossHatch" => Ok(FillStyle::CrossHatch), + _ => Err(de::Error::unknown_variant(v, &["None", "Solid", "Hachure", "CrossHatch"])), + } + } + } + + deserializer.deserialize_any(FillStyleVisitor) +} + static NEXT_ELEMENT_ID: AtomicU64 = AtomicU64::new(1); static NEXT_GROUP_ID: AtomicU64 = AtomicU64::new(1); @@ -105,7 +169,8 @@ pub enum DrawingElement { position: [f32; 2], size: [f32; 2], color: [f32; 4], - fill: bool, + #[serde(deserialize_with = "deserialize_fill_style", alias = "fill")] + fill_style: FillStyle, stroke_width: f32, rough_style: Option, }, @@ -113,7 +178,8 @@ pub enum DrawingElement { center: [f32; 2], radius: f32, color: [f32; 4], - fill: bool, + #[serde(deserialize_with = "deserialize_fill_style", alias = "fill")] + fill_style: FillStyle, stroke_width: f32, rough_style: Option, }, @@ -121,7 +187,8 @@ pub enum DrawingElement { position: [f32; 2], size: [f32; 2], color: [f32; 4], - fill: bool, + #[serde(deserialize_with = "deserialize_fill_style", alias = "fill")] + fill_style: FillStyle, stroke_width: f32, rough_style: Option, }, @@ -176,24 +243,33 @@ impl DrawingElement { } } - pub fn set_fill(&mut self, fill: bool) -> bool { + pub fn fill_style(&self) -> Option { + match self { + DrawingElement::Rectangle { fill_style, .. } + | DrawingElement::Circle { fill_style, .. } + | DrawingElement::Diamond { fill_style, .. } => Some(*fill_style), + _ => None, + } + } + + pub fn set_fill_style(&mut self, style: FillStyle) -> bool { match self { - DrawingElement::Rectangle { fill: value, .. } - | DrawingElement::Circle { fill: value, .. } - | DrawingElement::Diamond { fill: value, .. } => { - *value = fill; + DrawingElement::Rectangle { fill_style, .. } + | DrawingElement::Circle { fill_style, .. } + | DrawingElement::Diamond { fill_style, .. } => { + *fill_style = style; true } _ => false, } } - pub fn toggle_fill(&mut self) -> bool { + pub fn cycle_fill_style(&mut self) -> bool { match self { - DrawingElement::Rectangle { fill, .. } - | DrawingElement::Circle { fill, .. } - | DrawingElement::Diamond { fill, .. } => { - *fill = !*fill; + DrawingElement::Rectangle { fill_style, .. } + | DrawingElement::Circle { fill_style, .. } + | DrawingElement::Diamond { fill_style, .. } => { + *fill_style = fill_style.next(); true } _ => false, diff --git a/src/event_handler.rs b/src/event_handler.rs index 3e6abcc..17400a6 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -1,9 +1,9 @@ use crate::app_state::State; -use crate::drawing::{BoxState, DrawingElement, Element, ElementId, GroupId, Tool}; +use crate::drawing::{BoxState, DrawingElement, Element, ElementId, FillStyle, GroupId, Tool}; use crate::history::Action; use crate::state::ResizeHandle; use crate::state::UserInputState::{Dragging, Drawing, Idle, MarqueeSelecting, Panning, Resizing}; -use crate::ui::ColorInteraction; +use crate::ui::{ColorInteraction, FillInteraction}; use crate::update_logic::handle_positions; use rand::Rng; use winit::event::*; @@ -151,10 +151,25 @@ impl State { } } + match self.ui_renderer.handle_fill_interaction( + self.input.mouse_pos, + self.current_tool, + (self.size.width as f32, self.size.height as f32), + ) { + FillInteraction::None => {} + FillInteraction::SelectFill(style) => { + self.current_fill_style = style; + // Also apply to selected elements + self.set_fill_style_on_selection(style); + return true; + } + } + if self.ui_renderer.is_mouse_over_ui( self.input.mouse_pos, (self.size.width as f32, self.size.height as f32), &self.color_picker, + self.current_tool, ) || self.is_mouse_in_titlebar(self.input.mouse_pos) { return true; @@ -253,6 +268,7 @@ impl State { self.input.mouse_pos, (self.size.width as f32, self.size.height as f32), &self.color_picker, + self.current_tool, ) || self.is_mouse_in_titlebar(self.input.mouse_pos) { self.finish_drawing(); @@ -320,7 +336,7 @@ impl State { } } KeyCode::KeyF => { - self.toggle_fill_on_selection(); + self.cycle_fill_on_selection(); true } KeyCode::BracketLeft => { @@ -829,7 +845,27 @@ impl State { } } - fn toggle_fill_on_selection(&mut self) { + fn cycle_fill_on_selection(&mut self) { + let ids = self.input.selection.selected_ids.clone(); + if ids.is_empty() { + // Cycle the default fill style when nothing is selected + self.current_fill_style = self.current_fill_style.next(); + return; + } + let before = self.snapshot_elements(&ids); + let mut changed = false; + for id in &ids { + if let Some(element) = self.find_element_mut_by_id(*id) { + changed |= element.shape.cycle_fill_style(); + } + } + if changed { + let after = self.snapshot_elements(&ids); + self.record_action(Action::ModifyProperty { before, after }); + } + } + + fn set_fill_style_on_selection(&mut self, style: FillStyle) { let ids = self.input.selection.selected_ids.clone(); if ids.is_empty() { return; @@ -838,7 +874,7 @@ impl State { let mut changed = false; for id in &ids { if let Some(element) = self.find_element_mut_by_id(*id) { - changed |= element.shape.toggle_fill(); + changed |= element.shape.set_fill_style(style); } } if changed { @@ -1200,7 +1236,7 @@ impl State { position, size, color: self.current_color, - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: Some(rough_style), }) @@ -1215,7 +1251,7 @@ impl State { center: start, radius, color: self.current_color, - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: Some(rough_options), })) @@ -1230,7 +1266,7 @@ impl State { position, size, color: self.current_color, - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: Some(rough_style), }) @@ -1331,7 +1367,7 @@ impl State { self.current_color[2], 0.5, ], - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: None, }); @@ -1350,7 +1386,7 @@ impl State { self.current_color[2], 0.5, ], - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: None, }); @@ -1404,7 +1440,7 @@ impl State { self.current_color[2], 0.5, ], - fill: false, + fill_style: self.current_fill_style, stroke_width: self.stroke_width, rough_style: None, }); diff --git a/src/rough.rs b/src/rough.rs index bee5db2..5cb2968 100644 --- a/src/rough.rs +++ b/src/rough.rs @@ -499,6 +499,147 @@ impl RoughGenerator { (vertices, indices) } + /// Generate hachure fill lines for a polygon using a scanline algorithm. + /// + /// This follows the Excalidraw/RoughJS approach: + /// 1. Rotate the polygon by -hachure_angle so scanlines become horizontal + /// 2. Cast horizontal scanlines at `gap` intervals + /// 3. Find intersection points with polygon edges + /// 4. Connect pairs of intersections to form fill lines + /// 5. Rotate the resulting lines back by +hachure_angle + /// + /// Each fill line gets slight roughness applied for the hand-drawn look. + pub fn hachure_fill( + &mut self, + polygon: &[[f32; 2]], + hachure_angle: f32, + gap: f32, + fill_weight: f32, + options: &RoughOptions, + ) -> Vec> { + if polygon.len() < 3 || gap <= 0.0 { + return Vec::new(); + } + + let angle_rad = -hachure_angle.to_radians(); + let cos_a = angle_rad.cos(); + let sin_a = angle_rad.sin(); + + // Rotate polygon so we can use horizontal scanlines + let rotated: Vec<[f32; 2]> = polygon + .iter() + .map(|p| rotate_point(*p, cos_a, sin_a)) + .collect(); + + // Find vertical extent of rotated polygon + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + for p in &rotated { + min_y = min_y.min(p[1]); + max_y = max_y.max(p[1]); + } + + let mut lines = Vec::new(); + let cos_back = (-angle_rad).cos(); + let sin_back = (-angle_rad).sin(); + + // Cast horizontal scanlines at regular intervals + let mut y = min_y + gap; + while y < max_y { + // Find intersections with polygon edges + let mut intersections = Vec::new(); + let n = rotated.len(); + for i in 0..n { + let p1 = rotated[i]; + let p2 = rotated[(i + 1) % n]; + + if (p1[1] <= y && p2[1] > y) || (p2[1] <= y && p1[1] > y) { + let t = (y - p1[1]) / (p2[1] - p1[1]); + let x = p1[0] + t * (p2[0] - p1[0]); + intersections.push(x); + } + } + + intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + // Connect pairs of intersections + let mut i = 0; + while i + 1 < intersections.len() { + let x1 = intersections[i]; + let x2 = intersections[i + 1]; + + // Rotate the line endpoints back to original space + let start = rotate_point([x1, y], cos_back, sin_back); + let end = rotate_point([x2, y], cos_back, sin_back); + + // Apply roughness to the fill line + if options.roughness > 0.0 { + let rough_line = self.rough_fill_line(start, end, options, fill_weight); + lines.push(rough_line); + } else { + lines.push(vec![start, end]); + } + + i += 2; + } + + y += gap; + } + + lines + } + + /// Generate cross-hatch fill: hachure at angle θ, then again at θ+90°. + pub fn cross_hatch_fill( + &mut self, + polygon: &[[f32; 2]], + hachure_angle: f32, + gap: f32, + fill_weight: f32, + options: &RoughOptions, + ) -> Vec> { + let mut lines = self.hachure_fill(polygon, hachure_angle, gap, fill_weight, options); + let lines2 = self.hachure_fill(polygon, hachure_angle + 90.0, gap, fill_weight, options); + lines.extend(lines2); + lines + } + + /// Generate a rough fill line (less jitter than stroke lines, thinner). + fn rough_fill_line( + &mut self, + start: [f32; 2], + end: [f32; 2], + options: &RoughOptions, + _fill_weight: f32, + ) -> Vec<[f32; 2]> { + let dx = end[0] - start[0]; + let dy = end[1] - start[1]; + let length = (dx * dx + dy * dy).sqrt(); + + if length < 1.0 { + return vec![start, end]; + } + + // Lighter roughness for fill lines (matching Excalidraw behavior) + let roughness = options.roughness * 0.5; + let offset = (options.max_randomness_offset * 0.3).min(length * 0.1); + + let start_offset_x = self.random() * offset * roughness - offset * roughness * 0.5; + let start_offset_y = self.random() * offset * roughness - offset * roughness * 0.5; + let end_offset_x = self.random() * offset * roughness - offset * roughness * 0.5; + let end_offset_y = self.random() * offset * roughness - offset * roughness * 0.5; + + let mid_offset = roughness * options.bowing * 0.5; + let mid_x = (start[0] + end[0]) * 0.5 + (self.random() - 0.5) * mid_offset; + let mid_y = (start[1] + end[1]) * 0.5 + (self.random() - 0.5) * mid_offset; + + vec![ + [start[0] + start_offset_x, start[1] + start_offset_y], + [mid_x, mid_y], + [end[0] + end_offset_x, end[1] + end_offset_y], + ] + } + pub fn rough_arrow( &mut self, start: [f32; 2], @@ -552,3 +693,8 @@ impl RoughGenerator { lines } } + +/// Rotate a 2D point around the origin. +fn rotate_point(p: [f32; 2], cos: f32, sin: f32) -> [f32; 2] { + [p[0] * cos - p[1] * sin, p[0] * sin + p[1] * cos] +} diff --git a/src/ui.rs b/src/ui.rs index 5aafedc..f53eb98 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,5 +1,5 @@ use crate::{ - drawing::Tool, + drawing::{FillStyle, Tool}, state::{ColorPickerDragMode, ColorPickerState}, vertex::UiVertex, }; @@ -51,6 +51,11 @@ pub enum ColorInteraction { BeginDrag(ColorPickerDragMode, [f32; 4]), } +pub enum FillInteraction { + None, + SelectFill(FillStyle), +} + #[derive(Clone, Copy)] struct UiLayout { screen_size: (f32, f32), @@ -167,6 +172,27 @@ impl UiLayout { ] } + /// Fill style panel: positioned on the right side of the screen. + fn fill_panel_origin(&self) -> [f32; 2] { + [ + self.screen_size.0 - self.edge_padding - self.fill_panel_size()[0], + self.palette_origin[1], + ] + } + + fn fill_panel_size(&self) -> [f32; 2] { + let btn_size = self.fill_button_size(); + let padding = (btn_size * 0.3).clamp(6.0, 12.0); + [ + btn_size * 2.0 + padding + padding * 2.0, + btn_size * 2.0 + padding + padding * 2.0, + ] + } + + fn fill_button_size(&self) -> f32 { + (self.swatch_size * 0.9).clamp(28.0, 48.0) + } + fn zoom_center(&self) -> [f32; 2] { [ self.edge_padding + self.zoom_size()[0] * 0.5, @@ -1649,6 +1675,310 @@ impl UiRenderer { Some(hsv_to_rgb(picker.hue, saturation, value)) } + /// Returns true if the given tool supports fill styles. + fn tool_supports_fill(tool: Tool) -> bool { + matches!(tool, Tool::Rectangle | Tool::Circle | Tool::Diamond) + } + + /// Generate fill style panel (right side) when a shape tool is active. + fn generate_fill_panel( + &self, + vertices: &mut Vec, + indices: &mut Vec, + index_offset: &mut u16, + current_tool: Tool, + current_fill_style: FillStyle, + screen_size: (f32, f32), + ) { + if !Self::tool_supports_fill(current_tool) { + return; + } + + let layout = UiLayout::new(screen_size); + let origin = layout.fill_panel_origin(); + let panel_size = layout.fill_panel_size(); + let btn_size = layout.fill_button_size(); + let padding = (btn_size * 0.3).clamp(6.0, 12.0); + + // Panel background shadow + let panel_center = [ + origin[0] + panel_size[0] * 0.5 + 2.0, + origin[1] + panel_size[1] * 0.5 + 2.0, + ]; + self.create_rounded_rect( + vertices, + indices, + index_offset, + panel_center, + panel_size, + [0.0, 0.0, 0.0, 0.12], + 8.0 * layout.scale, + 0.0, + ); + + // Panel background + let panel_center = [ + origin[0] + panel_size[0] * 0.5, + origin[1] + panel_size[1] * 0.5, + ]; + self.create_rounded_rect( + vertices, + indices, + index_offset, + panel_center, + panel_size, + [0.96, 0.96, 0.97, 0.98], + 8.0 * layout.scale, + 1.5, + ); + + // 4 fill style buttons in a 2x2 grid: None, Hachure, CrossHatch, Solid + let styles = [ + FillStyle::None, + FillStyle::Hachure, + FillStyle::CrossHatch, + FillStyle::Solid, + ]; + + for (i, &style) in styles.iter().enumerate() { + let col = (i % 2) as f32; + let row = (i / 2) as f32; + let cx = origin[0] + padding + btn_size * 0.5 + col * (btn_size + padding); + let cy = origin[1] + padding + btn_size * 0.5 + row * (btn_size + padding); + + let is_selected = style == current_fill_style; + + let btn_color = if is_selected { + [0.25, 0.55, 0.95, 1.0] + } else { + [0.85, 0.85, 0.87, 1.0] + }; + + self.create_rounded_rect( + vertices, + indices, + index_offset, + [cx, cy], + [btn_size, btn_size], + btn_color, + 6.0 * layout.scale, + if is_selected { 0.0 } else { 1.0 }, + ); + + let icon_color = if is_selected { + [1.0, 1.0, 1.0, 1.0] + } else { + [0.25, 0.25, 0.28, 1.0] + }; + + self.draw_fill_style_icon( + vertices, + indices, + index_offset, + style, + [cx, cy], + btn_size * 0.4, + icon_color, + ); + } + } + + /// Draw a small icon representing a fill style. + fn draw_fill_style_icon( + &self, + vertices: &mut Vec, + indices: &mut Vec, + index_offset: &mut u16, + style: FillStyle, + center: [f32; 2], + size: f32, + color: [f32; 4], + ) { + let half = size * 0.5; + match style { + FillStyle::None => { + // Empty rectangle outline + let thickness = size * 0.15; + self.draw_rect_outline( + vertices, + indices, + index_offset, + center, + [size, size * 0.7], + thickness, + color, + ); + } + FillStyle::Hachure => { + // Rectangle with diagonal lines + let thickness = size * 0.12; + self.draw_rect_outline( + vertices, + indices, + index_offset, + center, + [size, size * 0.7], + thickness * 0.8, + color, + ); + // Diagonal hachure lines + let h = size * 0.35; + let line_w = size * 0.08; + for i in 0..3 { + let offset_x = (i as f32 - 1.0) * size * 0.28; + let x1 = center[0] + offset_x - h * 0.3; + let y1 = center[1] - h; + let x2 = center[0] + offset_x + h * 0.3; + let y2 = center[1] + h; + self.draw_line_segment(vertices, indices, index_offset, [x1, y1], [x2, y2], line_w, color); + } + } + FillStyle::CrossHatch => { + // Rectangle with cross-diagonal lines + let thickness = size * 0.12; + self.draw_rect_outline( + vertices, + indices, + index_offset, + center, + [size, size * 0.7], + thickness * 0.8, + color, + ); + let h = size * 0.35; + let line_w = size * 0.07; + // Forward diagonals + for i in 0..3 { + let offset_x = (i as f32 - 1.0) * size * 0.28; + let x1 = center[0] + offset_x - h * 0.3; + let y1 = center[1] - h; + let x2 = center[0] + offset_x + h * 0.3; + let y2 = center[1] + h; + self.draw_line_segment(vertices, indices, index_offset, [x1, y1], [x2, y2], line_w, color); + } + // Back diagonals + for i in 0..3 { + let offset_x = (i as f32 - 1.0) * size * 0.28; + let x1 = center[0] + offset_x + h * 0.3; + let y1 = center[1] - h; + let x2 = center[0] + offset_x - h * 0.3; + let y2 = center[1] + h; + self.draw_line_segment(vertices, indices, index_offset, [x1, y1], [x2, y2], line_w, color); + } + } + FillStyle::Solid => { + // Filled rectangle + self.create_simple_rect( + vertices, + indices, + index_offset, + center, + [size, size * 0.7], + color, + ); + } + } + } + + /// Draw a line segment as a quad (for fill style icons). + fn draw_line_segment( + &self, + vertices: &mut Vec, + indices: &mut Vec, + index_offset: &mut u16, + p1: [f32; 2], + p2: [f32; 2], + width: f32, + color: [f32; 4], + ) { + let dx = p2[0] - p1[0]; + let dy = p2[1] - p1[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let nx = -dy / len * width * 0.5; + let ny = dx / len * width * 0.5; + + vertices.extend_from_slice(&[ + UiVertex { position: [p1[0] - nx, p1[1] - ny], color, uv: [0.0, 0.0] }, + UiVertex { position: [p1[0] + nx, p1[1] + ny], color, uv: [0.0, 0.0] }, + UiVertex { position: [p2[0] + nx, p2[1] + ny], color, uv: [0.0, 0.0] }, + UiVertex { position: [p2[0] - nx, p2[1] - ny], color, uv: [0.0, 0.0] }, + ]); + indices.extend_from_slice(&[ + *index_offset, + *index_offset + 1, + *index_offset + 2, + *index_offset, + *index_offset + 2, + *index_offset + 3, + ]); + *index_offset += 4; + } + + /// Handle click on the fill style panel. Returns the selected FillStyle if clicked. + pub fn handle_fill_interaction( + &self, + mouse_pos: [f32; 2], + current_tool: Tool, + screen_size: (f32, f32), + ) -> FillInteraction { + if !Self::tool_supports_fill(current_tool) { + return FillInteraction::None; + } + + let layout = UiLayout::new(screen_size); + let origin = layout.fill_panel_origin(); + let btn_size = layout.fill_button_size(); + let padding = (btn_size * 0.3).clamp(6.0, 12.0); + + let styles = [ + FillStyle::None, + FillStyle::Hachure, + FillStyle::CrossHatch, + FillStyle::Solid, + ]; + + for (i, &style) in styles.iter().enumerate() { + let col = (i % 2) as f32; + let row = (i / 2) as f32; + let x = origin[0] + padding + col * (btn_size + padding); + let y = origin[1] + padding + row * (btn_size + padding); + + if mouse_pos[0] >= x + && mouse_pos[0] <= x + btn_size + && mouse_pos[1] >= y + && mouse_pos[1] <= y + btn_size + { + return FillInteraction::SelectFill(style); + } + } + + FillInteraction::None + } + + /// Check if mouse is over the fill panel area. + pub fn is_mouse_over_fill_panel( + &self, + mouse_pos: [f32; 2], + current_tool: Tool, + screen_size: (f32, f32), + ) -> bool { + if !Self::tool_supports_fill(current_tool) { + return false; + } + + let layout = UiLayout::new(screen_size); + let origin = layout.fill_panel_origin(); + let size = layout.fill_panel_size(); + + mouse_pos[0] >= origin[0] + && mouse_pos[0] <= origin[0] + size[0] + && mouse_pos[1] >= origin[1] + && mouse_pos[1] <= origin[1] + size[1] + } + fn generate_zoom_indicator( &self, vertices: &mut Vec, @@ -1699,6 +2029,7 @@ impl UiRenderer { picker: &ColorPickerState, screen_size: (f32, f32), _zoom_level: f32, + current_fill_style: FillStyle, ) -> (Vec, Vec) { let mut vertices = Vec::new(); let mut indices = Vec::new(); @@ -1719,6 +2050,14 @@ impl UiRenderer { current_tool, screen_size, ); + self.generate_fill_panel( + &mut vertices, + &mut indices, + &mut index_offset, + current_tool, + current_fill_style, + screen_size, + ); self.generate_zoom_indicator(&mut vertices, &mut indices, &mut index_offset, screen_size); (vertices, indices) @@ -1835,6 +2174,7 @@ impl UiRenderer { mouse_pos: [f32; 2], screen_size: (f32, f32), picker: &ColorPickerState, + current_tool: Tool, ) -> bool { let layout = UiLayout::new(screen_size); let toolbar_width = layout.toolbar_size[0]; @@ -1873,6 +2213,10 @@ impl UiRenderer { return true; } + if self.is_mouse_over_fill_panel(mouse_pos, current_tool, screen_size) { + return true; + } + picker.open && picker_bounds_contains(mouse_pos, layout) } diff --git a/src/update_logic.rs b/src/update_logic.rs index fca2a67..3928686 100644 --- a/src/update_logic.rs +++ b/src/update_logic.rs @@ -1,5 +1,5 @@ use crate::app_state::State; -use crate::drawing::{DrawingElement, Element, ElementId}; +use crate::drawing::{DrawingElement, Element, ElementId, FillStyle}; use crate::state::ResizeHandle; use crate::vector::path::Path; use crate::vector::sdf::SdfBatch; @@ -7,6 +7,13 @@ use crate::vector::style::StrokeStyle; use crate::vector::tessellator::PathTessellator; use wgpu::util::DeviceExt; +/// Default hachure angle in degrees (matching Excalidraw's -41°). +const HACHURE_ANGLE: f32 = -41.0; +/// Gap multiplier relative to stroke width (Excalidraw uses ~4x). +const HACHURE_GAP_MULTIPLIER: f32 = 4.0; +/// Fill line weight multiplier relative to stroke width (Excalidraw uses ~0.5x). +const FILL_WEIGHT_MULTIPLIER: f32 = 0.5; + impl State { pub fn update(&mut self) { if self.typing.active { @@ -30,6 +37,7 @@ impl State { &self.color_picker, (self.size.width as f32, self.size.height as f32), self.canvas.transform.scale, + self.current_fill_style, ); if !ui_vertices.is_empty() { @@ -223,11 +231,21 @@ impl State { position, size, color, - fill, + fill_style, stroke_width, rough_style, } => { if let Some(rough_options) = rough_style { + // Hachure/CrossHatch fill for rough shapes + Self::tessellate_rect_fill( + *fill_style, + *position, + *size, + *color, + *stroke_width, + rough_options, + tess, + ); // Rough style: tessellate the rough path segments let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); let rough_lines = generator.rough_rectangle(*position, *size, rough_options); @@ -237,8 +255,33 @@ impl State { tess.stroke(&path, &style); } } else { - // Clean shape: SDF vector rendering - sdf_batch.add_rect(*position, *size, *color, *stroke_width, *fill); + // For clean SDF shapes, hachure/cross-hatch are tessellated, + // solid fill is handled by the SDF shader + if *fill_style == FillStyle::Hachure || *fill_style == FillStyle::CrossHatch { + let default_rough = crate::rough::RoughOptions { + roughness: 0.5, + stroke_width: *stroke_width, + seed: Some(position_seed(*position)), + ..Default::default() + }; + Self::tessellate_rect_fill( + *fill_style, + *position, + *size, + *color, + *stroke_width, + &default_rough, + tess, + ); + } + // SDF handles stroke (and solid fill) + sdf_batch.add_rect( + *position, + *size, + *color, + *stroke_width, + *fill_style == FillStyle::Solid, + ); } } @@ -246,11 +289,21 @@ impl State { center, radius, color, - fill, + fill_style, stroke_width, rough_style, } => { if let Some(rough_options) = rough_style { + // Hachure/CrossHatch fill for rough circles + Self::tessellate_ellipse_fill( + *fill_style, + *center, + *radius, + *color, + *stroke_width, + rough_options, + tess, + ); let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); let diameter = *radius * 2.0; let rough_lines = @@ -261,8 +314,30 @@ impl State { tess.stroke(&path, &style); } } else { - // Clean shape: SDF vector rendering - sdf_batch.add_circle(*center, *radius, *color, *stroke_width, *fill); + if *fill_style == FillStyle::Hachure || *fill_style == FillStyle::CrossHatch { + let default_rough = crate::rough::RoughOptions { + roughness: 0.5, + stroke_width: *stroke_width, + seed: Some(center[0].to_bits() as u64 ^ center[1].to_bits() as u64), + ..Default::default() + }; + Self::tessellate_ellipse_fill( + *fill_style, + *center, + *radius, + *color, + *stroke_width, + &default_rough, + tess, + ); + } + sdf_batch.add_circle( + *center, + *radius, + *color, + *stroke_width, + *fill_style == FillStyle::Solid, + ); } } @@ -270,11 +345,21 @@ impl State { position, size, color, - fill, + fill_style, stroke_width, rough_style, } => { if let Some(rough_options) = rough_style { + // Hachure/CrossHatch fill for rough diamonds + Self::tessellate_diamond_fill( + *fill_style, + *position, + *size, + *color, + *stroke_width, + rough_options, + tess, + ); let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); let rough_lines = generator.rough_diamond(*position, *size, rough_options); let style = StrokeStyle::new(*color, rough_options.stroke_width); @@ -283,8 +368,30 @@ impl State { tess.stroke(&path, &style); } } else { - // Clean shape: SDF vector rendering - sdf_batch.add_diamond(*position, *size, *color, *stroke_width, *fill); + if *fill_style == FillStyle::Hachure || *fill_style == FillStyle::CrossHatch { + let default_rough = crate::rough::RoughOptions { + roughness: 0.5, + stroke_width: *stroke_width, + seed: Some(position_seed(*position)), + ..Default::default() + }; + Self::tessellate_diamond_fill( + *fill_style, + *position, + *size, + *color, + *stroke_width, + &default_rough, + tess, + ); + } + sdf_batch.add_diamond( + *position, + *size, + *color, + *stroke_width, + *fill_style == FillStyle::Solid, + ); } } @@ -343,6 +450,160 @@ impl State { } } + /// Tessellate hachure/cross-hatch fill for a rectangle. + fn tessellate_rect_fill( + fill_style: FillStyle, + position: [f32; 2], + size: [f32; 2], + color: [f32; 4], + stroke_width: f32, + rough_options: &crate::rough::RoughOptions, + tess: &mut PathTessellator, + ) { + if fill_style == FillStyle::None { + return; + } + if fill_style == FillStyle::Solid { + // Solid fill for rough shapes: fill the polygon + let polygon = [ + position, + [position[0] + size[0], position[1]], + [position[0] + size[0], position[1] + size[1]], + [position[0], position[1] + size[1]], + ]; + let fill_color = [color[0], color[1], color[2], color[3] * 0.35]; + tess.fill_convex(&polygon, fill_color); + return; + } + + let polygon = vec![ + position, + [position[0] + size[0], position[1]], + [position[0] + size[0], position[1] + size[1]], + [position[0], position[1] + size[1]], + ]; + let gap = stroke_width * HACHURE_GAP_MULTIPLIER; + let fill_weight = stroke_width * FILL_WEIGHT_MULTIPLIER; + let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); + + let fill_lines = match fill_style { + FillStyle::Hachure => { + generator.hachure_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + FillStyle::CrossHatch => { + generator.cross_hatch_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + _ => return, + }; + + let fill_color = [color[0], color[1], color[2], color[3] * 0.7]; + let style = StrokeStyle::new(fill_color, fill_weight.max(0.5)); + for line_points in fill_lines { + let path = Path::from_points(&line_points); + tess.stroke(&path, &style); + } + } + + /// Tessellate hachure/cross-hatch fill for a circle/ellipse. + fn tessellate_ellipse_fill( + fill_style: FillStyle, + center: [f32; 2], + radius: f32, + color: [f32; 4], + stroke_width: f32, + rough_options: &crate::rough::RoughOptions, + tess: &mut PathTessellator, + ) { + if fill_style == FillStyle::None { + return; + } + // Approximate the circle/ellipse as a polygon for scanline fill + let segments = 32; + let polygon: Vec<[f32; 2]> = (0..segments) + .map(|i| { + let angle = (i as f32 / segments as f32) * std::f32::consts::PI * 2.0; + [center[0] + radius * angle.cos(), center[1] + radius * angle.sin()] + }) + .collect(); + + if fill_style == FillStyle::Solid { + let fill_color = [color[0], color[1], color[2], color[3] * 0.35]; + tess.fill_convex(&polygon, fill_color); + return; + } + + let gap = stroke_width * HACHURE_GAP_MULTIPLIER; + let fill_weight = stroke_width * FILL_WEIGHT_MULTIPLIER; + let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); + + let fill_lines = match fill_style { + FillStyle::Hachure => { + generator.hachure_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + FillStyle::CrossHatch => { + generator.cross_hatch_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + _ => return, + }; + + let fill_color = [color[0], color[1], color[2], color[3] * 0.7]; + let style = StrokeStyle::new(fill_color, fill_weight.max(0.5)); + for line_points in fill_lines { + let path = Path::from_points(&line_points); + tess.stroke(&path, &style); + } + } + + /// Tessellate hachure/cross-hatch fill for a diamond. + fn tessellate_diamond_fill( + fill_style: FillStyle, + position: [f32; 2], + size: [f32; 2], + color: [f32; 4], + stroke_width: f32, + rough_options: &crate::rough::RoughOptions, + tess: &mut PathTessellator, + ) { + if fill_style == FillStyle::None { + return; + } + let cx = position[0] + size[0] / 2.0; + let cy = position[1] + size[1] / 2.0; + let polygon = vec![ + [cx, position[1]], + [position[0] + size[0], cy], + [cx, position[1] + size[1]], + [position[0], cy], + ]; + + if fill_style == FillStyle::Solid { + let fill_color = [color[0], color[1], color[2], color[3] * 0.35]; + tess.fill_convex(&polygon, fill_color); + return; + } + + let gap = stroke_width * HACHURE_GAP_MULTIPLIER; + let fill_weight = stroke_width * FILL_WEIGHT_MULTIPLIER; + let mut generator = crate::rough::RoughGenerator::new(rough_options.seed); + + let fill_lines = match fill_style { + FillStyle::Hachure => { + generator.hachure_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + FillStyle::CrossHatch => { + generator.cross_hatch_fill(&polygon, HACHURE_ANGLE, gap, fill_weight, rough_options) + } + _ => return, + }; + + let fill_color = [color[0], color[1], color[2], color[3] * 0.7]; + let style = StrokeStyle::new(fill_color, fill_weight.max(0.5)); + for line_points in fill_lines { + let path = Path::from_points(&line_points); + tess.stroke(&path, &style); + } + } + /// Generate selection highlight geometry using the PathTessellator. fn tessellate_selection_highlight(bounds: ([f32; 2], [f32; 2]), tess: &mut PathTessellator) { let style = StrokeStyle::new([0.0, 0.5, 1.0, 0.8], 3.0); @@ -439,3 +700,8 @@ pub fn handle_positions( (ResizeHandle::West, [min[0], center_y]), ]) } + +/// Derive a deterministic seed from a position for reproducible hachure patterns. +fn position_seed(pos: [f32; 2]) -> u64 { + pos[0].to_bits() as u64 ^ (pos[1].to_bits() as u64).rotate_left(32) +}