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:
0HyperCube
2024-01-13 14:32:10 +00:00
committed by GitHub
co-authored by Keavon Chambers
parent 78a1bb17cd
commit 456ca170a4
40 changed files with 2170 additions and 475 deletions
@@ -1,6 +1,7 @@
use crate::messages::input_mapper::utility_types::input_keyboard::Key;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GridSnapping};
use crate::messages::prelude::*;
use graph_craft::document::{NodeId, NodeNetwork};
@@ -63,6 +64,9 @@ pub enum DocumentMessage {
open: bool,
},
GraphViewOverlayToggle,
GridOptions(GridSnapping),
GridOverlays(OverlayContext),
GridVisible(bool),
GroupSelectedLayers,
ImaginateGenerate,
ImaginateRandom {
@@ -118,7 +122,7 @@ pub enum DocumentMessage {
SetSnapping {
snapping_enabled: Option<bool>,
bounding_box_snapping: Option<bool>,
node_snapping: Option<bool>,
geometry_snapping: Option<bool>,
},
SetViewMode {
view_mode: ViewMode,
@@ -6,6 +6,7 @@ use crate::consts::{ASYMPTOTIC_EFFECT, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX,
use crate::messages::input_mapper::utility_types::macros::action_keys;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::{GraphOperationHandlerData, NodeGraphHandlerData};
use crate::messages::portfolio::document::overlays::grid_overlays::{grid_overlay, overlay_options};
use crate::messages::portfolio::document::properties_panel::utility_types::PropertiesPanelMessageHandlerData;
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
use crate::messages::portfolio::document::utility_types::document_metadata::{is_artboard, DocumentMetadata, LayerNodeIdentifier};
@@ -58,7 +59,7 @@ pub struct DocumentMessageHandler {
#[serde(default = "default_commit_hash")]
commit_hash: String,
#[serde(default = "default_pan_tilt_zoom")]
navigation: PTZ,
pub navigation: PTZ,
#[serde(default = "default_document_mode")]
document_mode: DocumentMode,
#[serde(default = "default_view_mode")]
@@ -438,6 +439,21 @@ impl MessageHandler<DocumentMessage, DocumentInputs<'_>> for DocumentMessageHand
GraphViewOverlayToggle => {
responses.add(DocumentMessage::GraphViewOverlay { open: !self.graph_view_overlay_open });
}
GridOptions(grid) => {
self.snapping_state.grid = grid;
self.snapping_state.grid_snapping = true;
responses.add(OverlaysMessage::Draw);
responses.add(PortfolioMessage::UpdateDocumentWidgets);
}
GridOverlays(mut overlay_context) => {
if self.snapping_state.grid_snapping {
grid_overlay(self, &mut overlay_context)
}
}
GridVisible(enabled) => {
self.snapping_state.grid_snapping = enabled;
responses.add(OverlaysMessage::Draw);
}
GroupSelectedLayers => {
// TODO: Add code that changes the insert index of the new folder based on the selected layer
let parent = self
@@ -742,7 +758,7 @@ impl MessageHandler<DocumentMessage, DocumentInputs<'_>> for DocumentMessageHand
SetSnapping {
snapping_enabled,
bounding_box_snapping,
node_snapping,
geometry_snapping,
} => {
if let Some(state) = snapping_enabled {
self.snapping_state.snapping_enabled = state
@@ -750,8 +766,8 @@ impl MessageHandler<DocumentMessage, DocumentInputs<'_>> for DocumentMessageHand
if let Some(state) = bounding_box_snapping {
self.snapping_state.bounding_box_snapping = state
}
if let Some(state) = node_snapping {
self.snapping_state.node_snapping = state
if let Some(state) = geometry_snapping {
self.snapping_state.geometry_snapping = state
};
}
SetViewMode { view_mode } => {
@@ -1096,6 +1112,13 @@ impl DocumentMessageHandler {
pub fn update_document_widgets(&self, responses: &mut VecDeque<Message>) {
let snapping_state = self.snapping_state.clone();
let mut widgets = vec![
CheckboxInput::new(self.overlays_visible)
.icon("Overlays")
.tooltip("Overlays")
.on_update(|optional_input: &CheckboxInput| DocumentMessage::SetOverlaysVisibility { visible: optional_input.checked }.into())
.widget_holder(),
PopoverButton::new("Overlays", "Coming soon").widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
CheckboxInput::new(snapping_state.snapping_enabled)
.icon("Snapping")
.tooltip("Snapping")
@@ -1104,7 +1127,7 @@ impl DocumentMessageHandler {
DocumentMessage::SetSnapping {
snapping_enabled: Some(snapping_enabled),
bounding_box_snapping: Some(snapping_state.bounding_box_snapping),
node_snapping: Some(snapping_state.node_snapping),
geometry_snapping: Some(snapping_state.geometry_snapping),
}
.into()
})
@@ -1114,14 +1137,14 @@ impl DocumentMessageHandler {
LayoutGroup::Row {
widgets: vec![
TextLabel::new(SnappingOptions::BoundingBoxes.to_string()).table_align(true).min_width(96).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
CheckboxInput::new(snapping_state.bounding_box_snapping)
.tooltip(SnappingOptions::BoundingBoxes.to_string())
.on_update(move |input: &CheckboxInput| {
DocumentMessage::SetSnapping {
snapping_enabled: None,
bounding_box_snapping: Some(input.checked),
node_snapping: None,
geometry_snapping: None,
}
.into()
})
@@ -1131,15 +1154,15 @@ impl DocumentMessageHandler {
},
LayoutGroup::Row {
widgets: vec![
TextLabel::new(SnappingOptions::Points.to_string()).table_align(true).min_width(96).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
CheckboxInput::new(self.snapping_state.node_snapping)
.tooltip(SnappingOptions::Points.to_string())
TextLabel::new(SnappingOptions::Geometry.to_string()).table_align(true).min_width(96).widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
CheckboxInput::new(self.snapping_state.geometry_snapping)
.tooltip(SnappingOptions::Geometry.to_string())
.on_update(|input: &CheckboxInput| {
DocumentMessage::SetSnapping {
snapping_enabled: None,
bounding_box_snapping: None,
node_snapping: Some(input.checked),
geometry_snapping: Some(input.checked),
}
.into()
})
@@ -1149,19 +1172,15 @@ impl DocumentMessageHandler {
])
.widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
CheckboxInput::new(true)
CheckboxInput::new(self.snapping_state.grid_snapping)
.icon("Grid")
.tooltip("Grid")
.on_update(|_| DialogMessage::RequestComingSoonDialog { issue: Some(318) }.into())
.on_update(|optional_input: &CheckboxInput| DocumentMessage::GridVisible(optional_input.checked).into())
.widget_holder(),
PopoverButton::new("Grid", "Coming soon").widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
CheckboxInput::new(self.overlays_visible)
.icon("Overlays")
.tooltip("Overlays")
.on_update(|optional_input: &CheckboxInput| DocumentMessage::SetOverlaysVisibility { visible: optional_input.checked }.into())
PopoverButton::new("Grid", "Grid customization settings")
.options_widget(overlay_options(&self.snapping_state.grid))
.popover_min_width(Some(320))
.widget_holder(),
PopoverButton::new("Overlays", "Coming soon").widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
RadioInput::new(vec![
RadioEntryData::default()
@@ -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");
}
}
@@ -111,7 +111,7 @@ impl DocumentMetadata {
}
pub fn is_folder(&self, layer: LayerNodeIdentifier) -> bool {
self.folders.contains(&layer)
layer == LayerNodeIdentifier::ROOT || self.folders.contains(&layer)
}
pub fn is_artboard(&self, layer: LayerNodeIdentifier) -> bool {
@@ -58,30 +58,251 @@ impl DocumentMode {
pub struct SnappingState {
pub snapping_enabled: bool,
pub bounding_box_snapping: bool,
pub node_snapping: bool,
pub geometry_snapping: bool,
pub grid_snapping: bool,
pub bounds: BoundsSnapping,
pub nodes: NodeSnapping,
pub grid: GridSnapping,
pub tolerance: f64,
pub artboards: bool,
}
impl Default for SnappingState {
fn default() -> Self {
Self {
snapping_enabled: true,
bounding_box_snapping: true,
node_snapping: true,
geometry_snapping: true,
grid_snapping: false,
bounds: BoundsSnapping {
edges: true,
corners: true,
edge_midpoints: false,
centres: false,
},
nodes: NodeSnapping {
paths: true,
path_intersections: true,
sharp_nodes: true,
smooth_nodes: true,
line_midpoints: true,
normals: true,
tangents: true,
},
grid: GridSnapping {
origin: DVec2::ZERO,
grid_type: GridType::RECTANGLE,
},
tolerance: 8.,
artboards: true,
}
}
}
impl SnappingState {
pub const fn target_enabled(&self, target: SnapTarget) -> bool {
if !self.snapping_enabled {
return false;
}
match target {
SnapTarget::BoundingBox(bounding_box) if self.bounding_box_snapping => match bounding_box {
BoundingBoxSnapTarget::Corner => self.bounds.corners,
BoundingBoxSnapTarget::Edge => self.bounds.edges,
BoundingBoxSnapTarget::EdgeMidpoint => self.bounds.edge_midpoints,
BoundingBoxSnapTarget::Centre => self.bounds.centres,
},
SnapTarget::Geometry(nodes) if self.geometry_snapping => match nodes {
GeometrySnapTarget::Smooth => self.nodes.smooth_nodes,
GeometrySnapTarget::Sharp => self.nodes.sharp_nodes,
GeometrySnapTarget::LineMidpoint => self.nodes.line_midpoints,
GeometrySnapTarget::Path => self.nodes.paths,
GeometrySnapTarget::Normal => self.nodes.normals,
GeometrySnapTarget::Tangent => self.nodes.tangents,
GeometrySnapTarget::Intersection => self.nodes.path_intersections,
},
SnapTarget::Board(_) => self.artboards,
SnapTarget::Grid(_) => self.grid_snapping,
_ => false,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BoundsSnapping {
pub edges: bool,
pub corners: bool,
pub edge_midpoints: bool,
pub centres: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeSnapping {
pub paths: bool,
pub path_intersections: bool,
pub sharp_nodes: bool,
pub smooth_nodes: bool,
pub line_midpoints: bool,
pub normals: bool,
pub tangents: bool,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub enum GridType {
Rectangle { spacing: DVec2 },
Isometric { y_axis_spacing: f64, angle_a: f64, angle_b: f64 },
}
impl GridType {
pub const RECTANGLE: Self = GridType::Rectangle { spacing: DVec2::ONE };
pub const ISOMETRIC: Self = GridType::Isometric {
y_axis_spacing: 1.,
angle_a: 30.,
angle_b: 30.,
};
pub fn rect_spacing(&mut self) -> Option<&mut DVec2> {
match self {
Self::Rectangle { spacing } => Some(spacing),
_ => None,
}
}
pub fn isometric_y_spacing(&mut self) -> Option<&mut f64> {
match self {
Self::Isometric { y_axis_spacing, .. } => Some(y_axis_spacing),
_ => None,
}
}
pub fn angle_a(&mut self) -> Option<&mut f64> {
match self {
Self::Isometric { angle_a, .. } => Some(angle_a),
_ => None,
}
}
pub fn angle_b(&mut self) -> Option<&mut f64> {
match self {
Self::Isometric { angle_b, .. } => Some(angle_b),
_ => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct GridSnapping {
pub origin: DVec2,
pub grid_type: GridType,
}
impl GridSnapping {
// Double grid size until it takes up at least 10px.
pub fn compute_rectangle_spacing(mut size: DVec2, navigation: &PTZ) -> Option<DVec2> {
let mut iterations = 0;
size = size.abs();
while (size * navigation.zoom).cmplt(DVec2::splat(10.)).any() {
if iterations > 100 {
return None;
}
size *= 2.;
iterations += 1;
}
Some(size)
}
// Double grid size until it takes up at least 10px.
pub fn compute_isometric_multiplier(length: f64, divisor: f64, navigation: &PTZ) -> Option<f64> {
let length = length.abs();
let mut iterations = 0;
let mut multiplier = 1.;
while (length / divisor.abs().max(1.)) * multiplier * navigation.zoom < 10. {
if iterations > 100 {
return None;
}
multiplier *= 2.;
iterations += 1;
}
Some(multiplier)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundingBoxSnapSource {
Corner,
Centre,
EdgeMidpoint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardSnapSource {
Centre,
Corner,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeometrySnapSource {
Smooth,
Sharp,
LineMidpoint,
PathIntersection,
Handle,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapSource {
#[default]
None,
BoundingBox(BoundingBoxSnapSource),
Board(BoardSnapSource),
Geometry(GeometrySnapSource),
}
impl SnapSource {
pub fn is_some(&self) -> bool {
self != &Self::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoundingBoxSnapTarget {
Corner,
Edge,
EdgeMidpoint,
Centre,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeometrySnapTarget {
Smooth,
Sharp,
LineMidpoint,
Path,
Normal,
Tangent,
Intersection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardSnapTarget {
Edge,
Corner,
Centre,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridSnapTarget {
Line,
LineNormal,
Intersection,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapTarget {
#[default]
None,
BoundingBox(BoundingBoxSnapTarget),
Geometry(GeometrySnapTarget),
Board(BoardSnapTarget),
Grid(GridSnapTarget),
}
impl SnapTarget {
pub fn is_some(&self) -> bool {
self != &Self::None
}
pub fn bounding_box(&self) -> bool {
matches!(self, Self::BoundingBox(_) | Self::Board(_))
}
}
// TODO: implement icons for SnappingOptions eventually
pub enum SnappingOptions {
BoundingBoxes,
Points,
Geometry,
}
impl fmt::Display for SnappingOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SnappingOptions::BoundingBoxes => write!(f, "Bounding Boxes"),
SnappingOptions::Points => write!(f, "Points"),
SnappingOptions::Geometry => write!(f, "Geometry"),
}
}
}