Add grid color customization and choice to display as dots (#1743)

* dot grid

* fix warning: unreachable pattern

* grid color select

* add color for all grid types

* Dot grid checkbox and remove prefixed Color functions

* Display dot grid as grid aligned pixels

* Dashed line comment

* Code review and UI design widget placement updates

* Isometric dotted grid

* Early return when cos = 0

* Add spacing, x offset, and color to dot grids

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
adamgerhant 2024-05-08 14:36:15 -07:00 committed by GitHub
parent 1bfbe306be
commit bc33eabc3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 255 additions and 33 deletions

1
Cargo.lock generated
View File

@ -2376,6 +2376,7 @@ dependencies = [
"graphite-proc-macros",
"image 0.24.9",
"interpreted-executor",
"js-sys",
"log",
"num_enum 0.6.1",
"once_cell",

View File

@ -26,6 +26,7 @@ quantization = [
wasm = ["wasm-bindgen", "graphene-std/wasm", "wasm-bindgen-futures"]
[dependencies]
js-sys = "0.3.67"
log = { workspace = true }
bitflags = { workspace = true }
thiserror = { workspace = true }

View File

@ -1,13 +1,16 @@
use crate::consts::COLOR_OVERLAY_GRAY;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::misc::{GridSnapping, GridType};
use crate::messages::prelude::*;
use glam::DVec2;
use graphene_core::raster::color::Color;
use graphene_core::renderer::Quad;
use glam::DVec2;
fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) {
let origin = document.snapping_state.grid.origin;
let grid_color: Color = document.snapping_state.grid.grid_color;
let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.navigation) else {
return;
};
@ -33,12 +36,60 @@ 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(COLOR_OVERLAY_GRAY));
overlay_context.line(
document_to_viewport.transform_point2(start),
document_to_viewport.transform_point2(end),
Some(&("#".to_string() + &grid_color.rgba_hex())),
None,
);
}
}
}
// In the best case, where the x distance/total dots is an integer, this will reduce draw requests from the current m(horizontal dots)*n(vertical dots) to m(horizontal lines) * 1(line changes).
// In the worst case, where the x distance/total dots is an integer+0.5, then each pixel will require a new line, and requests will be m(horizontal lines)*n(line changes = horizontal dots).
// The draw dashed line method will also be not grid aligned for tilted grids.
// TODO: Potentially create an image and render the image onto the canvas a single time.
// TODO: Implement this with a dashed line (`set_line_dash`), with integer spacing which is continuously adjusted to correct the accumulated error.
fn grid_overlay_dot(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) {
let origin = document.snapping_state.grid.origin;
let grid_color = document.snapping_state.grid.grid_color;
let Some(spacing) = GridSnapping::compute_rectangle_spacing(spacing, &document.navigation) else {
return;
};
let document_to_viewport = document.metadata().document_to_viewport;
let bounds = document_to_viewport.inverse() * Quad::from_box([DVec2::ZERO, overlay_context.size]);
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();
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();
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;
// Round to avoid floating point errors
let total_dots = ((primary_end - primary_start) / spacing.x).round();
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 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(&("#".to_string() + &grid_color.rgba_hex())),
)
}
}
}
fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, y_axis_spacing: f64, angle_a: f64, angle_b: f64) {
let grid_color = document.snapping_state.grid.grid_color;
let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap();
let origin = document.snapping_state.grid.origin;
let document_to_viewport = document.metadata().document_to_viewport;
@ -60,7 +111,12 @@ fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &m
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(COLOR_OVERLAY_GRAY));
overlay_context.line(
document_to_viewport.transform_point2(start),
document_to_viewport.transform_point2(end),
Some(&("#".to_string() + &grid_color.rgba_hex())),
None,
);
}
for (tan, multiply) in [(tan_a, -1.), (tan_b, 1.)] {
@ -74,15 +130,78 @@ fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &m
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(COLOR_OVERLAY_GRAY));
overlay_context.line(
document_to_viewport.transform_point2(start),
document_to_viewport.transform_point2(end),
Some(&("#".to_string() + &grid_color.rgba_hex())),
None,
);
}
}
}
fn grid_overlay_isometric_dot(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, y_axis_spacing: f64, angle_a: f64, angle_b: f64) {
let grid_color = document.snapping_state.grid.grid_color;
let cmp = |a: &f64, b: &f64| a.partial_cmp(b).unwrap();
let origin = document.snapping_state.grid.origin;
let document_to_viewport = document.metadata().document_to_viewport;
let bounds = document_to_viewport.inverse() * Quad::from_box([DVec2::ZERO, overlay_context.size]);
let tan_a = angle_a.to_radians().tan();
let tan_b = angle_b.to_radians().tan();
let spacing = DVec2::new(y_axis_spacing / (tan_a + tan_b), y_axis_spacing);
let Some(spacing_multiplier) = GridSnapping::compute_isometric_multiplier(y_axis_spacing, tan_a + tan_b, &document.navigation) else {
return;
};
let isometric_spacing = spacing * spacing_multiplier;
let min_x = bounds.0.iter().map(|&corner| corner.x).min_by(cmp).unwrap_or_default();
let max_x = bounds.0.iter().map(|&corner| corner.x).max_by(cmp).unwrap_or_default();
let spacing_x = isometric_spacing.x;
let tan = tan_a;
let multiply = -1.0;
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_y = isometric_spacing.y;
let lines = ((inverse_project(&max_y) - inverse_project(&min_y)) / spacing_y).ceil() as i32;
let cos_a = angle_a.to_radians().cos();
// If cos_a is 0 then there will be no intersections and thus no dots should be drawn
if cos_a.abs() <= 0.00001 {
return;
}
let x_offset = (((min_x - origin.x) / spacing_x).ceil()) * spacing_x + origin.x - min_x;
for line_index in 0..=lines {
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)));
overlay_context.line(
document_to_viewport.transform_point2(start),
document_to_viewport.transform_point2(end),
Some(&("#".to_string() + &grid_color.rgba_hex())),
Some((spacing_x / cos_a) * document_to_viewport.matrix2.x_axis.length()),
);
}
}
pub fn grid_overlay(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) {
match document.snapping_state.grid.grid_type {
GridType::Rectangle { spacing } => grid_overlay_rectangular(document, overlay_context, spacing),
GridType::Isometric { y_axis_spacing, angle_a, angle_b } => grid_overlay_isometric(document, overlay_context, y_axis_spacing, angle_a, angle_b),
GridType::Rectangle { spacing } => {
if document.snapping_state.grid.dot_display {
grid_overlay_dot(document, overlay_context, spacing)
} else {
grid_overlay_rectangular(document, overlay_context, spacing)
}
}
GridType::Isometric { y_axis_spacing, angle_a, angle_b } => {
if document.snapping_state.grid.dot_display {
grid_overlay_isometric_dot(document, overlay_context, y_axis_spacing, angle_a, angle_b)
} else {
grid_overlay_isometric(document, overlay_context, y_axis_spacing, angle_a, angle_b)
}
}
}
}
@ -105,9 +224,65 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
}
})
};
let update_color = |grid, update: fn(&mut GridSnapping) -> Option<&mut Color>| {
update_val::<ColorButton>(grid, move |grid, color| {
if let Some(color) = color.value {
if let Some(update_color) = update(grid) {
*update_color = color;
}
}
})
};
let update_display = |grid, update: fn(&mut GridSnapping) -> Option<&mut bool>| {
update_val::<CheckboxInput>(grid, move |grid, checkbox| {
if let Some(update) = update(grid) {
*update = checkbox.checked;
}
})
};
widgets.push(LayoutGroup::Row {
widgets: vec![TextLabel::new("Grid").bold(true).widget_holder()],
});
widgets.push(LayoutGroup::Row {
widgets: vec![
TextLabel::new("Type").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
RadioInput::new(vec![
RadioEntryData::new("rectangular")
.label("Rectangular")
.on_update(update_val(grid, |grid, _| grid.grid_type = GridType::RECTANGLE)),
RadioEntryData::new("isometric")
.label("Isometric")
.on_update(update_val(grid, |grid, _| grid.grid_type = GridType::ISOMETRIC)),
])
.min_width(200)
.selected_index(Some(match grid.grid_type {
GridType::Rectangle { .. } => 0,
GridType::Isometric { .. } => 1,
}))
.widget_holder(),
],
});
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(
ColorButton::new(Some(grid.grid_color))
.tooltip("Grid display color")
.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(),
@ -127,23 +302,6 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
.widget_holder(),
],
});
widgets.push(LayoutGroup::Row {
widgets: vec![
TextLabel::new("Type").table_align(true).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
RadioInput::new(vec![
RadioEntryData::new("rectangular")
.label("Rectangular")
.on_update(update_val(grid, |grid, _| grid.grid_type = GridType::RECTANGLE)),
RadioEntryData::new("isometric")
.label("Isometric")
.on_update(update_val(grid, |grid, _| grid.grid_type = GridType::ISOMETRIC)),
])
.min_width(200)
.selected_index(Some(if matches!(grid.grid_type, GridType::Rectangle { .. }) { 0 } else { 1 }))
.widget_holder(),
],
});
match grid.grid_type {
GridType::Rectangle { spacing } => widgets.push(LayoutGroup::Row {

View File

@ -39,12 +39,12 @@ pub fn path_overlays(document: &DocumentMessageHandler, shape_editor: &mut Shape
let not_under_anchor = |&position: &DVec2| transform.transform_point2(position).distance_squared(anchor_position) >= HIDE_HANDLE_DISTANCE * HIDE_HANDLE_DISTANCE;
if let Some(in_handle) = manipulator_group.in_handle.filter(not_under_anchor) {
let handle_position = transform.transform_point2(in_handle);
overlay_context.line(handle_position, anchor_position, None);
overlay_context.line(handle_position, anchor_position, None, None);
overlay_context.manipulator_handle(handle_position, is_selected(selected, ManipulatorPointId::new(manipulator_group.id, SelectedType::InHandle)));
}
if let Some(out_handle) = manipulator_group.out_handle.filter(not_under_anchor) {
let handle_position = transform.transform_point2(out_handle);
overlay_context.line(handle_position, anchor_position, None);
overlay_context.line(handle_position, anchor_position, None, None);
overlay_context.manipulator_handle(handle_position, is_selected(selected, ManipulatorPointId::new(manipulator_group.id, SelectedType::OutHandle)));
}

View File

@ -4,8 +4,9 @@ use crate::messages::prelude::Message;
use bezier_rs::Subpath;
use graphene_core::renderer::Quad;
use wasm_bindgen::JsValue;
use core::f64::consts::PI;
use core::f64::consts::TAU;
use glam::{DAffine2, DVec2};
pub type OverlayProvider = fn(OverlayContext) -> Message;
@ -38,10 +39,24 @@ impl OverlayContext {
self.render_context.stroke();
}
pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>) {
pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, dash_width: Option<f64>) {
let start = start.round() - DVec2::splat(0.5);
let end = end.round() - DVec2::splat(0.5);
if let Some(dash_width) = dash_width {
let array = js_sys::Array::new();
array.push(&JsValue::from(1));
array.push(&JsValue::from(dash_width - 1.));
self.render_context
.set_line_dash(&JsValue::from(array))
.map_err(|error| log::debug!("Error drawing dashed line: {:?}", error))
.ok();
} else {
let array = js_sys::Array::new();
self.render_context
.set_line_dash(&JsValue::from(array))
.map_err(|error| log::debug!("Error drawing dashed line: {:?}", error))
.ok();
}
self.render_context.begin_path();
self.render_context.move_to(start.x, start.y);
self.render_context.line_to(end.x, end.y);
@ -53,7 +68,7 @@ impl OverlayContext {
let position = position.round() - DVec2::splat(0.5);
self.render_context.begin_path();
self.render_context.arc(position.x, position.y, MANIPULATOR_GROUP_MARKER_SIZE / 2., 0., PI * 2.).expect("draw circle");
self.render_context.arc(position.x, position.y, MANIPULATOR_GROUP_MARKER_SIZE / 2., 0., TAU).expect("draw circle");
let fill = if selected { COLOR_OVERLAY_BLUE } else { COLOR_OVERLAY_WHITE };
self.render_context.set_fill_style(&wasm_bindgen::JsValue::from_str(fill));
@ -84,13 +99,37 @@ impl OverlayContext {
self.render_context.stroke();
}
pub fn pixel(&mut self, position: DVec2, color: Option<&str>) {
let size = 1.;
let color_fill = color.unwrap_or(COLOR_OVERLAY_WHITE);
let position = position.round() - DVec2::splat(0.5);
let corner = position - DVec2::splat(size) / 2.;
self.render_context.begin_path();
self.render_context.rect(corner.x, corner.y, size, size);
self.render_context.set_fill_style(&wasm_bindgen::JsValue::from_str(color_fill));
self.render_context.fill();
}
pub fn circle(&mut self, position: DVec2, radius: f64, color_fill: Option<&str>, color_stroke: Option<&str>) {
let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
let color_stroke = color_stroke.unwrap_or(COLOR_OVERLAY_BLUE);
let position = position.round();
self.render_context.begin_path();
self.render_context.arc(position.x, position.y, radius, 0., TAU).expect("draw circle");
self.render_context.set_fill_style(&wasm_bindgen::JsValue::from_str(color_fill));
self.render_context.set_stroke_style(&wasm_bindgen::JsValue::from_str(color_stroke));
self.render_context.fill();
self.render_context.stroke();
}
pub fn pivot(&mut self, position: DVec2) {
let (x, y) = (position.round() - DVec2::splat(0.5)).into();
// Circle
self.render_context.begin_path();
self.render_context.arc(x, y, PIVOT_DIAMETER / 2., 0., PI * 2.).expect("draw circle");
self.render_context.arc(x, y, PIVOT_DIAMETER / 2., 0., TAU).expect("draw circle");
self.render_context.set_fill_style(&wasm_bindgen::JsValue::from_str(COLOR_OVERLAY_YELLOW));
self.render_context.fill();

View File

@ -1,5 +1,8 @@
use glam::DVec2;
use crate::consts::COLOR_OVERLAY_GRAY;
use graphene_core::raster::Color;
use glam::DVec2;
use std::fmt;
#[repr(transparent)]
@ -86,6 +89,11 @@ impl Default for SnappingState {
grid: GridSnapping {
origin: DVec2::ZERO,
grid_type: GridType::RECTANGLE,
grid_color: COLOR_OVERLAY_GRAY
.strip_prefix("#")
.and_then(|value| Color::from_rgb_str(value))
.expect("Should create Color from prefixed hex string"),
dot_display: false,
},
tolerance: 8.,
artboards: true,
@ -192,6 +200,8 @@ impl GridType {
pub struct GridSnapping {
pub origin: DVec2,
pub grid_type: GridType,
pub grid_color: Color,
pub dot_display: bool,
}
impl GridSnapping {
// Double grid size until it takes up at least 10px.

View File

@ -255,7 +255,7 @@ impl Fsm for GradientToolFsmState {
let Gradient { start, end, positions, .. } = gradient;
let (start, end) = (transform.transform_point2(start), transform.transform_point2(end));
overlay_context.line(start, end, None);
overlay_context.line(start, end, None, None);
overlay_context.manipulator_handle(start, dragging == Some(GradientDragTarget::Start));
overlay_context.manipulator_handle(end, dragging == Some(GradientDragTarget::End));

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<rect width="2" height="2" x="1" y="1" />
<rect width="2" height="2" x="5" y="1" />
<rect width="2" height="2" x="9" y="1" />
<rect width="2" height="2" x="1" y="5" />
<rect width="2" height="2" x="5" y="5" />
<rect width="2" height="2" x="9" y="5" />
<rect width="2" height="2" x="1" y="9" />
<rect width="2" height="2" x="5" y="9" />
<rect width="2" height="2" x="9" y="9" />
</svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -18,6 +18,7 @@ import Empty12px from "@graphite-frontend/assets/icon-12px-solid/empty-12px.svg"
import Failure from "@graphite-frontend/assets/icon-12px-solid/failure.svg";
import FullscreenEnter from "@graphite-frontend/assets/icon-12px-solid/fullscreen-enter.svg";
import FullscreenExit from "@graphite-frontend/assets/icon-12px-solid/fullscreen-exit.svg";
import GridDotted from "@graphite-frontend/assets/icon-12px-solid/grid-dotted.svg";
import Grid from "@graphite-frontend/assets/icon-12px-solid/grid.svg";
import Info from "@graphite-frontend/assets/icon-12px-solid/info.svg";
import KeyboardArrowDown from "@graphite-frontend/assets/icon-12px-solid/keyboard-arrow-down.svg";
@ -58,6 +59,7 @@ const SOLID_12PX = {
FullscreenEnter: { svg: FullscreenEnter, size: 12 },
FullscreenExit: { svg: FullscreenExit, size: 12 },
Grid: { svg: Grid, size: 12 },
GridDotted: { svg: GridDotted, size: 12 },
Info: { svg: Info, size: 12 },
KeyboardArrowDown: { svg: KeyboardArrowDown, size: 12 },
KeyboardArrowLeft: { svg: KeyboardArrowLeft, size: 12 },