mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-28 15:08: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
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user