Add shape fill overlays when closing a path (Pen tool) or filling it (Fill tool) (#2521)

* Make the Pen tool show a path being closed by drawing a filled overlay when hovering the endpoint

* Add to_css to color.rs

* Check before unwrapping layer

* Close if in the right place

* Fix typo

* Format code

* Support discontinuous paths for closing preview

* Code review

* Denser fill lines

* Fill tool preview with strip lines only and revert pen shape-closing opacity

* Small adjustments to fill preview

* Fix line width of fill preview

* Use a pattern to preview the fill tool and fix canvas clearing

* Update pattern

* Simplify code

* Format code

* Use secondary color to preview fill if shift is pressed

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Mateo 2025-04-23 22:33:08 -03:00 committed by GitHub
parent a4a0e110d7
commit 3d37ef79ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 350 additions and 17 deletions

View file

@ -30,7 +30,9 @@ ron = ["dep:ron"]
# Local dependencies
graphite-proc-macros = { path = "../proc-macros" }
graph-craft = { path = "../node-graph/graph-craft" }
interpreted-executor = { path = "../node-graph/interpreted-executor", features = ["serde"] }
interpreted-executor = { path = "../node-graph/interpreted-executor", features = [
"serde",
] }
graphene-core = { path = "../node-graph/gcore" }
graphene-std = { path = "../node-graph/gstd", features = ["serde"] }
@ -58,6 +60,9 @@ web-sys = { workspace = true, features = [
"Element",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"CanvasPattern",
"OffscreenCanvas",
"OffscreenCanvasRenderingContext2d",
"TextMetrics",
] }

View file

@ -282,6 +282,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(Enter); action_dispatch=SplineToolMessage::Confirm),
//
// FillToolMessage
entry!(PointerMove; refresh_keys=[Shift], action_dispatch=FillToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=FillToolMessage::FillPrimaryColor),
entry!(KeyDown(MouseLeft); modifiers=[Shift], action_dispatch=FillToolMessage::FillSecondaryColor),
entry!(KeyUp(MouseLeft); action_dispatch=FillToolMessage::PointerUp),

View file

