diff --git a/editor/src/consts.rs b/editor/src/consts.rs index b17a8621cc..2c268d5edc 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -146,9 +146,11 @@ pub const COLOR_OVERLAY_YELLOW_DULL: &str = "#d7ba8b"; pub const COLOR_OVERLAY_GREEN: &str = "#63ce63"; pub const COLOR_OVERLAY_RED: &str = "#ef5454"; pub const COLOR_OVERLAY_GRAY: &str = "#cccccc"; +pub const COLOR_OVERLAY_GRAY_DARK: &str = "#555555"; pub const COLOR_OVERLAY_GRAY_25: &str = "#cccccc40"; pub const COLOR_OVERLAY_WHITE: &str = "#ffffff"; pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf"; +pub const COLOR_OVERLAY_TRANSPARENT: &str = "#00000000"; // DOCUMENT pub const FILE_EXTENSION: &str = "graphite"; diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index 446b09ec0d..4a52a56167 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -516,6 +516,7 @@ impl WidgetHolder { && button1.tooltip == button2.tooltip && button1.tooltip_shortcut == button2.tooltip_shortcut && button1.popover_min_width == button2.popover_min_width + && button1.popover_layout.len() == button2.popover_layout.len() { let mut new_widget_path = widget_path.to_vec(); for (i, (a, b)) in button1.popover_layout.iter_mut().zip(button2.popover_layout.iter()).enumerate() { diff --git a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs index b02a61eda1..e42ecbbf44 100644 --- a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs +++ b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs @@ -10,9 +10,12 @@ use graphene_std::vector::style::FillChoice; fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) { let origin = document.snapping_state.grid.origin; let grid_color = "#".to_string() + &document.snapping_state.grid.grid_color.to_rgba_hex_srgb(); - let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.document_ptz) else { + let grid_color_minor = "#".to_string() + &document.snapping_state.grid.grid_color_minor.to_rgba_hex_srgb(); + let Some(scaled_spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.snapping_state.grid.rectangular_major_interval, &document.document_ptz) else { return; }; + let scale_is_adjusted = scaled_spacing != spacing; + let document_to_viewport = document .navigation_handler .calculate_offset_transform(overlay_context.viewport.center_in_viewport_space().into(), &document.document_ptz); @@ -25,8 +28,17 @@ fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: let max = bounds.0.iter().map(|&corner| corner[secondary]).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); let primary_start = bounds.0.iter().map(|&corner| corner[primary]).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); let primary_end = bounds.0.iter().map(|&corner| corner[primary]).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); - let spacing = spacing[secondary]; + let spacing = scaled_spacing[secondary]; + let first_index = ((min - origin[secondary]) / spacing).ceil() as i32; for line_index in 0..=((max - min) / spacing).ceil() as i32 { + let is_major = is_major_line( + line_index + first_index, + if primary == 1 { + document.snapping_state.grid.rectangular_major_interval.x + } else { + document.snapping_state.grid.rectangular_major_interval.y + }, + ) || scale_is_adjusted; let secondary_pos = (((min - origin[secondary]) / spacing).ceil() + line_index as f64) * spacing + origin[secondary]; let start = if primary == 0 { DVec2::new(primary_start, secondary_pos) @@ -38,7 +50,12 @@ fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: } else { DVec2::new(secondary_pos, primary_end) }; - overlay_context.line(document_to_viewport.transform_point2(start), document_to_viewport.transform_point2(end), Some(&grid_color), None); + overlay_context.line( + document_to_viewport.transform_point2(start), + document_to_viewport.transform_point2(end), + if is_major { Some(&grid_color) } else { Some(&grid_color_minor) }, + if is_major && document.snapping_state.grid.major_is_thick { Some(3.) } else { Some(1.) }, + ); } } } @@ -51,42 +68,95 @@ fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: fn grid_overlay_rectangular_dot(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) { let origin = document.snapping_state.grid.origin; let grid_color = "#".to_string() + &document.snapping_state.grid.grid_color.to_rgba_hex_srgb(); - let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.document_ptz) else { + let grid_color_minor = "#".to_string() + &document.snapping_state.grid.grid_color_minor.to_rgba_hex_srgb(); + let Some(scaled_spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.snapping_state.grid.rectangular_major_interval, &document.document_ptz) else { return; }; + let scale_is_adjusted = scaled_spacing != spacing; let document_to_viewport = document .navigation_handler .calculate_offset_transform(overlay_context.viewport.center_in_viewport_space().into(), &document.document_ptz); let bounds = document_to_viewport.inverse() * Quad::from_box([DVec2::ZERO, overlay_context.viewport.size().into()]); - let min = bounds.0.iter().map(|corner| corner.y).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); - let max = bounds.0.iter().map(|corner| corner.y).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + // Draw horizontal dotted lines + let min_y = bounds.0.iter().map(|corner| corner.y).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + let max_y = bounds.0.iter().map(|corner| corner.y).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); - let mut primary_start = bounds.0.iter().map(|corner| corner.x).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); - let mut primary_end = bounds.0.iter().map(|corner| corner.x).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + let primary_start_x = bounds.0.iter().map(|corner| corner.x).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + let primary_end_x = bounds.0.iter().map(|corner| corner.x).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); - primary_start = (primary_start / spacing.x).floor() * spacing.x + origin.x % spacing.x; - primary_end = (primary_end / spacing.x).floor() * spacing.x + origin.x % spacing.x; + let first_index_y = ((min_y - origin.y) / scaled_spacing.y).ceil() as i32; - // Round to avoid floating point errors - let total_dots = ((primary_end - primary_start) / spacing.x).round(); + for line_index in 0..=((max_y - min_y) / scaled_spacing.y).ceil() as i32 { + let y_is_major = is_major_line(line_index + first_index_y, document.snapping_state.grid.rectangular_major_interval.y); + let is_major = y_is_major || scale_is_adjusted; + let is_thick = is_major && document.snapping_state.grid.major_is_thick; - for line_index in 0..=((max - min) / spacing.y).ceil() as i32 { - let secondary_pos = (((min - origin.y) / spacing.y).ceil() + line_index as f64) * spacing.y + origin.y; - let start = DVec2::new(primary_start, secondary_pos); - let end = DVec2::new(primary_end, secondary_pos); + let secondary_pos = (((min_y - origin.y) / scaled_spacing.y).ceil() + line_index as f64) * scaled_spacing.y + origin.y; - let x_per_dot = (end.x - start.x) / total_dots; - for dot_index in 0..=total_dots as usize { - let exact_x = x_per_dot * dot_index as f64; - overlay_context.pixel(document_to_viewport.transform_point2(DVec2::new(start.x + exact_x, start.y)).round(), Some(&grid_color)) - } + // Align horizontal line endpoints to the grid in the x direction + let aligned_start_x = ((primary_start_x - origin.x) / scaled_spacing.x).floor() * scaled_spacing.x + origin.x; + let aligned_end_x = ((primary_end_x - origin.x) / scaled_spacing.x).ceil() * scaled_spacing.x + origin.x; + + let start = DVec2::new(aligned_start_x, secondary_pos); + let end = DVec2::new(aligned_end_x, secondary_pos); + + let dot_size = 3.; + let gap_size = scaled_spacing.x * document_to_viewport.matrix2.x_axis.length() - dot_size; + + overlay_context.pixel_snapped_dashed_line( + document_to_viewport.transform_point2(start), + document_to_viewport.transform_point2(end), + Some(if is_major { &grid_color } else { &grid_color_minor }), + Some(if is_thick { 3.0 } else { 1.0 }), + Some(dot_size), + Some(gap_size), + Some(2.0), + ); + } + + // Draw vertical dotted lines + let min_x = bounds.0.iter().map(|corner| corner.x).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + let max_x = bounds.0.iter().map(|corner| corner.x).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + + let primary_start_y = bounds.0.iter().map(|corner| corner.y).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + let primary_end_y = bounds.0.iter().map(|corner| corner.y).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default(); + + let first_index_x = ((min_x - origin.x) / scaled_spacing.x).ceil() as i32; + + for line_index in 0..=((max_x - min_x) / scaled_spacing.x).ceil() as i32 { + let x_is_major = is_major_line(line_index + first_index_x, document.snapping_state.grid.rectangular_major_interval.x); + let is_major = x_is_major || scale_is_adjusted; + let is_thick = is_major && document.snapping_state.grid.major_is_thick; + + let secondary_pos = (((min_x - origin.x) / scaled_spacing.x).ceil() + line_index as f64) * scaled_spacing.x + origin.x; + + // Align vertical line endpoints to the grid in the y direction + let aligned_start_y = ((primary_start_y - origin.y) / scaled_spacing.y).floor() * scaled_spacing.y + origin.y; + let aligned_end_y = ((primary_end_y - origin.y) / scaled_spacing.y).ceil() * scaled_spacing.y + origin.y; + + let start = DVec2::new(secondary_pos, aligned_start_y); + let end = DVec2::new(secondary_pos, aligned_end_y); + + let dot_size = 3.; + let gap_size = scaled_spacing.y * document_to_viewport.matrix2.y_axis.length() - dot_size; + + overlay_context.pixel_snapped_dashed_line( + document_to_viewport.transform_point2(start), + document_to_viewport.transform_point2(end), + Some(if is_major { &grid_color } else { &grid_color_minor }), + Some(if is_thick { 3.0 } else { 1.0 }), + Some(dot_size), + Some(gap_size), + Some(2.0), + ); } } fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, y_axis_spacing: f64, angle_a: f64, angle_b: f64) { let grid_color = "#".to_string() + &document.snapping_state.grid.grid_color.to_rgba_hex_srgb(); + let grid_color_minor = "#".to_string() + &document.snapping_state.grid.grid_color_minor.to_rgba_hex_srgb(); let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap(); let origin = document.snapping_state.grid.origin; let document_to_viewport = document @@ -107,31 +177,49 @@ fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &m let min_y = bounds.0.iter().map(|&corner| corner.y).min_by(cmp).unwrap_or_default(); let max_y = bounds.0.iter().map(|&corner| corner.y).max_by(cmp).unwrap_or_default(); let spacing = isometric_spacing.x; + let first_index = ((min_x - origin.x) / spacing).ceil() as i32; for line_index in 0..=((max_x - min_x) / spacing).ceil() as i32 { + let is_major = is_major_line(line_index + first_index, document.snapping_state.grid.isometric_major_interval.x) || spacing_multiplier != 1.0; let x_pos = (((min_x - origin.x) / spacing).ceil() + line_index as f64) * spacing + origin.x; let start = DVec2::new(x_pos, min_y); let end = DVec2::new(x_pos, max_y); - overlay_context.line(document_to_viewport.transform_point2(start), document_to_viewport.transform_point2(end), Some(&grid_color), None); + overlay_context.line( + document_to_viewport.transform_point2(start), + document_to_viewport.transform_point2(end), + if is_major { Some(&grid_color) } else { Some(&grid_color_minor) }, + if is_major && document.snapping_state.grid.major_is_thick { Some(3.) } else { Some(1.) }, + ); } - for (tan, multiply) in [(tan_a, -1.), (tan_b, 1.)] { + for (tan, multiply, major_interval) in [ + (tan_a, -1., document.snapping_state.grid.isometric_major_interval.z), + (tan_b, 1., document.snapping_state.grid.isometric_major_interval.y), + ] { let project = |corner: &DVec2| corner.y + multiply * tan * (corner.x - origin.x); let inverse_project = |corner: &DVec2| corner.y - tan * multiply * (corner.x - origin.x); let min_y = bounds.0.into_iter().min_by(|a, b| inverse_project(a).partial_cmp(&inverse_project(b)).unwrap()).unwrap_or_default(); let max_y = bounds.0.into_iter().max_by(|a, b| inverse_project(a).partial_cmp(&inverse_project(b)).unwrap()).unwrap_or_default(); let spacing = isometric_spacing.y; let lines = ((inverse_project(&max_y) - inverse_project(&min_y)) / spacing).ceil() as i32; + let first_index = ((inverse_project(&min_y) - origin.y) / spacing).ceil() as i32; for line_index in 0..=lines { + let is_major = is_major_line(line_index + first_index, major_interval) || spacing_multiplier != 1.0; let y_pos = (((inverse_project(&min_y) - origin.y) / spacing).ceil() + line_index as f64) * spacing + origin.y; let start = DVec2::new(min_x, project(&DVec2::new(min_x, y_pos))); let end = DVec2::new(max_x, project(&DVec2::new(max_x, y_pos))); - overlay_context.line(document_to_viewport.transform_point2(start), document_to_viewport.transform_point2(end), Some(&grid_color), None); + overlay_context.line( + document_to_viewport.transform_point2(start), + document_to_viewport.transform_point2(end), + if is_major { Some(&grid_color) } else { Some(&grid_color_minor) }, + if is_major && document.snapping_state.grid.major_is_thick { Some(3.) } else { Some(1.) }, + ); } } } fn grid_overlay_isometric_dot(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, y_axis_spacing: f64, angle_a: f64, angle_b: f64) { let grid_color = "#".to_string() + &document.snapping_state.grid.grid_color.to_rgba_hex_srgb(); + let grid_color_minor = "#".to_string() + &document.snapping_state.grid.grid_color_minor.to_rgba_hex_srgb(); let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap(); let origin = document.snapping_state.grid.origin; let document_to_viewport = document @@ -165,7 +253,9 @@ fn grid_overlay_isometric_dot(document: &DocumentMessageHandler, overlay_context return; } let x_offset = (((min_x - origin.x) / spacing_x).ceil()) * spacing_x + origin.x - min_x; + let first_index = ((inverse_project(&min_y) - origin.y) / spacing_y).ceil() as i32; for line_index in 0..=lines { + let is_major = is_major_line(line_index + first_index, document.snapping_state.grid.isometric_major_interval.z) || spacing_multiplier != 1.0; let y_pos = (((inverse_project(&min_y) - origin.y) / spacing_y).ceil() + line_index as f64) * spacing_y + origin.y; let start = DVec2::new(min_x + x_offset, project(&DVec2::new(min_x + x_offset, y_pos))); let end = DVec2::new(max_x + x_offset, project(&DVec2::new(max_x + x_offset, y_pos))); @@ -173,18 +263,22 @@ fn grid_overlay_isometric_dot(document: &DocumentMessageHandler, overlay_context overlay_context.dashed_line( document_to_viewport.transform_point2(start), document_to_viewport.transform_point2(end), - Some(&grid_color), - None, + if is_major { Some(&grid_color) } else { Some(&grid_color_minor) }, + if is_major && document.snapping_state.grid.major_is_thick { Some(3.) } else { Some(1.) }, + Some(3.), + Some((spacing_x / cos_a) * document_to_viewport.matrix2.x_axis.length() - 3.), Some(1.), - Some((spacing_x / cos_a) * document_to_viewport.matrix2.x_axis.length() - 1.), - None, ); } } +fn is_major_line(line_index: i32, major_interval: u32) -> bool { + line_index % major_interval as i32 == 0 +} + pub fn grid_overlay(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { match document.snapping_state.grid.grid_type { - GridType::Rectangular { spacing } => { + GridType::Rectangular { spacing, .. } => { if document.snapping_state.grid.dot_display { grid_overlay_rectangular_dot(document, overlay_context, spacing) } else { @@ -227,7 +321,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { } }) }; - let update_display = |grid, update: fn(&mut GridSnapping) -> Option<&mut bool>| { + let _update_display = |grid, update: fn(&mut GridSnapping) -> Option<&mut bool>| { update_val::(grid, move |grid, checkbox| { if let Some(update) = update(grid) { *update = checkbox.checked; @@ -238,7 +332,53 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { widgets.push(LayoutGroup::Row { widgets: vec![TextLabel::new("Grid").bold(true).widget_holder()], }); + let mut color_widgets = vec![TextLabel::new("Color").table_align(true).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder()]; + color_widgets.push( + ColorInput::new(FillChoice::Solid(grid.grid_color.to_gamma_srgb())) + .tooltip("Grid display color") + .allow_none(false) + .on_update(update_color(grid, |grid| Some(&mut grid.grid_color))) + .widget_holder(), + ); + if grid.has_minor_lines() { + color_widgets.push(Separator::new(SeparatorType::Related).widget_holder()); + color_widgets.push( + ColorInput::new(FillChoice::Solid(grid.grid_color_minor.to_gamma_srgb())) + .tooltip("Minor grid line display color") + .allow_none(false) + .on_update(update_color(grid, |grid| Some(&mut grid.grid_color_minor))) + .widget_holder(), + ); + } + widgets.push(LayoutGroup::Row { widgets: color_widgets }); + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Display").table_align(true).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + RadioInput::new(vec![ + RadioEntryData::new("small").icon("Dot").on_update(update_val(grid, |grid, _| { + grid.major_is_thick = false; + })), + RadioEntryData::new("large").icon("DotLarge").on_update(update_val(grid, |grid, _| { + grid.major_is_thick = true; + })), + ]) + .selected_index(Some(if grid.major_is_thick { 1 } else { 0 })) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + RadioInput::new(vec![ + RadioEntryData::new("lines").label("Lines").icon("Grid").on_update(update_val(grid, |grid, _| { + grid.dot_display = false; + })), + RadioEntryData::new("dots").label("Dots").icon("GridDotted").on_update(update_val(grid, |grid, _| { + grid.dot_display = true; + })), + ]) + .selected_index(Some(if grid.dot_display { 1 } else { 0 })) + .widget_holder(), + ], + }); widgets.push(LayoutGroup::Row { widgets: vec![ TextLabel::new("Type").table_align(true).widget_holder(), @@ -253,7 +393,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { grid.grid_type = GridType::Rectangular { spacing: grid.rectangular_spacing }; })), RadioEntryData::new("isometric").label("Isometric").on_update(update_val(grid, |grid, _| { - if let GridType::Rectangular { spacing } = grid.grid_type { + if let GridType::Rectangular { spacing, .. } = grid.grid_type { grid.rectangular_spacing = spacing; } grid.grid_type = GridType::Isometric { @@ -272,24 +412,6 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { ], }); - let mut color_widgets = vec![TextLabel::new("Display").table_align(true).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder()]; - color_widgets.extend([ - CheckboxInput::new(grid.dot_display) - .icon("GridDotted") - .tooltip("Display as dotted grid") - .on_update(update_display(grid, |grid| Some(&mut grid.dot_display))) - .widget_holder(), - Separator::new(SeparatorType::Related).widget_holder(), - ]); - color_widgets.push( - ColorInput::new(FillChoice::Solid(grid.grid_color.to_gamma_srgb())) - .tooltip("Grid display color") - .allow_none(false) - .on_update(update_color(grid, |grid| Some(&mut grid.grid_color))) - .widget_holder(), - ); - widgets.push(LayoutGroup::Row { widgets: color_widgets }); - widgets.push(LayoutGroup::Row { widgets: vec![ TextLabel::new("Origin").table_align(true).widget_holder(), @@ -311,27 +433,58 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { }); match grid.grid_type { - GridType::Rectangular { spacing } => widgets.push(LayoutGroup::Row { - widgets: vec![ - TextLabel::new("Spacing").table_align(true).widget_holder(), - Separator::new(SeparatorType::Unrelated).widget_holder(), - NumberInput::new(Some(spacing.x)) - .label("X") - .unit(" px") - .min(0.) - .min_width(98) - .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.x))) - .widget_holder(), - Separator::new(SeparatorType::Related).widget_holder(), - NumberInput::new(Some(spacing.y)) - .label("Y") - .unit(" px") - .min(0.) - .min_width(98) - .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.y))) - .widget_holder(), - ], - }), + GridType::Rectangular { spacing, .. } => { + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Spacing").table_align(true).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + NumberInput::new(Some(spacing.x)) + .label("X") + .unit(" px") + .min(0.) + .min_width(98) + .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.x))) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(spacing.y)) + .label("Y") + .unit(" px") + .min(0.) + .min_width(98) + .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.y))) + .widget_holder(), + ], + }); + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Mark Every").table_align(true).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + NumberInput::new(Some(grid.rectangular_major_interval.x as f64)) + .unit(" col") + .int() + .min(1.) + .min_width(98) + .on_update(update_val(grid, |grid, val: &NumberInput| { + if let Some(val) = val.value { + grid.rectangular_major_interval.x = val as u32; + } + })) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(grid.rectangular_major_interval.y as f64)) + .unit(" row") + .int() + .min(1.) + .min_width(98) + .on_update(update_val(grid, |grid, val: &NumberInput| { + if let Some(val) = val.value { + grid.rectangular_major_interval.y = val as u32; + } + })) + .widget_holder(), + ], + }); + } GridType::Isometric { y_axis_spacing, angle_a, angle_b } => { widgets.push(LayoutGroup::Row { widgets: vec![ @@ -350,18 +503,61 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { TextLabel::new("Angles").table_align(true).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), NumberInput::new(Some(angle_a)) + .label("A") .unit("°") .min_width(98) .on_update(update_origin(grid, |grid| grid.grid_type.angle_a())) .widget_holder(), Separator::new(SeparatorType::Related).widget_holder(), NumberInput::new(Some(angle_b)) + .label("B") .unit("°") .min_width(98) .on_update(update_origin(grid, |grid| grid.grid_type.angle_b())) .widget_holder(), ], }); + widgets.push(LayoutGroup::Row { + widgets: vec![ + TextLabel::new("Mark Every").table_align(true).widget_holder(), + Separator::new(SeparatorType::Unrelated).widget_holder(), + NumberInput::new(Some(grid.isometric_major_interval.x as f64)) + .label("X") + .int() + .min(1.) + .min_width(64) + .on_update(update_val(grid, |grid, val: &NumberInput| { + if let Some(val) = val.value { + grid.isometric_major_interval.x = val as u32; + } + })) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(grid.isometric_major_interval.y as f64)) + .label("B") + .int() + .min(1.) + .min_width(64) + .on_update(update_val(grid, |grid, val: &NumberInput| { + if let Some(val) = val.value { + grid.isometric_major_interval.y = val as u32; + } + })) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(grid.isometric_major_interval.z as f64)) + .label("A") + .int() + .min(1.) + .min_width(64) + .on_update(update_val(grid, |grid, val: &NumberInput| { + if let Some(val) = val.value { + grid.isometric_major_interval.z = val as u32; + } + })) + .widget_holder(), + ], + }); } } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs index e950c2b185..fea47b37fe 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs @@ -295,6 +295,70 @@ impl OverlayContext { self.end_dpi_aware_transform(); } + pub fn pixel_snapped_dashed_line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, thickness: Option, dash_width: Option, dash_gap_width: Option, dash_offset: Option) { + // Check if the line is horizontal or vertical + let is_horizontal = (start.y - end.y).abs() < f64::EPSILON; + let is_vertical = (start.x - end.x).abs() < f64::EPSILON; + + if !is_horizontal && !is_vertical { + // Fall back to regular dashed line for diagonal lines + self.dashed_line(start, end, color, thickness, dash_width, dash_gap_width, dash_offset); + return; + } + + self.start_dpi_aware_transform(); + + // Set the dash pattern + if let Some(dash_width) = dash_width { + let dash_gap_width = dash_gap_width.unwrap_or(1.); + let array = js_sys::Array::new(); + array.push(&JsValue::from(dash_width)); + array.push(&JsValue::from(dash_gap_width)); + + if let Some(dash_offset) = dash_offset { + if dash_offset != 0. { + self.render_context.set_line_dash_offset(dash_offset); + } + } + + self.render_context + .set_line_dash(&JsValue::from(array)) + .map_err(|error| log::warn!("Error drawing dashed line: {:?}", error)) + .ok(); + } + + let (draw_start, draw_end) = if is_horizontal { + // For horizontal lines, snap to the pixel grid and offset by 0.5 for crisp lines + let y = start.y.round() - 0.5; + (DVec2::new(start.x, y), DVec2::new(end.x, y)) + } else { + // For vertical lines, snap to the pixel grid and offset by 0.5 for crisp lines + let x = start.x.round() - 0.5; + (DVec2::new(x, start.y), DVec2::new(x, end.y)) + }; + + self.render_context.begin_path(); + self.render_context.move_to(draw_start.x, draw_start.y); + self.render_context.line_to(draw_end.x, draw_end.y); + self.render_context.set_line_width(thickness.unwrap_or(1.)); + self.render_context.set_stroke_style_str(color.unwrap_or(COLOR_OVERLAY_BLUE)); + self.render_context.stroke(); + self.render_context.set_line_width(1.); + + // Reset the dash pattern back to solid + if dash_width.is_some() { + self.render_context + .set_line_dash(&JsValue::from(js_sys::Array::new())) + .map_err(|error| log::warn!("Error drawing dashed line: {:?}", error)) + .ok(); + } + if dash_offset.is_some() && dash_offset != Some(0.) { + self.render_context.set_line_dash_offset(0.); + } + + self.end_dpi_aware_transform(); + } + #[allow(clippy::too_many_arguments)] pub fn dashed_ellipse( &mut self, diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs b/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs index 202fec98a8..6cee30938b 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_vello.rs @@ -256,6 +256,12 @@ impl OverlayContext { self.internal().dashed_line(start, end, color, thickness, dash_width, dash_gap_width, dash_offset); } + /// Creates a dashed line with pixel-perfect snapping for crisp rendering + #[allow(clippy::too_many_arguments)] + pub fn pixel_snapped_dashed_line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, thickness: Option, dash_width: Option, dash_gap_width: Option, dash_offset: Option) { + self.internal().pixel_snapped_dashed_line(start, end, color, thickness, dash_width, dash_gap_width, dash_offset); + } + pub fn hover_manipulator_handle(&mut self, position: DVec2, selected: bool) { self.internal().hover_manipulator_handle(position, selected); } @@ -540,6 +546,79 @@ impl OverlayContextInternal { self.scene.stroke(&stroke, transform, Self::parse_color(color.unwrap_or(COLOR_OVERLAY_BLUE)), None, &path); } + /// Creates a dashed line with pixel-perfect snapping for crisp rendering + /// Each dash segment is individually pixel-aligned while maintaining accuracy to input FP values + #[allow(clippy::too_many_arguments)] + fn pixel_snapped_dashed_line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, thickness: Option, dash_width: Option, dash_gap_width: Option, dash_offset: Option) { + let transform = self.get_transform(); + let thickness = thickness.unwrap_or(1.0).round().max(1.0); + + // If no dashing is specified, fall back to regular pixel-snapped line + let dash_width = match dash_width { + Some(width) => width, + None => { + let start = start.round() - DVec2::splat(0.5); + let end = end.round() - DVec2::splat(0.5); + + let mut path = BezPath::new(); + path.move_to(kurbo::Point::new(start.x, start.y)); + path.line_to(kurbo::Point::new(end.x, end.y)); + + let stroke = kurbo::Stroke::new(thickness); + self.scene.stroke(&stroke, transform, Self::parse_color(color.unwrap_or(COLOR_OVERLAY_BLUE)), None, &path); + return; + } + }; + + let dash_gap = dash_gap_width.unwrap_or(1.0); + let dash_offset = dash_offset.unwrap_or(0.0); + + // Calculate the line vector and length + let line_vec = end - start; + let line_length = line_vec.length(); + + if line_length < 0.001 { + return; // Line too short to render + } + + let line_unit = line_vec / line_length; + + // Calculate dash pattern cycle length + let dash_cycle = dash_width + dash_gap; + if dash_cycle <= 0.0 { + return; + } + + let mut path = BezPath::new(); + let mut current_distance = -dash_offset.rem_euclid(dash_cycle); + + while current_distance < line_length { + let dash_start_distance = current_distance.max(0.0); + let dash_end_distance = (current_distance + dash_width).min(line_length); + + if dash_start_distance < dash_end_distance { + // Calculate actual positions along the line + let dash_start_pos = start + line_unit * dash_start_distance; + let dash_end_pos = start + line_unit * dash_end_distance; + + // Snap each dash segment to pixel boundaries + let snapped_start = dash_start_pos.round() - DVec2::splat(0.5); + let snapped_end = dash_end_pos.round() - DVec2::splat(0.5); + + // Only add the dash if it has meaningful length after snapping + if (snapped_end - snapped_start).length() >= 0.5 { + path.move_to(kurbo::Point::new(snapped_start.x, snapped_start.y)); + path.line_to(kurbo::Point::new(snapped_end.x, snapped_end.y)); + } + } + + current_distance += dash_cycle; + } + + let stroke = kurbo::Stroke::new(thickness); + self.scene.stroke(&stroke, transform, Self::parse_color(color.unwrap_or(COLOR_OVERLAY_BLUE)), None, &path); + } + fn manipulator_handle(&mut self, position: DVec2, selected: bool, color: Option<&str>) { let transform = self.get_transform(); let position = position.round() - DVec2::splat(0.5); diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index ae8fd73532..f149da6403 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -1,5 +1,5 @@ -use crate::consts::COLOR_OVERLAY_GRAY; -use glam::DVec2; +use crate::consts::COLOR_OVERLAY_GRAY_DARK; +use glam::{DVec2, UVec2, UVec3}; use graphene_std::raster::Color; use std::fmt; @@ -213,10 +213,15 @@ pub struct GridSnapping { pub origin: DVec2, pub grid_type: GridType, pub rectangular_spacing: DVec2, + pub rectangular_major_interval: UVec2, pub isometric_y_spacing: f64, pub isometric_angle_a: f64, pub isometric_angle_b: f64, + /// X is the major interval along the X axis, Y is the major interval along the B axis, Z is the major interval along the A axis. + pub isometric_major_interval: UVec3, pub grid_color: Color, + pub grid_color_minor: Color, + pub major_is_thick: bool, pub dot_display: bool, } @@ -226,10 +231,14 @@ impl Default for GridSnapping { origin: DVec2::ZERO, grid_type: Default::default(), rectangular_spacing: DVec2::ONE, + rectangular_major_interval: UVec2::ONE, isometric_y_spacing: 1., isometric_angle_a: 30., isometric_angle_b: 30., - grid_color: Color::from_rgb_str(COLOR_OVERLAY_GRAY.strip_prefix('#').unwrap()).unwrap(), + isometric_major_interval: UVec3::ONE, + grid_color: Color::from_rgb_str(COLOR_OVERLAY_GRAY_DARK.strip_prefix('#').unwrap()).unwrap().with_alpha(0.4), + grid_color_minor: Color::from_rgb_str(COLOR_OVERLAY_GRAY_DARK.strip_prefix('#').unwrap()).unwrap().with_alpha(0.2), + major_is_thick: false, dot_display: false, } } @@ -237,14 +246,15 @@ impl Default for GridSnapping { impl GridSnapping { // Double grid size until it takes up at least 10px. - pub fn compute_rectangle_spacing(mut size: DVec2, navigation: &PTZ) -> Option { + pub fn compute_rectangle_spacing(mut size: DVec2, major_interval: &UVec2, navigation: &PTZ) -> Option { let mut iterations = 0; size = size.abs(); while (size * navigation.zoom()).cmplt(DVec2::splat(10.)).any() { if iterations > 100 { return None; } - size *= 2.; + size.x *= if iterations == 0 { major_interval.x as f64 } else { 2. }; + size.y *= if iterations == 0 { major_interval.y as f64 } else { 2. }; iterations += 1; } Some(size) @@ -264,6 +274,13 @@ impl GridSnapping { } Some(multiplier) } + + pub fn has_minor_lines(&self) -> bool { + match self.grid_type { + GridType::Rectangular { .. } => self.rectangular_major_interval.x > 1 || self.rectangular_major_interval.y > 1, + GridType::Isometric { .. } => self.isometric_major_interval.x > 1 || self.isometric_major_interval.z > 1 || self.isometric_major_interval.y > 1, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs b/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs index 1c339d4354..c37cc36ce1 100644 --- a/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs +++ b/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs @@ -1,6 +1,6 @@ use super::*; use crate::messages::portfolio::document::utility_types::misc::{GridSnapTarget, GridSnapping, GridType, SnapTarget}; -use glam::DVec2; +use glam::{DVec2, UVec2}; use graphene_std::renderer::Quad; struct Line { @@ -18,7 +18,7 @@ impl GridSnapper { let document = snap_data.document; let mut lines = Vec::new(); - let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.document_ptz) else { + let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &UVec2::ONE, &document.document_ptz) else { return lines; }; let origin = document.snapping_state.grid.origin; @@ -90,7 +90,7 @@ impl GridSnapper { fn get_snap_lines(&self, document_point: DVec2, snap_data: &mut SnapData) -> Vec { match snap_data.document.snapping_state.grid.grid_type { - GridType::Rectangular { spacing } => self.get_snap_lines_rectangular(document_point, snap_data, spacing), + GridType::Rectangular { spacing, .. } => self.get_snap_lines_rectangular(document_point, snap_data, spacing), GridType::Isometric { y_axis_spacing, angle_a, angle_b } => self.get_snap_lines_isometric(document_point, snap_data, y_axis_spacing, angle_a, angle_b), } } diff --git a/frontend/assets/icon-12px-solid/dot-large.svg b/frontend/assets/icon-12px-solid/dot-large.svg new file mode 100644 index 0000000000..054d0838ee --- /dev/null +++ b/frontend/assets/icon-12px-solid/dot-large.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/utility-functions/icons.ts b/frontend/src/utility-functions/icons.ts index bdbde00238..ca862de266 100644 --- a/frontend/src/utility-functions/icons.ts +++ b/frontend/src/utility-functions/icons.ts @@ -11,6 +11,7 @@ import Checkmark from "@graphite-frontend/assets/icon-12px-solid/checkmark.svg"; import Clipped from "@graphite-frontend/assets/icon-12px-solid/clipped.svg"; import CloseX from "@graphite-frontend/assets/icon-12px-solid/close-x.svg"; import Delay from "@graphite-frontend/assets/icon-12px-solid/delay.svg"; +import DotLarge from "@graphite-frontend/assets/icon-12px-solid/dot-large.svg"; import Dot from "@graphite-frontend/assets/icon-12px-solid/dot.svg"; import DropdownArrow from "@graphite-frontend/assets/icon-12px-solid/dropdown-arrow.svg"; import Edit12px from "@graphite-frontend/assets/icon-12px-solid/edit-12px.svg"; @@ -58,6 +59,7 @@ const SOLID_12PX = { Clipped: { svg: Clipped, size: 12 }, CloseX: { svg: CloseX, size: 12 }, Delay: { svg: Delay, size: 12 }, + DotLarge: { svg: DotLarge, size: 12 }, Dot: { svg: Dot, size: 12 }, DropdownArrow: { svg: DropdownArrow, size: 12 }, Edit12px: { svg: Edit12px, size: 12 },