Merge branch 'GraphiteEditor:master' into hierarchical-tree

This commit is contained in:
Mohd Mohsin 2025-05-28 16:26:40 +05:30 committed by GitHub
commit cff1d81208
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1742 additions and 751 deletions

18
.nix/flake.lock generated
View file

@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1743583204,
"narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=",
"lastModified": 1748190013,
"narHash": "sha256-R5HJFflOfsP5FBtk+zE8FpL8uqE7n62jqOsADvVshhE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5135c59491985879812717f4c9fea69604e7f26f",
"rev": "62b852f6c6742134ade1abdd2a21685fd617a291",
"type": "github"
},
"original": {
@ -36,11 +36,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1739214665,
"narHash": "sha256-26L8VAu3/1YRxS8MHgBOyOM8xALdo6N0I04PgorE7UM=",
"lastModified": 1748190013,
"narHash": "sha256-R5HJFflOfsP5FBtk+zE8FpL8uqE7n62jqOsADvVshhE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434",
"rev": "62b852f6c6742134ade1abdd2a21685fd617a291",
"type": "github"
},
"original": {
@ -65,11 +65,11 @@
]
},
"locked": {
"lastModified": 1743682350,
"narHash": "sha256-S/MyKOFajCiBm5H5laoE59wB6w0NJ4wJG53iAPfYW3k=",
"lastModified": 1748399823,
"narHash": "sha256-kahD8D5hOXOsGbNdoLLnqCL887cjHkx98Izc37nDjlA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c4a8327b0f25d1d81edecbb6105f74d7cf9d7382",
"rev": "d68a69dc71bc19beb3479800392112c2f6218159",
"type": "github"
},
"original": {

View file

@ -78,6 +78,7 @@
pkgs.git
pkgs.gobject-introspection
pkgs-unstable.cargo-tauri
pkgs-unstable.cargo-about
# Linker
pkgs.mold

File diff suppressed because one or more lines are too long

View file

@ -136,3 +136,6 @@ pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences
pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 15;
// INPUT
pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500;

View file

@ -145,12 +145,14 @@ impl LayoutHolder for ExportDialogMessageHandler {
DropdownInput::new(entries).selected_index(Some(index as u32)).widget_holder(),
];
let mut checkbox_id = CheckboxId::default();
let transparent_background = vec![
TextLabel::new("Transparency").table_align(true).min_width(100).widget_holder(),
TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(&mut checkbox_id).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
CheckboxInput::new(self.transparent_background)
.disabled(self.file_type == FileType::Jpg)
.on_update(move |value: &CheckboxInput| ExportDialogMessage::TransparentBackground(value.checked).into())
.for_label(checkbox_id.clone())
.widget_holder(),
];

View file

@ -78,11 +78,13 @@ impl LayoutHolder for NewDocumentDialogMessageHandler {
.widget_holder(),
];
let mut checkbox_id = CheckboxId::default();
let infinite = vec![
TextLabel::new("Infinite Canvas").table_align(true).min_width(90).widget_holder(),
TextLabel::new("Infinite Canvas").table_align(true).min_width(90).for_checkbox(&mut checkbox_id).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
CheckboxInput::new(self.infinite)
.on_update(|checkbox_input: &CheckboxInput| NewDocumentDialogMessage::Infinite(checkbox_input.checked).into())
.for_label(checkbox_id.clone())
.widget_holder(),
];

View file

@ -68,6 +68,7 @@ impl PreferencesDialogMessageHandler {
.widget_holder(),
];
let mut checkbox_id = CheckboxId::default();
let zoom_with_scroll_tooltip = "Use the scroll wheel for zooming instead of vertically panning (not recommended for trackpads)";
let zoom_with_scroll = vec![
Separator::new(SeparatorType::Unrelated).widget_holder(),
@ -80,8 +81,13 @@ impl PreferencesDialogMessageHandler {
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Zoom with Scroll")
.table_align(true)
.tooltip(zoom_with_scroll_tooltip)
.for_checkbox(&mut checkbox_id)
.widget_holder(),
TextLabel::new("Zoom with Scroll").table_align(true).tooltip(zoom_with_scroll_tooltip).widget_holder(),
];
// =======
@ -163,6 +169,7 @@ impl PreferencesDialogMessageHandler {
graph_wire_style,
];
let mut checkbox_id = CheckboxId::default();
let vello_tooltip = "Use the experimental Vello renderer (your browser must support WebGPU)";
let use_vello = vec![
Separator::new(SeparatorType::Unrelated).widget_holder(),
@ -171,14 +178,17 @@ impl PreferencesDialogMessageHandler {
.tooltip(vello_tooltip)
.disabled(!preferences.supports_wgpu())
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::UseVello { use_vello: checkbox_input.checked }.into())
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Vello Renderer")
.table_align(true)
.tooltip(vello_tooltip)
.disabled(!preferences.supports_wgpu())
.for_checkbox(&mut checkbox_id)
.widget_holder(),
];
let mut checkbox_id = CheckboxId::default();
let vector_mesh_tooltip = "Allow tools to produce vector meshes, where more than two segments can connect to an anchor point.\n\nCurrently this does not properly handle line joins and fills.";
let vector_meshes = vec![
Separator::new(SeparatorType::Unrelated).widget_holder(),
@ -186,8 +196,13 @@ impl PreferencesDialogMessageHandler {
CheckboxInput::new(preferences.vector_meshes)
.tooltip(vector_mesh_tooltip)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::VectorMeshes { enabled: checkbox_input.checked }.into())
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Vector Meshes")
.table_align(true)
.tooltip(vector_mesh_tooltip)
.for_checkbox(&mut checkbox_id)
.widget_holder(),
TextLabel::new("Vector Meshes").table_align(true).tooltip(vector_mesh_tooltip).widget_holder(),
];
// TODO: Reenable when Imaginate is restored

View file

@ -240,7 +240,17 @@ pub enum FrontendMessage {
#[serde(rename = "hintData")]
hint_data: HintData,
},
UpdateLayersPanelControlBarLayout {
UpdateLayersPanelControlBarLeftLayout {
#[serde(rename = "layoutTarget")]
layout_target: LayoutTarget,
diff: Vec<WidgetDiff>,
},
UpdateLayersPanelControlBarRightLayout {
#[serde(rename = "layoutTarget")]
layout_target: LayoutTarget,
diff: Vec<WidgetDiff>,
},
UpdateLayersPanelBottomBarLayout {
#[serde(rename = "layoutTarget")]
layout_target: LayoutTarget,
diff: Vec<WidgetDiff>,

View file

@ -429,7 +429,9 @@ impl LayoutMessageHandler {
LayoutTarget::DialogColumn2 => FrontendMessage::UpdateDialogColumn2 { layout_target, diff },
LayoutTarget::DocumentBar => FrontendMessage::UpdateDocumentBarLayout { layout_target, diff },
LayoutTarget::DocumentMode => FrontendMessage::UpdateDocumentModeLayout { layout_target, diff },
LayoutTarget::LayersPanelControlBar => FrontendMessage::UpdateLayersPanelControlBarLayout { layout_target, diff },
LayoutTarget::LayersPanelControlLeftBar => FrontendMessage::UpdateLayersPanelControlBarLeftLayout { layout_target, diff },
LayoutTarget::LayersPanelControlRightBar => FrontendMessage::UpdateLayersPanelControlBarRightLayout { layout_target, diff },
LayoutTarget::LayersPanelBottomBar => FrontendMessage::UpdateLayersPanelBottomBarLayout { layout_target, diff },
LayoutTarget::MenuBar => unreachable!("Menu bar is not diffed"),
LayoutTarget::NodeGraphControlBar => FrontendMessage::UpdateNodeGraphControlBarLayout { layout_target, diff },
LayoutTarget::PropertiesSections => FrontendMessage::UpdatePropertyPanelSectionsLayout { layout_target, diff },

View file

@ -31,8 +31,12 @@ pub enum LayoutTarget {
DocumentBar,
/// Contains the dropdown for design / select / guide mode found on the top left of the canvas.
DocumentMode,
/// Options for opacity seen at the top of the Layers panel.
LayersPanelControlBar,
/// Blending options at the top of the Layers panel.
LayersPanelControlLeftBar,
/// Selected layer status (locked/hidden) at the top of the Layers panel.
LayersPanelControlRightBar,
/// Controls for adding, grouping, and deleting layers at the bottom of the Layers panel.
LayersPanelBottomBar,
/// The dropdown menu at the very top of the application: File, Edit, etc.
MenuBar,
/// Bar at the top of the node graph containing the location and the "Preview" and "Hide" buttons.

View file

@ -42,6 +42,9 @@ pub struct IconButton {
pub struct PopoverButton {
pub style: Option<String>,
#[serde(rename = "menuDirection")]
pub menu_direction: Option<MenuDirection>,
pub icon: Option<String>,
pub disabled: bool,
@ -58,6 +61,20 @@ pub struct PopoverButton {
pub popover_min_width: Option<u32>,
}
#[derive(Clone, Default, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum MenuDirection {
Top,
#[default]
Bottom,
Left,
Right,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Center,
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder, specta::Type)]
#[derivative(Debug, PartialEq)]
pub struct ParameterExposeButton {

View file

@ -5,6 +5,8 @@ use graphene_core::Color;
use graphene_core::raster::curve::Curve;
use graphene_std::transform::ReferencePoint;
use graphite_proc_macros::WidgetBuilder;
use once_cell::sync::OnceCell;
use std::sync::Arc;
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder, specta::Type)]
#[derivative(Debug, PartialEq)]
@ -18,6 +20,9 @@ pub struct CheckboxInput {
pub tooltip: String,
#[serde(rename = "forLabel", skip_serializing_if = "checkbox_id_is_empty")]
pub for_label: CheckboxId,
#[serde(skip)]
pub tooltip_shortcut: Option<ActionKeys>,
@ -39,12 +44,51 @@ impl Default for CheckboxInput {
icon: "Checkmark".into(),
tooltip: Default::default(),
tooltip_shortcut: Default::default(),
for_label: CheckboxId::default(),
on_update: Default::default(),
on_commit: Default::default(),
}
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct CheckboxId(Arc<OnceCell<u64>>);
impl CheckboxId {
pub fn fill(&mut self) {
let _ = self.0.set(graphene_core::uuid::generate_uuid());
}
}
impl specta::Type for CheckboxId {
fn inline(_type_map: &mut specta::TypeCollection, _generics: specta::Generics) -> specta::datatype::DataType {
// TODO: This might not be right, but it works for now. We just need the type `bigint | undefined`.
specta::datatype::DataType::Primitive(specta::datatype::PrimitiveType::u64)
}
}
impl serde::Serialize for CheckboxId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.get().copied().serialize(serializer)
}
}
impl<'a> serde::Deserialize<'a> for CheckboxId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'a>,
{
let id = u64::deserialize(deserializer)?;
let checkbox_id = CheckboxId(OnceCell::new().into());
checkbox_id.0.set(id).map_err(serde::de::Error::custom)?;
Ok(checkbox_id)
}
}
fn checkbox_id_is_empty(id: &CheckboxId) -> bool {
id.0.get().is_none()
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)]
#[derivative(Debug, PartialEq, Default)]
pub struct DropdownInput {
@ -67,6 +111,13 @@ pub struct DropdownInput {
#[serde(skip)]
pub tooltip_shortcut: Option<ActionKeys>,
// Styling
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
//
// Callbacks
// `on_update` exists on the `MenuListEntry`, not this parent `DropdownInput`
@ -208,6 +259,9 @@ pub struct NumberInput {
#[serde(rename = "minWidth")]
pub min_width: u32,
#[serde(rename = "maxWidth")]
pub max_width: u32,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]

View file

@ -1,3 +1,4 @@
use super::input_widgets::CheckboxId;
use derivative::*;
use graphite_proc_macros::WidgetBuilder;
@ -56,9 +57,21 @@ pub struct TextLabel {
pub tooltip: String,
#[serde(rename = "checkboxId")]
#[widget_builder(skip)]
pub checkbox_id: CheckboxId,
// Body
#[widget_builder(constructor)]
pub value: String,
}
impl TextLabel {
pub fn for_checkbox(mut self, id: &mut CheckboxId) -> Self {
id.fill();
self.checkbox_id = id.clone();
self
}
}
// TODO: Add UserInputLabel

View file

@ -301,7 +301,17 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
// Clear the control bar
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(Default::default()),
layout_target: LayoutTarget::LayersPanelControlBar,
layout_target: LayoutTarget::LayersPanelControlLeftBar,
});
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(Default::default()),
layout_target: LayoutTarget::LayersPanelControlRightBar,
});
// Clear the bottom bar
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(Default::default()),
layout_target: LayoutTarget::LayersPanelBottomBar,
});
}
DocumentMessage::CreateEmptyFolder => {
@ -346,6 +356,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
DocumentMessage::DocumentHistoryForward => self.redo_with_history(ipp, responses),
DocumentMessage::DocumentStructureChanged => {
self.update_layers_panel_control_bar_widgets(responses);
self.update_layers_panel_bottom_bar_widgets(responses);
self.network_interface.load_structure();
let data_buffer: RawBuffer = self.serialize_root();
@ -2132,165 +2143,212 @@ impl DocumentMessageHandler {
widgets: vec![TextLabel::new("General").widget_holder()],
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.artboard_name)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::ArtboardName),
}
.into()
})
.widget_holder(),
TextLabel::new("Artboard Name".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.artboard_name)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::ArtboardName),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Artboard Name".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.transform_measurement)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::TransformMeasurement),
}
.into()
})
.widget_holder(),
TextLabel::new("G/R/S Measurement".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.transform_measurement)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::TransformMeasurement),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("G/R/S Measurement".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![TextLabel::new("Select Tool").widget_holder()],
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.quick_measurement)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::QuickMeasurement),
}
.into()
})
.widget_holder(),
TextLabel::new("Quick Measurement".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.quick_measurement)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::QuickMeasurement),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Quick Measurement".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.transform_cage)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::TransformCage),
}
.into()
})
.widget_holder(),
TextLabel::new("Transform Cage".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.transform_cage)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::TransformCage),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Transform Cage".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.compass_rose)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::CompassRose),
}
.into()
})
.widget_holder(),
TextLabel::new("Transform Dial".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.compass_rose)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::CompassRose),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Transform Dial".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.pivot)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Pivot),
}
.into()
})
.widget_holder(),
TextLabel::new("Transform Pivot".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.pivot)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Pivot),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Transform Pivot".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.hover_outline)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::HoverOutline),
}
.into()
})
.widget_holder(),
TextLabel::new("Hover Outline".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.hover_outline)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::HoverOutline),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Hover Outline".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.selection_outline)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::SelectionOutline),
}
.into()
})
.widget_holder(),
TextLabel::new("Selection Outline".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.selection_outline)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::SelectionOutline),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Selection Outline".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![TextLabel::new("Pen & Path Tools").widget_holder()],
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.path)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Path),
}
.into()
})
.widget_holder(),
TextLabel::new("Path".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.path)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Path),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Path".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.anchors)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Anchors),
}
.into()
})
.widget_holder(),
TextLabel::new("Anchors".to_string()).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.anchors)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Anchors),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Anchors".to_string()).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
},
LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.overlays_visibility_settings.handles)
.disabled(!self.overlays_visibility_settings.anchors)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Handles),
}
.into()
})
.widget_holder(),
TextLabel::new("Handles".to_string()).disabled(!self.overlays_visibility_settings.anchors).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(self.overlays_visibility_settings.handles)
.disabled(!self.overlays_visibility_settings.anchors)
.on_update(|optional_input: &CheckboxInput| {
DocumentMessage::SetOverlaysVisibility {
visible: optional_input.checked,
overlays_type: Some(OverlaysType::Handles),
}
.into()
})
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new("Handles".to_string())
.disabled(!self.overlays_visibility_settings.anchors)
.for_checkbox(&mut checkbox_id)
.widget_holder(),
]
},
},
])
.widget_holder(),
@ -2319,25 +2377,45 @@ impl DocumentMessageHandler {
]
.into_iter()
.chain(SNAP_FUNCTIONS_FOR_BOUNDING_BOXES.into_iter().map(|(name, closure, tooltip)| LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(*closure(&mut snapping_state))
.on_update(move |input: &CheckboxInput| DocumentMessage::SetSnapping { closure: Some(closure), snapping_state: input.checked }.into())
.tooltip(tooltip)
.widget_holder(),
TextLabel::new(name).tooltip(tooltip).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(*closure(&mut snapping_state))
.on_update(move |input: &CheckboxInput| {
DocumentMessage::SetSnapping {
closure: Some(closure),
snapping_state: input.checked,
}
.into()
})
.tooltip(tooltip)
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new(name).tooltip(tooltip).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
}))
.chain([LayoutGroup::Row {
widgets: vec![TextLabel::new(SnappingOptions::Paths.to_string()).widget_holder()],
}])
.chain(SNAP_FUNCTIONS_FOR_PATHS.into_iter().map(|(name, closure, tooltip)| LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(*closure(&mut snapping_state2))
.on_update(move |input: &CheckboxInput| DocumentMessage::SetSnapping { closure: Some(closure), snapping_state: input.checked }.into())
.tooltip(tooltip)
.widget_holder(),
TextLabel::new(name).tooltip(tooltip).widget_holder(),
],
widgets: {
let mut checkbox_id = CheckboxId::default();
vec![
CheckboxInput::new(*closure(&mut snapping_state2))
.on_update(move |input: &CheckboxInput| {
DocumentMessage::SetSnapping {
closure: Some(closure),
snapping_state: input.checked,
}
.into()
})
.tooltip(tooltip)
.for_label(checkbox_id.clone())
.widget_holder(),
TextLabel::new(name).tooltip(tooltip).for_checkbox(&mut checkbox_id).widget_holder(),
]
},
}))
.collect(),
)
@ -2511,12 +2589,14 @@ impl DocumentMessageHandler {
.selected_index(blend_mode.and_then(|blend_mode| blend_mode.index_in_list_svg_subset()).map(|index| index as u32))
.disabled(disabled)
.draw_icon(false)
.max_width(100)
.tooltip("Blend Mode")
.widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
NumberInput::new(opacity)
.label("Opacity")
.unit("%")
.display_decimal_places(2)
.display_decimal_places(0)
.disabled(disabled)
.min(0.)
.max(100.)
@ -2531,33 +2611,13 @@ impl DocumentMessageHandler {
}
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.max_width(100)
.tooltip("Opacity")
.widget_holder(),
//
Separator::new(SeparatorType::Unrelated).widget_holder(),
//
IconButton::new("NewLayer", 24)
.tooltip("New Layer")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_update(|_| DocumentMessage::CreateEmptyFolder.into())
.widget_holder(),
IconButton::new("Folder", 24)
.tooltip("Group Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::GroupSelectedLayers))
.on_update(|_| {
let group_folder_type = GroupFolderType::Layer;
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(!has_selection)
.widget_holder(),
IconButton::new("Trash", 24)
.tooltip("Delete Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::DeleteSelectedLayers))
.on_update(|_| DocumentMessage::DeleteSelectedLayers.into())
.disabled(!has_selection)
.widget_holder(),
//
Separator::new(SeparatorType::Unrelated).widget_holder(),
//
];
let layers_panel_control_bar_left = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]);
let widgets = vec![
IconButton::new(if selection_all_locked { "PadlockLocked" } else { "PadlockUnlocked" }, 24)
.hover_icon(Some((if selection_all_locked { "PadlockUnlocked" } else { "PadlockLocked" }).into()))
.tooltip(if selection_all_locked { "Unlock Selected" } else { "Lock Selected" })
@ -2573,11 +2633,75 @@ impl DocumentMessageHandler {
.disabled(!has_selection)
.widget_holder(),
];
let layers_panel_control_bar = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]);
let layers_panel_control_bar_right = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]);
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(layers_panel_control_bar),
layout_target: LayoutTarget::LayersPanelControlBar,
layout: Layout::WidgetLayout(layers_panel_control_bar_left),
layout_target: LayoutTarget::LayersPanelControlLeftBar,
});
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(layers_panel_control_bar_right),
layout_target: LayoutTarget::LayersPanelControlRightBar,
});
}
pub fn update_layers_panel_bottom_bar_widgets(&self, responses: &mut VecDeque<Message>) {
let selected_nodes = self.network_interface.selected_nodes();
let mut selected_layers = selected_nodes.selected_layers(self.metadata());
let selected_layer = selected_layers.next();
let has_selection = selected_layer.is_some();
let has_multiple_selection = selected_layers.next().is_some();
let widgets = vec![
PopoverButton::new()
.icon(Some("Node".to_string()))
.menu_direction(Some(MenuDirection::Top))
.tooltip("Add an operation to the end of this layer's chain of nodes")
.disabled(!has_selection || has_multiple_selection)
.popover_layout({
let node_chooser = NodeCatalog::new()
.on_update(move |node_type| {
if let Some(layer) = selected_layer {
NodeGraphMessage::CreateNodeInLayerWithTransaction {
node_type: node_type.clone(),
layer: LayerNodeIdentifier::new_unchecked(layer.to_node()),
}
.into()
} else {
Message::NoOp
}
})
.widget_holder();
vec![LayoutGroup::Row { widgets: vec![node_chooser] }]
})
.widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(),
IconButton::new("Folder", 24)
.tooltip("Group Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::GroupSelectedLayers))
.on_update(|_| {
let group_folder_type = GroupFolderType::Layer;
DocumentMessage::GroupSelectedLayers { group_folder_type }.into()
})
.disabled(!has_selection)
.widget_holder(),
IconButton::new("NewLayer", 24)
.tooltip("New Layer")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_update(|_| DocumentMessage::CreateEmptyFolder.into())
.widget_holder(),
IconButton::new("Trash", 24)
.tooltip("Delete Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::DeleteSelectedLayers))
.on_update(|_| DocumentMessage::DeleteSelectedLayers.into())
.disabled(!has_selection)
.widget_holder(),
];
let layers_panel_bottom_bar = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]);
responses.add(LayoutMessage::SendLayout {
layout: Layout::WidgetLayout(layers_panel_bottom_bar),
layout_target: LayoutTarget::LayersPanelBottomBar,
});
}