@ -47,7 +47,7 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(device_pixel_ratio)).to_cols_array();
let _ = context.set_transform(a, b, c, d, e, f);
context.clear_rect(0., 0., ipp.viewport_bounds.size().x, ipp.viewport_bounds.size().y);
context.clear_rect(0., 0., canvas.width().into(), canvas.height().into());
let _ = context.reset_transform();
if overlays_visible {

View file

@ -8,10 +8,12 @@ use bezier_rs::{Bezier, Subpath};
use core::borrow::Borrow;
use core::f64::consts::{FRAC_PI_2, TAU};
use glam::{DAffine2, DVec2};
use graphene_core::Color;
use graphene_core::renderer::Quad;
use graphene_std::vector::{PointId, SegmentId, VectorData};
use std::collections::HashMap;
use wasm_bindgen::JsValue;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d};
pub type OverlayProvider = fn(OverlayContext) -> Message;
@ -447,6 +449,7 @@ impl OverlayContext {
self.end_dpi_aware_transform();
}
/// Used by the Pen and Path tools to outline the path of the shape.
pub fn outline_vector(&mut self, vector_data: &VectorData, transform: DAffine2) {
self.start_dpi_aware_transform();
@ -465,6 +468,7 @@ impl OverlayContext {
self.end_dpi_aware_transform();
}
/// Used by the Pen tool in order to show how the bezier curve would look like.
pub fn outline_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();
@ -493,7 +497,7 @@ impl OverlayContext {
self.end_dpi_aware_transform();
}
pub fn outline(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
fn push_path(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
@ -540,10 +544,63 @@ impl OverlayContext {
}
}
self.end_dpi_aware_transform();
}
/// Used by the Select tool to outline a path selected or hovered.
pub fn outline(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
self.push_path(subpaths, transform);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.stroke();
}
self.end_dpi_aware_transform();
/// Fills the area inside the path. Assumes `color` is in gamma space.
/// Used by the Pen tool to show the path being closed.
pub fn fill_path(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2, color: &str) {
self.push_path(subpaths, transform);
self.render_context.set_fill_style_str(color);
self.render_context.fill();
}
/// Fills the area inside the path with a pattern. Assumes `color` is in gamma space.
/// Used by the fill tool to show the area to be filled.
pub fn fill_path_pattern(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2, color: &Color) {
const PATTERN_WIDTH: usize = 4;
const PATTERN_HEIGHT: usize = 4;
let pattern_canvas = OffscreenCanvas::new(PATTERN_WIDTH as u32, PATTERN_HEIGHT as u32).unwrap();
let pattern_context: OffscreenCanvasRenderingContext2d = pattern_canvas
.get_context("2d")
.ok()
.flatten()
.expect("Failed to get canvas context")
.dyn_into()
.expect("Context should be a canvas 2d context");
// 4x4 pixels, 4 components (RGBA) per pixel
let mut data = [0_u8; 4 * PATTERN_WIDTH * PATTERN_HEIGHT];
// ┌▄▄┬──┬──┬──┐
// ├▀▀┼──┼──┼──┤
// ├──┼──┼▄▄┼──┤
// ├──┼──┼▀▀┼──┤
// └──┴──┴──┴──┘
let pixels = [(0, 0), (2, 2)];
for &(x, y) in &pixels {
let index = (x + y * PATTERN_WIDTH as usize) * 4;
data[index..index + 4].copy_from_slice(&color.to_rgba8_srgb());
}
let image_data = web_sys::ImageData::new_with_u8_clamped_array_and_sh(wasm_bindgen::Clamped(&mut data), PATTERN_WIDTH as u32, PATTERN_HEIGHT as u32).unwrap();
pattern_context.put_image_data(&image_data, 0., 0.).unwrap();
let pattern = self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap();
self.push_path(subpaths, transform);
self.render_context.set_fill_style_canvas_pattern(&pattern);
self.render_context.fill();
}
pub fn get_width(&self, text: &str) -> f64 {

View file

@ -218,7 +218,7 @@ impl MessageHandler<ToolMessage, ToolMessageData<'_>> for ToolMessageHandler {
let document_data = &mut self.tool_state.document_tool_data;
document_data.primary_color = color;
self.tool_state.document_tool_data.update_working_colors(responses); // TODO: Make this an event
document_data.update_working_colors(responses); // TODO: Make this an event
}
ToolMessage::SelectRandomPrimaryColor => {
// Select a random primary color (rgba) based on an UUID

View file

@ -1,18 +1,23 @@
use super::tool_prelude::*;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer;
use graphene_core::vector::style::Fill;
#[derive(Default)]
pub struct FillTool {
fsm_state: FillToolFsmState,
}
#[impl_message(Message, ToolMessage, Fill)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize, specta::Type)]
#[derive(PartialEq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum FillToolMessage {
// Standard messages
Abort,
WorkingColorChanged,
Overlays(OverlayContext),
// Tool-specific messages
PointerMove,
PointerUp,
FillPrimaryColor,
FillSecondaryColor,
@ -45,8 +50,10 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for FillToo
FillToolFsmState::Ready => actions!(FillToolMessageDiscriminant;
FillPrimaryColor,
FillSecondaryColor,
PointerMove,
),
FillToolFsmState::Filling => actions!(FillToolMessageDiscriminant;
PointerMove,
PointerUp,
Abort,
),
@ -58,6 +65,8 @@ impl ToolTransition for FillTool {
fn event_to_message_map(&self) -> EventToMessageMap {
EventToMessageMap {
tool_abort: Some(FillToolMessage::Abort.into()),
working_color_changed: Some(FillToolMessage::WorkingColorChanged.into()),
overlay_provider: Some(|overlay_context| FillToolMessage::Overlays(overlay_context).into()),
..Default::default()
}
}
@ -82,6 +91,23 @@ impl Fsm for FillToolFsmState {
let ToolMessage::Fill(event) = event else { return self };
match (self, event) {
(_, FillToolMessage::Overlays(mut overlay_context)) => {
// Choose the working color to preview
let use_secondary = input.keyboard.get(Key::Shift as usize);
let preview_color = if use_secondary { global_tool_data.secondary_color } else { global_tool_data.primary_color };
// Get the layer the user is hovering over
if let Some(layer) = document.click(input) {
overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &preview_color);
}
self
}
(_, FillToolMessage::PointerMove | FillToolMessage::WorkingColorChanged) => {
// Generate the hover outline
responses.add(OverlaysMessage::Draw);
self
}
(FillToolFsmState::Ready, color_event) => {
let Some(layer_identifier) = document.click(input) else {
return self;

View file

@ -1030,7 +1030,7 @@ impl Fsm for PathToolFsmState {
match self {
Self::Drawing { selection_shape } => {
let mut fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
let mut fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
.unwrap()
.with_alpha(0.05)
.to_rgba_hex_srgb();

View file

@ -1,5 +1,5 @@
use super::tool_prelude::*;
use crate::consts::{DEFAULT_STROKE_WIDTH, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE};
use crate::consts::{COLOR_OVERLAY_BLUE, DEFAULT_STROKE_WIDTH, HIDE_HANDLE_DISTANCE, LINE_ROTATE_SNAP_ANGLE};
use crate::messages::input_mapper::utility_types::input_mouse::MouseKeys;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::overlays::utility_functions::path_overlays;
@ -15,7 +15,7 @@ use bezier_rs::{Bezier, BezierHandles};
use graph_craft::document::NodeId;
use graphene_core::Color;
use graphene_core::vector::{PointId, VectorModificationType};
use graphene_std::vector::{HandleId, ManipulatorPointId, NoHashBuilder, SegmentId, VectorData};
use graphene_std::vector::{HandleId, ManipulatorPointId, NoHashBuilder, SegmentId, StrokeId, VectorData};
#[derive(Default)]
pub struct PenTool {
@ -1614,6 +1614,54 @@ impl Fsm for PenToolFsmState {
overlay_context.manipulator_anchor(next_anchor, false, None);
}
// Display a filled overlay of the shape if the new point closes the path
if let Some(latest_point) = tool_data.latest_point() {
let handle_start = latest_point.handle_start;
let handle_end = tool_data.handle_end.unwrap_or(tool_data.next_handle_start);
let next_point = tool_data.next_point;
let start = latest_point.id;
if let Some(layer) = layer {
let mut vector_data = document.network_interface.compute_modified_vector(layer).unwrap();
let closest_point = vector_data.extendable_points(preferences.vector_meshes).filter(|&id| id != start).find(|&id| {
vector_data.point_domain.position_from_id(id).map_or(false, |pos| {
let dist_sq = transform.transform_point2(pos).distance_squared(transform.transform_point2(next_point));
dist_sq < crate::consts::SNAP_POINT_TOLERANCE.powi(2)
})
});
// We have the point. Join the 2 vertices and check if any path is closed.
if let Some(end) = closest_point {
let segment_id = SegmentId::generate();
vector_data.push(segment_id, start, end, BezierHandles::Cubic { handle_start, handle_end }, StrokeId::ZERO);
let grouped_segments = vector_data.auto_join_paths();
let closed_paths = grouped_segments.iter().filter(|path| path.is_closed() && path.contains(segment_id));
let subpaths: Vec<_> = closed_paths
.filter_map(|path| {
let segments = path.edges.iter().filter_map(|edge| {
vector_data
.segment_domain
.iter()
.find(|(id, _, _, _)| id == &edge.id)
.map(|(_, start, end, bezier)| if start == edge.start { (bezier, start, end) } else { (bezier.reversed(), end, start) })
});
vector_data.subpath_from_segments_ignore_discontinuities(segments)
})
.collect();
let mut fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
.unwrap()
.with_alpha(0.05)
.to_rgba_hex_srgb();
fill_color.insert(0, '#');
overlay_context.fill_path(subpaths.iter(), transform, fill_color.as_str());
}
}
}
// Draw the overlays that visualize current snapping
tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);

View file

@ -2,8 +2,8 @@
use super::tool_prelude::*;
use crate::consts::{
COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COMPASS_ROSE_HOVER_RING_DIAMETER, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, RESIZE_HANDLE_SIZE, ROTATE_INCREMENT, SELECTION_DRAG_ANGLE,
SELECTION_TOLERANCE,
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COMPASS_ROSE_HOVER_RING_DIAMETER, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, RESIZE_HANDLE_SIZE, ROTATE_INCREMENT,
SELECTION_DRAG_ANGLE, SELECTION_TOLERANCE,
};
use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
@ -759,7 +759,7 @@ impl Fsm for SelectToolFsmState {
}
// Update the selection box
let mut fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
let mut fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
.unwrap()
.with_alpha(0.05)
.to_rgba_hex_srgb();

View file

@ -1,7 +1,7 @@
#![allow(clippy::too_many_arguments)]
use super::tool_prelude::*;
use crate::consts::{COLOR_OVERLAY_RED, DRAG_THRESHOLD};
use crate::consts::{COLOR_OVERLAY_BLUE, COLOR_OVERLAY_RED, DRAG_THRESHOLD};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
@ -456,7 +456,7 @@ impl Fsm for TextToolFsmState {
font_cache,
..
} = transition_data;
let fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
.unwrap()
.with_alpha(0.05)
.to_rgba_hex_srgb();

View file

@ -553,6 +553,94 @@ impl RegionDomain {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HalfEdge {
pub id: SegmentId,
pub start: usize,
pub end: usize,
pub reverse: bool,
}
impl HalfEdge {
pub fn new(id: SegmentId, start: usize, end: usize, reverse: bool) -> Self {
Self { id, start, end, reverse }
}
pub fn reversed(&self) -> Self {
Self {
id: self.id,
start: self.start,
end: self.end,
reverse: !self.reverse,
}
}
pub fn normalize_direction(&self) -> Self {
if self.reverse {
Self {
id: self.id,
start: self.end,
end: self.start,
reverse: false,
}
} else {
*self
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FoundSubpath {
pub edges: Vec<HalfEdge>,
}
impl FoundSubpath {
pub fn new(segments: Vec<HalfEdge>) -> Self {
Self { edges: segments }
}
pub fn endpoints(&self) -> Option<(&HalfEdge, &HalfEdge)> {
match (self.edges.first(), self.edges.last()) {
(Some(first), Some(last)) => Some((first, last)),
_ => None,
}
}
pub fn push(&mut self, segment: HalfEdge) {
self.edges.push(segment);
}
pub fn insert(&mut self, index: usize, segment: HalfEdge) {
self.edges.insert(index, segment);
}
pub fn extend(&mut self, segments: impl IntoIterator<Item = HalfEdge>) {
self.edges.extend(segments);
}
pub fn splice<I>(&mut self, range: std::ops::Range<usize>, replace_with: I)
where
I: IntoIterator<Item = HalfEdge>,
{
self.edges.splice(range, replace_with);
}
pub fn is_closed(&self) -> bool {
match (self.edges.first(), self.edges.last()) {
(Some(first), Some(last)) => first.start == last.end,
_ => false,
}
}
pub fn from_segment(segment: HalfEdge) -> Self {
Self { edges: vec![segment] }
}
pub fn contains(&self, segment_id: SegmentId) -> bool {
self.edges.iter().any(|s| s.id == segment_id)
}
}
impl VectorData {
/// Construct a [`bezier_rs::Bezier`] curve spanning from the resolved position of the start and end points with the specified handles.
fn segment_to_bezier_with_index(&self, start: usize, end: usize, handles: bezier_rs::BezierHandles) -> bezier_rs::Bezier {
@ -592,6 +680,114 @@ impl VectorData {
.map(to_bezier)
}
pub fn auto_join_paths(&self) -> Vec<FoundSubpath> {
let segments = self.segment_domain.iter().map(|(id, start, end, _)| HalfEdge::new(id, start, end, false));
let mut paths: Vec<FoundSubpath> = Vec::new();
let mut current_path: Option<&mut FoundSubpath> = None;
let mut previous: Option<(usize, usize)> = None;
// First pass. Generates subpaths from continuous segments.
for seg_ref in segments {
let (start, end) = (seg_ref.start, seg_ref.end);
if previous.is_some_and(|(_, prev_end)| start == prev_end) {
if let Some(path) = current_path.as_mut() {
path.push(seg_ref);
}
} else {
paths.push(FoundSubpath::from_segment(seg_ref));
current_path = paths.last_mut();
}
previous = Some((start, end));
}
// Second pass. Try to join paths together.
let mut joined_paths = Vec::new();
loop {
let mut prev_index: Option<usize> = None;
let original_len = paths.len();
for current in paths.into_iter() {
// If there's no previous subpath, start a new one
if prev_index.is_none() {
joined_paths.push(current);
prev_index = Some(joined_paths.len() - 1);
continue;
}
let prev = &mut joined_paths[prev_index.unwrap()];
// Compare segment connections
let (prev_first, prev_last) = prev.endpoints().unwrap();
let (cur_first, cur_last) = current.endpoints().unwrap();
// Join paths if the endpoints connect
if prev_last.end == cur_first.start {
prev.edges.extend(current.edges.into_iter().map(|s| s.normalize_direction()));
} else if prev_first.start == cur_last.end {
prev.edges.splice(0..0, current.edges.into_iter().rev().map(|s| s.normalize_direction()));
} else if prev_last.end == cur_last.end {
prev.edges.extend(current.edges.into_iter().rev().map(|s| s.reversed().normalize_direction()));
} else if prev_first.start == cur_first.start {
prev.edges.splice(0..0, current.edges.into_iter().map(|s| s.reversed().normalize_direction()));
} else {
// If not connected, start a new subpath
joined_paths.push(current);
prev_index = Some(joined_paths.len() - 1);
}
}
// If no paths were joined in this pass, we're done
if joined_paths.len() == original_len {
return joined_paths;
}
// Repeat pass with newly joined paths
paths = joined_paths;
joined_paths = Vec::new();
}
}
/// Construct a [`bezier_rs::Bezier`] curve from an iterator of segments with (handles, start point, end point) independently of discontinuities.
pub fn subpath_from_segments_ignore_discontinuities(&self, segments: impl Iterator<Item = (bezier_rs::BezierHandles, usize, usize)>) -> Option<bezier_rs::Subpath<PointId>> {
let mut first_point = None;
let mut groups = Vec::new();
let mut last: Option<(usize, bezier_rs::BezierHandles)> = None;
for (handle, start, end) in segments {
first_point = Some(first_point.unwrap_or(start));
groups.push(bezier_rs::ManipulatorGroup {
anchor: self.point_domain.positions()[start],
in_handle: last.and_then(|(_, handle)| handle.end()),
out_handle: handle.start(),
id: self.point_domain.ids()[start],
});
last = Some((end, handle));
}
let closed = groups.len() > 1 && last.map(|(point, _)| point) == first_point;
if let Some((end, last_handle)) = last {
if closed {
groups[0].in_handle = last_handle.end();
} else {
groups.push(bezier_rs::ManipulatorGroup {
anchor: self.point_domain.positions()[end],
in_handle: last_handle.end(),
out_handle: None,
id: self.point_domain.ids()[end],
});
}
}
Some(bezier_rs::Subpath::new(groups, closed))
}
/// Construct a [`bezier_rs::Bezier`] curve from an iterator of segments with (handles, start point, end point). Returns None if any ids are invalid or if the segments are not continuous.
fn subpath_from_segments(&self, segments: impl Iterator<Item = (bezier_rs::BezierHandles, usize, usize)>) -> Option<bezier_rs::Subpath<PointId>> {
let mut first_point = None;
@ -670,12 +866,12 @@ impl VectorData {
/// Construct a [`bezier_rs::Bezier`] curve for stroke.
pub fn stroke_bezier_paths(&self) -> impl Iterator<Item = bezier_rs::Subpath<PointId>> {
self.build_stroke_path_iter().into_iter().map(|(group, closed)| bezier_rs::Subpath::new(group, closed))
self.build_stroke_path_iter().map(|(group, closed)| bezier_rs::Subpath::new(group, closed))
}
/// Construct a [`kurbo::BezPath`] curve for stroke.
pub fn stroke_bezpath_iter(&self) -> impl Iterator<Item = kurbo::BezPath> {
self.build_stroke_path_iter().into_iter().map(|(group, closed)| {
self.build_stroke_path_iter().map(|(group, closed)| {
let mut bezpath = kurbo::BezPath::new();
let mut out_handle;