mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-03 04:38:12 +08:00
Layer and grid snapping systems (#1521)
* Grid overlays * Rectangle tool basic snapping * Fix bezier demos * Fix bézier crate tests * Constrained snapping for circle & shape tool * Line tool snapping * Pen tool snapping * Path tool snapping * Snapping whilst dragging layers (not constrained) * Constrained drag * Resize snapping * Normal and tangent * Cleanup * Grid snapping * Grid snapping * Fix imports * Fix bug in artboard tool * Fix hang on 0 size grid spacing * Fix NaN when scaling * Polishing --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
co-authored by
Keavon Chambers
parent
78a1bb17cd
commit
456ca170a4
@@ -0,0 +1,202 @@
|
||||
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::renderer::Quad;
|
||||
|
||||
fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) {
|
||||
let origin = document.snapping_state.grid.origin;
|
||||
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]);
|
||||
|
||||
for primary in 0..2 {
|
||||
let secondary = 1 - primary;
|
||||
let min = bounds.0.iter().map(|&corner| corner[secondary]).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or_default();
|
||||
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];
|
||||
for line_index in 0..=((max - min) / spacing).ceil() as i32 {
|
||||
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)
|
||||
} else {
|
||||
DVec2::new(secondary_pos, primary_start)
|
||||
};
|
||||
let end = if primary == 0 {
|
||||
DVec2::new(primary_end, secondary_pos)
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_overlay_isometric(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, y_axis_spacing: f64, angle_a: f64, angle_b: f64) {
|
||||
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 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;
|
||||
for line_index in 0..=((max_x - min_x) / spacing).ceil() as i32 {
|
||||
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));
|
||||
}
|
||||
|
||||
for (tan, multiply) in [(tan_a, -1.), (tan_b, 1.)] {
|
||||
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;
|
||||
for line_index in 0..=lines {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
|
||||
let mut widgets = Vec::new();
|
||||
fn update_val<I>(grid: &GridSnapping, update: impl Fn(&mut GridSnapping, &I)) -> impl Fn(&I) -> Message {
|
||||
let grid = grid.clone();
|
||||
move |input: &I| {
|
||||
let mut grid = grid.clone();
|
||||
update(&mut grid, &input);
|
||||
DocumentMessage::GridOptions(grid).into()
|
||||
}
|
||||
}
|
||||
let update_origin = |grid, update: fn(&mut GridSnapping) -> Option<&mut f64>| {
|
||||
update_val::<NumberInput>(grid, move |grid, val| {
|
||||
if let Some(val) = val.value {
|
||||
if let Some(update) = update(grid) {
|
||||
*update = val;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
widgets.push(LayoutGroup::Row {
|
||||
widgets: vec![
|
||||
TextLabel::new("Origin").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
NumberInput::new(Some(grid.origin.x))
|
||||
.label("X")
|
||||
.unit(" px")
|
||||
.min_width(98)
|
||||
.on_update(update_origin(&grid, |grid| Some(&mut grid.origin.x)))
|
||||
.widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
NumberInput::new(Some(grid.origin.y))
|
||||
.label("Y")
|
||||
.unit(" px")
|
||||
.min_width(98)
|
||||
.on_update(update_origin(&grid, |grid| Some(&mut grid.origin.y)))
|
||||
.widget_holder(),
|
||||
],
|
||||
});
|
||||
widgets.push(LayoutGroup::Row {
|
||||
widgets: vec![
|
||||
TextLabel::new("Type").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
RadioInput::new(vec![
|
||||
RadioEntryData::new("Rectangular").on_update(update_val(grid, |grid, _| grid.grid_type = GridType::RECTANGLE)),
|
||||
RadioEntryData::new("Isometric").on_update(update_val(grid, |grid, _| grid.grid_type = GridType::ISOMETRIC)),
|
||||
])
|
||||
.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 {
|
||||
widgets: vec![
|
||||
TextLabel::new("Spacing").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).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.rect_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.rect_spacing().map(|spacing| &mut spacing.y)))
|
||||
.widget_holder(),
|
||||
],
|
||||
}),
|
||||
GridType::Isometric { y_axis_spacing, angle_a, angle_b } => {
|
||||
widgets.push(LayoutGroup::Row {
|
||||
widgets: vec![
|
||||
TextLabel::new("Spacing").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
NumberInput::new(Some(y_axis_spacing))
|
||||
.label("Y")
|
||||
.unit(" px")
|
||||
.min(0.)
|
||||
.min_width(200)
|
||||
.on_update(update_origin(&grid, |grid| grid.grid_type.isometric_y_spacing()))
|
||||
.widget_holder(),
|
||||
],
|
||||
});
|
||||
widgets.push(LayoutGroup::Row {
|
||||
widgets: vec![
|
||||
TextLabel::new("Angle A").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
NumberInput::new(Some(angle_a))
|
||||
.unit("°")
|
||||
.min_width(200)
|
||||
.on_update(update_origin(&grid, |grid| grid.grid_type.angle_a()))
|
||||
.widget_holder(),
|
||||
],
|
||||
});
|
||||
widgets.push(LayoutGroup::Row {
|
||||
widgets: vec![
|
||||
TextLabel::new("Angle B").table_align(true).widget_holder(),
|
||||
Separator::new(SeparatorType::Related).widget_holder(),
|
||||
NumberInput::new(Some(angle_b))
|
||||
.unit("°")
|
||||
.min_width(200)
|
||||
.on_update(update_origin(&grid, |grid| grid.grid_type.angle_b()))
|
||||
.widget_holder(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
widgets
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod grid_overlays;
|
||||
mod overlays_message;
|
||||
mod overlays_message_handler;
|
||||
pub mod utility_functions;
|
||||
|
||||
@@ -24,14 +24,22 @@ impl MessageHandler<OverlaysMessage, (bool, &InputPreprocessorMessageHandler)> f
|
||||
context.dyn_into().expect("Context should be a canvas 2d context")
|
||||
});
|
||||
|
||||
canvas.set_width(ipp.viewport_bounds.size().x as u32);
|
||||
canvas.set_height(ipp.viewport_bounds.size().y as u32);
|
||||
let size = ipp.viewport_bounds.size().as_uvec2();
|
||||
canvas.set_width(size.x);
|
||||
canvas.set_height(size.y);
|
||||
|
||||
context.clear_rect(0., 0., ipp.viewport_bounds.size().x, ipp.viewport_bounds.size().y);
|
||||
|
||||
if overlays_visible {
|
||||
responses.add(DocumentMessage::GridOverlays(OverlayContext {
|
||||
render_context: context.clone(),
|
||||
size: size.as_dvec2(),
|
||||
}));
|
||||
for provider in &self.overlay_providers {
|
||||
responses.add(provider(OverlayContext { render_context: context.clone() }));
|
||||
responses.add(provider(OverlayContext {
|
||||
render_context: context.clone(),
|
||||
size: size.as_dvec2(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
overlay_context.line(handle_position, anchor_position, None);
|
||||
overlay_context.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);
|
||||
overlay_context.line(handle_position, anchor_position, None);
|
||||
overlay_context.handle(handle_position, is_selected(selected, ManipulatorPointId::new(manipulator_group.id, SelectedType::OutHandle)));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,12 @@ pub fn empty_provider() -> OverlayProvider {
|
||||
|_| Message::NoOp
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
pub struct OverlayContext {
|
||||
// Serde functionality isn't used but is required by the message system macros
|
||||
#[serde(skip, default = "overlay_canvas_context")]
|
||||
pub render_context: web_sys::CanvasRenderingContext2d,
|
||||
pub size: DVec2,
|
||||
}
|
||||
// Message hashing isn't used but is required by the message system macros
|
||||
impl core::hash::Hash for OverlayContext {
|
||||
@@ -37,11 +38,11 @@ impl OverlayContext {
|
||||
self.render_context.stroke();
|
||||
}
|
||||
|
||||
pub fn line(&mut self, start: DVec2, end: DVec2) {
|
||||
pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>) {
|
||||
self.render_context.begin_path();
|
||||
self.render_context.move_to(start.x.round(), start.y.round());
|
||||
self.render_context.line_to(end.x.round(), end.y.round());
|
||||
self.render_context.set_stroke_style(&wasm_bindgen::JsValue::from_str(COLOR_OVERLAY_BLUE));
|
||||
self.render_context.move_to(start.x, start.y);
|
||||
self.render_context.line_to(end.x, end.y);
|
||||
self.render_context.set_stroke_style(&wasm_bindgen::JsValue::from_str(color.unwrap_or(COLOR_OVERLAY_BLUE)));
|
||||
self.render_context.stroke();
|
||||
}
|
||||
|
||||
@@ -131,4 +132,20 @@ impl OverlayContext {
|
||||
self.render_context.set_stroke_style(&wasm_bindgen::JsValue::from_str(COLOR_OVERLAY_BLUE));
|
||||
self.render_context.stroke();
|
||||
}
|
||||
|
||||
pub fn text(&self, text: &str, pos: DVec2, background: &str, padding: f64) {
|
||||
let pos = pos.round();
|
||||
let metrics = self.render_context.measure_text(text).expect("measure text");
|
||||
self.render_context.set_fill_style(&background.into());
|
||||
self.render_context.fill_rect(
|
||||
pos.x + metrics.actual_bounding_box_left(),
|
||||
pos.y - metrics.font_bounding_box_ascent() - metrics.font_bounding_box_descent() - padding * 2.,
|
||||
metrics.actual_bounding_box_right() - metrics.actual_bounding_box_left() + padding * 2.,
|
||||
metrics.font_bounding_box_ascent() + metrics.font_bounding_box_descent() + padding * 2.,
|
||||
);
|
||||
self.render_context.set_fill_style(&"white".into());
|
||||
self.render_context
|
||||
.fill_text(text, pos.x + padding, pos.y - padding - metrics.font_bounding_box_descent())
|
||||
.expect("draw text");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user