View file

@ -1215,11 +1215,6 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
if is_stack_wire(&wire) { stack_wires.push(wire) } else { node_wires.push(wire) }
}
// Auto convert node to layer when inserting on a single stack wire
if stack_wires.len() == 1 && node_wires.is_empty() {
network_interface.set_to_node_or_layer(&selected_node_id, selection_network_path, true)
}
let overlapping_wire = if network_interface.is_layer(&selected_node_id, selection_network_path) {
if stack_wires.len() == 1 {
stack_wires.first()
@ -1852,11 +1847,6 @@ impl NodeGraphMessageHandler {
//
Separator::new(SeparatorType::Unrelated).widget_holder(),
//
IconButton::new("NewLayer", 24)
.tooltip("New Layer")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_update(|_| DocumentMessage::CreateEmptyFolder.into())
.widget_holder(),
IconButton::new("Folder", 24)
.tooltip("Group Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::GroupSelectedLayers))
@ -1866,6 +1856,11 @@ impl NodeGraphMessageHandler {
})
.disabled(!has_selection)
.widget_holder(),
IconButton::new("NewLayer", 24)
.tooltip("New Layer")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::CreateEmptyFolder))
.on_update(|_| DocumentMessage::CreateEmptyFolder.into())
.widget_holder(),
IconButton::new("Trash", 24)
.tooltip("Delete Selected")
.tooltip_shortcut(action_keys!(DocumentMessageDiscriminant::DeleteSelectedLayers))
@ -2010,12 +2005,38 @@ impl NodeGraphMessageHandler {
}
// Next, we decide what to display based on the number of layers and nodes selected
match layers.len() {
match *layers.as_slice() {
// If no layers are selected, show properties for all selected nodes
0 => {
[] => {
let selected_nodes = nodes.iter().map(|node_id| node_properties::generate_node_properties(*node_id, context)).collect::<Vec<_>>();
if !selected_nodes.is_empty() {
return selected_nodes;
let mut properties = Vec::new();
if let [node_id] = *nodes.as_slice() {
properties.push(LayoutGroup::Row {
widgets: vec![
Separator::new(SeparatorType::Related).widget_holder(),
IconLabel::new("Node").tooltip("Name of the selected node").widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
TextInput::new(context.network_interface.display_name(&node_id, context.selection_network_path))
.tooltip("Name of the selected node")
.on_update(move |text_input| {
NodeGraphMessage::SetDisplayName {
node_id,
alias: text_input.value.clone(),
skip_adding_history_step: false,
}
.into()
})
.widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
],
});
}
properties.extend(selected_nodes);
return properties;
}
// TODO: Display properties for encapsulating node when no nodes are selected in a nested network
@ -2057,8 +2078,7 @@ impl NodeGraphMessageHandler {
properties
}
// If one layer is selected, filter out all selected nodes that are not upstream of it. If there are no nodes left, show properties for the layer. Otherwise, show nothing.
1 => {
let layer = layers[0];
[layer] => {
let nodes_not_upstream_of_layer = nodes.into_iter().filter(|&selected_node_id| {
!context
.network_interface

View file

@ -339,6 +339,7 @@
// resolution_index,
// ))
// .on_commit(commit_value)
// .for_label(checkbox_id.clone())
// .widget_holder(),
// Separator::new(SeparatorType::Related).widget_holder(),
// NumberInput::new(Some(vec2.x))
@ -438,7 +439,7 @@
// LayoutGroup::Row { widgets }.with_tooltip("A negative text prompt can be used to list things like objects or colors to avoid")
// };
// let base_image = {
// let widgets = bool_widget(document_node, node_id, base_img_index, "Adapt Input Image", CheckboxInput::default(), true);
// let widgets = bool_widget(document_node, node_id, base_img_index, "Adapt Input Image", CheckboxInput::default().for_label(checkbox_id.clone()), true);
// LayoutGroup::Row { widgets }.with_tooltip("Generate an image based upon the bitmap data plugged into this node")
// };
// let image_creativity = {
@ -529,7 +530,7 @@
// // }
// let improve_faces = {
// let widgets = bool_widget(document_node, node_id, faces_index, "Improve Faces", CheckboxInput::default(), true);
// let widgets = bool_widget(document_node, node_id, faces_index, "Improve Faces", CheckboxInput::default().for_label(checkbox_id.clone()), true);
// LayoutGroup::Row { widgets }.with_tooltip(
// "Postprocess human (or human-like) faces to look subtly less distorted.\n\
// \n\
@ -537,7 +538,7 @@
// )
// };
// let tiling = {
// let widgets = bool_widget(document_node, node_id, tiling_index, "Tiling", CheckboxInput::default(), true);
// let widgets = bool_widget(document_node, node_id, tiling_index, "Tiling", CheckboxInput::default().for_label(checkbox_id.clone()), true);
// LayoutGroup::Row { widgets }.with_tooltip("Generate the image so its edges loop seamlessly to make repeatable patterns or textures")
// };
// layout.extend_from_slice(&[improve_faces, tiling]);

View file

@ -1,5 +1,6 @@
use super::utility_types::{DrawHandles, OverlayContext};
use crate::consts::HIDE_HANDLE_DISTANCE;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState};
use crate::messages::tool::tool_messages::tool_prelude::{DocumentMessageHandler, PreferencesMessageHandler};
use bezier_rs::{Bezier, BezierHandles};
@ -23,7 +24,7 @@ pub fn overlay_canvas_context() -> web_sys::CanvasRenderingContext2d {
create_context().expect("Failed to get canvas context")
}
pub fn selected_segments(document: &DocumentMessageHandler, shape_editor: &mut ShapeState) -> Vec<SegmentId> {
pub fn selected_segments(network_interface: &NodeNetworkInterface, shape_editor: &ShapeState) -> Vec<SegmentId> {
let selected_points = shape_editor.selected_points();
let selected_anchors = selected_points
.filter_map(|point_id| if let ManipulatorPointId::Anchor(p) = point_id { Some(*p) } else { None })
@ -40,8 +41,8 @@ pub fn selected_segments(document: &DocumentMessageHandler, shape_editor: &mut S
// TODO: Currently if there are two duplicate layers, both of their segments get overlays
// Adding segments which are are connected to selected anchors
for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue };
for layer in network_interface.selected_nodes().selected_layers(network_interface.document_metadata()) {
let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
for (segment_id, _bezier, start, end) in vector_data.segment_bezier_iter() {
if selected_anchors.contains(&start) || selected_anchors.contains(&end) {

View file

@ -404,8 +404,7 @@ pub const SNAP_FUNCTIONS_FOR_BOUNDING_BOXES: [(&str, GetSnapState, &str); 5] = [
(
"Distribute Evenly",
(|snapping_state| &mut snapping_state.bounding_box.distribute_evenly) as GetSnapState,
// TODO: Fix the bug/limitation that requires 'Center Points' and 'Corner Points' to be enabled
"Snaps to a consistent distance offset established by the bounding boxes of nearby layers\n(due to a bug, 'Center Points' and 'Corner Points' must be enabled)",
"Snaps to a consistent distance offset established by the bounding boxes of nearby layers",
),
];
pub const SNAP_FUNCTIONS_FOR_PATHS: [(&str, GetSnapState, &str); 7] = [

View file

@ -90,6 +90,9 @@ pub enum PortfolioMessage {
PasteSerializedData {
data: String,
},
CenterPastedLayers {
layers: Vec<LayerNodeIdentifier>,
},
PasteImage {
name: Option<String>,
image: Image<Color>,

View file

@ -10,6 +10,7 @@ use crate::messages::dialog::simple_dialogs;
use crate::messages::frontend::utility_types::FrontendDocumentDetails;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::DocumentMessageData;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::utility_types::clipboards::{Clipboard, CopyBufferEntry, INTERNAL_CLIPBOARD_COUNT};
use crate::messages::portfolio::document::utility_types::nodes::SelectedNodes;
@ -18,9 +19,10 @@ use crate::messages::prelude::*;
use crate::messages::tool::utility_types::{HintData, HintGroup, ToolType};
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
use bezier_rs::Subpath;
use glam::IVec2;
use glam::{DAffine2, DVec2, IVec2};
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeInput};
use graphene_core::renderer::Quad;
use graphene_core::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, FillType, Gradient};
use graphene_std::vector::{VectorData, VectorDataTable};
@ -1003,8 +1005,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
}
PortfolioMessage::PasteSerializedData { data } => {
if let Some(document) = self.active_document() {
let mut all_new_ids = Vec::new();
if let Ok(data) = serde_json::from_str::<Vec<CopyBufferEntry>>(&data) {
let parent = document.new_layer_parent(false);
let mut layers = Vec::new();
let mut added_nodes = false;
@ -1014,16 +1018,129 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
responses.add(DocumentMessage::AddTransaction);
added_nodes = true;
}
document.load_layer_resources(responses);
let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect();
let layer = LayerNodeIdentifier::new_unchecked(new_ids[&NodeId(0)]);
all_new_ids.extend(new_ids.values().cloned());
responses.add(NodeGraphMessage::AddNodes { nodes: entry.nodes, new_ids });
responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index: 0 });
layers.push(layer);
}
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: all_new_ids });
responses.add(Message::StartBuffer);
responses.add(PortfolioMessage::CenterPastedLayers { layers });
}
}
}
PortfolioMessage::CenterPastedLayers { layers } => {
if let Some(document) = self.active_document_mut() {
let viewport_bounds_quad_pixels = Quad::from_box([DVec2::ZERO, ipp.viewport_bounds.size()]);
let viewport_center_pixels = viewport_bounds_quad_pixels.center(); // In viewport pixel coordinates
let doc_to_viewport_transform = document.metadata().document_to_viewport;
let viewport_to_doc_transform = doc_to_viewport_transform.inverse();
let viewport_quad_doc_space = viewport_to_doc_transform * viewport_bounds_quad_pixels;
let mut top_level_items_to_center: Vec<LayerNodeIdentifier> = Vec::new();
let mut artboards_in_selection: Vec<LayerNodeIdentifier> = Vec::new();
for &layer_id in &layers {
if document.network_interface.is_artboard(&layer_id.to_node(), &document.node_graph_handler.network) {
artboards_in_selection.push(layer_id);
}
}
for &layer_id in &layers {
let is_child_of_selected_artboard = artboards_in_selection.iter().any(|&artboard_id| {
if layer_id == artboard_id {
return false;
}
layer_id.ancestors(document.metadata()).any(|ancestor| ancestor == artboard_id)
});
if !is_child_of_selected_artboard {
top_level_items_to_center.push(layer_id);
}
}
if top_level_items_to_center.is_empty() {
return;
}
let mut combined_min_doc = DVec2::MAX;
let mut combined_max_doc = DVec2::MIN;
let mut has_any_bounds = false;
for &item_id in &top_level_items_to_center {
if let Some(bounds_doc) = document.metadata().bounding_box_document(item_id) {
combined_min_doc = combined_min_doc.min(bounds_doc[0]);
combined_max_doc = combined_max_doc.max(bounds_doc[1]);
has_any_bounds = true;
}
}
if !has_any_bounds {
return;
}
let combined_bounds_doc_quad = Quad::from_box([combined_min_doc, combined_max_doc]);
if combined_bounds_doc_quad.intersects(viewport_quad_doc_space) {
return;
}
let combined_center_doc = combined_bounds_doc_quad.center();
let combined_center_viewport_pixels = doc_to_viewport_transform.transform_point2(combined_center_doc);
let translation_viewport_pixels_rounded = (viewport_center_pixels - combined_center_viewport_pixels).round();
let final_translation_offset_doc = viewport_to_doc_transform.transform_vector2(translation_viewport_pixels_rounded);
if final_translation_offset_doc.abs_diff_eq(glam::DVec2::ZERO, 1e-9) {
return;
}
responses.add(DocumentMessage::AddTransaction);
for &item_id in &top_level_items_to_center {
if document.network_interface.is_artboard(&item_id.to_node(), &document.node_graph_handler.network) {
if let Some(bounds_doc) = document.metadata().bounding_box_document(item_id) {
let current_artboard_origin_doc = bounds_doc[0];
let dimensions_doc = bounds_doc[1] - bounds_doc[0];
let new_artboard_origin_doc = current_artboard_origin_doc + final_translation_offset_doc;
responses.add(GraphOperationMessage::ResizeArtboard {
layer: item_id,
location: new_artboard_origin_doc.round().as_ivec2(),
dimensions: dimensions_doc.round().as_ivec2(),
});
}
} else {
let current_abs_doc_transform = document.metadata().transform_to_document(item_id);
let new_abs_doc_transform = DAffine2 {
matrix2: current_abs_doc_transform.matrix2,
translation: current_abs_doc_transform.translation + final_translation_offset_doc,
};
let transform = doc_to_viewport_transform * new_abs_doc_transform;
responses.add(GraphOperationMessage::TransformSet {
layer: item_id,
transform,
transform_in: TransformIn::Viewport,
skip_rerender: false,
});
}
}
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
PortfolioMessage::PasteImage {
name,
image,
@ -1320,6 +1437,7 @@ impl PortfolioMessageHandler {
self.document_ids.push_back(document_id);
}
new_document.update_layers_panel_control_bar_widgets(responses);
new_document.update_layers_panel_bottom_bar_widgets(responses);
self.documents.insert(document_id, new_document);

View file

@ -2,12 +2,14 @@ use super::graph_modification_utils::{self, merge_layers};
use super::snapping::{SnapCache, SnapCandidatePoint, SnapData, SnapManager, SnappedPoint};
use super::utility_functions::calculate_segment_angle;
use crate::consts::HANDLE_LENGTH_FACTOR;
use crate::messages::portfolio::document::overlays::utility_functions::selected_segments;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{PathSnapSource, SnapSource};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::snapping::SnapTypeConfiguration;
use crate::messages::tool::tool_messages::path_tool::PointSelectState;
use crate::messages::tool::common_functionality::utility_functions::is_visible_point;
use crate::messages::tool::tool_messages::path_tool::{PathOverlayMode, PointSelectState};
use bezier_rs::{Bezier, BezierHandles, Subpath, TValue};
use glam::{DAffine2, DVec2};
use graphene_core::transform::Transform;
@ -70,7 +72,7 @@ impl SelectedLayerState {
}
pub fn ignore_handles(&mut self, status: bool) {
if self.ignore_handles == !status {
if self.ignore_handles != status {
return;
}
@ -86,7 +88,7 @@ impl SelectedLayerState {
}
pub fn ignore_anchors(&mut self, status: bool) {
if self.ignore_anchors == !status {
if self.ignore_anchors != status {
return;
}
@ -421,12 +423,20 @@ impl ShapeState {
/// Select/deselect the first point within the selection threshold.
/// Returns a tuple of the points if found and the offset, or `None` otherwise.
pub fn change_point_selection(&mut self, network_interface: &NodeNetworkInterface, mouse_position: DVec2, select_threshold: f64, extend_selection: bool) -> Option<Option<SelectedPointsInfo>> {
pub fn change_point_selection(
&mut self,
network_interface: &NodeNetworkInterface,
mouse_position: DVec2,
select_threshold: f64,
extend_selection: bool,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
) -> Option<Option<SelectedPointsInfo>> {
if self.selected_shape_state.is_empty() {
return None;
}
if let Some((layer, manipulator_point_id)) = self.find_nearest_point_indices(network_interface, mouse_position, select_threshold) {
if let Some((layer, manipulator_point_id)) = self.find_nearest_visible_point_indices(network_interface, mouse_position, select_threshold, path_overlay_mode, frontier_handles_info) {
let vector_data = network_interface.compute_modified_vector(layer)?;
let point_position = manipulator_point_id.get_position(&vector_data)?;
@ -467,7 +477,14 @@ impl ShapeState {
None
}
pub fn get_point_selection_state(&mut self, network_interface: &NodeNetworkInterface, mouse_position: DVec2, select_threshold: f64) -> Option<(bool, Option<SelectedPointsInfo>)> {
pub fn get_point_selection_state(
&mut self,
network_interface: &NodeNetworkInterface,
mouse_position: DVec2,
select_threshold: f64,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
) -> Option<(bool, Option<SelectedPointsInfo>)> {
if self.selected_shape_state.is_empty() {
return None;
}
@ -476,6 +493,13 @@ impl ShapeState {
let vector_data = network_interface.compute_modified_vector(layer)?;
let point_position = manipulator_point_id.get_position(&vector_data)?;
// Check if point is visible under current overlay mode or not
let selected_segments = selected_segments(network_interface, self);
let selected_points = self.selected_points().cloned().collect::<HashSet<_>>();
if !is_visible_point(manipulator_point_id, &vector_data, path_overlay_mode, frontier_handles_info, selected_segments, &selected_points) {
return None;
}
let selected_shape_state = self.selected_shape_state.get(&layer)?;
let already_selected = selected_shape_state.is_selected(manipulator_point_id);
@ -774,7 +798,7 @@ impl ShapeState {
// For a non-endpoint anchor, handles are perpendicular to the average tangent of adjacent segments.(Refer:https://github.com/GraphiteEditor/Graphite/pull/2620#issuecomment-2881501494)
let mut handle_direction = if segment_count > 1. {
segment_angle = segment_angle / segment_count;
segment_angle /= segment_count;
segment_angle += std::f64::consts::FRAC_PI_2;
DVec2::new(segment_angle.cos(), segment_angle.sin())
} else {
@ -801,7 +825,7 @@ impl ShapeState {
let (non_zero_handle, zero_handle) = if a.length(vector_data) > 1e-6 { (a, b) } else { (b, a) };
let Some(direction) = non_zero_handle
.to_manipulator_point()
.get_position(&vector_data)
.get_position(vector_data)
.and_then(|position| (position - anchor_position).try_normalize())
else {
return;
@ -1320,6 +1344,42 @@ impl ShapeState {
None
}
pub fn find_nearest_visible_point_indices(
&mut self,
network_interface: &NodeNetworkInterface,
mouse_position: DVec2,
select_threshold: f64,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
) -> Option<(LayerNodeIdentifier, ManipulatorPointId)> {
if self.selected_shape_state.is_empty() {
return None;
}
let select_threshold_squared = select_threshold.powi(2);
// Find the closest control point among all elements of shapes_to_modify
for &layer in self.selected_shape_state.keys() {
if let Some((manipulator_point_id, distance_squared)) = Self::closest_point_in_layer(network_interface, layer, mouse_position) {
// Choose the first point under the threshold
if distance_squared < select_threshold_squared {
// Check if point is visible in current PathOverlayMode
let vector_data = network_interface.compute_modified_vector(layer)?;
let selected_segments = selected_segments(network_interface, self);
let selected_points = self.selected_points().cloned().collect::<HashSet<_>>();
if !is_visible_point(manipulator_point_id, &vector_data, path_overlay_mode, frontier_handles_info, selected_segments, &selected_points) {
return None;
}
return Some((layer, manipulator_point_id));
}
}
}
None
}
// TODO Use quadtree or some equivalent spatial acceleration structure to improve this to O(log(n))
/// Find the closest manipulator, manipulator point, and distance so we can select path elements.
/// Brute force comparison to determine which manipulator (handle or anchor) we want to select taking O(n) time.
@ -1538,6 +1598,7 @@ impl ShapeState {
}
}
}
/// Converts a nearby clicked anchor point's handles between sharp (zero-length handles) and smooth (pulled-apart handle(s)).
/// If both handles aren't zero-length, they are set that. If both are zero-length, they are stretched apart by a reasonable amount.
/// This can can be activated by double clicking on an anchor with the Path tool.
@ -1568,44 +1629,47 @@ impl ShapeState {
.count();
// Check by comparing the handle positions to the anchor if this manipulator group is a point
if positions != 0 {
self.convert_manipulator_handles_to_colinear(&vector_data, id, responses, layer);
} else {
for handle in vector_data.all_connected(id) {
let Some(bezier) = vector_data.segment_from_id(handle.segment) else { continue };
for point in self.selected_points() {
let Some(point_id) = point.as_anchor() else { continue };
if positions != 0 {
self.convert_manipulator_handles_to_colinear(&vector_data, point_id, responses, layer);
} else {
for handle in vector_data.all_connected(point_id) {
let Some(bezier) = vector_data.segment_from_id(handle.segment) else { continue };
match bezier.handles {
BezierHandles::Linear => {}
BezierHandles::Quadratic { .. } => {
let segment = handle.segment;
// Convert to linear
let modification_type = VectorModificationType::SetHandles { segment, handles: [None; 2] };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
match bezier.handles {
BezierHandles::Linear => {}
BezierHandles::Quadratic { .. } => {
let segment = handle.segment;
// Convert to linear
let modification_type = VectorModificationType::SetHandles { segment, handles: [None; 2] };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&HandleId::primary(segment)) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&HandleId::primary(segment)) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
}
}
BezierHandles::Cubic { .. } => {
// Set handle position to anchor position
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
BezierHandles::Cubic { .. } => {
// Set handle position to anchor position
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
}
}
}
}
};
};
}
Some(true)
};
@ -1619,7 +1683,17 @@ impl ShapeState {
false
}
pub fn select_all_in_shape(&mut self, network_interface: &NodeNetworkInterface, selection_shape: SelectionShape, selection_change: SelectionChange) {
pub fn select_all_in_shape(
&mut self,
network_interface: &NodeNetworkInterface,
selection_shape: SelectionShape,
selection_change: SelectionChange,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
) {
let selected_points = self.selected_points().cloned().collect::<HashSet<_>>();
let selected_segments = selected_segments(network_interface, self);
for (&layer, state) in &mut self.selected_shape_state {
if selection_change == SelectionChange::Clear {
state.clear_points()
@ -1662,13 +1736,17 @@ impl ShapeState {
};
if select {
match selection_change {
SelectionChange::Shrink => state.deselect_point(id),
_ => {
// Select only the handles which are of nonzero length
if let Some(handle) = id.as_handle() {
if handle.length(&vector_data) > 0. {
state.select_point(id)
let is_visible_handle = is_visible_point(id, &vector_data, path_overlay_mode, frontier_handles_info.clone(), selected_segments.clone(), &selected_points);
if is_visible_handle {
match selection_change {
SelectionChange::Shrink => state.deselect_point(id),
_ => {
// Select only the handles which are of nonzero length
if let Some(handle) = id.as_handle() {
if handle.length(&vector_data) > 0. {
state.select_point(id)
}
}
}
}

View file

@ -114,13 +114,13 @@ fn get_closest_point(points: Vec<SnappedPoint>) -> Option<SnappedPoint> {
(Some(result), None) | (None, Some(result)) => Some(result),
(Some(mut result), Some(align)) => {
let SnapTarget::DistributeEvenly(distribution) = result.target else { return Some(result) };
if distribution.is_x() && align.alignment_target_x.is_some() {
if distribution.is_x() && align.alignment_target_horizontal.is_some() {
result.snapped_point_document.y = align.snapped_point_document.y;
result.alignment_target_x = align.alignment_target_x;
result.alignment_target_horizontal = align.alignment_target_horizontal;
}
if distribution.is_y() && align.alignment_target_y.is_some() {
if distribution.is_y() && align.alignment_target_vertical.is_some() {
result.snapped_point_document.x = align.snapped_point_document.x;
result.alignment_target_y = align.alignment_target_y;
result.alignment_target_vertical = align.alignment_target_vertical;
}
Some(result)
@ -456,10 +456,10 @@ impl SnapManager {
}
let viewport = to_viewport.transform_point2(ind.snapped_point_document);
Self::alignment_x_overlay(&ind.distribution_boxes_x, to_viewport, overlay_context);
Self::alignment_y_overlay(&ind.distribution_boxes_y, to_viewport, overlay_context);
Self::alignment_x_overlay(&ind.distribution_boxes_horizontal, to_viewport, overlay_context);
Self::alignment_y_overlay(&ind.distribution_boxes_vertical, to_viewport, overlay_context);
let align = [ind.alignment_target_x, ind.alignment_target_y].map(|target| target.map(|target| to_viewport.transform_point2(target)));
let align = [ind.alignment_target_horizontal, ind.alignment_target_vertical].map(|target| target.map(|target| to_viewport.transform_point2(target)));
let any_align = align.iter().flatten().next().is_some();
for &target in align.iter().flatten() {
overlay_context.line(viewport, target, None, None);
@ -471,7 +471,7 @@ impl SnapManager {
overlay_context.manipulator_handle(viewport, false, None);
}
if !any_align && ind.distribution_equal_distance_x.is_none() && ind.distribution_equal_distance_y.is_none() {
if !any_align && ind.distribution_equal_distance_horizontal.is_none() && ind.distribution_equal_distance_vertical.is_none() {
let text = format!("[{}] from [{}]", ind.target, ind.source);
let transform = DAffine2::from_translation(viewport - DVec2::new(0., 4.));
overlay_context.text(&text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_LABEL_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);

View file

@ -90,7 +90,7 @@ impl AlignmentSnapper {
distance_to_align_target,
fully_constrained: false,
at_intersection: true,
alignment_target_x: Some(endpoint),
alignment_target_horizontal: Some(endpoint),
..Default::default()
};
snap_results.points.push(snap_point);
@ -129,7 +129,7 @@ impl AlignmentSnapper {
distance: distance_to_snapped,
tolerance,
distance_to_align_target,
alignment_target_x: Some(target_position),
alignment_target_horizontal: Some(target_position),
fully_constrained: true,
at_intersection: matches!(constraint, SnapConstraint::Line { .. }),
..Default::default()
@ -148,7 +148,7 @@ impl AlignmentSnapper {
distance: distance_to_snapped,
tolerance,
distance_to_align_target,
alignment_target_y: Some(target_position),
alignment_target_vertical: Some(target_position),
fully_constrained: true,
at_intersection: matches!(constraint, SnapConstraint::Line { .. }),
..Default::default()
@ -174,8 +174,8 @@ impl AlignmentSnapper {
target_bounds: snap_x.target_bounds,
distance,
tolerance,
alignment_target_x: snap_x.alignment_target_x,
alignment_target_y: snap_y.alignment_target_y,
alignment_target_horizontal: snap_x.alignment_target_horizontal,
alignment_target_vertical: snap_y.alignment_target_vertical,
constrained: true,
at_intersection: true,
..Default::default()

View file

@ -1,9 +1,9 @@
use super::*;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::*;
use crate::messages::prelude::*;
use glam::DVec2;
use graphene_core::renderer::Quad;
use std::collections::VecDeque;
#[derive(Clone, Debug, Default)]
pub struct DistributionSnapper {
@ -79,18 +79,21 @@ impl DistributionSnapper {
let screen_bounds = (document.metadata().document_to_viewport.inverse() * Quad::from_box([DVec2::ZERO, snap_data.input.viewport_bounds.size()])).bounding_box();
let max_extent = (screen_bounds[1] - screen_bounds[0]).abs().max_element();
// Collect artboard bounds
for layer in document.metadata().all_layers() {
if document.network_interface.is_artboard(&layer.to_node(), &[]) && !snap_data.ignore.contains(&layer) {
self.add_bounds(layer, snap_data, bbox_to_snap, max_extent);
}
}
// Collect alignment candidate bounds
for &layer in snap_data.alignment_candidates.map_or([].as_slice(), |candidates| candidates.as_slice()) {
if !snap_data.ignore_bounds(layer) {
self.add_bounds(layer, snap_data, bbox_to_snap, max_extent);
}
}
// Sort and merge intersecting rectangles
self.right.sort_unstable_by(|a, b| a.center().x.total_cmp(&b.center().x));
self.left.sort_unstable_by(|a, b| b.center().x.total_cmp(&a.center().x));
self.down.sort_unstable_by(|a, b| a.center().y.total_cmp(&b.center().y));
@ -184,20 +187,18 @@ impl DistributionSnapper {
fn snap_bbox_points(&self, tolerance: f64, point: &SnapCandidatePoint, snap_results: &mut SnapResults, constraint: SnapConstraint, bounds: Rect) {
let mut consider_x = true;
let mut consider_y = true;
if let SnapConstraint::Line { direction, .. } = constraint {
let direction = direction.normalize_or_zero();
if direction.x == 0. {
consider_x = false;
} else if direction.y == 0. {
consider_y = false;
}
consider_x = direction.x != 0.;
consider_y = direction.y != 0.;
}
let mut snap_x: Option<SnappedPoint> = None;
let mut snap_y: Option<SnappedPoint> = None;
self.x(consider_x, bounds, tolerance, &mut snap_x, point);
self.y(consider_y, bounds, tolerance, &mut snap_y, point);
self.horizontal_snap(consider_x, bounds, tolerance, &mut snap_x, point);
self.vertical_snap(consider_y, bounds, tolerance, &mut snap_y, point);
match (snap_x, snap_y) {
(Some(x), Some(y)) => {
@ -209,8 +210,8 @@ impl DistributionSnapper {
final_point.snapped_point_document += y.snapped_point_document - point.document_point;
final_point.source_bounds = Some(final_bounds.into());
final_point.target = SnapTarget::DistributeEvenly(DistributionSnapTarget::XY);
final_point.distribution_boxes_y = y.distribution_boxes_y;
final_point.distribution_equal_distance_y = y.distribution_equal_distance_y;
final_point.distribution_boxes_vertical = y.distribution_boxes_vertical;
final_point.distribution_equal_distance_vertical = y.distribution_equal_distance_vertical;
final_point.distance = (final_point.distance * final_point.distance + y.distance * y.distance).sqrt();
snap_results.points.push(final_point);
}
@ -220,42 +221,55 @@ impl DistributionSnapper {
}
}
fn x(&self, consider_x: bool, bounds: Rect, tolerance: f64, snap_x: &mut Option<SnappedPoint>, point: &SnapCandidatePoint) {
// Right
if consider_x && !self.right.is_empty() {
fn horizontal_snap(&self, consider_x: bool, bounds: Rect, tolerance: f64, snap_x: &mut Option<SnappedPoint>, point: &SnapCandidatePoint) {
if !consider_x {
return;
}
// Try right distribution first
if !self.right.is_empty() {
let (equal_dist, mut vec_right) = Self::top_level_matches(bounds, &self.right, tolerance, dist_right);
if let Some(distances) = equal_dist {
let translation = DVec2::X * (distances.first - distances.equal);
vec_right.push_front(bounds.translate(translation));
// Find matching left distribution
for &left in Self::exact_further_matches(bounds.translate(translation), &self.left, dist_left, distances.equal, 2).iter().skip(1) {
vec_right.push_front(left);
}
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Right, vec_right, distances, bounds, translation, tolerance))
// Adjust bounds to maintain alignment
if vec_right.len() > 1 {
vec_right[0][0].y = vec_right[0][0].y.min(vec_right[1][1].y);
vec_right[0][1].y = vec_right[0][1].y.min(vec_right[1][1].y);
}
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Right, vec_right, distances, bounds, translation, tolerance));
return;
}
}
// Left
if consider_x && !self.left.is_empty() && snap_x.is_none() {
// Try left distribution if right didn't work
if !self.left.is_empty() {
let (equal_dist, mut vec_left) = Self::top_level_matches(bounds, &self.left, tolerance, dist_left);
if let Some(distances) = equal_dist {
let translation = -DVec2::X * (distances.first - distances.equal);
vec_left.make_contiguous().reverse();
vec_left.push_back(bounds.translate(translation));
// Find matching right distribution
for &right in Self::exact_further_matches(bounds.translate(translation), &self.right, dist_right, distances.equal, 2).iter().skip(1) {
vec_left.push_back(right);
}
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Left, vec_left, distances, bounds, translation, tolerance))
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Left, vec_left, distances, bounds, translation, tolerance));
return;
}
}
// Center X
if consider_x && !self.left.is_empty() && !self.right.is_empty() && snap_x.is_none() {
// Try center distribution if both sides exist
if !self.left.is_empty() && !self.right.is_empty() {
let target_x = (self.right[0].min() + self.left[0].max()).x / 2.;
let offset = target_x - bounds.center().x;
if offset.abs() < tolerance {
@ -263,67 +277,93 @@ impl DistributionSnapper {
let equal = bounds.translate(translation).min().x - self.left[0].max().x;
let first = equal + offset;
let distances = DistributionMatch { first, equal };
let boxes = VecDeque::from([self.left[0], bounds.translate(translation), self.right[0]]);
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::X, boxes, distances, bounds, translation, tolerance))
let mut boxes = VecDeque::from([self.left[0], bounds.translate(translation), self.right[0]]);
// Adjust bounds to maintain alignment
if boxes.len() > 1 {
boxes[1][0].y = boxes[1][0].y.min(boxes[0][1].y);
boxes[1][1].y = boxes[1][1].y.min(boxes[0][1].y);
}
*snap_x = Some(SnappedPoint::distribute(point, DistributionSnapTarget::X, boxes, distances, bounds, translation, tolerance));
}
}
}
fn y(&self, consider_y: bool, bounds: Rect, tolerance: f64, snap_y: &mut Option<SnappedPoint>, point: &SnapCandidatePoint) {
// Down
if consider_y && !self.down.is_empty() {
fn vertical_snap(&self, consider_y: bool, bounds: Rect, tolerance: f64, snap_y: &mut Option<SnappedPoint>, point: &SnapCandidatePoint) {
if !consider_y {
return;
}
// Try down distribution first
if !self.down.is_empty() {
let (equal_dist, mut vec_down) = Self::top_level_matches(bounds, &self.down, tolerance, dist_down);
if let Some(distances) = equal_dist {
let translation = DVec2::Y * (distances.first - distances.equal);
vec_down.push_front(bounds.translate(translation));
// Find matching up distribution
for &up in Self::exact_further_matches(bounds.translate(translation), &self.up, dist_up, distances.equal, 2).iter().skip(1) {
vec_down.push_front(up);
}
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Down, vec_down, distances, bounds, translation, tolerance))
// Adjust bounds to maintain alignment
if vec_down.len() > 1 {
vec_down[0][0].x = vec_down[0][0].x.min(vec_down[1][1].x);
vec_down[0][1].x = vec_down[0][1].x.min(vec_down[1][1].x);
}
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Down, vec_down, distances, bounds, translation, tolerance));
return;
}
}
// Up
if consider_y && !self.up.is_empty() && snap_y.is_none() {
// Try up distribution if down didn't work
if !self.up.is_empty() {
let (equal_dist, mut vec_up) = Self::top_level_matches(bounds, &self.up, tolerance, dist_up);
if let Some(distances) = equal_dist {
let translation = -DVec2::Y * (distances.first - distances.equal);
vec_up.make_contiguous().reverse();
vec_up.push_back(bounds.translate(translation));
// Find matching down distribution
for &down in Self::exact_further_matches(bounds.translate(translation), &self.down, dist_down, distances.equal, 2).iter().skip(1) {
vec_up.push_back(down);
}
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Up, vec_up, distances, bounds, translation, tolerance))
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Up, vec_up, distances, bounds, translation, tolerance));
return;
}
}
// Center Y
if consider_y && !self.up.is_empty() && !self.down.is_empty() && snap_y.is_none() {
// Try center distribution if both sides exist
if !self.up.is_empty() && !self.down.is_empty() {
let target_y = (self.down[0].min() + self.up[0].max()).y / 2.;
let offset = target_y - bounds.center().y;
if offset.abs() < tolerance {
let translation = DVec2::Y * offset;
let equal = bounds.translate(translation).min().y - self.up[0].max().y;
let first = equal + offset;
let distances = DistributionMatch { first, equal };
let boxes = VecDeque::from([self.up[0], bounds.translate(translation), self.down[0]]);
let mut boxes = VecDeque::from([self.up[0], bounds.translate(translation), self.down[0]]);
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Y, boxes, distances, bounds, translation, tolerance))
// Adjust bounds to maintain alignment
if boxes.len() > 1 {
boxes[1][0].x = boxes[1][0].x.min(boxes[0][1].x);
boxes[1][1].x = boxes[1][1].x.min(boxes[0][1].x);
}
*snap_y = Some(SnappedPoint::distribute(point, DistributionSnapTarget::Y, boxes, distances, bounds, translation, tolerance));
}
}
}
pub fn free_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults, config: SnapTypeConfiguration) {
let Some(bounds) = config.bbox else { return };
if point.source != SnapSource::BoundingBox(BoundingBoxSnapSource::CenterPoint) || !snap_data.document.snapping_state.bounding_box.distribute_evenly {
if !snap_data.document.snapping_state.bounding_box.distribute_evenly {
return;
}
@ -333,7 +373,7 @@ impl DistributionSnapper {
pub fn constrained_snap(&mut self, snap_data: &mut SnapData, point: &SnapCandidatePoint, snap_results: &mut SnapResults, constraint: SnapConstraint, config: SnapTypeConfiguration) {
let Some(bounds) = config.bbox else { return };
if point.source != SnapSource::BoundingBox(BoundingBoxSnapSource::CenterPoint) || !snap_data.document.snapping_state.bounding_box.distribute_evenly {
if !snap_data.document.snapping_state.bounding_box.distribute_evenly {
return;
}
@ -415,10 +455,14 @@ fn dist_snap_point_right() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_x[0], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(6.));
let mut expected_box = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_box[0].y = expected_box[0].y.min(dist_snapper.left[0][1].y);
expected_box[1].y = expected_box[1].y.min(dist_snapper.left[0][1].y);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[0], expected_box);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
}
#[test]
@ -428,16 +472,28 @@ fn dist_snap_point_right_left() {
left: [-2., -10., -15., -20.].map(|x| Rect::from_square(DVec2::new(x, 0.), 2.)).to_vec(),
..Default::default()
};
let source = Rect::from_square(DVec2::new(0.5, 0.), 2.);
let snap_results = &mut SnapResults::default();
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 5);
assert_eq!(snap_results.points[0].distribution_boxes_x[1], Rect::from_square(DVec2::new(-10., 0.), 2.));
assert_eq!(snap_results.points[0].distribution_boxes_x[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 5);
let mut expected_left1 = dist_snapper.left[1];
let mut expected_center = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_center[0].y = expected_center[0].y.min(dist_snapper.left[1][1].y).min(dist_snapper.right[0][1].y);
expected_center[1].y = expected_center[1].y.min(dist_snapper.left[1][1].y).min(dist_snapper.right[0][1].y);
expected_left1[0].y = expected_left1[0].y.min(dist_snapper.left[0][1].y).min(expected_center[1].y);
expected_left1[1].y = expected_left1[1].y.min(dist_snapper.left[0][1].y).min(expected_center[1].y);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[1], expected_left1);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[2], expected_center);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
}
#[test]
@ -451,10 +507,10 @@ fn dist_snap_point_left() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_x[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
}
#[test]
@ -469,10 +525,10 @@ fn dist_snap_point_left_right() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 4);
assert_eq!(snap_results.points[0].distribution_boxes_x[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 4);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
}
#[test]
@ -487,10 +543,15 @@ fn dist_snap_point_center_x() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_x[1], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(6.));
let mut expected_box = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_box[0].y = expected_box[0].y.min(dist_snapper.left[0][1].y);
expected_box[1].y = expected_box[1].y.min(dist_snapper.left[0][1].y);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_horizontal[1], expected_box);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
}
// ----------------------------------
@ -507,10 +568,15 @@ fn dist_snap_point_down() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_y[0], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
let mut expected_box = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_box[0].x = expected_box[0].x.min(dist_snapper.down[0][1].x);
expected_box[1].x = expected_box[1].x.min(dist_snapper.down[0][1].x);
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[0], expected_box);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}
#[test]
@ -523,13 +589,23 @@ fn dist_snap_point_down_up() {
let source = Rect::from_square(DVec2::new(0., 0.5), 2.);
let snap_results = &mut SnapResults::default();
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 5);
assert_eq!(snap_results.points[0].distribution_boxes_y[1], Rect::from_square(DVec2::new(0., -10.), 2.));
assert_eq!(snap_results.points[0].distribution_boxes_y[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 5);
let mut expected_center = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_center[0].x = expected_center[0].x.min(dist_snapper.up[1][1].x).min(dist_snapper.down[0][1].x);
expected_center[1].x = expected_center[1].x.min(dist_snapper.up[1][1].x).min(dist_snapper.down[0][1].x);
let mut expected_up = Rect::from_square(DVec2::new(0., -10.), 2.);
expected_up[0].x = expected_up[0].x.min(dist_snapper.up[0][1].x).min(expected_center[0].x);
expected_up[1].x = expected_up[1].x.min(dist_snapper.up[0][1].x).min(expected_center[1].x);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[1], expected_up);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[2], expected_center);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}
#[test]
@ -543,10 +619,10 @@ fn dist_snap_point_up() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_y[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}
#[test]
@ -561,10 +637,10 @@ fn dist_snap_point_up_down() {
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 4);
assert_eq!(snap_results.points[0].distribution_boxes_y[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 4);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[2], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}
#[test]
@ -577,12 +653,18 @@ fn dist_snap_point_center_y() {
let source = Rect::from_square(DVec2::new(0., 0.5), 2.);
let snap_results = &mut SnapResults::default();
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5);
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_y[1], Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 3);
let mut expected_box = Rect::from_square(DVec2::new(0., 0.), 2.);
expected_box[0].x = expected_box[0].x.min(dist_snapper.up[0][1].x).min(dist_snapper.down[0][1].x);
expected_box[1].x = expected_box[1].x.min(dist_snapper.up[0][1].x).min(dist_snapper.down[0][1].x);
assert_eq!(snap_results.points[0].distribution_boxes_vertical[1], expected_box);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}
#[test]
@ -592,17 +674,25 @@ fn dist_snap_point_center_xy() {
down: [10., 15.].map(|y| Rect::from_square(DVec2::new(0., y), 2.)).to_vec(),
left: [-12., -15.].map(|x| Rect::from_square(DVec2::new(x, 0.), 2.)).to_vec(),
right: [12., 15.].map(|x| Rect::from_square(DVec2::new(x, 0.), 2.)).to_vec(),
..Default::default()
};
let source = Rect::from_square(DVec2::new(0.3, 0.4), 2.);
let snap_results = &mut SnapResults::default();
dist_snapper.snap_bbox_points(1., &SnapCandidatePoint::default(), snap_results, SnapConstraint::None, source);
assert_eq!(snap_results.points.len(), 1);
assert_eq!(snap_results.points[0].distance, 0.5000000000000001);
assert_eq!(snap_results.points[0].distribution_equal_distance_x, Some(8.));
assert_eq!(snap_results.points[0].distribution_equal_distance_y, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_x.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_y.len(), 3);
assert_eq!(snap_results.points[0].distribution_equal_distance_horizontal, Some(8.));
assert_eq!(snap_results.points[0].distribution_equal_distance_vertical, Some(6.));
assert_eq!(snap_results.points[0].distribution_boxes_horizontal.len(), 3);
assert_eq!(snap_results.points[0].distribution_boxes_vertical.len(), 3);
assert!(snap_results.points[0].distribution_boxes_horizontal[1][0].y <= dist_snapper.left[0][1].y);
assert!(snap_results.points[0].distribution_boxes_horizontal[1][1].y <= dist_snapper.left[0][1].y);
assert!(snap_results.points[0].distribution_boxes_vertical[1][0].x <= dist_snapper.up[0][1].x);
assert!(snap_results.points[0].distribution_boxes_vertical[1][1].x <= dist_snapper.up[0][1].x);
assert_eq!(Rect::from_box(snap_results.points[0].source_bounds.unwrap().bounding_box()), Rect::from_square(DVec2::new(0., 0.), 2.));
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_x, 0);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_y, 1);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_horizontal, 0);
assert_boxes_in_order(&snap_results.points[0].distribution_boxes_vertical, 1);
}

View file

@ -29,17 +29,17 @@ pub struct SnappedPoint {
pub outline_layers: [Option<LayerNodeIdentifier>; 2],
pub distance: f64,
pub tolerance: f64,
pub distribution_boxes_x: VecDeque<Rect>,
pub distribution_equal_distance_x: Option<f64>,
pub distribution_boxes_y: VecDeque<Rect>,
pub distribution_equal_distance_y: Option<f64>,
pub distribution_boxes_horizontal: VecDeque<Rect>,
pub distribution_equal_distance_horizontal: Option<f64>,
pub distribution_boxes_vertical: VecDeque<Rect>,
pub distribution_equal_distance_vertical: Option<f64>,
pub distance_to_align_target: f64, // If aligning so that the top is aligned but the X pos is 200 from the target, this is 200.
pub alignment_target_x: Option<DVec2>,
pub alignment_target_y: Option<DVec2>,
pub alignment_target_horizontal: Option<DVec2>,
pub alignment_target_vertical: Option<DVec2>,
}
impl SnappedPoint {
pub fn align(&self) -> bool {
self.alignment_target_x.is_some() || self.alignment_target_y.is_some()
self.alignment_target_horizontal.is_some() || self.alignment_target_vertical.is_some()
}
pub fn infinite_snap(snapped_point_document: DVec2) -> Self {
Self {
@ -58,15 +58,15 @@ impl SnappedPoint {
pub fn distribute(point: &SnapCandidatePoint, target: DistributionSnapTarget, boxes: VecDeque<Rect>, distances: DistributionMatch, bounds: Rect, translation: DVec2, tolerance: f64) -> Self {
let is_x = target.is_x();
let [distribution_boxes_x, distribution_boxes_y] = if is_x { [boxes, Default::default()] } else { [Default::default(), boxes] };
let [distribution_boxes_horizontal, distribution_boxes_vertical] = if is_x { [boxes, Default::default()] } else { [Default::default(), boxes] };
Self {
snapped_point_document: point.document_point + translation,
source: point.source,
target: SnapTarget::DistributeEvenly(target),
distribution_boxes_x,
distribution_equal_distance_x: is_x.then_some(distances.equal),
distribution_boxes_y,
distribution_equal_distance_y: (!is_x).then_some(distances.equal),
distribution_boxes_horizontal,
distribution_equal_distance_horizontal: is_x.then_some(distances.equal),
distribution_boxes_vertical,
distribution_equal_distance_vertical: (!is_x).then_some(distances.equal),
distance: (distances.first - distances.equal).abs(),
constrained: true,
source_bounds: Some(bounds.translate(translation).into()),

View file

@ -1,6 +1,7 @@
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::get_text;
use crate::messages::tool::tool_messages::path_tool::PathOverlayMode;
use glam::DVec2;
use graphene_core::renderer::Quad;
use graphene_core::text::{FontCache, load_face};
@ -67,7 +68,7 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH
Quad::from_box([DVec2::ZERO, far])
}
pub fn calculate_segment_angle(anchor: PointId, segment: SegmentId, vector_data: &VectorData, pen_tool: bool) -> Option<f64> {
pub fn calculate_segment_angle(anchor: PointId, segment: SegmentId, vector_data: &VectorData, prefer_handle_direction: bool) -> Option<f64> {
let is_start = |point: PointId, segment: SegmentId| vector_data.segment_start_from_id(segment) == Some(point);
let anchor_position = vector_data.point_domain.position_from_id(anchor)?;
let end_handle = ManipulatorPointId::EndHandle(segment).get_position(vector_data);
@ -81,15 +82,58 @@ pub fn calculate_segment_angle(anchor: PointId, segment: SegmentId, vector_data:
let required_handle = if is_start(anchor, segment) {
start_handle
.filter(|&handle| pen_tool && handle != anchor_position)
.filter(|&handle| prefer_handle_direction && handle != anchor_position)
.or(end_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
} else {
end_handle
.filter(|&handle| pen_tool && handle != anchor_position)
.filter(|&handle| prefer_handle_direction && handle != anchor_position)
.or(start_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
};
required_handle.map(|handle| -(handle - anchor_position).angle_to(DVec2::X))
}
/// Check whether a point is visible in the current overlay mode.
pub fn is_visible_point(
manipulator_point_id: ManipulatorPointId,
vector_data: &VectorData,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
selected_segments: Vec<SegmentId>,
selected_points: &HashSet<ManipulatorPointId>,
) -> bool {
match manipulator_point_id {
ManipulatorPointId::Anchor(_) => true,
ManipulatorPointId::EndHandle(segment_id) | ManipulatorPointId::PrimaryHandle(segment_id) => {
match (path_overlay_mode, selected_points.len() == 1) {
(PathOverlayMode::AllHandles, _) => true,
(PathOverlayMode::SelectedPointHandles, _) | (PathOverlayMode::FrontierHandles, true) => {
if selected_segments.contains(&segment_id) {
return true;
}
// Either the segment is a part of selected segments or the opposite handle is a part of existing selection
let Some(handle_pair) = manipulator_point_id.get_handle_pair(vector_data) else { return false };
let other_handle = handle_pair[1].to_manipulator_point();
// Return whether the list of selected points contain the other handle
selected_points.contains(&other_handle)
}
(PathOverlayMode::FrontierHandles, false) => {
let Some(anchor) = manipulator_point_id.get_anchor(vector_data) else {
warn!("No anchor for selected handle");
return false;
};
let Some(frontier_handles) = &frontier_handles_info else {
warn!("No frontier handles info provided");
return false;
};
frontier_handles.get(&segment_id).map(|anchors| anchors.contains(&anchor)).unwrap_or_default()
}
}
}
}
}

View file

@ -6,7 +6,7 @@ use crate::consts::{
};
use crate::messages::portfolio::document::overlays::utility_functions::{path_overlays, selected_segments};
use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, OverlayContext};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::portfolio::document::utility_types::transformation::Axis;
use crate::messages::preferences::SelectionMode;
@ -181,6 +181,7 @@ impl LayoutHolder for PathTool {
})
// TODO: Remove `unwrap_or_default` once checkboxes are capable of displaying a mixed state
.unwrap_or_default();
let mut checkbox_id = CheckboxId::default();
let colinear_handle_checkbox = CheckboxInput::new(colinear_handles_state)
.disabled(!self.tool_data.can_toggle_colinearity)
.on_update(|&CheckboxInput { checked, .. }| {
@ -191,10 +192,12 @@ impl LayoutHolder for PathTool {
}
})
.tooltip(colinear_handles_tooltip)
.for_label(checkbox_id.clone())
.widget_holder();
let colinear_handles_label = TextLabel::new("Colinear Handles")
.disabled(!self.tool_data.can_toggle_colinearity)
.tooltip(colinear_handles_tooltip)
.for_checkbox(&mut checkbox_id)
.widget_holder();
let path_overlay_mode_widget = RadioInput::new(vec![
@ -366,6 +369,8 @@ struct PathToolData {
select_anchor_toggled: bool,
saved_points_before_handle_drag: Vec<ManipulatorPointId>,
handle_drag_toggle: bool,
saved_points_before_anchor_convert_smooth_sharp: HashSet<ManipulatorPointId>,
last_click_time: u64,
dragging_state: DraggingState,
angle: f64,
opposite_handle_position: Option<DVec2>,
@ -375,6 +380,8 @@ struct PathToolData {
alt_dragging_from_anchor: bool,
angle_locked: bool,
temporary_colinear_handles: bool,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
adjacent_anchor_offset: Option<DVec2>,
}
impl PathToolData {
@ -387,13 +394,13 @@ impl PathToolData {
self.saved_points_before_anchor_select_toggle.clear();
}
pub fn selection_quad(&self) -> Quad {
let bbox = self.selection_box();
pub fn selection_quad(&self, metadata: &DocumentMetadata) -> Quad {
let bbox = self.selection_box(metadata);
Quad::from_box(bbox)
}
pub fn calculate_selection_mode_from_direction(&mut self) -> SelectionMode {
let bbox = self.selection_box();
pub fn calculate_selection_mode_from_direction(&mut self, metadata: &DocumentMetadata) -> SelectionMode {
let bbox = self.selection_box(metadata);
let above_threshold = bbox[1].distance_squared(bbox[0]) > DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD.powi(2);
if self.selection_mode.is_none() && above_threshold {
@ -409,12 +416,15 @@ impl PathToolData {
self.selection_mode.unwrap_or(SelectionMode::Touched)
}
pub fn selection_box(&self) -> [DVec2; 2] {
if self.previous_mouse_position == self.drag_start_pos {
pub fn selection_box(&self, metadata: &DocumentMetadata) -> [DVec2; 2] {
// Convert previous mouse position to viewport space first
let document_to_viewport = metadata.document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(self.previous_mouse_position);
if previous_mouse == self.drag_start_pos {
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
[self.drag_start_pos - tolerance, self.drag_start_pos + tolerance]
} else {
[self.drag_start_pos, self.previous_mouse_position]
[self.drag_start_pos, previous_mouse]
}
}
@ -443,16 +453,30 @@ impl PathToolData {
lasso_select: bool,
handle_drag_from_anchor: bool,
drag_zero_handle: bool,
path_overlay_mode: PathOverlayMode,
) -> PathToolFsmState {
self.double_click_handled = false;
self.opposing_handle_lengths = None;
self.drag_start_pos = input.mouse.position;
if !self.saved_points_before_anchor_convert_smooth_sharp.is_empty() && (input.time - self.last_click_time > 500) {
self.saved_points_before_anchor_convert_smooth_sharp.clear();
}
self.last_click_time = input.time;
let old_selection = shape_editor.selected_points().cloned().collect::<Vec<_>>();
// Check if the point is already selected; if not, select the first point within the threshold (in pixels)
if let Some((already_selected, mut selection_info)) = shape_editor.get_point_selection_state(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD) {
// Don't select the points which are not shown currently in PathOverlayMode
if let Some((already_selected, mut selection_info)) = shape_editor.get_point_selection_state(
&document.network_interface,
input.mouse.position,
SELECTION_THRESHOLD,
path_overlay_mode,
self.frontier_handles_info.clone(),
) {
responses.add(DocumentMessage::StartTransaction);
self.last_clicked_point_was_selected = already_selected;
@ -460,7 +484,14 @@ impl PathToolData {
// If the point is already selected and shift (`extend_selection`) is used, keep the selection unchanged.
// Otherwise, select the first point within the threshold.
if !(already_selected && extend_selection) {
if let Some(updated_selection_info) = shape_editor.change_point_selection(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD, extend_selection) {
if let Some(updated_selection_info) = shape_editor.change_point_selection(
&document.network_interface,
input.mouse.position,
SELECTION_THRESHOLD,
extend_selection,
path_overlay_mode,
self.frontier_handles_info.clone(),
) {
selection_info = updated_selection_info;
}
}
@ -490,7 +521,7 @@ impl PathToolData {
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
if handles.contains(handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
@ -507,7 +538,7 @@ impl PathToolData {
if let Some((Some(point), Some(vector_data))) = shape_editor
.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD)
.and_then(|(layer, point)| Some((point.as_anchor(), document.network_interface.compute_modified_vector(layer))))
.map(|(layer, point)| (point.as_anchor(), document.network_interface.compute_modified_vector(layer)))
{
let handles = vector_data
.all_connected(point)
@ -716,18 +747,39 @@ impl PathToolData {
) -> f64 {
let current_angle = -handle_vector.angle_to(DVec2::X);
if let Some(vector_data) = shape_editor
if let Some((vector_data, layer)) = shape_editor
.selected_shape_state
.iter()
.next()
.and_then(|(layer, _)| document.network_interface.compute_modified_vector(*layer))
.and_then(|(layer, _)| document.network_interface.compute_modified_vector(*layer).map(|vector_data| (vector_data, layer)))
{
let adjacent_anchor = check_handle_over_adjacent_anchor(handle_id, &vector_data);
let mut required_angle = None;
// If the handle is dragged over one of its adjacent anchors while holding down the Ctrl key, compute the angle based on the tangent formed with the neighboring anchor points.
if adjacent_anchor.is_some() && lock_angle && !self.angle_locked {
let anchor = handle_id.get_anchor(&vector_data);
let (angle, anchor_position) = calculate_adjacent_anchor_tangent(handle_id, anchor, adjacent_anchor, &vector_data);
let layer_to_document = document.metadata().transform_to_document(*layer);
self.adjacent_anchor_offset = handle_id
.get_anchor_position(&vector_data)
.and_then(|handle_anchor| anchor_position.map(|adjacent_anchor| layer_to_document.transform_point2(adjacent_anchor) - layer_to_document.transform_point2(handle_anchor)));
required_angle = angle;
}
// If the handle is dragged near its adjacent anchors while holding down the Ctrl key, compute the angle using the tangent direction of neighboring segments.
if relative_vector.length() < 25. && lock_angle && !self.angle_locked {
if let Some(angle) = calculate_lock_angle(self, shape_editor, responses, document, &vector_data, handle_id, tangent_to_neighboring_tangents) {
self.angle = angle;
self.angle_locked = true;
return angle;
}
required_angle = calculate_lock_angle(self, shape_editor, responses, document, &vector_data, handle_id, tangent_to_neighboring_tangents);
}
// Finalize and apply angle locking if a valid target angle was determined.
if let Some(angle) = required_angle {
self.angle = angle;
self.angle_locked = true;
return angle;
}
}
@ -875,27 +927,36 @@ impl PathToolData {
let current_mouse = input.mouse.position;
let raw_delta = document_to_viewport.inverse().transform_vector2(current_mouse - previous_mouse);
let snapped_delta = if let Some((handle_pos, anchor_pos, handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) {
let cursor_pos = handle_pos + raw_delta;
let snapped_delta = if let Some((handle_position, anchor_position, handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) {
let cursor_position = handle_position + raw_delta;
let handle_angle = self.calculate_handle_angle(
shape_editor,
document,
responses,
handle_pos - anchor_pos,
cursor_pos - anchor_pos,
handle_position - anchor_position,
cursor_position - anchor_position,
handle_id,
lock_angle,
snap_angle,
equidistant,
);
let adjacent_anchor_offset = self.adjacent_anchor_offset.unwrap_or(DVec2::ZERO);
let constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin());
let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction);
let constrained_target = anchor_pos + constrained_direction * projected_length;
let constrained_delta = constrained_target - handle_pos;
let projected_length = (cursor_position - anchor_position - adjacent_anchor_offset).dot(constrained_direction);
let constrained_target = anchor_position + adjacent_anchor_offset + constrained_direction * projected_length;
let constrained_delta = constrained_target - handle_position;
self.apply_snapping(constrained_direction, handle_pos + constrained_delta, anchor_pos, lock_angle || snap_angle, handle_pos, document, input)
self.apply_snapping(
constrained_direction,
handle_position + constrained_delta,
anchor_position + adjacent_anchor_offset,
lock_angle || snap_angle,
handle_position,
document,
input,
)
} else {
shape_editor.snap(&mut self.snap_manager, &self.snap_cache, document, input, previous_mouse)
};
@ -1010,14 +1071,16 @@ impl Fsm for PathToolFsmState {
match tool_options.path_overlay_mode {
PathOverlayMode::AllHandles => {
path_overlays(document, DrawHandles::All, shape_editor, &mut overlay_context);
tool_data.frontier_handles_info = None;
}
PathOverlayMode::SelectedPointHandles => {
let selected_segments = selected_segments(document, shape_editor);
let selected_segments = selected_segments(&document.network_interface, shape_editor);
path_overlays(document, DrawHandles::SelectedAnchors(selected_segments), shape_editor, &mut overlay_context);
tool_data.frontier_handles_info = None;
}
PathOverlayMode::FrontierHandles => {
let selected_segments = selected_segments(document, shape_editor);
let selected_segments = selected_segments(&document.network_interface, shape_editor);
let selected_points = shape_editor.selected_points();
let selected_anchors = selected_points
.filter_map(|point_id| if let ManipulatorPointId::Anchor(p) = point_id { Some(*p) } else { None })
@ -1045,13 +1108,18 @@ impl Fsm for PathToolFsmState {
for (point, attached_segments) in selected_segments_by_point {
if attached_segments.len() == 1 {
segment_endpoints.entry(attached_segments[0]).or_default().push(point);
} else if !selected_anchors.contains(&point) {
}
// Handle the edge case where a point, although not explicitly selected, is shared by two segments.
else if !selected_anchors.contains(&point) {
segment_endpoints.entry(attached_segments[0]).or_default().push(point);
segment_endpoints.entry(attached_segments[1]).or_default().push(point);
}
}
}
// Caching segment endpoints for use in point selection logic
tool_data.frontier_handles_info = Some(segment_endpoints.clone());
// Now frontier anchors can be sent for rendering overlays
path_overlays(document, DrawHandles::FrontierHandles(segment_endpoints), shape_editor, &mut overlay_context);
}
@ -1088,11 +1156,11 @@ impl Fsm for PathToolFsmState {
let fill_color = Some(fill_color.as_str());
let selection_mode = match tool_action_data.preferences.get_selection_mode() {
SelectionMode::Directional => tool_data.calculate_selection_mode_from_direction(),
SelectionMode::Directional => tool_data.calculate_selection_mode_from_direction(document.metadata()),
selection_mode => selection_mode,
};
let quad = tool_data.selection_quad();
let quad = tool_data.selection_quad(document.metadata());
let polygon = &tool_data.lasso_polygon;
match (selection_shape, selection_mode) {
@ -1153,7 +1221,17 @@ impl Fsm for PathToolFsmState {
tool_data.selection_mode = None;
tool_data.lasso_polygon.clear();
tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, lasso_select, handle_drag_from_anchor, drag_zero_handle)
tool_data.mouse_down(
shape_editor,
document,
input,
responses,
extend_selection,
lasso_select,
handle_drag_from_anchor,
drag_zero_handle,
tool_options.path_overlay_mode,
)
}
(
PathToolFsmState::Drawing { selection_shape },
@ -1166,7 +1244,7 @@ impl Fsm for PathToolFsmState {
delete_segment,
},
) => {
tool_data.previous_mouse_position = input.mouse.position;
tool_data.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position);
if selection_shape == SelectionShapeType::Lasso {
extend_lasso(&mut tool_data.lasso_polygon, input.mouse.position);
@ -1255,6 +1333,7 @@ impl Fsm for PathToolFsmState {
if !lock_angle_state {
tool_data.angle_locked = false;
tool_data.adjacent_anchor_offset = None;
}
if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, tool_action_data.shape_editor, tool_action_data.document, responses) {
@ -1297,9 +1376,23 @@ impl Fsm for PathToolFsmState {
(PathToolFsmState::Ready, PathToolMessage::PointerMove { delete_segment, .. }) => {
tool_data.delete_segment_pressed = input.keyboard.get(delete_segment as usize);
if !tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp.clear();
}
if tool_data.adjacent_anchor_offset.is_some() {
tool_data.adjacent_anchor_offset = None;
}
// If there is a point nearby, then remove the overlay
if shape_editor
.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD)
.find_nearest_visible_point_indices(
&document.network_interface,
input.mouse.position,
SELECTION_THRESHOLD,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
)
.is_some()
{
tool_data.segment = None;
@ -1385,15 +1478,29 @@ impl Fsm for PathToolFsmState {
SelectionChange::Clear
};
if tool_data.drag_start_pos == tool_data.previous_mouse_position {
let document_to_viewport = document.metadata().document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(tool_data.previous_mouse_position);
if tool_data.drag_start_pos == previous_mouse {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
} else {
match selection_shape {
SelectionShapeType::Box => {
let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position];
shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), selection_change);
let bbox = [tool_data.drag_start_pos, previous_mouse];
shape_editor.select_all_in_shape(
&document.network_interface,
SelectionShape::Box(bbox),
selection_change,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), selection_change),
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(
&document.network_interface,
SelectionShape::Lasso(&tool_data.lasso_polygon),
selection_change,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
),
}
}
@ -1431,15 +1538,29 @@ impl Fsm for PathToolFsmState {
SelectionChange::Clear
};
if tool_data.drag_start_pos == tool_data.previous_mouse_position {
let document_to_viewport = document.metadata().document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(tool_data.previous_mouse_position);
if tool_data.drag_start_pos == previous_mouse {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
} else {
match selection_shape {
SelectionShapeType::Box => {
let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position];
shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), select_kind);
let bbox = [tool_data.drag_start_pos, previous_mouse];
shape_editor.select_all_in_shape(
&document.network_interface,
SelectionShape::Box(bbox),
select_kind,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), select_kind),
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(
&document.network_interface,
SelectionShape::Lasso(&tool_data.lasso_polygon),
select_kind,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
),
}
}
responses.add(OverlaysMessage::Draw);
@ -1450,7 +1571,14 @@ impl Fsm for PathToolFsmState {
(_, PathToolMessage::DragStop { extend_selection, .. }) => {
let extend_selection = input.keyboard.get(extend_selection as usize);
let drag_occurred = tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD;
let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);
// TODO: Here we want only visible points to be considered
let nearest_point = shape_editor.find_nearest_visible_point_indices(
&document.network_interface,
input.mouse.position,
SELECTION_THRESHOLD,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
);
if let Some((layer, nearest_point)) = nearest_point {
if !drag_occurred && extend_selection {
@ -1491,6 +1619,10 @@ impl Fsm for PathToolFsmState {
if !drag_occurred && !extend_selection {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if clicked_selected {
if tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp = shape_editor.selected_points().copied().collect::<HashSet<_>>();
}
shape_editor.deselect_all_points();
shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point);
responses.add(OverlaysMessage::Draw);
@ -1538,7 +1670,11 @@ impl Fsm for PathToolFsmState {
// Flip the selected point between smooth and sharp
if !tool_data.double_click_handled && tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD {
responses.add(DocumentMessage::StartTransaction);
shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_convert_smooth_sharp.iter().copied().collect::<Vec<_>>());
shape_editor.flip_smooth_sharp(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE, responses);
tool_data.saved_points_before_anchor_convert_smooth_sharp.clear();
responses.add(DocumentMessage::EndTransaction);
responses.add(PathToolMessage::SelectedPointUpdated);
}
@ -1860,3 +1996,82 @@ fn calculate_lock_angle(
}
}
}
fn check_handle_over_adjacent_anchor(handle_id: ManipulatorPointId, vector_data: &VectorData) -> Option<PointId> {
let Some((anchor, handle_position)) = handle_id.get_anchor(&vector_data).zip(handle_id.get_position(vector_data)) else {
return None;
};
let check_if_close = |point_id: &PointId| {
let Some(anchor_position) = vector_data.point_domain.position_from_id(*point_id) else {
return false;
};
(anchor_position - handle_position).length() < 10.
};
vector_data.connected_points(anchor).find(|point| check_if_close(point))
}
fn calculate_adjacent_anchor_tangent(
currently_dragged_handle: ManipulatorPointId,
anchor: Option<PointId>,
adjacent_anchor: Option<PointId>,
vector_data: &VectorData,
) -> (Option<f64>, Option<DVec2>) {
// Early return if no anchor or no adjacent anchors
let Some((dragged_handle_anchor, adjacent_anchor)) = anchor.zip(adjacent_anchor) else {
return (None, None);
};
let adjacent_anchor_position = vector_data.point_domain.position_from_id(adjacent_anchor);
let handles: Vec<_> = vector_data.all_connected(adjacent_anchor).filter(|handle| handle.length(vector_data) > 1e-6).collect();
match handles.len() {
0 => {
// Find non-shared segments
let non_shared_segment: Vec<_> = vector_data
.segment_bezier_iter()
.filter_map(|(segment_id, _, start, end)| {
let touches_adjacent = start == adjacent_anchor || end == adjacent_anchor;
let shares_with_dragged = start == dragged_handle_anchor || end == dragged_handle_anchor;
if touches_adjacent && !shares_with_dragged { Some(segment_id) } else { None }
})
.collect();
match non_shared_segment.first() {
Some(&segment) => {
let angle = calculate_segment_angle(adjacent_anchor, segment, vector_data, true);
(angle, adjacent_anchor_position)
}
None => (None, None),
}
}
1 => {
let segment = handles[0].segment;
let angle = calculate_segment_angle(adjacent_anchor, segment, vector_data, true);
(angle, adjacent_anchor_position)
}
2 => {
// Use the angle formed by the handle of the shared segment relative to its associated anchor point.
let Some(shared_segment_handle) = handles
.iter()
.find(|handle| handle.opposite().to_manipulator_point() == currently_dragged_handle)
.map(|handle| handle.to_manipulator_point())
else {
return (None, None);
};
let angle = shared_segment_handle
.get_position(&vector_data)
.zip(adjacent_anchor_position)
.map(|(handle, anchor)| -(handle - anchor).angle_to(DVec2::X));
(angle, adjacent_anchor_position)
}
_ => (None, None),
}
}

View file

@ -642,13 +642,13 @@ impl Fsm for SelectToolFsmState {
}
}
if overlay_context.visibility_settings.transform_cage() {
if let Some(bounds) = bounds {
let bounding_box_manager = tool_data.bounding_box_manager.get_or_insert(BoundingBoxManager::default());
if let Some(bounds) = bounds {
let bounding_box_manager = tool_data.bounding_box_manager.get_or_insert(BoundingBoxManager::default());
bounding_box_manager.bounds = bounds;
bounding_box_manager.transform = transform;
bounding_box_manager.transform_tampered = transform_tampered;
bounding_box_manager.bounds = bounds;
bounding_box_manager.transform = transform;
bounding_box_manager.transform_tampered = transform_tampered;
if overlay_context.visibility_settings.transform_cage() {
bounding_box_manager.render_overlays(&mut overlay_context, true);
}
} else {
@ -682,7 +682,7 @@ impl Fsm for SelectToolFsmState {
let is_resizing_or_rotating = matches!(self, SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds { .. } | SelectToolFsmState::RotatingBounds);
if overlay_context.visibility_settings.transform_cage() && bounds.is_some() {
if overlay_context.visibility_settings.transform_cage() {
if let Some(bounds) = tool_data.bounding_box_manager.as_mut() {
let edges = bounds.check_selected_edges(input.mouse.position);
let is_skewing = matches!(self, SelectToolFsmState::SkewingBounds { .. });

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<path d="M4.5,4H10V3H4.5C3.673,3,3,3.673,3,4.5V7H1l2.5,3L6,7H4V4.5C4,4.224,4.224,4,4.5,4z" />
</svg>

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

@ -3,10 +3,11 @@
import type { Editor } from "@graphite/editor";
import type { HSV, RGB, FillChoice } from "@graphite/messages";
import type { MenuDirection } from "@graphite/messages";
import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages";
import { clamp } from "@graphite/utility-functions/math";
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";

View file

@ -3,10 +3,10 @@
<script lang="ts">
import { createEventDispatcher, tick, onDestroy, onMount } from "svelte";
import type { MenuListEntry } from "@graphite/messages";
import type { MenuListEntry, MenuDirection } from "@graphite/messages";
import MenuList from "@graphite/components/floating-menus/MenuList.svelte";
import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";

View file

@ -1,5 +1,4 @@
<script lang="ts" context="module">
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
/// Prevents the escape key from closing the parent floating menu of the given element.
@ -22,6 +21,7 @@
<script lang="ts">
import { onMount, afterUpdate, createEventDispatcher, tick } from "svelte";
import type { MenuDirection } from "@graphite/messages";
import { browserVersion } from "@graphite/utility-functions/platform";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
@ -184,6 +184,7 @@
const floatingMenuContentDiv = floatingMenuContent?.div?.();
if (!workspace || !self || !floatingMenuContainer || !floatingMenuContent || !floatingMenuContentDiv) return;
const viewportBounds = document.documentElement.getBoundingClientRect();
workspaceBounds = workspace.getBoundingClientRect();
floatingMenuBounds = self.getBoundingClientRect();
const floatingMenuContainerBounds = floatingMenuContainer.getBoundingClientRect();
@ -195,17 +196,17 @@
// Required to correctly position content when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
const tailOffset = type === "Popover" ? 10 : 0;
if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.top}px`;
if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + floatingMenuBounds.bottom}px`;
if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.left}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + floatingMenuBounds.right}px`;
if (direction === "Bottom") floatingMenuContentDiv.style.top = `${tailOffset + floatingMenuBounds.y}px`;
if (direction === "Top") floatingMenuContentDiv.style.bottom = `${tailOffset + (viewportBounds.height - floatingMenuBounds.y)}px`;
if (direction === "Right") floatingMenuContentDiv.style.left = `${tailOffset + floatingMenuBounds.x}px`;
if (direction === "Left") floatingMenuContentDiv.style.right = `${tailOffset + (viewportBounds.width - floatingMenuBounds.x)}px`;
// Required to correctly position tail when scrolled (it has a `position: fixed` to prevent clipping)
// We use `.style` on a div (instead of a style DOM attribute binding) because the binding causes the `afterUpdate()` hook to call the function we're in recursively forever
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.top}px`;
if (tail && direction === "Top") tail.style.bottom = `${floatingMenuBounds.bottom}px`;
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.left}px`;
if (tail && direction === "Left") tail.style.right = `${floatingMenuBounds.right}px`;
if (tail && direction === "Bottom") tail.style.top = `${floatingMenuBounds.y}px`;
if (tail && direction === "Top") tail.style.bottom = `${viewportBounds.height - floatingMenuBounds.y}px`;
if (tail && direction === "Right") tail.style.left = `${floatingMenuBounds.x}px`;
if (tail && direction === "Left") tail.style.right = `${viewportBounds.width - floatingMenuBounds.x}px`;
}
type Edge = "Top" | "Bottom" | "Left" | "Right";

View file

@ -3,7 +3,15 @@
import type { Editor } from "@graphite/editor";
import { beginDraggingElement } from "@graphite/io-managers/drag";
import { defaultWidgetLayout, patchWidgetLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerStructureJs, UpdateLayersPanelControlBarLayout } from "@graphite/messages";
import {
defaultWidgetLayout,
patchWidgetLayout,
UpdateDocumentLayerDetails,
UpdateDocumentLayerStructureJs,
UpdateLayersPanelControlBarLeftLayout,
UpdateLayersPanelControlBarRightLayout,
UpdateLayersPanelBottomBarLayout,
} from "@graphite/messages";
import type { DataBuffer, LayerPanelEntry } from "@graphite/messages";
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
import { platformIsMac } from "@graphite/utility-functions/platform";
@ -13,6 +21,7 @@
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
type LayerListingInfo = {
@ -47,12 +56,24 @@
let dragInPanel = false;
// Layouts
let layersPanelControlBarLayout = defaultWidgetLayout();
let layersPanelControlBarLeftLayout = defaultWidgetLayout();
let layersPanelControlBarRightLayout = defaultWidgetLayout();
let layersPanelBottomBarLayout = defaultWidgetLayout();
onMount(() => {
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarLayout, (updateLayersPanelControlBarLayout) => {
patchWidgetLayout(layersPanelControlBarLayout, updateLayersPanelControlBarLayout);
layersPanelControlBarLayout = layersPanelControlBarLayout;
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarLeftLayout, (updateLayersPanelControlBarLeftLayout) => {
patchWidgetLayout(layersPanelControlBarLeftLayout, updateLayersPanelControlBarLeftLayout);
layersPanelControlBarLeftLayout = layersPanelControlBarLeftLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelControlBarRightLayout, (updateLayersPanelControlBarRightLayout) => {
patchWidgetLayout(layersPanelControlBarRightLayout, updateLayersPanelControlBarRightLayout);
layersPanelControlBarRightLayout = layersPanelControlBarRightLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateLayersPanelBottomBarLayout, (updateLayersPanelBottomBarLayout) => {
patchWidgetLayout(layersPanelBottomBarLayout, updateLayersPanelBottomBarLayout);
layersPanelBottomBarLayout = layersPanelBottomBarLayout;
});
editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerStructureJs, (updateDocumentLayerStructure) => {
@ -407,7 +428,9 @@
<LayoutCol class="layers" on:dragleave={() => (dragInPanel = false)}>
<LayoutRow class="control-bar" scrollableX={true}>
<WidgetLayout layout={layersPanelControlBarLayout} />
<WidgetLayout layout={layersPanelControlBarLeftLayout} />
<Separator />
<WidgetLayout layout={layersPanelControlBarRightLayout} />
</LayoutRow>
<LayoutRow class="list-area" scrollableY={true}>
<LayoutCol class="list" data-layer-panel bind:this={list} on:click={() => deselectAllLayers()} on:dragover={updateInsertLine} on:dragend={drop} on:drop={drop}>
@ -490,6 +513,9 @@
<div class="insert-mark" style:left={`${4 + draggingData.insertDepth * 16}px`} style:top={`${draggingData.markerHeight}px`} />
{/if}
</LayoutRow>
<LayoutRow class="bottom-bar" scrollableX={true}>
<WidgetLayout layout={layersPanelBottomBarLayout} />
</LayoutRow>
</LayoutCol>
<style lang="scss" global>
@ -499,40 +525,34 @@
height: 32px;
flex: 0 0 auto;
margin: 0 4px;
border-bottom: 1px solid var(--color-2-mildblack);
justify-content: space-between;
.widget-span {
width: 100%;
height: 100%;
min-width: 300px;
}
// Blend mode selector and opacity slider
.dropdown-input,
.number-input {
.widget-span:first-child {
flex: 1 1 auto;
}
}
// Blend mode selector
.dropdown-input {
max-width: 120px;
flex-basis: 120px;
}
// Bottom bar
.bottom-bar {
height: 24px;
padding-top: 4px;
flex: 0 0 auto;
margin: 0 4px;
justify-content: flex-end;
border-top: 1px solid var(--color-2-mildblack);
// Opacity slider
.number-input {
max-width: 180px;
flex-basis: 180px;
+ .separator ~ .separator {
flex-grow: 1;
}
.widget-span > * {
margin: 0;
}
}
// Layer hierarchy
.list-area {
margin: 4px 0;
position: relative;
margin-top: 4px;
// Combine with the bottom bar to avoid a double border
margin-bottom: -1px;
.layer {
flex: 0 0 auto;

View file

@ -42,7 +42,7 @@
width: calc(100% + 2 * 4px);
margin-top: 8px;
span {
.text-label {
white-space: wrap;
}
}

View file

@ -994,7 +994,7 @@
{/if}
<div class="details">
<!-- TODO: Allow the user to edit the name, just like in the Layers panel -->
<span>{node.displayName}</span>
<TextLabel>{node.displayName}</TextLabel>
</div>
<div class="solo-drag-grip" title="Drag only this layer without pushing others outside the stack"></div>
<IconButton
@ -1585,7 +1585,7 @@
.details {
margin: 0 8px;
span {
.text-label {
white-space: nowrap;
line-height: 48px;
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { isWidgetSpanColumn, isWidgetSpanRow, isWidgetSection, type WidgetLayout, isWidgetTable } from "@graphite/messages";
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
import WidgetSection from "@graphite/components/widgets/WidgetSection.svelte";
import WidgetSpan from "@graphite/components/widgets/WidgetSpan.svelte";
import WidgetTable from "@graphite/components/widgets/WidgetTable.svelte";
@ -19,7 +20,7 @@
{:else if isWidgetTable(layoutGroup)}
<WidgetTable widgetData={layoutGroup} layoutTarget={layout.layoutTarget} />
{:else}
<span style="color: #d6536e">Error: The widget layout that belongs here has an invalid layout group type</span>
<TextLabel styles={{ color: "#d6536e" }}>Error: The widget layout that belongs here has an invalid layout group type</TextLabel>
{/if}
{/each}

View file

@ -65,11 +65,11 @@
{#if isWidgetSpanRow(layoutGroup)}
<WidgetSpan widgetData={layoutGroup} {layoutTarget} />
{:else if isWidgetSpanColumn(layoutGroup)}
<span style="color: #d6536e">Error: The WidgetSpan used here should be a row not a column</span>
<TextLabel styles={{ color: "#d6536e" }}>Error: The WidgetSpan used here should be a row not a column</TextLabel>
{:else if isWidgetSection(layoutGroup)}
<svelte:self widgetData={layoutGroup} {layoutTarget} />
{:else}
<span style="color: #d6536e">Error: The widget that belongs here has an invalid layout group type</span>
<TextLabel styles={{ color: "#d6536e" }}>Error: The widget that belongs here has an invalid layout group type</TextLabel>
{/if}
{/each}
</LayoutCol>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type { MenuDirection } from "@graphite/messages";
import { type IconName, type PopoverButtonStyle } from "@graphite/utility-functions/icons";
import FloatingMenu from "@graphite/components/layout/FloatingMenu.svelte";
@ -7,6 +8,7 @@
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
export let style: PopoverButtonStyle = "DropdownArrow";
export let menuDirection: MenuDirection = "Bottom";
export let icon: IconName | undefined = undefined;
export let tooltip: string | undefined = undefined;
export let disabled = false;
@ -23,13 +25,13 @@
}
</script>
<LayoutRow class="popover-button" classes={{ "has-icon": icon !== undefined }}>
<LayoutRow class="popover-button" classes={{ "has-icon": icon !== undefined, "direction-top": menuDirection === "Top" }}>
<IconButton class="dropdown-icon" classes={{ open }} {disabled} action={() => onClick()} icon={style || "DropdownArrow"} size={16} {tooltip} data-floating-menu-spawner />
{#if icon !== undefined}
<IconLabel class="descriptive-icon" classes={{ open }} {disabled} {icon} {tooltip} />
{/if}
<FloatingMenu {open} on:open={({ detail }) => (open = detail)} minWidth={popoverMinWidth} type="Popover" direction="Bottom">
<FloatingMenu {open} on:open={({ detail }) => (open = detail)} minWidth={popoverMinWidth} type="Popover" direction={menuDirection || "Bottom"}>
<slot />
</FloatingMenu>
</LayoutRow>
@ -48,6 +50,10 @@
padding-left: calc(36px - 16px);
box-sizing: content-box;
}
&.direction-top .dropdown-icon .icon-label {
transform: rotate(180deg);
}
}
.dropdown-icon {
@ -86,5 +92,9 @@
margin-bottom: -8px;
}
}
&.direction-top .floating-menu {
bottom: 100%;
}
}
</style>

View file

@ -55,7 +55,7 @@
class:emphasized
class:disabled
class:flush
style:min-width={minWidth > 0 ? `${minWidth}px` : ""}
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
title={tooltip}
data-emphasized={emphasized || undefined}
data-disabled={disabled || undefined}

View file

@ -12,11 +12,13 @@
export let disabled = false;
export let icon: IconName = "Checkmark";
export let tooltip: string | undefined = undefined;
export let forLabel: bigint | undefined = undefined;
let inputElement: HTMLInputElement | undefined;
let id = String(Math.random()).substring(2);
const backupId = String(Math.random()).substring(2);
$: id = forLabel !== undefined ? String(forLabel) : backupId;
$: displayIcon = (!checked && icon === "Checkmark" ? "Empty12px" : icon) as IconName;
export function isChecked() {

View file

@ -21,12 +21,13 @@
export let interactive = true;
export let disabled = false;
export let tooltip: string | undefined = undefined;
export let minWidth = 0;
export let maxWidth = 0;
let activeEntry = makeActiveEntry();
let activeEntrySkipWatcher = false;
let initialSelectedIndex: number | undefined = undefined;
let open = false;
let minWidth = 0;
$: watchSelectedIndex(selectedIndex);
$: watchActiveEntry(activeEntry);
@ -76,11 +77,15 @@
}
</script>
<LayoutRow class="dropdown-input" bind:this={self} data-dropdown-input>
<LayoutRow
class="dropdown-input"
styles={{ ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}), ...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}) }}
bind:this={self}
data-dropdown-input
>
<LayoutRow
class="dropdown-box"
classes={{ disabled, open }}
styles={{ "min-width": `${minWidth}px` }}
{tooltip}
on:click={() => !disabled && (open = true)}
on:blur={unFocusDropdownBox}
@ -150,7 +155,7 @@
&.disabled {
background: var(--color-2-mildblack);
span {
.text-label {
color: var(--color-8-uppergray);
}

View file

@ -104,7 +104,15 @@
<!-- TODO: Combine this widget into the DropdownInput widget -->
<LayoutRow class="font-input">
<LayoutRow class="dropdown-box" classes={{ disabled }} styles={{ "min-width": `${minWidth}px` }} {tooltip} tabindex={disabled ? -1 : 0} on:click={toggleOpen} data-floating-menu-spawner>
<LayoutRow
class="dropdown-box"
classes={{ disabled }}
styles={{ ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}) }}
{tooltip}
tabindex={disabled ? -1 : 0}
on:click={toggleOpen}
data-floating-menu-spawner
>
<TextLabel class="dropdown-label">{activeEntry?.value || ""}</TextLabel>
<IconLabel class="dropdown-arrow" icon="DropdownArrow" />
</LayoutRow>
@ -148,7 +156,7 @@
&.open {
background: var(--color-6-lowergray);
span {
.text-label {
color: var(--color-f-white);
}
}
@ -160,7 +168,7 @@
&.disabled {
background: var(--color-2-mildblack);
span {
.text-label {
color: var(--color-8-uppergray);
}
}

View file

@ -53,6 +53,7 @@
// Styling
export let minWidth = 0;
export let maxWidth = 0;
// Callbacks
export let incrementCallbackIncrease: (() => void) | undefined = undefined;
@ -88,6 +89,7 @@
$: sliderStepValue = isInteger ? (step === undefined ? 1 : step) : "any";
$: styles = {
...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}),
...(maxWidth > 0 ? { "max-width": `${maxWidth}px` } : {}),
...(mode === "Range" ? { "--progress-factor": Math.min(Math.max((rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin), 0), 1) } : {}),
};

View file

@ -24,14 +24,14 @@
}
</script>
<LayoutRow class="radio-input" classes={{ disabled }} styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : "" }}>
<LayoutRow class="radio-input" classes={{ disabled, mixed }} styles={{ ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}) }}>
{#each entries as entry, index}
<button class:active={index === selectedIndex} class:mixed class:disabled on:click={() => handleEntryClick(entry)} title={entry.tooltip} tabindex={index === selectedIndex ? -1 : 0} {disabled}>
<button class:active={!mixed ? index === selectedIndex : undefined} on:click={() => handleEntryClick(entry)} title={entry.tooltip} tabindex={index === selectedIndex ? -1 : 0} {disabled}>
{#if entry.icon}
<IconLabel icon={entry.icon} />
{/if}
{#if entry.label}
<TextLabel italic={mixed}>{entry.label}</TextLabel>
<TextLabel>{entry.label}</TextLabel>
{/if}
</button>
{/each}
@ -39,12 +39,17 @@
<style lang="scss" global>
.radio-input {
background: var(--color-5-dullgray);
border-radius: 2px;
height: 24px;
button {
background: var(--color-5-dullgray);
fill: var(--color-e-nearwhite);
height: 24px;
margin: 0;
border-radius: 2px;
height: 20px;
padding: 0;
margin: 2px 1px;
border: none;
display: flex;
align-items: center;
@ -54,8 +59,12 @@
min-width: fit-content;
flex: 1 1 0;
&.mixed {
background: var(--color-4-dimgray);
&:first-of-type {
margin-left: 2px;
}
&:last-of-type {
margin-right: 2px;
}
&:hover {
@ -76,7 +85,34 @@
}
}
&.disabled {
.icon-label {
margin: 2px;
+ .text-label {
margin-left: 0;
}
}
.text-label {
margin: 0 8px;
overflow: hidden;
flex: 0 0 auto;
}
}
&.mixed {
background: var(--color-4-dimgray);
button:not(:hover),
&.disabled button:hover {
background: var(--color-5-dullgray);
}
}
&.disabled {
background: var(--color-4-dimgray);
button {
background: var(--color-4-dimgray);
color: var(--color-8-uppergray);
@ -93,37 +129,6 @@
}
}
}
& + button {
margin-left: 1px;
}
&:first-of-type {
border-radius: 2px 0 0 2px;
}
&:last-of-type {
border-radius: 0 2px 2px 0;
}
.icon-label {
margin: 0 4px;
+ .text-label {
margin-left: 0;
}
}
.text-label {
margin: 0 8px;
overflow: hidden;
flex: 0 0 auto;
}
}
&.combined-before button:first-of-type,
&.combined-after button:last-of-type {
border-radius: 0;
}
}
</style>

View file

@ -63,7 +63,7 @@
<FieldInput
class={`text-input ${className}`.trim()}
classes={{ centered, ...classes }}
styles={{ "min-width": minWidth > 0 ? `${minWidth}px` : undefined }}
styles={{ ...(minWidth > 0 ? { "min-width": `${minWidth}px` } : {}) }}
{value}
on:value
on:textFocused={onTextFocused}

View file

@ -13,6 +13,7 @@
export let minWidth = 0;
export let multiline = false;
export let tooltip: string | undefined = undefined;
export let checkboxId: bigint | undefined = undefined;
$: extraClasses = Object.entries(classes)
.flatMap(([className, stateName]) => (stateName ? [className] : []))
@ -22,7 +23,7 @@
.join(" ");
</script>
<span
<label
class={`text-label ${className} ${extraClasses}`.trim()}
class:disabled
class:bold
@ -30,12 +31,13 @@
class:multiline
class:center-align={centerAlign}
class:table-align={tableAlign}
style:min-width={minWidth > 0 ? `${minWidth}px` : ""}
style:min-width={minWidth > 0 ? `${minWidth}px` : undefined}
style={`${styleName} ${extraStyles}`.trim() || undefined}
title={tooltip}
for={checkboxId !== undefined ? `checkbox-input-${checkboxId}` : undefined}
>
<slot />
</span>
</label>
<style lang="scss" global>
.text-label {

View file

@ -248,7 +248,7 @@
}
}
span {
.text-label {
flex: 1 1 100%;
overflow-x: hidden;
white-space: nowrap;

View file

@ -212,7 +212,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
if (textToolInteractiveInputElement) return;
// Allow only double-clicks
if (e.detail !== 2) return;
if (e.detail % 2 == 1) return;
// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
let buttons = 1;

View file

@ -960,6 +960,8 @@ export class CheckboxInput extends WidgetProps {
@Transform(({ value }: { value: string }) => value || undefined)
tooltip!: string | undefined;
forLabel!: bigint | undefined;
}
export class ColorInput extends WidgetProps {
@ -1085,6 +1087,12 @@ export class DropdownInput extends WidgetProps {
@Transform(({ value }: { value: string }) => value || undefined)
tooltip!: string | undefined;
// Styling
minWidth!: number;
maxWidth!: number;
}
export class FontInput extends WidgetProps {
@ -1185,6 +1193,8 @@ export class NumberInput extends WidgetProps {
// Styling
minWidth!: number;
maxWidth!: number;
}
export class NodeCatalog extends WidgetProps {
@ -1194,6 +1204,8 @@ export class NodeCatalog extends WidgetProps {
export class PopoverButton extends WidgetProps {
style!: PopoverButtonStyle | undefined;
menuDirection!: MenuDirection | undefined;
icon!: IconName | undefined;
disabled!: boolean;
@ -1207,6 +1219,8 @@ export class PopoverButton extends WidgetProps {
popoverMinWidth: number | undefined;
}
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type RadioEntryData = {
value?: string;
label?: string;
@ -1348,6 +1362,8 @@ export class TextLabel extends WidgetProps {
@Transform(({ value }: { value: string }) => value || undefined)
tooltip!: string | undefined;
checkboxId!: bigint | undefined;
}
export type ReferencePoint = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight";
@ -1581,7 +1597,11 @@ export class UpdateDocumentBarLayout extends WidgetDiffUpdate {}
export class UpdateDocumentModeLayout extends WidgetDiffUpdate {}
export class UpdateLayersPanelControlBarLayout extends WidgetDiffUpdate {}
export class UpdateLayersPanelControlBarLeftLayout extends WidgetDiffUpdate {}
export class UpdateLayersPanelControlBarRightLayout extends WidgetDiffUpdate {}
export class UpdateLayersPanelBottomBarLayout extends WidgetDiffUpdate {}
// Extends JsMessage instead of WidgetDiffUpdate because the menu bar isn't diffed
export class UpdateMenuBarLayout extends JsMessage {
@ -1680,7 +1700,9 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateImportsExports,
UpdateInputHints,
UpdateInSelectedNetwork,
UpdateLayersPanelControlBarLayout,
UpdateLayersPanelControlBarLeftLayout,
UpdateLayersPanelControlBarRightLayout,
UpdateLayersPanelBottomBarLayout,
UpdateLayerWidths,
UpdateMenuBarLayout,
UpdateMouseCursor,

View file

@ -10,6 +10,7 @@ const GRAPHICS = {
// 12px Solid
import Add from "@graphite-frontend/assets/icon-12px-solid/add.svg";
import Checkmark from "@graphite-frontend/assets/icon-12px-solid/checkmark.svg";
import Clipped from "@graphite-frontend/assets/icon-12px-solid/clipped.svg";
import CloseX from "@graphite-frontend/assets/icon-12px-solid/close-x.svg";
import Delay from "@graphite-frontend/assets/icon-12px-solid/delay.svg";
import DropdownArrow from "@graphite-frontend/assets/icon-12px-solid/dropdown-arrow.svg";
@ -51,6 +52,7 @@ import WorkingColors from "@graphite-frontend/assets/icon-12px-solid/working-col
const SOLID_12PX = {
Add: { svg: Add, size: 12 },
Checkmark: { svg: Checkmark, size: 12 },
Clipped: { svg: Clipped, size: 12 },
CloseX: { svg: CloseX, size: 12 },
Delay: { svg: Delay, size: 12 },
DropdownArrow: { svg: DropdownArrow, size: 12 },

View file

@ -67,7 +67,7 @@ impl Rect {
Self::from_box([self[0] - delta, self[1] + delta])
}
/// Expand a rect by a certain amount on top/bottom and on left/right
/// Checks if two rects intersect
#[must_use]
pub fn intersects(&self, other: Self) -> bool {
let [mina, maxa] = [self[0].min(self[1]), self[0].max(self[1])];

View file

@ -1,7 +1,7 @@
use super::poisson_disk::poisson_disk_sample;
use crate::vector::misc::dvec2_to_point;
use glam::DVec2;
use kurbo::{Affine, BezPath, Line, ParamCurve, ParamCurveDeriv, PathSeg, Point, Rect, Shape};
use kurbo::{BezPath, Line, ParamCurve, ParamCurveDeriv, PathSeg, Point, Rect, Shape};
/// Accuracy to find the position on [kurbo::Bezpath].
const POSITION_ACCURACY: f64 = 1e-5;
@ -198,77 +198,46 @@ fn bezpath_t_value_to_parametric(bezpath: &kurbo::BezPath, t: BezPathTValue, pre
///
/// While the conceptual process described above asymptotically slows down and is never guaranteed to produce a maximal set in finite time,
/// this is implemented with an algorithm that produces a maximal set in O(n) time. The slowest part is actually checking if points are inside the subpath shape.
pub fn poisson_disk_points(bezpath: &BezPath, separation_disk_diameter: f64, rng: impl FnMut() -> f64, subpaths: &[(BezPath, Rect)], subpath_index: usize) -> Vec<DVec2> {
if bezpath.elements().is_empty() {
pub fn poisson_disk_points(bezpath_index: usize, bezpaths: &[(BezPath, Rect)], separation_disk_diameter: f64, rng: impl FnMut() -> f64) -> Vec<DVec2> {
let (this_bezpath, this_bbox) = bezpaths[bezpath_index].clone();
if this_bezpath.elements().is_empty() {
return Vec::new();
}
let bbox = bezpath.bounding_box();
let (offset_x, offset_y) = (bbox.x0, bbox.y0);
let (width, height) = (bbox.x1 - bbox.x0, bbox.y1 - bbox.y0);
// TODO: Optimize the following code and make it more robust
let mut shape = bezpath.clone();
shape.close_path();
shape.apply_affine(Affine::translate((-offset_x, -offset_y)));
let point_in_shape_checker = |point: DVec2| {
// Check against all paths the point is contained in to compute the correct winding number
let mut number = 0;
for (i, (shape, bbox)) in subpaths.iter().enumerate() {
let point = point + DVec2::new(bbox.x0, bbox.y0);
for (i, (shape, bbox)) in bezpaths.iter().enumerate() {
if bbox.x0 > point.x || bbox.y0 > point.y || bbox.x1 < point.x || bbox.y1 < point.y {
continue;
}
let winding = shape.winding(dvec2_to_point(point));
if i == subpath_index && winding == 0 {
let winding = shape.winding(dvec2_to_point(point));
if winding == 0 && i == bezpath_index {
return false;
}
number += winding;
}
// Non-zero fill rule
number != 0
};
let square_edges_intersect_shape_checker = |position: DVec2, size: f64| {
let rect = Rect::new(position.x, position.y, position.x + size, position.y + size);
bezpath_rectangle_intersections_exist(bezpath, rect)
};
let mut points = poisson_disk_sample(width, height, separation_disk_diameter, point_in_shape_checker, square_edges_intersect_shape_checker, rng);
for point in &mut points {
point.x += offset_x;
point.y += offset_y;
}
points
}
fn bezpath_rectangle_intersections_exist(bezpath: &BezPath, rect: Rect) -> bool {
if !bezpath.bounding_box().overlaps(rect) {
return false;
}
// Top left
let p1 = Point::new(rect.x0, rect.y0);
// Top right
let p2 = Point::new(rect.x1, rect.y0);
// Bottom right
let p3 = Point::new(rect.x1, rect.y1);
// Bottom left
let p4 = Point::new(rect.x0, rect.y1);
let top_line = Line::new((p1.x, p1.y), (p2.x, p2.y));
let right_line = Line::new((p2.x, p2.y), (p3.x, p3.y));
let bottom_line = Line::new((p3.x, p3.y), (p4.x, p4.y));
let left_line = Line::new((p4.x, p4.y), (p1.x, p1.y));
for segment in bezpath.segments() {
for line in [top_line, right_line, bottom_line, left_line] {
if !segment.intersect_line(line).is_empty() {
let line_intersect_shape_checker = |p0: (f64, f64), p1: (f64, f64)| {
for segment in this_bezpath.segments() {
if !segment.intersect_line(Line::new(p0, p1)).is_empty() {
return true;
}
}
}
false
false
};
let offset = DVec2::new(this_bbox.x0, this_bbox.y0);
let width = this_bbox.width();
let height = this_bbox.height();
poisson_disk_sample(offset, width, height, separation_disk_diameter, point_in_shape_checker, line_intersect_shape_checker, rng)
}

View file

@ -1,5 +1,6 @@
use core::f64;
use glam::DVec2;
use std::collections::HashMap;
const DEEPEST_SUBDIVISION_LEVEL_BEFORE_DISCARDING: usize = 8;
@ -8,11 +9,12 @@ const DEEPEST_SUBDIVISION_LEVEL_BEFORE_DISCARDING: usize = 8;
/// "Poisson Disk Point Sets by Hierarchical Dart Throwing"
/// <https://scholarsarchive.byu.edu/facpub/237/>
pub fn poisson_disk_sample(
offset: DVec2,
width: f64,
height: f64,
diameter: f64,
point_in_shape_checker: impl Fn(DVec2) -> bool,
square_edges_intersect_shape_checker: impl Fn(DVec2, f64) -> bool,
line_intersect_shape_checker: impl Fn((f64, f64), (f64, f64)) -> bool,
rng: impl FnMut() -> f64,
) -> Vec<DVec2> {
let mut rng = rng;
@ -28,7 +30,7 @@ pub fn poisson_disk_sample(
let base_level_grid_size = greater_dimension / (greater_dimension * std::f64::consts::SQRT_2 / (diameter / 2.)).ceil();
// Initialize the problem by including all base-level squares in the active list since they're all part of the yet-to-be-targetted dartboard domain
let base_level = ActiveListLevel::new_filled(base_level_grid_size, width, height, &point_in_shape_checker, &square_edges_intersect_shape_checker);
let base_level = ActiveListLevel::new_filled(base_level_grid_size, offset, width, height, &point_in_shape_checker, &line_intersect_shape_checker);
// In the future, if necessary, this could be turned into a fixed-length array with worst-case length `f64::MANTISSA_DIGITS`
let mut active_list_levels = vec![base_level];
@ -60,7 +62,7 @@ pub fn poisson_disk_sample(
// If the dart hit a valid spot, save that point (we're now permanently done with this target square's region)
if point_not_covered_by_poisson_points(point, diameter_squared, &points_grid) {
// Silently reject the point if it lies outside the shape
if active_square.fully_in_shape() || point_in_shape_checker(point) {
if active_square.fully_in_shape() || point_in_shape_checker(point + offset) {
points_grid.insert(point);
}
}
@ -105,10 +107,21 @@ pub fn poisson_disk_sample(
// Intersecting the shape's border
else {
// The sub-square is fully inside the shape if its top-left corner is inside and its edges don't intersect the shape border
let sub_square_fully_inside_shape =
!square_edges_intersect_shape_checker(sub_square, subdivided_size) && point_in_shape_checker(sub_square) && point_in_shape_checker(sub_square + subdivided_size);
// if !square_edges_intersect_shape_checker(sub_square, subdivided_size) { assert_eq!(point_in_shape_checker(sub_square), point_in_shape_checker(sub_square + subdivided_size)); }
// Sometimes this fails so it is necessary to also check the bottom right corner.
let point_with_offset = sub_square + offset;
let square_edges_intersect_shape = {
let min = point_with_offset;
let max = min + DVec2::splat(subdivided_size);
// Top edge line
line_intersect_shape_checker((min.x, min.y), (max.x, min.y)) ||
// Right edge line
line_intersect_shape_checker((max.x, min.y), (max.x, max.y)) ||
// Bottom edge line
line_intersect_shape_checker((max.x, max.y), (min.x, max.y)) ||
// Left edge line
line_intersect_shape_checker((min.x, max.y), (min.x, min.y))
};
let sub_square_fully_inside_shape = !square_edges_intersect_shape && point_in_shape_checker(point_with_offset) && point_in_shape_checker(point_with_offset + subdivided_size);
Some(ActiveSquare::new(sub_square, sub_square_fully_inside_shape))
}
@ -117,7 +130,7 @@ pub fn poisson_disk_sample(
}
}
points_grid.final_points()
points_grid.final_points(offset)
}
/// Randomly pick a square in the dartboard domain, with probability proportional to its area.
@ -209,24 +222,64 @@ impl ActiveListLevel {
}
}
#[inline(always)]
pub fn new_filled(square_size: f64, width: f64, height: f64, point_in_shape_checker: impl Fn(DVec2) -> bool, square_edges_intersect_shape_checker: impl Fn(DVec2, f64) -> bool) -> Self {
pub fn new_filled(
square_size: f64,
offset: DVec2,
width: f64,
height: f64,
point_in_shape_checker: impl Fn(DVec2) -> bool,
line_intersect_shape_checker: impl Fn((f64, f64), (f64, f64)) -> bool,
) -> Self {
// These should divide evenly but rounding is to protect against small numerical imprecision errors
let x_squares = (width / square_size).round() as usize;
let y_squares = (height / square_size).round() as usize;
// Hashes based on the grid cell coordinates and direction of the line: (x, y, is_vertical)
let mut line_intersection_cache: HashMap<(usize, usize, bool), bool> = HashMap::new();
// Populate each square with its top-left corner coordinate
let active_squares: Vec<_> = cartesian_product(0..x_squares, 0..y_squares)
.filter_map(|(x, y)| {
let corner = (x as f64 * square_size, y as f64 * square_size).into();
let corner = DVec2::new(x as f64 * square_size, y as f64 * square_size);
let corner_with_offset = corner + offset;
let point_in_shape = point_in_shape_checker(corner);
let square_edges_intersect_shape = square_edges_intersect_shape_checker(corner, square_size);
let square_not_outside_shape = point_in_shape || square_edges_intersect_shape;
let square_in_shape = point_in_shape_checker(corner + square_size) && !square_edges_intersect_shape;
// if !square_edges_intersect_shape { assert_eq!(point_in_shape_checker(corner), point_in_shape_checker(corner + square_size)); }
// Sometimes this fails so it is necessary to also check the bottom right corner.
square_not_outside_shape.then_some(ActiveSquare::new(corner, square_in_shape))
// Lazily check (and cache) if the square's edges intersect the shape, which is an expensive operation
let mut square_edges_intersect_shape_value = None;
let mut square_edges_intersect_shape = || {
square_edges_intersect_shape_value.unwrap_or_else(|| {
let square_edges_intersect_shape = {
let min = corner_with_offset;
let max = min + DVec2::splat(square_size);
// Top edge line
*line_intersection_cache.entry((x, y, false)).or_insert_with(|| line_intersect_shape_checker((min.x, min.y), (max.x, min.y))) ||
// Right edge line
*line_intersection_cache.entry((x + 1, y, true)).or_insert_with(|| line_intersect_shape_checker((max.x, min.y), (max.x, max.y))) ||
// Bottom edge line
*line_intersection_cache.entry((x, y + 1, false)).or_insert_with(|| line_intersect_shape_checker((max.x, max.y), (min.x, max.y))) ||
// Left edge line
*line_intersection_cache.entry((x, y, true)).or_insert_with(|| line_intersect_shape_checker((min.x, max.y), (min.x, min.y)))
};
square_edges_intersect_shape_value = Some(square_edges_intersect_shape);
square_edges_intersect_shape
})
};
// Check if this cell's top-left corner is inside the shape
let point_in_shape = point_in_shape_checker(corner_with_offset);
// Determine if the square is inside the shape
let square_not_outside_shape = point_in_shape || square_edges_intersect_shape();
if square_not_outside_shape {
// Check if this cell's bottom-right corner is inside the shape
let opposite_corner_with_offset = DVec2::new((x + 1) as f64 * square_size, (y + 1) as f64 * square_size) + offset;
let opposite_corner_in_shape = point_in_shape_checker(opposite_corner_with_offset);
let square_in_shape = opposite_corner_in_shape && !square_edges_intersect_shape();
Some(ActiveSquare::new(corner, square_in_shape))
} else {
None
}
})
.collect();
@ -363,7 +416,7 @@ impl AccelerationGrid {
}
#[inline(always)]
pub fn final_points(&self) -> Vec<DVec2> {
self.cells.iter().flat_map(|cell| cell.list_cell()).collect()
pub fn final_points(&self, offset: DVec2) -> Vec<DVec2> {
self.cells.iter().flat_map(|cell| cell.list_cell()).map(|point| point + offset).collect()
}
}

View file

@ -2,7 +2,7 @@ mod attributes;
mod indexed;
mod modification;
use super::misc::point_to_dvec2;
use super::misc::{dvec2_to_point, point_to_dvec2};
use super::style::{PathStyle, Stroke};
use crate::instances::Instances;
use crate::{AlphaBlending, Color, GraphicGroupTable};
@ -12,6 +12,7 @@ use core::borrow::Borrow;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
pub use indexed::VectorDataIndex;
use kurbo::{Affine, Shape};
pub use modification::*;
use std::collections::HashMap;
@ -193,6 +194,23 @@ impl VectorData {
vector_data
}
pub fn close_subpaths(&mut self) {
let segments_to_add: Vec<_> = self
.stroke_bezier_paths()
.filter(|subpath| !subpath.closed)
.filter_map(|subpath| {
let (first, last) = subpath.manipulator_groups().first().zip(subpath.manipulator_groups().last())?;
let (start, end) = self.point_domain.resolve_id(first.id).zip(self.point_domain.resolve_id(last.id))?;
Some((start, end))
})
.collect();
for (start, end) in segments_to_add {
let segment_id = self.segment_domain.next_id().next_id();
self.segment_domain.push(segment_id, start, end, bezier_rs::BezierHandles::Linear, StrokeId::ZERO);
}
}
/// Compute the bounding boxes of the subpaths without any transform
pub fn bounding_box(&self) -> Option<[DVec2; 2]> {
self.bounding_box_with_transform(DAffine2::IDENTITY)
@ -316,6 +334,34 @@ impl VectorData {
self.point_domain.resolve_id(point).map_or(0, |point| self.segment_domain.connected_count(point))
}
pub fn check_point_inside_shape(&self, vector_data_transform: DAffine2, point: DVec2) -> bool {
let bez_paths: Vec<_> = self
.stroke_bezpath_iter()
.map(|mut bezpath| {
// TODO: apply transform to points instead of modifying the paths
bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
bezpath.close_path();
let bbox = bezpath.bounding_box();
(bezpath, bbox)
})
.collect();
// Check against all paths the point is contained in to compute the correct winding number
let mut number = 0;
for (shape, bbox) in bez_paths {
if bbox.x0 > point.x || bbox.y0 > point.y || bbox.x1 < point.x || bbox.y1 < point.y {
continue;
}
let winding = shape.winding(dvec2_to_point(point));
number += winding;
}
// Non-zero fill rule
number != 0
}
/// Points that can be extended from.
///
/// This is usually only points with exactly one connection unless vector meshes are enabled.

View file

@ -560,9 +560,9 @@ async fn spatial_merge_by_distance(
) -> VectorDataTable {
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_ref_iter() {
let vector_data_transform = *vector_data.transform;
let vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let vector_data_transform = vector_data_instance.transform;
let vector_data = vector_data_instance.instance;
let point_count = vector_data.point_domain.positions().len();
@ -676,12 +676,9 @@ async fn spatial_merge_by_distance(
result.segment_domain = new_segment_domain;
// Create and return the result
result_table.push(Instance {
instance: result,
transform: vector_data_transform,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -695,9 +692,9 @@ async fn box_warp(_: impl Ctx, vector_data: VectorDataTable, #[expose] rectangle
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_ref_iter() {
let vector_data_transform = *vector_data.transform;
let vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let vector_data_transform = vector_data_instance.transform;
let vector_data = vector_data_instance.instance;
// Get the bounding box of the source vector data
let source_bbox = vector_data.bounding_box_with_transform(vector_data_transform).unwrap_or([DVec2::ZERO, DVec2::ONE]);
@ -755,12 +752,10 @@ async fn box_warp(_: impl Ctx, vector_data: VectorDataTable, #[expose] rectangle
result.style.set_stroke_transform(DAffine2::IDENTITY);
// Add this to the table and reset the transform since we've applied it directly to the points
result_table.push(Instance {
instance: result,
transform: DAffine2::IDENTITY,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = result;
vector_data_instance.transform = DAffine2::IDENTITY;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -787,9 +782,8 @@ async fn remove_handles(
) -> VectorDataTable {
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_iter() {
let vector_data_transform = vector_data.transform;
let mut vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let mut vector_data = vector_data_instance.instance;
for (_, handles, start, end) in vector_data.segment_domain.handles_mut() {
// Only convert to linear if handles are within the threshold distance
@ -821,12 +815,9 @@ async fn remove_handles(
}
}
result_table.push(Instance {
instance: vector_data,
transform: vector_data_transform,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = vector_data;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -1039,9 +1030,8 @@ async fn generate_handles(
async fn bounding_box(_: impl Ctx, vector_data: VectorDataTable) -> VectorDataTable {
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_ref_iter() {
let vector_data_transform = *vector_data.transform;
let vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let vector_data = vector_data_instance.instance;
let mut result = vector_data
.bounding_box()
@ -1050,12 +1040,9 @@ async fn bounding_box(_: impl Ctx, vector_data: VectorDataTable) -> VectorDataTa
result.style = vector_data.style.clone();
result.style.set_stroke_transform(DAffine2::IDENTITY);
result_table.push(Instance {
instance: result,
transform: vector_data_transform,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -1075,9 +1062,9 @@ async fn dimensions(_: impl Ctx, vector_data: VectorDataTable) -> DVec2 {
async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, line_join: LineJoin, #[default(4.)] miter_limit: f64) -> VectorDataTable {
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_ref_iter() {
let vector_data_transform = *vector_data.transform;
let vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let vector_data_transform = vector_data_instance.transform;
let vector_data = vector_data_instance.instance;
let subpaths = vector_data.stroke_bezier_paths();
let mut result = VectorData::empty();
@ -1105,12 +1092,9 @@ async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, l
result.append_subpath(subpath_out, false);
}
result_table.push(Instance {
instance: result,
transform: vector_data_transform,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -1120,9 +1104,8 @@ async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, l
async fn solidify_stroke(_: impl Ctx, vector_data: VectorDataTable) -> VectorDataTable {
let mut result_table = VectorDataTable::empty();
for vector_data in vector_data.instance_ref_iter() {
let vector_data_transform = *vector_data.transform;
let vector_data = vector_data.instance;
for mut vector_data_instance in vector_data.instance_iter() {
let vector_data = vector_data_instance.instance;
let stroke = vector_data.style.stroke().clone().unwrap_or_default();
let bezpaths = vector_data.stroke_bezpath_iter();
@ -1165,12 +1148,9 @@ async fn solidify_stroke(_: impl Ctx, vector_data: VectorDataTable) -> VectorDat
result.style.set_stroke(Stroke::default());
}
result_table.push(Instance {
instance: result,
transform: vector_data_transform,
alpha_blending: Default::default(),
source_node_id: None,
});
vector_data_instance.instance = result;
vector_data_instance.source_node_id = None;
result_table.push(vector_data_instance);
}
result_table
@ -1227,48 +1207,58 @@ async fn sample_points(_: impl Ctx, vector_data: VectorDataTable, spacing: f64,
// Limit the smallest spacing to something sensible to avoid freezing the application.
let spacing = spacing.max(0.01);
let vector_data_transform = vector_data.transform();
let mut result_table = VectorDataTable::empty();
// Using `stroke_bezpath_iter` so that the `subpath_segment_lengths` is aligned to the segments of each bezpath.
// So we can index into `subpath_segment_lengths` to get the length of the segments.
// NOTE: `subpath_segment_lengths` has precalulated lengths with transformation applied.
let bezpaths = vector_data.one_instance_ref().instance.stroke_bezpath_iter();
// Initialize the result VectorData with the same transformation as the input.
let mut result = VectorDataTable::default();
*result.transform_mut() = vector_data_transform;
// Keeps track of the index of the first segment of the next bezpath in order to get lengths of all segments.
let mut next_segment_index = 0;
for mut bezpath in bezpaths {
// Apply the tranformation to the current bezpath to calculate points after transformation.
bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
let segment_count = bezpath.segments().count();
// For the current bezpath we get its segment's length by calculating the start index and end index.
let current_bezpath_segments_length = &subpath_segment_lengths[next_segment_index..next_segment_index + segment_count];
// Increment the segment index by the number of segments in the current bezpath to calculate the next bezpath segment's length.
next_segment_index += segment_count;
let Some(mut sample_bezpath) = sample_points_on_bezpath(bezpath, spacing, start_offset, stop_offset, adaptive_spacing, current_bezpath_segments_length) else {
continue;
for mut vector_data_instance in vector_data.instance_iter() {
let mut result = VectorData {
point_domain: Default::default(),
segment_domain: Default::default(),
region_domain: Default::default(),
colinear_manipulators: Default::default(),
style: std::mem::take(&mut vector_data_instance.instance.style),
upstream_graphic_group: std::mem::take(&mut vector_data_instance.instance.upstream_graphic_group),
};
// Reverse the transformation applied to the bezpath as the `result` already has the transformation set.
sample_bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()).inverse());
// Using `stroke_bezpath_iter` so that the `subpath_segment_lengths` is aligned to the segments of each bezpath.
// So we can index into `subpath_segment_lengths` to get the length of the segments.
// NOTE: `subpath_segment_lengths` has precalulated lengths with transformation applied.
let bezpaths = vector_data_instance.instance.stroke_bezpath_iter();
// Append the bezpath (subpath) that connects generated points by lines.
result.one_instance_mut().instance.append_bezpath(sample_bezpath);
// Keeps track of the index of the first segment of the next bezpath in order to get lengths of all segments.
let mut next_segment_index = 0;
for mut bezpath in bezpaths {
// Apply the tranformation to the current bezpath to calculate points after transformation.
bezpath.apply_affine(Affine::new(vector_data_instance.transform.to_cols_array()));
let segment_count = bezpath.segments().count();
// For the current bezpath we get its segment's length by calculating the start index and end index.
let current_bezpath_segments_length = &subpath_segment_lengths[next_segment_index..next_segment_index + segment_count];
// Increment the segment index by the number of segments in the current bezpath to calculate the next bezpath segment's length.
next_segment_index += segment_count;
let Some(mut sample_bezpath) = sample_points_on_bezpath(bezpath, spacing, start_offset, stop_offset, adaptive_spacing, current_bezpath_segments_length) else {
continue;
};
// Reverse the transformation applied to the bezpath as the `result` already has the transformation set.
sample_bezpath.apply_affine(Affine::new(vector_data_instance.transform.to_cols_array()).inverse());
// Append the bezpath (subpath) that connects generated points by lines.
result.append_bezpath(sample_bezpath);
}
// Transfer the style from the input vector data to the result.
result.style = vector_data_instance.instance.style;
result.style.set_stroke_transform(vector_data_instance.transform);
vector_data_instance.instance = result;
result_table.push(vector_data_instance);
}
// Transfer the style from the input vector data to the result.
result.one_instance_mut().instance.style = vector_data.one_instance_ref().instance.style.clone();
result.one_instance_mut().instance.style.set_stroke_transform(vector_data_transform);
// Return the resulting vector data with newly generated points and segments.
result
result_table
}
/// Determines the position of a point on the path, given by its progress from 0 to 1 along the path.
@ -1287,18 +1277,21 @@ async fn position_on_path(
) -> DVec2 {
let euclidian = !parameterized_distance;
let vector_data_transform = vector_data.transform();
let vector_data = vector_data.one_instance_ref().instance;
let mut bezpaths = vector_data.stroke_bezpath_iter().collect::<Vec<kurbo::BezPath>>();
let mut bezpaths = vector_data
.instance_iter()
.flat_map(|vector_data| {
let transform = vector_data.transform;
vector_data.instance.stroke_bezpath_iter().map(|bezpath| (bezpath, transform)).collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let bezpath_count = bezpaths.len() as f64;
let progress = progress.clamp(0., bezpath_count);
let progress = if reverse { bezpath_count - progress } else { progress };
let index = if progress >= bezpath_count { (bezpath_count - 1.) as usize } else { progress as usize };
bezpaths.get_mut(index).map_or(DVec2::ZERO, |bezpath| {
bezpaths.get_mut(index).map_or(DVec2::ZERO, |(bezpath, transform)| {
let t = if progress == bezpath_count { 1. } else { progress.fract() };
bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
point_to_dvec2(position_on_bezpath(bezpath, t, euclidian, None))
})
@ -1320,18 +1313,21 @@ async fn tangent_on_path(
) -> f64 {
let euclidian = !parameterized_distance;
let vector_data_transform = vector_data.transform();
let vector_data = vector_data.one_instance_ref().instance;
let mut bezpaths = vector_data.stroke_bezpath_iter().collect::<Vec<kurbo::BezPath>>();
let mut bezpaths = vector_data
.instance_iter()
.flat_map(|vector_data| {
let transform = vector_data.transform;
vector_data.instance.stroke_bezpath_iter().map(|bezpath| (bezpath, transform)).collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let bezpath_count = bezpaths.len() as f64;
let progress = progress.clamp(0., bezpath_count);
let progress = if reverse { bezpath_count - progress } else { progress };
let index = if progress >= bezpath_count { (bezpath_count - 1.) as usize } else { progress as usize };
bezpaths.get_mut(index).map_or(0., |bezpath| {
bezpaths.get_mut(index).map_or(0., |(bezpath, transform)| {
let t = if progress == bezpath_count { 1. } else { progress.fract() };
bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t, euclidian, None));
if tangent == DVec2::ZERO {
@ -1355,59 +1351,68 @@ async fn poisson_disk_points(
separation_disk_diameter: f64,
seed: SeedValue,
) -> VectorDataTable {
let vector_data_transform = vector_data.transform();
let vector_data = vector_data.one_instance_ref().instance;
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
let mut result = VectorData::empty();
if separation_disk_diameter <= 0.01 {
return VectorDataTable::new(result);
}
let path_with_bounding_boxes: Vec<_> = vector_data
.stroke_bezpath_iter()
.map(|mut subpath| {
// TODO: apply transform to points instead of modifying the paths
subpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
let bbox = subpath.bounding_box();
(subpath, bbox)
})
.collect();
let mut result_table = VectorDataTable::empty();
for (i, (subpath, _)) in path_with_bounding_boxes.iter().enumerate() {
if subpath.segments().count() < 2 {
continue;
}
for mut vector_data_instance in vector_data.instance_iter() {
let mut result = VectorData::empty();
let mut poisson_disk_bezpath = BezPath::new();
let path_with_bounding_boxes: Vec<_> = vector_data_instance
.instance
.stroke_bezpath_iter()
.map(|mut bezpath| {
// TODO: apply transform to points instead of modifying the paths
bezpath.apply_affine(Affine::new(vector_data_instance.transform.to_cols_array()));
bezpath.close_path();
let bbox = bezpath.bounding_box();
(bezpath, bbox)
})
.collect();
for point in bezpath_algorithms::poisson_disk_points(subpath, separation_disk_diameter, || rng.random::<f64>(), &path_with_bounding_boxes, i) {
if poisson_disk_bezpath.elements().is_empty() {
poisson_disk_bezpath.move_to(dvec2_to_point(point));
} else {
poisson_disk_bezpath.line_to(dvec2_to_point(point));
for (i, (subpath, _)) in path_with_bounding_boxes.iter().enumerate() {
if subpath.segments().count() < 2 {
continue;
}
let mut poisson_disk_bezpath = BezPath::new();
for point in bezpath_algorithms::poisson_disk_points(i, &path_with_bounding_boxes, separation_disk_diameter, || rng.random::<f64>()) {
if poisson_disk_bezpath.elements().is_empty() {
poisson_disk_bezpath.move_to(dvec2_to_point(point));
} else {
poisson_disk_bezpath.line_to(dvec2_to_point(point));
}
}
result.append_bezpath(poisson_disk_bezpath);
}
result.append_bezpath(poisson_disk_bezpath);
// Transfer the style from the input vector data to the result.
result.style = vector_data_instance.instance.style.clone();
result.style.set_stroke_transform(DAffine2::IDENTITY);
vector_data_instance.instance = result;
result_table.push(vector_data_instance);
}
// Transfer the style from the input vector data to the result.
result.style = vector_data.style.clone();
result.style.set_stroke_transform(DAffine2::IDENTITY);
VectorDataTable::new(result)
result_table
}
#[node_macro::node(category(""), path(graphene_core::vector))]
async fn subpath_segment_lengths(_: impl Ctx, vector_data: VectorDataTable) -> Vec<f64> {
let vector_data_transform = vector_data.transform();
let vector_data = vector_data.one_instance_ref().instance;
vector_data
.stroke_bezpath_iter()
.flat_map(|mut bezpath| {
bezpath.apply_affine(Affine::new(vector_data_transform.to_cols_array()));
bezpath.segments().map(|segment| segment.perimeter(PERIMETER_ACCURACY)).collect::<Vec<f64>>()
.instance_iter()
.flat_map(|vector_data| {
let transform = vector_data.transform;
vector_data
.instance
.stroke_bezpath_iter()
.flat_map(|mut bezpath| {
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
bezpath.segments().map(|segment| segment.perimeter(PERIMETER_ACCURACY)).collect::<Vec<f64>>()
})
.collect::<Vec<f64>>()
})
.collect()
}
@ -1710,6 +1715,23 @@ fn bevel(_: impl Ctx, source: VectorDataTable, #[default(10.)] distance: Length)
result
}
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
fn close_path(_: impl Ctx, source: VectorDataTable) -> VectorDataTable {
let mut new_table = VectorDataTable::empty();
for mut source_instance in source.instance_iter() {
source_instance.instance.close_subpaths();
new_table.push(source_instance);
}
new_table
}
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
fn point_inside(_: impl Ctx, source: VectorDataTable, point: DVec2) -> bool {
source.instance_iter().any(|instance| instance.instance.check_point_inside_shape(instance.transform, point))
}
#[node_macro::node(name("Merge by Distance"), category("Vector"), path(graphene_core::vector))]
fn merge_by_distance(_: impl Ctx, source: VectorDataTable, #[default(10.)] distance: Length) -> VectorDataTable {
let source_transform = source.transform();

View file

@ -43,7 +43,7 @@ async fn create_surface<'a: 'n>(_: impl Ctx, editor: &'a WasmEditorApi) -> Arc<W
// image: ImageFrameTable<graphene_core::raster::SRGBA8>,
// surface_handle: Arc<WasmSurfaceHandle>,
// ) -> graphene_core::application_io::SurfaceHandleFrame<HtmlCanvasElement> {
// let image = image.one_instance().instance;
// let image = image.one_instance_ref().instance;
// let image_data = image.image.data;
// let array: Clamped<&[u8]> = Clamped(bytemuck::cast_slice(image_data.as_slice()));
// if image.image.width > 0 && image.image.height > 0 {

View file

@ -44,6 +44,7 @@ in
cargo-watch
cargo-nextest
cargo-expand
cargo-about
wasm-pack
binaryen
wasm-bindgen-cli